From 9ed844fd0bf13efc94d3aee12e8630d99ce980a0 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 12 Jun 2026 09:35:31 +0200 Subject: [PATCH] =?UTF-8?q?controller=20v0.45.0:=20storage=20UX=20polish?= =?UTF-8?q?=20=E2=80=94=20deterministic=20order,=20init=20filter,=20regist?= =?UTF-8?q?er=20shortcut,=20system-storage=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 sort /api/disks (user-data→system→backup, alpha within); B2 init wizard excludes mounted drives; B3 Regisztrálás primary action for mounted-unregistered user-data drives (POST /api/storage/register); B4 per-card purpose descriptions + app-backing tags + tiering note (local & local-lvm both kept); B5 eject already names affected apps. Pairs with felhom-agent v0.24.0 eject role-gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 25 ++++++++++ controller/README.md | 19 +++++++- .../internal/web/agent_disk_handlers.go | 31 +++++++++++++ controller/internal/web/storage_handlers.go | 31 +++++++++++++ .../internal/web/storage_handlers_test.go | 28 +++++++++++ .../internal/web/templates/settings.html | 46 +++++++++++++++++-- .../internal/web/templates/storage_init.html | 8 ++-- controller/internal/web/templates/style.css | 8 ++++ 8 files changed, 186 insertions(+), 10 deletions(-) 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 '
' +'
'+hum(d.used_bytes)+' / '+hum(d.total_bytes)+' ('+pct.toFixed(0)+'%)
'; } - function actions(d){ + function actions(d, registered){ // Destructive controls ONLY for user-data drives that are mounted under /mnt. System/backup get none. if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return ''; var dev = esc(d.backing_device||''), mp = esc(d.mount_path); - var btns = ''; + var btns = ''; + // A mounted-but-unregistered user-data drive: the natural intent is to USE it → Regisztrálás is + // the PRIMARY action (no format, no eject). Leválasztás/Törlés stay available but secondary. + if(!registered[d.mount_path]){ + btns += ' '; + } + btns += ''; if(d.backing_device){ btns += ' '; } return '
'+btns+'
'; } @@ -403,15 +426,19 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}} var disks=(j.data&&j.data.disks)||[]; if(disks.length===0){ box.innerHTML='

Nincs észlelt meghajtó.

'; return; } var registered={}; (window.__registeredPaths||[]).forEach(function(p){registered[p]=true;}); - var html='
'; + // One-line tiering explanation so "which storage do the apps use?" is answered at a glance. + 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+='
'; disks.forEach(function(d){ var sub = esc(d.type)+' · '+esc(d.backing_device||'—')+(d.mount_path?' · '+esc(d.mount_path):''); - var badges = roleBadge(d.role)+classBadge(d)+dataBadge(d)+regBadge(d,registered); + var badges = roleBadge(d.role)+appBackingTag(d)+classBadge(d)+dataBadge(d)+regBadge(d,registered); + var purpose = purposeDesc(d); html+='
' +'
'+esc(d.name)+''+sub+'
' +'
'+badges+'
' + +(purpose?'
'+esc(purpose)+'
':'') +capBar(d) - +actions(d) + +actions(d,registered) +'
'; }); html+='
'; @@ -445,6 +472,15 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}} document.getElementById('confirm-go').onclick=opts.onConfirm; } + // registerDrive records an already-mounted, unregistered user-data drive into the StoragePath + // registry (no format, no eject) — makes the existing mount usable (schedulable + FileBrowser sync). + window.registerDrive=async function(where){ + try{ + var r=await fetch('/api/storage/register',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({where:where})}); + var j=await r.json(); if(!j.ok){ alert('Regisztráció sikertelen: '+(j.error||'')); return; } + location.reload(); + }catch(e){ alert('Hiba: '+e.message); } + }; window.confirmEject=function(where){ var name=where.replace(/^\/mnt\//,''); openConfirm({title:'Meghajtó leválasztása', mount:where, mountName:name, diff --git a/controller/internal/web/templates/storage_init.html b/controller/internal/web/templates/storage_init.html index 03a90a8..d692315 100644 --- a/controller/internal/web/templates/storage_init.html +++ b/controller/internal/web/templates/storage_init.html @@ -80,9 +80,11 @@ async function loadDisks(){ var r = await fetch('/api/disks'); var j = await r.json(); if(!j.ok){ throw new Error(j.error||'Hiba'); } var disks = (j.data&&j.data.disks)||[]; - // Only USER-DATA drives with a block device are valid init (format) targets. - var formattable = disks.filter(function(d){ return d.backing_device!=="" && d.role==='user-data'; }); - if(formattable.length===0){ document.getElementById('disk-list').innerHTML='

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='
'; formattable.forEach(function(d,i){ var sub = esc(d.type)+' · '+esc(d.backing_device)+(d.mount_path?' · '+esc(d.mount_path):''); diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index f7d854d..d938b8d 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -3124,6 +3124,14 @@ a.stat-card:hover { .badge-ok { background: rgba(35, 134, 54, 0.18); color: #3fb950; } .badge-lock { background: rgba(210, 153, 34, 0.18); color: var(--yellow); } .badge-muted { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); } +.badge-info { background: rgba(0, 136, 204, 0.18); color: var(--accent-light); } +/* Per-card storage purpose description + the tiering one-liner above the drive list. */ +.drive-purpose { font-size: .8rem; color: var(--text-secondary); line-height: 1.4; } +.drive-tiering-note { + font-size: .8rem; color: var(--text-secondary); line-height: 1.4; + margin: 0 0 .75rem; padding: .5rem .75rem; + background: rgba(0, 136, 204, 0.06); border-left: 3px solid var(--accent-blue); border-radius: 6px; +} .badge .lock-ico { margin-right: .25rem; } span.mono, .mono { font-family: 'JetBrains Mono', monospace; }