v0.57.0: stable host-storage list + per-app Tier-2 config panel

Part A of the UI-fixes/storage-spike spec.

A1: enrichHostStorageTargets sorts /api/host-metrics storage_targets
server-side and attaches friendly Hungarian labels + purpose, fixing the
#host-storage-bars reorder-on-poll bug. Display labels only — PVE storage
ids are never renamed.

A2: new GET/POST /stacks/{name}/backup Tier-2 config panel; the "2. mentés"
Beállítás button is repointed there from the dead-end deploy page. Customer
can pin a target drive or disable Tier 2; preference is preserved across the
runner's status writes. Always visible (single-SSD + non-HDD apps included).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 14:23:34 +02:00
parent cae2bfbe5b
commit 13c6a0929a
13 changed files with 651 additions and 16 deletions
@@ -2,6 +2,9 @@ package web
import (
"net/http"
"sort"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// Agent-backed host metrics (slice 9).
@@ -34,5 +37,67 @@ func (s *Server) ServeHostMetricsAPI(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
// The agent enumerates storages via `pvesm` in a non-deterministic order, so #host-storage-bars
// reordered on every poll (item 2). Stabilise the order Go-side and attach friendly Hungarian
// labels + a one-line purpose per entry — display-only; we NEVER rename the PVE storage ids.
enrichHostStorageTargets(resp.StorageTargets)
writeDiskJSON(w, http.StatusOK, true, "", resp)
}
// enrichHostStorageTargets sorts the host's storage targets into a stable, customer-meaningful
// order (user-data → system+apps → backup → other; alphabetical by id within a tier) and fills in
// a friendly label + purpose per entry. Mirrors the disk-overview's sortDisksForView contract:
// a Go-side ordering beats relying on the agent's enumeration order or template JS.
func enrichHostStorageTargets(targets []agentapi.StorageTarget) {
sort.SliceStable(targets, func(i, j int) bool {
if ri, rj := storageTypeRank(targets[i].Type), storageTypeRank(targets[j].Type); ri != rj {
return ri < rj
}
return targets[i].Name < targets[j].Name
})
for i := range targets {
label, purpose := storageLabelAndPurpose(targets[i])
targets[i].Label = label
targets[i].Purpose = purpose
}
}
// storageTypeRank orders storage by what the customer cares about: where their app data lives
// first, then the system/app disk, then backup targets. Lower sorts first.
func storageTypeRank(typ string) int {
switch typ {
case "usb", "local-dir":
return 0 // external user-data drives (where browsable app data lives)
case "lvmthin", "lvm":
return 1 // the internal SSD: OS + the guest/app volumes
case "local":
return 2 // builtin dir: templates + local vzdump backups
case "pbs", "nfs", "cifs":
return 3 // backup targets (offsite / network)
default:
return 4
}
}
// storageLabelAndPurpose maps a storage target to a friendly Hungarian label + one-line purpose.
// Falls back to the raw id for unrecognised types. The raw id stays in Name (rendered muted).
func storageLabelAndPurpose(t agentapi.StorageTarget) (string, string) {
switch t.Type {
case "usb":
return "Külső adattároló (USB)", "Az alkalmazások adatai (fájlok, médiatár) ezen a meghajtón vannak."
case "local-dir":
return "Külső adattároló", "Az alkalmazások adatai (fájlok, médiatár) ezen a meghajtón vannak."
case "lvmthin", "lvm":
return "Belső SSD rendszer és alkalmazások", "Az operációs rendszer és a telepített alkalmazások tárhelye."
case "local":
return "Belső lemez sablonok és helyi mentések", "Rendszersablonok és helyi biztonsági mentések."
case "pbs":
return "Távoli biztonsági mentés", "Titkosított, telephelyen kívüli biztonsági mentések."
case "nfs":
return "Hálózati mentés (NFS)", "Hálózati tárolón őrzött biztonsági mentések."
case "cifs":
return "Hálózati mentés (SMB)", "Hálózati tárolón őrzött biztonsági mentések."
default:
return t.Name, ""
}
}
@@ -0,0 +1,81 @@
package web
import (
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// enrichHostStorageTargets must produce a STABLE order regardless of how the agent enumerated the
// storages (the #host-storage-bars reorder bug, item 2), and attach a friendly label + purpose per
// entry without ever mutating the raw PVE id (Name).
func TestEnrichHostStorageTargets_OrderAndLabels(t *testing.T) {
// Deliberately shuffled relative to the desired display order.
targets := []agentapi.StorageTarget{
{Name: "felhom-pbs", Type: "pbs"},
{Name: "local", Type: "local"},
{Name: "felhom-usb", Type: "usb"},
{Name: "local-lvm", Type: "lvmthin"},
}
enrichHostStorageTargets(targets)
wantOrder := []string{"felhom-usb", "local-lvm", "local", "felhom-pbs"}
for i, want := range wantOrder {
if targets[i].Name != want {
t.Fatalf("position %d = %q, want %q (full order: %v)", i, targets[i].Name, want, names(targets))
}
}
// Friendly labels attached; raw ids untouched.
for _, tgt := range targets {
if tgt.Label == "" || tgt.Purpose == "" {
t.Errorf("%s (%s): missing label/purpose (label=%q purpose=%q)", tgt.Name, tgt.Type, tgt.Label, tgt.Purpose)
}
}
if targets[0].Label != "Külső adattároló (USB)" {
t.Errorf("usb label = %q", targets[0].Label)
}
if targets[1].Label != "Belső SSD rendszer és alkalmazások" {
t.Errorf("lvmthin label = %q", targets[1].Label)
}
}
// A second run with the same input must yield the same order (determinism / idempotence).
func TestEnrichHostStorageTargets_Stable(t *testing.T) {
mk := func() []agentapi.StorageTarget {
return []agentapi.StorageTarget{
{Name: "b-usb", Type: "usb"},
{Name: "a-usb", Type: "usb"},
{Name: "local-lvm", Type: "lvmthin"},
}
}
a, b := mk(), mk()
enrichHostStorageTargets(a)
enrichHostStorageTargets(b)
for i := range a {
if a[i].Name != b[i].Name {
t.Fatalf("non-deterministic at %d: %q vs %q", i, a[i].Name, b[i].Name)
}
}
// Within the same tier, alphabetical by id.
if a[0].Name != "a-usb" || a[1].Name != "b-usb" {
t.Errorf("within-tier order = %v, want a-usb,b-usb first", names(a))
}
}
// An unrecognised type falls back to the raw id and an empty purpose.
func TestEnrichHostStorageTargets_UnknownType(t *testing.T) {
targets := []agentapi.StorageTarget{{Name: "weird-store", Type: "zfspool"}}
enrichHostStorageTargets(targets)
if targets[0].Label != "weird-store" || targets[0].Purpose != "" {
t.Errorf("unknown type: label=%q purpose=%q, want raw id + empty", targets[0].Label, targets[0].Purpose)
}
}
func names(ts []agentapi.StorageTarget) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.Name
}
return out
}
+6 -1
View File
@@ -614,6 +614,8 @@ type AppBackupRow struct {
Tier2DestDisconnected bool
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
Tier2DestInactive bool
// Tier2UserDisabled — customer turned Tier 2 off for this app from the config panel.
Tier2UserDisabled bool
// Warnings accumulated for this app
Warnings []string
@@ -701,7 +703,10 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
// Tier 2 (off-drive copy) status, from the config the Tier 2 runner persists.
if cd := s.settings.GetCrossDriveConfig(app.StackName); cd != nil {
if cd.LastStatus == "no_target" {
row.Tier2UserDisabled = cd.UserDisabled
if cd.UserDisabled {
// Customer turned Tier 2 off — show nothing more; the panel button still appears.
} else if cd.LastStatus == "no_target" {
// Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap).
row.Tier2Configured = false
row.Tier2StatusBadge = "Nincs 2. meghajtó"
+6
View File
@@ -263,6 +263,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/deploy")
s.deployHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodGet:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
s.tier2ConfigPageHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodPost:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
s.tier2ConfigSaveHandler(w, r, name)
case path == "/import":
s.importPageHandler(w, r)
case path == "/static/style.css":
+12 -4
View File
@@ -317,7 +317,12 @@
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
<div class="backup-layer-row">
<span class="tier-label">2. mentés</span>
{{if and .Tier2Configured .Tier2DestDisconnected}}
{{if .Tier2UserDisabled}}
<span class="layer-unconfigured">2. mentés kikapcsolva</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else if and .Tier2Configured .Tier2DestDisconnected}}
<span class="layer-method" style="opacity:.6">rsync</span>
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</span>
<span class="badge badge-warn" style="font-size:.7rem">Cél meghajtó leválasztva</span>
@@ -326,7 +331,7 @@
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else if and .Tier2Configured .Tier2DestInactive}}
<span class="layer-method" style="opacity:.6">rsync</span>
@@ -337,7 +342,7 @@
{{end}}
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else if .Tier2Configured}}
<span class="layer-method">rsync</span>
@@ -354,7 +359,7 @@
<span class="tier-contents">{{.BackupContents}}</span>
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben">📁</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{else}}
<span class="layer-auto-ok">✓ 1. mentés auto</span>
@@ -362,6 +367,9 @@
{{if .Tier2LastError}}
<span class="layer-reason" style="opacity:.85" title="A 2. mentés automatikus — külön beállítás nem kell">{{.Tier2LastError}}</span>
{{end}}
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
</div>
{{end}}
</div>
<!-- Tier 3: Remote backup (future) -->
@@ -749,12 +749,17 @@
} else {
var html = '';
targets.forEach(function(t) {
var label = escapeHtml(t.name || '') + (t.type ? ' (' + escapeHtml(t.type) + ')' : '');
// Friendly label (server-supplied) with the raw PVE storage id shown muted for clarity.
var friendly = escapeHtml(t.label || t.name || '');
var rawId = escapeHtml(t.name || '');
var idHtml = rawId ? ' <span style="color:var(--text-muted);font-size:.72rem">(' + rawId + ')</span>' : '';
var label = friendly + idHtml;
var purposeHtml = t.purpose ? '<div class="storage-purpose" style="font-size:.72rem;opacity:.65;margin-top:.2rem">' + escapeHtml(t.purpose) + '</div>' : '';
if (t.state && t.state !== 'attached') {
html += '<div class="storage-item storage-disconnected">' +
'<div class="storage-header"><span class="storage-label">' + label + '</span>' +
'<span class="storage-value badge-error" style="font-size:.75rem">Nem elérhető</span></div>' +
'<div class="system-bar"><div class="system-bar-disconnected"></div></div></div>';
'<div class="system-bar"><div class="system-bar-disconnected"></div></div>' + purposeHtml + '</div>';
return;
}
var pct = (t.used_fraction != null ? t.used_fraction * 100 : 0);
@@ -774,7 +779,7 @@
'<span class="storage-value">' + fmtBytesGB(t.used_bytes) + ' / ' + fmtBytesGB(t.total_bytes) +
' (' + Math.round(pct) + '%)</span></div>' +
'<div class="system-bar"><div class="system-bar-fill ' + usageColorClass(pct) +
'" style="width:' + Math.min(100, pct).toFixed(1) + '%"></div></div></div>';
'" style="width:' + Math.min(100, pct).toFixed(1) + '%"></div></div>' + purposeHtml + '</div>';
});
bars.innerHTML = html;
}
@@ -0,0 +1,93 @@
{{define "tier2_config"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>2. mentés beállítása — {{.DisplayName}}</h2>
<a href="/backups" class="btn btn-outline btn-sm">← Vissza a mentésekhez</a>
</div>
{{if .Flash}}<div class="monitoring-banner monitoring-banner-green">{{.Flash}}</div>{{end}}
{{if .FlashError}}<div class="monitoring-banner monitoring-banner-red">{{.FlashError}}</div>{{end}}
<div class="monitor-card">
{{with .Tier2}}
<p style="color:var(--text-muted);font-size:.9rem;margin-top:0">
A 2. mentés egy <strong>másik fizikai meghajtóra</strong> készít másolatot az alkalmazás
helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető
felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).
</p>
{{if not .IsHDDApp}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem">
Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek
<strong>már szerepelnek a teljes rendszermentésben (PBS)</strong>. A 2. (off-drive) másolat
kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az
alkalmazáshoz nincs külön teendő.
</div>
{{else}}
<h3 style="margin-top:1.5rem">Jelenlegi állapot</h3>
<div class="sysinfo-grid">
<div class="sysinfo-row">
<span class="sysinfo-label">2. mentés</span>
<span class="sysinfo-value">{{if .Disabled}}Kikapcsolva{{else}}Bekapcsolva{{end}}</span>
</div>
{{if .NoTarget}}
<div class="sysinfo-row">
<span class="sysinfo-label">Cél</span>
<span class="sysinfo-value text-error">Nincs elérhető off-drive cél</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">Megjegyzés</span>
<span class="sysinfo-value" style="color:var(--text-muted)">{{.NoTargetReason}}</span>
</div>
{{else}}
<div class="sysinfo-row">
<span class="sysinfo-label">Cél meghajtó</span>
<span class="sysinfo-value">{{.EffectiveLabel}}{{if .EffectiveIsSSD}} — csak DB/konfiguráció{{end}}</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">Kiválasztás módja</span>
<span class="sysinfo-value" style="color:var(--text-muted)">{{if .Preferred}}kézi választás{{else}}automatikus{{end}} — {{.EffectiveDesc}}</span>
</div>
{{end}}
</div>
{{if .EffectiveIsSSD}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem">
Jelenleg csak a belső SSD érhető el 2. célként, ezért csak az adatbázis és a konfiguráció
másolódik. A belső rendszerlemez kicsi, ezért a nagy fájlok off-drive mentéséhez egy
<strong>2. adatmeghajtó</strong> szükséges (hogy a rendszerlemez ne teljen meg).
</div>
{{end}}
<form method="POST" action="/stacks/{{$.StackName}}/backup" style="margin-top:1.5rem">
{{$.CSRFField}}
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" name="enabled" value="on" {{if not .Disabled}}checked{{end}}>
2. mentés bekapcsolva
</label>
</div>
<div class="form-group">
<label for="target">Cél meghajtó</label>
<select id="target" name="target" class="form-control">
<option value="">Automatikus{{if not .NoTarget}} (jelenleg: {{.EffectiveLabel}}){{end}}</option>
{{range .Alternatives}}
<option value="{{.Path}}" {{if eq .Path $.Tier2.Preferred}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
{{if not .Alternatives}}
<div style="font-size:.8rem;color:var(--text-muted);margin-top:.4rem">
Nincs másik adatmeghajtó — automatikus cél a belső SSD (csak DB/konfiguráció). Egy 2.
adatmeghajtó hozzáadásával a teljes adat is off-drive menthető.
</div>
{{end}}
</div>
<button type="submit" class="btn btn-primary">Mentés</button>
</form>
{{end}}
{{end}}
</div>
{{template "layout_end" .}}
{{end}}
@@ -0,0 +1,110 @@
package web
import (
"net/http"
"net/url"
)
// Per-app Tier-2 (off-drive copy) config panel — item 4.
//
// The "2. mentés" row on the backup page used to link its "Beállítás" button at the app's deploy
// page, which has no backup-location setting (a dead end). This is the real surface: it shows the
// current/auto off-drive target + last-run status, and lets the customer pin a different registered
// drive or turn Tier 2 off. It is ALWAYS shown — even when only the internal SSD qualifies, or the
// app's data lives on the rootfs (already in PBS) — with honest context rather than a hidden control.
//
// Routes (wired in server.go, behind RequireAuth + CsrfProtect):
// GET /stacks/{name}/backup → tier2ConfigPageHandler
// POST /stacks/{name}/backup → tier2ConfigSaveHandler
func (s *Server) tier2ConfigPageHandler(w http.ResponseWriter, r *http.Request, name string) {
stack, ok := s.stackMgr.GetStack(name)
if !ok {
http.NotFound(w, r)
return
}
if s.backupMgr == nil {
http.Error(w, "A mentés nincs beállítva ezen a szerveren.", http.StatusServiceUnavailable)
return
}
info := s.backupMgr.Tier2Info(name)
data := s.baseData("backups", "2. mentés beállítása — "+stack.Meta.DisplayName)
data["StackName"] = name
data["DisplayName"] = stack.Meta.DisplayName
data["Tier2"] = info
if flash := r.URL.Query().Get("flash"); flash != "" {
data["Flash"] = flash
}
if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
data["FlashError"] = flashErr
}
s.executeTemplate(w, r, "tier2_config", data)
}
func (s *Server) tier2ConfigSaveHandler(w http.ResponseWriter, r *http.Request, name string) {
if _, ok := s.stackMgr.GetStack(name); !ok {
http.NotFound(w, r)
return
}
if s.backupMgr == nil {
http.Error(w, "A mentés nincs beállítva ezen a szerveren.", http.StatusServiceUnavailable)
return
}
_ = r.ParseForm()
// "enabled" checkbox: present → Tier 2 on; absent → off (UserDisabled = !enabled).
enabled := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true"
target := r.FormValue("target") // "" = automatic; otherwise a registered drive path
// Validate the chosen target against the eligible alternatives (defence-in-depth: the runner
// also re-validates off-disk at run time, but reject a bogus path here for a clean message).
if target != "" {
valid := false
for _, opt := range s.backupMgr.Tier2Info(name).Alternatives {
if opt.Path == target {
valid = true
break
}
}
if !valid {
s.redirectTier2(w, r, name, "", "A választott cél meghajtó nem érvényes.")
return
}
}
if err := s.settings.SetTier2Preference(name, !enabled, target); err != nil {
s.logger.Printf("[ERROR] [web] save Tier 2 preference for %s: %v", name, err)
s.redirectTier2(w, r, name, "", "A beállítás mentése nem sikerült.")
return
}
s.logger.Printf("[INFO] [web] Tier 2 preference saved for %s: enabled=%v target=%q", name, enabled, target)
// Apply immediately when enabled for an HDD app so the customer sees the result on return.
if enabled && s.backupMgr.Tier2Info(name).IsHDDApp {
go func() {
if err := s.backupMgr.RunTier2(name); err != nil {
s.logger.Printf("[WARN] [web] immediate Tier 2 run for %s failed: %v", name, err)
}
}()
}
s.redirectTier2(w, r, name, "A 2. mentés beállítása elmentve.", "")
}
// redirectTier2 sends the customer back to the panel with a flash message.
func (s *Server) redirectTier2(w http.ResponseWriter, r *http.Request, name, flash, flashErr string) {
dest := "/stacks/" + url.PathEscape(name) + "/backup"
q := url.Values{}
if flash != "" {
q.Set("flash", flash)
}
if flashErr != "" {
q.Set("flash_error", flashErr)
}
if e := q.Encode(); e != "" {
dest += "?" + e
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}