Преглед изворни кода

Cluster storage info and nightly cleanup

This change makes cluster-owned storage behave like a first-class file system: nightly trash/version cleanup now runs only on the cluster master for shared cluster:/ volumes, while local drives keep their own per-node maintenance. The cluster now reports its master-node status through the nightly task manager and applies the same rule when clearing expired items or version history.

It also adds a generic storage-info API for file system abstractions. The File Manager properties dialog can now show a Storage tab for cluster files, listing each physical copy with its node, volume, state, replica policy, checksum and related metadata. The cluster backend and abstraction wiring implement the provider, and the UI/locale updates render the copy cards in a consistent way. Tests cover the master-node logic, ordering of replicas and the storage-info flow.
Toby Chui пре 1 недеља
родитељ
комит
608bdd2bf1

+ 7 - 0
CLAUDE.md

@@ -300,6 +300,13 @@ before changing how a phase works, but start new work from the packages below.
   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.
+  Nightly maintenance of a drive shared by the cluster belongs to the master
+  node (the metadata leader) alone, through
+  `nightly.TaskOption{MasterNodeOnly: true}` and `nightlyShouldMaintainFsh`,
+  so expired trash and old version history are not swept once per member.
+  The abstraction also answers `arozfs.StorageInfoProvider`, which is what
+  puts the copies of a file, their nodes, volumes and states in the File
+  Manager properties dialog (`src/cluster.fsinfo.go`).
   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

+ 278 - 0
src/cluster.fsinfo.go

@@ -0,0 +1,278 @@
+package main
+
+/*
+	Where a cluster:/ file physically lives.
+
+	The cluster drive presents one namespace, but every file in it sits on
+	one or more real volumes on real nodes, and which those are changes over
+	time as the replication planner works. Nothing in an ordinary file
+	listing shows that, so this file answers it: given a namespace path, it
+	reads the metadata record and turns the record, the volumes it points at
+	and the state of the nodes holding them into the generic shape the File
+	Manager properties dialog renders (mod/filesystem/arozfs/storageinfo.go).
+
+	It is a read of the local replica of the metadata store, so it costs
+	nothing and works even while some of the nodes involved are offline.
+*/
+
+import (
+	"errors"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"imuslab.com/arozos/mod/cluster/membership"
+	"imuslab.com/arozos/mod/cluster/metadata"
+	fs "imuslab.com/arozos/mod/filesystem"
+	"imuslab.com/arozos/mod/filesystem/arozfs"
+)
+
+// Copy states in the words the dialog shows, and the colour each one gets
+var clusterCopyStatus = map[string][2]string{
+	metadata.LocVerified:  {"Verified", arozfs.StorageStateOK},
+	metadata.LocCommitted: {"Committed", arozfs.StorageStateOK},
+	metadata.LocPending:   {"Pending", arozfs.StorageStatePending},
+	metadata.LocWriting:   {"Writing", arozfs.StorageStatePending},
+	metadata.LocStale:     {"Stale", arozfs.StorageStateStale},
+	metadata.LocFailed:    {"Failed", arozfs.StorageStateError},
+}
+
+// Node states in the words the dialog shows
+var clusterNodeStateWords = map[membership.NodeState]string{
+	membership.StateOnline:      "Online",
+	membership.StateDegraded:    "Degraded",
+	membership.StateOffline:     "Offline",
+	membership.StateDraining:    "Draining",
+	membership.StateMaintenance: "Maintenance",
+	membership.StateUnknown:     "Unknown",
+}
+
+// StorageInfo resolves one namespace path into the copies that hold it. It
+// completes the clusterfs.InfoBackend interface.
+func (b *clusterBackend) StorageInfo(logical string) (arozfs.StorageInfo, error) {
+	if clusterManager == nil || clusterMetadata == nil || !clusterManager.InCluster() {
+		return arozfs.StorageInfo{}, errors.New("this node is not in a cluster")
+	}
+
+	rec, err := clusterMetadata.Stat(logical)
+	if err != nil {
+		return arozfs.StorageInfo{}, err
+	}
+
+	info := arozfs.StorageInfo{
+		Type:  "cluster",
+		Title: "Cluster",
+	}
+
+	//Properties of the file itself
+	clusterName := ""
+	if c := clusterManager.Cluster(); c != nil {
+		clusterName = c.Name
+	}
+	policy := clusterMetadata.PolicyFor(rec.Path)
+	info.Fields = append(info.Fields,
+		arozfs.StorageInfoField{Key: "Cluster", Value: clusterName},
+		arozfs.StorageInfoField{Key: "Namespace Path", Value: rec.Path},
+	)
+
+	if rec.IsDir {
+		//A folder holds no copies of its own: what it decides is how many
+		//copies the files created inside it are kept at
+		info.Fields = append(info.Fields,
+			clusterReplicaPolicyField(policy),
+			arozfs.StorageInfoField{Key: "Items Inside", Value: clusterFolderItemCount(rec.Path)},
+		)
+		return info, nil
+	}
+
+	healthy := len(rec.HealthyLocations())
+	info.Fields = append(info.Fields,
+		clusterReplicaPolicyField(policy),
+		clusterCopyCountField(healthy, len(rec.Locations)),
+		arozfs.StorageInfoField{Key: "Checksum", Value: clusterChecksumText(rec.Checksum)},
+		arozfs.StorageInfoField{Key: "File ID", Value: rec.ID},
+	)
+	if master := clusterMetadata.Leader(); master != "" {
+		info.Fields = append(info.Fields,
+			arozfs.StorageInfoField{Key: "Master Node", Value: clusterNodeLabel(master)})
+	}
+
+	//One entry per physical copy, this node first so the reader sees at a
+	//glance whether the file is on the host they are logged in to
+	localNode := clusterManager.NodeID()
+	for _, loc := range clusterOrderedLocations(rec, localNode) {
+		info.Items = append(info.Items, clusterCopyItem(rec, loc, localNode))
+	}
+
+	return info, nil
+}
+
+// clusterOrderedLocations lists the copies of a file with this node first,
+// then the readable ones, keeping the record order within each group.
+func clusterOrderedLocations(rec *metadata.FileRecord, localNode string) []metadata.Location {
+	ordered := []metadata.Location{}
+	for pass := 0; pass < 3; pass++ {
+		for _, loc := range rec.Locations {
+			isLocal := loc.NodeID == localNode
+			switch pass {
+			case 0:
+				if !isLocal {
+					continue
+				}
+			case 1:
+				if isLocal || !loc.Healthy() {
+					continue
+				}
+			default:
+				if isLocal || loc.Healthy() {
+					continue
+				}
+			}
+			ordered = append(ordered, loc)
+		}
+	}
+	return ordered
+}
+
+// clusterCopyItem describes one physical copy of a file.
+func clusterCopyItem(rec *metadata.FileRecord, loc metadata.Location, localNode string) arozfs.StorageInfoItem {
+	status, state := "Unknown", arozfs.StorageStateUnknown
+	if words, ok := clusterCopyStatus[loc.State]; ok {
+		status, state = words[0], words[1]
+	}
+
+	item := arozfs.StorageInfoItem{
+		Title:  clusterNodeLabel(loc.NodeID),
+		State:  state,
+		Status: status,
+		Local:  loc.NodeID == localNode,
+		Fields: []arozfs.StorageInfoField{{Key: "Node ID", Value: loc.NodeID}},
+	}
+
+	//Node health, as an offline node explains a copy that cannot be read
+	if nodeState, found := clusterNodeState(loc.NodeID); found {
+		words, ok := clusterNodeStateWords[nodeState]
+		if !ok {
+			words = string(nodeState)
+		}
+		item.Fields = append(item.Fields, arozfs.StorageInfoField{Key: "Node State", Value: words})
+		if state == arozfs.StorageStateOK && nodeState != membership.StateOnline &&
+			nodeState != membership.StateDegraded {
+			//The copy itself is fine, the node holding it is not
+			item.State = arozfs.StorageStateStale
+		}
+	} else {
+		item.Fields = append(item.Fields,
+			arozfs.StorageInfoField{Key: "Node State", Value: "Not a member"})
+		item.State = arozfs.StorageStateStale
+	}
+
+	//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.Fields = append(item.Fields,
+			arozfs.StorageInfoField{Key: "Volume", Value: vol.Name},
+			arozfs.StorageInfoField{Key: "Volume Path", Value: clusterVolumePathText(*vol, rec.Path)},
+		)
+		if vol.Capacity > 0 {
+			item.Fields = append(item.Fields, arozfs.StorageInfoField{Key: "Volume Free",
+				Value: "{0} of {1}",
+				Args:  []string{fs.GetFileDisplaySize(vol.Free, 2), fs.GetFileDisplaySize(vol.Capacity, 2)}})
+		}
+		if vol.Evacuating {
+			item.Fields = append(item.Fields,
+				arozfs.StorageInfoField{Key: "Volume Access", Value: "Evacuating"})
+		} else if vol.ReadOnly || vol.LowSpace {
+			item.Fields = append(item.Fields,
+				arozfs.StorageInfoField{Key: "Volume Access", Value: "Read only"})
+		}
+	} else {
+		item.Subtitle = loc.VolumeID
+	}
+
+	if loc.VolumeID == rec.Primary {
+		item.Fields = append(item.Fields, arozfs.StorageInfoField{Key: "Primary Copy", Value: "Yes"})
+	}
+
+	//A copy whose hash no longer matches the file is worth seeing
+	if loc.Checksum != "" && rec.Checksum != "" && loc.Checksum != rec.Checksum {
+		item.Fields = append(item.Fields,
+			arozfs.StorageInfoField{Key: "Checksum", Value: clusterChecksumText(loc.Checksum)})
+	}
+
+	if loc.Updated > 0 {
+		item.Fields = append(item.Fields, arozfs.StorageInfoField{Key: "Last Checked",
+			Value: time.Unix(loc.Updated, 0).Format("2006-01-02 15:04:05")})
+	}
+
+	return item
+}
+
+// clusterNodeState returns the computed state of a node, and whether the node
+// is still a member at all.
+func clusterNodeState(nodeID string) (membership.NodeState, bool) {
+	for _, n := range clusterManager.NodeViews() {
+		if n.ID == nodeID {
+			return n.State, true
+		}
+	}
+	return membership.StateUnknown, false
+}
+
+// clusterNodeLabel names a node, falling back to its ID when it has no name.
+func clusterNodeLabel(nodeID string) string {
+	name := clusterManager.NodeName(nodeID)
+	if strings.TrimSpace(name) == "" {
+		return nodeID
+	}
+	return name
+}
+
+// clusterVolumePathText is where the copy sits on the node holding it, in the
+// virtual path form that node uses.
+func clusterVolumePathText(vol metadata.Volume, logical string) string {
+	root := vol.FshUUID + ":" + path.Join("/", vol.Subpath)
+	return path.Join(root, logical)
+}
+
+// clusterReplicaPolicyField reports how many copies of each file the folder
+// policy asks for.
+func clusterReplicaPolicyField(policy metadata.Policy) arozfs.StorageInfoField {
+	if policy.Replicas <= 1 {
+		return arozfs.StorageInfoField{Key: "Replica Policy", Value: "{0} copy",
+			Args: []string{"1"}}
+	}
+	return arozfs.StorageInfoField{Key: "Replica Policy", Value: "{0} copies",
+		Args: []string{strconv.Itoa(policy.Replicas)}}
+}
+
+// clusterCopyCountField reports how many copies of a file can be read now.
+func clusterCopyCountField(healthy int, total int) arozfs.StorageInfoField {
+	if healthy == total {
+		return arozfs.StorageInfoField{Key: "Copies", Value: "{0}",
+			Args: []string{strconv.Itoa(total)}}
+	}
+	return arozfs.StorageInfoField{Key: "Copies", Value: "{0} of {1} readable",
+		Args: []string{strconv.Itoa(healthy), strconv.Itoa(total)}}
+}
+
+// clusterChecksumText shortens a hash to something a dialog row can hold.
+func clusterChecksumText(checksum string) string {
+	if checksum == "" {
+		return ""
+	}
+	if len(checksum) > 24 {
+		return checksum[:24] + "..."
+	}
+	return checksum
+}
+
+// clusterFolderItemCount counts the records directly inside a folder.
+func clusterFolderItemCount(folder string) string {
+	entries, err := clusterMetadata.ListDir(folder)
+	if err != nil {
+		return "0"
+	}
+	return strconv.Itoa(len(entries))
+}

+ 181 - 0
src/cluster.fsinfo_test.go

@@ -0,0 +1,181 @@
+package main
+
+/*
+	Tests for the cluster side of two features that cross into the core:
+
+	- which node performs nightly maintenance of a file system handler
+	- how a cluster file record is turned into the rows and locations the
+	  File Manager properties dialog shows
+*/
+
+import (
+	"strings"
+	"testing"
+
+	"imuslab.com/arozos/mod/cluster/metadata"
+	fs "imuslab.com/arozos/mod/filesystem"
+	"imuslab.com/arozos/mod/filesystem/arozfs"
+	"imuslab.com/arozos/mod/time/nightly"
+)
+
+func TestNightlyFshOption(t *testing.T) {
+	testcases := []struct {
+		name       string
+		fsh        *fs.FileSystemHandler
+		masterOnly bool
+	}{
+		{"cluster drive", &fs.FileSystemHandler{Filesystem: "cluster"}, true},
+		{"local disk", &fs.FileSystemHandler{Filesystem: "ext4"}, false},
+		{"network drive", &fs.FileSystemHandler{Filesystem: "webdav"}, false},
+		{"no handler", nil, false},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := nightlyFshOption(tc.fsh).MasterNodeOnly; got != tc.masterOnly {
+				t.Errorf("Expected MasterNodeOnly to be %v, got %v", tc.masterOnly, got)
+			}
+		})
+	}
+}
+
+// Every node sees the same files on cluster:/, so a nightly pass that runs
+// on all of them deletes the same expired trash and version history once per
+// node. Only the master node does that work; the local drives of each node
+// are still maintained by that node.
+func TestNightlyShouldMaintainFsh(t *testing.T) {
+	previous := nightlyManager
+	defer func() { nightlyManager = previous }()
+	nightlyManager = nightly.NewNightlyTaskManager(3)
+
+	clusterDrive := &fs.FileSystemHandler{Filesystem: "cluster"}
+	localDrive := &fs.FileSystemHandler{Filesystem: "ext4"}
+
+	nightlyManager.SetMasterNodeResolver(func() bool { return false })
+	if nightlyShouldMaintainFsh(clusterDrive) {
+		t.Error("Expected cluster:/ maintenance to be left to the master node")
+	}
+	if !nightlyShouldMaintainFsh(localDrive) {
+		t.Error("Expected a local drive to be maintained by its own host")
+	}
+
+	nightlyManager.SetMasterNodeResolver(func() bool { return true })
+	if !nightlyShouldMaintainFsh(clusterDrive) {
+		t.Error("Expected the master node to maintain cluster:/")
+	}
+}
+
+func TestClusterIsMasterNodeWithoutCluster(t *testing.T) {
+	previous := clusterManager
+	defer func() { clusterManager = previous }()
+	clusterManager = nil
+
+	//A host with no cluster is the only node there is
+	if !clusterIsMasterNode() {
+		t.Error("Expected a host outside a cluster to count as the master node")
+	}
+}
+
+// Copy ordering: the first question anyone opening the tab has is whether
+// the file is on the host they are using, and the second is which copies can
+// be read at all.
+func TestClusterOrderedLocations(t *testing.T) {
+	rec := &metadata.FileRecord{Locations: []metadata.Location{
+		{VolumeID: "v-far", NodeID: "node-c", State: metadata.LocFailed},
+		{VolumeID: "v-ok", NodeID: "node-b", State: metadata.LocVerified},
+		{VolumeID: "v-here", NodeID: "node-a", State: metadata.LocCommitted},
+		{VolumeID: "v-stale", NodeID: "node-d", State: metadata.LocStale},
+	}}
+
+	got := []string{}
+	for _, loc := range clusterOrderedLocations(rec, "node-a") {
+		got = append(got, loc.VolumeID)
+	}
+
+	wanted := "v-here,v-ok,v-far,v-stale"
+	if strings.Join(got, ",") != wanted {
+		t.Errorf("Expected the copies in the order %q, got %q", wanted, strings.Join(got, ","))
+	}
+	if len(got) != len(rec.Locations) {
+		t.Errorf("Expected all %d copies to be listed, got %d", len(rec.Locations), len(got))
+	}
+}
+
+func TestClusterReplicaPolicyField(t *testing.T) {
+	testcases := []struct {
+		replicas int
+		value    string
+		arg      string
+	}{
+		{0, "{0} copy", "1"}, //a folder with no policy keeps one copy
+		{1, "{0} copy", "1"},
+		{3, "{0} copies", "3"},
+	}
+
+	for _, tc := range testcases {
+		field := clusterReplicaPolicyField(metadata.Policy{Replicas: tc.replicas})
+		if field.Value != tc.value || len(field.Args) != 1 || field.Args[0] != tc.arg {
+			t.Errorf("Policy of %d replicas gave %q %v, wanted %q [%s]",
+				tc.replicas, field.Value, field.Args, tc.value, tc.arg)
+		}
+	}
+}
+
+func TestClusterCopyCountField(t *testing.T) {
+	//Nothing to point out while every copy can be read
+	full := clusterCopyCountField(2, 2)
+	if full.Value != "{0}" || full.Args[0] != "2" {
+		t.Errorf("Expected a plain count, got %q %v", full.Value, full.Args)
+	}
+
+	//A copy that cannot be read is the whole point of the row
+	partial := clusterCopyCountField(1, 3)
+	if partial.Value != "{0} of {1} readable" || partial.Args[0] != "1" || partial.Args[1] != "3" {
+		t.Errorf("Expected a readable count, got %q %v", partial.Value, partial.Args)
+	}
+}
+
+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" {
+		t.Errorf("Expected the path on the holding node, 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" {
+		t.Errorf("Expected a root volume path, got %q", got)
+	}
+}
+
+func TestClusterChecksumText(t *testing.T) {
+	long := strings.Repeat("a", 64)
+	if got := clusterChecksumText(long); len(got) != 27 || !strings.HasSuffix(got, "...") {
+		t.Errorf("Expected a shortened hash, got %q", got)
+	}
+	if got := clusterChecksumText("abc"); got != "abc" {
+		t.Errorf("Expected a short hash to be left alone, got %q", got)
+	}
+	if got := clusterChecksumText(""); got != "" {
+		t.Errorf("Expected no text for a file with no hash, got %q", got)
+	}
+}
+
+func TestClusterCopyStatusCoversEveryState(t *testing.T) {
+	//A state with no entry would show up as Unknown in the dialog
+	states := []string{metadata.LocPending, metadata.LocWriting, metadata.LocCommitted,
+		metadata.LocVerified, metadata.LocStale, metadata.LocFailed}
+	for _, state := range states {
+		words, ok := clusterCopyStatus[state]
+		if !ok {
+			t.Errorf("No wording for the copy state %q", state)
+			continue
+		}
+		switch words[1] {
+		case arozfs.StorageStateOK, arozfs.StorageStatePending,
+			arozfs.StorageStateStale, arozfs.StorageStateError:
+		default:
+			t.Errorf("Copy state %q maps to the unexpected display state %q", state, words[1])
+		}
+	}
+}

+ 40 - 0
src/cluster.go

@@ -27,6 +27,7 @@ import (
 	"imuslab.com/arozos/mod/info/usageinfo"
 	"imuslab.com/arozos/mod/network/neighbour"
 	prout "imuslab.com/arozos/mod/prouter"
+	"imuslab.com/arozos/mod/time/nightly"
 )
 
 /*
@@ -301,6 +302,42 @@ func clusterHasVolume(vols []metadata.Volume) bool {
 	return false
 }
 
+/*
+	Master node
+
+	Nightly maintenance of cluster:/ (expired trash, old version history)
+	acts on files every member can see, so letting every node run it means
+	doing the same scan, and the same deletions, once per node. The master
+	node is the node holding the metadata leader lease, which is also the
+	node that hands out placement and replication decisions.
+*/
+
+// clusterIsMasterNode reports whether this node maintains the storage shared
+// by the cluster. A node with no cluster is the only node there is, so it is
+// its own master.
+func clusterIsMasterNode() bool {
+	if *disable_cluster || clusterManager == nil || clusterMetadata == nil || !clusterManager.InCluster() {
+		return true
+	}
+	return clusterMetadata.IsLeader()
+}
+
+// nightlyFshOption returns how nightly maintenance of one file system handler
+// should be run: a drive shared by the whole cluster is maintained by the
+// master node only, every other drive by the host it belongs to.
+func nightlyFshOption(fsh *fs.FileSystemHandler) nightly.TaskOption {
+	if fsh != nil && fsh.Filesystem == "cluster" {
+		return nightly.TaskOption{Name: "cluster:/ maintenance", MasterNodeOnly: true}
+	}
+	return nightly.TaskOption{}
+}
+
+// nightlyShouldMaintainFsh reports whether tonight's maintenance of this file
+// system handler belongs to this host.
+func nightlyShouldMaintainFsh(fsh *fs.FileSystemHandler) bool {
+	return nightlyManager.ShouldRun(nightlyFshOption(fsh))
+}
+
 // clusterSyncDrive mounts or unmounts cluster:/ to match clusterDriveWanted.
 func clusterSyncDrive() {
 	if clusterDriveWanted() {
@@ -422,6 +459,9 @@ func ClusterInit() {
 		clusterStartAgent()
 	}
 	clusterStartNeighbourhood()
+
+	//Let the nightly tasks know which node maintains the shared storage
+	nightlyManager.SetMasterNodeResolver(clusterIsMasterNode)
 }
 
 // clusterStartAgent starts the cluster agent and every cluster service on

+ 73 - 3
src/file_system.go

@@ -156,6 +156,7 @@ func FileSystemInit() {
 	router.HandleFunc("/system/file_system/restoreTrash", system_fs_restoreFile)
 	router.HandleFunc("/system/file_system/zipHandler", system_fs_zipHandler)
 	router.HandleFunc("/system/file_system/getProperties", system_fs_getFileProperties)
+	router.HandleFunc("/system/file_system/getStorageInfo", system_fs_getStorageInfo)
 	router.HandleFunc("/system/file_system/versionHistory", system_fs_FileVersionHistory)
 
 	router.HandleFunc("/system/file_system/handleFilePermission", system_fs_handleFilePermission)
@@ -1292,6 +1293,12 @@ func system_fs_clearExpiredTrash() {
 
 		removed := 0
 		for c, file := range files {
+			if !nightlyShouldMaintainFsh(fshs[c]) {
+				//Trash inside a drive shared by the cluster is emptied by the
+				//master node, so it is not purged once per member
+				continue
+			}
+
 			/*
 				The removal time is stored as the file extension when the file
 				was recycled. Anything without a parsable timestamp is left
@@ -3020,6 +3027,65 @@ func system_fs_getFileProperties(w http.ResponseWriter, r *http.Request) {
 
 }
 
+/*
+	Storage info
+
+	Where the file actually is. Most drives have nothing to add beyond the
+	storage path already in the properties dialog, but a drive that spreads
+	its files over several hosts does: cluster:/ answers with the nodes and
+	volumes holding a copy of the file. The abstraction resolves it
+	(arozfs.StorageInfoProvider), so this handler stays the same whatever
+	kind of drive the file is on.
+*/
+
+func system_fs_getStorageInfo(w http.ResponseWriter, r *http.Request) {
+	userinfo, err := userHandler.GetUserInfoFromRequest(w, r)
+	if err != nil {
+		utils.SendErrorResponse(w, "User not logged in")
+		return
+	}
+
+	vpath, err := utils.PostPara(r, "path")
+	if err != nil {
+		utils.SendErrorResponse(w, "path not defined")
+		return
+	}
+
+	fsh, subpath, err := GetFSHandlerSubpathFromVpath(vpath)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	//Reading a file the user has no access to must not leak where it is kept
+	if !userinfo.CanRead(vpath) {
+		utils.SendErrorResponse(w, "Permission denied")
+		return
+	}
+
+	provider, ok := fsh.FileSystemAbstraction.(arozfs.StorageInfoProvider)
+	if !ok {
+		//Nothing to add for this kind of drive
+		utils.SendErrorResponse(w, "Storage info not supported by this drive")
+		return
+	}
+
+	rpath, err := fsh.FileSystemAbstraction.VirtualPathToRealPath(subpath, userinfo.Username)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	info, err := provider.StorageInfo(rpath)
+	if err != nil {
+		utils.SendErrorResponse(w, err.Error())
+		return
+	}
+
+	js, _ := json.Marshal(info)
+	utils.SendJSONResponse(w, string(js))
+}
+
 /*
 	List directory in the given path
 
@@ -3540,10 +3606,14 @@ func system_fs_FileVersionHistory(w http.ResponseWriter, r *http.Request) {
 func system_fs_clearVersionHistories() {
 	allFsh := GetAllLoadedFsh()
 	for _, fsh := range allFsh {
-		if !fsh.ReadOnly {
-			localversion.CleanExpiredVersionBackups(fsh, fsh.Path, 30*86400)
+		if fsh.ReadOnly {
+			continue
 		}
-
+		if !nightlyShouldMaintainFsh(fsh) {
+			//A drive shared by the cluster is cleaned by the master node
+			continue
+		}
+		localversion.CleanExpiredVersionBackups(fsh, fsh.Path, 30*86400)
 	}
 }
 

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

@@ -237,6 +237,22 @@ every membership change and every volume record change.
   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.
+- **Nightly maintenance**: every member sees the same files on `cluster:/`,
+  so a nightly pass that walks it from each node deletes the same expired
+  trash and the same old version history once per node. Work on a drive
+  shared by the cluster is therefore left to the **master node**, the
+  holder of the metadata leader lease: `nightly.TaskOption{MasterNodeOnly:
+  true}` marks it, `nightlyShouldMaintainFsh` in `src/cluster.go` answers it
+  per file system handler, and a host outside a cluster is its own master so
+  nothing changes for a single node. Each node still maintains its own local
+  drives every night.
+- **Where a file is**: the abstraction implements
+  `arozfs.StorageInfoProvider`, so the File Manager properties dialog gains a
+  Cluster tab listing the copies of the file with the node and volume holding
+  each one, its state, the replica policy and the checksum
+  (`/system/file_system/getStorageInfo` → `src/cluster.fsinfo.go`). The shape
+  is generic: any abstraction that can explain where its files live gets the
+  same tab.
 
 Admin API: `/system/cluster/storage/{status,volume/add,volume/remove,volume/readonly,rescan,autoreadonly}`.
 

+ 19 - 0
src/mod/filesystem/abstractions/clusterfs/clusterfs.go

@@ -46,8 +46,17 @@ type Backend interface {
 	Ready() bool
 }
 
+// InfoBackend is an optional Backend extension: a backend that implements it
+// can explain where a file physically lives (which nodes hold a copy of it
+// and in what state), which the File Manager properties dialog shows.
+type InfoBackend interface {
+	StorageInfo(logical string) (arozfs.StorageInfo, error)
+}
+
 var errUnsupported = errors.New("filesystem type not supported")
 
+var errNoStorageInfo = errors.New("storage info not available")
+
 // ClusterFileSystem is the abstraction.
 type ClusterFileSystem struct {
 	UUID    string
@@ -281,6 +290,16 @@ func (c *ClusterFileSystem) walk(p string, info Info, walkFn filepath.WalkFunc)
 	return nil
 }
 
+// StorageInfo resolves the replicas of one namespace path through the
+// backend. It is what makes cluster:/ files show where their copies are.
+func (c *ClusterFileSystem) StorageInfo(p string) (arozfs.StorageInfo, error) {
+	backend, ok := c.backend.(InfoBackend)
+	if !ok {
+		return arozfs.StorageInfo{}, errNoStorageInfo
+	}
+	return backend.StorageInfo(normalize(p))
+}
+
 func (c *ClusterFileSystem) Heartbeat() error {
 	if !c.backend.Ready() {
 		return errors.New("cluster storage not ready")

+ 72 - 0
src/mod/filesystem/abstractions/clusterfs/clusterfs_test.go

@@ -2,6 +2,7 @@ package clusterfs
 
 import (
 	"bytes"
+	"errors"
 	"io"
 	"os"
 	"path"
@@ -10,6 +11,8 @@ import (
 	"strings"
 	"sync"
 	"testing"
+
+	"imuslab.com/arozos/mod/filesystem/arozfs"
 )
 
 // fakeBackend is an in-memory namespace.
@@ -235,3 +238,72 @@ func TestClusterFileSystemRoundTrip(t *testing.T) {
 		t.Errorf("Heartbeat should fail when backend not ready")
 	}
 }
+
+/*
+	Storage info
+
+	A backend that can explain where a file is kept answers through the
+	abstraction, so the File Manager properties dialog does not need to know
+	that the cluster is involved. A backend that cannot say anything must
+	fail cleanly instead of pretending the file is nowhere.
+*/
+
+// infoBackend is a fakeBackend that also reports where a file is kept.
+type infoBackend struct {
+	*fakeBackend
+	info arozfs.StorageInfo
+	err  error
+	last string
+}
+
+func (i *infoBackend) StorageInfo(logical string) (arozfs.StorageInfo, error) {
+	i.last = logical
+	if i.err != nil {
+		return arozfs.StorageInfo{}, i.err
+	}
+	return i.info, nil
+}
+
+func TestStorageInfoFromBackend(t *testing.T) {
+	backend := &infoBackend{fakeBackend: newFake(), info: arozfs.StorageInfo{
+		Type:  "cluster",
+		Title: "Cluster",
+		Items: []arozfs.StorageInfoItem{{Title: "NodeA", State: arozfs.StorageStateOK}},
+	}}
+	c := New("cluster", backend)
+
+	//Any path form the file system accepts has to reach the backend as a
+	//plain namespace path
+	testcases := []struct{ given, wanted string }{
+		{"cluster:/photos/a.jpg", "/photos/a.jpg"},
+		{"/photos/a.jpg", "/photos/a.jpg"},
+		{"photos/a.jpg", "/photos/a.jpg"},
+		{"cluster:/", "/"},
+	}
+	for _, tc := range testcases {
+		info, err := c.StorageInfo(tc.given)
+		if err != nil {
+			t.Fatalf("StorageInfo(%q) returned an error: %v", tc.given, err)
+		}
+		if backend.last != tc.wanted {
+			t.Errorf("StorageInfo(%q) asked the backend for %q, wanted %q", tc.given, backend.last, tc.wanted)
+		}
+		if info.Type != "cluster" || len(info.Items) != 1 {
+			t.Errorf("StorageInfo(%q) did not pass the backend answer through: %+v", tc.given, info)
+		}
+	}
+}
+
+func TestStorageInfoErrors(t *testing.T) {
+	//A backend that cannot resolve the file passes its error on
+	failing := &infoBackend{fakeBackend: newFake(), err: errors.New("no such record")}
+	if _, err := New("cluster", failing).StorageInfo("/photos/a.jpg"); err == nil {
+		t.Error("Expected the backend error to be returned")
+	}
+
+	//A backend with no idea where files are kept is not an error case worth
+	//guessing at: it simply has nothing to report
+	if _, err := New("cluster", newFake()).StorageInfo("/photos/a.jpg"); err == nil {
+		t.Error("Expected an error from a backend that does not report storage info")
+	}
+}

+ 69 - 0
src/mod/filesystem/arozfs/storageinfo.go

@@ -0,0 +1,69 @@
+package arozfs
+
+/*
+	storageinfo.go
+
+	Where a file physically lives.
+
+	os.FileInfo describes a file as the abstraction presents it: a name, a
+	size and a time. It says nothing about where the bytes actually are,
+	which for most file systems is obvious (the disk behind the drive) but
+	not for one that spreads its files over several hosts: a file on the
+	cluster drive is kept on whichever nodes the replica policy placed it,
+	and a user looking at its properties cannot tell which those are.
+
+	A file system abstraction that knows more implements StorageInfoProvider
+	and answers in the shape below. It is deliberately a list of rows and a
+	list of locations rather than a cluster specific structure, so the File
+	Manager properties dialog can render whatever any abstraction reports
+	without knowing what kind of drive it came from.
+*/
+
+// Location states, for the colour the front end gives an entry
+const (
+	StorageStateOK      = "ok"      //readable and up to date
+	StorageStatePending = "pending" //being written or copied right now
+	StorageStateStale   = "stale"   //present but out of date
+	StorageStateError   = "error"   //unusable
+	StorageStateUnknown = "unknown"
+)
+
+// StorageInfoField is one key/value row. Both sides are English text: the
+// front end translates the ones it has a string for and shows the rest as
+// they are, so an abstraction may report values (paths, hashes, node names)
+// that no locale file can know in advance. Anything that mixes wording with
+// data says so through Args, where Value is a template ("{0} copies") whose
+// slots are filled after it has been translated, so the wording and the
+// number can swap places in the languages that need them to.
+type StorageInfoField struct {
+	Key   string   `json:"key"`
+	Value string   `json:"value"`
+	Args  []string `json:"args,omitempty"`
+}
+
+// StorageInfoItem is one physical location of the file: on the cluster drive
+// one replica, on another abstraction whatever a location means there.
+type StorageInfoItem struct {
+	Title    string             `json:"title"`    //what holds the copy, e.g. the node name
+	Subtitle string             `json:"subtitle"` //where inside it, e.g. the volume name
+	State    string             `json:"state"`    //one of the StorageState constants
+	Status   string             `json:"status"`   //the state in words
+	Local    bool               `json:"local"`    //true when this host holds this copy
+	Fields   []StorageInfoField `json:"fields"`
+}
+
+// StorageInfo is everything an abstraction can tell about one file beyond
+// its os.FileInfo.
+type StorageInfo struct {
+	Type   string             `json:"type"`   //file system type, e.g. "cluster"
+	Title  string             `json:"title"`  //heading for the section, e.g. "Cluster"
+	Fields []StorageInfoField `json:"fields"` //properties of the file as a whole
+	Items  []StorageInfoItem  `json:"items"`  //one entry per physical location
+}
+
+// StorageInfoProvider is implemented by file system abstractions that can
+// resolve where and how a file is stored. Abstractions that cannot are simply
+// left out: the properties dialog then shows its usual rows only.
+type StorageInfoProvider interface {
+	StorageInfo(realpath string) (StorageInfo, error)
+}

+ 77 - 2
src/mod/time/nightly/nightly.go

@@ -1,6 +1,9 @@
 package nightly
 
-import "time"
+import (
+	"sync"
+	"time"
+)
 
 /*
 	Nightly.go
@@ -11,8 +14,32 @@ import "time"
 
 */
 
+// TaskOption describes how a nightly task (or one step of it) should be
+// treated by the manager.
+type TaskOption struct {
+	//Name of the task, for logging
+	Name string
+
+	/*
+		MasterNodeOnly limits the work to the master node of the cluster.
+
+		Shared storage like the cluster:/ file system shows the same files on
+		every member, so a maintenance pass that runs on all of them repeats
+		the same scan, and the same deletions, once per node. Marking that
+		work master node only keeps it to a single pass per cluster per night.
+		A host that is not part of a cluster is the only node there is, so it
+		counts as its own master and still runs the task.
+	*/
+	MasterNodeOnly bool
+}
+
 type TaskManager struct {
 	NightlTasks []func()
+
+	//Answers TaskOption.MasterNodeOnly, installed by the cluster once it
+	//starts. Guarded by mu as the nightly timer runs in its own goroutine.
+	masterNodeResolver func() bool
+	mu                 sync.RWMutex
 }
 
 func NewNightlyTaskManager(nightlyTaskRunTime int) *TaskManager {
@@ -46,5 +73,53 @@ func (tm *TaskManager) NightlyTaskRun() {
 }
 
 func (tm *TaskManager) RegisterNightlyTask(task func()) {
-	tm.NightlTasks = append(tm.NightlTasks, task)
+	tm.RegisterNightlyTaskWithOption(TaskOption{}, task)
+}
+
+// Register a nightly task that only runs where its option allows it
+func (tm *TaskManager) RegisterNightlyTaskWithOption(option TaskOption, task func()) {
+	tm.NightlTasks = append(tm.NightlTasks, func() {
+		if !tm.ShouldRun(option) {
+			return
+		}
+		task()
+	})
+}
+
+/*
+	Master node
+
+	The master node is the one node that performs maintenance on storage
+	shared by the whole cluster. The resolver is installed by the cluster
+	module; until then, and on a standalone host, this node is the master.
+*/
+
+// Set the function that reports if this host is the cluster master node
+func (tm *TaskManager) SetMasterNodeResolver(resolver func() bool) {
+	tm.mu.Lock()
+	defer tm.mu.Unlock()
+	tm.masterNodeResolver = resolver
+}
+
+// Check if this host is currently the master node of its cluster
+func (tm *TaskManager) IsMasterNode() bool {
+	tm.mu.RLock()
+	resolver := tm.masterNodeResolver
+	tm.mu.RUnlock()
+	if resolver == nil {
+		//Not in a cluster: this host maintains its own storage
+		return true
+	}
+	return resolver()
+}
+
+// Check if work carrying this option belongs to this host tonight. A task
+// that walks several file systems can call this once per file system instead
+// of gating the whole task, so local drives are still maintained on every
+// node while the shared cluster drive is left to the master node.
+func (tm *TaskManager) ShouldRun(option TaskOption) bool {
+	if option.MasterNodeOnly {
+		return tm.IsMasterNode()
+	}
+	return true
 }

+ 80 - 0
src/mod/time/nightly/nightly_test.go

@@ -78,3 +78,83 @@ func TestRegisterAndRunMultipleTimes(t *testing.T) {
 		t.Errorf("Expected callCount to be 3, got %d", callCount)
 	}
 }
+
+/*
+	Master node only tasks
+
+	A nightly task marked MasterNodeOnly belongs to whichever node maintains
+	the storage shared by the whole cluster, so that a cluster wide scan runs
+	once per night instead of once per node per night.
+*/
+
+func TestShouldRunWithoutResolver(t *testing.T) {
+	tm := NewNightlyTaskManager(23)
+
+	//A host outside a cluster maintains its own storage
+	if !tm.IsMasterNode() {
+		t.Error("Expected a host with no resolver to count as the master node")
+	}
+	if !tm.ShouldRun(TaskOption{MasterNodeOnly: true}) {
+		t.Error("Expected a master node only task to run where there is no cluster")
+	}
+}
+
+func TestShouldRunOnMasterNodeOnly(t *testing.T) {
+	tm := NewNightlyTaskManager(23)
+
+	testcases := []struct {
+		name     string
+		isMaster bool
+		option   TaskOption
+		expected bool
+	}{
+		{"plain task on the master", true, TaskOption{}, true},
+		{"plain task on a follower", false, TaskOption{}, true},
+		{"master only task on the master", true, TaskOption{MasterNodeOnly: true}, true},
+		{"master only task on a follower", false, TaskOption{MasterNodeOnly: true}, false},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			isMaster := tc.isMaster
+			tm.SetMasterNodeResolver(func() bool { return isMaster })
+			if got := tm.ShouldRun(tc.option); got != tc.expected {
+				t.Errorf("Expected ShouldRun to be %v, got %v", tc.expected, got)
+			}
+		})
+	}
+}
+
+func TestRegisterNightlyTaskWithOption(t *testing.T) {
+	tm := NewNightlyTaskManager(23)
+
+	var masterOnlyRuns int32
+	var everyNodeRuns int32
+	tm.RegisterNightlyTaskWithOption(TaskOption{Name: "cluster scan", MasterNodeOnly: true},
+		func() { atomic.AddInt32(&masterOnlyRuns, 1) })
+	tm.RegisterNightlyTask(func() { atomic.AddInt32(&everyNodeRuns, 1) })
+
+	if len(tm.NightlTasks) != 2 {
+		t.Fatalf("Expected 2 tasks after registration, got %d", len(tm.NightlTasks))
+	}
+
+	//Registration happens before the cluster starts, so the decision has to
+	//be taken when the task runs and not when it is registered
+	tm.SetMasterNodeResolver(func() bool { return false })
+	tm.NightlyTaskRun()
+	if atomic.LoadInt32(&masterOnlyRuns) != 0 {
+		t.Errorf("Expected the master only task to be skipped on a follower, ran %d time(s)", masterOnlyRuns)
+	}
+	if atomic.LoadInt32(&everyNodeRuns) != 1 {
+		t.Errorf("Expected the plain task to run on a follower, ran %d time(s)", everyNodeRuns)
+	}
+
+	tm.SetMasterNodeResolver(func() bool { return true })
+	tm.NightlyTaskRun()
+	if atomic.LoadInt32(&masterOnlyRuns) != 1 {
+		t.Errorf("Expected the master only task to run once this node is the master, ran %d time(s)", masterOnlyRuns)
+	}
+	if atomic.LoadInt32(&everyNodeRuns) != 2 {
+		t.Errorf("Expected the plain task to run again, ran %d time(s)", everyNodeRuns)
+	}
+}

+ 2 - 2
src/web/Musicify/embedded.html

@@ -43,11 +43,11 @@
 		.bg-blur {
 			position: absolute; inset: 0;
 			background-size: cover; background-position: center;
-			filter: blur(4px) brightness(0.42);
+			filter: blur(4px) brightness(0.22);
 			transform: scale(1.5);
 			transition: background-image 0.6s ease;
 			z-index: 0;
-			opacity: 0.8;
+			opacity: 0.85;
 		}
 		.player-inner {
 			position: relative; z-index: 1;

+ 83 - 0
src/web/SystemAO/file_system/dialog.css

@@ -28,6 +28,8 @@ body.whiteTheme{
     --dlg_accent_text: #ffffff;
     --dlg_accent_soft: #e8f0fe;
     --dlg_danger: #d93025;
+    --dlg_ok: #188038;
+    --dlg_warn: #b06000;
     --dlg_field_bg: #ffffff;
 }
 
@@ -44,6 +46,8 @@ body.darkTheme{
     --dlg_accent_text: #202124;
     --dlg_accent_soft: #283142;
     --dlg_danger: #f28b82;
+    --dlg_ok: #81c995;
+    --dlg_warn: #fdd663;
     --dlg_field_bg: #2b2c2f;
 }
 
@@ -572,6 +576,85 @@ select.dlgField{
     color: var(--dlg_text_dim);
 }
 
+/*
+    Storage tab: one card per physical copy of the file.
+
+    A copy is a small stack - which node holds it, how that copy is doing,
+    then its own property rows - rather than a list row, because the rows
+    underneath are the same key/value shape as the Basic tab and should read
+    the same way.
+*/
+.dlgCopy{
+    background-color: var(--dlg_bg_soft);
+    border: 1px solid var(--dlg_border);
+    border-radius: 8px;
+    padding: 10px 12px;
+    margin-bottom: 10px;
+}
+
+.dlgCopy .copyHead{
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.dlgCopy .copyName{
+    font-weight: 600;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+/* The state of the copy, as a dot next to the node name and as a word */
+.dlgCopy .copyDot{
+    flex: 0 0 auto;
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    background-color: var(--dlg_text_dim);
+}
+
+.dlgCopy .copyDot.ok{ background-color: var(--dlg_ok); }
+.dlgCopy .copyDot.pending, .dlgCopy .copyDot.stale{ background-color: var(--dlg_warn); }
+.dlgCopy .copyDot.error{ background-color: var(--dlg_danger); }
+
+.dlgCopy .copyStatus{
+    margin-left: auto;
+    flex: 0 0 auto;
+    font-size: 12.5px;
+    color: var(--dlg_text_dim);
+}
+
+.dlgCopy .copyStatus.ok{ color: var(--dlg_ok); }
+.dlgCopy .copyStatus.pending, .dlgCopy .copyStatus.stale{ color: var(--dlg_warn); }
+.dlgCopy .copyStatus.error{ color: var(--dlg_danger); }
+
+/* Marks the copy that is on the host the user is logged in to */
+.dlgCopy .copyBadge{
+    flex: 0 0 auto;
+    font-size: 11.5px;
+    padding: 1px 7px;
+    border-radius: 10px;
+    background-color: var(--dlg_accent_soft);
+    color: var(--dlg_accent);
+}
+
+.dlgCopy .copySub{
+    color: var(--dlg_text_dim);
+    font-size: 12.5px;
+    margin-top: 2px;
+}
+
+.dlgCopy .dlgPropRow{
+    padding: 5px 0px;
+}
+
+.dlgCopy .dlgPropRow:first-of-type{
+    margin-top: 6px;
+    border-top: 1px solid var(--dlg_border);
+    padding-top: 9px;
+}
+
 /* Anything the host cannot do is shown, but visibly out of reach */
 .dlgUnsupported{
     opacity: 0.45;

+ 132 - 2
src/web/SystemAO/file_system/file_properties.html

@@ -17,6 +17,7 @@
                 <div class="dlgTab active" tab="basic" onclick="switchTab('basic');" locale="tab/basic">Basic</div>
                 <div class="dlgTab" tab="permission" onclick="switchTab('permission');" locale="tab/permission">Permissions</div>
                 <div class="dlgTab" tab="versions" onclick="switchTab('versions');" locale="tab/versions">Versions</div>
+                <div class="dlgTab" tab="storage" id="storageTab" style="display:none;" onclick="switchTab('storage');" locale="tab/storage">Storage</div>
             </div>
             <div class="dialogBody">
                 <!-- Basic -->
@@ -118,6 +119,15 @@
                         <div class="dlgList" id="versionList"></div>
                     </div>
                 </div>
+
+                <!-- Storage: where the file physically is, for drives that know -->
+                <div class="dlgPage" id="page_storage">
+                    <div id="storageFields"></div>
+                    <div id="storageCopiesSection" style="display:none;">
+                        <div class="dlgSectionLabel" id="storageCopiesLabel" locale="storage/copies">File Copies</div>
+                        <div id="storageCopies"></div>
+                    </div>
+                </div>
             </div>
 
             <div class="dialogFooter">
@@ -126,7 +136,7 @@
                     <i class="trash icon"></i> <span locale="versions/removeAll">Delete All Versions</span>
                 </button>
                 <div class="spacer"></div>
-                <button class="dlgBtn footerBasic footerVersions" onclick="ao_module_close();" locale="button/close">Close</button>
+                <button class="dlgBtn footerBasic footerVersions footerStorage" onclick="ao_module_close();" locale="button/close">Close</button>
                 <button class="dlgBtn footerPermission" style="display:none;" onclick="ao_module_close();" locale="button/cancel">Cancel</button>
                 <button id="permApplyBtn" class="dlgBtn primary footerPermission" style="display:none;" onclick="applyPermission();" locale="button/confirm">OK</button>
             </div>
@@ -170,6 +180,9 @@
             var permissionLoaded = false;
             var currentTab = "basic";
 
+            //Storage tab state, filled in only by a drive that reports one
+            var storageInfo = null;
+
             //Version manager state
             var versionsLoaded = false;
             var versionList = [];
@@ -199,6 +212,7 @@
                 dlgInitTheme();
                 loadHostInfo();
                 initFileProperties();
+                loadStorageInfo();
             });
 
             /* ---------------------------------------------------------------
@@ -211,12 +225,14 @@
                 $(".dlgPage").removeClass("active");
                 $("#page_" + tabName).addClass("active");
 
-                $(".footerBasic, .footerVersions, .footerPermission").hide();
+                $(".footerBasic, .footerVersions, .footerPermission, .footerStorage").hide();
                 if (tabName == "permission"){
                     $(".footerPermission").show();
                     if (!permissionLoaded){
                         loadPermission();
                     }
+                }else if (tabName == "storage"){
+                    $(".footerStorage").show();
                 }else if (tabName == "versions"){
                     $(".footerVersions").show();
                     if (!versionsSupported()){
@@ -769,6 +785,120 @@
                 $("#dlgLoader").hide();
             }
 
+            /* ---------------------------------------------------------------
+                Storage tab
+
+                Most drives have nothing to add: the storage path on the Basic
+                tab is the whole answer. A drive whose files live somewhere
+                else - cluster:/ keeps every file on one or more other nodes -
+                reports where through its file system abstraction, and this
+                tab appears to show it. Nothing here is cluster specific: the
+                server sends rows and locations, whatever kind of drive
+                resolved them.
+            --------------------------------------------------------------- */
+            function loadStorageInfo(){
+                if (files.length != 1){
+                    //A selection has no single place to point at
+                    return;
+                }
+                $.ajax({
+                    url: "../../system/file_system/getStorageInfo",
+                    data: {path: files[0]},
+                    method: "POST",
+                    success: function(data){
+                        if (data == undefined || data.error !== undefined){
+                            //This drive does not report one. Leave the tab out
+                            return;
+                        }
+                        storageInfo = data;
+                        renderStorageTab(data);
+                        $("#storageTab").show();
+                    }
+                });
+            }
+
+            //Keys and the wordy half of values are translated where the locale
+            //file has them and shown as they came otherwise, so a drive can
+            //report a node name or a hash that no locale can know about
+            function storageKey(key){
+                return applocale.getString("storage/key/" + key, key);
+            }
+
+            function storageValue(value, args){
+                var text = applocale.getString("storage/value/" + value, value);
+                if (args != undefined){
+                    for (var i = 0; i < args.length; i++){
+                        text = text.split("{" + i + "}").join(
+                            applocale.getString("storage/value/" + args[i], args[i]));
+                    }
+                }
+                return text;
+            }
+
+            function storageFieldRows(fields){
+                var rows = [];
+                for (var i = 0; i < fields.length; i++){
+                    if (fields[i].value == ""){
+                        continue;
+                    }
+                    rows.push(propRow(storageKey(fields[i].key),
+                        dlgEscape(storageValue(fields[i].value, fields[i].args))));
+                }
+                return rows;
+            }
+
+            function renderStorageTab(data){
+                //The tab is named after whatever answered, e.g. Cluster
+                $("#storageTab").text(applocale.getString("storage/title/" + data.type, data.title));
+
+                $("#storageFields").html(propGroup(storageFieldRows(data.fields || [])));
+
+                var items = data.items || [];
+                if (items.length == 0){
+                    //A folder has no copies of its own, and a file that has
+                    //just been written may not have one recorded yet
+                    $("#storageCopiesSection").hide();
+                    return;
+                }
+
+                var html = "";
+                for (var i = 0; i < items.length; i++){
+                    html += storageCopyHTML(items[i]);
+                }
+                $("#storageCopies").html(html);
+                $("#storageCopiesSection").show();
+            }
+
+            function storageCopyHTML(item){
+                var state = item.state;
+                if (state != "ok" && state != "pending" && state != "stale" && state != "error"){
+                    state = "unknown";
+                }
+
+                var head = '<div class="copyHead">' +
+                    '<span class="copyDot ' + state + '"></span>' +
+                    '<span class="copyName">' + dlgEscape(item.title) + "</span>";
+                if (item.local){
+                    //Whether the file is on the host being used right now is
+                    //the first thing anyone opening this tab wants to know
+                    head += '<span class="copyBadge">' +
+                        dlgEscape(applocale.getString("storage/thisNode", "This node")) + "</span>";
+                }
+                if (item.status != undefined && item.status != ""){
+                    head += '<span class="copyStatus ' + state + '">' +
+                        dlgEscape(storageValue(item.status)) + "</span>";
+                }
+                head += "</div>";
+
+                var sub = "";
+                if (item.subtitle != undefined && item.subtitle != ""){
+                    sub = '<div class="copySub">' + dlgEscape(item.subtitle) + "</div>";
+                }
+
+                return '<div class="dlgCopy">' + head + sub +
+                    storageFieldRows(item.fields || []).join("") + "</div>";
+            }
+
             /* ---------------------------------------------------------------
                 Permission tab
             --------------------------------------------------------------- */

+ 48 - 9
src/web/SystemAO/file_system/js/explorer/uploadui.js

@@ -311,6 +311,13 @@ function renderUploadTask(taskUUID){
         (showDoneLink ? " withDoneLink" : ""));
     row.find(".uploadTaskBarFill").css("width", indeterminate ? "" : percentage + "%");
 
+    //Keep the list ordered: finished rows on top, in progress next, queued last
+    let rank = uploadRowRank(info.state);
+    if (row.attr("data-rank") != String(rank)){
+        row.attr("data-rank", rank);
+        placeUploadRow(row[0], rank);
+    }
+
     //Byte counter. Without a known total there is nothing meaningful to divide by.
     let sizeHTML = "";
     if (knownSize){
@@ -402,6 +409,43 @@ function renderUploadTask(taskUUID){
     }
 }
 
+/*
+    Row ordering: 0 finished (done / failed), 1 in progress, 2 queued.
+
+    Only the row whose group changed is moved, to the end of its new group, so
+    a state change costs one scan rather than a re-sort of the whole list.
+*/
+function uploadRowRank(state){
+    if (state == "done" || state == "failed"){
+        return 0;
+    }
+    return state == "pending" ? 2 : 1;
+}
+
+function placeUploadRow(rowEl, rank){
+    let list = document.getElementById("uploadProgressList");
+    if (list == null || rowEl.parentNode !== list){
+        return;
+    }
+    //Insert before the first row of a later group
+    let before = null;
+    if (rank < 2){
+        for (var el = list.firstElementChild; el !== null; el = el.nextElementSibling){
+            if (el !== rowEl && Number(el.getAttribute("data-rank")) > rank){
+                before = el;
+                break;
+            }
+        }
+    }
+    if (before === null){
+        if (list.lastElementChild !== rowEl){
+            list.appendChild(rowEl);
+        }
+    }else if (before !== rowEl.nextElementSibling){
+        list.insertBefore(rowEl, before);
+    }
+}
+
 /*
     Panel level chrome
 */
@@ -702,15 +746,10 @@ function scrollActiveUploadIntoView(){
         return;
     }
 
-    let listBox = list.getBoundingClientRect();
-    let rowBox = active.getBoundingClientRect();
-    let delta = 0;
-    if (rowBox.top < listBox.top || rowBox.height > listBox.height){
-        delta = rowBox.top - listBox.top;
-    }else if (rowBox.bottom > listBox.bottom){
-        delta = rowBox.bottom - listBox.bottom;
-    }
-    if (delta == 0){
+    //Rows are kept in finished / in progress / queued order, so this row is the
+    //first in-progress one: line its top edge up with the top of the list
+    let delta = active.getBoundingClientRect().top - list.getBoundingClientRect().top;
+    if (Math.abs(delta) < 1){
         return;
     }
 

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

@@ -92,7 +92,47 @@
                 "versions/label": "版本",
                 "versions/current": "(目前)",
                 "versions/failed": "無法讀取版本記錄",
-                "": ""
+                "": "",
+                "tab/storage": "儲存位置",
+                "storage/title/cluster": "叢集",
+                "storage/copies": "檔案複本",
+                "storage/thisNode": "本機節點",
+                "storage/key/Cluster": "叢集",
+                "storage/key/Namespace Path": "命名空間路徑",
+                "storage/key/Replica Policy": "複本策略",
+                "storage/key/Copies": "複本數量",
+                "storage/key/Checksum": "校驗碼",
+                "storage/key/File ID": "檔案 ID",
+                "storage/key/Master Node": "主節點",
+                "storage/key/Items Inside": "內含項目",
+                "storage/key/Node ID": "節點 ID",
+                "storage/key/Node State": "節點狀態",
+                "storage/key/Volume": "儲存區",
+                "storage/key/Volume Path": "儲存區路徑",
+                "storage/key/Volume Free": "可用空間",
+                "storage/key/Volume Access": "儲存區存取",
+                "storage/key/Primary Copy": "主要複本",
+                "storage/key/Last Checked": "最後檢查",
+                "storage/value/{0} copy": "{0} 份複本",
+                "storage/value/{0} copies": "{0} 份複本",
+                "storage/value/{0} of {1} readable": "{1} 份中 {0} 份可讀取",
+                "storage/value/{0} of {1}": "{0} / {1}",
+                "storage/value/Verified": "已驗證",
+                "storage/value/Committed": "已寫入",
+                "storage/value/Pending": "等待中",
+                "storage/value/Writing": "寫入中",
+                "storage/value/Stale": "已過期",
+                "storage/value/Failed": "失敗",
+                "storage/value/Unknown": "未知",
+                "storage/value/Online": "線上",
+                "storage/value/Degraded": "降級",
+                "storage/value/Offline": "離線",
+                "storage/value/Draining": "疏散中",
+                "storage/value/Maintenance": "維護中",
+                "storage/value/Not a member": "非叢集成員",
+                "storage/value/Read only": "唯讀",
+                "storage/value/Evacuating": "疏散中",
+                "storage/value/Yes": "是"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "此伺服器不支援遞迴變更存取權限"
@@ -188,7 +228,47 @@
                 "versions/label": "版本",
                 "versions/current": "(目前)",
                 "versions/failed": "無法讀取版本記錄",
-                "": ""
+                "": "",
+                "tab/storage": "儲存位置",
+                "storage/title/cluster": "叢集",
+                "storage/copies": "檔案複本",
+                "storage/thisNode": "本機節點",
+                "storage/key/Cluster": "叢集",
+                "storage/key/Namespace Path": "命名空間路徑",
+                "storage/key/Replica Policy": "複本策略",
+                "storage/key/Copies": "複本數量",
+                "storage/key/Checksum": "校驗碼",
+                "storage/key/File ID": "檔案 ID",
+                "storage/key/Master Node": "主節點",
+                "storage/key/Items Inside": "內含項目",
+                "storage/key/Node ID": "節點 ID",
+                "storage/key/Node State": "節點狀態",
+                "storage/key/Volume": "儲存區",
+                "storage/key/Volume Path": "儲存區路徑",
+                "storage/key/Volume Free": "可用空間",
+                "storage/key/Volume Access": "儲存區存取",
+                "storage/key/Primary Copy": "主要複本",
+                "storage/key/Last Checked": "最後檢查",
+                "storage/value/{0} copy": "{0} 份複本",
+                "storage/value/{0} copies": "{0} 份複本",
+                "storage/value/{0} of {1} readable": "{1} 份中 {0} 份可讀取",
+                "storage/value/{0} of {1}": "{0} / {1}",
+                "storage/value/Verified": "已驗證",
+                "storage/value/Committed": "已寫入",
+                "storage/value/Pending": "等待中",
+                "storage/value/Writing": "寫入中",
+                "storage/value/Stale": "已過期",
+                "storage/value/Failed": "失敗",
+                "storage/value/Unknown": "未知",
+                "storage/value/Online": "線上",
+                "storage/value/Degraded": "降級",
+                "storage/value/Offline": "離線",
+                "storage/value/Draining": "疏散中",
+                "storage/value/Maintenance": "維護中",
+                "storage/value/Not a member": "非叢集成員",
+                "storage/value/Read only": "唯讀",
+                "storage/value/Evacuating": "疏散中",
+                "storage/value/Yes": "是"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "此伺服器不支援遞迴變更存取權限"
@@ -284,7 +364,47 @@
                 "versions/label": "版本",
                 "versions/current": "(当前)",
                 "versions/failed": "无法读取版本记录",
-                "": ""
+                "": "",
+                "tab/storage": "存储位置",
+                "storage/title/cluster": "集群",
+                "storage/copies": "文件副本",
+                "storage/thisNode": "本机节点",
+                "storage/key/Cluster": "集群",
+                "storage/key/Namespace Path": "命名空间路径",
+                "storage/key/Replica Policy": "副本策略",
+                "storage/key/Copies": "副本数量",
+                "storage/key/Checksum": "校验码",
+                "storage/key/File ID": "文件 ID",
+                "storage/key/Master Node": "主节点",
+                "storage/key/Items Inside": "包含项目",
+                "storage/key/Node ID": "节点 ID",
+                "storage/key/Node State": "节点状态",
+                "storage/key/Volume": "存储区",
+                "storage/key/Volume Path": "存储区路径",
+                "storage/key/Volume Free": "可用空间",
+                "storage/key/Volume Access": "存储区访问",
+                "storage/key/Primary Copy": "主副本",
+                "storage/key/Last Checked": "最后检查",
+                "storage/value/{0} copy": "{0} 份副本",
+                "storage/value/{0} copies": "{0} 份副本",
+                "storage/value/{0} of {1} readable": "{1} 份中 {0} 份可读取",
+                "storage/value/{0} of {1}": "{0} / {1}",
+                "storage/value/Verified": "已验证",
+                "storage/value/Committed": "已写入",
+                "storage/value/Pending": "等待中",
+                "storage/value/Writing": "写入中",
+                "storage/value/Stale": "已过期",
+                "storage/value/Failed": "失败",
+                "storage/value/Unknown": "未知",
+                "storage/value/Online": "在线",
+                "storage/value/Degraded": "降级",
+                "storage/value/Offline": "离线",
+                "storage/value/Draining": "疏散中",
+                "storage/value/Maintenance": "维护中",
+                "storage/value/Not a member": "非集群成员",
+                "storage/value/Read only": "只读",
+                "storage/value/Evacuating": "疏散中",
+                "storage/value/Yes": "是"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "此服务器不支持递归更改存取权限"
@@ -380,7 +500,47 @@
                 "versions/label": "Version",
                 "versions/current": "(current)",
                 "versions/failed": "Unable to read the version history",
-                "": ""
+                "": "",
+                "tab/storage": "Storage",
+                "storage/title/cluster": "Cluster",
+                "storage/copies": "File Copies",
+                "storage/thisNode": "This node",
+                "storage/key/Cluster": "Cluster",
+                "storage/key/Namespace Path": "Namespace Path",
+                "storage/key/Replica Policy": "Replica Policy",
+                "storage/key/Copies": "Copies",
+                "storage/key/Checksum": "Checksum",
+                "storage/key/File ID": "File ID",
+                "storage/key/Master Node": "Master Node",
+                "storage/key/Items Inside": "Items Inside",
+                "storage/key/Node ID": "Node ID",
+                "storage/key/Node State": "Node State",
+                "storage/key/Volume": "Volume",
+                "storage/key/Volume Path": "Volume Path",
+                "storage/key/Volume Free": "Free Space",
+                "storage/key/Volume Access": "Volume Access",
+                "storage/key/Primary Copy": "Primary Copy",
+                "storage/key/Last Checked": "Last Checked",
+                "storage/value/{0} copy": "{0} copy",
+                "storage/value/{0} copies": "{0} copies",
+                "storage/value/{0} of {1} readable": "{0} of {1} readable",
+                "storage/value/{0} of {1}": "{0} of {1}",
+                "storage/value/Verified": "Verified",
+                "storage/value/Committed": "Committed",
+                "storage/value/Pending": "Pending",
+                "storage/value/Writing": "Writing",
+                "storage/value/Stale": "Out of date",
+                "storage/value/Failed": "Failed",
+                "storage/value/Unknown": "Unknown",
+                "storage/value/Online": "Online",
+                "storage/value/Degraded": "Degraded",
+                "storage/value/Offline": "Offline",
+                "storage/value/Draining": "Draining",
+                "storage/value/Maintenance": "Maintenance",
+                "storage/value/Not a member": "Not a member",
+                "storage/value/Read only": "Read only",
+                "storage/value/Evacuating": "Evacuating",
+                "storage/value/Yes": "Yes"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "Recursive permission change is not supported by this server"
@@ -476,7 +636,47 @@
                 "versions/label": "バージョン",
                 "versions/current": "(現在)",
                 "versions/failed": "バージョン履歴を読み取れません",
-                "": ""
+                "": "",
+                "tab/storage": "保存場所",
+                "storage/title/cluster": "クラスター",
+                "storage/copies": "ファイルの複製",
+                "storage/thisNode": "このノード",
+                "storage/key/Cluster": "クラスター",
+                "storage/key/Namespace Path": "名前空間パス",
+                "storage/key/Replica Policy": "複製ポリシー",
+                "storage/key/Copies": "複製数",
+                "storage/key/Checksum": "チェックサム",
+                "storage/key/File ID": "ファイル ID",
+                "storage/key/Master Node": "マスターノード",
+                "storage/key/Items Inside": "含まれる項目",
+                "storage/key/Node ID": "ノード ID",
+                "storage/key/Node State": "ノードの状態",
+                "storage/key/Volume": "ボリューム",
+                "storage/key/Volume Path": "ボリュームパス",
+                "storage/key/Volume Free": "空き容量",
+                "storage/key/Volume Access": "ボリュームのアクセス",
+                "storage/key/Primary Copy": "プライマリ複製",
+                "storage/key/Last Checked": "最終確認",
+                "storage/value/{0} copy": "{0} 個の複製",
+                "storage/value/{0} copies": "{0} 個の複製",
+                "storage/value/{0} of {1} readable": "{1} 個中 {0} 個が読み取り可能",
+                "storage/value/{0} of {1}": "{0} / {1}",
+                "storage/value/Verified": "検証済み",
+                "storage/value/Committed": "書き込み済み",
+                "storage/value/Pending": "保留中",
+                "storage/value/Writing": "書き込み中",
+                "storage/value/Stale": "古い状態",
+                "storage/value/Failed": "失敗",
+                "storage/value/Unknown": "不明",
+                "storage/value/Online": "オンライン",
+                "storage/value/Degraded": "低下",
+                "storage/value/Offline": "オフライン",
+                "storage/value/Draining": "退避中",
+                "storage/value/Maintenance": "メンテナンス",
+                "storage/value/Not a member": "メンバーではありません",
+                "storage/value/Read only": "読み取り専用",
+                "storage/value/Evacuating": "退避中",
+                "storage/value/Yes": "はい"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "このサーバーは権限の再帰的な変更に対応していません"
@@ -572,7 +772,47 @@
                 "versions/label": "버전",
                 "versions/current": "(현재)",
                 "versions/failed": "버전 기록을 읽을 수 없습니다",
-                "": ""
+                "": "",
+                "tab/storage": "저장 위치",
+                "storage/title/cluster": "클러스터",
+                "storage/copies": "파일 복제본",
+                "storage/thisNode": "이 노드",
+                "storage/key/Cluster": "클러스터",
+                "storage/key/Namespace Path": "네임스페이스 경로",
+                "storage/key/Replica Policy": "복제 정책",
+                "storage/key/Copies": "복제본 수",
+                "storage/key/Checksum": "체크섬",
+                "storage/key/File ID": "파일 ID",
+                "storage/key/Master Node": "마스터 노드",
+                "storage/key/Items Inside": "포함된 항목",
+                "storage/key/Node ID": "노드 ID",
+                "storage/key/Node State": "노드 상태",
+                "storage/key/Volume": "볼륨",
+                "storage/key/Volume Path": "볼륨 경로",
+                "storage/key/Volume Free": "남은 공간",
+                "storage/key/Volume Access": "볼륨 접근",
+                "storage/key/Primary Copy": "기본 복제본",
+                "storage/key/Last Checked": "마지막 확인",
+                "storage/value/{0} copy": "복제본 {0}개",
+                "storage/value/{0} copies": "복제본 {0}개",
+                "storage/value/{0} of {1} readable": "{1}개 중 {0}개 읽기 가능",
+                "storage/value/{0} of {1}": "{0} / {1}",
+                "storage/value/Verified": "확인됨",
+                "storage/value/Committed": "기록됨",
+                "storage/value/Pending": "대기 중",
+                "storage/value/Writing": "기록 중",
+                "storage/value/Stale": "오래됨",
+                "storage/value/Failed": "실패",
+                "storage/value/Unknown": "알 수 없음",
+                "storage/value/Online": "온라인",
+                "storage/value/Degraded": "성능 저하",
+                "storage/value/Offline": "오프라인",
+                "storage/value/Draining": "이전 중",
+                "storage/value/Maintenance": "유지 보수",
+                "storage/value/Not a member": "구성원 아님",
+                "storage/value/Read only": "읽기 전용",
+                "storage/value/Evacuating": "이전 중",
+                "storage/value/Yes": "예"
             },
             "titles": {
                 "Recursive permission change is not supported by this server": "이 서버는 권한의 재귀 변경을 지원하지 않습니다"