Part A: shared app_list_row partial — ONE row grammar on four surfaces (v0.126.0)
- 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
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -379,6 +379,30 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
|||||||
b, _ := json.Marshal(v)
|
b, _ := json.Marshal(v)
|
||||||
return template.JS(b)
|
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.
|
// pageMatch returns true if currentPage is in the pages slice.
|
||||||
// Used to filter page-specific alerts in layout.html.
|
// Used to filter page-specific alerts in layout.html.
|
||||||
"pageMatch": func(pages []string, currentPage string) bool {
|
"pageMatch": func(pages []string, currentPage string) bool {
|
||||||
|
|||||||
@@ -730,6 +730,7 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
type OffboxAppRow struct {
|
type OffboxAppRow struct {
|
||||||
Name string
|
Name string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
|
Slug string // catalog slug for the shared app-row icon (logoURL)
|
||||||
Enabled bool
|
Enabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,7 +748,7 @@ func (s *Server) buildOffboxApps() []OffboxAppRow {
|
|||||||
if dn == "" {
|
if dn == "" {
|
||||||
dn = st.Name
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -756,6 +757,7 @@ func (s *Server) buildOffboxApps() []OffboxAppRow {
|
|||||||
type AppBackupRow struct {
|
type AppBackupRow struct {
|
||||||
StackName string
|
StackName string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
|
Slug string // catalog slug for the aligned header icon (logoURL)
|
||||||
Status string // "green", "yellow", "red", "auto"
|
Status string // "green", "yellow", "red", "auto"
|
||||||
StatusText string // short Hungarian tooltip
|
StatusText string // short Hungarian tooltip
|
||||||
|
|
||||||
@@ -869,9 +871,17 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
|
|||||||
}
|
}
|
||||||
contents := strings.Join(parts, " + ")
|
contents := strings.Join(parts, " + ")
|
||||||
|
|
||||||
|
slug := ""
|
||||||
|
if s.stackMgr != nil {
|
||||||
|
if st, ok := s.stackMgr.GetStack(app.StackName); ok {
|
||||||
|
slug = st.Meta.Slug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
row := AppBackupRow{
|
row := AppBackupRow{
|
||||||
StackName: app.StackName,
|
StackName: app.StackName,
|
||||||
DisplayName: app.DisplayName,
|
DisplayName: app.DisplayName,
|
||||||
|
Slug: slug,
|
||||||
HasHDDData: app.HasHDDData,
|
HasHDDData: app.HasHDDData,
|
||||||
HasDB: hasDB,
|
HasDB: hasDB,
|
||||||
HasVolumeData: app.HasVolumeData,
|
HasVolumeData: app.HasVolumeData,
|
||||||
|
|||||||
@@ -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"}}
|
||||||
|
<div class="app-row{{with .RowClass}} {{.}}{{end}}"{{with .Href}} data-href="{{.}}"{{end}}>
|
||||||
|
<img class="app-row-icon" src="{{logoURL .Slug}}" alt=""{{with .FallbackIcon}} data-fallback="{{.}}"{{end}}
|
||||||
|
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';}">
|
||||||
|
<div class="app-row-text">
|
||||||
|
<span class="app-row-name">{{.Name}}</span>
|
||||||
|
{{with .Secondary}}<span class="app-row-secondary">{{.}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="app-row-actions">
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "app_list_row_end"}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -137,8 +137,12 @@
|
|||||||
|
|
||||||
{{range .AppBackupRows}}
|
{{range .AppBackupRows}}
|
||||||
<div class="app-backup-row" data-status="{{.Status}}">
|
<div class="app-backup-row" data-status="{{.Status}}">
|
||||||
|
<!-- Aligned to the shared app-row grammar (icon + name left, status + chevron right);
|
||||||
|
the expander behavior is untouched — this header is the one allowlisted aligned
|
||||||
|
copy in scripts/app_row_dedup_gate.py, NOT a partial render (it owns the toggle). -->
|
||||||
<div class="app-backup-row-header" onclick="toggleBackupDetail(this)">
|
<div class="app-backup-row-header" onclick="toggleBackupDetail(this)">
|
||||||
<span class="status-dot status-{{.Status}}" title="{{.StatusText}}"></span>
|
<img class="app-row-icon" src="{{logoURL .Slug}}" alt=""
|
||||||
|
onerror="this.onerror=function(){this.style.visibility='hidden'};this.src='{{logoPNGURL .Slug}}'">
|
||||||
<span class="app-backup-row-name">{{.DisplayName}}</span>
|
<span class="app-backup-row-name">{{.DisplayName}}</span>
|
||||||
<div class="app-backup-row-meta">
|
<div class="app-backup-row-meta">
|
||||||
{{if .DriveDisconnected}}
|
{{if .DriveDisconnected}}
|
||||||
@@ -151,6 +155,7 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<span class="meta-badge">Konfig{{if .HasDB}} + DB{{end}}</span>
|
<span class="meta-badge">Konfig{{if .HasDB}} + DB{{end}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<span class="status-dot status-{{.Status}}" title="{{.StatusText}}"></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="expand-icon">▶</span>
|
<span class="expand-icon">▶</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,20 +70,15 @@
|
|||||||
<p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.</p>
|
<p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .OffboxApps}}
|
{{if .OffboxApps}}
|
||||||
<div class="storage-paths-list">
|
<div class="app-row-list">
|
||||||
{{range .OffboxApps}}
|
{{range .OffboxApps}}
|
||||||
<div class="storage-path-item">
|
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
|
||||||
<div class="storage-path-header">
|
<form method="POST" action="/backup/offbox/toggle" style="display:inline">{{$.CSRFField}}
|
||||||
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
|
<input type="hidden" name="app" value="{{.Name}}">
|
||||||
<div class="storage-path-actions">
|
<input type="hidden" name="enabled" value="{{if .Enabled}}false{{else}}true{{end}}">
|
||||||
<form method="POST" action="/backup/offbox/toggle" style="display:inline">{{$.CSRFField}}
|
<button type="submit" class="btn btn-xs {{if .Enabled}}btn-outline{{else}}btn-primary{{end}}">{{if .Enabled}}Távoli mentés kikapcsolása{{else}}Távoli mentés bekapcsolása{{end}}</button>
|
||||||
<input type="hidden" name="app" value="{{.Name}}">
|
</form>
|
||||||
<input type="hidden" name="enabled" value="{{if .Enabled}}false{{else}}true{{end}}">
|
{{template "app_list_row_end"}}
|
||||||
<button type="submit" class="btn btn-xs {{if .Enabled}}btn-outline{{else}}btn-primary{{end}}">{{if .Enabled}}Távoli mentés kikapcsolása{{else}}Távoli mentés bekapcsolása{{end}}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else}}<p class="form-hint">Nincs telepített alkalmazás.</p>{{end}}
|
{{else}}<p class="form-hint">Nincs telepített alkalmazás.</p>{{end}}
|
||||||
|
|||||||
@@ -66,20 +66,15 @@
|
|||||||
<h3>Ellenőrző visszaállítás a távoli tárolóból</h3>
|
<h3>Ellenőrző visszaállítás a távoli tárolóból</h3>
|
||||||
<p class="form-hint" style="margin:-0.25rem 0 1rem">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.</p>
|
<p class="form-hint" style="margin:-0.25rem 0 1rem">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.</p>
|
||||||
{{if .OffboxToggledCount}}
|
{{if .OffboxToggledCount}}
|
||||||
<div class="storage-paths-list">
|
<div class="app-row-list">
|
||||||
{{range .OffboxApps}}
|
{{range .OffboxApps}}
|
||||||
{{if .Enabled}}
|
{{if .Enabled}}
|
||||||
<div class="storage-path-item">
|
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
|
||||||
<div class="storage-path-header">
|
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
|
||||||
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
|
<input type="hidden" name="app" value="{{.Name}}">
|
||||||
<div class="storage-path-actions">
|
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a(z) {{.DisplayName}} adatait a távoli tárolóról egy ellenőrző mappába? A meglévő adatok NEM íródnak felül.">Visszaállítás (ellenőrzéshez)</button>
|
||||||
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
|
</form>
|
||||||
<input type="hidden" name="app" value="{{.Name}}">
|
{{template "app_list_row_end"}}
|
||||||
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítja a(z) {{.DisplayName}} adatait a távoli tárolóról egy ellenőrző mappába? A meglévő adatok NEM íródnak felül.">Visszaállítás (ellenőrzéshez)</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -101,16 +96,11 @@
|
|||||||
<div class="form-row" style="max-width:420px"><label>Jelszavas titkosítás (opcionális)</label>
|
<div class="form-row" style="max-width:420px"><label>Jelszavas titkosítás (opcionális)</label>
|
||||||
<input type="password" id="fab-dl-password" class="form-input" autocomplete="new-password" placeholder="Üresen hagyva a csomag titkosítatlan">
|
<input type="password" id="fab-dl-password" class="form-input" autocomplete="new-password" placeholder="Üresen hagyva a csomag titkosítatlan">
|
||||||
</div>
|
</div>
|
||||||
<div class="storage-paths-list">
|
<div class="app-row-list">
|
||||||
{{range .OffboxApps}}
|
{{range .OffboxApps}}
|
||||||
<div class="storage-path-item">
|
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
|
||||||
<div class="storage-path-header">
|
<button type="button" class="btn btn-xs btn-outline fab-dl-btn" data-stack="{{.Name}}" onclick="fabDownload(this)">Letöltés (.fab)</button>
|
||||||
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
|
{{template "app_list_row_end"}}
|
||||||
<div class="storage-path-actions">
|
|
||||||
<button type="button" class="btn btn-xs btn-outline fab-dl-btn" data-stack="{{.Name}}" onclick="fabDownload(this)">Letöltés (.fab)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="schedule-actions" style="margin-top:.75rem">
|
<div class="schedule-actions" style="margin-top:.75rem">
|
||||||
|
|||||||
@@ -146,16 +146,7 @@
|
|||||||
|
|
||||||
<div class="stack-list">
|
<div class="stack-list">
|
||||||
{{range .Stacks}}
|
{{range .Stacks}}
|
||||||
<div class="stack-card stack-state-{{stateColor .State}}"{{if .Meta.Slug}} data-href="/apps/{{.Meta.Slug}}"{{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)}}
|
||||||
<div class="stack-info">
|
|
||||||
<img class="stack-logo" src="{{logoURL .Meta.Slug}}"
|
|
||||||
alt="{{.Meta.DisplayName}}" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
|
||||||
<div>
|
|
||||||
<strong class="stack-name">{{.Meta.DisplayName}}</strong>
|
|
||||||
{{if .Meta.Description}}<span class="stack-desc">{{.Meta.Description}}</span>{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stack-actions">
|
|
||||||
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
||||||
{{if .Orphaned}}<span class="tag tag-warn">Elavult</span>{{end}}
|
{{if .Orphaned}}<span class="tag tag-warn">Elavult</span>{{end}}
|
||||||
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="tag tag-warn" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hiányzó tárhely: {{$ms}}</span>{{end}}
|
{{$ms := index $.MissingStorage .Name}}{{if $ms}}<span class="tag tag-warn" title="Az alkalmazás adattárolója nem elérhető. Csatlakoztasd újra a meghajtót, vagy helyezd át az adatokat egy másik tárhelyre."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hiányzó tárhely: {{$ms}}</span>{{end}}
|
||||||
@@ -183,8 +174,7 @@
|
|||||||
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||||
{{if .Orphaned}}<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Name}}')">Törlés</button>{{end}}
|
{{if .Orphaned}}<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Name}}')">Törlés</button>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
{{template "app_list_row_end"}}
|
||||||
</div>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>Nincs elérhető alkalmazás.</p>
|
<p>Nincs elérhető alkalmazás.</p>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@
|
|||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
if (e.target.closest('a, button, .btn, input, select, textarea, .stack-actions, .stack-detail-actions')) return;
|
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
|
||||||
var card = e.target.closest('[data-href]');
|
var card = e.target.closest('[data-href]');
|
||||||
if (card) window.location.href = card.dataset.href;
|
if (card) window.location.href = card.dataset.href;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -287,47 +287,89 @@ h3 {
|
|||||||
.meter.crit .meter-flag { color: var(--crit); background: var(--crit-dim); }
|
.meter.crit .meter-flag { color: var(--crit); background: var(--crit-dim); }
|
||||||
.meter-flag .ico { width: 13px; height: 13px; }
|
.meter-flag .ico { width: 13px; height: 13px; }
|
||||||
|
|
||||||
/* Stack list (dashboard) — one panel, hairline-separated rows, 2px state edge. */
|
/* ─── Shared app-list row (v0.126.0) ──────────────────────────────────
|
||||||
.stack-list {
|
THE canonical list grammar (app_row.html partial): icon + name left, caller action
|
||||||
background: var(--bg-1);
|
block right. Compact single-row height unless a secondary line is present — matches
|
||||||
|
the Alkalmazások backup-status collapsed-header density. */
|
||||||
|
.app-row-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
.app-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
min-height: 44px;
|
||||||
|
background: var(--bg-2);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.stack-card {
|
.app-row-icon {
|
||||||
position: relative;
|
width: 24px;
|
||||||
padding: .85rem 1.25rem;
|
height: 24px;
|
||||||
border-top: 1px solid var(--line-soft);
|
border-radius: var(--radius);
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--bg-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.app-row-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .1rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.app-row-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.app-row-secondary {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--text-2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.app-row-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: background 0.15s ease;
|
justify-content: flex-end;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.stack-card:first-child { border-top: none; }
|
/* 2px state edge (dashboard rows carry stack-state-<color> as RowClass). */
|
||||||
.stack-card:hover {
|
.app-row::before {
|
||||||
background: rgba(0,131,216,.04);
|
|
||||||
}
|
|
||||||
.stack-card::before {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0; top: 0; bottom: 0;
|
left: 0; top: 0; bottom: 0;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.stack-card.stack-state-run::before,
|
.app-row.stack-state-run::before,
|
||||||
.stack-card.stack-state-progress::before { background: var(--blue); }
|
.app-row.stack-state-progress::before { background: var(--blue); }
|
||||||
.stack-card.stack-state-warn::before { background: var(--warn); }
|
.app-row.stack-state-warn::before { background: var(--warn); }
|
||||||
.stack-info {
|
|
||||||
display: flex;
|
/* Stack list (dashboard) — one panel, hairline-separated shared rows. */
|
||||||
align-items: center;
|
.stack-list {
|
||||||
gap: .75rem;
|
background: var(--bg-1);
|
||||||
}
|
border: 1px solid var(--line);
|
||||||
.stack-logo {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
object-fit: contain;
|
}
|
||||||
background: var(--bg-2);
|
.stack-list .app-row {
|
||||||
padding: 4px;
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--line-soft);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: .6rem 1.25rem;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.stack-list .app-row:first-child { border-top: none; }
|
||||||
|
.stack-list .app-row:hover {
|
||||||
|
background: rgba(0,131,216,.04);
|
||||||
}
|
}
|
||||||
.stack-logo-lg {
|
.stack-logo-lg {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
@@ -337,13 +379,6 @@ h3 {
|
|||||||
background: var(--bg-2);
|
background: var(--bg-2);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
.stack-name { font-size: 1rem; font-weight: 500; }
|
|
||||||
.stack-desc { display: block; font-size: .8rem; color: var(--text-2); }
|
|
||||||
.stack-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .5rem;
|
|
||||||
}
|
|
||||||
.stack-state-label {
|
.stack-state-label {
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
@@ -2392,8 +2427,8 @@ a.stat-card:hover {
|
|||||||
.nav-links a.active { border-left: none; border-bottom: 2px solid var(--blue); }
|
.nav-links a.active { border-left: none; border-bottom: 2px solid var(--blue); }
|
||||||
.content { margin-left: 0; padding: 1rem; }
|
.content { margin-left: 0; padding: 1rem; }
|
||||||
body { flex-direction: column; }
|
body { flex-direction: column; }
|
||||||
.stack-card { flex-direction: column; align-items: flex-start; gap: .75rem; }
|
.app-row { flex-wrap: wrap; }
|
||||||
.stack-actions { width: 100%; justify-content: flex-end; }
|
.app-row-actions { width: 100%; justify-content: flex-end; }
|
||||||
.stack-grid { grid-template-columns: 1fr; }
|
.stack-grid { grid-template-columns: 1fr; }
|
||||||
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
.deploy-info { flex-direction: column; }
|
.deploy-info { flex-direction: column; }
|
||||||
@@ -2694,7 +2729,8 @@ a.stat-card:hover {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .75rem;
|
gap: .75rem;
|
||||||
padding: .65rem 1rem;
|
padding: .5rem 1rem;
|
||||||
|
min-height: 44px; /* same compact height as the shared .app-row */
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""v0.126.0 shared app-row dedup gate — the app-list row markup exists ONCE.
|
||||||
|
|
||||||
|
The canonical row (icon + name left, action block right) is defined in
|
||||||
|
templates/app_row.html (app_list_row / app_list_row_end). Every list surface renders
|
||||||
|
THROUGH it; hand-rolled copies are the defect this gate extinguishes (the pre-0.126.0
|
||||||
|
state: three visually diverging row structures across four surfaces).
|
||||||
|
|
||||||
|
Asserts:
|
||||||
|
1. The row-opening markup (`class="app-row"...`) appears in app_row.html ONLY.
|
||||||
|
2. The row-icon markup (`app-row-icon`) appears only in app_row.html plus the ONE
|
||||||
|
allowlisted aligned copy: the backups_apps.html expander header (it owns the
|
||||||
|
expand/collapse toggle, so it is aligned to the grammar, not rendered through
|
||||||
|
the partial).
|
||||||
|
3. The old duplicated structures are gone from the converted surfaces:
|
||||||
|
storage-path-item rows on the backups pages, stack-card rows on the dashboard.
|
||||||
|
4. Each converted surface actually references the partial.
|
||||||
|
|
||||||
|
Run from controller/: python scripts/app_row_dedup_gate.py
|
||||||
|
Exit 1 on any violation.
|
||||||
|
"""
|
||||||
|
import io, os, re, sys
|
||||||
|
|
||||||
|
TPL = os.path.join("internal", "web", "templates")
|
||||||
|
|
||||||
|
# (file, forbidden-pattern, why)
|
||||||
|
FORBIDDEN = [
|
||||||
|
("backups_remote.html", r"storage-path-item", "toggle list must render through app_list_row"),
|
||||||
|
("backups_remote.html", r"storage-path-header", "old row structure"),
|
||||||
|
("backups_restore.html", r"storage-path-item", ".fab + restore-to-verify lists must render through app_list_row"),
|
||||||
|
("backups_restore.html", r"storage-path-header", "old row structure"),
|
||||||
|
("dashboard.html", r"stack-card", "dashboard rows must render through app_list_row"),
|
||||||
|
("dashboard.html", r"stack-info", "old row structure"),
|
||||||
|
("dashboard.html", r'stack-logo"', "old row logo (stack-logo-lg on stacks.html is the card, not a list row)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# files that MUST reference the shared partial
|
||||||
|
MUST_USE = ["backups_remote.html", "backups_restore.html", "dashboard.html"]
|
||||||
|
|
||||||
|
# `class="app-row"` / `class="app-row {{...}}"` opening markup — [^-] excludes the
|
||||||
|
# derived class names (app-row-list, app-row-icon, ...).
|
||||||
|
ROW_OPEN_RE = re.compile(r'class="app-row[^-]')
|
||||||
|
ICON_RE = re.compile(r'app-row-icon')
|
||||||
|
ICON_ALLOW = {"app_row.html", "backups_apps.html"} # backups_apps: the aligned expander header
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
if not os.path.isdir(TPL):
|
||||||
|
print("run from controller/ (internal/web/templates not found)")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
files = {f: io.open(os.path.join(TPL, f), encoding="utf-8").read()
|
||||||
|
for f in sorted(os.listdir(TPL)) if f.endswith(".html")}
|
||||||
|
|
||||||
|
if "app_row.html" not in files:
|
||||||
|
failures.append("templates/app_row.html is missing — the canonical row partial")
|
||||||
|
|
||||||
|
for fname, src in files.items():
|
||||||
|
n_open = len(ROW_OPEN_RE.findall(src))
|
||||||
|
if fname == "app_row.html":
|
||||||
|
if n_open != 1:
|
||||||
|
failures.append("app_row.html: expected the row-opening markup exactly once, found %d" % n_open)
|
||||||
|
elif n_open:
|
||||||
|
failures.append("%s: hand-rolled app-row markup (%d occurrence(s)) — render through the app_list_row partial" % (fname, n_open))
|
||||||
|
if ICON_RE.search(src) and fname not in ICON_ALLOW:
|
||||||
|
failures.append("%s: app-row-icon outside the partial/allowlist — do not copy the icon markup" % fname)
|
||||||
|
|
||||||
|
for fname, pat, why in FORBIDDEN:
|
||||||
|
src = files.get(fname, "")
|
||||||
|
if re.search(pat, src):
|
||||||
|
failures.append("%s: forbidden old structure %r survives (%s)" % (fname, pat, why))
|
||||||
|
|
||||||
|
for fname in MUST_USE:
|
||||||
|
if '{{template "app_list_row"' not in files.get(fname, ""):
|
||||||
|
failures.append("%s: does not render through the app_list_row partial" % fname)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print("app_row_dedup_gate: FAIL")
|
||||||
|
for f in failures:
|
||||||
|
print(" - " + f)
|
||||||
|
sys.exit(1)
|
||||||
|
print("app_row_dedup_gate: OK (row markup single-sourced in app_row.html; %d templates scanned)" % len(files))
|
||||||
Reference in New Issue
Block a user