diff --git a/controller/internal/web/app_row_test.go b/controller/internal/web/app_row_test.go new file mode 100644 index 0000000..1065fcd --- /dev/null +++ b/controller/internal/web/app_row_test.go @@ -0,0 +1,131 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + "gitea.dooplex.hu/admin/felhom-controller/internal/system" +) + +// v0.126.0 Part A — the shared app_list_row partial (app_row.html) is the ONE row source on +// the list surfaces. Per-surface render tests: the partial's marker classes are present, the +// old hand-rolled structures (storage-path-item rows / stack-card rows) are gone, and the +// caller-provided action blocks (behavior) are untouched. +// COMPANION red-proof (recorded in REPORT): paste one pre-0.126.0 storage-path-item row block +// back into backups_remote.html → scripts/app_row_dedup_gate.py exits 1. + +func appRowSplitData() map[string]interface{} { + d := splitTestData() + d["OffboxApps"] = []OffboxAppRow{ + {Name: "calibre-web", DisplayName: "Calibre-Web", Slug: "calibre-web", Enabled: true}, + {Name: "radarr", DisplayName: "Radarr", Slug: "radarr", Enabled: false}, + } + return d +} + +func TestAppRow_RemoteToggleList(t *testing.T) { + html := renderBackupPage(t, "backups_remote", appRowSplitData()) + if !strings.Contains(html, `class="app-row"`) || !strings.Contains(html, `app-row-name`) { + t.Error("remote toggle list must render through the app_list_row partial") + } + if strings.Contains(html, "storage-path-item") { + t.Error("old storage-path-item row structure survives on the remote page") + } + // Behavior unchanged: the toggle form + both label variants render as before. + if !strings.Contains(html, `action="/backup/offbox/toggle"`) { + t.Error("toggle form missing") + } + if !strings.Contains(html, "Távoli mentés kikapcsolása") || !strings.Contains(html, "Távoli mentés bekapcsolása") { + t.Error("toggle button labels changed") + } +} + +func TestAppRow_RestoreLists(t *testing.T) { + html := renderBackupPage(t, "backups_restore", appRowSplitData()) + if !strings.Contains(html, `class="app-row"`) { + t.Error("restore-page lists must render through the app_list_row partial") + } + if strings.Contains(html, "storage-path-item") { + t.Error("old storage-path-item row structure survives on the restore page") + } + // Behavior unchanged: restore-to-verify form + .fab download button. + if !strings.Contains(html, `action="/backup/offbox/restore"`) { + t.Error("restore-to-verify form missing") + } + if !strings.Contains(html, `fab-dl-btn`) || !strings.Contains(html, "Letöltés (.fab)") { + t.Error(".fab download action missing") + } + // The disabled app (radarr) must NOT appear in the restore-to-verify list (Enabled filter), + // but MUST appear in the .fab list — the partial must not have changed the filters. + if strings.Count(html, `value="radarr"`) != 0 { + t.Error("restore-to-verify must list only offbox-enabled apps") + } + if !strings.Contains(html, `data-stack="radarr"`) { + t.Error(".fab list must list all apps") + } +} + +func TestAppRow_Dashboard(t *testing.T) { + data := map[string]interface{}{ + "Page": "dashboard", "Title": "Vezérlőpult", + "Stacks": []stacks.Stack{ + {Name: "radarr", Deployed: true, State: stacks.StateRunning, + Meta: stacks.Metadata{Slug: "radarr", DisplayName: "Radarr", Description: "Filmgyűjtemény kezelése"}}, + }, + "MissingStorage": map[string]string{}, + "NetworkWarnings": map[string]string{}, + "NetworkStubs": map[string]string{}, + "Subdomains": map[string]string{"radarr": "radarr"}, + "RunningCount": 1, "StoppedCount": 0, "TotalCount": 1, + "SystemInfo": system.SystemInfo{}, + "BackupEnabled": false, + "Domain": "demo-felhom.eu", + } + html := renderBackupPage(t, "dashboard", data) + if !strings.Contains(html, `class="app-row stack-state-run"`) { + t.Error("dashboard rows must render through app_list_row with the state RowClass") + } + if !strings.Contains(html, `data-href="/apps/radarr"`) { + t.Error("dashboard row lost its data-href") + } + if !strings.Contains(html, "Filmgyűjtemény kezelése") { + t.Error("dashboard row lost the description secondary line") + } + if strings.Contains(html, "stack-card") || strings.Contains(html, "stack-info") { + t.Error("old stack-card row structure survives on the dashboard") + } +} + +// The Alkalmazások collapsed header is ALIGNED to the row grammar (icon + name left, +// status + chevron right) while keeping its own expander structure. +func TestAppRow_AppsHeaderAligned(t *testing.T) { + d := appRowSplitData() + d["AppBackupRows"] = []AppBackupRow{ + {StackName: "calibre-web", DisplayName: "Calibre-Web", Slug: "calibre-web", + Status: "green", Tier3State: "active"}, + } + d["Backup"] = &backup.FullBackupStatus{ + AppDataInfo: []backup.AppBackupInfo{{StackName: "calibre-web", DisplayName: "Calibre-Web"}}, + } + d["Offbox"] = &settings.OffboxTarget{Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed"} + html := renderBackupPage(t, "backups_apps", d) + + hdr := html[strings.Index(html, `class="app-backup-row-header"`):] + hdr = hdr[:strings.Index(hdr, "app-backup-row-detail")] + iIcon := strings.Index(hdr, "app-row-icon") + iName := strings.Index(hdr, "app-backup-row-name") + iDot := strings.Index(hdr, "status-dot") + iChevron := strings.Index(hdr, "expand-icon") + if iIcon < 0 || iName < 0 || iDot < 0 || iChevron < 0 { + t.Fatalf("aligned header pieces missing (icon=%d name=%d dot=%d chevron=%d)", iIcon, iName, iDot, iChevron) + } + if !(iIcon < iName && iName < iDot && iDot < iChevron) { + t.Error("header grammar must be icon, name, ..., status, chevron (left→right)") + } + if !strings.Contains(hdr, `onclick="toggleBackupDetail(this)"`) { + t.Error("expander behavior must be untouched") + } +} diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index e64f082..e4f35a8 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -379,6 +379,30 @@ func (s *Server) templateFuncMap() template.FuncMap { b, _ := json.Marshal(v) return template.JS(b) }, + // dict builds a map from key/value pairs — the argument carrier for the shared + // app_list_row partial (app_row.html). Keys must be strings. + "dict": func(pairs ...interface{}) (map[string]interface{}, error) { + if len(pairs)%2 != 0 { + return nil, fmt.Errorf("dict: odd argument count %d", len(pairs)) + } + m := make(map[string]interface{}, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + k, ok := pairs[i].(string) + if !ok { + return nil, fmt.Errorf("dict: key %d is not a string", i) + } + m[k] = pairs[i+1] + } + return m, nil + }, + // appHref is appPageURL that yields "" for a slug-less stack, so the shared row + // partial's {{with .Href}} skips the data-href attribute entirely. + "appHref": func(slug string) string { + if slug == "" { + return "" + } + return s.cfg.AppPageURL(slug) + }, // pageMatch returns true if currentPage is in the pages slice. // Used to filter page-specific alerts in layout.html. "pageMatch": func(pages []string, currentPage string) bool { diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 566a9ba..1b88fdb 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -730,6 +730,7 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) { type OffboxAppRow struct { Name string DisplayName string + Slug string // catalog slug for the shared app-row icon (logoURL) Enabled bool } @@ -747,7 +748,7 @@ func (s *Server) buildOffboxApps() []OffboxAppRow { if dn == "" { dn = st.Name } - out = append(out, OffboxAppRow{Name: st.Name, DisplayName: dn, Enabled: s.settings.IsAppOffbox(st.Name)}) + out = append(out, OffboxAppRow{Name: st.Name, DisplayName: dn, Slug: st.Meta.Slug, Enabled: s.settings.IsAppOffbox(st.Name)}) } return out } @@ -756,6 +757,7 @@ func (s *Server) buildOffboxApps() []OffboxAppRow { type AppBackupRow struct { StackName string DisplayName string + Slug string // catalog slug for the aligned header icon (logoURL) Status string // "green", "yellow", "red", "auto" StatusText string // short Hungarian tooltip @@ -869,9 +871,17 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup } contents := strings.Join(parts, " + ") + slug := "" + if s.stackMgr != nil { + if st, ok := s.stackMgr.GetStack(app.StackName); ok { + slug = st.Meta.Slug + } + } + row := AppBackupRow{ StackName: app.StackName, DisplayName: app.DisplayName, + Slug: slug, HasHDDData: app.HasHDDData, HasDB: hasDB, HasVolumeData: app.HasVolumeData, diff --git a/controller/internal/web/templates/app_row.html b/controller/internal/web/templates/app_row.html new file mode 100644 index 0000000..3067df8 --- /dev/null +++ b/controller/internal/web/templates/app_row.html @@ -0,0 +1,30 @@ +{{/* app_list_row / app_list_row_end — THE canonical app-list row (v0.126.0). + One grammar on every list surface: icon + name (+ optional one-line secondary) left, + caller-provided action block right. A row without a secondary line stays compact. + + Usage (the layout_start/layout_end idiom): + {{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}} + ...caller action buttons / status... + {{template "app_list_row_end"}} + + dict keys: Slug (icon lookup), Name, Secondary (optional one-liner), + RowClass (optional extra row class, e.g. stack-state-run), Href (optional data-href), + FallbackIcon (optional last-resort icon URL — infra stacks pass the generic infra SVG). + + Do NOT hand-roll app rows — scripts/app_row_dedup_gate.py asserts this markup exists + here ONCE (the backups_apps expander header is the single allowlisted aligned copy). */}} +{{define "app_list_row"}} +
Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.
{{end}} {{if .OffboxApps}} -Nincs telepített alkalmazás.
{{end}} diff --git a/controller/internal/web/templates/backups_restore.html b/controller/internal/web/templates/backups_restore.html index 5acc1c9..0718c51 100644 --- a/controller/internal/web/templates/backups_restore.html +++ b/controller/internal/web/templates/backups_restore.html @@ -66,20 +66,15 @@A távoli mentésre kijelölt alkalmazások legutóbbi pillanatképe egy külön ellenőrző mappába állítható vissza — a meglévő adatok nem változnak.
{{if .OffboxToggledCount}} -Nincs elérhető alkalmazás.
diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index 08af8bf..7eeed11 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -108,7 +108,7 @@