From 13c6a0929ace8038c1cc68f9621640ef86ade768 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 13 Jun 2026 14:23:34 +0200 Subject: [PATCH] v0.57.0: stable host-storage list + per-app Tier-2 config panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 34 +++++ controller/internal/agentapi/client.go | 5 + controller/internal/backup/tier2.go | 126 +++++++++++++++++- controller/internal/backup/tier2_test.go | 87 +++++++++++- controller/internal/settings/settings.go | 26 ++++ .../web/agent_host_metrics_handler.go | 65 +++++++++ .../web/agent_host_metrics_handler_test.go | 81 +++++++++++ controller/internal/web/handlers.go | 7 +- controller/internal/web/server.go | 6 + .../internal/web/templates/backups.html | 16 ++- .../internal/web/templates/monitoring.html | 11 +- .../internal/web/templates/tier2_config.html | 93 +++++++++++++ .../internal/web/tier2_config_handler.go | 110 +++++++++++++++ 13 files changed, 651 insertions(+), 16 deletions(-) create mode 100644 controller/internal/web/agent_host_metrics_handler_test.go create mode 100644 controller/internal/web/templates/tier2_config.html create mode 100644 controller/internal/web/tier2_config_handler.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 13e5b49..96ef520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ ## Changelog +### v0.57.0 — UI fixes: stable host-storage list + per-app Tier-2 config panel (2026-06-13) + +Part A of the UI-fixes/storage-spike spec (Part B is a build-nothing findings report). + +- **A1 — host storage list no longer reorders (item 2):** the monitoring page's `#host-storage-bars` + list (the client-side one filled from the agent's PVE-storage list — `local`, `local-lvm`, + `felhom-pbs`, `felhom-usb` with thin-pool % + temperature) reordered on every 8 s poll because the + agent enumerates `pvesm` in a non-deterministic order and the list never passed through a Go sort. + Now `enrichHostStorageTargets` (`agent_host_metrics_handler.go`) sorts the `/api/host-metrics` + response server-side (user-data → system+apps → backup → other; alphabetical by id within a tier) + and attaches a **friendly Hungarian label + one-line purpose** per entry (e.g. `local-lvm` → + "Belső SSD – rendszer és alkalmazások"). The raw PVE id is kept and shown muted — **display labels + only; PVE storage ids are never renamed** (vzdump/PBS configs reference them by name). The + monitoring JS renders the friendly label + the purpose sub-line. (Note: this is the JS-driven list, + NOT the server-rendered user-data `buildStorageBars` list that v0.56.0's 4C already sorted.) +- **A2 — per-app Tier-2 config panel (item 4):** the "2. mentés" row's **Beállítás** button used to + link to the app's deploy page, which has no backup-location setting (a dead end). New route + `GET/POST /stacks/{name}/backup` (`tier2_config_handler.go` + `tier2_config.html`) is the real + surface: it shows the current/effective off-drive target, whether it's the size-limited internal + SSD, the last-run status, and lets the customer **pin a different registered drive** or **turn + Tier 2 off**. The control is **always visible** — even when only the internal SSD qualifies (shows + "automatikus: belső SSD — csak DB/konfiguráció" + the rootfs-headroom note) and for non-HDD apps + (shows honest "already in the PBS whole-guest snapshot; the off-drive copy is supplementary" + context). The button is repointed on every "2. mentés" branch (incl. the unconfigured + disabled + states). + - Persistence: two preference fields on `settings.CrossDriveBackup` — `UserDisabled` and + `PreferredTarget` — set via `SetTier2Preference` and **preserved across the runner's status + writes** (`withTier2Prefs`). `selectTier2Target` now honors a valid pinned target (off-disk, + registered) before the auto-pick; an invalid pin silently falls back to auto. `RunTier2` skips a + customer-disabled app. Saving with Tier 2 on for an HDD app triggers an immediate run so the + result shows on return. +- Tests: `enrichHostStorageTargets` order/labels/determinism; `selectTier2Target` honors/falls-back + on a pin; status writes preserve the preference. + ### v0.56.0 — Phase 4: FileBrowser scoping + deploy DB-on-SSD note + monitoring storage descriptions (2026-06-13) Polish layer closing the slice. diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index c696518..998d5ff 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -473,6 +473,11 @@ type StorageTarget struct { ClassHint string `json:"class_hint"` ThinPool *ThinPoolFill `json:"thin_pool,omitempty"` Smart SmartSummary `json:"smart"` + // Label and Purpose are controller-side display enrichment (NOT from the agent): a friendly + // Hungarian name + one-line purpose so the customer understands what each storage holds. The + // raw PVE storage id stays in Name (display-only labels — we never rename the actual storage). + Label string `json:"label,omitempty"` + Purpose string `json:"purpose,omitempty"` } // HostMetricsResponse mirrors the agent's GET /host/metrics payload (host-wide health + per-storage diff --git a/controller/internal/backup/tier2.go b/controller/internal/backup/tier2.go index 9df6e97..b443b37 100644 --- a/controller/internal/backup/tier2.go +++ b/controller/internal/backup/tier2.go @@ -48,13 +48,40 @@ func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool { return (availGB - unitGB) >= reserve } -// selectTier2Target auto-picks the off-drive destination for an app's Tier 2 copy. +// selectTier2Target picks the off-drive destination for an app's Tier 2 copy. A customer-pinned +// target (PreferredTarget, set from the config panel) wins when it is still valid; otherwise it +// auto-picks: another user-data drive, else the internal SSD for small units (headroom-guarded). func (m *Manager) selectTier2Target(stackName string, unitSizeBytes int64) (*Tier2Target, error) { sourceDrive := m.GetAppDrivePath(stackName) if sourceDrive == "" { return nil, fmt.Errorf("no source drive for %s", stackName) } + // 0. Honor a customer-pinned target if it is still valid (registered, schedulable, off-disk). + // An invalid pin (gone / same physical disk) silently falls through to the auto-pick. + if m.settings != nil { + if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil && cd.PreferredTarget != "" { + for _, sp := range m.settings.GetSchedulableStoragePaths() { + if sp.Path != cd.PreferredTarget { + continue + } + if sp.Path == sourceDrive || system.SamePhysicalDevice(sourceDrive, sp.Path) { + break // pinned target is on the same physical disk — not off-drive; fall through + } + label := sp.Label + if label == "" { + label = filepath.Base(sp.Path) + } + return &Tier2Target{ + NamespaceRoot: NamespaceRoot(sp.Path, true), + Label: label, + IsSystemDrive: false, + Reason: "kézi választás", + }, nil + } + } + } + // 1. Prefer another registered user-data drive on a DIFFERENT physical disk (can hold bulk userdata). if m.settings != nil { for _, sp := range m.settings.GetSchedulableStoragePaths() { @@ -103,6 +130,13 @@ func (m *Manager) tier2FitsSystemDrive(sys string, unitSizeBytes int64) bool { // Best-effort and idempotent (rsync mirror). Records status into settings for the UI; returns an // error only on an actual copy failure (no valid target is a recorded status, not an error). func (m *Manager) RunTier2(stackName string) error { + // Customer turned Tier 2 off for this app (config panel) — skip without touching status. + if m.settings != nil { + if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil && cd.UserDisabled { + m.logger.Printf("[INFO] [backup] Tier 2 for %s skipped — disabled by customer", stackName) + return nil + } + } sourceDrive := m.GetAppDrivePath(stackName) if sourceDrive == "" { return fmt.Errorf("no source drive for %s", stackName) @@ -183,13 +217,91 @@ func (m *Manager) RunAllTier2() { m.logger.Printf("[INFO] [backup] Tier 2 run complete: %d HDD app(s) processed", n) } +// --- per-app config-panel view (drives the Tier-2 "Beállítás" page) --- + +// Tier2Option is one selectable off-drive destination in the config panel. +type Tier2Option struct { + Path string // registered storage path (the value persisted as PreferredTarget) + Label string // human label for the dropdown +} + +// Tier2Info is the per-app Tier-2 view the config panel renders. It exposes the effective target +// (pinned or auto), whether that is the size-limited internal SSD, the honest no-target reason, and +// the off-disk drives the customer may pin — so the control is meaningful even with a single target. +type Tier2Info struct { + IsHDDApp bool // false = the app lives on the rootfs (already inside the PBS whole-guest snapshot) + SourceDrive string // where the app's data currently lives + Disabled bool // customer turned Tier 2 off + Preferred string // customer-pinned target path ("" = automatic) + EffectiveLabel string // label of the target that WOULD be used right now + EffectiveIsSSD bool // the effective target is the internal SSD (DB/config only) + EffectiveDesc string // why this target (Hungarian) + NoTarget bool // no off-drive target fits at all + NoTargetReason string // honest reason when NoTarget + Alternatives []Tier2Option +} + +// Tier2Info builds the config-panel view for one app. Read-only (no status writes). +func (m *Manager) Tier2Info(stackName string) Tier2Info { + var info Tier2Info + if m.stackProvider != nil { + info.IsHDDApp = m.stackProvider.GetStackHDDPath(stackName) != "" + } + source := m.GetAppDrivePath(stackName) + info.SourceDrive = source + + if m.settings != nil { + if cd := m.settings.GetCrossDriveConfig(stackName); cd != nil { + info.Disabled = cd.UserDisabled + info.Preferred = cd.PreferredTarget + } + // Eligible alternative drives: registered, schedulable, on a DIFFERENT physical disk. + for _, sp := range m.settings.GetSchedulableStoragePaths() { + if sp.Path == source || system.SamePhysicalDevice(source, sp.Path) { + continue + } + label := sp.Label + if label == "" { + label = filepath.Base(sp.Path) + } + info.Alternatives = append(info.Alternatives, Tier2Option{Path: sp.Path, Label: label}) + } + } + + // Resolve what the runner WOULD pick right now (real unit size feeds the SSD headroom guard). + sourceNsRoot := m.namespaceRoot(source) + unitSize := dirSizeBytes(RecoveryUnitPath(sourceNsRoot, stackName)) + dirSizeBytes(AppDataDir(sourceNsRoot, stackName)) + target, err := m.selectTier2Target(stackName, unitSize) + if err != nil { + info.NoTarget = true + info.NoTargetReason = tier2NoTargetReason(err) + return info + } + info.EffectiveLabel = target.Label + info.EffectiveIsSSD = target.IsSystemDrive + info.EffectiveDesc = target.Reason + return info +} + // --- status persistence (drives the "2. mentés" UI card) --- +// withTier2Prefs carries the customer-preference fields (UserDisabled/PreferredTarget) from any +// existing config into a freshly-built status struct, so a runner status write never clobbers them. +func (m *Manager) withTier2Prefs(stackName string, cfg *settings.CrossDriveBackup) *settings.CrossDriveBackup { + if m.settings != nil { + if existing := m.settings.GetCrossDriveConfig(stackName); existing != nil { + cfg.UserDisabled = existing.UserDisabled + cfg.PreferredTarget = existing.PreferredTarget + } + } + return cfg +} + func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, sizeBytes int64, dur time.Duration) { if m.settings == nil { return } - _ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{ + _ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{ Enabled: true, Method: "rsync", DestinationPath: target.NamespaceRoot, @@ -198,14 +310,14 @@ func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, size LastStatus: "ok", LastDuration: dur.Round(time.Second).String(), LastSizeHuman: humanizeBytes(sizeBytes), - }) + })) } func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, cause error) { if m.settings == nil { return } - _ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{ + _ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{ Enabled: true, Method: "rsync", DestinationPath: target.NamespaceRoot, @@ -213,20 +325,20 @@ func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, caus LastRun: time.Now().Format(time.RFC3339), LastStatus: "error", LastError: cause.Error(), - }) + })) } func (m *Manager) recordTier2NoTarget(stackName, reason string) { if m.settings == nil { return } - _ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{ + _ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{ Enabled: false, Method: "rsync", Schedule: "daily", LastStatus: "no_target", LastError: reason, - }) + })) } func tier2NoTargetReason(err error) string { diff --git a/controller/internal/backup/tier2_test.go b/controller/internal/backup/tier2_test.go index 1591433..0af2844 100644 --- a/controller/internal/backup/tier2_test.go +++ b/controller/internal/backup/tier2_test.go @@ -1,6 +1,91 @@ package backup -import "testing" +import ( + "log" + "os" + "path/filepath" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// newTestManager builds a Manager backed by a real (temp-file) settings store and the given +// system-data path as the SSD fallback source (no stackProvider → source = systemDataPath). +func newTestManager(t *testing.T, systemDataPath string) (*Manager, *settings.Settings) { + t.Helper() + logger := log.New(os.Stderr, "", 0) + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), logger) + if err != nil { + t.Fatalf("settings.Load: %v", err) + } + cfg := &config.Config{} + cfg.Paths.SystemDataPath = systemDataPath + return NewManager(cfg, sett, logger), sett +} + +// A customer-pinned PreferredTarget must win over the auto-pick (which would take the first +// off-disk drive), and be reported with the "kézi választás" reason. +func TestSelectTier2Target_HonorsPreferred(t *testing.T) { + m, sett := newTestManager(t, "/srv/sys") + // Two eligible drives; auto-pick would take the alphabetically-first ("/mnt/a"). + if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/a", Label: "A", Schedulable: true}); err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/b", Label: "B", Schedulable: true}); err != nil { + t.Fatal(err) + } + if err := sett.SetTier2Preference("app", false, "/mnt/b"); err != nil { + t.Fatal(err) + } + target, err := m.selectTier2Target("app", 1024) + if err != nil { + t.Fatalf("selectTier2Target: %v", err) + } + if target.NamespaceRoot != filepath.FromSlash("/mnt/b") { + t.Errorf("NamespaceRoot = %q, want /mnt/b (pinned)", target.NamespaceRoot) + } + if target.Reason != "kézi választás" { + t.Errorf("Reason = %q, want 'kézi választás'", target.Reason) + } +} + +// An invalid pin (path not registered) silently falls through to the auto-pick. +func TestSelectTier2Target_InvalidPreferredFallsBack(t *testing.T) { + m, sett := newTestManager(t, "/srv/sys") + if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/a", Label: "A", Schedulable: true}); err != nil { + t.Fatal(err) + } + if err := sett.SetTier2Preference("app", false, "/mnt/gone"); err != nil { + t.Fatal(err) + } + target, err := m.selectTier2Target("app", 1024) + if err != nil { + t.Fatalf("selectTier2Target: %v", err) + } + if target.NamespaceRoot != filepath.FromSlash("/mnt/a") || target.Reason != "másik adatmeghajtó" { + t.Errorf("got %q/%q, want /mnt/a auto-pick", target.NamespaceRoot, target.Reason) + } +} + +// A runner status write must NOT clobber the customer's preference fields. +func TestRecordTier2_PreservesPreference(t *testing.T) { + m, sett := newTestManager(t, "/srv/sys") + if err := sett.SetTier2Preference("app", true, "/mnt/b"); err != nil { + t.Fatal(err) + } + m.recordTier2NoTarget("app", "teszt") + cd := sett.GetCrossDriveConfig("app") + if cd == nil { + t.Fatal("config missing after status write") + } + if !cd.UserDisabled || cd.PreferredTarget != "/mnt/b" { + t.Errorf("preference clobbered: UserDisabled=%v PreferredTarget=%q", cd.UserDisabled, cd.PreferredTarget) + } + if cd.LastStatus != "no_target" { + t.Errorf("LastStatus = %q, want no_target", cd.LastStatus) + } +} // TestTier2FitsHeadroom covers the size-aware rootfs-headroom guard that protects the ~8 GB guest // rootfs from being filled by a Tier 2 SSD copy (reserve = max(2 GB, 20% of total)). diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index c36f030..aaf0c06 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -85,6 +85,12 @@ type CrossDriveBackup struct { LastError string `json:"last_error,omitempty"` LastDuration string `json:"last_duration,omitempty"` // "2m34s" LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB" + + // Customer preference (set from the per-app Tier-2 config panel; PRESERVED across the runner's + // status writes). UserDisabled turns Tier 2 off for this app; PreferredTarget pins a chosen + // destination drive (a registered storage Path) instead of the auto-pick ("" = auto). + UserDisabled bool `json:"user_disabled,omitempty"` + PreferredTarget string `json:"preferred_target,omitempty"` } // StoragePath represents a registered external storage location. @@ -388,6 +394,26 @@ func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveB return s.save() } +// SetTier2Preference records the customer's Tier-2 choice (from the per-app config panel) WITHOUT +// disturbing the runner's status fields: it merges into the existing config if one is present, else +// seeds a minimal config carrying just the preference. The Tier-2 runner reads UserDisabled (skip) +// and PreferredTarget (pin a destination) and preserves both on every status write. +func (s *Settings) SetTier2Preference(stackName string, disabled bool, preferredTarget string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.AppBackup == nil { + s.AppBackup = make(map[string]AppBackupPrefs) + } + existing := s.AppBackup[stackName] + if existing.CrossDrive == nil { + existing.CrossDrive = &CrossDriveBackup{Method: "rsync", Schedule: "daily"} + } + existing.CrossDrive.UserDisabled = disabled + existing.CrossDrive.PreferredTarget = preferredTarget + s.AppBackup[stackName] = existing + return s.save() +} + // GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not). func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup { s.mu.RLock() diff --git a/controller/internal/web/agent_host_metrics_handler.go b/controller/internal/web/agent_host_metrics_handler.go index 564375c..f513d25 100644 --- a/controller/internal/web/agent_host_metrics_handler.go +++ b/controller/internal/web/agent_host_metrics_handler.go @@ -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, "" + } +} diff --git a/controller/internal/web/agent_host_metrics_handler_test.go b/controller/internal/web/agent_host_metrics_handler_test.go new file mode 100644 index 0000000..5238f5b --- /dev/null +++ b/controller/internal/web/agent_host_metrics_handler_test.go @@ -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 +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 7bcbc01..bb0f9ed 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -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ó" diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 5540643..3ee91a3 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -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": diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index b8aa26a..a7413eb 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -317,7 +317,12 @@
2. mentés - {{if and .Tier2Configured .Tier2DestDisconnected}} + {{if .Tier2UserDisabled}} + 2. mentés kikapcsolva + + {{else if and .Tier2Configured .Tier2DestDisconnected}} rsync → {{.Tier2Dest}} Cél meghajtó leválasztva @@ -326,7 +331,7 @@ {{end}} {{.BackupContents}} {{else if and .Tier2Configured .Tier2DestInactive}} rsync @@ -337,7 +342,7 @@ {{end}} {{.BackupContents}} {{else if .Tier2Configured}} rsync @@ -354,7 +359,7 @@ {{.BackupContents}} 📁 {{else}} ✓ 1. mentés auto @@ -362,6 +367,9 @@ {{if .Tier2LastError}} {{.Tier2LastError}} {{end}} + {{end}}
diff --git a/controller/internal/web/templates/monitoring.html b/controller/internal/web/templates/monitoring.html index 5f42b3e..ae48cbb 100644 --- a/controller/internal/web/templates/monitoring.html +++ b/controller/internal/web/templates/monitoring.html @@ -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 ? ' (' + rawId + ')' : ''; + var label = friendly + idHtml; + var purposeHtml = t.purpose ? '
' + escapeHtml(t.purpose) + '
' : ''; if (t.state && t.state !== 'attached') { html += '
' + '
' + label + '' + 'Nem elérhető
' + - '
'; + '
' + purposeHtml + ''; return; } var pct = (t.used_fraction != null ? t.used_fraction * 100 : 0); @@ -774,7 +779,7 @@ '' + fmtBytesGB(t.used_bytes) + ' / ' + fmtBytesGB(t.total_bytes) + ' (' + Math.round(pct) + '%)' + '
'; + '" style="width:' + Math.min(100, pct).toFixed(1) + '%">' + purposeHtml + ''; }); bars.innerHTML = html; } diff --git a/controller/internal/web/templates/tier2_config.html b/controller/internal/web/templates/tier2_config.html new file mode 100644 index 0000000..b55192a --- /dev/null +++ b/controller/internal/web/templates/tier2_config.html @@ -0,0 +1,93 @@ +{{define "tier2_config"}} +{{template "layout_start" .}} + + + +{{if .Flash}}
{{.Flash}}
{{end}} +{{if .FlashError}}
{{.FlashError}}
{{end}} + +
+{{with .Tier2}} +

+ A 2. mentés egy másik fizikai meghajtóra 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). +

+ + {{if not .IsHDDApp}} +
+ Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, amelyek + már szerepelnek a teljes rendszermentésben (PBS). 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ő. +
+ {{else}} +

Jelenlegi állapot

+
+
+ 2. mentés + {{if .Disabled}}Kikapcsolva{{else}}Bekapcsolva{{end}} +
+ {{if .NoTarget}} +
+ Cél + Nincs elérhető off-drive cél +
+
+ Megjegyzés + {{.NoTargetReason}} +
+ {{else}} +
+ Cél meghajtó + {{.EffectiveLabel}}{{if .EffectiveIsSSD}} — csak DB/konfiguráció{{end}} +
+
+ Kiválasztás módja + {{if .Preferred}}kézi választás{{else}}automatikus{{end}} — {{.EffectiveDesc}} +
+ {{end}} +
+ + {{if .EffectiveIsSSD}} +
+ 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 + 2. adatmeghajtó szükséges (hogy a rendszerlemez ne teljen meg). +
+ {{end}} + +
+ {{$.CSRFField}} +
+ +
+
+ + + {{if not .Alternatives}} +
+ 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ő. +
+ {{end}} +
+ +
+ {{end}} +{{end}} +
+ +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/tier2_config_handler.go b/controller/internal/web/tier2_config_handler.go new file mode 100644 index 0000000..4a777e1 --- /dev/null +++ b/controller/internal/web/tier2_config_handler.go @@ -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) +}