From 3eacb6c32605bfed0fc07ee41127ac588e779113 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 13 Jul 2026 11:26:48 +0200 Subject: [PATCH] =?UTF-8?q?Part=20A:=20shared=20app=5Flist=5Frow=20partial?= =?UTF-8?q?=20=E2=80=94=20ONE=20row=20grammar=20on=20four=20surfaces=20(v0?= =?UTF-8?q?.126.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - templates/app_row.html: app_list_row/app_list_row_end (layout_start/_end idiom) — icon+name (+optional secondary) left, caller action block right; compact 44px row; logoURL→PNG→ optional FallbackIcon→hidden onerror chain - applied: Távoli mentés toggle list, Visszaállítás restore-to-verify + .fab lists, dashboard Telepített alkalmazások (state edge + data-href preserved); Alkalmazások collapsed headers ALIGNED (icon+name left, status dot moved right before chevron; expander untouched) - funcmap: dict + appHref; OffboxAppRow/AppBackupRow gain Slug - style.css: .app-row family; stack-card/stack-info/stack-logo/stack-name/stack-desc/ stack-actions rules retired (dashboard rows now shared); .app-backup-row-header matched to the shared row height - scripts/app_row_dedup_gate.py: row markup single-sourced (red-proven: pasted old storage-path-item block → exit 1); render tests per surface (app_row_test.go) - NO behavior change: toggle/download/expander actions byte-identical --- controller/internal/web/app_row_test.go | 131 ++++++++++++++++++ controller/internal/web/funcmap.go | 24 ++++ controller/internal/web/handlers.go | 12 +- .../internal/web/templates/app_row.html | 30 ++++ .../internal/web/templates/backups_apps.html | 7 +- .../web/templates/backups_remote.html | 21 ++- .../web/templates/backups_restore.html | 32 ++--- .../internal/web/templates/dashboard.html | 14 +- controller/internal/web/templates/layout.html | 2 +- controller/internal/web/templates/style.css | 112 ++++++++++----- controller/scripts/app_row_dedup_gate.py | 82 +++++++++++ 11 files changed, 380 insertions(+), 87 deletions(-) create mode 100644 controller/internal/web/app_row_test.go create mode 100644 controller/internal/web/templates/app_row.html create mode 100644 controller/scripts/app_row_dedup_gate.py 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"}} +
+ +
+ {{.Name}} + {{with .Secondary}}{{.}}{{end}} +
+
+{{end}} + +{{define "app_list_row_end"}} +
+
+{{end}} diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html index 4022416..001f347 100644 --- a/controller/internal/web/templates/backups_apps.html +++ b/controller/internal/web/templates/backups_apps.html @@ -137,8 +137,12 @@ {{range .AppBackupRows}}
+
- + {{.DisplayName}}
{{if .DriveDisconnected}} @@ -151,6 +155,7 @@ {{else}} Konfig{{if .HasDB}} + DB{{end}} {{end}} +
diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html index 826d2c1..ae51ecb 100644 --- a/controller/internal/web/templates/backups_remote.html +++ b/controller/internal/web/templates/backups_remote.html @@ -70,20 +70,15 @@

Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.

{{end}} {{if .OffboxApps}} -
+
{{range .OffboxApps}} -
-
-
{{.DisplayName}}
-
-
{{$.CSRFField}} - - - -
-
-
-
+ {{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}} +
{{$.CSRFField}} + + + +
+ {{template "app_list_row_end"}} {{end}}
{{else}}

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

Ellenőrző visszaállítás a távoli tárolóból

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}} -
+
{{range .OffboxApps}} {{if .Enabled}} -
-
-
{{.DisplayName}}
-
-
{{$.CSRFField}} - - -
-
-
-
+ {{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}} +
{{$.CSRFField}} + + +
+ {{template "app_list_row_end"}} {{end}} {{end}}
@@ -101,16 +96,11 @@
-
+
{{range .OffboxApps}} -
-
-
{{.DisplayName}}
-
- -
-
-
+ {{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}} + + {{template "app_list_row_end"}} {{end}}
diff --git a/controller/internal/web/templates/dashboard.html b/controller/internal/web/templates/dashboard.html index 01ef778..c78b3ce 100644 --- a/controller/internal/web/templates/dashboard.html +++ b/controller/internal/web/templates/dashboard.html @@ -146,16 +146,7 @@
{{range .Stacks}} -
-
- -
- {{.Meta.DisplayName}} - {{if .Meta.Description}}{{.Meta.Description}}{{end}} -
-
-
+ {{template "app_list_row" dict "Slug" .Meta.Slug "Name" .Meta.DisplayName "Secondary" .Meta.Description "RowClass" (printf "stack-state-%s" (stateColor .State)) "Href" (appHref .Meta.Slug)}} {{stateLabel .State}} {{if .Orphaned}}Elavult{{end}} {{$ms := index $.MissingStorage .Name}}{{if $ms}}Hiányzó tárhely: {{$ms}}{{end}} @@ -183,8 +174,7 @@ Napló {{if .Orphaned}}{{end}} {{end}} -
-
+ {{template "app_list_row_end"}} {{else}}

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