浏览代码

Revamp cluster UI and metadata persistence

Split cluster administration into dedicated Settings, Info, and Jobs pages with shared styling/helpers, full cluster localization, and a new `-disable_cluster` flag. The cluster drive is now only mounted when the node is in a cluster with at least one volume, and storage settings add a cluster-wide auto read-only toggle for nearly full volumes.

Rework cluster metadata persistence to use batched write-behind disk flushes via new database batch writes, preventing store lock contention and lease starvation while adding coverage for the new persistence paths. This also tightens pending-log draining, syncs snapshot change hooks, and updates docs/examples to match the new cluster UX. Miscellaneous fixes improve special URI decoding, media path handling, and failed-upload retry UI.
Toby Chui 2 周之前
父节点
当前提交
3622f25c8d
共有 34 个文件被更改,包括 5113 次插入 和 760 次删除
  1. 14 4
      CLAUDE.md
  2. 2 0
      README.md
  3. 2 2
      examples/clusters_jobs/README.md
  4. 22 4
      examples/clusters_jobs/ffmpeg_convert/ffmpeg_convert.agi
  5. 1 1
      examples/clusters_jobs/hello_world/hello_world.agi
  6. 24 6
      examples/clusters_jobs/list_files/list_files.agi
  7. 72 15
      src/cluster.go
  8. 3 3
      src/file_system.go
  9. 2 1
      src/flags.go
  10. 2 1
      src/main.router.go
  11. 60 10
      src/mod/cluster/README.md
  12. 31 2
      src/mod/cluster/metadata/log.go
  13. 15 1
      src/mod/cluster/metadata/manager.go
  14. 184 0
      src/mod/cluster/metadata/persist.go
  15. 158 0
      src/mod/cluster/metadata/persist_test.go
  16. 44 28
      src/mod/cluster/metadata/store.go
  17. 24 5
      src/mod/cluster/storage/handlers_admin.go
  18. 1 1
      src/mod/cluster/storage/service.go
  19. 52 0
      src/mod/cluster/storage/storage_test.go
  20. 61 5
      src/mod/cluster/storage/volumes.go
  21. 28 0
      src/mod/database/database.go
  22. 87 0
      src/mod/database/database_batch_test.go
  23. 36 0
      src/mod/database/database_core.go
  24. 34 0
      src/mod/database/database_openwrt.go
  25. 3 4
      src/mod/media/mediaserver/mediaserver.go
  26. 226 0
      src/web/SystemAO/cluster/cluster.common.js
  27. 149 0
      src/web/SystemAO/cluster/cluster.css
  28. 197 594
      src/web/SystemAO/cluster/cluster.html
  29. 424 0
      src/web/SystemAO/cluster/clusterinfo.html
  30. 53 72
      src/web/SystemAO/cluster/jobs.html
  31. 25 1
      src/web/SystemAO/file_system/file_explorer.css
  32. 8 0
      src/web/SystemAO/file_system/js/explorer/uploadui.js
  33. 3048 0
      src/web/SystemAO/locale/cluster.json
  34. 21 0
      src/web/SystemAO/locale/system_settings.json

+ 14 - 4
CLAUDE.md

@@ -269,9 +269,14 @@ before changing how a phase works, but start new work from the packages below.
   is the cluster agent: create / join (pasteable join tokens) / leave, records
   merged last-writer-wins by gossip, heartbeats, computed node states, health
   and capability manifests ([`src/mod/cluster/capability/`](src/mod/cluster/capability/)).
-  Admin API `/system/cluster/*` and the System Settings page
-  [`src/web/SystemAO/cluster/cluster.html`](src/web/SystemAO/cluster/cluster.html);
-  wiring in [`src/cluster.go`](src/cluster.go).
+  Admin API `/system/cluster/*` and the System Settings tabs Cluster
+  Settings ([`cluster.html`](src/web/SystemAO/cluster/cluster.html)), Cluster
+  Info ([`clusterinfo.html`](src/web/SystemAO/cluster/clusterinfo.html)) and
+  Cluster Jobs; all three are localized through
+  [`src/web/SystemAO/locale/cluster.json`](src/web/SystemAO/locale/cluster.json)
+  (server messages included, via `CL.tr`), so add new user-facing strings and
+  server messages there. Wiring in [`src/cluster.go`](src/cluster.go);
+  `-disable_cluster` turns the whole feature off.
 - **Identity** ([`src/mod/cluster/identity/`](src/mod/cluster/identity/)):
   one member is the *identity origin*; other members forward logins to it
   through the auth agent's `ForwardAuth` hook (password hash only), mirror the
@@ -286,10 +291,15 @@ before changing how a phase works, but start new work from the packages below.
   replicated log with catch-up and snapshots. Write through
   `Submit(kind, record)`, read through `Stat` / `ListDir` / `Volumes` /
   `PolicyFor`; new cluster tables go through `membership.RegisterClusterTable`.
+  The disk copy is written behind (`persist.go`, batched through
+  `database.WriteBatch`): never add a synced disk write inside the store
+  lock, or a write burst starves lease renewal and the leader loses its lease.
 - **Storage and the `cluster:/` drive**
   ([`src/mod/cluster/storage/`](src/mod/cluster/storage/),
   [`src/mod/filesystem/abstractions/clusterfs/`](src/mod/filesystem/abstractions/clusterfs/)):
   admins contribute folders (volumes); files stay whole files inside them.
+  The drive is only mounted while the node is in a cluster that has at least
+  one volume (`clusterSyncDrive`), so otherwise nothing sees or scans it.
   Writes spool and hash locally, get placed by the leader, are copied
   (locally or by the 4 MiB chunked, SHA-256 verified `store/*` protocol) and
   only then published. The core mounts the drive into the base storage pool
@@ -310,7 +320,7 @@ before changing how a phase works, but start new work from the packages below.
   one weighted scorer answers "which node?" for jobs, write placement and
   replication. Weights (locality, free CPU/RAM/disk, queue depth, network
   distance, health, wanted features) are a replicated cluster setting edited
-  on the Cluster page; every score carries its factors, so
+  on the Cluster Settings page; every score carries its factors, so
   `/system/cluster/sched/explain?job=<id>` can show why a node won. Heartbeat
   round trips feed `NodeView.LatencyMs`, and a volume under 5 % free goes
   read only with a `node.diskfull` event. For a second copy the planner also

+ 2 - 0
README.md

@@ -195,6 +195,8 @@ The following startup parameters are supported (v2.016)
         Enable directory listing (default true)
   -disable_http
         Disable HTTP server, require tls=true
+  -disable_cluster
+        Disable the cluster features (joining, the cluster:/ drive, cluster jobs and their settings pages)
   -disable_ip_resolver
         Disable IP resolving if the system is running under reverse proxy environment
   -disable_subservice

+ 2 - 2
examples/clusters_jobs/README.md

@@ -13,7 +13,7 @@ pair of files:
 
 Keep both files of a pair in the same folder: the launcher refers to its job
 by a relative path. The node you run the launcher on must be a member of a
-cluster (System Settings > Cluster).
+cluster (System Settings > Cluster Settings).
 
 | Example | What it shows |
 |---|---|
@@ -61,7 +61,7 @@ var rec = cluster.jobs.wait(id, 60);   // throws if it is still running after 60
 `cluster.jobs.status(id)`, `cluster.jobs.list()` and `cluster.jobs.cancel(id)`
 work on jobs you submitted. Every job also appears on System Settings >
 Cluster Jobs, and an administrator can see why a job landed where it did on
-the Scheduling card of the Cluster page.
+the Scheduling card of the Cluster Info page.
 
 A job that no node can take yet stays queued, and `rec.state.reason` says
 why, for example that no online node has ffmpeg.

+ 22 - 4
examples/clusters_jobs/ffmpeg_convert/ffmpeg_convert.agi

@@ -6,7 +6,7 @@
     names its input file, and the scheduler picks a node that has ffmpeg,
     preferring one that already stores demo.mp4.
 
-    Optional GET/POST parameters:
+    Optional parameters, in the URL query or a form body:
         input   default cluster:/demo.mp4
         output  default cluster:/demo.webm
 
@@ -19,13 +19,31 @@
 
 requirelib("cluster");
 
+//Read a request parameter. A script run as a Serverless endpoint gets the
+//request through getPara() (URL query) and postPara() (form body); a WebApp
+//backend script gets its POST fields as global variables instead.
+function requestParam(name) {
+    var v = null;
+    if (typeof getPara === "function") {
+        v = getPara(name);
+    }
+    if ((v === null || v === "") && typeof postPara === "function") {
+        v = postPara(name);
+    }
+    if (v === null || v === "") {
+        var globalScope = (function () { return this; })();
+        v = (typeof globalScope[name] === "string") ? globalScope[name] : "";
+    }
+    return v === null ? "" : String(v);
+}
+
 function main() {
     if (!cluster.inCluster()) {
-        return { error: "This node is not part of a cluster. Create or join one in System Settings > Cluster." };
+        return { error: "This node is not part of a cluster. Create or join one in System Settings > Cluster Settings." };
     }
 
-    var src = (typeof input !== "undefined" && input !== "") ? input : "cluster:/demo.mp4";
-    var dst = (typeof output !== "undefined" && output !== "") ? output : "cluster:/demo.webm";
+    var src = requestParam("input") || "cluster:/demo.mp4";
+    var dst = requestParam("output") || "cluster:/demo.webm";
 
     var id = cluster.jobs.submit({
         name: "Convert " + src.split("/").pop() + " to " + dst.split(".").pop().toUpperCase(),

+ 1 - 1
examples/clusters_jobs/hello_world/hello_world.agi

@@ -23,7 +23,7 @@ var WAIT_SECONDS = 60;
 
 function main() {
     if (!cluster.inCluster()) {
-        return { error: "This node is not part of a cluster. Create or join one in System Settings > Cluster." };
+        return { error: "This node is not part of a cluster. Create or join one in System Settings > Cluster Settings." };
     }
 
     var submitted = [];

+ 24 - 6
examples/clusters_jobs/list_files/list_files.agi

@@ -5,7 +5,7 @@
     its output: every file under cluster:/ with its size and extension, and
     a size total per extension.
 
-    Optional GET/POST parameters:
+    Optional parameters, in the URL query or a form body:
         root   folder to list, default cluster:/
         limit  maximum files in the list, default 5000
 
@@ -14,17 +14,35 @@
 
 requirelib("cluster");
 
+//Read a request parameter. A script run as a Serverless endpoint gets the
+//request through getPara() (URL query) and postPara() (form body); a WebApp
+//backend script gets its POST fields as global variables instead.
+function requestParam(name) {
+    var v = null;
+    if (typeof getPara === "function") {
+        v = getPara(name);
+    }
+    if ((v === null || v === "") && typeof postPara === "function") {
+        v = postPara(name);
+    }
+    if (v === null || v === "") {
+        var globalScope = (function () { return this; })();
+        v = (typeof globalScope[name] === "string") ? globalScope[name] : "";
+    }
+    return v === null ? "" : String(v);
+}
+
 function main() {
     if (!cluster.inCluster()) {
-        return { error: "This node is not part of a cluster. Create or join one in System Settings > Cluster." };
+        return { error: "This node is not part of a cluster. Create or join one in System Settings > Cluster Settings." };
     }
 
     var args = {};
-    if (typeof root !== "undefined" && root !== "") {
-        args.root = root;
+    if (requestParam("root") !== "") {
+        args.root = requestParam("root");
     }
-    if (typeof limit !== "undefined" && parseInt(limit) > 0) {
-        args.limit = parseInt(limit);
+    if (parseInt(requestParam("limit")) > 0) {
+        args.limit = parseInt(requestParam("limit"));
     }
 
     var id = cluster.jobs.submit({

+ 72 - 15
src/cluster.go

@@ -280,6 +280,36 @@ func (b *clusterBackend) Ready() bool { return b.s.Ready() }
 
 // clusterMountDrive attaches the cluster:/ drive to the base storage pool so
 // every user sees it, and clusterUnmountDrive removes it again.
+// clusterDriveWanted reports whether cluster:/ should be visible: this node
+// is in a cluster and the cluster has at least one volume to keep files in.
+// Without either, the drive is left out of File Manager and every other part
+// of the system, nightly tasks included.
+func clusterDriveWanted() bool {
+	if clusterManager == nil || clusterStorage == nil || clusterMetadata == nil || !clusterManager.InCluster() {
+		return false
+	}
+	return clusterHasVolume(clusterMetadata.Volumes())
+}
+
+// clusterHasVolume reports whether any volume is still part of the cluster.
+func clusterHasVolume(vols []metadata.Volume) bool {
+	for _, v := range vols {
+		if !v.Removed {
+			return true
+		}
+	}
+	return false
+}
+
+// clusterSyncDrive mounts or unmounts cluster:/ to match clusterDriveWanted.
+func clusterSyncDrive() {
+	if clusterDriveWanted() {
+		clusterMountDrive()
+	} else {
+		clusterUnmountDrive()
+	}
+}
+
 func clusterMountDrive() {
 	clusterMountMu.Lock()
 	defer clusterMountMu.Unlock()
@@ -386,8 +416,19 @@ func (p *clusterHealthProvider) snapshot() membership.Health {
 }
 
 func ClusterInit() {
-	//Cluster agent: always available so a node can create or join a cluster
-	//from System Settings regardless of the LAN discovery features
+	if *disable_cluster {
+		systemWideLogger.PrintAndLog("Cluster", "Cluster features are disabled by the -disable_cluster flag", nil)
+	} else {
+		clusterStartAgent()
+	}
+	clusterStartNeighbourhood()
+}
+
+// clusterStartAgent starts the cluster agent and every cluster service on
+// top of it. It is always available unless -disable_cluster is set, so a node
+// can create or join a cluster from System Settings regardless of the LAN
+// discovery features.
+func clusterStartAgent() {
 	health := &clusterHealthProvider{}
 	manager, err := membership.NewManager(membership.Option{
 		NodeID:      deviceUUID,
@@ -401,6 +442,23 @@ func ClusterInit() {
 		systemWideLogger.PrintAndLog("Cluster", "Unable to start cluster agent: "+err.Error(), err)
 	} else {
 		clusterManager = manager
+		//Settings first, then Info; Cluster Jobs is added once the job runtime starts
+		registerSetting(settingModule{
+			Name:         "Cluster Settings",
+			Desc:         "Create or join a cluster and set up its storage and scheduling",
+			IconPath:     "SystemAO/cluster/img/small_icon.png",
+			Group:        "Cluster",
+			StartDir:     "SystemAO/cluster/cluster.html",
+			RequireAdmin: true,
+		})
+		registerSetting(settingModule{
+			Name:         "Cluster Info",
+			Desc:         "Health of this node, its peers, replication and scheduling",
+			IconPath:     "SystemAO/cluster/img/small_icon.png",
+			Group:        "Cluster",
+			StartDir:     "SystemAO/cluster/clusterinfo.html",
+			RequireAdmin: true,
+		})
 
 		adminRouter := prout.NewModuleRouter(prout.RouterOption{
 			ModuleName:  "System Setting",
@@ -463,13 +521,15 @@ func ClusterInit() {
 					if prevChange != nil {
 						prevChange(in)
 					}
-					if in {
-						clusterMountDrive()
-					} else {
-						clusterUnmountDrive()
+					clusterSyncDrive()
+				}
+				//Show or hide cluster:/ as volumes come and go
+				clusterMetadata.OnChange = func(kind string, payload []byte) {
+					if kind == metadata.KindVolume {
+						go clusterSyncDrive()
 					}
 				}
-				clusterMountDrive()
+				clusterSyncDrive()
 
 				//Event bus: file / replica / node events, script hooks, web feed
 				bus, err := events.New(clusterManager, clusterRunHook)
@@ -595,16 +655,13 @@ func ClusterInit() {
 			}
 		}
 
-		registerSetting(settingModule{
-			Name:         "Cluster",
-			Desc:         "Create or join an ArozOS cluster",
-			IconPath:     "SystemAO/cluster/img/small_icon.png",
-			Group:        "Cluster",
-			StartDir:     "SystemAO/cluster/cluster.html",
-			RequireAdmin: true,
-		})
 	}
 
+}
+
+// clusterStartNeighbourhood runs the older mDNS neighbour discovery, which is
+// separate from the cluster and governed by -allow_mdns.
+func clusterStartNeighbourhood() {
 	//Only enable neighbourhood scanning on mdns enabled mode
 	if *allow_mdns && MDNS != nil {
 		//Start the network discovery

+ 3 - 3
src/file_system.go

@@ -1103,7 +1103,7 @@ func system_fs_validateFileOpr(w http.ResponseWriter, r *http.Request) {
 
 	//Loop through all files are see if there are duplication during copy and paste
 	sourceFiles := []string{}
-	decodedSourceFiles, _ := url.QueryUnescape(vsrcFiles)
+	decodedSourceFiles := system_fs_specialURIDecode(vsrcFiles)
 	err = json.Unmarshal([]byte(decodedSourceFiles), &sourceFiles)
 	if err != nil {
 		utils.SendErrorResponse(w, "Source file JSON parse error.")
@@ -1947,7 +1947,7 @@ func parseFileOperationRequest(operation string, vsrcFiles string, vdestFile str
 
 	//Decode the source file list
 	var sourceFiles []string
-	decodedSourceFiles, _ := url.QueryUnescape(vsrcFiles)
+	decodedSourceFiles := system_fs_specialURIDecode(vsrcFiles)
 	err := json.Unmarshal([]byte(decodedSourceFiles), &sourceFiles)
 	if err != nil {
 		systemWideLogger.PrintAndLog("File System", "File operation source file JSON parse error", err)
@@ -1967,7 +1967,7 @@ func parseFileOperationRequest(operation string, vsrcFiles string, vdestFile str
 	vdestFile = strings.ReplaceAll(vdestFile, "{{plug_sign}}", "+")
 
 	//Decode the target position
-	escapedVdest, _ := url.QueryUnescape(vdestFile)
+	escapedVdest := system_fs_specialURIDecode(vdestFile)
 	vdestFile = escapedVdest
 
 	if vdestFile == "" {

+ 2 - 1
src/flags.go

@@ -35,7 +35,7 @@ var subserviceBasePort = 12810            //Next subservice port
 
 // =========== SYSTEM BUILD INFORMATION ==============
 var build_version = "development"                      //System build flag, this can be either {development / production / stable}
-var internal_version = "3.0.3"                         //Internal build version, [fork_id].[major_release_no].[minor_release_no]
+var internal_version = "3.0.4"                         //Internal build version, [fork_id].[major_release_no].[minor_release_no]
 var deviceUUID string                                  //The device uuid of this host
 var deviceVendor = "IMUSLAB.INC"                       //Vendor of the system
 var deviceVendorURL = "http://imuslab.com"             //Vendor contact information
@@ -59,6 +59,7 @@ var show_version = flag.Bool("version", false, "Show system build version")
 var host_name = flag.String("hostname", "My ArOZ", "Default name for this host")
 var system_uuid = flag.String("uuid", "", "System UUID for clustering and distributed computing. Only need to config once for first time startup. Leave empty for auto generation.")
 var disable_subservices = flag.Bool("disable_subservice", false, "Disable subservices completely")
+var disable_cluster = flag.Bool("disable_cluster", false, "Disable the cluster features (joining, the cluster:/ drive, cluster jobs and their settings pages)")
 
 // Flags related to Networking
 var allow_upnp = flag.Bool("allow_upnp", false, "Enable uPNP service, recommended for host under NAT router")

+ 2 - 1
src/main.router.go

@@ -68,7 +68,8 @@ func mrouter(h http.Handler) http.Handler {
 			//Cluster node-to-node protocol (ACN). Requests are authenticated by
 			//node signatures, not user sessions, see mod/cluster/acn
 			if clusterManager == nil {
-				errorHandleInternalServerError(w, r)
+				//Cluster disabled (-disable_cluster) or its agent failed to start
+				http.NotFound(w, r)
 				return
 			}
 			clusterManager.ACNHandler().ServeHTTP(w, r)

+ 60 - 10
src/mod/cluster/README.md

@@ -186,19 +186,34 @@ master and can always be rebuilt from the real files (storage reconcile).
   online peers (`meta/append`). Followers submit through the leader
   (`meta/submit`) or queue in `meta_pending` until one is reachable. Gaps
   are filled by `meta/log?after=N`; a compacted range or a new term triggers
-  a full `meta/snapshot`.
+  a full `meta/snapshot`. Only one goroutine drains the pending queue at a
+  time; other callers ask it for another round.
+- **Persistence** (`persist.go`): the store serves everything from memory
+  and writes its disk copy behind. A change updates memory under the store
+  lock and queues its disk write; queued writes are coalesced per key and
+  committed in one `database.WriteBatch` transaction every 50 ms, and
+  `Close` commits the rest. No disk sync ever happens under the store lock,
+  and the lease has its own lock, so a burst of writes cannot starve lease
+  renewal. A crash loses at most the last flush interval of this node's disk
+  copy, which catch-up or a snapshot restores like any other gap. Reading
+  the log back (`meta/log`, compaction) flushes first.
 - Tables (`meta_*`) are registered with `membership.RegisterClusterTable`
-  and wiped when the node leaves.
+  and wiped when the node leaves; the old store's queued writes are dropped
+  then, so they cannot bring wiped records back.
 
 Admin API: `/system/cluster/meta/{status,ls,stat,policy/list,policy/set}`.
 
 ## Storage (ACS) and the `cluster:/` drive
 
 Package `storage/` plus the file-system backend
-`mod/filesystem/abstractions/clusterfs`. When a node is in a cluster the core
-mounts a `Cluster` drive (`cluster:/`, public hierarchy, buffered) into the
-base storage pool, so File Manager, WebDAV, media serving and the AGI
-`filelib` use it like any other drive.
+`mod/filesystem/abstractions/clusterfs`. When a node is in a cluster **and
+the cluster has at least one volume**, the core mounts a `Cluster` drive
+(`cluster:/`, public hierarchy, buffered) into the base storage pool, so File
+Manager, WebDAV, media serving and the AGI `filelib` use it like any other
+drive. Without a cluster or without a volume the drive is unmounted, so it is
+missing from File Manager and from everything that walks the mounted drives
+(nightly tasks included). `clusterSyncDrive` in `src/cluster.go` re-checks on
+every membership change and every volume record change.
 
 - **Volumes**: an admin contributes a folder of a local drive
   (`storage/volume/add`, e.g. `user:/cluster`). Files in the namespace are
@@ -216,7 +231,14 @@ base storage pool, so File Manager, WebDAV, media serving and the AGI
   whole-file SHA-256 at commit, resumable, `.part-<session>` files renamed
   into place, sized for Cloudflare's request limits.
 
-Admin API: `/system/cluster/storage/{status,volume/add,volume/remove,volume/readonly,rescan}`.
+- **Nearly full volumes**: below 5 % free a volume goes read only
+  (`LowSpace`) and stops taking new files, and it recovers above 7 %. The
+  cluster-wide setting `storage.autoReadOnly` (Cluster Settings > Storage)
+  turns this off; volumes the guard had locked become writable again at
+  each node's next volume refresh (within a minute). Read only set by an
+  admin is never touched.
+
+Admin API: `/system/cluster/storage/{status,volume/add,volume/remove,volume/readonly,rescan,autoreadonly}`.
 
 ## Replication
 
@@ -271,6 +293,12 @@ status queries locally and a new leader resumes scheduling.
   `job.log()`, `job.progress()`, `job.cancelled()` and `job.abortIfCancelled()`,
   runs it as the owner through the AGI gateway, and publishes the return value
   as the job output. At most `NumCPU()` jobs run per node; the rest wait.
+- **Dispatch once**: scheduling passes are serialised, so two passes never
+  both see a job as queued and hand it out twice, and a node accepts each
+  job once even if a hand-over is repeated. A node that answers a hand-over
+  with a temporary error (a tunnel still connecting after a restart, not
+  yet knowing the leader) is retried; only "the owner has no account here"
+  or "this node cannot run jobs" keeps it away from that job for good.
 - **Leases**: a running node refreshes the job lease every 10 s. If it goes
   silent the leader requeues the job, and fails it after `MaxAttempts`.
   Cancellation marks the record; the running node interrupts its VM.
@@ -351,7 +379,8 @@ volume.
 Endpoints: `/system/cluster/sched/status` (weights, a per-node score preview
 and the latency matrix), `sched/weights` (GET / POST, any subset of the fields
 or `reset=true`) and `sched/explain?job=<id>`. UI: the Scheduling card on the
-Cluster page, with the weight sliders, the score preview and the matrix.
+Cluster Settings page (weight sliders) and the Cluster Info page (score
+preview, latency matrix and the explain box).
 
 Weights are read back through the defaults, so a record written by an older
 version keeps the shipped value for a weight it never knew about.
@@ -360,8 +389,29 @@ version keeps the shipped value for a weight it never knew about.
 
 `status`, `create`, `join`, `leave`, `config`, `testurl`, `token/new`,
 `token/list`, `token/revoke`, `node/remove`, `node/state`, `node/probe`,
-`nodes`, `capabilities`, `sched/{status,weights,explain}`. The UI is
-[`web/SystemAO/cluster/cluster.html`](../../web/SystemAO/cluster/cluster.html).
+`nodes`, `capabilities`, `sched/{status,weights,explain}`.
+
+The UI is three System Settings tabs in the Cluster group:
+
+| Tab | Page | What it holds |
+|---|---|---|
+| Cluster Settings | [`cluster.html`](../../web/SystemAO/cluster/cluster.html) | this node's name and URL, create / join / leave, join tokens, identity origin, volumes and the nearly-full toggle, replica policies, member maintenance and draining, scheduling weights |
+| Cluster Info | [`clusterinfo.html`](../../web/SystemAO/cluster/clusterinfo.html) | this node's reachability, platform and health, the cluster summary and namespace state, the node table with probes, replication health and tools, scheduling scores, the latency matrix and the job explainer |
+| Cluster Jobs | [`jobs.html`](../../web/SystemAO/cluster/jobs.html) | submit, follow and cancel jobs |
+
+They share `cluster.css` and `cluster.common.js`. Every string is localized
+through [`web/SystemAO/locale/cluster.json`](../../web/SystemAO/locale/cluster.json)
+(en-us, zh-tw, zh-hk, zh-cn, ja-jp, ko-kr): static labels carry a `locale`
+attribute, scripts call `CL.t(key, fallback)`, and messages that come back
+from the server go through `CL.tr(message)`, which matches `msg/<text>`
+entries exactly and `msgp/<template>` entries with `{0}`-style parts (each
+part translated again). When you add a server message that reaches these
+pages, add it to the locale file too; untranslated messages simply stay in
+English.
+
+Start ArozOS with `-disable_cluster` to leave the whole feature off: no
+cluster agent, no `cluster:/` drive, no cluster tabs, and `/cluster/acn/*`
+answers 404.
 
 ## Roadmap
 

+ 31 - 2
src/mod/cluster/metadata/log.go

@@ -236,10 +236,16 @@ func (mgr *Manager) applySnapshot(snap Snapshot) {
 		}
 	}
 	for i := range snap.Volumes {
-		mgr.st.putVolume(&snap.Volumes[i])
+		if mgr.st.putVolume(&snap.Volumes[i]) && mgr.OnChange != nil {
+			js, _ := json.Marshal(snap.Volumes[i])
+			mgr.OnChange(KindVolume, js)
+		}
 	}
 	for i := range snap.Policies {
-		mgr.st.putPolicy(&snap.Policies[i])
+		if mgr.st.putPolicy(&snap.Policies[i]) && mgr.OnChange != nil {
+			js, _ := json.Marshal(snap.Policies[i])
+			mgr.OnChange(KindPolicy, js)
+		}
 	}
 	for i := range snap.Jobs {
 		if mgr.st.putJob(&snap.Jobs[i]) && mgr.OnChange != nil {
@@ -274,7 +280,30 @@ func (mgr *Manager) pendingLoop() {
 
 // flushPending hands queued entries to the leader (or accepts them locally
 // when this node became the leader meanwhile).
+// flushPending drains the pending queue. It is called after every local
+// write, so only one call does the work at a time: a second call just asks
+// the running one for another round. Many concurrent drains each walking the
+// whole queue made the work grow with the square of the backlog.
 func (mgr *Manager) flushPending() {
+	mgr.flushAgain.Store(true)
+	for {
+		if !mgr.flushing.CompareAndSwap(false, true) {
+			return //the active drain will see flushAgain
+		}
+		for mgr.flushAgain.Swap(false) {
+			mgr.drainPending()
+		}
+		mgr.flushing.Store(false)
+		//A request that arrived as we were finishing gets its round now
+		if !mgr.flushAgain.Load() {
+			return
+		}
+	}
+}
+
+// drainPending sends every queued change to the leader, or accepts it when
+// this node is the leader.
+func (mgr *Manager) drainPending() {
 	pending := mgr.st.pendingEntries()
 	if len(pending) == 0 {
 		return

+ 15 - 1
src/mod/cluster/metadata/manager.go

@@ -12,6 +12,7 @@ package metadata
 import (
 	"errors"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"imuslab.com/arozos/mod/cluster/membership"
@@ -41,6 +42,11 @@ type Manager struct {
 	catchupCh chan struct{}
 	appends   int
 
+	//flushing is set while one goroutine drains the pending queue; the
+	//others only raise flushAgain so the active one does another round.
+	flushing   atomic.Bool
+	flushAgain atomic.Bool
+
 	//OnChange fires after a record changed locally or by replication.
 	OnChange func(kind string, payload []byte)
 }
@@ -104,12 +110,20 @@ func New(opt Option) (*Manager, error) {
 func (mgr *Manager) Close() {
 	mgr.once.Do(func() { close(mgr.stop) })
 	mgr.wg.Wait()
+	mgr.st.close()
 }
 
 // resetLocal forgets everything after the node left the cluster (the tables
 // were already wiped by membership; the in-memory maps must follow).
 func (mgr *Manager) resetLocal() {
-	mgr.st = newStore(mgr.m.DB())
+	//Drop the old store's queued disk writes, then wipe the tables again in
+	//case one of them landed between membership's wipe and this call
+	mgr.st.discard()
+	db := mgr.m.DB()
+	for _, t := range Tables {
+		db.DropTable(t)
+	}
+	mgr.st = newStore(db)
 }
 
 /*

+ 184 - 0
src/mod/cluster/metadata/persist.go

@@ -0,0 +1,184 @@
+package metadata
+
+/*
+	Write-behind persistence for the metadata store.
+
+	The store keeps everything it serves in memory; the disk copy exists so
+	a node can restart without a full resync. Writing that copy inline, one
+	synced transaction per change while holding the store lock, made every
+	reader and writer queue behind the disk. Under a burst the lease renewal
+	waited long enough for the leader to lose its lease, and the node never
+	caught up.
+
+	So a change now updates memory and queues its disk write here. Queued
+	writes are coalesced per key (only the newest value of a key is written)
+	and a background writer commits the whole queue in one transaction every
+	FlushInterval. Close flushes what is left.
+
+	A crash can lose the last FlushInterval of changes from this node's disk
+	copy. That copy is not authoritative: the replicated log and snapshots
+	bring a restarted node back up to date, exactly as they do for a node
+	that was offline.
+*/
+
+import (
+	"encoding/json"
+	"sync"
+	"time"
+
+	"imuslab.com/arozos/mod/database"
+	"imuslab.com/arozos/mod/info/logger"
+)
+
+// FlushInterval is how often queued writes reach the disk.
+var FlushInterval = 50 * time.Millisecond
+
+type opKey struct{ table, key string }
+
+type persister struct {
+	db       *database.Database
+	interval time.Duration //FlushInterval when the persister was made
+
+	mu      sync.Mutex
+	queue   map[opKey]database.BatchOp
+	stopped bool
+
+	flushMu sync.Mutex //one commit at a time, so older values never land last
+	once    sync.Once  //close or discard, whichever comes first
+	wake    chan struct{}
+	stop    chan struct{}
+	done    chan struct{}
+}
+
+func newPersister(db *database.Database) *persister {
+	p := &persister{
+		db:       db,
+		interval: FlushInterval,
+		queue:    map[opKey]database.BatchOp{},
+		wake:     make(chan struct{}, 1),
+		stop:     make(chan struct{}),
+		done:     make(chan struct{}),
+	}
+	go p.loop()
+	return p
+}
+
+// put queues a write. The value is marshalled now, so the caller may keep
+// changing its own copy after put returns.
+func (p *persister) put(table string, key string, value interface{}) {
+	js, err := json.Marshal(value)
+	if err != nil {
+		logger.PrintAndLog("Cluster", "Metadata "+table+"/"+key+" could not be encoded", err)
+		return
+	}
+	p.enqueue(database.BatchOp{Table: table, Key: key, Value: json.RawMessage(js)})
+}
+
+// del queues a removal.
+func (p *persister) del(table string, key string) {
+	p.enqueue(database.BatchOp{Table: table, Key: key, Delete: true})
+}
+
+func (p *persister) enqueue(op database.BatchOp) {
+	p.mu.Lock()
+	if p.stopped {
+		p.mu.Unlock()
+		return
+	}
+	p.queue[opKey{op.Table, op.Key}] = op //newest change of a key wins
+	p.mu.Unlock()
+	select {
+	case p.wake <- struct{}{}:
+	default:
+	}
+}
+
+func (p *persister) loop() {
+	defer close(p.done)
+	for {
+		select {
+		case <-p.stop:
+			return
+		case <-p.wake:
+		}
+		//Let a burst gather so it shares one transaction
+		select {
+		case <-p.stop:
+			return
+		case <-time.After(p.interval):
+		}
+		p.flush()
+	}
+}
+
+// flush commits everything queued so far and returns once it is on disk.
+func (p *persister) flush() {
+	p.flushMu.Lock()
+	defer p.flushMu.Unlock()
+
+	p.mu.Lock()
+	if len(p.queue) == 0 {
+		p.mu.Unlock()
+		return
+	}
+	batch := p.queue
+	p.queue = map[opKey]database.BatchOp{}
+	p.mu.Unlock()
+
+	ops := make([]database.BatchOp, 0, len(batch))
+	for _, op := range batch {
+		ops = append(ops, op)
+	}
+	if err := p.db.WriteBatch(ops); err != nil {
+		logger.PrintAndLog("Cluster", "Metadata could not be written to disk; retrying", err)
+		//Put the batch back unless a newer change of the same key arrived
+		p.mu.Lock()
+		if !p.stopped {
+			for k, op := range batch {
+				if _, newer := p.queue[k]; !newer {
+					p.queue[k] = op
+				}
+			}
+		}
+		p.mu.Unlock()
+		select {
+		case p.wake <- struct{}{}:
+		default:
+		}
+	}
+}
+
+// pending reports how many keys wait to be written (for tests and status).
+func (p *persister) pending() int {
+	p.mu.Lock()
+	defer p.mu.Unlock()
+	return len(p.queue)
+}
+
+// close stops the writer and commits what is left.
+func (p *persister) close() {
+	p.once.Do(func() {
+		close(p.stop)
+		<-p.done
+		p.mu.Lock()
+		p.stopped = true
+		p.mu.Unlock()
+		p.flush()
+	})
+}
+
+// discard stops the writer and drops what is queued, for a store whose
+// tables were wiped: writing its leftovers would bring the old data back.
+func (p *persister) discard() {
+	p.once.Do(func() {
+		p.mu.Lock()
+		p.stopped = true
+		p.queue = map[opKey]database.BatchOp{}
+		p.mu.Unlock()
+		close(p.stop)
+		<-p.done
+		//Wait out a commit another caller had already started
+		p.flushMu.Lock()
+		p.flushMu.Unlock()
+	})
+}

+ 158 - 0
src/mod/cluster/metadata/persist_test.go

@@ -0,0 +1,158 @@
+package metadata
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"sync"
+	"testing"
+	"time"
+
+	"imuslab.com/arozos/mod/database"
+)
+
+func persistDB(t *testing.T) *database.Database {
+	t.Helper()
+	db, err := database.NewDatabase(filepath.Join(t.TempDir(), "persist.db"), false)
+	if err != nil {
+		t.Fatalf("NewDatabase: %v", err)
+	}
+	t.Cleanup(db.Close)
+	db.NewTable("t")
+	return db
+}
+
+func readInt(t *testing.T, db *database.Database, key string) (int, bool) {
+	t.Helper()
+	if !db.KeyExists("t", key) {
+		return 0, false
+	}
+	var v int
+	if err := db.Read("t", key, &v); err != nil {
+		t.Fatalf("Read(%s): %v", key, err)
+	}
+	return v, true
+}
+
+func TestPersisterWritesBehind(t *testing.T) {
+	db := persistDB(t)
+	p := newPersister(db)
+	defer p.close()
+
+	p.put("t", "a", 1)
+	p.put("t", "a", 2) //coalesced: only the newest value is written
+	p.put("t", "b", 1)
+	p.del("t", "b") //a put then a delete of one key is just a delete
+	if p.pending() != 2 {
+		t.Errorf("two keys should be queued, got %d", p.pending())
+	}
+	p.flush()
+	if p.pending() != 0 {
+		t.Errorf("flush should empty the queue, %d left", p.pending())
+	}
+	if v, ok := readInt(t, db, "a"); !ok || v != 2 {
+		t.Errorf("a = %v (%v), want 2", v, ok)
+	}
+	if db.KeyExists("t", "b") {
+		t.Errorf("b should have been deleted")
+	}
+
+	//The background writer commits without an explicit flush
+	p.put("t", "c", 3)
+	deadline := time.Now().Add(2 * time.Second)
+	for time.Now().Before(deadline) {
+		if _, ok := readInt(t, db, "c"); ok {
+			return
+		}
+		time.Sleep(10 * time.Millisecond)
+	}
+	t.Errorf("the background writer never committed c")
+}
+
+func TestPersisterCapturesValueAtPut(t *testing.T) {
+	db := persistDB(t)
+	p := newPersister(db)
+	defer p.close()
+	v := map[string]int{"n": 1}
+	p.put("t", "m", v)
+	v["n"] = 99 //changing the caller's copy afterwards must not leak into the write
+	p.flush()
+	var got map[string]int
+	db.Read("t", "m", &got)
+	if got["n"] != 1 {
+		t.Errorf("stored %v, want the value as it was at put", got)
+	}
+}
+
+func TestPersisterCloseAndDiscard(t *testing.T) {
+	tests := []struct {
+		name      string
+		finish    func(p *persister)
+		wantSaved bool
+	}{
+		{"close commits what is queued", func(p *persister) { p.close() }, true},
+		{"discard drops what is queued", func(p *persister) { p.discard() }, false},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			db := persistDB(t)
+			old := FlushInterval
+			FlushInterval = time.Hour //keep the background writer out of the way
+			p := newPersister(db)
+			FlushInterval = old
+
+			p.put("t", "k", 7)
+			tc.finish(p)
+			if _, ok := readInt(t, db, "k"); ok != tc.wantSaved {
+				t.Errorf("saved = %v, want %v", ok, tc.wantSaved)
+			}
+			//After either, further changes are ignored and both calls are safe to repeat
+			p.put("t", "late", 1)
+			p.close()
+			p.discard()
+			if db.KeyExists("t", "late") {
+				t.Errorf("a stopped persister must not write")
+			}
+		})
+	}
+}
+
+func TestPersisterRetriesAFailedWrite(t *testing.T) {
+	db := persistDB(t)
+	p := newPersister(db)
+	defer p.close()
+
+	db.UpdateReadWriteMode(true) //every write fails
+	p.put("t", "r", 1)
+	p.flush()
+	if p.pending() != 1 {
+		t.Fatalf("a failed write must stay queued, %d queued", p.pending())
+	}
+	db.UpdateReadWriteMode(false)
+	p.flush()
+	if v, ok := readInt(t, db, "r"); !ok || v != 1 {
+		t.Errorf("the retried write did not land: %v %v", v, ok)
+	}
+}
+
+func TestPersisterConcurrentPuts(t *testing.T) {
+	db := persistDB(t)
+	p := newPersister(db)
+	var wg sync.WaitGroup
+	for i := 0; i < 50; i++ {
+		wg.Add(1)
+		go func(i int) {
+			defer wg.Done()
+			js, _ := json.Marshal(i)
+			p.put("t", string(rune('a'+i%26))+string(js), i)
+		}(i)
+	}
+	wg.Wait()
+	p.close()
+	rows, err := db.ListTable("t")
+	if err != nil {
+		t.Fatalf("ListTable: %v", err)
+	}
+	if len(rows) != 50 {
+		t.Errorf("want 50 rows after close, got %d", len(rows))
+	}
+}

+ 44 - 28
src/mod/cluster/metadata/store.go

@@ -43,8 +43,9 @@ const (
 var Tables = []string{tableFiles, tablePaths, tableVolumes, tablePolicy, tableJobs, tableSettings, tableLog, tablePending, tableState}
 
 type store struct {
-	db *database.Database
-	mu sync.RWMutex
+	db      *database.Database
+	persist *persister //disk writes happen here, never under mu
+	mu      sync.RWMutex
 
 	files    map[string]*FileRecord
 	paths    map[string]string
@@ -57,6 +58,10 @@ type store struct {
 	lastSeq uint64
 	applied uint64
 	term    uint64
+
+	//The lease has its own lock: renewing it must never wait behind the
+	//namespace, or a busy leader loses its lease.
+	leaseMu sync.RWMutex
 	lease   Lease
 }
 
@@ -77,9 +82,20 @@ func newStore(db *database.Database) *store {
 		db.NewTable(t)
 	}
 	s.load()
+	s.persist = newPersister(db)
 	return s
 }
 
+// close commits pending disk writes and stops the writer.
+func (s *store) close() {
+	s.persist.close()
+}
+
+// discard drops pending disk writes; used when the tables were wiped.
+func (s *store) discard() {
+	s.persist.discard()
+}
+
 func (s *store) load() {
 	if entries, err := s.db.ListTable(tableFiles); err == nil {
 		for _, kv := range entries {
@@ -162,25 +178,25 @@ func (s *store) putFile(in *FileRecord) bool {
 	}
 	if ok && existing.Path != rec.Path && s.paths[existing.Path] == rec.ID {
 		delete(s.paths, existing.Path)
-		s.db.Delete(tablePaths, existing.Path)
+		s.persist.del(tablePaths, existing.Path)
 	}
 	s.files[rec.ID] = rec
-	s.db.Write(tableFiles, rec.ID, rec)
+	s.persist.put(tableFiles, rec.ID, rec)
 	if rec.Removed {
 		if s.paths[rec.Path] == rec.ID {
 			delete(s.paths, rec.Path)
-			s.db.Delete(tablePaths, rec.Path)
+			s.persist.del(tablePaths, rec.Path)
 		}
 	} else {
 		//A newer record for the same path replaces an older one's claim
 		if otherID, taken := s.paths[rec.Path]; taken && otherID != rec.ID {
 			if other, ok := s.files[otherID]; ok && other.Version < rec.Version {
 				s.paths[rec.Path] = rec.ID
-				s.db.Write(tablePaths, rec.Path, rec.ID)
+				s.persist.put(tablePaths, rec.Path, rec.ID)
 			}
 		} else {
 			s.paths[rec.Path] = rec.ID
-			s.db.Write(tablePaths, rec.Path, rec.ID)
+			s.persist.put(tablePaths, rec.Path, rec.ID)
 		}
 	}
 	return true
@@ -300,7 +316,7 @@ func (s *store) putVolume(in *Volume) bool {
 	}
 	v := *in
 	s.volumes[v.ID] = &v
-	s.db.Write(tableVolumes, v.ID, v)
+	s.persist.put(tableVolumes, v.ID, v)
 	return true
 }
 
@@ -338,7 +354,7 @@ func (s *store) putPolicy(in *Policy) bool {
 		return false
 	}
 	s.policies[p.Folder] = &p
-	s.db.Write(tablePolicy, p.Folder, p)
+	s.persist.put(tablePolicy, p.Folder, p)
 	return true
 }
 
@@ -378,7 +394,7 @@ func (s *store) putJob(in *Job) bool {
 	}
 	j := *in
 	s.jobs[j.ID] = &j
-	s.db.Write(tableJobs, j.ID, j)
+	s.persist.put(tableJobs, j.ID, j)
 	return true
 }
 
@@ -412,7 +428,7 @@ func (s *store) gcJobs(cutoff int64, keepStatus map[string]bool) int {
 	for id, j := range s.jobs {
 		if j.Created < cutoff && !keepStatus[j.Status] {
 			delete(s.jobs, id)
-			s.db.Delete(tableJobs, id)
+			s.persist.del(tableJobs, id)
 			n++
 		}
 	}
@@ -430,7 +446,7 @@ func (s *store) putSetting(in *Setting) bool {
 	}
 	st := *in
 	s.settings[st.Key] = &st
-	s.db.Write(tableSettings, st.Key, st)
+	s.persist.put(tableSettings, st.Key, st)
 	return true
 }
 
@@ -456,15 +472,16 @@ func (s *store) appendLog(e Entry, term uint64) Entry {
 	s.lastSeq++
 	e.Seq = s.lastSeq
 	e.Term = term
-	s.db.Write(tableLog, logKey(e.Seq), e)
+	s.persist.put(tableLog, logKey(e.Seq), e)
 	return e
 }
 
 // logAfter returns up to max entries with Seq > after. ok is false when the
 // requested range was compacted away.
 func (s *store) logAfter(after uint64, max int) ([]Entry, bool) {
-	s.mu.RLock()
-	defer s.mu.RUnlock()
+	//The log is read back from disk, so commit what is still queued first
+	s.persist.flush()
+	lastSeq := s.getLastSeq()
 	entries, err := s.db.ListTableWithPrefix(tableLog, "")
 	if err != nil {
 		return nil, false
@@ -486,7 +503,7 @@ func (s *store) logAfter(after uint64, max int) ([]Entry, bool) {
 			}
 		}
 	}
-	if after > 0 && (first > after+1 || (len(entries) == 0 && s.lastSeq > after)) {
+	if after > 0 && (first > after+1 || (len(entries) == 0 && lastSeq > after)) {
 		return nil, false
 	}
 	return out, true
@@ -494,14 +511,13 @@ func (s *store) logAfter(after uint64, max int) ([]Entry, bool) {
 
 // compactLog keeps only the newest keepLast entries.
 func (s *store) compactLog(keepLast int) {
-	s.mu.Lock()
-	defer s.mu.Unlock()
+	s.persist.flush()
 	entries, err := s.db.ListTableWithPrefix(tableLog, "")
 	if err != nil || len(entries) <= keepLast {
 		return
 	}
 	for _, kv := range entries[:len(entries)-keepLast] {
-		s.db.Delete(tableLog, string(kv[0]))
+		s.persist.del(tableLog, string(kv[0]))
 	}
 }
 
@@ -530,35 +546,35 @@ func (s *store) setApplied(seq uint64, term uint64) {
 	defer s.mu.Unlock()
 	s.applied = seq
 	s.term = term
-	s.db.Write(tableState, stateApplied, seq)
-	s.db.Write(tableState, stateTerm, term)
+	s.persist.put(tableState, stateApplied, seq)
+	s.persist.put(tableState, stateTerm, term)
 }
 
 func (s *store) getLease() Lease {
-	s.mu.RLock()
-	defer s.mu.RUnlock()
+	s.leaseMu.RLock()
+	defer s.leaseMu.RUnlock()
 	return s.lease
 }
 
 func (s *store) setLease(l Lease) {
-	s.mu.Lock()
-	defer s.mu.Unlock()
+	s.leaseMu.Lock()
 	s.lease = l
-	s.db.Write(tableState, stateLease, l)
+	s.leaseMu.Unlock()
+	s.persist.put(tableState, stateLease, l)
 }
 
 func (s *store) addPending(id string, e Entry) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	s.pending[id] = e
-	s.db.Write(tablePending, id, e)
+	s.persist.put(tablePending, id, e)
 }
 
 func (s *store) removePending(id string) {
 	s.mu.Lock()
 	defer s.mu.Unlock()
 	delete(s.pending, id)
-	s.db.Delete(tablePending, id)
+	s.persist.del(tablePending, id)
 }
 
 func (s *store) pendingEntries() map[string]Entry {

+ 24 - 5
src/mod/cluster/storage/handlers_admin.go

@@ -30,10 +30,11 @@ type VolumeView struct {
 
 // StatusView is the storage picture for the settings page.
 type StatusView struct {
-	Ready      bool         `json:"ready"`
-	Volumes    []VolumeView `json:"volumes"`
-	LocalRoots []RootView   `json:"localRoots"`
-	Sessions   int          `json:"sessions"`
+	Ready        bool         `json:"ready"`
+	AutoReadOnly bool         `json:"autoReadOnly"` //nearly full volumes become read only
+	Volumes      []VolumeView `json:"volumes"`
+	LocalRoots   []RootView   `json:"localRoots"`
+	Sessions     int          `json:"sessions"`
 }
 
 // RootView is a local drive the admin may contribute a folder from.
@@ -44,7 +45,7 @@ type RootView struct {
 
 // Status builds the storage status.
 func (s *Service) Status() StatusView {
-	st := StatusView{Ready: s.Ready(), Volumes: []VolumeView{}, LocalRoots: []RootView{}}
+	st := StatusView{Ready: s.Ready(), AutoReadOnly: s.AutoReadOnly(), Volumes: []VolumeView{}, LocalRoots: []RootView{}}
 	me := s.m.NodeID()
 	for _, v := range s.meta.Volumes() {
 		if v.Removed {
@@ -137,4 +138,22 @@ func (s *Service) RegisterAdminRoutes(register func(pattern string, handler func
 	register("/system/cluster/storage/volume/remove", s.HandleVolumeRemove)
 	register("/system/cluster/storage/volume/readonly", s.HandleVolumeReadOnly)
 	register("/system/cluster/storage/rescan", s.HandleRescan)
+	register("/system/cluster/storage/autoreadonly", s.HandleAutoReadOnly)
+}
+
+// HandleAutoReadOnly reads (GET) or sets (POST enabled=true|false) whether
+// nearly full volumes are made read only automatically.
+func (s *Service) HandleAutoReadOnly(w http.ResponseWriter, r *http.Request) {
+	if r.Method == http.MethodPost {
+		raw, err := utils.PostPara(r, "enabled")
+		if err != nil || (raw != "true" && raw != "false") {
+			utils.SendErrorResponse(w, "enabled must be true or false")
+			return
+		}
+		if err := s.SetAutoReadOnly(raw == "true"); err != nil {
+			utils.SendErrorResponse(w, err.Error())
+			return
+		}
+	}
+	sendJSON(w, map[string]bool{"enabled": s.AutoReadOnly()})
 }

+ 1 - 1
src/mod/cluster/storage/service.go

@@ -50,7 +50,7 @@ const (
 )
 
 var (
-	ErrNoVolume       = errors.New("no cluster volume available; add one in System Settings > Cluster > Storage")
+	ErrNoVolume       = errors.New("no cluster volume available; add one in System Settings > Cluster Settings > Storage")
 	ErrNoHealthyCopy  = errors.New("no healthy copy of this file is reachable right now")
 	ErrNotLocalVolume = errors.New("volume is not on this node")
 	ErrPathEscape     = errors.New("path escapes the volume")

+ 52 - 0
src/mod/cluster/storage/storage_test.go

@@ -475,3 +475,55 @@ func TestSpaceStateHysteresis(t *testing.T) {
 		})
 	}
 }
+
+func TestLowSpaceAction(t *testing.T) {
+	const total = 1000
+	tests := []struct {
+		name      string
+		free      int64
+		wasFull   bool
+		auto      bool
+		wantMark  bool
+		wantClear bool
+	}{
+		{"guard on, plenty of room", 500, false, true, false, false},
+		{"guard on, nearly full is marked", 40, false, true, true, false},
+		{"guard on, already marked stays", 60, true, true, false, false},
+		{"guard on, recovered is released", 80, true, true, false, true},
+		{"guard off, nearly full is left alone", 40, false, false, false, false},
+		{"guard off, earlier mark is released", 40, true, false, false, true},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			mark, clear := lowSpaceAction(tc.free, total, tc.wasFull, tc.auto)
+			if mark != tc.wantMark || clear != tc.wantClear {
+				t.Errorf("lowSpaceAction(%d, %d, %v, %v) = %v, %v; want %v, %v",
+					tc.free, total, tc.wasFull, tc.auto, mark, clear, tc.wantMark, tc.wantClear)
+			}
+		})
+	}
+}
+
+func TestAutoReadOnlySetting(t *testing.T) {
+	a := newTestNode(t, "solo")
+	a.m.CreateCluster("Solo")
+	waitFor(t, "lead", 5*time.Second, func() bool { return a.meta.IsLeader() })
+	if !a.svc.AutoReadOnly() {
+		t.Fatalf("the guard must be on by default")
+	}
+	if err := a.svc.SetAutoReadOnly(false); err != nil {
+		t.Fatalf("SetAutoReadOnly(false): %v", err)
+	}
+	if a.svc.AutoReadOnly() {
+		t.Errorf("the guard should be off")
+	}
+	if a.svc.Status().AutoReadOnly != a.svc.AutoReadOnly() {
+		t.Errorf("status must report the setting")
+	}
+	if err := a.svc.SetAutoReadOnly(true); err != nil {
+		t.Fatalf("SetAutoReadOnly(true): %v", err)
+	}
+	if !a.svc.AutoReadOnly() {
+		t.Errorf("the guard should be back on")
+	}
+}

+ 61 - 5
src/mod/cluster/storage/volumes.go

@@ -5,6 +5,7 @@ package storage
 */
 
 import (
+	"encoding/json"
 	"errors"
 	"fmt"
 	"os"
@@ -191,6 +192,7 @@ func (s *Service) SetVolumeReadOnly(id string, ro bool) error {
 
 // refreshVolumes republishes free space when it moved by more than 1 %.
 func (s *Service) refreshVolumes() {
+	auto := s.AutoReadOnly()
 	for _, v := range s.LocalVolumes() {
 		root, err := s.volumeRoot(&v)
 		if err != nil {
@@ -205,19 +207,24 @@ func (s *Service) refreshVolumes() {
 			delta = -delta
 		}
 		//A volume under the low water mark stops taking new files until it
-		//recovers, so a node never fills its own disk.
-		isFull, recovered := spaceState(free, total, v.ReadOnly && v.LowSpace)
+		//recovers, so a node never fills its own disk (unless an admin turned
+		//that off for the cluster).
 		wasFull := v.ReadOnly && v.LowSpace
+		mark, clear := lowSpaceAction(free, total, wasFull, auto)
 		changed := total != v.Capacity || (total > 0 && delta*100 > total)
-		if isFull && !wasFull {
+		if mark {
 			v.ReadOnly, v.LowSpace, changed = true, true, true
 			logger.PrintAndLog("Cluster", "Volume "+v.Name+" is nearly full and stops taking new files", nil)
 			if s.OnDiskFull != nil {
 				s.OnDiskFull(v)
 			}
-		} else if recovered {
+		} else if clear {
 			v.ReadOnly, v.LowSpace, changed = false, false, true
-			logger.PrintAndLog("Cluster", "Volume "+v.Name+" has room again and takes new files", nil)
+			if auto {
+				logger.PrintAndLog("Cluster", "Volume "+v.Name+" has room again and takes new files", nil)
+			} else {
+				logger.PrintAndLog("Cluster", "Volume "+v.Name+" takes new files again: automatic read-only is turned off", nil)
+			}
 		}
 		if changed {
 			v.Free, v.Capacity = free, total
@@ -226,6 +233,55 @@ func (s *Service) refreshVolumes() {
 	}
 }
 
+// AutoReadOnlyKey is the replicated setting that turns the nearly-full guard
+// on or off for the whole cluster.
+const AutoReadOnlyKey = "storage.autoReadOnly"
+
+type autoReadOnlySetting struct {
+	Enabled bool `json:"enabled"`
+}
+
+// AutoReadOnly reports whether volumes that are nearly full are made read
+// only automatically. It is on unless an admin turned it off.
+func (s *Service) AutoReadOnly() bool {
+	st, ok := s.meta.Setting(AutoReadOnlyKey)
+	if !ok {
+		return true
+	}
+	var v autoReadOnlySetting
+	if json.Unmarshal(st.Value, &v) != nil {
+		return true
+	}
+	return v.Enabled
+}
+
+// SetAutoReadOnly turns the nearly-full guard on or off for every node. This
+// node applies it at once; the others at their next volume refresh.
+func (s *Service) SetAutoReadOnly(enabled bool) error {
+	js, err := json.Marshal(autoReadOnlySetting{Enabled: enabled})
+	if err != nil {
+		return err
+	}
+	if err := s.meta.Submit(metadata.KindSetting, &metadata.Setting{Key: AutoReadOnlyKey, Value: js}); err != nil {
+		return err
+	}
+	go s.refreshVolumes()
+	return nil
+}
+
+// lowSpaceAction decides what the guard does to one volume. wasFull says the
+// guard itself made the volume read only earlier. With the guard on, a volume
+// is marked below the low water mark and released above the recover mark;
+// with it off, nothing is marked and anything the guard had marked is
+// released, while read-only set by an admin is never touched.
+func lowSpaceAction(free int64, total int64, wasFull bool, auto bool) (mark bool, clear bool) {
+	if !auto {
+		return false, wasFull
+	}
+	full, recovered := spaceState(free, total, wasFull)
+	return full && !wasFull, recovered
+}
+
 // spaceState decides whether a volume is too full to take new files. The two
 // thresholds differ so a volume hovering at the limit does not flap: it stops
 // below diskFullFraction and only starts again above diskRecoverFraction.

+ 28 - 0
src/mod/database/database.go

@@ -97,6 +97,34 @@ func (d *Database) Delete(tableName string, key string) error {
 	return d.delete(tableName, key)
 }
 
+// BatchOp is one change inside a WriteBatch: a Put of Value under Key, or a
+// removal of Key when Delete is true. Value is marshalled to JSON like Write
+// does; a json.RawMessage is stored as it is.
+type BatchOp struct {
+	Table  string
+	Key    string
+	Value  interface{}
+	Delete bool
+}
+
+/*
+WriteBatch applies many changes at once. On the bolt backend they share one
+transaction, so they cost a single disk sync instead of one each, and either
+all of them land or none do. Tables are created when missing. Operations run
+in the order given.
+
+	err := sysdb.WriteBatch([]database.BatchOp{
+		{Table: "MyTable", Key: "a", Value: 1},
+		{Table: "MyTable", Key: "b", Delete: true},
+	})
+*/
+func (d *Database) WriteBatch(ops []BatchOp) error {
+	if len(ops) == 0 {
+		return nil
+	}
+	return d.writeBatch(ops)
+}
+
 /*
 	//List table example usage
 	//Assume the value is stored as a struct named "groupstruct"

+ 87 - 0
src/mod/database/database_batch_test.go

@@ -0,0 +1,87 @@
+package database
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+)
+
+func openBatchDB(t *testing.T) *Database {
+	t.Helper()
+	db, err := NewDatabase(filepath.Join(t.TempDir(), "batch.db"), false)
+	if err != nil {
+		t.Fatalf("NewDatabase: %v", err)
+	}
+	t.Cleanup(db.Close)
+	return db
+}
+
+func TestWriteBatch(t *testing.T) {
+	db := openBatchDB(t)
+	db.NewTable("existing")
+	db.Write("existing", "gone", "old")
+
+	err := db.WriteBatch([]BatchOp{
+		{Table: "existing", Key: "a", Value: 1},
+		{Table: "existing", Key: "gone", Delete: true},
+		{Table: "created", Key: "b", Value: map[string]string{"x": "y"}},
+		{Table: "existing", Key: "raw", Value: json.RawMessage(`{"n":2}`)},
+		{Table: "existing", Key: "a", Value: 3}, //later op on the same key wins
+	})
+	if err != nil {
+		t.Fatalf("WriteBatch: %v", err)
+	}
+
+	tests := []struct {
+		name  string
+		table string
+		key   string
+		want  string //JSON of the stored value, "" when it must be absent
+	}{
+		{"put", "existing", "a", "3"},
+		{"delete", "existing", "gone", ""},
+		{"table is created", "created", "b", `{"x":"y"}`},
+		{"raw JSON is stored as is", "existing", "raw", `{"n":2}`},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.want == "" {
+				if db.KeyExists(tc.table, tc.key) {
+					t.Errorf("%s/%s should not exist", tc.table, tc.key)
+				}
+				return
+			}
+			var got json.RawMessage
+			if err := db.Read(tc.table, tc.key, &got); err != nil {
+				t.Fatalf("Read: %v", err)
+			}
+			if string(got) != tc.want {
+				t.Errorf("%s/%s = %s, want %s", tc.table, tc.key, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestWriteBatchEdgeCases(t *testing.T) {
+	db := openBatchDB(t)
+	if err := db.WriteBatch(nil); err != nil {
+		t.Errorf("an empty batch should be a no-op, got %v", err)
+	}
+
+	//A value that cannot be marshalled fails the batch before anything lands
+	err := db.WriteBatch([]BatchOp{
+		{Table: "t", Key: "first", Value: 1},
+		{Table: "t", Key: "bad", Value: make(chan int)},
+	})
+	if err == nil {
+		t.Fatalf("an unmarshallable value must fail the batch")
+	}
+	if db.KeyExists("t", "first") {
+		t.Errorf("a failed batch must not write its other operations")
+	}
+
+	db.UpdateReadWriteMode(true)
+	if err := db.WriteBatch([]BatchOp{{Table: "t", Key: "k", Value: 1}}); err == nil {
+		t.Errorf("a read only database must reject a batch")
+	}
+}

+ 36 - 0
src/mod/database/database_core.go

@@ -158,6 +158,42 @@ func (d *Database) keyExists(tableName string, key string) bool {
 	}
 }
 
+func (d *Database) writeBatch(ops []BatchOp) error {
+	if d.ReadOnly {
+		return errors.New("Operation rejected in ReadOnly mode")
+	}
+	//Marshal first so a bad value fails the batch before anything is written
+	values := make([][]byte, len(ops))
+	for i, op := range ops {
+		if op.Delete {
+			continue
+		}
+		js, err := json.Marshal(op.Value)
+		if err != nil {
+			return err
+		}
+		values[i] = js
+	}
+	return d.Db.(*bolt.DB).Update(func(tx *bolt.Tx) error {
+		for i, op := range ops {
+			b, err := tx.CreateBucketIfNotExists([]byte(op.Table))
+			if err != nil {
+				return err
+			}
+			if op.Delete {
+				if err := b.Delete([]byte(op.Key)); err != nil {
+					return err
+				}
+				continue
+			}
+			if err := b.Put([]byte(op.Key), values[i]); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
 func (d *Database) delete(tableName string, key string) error {
 	if d.ReadOnly {
 		return errors.New("Operation rejected in ReadOnly mode")

+ 34 - 0
src/mod/database/database_openwrt.go

@@ -138,6 +138,40 @@ func (d *Database) keyExists(tableName string, key string) bool {
 	return fileExists(entryPath)
 }
 
+// writeBatch has no transactions on this backend; it applies the operations
+// one after another and stops at the first failure.
+func (d *Database) writeBatch(ops []BatchOp) error {
+	if d.ReadOnly {
+		return errors.New("Operation rejected in ReadOnly mode")
+	}
+	//Reject a value that cannot be stored before writing anything
+	for _, op := range ops {
+		if op.Delete {
+			continue
+		}
+		if _, err := json.Marshal(op.Value); err != nil {
+			return err
+		}
+	}
+	for _, op := range ops {
+		if !d.tableExists(op.Table) {
+			if err := d.newTable(op.Table); err != nil {
+				return err
+			}
+		}
+		var err error
+		if op.Delete {
+			err = d.delete(op.Table, op.Key)
+		} else {
+			err = d.write(op.Table, op.Key, op.Value)
+		}
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func (d *Database) delete(tableName string, key string) error {
 	if d.ReadOnly {
 		return errors.New("Operation rejected in ReadOnly mode")

+ 3 - 4
src/mod/media/mediaserver/mediaserver.go

@@ -102,10 +102,9 @@ func (s *Instance) ValidateSourceFile(w http.ResponseWriter, r *http.Request) (*
 	}
 
 	targetfile, _ := utils.GetPara(r, "file")
-	targetfile, err = url.QueryUnescape(targetfile)
-	if err != nil {
-		return nil, "", "", err
-	}
+	//GetPara has already decoded the query once. Decode again for clients that
+	//double encode, but keep a literal "+" (QueryUnescape would turn it into a space)
+	targetfile = filesystem.DecodeURI(targetfile)
 	if targetfile == "" {
 		return nil, "", "", errors.New("Missing paramter 'file'")
 	}

+ 226 - 0
src/web/SystemAO/cluster/cluster.common.js

@@ -0,0 +1,226 @@
+/*
+    Cluster pages - shared helpers
+
+    Used by Cluster Settings (cluster.html), Cluster Info (clusterinfo.html)
+    and Cluster Jobs (jobs.html). Everything lives on the CL object:
+
+        CL.init(callback)        load ../locale/cluster.json, translate the
+                                 static page, then call callback (also called
+                                 when the locale cannot be loaded)
+        CL.t(key, fallback)      a UI string in the viewer's language
+        CL.tr(message)           a message that came back from the server,
+                                 translated when the locale knows it
+        CL.state(name)           a node / job / copy state label
+
+    Server messages are matched two ways. "msg/<text>" entries translate a
+    fixed message. "msgp/<template>" entries translate a message with
+    variable parts: the template marks each part {0}, {1}, ... and the
+    translation puts them back where that language wants them. Each part is
+    translated again, so "node X could not take the job: <reason>" also
+    translates the reason.
+*/
+
+var CL = (function () {
+    var loc = null;
+    var patterns = null;
+
+    function strings() {
+        if (!loc || !loc.localData || !loc.localData.keys) return null;
+        var set = loc.localData.keys[loc.lang];
+        return (set && set.strings) ? set.strings : null;
+    }
+
+    function t(key, fallback) {
+        var s = strings();
+        if (s && s[key] !== undefined && s[key] !== '') return s[key];
+        return fallback;
+    }
+
+    function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
+
+    //Compile every "msgp/" template once into a regular expression
+    function compilePatterns() {
+        patterns = [];
+        var s = strings();
+        if (!s) return;
+        Object.keys(s).forEach(function (key) {
+            if (key.indexOf('msgp/') !== 0) return;
+            var template = key.substring(5);
+            var order = [];
+            var parts = template.split(/(\{\d\})/);
+            var re = '^';
+            parts.forEach(function (p, i) {
+                var m = p.match(/^\{(\d)\}$/);
+                if (m) {
+                    order.push(parseInt(m[1], 10));
+                    //The last placeholder takes the rest of the message
+                    re += (i === parts.length - 2 && parts[parts.length - 1] === '') ? '([\\s\\S]*)' : '([\\s\\S]*?)';
+                } else {
+                    re += escapeRegex(p);
+                }
+            });
+            patterns.push({ re: new RegExp(re + '$'), order: order, out: s[key], literal: template.replace(/\{\d\}/g, '').length });
+        });
+        //Try the most specific templates first
+        patterns.sort(function (a, b) { return b.literal - a.literal; });
+    }
+
+    function tr(message, depth) {
+        if (message === null || message === undefined) return message;
+        var text = String(message).trim();
+        if (text === '') return text;
+        depth = depth || 0;
+        var s = strings();
+        if (!s) return text;
+        if (s['msg/' + text] !== undefined && s['msg/' + text] !== '') return s['msg/' + text];
+        if (depth > 3) return text;
+        if (patterns === null) compilePatterns();
+        for (var i = 0; i < patterns.length; i++) {
+            var m = text.match(patterns[i].re);
+            if (!m) continue;
+            var out = patterns[i].out;
+            if (!out) return text;
+            patterns[i].order.forEach(function (slot, idx) {
+                out = out.split('{' + slot + '}').join(tr(m[idx + 1], depth + 1));
+            });
+            return out;
+        }
+        return text;
+    }
+
+    function state(name) {
+        if (!name) return '';
+        return t('cluster/state/' + String(name).toLowerCase(), String(name).toUpperCase());
+    }
+
+    function init(callback) {
+        var done = false;
+        function finish() {
+            if (done) return;
+            done = true;
+            if (callback) callback();
+        }
+        if (typeof NewAppLocale !== 'function' || typeof $ === 'undefined') { finish(); return; }
+        loc = NewAppLocale();
+        loc.init('../locale/cluster.json', function () {
+            patterns = null;
+            try { loc.translate(); } catch (e) {}
+            finish();
+        });
+        //Never hold the page back if the locale file is missing
+        setTimeout(finish, 1500);
+    }
+
+    /* Theme */
+    function applyTheme(rootId) {
+        var root = document.getElementById(rootId);
+        var set = function (isDark) { if (root) root.classList.toggle('dark', isDark); };
+        try {
+            if (typeof ao_module_getSystemThemeColor === 'function') {
+                ao_module_getSystemThemeColor(function (c) { set(c !== 'whiteTheme'); });
+            } else {
+                var theme = null;
+                if (typeof preferredTheme !== 'undefined') theme = preferredTheme;
+                else if (parent && typeof parent.preferredTheme !== 'undefined') theme = parent.preferredTheme;
+                if (theme) set(theme === 'dark' || theme === 'darkTheme');
+            }
+        } catch (e) {}
+        window.detailPageThemeCallback = function (isDark) { set(isDark); };
+    }
+
+    /* Formatting */
+    function esc(s) {
+        return String(s === undefined || s === null ? '' : s)
+            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+    }
+    function fmtBytes(b) {
+        if (!b || b <= 0) return '-';
+        var u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'], i = 0;
+        while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; }
+        return (i === 0 ? b : b.toFixed(1)) + ' ' + u[i];
+    }
+    function fmtAgo(unix) {
+        if (!unix) return t('cluster/never', 'never');
+        var d = Math.max(0, Math.floor(Date.now() / 1000 - unix));
+        if (d < 5) return t('cluster/justnow', 'just now');
+        var n, unit;
+        if (d < 60) { n = d; unit = 's'; }
+        else if (d < 3600) { n = Math.floor(d / 60); unit = 'm'; }
+        else if (d < 86400) { n = Math.floor(d / 3600); unit = 'h'; }
+        else { n = Math.floor(d / 86400); unit = 'd'; }
+        return t('cluster/ago/' + unit, '{0}' + unit + ' ago').replace('{0}', n);
+    }
+    function fmtDate(unix) { return unix ? new Date(unix * 1000).toLocaleString() : '-'; }
+    function bar(pct) {
+        var cls = pct >= 95 ? 'bad' : (pct >= 80 ? 'warn' : '');
+        return '<div class="cl-bar ' + cls + '"><div style="width:' + Math.min(100, Math.max(0, pct)).toFixed(0) + '%"></div></div>';
+    }
+    //Fill {0}, {1}, ... in a translated string
+    function fmt(template) {
+        var args = Array.prototype.slice.call(arguments, 1);
+        return String(template).replace(/\{(\d)\}/g, function (m, i) { return args[i] !== undefined ? args[i] : m; });
+    }
+
+    /* Requests */
+    function apiPost(url, data, cb) {
+        $.ajax({ url: url, method: 'POST', data: data, dataType: 'json' })
+            .done(function (r) { cb(null, r); })
+            .fail(function (xhr) {
+                var msg = xhr.responseText || t('cluster/requestfailed', 'Request failed');
+                try { var j = JSON.parse(xhr.responseText); if (j.error) msg = j.error; } catch (e) {}
+                cb(msg, null);
+            });
+    }
+    function apiResult(r) { return (r && typeof r === 'object' && r.error) ? r.error : null; }
+
+    //Show a message in a .cl-msg / .cj-msg box. Errors from the server are
+    //translated here, so every caller gets it for free.
+    function showMsg(id, text, ok, cls) {
+        var el = document.getElementById(id);
+        if (!el) return;
+        cls = cls || 'cl-msg';
+        el.textContent = ok ? text : tr(text);
+        el.className = cls + ' ' + (ok ? 'ok' : 'err');
+        clearTimeout(el._t);
+        el._t = setTimeout(function () { el.className = cls; }, ok ? 4000 : 8000);
+    }
+
+    /*
+        System Settings loads each tab into its own page with jQuery .load,
+        so relative links would resolve against /SystemAO/system_setting/
+        and timers would outlive the tab. These two helpers handle both.
+    */
+
+    //Open another cluster page: switch tabs inside System Settings, or
+    //follow the link when the page was opened on its own.
+    function openPage(settingName, href) {
+        try {
+            if (typeof loadViaSystemSetting !== 'undefined' && loadViaSystemSetting && typeof openSubcat === 'function' && typeof currentSubcats !== 'undefined' && currentSubcats) {
+                for (var i = 0; i < currentSubcats.length; i++) {
+                    if (currentSubcats[i].Name === settingName) { openSubcat(currentSubcats[i]); return false; }
+                }
+            }
+        } catch (e) {}
+        window.location.href = href;
+        return false;
+    }
+
+    //Run fn every ms while the page root is on screen. Once the tab is
+    //replaced (another settings tab, or this one reopened) the timer stops
+    //itself instead of piling up.
+    function every(fn, ms, rootId) {
+        var root = document.getElementById(rootId);
+        var handle = setInterval(function () {
+            if (!root || !document.body.contains(root)) { clearInterval(handle); return; }
+            fn();
+        }, ms);
+        return handle;
+    }
+
+    return {
+        openPage: openPage, every: every,
+        init: init, t: t, tr: tr, state: state, fmt: fmt,
+        applyTheme: applyTheme, esc: esc, fmtBytes: fmtBytes, fmtAgo: fmtAgo, fmtDate: fmtDate, bar: bar,
+        apiPost: apiPost, apiResult: apiResult, showMsg: showMsg
+    };
+})();

+ 149 - 0
src/web/SystemAO/cluster/cluster.css

@@ -0,0 +1,149 @@
+/*
+    Cluster Settings / Cluster Info shared styles
+*/
+/* Scoped with :where() so it neither leaks into the System Settings page
+   this is injected into nor outranks the class rules below */
+:where(#cl-root, #cl-root *) { box-sizing: border-box; margin: 0; padding: 0; }
+
+#cl-root {
+    --cl-bg:        #f5f5f7;
+    --cl-card:      #ffffff;
+    --cl-card-head: #fafafa;
+    --cl-border:    rgba(0,0,0,0.08);
+    --cl-text:      #1d1d1f;
+    --cl-dim:       #6e6e73;
+    --cl-muted:     #98989d;
+    --cl-accent:    #0071e3;
+    --cl-success:   #34c759;
+    --cl-warn:      #ff9f0a;
+    --cl-danger:    #ff3b30;
+    --cl-hover:     rgba(0,0,0,0.04);
+    --cl-input:     #ffffff;
+    --cl-chip:      rgba(0,113,227,0.10);
+    --cl-shadow:    0 1px 4px rgba(0,0,0,0.07), 0 0 0 0.5px rgba(0,0,0,0.06);
+
+    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+    font-size: 14px;
+    -webkit-font-smoothing: antialiased;
+    color: var(--cl-text);
+    background: var(--cl-bg);
+    padding: 20px;
+    min-height: 100vh;
+}
+#cl-root.dark {
+    --cl-bg:        #1f1f1f;
+    --cl-card:      #2c2c2e;
+    --cl-card-head: #232325;
+    --cl-border:    rgba(255,255,255,0.09);
+    --cl-text:      #f2f2f7;
+    --cl-dim:       #aeaeb2;
+    --cl-muted:     #636366;
+    --cl-accent:    #2997ff;
+    --cl-success:   #30d158;
+    --cl-warn:      #ffd60a;
+    --cl-danger:    #ff453a;
+    --cl-hover:     rgba(255,255,255,0.05);
+    --cl-input:     #1c1c1e;
+    --cl-chip:      rgba(41,151,255,0.18);
+    --cl-shadow:    0 1px 6px rgba(0,0,0,0.35), 0 0 0 0.5px rgba(255,255,255,0.06);
+}
+
+#cl-header { display: flex; align-items: center; gap: 12px; margin-bottom: 22px; }
+#cl-header img { width: 40px; height: 40px; border-radius: 9px; }
+#cl-header h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.2px; }
+#cl-header p  { font-size: 13px; color: var(--cl-dim); margin-top: 2px; }
+
+.cl-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+@media (max-width: 900px) { .cl-grid { grid-template-columns: 1fr; } }
+
+.cl-card {
+    background: var(--cl-card); border-radius: 12px; box-shadow: var(--cl-shadow);
+    overflow: hidden; margin-bottom: 16px;
+}
+.cl-card-head {
+    display: flex; align-items: center; justify-content: space-between;
+    padding: 12px 16px; background: var(--cl-card-head); border-bottom: 1px solid var(--cl-border);
+    font-weight: 600; font-size: 14px;
+}
+.cl-card-head .cl-sub { font-weight: 400; color: var(--cl-dim); font-size: 12px; margin-left: 8px; }
+.cl-card-body { padding: 16px; }
+
+.cl-field { margin-bottom: 12px; }
+.cl-field label { display: block; font-size: 12px; color: var(--cl-dim); margin-bottom: 4px; }
+.cl-field input[type=text], .cl-field textarea, .cl-field select {
+    width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--cl-border);
+    background: var(--cl-input); color: var(--cl-text); font: inherit; font-size: 13px;
+}
+.cl-field textarea { min-height: 72px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
+.cl-hint { font-size: 11px; color: var(--cl-muted); margin-top: 4px; }
+.cl-row { display: flex; gap: 8px; align-items: center; }
+.cl-row > * { flex: 1; }
+.cl-row > .cl-fixed { flex: 0 0 auto; }
+.cl-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--cl-text); cursor: pointer; }
+
+.cl-btn {
+    display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 8px;
+    border: 1px solid var(--cl-border); background: var(--cl-card); color: var(--cl-text);
+    font: inherit; font-size: 13px; cursor: pointer; white-space: nowrap;
+}
+.cl-btn:hover { background: var(--cl-hover); }
+.cl-btn.primary { background: var(--cl-accent); border-color: transparent; color: #fff; }
+.cl-btn.primary:hover { filter: brightness(1.08); }
+.cl-btn.danger { color: var(--cl-danger); }
+.cl-btn.small { padding: 4px 9px; font-size: 12px; }
+.cl-btn:disabled { opacity: 0.5; cursor: default; }
+.cl-btn svg { width: 13px; height: 13px; }
+
+.cl-kv { display: grid; grid-template-columns: 130px 1fr; gap: 6px 12px; font-size: 13px; }
+.cl-kv dt { color: var(--cl-dim); }
+.cl-kv dd { word-break: break-all; }
+.cl-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
+
+.cl-chips { display: flex; flex-wrap: wrap; gap: 6px; }
+.cl-chip { background: var(--cl-chip); color: var(--cl-accent); border-radius: 999px; padding: 2px 9px; font-size: 11px; font-weight: 500; }
+
+.cl-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.2px; color: #fff; }
+.cl-badge.ONLINE      { background: var(--cl-success); }
+.cl-badge.DEGRADED    { background: var(--cl-warn); color: #1d1d1f; }
+.cl-badge.UNKNOWN     { background: var(--cl-muted); }
+.cl-badge.OFFLINE     { background: var(--cl-danger); }
+.cl-badge.MAINTENANCE { background: #5e5ce6; }
+.cl-badge.DRAINING    { background: #ff9f0a; color: #1d1d1f; }
+
+.cl-bar { height: 6px; border-radius: 3px; background: var(--cl-border); overflow: hidden; margin-top: 4px; }
+.cl-bar > div { height: 100%; background: var(--cl-accent); }
+.cl-bar.warn > div { background: var(--cl-warn); }
+.cl-bar.bad > div { background: var(--cl-danger); }
+
+table.cl-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+table.cl-table th { text-align: left; font-weight: 600; color: var(--cl-dim); font-size: 12px; padding: 8px 10px; border-bottom: 1px solid var(--cl-border); }
+table.cl-table td { padding: 10px; border-bottom: 1px solid var(--cl-border); vertical-align: top; }
+table.cl-table tr:last-child td { border-bottom: none; }
+table.cl-table tr:hover td { background: var(--cl-hover); }
+.cl-node-name { font-weight: 600; }
+.cl-node-meta { color: var(--cl-dim); font-size: 12px; margin-top: 2px; }
+.cl-actions { display: flex; gap: 4px; flex-wrap: wrap; }
+
+.cl-empty { text-align: center; color: var(--cl-muted); padding: 24px 0; font-size: 13px; }
+.cl-msg { padding: 8px 12px; border-radius: 8px; font-size: 13px; margin-top: 10px; display: none; }
+.cl-msg.ok { display: block; background: rgba(52,199,89,0.14); color: var(--cl-success); }
+.cl-msg.err { display: block; background: rgba(255,59,48,0.12); color: var(--cl-danger); }
+.cl-sep { height: 1px; background: var(--cl-border); margin: 14px 0; }
+.cl-token-box {
+    background: var(--cl-input); border: 1px dashed var(--cl-border); border-radius: 8px; padding: 10px;
+    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; word-break: break-all; margin-top: 8px;
+}
+.cl-inline-list { list-style: none; }
+.cl-inline-list li { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--cl-border); font-size: 12px; }
+.cl-inline-list li:last-child { border-bottom: none; }
+#cl-refresh { color: var(--cl-muted); font-size: 11px; }
+
+/* Shared by Cluster Settings and Cluster Info */
+#cl-header .cl-links { display: flex; gap: 6px; }
+#cl-header .cl-links a { font-size: 12px; color: var(--cl-accent); text-decoration: none; padding: 5px 10px; border-radius: 8px; border: 1px solid var(--cl-border); background: var(--cl-card); }
+#cl-header .cl-links a:hover { background: var(--cl-hover); }
+.cl-notice { background: var(--cl-card); border-radius: 12px; box-shadow: var(--cl-shadow); padding: 18px 20px; margin-bottom: 16px; font-size: 13px; color: var(--cl-dim); }
+.cl-notice b { color: var(--cl-text); }
+.cl-toggle { display: flex; align-items: flex-start; gap: 10px; }
+.cl-toggle input { margin-top: 3px; }
+.cl-toggle .cl-hint { margin-top: 2px; }

文件差异内容过多而无法显示
+ 197 - 594
src/web/SystemAO/cluster/cluster.html


+ 424 - 0
src/web/SystemAO/cluster/clusterinfo.html

@@ -0,0 +1,424 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta name="mobile-web-app-capable" content="yes">
+    <meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"/>
+    <meta charset="UTF-8">
+    <title>Cluster Info</title>
+    <script src="../../script/jquery.min.js"></script>
+    <script src="../../script/ao_module.js"></script>
+    <script src="../../script/applocale.js"></script>
+    <script src="../cluster/cluster.common.js"></script>
+    <link rel="stylesheet" href="../cluster/cluster.css">
+</head>
+<body>
+<div id="cl-root">
+    <div id="cl-header">
+        <img src="../cluster/img/small_icon.png" alt="">
+        <div style="flex:1">
+            <h1 locale="cluster/info/title">Cluster Info</h1>
+            <p locale="cluster/info/tagline">Health of this node, its peers, the namespace and the scheduler</p>
+        </div>
+        <span id="cl-refresh"></span>
+        <div class="cl-links">
+            <a href="../cluster/cluster.html" onclick="return CL.openPage('Cluster Settings', this.href)" locale="cluster/link/settings">Cluster Settings</a>
+            <a href="../cluster/jobs.html" onclick="return CL.openPage('Cluster Jobs', this.href)" locale="cluster/link/jobs">Cluster Jobs</a>
+        </div>
+    </div>
+
+    <div class="cl-notice" id="notice-standalone" style="display:none">
+        <b locale="cluster/info/standalone">This node is not part of a cluster.</b>
+        <span locale="cluster/info/standalonehint">Create or join one in Cluster Settings. Until then the cluster:/ drive stays hidden.</span>
+    </div>
+    <div class="cl-notice" id="notice-novolume" style="display:none">
+        <b locale="cluster/info/novolume">The cluster has no storage yet.</b>
+        <span locale="cluster/info/novolumehint">Add a volume in Cluster Settings to make the cluster:/ drive appear in File Manager.</span>
+    </div>
+
+    <div class="cl-grid">
+        <!-- This node -->
+        <div class="cl-card">
+            <div class="cl-card-head">
+                <span><span locale="cluster/thisnode">This node</span><span class="cl-sub cl-mono" id="cl-nodeid"></span></span>
+                <button class="cl-btn small" onclick="refreshCapabilities()" title="Re-detect capabilities">
+                    <svg viewBox="0 0 16 16" fill="none"><path d="M13 8a5 5 0 1 1-1.5-3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M13 2v3h-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
+                    <span locale="cluster/redetect">Re-detect</span>
+                </button>
+            </div>
+            <div class="cl-card-body">
+                <dl class="cl-kv">
+                    <dt locale="cluster/reach">Reachability</dt><dd id="v-reach">-</dd>
+                    <dt locale="cluster/version">Version</dt><dd id="v-version">-</dd>
+                    <dt locale="cluster/platform">Platform</dt><dd id="v-platform">-</dd>
+                    <dt locale="cluster/key">Node key</dt><dd class="cl-mono" id="v-key">-</dd>
+                    <dt locale="cluster/caps">Capabilities</dt><dd><div class="cl-chips" id="v-caps"></div></dd>
+                </dl>
+                <div class="cl-sep"></div>
+                <dl class="cl-kv" id="v-health"></dl>
+            </div>
+        </div>
+
+        <!-- Cluster summary -->
+        <div class="cl-card" id="card-summary" style="display:none">
+            <div class="cl-card-head"><span id="v-clustername">Cluster</span></div>
+            <div class="cl-card-body">
+                <dl class="cl-kv">
+                    <dt locale="cluster/clusterid">Cluster ID</dt><dd class="cl-mono" id="v-clusterid">-</dd>
+                    <dt locale="cluster/created">Created</dt><dd id="v-created">-</dd>
+                    <dt locale="cluster/members">Members</dt><dd id="v-membercount">-</dd>
+                </dl>
+                <div class="cl-sep"></div>
+                <dl class="cl-kv" id="meta-kv"></dl>
+            </div>
+        </div>
+    </div>
+
+    <!-- Nodes -->
+    <div class="cl-card" id="card-nodes" style="display:none">
+        <div class="cl-card-head"><span locale="cluster/nodes">Nodes</span></div>
+        <div class="cl-card-body" style="padding:0; overflow-x:auto">
+            <table class="cl-table">
+                <thead><tr>
+                    <th locale="cluster/node">Node</th>
+                    <th locale="cluster/state">State</th>
+                    <th locale="cluster/route">Route</th>
+                    <th locale="cluster/platform">Platform</th>
+                    <th locale="cluster/load">Load</th>
+                    <th locale="cluster/lastseen">Last seen</th>
+                    <th></th>
+                </tr></thead>
+                <tbody id="nodes-body"></tbody>
+            </table>
+        </div>
+        <div class="cl-card-body" style="padding-top:0"><div class="cl-msg" id="msg-nodes"></div></div>
+    </div>
+
+    <!-- Replication -->
+    <div class="cl-card" id="card-repl" style="display:none">
+        <div class="cl-card-head">
+            <span><span locale="cluster/repl/title">Replication</span><span class="cl-sub" id="repl-summary"></span></span>
+            <span>
+                <button class="cl-btn small" onclick="verifyCopies()"><span locale="cluster/repl/verify">Verify this node</span></button>
+                <button class="cl-btn small" onclick="planNow()"><span locale="cluster/repl/plan">Repair now</span></button>
+            </span>
+        </div>
+        <div class="cl-card-body">
+            <div class="cl-grid">
+                <dl class="cl-kv" id="repl-kv"></dl>
+                <div>
+                    <div class="cl-field">
+                        <label locale="cluster/repl/inspect">Inspect a file</label>
+                        <div class="cl-row">
+                            <input type="text" id="inspect-path" placeholder="cluster:/photos/a.jpg">
+                            <button class="cl-btn cl-fixed" onclick="inspectFile()"><span locale="cluster/repl/inspectbtn">Show copies</span></button>
+                        </div>
+                    </div>
+                    <ul class="cl-inline-list" id="inspect-list"></ul>
+                </div>
+            </div>
+            <div style="overflow-x:auto; margin-top:12px">
+                <table class="cl-table">
+                    <thead><tr><th locale="cluster/repl/task">Task</th><th locale="cluster/repl/route">From / to</th><th locale="cluster/state">State</th><th locale="cluster/when">When</th></tr></thead>
+                    <tbody id="repl-tasks"></tbody>
+                </table>
+            </div>
+            <div class="cl-msg" id="msg-repl"></div>
+        </div>
+    </div>
+
+    <!-- Scheduling -->
+    <div class="cl-card" id="card-sched" style="display:none">
+        <div class="cl-card-head">
+            <span><span locale="cluster/sched/title">Scheduling</span><span class="cl-sub" id="sched-summary"></span></span>
+        </div>
+        <div class="cl-card-body">
+            <div class="cl-hint" locale="cluster/sched/infohint">How each node scores right now with the weights set in Cluster Settings. Jobs, new files and extra copies go to the highest score.</div>
+            <div style="overflow-x:auto; margin-top:10px">
+                <table class="cl-table">
+                    <thead><tr>
+                        <th locale="cluster/node">Node</th><th locale="cluster/sched/cpu">CPU free</th><th locale="cluster/sched/mem">RAM free</th>
+                        <th locale="cluster/sched/diskfree">Disk free</th><th locale="cluster/sched/ping">Round trip</th><th locale="cluster/sched/score">Score</th>
+                    </tr></thead>
+                    <tbody id="sched-body"></tbody>
+                </table>
+            </div>
+            <div id="sched-matrix-wrap" style="display:none; margin-top:12px">
+                <div class="cl-hint" locale="cluster/sched/matrixhint">Round trip between each pair of nodes, in milliseconds. Copies of a file are spread over the slowest pairs, because distance usually means a different site.</div>
+                <div style="overflow-x:auto; margin-top:6px">
+                    <table class="cl-table" id="sched-matrix"></table>
+                </div>
+            </div>
+            <div class="cl-sep"></div>
+            <div class="cl-field">
+                <label locale="cluster/sched/explain">Why did a job land there?</label>
+                <div class="cl-row">
+                    <input type="text" id="sched-job" placeholder="job id">
+                    <button class="cl-btn cl-fixed" onclick="explainJob()"><span locale="cluster/sched/explainbtn">Explain</span></button>
+                </div>
+                <div class="cl-hint" locale="cluster/sched/explainhint">Copy a job id from the Cluster Jobs page to see the ranking it was placed with.</div>
+            </div>
+            <ul class="cl-inline-list" id="sched-explain"></ul>
+            <div class="cl-msg" id="msg-sched"></div>
+        </div>
+    </div>
+</div>
+
+<script>
+    var t = CL.t, fmt = CL.fmt, esc = CL.esc;
+    var API = '../../system/cluster/';
+    var clusterStatus = null;
+    CL.applyTheme('cl-root');
+
+    function post(path, data, cb) { CL.apiPost(API + path, data, cb); }
+    function fail(box, err, r) {
+        var e = err || CL.apiResult(r);
+        if (e) { CL.showMsg(box, e, false); return true; }
+        return false;
+    }
+    function nodeName(id) {
+        if (!clusterStatus) return id;
+        for (var i = 0; i < clusterStatus.nodes.length; i++) if (clusterStatus.nodes[i].id === id) return clusterStatus.nodes[i].name;
+        return String(id || '').substring(0, 8);
+    }
+    function badge(state, label) { return '<span class="cl-badge ' + esc(state) + '">' + esc(label || CL.state(state)) + '</span>'; }
+
+    /* This node */
+    function healthRows(h) {
+        if (!h || !h.timestamp) return '<dt>' + esc(t('cluster/load', 'Load')) + '</dt><dd style="color:var(--cl-muted)">' + esc(t('cluster/noreport', 'no report yet')) + '</dd>';
+        var ramPct = h.ramTotal > 0 ? h.ramUsed / h.ramTotal * 100 : 0;
+        var diskPct = h.diskTotal > 0 ? (h.diskTotal - h.diskFree) / h.diskTotal * 100 : 0;
+        return '<dt>CPU</dt><dd>' + h.cpuUsage.toFixed(0) + '%' + CL.bar(h.cpuUsage) + '</dd>' +
+            '<dt>RAM</dt><dd>' + (h.ramTotal > 0 ? CL.fmtBytes(h.ramUsed) + ' / ' + CL.fmtBytes(h.ramTotal) + CL.bar(ramPct) : '-') + '</dd>' +
+            '<dt>' + esc(t('cluster/disk', 'Disk')) + '</dt><dd>' + (h.diskTotal > 0 ? esc(fmt(t('cluster/diskfree', '{0} free of {1}'), CL.fmtBytes(h.diskFree), CL.fmtBytes(h.diskTotal))) + CL.bar(diskPct) : '-') + '</dd>' +
+            '<dt>' + esc(t('cluster/uptime', 'Uptime')) + '</dt><dd>' + esc(fmt(t('cluster/uptimevalue', '{0}h {1}m'), Math.floor(h.uptime / 3600), Math.floor((h.uptime % 3600) / 60))) + '</dd>';
+    }
+    function routeOf(n) {
+        if (n.local) return esc(t('cluster/thisnode', 'This node'));
+        if (n.tunnel) return esc(t('cluster/tunnelhere', 'Tunnel to this node'));
+        if (n.advertiseUrl) return '<a href="' + esc(n.advertiseUrl) + '" target="_blank" style="color:var(--cl-accent)">' + esc(n.advertiseUrl.replace(/^https?:\/\//, '')) + '</a>';
+        if (n.tunnelVia) return esc(fmt(t('cluster/tunnelvia', 'Tunnel via {0}'), nodeName(n.tunnelVia)));
+        return '<span style="color:var(--cl-muted)">' + esc(t('cluster/noroute', 'No route')) + '</span>';
+    }
+    function render() {
+        var s = clusterStatus, local = s.local;
+        document.getElementById('cl-nodeid').textContent = ' ' + local.nodeId;
+        var reach;
+        if (local.reachable) {
+            reach = badge('ONLINE', t('cluster/direct', 'DIRECT')) + ' ' + esc(local.config.advertiseUrl);
+        } else if (local.tunnelConnected) {
+            reach = badge('ONLINE', t('cluster/tunnel', 'TUNNEL')) + ' ' + esc(fmt(t('cluster/via', 'via {0}'), nodeName(local.tunnelHost)));
+        } else if (s.inCluster) {
+            reach = badge('OFFLINE', t('cluster/notunnel', 'NO TUNNEL')) + ' ' + esc(t('cluster/waitinghost', 'waiting for a reachable member'));
+        } else {
+            reach = badge('UNKNOWN', t('cluster/natonly', 'NO URL')) + ' ' + esc(t('cluster/willtunnel', 'will tunnel through a member once in a cluster'));
+        }
+        document.getElementById('v-reach').innerHTML = reach;
+        document.getElementById('v-version').textContent = local.version;
+        var c = local.capabilities || {};
+        document.getElementById('v-platform').textContent = fmt(t('cluster/platformline', '{0} / {1}, {2} cores'), c.os || '?', c.arch || '?', c.cpuCores || '?') + (c.totalRam > 0 ? ', ' + CL.fmtBytes(c.totalRam) + ' RAM' : '') + (c.hostname ? ' (' + c.hostname + ')' : '');
+        document.getElementById('v-key').textContent = (local.publicKey || '').substring(0, 20) + '...';
+        var chips = '';
+        Object.keys(c.features || {}).filter(function (k) { return c.features[k]; }).sort().forEach(function (f) { chips += '<span class="cl-chip">' + esc(f) + '</span>'; });
+        document.getElementById('v-caps').innerHTML = chips || '<span style="color:var(--cl-muted)">-</span>';
+        document.getElementById('v-health').innerHTML = healthRows(local.health);
+
+        document.getElementById('notice-standalone').style.display = s.inCluster ? 'none' : '';
+        ['card-summary', 'card-nodes', 'card-repl', 'card-sched'].forEach(function (id) {
+            document.getElementById(id).style.display = s.inCluster ? '' : 'none';
+        });
+        if (s.inCluster) {
+            document.getElementById('v-clustername').textContent = s.cluster.name;
+            document.getElementById('v-clusterid').textContent = s.cluster.id;
+            document.getElementById('v-created').textContent = CL.fmtDate(s.cluster.created);
+            document.getElementById('v-membercount').textContent = s.nodes.length;
+            renderNodes(s.nodes || []);
+        } else {
+            document.getElementById('notice-novolume').style.display = 'none';
+        }
+        document.getElementById('cl-refresh').textContent = fmt(t('cluster/updated', 'Updated {0}'), new Date().toLocaleTimeString());
+    }
+    function renderNodes(nodes) {
+        var html = '';
+        nodes.forEach(function (n) {
+            var c = n.capabilities || {}, h = n.health || {};
+            var load = h.timestamp
+                ? 'CPU ' + h.cpuUsage.toFixed(0) + '%' + (h.ramTotal > 0 ? ' · RAM ' + (h.ramUsed / h.ramTotal * 100).toFixed(0) + '%' : '') + (h.diskTotal > 0 ? ' · ' + esc(fmt(t('cluster/freeamount', '{0} free'), CL.fmtBytes(h.diskFree))) : '')
+                : '<span style="color:var(--cl-muted)">-</span>';
+            var feats = Object.keys(c.features || {}).filter(function (k) { return c.features[k] && ['ffmpeg', 'docker', 'cuda', 'gpu', 'git'].indexOf(k) >= 0; });
+            var probe = n.local ? '' : '<button class="cl-btn small" onclick="probeNode(\'' + esc(n.id) + '\', this)">' + esc(t('cluster/probe', 'Probe')) + '</button>';
+            html += '<tr>' +
+                '<td><div class="cl-node-name">' + esc(n.name) + (n.local ? ' <span class="cl-chip">' + esc(t('cluster/you', 'you')) + '</span>' : '') + '</div><div class="cl-node-meta cl-mono">' + esc(n.id) + '</div></td>' +
+                '<td>' + badge(n.state) + '</td>' +
+                '<td>' + routeOf(n) + '</td>' +
+                '<td>' + esc(c.os || '?') + '/' + esc(c.arch || '?') + '<div class="cl-node-meta">' + esc(fmt(t('cluster/coresvalue', '{0} cores'), c.cpuCores || '?')) + (c.totalRam > 0 ? ', ' + CL.fmtBytes(c.totalRam) : '') + (feats.length ? ' · ' + esc(feats.join(', ')) : '') + '</div></td>' +
+                '<td>' + load + '</td>' +
+                '<td>' + esc(n.local ? t('cluster/now', 'now') : CL.fmtAgo(n.lastSeen)) + '<div class="cl-node-meta">' + esc(n.version) + '</div></td>' +
+                '<td>' + probe + '</td></tr>';
+        });
+        document.getElementById('nodes-body').innerHTML = html || '<tr><td colspan="7" class="cl-empty">' + esc(t('cluster/nonodes', 'No nodes')) + '</td></tr>';
+    }
+    function refreshCapabilities() {
+        $.getJSON(API + 'capabilities?refresh=true', function () { loadStatus(); });
+    }
+    function probeNode(id, btn) {
+        btn.disabled = true;
+        $.getJSON(API + 'node/probe?id=' + encodeURIComponent(id), function (r) {
+            btn.disabled = false;
+            var route = String(r.route || '');
+            route = route.indexOf('relay via ') === 0
+                ? fmt(t('cluster/route/relay', 'relay via {0}'), nodeName(route.substring(10)))
+                : t('cluster/route/' + route, route);
+            if (r.ok) CL.showMsg('msg-nodes', fmt(t('cluster/probeok', '{0}: {1} ms ({2})'), nodeName(id), r.latencyMs, route), true);
+            else CL.showMsg('msg-nodes', fmt(t('cluster/probefail', '{0}: {1} ({2})'), nodeName(id), CL.tr(r.error || t('cluster/unreachable', 'unreachable')), route), false);
+        }).fail(function () { btn.disabled = false; });
+    }
+
+    /* Namespace */
+    function loadMeta() {
+        $.getJSON(API + 'meta/status', function (st) {
+            if (CL.apiResult(st)) return;
+            var leader = st.leader
+                ? esc(st.leaderName) + (st.isLeader ? ' <span class="cl-chip">' + esc(t('cluster/thisnode', 'This node')) + '</span>' : '') + ' <span class="cl-node-meta">' + esc(fmt(t('cluster/meta/term', 'term {0}'), st.term)) + '</span>'
+                : badge('UNKNOWN', t('cluster/meta/noleader', 'ELECTING'));
+            document.getElementById('meta-kv').innerHTML =
+                '<dt>' + esc(t('cluster/meta/leader', 'Leader')) + '</dt><dd>' + leader + '</dd>' +
+                '<dt>' + esc(t('cluster/meta/entries', 'Entries')) + '</dt><dd>' + esc(fmt(t('cluster/meta/entriesvalue', '{0} files, {1} folders, {2} volumes'), st.files, st.dirs, st.volumes)) + '</dd>' +
+                '<dt>' + esc(t('cluster/meta/log', 'Log')) + '</dt><dd>' + st.applied + ' / ' + st.lastSeq + (st.pending ? ' <span style="color:var(--cl-warn)">' + esc(fmt(t('cluster/meta/pending', '{0} pending'), st.pending)) + '</span>' : '') + '</dd>';
+            document.getElementById('notice-novolume').style.display = (clusterStatus && clusterStatus.inCluster && st.volumes === 0) ? '' : 'none';
+        });
+    }
+
+    /* Replication */
+    function loadRepl() {
+        $.getJSON(API + 'repl/status', function (st) {
+            if (CL.apiResult(st)) return;
+            var problems = st.underReplicated + st.noHealthyCopy + st.staleCopies;
+            document.getElementById('repl-summary').textContent = ' ' + (problems === 0 ? t('cluster/repl/healthy', 'all files at policy') : fmt(t('cluster/repl/issues', '{0} issues'), problems));
+            var planner = st.isLeader ? badge('ONLINE', t('cluster/thisnode', 'This node')) : (st.leader ? esc(st.leaderName) : badge('UNKNOWN', t('cluster/meta/noleader', 'ELECTING')));
+            if (st.lastPlan) planner += ' <span class="cl-node-meta">' + esc(fmt(t('cluster/repl/lastrun', 'last run {0}'), CL.fmtAgo(st.lastPlan))) + '</span>';
+            var inflight = fmt(t('cluster/repl/inflightvalue', '{0} running, {1} queued'), st.running, st.queued) + (st.evacuating ? ', ' + fmt(t('cluster/repl/evacuating', '{0} volume(s) evacuating'), st.evacuating) : '');
+            document.getElementById('repl-kv').innerHTML =
+                '<dt>' + esc(t('cluster/repl/planner', 'Planner')) + '</dt><dd>' + planner + '</dd>' +
+                '<dt>' + esc(t('cluster/repl/files', 'Files')) + '</dt><dd>' + st.files + '</dd>' +
+                '<dt>' + esc(t('cluster/repl/under', 'Under-replicated')) + '</dt><dd>' + (st.underReplicated ? '<span style="color:var(--cl-warn)">' + st.underReplicated + '</span>' : '0') + '</dd>' +
+                '<dt>' + esc(t('cluster/repl/nocopy', 'No healthy copy')) + '</dt><dd>' + (st.noHealthyCopy ? '<span style="color:var(--cl-danger)">' + st.noHealthyCopy + '</span>' : '0') + '</dd>' +
+                '<dt>' + esc(t('cluster/repl/stale', 'Stale copies')) + '</dt><dd>' + st.staleCopies + '</dd>' +
+                '<dt>' + esc(t('cluster/repl/over', 'Extra copies')) + '</dt><dd>' + st.overReplicated + '</dd>' +
+                '<dt>' + esc(t('cluster/repl/inflight', 'In flight')) + '</dt><dd>' + esc(inflight) + '</dd>';
+            var tasks = (st.tasks || []).concat((st.history || []).slice(0, 10));
+            var html = '';
+            tasks.forEach(function (tk) {
+                var cls = tk.state === 'done' ? 'ONLINE' : (tk.state === 'failed' ? 'OFFLINE' : (tk.state === 'running' ? 'DEGRADED' : 'UNKNOWN'));
+                html += '<tr><td><div class="cl-node-name">' + esc(tk.path) + '</div><div class="cl-node-meta">' + esc(t('cluster/repl/reason/' + tk.reason, tk.reason)) + ' · ' + CL.fmtBytes(tk.size) + (tk.error ? ' · <span style="color:var(--cl-danger)">' + esc(CL.tr(tk.error)) + '</span>' : '') + '</div></td>' +
+                    '<td>' + esc(fmt(t('cluster/repl/fromto', '{0} to {1}'), nodeName(tk.sourceNode), nodeName(tk.targetNode))) + '</td>' +
+                    '<td>' + badge(cls, CL.state(tk.state)) + (tk.attempts > 1 ? ' <span class="cl-node-meta">x' + tk.attempts + '</span>' : '') + '</td>' +
+                    '<td>' + esc(CL.fmtAgo(tk.updated || tk.created)) + '</td></tr>';
+            });
+            document.getElementById('repl-tasks').innerHTML = html || '<tr><td colspan="4" class="cl-empty">' + esc(t('cluster/repl/notasks', 'No replication tasks')) + '</td></tr>';
+        });
+    }
+    function planNow() {
+        post('repl/plan', {}, function (err, r) {
+            if (fail('msg-repl', err, r)) return;
+            CL.showMsg('msg-repl', fmt(t('cluster/repl/planned', 'Planner ran: {0} queued, {1} dropped, {2} marked stale'), r.queued, r.dropped, r.markedStale), true);
+            loadRepl();
+        });
+    }
+    function verifyCopies() {
+        post('repl/verify', { budget: 512 }, function (err, r) {
+            if (fail('msg-repl', err, r)) return;
+            CL.showMsg('msg-repl', fmt(t('cluster/repl/verified', 'Verified {0} copies ({1}), {2} stale'), r.checked, CL.fmtBytes(r.bytes), r.stale), r.stale === 0);
+            loadRepl();
+        });
+    }
+    function inspectFile() {
+        var p = document.getElementById('inspect-path').value.trim();
+        if (!p) return;
+        $.getJSON(API + 'meta/stat?path=' + encodeURIComponent(p), function (rec) {
+            if (fail('msg-repl', null, rec)) return;
+            var html = '<li><span>' + esc(rec.path) + '</span><span class="cl-node-meta">' + (rec.isDir ? esc(t('cluster/folder', 'folder')) : CL.fmtBytes(rec.size) + ' · ' + esc((rec.checksum || '').substring(0, 12))) + '</span></li>';
+            (rec.locations || []).forEach(function (l) {
+                var cls = l.state === 'verified' || l.state === 'committed' ? 'ONLINE' : (l.state === 'stale' || l.state === 'failed' ? 'OFFLINE' : 'DEGRADED');
+                html += '<li><span>' + esc(nodeName(l.nodeId)) + (l.volumeId === rec.primary ? ' <span class="cl-chip">' + esc(t('cluster/repl/primary', 'primary')) + '</span>' : '') + '</span><span>' + badge(cls, CL.state(l.state)) + ' <span class="cl-node-meta">' + esc(CL.fmtAgo(l.updated)) + '</span></span></li>';
+            });
+            if (!rec.isDir && (rec.locations || []).length === 0) html += '<li class="cl-empty">' + esc(t('cluster/repl/nolocations', 'No copies recorded')) + '</li>';
+            document.getElementById('inspect-list').innerHTML = html;
+        }).fail(function () { CL.showMsg('msg-repl', t('cluster/repl/notfound', 'Path not found in the namespace'), false); });
+    }
+
+    /* Scheduling */
+    function loadSched() {
+        $.getJSON(API + 'sched/status', function (st) {
+            if (CL.apiResult(st)) return;
+            var rows = '';
+            (st.nodes || []).forEach(function (n) {
+                var pct = Math.round(n.score * 100);
+                rows += '<tr><td><div class="cl-node-name">' + esc(n.name) + (n.local ? ' <span class="cl-chip">' + esc(t('cluster/thisnode', 'This node')) + '</span>' : '') + '</div>' +
+                    '<div class="cl-node-meta">' + badge(String(n.state).toUpperCase()) + ' ' + (n.features ? esc(fmt(t('cluster/sched/features', '{0} features'), n.features)) : '') + '</div></td>' +
+                    '<td>' + Math.round(n.cpuFree * 100) + '%</td>' +
+                    '<td>' + Math.round(n.memFree * 100) + '%</td>' +
+                    '<td>' + Math.round(n.diskFree * 100) + '%</td>' +
+                    '<td>' + (n.local ? esc(t('cluster/sched/here', 'local')) : (n.latencyMs < 0 ? '-' : Math.round(n.latencyMs) + ' ms')) + '</td>' +
+                    '<td>' + pct + '%' + CL.bar(pct) + '</td></tr>';
+            });
+            document.getElementById('sched-body').innerHTML = rows || '<tr><td colspan="6" class="cl-empty">' + esc(t('cluster/sched/nonodes', 'No nodes to score')) + '</td></tr>';
+            var top = (st.nodes || [])[0];
+            document.getElementById('sched-summary').textContent = top ? ' ' + fmt(t('cluster/sched/next', 'next job would run on {0}'), top.name) : '';
+            renderMatrix(st);
+        });
+    }
+    function renderMatrix(st) {
+        var wrap = document.getElementById('sched-matrix-wrap');
+        var nodes = st.nodes || [], matrix = st.matrix || {};
+        if (nodes.length < 2 || Object.keys(matrix).length === 0) { wrap.style.display = 'none'; return; }
+        var head = '<thead><tr><th>' + esc(t('cluster/sched/from', 'From / to')) + '</th>';
+        nodes.forEach(function (n) { head += '<th>' + esc(n.name) + '</th>'; });
+        var body = '</tr></thead><tbody>';
+        nodes.forEach(function (from) {
+            body += '<tr><td><div class="cl-node-name">' + esc(from.name) + '</div></td>';
+            nodes.forEach(function (to) {
+                var cell;
+                if (from.id === to.id) cell = '<span class="cl-node-meta">-</span>';
+                else {
+                    var v = (matrix[from.id] || {})[to.id];
+                    cell = (v === undefined) ? '<span class="cl-node-meta">' + esc(t('cluster/sched/unmeasured', 'not measured')) + '</span>' : (v < 10 ? v.toFixed(1) : Math.round(v)) + ' ms';
+                }
+                body += '<td>' + cell + '</td>';
+            });
+            body += '</tr>';
+        });
+        document.getElementById('sched-matrix').innerHTML = head + body + '</tbody>';
+        wrap.style.display = '';
+    }
+    function explainJob() {
+        var id = document.getElementById('sched-job').value.trim();
+        if (!id) return;
+        $.getJSON(API + 'sched/explain?job=' + encodeURIComponent(id), function (res) {
+            if (fail('msg-sched', null, res)) return;
+            var html = '<li><span>' + esc(res.job || id) + '</span><span class="cl-node-meta">' + esc(CL.state(res.status)) + (res.node ? ' · ' + esc(nodeName(res.node)) : '') + '</span></li>';
+            (res.ranking || []).forEach(function (r) {
+                var detail = r.eligible ? (r.factors || []).map(function (f) {
+                    return esc(t('cluster/sched/factor/' + f.name, f.name)) + ' ' + (f.points >= 0 ? '+' : '') + f.points.toFixed(2);
+                }).join(', ') : esc(CL.tr(r.reason));
+                html += '<li><span>' + esc(r.nodeName) + ' <span class="cl-node-meta">' + detail + '</span></span>' +
+                    '<span>' + (r.eligible ? (r.score * 100).toFixed(0) + '%' : badge('OFFLINE', t('cluster/sched/skipped', 'SKIPPED'))) + '</span></li>';
+            });
+            document.getElementById('sched-explain').innerHTML = html;
+        }).fail(function () { CL.showMsg('msg-sched', t('cluster/sched/nojob', 'No such job on this cluster'), false); });
+    }
+
+    function loadStatus() {
+        $.getJSON(API + 'status', function (data) {
+            if (CL.apiResult(data)) return;
+            clusterStatus = data;
+            render();
+            if (data.inCluster) { loadMeta(); loadRepl(); loadSched(); }
+        });
+    }
+    CL.init(function () {
+        loadStatus();
+        CL.every(loadStatus, 10000, 'cl-root');
+    });
+</script>
+</body>
+</html>

+ 53 - 72
src/web/SystemAO/cluster/jobs.html

@@ -8,8 +8,9 @@
     <script src="../../script/jquery.min.js"></script>
     <script src="../../script/ao_module.js"></script>
     <script src="../../script/applocale.js"></script>
+    <script src="../cluster/cluster.common.js"></script>
     <style>
-        * { box-sizing: border-box; margin: 0; padding: 0; }
+        :where(#cj-root, #cj-root *) { box-sizing: border-box; margin: 0; padding: 0; }
         #cj-root {
             --cj-bg:#f5f5f7; --cj-card:#ffffff; --cj-head:#fafafa; --cj-border:rgba(0,0,0,0.08);
             --cj-text:#1d1d1f; --cj-dim:#6e6e73; --cj-muted:#98989d; --cj-accent:#0071e3;
@@ -31,6 +32,9 @@
         #cj-header img { width:40px; height:40px; border-radius:9px; }
         #cj-header h1 { font-size:20px; font-weight:600; letter-spacing:-0.2px; }
         #cj-header p { font-size:13px; color:var(--cj-dim); margin-top:2px; }
+        #cj-header .cj-links { display:flex; gap:6px; }
+        #cj-header .cj-links a { font-size:12px; color:var(--cj-accent); text-decoration:none; padding:5px 10px; border-radius:8px; border:1px solid var(--cj-border); background:var(--cj-card); }
+        #cj-header .cj-links a:hover { background:var(--cj-hover); }
         .cj-card { background:var(--cj-card); border-radius:12px; box-shadow:var(--cj-shadow); overflow:hidden; margin-bottom:16px; }
         .cj-head { display:flex; align-items:center; justify-content:space-between; padding:12px 16px;
             background:var(--cj-head); border-bottom:1px solid var(--cj-border); font-weight:600; font-size:14px; }
@@ -89,59 +93,63 @@
 <body>
 <div id="cj-root">
     <div id="cj-header">
-        <img src="img/small_icon.png" alt="">
+        <img src="../cluster/img/small_icon.png" alt="">
         <div style="flex:1">
-            <h1 id="lbl-title">Cluster Jobs</h1>
-            <p id="lbl-tagline">Run a script on whichever node fits best</p>
+            <h1 id="lbl-title" locale="cluster/jobs/title">Cluster Jobs</h1>
+            <p id="lbl-tagline" locale="cluster/jobs/tagline">Run a script on whichever node fits best</p>
         </div>
         <span id="cj-refresh" style="color:var(--cj-muted); font-size:11px"></span>
+        <div class="cj-links">
+            <a href="../cluster/cluster.html" onclick="return CL.openPage('Cluster Settings', this.href)" locale="cluster/link/settings">Cluster Settings</a>
+            <a href="../cluster/clusterinfo.html" onclick="return CL.openPage('Cluster Info', this.href)" locale="cluster/link/info">Cluster Info</a>
+        </div>
     </div>
 
     <div class="cj-grid">
         <div class="cj-card">
-            <div class="cj-head"><span id="lbl-submit">Submit a job</span></div>
+            <div class="cj-head"><span id="lbl-submit" locale="cluster/jobs/submit">Submit a job</span></div>
             <div class="cj-body">
                 <div class="cj-field">
-                    <label id="lbl-name">Name</label>
+                    <label id="lbl-name" locale="cluster/jobs/name">Name</label>
                     <input type="text" id="job-name" placeholder="Nightly index">
                 </div>
                 <div class="cj-field">
-                    <label id="lbl-script">Script (a .agi file that defines run(job))</label>
+                    <label id="lbl-script" locale="cluster/jobs/scriptlabel">Script (a .agi file that defines run(job))</label>
                     <div class="cj-row">
                         <input type="text" id="job-script" placeholder="user:/jobs/index.agi">
-                        <button class="cj-btn cj-fixed" onclick="pickScript()"><span id="lbl-browse">Browse</span></button>
+                        <button class="cj-btn cj-fixed" onclick="pickScript()"><span id="lbl-browse" locale="cluster/jobs/browse">Browse</span></button>
                     </div>
                 </div>
                 <div class="cj-field">
-                    <label id="lbl-args">Arguments (JSON, optional)</label>
+                    <label id="lbl-args" locale="cluster/jobs/args">Arguments (JSON, optional)</label>
                     <textarea id="job-args" placeholder='{"album": "2026"}'></textarea>
                 </div>
                 <div class="cj-field">
-                    <label id="lbl-inputs">Input paths (comma separated, used to run near the data)</label>
+                    <label id="lbl-inputs" locale="cluster/jobs/inputs">Input paths (comma separated, used to run near the data)</label>
                     <input type="text" id="job-inputs" placeholder="cluster:/photos/2026">
                 </div>
                 <div class="cj-field">
-                    <label id="lbl-dataset">Map/reduce dataset (optional glob; turns this into a map/reduce job)</label>
+                    <label id="lbl-dataset" locale="cluster/jobs/datasetlabel">Map/reduce dataset (optional glob; turns this into a map/reduce job)</label>
                     <div class="cj-row">
                         <input type="text" id="job-dataset" placeholder="cluster:/photos/**/*.jpg">
                         <input type="text" id="job-partition" class="cj-fixed" style="width:120px" placeholder="50 per task">
                     </div>
-                    <div class="cj-hint" id="hint-dataset">With a dataset the script must define mapper(files, emit) and reducer(key, values); the files are processed on the nodes that hold them.</div>
+                    <div class="cj-hint" id="hint-dataset" locale="cluster/jobs/datasethint">With a dataset the script must define mapper(files, emit) and reducer(key, values); the files are processed on the nodes that hold them.</div>
                 </div>
                 <div class="cj-row">
                     <div class="cj-field" style="margin:0">
-                        <label id="lbl-features">Required features</label>
+                        <label id="lbl-features" locale="cluster/jobs/features">Required features</label>
                         <input type="text" id="job-features" placeholder="ffmpeg, cuda">
                     </div>
                     <div class="cj-field" style="margin:0; max-width:140px">
-                        <label id="lbl-timeout">Timeout (s)</label>
+                        <label id="lbl-timeout" locale="cluster/jobs/timeout">Timeout (s)</label>
                         <input type="text" id="job-timeout" value="3600">
                     </div>
                 </div>
-                <div class="cj-hint" id="hint-submit">The script runs with your permissions on the chosen node. cluster:/ paths work as usual.</div>
+                <div class="cj-hint" id="hint-submit" locale="cluster/jobs/submithint">The script runs with your permissions on the chosen node. cluster:/ paths work as usual.</div>
                 <div class="cj-row" style="margin-top:10px">
                     <div></div>
-                    <button class="cj-btn primary cj-fixed" onclick="submitJob()"><span id="lbl-run">Run job</span></button>
+                    <button class="cj-btn primary cj-fixed" onclick="submitJob()"><span id="lbl-run" locale="cluster/jobs/run">Run job</span></button>
                 </div>
                 <div class="cj-msg" id="msg-submit"></div>
             </div>
@@ -149,17 +157,17 @@
 
         <div class="cj-card">
             <div class="cj-head">
-                <span><span id="lbl-detail">Job detail</span><span class="cj-sub" id="detail-id"></span></span>
-                <button class="cj-btn small danger" id="btn-cancel" style="display:none" onclick="cancelSelected()"><span id="lbl-cancel">Cancel</span></button>
+                <span><span id="lbl-detail" locale="cluster/jobs/detail">Job detail</span><span class="cj-sub" id="detail-id"></span></span>
+                <button class="cj-btn small danger" id="btn-cancel" style="display:none" onclick="cancelSelected()"><span id="lbl-cancel" locale="cluster/cancel">Cancel</span></button>
             </div>
             <div class="cj-body">
-                <dl class="cj-kv" id="detail-kv"><dd class="cj-empty" style="grid-column:1/3" id="lbl-nodetail">Select a job to see its log and output</dd></dl>
+                <dl class="cj-kv" id="detail-kv"><dd class="cj-empty" style="grid-column:1/3" id="lbl-nodetail" locale="cluster/jobs/nodetail">Select a job to see its log and output</dd></dl>
                 <div id="detail-log-wrap" style="display:none">
-                    <div class="cj-hint" id="lbl-log">Log</div>
+                    <div class="cj-hint" id="lbl-log" locale="cluster/jobs/log">Log</div>
                     <pre class="cj-pre" id="detail-log"></pre>
                 </div>
                 <div id="detail-out-wrap" style="display:none">
-                    <div class="cj-hint" id="lbl-output">Output</div>
+                    <div class="cj-hint" id="lbl-output" locale="cluster/jobs/output">Output</div>
                     <pre class="cj-pre" id="detail-out"></pre>
                 </div>
             </div>
@@ -167,12 +175,12 @@
     </div>
 
     <div class="cj-card">
-        <div class="cj-head"><span><span id="lbl-jobs">Jobs</span><span class="cj-sub" id="jobs-summary"></span></span></div>
+        <div class="cj-head"><span><span id="lbl-jobs" locale="cluster/jobs/jobs">Jobs</span><span class="cj-sub" id="jobs-summary"></span></span></div>
         <div class="cj-body" style="padding:0; overflow-x:auto">
             <table class="cj-table">
                 <thead><tr>
-                    <th id="th-job">Job</th><th id="th-status">Status</th><th id="th-node">Node</th>
-                    <th id="th-progress">Progress</th><th id="th-when">When</th><th></th>
+                    <th id="th-job" locale="cluster/jobs/job">Job</th><th id="th-status" locale="cluster/jobs/status">Status</th><th id="th-node" locale="cluster/node">Node</th>
+                    <th id="th-progress" locale="cluster/jobs/progress">Progress</th><th id="th-when" locale="cluster/when">When</th><th></th>
                 </tr></thead>
                 <tbody id="jobs-body"></tbody>
             </table>
@@ -181,24 +189,7 @@
 </div>
 
 <script>
-    var cjLocale = (typeof NewAppLocale === 'function') ? NewAppLocale() : null;
-    function t(key, fallback) { return cjLocale ? cjLocale.getString(key, fallback) : fallback; }
-    function cjTheme(isDark) { document.getElementById('cj-root').classList.toggle('dark', isDark); }
-    (function() {
-        try {
-            if (typeof ao_module_getSystemThemeColor === 'function') {
-                ao_module_getSystemThemeColor(function(c) { cjTheme(c !== 'whiteTheme'); });
-                return;
-            }
-        } catch(e) {}
-        try {
-            var theme = null;
-            if (typeof preferredTheme !== 'undefined') theme = preferredTheme;
-            else if (parent && typeof parent.preferredTheme !== 'undefined') theme = parent.preferredTheme;
-            if (theme) cjTheme(theme === 'dark' || theme === 'darkTheme');
-        } catch(e) {}
-    })();
-    window.detailPageThemeCallback = function(isDark) { cjTheme(isDark); };
+    var t = CL.t, fmt = CL.fmt;
 
     var API = '../../system/cluster/jobs/';
     var selected = null;
@@ -208,34 +199,21 @@
         return String(s === undefined || s === null ? '' : s)
             .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
     }
-    function fmtAgo(unix) {
-        if (!unix) return '-';
-        var d = Math.max(0, Math.floor(Date.now()/1000 - unix));
-        if (d < 5) return t('cluster/justnow', 'just now');
-        if (d < 60) return d + 's ' + t('cluster/ago', 'ago');
-        if (d < 3600) return Math.floor(d/60) + 'm ' + t('cluster/ago', 'ago');
-        if (d < 86400) return Math.floor(d/3600) + 'h ' + t('cluster/ago', 'ago');
-        return Math.floor(d/86400) + 'd ' + t('cluster/ago', 'ago');
-    }
+    function fmtAgo(unix) { return unix ? CL.fmtAgo(unix) : '-'; }
     function duration(rec) {
         if (!rec.state.started) return '-';
         var end = rec.state.finished || Math.floor(Date.now()/1000);
         var s = Math.max(0, end - rec.state.started);
-        if (s < 60) return s + 's';
-        return Math.floor(s/60) + 'm ' + (s%60) + 's';
-    }
-    function showMsg(id, text, ok) {
-        var el = document.getElementById(id);
-        el.textContent = text;
-        el.className = 'cj-msg ' + (ok ? 'ok' : 'err');
-        clearTimeout(el._t);
-        el._t = setTimeout(function() { el.className = 'cj-msg'; }, ok ? 5000 : 9000);
+        if (s < 60) return fmt(t('cluster/jobs/secs', '{0}s'), s);
+        return fmt(t('cluster/jobs/minsecs', '{0}m {1}s'), Math.floor(s/60), s%60);
     }
+    //Errors from the server are translated by CL.showMsg
+    function showMsg(id, text, ok) { CL.showMsg(id, text, ok, 'cj-msg'); }
     function apiPost(path, data, cb) {
         $.ajax({ url: API + path, method: 'POST', data: data, dataType: 'json' })
             .done(function(r) { cb(null, r); })
             .fail(function(xhr) {
-                var msg = xhr.responseText || 'request failed';
+                var msg = xhr.responseText || t('cluster/requestfailed', 'Request failed');
                 try { var j = JSON.parse(xhr.responseText); if (j.error) msg = j.error; } catch(e) {}
                 cb(msg, null);
             });
@@ -301,19 +279,19 @@
         document.getElementById('btn-cancel').style.display = running ? '' : 'none';
         var kids = lastJobs.filter(function(j) { return j.spec.parentId === rec.spec.id; });
         var rows = '<dt>' + t('cluster/jobs/name', 'Name') + '</dt><dd>' + esc(rec.spec.name) +
-            (rec.spec.kind && rec.spec.kind !== 'run' ? ' <span class="cj-chip">' + esc(rec.spec.kind) + '</span>' : '') + '</dd>' +
-            '<dt>' + t('cluster/jobs/status', 'Status') + '</dt><dd><span class="cj-badge ' + esc(rec.state.status) + '">' + esc(rec.state.status).toUpperCase() + '</span>' +
-            (rec.state.error ? ' <span style="color:var(--cj-danger)">' + esc(rec.state.error) + '</span>' : '') + '</dd>' +
+            (rec.spec.kind && rec.spec.kind !== 'run' ? ' <span class="cj-chip">' + esc(t('cluster/jobs/kind/' + rec.spec.kind, rec.spec.kind)) + '</span>' : '') + '</dd>' +
+            '<dt>' + t('cluster/jobs/status', 'Status') + '</dt><dd><span class="cj-badge ' + esc(rec.state.status) + '">' + esc(CL.state(rec.state.status)) + '</span>' +
+            (rec.state.error ? ' <span style="color:var(--cj-danger)">' + esc(CL.tr(rec.state.error)) + '</span>' : '') + '</dd>' +
             '<dt>' + t('cluster/jobs/node', 'Node') + '</dt><dd>' + (rec.state.node ? esc(rec.state.node) : '-') + '</dd>' +
             '<dt>' + t('cluster/jobs/script', 'Script') + '</dt><dd class="cj-mono">' + esc(rec.spec.scriptName) + '</dd>' +
             '<dt>' + t('cluster/jobs/owner', 'Owner') + '</dt><dd>' + esc(rec.spec.owner) + '</dd>' +
-            '<dt>' + t('cluster/jobs/duration', 'Duration') + '</dt><dd>' + duration(rec) + ' <span class="cj-meta">' + t('cluster/jobs/attempts', 'attempt') + ' ' + rec.state.attempts + '</span></dd>';
+            '<dt>' + t('cluster/jobs/duration', 'Duration') + '</dt><dd>' + duration(rec) + ' <span class="cj-meta">' + esc(fmt(t('cluster/jobs/attemptn', 'attempt {0}'), rec.state.attempts)) + '</span></dd>';
         if (rec.spec.dataset) rows += '<dt>' + t('cluster/jobs/dataset', 'Dataset') + '</dt><dd class="cj-mono">' + esc(rec.spec.dataset) + '</dd>';
         if (kids.length) {
             var doneKids = kids.filter(function(k) { return k.state.status === 'succeeded'; }).length;
-            rows += '<dt>' + t('cluster/jobs/tasks', 'Tasks') + '</dt><dd>' + doneKids + ' / ' + kids.length + ' ' + t('cluster/jobs/complete', 'complete') + '</dd>';
+            rows += '<dt>' + t('cluster/jobs/tasks', 'Tasks') + '</dt><dd>' + esc(fmt(t('cluster/jobs/complete', '{0} / {1} complete'), doneKids, kids.length)) + '</dd>';
         }
-        if (rec.state.reason) rows += '<dt>' + t('cluster/jobs/waiting', 'Waiting') + '</dt><dd style="color:var(--cj-warn)">' + esc(rec.state.reason) + '</dd>';
+        if (rec.state.reason) rows += '<dt>' + t('cluster/jobs/waiting', 'Waiting') + '</dt><dd style="color:var(--cj-warn)">' + esc(CL.tr(rec.state.reason)) + '</dd>';
         kv.innerHTML = rows;
         var logs = rec.state.log || [];
         document.getElementById('detail-log-wrap').style.display = logs.length ? '' : 'none';
@@ -336,15 +314,15 @@
                 var pct = Math.round((rec.state.progress || 0) * 100);
                 html += '<tr class="' + (rec.spec.id === selected ? 'selected' : '') + '" onclick="selectJob(\'' + esc(rec.spec.id) + '\')">' +
                     '<td><div class="cj-name">' + esc(rec.spec.name || rec.spec.scriptName) + '</div><div class="cj-meta cj-mono">' + esc(rec.spec.id.substring(0, 8)) + ' · ' + esc(rec.spec.owner) + '</div></td>' +
-                    '<td><span class="cj-badge ' + esc(rec.state.status) + '">' + esc(rec.state.status).toUpperCase() + '</span></td>' +
+                    '<td><span class="cj-badge ' + esc(rec.state.status) + '">' + esc(CL.state(rec.state.status)) + '</span></td>' +
                     '<td>' + (rec.state.node ? esc(rec.state.node) : '<span style="color:var(--cj-muted)">-</span>') + '</td>' +
                     '<td>' + pct + '%<div class="cj-bar"><div style="width:' + pct + '%"></div></div></td>' +
                     '<td>' + fmtAgo(rec.state.finished || rec.state.started || rec.spec.created) + '<div class="cj-meta">' + duration(rec) + '</div></td>' +
                     '<td>' + (running ? '<button class="cj-btn small danger" onclick="event.stopPropagation(); cancelJob(\'' + esc(rec.spec.id) + '\')">' + t('cluster/jobs/cancel', 'Cancel') + '</button>' : '') + '</td></tr>';
             });
             document.getElementById('jobs-body').innerHTML = html || '<tr><td colspan="6" class="cj-empty">' + t('cluster/jobs/none', 'No jobs yet') + '</td></tr>';
-            document.getElementById('jobs-summary').textContent = ' ' + st.running + ' ' + t('cluster/jobs/running', 'running') + ', ' + st.queued + ' ' + t('cluster/jobs/queued2', 'queued') + ', ' + st.succeeded + ' ' + t('cluster/jobs/done', 'done') + (st.failed ? ', ' + st.failed + ' ' + t('cluster/jobs/failed', 'failed') : '');
-            document.getElementById('cj-refresh').textContent = (st.leaderName ? t('cluster/jobs/scheduler', 'Scheduler') + ': ' + st.leaderName + ' · ' : '') + st.localBusy + '/' + st.localSlots + ' ' + t('cluster/jobs/slots', 'slots busy here');
+            document.getElementById('jobs-summary').textContent = ' ' + fmt(t('cluster/jobs/summary', '{0} running, {1} queued, {2} done'), st.running, st.queued, st.succeeded) + (st.failed ? ', ' + fmt(t('cluster/jobs/failedn', '{0} failed'), st.failed) : '');
+            document.getElementById('cj-refresh').textContent = (st.leaderName ? fmt(t('cluster/jobs/scheduler', 'Scheduler: {0}'), st.leaderName) + ' · ' : '') + fmt(t('cluster/jobs/slots', '{0}/{1} slots busy here'), st.localBusy, st.localSlots);
             renderDetail();
         });
     }
@@ -357,8 +335,11 @@
         });
     }
 
-    loadJobs();
-    setInterval(loadJobs, 3000);
+    CL.applyTheme('cj-root');
+    CL.init(function() {
+        loadJobs();
+        CL.every(loadJobs, 3000, 'cj-root');
+    });
 </script>
 </body>
 </html>

+ 25 - 1
src/web/SystemAO/file_system/file_explorer.css

@@ -1825,8 +1825,32 @@ body.fmResizing *{
     */
     pointer-events: none;
 }
+/* A failed row uses the "Retry" text link in the status line instead of the button */
 #uploadTab .uploadTask.failed .uploadTaskAction{
-    color: var(--upload_progress_failed);
+    display: none;
+}
+#uploadTab .uploadTaskRetry{
+    display: none;
+    flex: 0 0 auto;
+    white-space: nowrap;
+    color: var(--upload_progressbar);
+    cursor: pointer;
+}
+#uploadTab .uploadTask.failed .uploadTaskRetry{
+    display: inline-block;
+    margin-left: -2px;
+}
+#uploadTab .uploadTask.failed .uploadTaskRetry::before{
+    content: "";
+    display: inline-block;
+    width: 1px;
+    height: 12px;
+    margin-right: 10px;
+    vertical-align: -2px;
+    background-color: var(--fs-line);
+}
+#uploadTab .uploadTaskRetry:hover{
+    text-decoration: underline;
 }
 #uploadTab .uploadTaskAction:hover{
     background-color: var(--fs-fill-hover);

+ 8 - 0
src/web/SystemAO/file_system/js/explorer/uploadui.js

@@ -67,6 +67,7 @@ function appendUploadFileItem(filename, filesize){
             <span class="uploadTaskSize"></span>
             <span class="uploadTaskDoneLink" onclick="onUploadTaskDoneLink('${newuuid}');"></span>
             <span class="uploadTaskStatus"></span>
+            <span class="uploadTaskRetry" onclick="onUploadTaskButton('${newuuid}');"></span>
         </div>
         <div class="uploadTaskActions">
             <div class="uploadTaskCancel" onclick="cancelUploadTask('${newuuid}');">${FSIcons.closeCircle}</div>
@@ -342,6 +343,13 @@ function renderUploadTask(taskUUID){
     }
     row.find(".uploadTaskStatus").text(statusText);
 
+    //A failed row offers a "Retry" text link after the status instead of a round button
+    let retryEl = row.find(".uploadTaskRetry");
+    let retryText = info.state == "failed" ? applocale.getString("upload/retry", "Retry") : "";
+    if (retryEl.text() != retryText){
+        retryEl.text(retryText);
+    }
+
     //The other half of that line: the download link of a finished download.
     //Written only when it changes, for the same reason the glyph below is.
     let linkEl = row.find(".uploadTaskDoneLink");

+ 3048 - 0
src/web/SystemAO/locale/cluster.json

@@ -0,0 +1,3048 @@
+{
+    "author": "tobychui",
+    "version": "1.0",
+    "keys": {
+        "en-us": {
+            "name": "English (US)",
+            "fontFamily": "Arial, Helvetica, sans-serif",
+            "strings": {
+                "cluster/ago/d": "{0}d ago",
+                "cluster/ago/h": "{0}h ago",
+                "cluster/ago/m": "{0}m ago",
+                "cluster/ago/s": "{0}s ago",
+                "cluster/auto": "Automatic (any reachable member)",
+                "cluster/cancel": "Cancel",
+                "cluster/caps": "Capabilities",
+                "cluster/cfg/insecure": "Accept self-signed certificates from other nodes (LAN only)",
+                "cluster/cfg/name": "Node name",
+                "cluster/cfg/test": "Test",
+                "cluster/cfg/tunnel": "Tunnel through",
+                "cluster/cfg/url": "Advertised URL (how other nodes reach this one)",
+                "cluster/cfg/urlhint": "Leave empty if this node has no public address. It will then keep a tunnel open to a reachable member, which also works through Cloudflare.",
+                "cluster/clusterid": "Cluster ID",
+                "cluster/confirmleave": "Leave the cluster? Files and jobs on other nodes will no longer be reachable from here.",
+                "cluster/confirmremove": "Remove node \"{0}\"?",
+                "cluster/copied": "Token copied to clipboard",
+                "cluster/copy": "Copy",
+                "cluster/coresvalue": "{0} cores",
+                "cluster/create": "Create a new cluster",
+                "cluster/createbtn": "Create",
+                "cluster/created": "Created",
+                "cluster/createhint": "This node becomes the first member. Generate join tokens here afterwards to add more nodes.",
+                "cluster/direct": "DIRECT",
+                "cluster/disk": "Disk",
+                "cluster/diskfree": "{0} free of {1}",
+                "cluster/drain": "Drain",
+                "cluster/entername": "Enter a cluster name",
+                "cluster/entertoken": "Paste a join token",
+                "cluster/enterurl": "Enter a URL to test",
+                "cluster/expiresat": "expires {0}",
+                "cluster/folder": "folder",
+                "cluster/freeamount": "{0} free",
+                "cluster/gentoken": "Generate join token",
+                "cluster/id/accounts": "Accounts",
+                "cluster/id/apply": "Apply",
+                "cluster/id/conflicts": "Local only",
+                "cluster/id/conflictswhy": "a local account with the same name is kept as is",
+                "cluster/id/disabled": "Disabled (each node checks its own accounts)",
+                "cluster/id/fallback": "origin unreachable, local accounts used",
+                "cluster/id/forward": "FORWARD",
+                "cluster/id/hint": "The identity origin verifies every login in the cluster. Accounts are copied to the other nodes so they still work when the origin is unreachable. Groups are matched by name, so create the same permission groups on every node.",
+                "cluster/id/lastlogin": "Last login check",
+                "cluster/id/lastsync": "Last sync",
+                "cluster/id/local": "Each node verifies logins against its own accounts",
+                "cluster/id/mirrors": "{0} of {1} local accounts mirror the origin",
+                "cluster/id/mode": "Mode",
+                "cluster/id/origin": "ORIGIN",
+                "cluster/id/originhere": "This node verifies logins for the whole cluster",
+                "cluster/id/published": "{0} published to members",
+                "cluster/id/replicated": "Replicated",
+                "cluster/id/skipped": "Skipped",
+                "cluster/id/skippedwhy": "none of their groups exist on this node",
+                "cluster/id/sync": "Sync now",
+                "cluster/id/synced": "Accounts synchronised",
+                "cluster/id/title": "Identity (single sign-on)",
+                "cluster/id/verifiedby": "Logins verified by {0}",
+                "cluster/info/novolume": "The cluster has no storage yet.",
+                "cluster/info/novolumehint": "Add a volume in Cluster Settings to make the cluster:/ drive appear in File Manager.",
+                "cluster/info/standalone": "This node is not part of a cluster.",
+                "cluster/info/standalonehint": "Create or join one in Cluster Settings. Until then the cluster:/ drive stays hidden.",
+                "cluster/info/tagline": "Health of this node, its peers, the namespace and the scheduler",
+                "cluster/info/title": "Cluster Info",
+                "cluster/invite": "Add a node",
+                "cluster/jobs/args": "Arguments (JSON, optional)",
+                "cluster/jobs/attemptn": "attempt {0}",
+                "cluster/jobs/browse": "Browse",
+                "cluster/jobs/cancel": "Cancel",
+                "cluster/jobs/complete": "{0} / {1} complete",
+                "cluster/jobs/dataset": "Dataset",
+                "cluster/jobs/datasethint": "With a dataset the script must define mapper(files, emit) and reducer(key, values); the files are processed on the nodes that hold them.",
+                "cluster/jobs/datasetlabel": "Map/reduce dataset (optional glob; turns this into a map/reduce job)",
+                "cluster/jobs/detail": "Job detail",
+                "cluster/jobs/duration": "Duration",
+                "cluster/jobs/failedn": "{0} failed",
+                "cluster/jobs/features": "Required features",
+                "cluster/jobs/inputs": "Input paths (comma separated, used to run near the data)",
+                "cluster/jobs/job": "Job",
+                "cluster/jobs/jobs": "Jobs",
+                "cluster/jobs/kind/map": "map",
+                "cluster/jobs/kind/mapreduce": "map/reduce",
+                "cluster/jobs/kind/reduce": "reduce",
+                "cluster/jobs/log": "Log",
+                "cluster/jobs/minsecs": "{0}m {1}s",
+                "cluster/jobs/name": "Name",
+                "cluster/jobs/needscript": "A script path is required",
+                "cluster/jobs/node": "Node",
+                "cluster/jobs/nodetail": "Select a job to see its log and output",
+                "cluster/jobs/none": "No jobs yet",
+                "cluster/jobs/output": "Output",
+                "cluster/jobs/owner": "Owner",
+                "cluster/jobs/progress": "Progress",
+                "cluster/jobs/queued": "Job queued",
+                "cluster/jobs/run": "Run job",
+                "cluster/jobs/scheduler": "Scheduler: {0}",
+                "cluster/jobs/script": "Script",
+                "cluster/jobs/scriptlabel": "Script (a .agi file that defines run(job))",
+                "cluster/jobs/secs": "{0}s",
+                "cluster/jobs/slots": "{0}/{1} slots busy here",
+                "cluster/jobs/status": "Status",
+                "cluster/jobs/submit": "Submit a job",
+                "cluster/jobs/submithint": "The script runs with your permissions on the chosen node. cluster:/ paths work as usual.",
+                "cluster/jobs/summary": "{0} running, {1} queued, {2} done",
+                "cluster/jobs/tagline": "Run a script on whichever node fits best",
+                "cluster/jobs/tasks": "Tasks",
+                "cluster/jobs/timeout": "Timeout (s)",
+                "cluster/jobs/title": "Cluster Jobs",
+                "cluster/jobs/typepath": "Type the script path, for example user:/jobs/index.agi",
+                "cluster/jobs/waiting": "Waiting",
+                "cluster/join": "Join an existing cluster",
+                "cluster/joinbtn": "Join",
+                "cluster/joinhint": "Paste a join token generated on a reachable member of the cluster.",
+                "cluster/joining": "Joining...",
+                "cluster/justnow": "just now",
+                "cluster/key": "Node key",
+                "cluster/lastseen": "Last seen",
+                "cluster/leave": "Leave cluster",
+                "cluster/link/info": "Cluster Info",
+                "cluster/link/jobs": "Cluster Jobs",
+                "cluster/link/settings": "Cluster Settings",
+                "cluster/load": "Load",
+                "cluster/maintenance": "Maintenance",
+                "cluster/members": "Members",
+                "cluster/membershint": "Maintenance takes a node out of scheduling. Draining also moves its copies to other nodes so it can be switched off safely.",
+                "cluster/meta/entries": "Entries",
+                "cluster/meta/entriesvalue": "{0} files, {1} folders, {2} volumes",
+                "cluster/meta/leader": "Leader",
+                "cluster/meta/log": "Log",
+                "cluster/meta/noleader": "ELECTING",
+                "cluster/meta/pending": "{0} pending",
+                "cluster/meta/term": "term {0}",
+                "cluster/natonly": "NO URL",
+                "cluster/never": "never",
+                "cluster/node": "Node",
+                "cluster/nodes": "Nodes",
+                "cluster/nonodes": "No nodes",
+                "cluster/noreport": "no report yet",
+                "cluster/noroute": "No route",
+                "cluster/notmember": "Not part of a cluster",
+                "cluster/notunnel": "NO TUNNEL",
+                "cluster/now": "now",
+                "cluster/platform": "Platform",
+                "cluster/platformline": "{0} / {1}, {2} cores",
+                "cluster/pol/1": "1 copy",
+                "cluster/pol/2": "2 copies",
+                "cluster/pol/3": "3 copies",
+                "cluster/pol/4": "4 copies",
+                "cluster/pol/copies": "{0} copies",
+                "cluster/pol/hint": "Folders without a policy keep one copy. Extra copies are created by the replication service.",
+                "cluster/pol/label": "Copies per top-level folder",
+                "cluster/pol/set": "Set",
+                "cluster/pol/title": "Replica policies",
+                "cluster/probe": "Probe",
+                "cluster/probefail": "{0}: {1} ({2})",
+                "cluster/probeok": "{0}: {1} ms ({2})",
+                "cluster/reach": "Reachability",
+                "cluster/reachableok": "Reachable, round trip {0} ms",
+                "cluster/redetect": "Re-detect",
+                "cluster/remove": "Remove",
+                "cluster/repl/evacuating": "{0} volume(s) evacuating",
+                "cluster/repl/files": "Files",
+                "cluster/repl/fromto": "{0} to {1}",
+                "cluster/repl/healthy": "all files at policy",
+                "cluster/repl/inflight": "In flight",
+                "cluster/repl/inflightvalue": "{0} running, {1} queued",
+                "cluster/repl/inspect": "Inspect a file",
+                "cluster/repl/inspectbtn": "Show copies",
+                "cluster/repl/issues": "{0} issues",
+                "cluster/repl/lastrun": "last run {0}",
+                "cluster/repl/nocopy": "No healthy copy",
+                "cluster/repl/nolocations": "No copies recorded",
+                "cluster/repl/notasks": "No replication tasks",
+                "cluster/repl/notfound": "Path not found in the namespace",
+                "cluster/repl/over": "Extra copies",
+                "cluster/repl/plan": "Repair now",
+                "cluster/repl/planned": "Planner ran: {0} queued, {1} dropped, {2} marked stale",
+                "cluster/repl/planner": "Planner",
+                "cluster/repl/primary": "primary",
+                "cluster/repl/reason/evacuate": "evacuation",
+                "cluster/repl/reason/repair": "repair",
+                "cluster/repl/reason/replica": "new copy",
+                "cluster/repl/route": "From / to",
+                "cluster/repl/stale": "Stale copies",
+                "cluster/repl/task": "Task",
+                "cluster/repl/title": "Replication",
+                "cluster/repl/under": "Under-replicated",
+                "cluster/repl/verified": "Verified {0} copies ({1}), {2} stale",
+                "cluster/repl/verify": "Verify this node",
+                "cluster/requestfailed": "Request failed",
+                "cluster/resume": "Resume",
+                "cluster/revoke": "Revoke",
+                "cluster/route": "Route",
+                "cluster/route/direct": "direct",
+                "cluster/route/none": "no route",
+                "cluster/route/relay": "relay via {0}",
+                "cluster/route/tunnel": "tunnel",
+                "cluster/save": "Save",
+                "cluster/saved": "Saved",
+                "cluster/sched/cpu": "CPU free",
+                "cluster/sched/cpu/hint": "Prefer an idle processor",
+                "cluster/sched/degraded": "Health",
+                "cluster/sched/degraded/hint": "Avoid a node that is struggling",
+                "cluster/sched/disk": "Free disk",
+                "cluster/sched/disk/hint": "Prefer a node with room to grow",
+                "cluster/sched/diskfree": "Disk free",
+                "cluster/sched/diversity": "Site diversity",
+                "cluster/sched/diversity/hint": "Put a second copy far from the first, so one site going down loses nothing",
+                "cluster/sched/explain": "Why did a job land there?",
+                "cluster/sched/explainbtn": "Explain",
+                "cluster/sched/explainhint": "Copy a job id from the Cluster Jobs page to see the ranking it was placed with.",
+                "cluster/sched/factor/cpu free": "cpu free",
+                "cluster/sched/factor/degraded": "degraded",
+                "cluster/sched/factor/disk free": "disk free",
+                "cluster/sched/factor/locality": "locality",
+                "cluster/sched/factor/memory free": "memory free",
+                "cluster/sched/factor/network distance": "network distance",
+                "cluster/sched/factor/preferred features": "preferred features",
+                "cluster/sched/factor/queue depth": "queue depth",
+                "cluster/sched/factor/site diversity": "site diversity",
+                "cluster/sched/features": "{0} features",
+                "cluster/sched/from": "From / to",
+                "cluster/sched/here": "local",
+                "cluster/sched/hint": "Jobs, new files and extra copies go to the node with the highest score. Raise a weight to make that quality matter more. The resulting scores are shown on Cluster Info.",
+                "cluster/sched/infohint": "How each node scores right now with the weights set in Cluster Settings. Jobs, new files and extra copies go to the highest score.",
+                "cluster/sched/latency": "Network distance",
+                "cluster/sched/latency/hint": "Avoid a node far from the data",
+                "cluster/sched/locality": "Data locality",
+                "cluster/sched/locality/hint": "Prefer a node that already holds the data",
+                "cluster/sched/matrixhint": "Round trip between each pair of nodes, in milliseconds. Copies of a file are spread over the slowest pairs, because distance usually means a different site.",
+                "cluster/sched/mem": "RAM free",
+                "cluster/sched/memory": "Free memory",
+                "cluster/sched/memory/hint": "Prefer a node with spare RAM",
+                "cluster/sched/next": "next job would run on {0}",
+                "cluster/sched/nojob": "No such job on this cluster",
+                "cluster/sched/nonodes": "No nodes to score",
+                "cluster/sched/ping": "Round trip",
+                "cluster/sched/preferred": "Wanted features",
+                "cluster/sched/preferred/hint": "Prefer a node with the optional features a job asks for",
+                "cluster/sched/queue": "Queue depth",
+                "cluster/sched/queue/hint": "Avoid a node that is already busy",
+                "cluster/sched/reset": "Restore defaults",
+                "cluster/sched/restored": "Default weights restored",
+                "cluster/sched/save": "Save weights",
+                "cluster/sched/saved": "Weights saved for the whole cluster",
+                "cluster/sched/score": "Score",
+                "cluster/sched/skipped": "SKIPPED",
+                "cluster/sched/title": "Scheduling",
+                "cluster/sched/unmeasured": "not measured",
+                "cluster/settings/tagline": "Set up this node and how the cluster stores files and runs work",
+                "cluster/settings/title": "Cluster Settings",
+                "cluster/state": "State",
+                "cluster/state/cancelled": "CANCELLED",
+                "cluster/state/committed": "COMMITTED",
+                "cluster/state/degraded": "DEGRADED",
+                "cluster/state/done": "DONE",
+                "cluster/state/draining": "DRAINING",
+                "cluster/state/failed": "FAILED",
+                "cluster/state/maintenance": "MAINTENANCE",
+                "cluster/state/offline": "OFFLINE",
+                "cluster/state/online": "ONLINE",
+                "cluster/state/pending": "PENDING",
+                "cluster/state/queued": "QUEUED",
+                "cluster/state/running": "RUNNING",
+                "cluster/state/scheduled": "SCHEDULED",
+                "cluster/state/stale": "STALE",
+                "cluster/state/succeeded": "SUCCEEDED",
+                "cluster/state/unknown": "UNKNOWN",
+                "cluster/state/verified": "VERIFIED",
+                "cluster/state/writing": "WRITING",
+                "cluster/storage": "Storage",
+                "cluster/testing": "Testing...",
+                "cluster/thisnode": "This node",
+                "cluster/tokenhint": "Tokens can only be generated on a node with an advertised URL. Paste the token into the other node's Cluster Settings.",
+                "cluster/tokenline": "expires {0} · {1} uses",
+                "cluster/ttl/1h": "1 hour",
+                "cluster/ttl/24h": "24 hours",
+                "cluster/ttl/7d": "7 days",
+                "cluster/tunnel": "TUNNEL",
+                "cluster/tunnelhere": "Tunnel to this node",
+                "cluster/tunnelvia": "Tunnel via {0}",
+                "cluster/unreachable": "unreachable",
+                "cluster/updated": "Updated {0}",
+                "cluster/uptime": "Uptime",
+                "cluster/uptimevalue": "{0}h {1}m",
+                "cluster/version": "Version",
+                "cluster/via": "via {0}",
+                "cluster/vol/add": "Contribute a folder from this node",
+                "cluster/vol/addbtn": "Add volume",
+                "cluster/vol/added": "Volume added",
+                "cluster/vol/autoro": "Make nearly full volumes read only automatically",
+                "cluster/vol/autorohint": "Applies to every node. A volume under 5% free stops taking new files and takes them again above 7%. Turn this off to let volumes fill up; volumes it had locked become writable again within a minute.",
+                "cluster/vol/autorooff": "Volumes may now fill up completely",
+                "cluster/vol/autoroon": "Nearly full volumes will be made read only",
+                "cluster/vol/confirmevac": "Move every file off \"{0}\"? The volume becomes read only and is retired once all copies exist elsewhere.",
+                "cluster/vol/confirmremove": "Stop contributing \"{0}\"? Files stay on disk but copies only held there become unavailable.",
+                "cluster/vol/evacstarted": "Evacuation started: {0} files to move",
+                "cluster/vol/evacuate": "Evacuate",
+                "cluster/vol/evacuating": "EVACUATING",
+                "cluster/vol/files": "Files",
+                "cluster/vol/full": "nearly full",
+                "cluster/vol/hint": "Files written to cluster:/ are stored as ordinary files inside contributed folders. Files already in the folder are adopted into the namespace on the next rescan. The cluster:/ drive appears in File Manager once the cluster has at least one volume.",
+                "cluster/vol/none": "No volumes yet. Add a folder from this node to start storing files in cluster:/",
+                "cluster/vol/readonly": "Read only",
+                "cluster/vol/rescan": "Rescan this node",
+                "cluster/vol/rescanned": "Rescan done: {0} adopted, {1} stale",
+                "cluster/vol/ro": "read only",
+                "cluster/vol/space": "Free / total",
+                "cluster/vol/summary": "{0} volumes, {1} free",
+                "cluster/vol/volume": "Volume",
+                "cluster/vol/writable": "Make writable",
+                "cluster/waitinghost": "waiting for a reachable member",
+                "cluster/when": "When",
+                "cluster/willtunnel": "will tunnel through a member once in a cluster",
+                "cluster/you": "you",
+                "msg/AGI gateway not ready": "AGI gateway not ready",
+                "msg/Invalid MAC address": "Invalid MAC address",
+                "msg/POST required": "POST required",
+                "msg/URL must start with http:// or https://": "URL must start with http:// or https://",
+                "msg/a job id is required": "a job id is required",
+                "msg/a map/reduce job needs a dataset pattern": "a map/reduce job needs a dataset pattern",
+                "msg/a map/reduce script must define mapper(files, emit) and reducer(key, values)": "a map/reduce script must define mapper(files, emit) and reducer(key, values)",
+                "msg/advertise URL must start with http:// or https://": "advertise URL must start with http:// or https://",
+                "msg/args must be valid JSON": "args must be valid JSON",
+                "msg/cannot remove the namespace root": "cannot remove the namespace root",
+                "msg/checksum mismatch": "checksum mismatch",
+                "msg/chunk checksum mismatch": "chunk checksum mismatch",
+                "msg/chunk exceeds file size": "chunk exceeds file size",
+                "msg/chunk index out of range": "chunk index out of range",
+                "msg/chunk size invalid": "chunk size invalid",
+                "msg/cluster jobs not available": "cluster jobs not available",
+                "msg/cluster name cannot be empty": "cluster name cannot be empty",
+                "msg/cluster name required": "cluster name required",
+                "msg/directory not empty": "directory not empty",
+                "msg/disk usage not supported on this platform": "disk usage not supported on this platform",
+                "msg/drive required": "drive required",
+                "msg/empty response": "empty response",
+                "msg/enabled must be true or false": "enabled must be true or false",
+                "msg/event bus not available": "event bus not available",
+                "msg/event data too large (64 KB max)": "event data too large (64 KB max)",
+                "msg/event type required": "event type required",
+                "msg/evict notice is not for this node": "evict notice is not for this node",
+                "msg/file changed since the copy was planned": "file changed since the copy was planned",
+                "msg/file no longer exists in the namespace": "file no longer exists in the namespace",
+                "msg/file not found on volume": "file not found on volume",
+                "msg/folder path required": "folder path required",
+                "msg/folder required": "folder required",
+                "msg/hook not found": "hook not found",
+                "msg/insufficient cpu cores": "insufficient cpu cores",
+                "msg/insufficient memory": "insufficient memory",
+                "msg/invalid append payload": "invalid append payload",
+                "msg/invalid batch": "invalid batch",
+                "msg/invalid begin request": "invalid begin request",
+                "msg/invalid entry": "invalid entry",
+                "msg/invalid heartbeat": "invalid heartbeat",
+                "msg/invalid job spec": "invalid job spec",
+                "msg/invalid join request": "invalid join request",
+                "msg/invalid lease": "invalid lease",
+                "msg/invalid node URL": "invalid node URL",
+                "msg/invalid node public key": "invalid node public key",
+                "msg/invalid node record": "invalid node record",
+                "msg/invalid range": "invalid range",
+                "msg/invalid rename": "invalid rename",
+                "msg/invalid report": "invalid report",
+                "msg/invalid request": "invalid request",
+                "msg/invalid sync payload": "invalid sync payload",
+                "msg/invalid task": "invalid task",
+                "msg/invalid username": "invalid username",
+                "msg/is a directory": "is a directory",
+                "msg/job cancelled": "job cancelled",
+                "msg/job id required": "job id required",
+                "msg/job is limited to other nodes": "job is limited to other nodes",
+                "msg/job not found": "job not found",
+                "msg/job owner is required": "job owner is required",
+                "msg/job script is empty": "job script is empty",
+                "msg/job timed out": "job timed out",
+                "msg/join response does not match the token's cluster": "join response does not match the token's cluster",
+                "msg/join token has an invalid node URL": "join token has an invalid node URL",
+                "msg/join token is corrupted": "join token is corrupted",
+                "msg/join token is for another cluster": "join token is for another cluster",
+                "msg/join token is incomplete": "join token is incomplete",
+                "msg/join token is invalid or expired": "join token is invalid or expired",
+                "msg/join token required": "join token required",
+                "msg/metadata store not available": "metadata store not available",
+                "msg/no cluster volume available; add one in System Settings > Cluster Settings > Storage": "no cluster volume available; add one in System Settings > Cluster Settings > Storage",
+                "msg/no healthy copy of this file is reachable right now": "no healthy copy of this file is reachable right now",
+                "msg/no node is available": "no node is available",
+                "msg/node already has enough copies in flight": "node already has enough copies in flight",
+                "msg/node id is required": "node id is required",
+                "msg/node id required": "node id required",
+                "msg/node is DEGRADED": "node is DEGRADED",
+                "msg/node is DRAINING": "node is DRAINING",
+                "msg/node is MAINTENANCE": "node is MAINTENANCE",
+                "msg/node is OFFLINE": "node is OFFLINE",
+                "msg/node is UNKNOWN": "node is UNKNOWN",
+                "msg/node is degraded": "node is degraded",
+                "msg/node is draining": "node is draining",
+                "msg/node is maintenance": "node is maintenance",
+                "msg/node is not available": "node is not available",
+                "msg/node is offline": "node is offline",
+                "msg/node is unknown": "node is unknown",
+                "msg/node lost while running the job": "node lost while running the job",
+                "msg/node name cannot be empty": "node name cannot be empty",
+                "msg/node not found": "node not found",
+                "msg/node refused this job earlier": "node refused this job earlier",
+                "msg/not a directory": "not a directory",
+                "msg/not a file": "not a file",
+                "msg/not an ArozOS join token": "not an ArozOS join token",
+                "msg/not in a cluster": "not in a cluster",
+                "msg/not logged in": "not logged in",
+                "msg/offset beyond end of file": "offset beyond end of file",
+                "msg/only the metadata leader assigns jobs": "only the metadata leader assigns jobs",
+                "msg/only the metadata leader assigns replication tasks": "only the metadata leader assigns replication tasks",
+                "msg/owner, script and at least one event type are required": "owner, script and at least one event type are required",
+                "msg/path already exists": "path already exists",
+                "msg/path escapes the volume": "path escapes the volume",
+                "msg/path not found in the cluster namespace": "path not found in the cluster namespace",
+                "msg/path required": "path required",
+                "msg/peer has no reachable endpoint": "peer has no reachable endpoint",
+                "msg/permission denied": "permission denied",
+                "msg/previous node stopped reporting": "previous node stopped reporting",
+                "msg/refusing to delete the volume root": "refusing to delete the volume root",
+                "msg/refusing to drop the last healthy copy": "refusing to drop the last healthy copy",
+                "msg/replicas must be between 0 and 16": "replicas must be between 0 and 16",
+                "msg/replicas must be between 1 and 16": "replicas must be between 1 and 16",
+                "msg/request is not signed": "request is not signed",
+                "msg/request signature invalid": "request signature invalid",
+                "msg/request targets another cluster": "request targets another cluster",
+                "msg/request timestamp outside the accepted window": "request timestamp outside the accepted window",
+                "msg/requested log range was compacted, take a snapshot": "requested log range was compacted, take a snapshot",
+                "msg/script path required": "script path required",
+                "msg/sender is not a member of this cluster": "sender is not a member of this cluster",
+                "msg/shutting down": "shutting down",
+                "msg/source copy is not readable": "source copy is not readable",
+                "msg/target node is not connected through a tunnel on this node": "target node is not connected through a tunnel on this node",
+                "msg/task is for another node": "task is for another node",
+                "msg/the URL answered but it is not an ArozOS cluster endpoint": "the URL answered but it is not an ArozOS cluster endpoint",
+                "msg/the job has no dataset pattern": "the job has no dataset pattern",
+                "msg/the job owner has no account on this node": "the job owner has no account on this node",
+                "msg/the mappers emitted more than 8 MB; narrow the dataset or emit fewer values": "the mappers emitted more than 8 MB; narrow the dataset or emit fewer values",
+                "msg/the user root itself cannot be a cluster volume; pick a sub folder such as /cluster": "the user root itself cannot be a cluster volume; pick a sub folder such as /cluster",
+                "msg/this folder is already a cluster volume": "this folder is already a cluster volume",
+                "msg/this job script defines no run(job) function": "this job script defines no run(job) function",
+                "msg/this node cannot execute jobs": "this node cannot execute jobs",
+                "msg/this node has no advertised URL; generate the join token on a node that other nodes can reach": "this node has no advertised URL; generate the join token on a node that other nodes can reach",
+                "msg/this node is already part of a cluster, leave it first": "this node is already part of a cluster, leave it first",
+                "msg/this node is not part of a cluster": "this node is not part of a cluster",
+                "msg/this node is not the identity origin of the cluster": "this node is not the identity origin of the cluster",
+                "msg/this node is not the metadata leader": "this node is not the metadata leader",
+                "msg/timed out waiting for the job to finish": "timed out waiting for the job to finish",
+                "msg/token id required": "token id required",
+                "msg/token not found": "token not found",
+                "msg/tunnel closed": "tunnel closed",
+                "msg/unknown upload session": "unknown upload session",
+                "msg/unreachable": "unreachable",
+                "msg/unsupported node state": "unsupported node state",
+                "msg/upload incomplete": "upload incomplete",
+                "msg/url required": "url required",
+                "msg/use leave to remove this node": "use leave to remove this node",
+                "msg/user assertion expired": "user assertion expired",
+                "msg/user assertion invalid": "user assertion invalid",
+                "msg/user not found on the identity origin": "user not found on the identity origin",
+                "msg/volume id required": "volume id required",
+                "msg/volume is not on this node": "volume is not on this node",
+                "msg/volume not found": "volume not found",
+                "msg/volume not found on this node": "volume not found on this node",
+                "msgp/architecture {0} not allowed": "architecture {0} not allowed",
+                "msgp/cancelled by {0}": "cancelled by {0}",
+                "msgp/could not assemble the reduce input: {0}": "could not assemble the reduce input: {0}",
+                "msgp/could not create a map task: {0}": "could not create a map task: {0}",
+                "msgp/could not create the reduce task: {0}": "could not create the reduce task: {0}",
+                "msgp/drive {0} is not a local drive on this node": "drive {0} is not a local drive on this node",
+                "msgp/hook script not found: {0}": "hook script not found: {0}",
+                "msgp/invalid join response from {0}": "invalid join response from {0}",
+                "msgp/invalid node key encoding: {0}": "invalid node key encoding: {0}",
+                "msgp/invalid public key encoding: {0}": "invalid public key encoding: {0}",
+                "msgp/job timed out after {0}s": "job timed out after {0}s",
+                "msgp/local drive {0} is not mounted": "local drive {0} is not mounted",
+                "msgp/missing feature {0}": "missing feature {0}",
+                "msgp/no cluster volume available; add one in System Settings > Cluster Settings > Storage ({0})": "no cluster volume available; add one in System Settings > Cluster Settings > Storage ({0})",
+                "msgp/no reachable copy of: {0}": "no reachable copy of: {0}",
+                "msgp/node {0} could not take the job: {1}": "node {0} could not take the job: {1}",
+                "msgp/os {0} not allowed": "os {0} not allowed",
+                "msgp/permission denied: {0}": "permission denied: {0}",
+                "msgp/remote node returned {0}: {1}": "remote node returned {0}: {1}",
+                "msgp/script not found: {0}": "script not found: {0}",
+                "msgp/this volume holds the only copy of {0} file(s); evacuate it first": "this volume holds the only copy of {0} file(s); evacuate it first",
+                "msgp/unable to reach {0}: {1}": "unable to reach {0}: {1}",
+                "msgp/unreachable: {0}": "unreachable: {0}",
+                "msgp/unsupported node URL scheme: {0}": "unsupported node URL scheme: {0}",
+                "msgp/weight {0} must be a number": "weight {0} must be a number",
+                "msgp/weight {0} must be between 0 and 1": "weight {0} must be between 0 and 1",
+                "msgp/{0} produced an unreadable result": "{0} produced an unreadable result"
+            },
+            "placeholder": {
+                "Name (optional)": "Name (optional)",
+                "job id": "job id",
+                "Nightly index": "Nightly index",
+                "50 per task": "50 per task"
+            },
+            "titles": {
+                "Re-detect capabilities": "Re-detect capabilities"
+            }
+        },
+        "zh-tw": {
+            "name": "繁體中文(台灣)",
+            "fontFamily": "\"Microsoft JhengHei\",\"SimHei\", \"Apple LiGothic Medium\", \"STHeiti\"",
+            "strings": {
+                "cluster/ago/d": "{0} 天前",
+                "cluster/ago/h": "{0} 小時前",
+                "cluster/ago/m": "{0} 分鐘前",
+                "cluster/ago/s": "{0} 秒前",
+                "cluster/auto": "自動(任一可連線的成員)",
+                "cluster/cancel": "取消",
+                "cluster/caps": "功能",
+                "cluster/cfg/insecure": "接受其他節點的自簽憑證(僅限區域網路)",
+                "cluster/cfg/name": "節點名稱",
+                "cluster/cfg/test": "測試",
+                "cluster/cfg/tunnel": "經由通道連線",
+                "cluster/cfg/url": "對外網址(其他節點連線到此節點的方式)",
+                "cluster/cfg/urlhint": "若此節點沒有公開位址請留空。它會改為對一個可連線的成員保持通道連線,這也能穿越 Cloudflare。",
+                "cluster/clusterid": "叢集 ID",
+                "cluster/confirmleave": "要離開叢集嗎?之後將無法從這裡存取其他節點上的檔案與工作。",
+                "cluster/confirmremove": "要移除節點「{0}」嗎?",
+                "cluster/copied": "已將權杖複製到剪貼簿",
+                "cluster/copy": "複製",
+                "cluster/coresvalue": "{0} 核心",
+                "cluster/create": "建立新叢集",
+                "cluster/createbtn": "建立",
+                "cluster/created": "建立時間",
+                "cluster/createhint": "此節點會成為第一個成員。之後在這裡產生加入權杖以加入更多節點。",
+                "cluster/direct": "直連",
+                "cluster/disk": "磁碟",
+                "cluster/diskfree": "剩餘 {0},共 {1}",
+                "cluster/drain": "排空",
+                "cluster/entername": "請輸入叢集名稱",
+                "cluster/entertoken": "請貼上加入權杖",
+                "cluster/enterurl": "請輸入要測試的網址",
+                "cluster/expiresat": "於 {0} 到期",
+                "cluster/folder": "資料夾",
+                "cluster/freeamount": "剩餘 {0}",
+                "cluster/gentoken": "產生加入權杖",
+                "cluster/id/accounts": "帳號",
+                "cluster/id/apply": "套用",
+                "cluster/id/conflicts": "僅限本機",
+                "cluster/id/conflictswhy": "同名的本機帳號會維持原狀",
+                "cluster/id/disabled": "停用(每個節點各自驗證自己的帳號)",
+                "cluster/id/fallback": "無法連線到身分來源,已改用本機帳號",
+                "cluster/id/forward": "轉送",
+                "cluster/id/hint": "身分來源會驗證叢集中的每一次登入。帳號會複製到其他節點,因此在身分來源無法連線時仍可使用。群組以名稱對應,請在每個節點上建立相同的權限群組。",
+                "cluster/id/lastlogin": "上次登入檢查",
+                "cluster/id/lastsync": "上次同步",
+                "cluster/id/local": "每個節點以自己的帳號驗證登入",
+                "cluster/id/mirrors": "{1} 個本機帳號中有 {0} 個與身分來源同步",
+                "cluster/id/mode": "模式",
+                "cluster/id/origin": "來源",
+                "cluster/id/originhere": "此節點負責驗證整個叢集的登入",
+                "cluster/id/published": "已發布 {0} 個給成員",
+                "cluster/id/replicated": "已複製",
+                "cluster/id/skipped": "已略過",
+                "cluster/id/skippedwhy": "此節點上沒有他們所屬的任何群組",
+                "cluster/id/sync": "立即同步",
+                "cluster/id/synced": "帳號已同步",
+                "cluster/id/title": "身分(單一登入)",
+                "cluster/id/verifiedby": "登入由 {0} 驗證",
+                "cluster/info/novolume": "叢集尚未設定儲存空間。",
+                "cluster/info/novolumehint": "在叢集設定中加入儲存卷,cluster:/ 磁碟才會出現在檔案管理器中。",
+                "cluster/info/standalone": "此節點尚未加入叢集。",
+                "cluster/info/standalonehint": "請在叢集設定中建立或加入叢集。在此之前 cluster:/ 磁碟會保持隱藏。",
+                "cluster/info/tagline": "此節點、其他節點、命名空間與排程器的狀態",
+                "cluster/info/title": "叢集資訊",
+                "cluster/invite": "加入節點",
+                "cluster/jobs/args": "參數(JSON,選填)",
+                "cluster/jobs/attemptn": "第 {0} 次嘗試",
+                "cluster/jobs/browse": "瀏覽",
+                "cluster/jobs/cancel": "取消",
+                "cluster/jobs/complete": "已完成 {0} / {1}",
+                "cluster/jobs/dataset": "資料集",
+                "cluster/jobs/datasethint": "使用資料集時,腳本必須定義 mapper(files, emit) 與 reducer(key, values);檔案會在持有它們的節點上處理。",
+                "cluster/jobs/datasetlabel": "Map/reduce 資料集(選填的萬用字元;會將此工作變成 map/reduce 工作)",
+                "cluster/jobs/detail": "工作詳細資料",
+                "cluster/jobs/duration": "耗時",
+                "cluster/jobs/failedn": "{0} 個失敗",
+                "cluster/jobs/features": "必要功能",
+                "cluster/jobs/inputs": "輸入路徑(以逗號分隔,用於在資料附近執行)",
+                "cluster/jobs/job": "工作",
+                "cluster/jobs/jobs": "工作",
+                "cluster/jobs/kind/map": "map 子工作",
+                "cluster/jobs/kind/mapreduce": "map/reduce",
+                "cluster/jobs/kind/reduce": "reduce 子工作",
+                "cluster/jobs/log": "紀錄",
+                "cluster/jobs/minsecs": "{0} 分 {1} 秒",
+                "cluster/jobs/name": "名稱",
+                "cluster/jobs/needscript": "必須指定腳本路徑",
+                "cluster/jobs/node": "節點",
+                "cluster/jobs/nodetail": "選擇一個工作以查看其紀錄與輸出",
+                "cluster/jobs/none": "尚無工作",
+                "cluster/jobs/output": "輸出",
+                "cluster/jobs/owner": "擁有者",
+                "cluster/jobs/progress": "進度",
+                "cluster/jobs/queued": "工作已排入佇列",
+                "cluster/jobs/run": "執行工作",
+                "cluster/jobs/scheduler": "排程器:{0}",
+                "cluster/jobs/script": "腳本",
+                "cluster/jobs/scriptlabel": "腳本(定義 run(job) 的 .agi 檔案)",
+                "cluster/jobs/secs": "{0} 秒",
+                "cluster/jobs/slots": "此節點 {0}/{1} 個執行槽忙碌中",
+                "cluster/jobs/status": "狀態",
+                "cluster/jobs/submit": "提交工作",
+                "cluster/jobs/submithint": "腳本會以你的權限在選定的節點上執行。cluster:/ 路徑照常可用。",
+                "cluster/jobs/summary": "{0} 個執行中,{1} 個排隊中,{2} 個已完成",
+                "cluster/jobs/tagline": "在最適合的節點上執行腳本",
+                "cluster/jobs/tasks": "子工作",
+                "cluster/jobs/timeout": "逾時(秒)",
+                "cluster/jobs/title": "叢集工作",
+                "cluster/jobs/typepath": "請輸入腳本路徑,例如 user:/jobs/index.agi",
+                "cluster/jobs/waiting": "等待原因",
+                "cluster/join": "加入現有叢集",
+                "cluster/joinbtn": "加入",
+                "cluster/joinhint": "貼上在叢集中可連線成員上產生的加入權杖。",
+                "cluster/joining": "正在加入...",
+                "cluster/justnow": "剛剛",
+                "cluster/key": "節點金鑰",
+                "cluster/lastseen": "最後連線",
+                "cluster/leave": "離開叢集",
+                "cluster/link/info": "叢集資訊",
+                "cluster/link/jobs": "叢集工作",
+                "cluster/link/settings": "叢集設定",
+                "cluster/load": "負載",
+                "cluster/maintenance": "維護",
+                "cluster/members": "成員",
+                "cluster/membershint": "維護模式會讓節點退出排程。排空還會把它的副本移到其他節點,之後即可安全關機。",
+                "cluster/meta/entries": "項目",
+                "cluster/meta/entriesvalue": "{0} 個檔案、{1} 個資料夾、{2} 個儲存卷",
+                "cluster/meta/leader": "主導節點",
+                "cluster/meta/log": "紀錄",
+                "cluster/meta/noleader": "選舉中",
+                "cluster/meta/pending": "{0} 筆待處理",
+                "cluster/meta/term": "任期 {0}",
+                "cluster/natonly": "無網址",
+                "cluster/never": "從未",
+                "cluster/node": "節點",
+                "cluster/nodes": "節點",
+                "cluster/nonodes": "沒有節點",
+                "cluster/noreport": "尚無回報",
+                "cluster/noroute": "無路徑",
+                "cluster/notmember": "尚未加入叢集",
+                "cluster/notunnel": "無通道",
+                "cluster/now": "現在",
+                "cluster/platform": "平台",
+                "cluster/platformline": "{0} / {1},{2} 核心",
+                "cluster/pol/1": "1 份副本",
+                "cluster/pol/2": "2 份副本",
+                "cluster/pol/3": "3 份副本",
+                "cluster/pol/4": "4 份副本",
+                "cluster/pol/copies": "{0} 份副本",
+                "cluster/pol/hint": "沒有設定原則的資料夾只保留一份副本。額外的副本由複製服務建立。",
+                "cluster/pol/label": "每個最上層資料夾的副本數",
+                "cluster/pol/set": "設定",
+                "cluster/pol/title": "副本原則",
+                "cluster/probe": "探測",
+                "cluster/probefail": "{0}:{1}({2})",
+                "cluster/probeok": "{0}:{1} 毫秒({2})",
+                "cluster/reach": "連線方式",
+                "cluster/reachableok": "可連線,往返 {0} 毫秒",
+                "cluster/redetect": "重新偵測",
+                "cluster/remove": "移除",
+                "cluster/repl/evacuating": "{0} 個儲存卷撤離中",
+                "cluster/repl/files": "檔案",
+                "cluster/repl/fromto": "{0} 到 {1}",
+                "cluster/repl/healthy": "所有檔案皆符合原則",
+                "cluster/repl/inflight": "進行中",
+                "cluster/repl/inflightvalue": "{0} 個執行中,{1} 個排隊中",
+                "cluster/repl/inspect": "檢查檔案",
+                "cluster/repl/inspectbtn": "顯示副本",
+                "cluster/repl/issues": "{0} 個問題",
+                "cluster/repl/lastrun": "上次執行 {0}",
+                "cluster/repl/nocopy": "無健康副本",
+                "cluster/repl/nolocations": "沒有副本紀錄",
+                "cluster/repl/notasks": "沒有複製工作",
+                "cluster/repl/notfound": "在命名空間中找不到此路徑",
+                "cluster/repl/over": "多餘副本",
+                "cluster/repl/plan": "立即修復",
+                "cluster/repl/planned": "規劃器已執行:{0} 個排入,{1} 個刪除,{2} 個標記為過期",
+                "cluster/repl/planner": "規劃器",
+                "cluster/repl/primary": "主要",
+                "cluster/repl/reason/evacuate": "撤離",
+                "cluster/repl/reason/repair": "修復",
+                "cluster/repl/reason/replica": "新增副本",
+                "cluster/repl/route": "來源 / 目的地",
+                "cluster/repl/stale": "過期副本",
+                "cluster/repl/task": "工作",
+                "cluster/repl/title": "副本複製",
+                "cluster/repl/under": "副本不足",
+                "cluster/repl/verified": "已驗證 {0} 份副本({1}),{2} 份過期",
+                "cluster/repl/verify": "驗證此節點",
+                "cluster/requestfailed": "要求失敗",
+                "cluster/resume": "恢復",
+                "cluster/revoke": "撤銷",
+                "cluster/route": "路徑",
+                "cluster/route/direct": "直連",
+                "cluster/route/none": "無路徑",
+                "cluster/route/relay": "經由 {0} 轉送",
+                "cluster/route/tunnel": "通道",
+                "cluster/save": "儲存",
+                "cluster/saved": "已儲存",
+                "cluster/sched/cpu": "CPU 閒置",
+                "cluster/sched/cpu/hint": "優先選擇閒置的處理器",
+                "cluster/sched/degraded": "健康狀態",
+                "cluster/sched/degraded/hint": "避開效能不佳的節點",
+                "cluster/sched/disk": "可用磁碟",
+                "cluster/sched/disk/hint": "優先選擇有成長空間的節點",
+                "cluster/sched/diskfree": "磁碟剩餘",
+                "cluster/sched/diversity": "站點分散",
+                "cluster/sched/diversity/hint": "把第二份副本放在離第一份較遠的地方,這樣某個站點停擺時不會遺失資料",
+                "cluster/sched/explain": "工作為何被分配到那裡?",
+                "cluster/sched/explainbtn": "說明",
+                "cluster/sched/explainhint": "從叢集工作頁面複製工作 ID,即可查看分配時的排名。",
+                "cluster/sched/factor/cpu free": "CPU 閒置",
+                "cluster/sched/factor/degraded": "效能降低",
+                "cluster/sched/factor/disk free": "磁碟剩餘",
+                "cluster/sched/factor/locality": "資料位置",
+                "cluster/sched/factor/memory free": "記憶體剩餘",
+                "cluster/sched/factor/network distance": "網路距離",
+                "cluster/sched/factor/preferred features": "偏好功能",
+                "cluster/sched/factor/queue depth": "佇列長度",
+                "cluster/sched/factor/site diversity": "站點分散",
+                "cluster/sched/features": "{0} 項功能",
+                "cluster/sched/from": "來源 / 目的地",
+                "cluster/sched/here": "本機",
+                "cluster/sched/hint": "工作、新檔案與額外副本會交給分數最高的節點。調高權重可讓該項特質更重要。實際分數顯示在叢集資訊頁面。",
+                "cluster/sched/infohint": "依叢集設定中的權重,各節點目前的分數。工作、新檔案與額外副本會交給分數最高者。",
+                "cluster/sched/latency": "網路距離",
+                "cluster/sched/latency/hint": "避開距離資料太遠的節點",
+                "cluster/sched/locality": "資料位置",
+                "cluster/sched/locality/hint": "優先選擇已持有資料的節點",
+                "cluster/sched/matrixhint": "每對節點之間的往返時間(毫秒)。檔案的副本會分散到距離最遠的節點上,因為距離通常代表不同的站點。",
+                "cluster/sched/mem": "記憶體剩餘",
+                "cluster/sched/memory": "可用記憶體",
+                "cluster/sched/memory/hint": "優先選擇有多餘記憶體的節點",
+                "cluster/sched/next": "下一個工作將在 {0} 執行",
+                "cluster/sched/nojob": "此叢集中沒有這個工作",
+                "cluster/sched/nonodes": "沒有可評分的節點",
+                "cluster/sched/ping": "往返時間",
+                "cluster/sched/preferred": "偏好功能",
+                "cluster/sched/preferred/hint": "優先選擇具備工作所要求之選用功能的節點",
+                "cluster/sched/queue": "佇列長度",
+                "cluster/sched/queue/hint": "避開已經忙碌的節點",
+                "cluster/sched/reset": "還原預設值",
+                "cluster/sched/restored": "已還原預設權重",
+                "cluster/sched/save": "儲存權重",
+                "cluster/sched/saved": "權重已套用到整個叢集",
+                "cluster/sched/score": "分數",
+                "cluster/sched/skipped": "略過",
+                "cluster/sched/title": "排程",
+                "cluster/sched/unmeasured": "尚未測量",
+                "cluster/settings/tagline": "設定此節點,以及叢集如何儲存檔案與執行工作",
+                "cluster/settings/title": "叢集設定",
+                "cluster/state": "狀態",
+                "cluster/state/cancelled": "已取消",
+                "cluster/state/committed": "已寫入",
+                "cluster/state/degraded": "效能降低",
+                "cluster/state/done": "完成",
+                "cluster/state/draining": "排空中",
+                "cluster/state/failed": "失敗",
+                "cluster/state/maintenance": "維護中",
+                "cluster/state/offline": "離線",
+                "cluster/state/online": "線上",
+                "cluster/state/pending": "待處理",
+                "cluster/state/queued": "排隊中",
+                "cluster/state/running": "執行中",
+                "cluster/state/scheduled": "已排程",
+                "cluster/state/stale": "過期",
+                "cluster/state/succeeded": "成功",
+                "cluster/state/unknown": "未知",
+                "cluster/state/verified": "已驗證",
+                "cluster/state/writing": "寫入中",
+                "cluster/storage": "儲存空間",
+                "cluster/testing": "測試中...",
+                "cluster/thisnode": "此節點",
+                "cluster/tokenhint": "只有設定了對外網址的節點才能產生權杖。將權杖貼到另一個節點的叢集設定中。",
+                "cluster/tokenline": "於 {0} 到期 · 已使用 {1} 次",
+                "cluster/ttl/1h": "1 小時",
+                "cluster/ttl/24h": "24 小時",
+                "cluster/ttl/7d": "7 天",
+                "cluster/tunnel": "通道",
+                "cluster/tunnelhere": "通道連到此節點",
+                "cluster/tunnelvia": "經由 {0} 的通道",
+                "cluster/unreachable": "無法連線",
+                "cluster/updated": "更新於 {0}",
+                "cluster/uptime": "運作時間",
+                "cluster/uptimevalue": "{0} 小時 {1} 分",
+                "cluster/version": "版本",
+                "cluster/via": "經由 {0}",
+                "cluster/vol/add": "提供此節點上的資料夾",
+                "cluster/vol/addbtn": "加入儲存卷",
+                "cluster/vol/added": "已加入儲存卷",
+                "cluster/vol/autoro": "自動將快滿的儲存卷設為唯讀",
+                "cluster/vol/autorohint": "套用到每個節點。剩餘空間低於 5% 的儲存卷會停止接收新檔案,高於 7% 時再恢復。關閉此項可讓儲存卷用到滿;先前因此被鎖定的儲存卷會在一分鐘內恢復可寫入。",
+                "cluster/vol/autorooff": "儲存卷現在可以完全用滿",
+                "cluster/vol/autoroon": "快滿的儲存卷將會設為唯讀",
+                "cluster/vol/confirmevac": "要將「{0}」上的所有檔案移走嗎?此儲存卷會變成唯讀,並在所有副本都存在於別處後退役。",
+                "cluster/vol/confirmremove": "要停止提供「{0}」嗎?檔案會留在磁碟上,但只存在那裡的副本將無法使用。",
+                "cluster/vol/evacstarted": "已開始撤離:{0} 個檔案待移動",
+                "cluster/vol/evacuate": "撤離",
+                "cluster/vol/evacuating": "撤離中",
+                "cluster/vol/files": "檔案",
+                "cluster/vol/full": "快滿",
+                "cluster/vol/hint": "寫入 cluster:/ 的檔案會以一般檔案存放在提供的資料夾中。資料夾中原有的檔案會在下次重新掃描時納入命名空間。叢集至少有一個儲存卷後,cluster:/ 磁碟才會出現在檔案管理器中。",
+                "cluster/vol/none": "尚無儲存卷。加入此節點上的資料夾即可開始在 cluster:/ 中存放檔案",
+                "cluster/vol/readonly": "設為唯讀",
+                "cluster/vol/rescan": "重新掃描此節點",
+                "cluster/vol/rescanned": "重新掃描完成:納入 {0} 個,過期 {1} 個",
+                "cluster/vol/ro": "唯讀",
+                "cluster/vol/space": "剩餘 / 總容量",
+                "cluster/vol/summary": "{0} 個儲存卷,剩餘 {1}",
+                "cluster/vol/volume": "儲存卷",
+                "cluster/vol/writable": "設為可寫入",
+                "cluster/waitinghost": "正在等待可連線的成員",
+                "cluster/when": "時間",
+                "cluster/willtunnel": "加入叢集後將透過成員建立通道",
+                "cluster/you": "你",
+                "msg/AGI gateway not ready": "AGI 閘道尚未就緒",
+                "msg/Invalid MAC address": "MAC 位址無效",
+                "msg/POST required": "必須使用 POST",
+                "msg/URL must start with http:// or https://": "網址必須以 http:// 或 https:// 開頭",
+                "msg/a job id is required": "必須提供工作 ID",
+                "msg/a map/reduce job needs a dataset pattern": "map/reduce 工作需要資料集樣式",
+                "msg/a map/reduce script must define mapper(files, emit) and reducer(key, values)": "map/reduce 腳本必須定義 mapper(files, emit) 與 reducer(key, values)",
+                "msg/advertise URL must start with http:// or https://": "對外網址必須以 http:// 或 https:// 開頭",
+                "msg/args must be valid JSON": "參數必須是有效的 JSON",
+                "msg/cannot remove the namespace root": "無法移除命名空間的根目錄",
+                "msg/checksum mismatch": "檢查碼不符",
+                "msg/chunk checksum mismatch": "區塊檢查碼不符",
+                "msg/chunk exceeds file size": "區塊超過檔案大小",
+                "msg/chunk index out of range": "區塊索引超出範圍",
+                "msg/chunk size invalid": "區塊大小無效",
+                "msg/cluster jobs not available": "叢集工作無法使用",
+                "msg/cluster name cannot be empty": "叢集名稱不可為空",
+                "msg/cluster name required": "必須輸入叢集名稱",
+                "msg/directory not empty": "資料夾不是空的",
+                "msg/disk usage not supported on this platform": "此平台不支援磁碟使用量查詢",
+                "msg/drive required": "必須選擇磁碟",
+                "msg/empty response": "回應是空的",
+                "msg/enabled must be true or false": "enabled 必須是 true 或 false",
+                "msg/event bus not available": "事件匯流排無法使用",
+                "msg/event data too large (64 KB max)": "事件資料太大(上限 64 KB)",
+                "msg/event type required": "必須提供事件類型",
+                "msg/evict notice is not for this node": "移除通知不是給此節點的",
+                "msg/file changed since the copy was planned": "檔案在規劃複製後已變更",
+                "msg/file no longer exists in the namespace": "檔案已不存在於命名空間中",
+                "msg/file not found on volume": "儲存卷上找不到檔案",
+                "msg/folder path required": "必須輸入資料夾路徑",
+                "msg/folder required": "必須輸入資料夾",
+                "msg/hook not found": "找不到掛鉤",
+                "msg/insufficient cpu cores": "CPU 核心不足",
+                "msg/insufficient memory": "記憶體不足",
+                "msg/invalid append payload": "無效的附加資料",
+                "msg/invalid batch": "無效的批次",
+                "msg/invalid begin request": "無效的開始要求",
+                "msg/invalid entry": "無效的項目",
+                "msg/invalid heartbeat": "無效的心跳",
+                "msg/invalid job spec": "無效的工作設定",
+                "msg/invalid join request": "無效的加入要求",
+                "msg/invalid lease": "無效的租約",
+                "msg/invalid node URL": "節點網址無效",
+                "msg/invalid node public key": "節點公開金鑰無效",
+                "msg/invalid node record": "無效的節點紀錄",
+                "msg/invalid range": "無效的範圍",
+                "msg/invalid rename": "無效的重新命名",
+                "msg/invalid report": "無效的回報",
+                "msg/invalid request": "無效的要求",
+                "msg/invalid sync payload": "無效的同步資料",
+                "msg/invalid task": "無效的工作",
+                "msg/invalid username": "使用者名稱無效",
+                "msg/is a directory": "這是資料夾",
+                "msg/job cancelled": "工作已取消",
+                "msg/job id required": "必須提供工作 ID",
+                "msg/job is limited to other nodes": "此工作只能在其他節點上執行",
+                "msg/job not found": "找不到工作",
+                "msg/job owner is required": "必須提供工作擁有者",
+                "msg/job script is empty": "工作腳本是空的",
+                "msg/job timed out": "工作逾時",
+                "msg/join response does not match the token's cluster": "加入回應與權杖所屬的叢集不符",
+                "msg/join token has an invalid node URL": "加入權杖中的節點網址無效",
+                "msg/join token is corrupted": "加入權杖已損毀",
+                "msg/join token is for another cluster": "此加入權杖屬於另一個叢集",
+                "msg/join token is incomplete": "加入權杖不完整",
+                "msg/join token is invalid or expired": "加入權杖無效或已過期",
+                "msg/join token required": "必須提供加入權杖",
+                "msg/metadata store not available": "中繼資料儲存區無法使用",
+                "msg/no cluster volume available; add one in System Settings > Cluster Settings > Storage": "沒有可用的叢集儲存卷;請在「系統設定 > 叢集設定 > 儲存空間」中加入",
+                "msg/no healthy copy of this file is reachable right now": "目前無法連線到此檔案的任何健康副本",
+                "msg/no node is available": "沒有可用的節點",
+                "msg/node already has enough copies in flight": "此節點已有足夠的複製正在進行",
+                "msg/node id is required": "必須提供節點 ID",
+                "msg/node id required": "必須提供節點 ID",
+                "msg/node is DEGRADED": "節點效能降低",
+                "msg/node is DRAINING": "節點排空中",
+                "msg/node is MAINTENANCE": "節點維護中",
+                "msg/node is OFFLINE": "節點已離線",
+                "msg/node is UNKNOWN": "節點狀態不明",
+                "msg/node is degraded": "節點效能降低",
+                "msg/node is draining": "節點排空中",
+                "msg/node is maintenance": "節點維護中",
+                "msg/node is not available": "節點無法使用",
+                "msg/node is offline": "節點已離線",
+                "msg/node is unknown": "節點狀態不明",
+                "msg/node lost while running the job": "執行工作時節點失去連線",
+                "msg/node name cannot be empty": "節點名稱不可為空",
+                "msg/node not found": "找不到節點",
+                "msg/node refused this job earlier": "此節點先前拒絕了這個工作",
+                "msg/not a directory": "不是資料夾",
+                "msg/not a file": "不是檔案",
+                "msg/not an ArozOS join token": "這不是 ArozOS 加入權杖",
+                "msg/not in a cluster": "未加入叢集",
+                "msg/not logged in": "尚未登入",
+                "msg/offset beyond end of file": "位移超出檔案結尾",
+                "msg/only the metadata leader assigns jobs": "只有中繼資料主導節點可以分配工作",
+                "msg/only the metadata leader assigns replication tasks": "只有中繼資料主導節點可以分配複製工作",
+                "msg/owner, script and at least one event type are required": "必須提供擁有者、腳本與至少一種事件類型",
+                "msg/path already exists": "路徑已存在",
+                "msg/path escapes the volume": "路徑超出了儲存卷範圍",
+                "msg/path not found in the cluster namespace": "在叢集命名空間中找不到此路徑",
+                "msg/path required": "必須輸入路徑",
+                "msg/peer has no reachable endpoint": "對方節點沒有可連線的端點",
+                "msg/permission denied": "權限不足",
+                "msg/previous node stopped reporting": "先前的節點停止回報",
+                "msg/refusing to delete the volume root": "拒絕刪除儲存卷根目錄",
+                "msg/refusing to drop the last healthy copy": "拒絕刪除最後一份健康副本",
+                "msg/replicas must be between 0 and 16": "副本數必須介於 0 到 16 之間",
+                "msg/replicas must be between 1 and 16": "副本數必須介於 1 到 16 之間",
+                "msg/request is not signed": "要求未簽署",
+                "msg/request signature invalid": "要求的簽章無效",
+                "msg/request targets another cluster": "要求的目標是另一個叢集",
+                "msg/request timestamp outside the accepted window": "要求的時間戳記超出允許範圍(請檢查節點的時鐘)",
+                "msg/requested log range was compacted, take a snapshot": "要求的紀錄範圍已被壓縮,請改用快照",
+                "msg/script path required": "必須提供腳本路徑",
+                "msg/sender is not a member of this cluster": "傳送者不是此叢集的成員",
+                "msg/shutting down": "正在關閉",
+                "msg/source copy is not readable": "來源副本無法讀取",
+                "msg/target node is not connected through a tunnel on this node": "目標節點沒有透過此節點的通道連線",
+                "msg/task is for another node": "此工作屬於另一個節點",
+                "msg/the URL answered but it is not an ArozOS cluster endpoint": "此網址有回應,但不是 ArozOS 叢集端點",
+                "msg/the job has no dataset pattern": "此工作沒有資料集樣式",
+                "msg/the job owner has no account on this node": "此節點上沒有工作擁有者的帳號",
+                "msg/the mappers emitted more than 8 MB; narrow the dataset or emit fewer values": "mapper 輸出超過 8 MB;請縮小資料集或減少輸出的值",
+                "msg/the user root itself cannot be a cluster volume; pick a sub folder such as /cluster": "使用者根目錄本身不能作為叢集儲存卷;請選擇子資料夾,例如 /cluster",
+                "msg/this folder is already a cluster volume": "此資料夾已經是叢集儲存卷",
+                "msg/this job script defines no run(job) function": "此工作腳本沒有定義 run(job) 函式",
+                "msg/this node cannot execute jobs": "此節點無法執行工作",
+                "msg/this node has no advertised URL; generate the join token on a node that other nodes can reach": "此節點沒有對外網址;請在其他節點可以連線的節點上產生加入權杖",
+                "msg/this node is already part of a cluster, leave it first": "此節點已加入叢集,請先離開",
+                "msg/this node is not part of a cluster": "此節點尚未加入叢集",
+                "msg/this node is not the identity origin of the cluster": "此節點不是叢集的身分來源",
+                "msg/this node is not the metadata leader": "此節點不是中繼資料的主導節點",
+                "msg/timed out waiting for the job to finish": "等待工作完成逾時",
+                "msg/token id required": "必須提供權杖 ID",
+                "msg/token not found": "找不到權杖",
+                "msg/tunnel closed": "通道已關閉",
+                "msg/unknown upload session": "未知的上傳工作階段",
+                "msg/unreachable": "無法連線",
+                "msg/unsupported node state": "不支援的節點狀態",
+                "msg/upload incomplete": "上傳未完成",
+                "msg/url required": "必須輸入網址",
+                "msg/use leave to remove this node": "要移除此節點請使用「離開叢集」",
+                "msg/user assertion expired": "使用者聲明已過期",
+                "msg/user assertion invalid": "使用者聲明無效",
+                "msg/user not found on the identity origin": "身分來源上找不到此使用者",
+                "msg/volume id required": "必須提供儲存卷 ID",
+                "msg/volume is not on this node": "儲存卷不在此節點上",
+                "msg/volume not found": "找不到儲存卷",
+                "msg/volume not found on this node": "此節點上找不到儲存卷",
+                "msgp/architecture {0} not allowed": "不允許的架構 {0}",
+                "msgp/cancelled by {0}": "由 {0} 取消",
+                "msgp/could not assemble the reduce input: {0}": "無法組合 reduce 的輸入:{0}",
+                "msgp/could not create a map task: {0}": "無法建立 map 子工作:{0}",
+                "msgp/could not create the reduce task: {0}": "無法建立 reduce 子工作:{0}",
+                "msgp/drive {0} is not a local drive on this node": "磁碟 {0} 不是此節點上的本機磁碟",
+                "msgp/hook script not found: {0}": "找不到掛鉤腳本:{0}",
+                "msgp/invalid join response from {0}": "{0} 的加入回應無效",
+                "msgp/invalid node key encoding: {0}": "節點金鑰編碼無效:{0}",
+                "msgp/invalid public key encoding: {0}": "公開金鑰編碼無效:{0}",
+                "msgp/job timed out after {0}s": "工作在 {0} 秒後逾時",
+                "msgp/local drive {0} is not mounted": "本機磁碟 {0} 未掛載",
+                "msgp/missing feature {0}": "缺少功能 {0}",
+                "msgp/no cluster volume available; add one in System Settings > Cluster Settings > Storage ({0})": "沒有可用的叢集儲存卷;請在「系統設定 > 叢集設定 > 儲存空間」中加入({0})",
+                "msgp/no reachable copy of: {0}": "無法連線到下列檔案的副本:{0}",
+                "msgp/node {0} could not take the job: {1}": "節點 {0} 無法接收此工作:{1}",
+                "msgp/os {0} not allowed": "不允許的作業系統 {0}",
+                "msgp/permission denied: {0}": "權限不足:{0}",
+                "msgp/remote node returned {0}: {1}": "遠端節點回傳 {0}:{1}",
+                "msgp/script not found: {0}": "找不到腳本:{0}",
+                "msgp/this volume holds the only copy of {0} file(s); evacuate it first": "此儲存卷持有 {0} 個檔案的唯一副本;請先撤離",
+                "msgp/unable to reach {0}: {1}": "無法連線到 {0}:{1}",
+                "msgp/unreachable: {0}": "無法連線:{0}",
+                "msgp/unsupported node URL scheme: {0}": "不支援的節點網址協定:{0}",
+                "msgp/weight {0} must be a number": "權重 {0} 必須是數字",
+                "msgp/weight {0} must be between 0 and 1": "權重 {0} 必須介於 0 與 1 之間",
+                "msgp/{0} produced an unreadable result": "{0} 產生了無法讀取的結果"
+            },
+            "placeholder": {
+                "Name (optional)": "名稱(選填)",
+                "job id": "工作 ID",
+                "Nightly index": "每晚索引",
+                "50 per task": "每個子工作 50 個"
+            },
+            "titles": {
+                "Re-detect capabilities": "重新偵測功能"
+            }
+        },
+        "zh-hk": {
+            "name": "繁體中文(香港)",
+            "fontFamily": "\"Microsoft JhengHei\",\"SimHei\", \"Apple LiGothic Medium\", \"STHeiti\"",
+            "strings": {
+                "cluster/ago/d": "{0} 天前",
+                "cluster/ago/h": "{0} 小時前",
+                "cluster/ago/m": "{0} 分鐘前",
+                "cluster/ago/s": "{0} 秒前",
+                "cluster/auto": "自動(任一可連線的成員)",
+                "cluster/cancel": "取消",
+                "cluster/caps": "功能",
+                "cluster/cfg/insecure": "接受其他節點的自簽憑證(僅限區域網路)",
+                "cluster/cfg/name": "節點名稱",
+                "cluster/cfg/test": "測試",
+                "cluster/cfg/tunnel": "經由通道連線",
+                "cluster/cfg/url": "對外網址(其他節點連線到此節點的方式)",
+                "cluster/cfg/urlhint": "若此節點沒有公開位址請留空。它會改為對一個可連線的成員保持通道連線,這也能穿越 Cloudflare。",
+                "cluster/clusterid": "叢集 ID",
+                "cluster/confirmleave": "要離開叢集嗎?之後將無法從這裡存取其他節點上的檔案與工作。",
+                "cluster/confirmremove": "要移除節點「{0}」嗎?",
+                "cluster/copied": "已將權杖複製到剪貼簿",
+                "cluster/copy": "複製",
+                "cluster/coresvalue": "{0} 核心",
+                "cluster/create": "建立新叢集",
+                "cluster/createbtn": "建立",
+                "cluster/created": "建立時間",
+                "cluster/createhint": "此節點會成為第一個成員。之後在這裡產生加入權杖以加入更多節點。",
+                "cluster/direct": "直連",
+                "cluster/disk": "磁碟",
+                "cluster/diskfree": "剩餘 {0},共 {1}",
+                "cluster/drain": "排空",
+                "cluster/entername": "請輸入叢集名稱",
+                "cluster/entertoken": "請貼上加入權杖",
+                "cluster/enterurl": "請輸入要測試的網址",
+                "cluster/expiresat": "於 {0} 到期",
+                "cluster/folder": "資料夾",
+                "cluster/freeamount": "剩餘 {0}",
+                "cluster/gentoken": "產生加入權杖",
+                "cluster/id/accounts": "帳號",
+                "cluster/id/apply": "套用",
+                "cluster/id/conflicts": "僅限本機",
+                "cluster/id/conflictswhy": "同名的本機帳號會維持原狀",
+                "cluster/id/disabled": "停用(每個節點各自驗證自己的帳號)",
+                "cluster/id/fallback": "無法連線到身分來源,已改用本機帳號",
+                "cluster/id/forward": "轉送",
+                "cluster/id/hint": "身分來源會驗證叢集中的每一次登入。帳號會複製到其他節點,因此在身分來源無法連線時仍可使用。群組以名稱對應,請在每個節點上建立相同的權限群組。",
+                "cluster/id/lastlogin": "上次登入檢查",
+                "cluster/id/lastsync": "上次同步",
+                "cluster/id/local": "每個節點以自己的帳號驗證登入",
+                "cluster/id/mirrors": "{1} 個本機帳號中有 {0} 個與身分來源同步",
+                "cluster/id/mode": "模式",
+                "cluster/id/origin": "來源",
+                "cluster/id/originhere": "此節點負責驗證整個叢集的登入",
+                "cluster/id/published": "已發布 {0} 個給成員",
+                "cluster/id/replicated": "已複製",
+                "cluster/id/skipped": "已略過",
+                "cluster/id/skippedwhy": "此節點上沒有他們所屬的任何群組",
+                "cluster/id/sync": "立即同步",
+                "cluster/id/synced": "帳號已同步",
+                "cluster/id/title": "身分(單一登入)",
+                "cluster/id/verifiedby": "登入由 {0} 驗證",
+                "cluster/info/novolume": "叢集尚未設定儲存空間。",
+                "cluster/info/novolumehint": "在叢集設定中加入儲存卷,cluster:/ 磁碟才會出現在檔案管理器中。",
+                "cluster/info/standalone": "此節點尚未加入叢集。",
+                "cluster/info/standalonehint": "請在叢集設定中建立或加入叢集。在此之前 cluster:/ 磁碟會保持隱藏。",
+                "cluster/info/tagline": "此節點、其他節點、命名空間與排程器的狀態",
+                "cluster/info/title": "叢集資訊",
+                "cluster/invite": "加入節點",
+                "cluster/jobs/args": "參數(JSON,選填)",
+                "cluster/jobs/attemptn": "第 {0} 次嘗試",
+                "cluster/jobs/browse": "瀏覽",
+                "cluster/jobs/cancel": "取消",
+                "cluster/jobs/complete": "已完成 {0} / {1}",
+                "cluster/jobs/dataset": "資料集",
+                "cluster/jobs/datasethint": "使用資料集時,腳本必須定義 mapper(files, emit) 與 reducer(key, values);檔案會在持有它們的節點上處理。",
+                "cluster/jobs/datasetlabel": "Map/reduce 資料集(選填的萬用字元;會將此工作變成 map/reduce 工作)",
+                "cluster/jobs/detail": "工作詳細資料",
+                "cluster/jobs/duration": "耗時",
+                "cluster/jobs/failedn": "{0} 個失敗",
+                "cluster/jobs/features": "必要功能",
+                "cluster/jobs/inputs": "輸入路徑(以逗號分隔,用於在資料附近執行)",
+                "cluster/jobs/job": "工作",
+                "cluster/jobs/jobs": "工作",
+                "cluster/jobs/kind/map": "map 子工作",
+                "cluster/jobs/kind/mapreduce": "map/reduce",
+                "cluster/jobs/kind/reduce": "reduce 子工作",
+                "cluster/jobs/log": "紀錄",
+                "cluster/jobs/minsecs": "{0} 分 {1} 秒",
+                "cluster/jobs/name": "名稱",
+                "cluster/jobs/needscript": "必須指定腳本路徑",
+                "cluster/jobs/node": "節點",
+                "cluster/jobs/nodetail": "選擇一個工作以查看其紀錄與輸出",
+                "cluster/jobs/none": "尚無工作",
+                "cluster/jobs/output": "輸出",
+                "cluster/jobs/owner": "擁有者",
+                "cluster/jobs/progress": "進度",
+                "cluster/jobs/queued": "工作已排入佇列",
+                "cluster/jobs/run": "執行工作",
+                "cluster/jobs/scheduler": "排程器:{0}",
+                "cluster/jobs/script": "腳本",
+                "cluster/jobs/scriptlabel": "腳本(定義 run(job) 的 .agi 檔案)",
+                "cluster/jobs/secs": "{0} 秒",
+                "cluster/jobs/slots": "此節點 {0}/{1} 個執行槽忙碌中",
+                "cluster/jobs/status": "狀態",
+                "cluster/jobs/submit": "提交工作",
+                "cluster/jobs/submithint": "腳本會以你的權限在選定的節點上執行。cluster:/ 路徑照常可用。",
+                "cluster/jobs/summary": "{0} 個執行中,{1} 個排隊中,{2} 個已完成",
+                "cluster/jobs/tagline": "在最適合的節點上執行腳本",
+                "cluster/jobs/tasks": "子工作",
+                "cluster/jobs/timeout": "逾時(秒)",
+                "cluster/jobs/title": "叢集工作",
+                "cluster/jobs/typepath": "請輸入腳本路徑,例如 user:/jobs/index.agi",
+                "cluster/jobs/waiting": "等待原因",
+                "cluster/join": "加入現有叢集",
+                "cluster/joinbtn": "加入",
+                "cluster/joinhint": "貼上在叢集中可連線成員上產生的加入權杖。",
+                "cluster/joining": "正在加入...",
+                "cluster/justnow": "剛剛",
+                "cluster/key": "節點金鑰",
+                "cluster/lastseen": "最後連線",
+                "cluster/leave": "離開叢集",
+                "cluster/link/info": "叢集資訊",
+                "cluster/link/jobs": "叢集工作",
+                "cluster/link/settings": "叢集設定",
+                "cluster/load": "負載",
+                "cluster/maintenance": "維護",
+                "cluster/members": "成員",
+                "cluster/membershint": "維護模式會讓節點退出排程。排空還會把它的副本移到其他節點,之後即可安全關機。",
+                "cluster/meta/entries": "項目",
+                "cluster/meta/entriesvalue": "{0} 個檔案、{1} 個資料夾、{2} 個儲存卷",
+                "cluster/meta/leader": "主導節點",
+                "cluster/meta/log": "紀錄",
+                "cluster/meta/noleader": "選舉中",
+                "cluster/meta/pending": "{0} 筆待處理",
+                "cluster/meta/term": "任期 {0}",
+                "cluster/natonly": "無網址",
+                "cluster/never": "從未",
+                "cluster/node": "節點",
+                "cluster/nodes": "節點",
+                "cluster/nonodes": "沒有節點",
+                "cluster/noreport": "尚無回報",
+                "cluster/noroute": "無路徑",
+                "cluster/notmember": "尚未加入叢集",
+                "cluster/notunnel": "無通道",
+                "cluster/now": "現在",
+                "cluster/platform": "平台",
+                "cluster/platformline": "{0} / {1},{2} 核心",
+                "cluster/pol/1": "1 份副本",
+                "cluster/pol/2": "2 份副本",
+                "cluster/pol/3": "3 份副本",
+                "cluster/pol/4": "4 份副本",
+                "cluster/pol/copies": "{0} 份副本",
+                "cluster/pol/hint": "沒有設定原則的資料夾只保留一份副本。額外的副本由複製服務建立。",
+                "cluster/pol/label": "每個最上層資料夾的副本數",
+                "cluster/pol/set": "設定",
+                "cluster/pol/title": "副本原則",
+                "cluster/probe": "探測",
+                "cluster/probefail": "{0}:{1}({2})",
+                "cluster/probeok": "{0}:{1} 毫秒({2})",
+                "cluster/reach": "連線方式",
+                "cluster/reachableok": "可連線,往返 {0} 毫秒",
+                "cluster/redetect": "重新偵測",
+                "cluster/remove": "移除",
+                "cluster/repl/evacuating": "{0} 個儲存卷撤離中",
+                "cluster/repl/files": "檔案",
+                "cluster/repl/fromto": "{0} 到 {1}",
+                "cluster/repl/healthy": "所有檔案皆符合原則",
+                "cluster/repl/inflight": "進行中",
+                "cluster/repl/inflightvalue": "{0} 個執行中,{1} 個排隊中",
+                "cluster/repl/inspect": "檢查檔案",
+                "cluster/repl/inspectbtn": "顯示副本",
+                "cluster/repl/issues": "{0} 個問題",
+                "cluster/repl/lastrun": "上次執行 {0}",
+                "cluster/repl/nocopy": "無健康副本",
+                "cluster/repl/nolocations": "沒有副本紀錄",
+                "cluster/repl/notasks": "沒有複製工作",
+                "cluster/repl/notfound": "在命名空間中找不到此路徑",
+                "cluster/repl/over": "多餘副本",
+                "cluster/repl/plan": "立即修復",
+                "cluster/repl/planned": "規劃器已執行:{0} 個排入,{1} 個刪除,{2} 個標記為過期",
+                "cluster/repl/planner": "規劃器",
+                "cluster/repl/primary": "主要",
+                "cluster/repl/reason/evacuate": "撤離",
+                "cluster/repl/reason/repair": "修復",
+                "cluster/repl/reason/replica": "新增副本",
+                "cluster/repl/route": "來源 / 目的地",
+                "cluster/repl/stale": "過期副本",
+                "cluster/repl/task": "工作",
+                "cluster/repl/title": "副本複製",
+                "cluster/repl/under": "副本不足",
+                "cluster/repl/verified": "已驗證 {0} 份副本({1}),{2} 份過期",
+                "cluster/repl/verify": "驗證此節點",
+                "cluster/requestfailed": "要求失敗",
+                "cluster/resume": "恢復",
+                "cluster/revoke": "撤銷",
+                "cluster/route": "路徑",
+                "cluster/route/direct": "直連",
+                "cluster/route/none": "無路徑",
+                "cluster/route/relay": "經由 {0} 轉送",
+                "cluster/route/tunnel": "通道",
+                "cluster/save": "儲存",
+                "cluster/saved": "已儲存",
+                "cluster/sched/cpu": "CPU 閒置",
+                "cluster/sched/cpu/hint": "優先選擇閒置的處理器",
+                "cluster/sched/degraded": "健康狀態",
+                "cluster/sched/degraded/hint": "避開效能不佳的節點",
+                "cluster/sched/disk": "可用磁碟",
+                "cluster/sched/disk/hint": "優先選擇有成長空間的節點",
+                "cluster/sched/diskfree": "磁碟剩餘",
+                "cluster/sched/diversity": "站點分散",
+                "cluster/sched/diversity/hint": "把第二份副本放在離第一份較遠的地方,這樣某個站點停擺時不會遺失資料",
+                "cluster/sched/explain": "工作為何被分配到那裡?",
+                "cluster/sched/explainbtn": "說明",
+                "cluster/sched/explainhint": "從叢集工作頁面複製工作 ID,即可查看分配時的排名。",
+                "cluster/sched/factor/cpu free": "CPU 閒置",
+                "cluster/sched/factor/degraded": "效能降低",
+                "cluster/sched/factor/disk free": "磁碟剩餘",
+                "cluster/sched/factor/locality": "資料位置",
+                "cluster/sched/factor/memory free": "記憶體剩餘",
+                "cluster/sched/factor/network distance": "網路距離",
+                "cluster/sched/factor/preferred features": "偏好功能",
+                "cluster/sched/factor/queue depth": "佇列長度",
+                "cluster/sched/factor/site diversity": "站點分散",
+                "cluster/sched/features": "{0} 項功能",
+                "cluster/sched/from": "來源 / 目的地",
+                "cluster/sched/here": "本機",
+                "cluster/sched/hint": "工作、新檔案與額外副本會交給分數最高的節點。調高權重可讓該項特質更重要。實際分數顯示在叢集資訊頁面。",
+                "cluster/sched/infohint": "依叢集設定中的權重,各節點目前的分數。工作、新檔案與額外副本會交給分數最高者。",
+                "cluster/sched/latency": "網路距離",
+                "cluster/sched/latency/hint": "避開距離資料太遠的節點",
+                "cluster/sched/locality": "資料位置",
+                "cluster/sched/locality/hint": "優先選擇已持有資料的節點",
+                "cluster/sched/matrixhint": "每對節點之間的往返時間(毫秒)。檔案的副本會分散到距離最遠的節點上,因為距離通常代表不同的站點。",
+                "cluster/sched/mem": "記憶體剩餘",
+                "cluster/sched/memory": "可用記憶體",
+                "cluster/sched/memory/hint": "優先選擇有多餘記憶體的節點",
+                "cluster/sched/next": "下一個工作將在 {0} 執行",
+                "cluster/sched/nojob": "此叢集中沒有這個工作",
+                "cluster/sched/nonodes": "沒有可評分的節點",
+                "cluster/sched/ping": "往返時間",
+                "cluster/sched/preferred": "偏好功能",
+                "cluster/sched/preferred/hint": "優先選擇具備工作所要求之選用功能的節點",
+                "cluster/sched/queue": "佇列長度",
+                "cluster/sched/queue/hint": "避開已經忙碌的節點",
+                "cluster/sched/reset": "還原預設值",
+                "cluster/sched/restored": "已還原預設權重",
+                "cluster/sched/save": "儲存權重",
+                "cluster/sched/saved": "權重已套用到整個叢集",
+                "cluster/sched/score": "分數",
+                "cluster/sched/skipped": "略過",
+                "cluster/sched/title": "排程",
+                "cluster/sched/unmeasured": "尚未測量",
+                "cluster/settings/tagline": "設定此節點,以及叢集如何儲存檔案與執行工作",
+                "cluster/settings/title": "叢集設定",
+                "cluster/state": "狀態",
+                "cluster/state/cancelled": "已取消",
+                "cluster/state/committed": "已寫入",
+                "cluster/state/degraded": "效能降低",
+                "cluster/state/done": "完成",
+                "cluster/state/draining": "排空中",
+                "cluster/state/failed": "失敗",
+                "cluster/state/maintenance": "維護中",
+                "cluster/state/offline": "離線",
+                "cluster/state/online": "線上",
+                "cluster/state/pending": "待處理",
+                "cluster/state/queued": "排隊中",
+                "cluster/state/running": "執行中",
+                "cluster/state/scheduled": "已排程",
+                "cluster/state/stale": "過期",
+                "cluster/state/succeeded": "成功",
+                "cluster/state/unknown": "未知",
+                "cluster/state/verified": "已驗證",
+                "cluster/state/writing": "寫入中",
+                "cluster/storage": "儲存空間",
+                "cluster/testing": "測試中...",
+                "cluster/thisnode": "此節點",
+                "cluster/tokenhint": "只有設定了對外網址的節點才能產生權杖。將權杖貼到另一個節點的叢集設定中。",
+                "cluster/tokenline": "於 {0} 到期 · 已使用 {1} 次",
+                "cluster/ttl/1h": "1 小時",
+                "cluster/ttl/24h": "24 小時",
+                "cluster/ttl/7d": "7 天",
+                "cluster/tunnel": "通道",
+                "cluster/tunnelhere": "通道連到此節點",
+                "cluster/tunnelvia": "經由 {0} 的通道",
+                "cluster/unreachable": "無法連線",
+                "cluster/updated": "更新於 {0}",
+                "cluster/uptime": "運作時間",
+                "cluster/uptimevalue": "{0} 小時 {1} 分",
+                "cluster/version": "版本",
+                "cluster/via": "經由 {0}",
+                "cluster/vol/add": "提供此節點上的資料夾",
+                "cluster/vol/addbtn": "加入儲存卷",
+                "cluster/vol/added": "已加入儲存卷",
+                "cluster/vol/autoro": "自動將快滿的儲存卷設為唯讀",
+                "cluster/vol/autorohint": "套用到每個節點。剩餘空間低於 5% 的儲存卷會停止接收新檔案,高於 7% 時再恢復。關閉此項可讓儲存卷用到滿;先前因此被鎖定的儲存卷會在一分鐘內恢復可寫入。",
+                "cluster/vol/autorooff": "儲存卷現在可以完全用滿",
+                "cluster/vol/autoroon": "快滿的儲存卷將會設為唯讀",
+                "cluster/vol/confirmevac": "要將「{0}」上的所有檔案移走嗎?此儲存卷會變成唯讀,並在所有副本都存在於別處後退役。",
+                "cluster/vol/confirmremove": "要停止提供「{0}」嗎?檔案會留在磁碟上,但只存在那裡的副本將無法使用。",
+                "cluster/vol/evacstarted": "已開始撤離:{0} 個檔案待移動",
+                "cluster/vol/evacuate": "撤離",
+                "cluster/vol/evacuating": "撤離中",
+                "cluster/vol/files": "檔案",
+                "cluster/vol/full": "快滿",
+                "cluster/vol/hint": "寫入 cluster:/ 的檔案會以一般檔案存放在提供的資料夾中。資料夾中原有的檔案會在下次重新掃描時納入命名空間。叢集至少有一個儲存卷後,cluster:/ 磁碟才會出現在檔案管理器中。",
+                "cluster/vol/none": "尚無儲存卷。加入此節點上的資料夾即可開始在 cluster:/ 中存放檔案",
+                "cluster/vol/readonly": "設為唯讀",
+                "cluster/vol/rescan": "重新掃描此節點",
+                "cluster/vol/rescanned": "重新掃描完成:納入 {0} 個,過期 {1} 個",
+                "cluster/vol/ro": "唯讀",
+                "cluster/vol/space": "剩餘 / 總容量",
+                "cluster/vol/summary": "{0} 個儲存卷,剩餘 {1}",
+                "cluster/vol/volume": "儲存卷",
+                "cluster/vol/writable": "設為可寫入",
+                "cluster/waitinghost": "正在等待可連線的成員",
+                "cluster/when": "時間",
+                "cluster/willtunnel": "加入叢集後將透過成員建立通道",
+                "cluster/you": "你",
+                "msg/AGI gateway not ready": "AGI 閘道尚未就緒",
+                "msg/Invalid MAC address": "MAC 位址無效",
+                "msg/POST required": "必須使用 POST",
+                "msg/URL must start with http:// or https://": "網址必須以 http:// 或 https:// 開頭",
+                "msg/a job id is required": "必須提供工作 ID",
+                "msg/a map/reduce job needs a dataset pattern": "map/reduce 工作需要資料集樣式",
+                "msg/a map/reduce script must define mapper(files, emit) and reducer(key, values)": "map/reduce 腳本必須定義 mapper(files, emit) 與 reducer(key, values)",
+                "msg/advertise URL must start with http:// or https://": "對外網址必須以 http:// 或 https:// 開頭",
+                "msg/args must be valid JSON": "參數必須是有效的 JSON",
+                "msg/cannot remove the namespace root": "無法移除命名空間的根目錄",
+                "msg/checksum mismatch": "檢查碼不符",
+                "msg/chunk checksum mismatch": "區塊檢查碼不符",
+                "msg/chunk exceeds file size": "區塊超過檔案大小",
+                "msg/chunk index out of range": "區塊索引超出範圍",
+                "msg/chunk size invalid": "區塊大小無效",
+                "msg/cluster jobs not available": "叢集工作無法使用",
+                "msg/cluster name cannot be empty": "叢集名稱不可為空",
+                "msg/cluster name required": "必須輸入叢集名稱",
+                "msg/directory not empty": "資料夾不是空的",
+                "msg/disk usage not supported on this platform": "此平台不支援磁碟使用量查詢",
+                "msg/drive required": "必須選擇磁碟",
+                "msg/empty response": "回應是空的",
+                "msg/enabled must be true or false": "enabled 必須是 true 或 false",
+                "msg/event bus not available": "事件匯流排無法使用",
+                "msg/event data too large (64 KB max)": "事件資料太大(上限 64 KB)",
+                "msg/event type required": "必須提供事件類型",
+                "msg/evict notice is not for this node": "移除通知不是給此節點的",
+                "msg/file changed since the copy was planned": "檔案在規劃複製後已變更",
+                "msg/file no longer exists in the namespace": "檔案已不存在於命名空間中",
+                "msg/file not found on volume": "儲存卷上找不到檔案",
+                "msg/folder path required": "必須輸入資料夾路徑",
+                "msg/folder required": "必須輸入資料夾",
+                "msg/hook not found": "找不到掛鉤",
+                "msg/insufficient cpu cores": "CPU 核心不足",
+                "msg/insufficient memory": "記憶體不足",
+                "msg/invalid append payload": "無效的附加資料",
+                "msg/invalid batch": "無效的批次",
+                "msg/invalid begin request": "無效的開始要求",
+                "msg/invalid entry": "無效的項目",
+                "msg/invalid heartbeat": "無效的心跳",
+                "msg/invalid job spec": "無效的工作設定",
+                "msg/invalid join request": "無效的加入要求",
+                "msg/invalid lease": "無效的租約",
+                "msg/invalid node URL": "節點網址無效",
+                "msg/invalid node public key": "節點公開金鑰無效",
+                "msg/invalid node record": "無效的節點紀錄",
+                "msg/invalid range": "無效的範圍",
+                "msg/invalid rename": "無效的重新命名",
+                "msg/invalid report": "無效的回報",
+                "msg/invalid request": "無效的要求",
+                "msg/invalid sync payload": "無效的同步資料",
+                "msg/invalid task": "無效的工作",
+                "msg/invalid username": "使用者名稱無效",
+                "msg/is a directory": "這是資料夾",
+                "msg/job cancelled": "工作已取消",
+                "msg/job id required": "必須提供工作 ID",
+                "msg/job is limited to other nodes": "此工作只能在其他節點上執行",
+                "msg/job not found": "找不到工作",
+                "msg/job owner is required": "必須提供工作擁有者",
+                "msg/job script is empty": "工作腳本是空的",
+                "msg/job timed out": "工作逾時",
+                "msg/join response does not match the token's cluster": "加入回應與權杖所屬的叢集不符",
+                "msg/join token has an invalid node URL": "加入權杖中的節點網址無效",
+                "msg/join token is corrupted": "加入權杖已損毀",
+                "msg/join token is for another cluster": "此加入權杖屬於另一個叢集",
+                "msg/join token is incomplete": "加入權杖不完整",
+                "msg/join token is invalid or expired": "加入權杖無效或已過期",
+                "msg/join token required": "必須提供加入權杖",
+                "msg/metadata store not available": "中繼資料儲存區無法使用",
+                "msg/no cluster volume available; add one in System Settings > Cluster Settings > Storage": "沒有可用的叢集儲存卷;請在「系統設定 > 叢集設定 > 儲存空間」中加入",
+                "msg/no healthy copy of this file is reachable right now": "目前無法連線到此檔案的任何健康副本",
+                "msg/no node is available": "沒有可用的節點",
+                "msg/node already has enough copies in flight": "此節點已有足夠的複製正在進行",
+                "msg/node id is required": "必須提供節點 ID",
+                "msg/node id required": "必須提供節點 ID",
+                "msg/node is DEGRADED": "節點效能降低",
+                "msg/node is DRAINING": "節點排空中",
+                "msg/node is MAINTENANCE": "節點維護中",
+                "msg/node is OFFLINE": "節點已離線",
+                "msg/node is UNKNOWN": "節點狀態不明",
+                "msg/node is degraded": "節點效能降低",
+                "msg/node is draining": "節點排空中",
+                "msg/node is maintenance": "節點維護中",
+                "msg/node is not available": "節點無法使用",
+                "msg/node is offline": "節點已離線",
+                "msg/node is unknown": "節點狀態不明",
+                "msg/node lost while running the job": "執行工作時節點失去連線",
+                "msg/node name cannot be empty": "節點名稱不可為空",
+                "msg/node not found": "找不到節點",
+                "msg/node refused this job earlier": "此節點先前拒絕了這個工作",
+                "msg/not a directory": "不是資料夾",
+                "msg/not a file": "不是檔案",
+                "msg/not an ArozOS join token": "這不是 ArozOS 加入權杖",
+                "msg/not in a cluster": "未加入叢集",
+                "msg/not logged in": "尚未登入",
+                "msg/offset beyond end of file": "位移超出檔案結尾",
+                "msg/only the metadata leader assigns jobs": "只有中繼資料主導節點可以分配工作",
+                "msg/only the metadata leader assigns replication tasks": "只有中繼資料主導節點可以分配複製工作",
+                "msg/owner, script and at least one event type are required": "必須提供擁有者、腳本與至少一種事件類型",
+                "msg/path already exists": "路徑已存在",
+                "msg/path escapes the volume": "路徑超出了儲存卷範圍",
+                "msg/path not found in the cluster namespace": "在叢集命名空間中找不到此路徑",
+                "msg/path required": "必須輸入路徑",
+                "msg/peer has no reachable endpoint": "對方節點沒有可連線的端點",
+                "msg/permission denied": "權限不足",
+                "msg/previous node stopped reporting": "先前的節點停止回報",
+                "msg/refusing to delete the volume root": "拒絕刪除儲存卷根目錄",
+                "msg/refusing to drop the last healthy copy": "拒絕刪除最後一份健康副本",
+                "msg/replicas must be between 0 and 16": "副本數必須介於 0 到 16 之間",
+                "msg/replicas must be between 1 and 16": "副本數必須介於 1 到 16 之間",
+                "msg/request is not signed": "要求未簽署",
+                "msg/request signature invalid": "要求的簽章無效",
+                "msg/request targets another cluster": "要求的目標是另一個叢集",
+                "msg/request timestamp outside the accepted window": "要求的時間戳記超出允許範圍(請檢查節點的時鐘)",
+                "msg/requested log range was compacted, take a snapshot": "要求的紀錄範圍已被壓縮,請改用快照",
+                "msg/script path required": "必須提供腳本路徑",
+                "msg/sender is not a member of this cluster": "傳送者不是此叢集的成員",
+                "msg/shutting down": "正在關閉",
+                "msg/source copy is not readable": "來源副本無法讀取",
+                "msg/target node is not connected through a tunnel on this node": "目標節點沒有透過此節點的通道連線",
+                "msg/task is for another node": "此工作屬於另一個節點",
+                "msg/the URL answered but it is not an ArozOS cluster endpoint": "此網址有回應,但不是 ArozOS 叢集端點",
+                "msg/the job has no dataset pattern": "此工作沒有資料集樣式",
+                "msg/the job owner has no account on this node": "此節點上沒有工作擁有者的帳號",
+                "msg/the mappers emitted more than 8 MB; narrow the dataset or emit fewer values": "mapper 輸出超過 8 MB;請縮小資料集或減少輸出的值",
+                "msg/the user root itself cannot be a cluster volume; pick a sub folder such as /cluster": "使用者根目錄本身不能作為叢集儲存卷;請選擇子資料夾,例如 /cluster",
+                "msg/this folder is already a cluster volume": "此資料夾已經是叢集儲存卷",
+                "msg/this job script defines no run(job) function": "此工作腳本沒有定義 run(job) 函式",
+                "msg/this node cannot execute jobs": "此節點無法執行工作",
+                "msg/this node has no advertised URL; generate the join token on a node that other nodes can reach": "此節點沒有對外網址;請在其他節點可以連線的節點上產生加入權杖",
+                "msg/this node is already part of a cluster, leave it first": "此節點已加入叢集,請先離開",
+                "msg/this node is not part of a cluster": "此節點尚未加入叢集",
+                "msg/this node is not the identity origin of the cluster": "此節點不是叢集的身分來源",
+                "msg/this node is not the metadata leader": "此節點不是中繼資料的主導節點",
+                "msg/timed out waiting for the job to finish": "等待工作完成逾時",
+                "msg/token id required": "必須提供權杖 ID",
+                "msg/token not found": "找不到權杖",
+                "msg/tunnel closed": "通道已關閉",
+                "msg/unknown upload session": "未知的上傳工作階段",
+                "msg/unreachable": "無法連線",
+                "msg/unsupported node state": "不支援的節點狀態",
+                "msg/upload incomplete": "上傳未完成",
+                "msg/url required": "必須輸入網址",
+                "msg/use leave to remove this node": "要移除此節點請使用「離開叢集」",
+                "msg/user assertion expired": "使用者聲明已過期",
+                "msg/user assertion invalid": "使用者聲明無效",
+                "msg/user not found on the identity origin": "身分來源上找不到此使用者",
+                "msg/volume id required": "必須提供儲存卷 ID",
+                "msg/volume is not on this node": "儲存卷不在此節點上",
+                "msg/volume not found": "找不到儲存卷",
+                "msg/volume not found on this node": "此節點上找不到儲存卷",
+                "msgp/architecture {0} not allowed": "不允許的架構 {0}",
+                "msgp/cancelled by {0}": "由 {0} 取消",
+                "msgp/could not assemble the reduce input: {0}": "無法組合 reduce 的輸入:{0}",
+                "msgp/could not create a map task: {0}": "無法建立 map 子工作:{0}",
+                "msgp/could not create the reduce task: {0}": "無法建立 reduce 子工作:{0}",
+                "msgp/drive {0} is not a local drive on this node": "磁碟 {0} 不是此節點上的本機磁碟",
+                "msgp/hook script not found: {0}": "找不到掛鉤腳本:{0}",
+                "msgp/invalid join response from {0}": "{0} 的加入回應無效",
+                "msgp/invalid node key encoding: {0}": "節點金鑰編碼無效:{0}",
+                "msgp/invalid public key encoding: {0}": "公開金鑰編碼無效:{0}",
+                "msgp/job timed out after {0}s": "工作在 {0} 秒後逾時",
+                "msgp/local drive {0} is not mounted": "本機磁碟 {0} 未掛載",
+                "msgp/missing feature {0}": "缺少功能 {0}",
+                "msgp/no cluster volume available; add one in System Settings > Cluster Settings > Storage ({0})": "沒有可用的叢集儲存卷;請在「系統設定 > 叢集設定 > 儲存空間」中加入({0})",
+                "msgp/no reachable copy of: {0}": "無法連線到下列檔案的副本:{0}",
+                "msgp/node {0} could not take the job: {1}": "節點 {0} 無法接收此工作:{1}",
+                "msgp/os {0} not allowed": "不允許的作業系統 {0}",
+                "msgp/permission denied: {0}": "權限不足:{0}",
+                "msgp/remote node returned {0}: {1}": "遠端節點回傳 {0}:{1}",
+                "msgp/script not found: {0}": "找不到腳本:{0}",
+                "msgp/this volume holds the only copy of {0} file(s); evacuate it first": "此儲存卷持有 {0} 個檔案的唯一副本;請先撤離",
+                "msgp/unable to reach {0}: {1}": "無法連線到 {0}:{1}",
+                "msgp/unreachable: {0}": "無法連線:{0}",
+                "msgp/unsupported node URL scheme: {0}": "不支援的節點網址協定:{0}",
+                "msgp/weight {0} must be a number": "權重 {0} 必須是數字",
+                "msgp/weight {0} must be between 0 and 1": "權重 {0} 必須介於 0 與 1 之間",
+                "msgp/{0} produced an unreadable result": "{0} 產生了無法讀取的結果"
+            },
+            "placeholder": {
+                "Name (optional)": "名稱(選填)",
+                "job id": "工作 ID",
+                "Nightly index": "每晚索引",
+                "50 per task": "每個子工作 50 個"
+            },
+            "titles": {
+                "Re-detect capabilities": "重新偵測功能"
+            }
+        },
+        "zh-cn": {
+            "name": "简体中文",
+            "fontFamily": "\"Microsoft YaHei\",\"SimHei\", \"PingFangSC-Medium\", \"STHeiti\"",
+            "strings": {
+                "cluster/ago/d": "{0} 天前",
+                "cluster/ago/h": "{0} 小时前",
+                "cluster/ago/m": "{0} 分钟前",
+                "cluster/ago/s": "{0} 秒前",
+                "cluster/auto": "自动(任一可连接的成员)",
+                "cluster/cancel": "取消",
+                "cluster/caps": "功能",
+                "cluster/cfg/insecure": "接受其他节点的自签名证书(仅限局域网)",
+                "cluster/cfg/name": "节点名称",
+                "cluster/cfg/test": "测试",
+                "cluster/cfg/tunnel": "通过隧道连接",
+                "cluster/cfg/url": "对外网址(其他节点连接到此节点的方式)",
+                "cluster/cfg/urlhint": "如果此节点没有公网地址请留空。它会改为与一个可连接的成员保持隧道连接,这也能穿过 Cloudflare。",
+                "cluster/clusterid": "集群 ID",
+                "cluster/confirmleave": "要离开集群吗?之后将无法从这里访问其他节点上的文件和任务。",
+                "cluster/confirmremove": "要移除节点“{0}”吗?",
+                "cluster/copied": "已将令牌复制到剪贴板",
+                "cluster/copy": "复制",
+                "cluster/coresvalue": "{0} 核",
+                "cluster/create": "创建新集群",
+                "cluster/createbtn": "创建",
+                "cluster/created": "创建时间",
+                "cluster/createhint": "此节点将成为第一个成员。之后在这里生成加入令牌以添加更多节点。",
+                "cluster/direct": "直连",
+                "cluster/disk": "磁盘",
+                "cluster/diskfree": "剩余 {0},共 {1}",
+                "cluster/drain": "排空",
+                "cluster/entername": "请输入集群名称",
+                "cluster/entertoken": "请粘贴加入令牌",
+                "cluster/enterurl": "请输入要测试的网址",
+                "cluster/expiresat": "于 {0} 过期",
+                "cluster/folder": "文件夹",
+                "cluster/freeamount": "剩余 {0}",
+                "cluster/gentoken": "生成加入令牌",
+                "cluster/id/accounts": "账户",
+                "cluster/id/apply": "应用",
+                "cluster/id/conflicts": "仅本地",
+                "cluster/id/conflictswhy": "同名的本地账户会保持不变",
+                "cluster/id/disabled": "停用(每个节点各自验证自己的账户)",
+                "cluster/id/fallback": "无法连接到身份源,已改用本地账户",
+                "cluster/id/forward": "转发",
+                "cluster/id/hint": "身份源会验证集群中的每一次登录。账户会复制到其他节点,因此在身份源无法连接时仍可使用。用户组按名称对应,请在每个节点上创建相同的权限组。",
+                "cluster/id/lastlogin": "上次登录检查",
+                "cluster/id/lastsync": "上次同步",
+                "cluster/id/local": "每个节点以自己的账户验证登录",
+                "cluster/id/mirrors": "{1} 个本地账户中有 {0} 个与身份源同步",
+                "cluster/id/mode": "模式",
+                "cluster/id/origin": "源",
+                "cluster/id/originhere": "此节点负责验证整个集群的登录",
+                "cluster/id/published": "已发布 {0} 个给成员",
+                "cluster/id/replicated": "已复制",
+                "cluster/id/skipped": "已跳过",
+                "cluster/id/skippedwhy": "此节点上没有他们所属的任何用户组",
+                "cluster/id/sync": "立即同步",
+                "cluster/id/synced": "账户已同步",
+                "cluster/id/title": "身份(单点登录)",
+                "cluster/id/verifiedby": "登录由 {0} 验证",
+                "cluster/info/novolume": "集群尚未设置存储空间。",
+                "cluster/info/novolumehint": "在集群设置中添加存储卷,cluster:/ 磁盘才会出现在文件管理器中。",
+                "cluster/info/standalone": "此节点尚未加入集群。",
+                "cluster/info/standalonehint": "请在集群设置中创建或加入集群。在此之前 cluster:/ 磁盘会保持隐藏。",
+                "cluster/info/tagline": "此节点、其他节点、命名空间与调度器的状态",
+                "cluster/info/title": "集群信息",
+                "cluster/invite": "添加节点",
+                "cluster/jobs/args": "参数(JSON,可选)",
+                "cluster/jobs/attemptn": "第 {0} 次尝试",
+                "cluster/jobs/browse": "浏览",
+                "cluster/jobs/cancel": "取消",
+                "cluster/jobs/complete": "已完成 {0} / {1}",
+                "cluster/jobs/dataset": "数据集",
+                "cluster/jobs/datasethint": "使用数据集时,脚本必须定义 mapper(files, emit) 和 reducer(key, values);文件会在持有它们的节点上处理。",
+                "cluster/jobs/datasetlabel": "Map/reduce 数据集(可选的通配符;会将此任务变为 map/reduce 任务)",
+                "cluster/jobs/detail": "任务详情",
+                "cluster/jobs/duration": "耗时",
+                "cluster/jobs/failedn": "{0} 个失败",
+                "cluster/jobs/features": "必需功能",
+                "cluster/jobs/inputs": "输入路径(以逗号分隔,用于在数据附近运行)",
+                "cluster/jobs/job": "任务",
+                "cluster/jobs/jobs": "任务",
+                "cluster/jobs/kind/map": "map 子任务",
+                "cluster/jobs/kind/mapreduce": "map/reduce",
+                "cluster/jobs/kind/reduce": "reduce 子任务",
+                "cluster/jobs/log": "日志",
+                "cluster/jobs/minsecs": "{0} 分 {1} 秒",
+                "cluster/jobs/name": "名称",
+                "cluster/jobs/needscript": "必须指定脚本路径",
+                "cluster/jobs/node": "节点",
+                "cluster/jobs/nodetail": "选择一个任务以查看其日志和输出",
+                "cluster/jobs/none": "尚无任务",
+                "cluster/jobs/output": "输出",
+                "cluster/jobs/owner": "所有者",
+                "cluster/jobs/progress": "进度",
+                "cluster/jobs/queued": "任务已加入队列",
+                "cluster/jobs/run": "运行任务",
+                "cluster/jobs/scheduler": "调度器:{0}",
+                "cluster/jobs/script": "脚本",
+                "cluster/jobs/scriptlabel": "脚本(定义 run(job) 的 .agi 文件)",
+                "cluster/jobs/secs": "{0} 秒",
+                "cluster/jobs/slots": "此节点 {0}/{1} 个执行槽繁忙",
+                "cluster/jobs/status": "状态",
+                "cluster/jobs/submit": "提交任务",
+                "cluster/jobs/submithint": "脚本会以你的权限在选定的节点上运行。cluster:/ 路径照常可用。",
+                "cluster/jobs/summary": "{0} 个运行中,{1} 个排队中,{2} 个已完成",
+                "cluster/jobs/tagline": "在最合适的节点上运行脚本",
+                "cluster/jobs/tasks": "子任务",
+                "cluster/jobs/timeout": "超时(秒)",
+                "cluster/jobs/title": "集群任务",
+                "cluster/jobs/typepath": "请输入脚本路径,例如 user:/jobs/index.agi",
+                "cluster/jobs/waiting": "等待原因",
+                "cluster/join": "加入现有集群",
+                "cluster/joinbtn": "加入",
+                "cluster/joinhint": "粘贴在集群中可连接成员上生成的加入令牌。",
+                "cluster/joining": "正在加入...",
+                "cluster/justnow": "刚刚",
+                "cluster/key": "节点密钥",
+                "cluster/lastseen": "最后连接",
+                "cluster/leave": "离开集群",
+                "cluster/link/info": "集群信息",
+                "cluster/link/jobs": "集群任务",
+                "cluster/link/settings": "集群设置",
+                "cluster/load": "负载",
+                "cluster/maintenance": "维护",
+                "cluster/members": "成员",
+                "cluster/membershint": "维护模式会让节点退出调度。排空还会把它的副本移到其他节点,之后即可安全关机。",
+                "cluster/meta/entries": "条目",
+                "cluster/meta/entriesvalue": "{0} 个文件、{1} 个文件夹、{2} 个存储卷",
+                "cluster/meta/leader": "主节点",
+                "cluster/meta/log": "日志",
+                "cluster/meta/noleader": "选举中",
+                "cluster/meta/pending": "{0} 条待处理",
+                "cluster/meta/term": "任期 {0}",
+                "cluster/natonly": "无网址",
+                "cluster/never": "从未",
+                "cluster/node": "节点",
+                "cluster/nodes": "节点",
+                "cluster/nonodes": "没有节点",
+                "cluster/noreport": "尚无报告",
+                "cluster/noroute": "无路径",
+                "cluster/notmember": "尚未加入集群",
+                "cluster/notunnel": "无隧道",
+                "cluster/now": "现在",
+                "cluster/platform": "平台",
+                "cluster/platformline": "{0} / {1},{2} 核",
+                "cluster/pol/1": "1 份副本",
+                "cluster/pol/2": "2 份副本",
+                "cluster/pol/3": "3 份副本",
+                "cluster/pol/4": "4 份副本",
+                "cluster/pol/copies": "{0} 份副本",
+                "cluster/pol/hint": "没有设置策略的文件夹只保留一份副本。额外的副本由复制服务创建。",
+                "cluster/pol/label": "每个顶层文件夹的副本数",
+                "cluster/pol/set": "设置",
+                "cluster/pol/title": "副本策略",
+                "cluster/probe": "探测",
+                "cluster/probefail": "{0}:{1}({2})",
+                "cluster/probeok": "{0}:{1} 毫秒({2})",
+                "cluster/reach": "连接方式",
+                "cluster/reachableok": "可连接,往返 {0} 毫秒",
+                "cluster/redetect": "重新检测",
+                "cluster/remove": "移除",
+                "cluster/repl/evacuating": "{0} 个存储卷撤离中",
+                "cluster/repl/files": "文件",
+                "cluster/repl/fromto": "{0} 到 {1}",
+                "cluster/repl/healthy": "所有文件均符合策略",
+                "cluster/repl/inflight": "进行中",
+                "cluster/repl/inflightvalue": "{0} 个运行中,{1} 个排队中",
+                "cluster/repl/inspect": "检查文件",
+                "cluster/repl/inspectbtn": "显示副本",
+                "cluster/repl/issues": "{0} 个问题",
+                "cluster/repl/lastrun": "上次运行 {0}",
+                "cluster/repl/nocopy": "无健康副本",
+                "cluster/repl/nolocations": "没有副本记录",
+                "cluster/repl/notasks": "没有复制任务",
+                "cluster/repl/notfound": "在命名空间中找不到此路径",
+                "cluster/repl/over": "多余副本",
+                "cluster/repl/plan": "立即修复",
+                "cluster/repl/planned": "规划器已运行:{0} 个加入队列,{1} 个删除,{2} 个标记为过期",
+                "cluster/repl/planner": "规划器",
+                "cluster/repl/primary": "主",
+                "cluster/repl/reason/evacuate": "撤离",
+                "cluster/repl/reason/repair": "修复",
+                "cluster/repl/reason/replica": "新增副本",
+                "cluster/repl/route": "来源 / 目的地",
+                "cluster/repl/stale": "过期副本",
+                "cluster/repl/task": "任务",
+                "cluster/repl/title": "副本复制",
+                "cluster/repl/under": "副本不足",
+                "cluster/repl/verified": "已验证 {0} 份副本({1}),{2} 份过期",
+                "cluster/repl/verify": "验证此节点",
+                "cluster/requestfailed": "请求失败",
+                "cluster/resume": "恢复",
+                "cluster/revoke": "撤销",
+                "cluster/route": "路径",
+                "cluster/route/direct": "直连",
+                "cluster/route/none": "无路径",
+                "cluster/route/relay": "经由 {0} 中继",
+                "cluster/route/tunnel": "隧道",
+                "cluster/save": "保存",
+                "cluster/saved": "已保存",
+                "cluster/sched/cpu": "CPU 空闲",
+                "cluster/sched/cpu/hint": "优先选择空闲的处理器",
+                "cluster/sched/degraded": "健康状态",
+                "cluster/sched/degraded/hint": "避开性能不佳的节点",
+                "cluster/sched/disk": "可用磁盘",
+                "cluster/sched/disk/hint": "优先选择有增长空间的节点",
+                "cluster/sched/diskfree": "磁盘剩余",
+                "cluster/sched/diversity": "站点分散",
+                "cluster/sched/diversity/hint": "把第二份副本放在离第一份较远的地方,这样某个站点停机时不会丢失数据",
+                "cluster/sched/explain": "任务为何被分配到那里?",
+                "cluster/sched/explainbtn": "说明",
+                "cluster/sched/explainhint": "从集群任务页面复制任务 ID,即可查看分配时的排名。",
+                "cluster/sched/factor/cpu free": "CPU 空闲",
+                "cluster/sched/factor/degraded": "性能下降",
+                "cluster/sched/factor/disk free": "磁盘剩余",
+                "cluster/sched/factor/locality": "数据位置",
+                "cluster/sched/factor/memory free": "内存剩余",
+                "cluster/sched/factor/network distance": "网络距离",
+                "cluster/sched/factor/preferred features": "偏好功能",
+                "cluster/sched/factor/queue depth": "队列长度",
+                "cluster/sched/factor/site diversity": "站点分散",
+                "cluster/sched/features": "{0} 项功能",
+                "cluster/sched/from": "来源 / 目的地",
+                "cluster/sched/here": "本地",
+                "cluster/sched/hint": "任务、新文件和额外副本会交给分数最高的节点。调高权重可让该项特质更重要。实际分数显示在集群信息页面。",
+                "cluster/sched/infohint": "依集群设置中的权重,各节点当前的分数。任务、新文件和额外副本会交给分数最高者。",
+                "cluster/sched/latency": "网络距离",
+                "cluster/sched/latency/hint": "避开距离数据太远的节点",
+                "cluster/sched/locality": "数据位置",
+                "cluster/sched/locality/hint": "优先选择已持有数据的节点",
+                "cluster/sched/matrixhint": "每对节点之间的往返时间(毫秒)。文件的副本会分散到距离最远的节点上,因为距离通常代表不同的站点。",
+                "cluster/sched/mem": "内存剩余",
+                "cluster/sched/memory": "可用内存",
+                "cluster/sched/memory/hint": "优先选择有富余内存的节点",
+                "cluster/sched/next": "下一个任务将在 {0} 运行",
+                "cluster/sched/nojob": "此集群中没有这个任务",
+                "cluster/sched/nonodes": "没有可评分的节点",
+                "cluster/sched/ping": "往返时间",
+                "cluster/sched/preferred": "偏好功能",
+                "cluster/sched/preferred/hint": "优先选择具备任务所要求的可选功能的节点",
+                "cluster/sched/queue": "队列长度",
+                "cluster/sched/queue/hint": "避开已经繁忙的节点",
+                "cluster/sched/reset": "恢复默认值",
+                "cluster/sched/restored": "已恢复默认权重",
+                "cluster/sched/save": "保存权重",
+                "cluster/sched/saved": "权重已应用到整个集群",
+                "cluster/sched/score": "分数",
+                "cluster/sched/skipped": "跳过",
+                "cluster/sched/title": "调度",
+                "cluster/sched/unmeasured": "尚未测量",
+                "cluster/settings/tagline": "设置此节点,以及集群如何存储文件和运行任务",
+                "cluster/settings/title": "集群设置",
+                "cluster/state": "状态",
+                "cluster/state/cancelled": "已取消",
+                "cluster/state/committed": "已写入",
+                "cluster/state/degraded": "性能下降",
+                "cluster/state/done": "完成",
+                "cluster/state/draining": "排空中",
+                "cluster/state/failed": "失败",
+                "cluster/state/maintenance": "维护中",
+                "cluster/state/offline": "离线",
+                "cluster/state/online": "在线",
+                "cluster/state/pending": "待处理",
+                "cluster/state/queued": "排队中",
+                "cluster/state/running": "运行中",
+                "cluster/state/scheduled": "已调度",
+                "cluster/state/stale": "过期",
+                "cluster/state/succeeded": "成功",
+                "cluster/state/unknown": "未知",
+                "cluster/state/verified": "已验证",
+                "cluster/state/writing": "写入中",
+                "cluster/storage": "存储空间",
+                "cluster/testing": "测试中...",
+                "cluster/thisnode": "此节点",
+                "cluster/tokenhint": "只有设置了对外网址的节点才能生成令牌。将令牌粘贴到另一个节点的集群设置中。",
+                "cluster/tokenline": "于 {0} 过期 · 已使用 {1} 次",
+                "cluster/ttl/1h": "1 小时",
+                "cluster/ttl/24h": "24 小时",
+                "cluster/ttl/7d": "7 天",
+                "cluster/tunnel": "隧道",
+                "cluster/tunnelhere": "隧道连到此节点",
+                "cluster/tunnelvia": "经由 {0} 的隧道",
+                "cluster/unreachable": "无法连接",
+                "cluster/updated": "更新于 {0}",
+                "cluster/uptime": "运行时间",
+                "cluster/uptimevalue": "{0} 小时 {1} 分",
+                "cluster/version": "版本",
+                "cluster/via": "经由 {0}",
+                "cluster/vol/add": "提供此节点上的文件夹",
+                "cluster/vol/addbtn": "添加存储卷",
+                "cluster/vol/added": "已添加存储卷",
+                "cluster/vol/autoro": "自动将快满的存储卷设为只读",
+                "cluster/vol/autorohint": "应用到每个节点。剩余空间低于 5% 的存储卷会停止接收新文件,高于 7% 时再恢复。关闭此项可让存储卷用满;先前因此被锁定的存储卷会在一分钟内恢复可写。",
+                "cluster/vol/autorooff": "存储卷现在可以完全用满",
+                "cluster/vol/autoroon": "快满的存储卷将会设为只读",
+                "cluster/vol/confirmevac": "要将“{0}”上的所有文件移走吗?此存储卷会变成只读,并在所有副本都存在于别处后退役。",
+                "cluster/vol/confirmremove": "要停止提供“{0}”吗?文件会保留在磁盘上,但只存在那里的副本将无法使用。",
+                "cluster/vol/evacstarted": "已开始撤离:{0} 个文件待移动",
+                "cluster/vol/evacuate": "撤离",
+                "cluster/vol/evacuating": "撤离中",
+                "cluster/vol/files": "文件",
+                "cluster/vol/full": "快满",
+                "cluster/vol/hint": "写入 cluster:/ 的文件会以普通文件存放在提供的文件夹中。文件夹中原有的文件会在下次重新扫描时纳入命名空间。集群至少有一个存储卷后,cluster:/ 磁盘才会出现在文件管理器中。",
+                "cluster/vol/none": "尚无存储卷。添加此节点上的文件夹即可开始在 cluster:/ 中存放文件",
+                "cluster/vol/readonly": "设为只读",
+                "cluster/vol/rescan": "重新扫描此节点",
+                "cluster/vol/rescanned": "重新扫描完成:纳入 {0} 个,过期 {1} 个",
+                "cluster/vol/ro": "只读",
+                "cluster/vol/space": "剩余 / 总容量",
+                "cluster/vol/summary": "{0} 个存储卷,剩余 {1}",
+                "cluster/vol/volume": "存储卷",
+                "cluster/vol/writable": "设为可写",
+                "cluster/waitinghost": "正在等待可连接的成员",
+                "cluster/when": "时间",
+                "cluster/willtunnel": "加入集群后将通过成员建立隧道",
+                "cluster/you": "你",
+                "msg/AGI gateway not ready": "AGI 网关尚未就绪",
+                "msg/Invalid MAC address": "MAC 地址无效",
+                "msg/POST required": "必须使用 POST",
+                "msg/URL must start with http:// or https://": "网址必须以 http:// 或 https:// 开头",
+                "msg/a job id is required": "必须提供任务 ID",
+                "msg/a map/reduce job needs a dataset pattern": "map/reduce 任务需要数据集模式",
+                "msg/a map/reduce script must define mapper(files, emit) and reducer(key, values)": "map/reduce 脚本必须定义 mapper(files, emit) 和 reducer(key, values)",
+                "msg/advertise URL must start with http:// or https://": "对外网址必须以 http:// 或 https:// 开头",
+                "msg/args must be valid JSON": "参数必须是有效的 JSON",
+                "msg/cannot remove the namespace root": "无法移除命名空间的根目录",
+                "msg/checksum mismatch": "校验和不匹配",
+                "msg/chunk checksum mismatch": "分块校验和不匹配",
+                "msg/chunk exceeds file size": "分块超过文件大小",
+                "msg/chunk index out of range": "分块索引超出范围",
+                "msg/chunk size invalid": "分块大小无效",
+                "msg/cluster jobs not available": "集群任务不可用",
+                "msg/cluster name cannot be empty": "集群名称不能为空",
+                "msg/cluster name required": "必须输入集群名称",
+                "msg/directory not empty": "文件夹不是空的",
+                "msg/disk usage not supported on this platform": "此平台不支持磁盘用量查询",
+                "msg/drive required": "必须选择磁盘",
+                "msg/empty response": "响应为空",
+                "msg/enabled must be true or false": "enabled 必须是 true 或 false",
+                "msg/event bus not available": "事件总线不可用",
+                "msg/event data too large (64 KB max)": "事件数据太大(上限 64 KB)",
+                "msg/event type required": "必须提供事件类型",
+                "msg/evict notice is not for this node": "移除通知不是发给此节点的",
+                "msg/file changed since the copy was planned": "文件在规划复制后已更改",
+                "msg/file no longer exists in the namespace": "文件已不存在于命名空间中",
+                "msg/file not found on volume": "存储卷上找不到文件",
+                "msg/folder path required": "必须输入文件夹路径",
+                "msg/folder required": "必须输入文件夹",
+                "msg/hook not found": "找不到钩子",
+                "msg/insufficient cpu cores": "CPU 核心不足",
+                "msg/insufficient memory": "内存不足",
+                "msg/invalid append payload": "无效的追加数据",
+                "msg/invalid batch": "无效的批次",
+                "msg/invalid begin request": "无效的开始请求",
+                "msg/invalid entry": "无效的条目",
+                "msg/invalid heartbeat": "无效的心跳",
+                "msg/invalid job spec": "无效的任务规格",
+                "msg/invalid join request": "无效的加入请求",
+                "msg/invalid lease": "无效的租约",
+                "msg/invalid node URL": "节点网址无效",
+                "msg/invalid node public key": "节点公钥无效",
+                "msg/invalid node record": "无效的节点记录",
+                "msg/invalid range": "无效的范围",
+                "msg/invalid rename": "无效的重命名",
+                "msg/invalid report": "无效的报告",
+                "msg/invalid request": "无效的请求",
+                "msg/invalid sync payload": "无效的同步数据",
+                "msg/invalid task": "无效的任务",
+                "msg/invalid username": "用户名无效",
+                "msg/is a directory": "这是文件夹",
+                "msg/job cancelled": "任务已取消",
+                "msg/job id required": "必须提供任务 ID",
+                "msg/job is limited to other nodes": "此任务只能在其他节点上运行",
+                "msg/job not found": "找不到任务",
+                "msg/job owner is required": "必须提供任务所有者",
+                "msg/job script is empty": "任务脚本为空",
+                "msg/job timed out": "任务超时",
+                "msg/join response does not match the token's cluster": "加入响应与令牌所属的集群不匹配",
+                "msg/join token has an invalid node URL": "加入令牌中的节点网址无效",
+                "msg/join token is corrupted": "加入令牌已损坏",
+                "msg/join token is for another cluster": "此加入令牌属于另一个集群",
+                "msg/join token is incomplete": "加入令牌不完整",
+                "msg/join token is invalid or expired": "加入令牌无效或已过期",
+                "msg/join token required": "必须提供加入令牌",
+                "msg/metadata store not available": "元数据存储不可用",
+                "msg/no cluster volume available; add one in System Settings > Cluster Settings > Storage": "没有可用的集群存储卷;请在“系统设置 > 集群设置 > 存储空间”中添加",
+                "msg/no healthy copy of this file is reachable right now": "目前无法连接到此文件的任何健康副本",
+                "msg/no node is available": "没有可用的节点",
+                "msg/node already has enough copies in flight": "此节点已有足够的复制正在进行",
+                "msg/node id is required": "必须提供节点 ID",
+                "msg/node id required": "必须提供节点 ID",
+                "msg/node is DEGRADED": "节点性能下降",
+                "msg/node is DRAINING": "节点排空中",
+                "msg/node is MAINTENANCE": "节点维护中",
+                "msg/node is OFFLINE": "节点已离线",
+                "msg/node is UNKNOWN": "节点状态未知",
+                "msg/node is degraded": "节点性能下降",
+                "msg/node is draining": "节点排空中",
+                "msg/node is maintenance": "节点维护中",
+                "msg/node is not available": "节点不可用",
+                "msg/node is offline": "节点已离线",
+                "msg/node is unknown": "节点状态未知",
+                "msg/node lost while running the job": "运行任务时节点失去连接",
+                "msg/node name cannot be empty": "节点名称不能为空",
+                "msg/node not found": "找不到节点",
+                "msg/node refused this job earlier": "此节点先前拒绝了这个任务",
+                "msg/not a directory": "不是文件夹",
+                "msg/not a file": "不是文件",
+                "msg/not an ArozOS join token": "这不是 ArozOS 加入令牌",
+                "msg/not in a cluster": "未加入集群",
+                "msg/not logged in": "尚未登录",
+                "msg/offset beyond end of file": "偏移超出文件末尾",
+                "msg/only the metadata leader assigns jobs": "只有元数据主节点可以分配任务",
+                "msg/only the metadata leader assigns replication tasks": "只有元数据主节点可以分配复制任务",
+                "msg/owner, script and at least one event type are required": "必须提供所有者、脚本和至少一种事件类型",
+                "msg/path already exists": "路径已存在",
+                "msg/path escapes the volume": "路径超出了存储卷范围",
+                "msg/path not found in the cluster namespace": "在集群命名空间中找不到此路径",
+                "msg/path required": "必须输入路径",
+                "msg/peer has no reachable endpoint": "对方节点没有可连接的端点",
+                "msg/permission denied": "权限不足",
+                "msg/previous node stopped reporting": "先前的节点停止报告",
+                "msg/refusing to delete the volume root": "拒绝删除存储卷根目录",
+                "msg/refusing to drop the last healthy copy": "拒绝删除最后一份健康副本",
+                "msg/replicas must be between 0 and 16": "副本数必须在 0 到 16 之间",
+                "msg/replicas must be between 1 and 16": "副本数必须在 1 到 16 之间",
+                "msg/request is not signed": "请求未签名",
+                "msg/request signature invalid": "请求签名无效",
+                "msg/request targets another cluster": "请求的目标是另一个集群",
+                "msg/request timestamp outside the accepted window": "请求的时间戳超出允许范围(请检查节点的时钟)",
+                "msg/requested log range was compacted, take a snapshot": "请求的日志范围已被压缩,请改用快照",
+                "msg/script path required": "必须提供脚本路径",
+                "msg/sender is not a member of this cluster": "发送者不是此集群的成员",
+                "msg/shutting down": "正在关闭",
+                "msg/source copy is not readable": "来源副本无法读取",
+                "msg/target node is not connected through a tunnel on this node": "目标节点没有通过此节点的隧道连接",
+                "msg/task is for another node": "此任务属于另一个节点",
+                "msg/the URL answered but it is not an ArozOS cluster endpoint": "此网址有响应,但不是 ArozOS 集群端点",
+                "msg/the job has no dataset pattern": "此任务没有数据集模式",
+                "msg/the job owner has no account on this node": "此节点上没有任务所有者的账户",
+                "msg/the mappers emitted more than 8 MB; narrow the dataset or emit fewer values": "mapper 输出超过 8 MB;请缩小数据集或减少输出的值",
+                "msg/the user root itself cannot be a cluster volume; pick a sub folder such as /cluster": "用户根目录本身不能作为集群存储卷;请选择子文件夹,例如 /cluster",
+                "msg/this folder is already a cluster volume": "此文件夹已经是集群存储卷",
+                "msg/this job script defines no run(job) function": "此任务脚本没有定义 run(job) 函数",
+                "msg/this node cannot execute jobs": "此节点无法运行任务",
+                "msg/this node has no advertised URL; generate the join token on a node that other nodes can reach": "此节点没有对外网址;请在其他节点可以连接的节点上生成加入令牌",
+                "msg/this node is already part of a cluster, leave it first": "此节点已加入集群,请先离开",
+                "msg/this node is not part of a cluster": "此节点尚未加入集群",
+                "msg/this node is not the identity origin of the cluster": "此节点不是集群的身份源",
+                "msg/this node is not the metadata leader": "此节点不是元数据主节点",
+                "msg/timed out waiting for the job to finish": "等待任务完成超时",
+                "msg/token id required": "必须提供令牌 ID",
+                "msg/token not found": "找不到令牌",
+                "msg/tunnel closed": "隧道已关闭",
+                "msg/unknown upload session": "未知的上传会话",
+                "msg/unreachable": "无法连接",
+                "msg/unsupported node state": "不支持的节点状态",
+                "msg/upload incomplete": "上传未完成",
+                "msg/url required": "必须输入网址",
+                "msg/use leave to remove this node": "要移除此节点请使用“离开集群”",
+                "msg/user assertion expired": "用户声明已过期",
+                "msg/user assertion invalid": "用户声明无效",
+                "msg/user not found on the identity origin": "身份源上找不到此用户",
+                "msg/volume id required": "必须提供存储卷 ID",
+                "msg/volume is not on this node": "存储卷不在此节点上",
+                "msg/volume not found": "找不到存储卷",
+                "msg/volume not found on this node": "此节点上找不到存储卷",
+                "msgp/architecture {0} not allowed": "不允许的架构 {0}",
+                "msgp/cancelled by {0}": "由 {0} 取消",
+                "msgp/could not assemble the reduce input: {0}": "无法组合 reduce 的输入:{0}",
+                "msgp/could not create a map task: {0}": "无法创建 map 子任务:{0}",
+                "msgp/could not create the reduce task: {0}": "无法创建 reduce 子任务:{0}",
+                "msgp/drive {0} is not a local drive on this node": "磁盘 {0} 不是此节点上的本地磁盘",
+                "msgp/hook script not found: {0}": "找不到钩子脚本:{0}",
+                "msgp/invalid join response from {0}": "{0} 的加入响应无效",
+                "msgp/invalid node key encoding: {0}": "节点密钥编码无效:{0}",
+                "msgp/invalid public key encoding: {0}": "公钥编码无效:{0}",
+                "msgp/job timed out after {0}s": "任务在 {0} 秒后超时",
+                "msgp/local drive {0} is not mounted": "本地磁盘 {0} 未挂载",
+                "msgp/missing feature {0}": "缺少功能 {0}",
+                "msgp/no cluster volume available; add one in System Settings > Cluster Settings > Storage ({0})": "没有可用的集群存储卷;请在“系统设置 > 集群设置 > 存储空间”中添加({0})",
+                "msgp/no reachable copy of: {0}": "无法连接到以下文件的副本:{0}",
+                "msgp/node {0} could not take the job: {1}": "节点 {0} 无法接收此任务:{1}",
+                "msgp/os {0} not allowed": "不允许的操作系统 {0}",
+                "msgp/permission denied: {0}": "权限不足:{0}",
+                "msgp/remote node returned {0}: {1}": "远端节点返回 {0}:{1}",
+                "msgp/script not found: {0}": "找不到脚本:{0}",
+                "msgp/this volume holds the only copy of {0} file(s); evacuate it first": "此存储卷持有 {0} 个文件的唯一副本;请先撤离",
+                "msgp/unable to reach {0}: {1}": "无法连接到 {0}:{1}",
+                "msgp/unreachable: {0}": "无法连接:{0}",
+                "msgp/unsupported node URL scheme: {0}": "不支持的节点网址协议:{0}",
+                "msgp/weight {0} must be a number": "权重 {0} 必须是数字",
+                "msgp/weight {0} must be between 0 and 1": "权重 {0} 必须在 0 与 1 之间",
+                "msgp/{0} produced an unreadable result": "{0} 产生了无法读取的结果"
+            },
+            "placeholder": {
+                "Name (optional)": "名称(可选)",
+                "job id": "任务 ID",
+                "Nightly index": "每晚索引",
+                "50 per task": "每个子任务 50 个"
+            },
+            "titles": {
+                "Re-detect capabilities": "重新检测功能"
+            }
+        },
+        "ja-jp": {
+            "name": "日本語",
+            "fontFamily": "\"Meiryo UI\", \"Arial Unicode MS\", \"Hiragino Kaku Gothic Pro\"",
+            "strings": {
+                "cluster/ago/d": "{0} 日前",
+                "cluster/ago/h": "{0} 時間前",
+                "cluster/ago/m": "{0} 分前",
+                "cluster/ago/s": "{0} 秒前",
+                "cluster/auto": "自動(到達可能な任意のメンバー)",
+                "cluster/cancel": "キャンセル",
+                "cluster/caps": "機能",
+                "cluster/cfg/insecure": "他のノードの自己署名証明書を受け入れる(LAN のみ)",
+                "cluster/cfg/name": "ノード名",
+                "cluster/cfg/test": "テスト",
+                "cluster/cfg/tunnel": "トンネル経由",
+                "cluster/cfg/url": "公開 URL(他のノードがこのノードに接続する方法)",
+                "cluster/cfg/urlhint": "このノードに公開アドレスがない場合は空欄のままにしてください。到達可能なメンバーへのトンネルを維持し、Cloudflare 経由でも動作します。",
+                "cluster/clusterid": "クラスター ID",
+                "cluster/confirmleave": "クラスターから離脱しますか?他のノード上のファイルとジョブにはここからアクセスできなくなります。",
+                "cluster/confirmremove": "ノード「{0}」を削除しますか?",
+                "cluster/copied": "トークンをクリップボードにコピーしました",
+                "cluster/copy": "コピー",
+                "cluster/coresvalue": "{0} コア",
+                "cluster/create": "新しいクラスターを作成",
+                "cluster/createbtn": "作成",
+                "cluster/created": "作成日時",
+                "cluster/createhint": "このノードが最初のメンバーになります。その後ここで参加トークンを生成してノードを追加します。",
+                "cluster/direct": "直接",
+                "cluster/disk": "ディスク",
+                "cluster/diskfree": "空き {0} / {1}",
+                "cluster/drain": "ドレイン",
+                "cluster/entername": "クラスター名を入力してください",
+                "cluster/entertoken": "参加トークンを貼り付けてください",
+                "cluster/enterurl": "テストする URL を入力してください",
+                "cluster/expiresat": "{0} に期限切れ",
+                "cluster/folder": "フォルダー",
+                "cluster/freeamount": "空き {0}",
+                "cluster/gentoken": "参加トークンを生成",
+                "cluster/id/accounts": "アカウント",
+                "cluster/id/apply": "適用",
+                "cluster/id/conflicts": "ローカルのみ",
+                "cluster/id/conflictswhy": "同じ名前のローカルアカウントはそのまま維持されます",
+                "cluster/id/disabled": "無効(各ノードが自身のアカウントで確認)",
+                "cluster/id/fallback": "ID オリジンに到達できないため、ローカルアカウントを使用しました",
+                "cluster/id/forward": "転送",
+                "cluster/id/hint": "ID オリジンはクラスター内のすべてのログインを検証します。アカウントは他のノードにコピーされるため、オリジンに到達できなくても利用できます。グループは名前で照合されるので、すべてのノードに同じ権限グループを作成してください。",
+                "cluster/id/lastlogin": "前回のログイン確認",
+                "cluster/id/lastsync": "前回の同期",
+                "cluster/id/local": "各ノードが自身のアカウントでログインを確認します",
+                "cluster/id/mirrors": "ローカルアカウント {1} 件中 {0} 件がオリジンを反映しています",
+                "cluster/id/mode": "モード",
+                "cluster/id/origin": "オリジン",
+                "cluster/id/originhere": "このノードがクラスター全体のログインを確認します",
+                "cluster/id/published": "{0} 件をメンバーに公開",
+                "cluster/id/replicated": "複製済み",
+                "cluster/id/skipped": "スキップ",
+                "cluster/id/skippedwhy": "このノードには所属グループがひとつもありません",
+                "cluster/id/sync": "今すぐ同期",
+                "cluster/id/synced": "アカウントを同期しました",
+                "cluster/id/title": "ID(シングルサインオン)",
+                "cluster/id/verifiedby": "ログインは {0} が確認",
+                "cluster/info/novolume": "クラスターにまだストレージがありません。",
+                "cluster/info/novolumehint": "クラスター設定でボリュームを追加すると、ファイルマネージャーに cluster:/ ドライブが表示されます。",
+                "cluster/info/standalone": "このノードはクラスターに参加していません。",
+                "cluster/info/standalonehint": "クラスター設定でクラスターを作成または参加してください。それまで cluster:/ ドライブは表示されません。",
+                "cluster/info/tagline": "このノード、ピア、名前空間、スケジューラーの状態",
+                "cluster/info/title": "クラスター情報",
+                "cluster/invite": "ノードを追加",
+                "cluster/jobs/args": "引数(JSON、任意)",
+                "cluster/jobs/attemptn": "{0} 回目の試行",
+                "cluster/jobs/browse": "参照",
+                "cluster/jobs/cancel": "キャンセル",
+                "cluster/jobs/complete": "{0} / {1} 完了",
+                "cluster/jobs/dataset": "データセット",
+                "cluster/jobs/datasethint": "データセットを使う場合、スクリプトは mapper(files, emit) と reducer(key, values) を定義する必要があります。ファイルはそれを保持するノードで処理されます。",
+                "cluster/jobs/datasetlabel": "Map/reduce データセット(任意の glob。指定するとこのジョブが map/reduce ジョブになります)",
+                "cluster/jobs/detail": "ジョブの詳細",
+                "cluster/jobs/duration": "所要時間",
+                "cluster/jobs/failedn": "{0} 件失敗",
+                "cluster/jobs/features": "必要な機能",
+                "cluster/jobs/inputs": "入力パス(カンマ区切り、データの近くで実行するために使用)",
+                "cluster/jobs/job": "ジョブ",
+                "cluster/jobs/jobs": "ジョブ",
+                "cluster/jobs/kind/map": "map タスク",
+                "cluster/jobs/kind/mapreduce": "map/reduce",
+                "cluster/jobs/kind/reduce": "reduce タスク",
+                "cluster/jobs/log": "ログ",
+                "cluster/jobs/minsecs": "{0} 分 {1} 秒",
+                "cluster/jobs/name": "名前",
+                "cluster/jobs/needscript": "スクリプトのパスが必要です",
+                "cluster/jobs/node": "ノード",
+                "cluster/jobs/nodetail": "ジョブを選択するとログと出力が表示されます",
+                "cluster/jobs/none": "ジョブはまだありません",
+                "cluster/jobs/output": "出力",
+                "cluster/jobs/owner": "所有者",
+                "cluster/jobs/progress": "進捗",
+                "cluster/jobs/queued": "ジョブをキューに追加しました",
+                "cluster/jobs/run": "ジョブを実行",
+                "cluster/jobs/scheduler": "スケジューラー: {0}",
+                "cluster/jobs/script": "スクリプト",
+                "cluster/jobs/scriptlabel": "スクリプト(run(job) を定義する .agi ファイル)",
+                "cluster/jobs/secs": "{0} 秒",
+                "cluster/jobs/slots": "このノードで {0}/{1} スロット使用中",
+                "cluster/jobs/status": "状態",
+                "cluster/jobs/submit": "ジョブを投入",
+                "cluster/jobs/submithint": "スクリプトは選ばれたノード上であなたの権限で実行されます。cluster:/ パスは通常どおり使えます。",
+                "cluster/jobs/summary": "実行中 {0}、待機中 {1}、完了 {2}",
+                "cluster/jobs/tagline": "最適なノードでスクリプトを実行",
+                "cluster/jobs/tasks": "タスク",
+                "cluster/jobs/timeout": "タイムアウト(秒)",
+                "cluster/jobs/title": "クラスタージョブ",
+                "cluster/jobs/typepath": "スクリプトのパスを入力してください(例: user:/jobs/index.agi)",
+                "cluster/jobs/waiting": "待機理由",
+                "cluster/join": "既存のクラスターに参加",
+                "cluster/joinbtn": "参加",
+                "cluster/joinhint": "クラスターの到達可能なメンバーで生成した参加トークンを貼り付けてください。",
+                "cluster/joining": "参加しています...",
+                "cluster/justnow": "たった今",
+                "cluster/key": "ノードキー",
+                "cluster/lastseen": "最終確認",
+                "cluster/leave": "クラスターから離脱",
+                "cluster/link/info": "クラスター情報",
+                "cluster/link/jobs": "クラスタージョブ",
+                "cluster/link/settings": "クラスター設定",
+                "cluster/load": "負荷",
+                "cluster/maintenance": "メンテナンス",
+                "cluster/members": "メンバー",
+                "cluster/membershint": "メンテナンスはノードをスケジューリングから外します。ドレインはさらにコピーを他のノードへ移すため、安全に停止できます。",
+                "cluster/meta/entries": "エントリー",
+                "cluster/meta/entriesvalue": "ファイル {0}、フォルダー {1}、ボリューム {2}",
+                "cluster/meta/leader": "リーダー",
+                "cluster/meta/log": "ログ",
+                "cluster/meta/noleader": "選出中",
+                "cluster/meta/pending": "保留中 {0}",
+                "cluster/meta/term": "任期 {0}",
+                "cluster/natonly": "URL なし",
+                "cluster/never": "なし",
+                "cluster/node": "ノード",
+                "cluster/nodes": "ノード",
+                "cluster/nonodes": "ノードがありません",
+                "cluster/noreport": "まだ報告がありません",
+                "cluster/noroute": "経路なし",
+                "cluster/notmember": "クラスターに参加していません",
+                "cluster/notunnel": "トンネルなし",
+                "cluster/now": "現在",
+                "cluster/platform": "プラットフォーム",
+                "cluster/platformline": "{0} / {1}、{2} コア",
+                "cluster/pol/1": "コピー 1 個",
+                "cluster/pol/2": "コピー 2 個",
+                "cluster/pol/3": "コピー 3 個",
+                "cluster/pol/4": "コピー 4 個",
+                "cluster/pol/copies": "コピー {0} 個",
+                "cluster/pol/hint": "ポリシーのないフォルダーはコピーを 1 つだけ保持します。追加のコピーはレプリケーションサービスが作成します。",
+                "cluster/pol/label": "最上位フォルダーごとのコピー数",
+                "cluster/pol/set": "設定",
+                "cluster/pol/title": "コピーポリシー",
+                "cluster/probe": "疎通確認",
+                "cluster/probefail": "{0}: {1}({2})",
+                "cluster/probeok": "{0}: {1} ms({2})",
+                "cluster/reach": "到達性",
+                "cluster/reachableok": "到達可能、往復 {0} ms",
+                "cluster/redetect": "再検出",
+                "cluster/remove": "削除",
+                "cluster/repl/evacuating": "{0} 個のボリュームを退避中",
+                "cluster/repl/files": "ファイル",
+                "cluster/repl/fromto": "{0} から {1}",
+                "cluster/repl/healthy": "すべてのファイルがポリシーを満たしています",
+                "cluster/repl/inflight": "処理中",
+                "cluster/repl/inflightvalue": "実行中 {0}、待機中 {1}",
+                "cluster/repl/inspect": "ファイルを調べる",
+                "cluster/repl/inspectbtn": "コピーを表示",
+                "cluster/repl/issues": "問題 {0} 件",
+                "cluster/repl/lastrun": "前回実行 {0}",
+                "cluster/repl/nocopy": "正常なコピーなし",
+                "cluster/repl/nolocations": "記録されたコピーはありません",
+                "cluster/repl/notasks": "レプリケーションタスクはありません",
+                "cluster/repl/notfound": "名前空間にこのパスが見つかりません",
+                "cluster/repl/over": "余分なコピー",
+                "cluster/repl/plan": "今すぐ修復",
+                "cluster/repl/planned": "プランナーを実行: キュー {0}、削除 {1}、期限切れ {2}",
+                "cluster/repl/planner": "プランナー",
+                "cluster/repl/primary": "プライマリ",
+                "cluster/repl/reason/evacuate": "退避",
+                "cluster/repl/reason/repair": "修復",
+                "cluster/repl/reason/replica": "新しいコピー",
+                "cluster/repl/route": "元 / 先",
+                "cluster/repl/stale": "古いコピー",
+                "cluster/repl/task": "タスク",
+                "cluster/repl/title": "レプリケーション",
+                "cluster/repl/under": "コピー不足",
+                "cluster/repl/verified": "{0} 個のコピー({1})を検証、古いもの {2}",
+                "cluster/repl/verify": "このノードを検証",
+                "cluster/requestfailed": "リクエストに失敗しました",
+                "cluster/resume": "再開",
+                "cluster/revoke": "取り消す",
+                "cluster/route": "経路",
+                "cluster/route/direct": "直接",
+                "cluster/route/none": "経路なし",
+                "cluster/route/relay": "{0} 経由で中継",
+                "cluster/route/tunnel": "トンネル",
+                "cluster/save": "保存",
+                "cluster/saved": "保存しました",
+                "cluster/sched/cpu": "CPU 空き",
+                "cluster/sched/cpu/hint": "空いているプロセッサーを優先",
+                "cluster/sched/degraded": "健全性",
+                "cluster/sched/degraded/hint": "負荷の高いノードを避ける",
+                "cluster/sched/disk": "空きディスク",
+                "cluster/sched/disk/hint": "容量に余裕のあるノードを優先",
+                "cluster/sched/diskfree": "ディスク空き",
+                "cluster/sched/diversity": "サイト分散",
+                "cluster/sched/diversity/hint": "2 つ目のコピーを 1 つ目から離れた場所に置き、1 つのサイトが止まってもデータを失わないようにする",
+                "cluster/sched/explain": "ジョブがそこに割り当てられた理由は?",
+                "cluster/sched/explainbtn": "説明",
+                "cluster/sched/explainhint": "クラスタージョブページからジョブ ID をコピーすると、割り当て時の順位を確認できます。",
+                "cluster/sched/factor/cpu free": "CPU 空き",
+                "cluster/sched/factor/degraded": "低下",
+                "cluster/sched/factor/disk free": "ディスク空き",
+                "cluster/sched/factor/locality": "データの場所",
+                "cluster/sched/factor/memory free": "メモリ空き",
+                "cluster/sched/factor/network distance": "ネットワーク距離",
+                "cluster/sched/factor/preferred features": "希望する機能",
+                "cluster/sched/factor/queue depth": "キューの長さ",
+                "cluster/sched/factor/site diversity": "サイト分散",
+                "cluster/sched/features": "機能 {0}",
+                "cluster/sched/from": "元 / 先",
+                "cluster/sched/here": "ローカル",
+                "cluster/sched/hint": "ジョブ、新しいファイル、追加のコピーは最高スコアのノードに送られます。重みを上げるとその要素がより重視されます。結果のスコアはクラスター情報に表示されます。",
+                "cluster/sched/infohint": "クラスター設定の重みによる各ノードの現在のスコアです。ジョブ、新しいファイル、追加のコピーは最高スコアに送られます。",
+                "cluster/sched/latency": "ネットワーク距離",
+                "cluster/sched/latency/hint": "データから遠いノードを避ける",
+                "cluster/sched/locality": "データの場所",
+                "cluster/sched/locality/hint": "既にデータを持つノードを優先",
+                "cluster/sched/matrixhint": "ノード間の往復時間(ミリ秒)。距離は通常別のサイトを意味するため、ファイルのコピーは最も遠いペアに分散されます。",
+                "cluster/sched/mem": "メモリ空き",
+                "cluster/sched/memory": "空きメモリ",
+                "cluster/sched/memory/hint": "RAM に余裕のあるノードを優先",
+                "cluster/sched/next": "次のジョブは {0} で実行",
+                "cluster/sched/nojob": "このクラスターにそのジョブはありません",
+                "cluster/sched/nonodes": "スコアを付けるノードがありません",
+                "cluster/sched/ping": "往復時間",
+                "cluster/sched/preferred": "希望する機能",
+                "cluster/sched/preferred/hint": "ジョブが求める任意機能を持つノードを優先",
+                "cluster/sched/queue": "キューの長さ",
+                "cluster/sched/queue/hint": "既に忙しいノードを避ける",
+                "cluster/sched/reset": "既定値に戻す",
+                "cluster/sched/restored": "既定の重みに戻しました",
+                "cluster/sched/save": "重みを保存",
+                "cluster/sched/saved": "重みをクラスター全体に保存しました",
+                "cluster/sched/score": "スコア",
+                "cluster/sched/skipped": "対象外",
+                "cluster/sched/title": "スケジューリング",
+                "cluster/sched/unmeasured": "未計測",
+                "cluster/settings/tagline": "このノードと、クラスターのファイル保存・ジョブ実行方法を設定します",
+                "cluster/settings/title": "クラスター設定",
+                "cluster/state": "状態",
+                "cluster/state/cancelled": "キャンセル済み",
+                "cluster/state/committed": "書き込み済み",
+                "cluster/state/degraded": "低下",
+                "cluster/state/done": "完了",
+                "cluster/state/draining": "ドレイン中",
+                "cluster/state/failed": "失敗",
+                "cluster/state/maintenance": "メンテナンス中",
+                "cluster/state/offline": "オフライン",
+                "cluster/state/online": "オンライン",
+                "cluster/state/pending": "保留中",
+                "cluster/state/queued": "待機中",
+                "cluster/state/running": "実行中",
+                "cluster/state/scheduled": "割り当て済み",
+                "cluster/state/stale": "古い",
+                "cluster/state/succeeded": "成功",
+                "cluster/state/unknown": "不明",
+                "cluster/state/verified": "検証済み",
+                "cluster/state/writing": "書き込み中",
+                "cluster/storage": "ストレージ",
+                "cluster/testing": "テスト中...",
+                "cluster/thisnode": "このノード",
+                "cluster/tokenhint": "トークンは公開 URL を持つノードでのみ生成できます。もう一方のノードのクラスター設定に貼り付けてください。",
+                "cluster/tokenline": "{0} に期限切れ · 使用 {1} 回",
+                "cluster/ttl/1h": "1 時間",
+                "cluster/ttl/24h": "24 時間",
+                "cluster/ttl/7d": "7 日",
+                "cluster/tunnel": "トンネル",
+                "cluster/tunnelhere": "このノードへのトンネル",
+                "cluster/tunnelvia": "{0} 経由のトンネル",
+                "cluster/unreachable": "到達不能",
+                "cluster/updated": "{0} に更新",
+                "cluster/uptime": "稼働時間",
+                "cluster/uptimevalue": "{0} 時間 {1} 分",
+                "cluster/version": "バージョン",
+                "cluster/via": "{0} 経由",
+                "cluster/vol/add": "このノードのフォルダーを提供",
+                "cluster/vol/addbtn": "ボリュームを追加",
+                "cluster/vol/added": "ボリュームを追加しました",
+                "cluster/vol/autoro": "空き容量が少ないボリュームを自動的に読み取り専用にする",
+                "cluster/vol/autorohint": "すべてのノードに適用されます。空きが 5% 未満のボリュームは新しいファイルの受け入れを停止し、7% を超えると再開します。オフにするとボリュームを満杯まで使えます。これによりロックされていたボリュームは 1 分以内に書き込み可能に戻ります。",
+                "cluster/vol/autorooff": "ボリュームを満杯まで使えるようになりました",
+                "cluster/vol/autoroon": "空き容量が少ないボリュームは読み取り専用になります",
+                "cluster/vol/confirmevac": "「{0}」からすべてのファイルを移動しますか?ボリュームは読み取り専用になり、すべてのコピーが他所に揃った時点で廃止されます。",
+                "cluster/vol/confirmremove": "「{0}」の提供を停止しますか?ファイルはディスクに残りますが、そこにしかないコピーは使えなくなります。",
+                "cluster/vol/evacstarted": "退避を開始しました: 移動するファイル {0}",
+                "cluster/vol/evacuate": "退避",
+                "cluster/vol/evacuating": "退避中",
+                "cluster/vol/files": "ファイル",
+                "cluster/vol/full": "空き不足",
+                "cluster/vol/hint": "cluster:/ に書き込んだファイルは、提供されたフォルダー内に通常のファイルとして保存されます。フォルダーに既にあるファイルは次回の再スキャンで名前空間に取り込まれます。クラスターにボリュームが 1 つ以上あると、ファイルマネージャーに cluster:/ ドライブが表示されます。",
+                "cluster/vol/none": "ボリュームはまだありません。このノードのフォルダーを追加すると cluster:/ にファイルを保存できます",
+                "cluster/vol/readonly": "読み取り専用",
+                "cluster/vol/rescan": "このノードを再スキャン",
+                "cluster/vol/rescanned": "再スキャン完了: 取り込み {0}、古いもの {1}",
+                "cluster/vol/ro": "読み取り専用",
+                "cluster/vol/space": "空き / 合計",
+                "cluster/vol/summary": "ボリューム {0}、空き {1}",
+                "cluster/vol/volume": "ボリューム",
+                "cluster/vol/writable": "書き込み可能にする",
+                "cluster/waitinghost": "到達可能なメンバーを待っています",
+                "cluster/when": "日時",
+                "cluster/willtunnel": "クラスター参加後はメンバー経由でトンネルします",
+                "cluster/you": "自分",
+                "msg/AGI gateway not ready": "AGI ゲートウェイの準備ができていません",
+                "msg/Invalid MAC address": "MAC アドレスが無効です",
+                "msg/POST required": "POST が必要です",
+                "msg/URL must start with http:// or https://": "URL は http:// または https:// で始まる必要があります",
+                "msg/a job id is required": "ジョブ ID が必要です",
+                "msg/a map/reduce job needs a dataset pattern": "map/reduce ジョブにはデータセットのパターンが必要です",
+                "msg/a map/reduce script must define mapper(files, emit) and reducer(key, values)": "map/reduce スクリプトは mapper(files, emit) と reducer(key, values) を定義する必要があります",
+                "msg/advertise URL must start with http:// or https://": "公開 URL は http:// または https:// で始まる必要があります",
+                "msg/args must be valid JSON": "引数は有効な JSON である必要があります",
+                "msg/cannot remove the namespace root": "名前空間のルートは削除できません",
+                "msg/checksum mismatch": "チェックサムが一致しません",
+                "msg/chunk checksum mismatch": "チャンクのチェックサムが一致しません",
+                "msg/chunk exceeds file size": "チャンクがファイルサイズを超えています",
+                "msg/chunk index out of range": "チャンク番号が範囲外です",
+                "msg/chunk size invalid": "チャンクサイズが無効です",
+                "msg/cluster jobs not available": "クラスタージョブは利用できません",
+                "msg/cluster name cannot be empty": "クラスター名を空にはできません",
+                "msg/cluster name required": "クラスター名が必要です",
+                "msg/directory not empty": "フォルダーが空ではありません",
+                "msg/disk usage not supported on this platform": "このプラットフォームではディスク使用量を取得できません",
+                "msg/drive required": "ドライブが必要です",
+                "msg/empty response": "応答が空です",
+                "msg/enabled must be true or false": "enabled は true か false である必要があります",
+                "msg/event bus not available": "イベントバスは利用できません",
+                "msg/event data too large (64 KB max)": "イベントデータが大きすぎます(最大 64 KB)",
+                "msg/event type required": "イベントの種類が必要です",
+                "msg/evict notice is not for this node": "削除通知はこのノード宛てではありません",
+                "msg/file changed since the copy was planned": "コピーの計画後にファイルが変更されました",
+                "msg/file no longer exists in the namespace": "ファイルはもう名前空間に存在しません",
+                "msg/file not found on volume": "ボリュームにファイルが見つかりません",
+                "msg/folder path required": "フォルダーのパスが必要です",
+                "msg/folder required": "フォルダーが必要です",
+                "msg/hook not found": "フックが見つかりません",
+                "msg/insufficient cpu cores": "CPU コアが不足しています",
+                "msg/insufficient memory": "メモリが不足しています",
+                "msg/invalid append payload": "無効な追記データです",
+                "msg/invalid batch": "無効なバッチです",
+                "msg/invalid begin request": "無効な開始リクエストです",
+                "msg/invalid entry": "無効なエントリーです",
+                "msg/invalid heartbeat": "無効なハートビートです",
+                "msg/invalid job spec": "無効なジョブ仕様です",
+                "msg/invalid join request": "無効な参加リクエストです",
+                "msg/invalid lease": "無効なリースです",
+                "msg/invalid node URL": "ノード URL が無効です",
+                "msg/invalid node public key": "ノードの公開鍵が無効です",
+                "msg/invalid node record": "無効なノードレコードです",
+                "msg/invalid range": "無効な範囲です",
+                "msg/invalid rename": "無効な名前の変更です",
+                "msg/invalid report": "無効なレポートです",
+                "msg/invalid request": "無効なリクエストです",
+                "msg/invalid sync payload": "無効な同期データです",
+                "msg/invalid task": "無効なタスクです",
+                "msg/invalid username": "ユーザー名が無効です",
+                "msg/is a directory": "フォルダーです",
+                "msg/job cancelled": "ジョブはキャンセルされました",
+                "msg/job id required": "ジョブ ID が必要です",
+                "msg/job is limited to other nodes": "このジョブは他のノードに限定されています",
+                "msg/job not found": "ジョブが見つかりません",
+                "msg/job owner is required": "ジョブの所有者が必要です",
+                "msg/job script is empty": "ジョブのスクリプトが空です",
+                "msg/job timed out": "ジョブがタイムアウトしました",
+                "msg/join response does not match the token's cluster": "参加の応答がトークンのクラスターと一致しません",
+                "msg/join token has an invalid node URL": "参加トークンのノード URL が無効です",
+                "msg/join token is corrupted": "参加トークンが壊れています",
+                "msg/join token is for another cluster": "参加トークンは別のクラスターのものです",
+                "msg/join token is incomplete": "参加トークンが不完全です",
+                "msg/join token is invalid or expired": "参加トークンが無効か期限切れです",
+                "msg/join token required": "参加トークンが必要です",
+                "msg/metadata store not available": "メタデータストアは利用できません",
+                "msg/no cluster volume available; add one in System Settings > Cluster Settings > Storage": "使用できるクラスターボリュームがありません。「システム設定 > クラスター設定 > ストレージ」で追加してください",
+                "msg/no healthy copy of this file is reachable right now": "このファイルの正常なコピーに現在到達できません",
+                "msg/no node is available": "利用できるノードがありません",
+                "msg/node already has enough copies in flight": "このノードでは既に十分なコピーが進行中です",
+                "msg/node id is required": "ノード ID が必要です",
+                "msg/node id required": "ノード ID が必要です",
+                "msg/node is DEGRADED": "ノードの性能が低下しています",
+                "msg/node is DRAINING": "ノードはドレイン中です",
+                "msg/node is MAINTENANCE": "ノードはメンテナンス中です",
+                "msg/node is OFFLINE": "ノードはオフラインです",
+                "msg/node is UNKNOWN": "ノードの状態が不明です",
+                "msg/node is degraded": "ノードの性能が低下しています",
+                "msg/node is draining": "ノードはドレイン中です",
+                "msg/node is maintenance": "ノードはメンテナンス中です",
+                "msg/node is not available": "ノードは利用できません",
+                "msg/node is offline": "ノードはオフラインです",
+                "msg/node is unknown": "ノードの状態が不明です",
+                "msg/node lost while running the job": "ジョブの実行中にノードとの接続が失われました",
+                "msg/node name cannot be empty": "ノード名を空にはできません",
+                "msg/node not found": "ノードが見つかりません",
+                "msg/node refused this job earlier": "このノードは以前このジョブを拒否しました",
+                "msg/not a directory": "フォルダーではありません",
+                "msg/not a file": "ファイルではありません",
+                "msg/not an ArozOS join token": "ArozOS の参加トークンではありません",
+                "msg/not in a cluster": "クラスターに参加していません",
+                "msg/not logged in": "ログインしていません",
+                "msg/offset beyond end of file": "オフセットがファイルの終わりを超えています",
+                "msg/only the metadata leader assigns jobs": "ジョブを割り当てられるのはメタデータのリーダーだけです",
+                "msg/only the metadata leader assigns replication tasks": "レプリケーションタスクを割り当てられるのはメタデータのリーダーだけです",
+                "msg/owner, script and at least one event type are required": "所有者、スクリプト、少なくとも 1 つのイベントの種類が必要です",
+                "msg/path already exists": "パスは既に存在します",
+                "msg/path escapes the volume": "パスがボリュームの外を指しています",
+                "msg/path not found in the cluster namespace": "クラスターの名前空間にパスが見つかりません",
+                "msg/path required": "パスが必要です",
+                "msg/peer has no reachable endpoint": "ピアに到達可能なエンドポイントがありません",
+                "msg/permission denied": "権限がありません",
+                "msg/previous node stopped reporting": "前のノードが応答を停止しました",
+                "msg/refusing to delete the volume root": "ボリュームのルートは削除できません",
+                "msg/refusing to drop the last healthy copy": "最後の正常なコピーは削除できません",
+                "msg/replicas must be between 0 and 16": "コピー数は 0 から 16 の間である必要があります",
+                "msg/replicas must be between 1 and 16": "コピー数は 1 から 16 の間である必要があります",
+                "msg/request is not signed": "リクエストが署名されていません",
+                "msg/request signature invalid": "リクエストの署名が無効です",
+                "msg/request targets another cluster": "リクエストの宛先が別のクラスターです",
+                "msg/request timestamp outside the accepted window": "リクエストのタイムスタンプが許容範囲外です(ノードの時計を確認してください)",
+                "msg/requested log range was compacted, take a snapshot": "要求されたログ範囲は圧縮済みです。スナップショットを取得してください",
+                "msg/script path required": "スクリプトのパスが必要です",
+                "msg/sender is not a member of this cluster": "送信元はこのクラスターのメンバーではありません",
+                "msg/shutting down": "シャットダウン中",
+                "msg/source copy is not readable": "コピー元を読み取れません",
+                "msg/target node is not connected through a tunnel on this node": "対象ノードはこのノードのトンネルに接続されていません",
+                "msg/task is for another node": "このタスクは別のノード宛てです",
+                "msg/the URL answered but it is not an ArozOS cluster endpoint": "URL は応答しましたが、ArozOS クラスターのエンドポイントではありません",
+                "msg/the job has no dataset pattern": "このジョブにはデータセットのパターンがありません",
+                "msg/the job owner has no account on this node": "このノードにジョブ所有者のアカウントがありません",
+                "msg/the mappers emitted more than 8 MB; narrow the dataset or emit fewer values": "mapper の出力が 8 MB を超えました。データセットを絞るか、出力する値を減らしてください",
+                "msg/the user root itself cannot be a cluster volume; pick a sub folder such as /cluster": "ユーザーのルート自体はクラスターボリュームにできません。/cluster のようなサブフォルダーを選んでください",
+                "msg/this folder is already a cluster volume": "このフォルダーは既にクラスターボリュームです",
+                "msg/this job script defines no run(job) function": "このジョブスクリプトには run(job) 関数がありません",
+                "msg/this node cannot execute jobs": "このノードはジョブを実行できません",
+                "msg/this node has no advertised URL; generate the join token on a node that other nodes can reach": "このノードには公開 URL がありません。他のノードから到達できるノードで参加トークンを生成してください",
+                "msg/this node is already part of a cluster, leave it first": "このノードは既にクラスターに参加しています。先に離脱してください",
+                "msg/this node is not part of a cluster": "このノードはクラスターに参加していません",
+                "msg/this node is not the identity origin of the cluster": "このノードはクラスターの ID オリジンではありません",
+                "msg/this node is not the metadata leader": "このノードはメタデータのリーダーではありません",
+                "msg/timed out waiting for the job to finish": "ジョブの完了待ちがタイムアウトしました",
+                "msg/token id required": "トークン ID が必要です",
+                "msg/token not found": "トークンが見つかりません",
+                "msg/tunnel closed": "トンネルが閉じられました",
+                "msg/unknown upload session": "不明なアップロードセッションです",
+                "msg/unreachable": "到達不能",
+                "msg/unsupported node state": "サポートされていないノード状態です",
+                "msg/upload incomplete": "アップロードが完了していません",
+                "msg/url required": "URL が必要です",
+                "msg/use leave to remove this node": "このノードを削除するには「クラスターから離脱」を使ってください",
+                "msg/user assertion expired": "ユーザーアサーションの期限が切れました",
+                "msg/user assertion invalid": "ユーザーアサーションが無効です",
+                "msg/user not found on the identity origin": "ID オリジンにユーザーが見つかりません",
+                "msg/volume id required": "ボリューム ID が必要です",
+                "msg/volume is not on this node": "ボリュームはこのノードにありません",
+                "msg/volume not found": "ボリュームが見つかりません",
+                "msg/volume not found on this node": "このノードにボリュームが見つかりません",
+                "msgp/architecture {0} not allowed": "アーキテクチャ {0} は許可されていません",
+                "msgp/cancelled by {0}": "{0} がキャンセルしました",
+                "msgp/could not assemble the reduce input: {0}": "reduce の入力を組み立てられませんでした: {0}",
+                "msgp/could not create a map task: {0}": "map タスクを作成できませんでした: {0}",
+                "msgp/could not create the reduce task: {0}": "reduce タスクを作成できませんでした: {0}",
+                "msgp/drive {0} is not a local drive on this node": "ドライブ {0} はこのノードのローカルドライブではありません",
+                "msgp/hook script not found: {0}": "フックスクリプトが見つかりません: {0}",
+                "msgp/invalid join response from {0}": "{0} からの参加応答が無効です",
+                "msgp/invalid node key encoding: {0}": "ノードキーのエンコードが無効です: {0}",
+                "msgp/invalid public key encoding: {0}": "公開鍵のエンコードが無効です: {0}",
+                "msgp/job timed out after {0}s": "ジョブは {0} 秒後にタイムアウトしました",
+                "msgp/local drive {0} is not mounted": "ローカルドライブ {0} はマウントされていません",
+                "msgp/missing feature {0}": "機能 {0} がありません",
+                "msgp/no cluster volume available; add one in System Settings > Cluster Settings > Storage ({0})": "使用できるクラスターボリュームがありません。「システム設定 > クラスター設定 > ストレージ」で追加してください({0})",
+                "msgp/no reachable copy of: {0}": "次のファイルのコピーに到達できません: {0}",
+                "msgp/node {0} could not take the job: {1}": "ノード {0} はジョブを受け取れませんでした: {1}",
+                "msgp/os {0} not allowed": "OS {0} は許可されていません",
+                "msgp/permission denied: {0}": "権限がありません: {0}",
+                "msgp/remote node returned {0}: {1}": "リモートノードが {0} を返しました: {1}",
+                "msgp/script not found: {0}": "スクリプトが見つかりません: {0}",
+                "msgp/this volume holds the only copy of {0} file(s); evacuate it first": "このボリュームには {0} 個のファイルの唯一のコピーがあります。先に退避してください",
+                "msgp/unable to reach {0}: {1}": "{0} に到達できません: {1}",
+                "msgp/unreachable: {0}": "到達不能: {0}",
+                "msgp/unsupported node URL scheme: {0}": "サポートされていないノード URL スキームです: {0}",
+                "msgp/weight {0} must be a number": "重み {0} は数値である必要があります",
+                "msgp/weight {0} must be between 0 and 1": "重み {0} は 0 から 1 の間である必要があります",
+                "msgp/{0} produced an unreadable result": "{0} が読み取れない結果を返しました"
+            },
+            "placeholder": {
+                "Name (optional)": "名前(任意)",
+                "job id": "ジョブ ID",
+                "Nightly index": "夜間インデックス",
+                "50 per task": "タスクごとに 50"
+            },
+            "titles": {
+                "Re-detect capabilities": "機能を再検出"
+            }
+        },
+        "ko-kr": {
+            "name": "한국어",
+            "fontFamily": "\"Microsoft JhengHei\",\"SimHei\", \"Apple LiGothic Medium\", \"STHeiti\"",
+            "strings": {
+                "cluster/ago/d": "{0}일 전",
+                "cluster/ago/h": "{0}시간 전",
+                "cluster/ago/m": "{0}분 전",
+                "cluster/ago/s": "{0}초 전",
+                "cluster/auto": "자동(연결 가능한 아무 구성원)",
+                "cluster/cancel": "취소",
+                "cluster/caps": "기능",
+                "cluster/cfg/insecure": "다른 노드의 자체 서명 인증서 허용(LAN 전용)",
+                "cluster/cfg/name": "노드 이름",
+                "cluster/cfg/test": "테스트",
+                "cluster/cfg/tunnel": "터널 경유",
+                "cluster/cfg/url": "공개 URL(다른 노드가 이 노드에 연결하는 방법)",
+                "cluster/cfg/urlhint": "이 노드에 공개 주소가 없으면 비워 두세요. 대신 연결 가능한 구성원과 터널을 유지하며, Cloudflare를 통해서도 동작합니다.",
+                "cluster/clusterid": "클러스터 ID",
+                "cluster/confirmleave": "클러스터에서 나가시겠습니까? 다른 노드의 파일과 작업에 더 이상 여기에서 접근할 수 없습니다.",
+                "cluster/confirmremove": "노드 \"{0}\"을(를) 제거하시겠습니까?",
+                "cluster/copied": "토큰을 클립보드에 복사했습니다",
+                "cluster/copy": "복사",
+                "cluster/coresvalue": "{0}코어",
+                "cluster/create": "새 클러스터 만들기",
+                "cluster/createbtn": "만들기",
+                "cluster/created": "생성 시간",
+                "cluster/createhint": "이 노드가 첫 번째 구성원이 됩니다. 이후 여기에서 참가 토큰을 생성해 노드를 추가하세요.",
+                "cluster/direct": "직접",
+                "cluster/disk": "디스크",
+                "cluster/diskfree": "여유 {0} / 전체 {1}",
+                "cluster/drain": "드레인",
+                "cluster/entername": "클러스터 이름을 입력하세요",
+                "cluster/entertoken": "참가 토큰을 붙여 넣으세요",
+                "cluster/enterurl": "테스트할 URL을 입력하세요",
+                "cluster/expiresat": "{0}에 만료",
+                "cluster/folder": "폴더",
+                "cluster/freeamount": "여유 {0}",
+                "cluster/gentoken": "참가 토큰 생성",
+                "cluster/id/accounts": "계정",
+                "cluster/id/apply": "적용",
+                "cluster/id/conflicts": "로컬 전용",
+                "cluster/id/conflictswhy": "같은 이름의 로컬 계정은 그대로 유지됩니다",
+                "cluster/id/disabled": "사용 안 함(각 노드가 자체 계정으로 확인)",
+                "cluster/id/fallback": "ID 원본에 연결할 수 없어 로컬 계정을 사용했습니다",
+                "cluster/id/forward": "전달",
+                "cluster/id/hint": "ID 원본은 클러스터의 모든 로그인을 확인합니다. 계정은 다른 노드로 복사되므로 원본에 연결할 수 없을 때도 사용할 수 있습니다. 그룹은 이름으로 대응하므로 모든 노드에 같은 권한 그룹을 만드세요.",
+                "cluster/id/lastlogin": "마지막 로그인 확인",
+                "cluster/id/lastsync": "마지막 동기화",
+                "cluster/id/local": "각 노드가 자체 계정으로 로그인을 확인합니다",
+                "cluster/id/mirrors": "로컬 계정 {1}개 중 {0}개가 원본과 동기화됨",
+                "cluster/id/mode": "모드",
+                "cluster/id/origin": "원본",
+                "cluster/id/originhere": "이 노드가 클러스터 전체의 로그인을 확인합니다",
+                "cluster/id/published": "구성원에게 {0}개 게시됨",
+                "cluster/id/replicated": "복제됨",
+                "cluster/id/skipped": "건너뜀",
+                "cluster/id/skippedwhy": "이 노드에 해당 그룹이 하나도 없습니다",
+                "cluster/id/sync": "지금 동기화",
+                "cluster/id/synced": "계정을 동기화했습니다",
+                "cluster/id/title": "ID(단일 로그인)",
+                "cluster/id/verifiedby": "{0}에서 로그인 확인",
+                "cluster/info/novolume": "클러스터에 아직 저장소가 없습니다.",
+                "cluster/info/novolumehint": "클러스터 설정에서 볼륨을 추가하면 파일 관리자에 cluster:/ 드라이브가 나타납니다.",
+                "cluster/info/standalone": "이 노드는 클러스터에 속해 있지 않습니다.",
+                "cluster/info/standalonehint": "클러스터 설정에서 클러스터를 만들거나 참가하세요. 그 전까지 cluster:/ 드라이브는 숨겨져 있습니다.",
+                "cluster/info/tagline": "이 노드, 다른 노드, 네임스페이스와 스케줄러의 상태",
+                "cluster/info/title": "클러스터 정보",
+                "cluster/invite": "노드 추가",
+                "cluster/jobs/args": "인수(JSON, 선택)",
+                "cluster/jobs/attemptn": "{0}번째 시도",
+                "cluster/jobs/browse": "찾아보기",
+                "cluster/jobs/cancel": "취소",
+                "cluster/jobs/complete": "{0} / {1} 완료",
+                "cluster/jobs/dataset": "데이터 세트",
+                "cluster/jobs/datasethint": "데이터 세트를 사용하면 스크립트에 mapper(files, emit)와 reducer(key, values)를 정의해야 합니다. 파일은 해당 파일을 가진 노드에서 처리됩니다.",
+                "cluster/jobs/datasetlabel": "Map/reduce 데이터 세트(선택적 glob, 지정하면 map/reduce 작업이 됨)",
+                "cluster/jobs/detail": "작업 상세",
+                "cluster/jobs/duration": "소요 시간",
+                "cluster/jobs/failedn": "{0}개 실패",
+                "cluster/jobs/features": "필요한 기능",
+                "cluster/jobs/inputs": "입력 경로(쉼표로 구분, 데이터 가까이에서 실행하는 데 사용)",
+                "cluster/jobs/job": "작업",
+                "cluster/jobs/jobs": "작업",
+                "cluster/jobs/kind/map": "map 하위 작업",
+                "cluster/jobs/kind/mapreduce": "map/reduce",
+                "cluster/jobs/kind/reduce": "reduce 하위 작업",
+                "cluster/jobs/log": "로그",
+                "cluster/jobs/minsecs": "{0}분 {1}초",
+                "cluster/jobs/name": "이름",
+                "cluster/jobs/needscript": "스크립트 경로가 필요합니다",
+                "cluster/jobs/node": "노드",
+                "cluster/jobs/nodetail": "작업을 선택하면 로그와 출력을 볼 수 있습니다",
+                "cluster/jobs/none": "아직 작업이 없습니다",
+                "cluster/jobs/output": "출력",
+                "cluster/jobs/owner": "소유자",
+                "cluster/jobs/progress": "진행률",
+                "cluster/jobs/queued": "작업이 대기열에 추가되었습니다",
+                "cluster/jobs/run": "작업 실행",
+                "cluster/jobs/scheduler": "스케줄러: {0}",
+                "cluster/jobs/script": "스크립트",
+                "cluster/jobs/scriptlabel": "스크립트(run(job)을 정의하는 .agi 파일)",
+                "cluster/jobs/secs": "{0}초",
+                "cluster/jobs/slots": "이 노드에서 {0}/{1} 슬롯 사용 중",
+                "cluster/jobs/status": "상태",
+                "cluster/jobs/submit": "작업 제출",
+                "cluster/jobs/submithint": "스크립트는 선택된 노드에서 사용자 권한으로 실행됩니다. cluster:/ 경로는 평소처럼 동작합니다.",
+                "cluster/jobs/summary": "실행 중 {0}, 대기 중 {1}, 완료 {2}",
+                "cluster/jobs/tagline": "가장 적합한 노드에서 스크립트 실행",
+                "cluster/jobs/tasks": "하위 작업",
+                "cluster/jobs/timeout": "제한 시간(초)",
+                "cluster/jobs/title": "클러스터 작업",
+                "cluster/jobs/typepath": "스크립트 경로를 입력하세요(예: user:/jobs/index.agi)",
+                "cluster/jobs/waiting": "대기 이유",
+                "cluster/join": "기존 클러스터에 참가",
+                "cluster/joinbtn": "참가",
+                "cluster/joinhint": "클러스터의 연결 가능한 구성원에서 생성한 참가 토큰을 붙여 넣으세요.",
+                "cluster/joining": "참가하는 중...",
+                "cluster/justnow": "방금",
+                "cluster/key": "노드 키",
+                "cluster/lastseen": "마지막 확인",
+                "cluster/leave": "클러스터 나가기",
+                "cluster/link/info": "클러스터 정보",
+                "cluster/link/jobs": "클러스터 작업",
+                "cluster/link/settings": "클러스터 설정",
+                "cluster/load": "부하",
+                "cluster/maintenance": "유지 보수",
+                "cluster/members": "구성원",
+                "cluster/membershint": "유지 보수는 노드를 스케줄링에서 제외합니다. 드레인은 사본도 다른 노드로 옮기므로 안전하게 끌 수 있습니다.",
+                "cluster/meta/entries": "항목",
+                "cluster/meta/entriesvalue": "파일 {0}개, 폴더 {1}개, 볼륨 {2}개",
+                "cluster/meta/leader": "리더",
+                "cluster/meta/log": "로그",
+                "cluster/meta/noleader": "선출 중",
+                "cluster/meta/pending": "{0}건 대기 중",
+                "cluster/meta/term": "임기 {0}",
+                "cluster/natonly": "URL 없음",
+                "cluster/never": "없음",
+                "cluster/node": "노드",
+                "cluster/nodes": "노드",
+                "cluster/nonodes": "노드가 없습니다",
+                "cluster/noreport": "아직 보고가 없습니다",
+                "cluster/noroute": "경로 없음",
+                "cluster/notmember": "클러스터에 속해 있지 않음",
+                "cluster/notunnel": "터널 없음",
+                "cluster/now": "지금",
+                "cluster/platform": "플랫폼",
+                "cluster/platformline": "{0} / {1}, {2}코어",
+                "cluster/pol/1": "사본 1개",
+                "cluster/pol/2": "사본 2개",
+                "cluster/pol/3": "사본 3개",
+                "cluster/pol/4": "사본 4개",
+                "cluster/pol/copies": "사본 {0}개",
+                "cluster/pol/hint": "정책이 없는 폴더는 사본을 1개만 보관합니다. 추가 사본은 복제 서비스가 만듭니다.",
+                "cluster/pol/label": "최상위 폴더별 사본 수",
+                "cluster/pol/set": "설정",
+                "cluster/pol/title": "사본 정책",
+                "cluster/probe": "연결 확인",
+                "cluster/probefail": "{0}: {1} ({2})",
+                "cluster/probeok": "{0}: {1}ms ({2})",
+                "cluster/reach": "연결 상태",
+                "cluster/reachableok": "연결 가능, 왕복 {0}ms",
+                "cluster/redetect": "다시 감지",
+                "cluster/remove": "제거",
+                "cluster/repl/evacuating": "볼륨 {0}개 비우는 중",
+                "cluster/repl/files": "파일",
+                "cluster/repl/fromto": "{0} → {1}",
+                "cluster/repl/healthy": "모든 파일이 정책을 충족합니다",
+                "cluster/repl/inflight": "진행 중",
+                "cluster/repl/inflightvalue": "실행 중 {0}, 대기 중 {1}",
+                "cluster/repl/inspect": "파일 검사",
+                "cluster/repl/inspectbtn": "사본 보기",
+                "cluster/repl/issues": "문제 {0}건",
+                "cluster/repl/lastrun": "마지막 실행 {0}",
+                "cluster/repl/nocopy": "정상 사본 없음",
+                "cluster/repl/nolocations": "기록된 사본이 없습니다",
+                "cluster/repl/notasks": "복제 작업이 없습니다",
+                "cluster/repl/notfound": "네임스페이스에서 경로를 찾을 수 없습니다",
+                "cluster/repl/over": "초과 사본",
+                "cluster/repl/plan": "지금 복구",
+                "cluster/repl/planned": "플래너 실행: 대기열 {0}, 삭제 {1}, 오래됨 표시 {2}",
+                "cluster/repl/planner": "플래너",
+                "cluster/repl/primary": "기본",
+                "cluster/repl/reason/evacuate": "비우기",
+                "cluster/repl/reason/repair": "복구",
+                "cluster/repl/reason/replica": "새 사본",
+                "cluster/repl/route": "보낸 곳 / 받는 곳",
+                "cluster/repl/stale": "오래된 사본",
+                "cluster/repl/task": "작업",
+                "cluster/repl/title": "복제",
+                "cluster/repl/under": "사본 부족",
+                "cluster/repl/verified": "사본 {0}개({1}) 검증, 오래됨 {2}개",
+                "cluster/repl/verify": "이 노드 검증",
+                "cluster/requestfailed": "요청에 실패했습니다",
+                "cluster/resume": "재개",
+                "cluster/revoke": "취소",
+                "cluster/route": "경로",
+                "cluster/route/direct": "직접",
+                "cluster/route/none": "경로 없음",
+                "cluster/route/relay": "{0} 경유 중계",
+                "cluster/route/tunnel": "터널",
+                "cluster/save": "저장",
+                "cluster/saved": "저장했습니다",
+                "cluster/sched/cpu": "CPU 여유",
+                "cluster/sched/cpu/hint": "한가한 프로세서를 우선",
+                "cluster/sched/degraded": "상태",
+                "cluster/sched/degraded/hint": "힘겨워하는 노드를 피함",
+                "cluster/sched/disk": "여유 디스크",
+                "cluster/sched/disk/hint": "공간 여유가 있는 노드를 우선",
+                "cluster/sched/diskfree": "디스크 여유",
+                "cluster/sched/diversity": "사이트 분산",
+                "cluster/sched/diversity/hint": "두 번째 사본을 첫 번째와 멀리 두어 한 사이트가 멈춰도 데이터를 잃지 않게 함",
+                "cluster/sched/explain": "작업이 왜 거기에 배정되었나요?",
+                "cluster/sched/explainbtn": "설명",
+                "cluster/sched/explainhint": "클러스터 작업 페이지에서 작업 ID를 복사하면 배정 당시의 순위를 볼 수 있습니다.",
+                "cluster/sched/factor/cpu free": "CPU 여유",
+                "cluster/sched/factor/degraded": "성능 저하",
+                "cluster/sched/factor/disk free": "디스크 여유",
+                "cluster/sched/factor/locality": "데이터 위치",
+                "cluster/sched/factor/memory free": "메모리 여유",
+                "cluster/sched/factor/network distance": "네트워크 거리",
+                "cluster/sched/factor/preferred features": "선호 기능",
+                "cluster/sched/factor/queue depth": "대기열 길이",
+                "cluster/sched/factor/site diversity": "사이트 분산",
+                "cluster/sched/features": "기능 {0}개",
+                "cluster/sched/from": "출발 / 도착",
+                "cluster/sched/here": "로컬",
+                "cluster/sched/hint": "작업, 새 파일, 추가 사본은 점수가 가장 높은 노드로 갑니다. 가중치를 높이면 해당 요소가 더 중요해집니다. 결과 점수는 클러스터 정보에 표시됩니다.",
+                "cluster/sched/infohint": "클러스터 설정의 가중치에 따른 각 노드의 현재 점수입니다. 작업, 새 파일, 추가 사본은 가장 높은 점수로 갑니다.",
+                "cluster/sched/latency": "네트워크 거리",
+                "cluster/sched/latency/hint": "데이터에서 먼 노드를 피함",
+                "cluster/sched/locality": "데이터 위치",
+                "cluster/sched/locality/hint": "이미 데이터를 가진 노드를 우선",
+                "cluster/sched/matrixhint": "노드 쌍 사이의 왕복 시간(밀리초)입니다. 거리는 보통 다른 사이트를 뜻하므로 파일 사본은 가장 먼 쌍으로 분산됩니다.",
+                "cluster/sched/mem": "메모리 여유",
+                "cluster/sched/memory": "여유 메모리",
+                "cluster/sched/memory/hint": "RAM 여유가 있는 노드를 우선",
+                "cluster/sched/next": "다음 작업은 {0}에서 실행",
+                "cluster/sched/nojob": "이 클러스터에 해당 작업이 없습니다",
+                "cluster/sched/nonodes": "점수를 매길 노드가 없습니다",
+                "cluster/sched/ping": "왕복 시간",
+                "cluster/sched/preferred": "선호 기능",
+                "cluster/sched/preferred/hint": "작업이 원하는 선택 기능을 가진 노드를 우선",
+                "cluster/sched/queue": "대기열 길이",
+                "cluster/sched/queue/hint": "이미 바쁜 노드를 피함",
+                "cluster/sched/reset": "기본값 복원",
+                "cluster/sched/restored": "기본 가중치를 복원했습니다",
+                "cluster/sched/save": "가중치 저장",
+                "cluster/sched/saved": "가중치를 클러스터 전체에 저장했습니다",
+                "cluster/sched/score": "점수",
+                "cluster/sched/skipped": "제외",
+                "cluster/sched/title": "스케줄링",
+                "cluster/sched/unmeasured": "측정 안 됨",
+                "cluster/settings/tagline": "이 노드와 클러스터의 파일 저장 및 작업 실행 방식을 설정합니다",
+                "cluster/settings/title": "클러스터 설정",
+                "cluster/state": "상태",
+                "cluster/state/cancelled": "취소됨",
+                "cluster/state/committed": "기록됨",
+                "cluster/state/degraded": "성능 저하",
+                "cluster/state/done": "완료",
+                "cluster/state/draining": "드레인 중",
+                "cluster/state/failed": "실패",
+                "cluster/state/maintenance": "유지 보수 중",
+                "cluster/state/offline": "오프라인",
+                "cluster/state/online": "온라인",
+                "cluster/state/pending": "대기",
+                "cluster/state/queued": "대기 중",
+                "cluster/state/running": "실행 중",
+                "cluster/state/scheduled": "배정됨",
+                "cluster/state/stale": "오래됨",
+                "cluster/state/succeeded": "성공",
+                "cluster/state/unknown": "알 수 없음",
+                "cluster/state/verified": "검증됨",
+                "cluster/state/writing": "쓰는 중",
+                "cluster/storage": "저장소",
+                "cluster/testing": "테스트 중...",
+                "cluster/thisnode": "이 노드",
+                "cluster/tokenhint": "토큰은 공개 URL이 있는 노드에서만 생성할 수 있습니다. 다른 노드의 클러스터 설정에 붙여 넣으세요.",
+                "cluster/tokenline": "{0}에 만료 · {1}회 사용",
+                "cluster/ttl/1h": "1시간",
+                "cluster/ttl/24h": "24시간",
+                "cluster/ttl/7d": "7일",
+                "cluster/tunnel": "터널",
+                "cluster/tunnelhere": "이 노드로의 터널",
+                "cluster/tunnelvia": "{0} 경유 터널",
+                "cluster/unreachable": "연결할 수 없음",
+                "cluster/updated": "{0}에 업데이트",
+                "cluster/uptime": "가동 시간",
+                "cluster/uptimevalue": "{0}시간 {1}분",
+                "cluster/version": "버전",
+                "cluster/via": "{0} 경유",
+                "cluster/vol/add": "이 노드의 폴더 제공",
+                "cluster/vol/addbtn": "볼륨 추가",
+                "cluster/vol/added": "볼륨을 추가했습니다",
+                "cluster/vol/autoro": "거의 찬 볼륨을 자동으로 읽기 전용으로 설정",
+                "cluster/vol/autorohint": "모든 노드에 적용됩니다. 여유 공간이 5% 미만인 볼륨은 새 파일을 받지 않고 7%를 넘으면 다시 받습니다. 끄면 볼륨을 가득 채울 수 있으며, 이 기능으로 잠겼던 볼륨은 1분 안에 다시 쓰기 가능해집니다.",
+                "cluster/vol/autorooff": "이제 볼륨을 가득 채울 수 있습니다",
+                "cluster/vol/autoroon": "거의 찬 볼륨은 읽기 전용이 됩니다",
+                "cluster/vol/confirmevac": "\"{0}\"의 모든 파일을 옮기시겠습니까? 볼륨은 읽기 전용이 되며 모든 사본이 다른 곳에 생기면 퇴역합니다.",
+                "cluster/vol/confirmremove": "\"{0}\" 제공을 중지하시겠습니까? 파일은 디스크에 남지만 그곳에만 있는 사본은 사용할 수 없게 됩니다.",
+                "cluster/vol/evacstarted": "비우기 시작: 옮길 파일 {0}개",
+                "cluster/vol/evacuate": "비우기",
+                "cluster/vol/evacuating": "비우는 중",
+                "cluster/vol/files": "파일",
+                "cluster/vol/full": "거의 참",
+                "cluster/vol/hint": "cluster:/에 쓴 파일은 제공된 폴더 안에 일반 파일로 저장됩니다. 폴더에 원래 있던 파일은 다음 재검색 때 네임스페이스에 포함됩니다. 클러스터에 볼륨이 하나 이상 있으면 파일 관리자에 cluster:/ 드라이브가 나타납니다.",
+                "cluster/vol/none": "아직 볼륨이 없습니다. 이 노드의 폴더를 추가하면 cluster:/에 파일을 저장할 수 있습니다",
+                "cluster/vol/readonly": "읽기 전용",
+                "cluster/vol/rescan": "이 노드 재검색",
+                "cluster/vol/rescanned": "재검색 완료: 포함 {0}개, 오래됨 {1}개",
+                "cluster/vol/ro": "읽기 전용",
+                "cluster/vol/space": "여유 / 전체",
+                "cluster/vol/summary": "볼륨 {0}개, 여유 {1}",
+                "cluster/vol/volume": "볼륨",
+                "cluster/vol/writable": "쓰기 가능으로",
+                "cluster/waitinghost": "연결 가능한 구성원을 기다리는 중",
+                "cluster/when": "시간",
+                "cluster/willtunnel": "클러스터에 참가하면 구성원을 통해 터널을 엽니다",
+                "cluster/you": "나",
+                "msg/AGI gateway not ready": "AGI 게이트웨이가 준비되지 않았습니다",
+                "msg/Invalid MAC address": "MAC 주소가 올바르지 않습니다",
+                "msg/POST required": "POST가 필요합니다",
+                "msg/URL must start with http:// or https://": "URL은 http:// 또는 https://로 시작해야 합니다",
+                "msg/a job id is required": "작업 ID가 필요합니다",
+                "msg/a map/reduce job needs a dataset pattern": "map/reduce 작업에는 데이터 세트 패턴이 필요합니다",
+                "msg/a map/reduce script must define mapper(files, emit) and reducer(key, values)": "map/reduce 스크립트에는 mapper(files, emit)와 reducer(key, values)가 있어야 합니다",
+                "msg/advertise URL must start with http:// or https://": "공개 URL은 http:// 또는 https://로 시작해야 합니다",
+                "msg/args must be valid JSON": "인수는 올바른 JSON이어야 합니다",
+                "msg/cannot remove the namespace root": "네임스페이스 루트는 제거할 수 없습니다",
+                "msg/checksum mismatch": "체크섬이 일치하지 않습니다",
+                "msg/chunk checksum mismatch": "청크 체크섬이 일치하지 않습니다",
+                "msg/chunk exceeds file size": "청크가 파일 크기를 넘습니다",
+                "msg/chunk index out of range": "청크 번호가 범위를 벗어났습니다",
+                "msg/chunk size invalid": "청크 크기가 올바르지 않습니다",
+                "msg/cluster jobs not available": "클러스터 작업을 사용할 수 없습니다",
+                "msg/cluster name cannot be empty": "클러스터 이름은 비워 둘 수 없습니다",
+                "msg/cluster name required": "클러스터 이름이 필요합니다",
+                "msg/directory not empty": "폴더가 비어 있지 않습니다",
+                "msg/disk usage not supported on this platform": "이 플랫폼에서는 디스크 사용량을 확인할 수 없습니다",
+                "msg/drive required": "드라이브가 필요합니다",
+                "msg/empty response": "응답이 비어 있습니다",
+                "msg/enabled must be true or false": "enabled는 true 또는 false여야 합니다",
+                "msg/event bus not available": "이벤트 버스를 사용할 수 없습니다",
+                "msg/event data too large (64 KB max)": "이벤트 데이터가 너무 큽니다(최대 64KB)",
+                "msg/event type required": "이벤트 유형이 필요합니다",
+                "msg/evict notice is not for this node": "제거 알림이 이 노드용이 아닙니다",
+                "msg/file changed since the copy was planned": "복사를 계획한 뒤 파일이 변경되었습니다",
+                "msg/file no longer exists in the namespace": "파일이 더 이상 네임스페이스에 없습니다",
+                "msg/file not found on volume": "볼륨에서 파일을 찾을 수 없습니다",
+                "msg/folder path required": "폴더 경로가 필요합니다",
+                "msg/folder required": "폴더가 필요합니다",
+                "msg/hook not found": "훅을 찾을 수 없습니다",
+                "msg/insufficient cpu cores": "CPU 코어가 부족합니다",
+                "msg/insufficient memory": "메모리가 부족합니다",
+                "msg/invalid append payload": "잘못된 추가 데이터입니다",
+                "msg/invalid batch": "잘못된 일괄 처리입니다",
+                "msg/invalid begin request": "잘못된 시작 요청입니다",
+                "msg/invalid entry": "잘못된 항목입니다",
+                "msg/invalid heartbeat": "잘못된 하트비트입니다",
+                "msg/invalid job spec": "잘못된 작업 사양입니다",
+                "msg/invalid join request": "잘못된 참가 요청입니다",
+                "msg/invalid lease": "잘못된 임대입니다",
+                "msg/invalid node URL": "노드 URL이 올바르지 않습니다",
+                "msg/invalid node public key": "노드 공개 키가 올바르지 않습니다",
+                "msg/invalid node record": "잘못된 노드 기록입니다",
+                "msg/invalid range": "잘못된 범위입니다",
+                "msg/invalid rename": "잘못된 이름 변경입니다",
+                "msg/invalid report": "잘못된 보고입니다",
+                "msg/invalid request": "잘못된 요청입니다",
+                "msg/invalid sync payload": "잘못된 동기화 데이터입니다",
+                "msg/invalid task": "잘못된 작업입니다",
+                "msg/invalid username": "사용자 이름이 올바르지 않습니다",
+                "msg/is a directory": "폴더입니다",
+                "msg/job cancelled": "작업이 취소되었습니다",
+                "msg/job id required": "작업 ID가 필요합니다",
+                "msg/job is limited to other nodes": "이 작업은 다른 노드로 제한되어 있습니다",
+                "msg/job not found": "작업을 찾을 수 없습니다",
+                "msg/job owner is required": "작업 소유자가 필요합니다",
+                "msg/job script is empty": "작업 스크립트가 비어 있습니다",
+                "msg/job timed out": "작업 시간이 초과되었습니다",
+                "msg/join response does not match the token's cluster": "참가 응답이 토큰의 클러스터와 일치하지 않습니다",
+                "msg/join token has an invalid node URL": "참가 토큰의 노드 URL이 올바르지 않습니다",
+                "msg/join token is corrupted": "참가 토큰이 손상되었습니다",
+                "msg/join token is for another cluster": "참가 토큰이 다른 클러스터용입니다",
+                "msg/join token is incomplete": "참가 토큰이 불완전합니다",
+                "msg/join token is invalid or expired": "참가 토큰이 올바르지 않거나 만료되었습니다",
+                "msg/join token required": "참가 토큰이 필요합니다",
+                "msg/metadata store not available": "메타데이터 저장소를 사용할 수 없습니다",
+                "msg/no cluster volume available; add one in System Settings > Cluster Settings > Storage": "사용할 수 있는 클러스터 볼륨이 없습니다. \"시스템 설정 > 클러스터 설정 > 저장소\"에서 추가하세요",
+                "msg/no healthy copy of this file is reachable right now": "현재 이 파일의 정상 사본에 연결할 수 없습니다",
+                "msg/no node is available": "사용할 수 있는 노드가 없습니다",
+                "msg/node already has enough copies in flight": "이 노드에서 이미 충분한 복사가 진행 중입니다",
+                "msg/node id is required": "노드 ID가 필요합니다",
+                "msg/node id required": "노드 ID가 필요합니다",
+                "msg/node is DEGRADED": "노드 성능이 저하되었습니다",
+                "msg/node is DRAINING": "노드가 드레인 중입니다",
+                "msg/node is MAINTENANCE": "노드가 유지 보수 중입니다",
+                "msg/node is OFFLINE": "노드가 오프라인입니다",
+                "msg/node is UNKNOWN": "노드 상태를 알 수 없습니다",
+                "msg/node is degraded": "노드 성능이 저하되었습니다",
+                "msg/node is draining": "노드가 드레인 중입니다",
+                "msg/node is maintenance": "노드가 유지 보수 중입니다",
+                "msg/node is not available": "노드를 사용할 수 없습니다",
+                "msg/node is offline": "노드가 오프라인입니다",
+                "msg/node is unknown": "노드 상태를 알 수 없습니다",
+                "msg/node lost while running the job": "작업 실행 중 노드 연결이 끊겼습니다",
+                "msg/node name cannot be empty": "노드 이름은 비워 둘 수 없습니다",
+                "msg/node not found": "노드를 찾을 수 없습니다",
+                "msg/node refused this job earlier": "이 노드가 이전에 이 작업을 거부했습니다",
+                "msg/not a directory": "폴더가 아닙니다",
+                "msg/not a file": "파일이 아닙니다",
+                "msg/not an ArozOS join token": "ArozOS 참가 토큰이 아닙니다",
+                "msg/not in a cluster": "클러스터에 속해 있지 않습니다",
+                "msg/not logged in": "로그인하지 않았습니다",
+                "msg/offset beyond end of file": "오프셋이 파일 끝을 넘었습니다",
+                "msg/only the metadata leader assigns jobs": "메타데이터 리더만 작업을 배정할 수 있습니다",
+                "msg/only the metadata leader assigns replication tasks": "메타데이터 리더만 복제 작업을 배정할 수 있습니다",
+                "msg/owner, script and at least one event type are required": "소유자, 스크립트, 이벤트 유형 하나 이상이 필요합니다",
+                "msg/path already exists": "경로가 이미 있습니다",
+                "msg/path escapes the volume": "경로가 볼륨 밖을 가리킵니다",
+                "msg/path not found in the cluster namespace": "클러스터 네임스페이스에서 경로를 찾을 수 없습니다",
+                "msg/path required": "경로가 필요합니다",
+                "msg/peer has no reachable endpoint": "상대 노드에 연결 가능한 엔드포인트가 없습니다",
+                "msg/permission denied": "권한이 없습니다",
+                "msg/previous node stopped reporting": "이전 노드가 보고를 멈췄습니다",
+                "msg/refusing to delete the volume root": "볼륨 루트는 삭제할 수 없습니다",
+                "msg/refusing to drop the last healthy copy": "마지막 정상 사본은 삭제할 수 없습니다",
+                "msg/replicas must be between 0 and 16": "사본 수는 0에서 16 사이여야 합니다",
+                "msg/replicas must be between 1 and 16": "사본 수는 1에서 16 사이여야 합니다",
+                "msg/request is not signed": "요청에 서명이 없습니다",
+                "msg/request signature invalid": "요청 서명이 올바르지 않습니다",
+                "msg/request targets another cluster": "요청 대상이 다른 클러스터입니다",
+                "msg/request timestamp outside the accepted window": "요청 타임스탬프가 허용 범위를 벗어났습니다(노드 시계를 확인하세요)",
+                "msg/requested log range was compacted, take a snapshot": "요청한 로그 범위가 압축되었습니다. 스냅샷을 받으세요",
+                "msg/script path required": "스크립트 경로가 필요합니다",
+                "msg/sender is not a member of this cluster": "보낸 노드가 이 클러스터의 구성원이 아닙니다",
+                "msg/shutting down": "종료하는 중",
+                "msg/source copy is not readable": "원본 사본을 읽을 수 없습니다",
+                "msg/target node is not connected through a tunnel on this node": "대상 노드가 이 노드의 터널로 연결되어 있지 않습니다",
+                "msg/task is for another node": "이 작업은 다른 노드용입니다",
+                "msg/the URL answered but it is not an ArozOS cluster endpoint": "URL이 응답했지만 ArozOS 클러스터 엔드포인트가 아닙니다",
+                "msg/the job has no dataset pattern": "이 작업에는 데이터 세트 패턴이 없습니다",
+                "msg/the job owner has no account on this node": "이 노드에 작업 소유자의 계정이 없습니다",
+                "msg/the mappers emitted more than 8 MB; narrow the dataset or emit fewer values": "mapper 출력이 8MB를 넘었습니다. 데이터 세트를 줄이거나 출력 값을 줄이세요",
+                "msg/the user root itself cannot be a cluster volume; pick a sub folder such as /cluster": "사용자 루트 자체는 클러스터 볼륨이 될 수 없습니다. /cluster 같은 하위 폴더를 고르세요",
+                "msg/this folder is already a cluster volume": "이 폴더는 이미 클러스터 볼륨입니다",
+                "msg/this job script defines no run(job) function": "이 작업 스크립트에 run(job) 함수가 없습니다",
+                "msg/this node cannot execute jobs": "이 노드는 작업을 실행할 수 없습니다",
+                "msg/this node has no advertised URL; generate the join token on a node that other nodes can reach": "이 노드에는 공개 URL이 없습니다. 다른 노드가 연결할 수 있는 노드에서 참가 토큰을 생성하세요",
+                "msg/this node is already part of a cluster, leave it first": "이 노드는 이미 클러스터에 속해 있습니다. 먼저 나가세요",
+                "msg/this node is not part of a cluster": "이 노드는 클러스터에 속해 있지 않습니다",
+                "msg/this node is not the identity origin of the cluster": "이 노드는 클러스터의 ID 원본이 아닙니다",
+                "msg/this node is not the metadata leader": "이 노드는 메타데이터 리더가 아닙니다",
+                "msg/timed out waiting for the job to finish": "작업 완료 대기 시간이 초과되었습니다",
+                "msg/token id required": "토큰 ID가 필요합니다",
+                "msg/token not found": "토큰을 찾을 수 없습니다",
+                "msg/tunnel closed": "터널이 닫혔습니다",
+                "msg/unknown upload session": "알 수 없는 업로드 세션입니다",
+                "msg/unreachable": "연결할 수 없음",
+                "msg/unsupported node state": "지원하지 않는 노드 상태입니다",
+                "msg/upload incomplete": "업로드가 완료되지 않았습니다",
+                "msg/url required": "URL이 필요합니다",
+                "msg/use leave to remove this node": "이 노드를 제거하려면 \"클러스터 나가기\"를 사용하세요",
+                "msg/user assertion expired": "사용자 어설션이 만료되었습니다",
+                "msg/user assertion invalid": "사용자 어설션이 올바르지 않습니다",
+                "msg/user not found on the identity origin": "ID 원본에서 사용자를 찾을 수 없습니다",
+                "msg/volume id required": "볼륨 ID가 필요합니다",
+                "msg/volume is not on this node": "볼륨이 이 노드에 없습니다",
+                "msg/volume not found": "볼륨을 찾을 수 없습니다",
+                "msg/volume not found on this node": "이 노드에서 볼륨을 찾을 수 없습니다",
+                "msgp/architecture {0} not allowed": "아키텍처 {0}은(는) 허용되지 않습니다",
+                "msgp/cancelled by {0}": "{0}이(가) 취소함",
+                "msgp/could not assemble the reduce input: {0}": "reduce 입력을 구성할 수 없습니다: {0}",
+                "msgp/could not create a map task: {0}": "map 작업을 만들 수 없습니다: {0}",
+                "msgp/could not create the reduce task: {0}": "reduce 작업을 만들 수 없습니다: {0}",
+                "msgp/drive {0} is not a local drive on this node": "드라이브 {0}은(는) 이 노드의 로컬 드라이브가 아닙니다",
+                "msgp/hook script not found: {0}": "훅 스크립트를 찾을 수 없습니다: {0}",
+                "msgp/invalid join response from {0}": "{0}의 참가 응답이 올바르지 않습니다",
+                "msgp/invalid node key encoding: {0}": "노드 키 인코딩이 올바르지 않습니다: {0}",
+                "msgp/invalid public key encoding: {0}": "공개 키 인코딩이 올바르지 않습니다: {0}",
+                "msgp/job timed out after {0}s": "작업이 {0}초 후 시간 초과되었습니다",
+                "msgp/local drive {0} is not mounted": "로컬 드라이브 {0}이(가) 마운트되지 않았습니다",
+                "msgp/missing feature {0}": "기능 {0}이(가) 없습니다",
+                "msgp/no cluster volume available; add one in System Settings > Cluster Settings > Storage ({0})": "사용할 수 있는 클러스터 볼륨이 없습니다. \"시스템 설정 > 클러스터 설정 > 저장소\"에서 추가하세요({0})",
+                "msgp/no reachable copy of: {0}": "다음 파일의 사본에 연결할 수 없습니다: {0}",
+                "msgp/node {0} could not take the job: {1}": "노드 {0}이(가) 작업을 받지 못했습니다: {1}",
+                "msgp/os {0} not allowed": "운영 체제 {0}은(는) 허용되지 않습니다",
+                "msgp/permission denied: {0}": "권한이 없습니다: {0}",
+                "msgp/remote node returned {0}: {1}": "원격 노드가 {0}을(를) 반환했습니다: {1}",
+                "msgp/script not found: {0}": "스크립트를 찾을 수 없습니다: {0}",
+                "msgp/this volume holds the only copy of {0} file(s); evacuate it first": "이 볼륨에 파일 {0}개의 유일한 사본이 있습니다. 먼저 비우세요",
+                "msgp/unable to reach {0}: {1}": "{0}에 연결할 수 없습니다: {1}",
+                "msgp/unreachable: {0}": "연결할 수 없음: {0}",
+                "msgp/unsupported node URL scheme: {0}": "지원하지 않는 노드 URL 스킴입니다: {0}",
+                "msgp/weight {0} must be a number": "가중치 {0}은(는) 숫자여야 합니다",
+                "msgp/weight {0} must be between 0 and 1": "가중치 {0}은(는) 0과 1 사이여야 합니다",
+                "msgp/{0} produced an unreadable result": "{0}이(가) 읽을 수 없는 결과를 냈습니다"
+            },
+            "placeholder": {
+                "Name (optional)": "이름(선택)",
+                "job id": "작업 ID",
+                "Nightly index": "야간 색인",
+                "50 per task": "작업당 50개"
+            },
+            "titles": {
+                "Re-detect capabilities": "기능 다시 감지"
+            }
+        }
+    }
+}

+ 21 - 0
src/web/SystemAO/locale/system_settings.json

@@ -57,6 +57,9 @@
                 "tab/cluster/Neighbourhood": "網路上的芳鄰",
                 "tab/cluster/Cluster List": "集群管理",
                 "tab/cluster/Cluster Toolbox": "集群工具",
+                "tab/cluster/Cluster Settings": "叢集設定",
+                "tab/cluster/Cluster Info": "叢集資訊",
+                "tab/cluster/Cluster Jobs": "叢集工作",
                 "tab/security/Connection Log": "連接記錄",
                 "tab/security/Access Control": "存取管理",
                 "tab/security/OAuth": "OAuth 設定",
@@ -178,6 +181,9 @@
                 "tab/cluster/Neighbourhood": "網路上的芳鄰",
                 "tab/cluster/Cluster List": "集群管理",
                 "tab/cluster/Cluster Toolbox": "集群工具",
+                "tab/cluster/Cluster Settings": "叢集設定",
+                "tab/cluster/Cluster Info": "叢集資訊",
+                "tab/cluster/Cluster Jobs": "叢集工作",
                 "tab/security/Connection Log": "連接記錄",
                 "tab/security/Access Control": "存取管理",
                 "tab/security/OAuth": "OAuth 設定",
@@ -298,6 +304,9 @@
                 "tab/cluster/Neighbourhood": "网络邻居",
                 "tab/cluster/Cluster List": "集群管理",
                 "tab/cluster/Cluster Toolbox": "集群工具",
+                "tab/cluster/Cluster Settings": "集群设置",
+                "tab/cluster/Cluster Info": "集群信息",
+                "tab/cluster/Cluster Jobs": "集群任务",
                 "tab/security/Connection Log": "连接记录",
                 "tab/security/Access Control": "访问管理",
                 "tab/security/OAuth": "OAuth 设定",
@@ -419,6 +428,9 @@
                 "tab/cluster/Neighbourhood": "Network Neighborhood",
                 "tab/cluster/Cluster List": "Cluster Management",
                 "tab/cluster/Cluster Toolbox": "Cluster Tools",
+                "tab/cluster/Cluster Settings": "Cluster Settings",
+                "tab/cluster/Cluster Info": "Cluster Info",
+                "tab/cluster/Cluster Jobs": "Cluster Jobs",
                 "tab/security/Connection Log": "Connection Log",
                 "tab/security/Access Control": "Access Control",
                 "tab/security/OAuth": "OAuth Settings",
@@ -539,6 +551,9 @@
                 "tab/cluster/Neighbourhood": "Network Neighborhood",
                 "tab/cluster/Cluster List": "Cluster Management",
                 "tab/cluster/Cluster Toolbox": "Cluster Tools",
+                "tab/cluster/Cluster Settings": "Cluster Settings",
+                "tab/cluster/Cluster Info": "Cluster Info",
+                "tab/cluster/Cluster Jobs": "Cluster Jobs",
                 "tab/security/Connection Log": "Connection Log",
                 "tab/security/Access Control": "Access Control",
                 "tab/security/OAuth": "OAuth Settings",
@@ -659,6 +674,9 @@
                 "tab/cluster/Neighbourhood": "ネットワーク近隣",
                 "tab/cluster/Cluster List": "クラスター管理",
                 "tab/cluster/Cluster Toolbox": "クラスターツール",
+                "tab/cluster/Cluster Settings": "クラスター設定",
+                "tab/cluster/Cluster Info": "クラスター情報",
+                "tab/cluster/Cluster Jobs": "クラスタージョブ",
                 "tab/security/Connection Log": "接続ログ",
                 "tab/security/Access Control": "アクセス制御",
                 "tab/security/OAuth": "OAuth設定",
@@ -728,6 +746,9 @@
                 "tab/cluster/Neighbourhood": "네트워크 이웃",
                 "tab/cluster/Cluster List": "클러스터 관리",
                 "tab/cluster/Cluster Toolbox": "클러스터 도구",
+                "tab/cluster/Cluster Settings": "클러스터 설정",
+                "tab/cluster/Cluster Info": "클러스터 정보",
+                "tab/cluster/Cluster Jobs": "클러스터 작업",
                 "tab/security/Connection Log": "연결 로그",
                 "tab/security/Access Control": "액세스 제어",
                 "tab/security/OAuth": "OAuth 설정",

部分文件因为文件数量过多而无法显示