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:
@@ -1,5 +1,39 @@
|
|||||||
## Changelog
|
## 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)
|
### v0.56.0 — Phase 4: FileBrowser scoping + deploy DB-on-SSD note + monitoring storage descriptions (2026-06-13)
|
||||||
|
|
||||||
Polish layer closing the slice.
|
Polish layer closing the slice.
|
||||||
|
|||||||
@@ -473,6 +473,11 @@ type StorageTarget struct {
|
|||||||
ClassHint string `json:"class_hint"`
|
ClassHint string `json:"class_hint"`
|
||||||
ThinPool *ThinPoolFill `json:"thin_pool,omitempty"`
|
ThinPool *ThinPoolFill `json:"thin_pool,omitempty"`
|
||||||
Smart SmartSummary `json:"smart"`
|
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
|
// HostMetricsResponse mirrors the agent's GET /host/metrics payload (host-wide health + per-storage
|
||||||
|
|||||||
@@ -48,13 +48,40 @@ func tier2FitsHeadroom(availGB, totalGB, unitGB float64) bool {
|
|||||||
return (availGB - unitGB) >= reserve
|
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) {
|
func (m *Manager) selectTier2Target(stackName string, unitSizeBytes int64) (*Tier2Target, error) {
|
||||||
sourceDrive := m.GetAppDrivePath(stackName)
|
sourceDrive := m.GetAppDrivePath(stackName)
|
||||||
if sourceDrive == "" {
|
if sourceDrive == "" {
|
||||||
return nil, fmt.Errorf("no source drive for %s", stackName)
|
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).
|
// 1. Prefer another registered user-data drive on a DIFFERENT physical disk (can hold bulk userdata).
|
||||||
if m.settings != nil {
|
if m.settings != nil {
|
||||||
for _, sp := range m.settings.GetSchedulableStoragePaths() {
|
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
|
// 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).
|
// error only on an actual copy failure (no valid target is a recorded status, not an error).
|
||||||
func (m *Manager) RunTier2(stackName string) 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)
|
sourceDrive := m.GetAppDrivePath(stackName)
|
||||||
if sourceDrive == "" {
|
if sourceDrive == "" {
|
||||||
return fmt.Errorf("no source drive for %s", stackName)
|
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)
|
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) ---
|
// --- 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) {
|
func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, sizeBytes int64, dur time.Duration) {
|
||||||
if m.settings == nil {
|
if m.settings == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{
|
_ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Method: "rsync",
|
Method: "rsync",
|
||||||
DestinationPath: target.NamespaceRoot,
|
DestinationPath: target.NamespaceRoot,
|
||||||
@@ -198,14 +310,14 @@ func (m *Manager) recordTier2Success(stackName string, target *Tier2Target, size
|
|||||||
LastStatus: "ok",
|
LastStatus: "ok",
|
||||||
LastDuration: dur.Round(time.Second).String(),
|
LastDuration: dur.Round(time.Second).String(),
|
||||||
LastSizeHuman: humanizeBytes(sizeBytes),
|
LastSizeHuman: humanizeBytes(sizeBytes),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, cause error) {
|
func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, cause error) {
|
||||||
if m.settings == nil {
|
if m.settings == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{
|
_ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Method: "rsync",
|
Method: "rsync",
|
||||||
DestinationPath: target.NamespaceRoot,
|
DestinationPath: target.NamespaceRoot,
|
||||||
@@ -213,20 +325,20 @@ func (m *Manager) recordTier2Failure(stackName string, target *Tier2Target, caus
|
|||||||
LastRun: time.Now().Format(time.RFC3339),
|
LastRun: time.Now().Format(time.RFC3339),
|
||||||
LastStatus: "error",
|
LastStatus: "error",
|
||||||
LastError: cause.Error(),
|
LastError: cause.Error(),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) recordTier2NoTarget(stackName, reason string) {
|
func (m *Manager) recordTier2NoTarget(stackName, reason string) {
|
||||||
if m.settings == nil {
|
if m.settings == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = m.settings.SetCrossDriveConfig(stackName, &settings.CrossDriveBackup{
|
_ = m.settings.SetCrossDriveConfig(stackName, m.withTier2Prefs(stackName, &settings.CrossDriveBackup{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Method: "rsync",
|
Method: "rsync",
|
||||||
Schedule: "daily",
|
Schedule: "daily",
|
||||||
LastStatus: "no_target",
|
LastStatus: "no_target",
|
||||||
LastError: reason,
|
LastError: reason,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func tier2NoTargetReason(err error) string {
|
func tier2NoTargetReason(err error) string {
|
||||||
|
|||||||
@@ -1,6 +1,91 @@
|
|||||||
package backup
|
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
|
// 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)).
|
// rootfs from being filled by a Tier 2 SSD copy (reserve = max(2 GB, 20% of total)).
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ type CrossDriveBackup struct {
|
|||||||
LastError string `json:"last_error,omitempty"`
|
LastError string `json:"last_error,omitempty"`
|
||||||
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
|
LastDuration string `json:"last_duration,omitempty"` // "2m34s"
|
||||||
LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"
|
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.
|
// StoragePath represents a registered external storage location.
|
||||||
@@ -388,6 +394,26 @@ func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveB
|
|||||||
return s.save()
|
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).
|
// GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not).
|
||||||
func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup {
|
func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Agent-backed host metrics (slice 9).
|
// 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)
|
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||||
return
|
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)
|
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
|
||||||
|
}
|
||||||
@@ -614,6 +614,8 @@ type AppBackupRow struct {
|
|||||||
Tier2DestDisconnected bool
|
Tier2DestDisconnected bool
|
||||||
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
|
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
|
||||||
Tier2DestInactive bool
|
Tier2DestInactive bool
|
||||||
|
// Tier2UserDisabled — customer turned Tier 2 off for this app from the config panel.
|
||||||
|
Tier2UserDisabled bool
|
||||||
|
|
||||||
// Warnings accumulated for this app
|
// Warnings accumulated for this app
|
||||||
Warnings []string
|
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.
|
// 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 := 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).
|
// Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap).
|
||||||
row.Tier2Configured = false
|
row.Tier2Configured = false
|
||||||
row.Tier2StatusBadge = "Nincs 2. meghajtó"
|
row.Tier2StatusBadge = "Nincs 2. meghajtó"
|
||||||
|
|||||||
@@ -263,6 +263,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
name := strings.TrimPrefix(path, "/stacks/")
|
name := strings.TrimPrefix(path, "/stacks/")
|
||||||
name = strings.TrimSuffix(name, "/deploy")
|
name = strings.TrimSuffix(name, "/deploy")
|
||||||
s.deployHandler(w, r, name)
|
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":
|
case path == "/import":
|
||||||
s.importPageHandler(w, r)
|
s.importPageHandler(w, r)
|
||||||
case path == "/static/style.css":
|
case path == "/static/style.css":
|
||||||
|
|||||||
@@ -317,7 +317,12 @@
|
|||||||
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
|
<!-- Tier 2: Cross-drive backup (opt-in, different device) -->
|
||||||
<div class="backup-layer-row">
|
<div class="backup-layer-row">
|
||||||
<span class="tier-label">2. mentés</span>
|
<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-method" style="opacity:.6">rsync</span>
|
||||||
<span class="layer-dest" style="opacity:.6">→ {{.Tier2Dest}}</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>
|
<span class="badge badge-warn" style="font-size:.7rem">Cél meghajtó leválasztva</span>
|
||||||
@@ -326,7 +331,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
|
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
|
||||||
<div class="layer-actions">
|
<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>
|
</div>
|
||||||
{{else if and .Tier2Configured .Tier2DestInactive}}
|
{{else if and .Tier2Configured .Tier2DestInactive}}
|
||||||
<span class="layer-method" style="opacity:.6">rsync</span>
|
<span class="layer-method" style="opacity:.6">rsync</span>
|
||||||
@@ -337,7 +342,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
|
<span class="tier-contents" style="opacity:.6">{{.BackupContents}}</span>
|
||||||
<div class="layer-actions">
|
<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>
|
</div>
|
||||||
{{else if .Tier2Configured}}
|
{{else if .Tier2Configured}}
|
||||||
<span class="layer-method">rsync</span>
|
<span class="layer-method">rsync</span>
|
||||||
@@ -354,7 +359,7 @@
|
|||||||
<span class="tier-contents">{{.BackupContents}}</span>
|
<span class="tier-contents">{{.BackupContents}}</span>
|
||||||
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben">📁</span>
|
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben">📁</span>
|
||||||
<div class="layer-actions">
|
<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>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="layer-auto-ok">✓ 1. mentés auto</span>
|
<span class="layer-auto-ok">✓ 1. mentés auto</span>
|
||||||
@@ -362,6 +367,9 @@
|
|||||||
{{if .Tier2LastError}}
|
{{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>
|
<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}}
|
{{end}}
|
||||||
|
<div class="layer-actions">
|
||||||
|
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">Beállítás</a>
|
||||||
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<!-- Tier 3: Remote backup (future) -->
|
<!-- Tier 3: Remote backup (future) -->
|
||||||
|
|||||||
@@ -749,12 +749,17 @@
|
|||||||
} else {
|
} else {
|
||||||
var html = '';
|
var html = '';
|
||||||
targets.forEach(function(t) {
|
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') {
|
if (t.state && t.state !== 'attached') {
|
||||||
html += '<div class="storage-item storage-disconnected">' +
|
html += '<div class="storage-item storage-disconnected">' +
|
||||||
'<div class="storage-header"><span class="storage-label">' + label + '</span>' +
|
'<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>' +
|
'<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;
|
return;
|
||||||
}
|
}
|
||||||
var pct = (t.used_fraction != null ? t.used_fraction * 100 : 0);
|
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) +
|
'<span class="storage-value">' + fmtBytesGB(t.used_bytes) + ' / ' + fmtBytesGB(t.total_bytes) +
|
||||||
' (' + Math.round(pct) + '%)</span></div>' +
|
' (' + Math.round(pct) + '%)</span></div>' +
|
||||||
'<div class="system-bar"><div class="system-bar-fill ' + usageColorClass(pct) +
|
'<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;
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user