diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a37a25..1bc9fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ ## Changelog +### v0.45.0 — storage UX polish: deterministic order, init filter, register shortcut, system-storage clarity (2026-06-12) + +Builds on v0.44.0's role-aware drive management. Pairs with felhom-agent v0.24.0 (the eject role-gate +lives at the agent — see its CHANGELOG). This release is the controller-side clarity/ordering polish. + +- **Deterministic disk order (B1)** — `GET /api/disks` now sorts the agent's drive list server-side: + **user-data → system → backup** (then unrecognized), alphabetical by storage name within each tier. + The agent's storage view iterates an unordered Go map, so the list previously reordered on every + reload (CLAUDE.md lesson #3). The customer's manageable drives are now always on top, stably. + `sortDisksForView` in `agent_disk_handlers.go` + `TestSortDisksForView`. +- **Init wizard excludes mounted drives (B2)** — `storage_init.html`'s formattable filter gained + `&& !d.mount_path`, matching the attach wizard: an already-mounted drive (e.g. `felhom-usb`) no + longer appears as an "initialize" candidate. Eject it first to make it an init target. +- **Register shortcut (B3)** — a mounted, unregistered **user-data** drive now offers **Regisztrálás** + as its PRIMARY per-card action (Leválasztás/Törlés stay secondary). It records the existing mount + into the `StoragePath` registry (no format, no eject) via the new `POST /api/storage/register` → + `registerStoragePath`, then FileBrowser syncs. The natural "use this drive" intent, not "wipe it". +- **System-storage clarity (B4)** — `local` and `local-lvm` are both kept (not collapsed); each + storage card now carries a plain-Hungarian **purpose description** keyed on the agent's role/type, + the app-backing storages (`local-lvm` → "Alkalmazás-rendszer"; user-data → "Alkalmazás-adatok") are + tagged, and a one-line tiering note above the list answers "which storage do the apps use?". Pure + controller-side presentation — no agent contract change; role/type stay authoritative from the agent. +- **Eject impact (B5)** — the eject confirmation already lists, by name, the deployed apps that lose + their storage (via `/api/storage/impact`), at parity with the wipe warning — verified, no change. + ### v0.44.0 — role-aware drive management: protected lockout + customer type-to-confirm wipe + drive-list restyle (2026-06-11) The controller half of the storage-authorization redesign. The drive UI is now driven by the agent's diff --git a/controller/README.md b/controller/README.md index 33f741f..0e244fa 100644 --- a/controller/README.md +++ b/controller/README.md @@ -514,7 +514,8 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an ### 4. Storage Management -> **⚠️ Rebuilt on the agent-delegated disk model (v0.43.0), made ROLE-AWARE in v0.44.0.** After the 8C +> **⚠️ Rebuilt on the agent-delegated disk model (v0.43.0), made ROLE-AWARE in v0.44.0, UX-polished in +> v0.45.0.** After the 8C > de-privileging, the controller holds **no Proxmox/disk credentials and no destructive authority** — disk > execution + the gate live entirely in the **host agent**. The drive UI is driven by the agent's > authoritative **role** (`system` | `backup` | `user-data`, from `GET /api/disks`): the appliance's own @@ -526,6 +527,17 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an > Biztonsági mentés — védett / Felhasználói adat) and registered state, plus a **capacity bar** (the > monitoring `system-bar`, from the agent's `total_bytes`/`used_bytes`). Eject/Wipe render **only** for > user-data drives mounted under `/mnt`. +> - **(v0.45.0) Deterministic order** — `agentDisksListHandler` sorts the list server-side +> (`sortDisksForView`): **user-data → system → backup** (then unrecognized), alpha by name within a +> tier, so it no longer reorders on each reload (the agent's view iterates an unordered Go map). +> - **(v0.45.0) Purpose + app-backing clarity (B4)** — `local` and `local-lvm` are both shown (not +> collapsed); each card carries a plain-Hungarian **purpose description** keyed on the agent's +> role/type, the app-backing storages are tagged (`local-lvm` → "Alkalmazás-rendszer"; user-data → +> "Alkalmazás-adatok"), and a one-line tiering note above the list answers "which storage do the +> apps use?". Pure presentation — role/type stay authoritative from the agent. +> - **(v0.45.0) Register shortcut (B3)** — a mounted, **unregistered** user-data drive offers +> **Regisztrálás** as its PRIMARY action: `POST /api/storage/register` → `registerStoragePath` records +> the existing mount (no format, no eject) + FileBrowser-syncs. Leválasztás/Törlés stay secondary. > - **Customer wipe/eject** — a **type-to-confirm** modal that names the deployed apps that break > (`GET /api/storage/impact` → `appsUsingPath`) and disables the destructive button until the **mount > name is typed exactly**. Wipe (`POST /api/storage/wipe`): eject (unmount + deregister) → server-side @@ -538,7 +550,10 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an > only if a protected device somehow reaches init. > - **Guided attach** (`/settings/storage/attach`, `POST /api/storage/attach`): non-destructive — resolve > the existing fs UUID → `assign` → register. Selector restyled to cards (user-data only). -> - **Eject** (`POST /api/storage/eject`): benign unmount + deregister, with the agent's dependent-guest warning. +> - **Eject** (`POST /api/storage/eject`): benign unmount + deregister, with the agent's dependent-guest +> warning + the affected-app list (parity with wipe). **The eject is ROLE-GATED at the agent** (felhom- +> agent v0.24.0): `POST /disks/eject` refuses to unmount a system/backup mount — the UI hiding the button +> is defense-in-depth, not the control. Only user-data mounts are ejectable. > - **`agentapi`** (`internal/agentapi`) is the pinned client to the agent local API: `Disks`/`AssignDisk`/ > `EjectDisk`/`FormatDisk(…, confirmed, durableID)`; `DiskInfo.role`+capacity; > `FormatResult.{role,needs_confirmation,durable_id}`; `ErrNeedsConfirmation` (user-data) vs diff --git a/controller/internal/web/agent_disk_handlers.go b/controller/internal/web/agent_disk_handlers.go index 03e72e3..419892e 100644 --- a/controller/internal/web/agent_disk_handlers.go +++ b/controller/internal/web/agent_disk_handlers.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "sort" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" ) @@ -73,9 +74,39 @@ func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) return } + // Deterministic order: the agent's storage view iterates a Go map (unordered), so the list would + // otherwise reorder on every reload (CLAUDE.md lesson #3). The customer's manageable drives go on + // top, in a stable order: user-data first, then system, then backup, alpha by name within a tier. + sortDisksForView(resp.Disks) writeDiskJSON(w, http.StatusOK, true, "", resp) } +// sortDisksForView orders the agent's disk list deterministically (user-data → system → backup → +// unrecognized; alphabetical by storage name within each tier). A stable Go-side contract beats +// relying on map iteration order or template JS alone. +func sortDisksForView(disks []agentapi.DiskInfo) { + sort.SliceStable(disks, func(i, j int) bool { + if ri, rj := diskRoleRank(disks[i].Role), diskRoleRank(disks[j].Role); ri != rj { + return ri < rj + } + return disks[i].Name < disks[j].Name + }) +} + +// diskRoleRank ranks a role for the overview ordering (lower sorts first). +func diskRoleRank(role string) int { + switch role { + case "user-data": + return 0 + case "system": + return 1 + case "backup": + return 2 + default: + return 3 + } +} + // agentDiskAssignHandler proxies POST /api/disks/assign → agent POST /disks/assign. func (s *Server) agentDiskAssignHandler(w http.ResponseWriter, r *http.Request) { var req struct { diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index abd2355..e2f1a7c 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -185,6 +185,8 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { s.handleStorageWipe(w, r) case r.URL.Path == "/api/storage/impact" && r.Method == http.MethodGet: s.handleStorageImpact(w, r) + case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost: + s.handleStorageRegister(w, r) default: http.NotFound(w, r) } @@ -325,6 +327,35 @@ func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID}) } +// handleStorageRegister records an ALREADY-mounted, unregistered user-data drive into the StoragePath +// registry — no format, no eject. It is the natural primary action for a mounted-but-unregistered data +// drive (e.g. felhom-usb): the customer's intent is to USE the existing data, not wipe it. It reuses +// registerStoragePath (the manual-add path) — AddStoragePath dedupes, so a double-register is a clean +// error, not a duplicate. +func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) { + var req struct { + Where string `json:"where"` + Label string `json:"label"` + SetDefault bool `json:"set_default"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil) + return + } + req.Where = path.Clean(strings.TrimSpace(req.Where)) + if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") { + writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil) + return + } + if err := s.registerStoragePath(req.Where, req.Label, req.SetDefault); err != nil { + s.logger.Printf("[WARN] [web] storage register %s failed: %v", req.Where, err) + writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + return + } + s.logger.Printf("[INFO] [web] storage path registered (existing mount): %s", req.Where) + writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": req.Where}) +} + func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) { var req storageProvReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/controller/internal/web/storage_handlers_test.go b/controller/internal/web/storage_handlers_test.go index cded7e8..3ab0694 100644 --- a/controller/internal/web/storage_handlers_test.go +++ b/controller/internal/web/storage_handlers_test.go @@ -237,6 +237,34 @@ func TestAppsUsingPathIn(t *testing.T) { } } +// B1: the disk overview must render in a deterministic order — user-data first, then system, then +// backup (then anything unrecognized), alphabetical by name within each tier — so the list does not +// reorder on each reload (the agent's storage view iterates an unordered Go map). +func TestSortDisksForView(t *testing.T) { + disks := []agentapi.DiskInfo{ + {Name: "felhom-pbs", Role: "backup"}, + {Name: "local-lvm", Role: "system"}, + {Name: "zdata", Role: "user-data"}, + {Name: "local", Role: "system"}, + {Name: "adata", Role: "user-data"}, + {Name: "mystery", Role: ""}, + } + sortDisksForView(disks) + var got []string + for _, d := range disks { + got = append(got, d.Name) + } + want := []string{"adata", "zdata", "local", "local-lvm", "felhom-pbs", "mystery"} + if len(got) != len(want) { + t.Fatalf("length mismatch: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("order at %d: got %q want %q (full: %v)", i, got[i], want[i], got) + } + } +} + func TestMountWhere(t *testing.T) { if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" { t.Errorf("mountWhere(hdd_1) = %q, %v", w, err) diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index 250c134..98e935b 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -380,6 +380,23 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}} if(!d.mount_path) return ''; return registered[d.mount_path] ? 'Regisztrálva' : 'Nem regisztrált'; } + // appBackingTag marks the storages that actually hold deployed apps: the internal SSD (app + // databases + Docker) and the external user-data drives (large app files). Keyed on the agent's + // authoritative role/type — pure presentation, no agent contract change. + function appBackingTag(d){ + if(d.type==='lvmthin') return 'Alkalmazás-rendszer'; + if(d.role==='user-data') return 'Alkalmazás-adatok'; + return ''; + } + // purposeDesc explains, in plain Hungarian, what each storage is for — so the "which one do the + // apps use?" question is answered per-card. Keyed on type first, then role. + function purposeDesc(d){ + if(d.type==='lvmthin') return 'Belső SSD — a szerver rendszere, a Docker és a telepített alkalmazások adatbázisai itt találhatók.'; + if(d.type==='local'||d.type==='dir') return 'Host tárhely — rendszer-sablonok, ISO-k, host szintű mentések. Nem tárol alkalmazásadatot.'; + if(d.type==='pbs'||d.role==='backup') return 'A biztonsági mentések tárhelye.'; + if(d.role==='user-data') return 'Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek.'; + return ''; + } function capBar(d){ if(!d.total_bytes || d.total_bytes<=0) return ''; var pct = d.used_fraction ? d.used_fraction*100 : (d.used_bytes/d.total_bytes*100); @@ -387,11 +404,17 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}} return '
Nincs észlelt meghajtó.
'; return; } var registered={}; (window.__registeredPaths||[]).forEach(function(p){registered[p]=true;}); - var html='Az alkalmazások rendszere és adatbázisai a belső SSD-n (local-lvm), a nagy méretű fájljaik a külső adattárolókon tárolódnak.
'; + html+='Nincs formázható felhasználói adatmeghajtó. (A rendszer- és biztonsági-mentés meghajtók védettek.)
'; return; } + // Only USER-DATA drives with a block device that are NOT already mounted are valid init (format) + // targets — a mounted drive must be ejected (Leválasztás) first, like the attach wizard. This + // keeps an already-managed drive (e.g. felhom-usb) from showing up as an "initialize" candidate. + var formattable = disks.filter(function(d){ return d.backing_device!=="" && d.role==='user-data' && !d.mount_path; }); + if(formattable.length===0){ document.getElementById('disk-list').innerHTML='Nincs formázható felhasználói adatmeghajtó. (A csatlakoztatott meghajtókat előbb le kell választani; a rendszer- és biztonsági-mentés meghajtók védettek.)
'; return; } var html='