Przeglądaj źródła

Clarify cluster volume and virtual path IDs

This change fixes path and volume identification in cluster-backed file properties and storage views by using node and volume UUIDs instead of ambiguous names. It also adds stable virtual-path cleaning for cross-platform behavior, updates AGI sorting keys to the same canonical form, and adds regression tests covering Windows-safe virtual path handling and cluster path uniqueness.
Toby Chui 1 tydzień temu
rodzic
commit
d241b540a2

+ 38 - 7
src/cluster.fsinfo.go

@@ -95,7 +95,7 @@ func (b *clusterBackend) StorageInfo(logical string) (arozfs.StorageInfo, error)
 	)
 	if master := clusterMetadata.Leader(); master != "" {
 		info.Fields = append(info.Fields,
-			arozfs.StorageInfoField{Key: "Master Node", Value: clusterNodeLabel(master)})
+			arozfs.StorageInfoField{Key: "Master Node", Value: clusterNodeLabelWithID(master)})
 	}
 
 	//One entry per physical copy, this node first so the reader sees at a
@@ -170,9 +170,10 @@ func clusterCopyItem(rec *metadata.FileRecord, loc metadata.Location, localNode
 
 	//The volume it landed on, and where that volume sits on the holding node
 	if vol, ok := clusterMetadata.Volume(loc.VolumeID); ok {
-		item.Subtitle = vol.Name
+		item.Subtitle = clusterVolumeRootText(*vol)
 		item.Fields = append(item.Fields,
 			arozfs.StorageInfoField{Key: "Volume", Value: vol.Name},
+			arozfs.StorageInfoField{Key: "Volume ID", Value: vol.ID},
 			arozfs.StorageInfoField{Key: "Volume Path", Value: clusterVolumePathText(*vol, rec.Path)},
 		)
 		if vol.Capacity > 0 {
@@ -188,7 +189,10 @@ func clusterCopyItem(rec *metadata.FileRecord, loc metadata.Location, localNode
 				arozfs.StorageInfoField{Key: "Volume Access", Value: "Read only"})
 		}
 	} else {
-		item.Subtitle = loc.VolumeID
+		//The volume record is gone, so name the copy by the ids it still has
+		item.Subtitle = loc.NodeID + ":" + loc.VolumeID
+		item.Fields = append(item.Fields,
+			arozfs.StorageInfoField{Key: "Volume ID", Value: loc.VolumeID})
 	}
 
 	if loc.VolumeID == rec.Primary {
@@ -229,11 +233,38 @@ func clusterNodeLabel(nodeID string) string {
 	return name
 }
 
-// clusterVolumePathText is where the copy sits on the node holding it, in the
-// virtual path form that node uses.
+// clusterNodeLabelWithID names a node and states its ID. Node names are
+// operator set and default to the same "My ArOZ" on every host, so a name on
+// its own cannot say which node is meant.
+func clusterNodeLabelWithID(nodeID string) string {
+	name := clusterManager.NodeName(nodeID)
+	if strings.TrimSpace(name) == "" || name == nodeID {
+		return nodeID
+	}
+	return name + " (" + nodeID + ")"
+}
+
+/*
+	Paths across the cluster
+
+	A path is only an answer if it says which node it is on, and the only
+	thing that identifies a node is its UUID: names repeat, and the default
+	name is the same on every fresh install. So every path this report shows
+	is written <node uuid>:<drive uuid>/<path on that drive>, e.g.
+
+		3f7a...c1:user/cluster/photos/a.jpg
+
+	which reads as: on node 3f7a…c1, drive user, at /cluster/photos/a.jpg.
+*/
+
+// clusterVolumeRootText is the folder a volume contributes, node included.
+func clusterVolumeRootText(vol metadata.Volume) string {
+	return vol.NodeID + ":" + vol.FshUUID + path.Join("/", vol.Subpath)
+}
+
+// clusterVolumePathText is where one copy of a file sits, node included.
 func clusterVolumePathText(vol metadata.Volume, logical string) string {
-	root := vol.FshUUID + ":" + path.Join("/", vol.Subpath)
-	return path.Join(root, logical)
+	return vol.NodeID + ":" + vol.FshUUID + path.Join("/", vol.Subpath, logical)
 }
 
 // clusterReplicaPolicyField reports how many copies of each file the folder

+ 19 - 4
src/cluster.fsinfo_test.go

@@ -135,17 +135,32 @@ func TestClusterCopyCountField(t *testing.T) {
 	}
 }
 
+// A node name is operator set and defaults to the same "My ArOZ" on every
+// host, so a path is only an answer when it names the node by its UUID:
+// <node uuid>:<drive uuid>/<path on that drive>.
 func TestClusterVolumePathText(t *testing.T) {
-	vol := metadata.Volume{FshUUID: "user", Subpath: "/cluster"}
-	if got := clusterVolumePathText(vol, "/docs/a.txt"); got != "user:/cluster/docs/a.txt" {
+	vol := metadata.Volume{NodeID: "node-a-test", FshUUID: "user", Subpath: "/cluster"}
+	if got := clusterVolumePathText(vol, "/docs/a.txt"); got != "node-a-test:user/cluster/docs/a.txt" {
 		t.Errorf("Expected the path on the holding node, got %q", got)
 	}
+	if got := clusterVolumeRootText(vol); got != "node-a-test:user/cluster" {
+		t.Errorf("Expected the volume root, got %q", got)
+	}
 
 	//A volume contributed at the root of its drive
-	root := metadata.Volume{FshUUID: "s1", Subpath: ""}
-	if got := clusterVolumePathText(root, "/a.txt"); got != "s1:/a.txt" {
+	root := metadata.Volume{NodeID: "node-b-test", FshUUID: "s1", Subpath: ""}
+	if got := clusterVolumePathText(root, "/a.txt"); got != "node-b-test:s1/a.txt" {
 		t.Errorf("Expected a root volume path, got %q", got)
 	}
+	if got := clusterVolumeRootText(root); got != "node-b-test:s1/" {
+		t.Errorf("Expected the root of a whole drive, got %q", got)
+	}
+
+	//Two nodes of the same name contributing the same folder stay apart
+	twin := metadata.Volume{NodeID: "node-c-test", FshUUID: "user", Subpath: "/cluster"}
+	if clusterVolumePathText(vol, "/docs/a.txt") == clusterVolumePathText(twin, "/docs/a.txt") {
+		t.Error("Expected the same folder on two nodes to give two different paths")
+	}
 }
 
 func TestClusterChecksumText(t *testing.T) {

+ 17 - 3
src/file_system.go

@@ -17,6 +17,7 @@ import (
 	"sync"
 	"sync/atomic"
 
+	"path"
 	"path/filepath"
 	"runtime"
 	"sort"
@@ -2886,6 +2887,19 @@ func system_fs_specialURIEncode(inputPath string) string {
 }
 */
 
+/*
+The parent folder of a virtual path, as shown in the properties dialog.
+
+A virtual path is always slash separated and carries a "vdID:" prefix, so
+filepath.Dir must not be used here: on Windows it reads "user:" as a volume
+name and Clean guards the result with a "./" prefix, turning
+"user:/cluster/a.agi" into "./user:/cluster". GetIDFromVirtualPath strips
+that prefix back off for the same reason.
+*/
+func virtualDirname(vpath string) string {
+	return path.Dir(vpath)
+}
+
 // Handle file properties request
 func system_fs_getFileProperties(w http.ResponseWriter, r *http.Request) {
 	type fileProperties struct {
@@ -3008,7 +3022,7 @@ func system_fs_getFileProperties(w http.ResponseWriter, r *http.Request) {
 		VirtualPath:    vpath,
 		StoragePath:    filepath.ToSlash(filepath.Clean(rpath)),
 		Basename:       filepath.Base(rpath),
-		VirtualDirname: filepath.ToSlash(filepath.Dir(vpath)),
+		VirtualDirname: virtualDirname(vpath),
 		StorageDirname: filepath.ToSlash(filepath.Dir(rpath)),
 		Ext:            filepath.Ext(rpath),
 		MimeType:       fileMime,
@@ -3635,7 +3649,7 @@ func system_fs_handleCacheRender(w http.ResponseWriter, r *http.Request) {
 
 	//Get folder sort mode
 	sortMode := "default"
-	folder := filepath.ToSlash(filepath.Clean(vpath))
+	folder := arozfs.Clean(vpath)
 	if sysdb.KeyExists("fs-sortpref", userinfo.Username+"/"+folder) {
 		sysdb.Read("fs-sortpref", userinfo.Username+"/"+folder, &sortMode)
 	}
@@ -3729,7 +3743,7 @@ func system_fs_handleFolderSortModePreference(w http.ResponseWriter, r *http.Req
 
 	opr, _ := utils.PostPara(r, "opr")
 
-	folder = filepath.ToSlash(filepath.Clean(folder))
+	folder = arozfs.Clean(folder)
 
 	if opr == "" || opr == "get" {
 		sortMode := "default"

+ 65 - 0
src/file_system_vpath_test.go

@@ -0,0 +1,65 @@
+package main
+
+import (
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+)
+
+/*
+virtualDirname feeds the "Location" row of the file properties dialog.
+
+It must stay lexical and slash based. filepath.Dir looks correct on Linux
+but mangles a virtual path on Windows, which is the regression this guards.
+*/
+func TestVirtualDirname(t *testing.T) {
+	tests := []struct {
+		name  string
+		vpath string
+		want  string
+	}{
+		{"nested file", "user:/cluster/hello_world.job.agi", "user:/cluster"},
+		{"nested folder", "user:/Desktop/Photos", "user:/Desktop"},
+		{"file at root of vroot", "user:/a.txt", "user:"},
+		{"deep path", "tmp:/a/b/c/d.txt", "tmp:/a/b/c"},
+		{"name with plus and space", "user:/My +Stuff/a b.mkv", "user:/My +Stuff"},
+		{"other vroot id", "cluster:/shared/report.pdf", "cluster:/shared"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := virtualDirname(tt.vpath)
+			if got != tt.want {
+				t.Errorf("virtualDirname(%q) = %q, want %q", tt.vpath, got, tt.want)
+			}
+			if strings.HasPrefix(got, "./") {
+				t.Errorf("virtualDirname(%q) = %q, must not carry a \"./\" prefix", tt.vpath, got)
+			}
+			if strings.Contains(got, "\\") {
+				t.Errorf("virtualDirname(%q) = %q, must stay slash separated", tt.vpath, got)
+			}
+		})
+	}
+}
+
+// Documents why filepath.Dir cannot be used on a virtual path. It is the exact
+// call this fix replaced, and it only misbehaves on Windows.
+func TestFilepathDirIsUnsafeForVirtualPathOnWindows(t *testing.T) {
+	const vpath = "user:/cluster/hello_world.job.agi"
+	got := filepath.ToSlash(filepath.Dir(vpath))
+
+	if runtime.GOOS == "windows" {
+		if !strings.HasPrefix(got, "./") {
+			t.Skipf("filepath.Dir no longer prefixes %q with \"./\" on this Go version (got %q); virtualDirname stays correct either way", vpath, got)
+		}
+		if virtualDirname(vpath) == got {
+			t.Errorf("virtualDirname must not reproduce filepath.Dir's %q", got)
+		}
+		return
+	}
+
+	if got != virtualDirname(vpath) {
+		t.Errorf("on %s filepath.Dir and virtualDirname should agree, got %q vs %q", runtime.GOOS, got, virtualDirname(vpath))
+	}
+}

+ 10 - 6
src/mod/agi/agi.file.go

@@ -14,6 +14,7 @@ import (
 	"github.com/robertkrimen/otto"
 
 	"imuslab.com/arozos/mod/agi/static"
+	"imuslab.com/arozos/mod/filesystem/arozfs"
 	"imuslab.com/arozos/mod/filesystem/fssort"
 	"imuslab.com/arozos/mod/filesystem/hidden"
 	"imuslab.com/arozos/mod/info/logger"
@@ -275,8 +276,9 @@ func (g *Gateway) injectFileLibFunctions(payload *static.AgiLibInjectionPayload)
 			//Rewrite and validate the sort mode
 			if userSortMode == "user" {
 				//Use user sorting mode.
-				if g.Option.UserHandler.GetDatabase().KeyExists("fs-sortpref", u.Username+"/"+filepath.ToSlash(filepath.Clean(vrootPath))) {
-					g.Option.UserHandler.GetDatabase().Read("fs-sortpref", u.Username+"/"+filepath.ToSlash(filepath.Clean(vrootPath)), &userSortMode)
+				sortPrefKey := u.Username + "/" + arozfs.Clean(vrootPath)
+				if g.Option.UserHandler.GetDatabase().KeyExists("fs-sortpref", sortPrefKey) {
+					g.Option.UserHandler.GetDatabase().Read("fs-sortpref", sortPrefKey, &userSortMode)
 				} else {
 					userSortMode = "default"
 				}
@@ -353,8 +355,9 @@ func (g *Gateway) injectFileLibFunctions(payload *static.AgiLibInjectionPayload)
 		//Rewrite and validate the sort mode
 		if userSortMode == "user" {
 			//Use user sorting mode.
-			if g.Option.UserHandler.GetDatabase().KeyExists("fs-sortpref", u.Username+"/"+filepath.ToSlash(filepath.Clean(vrootPath))) {
-				g.Option.UserHandler.GetDatabase().Read("fs-sortpref", u.Username+"/"+filepath.ToSlash(filepath.Clean(vrootPath)), &userSortMode)
+			sortPrefKey := u.Username + "/" + arozfs.Clean(vrootPath)
+			if g.Option.UserHandler.GetDatabase().KeyExists("fs-sortpref", sortPrefKey) {
+				g.Option.UserHandler.GetDatabase().Read("fs-sortpref", sortPrefKey, &userSortMode)
 			} else {
 				userSortMode = "default"
 			}
@@ -430,8 +433,9 @@ func (g *Gateway) injectFileLibFunctions(payload *static.AgiLibInjectionPayload)
 		//Rewrite and validate the sort mode
 		if userSortMode == "user" {
 			//Use user sorting mode.
-			if g.Option.UserHandler.GetDatabase().KeyExists("fs-sortpref", u.Username+"/"+filepath.ToSlash(filepath.Clean(vpath))) {
-				g.Option.UserHandler.GetDatabase().Read("fs-sortpref", u.Username+"/"+filepath.ToSlash(filepath.Clean(vpath)), &userSortMode)
+			sortPrefKey := u.Username + "/" + arozfs.Clean(vpath)
+			if g.Option.UserHandler.GetDatabase().KeyExists("fs-sortpref", sortPrefKey) {
+				g.Option.UserHandler.GetDatabase().Read("fs-sortpref", sortPrefKey, &userSortMode)
 			} else {
 				userSortMode = "default"
 			}

+ 40 - 0
src/mod/cluster/README.md

@@ -89,6 +89,46 @@ well under Cloudflare's 100 MB request limit.
 | `POST /cluster/acn/evict` | signed | sender removed the receiver |
 | `GET /cluster/acn/latency` | signed | sender's round trips to its own peers |
 
+## Identifiers
+
+Nothing in the cluster is addressed by a name. Names are operator set and
+every fresh install answers to the same `My ArOZ`, so two members can carry
+the same name and the same volume name at once; only the IDs below are
+unique, and they are what records, paths and the UI point at.
+
+| Thing | Identifier | Where it comes from | Unique within | Notes |
+|---|---|---|---|---|
+| Cluster | `ClusterInfo.ID` (UUID v4) | `CreateCluster`, carried in join tokens | everywhere | `Name` is a label only |
+| Node | `NodeRecord.ID` = the host's device UUID | `system/dev.uuid`, or the `-uuid` start flag | the cluster | the key of every node record, volume and file copy |
+| Node identity | Ed25519 key pair | `system/cluster/node.key` (`acn.LoadOrCreateNodeKey`) | everywhere | signs every ACN request; the public key rides in the node record |
+| Node name | `NodeRecord.Name` | `-hostname`, default `My ArOZ` | **not unique** | display only, never an address |
+| Drive on a node | `FileSystemHandler.UUID`, e.g. `user`, `s1` | that node's storage config | **that node only** | every node has a `user` drive |
+| Volume | `Volume.ID` (UUID v4) | `AddVolume` | everywhere | what a file copy points at |
+| Volume location | `NodeID` + `FshUUID` + `Subpath` | `AddVolume` (refuses a duplicate) | everywhere | the folder actually holding the files |
+| Volume name | `Volume.Name`, default `<drive>:<subpath>` | `AddVolume` | **not unique** | display only |
+| File | `FileRecord.ID` (UUID v4), keyed by `Path` | first write of that path | the cluster | the path is the namespace key, the ID survives a rename |
+| Copy | `VolumeID` (+ its `NodeID`) inside the record | placement / replication | per file | one entry per physical copy |
+| Job | `Job.ID` (UUID v4) | job submit | the cluster | |
+
+Because a drive UUID only means something on one node, **every path the
+cluster shows is written node first**:
+
+```
+<node uuid>:<drive uuid>/<path on that drive>
+3f7a…c1:user/cluster/photos/a.jpg
+```
+
+That is the form used by the copy list in the File Manager properties dialog
+(`clusterVolumePathText` in `src/cluster.fsinfo.go`), by the volume list on
+the Cluster Settings page (`CL.nodePath`) and by anything else that has to
+say where a file physically is. Two nodes contributing `user:/cluster` are
+therefore still told apart, whatever they are called.
+
+A cloned installation is the one way to break this: copying `system/dev.uuid`
+to a second host gives two members the same node ID, and the membership
+records, which are keyed by it, will merge into one. Delete `system/dev.uuid`
+(and `system/cluster/`) on the clone before it joins.
+
 ## Membership
 
 Each node owns one `NodeRecord` (ID, name, public key, advertised URL, tunnel

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

@@ -103,7 +103,11 @@ func (s *Service) AddVolume(fshUUID string, subpath string, name string) (*metad
 		return nil, err
 	}
 	if strings.TrimSpace(name) == "" {
-		name = s.m.NodeName(s.m.NodeID()) + " " + fshUUID + ":" + subpath
+		//The node is named by its ID wherever a volume is pointed at, so the
+		//default name says nothing about which node it is on: node names are
+		//operator set and every fresh install answers to "My ArOZ", which
+		//would make two volumes of the same folder indistinguishable.
+		name = fshUUID + ":" + subpath
 	}
 	vol := &metadata.Volume{
 		ID:      uuid.NewV4().String(),

+ 12 - 0
src/mod/filesystem/arozfs/arozfs.go

@@ -10,6 +10,7 @@ import (
 	"errors"
 	"io"
 	"io/fs"
+	"path"
 	"path/filepath"
 	"regexp"
 	"strings"
@@ -165,6 +166,17 @@ func ToSlash(filename string) string {
 	return strings.ReplaceAll(filename, "\\", "/")
 }
 
+// Clean is the virtual path counterpart of filepath.Clean.
+//
+// A virtual path is always slash separated and starts with a "vdID:" prefix,
+// which filepath.Clean cannot be trusted with: on Windows it reads "user:" as
+// a volume name and guards the result with a "./" prefix, so the same path
+// normalises differently there than on Linux. Going through path.Clean keeps
+// the result identical on every platform.
+func Clean(filename string) string {
+	return path.Clean(ToSlash(filename))
+}
+
 func Base(filename string) string {
 	filename = ToSlash(filename)
 	if filename == "" {

+ 61 - 0
src/mod/filesystem/arozfs/arozfs_test.go

@@ -363,3 +363,64 @@ func TestGenericRealPathToVirtualPathTranslator_PublicHierarchy(t *testing.T) {
 		t.Errorf("expected result to start with UUID prefix, got %q", result)
 	}
 }
+
+// --- Clean ---
+
+/*
+Clean normalises a virtual path identically on every platform.
+
+filepath.Clean cannot be used on a vpath: on Windows it reads the "user:"
+vdID as a volume name and guards the result with a "./" prefix, so the same
+path would key differently there than on Linux.
+*/
+func TestClean_VirtualPathCases(t *testing.T) {
+	tests := []struct {
+		name  string
+		given string
+		want  string
+	}{
+		{"already clean", "user:/cluster", "user:/cluster"},
+		{"nested file", "user:/cluster/hello_world.job.agi", "user:/cluster/hello_world.job.agi"},
+		{"trailing slash", "user:/Desktop/", "user:/Desktop"},
+		{"duplicated slashes", "user://Desktop///Photos", "user:/Desktop/Photos"},
+		{"dot segment", "user:/Desktop/./Photos", "user:/Desktop/Photos"},
+		{"backslashes converted", "user:\\Desktop\\Photos", "user:/Desktop/Photos"},
+		{"other vroot id", "cluster:/shared/report.pdf", "cluster:/shared/report.pdf"},
+		{"name with plus", "user:/My +Stuff/a b.mkv", "user:/My +Stuff/a b.mkv"},
+		{"vroot only", "user:/", "user:"},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := Clean(tt.given)
+			if got != tt.want {
+				t.Errorf("Clean(%q) = %q, want %q", tt.given, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestClean_NeverAddsDotSlashPrefix(t *testing.T) {
+	for _, vpath := range []string{
+		"user:/cluster/a.agi",
+		"tmp:/x/y.txt",
+		"cluster:/shared",
+	} {
+		if got := Clean(vpath); strings.HasPrefix(got, "./") {
+			t.Errorf("Clean(%q) = %q, must not carry a \"./\" prefix", vpath, got)
+		}
+	}
+}
+
+func TestClean_NeverReturnsBackslash(t *testing.T) {
+	got := Clean("user:\\Desktop\\Photos\\a.png")
+	if strings.Contains(got, "\\") {
+		t.Errorf("Clean returned %q, must stay slash separated", got)
+	}
+}
+
+func TestClean_Empty(t *testing.T) {
+	if got := Clean(""); got != "." {
+		t.Errorf("Clean(\"\") = %q, want %q", got, ".")
+	}
+}

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

@@ -155,6 +155,21 @@ var CL = (function () {
         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>';
     }
+    /*
+        Where something is, across the cluster.
+
+        Only the node UUID identifies a node: names are operator set and every
+        fresh install answers to the same one, so a path that names the node
+        by name cannot be acted on. Paths are written
+
+            <node uuid>:<drive uuid>/<path on that drive>
+    */
+    function nodePath(nodeId, fshUuid, subpath) {
+        var tail = String(subpath || '');
+        if (tail.charAt(0) != '/') { tail = '/' + tail; }
+        return String(nodeId) + ':' + String(fshUuid) + tail;
+    }
+
     //Fill {0}, {1}, ... in a translated string
     function fmt(template) {
         var args = Array.prototype.slice.call(arguments, 1);
@@ -221,6 +236,7 @@ var CL = (function () {
         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,
+        nodePath: nodePath,
         apiPost: apiPost, apiResult: apiResult, showMsg: showMsg
     };
 })();

+ 3 - 1
src/web/SystemAO/cluster/cluster.html

@@ -400,7 +400,9 @@
                 if (v.lowSpace) flags += ' <span class="cl-chip">' + esc(t('cluster/vol/full', 'nearly full')) + '</span>';
                 else if (v.readOnly) flags += ' <span class="cl-chip">' + esc(t('cluster/vol/ro', 'read only')) + '</span>';
                 var pct = v.capacity > 0 ? (v.capacity - v.free) / v.capacity * 100 : 0;
-                rows += '<tr><td><div class="cl-node-name">' + esc(v.name) + flags + '</div><div class="cl-node-meta cl-mono">' + esc(v.fshUuid) + ':' + esc(v.subpath) + '</div></td>' +
+                //The node is named by its ID here: node names are operator set
+                //and default to the same one on every host
+                rows += '<tr><td><div class="cl-node-name">' + esc(v.name) + flags + '</div><div class="cl-node-meta cl-mono">' + esc(CL.nodePath(v.nodeId, v.fshUuid, v.subpath)) + '</div></td>' +
                     '<td>' + esc(v.nodeName) + (v.local ? ' <span class="cl-chip">' + esc(t('cluster/you', 'you')) + '</span>' : '') + '<div class="cl-node-meta"><span class="cl-badge ' + (v.online ? 'ONLINE' : 'OFFLINE') + '">' + esc(CL.state(v.online ? 'online' : 'offline')) + '</span></div></td>' +
                     '<td>' + CL.fmtBytes(v.free) + ' / ' + CL.fmtBytes(v.capacity) + CL.bar(pct) + '</td>' +
                     '<td>' + v.files + '<div class="cl-node-meta">' + CL.fmtBytes(v.bytes) + '</div></td>' +

+ 3 - 1
src/web/SystemAO/cluster/clusterinfo.html

@@ -339,7 +339,9 @@
             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>';
+                //Named by ID as well: two nodes may answer to the same name
+                html += '<li><span>' + esc(nodeName(l.nodeId)) + (l.volumeId === rec.primary ? ' <span class="cl-chip">' + esc(t('cluster/repl/primary', 'primary')) + '</span>' : '') +
+                    '<div class="cl-node-meta cl-mono">' + esc(l.nodeId) + '</div></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;

+ 12 - 6
src/web/SystemAO/locale/file_properties.json

@@ -132,7 +132,8 @@
                 "storage/value/Not a member": "非叢集成員",
                 "storage/value/Read only": "唯讀",
                 "storage/value/Evacuating": "疏散中",
-                "storage/value/Yes": "是"
+                "storage/value/Yes": "是",
+                "storage/key/Volume ID": "儲存區 ID"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "此伺服器不支援遞迴變更存取權限"
@@ -268,7 +269,8 @@
                 "storage/value/Not a member": "非叢集成員",
                 "storage/value/Read only": "唯讀",
                 "storage/value/Evacuating": "疏散中",
-                "storage/value/Yes": "是"
+                "storage/value/Yes": "是",
+                "storage/key/Volume ID": "儲存區 ID"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "此伺服器不支援遞迴變更存取權限"
@@ -404,7 +406,8 @@
                 "storage/value/Not a member": "非集群成员",
                 "storage/value/Read only": "只读",
                 "storage/value/Evacuating": "疏散中",
-                "storage/value/Yes": "是"
+                "storage/value/Yes": "是",
+                "storage/key/Volume ID": "存储区 ID"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "此服务器不支持递归更改存取权限"
@@ -540,7 +543,8 @@
                 "storage/value/Not a member": "Not a member",
                 "storage/value/Read only": "Read only",
                 "storage/value/Evacuating": "Evacuating",
-                "storage/value/Yes": "Yes"
+                "storage/value/Yes": "Yes",
+                "storage/key/Volume ID": "Volume ID"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "Recursive permission change is not supported by this server"
@@ -676,7 +680,8 @@
                 "storage/value/Not a member": "メンバーではありません",
                 "storage/value/Read only": "読み取り専用",
                 "storage/value/Evacuating": "退避中",
-                "storage/value/Yes": "はい"
+                "storage/value/Yes": "はい",
+                "storage/key/Volume ID": "ボリューム ID"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "このサーバーは権限の再帰的な変更に対応していません"
@@ -812,7 +817,8 @@
                 "storage/value/Not a member": "구성원 아님",
                 "storage/value/Read only": "읽기 전용",
                 "storage/value/Evacuating": "이전 중",
-                "storage/value/Yes": "예"
+                "storage/value/Yes": "예",
+                "storage/key/Volume ID": "볼륨 ID"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "이 서버는 권한의 재귀 변경을 지원하지 않습니다"