controller v0.242.0: a removed app is listed with its kept backup; five small ones (R-487 R-491 R-490 R-489 R-476 R-456)
gates / gates (push) Successful in 14s
gates / gates (push) Successful in 14s
R-487: the local backup lists are keyed on the drives, not on what is deployed — a removed app whose unit was kept is listed with the restore that reinstalls it, the picker answers for it, and the restore opens the unit where it sits. R-491: a removal clears the app's update hold. R-490: /api/system/info reaches the API router and reads the default storage path. R-489: volumes_removed is the real before/after difference, [] when none. R-476: a Tier-2 copy is dated by its data, not its manifest. R-456: the boot-orphan rule is pinned. Every fix red-proofed.
This commit is contained in:
@@ -1090,6 +1090,8 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// page listed these, so they accumulated invisibly and the only way to find them was SSH.
|
||||
if s.backupMgr != nil {
|
||||
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
|
||||
// R-487: removed apps whose unit was kept join the local restore picker.
|
||||
data["RemovedApps"] = s.backupMgr.ListRemovedAppUnits()
|
||||
}
|
||||
// R-237: the restore list is driven by the STORE, not by what is deployed and toggled. A rebuilt
|
||||
// box has neither, and used to be shown nothing at all while its snapshots sat in the repository.
|
||||
@@ -1209,6 +1211,11 @@ type AppBackupRow struct {
|
||||
|
||||
// Drive disconnected — app's home drive is currently disconnected
|
||||
DriveDisconnected bool
|
||||
// Removed (R-487) — the app is NOT deployed; this row stands for a recovery unit kept on a
|
||||
// drive after „Töröld az adataimat is" with the backups kept. Its one action is the unit restore,
|
||||
// which reinstalls (R-253). RemovedUnitTime is the unit's newest artifact, RFC3339.
|
||||
Removed bool
|
||||
RemovedUnitTime string
|
||||
// Tier2 destination drive is currently disconnected (backup paused, not failed)
|
||||
Tier2DestDisconnected bool
|
||||
// Tier2 destination drive is inactive (Schedulable=false, backup paused)
|
||||
@@ -1458,6 +1465,23 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
|
||||
|
||||
rows = append(rows, row)
|
||||
}
|
||||
// R-487: the list is keyed on the DRIVES, not on what is deployed — a removed app whose unit was
|
||||
// kept is listed after the deployed ones, with the restore that brings it back.
|
||||
if s.backupMgr != nil {
|
||||
for _, u := range s.backupMgr.ListRemovedAppUnits() {
|
||||
rows = append(rows, AppBackupRow{
|
||||
StackName: u.StackName,
|
||||
DisplayName: u.DisplayName,
|
||||
Slug: u.StackName,
|
||||
StorageLabel: u.DriveLabel,
|
||||
Status: "yellow",
|
||||
StatusText: "Eltávolított alkalmazás — a mentése megvan, visszaállítható",
|
||||
Removed: true,
|
||||
RemovedUnitTime: u.Time,
|
||||
Tier1LastRun: u.Time,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// R-487 — a removed app whose unit was kept renders on the backups page with the restore that brings
|
||||
// it back; a page without such rows carries none of it (the negative control).
|
||||
func TestR487_BackupsPageListsARemovedAppWithItsRestore(t *testing.T) {
|
||||
data := splitTestData()
|
||||
data["AppBackupRows"] = []AppBackupRow{
|
||||
{StackName: "calibre-web", DisplayName: "Calibre-Web"},
|
||||
{StackName: "gone-app", DisplayName: "Gone", Removed: true, RemovedUnitTime: "2026-09-13T00:30:00Z", StorageLabel: "HDD"},
|
||||
}
|
||||
html := renderBackupPage(t, "backups_apps", data)
|
||||
for _, want := range []string{
|
||||
`name="stack_name" value="gone-app"`,
|
||||
`name="snapshot_id" value="helyi"`,
|
||||
`action="/backup/restore"`,
|
||||
`class="layer-badge">Megtartva<`,
|
||||
} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("removed row must render %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Count(html, `class="layer-badge">Megtartva<`) != 1 {
|
||||
t.Error("the kept badge must render for the removed row only")
|
||||
}
|
||||
control := renderBackupPage(t, "backups_apps", splitTestData())
|
||||
if strings.Contains(control, "Megtartva") || strings.Contains(control, `value="helyi"`) {
|
||||
t.Error("negative control: a page with no removed app must not offer the restore")
|
||||
}
|
||||
}
|
||||
|
||||
// R-487 — the restore picker lists removed apps in their own group.
|
||||
func TestR487_RestorePickerListsRemovedApps(t *testing.T) {
|
||||
data := splitTestData()
|
||||
data["RemovedApps"] = []backup.RemovedAppUnit{{StackName: "gone-app", DisplayName: "Gone"}}
|
||||
html := renderBackupPage(t, "backups_restore", data)
|
||||
if !strings.Contains(html, `<option value="gone-app" data-removed="true"`) {
|
||||
t.Error("removed app must be an option in the picker")
|
||||
}
|
||||
if strings.Contains(renderBackupPage(t, "backups_restore", splitTestData()), "data-removed") {
|
||||
t.Error("negative control: no removed apps, no group")
|
||||
}
|
||||
}
|
||||
|
||||
// r487Provider is the smallest stack provider that can say "this app is not deployed".
|
||||
type r487Provider struct{ deployed []string }
|
||||
|
||||
func (p *r487Provider) GetStackComposePath(name string) (string, bool) {
|
||||
for _, s := range p.deployed {
|
||||
if s == name {
|
||||
return "/stacks/" + name + "/docker-compose.yml", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
func (p *r487Provider) ListDeployedStacks() []backup.StackSummary {
|
||||
out := make([]backup.StackSummary, 0, len(p.deployed))
|
||||
for _, s := range p.deployed {
|
||||
out = append(out, backup.StackSummary{Name: s})
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (p *r487Provider) GetStackHDDMounts(string) []string { return nil }
|
||||
func (p *r487Provider) GetStackHDDPath(string) string { return "" }
|
||||
func (p *r487Provider) GetImportRoot() string { return "" }
|
||||
func (p *r487Provider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *r487Provider) StopStack(string) error { return nil }
|
||||
func (p *r487Provider) StartStack(string) error { return nil }
|
||||
func (p *r487Provider) RefreshAndIsRunning(string) bool { return true }
|
||||
func (p *r487Provider) GetStackRecoveryInfo(string) (backup.RecoveryInfo, bool) {
|
||||
return backup.RecoveryInfo{}, false
|
||||
}
|
||||
func (p *r487Provider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
||||
func (p *r487Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (p *r487Provider) StartStackServices(string, []string) error { return nil }
|
||||
func (p *r487Provider) GetStackClassifiedBinds(string) ([]backup.ClassifiedBind, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// R-487 — the page's row builder appends a removed app's kept unit after the deployed rows, from the
|
||||
// real manager over a real drive layout (the seam is the provider, and it says only "not deployed").
|
||||
func TestR487_BuildAppBackupRowsAppendsRemovedApps(t *testing.T) {
|
||||
s := testServer(t)
|
||||
sys := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.SystemDataPath = sys
|
||||
mgr := backup.NewManager(cfg, s.settings, s.logger)
|
||||
mgr.SetStackProvider(&r487Provider{})
|
||||
s.backupMgr = mgr
|
||||
unit := backup.RecoveryUnitPath(backup.NamespaceRootFor(sys, sys), "gone-app")
|
||||
if err := os.MkdirAll(unit, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(backup.UnitManifestFile(unit), []byte(`{"schema_version":2,"app_name":"gone-app","display_name":"Gone","created_at":"2026-09-12T02:15:29Z"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := s.buildAppBackupRows(&backup.FullBackupStatus{})
|
||||
if len(rows) != 1 || rows[0].StackName != "gone-app" || !rows[0].Removed || rows[0].DisplayName != "Gone" || rows[0].RemovedUnitTime == "" {
|
||||
t.Fatalf("want one removed row for gone-app, got %+v", rows)
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,10 @@
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
|
||||
<span class="app-backup-row-name">{{.DisplayName}}</span>
|
||||
<div class="app-backup-row-meta">
|
||||
{{if .DriveDisconnected}}
|
||||
{{if .Removed}}
|
||||
<span class="tag tag-warn">Eltávolítva — visszaállítható</span>
|
||||
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
|
||||
{{else if .DriveDisconnected}}
|
||||
<span class="tag tag-warn">Meghajtó leválasztva</span>
|
||||
{{else if .HasHDDData}}
|
||||
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
|
||||
@@ -162,6 +165,26 @@
|
||||
<span class="expand-icon">▶</span>
|
||||
</div>
|
||||
<div class="app-backup-row-detail" style="display:none">
|
||||
{{if .Removed}}
|
||||
{{/* R-487: a removed app's kept unit. One action — the same restore the picker on the
|
||||
Visszaállítás page runs (POST /backup/restore, snapshot „helyi"); it reinstalls the app
|
||||
from the unit (R-253). No tier rows: nothing is scheduled for an app that is not here. */}}
|
||||
<div class="backup-layers">
|
||||
<div class="backup-layer-row">
|
||||
<span class="tier-label">Mentés</span>
|
||||
<span class="layer-badge">Megtartva</span>
|
||||
<span class="tier-location">helyi</span>
|
||||
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: {{fmtTimeStr .RemovedUnitTime}}.</span>
|
||||
<div class="layer-actions">
|
||||
<form method="POST" action="/backup/restore" style="display:inline">{{$.CSRFField}}
|
||||
<input type="hidden" name="stack_name" value="{{.StackName}}">
|
||||
<input type="hidden" name="snapshot_id" value="helyi">
|
||||
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) {{.DisplayName}} alkalmazást a megtartott mentésből ({{fmtTimeStr .RemovedUnitTime}})? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="backup-layers">
|
||||
<!-- Tier 1: Nightly backup (mandatory, same drive) -->
|
||||
<div class="backup-layer-row">
|
||||
@@ -332,6 +355,7 @@
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
{{range .Backup.AppDataInfo}}
|
||||
<option value="{{.StackName}}" data-has-hdd="{{.HasHDDData}}" data-has-db="{{.HasDBDump}}" data-has-volumes="{{.HasVolumeData}}">{{.DisplayName}}</option>
|
||||
{{end}}
|
||||
{{/* R-487: removed apps whose unit was kept — the restore reinstalls them (R-253). */}}
|
||||
{{if .RemovedApps}}
|
||||
<optgroup label="Eltávolított alkalmazások (mentés megtartva)">
|
||||
{{range .RemovedApps}}
|
||||
<option value="{{.StackName}}" data-removed="true" data-has-hdd="false" data-has-db="false" data-has-volumes="true">{{.DisplayName}} — eltávolítva</option>
|
||||
{{end}}
|
||||
</optgroup>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="restore-form-row">
|
||||
|
||||
Reference in New Issue
Block a user