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
@@ -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
}