Compare commits

..

7 Commits

Author SHA1 Message Date
admin 912e6a62b6 docs: v0.126.0 UI uniformity bundle — CHANGELOG (parts A-E + gate housekeeping), REUSE (app_list_row canonical row + gate list), README (shared-row pattern, infra identity, Part E display pick), CONTEXT 2026-07-13 11:41:11 +02:00
admin 02c84e1ce2 Part E: the stale zero-toggle line tells the truth (v0.126.0)
- offboxWarningDisplay(lastWarning, toggledCount) — pure DISPLAY pick, no state mutation:
  a persisted 'nincs mentésre jelölt alkalmazás' run-result is replaced by
  'A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza.'
  once ≥1 app is toggled; 0 toggled keeps the v0.123.0 honesty verbatim; every other
  warning (quota, partial) passes through untouched
- replacement renders NEUTRAL (exception-color principle: reassurance, not deviation);
  the untouched original keeps the warn color
- unit + render tests; red-proven (pick removed → 1-enabled case fails at both levels)
2026-07-13 11:37:47 +02:00
admin 458cf4a4c2 Part D: mojibake_gate.py — double-encoded UTF-8 can never enter the source again (v0.126.0)
- scans all templates + Go sources (291 files) for the double-encoding signature chars
  (Ã Â Ă ă ˘ ˇ) + strict-UTF-8 decode; allowlist ZERO by design; Python per the
  multibyte rule (this task exists partly because of a bash-era mojibake sweep)
- byte-level sweep result: the committed source contains NO mojibake — the 'Tárhely'-class
  text on the live import page is the felhom-usb DRIVE-LABEL DATA (settings.json on the
  demo box; default-label pattern settings.go GenerateLabel), repaired via the label-edit
  UI in the live-validation step, not by code
- red-proven: reintroduced 'Tárhely' into app_import.html → exit 1 naming file:line; reverted → 0
2026-07-13 11:35:04 +02:00
admin 67494f9890 Part C: restore-form polish — the .fab encryption field is a standard form field (v0.126.0)
- backups_restore: placeholder 'Opcionális jelszó' + helper under the field
  ('Üresen hagyva a csomag titkosítás nélkül készül.') — the long clipped placeholder is gone
- app_import: the bundle-password input picks up the standard .form-input class
  (was a bare browser-default input)
2026-07-13 11:32:41 +02:00
admin 4f06149abc Part B: infra stacks carry identity — curated Hungarian metadata for cloudflared/traefik/filebrowser (v0.126.0)
- inframeta.go: static display-only map (name/description/Linked) + infraMeta template func;
  filebrowser is the ONLY Linked stack (files.<domain> Megnyitás on the dashboard row;
  the stacks card already links via Subdomains)
- generic infra icon: embedded /static/infra-logo.svg (Lucide-style server, monochrome)
  wired through the app-row FallbackIcon slot + the stacks-card onerror chain
- dashboard rows + app cards: infra name + description + existing Védett chip
- guarded WRONG outcome: no customer link for cloudflared/traefik — render tests count
  exactly ONE https:// link; red-proven (Linked:true on cloudflared → test FAILS)
2026-07-13 11:31:38 +02:00
admin 3eacb6c326 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
2026-07-13 11:26:48 +02:00
admin 7b3e7f2e71 chore: retire backups_split_move_check.py — the one-shot v0.124.0 migration gate served its purpose (v0.126.0 legitimately rewrites the moved blocks onto the shared row partial); doc refs updated (README page-map note, REUSE builder row) 2026-07-13 11:15:12 +02:00
23 changed files with 818 additions and 178 deletions
+45
View File
@@ -1,5 +1,50 @@
## Changelog
### v0.126.0 — UI uniformity bundle: shared app-list rows, infra-app metadata, restore-form polish, mojibake gate (2026-07-13)
Operator review (2026-07-13 screenshots): app lists looked designed three different ways across
four+ surfaces; infra stacks rendered as bare names; the .fab password input clipped its
placeholder; the zero-toggle run-warning went stale. Controller-only, presentation-layer — NO
backup/engine/toggle behavior change (render tests assert the action markup is untouched).
- **Part A — ONE row grammar, four surfaces:** `templates/app_row.html` defines
`app_list_row`/`app_list_row_end` (the layout_start/_end idiom): icon + name (+ optional
one-line secondary) left, caller action block right; compact 44px row. Applied to the Távoli
mentés toggle list, the Visszaállítás restore-to-verify + .fab lists and the dashboard
Telepített alkalmazások (state edge + data-href preserved); the Alkalmazások collapsed
headers ALIGNED to the grammar (icon+name left, status dot moved right before the chevron;
expander untouched — the one allowlisted aligned copy). funcmap: `dict` + `appHref`;
`OffboxAppRow`/`AppBackupRow` gain `Slug`. Old `.stack-card`/`.storage-path-item` row CSS+markup
retired. **NEW gate `scripts/app_row_dedup_gate.py`** — row markup single-sourced (red-proven:
pasted an old row block back → exit 1).
- **Part B — infra stacks carry identity:** `inframeta.go` static display-only map —
cloudflared → „Cloudflare Tunnel", traefik → „Traefik", filebrowser → „FileBrowser", each with
curated Hungarian description; dashboard rows + app cards show icon + description + the
existing Védett chip. Generic embedded `/static/infra-logo.svg` as icon fallback. filebrowser
is the ONLY Linked stack (files.<domain> Megnyitás); guarded WRONG outcome — no customer link
for cloudflared/traefik (render test counts exactly one https:// link; red-proven by flipping
Linked on cloudflared → FAIL).
- **Part C — restore-form polish:** the .fab encryption input is a standard form field —
placeholder „Opcionális jelszó" + helper under the field („Üresen hagyva a csomag titkosítás
nélkül készül."); the import-page bundle-password input picks up `.form-input` (was a bare
browser default).
- **Part D — mojibake fixed-by-construction:** byte-level sweep found ZERO double-encoded
literals in the committed source — the live „Tárhely"-class text on the import page is the
felhom-usb DRIVE-LABEL DATA (settings.json), repaired via the label-edit UI during live
validation. **NEW gate `scripts/mojibake_gate.py`** (Python per the multibyte rule): all
templates + Go sources must strict-UTF-8-decode and contain none of Ã Â Ă ă ˘ ˇ; allowlist
ZERO. Red-proven (reintroduced „Tárhely" → exit 1 naming file:line).
- **Part E — the stale zero-toggle line tells the truth:** `offboxWarningDisplay` display pick
(no state mutation): a persisted „nincs mentésre jelölt alkalmazás" run-warning is replaced by
„A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza." once
≥1 app is toggled (rendered neutral — reassurance, not deviation); 0 toggled keeps the
v0.123.0 line verbatim; quota/partial warnings pass through. Red-proven at unit + render level.
- **Gate housekeeping (first commit):** `scripts/backups_split_move_check.py` retired — the
one-shot v0.124.0 migration gate served its purpose (it pinned the split to verbatim moves vs
df7ad37); this release legitimately rewrites those blocks onto the shared row partial. All
other template gates stay mandatory (template_id, emoji, native_confirm, offbox_rename,
docker_run_volume_path + the two new ones).
### v0.125.0 — .fab volume export/import: containerized path-strand data loss FIXED (IA finding 1, HIGH) (2026-07-13) — MinAgent: 0.81.0
Both .fab volume legs streamed via `docker run -v <controller-temp-path>` host mounts — correct
+20 -1
View File
@@ -7,7 +7,26 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-07-13 (v0.125.0 — .fab volume path-strand fix)
Last updated: 2026-07-13 (v0.126.0 — UI uniformity bundle)
> **2026-07-13 — v0.126.0: UI UNIFORMITY BUNDLE (shared app-list rows + infra identity +
> restore-form polish + mojibake gate + honest stale line). Presentation-layer only — NO
> backup/toggle/engine behavior change. MinAgent 0.81 + floor unchanged.**
> (A) `templates/app_row.html` `app_list_row`/`app_list_row_end` is THE canonical list row
> (icon+name left, caller action right, compact 44px) — dashboard installed-apps, Távoli mentés
> toggles, Visszaállítás restore-to-verify + .fab lists render through it; the backups-apps
> expander header is ALIGNED (own markup, allowlisted); gate `scripts/app_row_dedup_gate.py`
> (red-proven). funcmap: `dict`/`appHref`/`infraMeta`. (B) `inframeta.go`: cloudflared →
> „Cloudflare Tunnel", traefik → „Traefik", filebrowser → „FileBrowser" + Hungarian descriptions
> + generic `/static/infra-logo.svg` fallback; filebrowser = the ONLY Linked infra
> (files.<domain>); render test counts exactly one customer link (red-proven). (C) .fab password
> field standard („Opcionális jelszó" + helper; import-page input got `.form-input`).
> (D) `scripts/mojibake_gate.py` — templates+Go strict UTF-8, zero Ã/Â/Ă-signature chars,
> allowlist ZERO (red-proven); source had NO mojibake — the live „Tárhely" is the felhom-usb
> drive-label DATA, repaired via the label-edit UI (live step). (E) `offboxWarningDisplay`
> display pick — stale „nincs mentésre jelölt alkalmazás" run-warning → „A kijelölés módosult…"
> note once ≥1 app toggled (neutral color); 0 toggled unchanged (red-proven).
> Housekeeping: one-shot `backups_split_move_check.py` RETIRED (served its purpose).
> **2026-07-13 — v0.125.0: .FAB VOLUME PATH-STRAND DATA LOSS FIXED (IA finding 1, HIGH).
> MinAgent 0.81 unchanged; floor may advance to 0.125.0 next train (must NOT halt above 0.124.0
+3 -2
View File
@@ -207,10 +207,11 @@ Cross-repo edges:
- **New REST endpoint**: path dispatch in `Router.ServeHTTP` (controller/internal/api/router.go); use `writeJSON` + `limitBody`.
- **New background job**: `sched.Every`/`sched.Daily` registration block in controller/cmd/controller/main.go.
- **New template function**: `Server.templateFuncMap` (controller/internal/web/funcmap.go) — obey v2 state-suffix vocabulary.
- **New page/nav item**: `baseData` + sidebar in controller/internal/web/templates/ (nested sub-links pattern `.nav-links-nested`); must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` + `controller/scripts/native_confirm_gate.py` + `controller/scripts/offbox_rename_gate.py`.
- **New page/nav item**: `baseData` + sidebar in controller/internal/web/templates/ (nested sub-links pattern `.nav-links-nested`); must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` + `controller/scripts/native_confirm_gate.py` + `controller/scripts/offbox_rename_gate.py` + `controller/scripts/app_row_dedup_gate.py` + `controller/scripts/mojibake_gate.py`.
- **Docker volume tar streaming (v0.125.0)**: `appexport.dockerExec` (seam, package var) + `withVolumeHelper`/`exportVolumeTar`/`importVolumeTar` — stream volume content via `docker cp` through a stopped helper container. NEVER `docker run -v <controller-local path>` — the daemon resolves `-v` host-side and strands the data when the controller is containerized (the v0.124.0 HIGH finding); `controller/scripts/docker_run_volume_path_gate.py` enforces (every `"-v"` allowlisted with its WHY).
- **Guarded file download (v0.124.0)**: `handler_export_download.go` — the canonical shape for streaming a server-side file to the browser: accept a BASENAME only (shape regexp + no separators/`..`), `filepath.Join` then assert `filepath.Dir(path) == dir`, `io.Copy` (never ReadAll), `Content-Disposition: attachment`, remove after a successful stream, TTL sweep (`sweepFabDownloads(dir, now, maxAge, logger)` — now injected for tests). Red-proof the guard by loosening to prefix-matching (the `..` case must fail).
- **Backups sub-page data**: `backupsCommonData(page, title, r)` + `backupsOffboxData(data)` (handlers.go) — the ONLY builders for the four `/backups*` pages; a new backups section extends these, never re-derives in a page handler. Section moves are gated by `scripts/backups_split_move_check.py` (one-shot vs df7ad37).
- **Backups sub-page data**: `backupsCommonData(page, title, r)` + `backupsOffboxData(data)` (handlers.go) — the ONLY builders for the four `/backups*` pages; a new backups section extends these, never re-derives in a page handler. (The one-shot v0.124.0 move gate `backups_split_move_check.py` was retired in v0.126.0.)
- **App-list row (v0.126.0)**: `app_list_row`/`app_list_row_end` in `templates/app_row.html` is THE canonical list pattern — icon+name(+secondary) left, caller action block right; open with `dict "Slug" ... "Name" ...` (optional `Secondary`/`RowClass`/`Href`/`FallbackIcon`), close with `app_list_row_end`. Do NOT hand-roll app rows — `scripts/app_row_dedup_gate.py` enforces single-sourcing (the backups_apps expander header is the one allowlisted aligned copy). Infra display identity: `inframeta.go` map + `infraMeta` func (filebrowser is the only Linked stack).
- **Consequential-action confirm (LIGHT)**: `felhomConfirm(el, question, onYes)` in layout.html (v0.123.0) — the trigger swaps in place to "kérdés + Igen/Mégse"; form buttons opt in with `data-confirm="…"` (delegated listener, `requestSubmit` keeps formaction/name-value). NEVER native `confirm()`/`prompt()` (OS-modals freeze browser automation — drill F-11; `native_confirm_gate.py` enforces). Heavy destructive flows keep the `.confirm-overlay` `openDialog` pattern.
- **New hub event**: typed `Notify*` wrapper on Notifier + hub allowlist entry (cross-repo).
- **New app integration**: `integrations.Manager.RegisterHandler` with `IntegrationKey(provider, target)`.
+15 -4
View File
@@ -123,9 +123,19 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated
(`felhomConfirm` in layout.html, v0.123.0: the trigger swaps in place to "kérdés + Igen/Mégse";
form buttons opt in via `data-confirm="…"`) — never native `confirm()`/`prompt()` (OS-modals
freeze browser automation; drill F-11).
**Shared app-list row (v0.126.0):** `templates/app_row.html` (`app_list_row`/`app_list_row_end`)
is the ONE row grammar for app lists — icon + name (+ optional secondary line) left, caller
action block right; used by the dashboard installed-apps list, the Távoli mentés toggle list
and the Visszaállítás restore-to-verify/.fab lists; the backups-apps expander header is
ALIGNED to the same grammar (own markup — it carries the toggle). Protected infra stacks
(traefik/cloudflared/filebrowser) get curated Hungarian display identity from the
`inframeta.go` map (name + description + generic `/static/infra-logo.svg` fallback icon);
filebrowser is the only infra stack with a customer link (`files.<domain>`).
**Enforcement:** `scripts/template_id_gate.py` (JS element-ID integrity) + `scripts/emoji_gate.py`
(no emoji) + `scripts/native_confirm_gate.py` (zero native confirm/prompt) — run after any
template change; the Go `TestNoEmojiInTemplates` mirrors the emoji gate.
(no emoji) + `scripts/native_confirm_gate.py` (zero native confirm/prompt) +
`scripts/app_row_dedup_gate.py` (row markup single-sourced) + `scripts/mojibake_gate.py`
(no double-encoded UTF-8 in templates/Go sources) — run after any template change; the Go
`TestNoEmojiInTemplates` mirrors the emoji gate.
- **Europe/Budapest timezone** — All scheduled jobs, timestamps, and UI labels use Hungarian timezone.
### Module Map
@@ -361,13 +371,14 @@ self-sufficient backup** — any single tier can fully restore an app.
| Route | Page | Sections |
|-------|------|----------|
| `/backups` | Áttekintés | storage overview, whole-guest Rendszermentés, status stat cards, single-copy warning |
| `/backups/remote` | Távoli mentés | Felhom-offsite status card (3 states, display-only), tier-3 status block + quota, participation toggles (+ zero-toggle hint), manual-target form (`#offbox-section`) |
| `/backups/remote` | Távoli mentés | Felhom-offsite status card (3 states, display-only), tier-3 status block + quota, participation toggles (+ zero-toggle hint; the persisted zero-toggle run-warning is DISPLAY-replaced by a "kijelölés módosult" note once ≥1 app is toggled — `offboxWarningDisplay`, v0.126.0), manual-target form (`#offbox-section`) |
| `/backups/apps` | Alkalmazások | schedule, Adatbázisok table, per-app 1./2./3. tier rows (tier-2 config entry; tier-3 actions deep-link to `/backups/remote#offbox-section`) |
| `/backups/restore` | Visszaállítás | restore panel, offbox restore-to-verify list, .fab download/import loop |
Shared data builders: `backupsCommonData` (chrome + full-status + flash) + `backupsOffboxData`
(offbox target/toggles) in `handlers.go`; shared partials in `templates/backups_shared.html`.
The split is MOVE-only vs v0.123.0 — `scripts/backups_split_move_check.py` gates it.
(The v0.124.0 split was MOVE-only, gated one-shot by `backups_split_move_check.py`; the gate was
retired in v0.126.0 when the moved blocks were legitimately rewritten onto the shared row partial.)
| Tier | Contents | Location | Can fully restore? |
|------|----------|----------|--------------------|
+131
View File
@@ -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")
}
}
+27
View File
@@ -379,6 +379,33 @@ 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)
},
// infraMeta resolves a protected infra stack's curated Hungarian identity
// (inframeta.go); nil for regular apps — templates branch on it.
"infraMeta": infraMetaFor,
// 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 {
+38 -1
View File
@@ -653,10 +653,37 @@ func (s *Server) backupsOffboxData(data map[string]interface{}) {
}
}
data["OffboxToggledCount"] = offboxToggled
// Part E (v0.126.0): the LastWarning DISPLAY pick — never a state mutation.
if offboxTgt != nil {
data["OffboxWarningDisplay"] = offboxWarningDisplay(offboxTgt.LastWarning, offboxToggled)
} else {
data["OffboxWarningDisplay"] = ""
}
// SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model).
data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt)
}
// offboxStaleWarningMarker is the substring the zero-toggled offbox run writes into
// LastWarning (backup/offbox.go); offboxSelectionChangedLine replaces it once the
// selection has moved on.
const (
offboxStaleWarningMarker = "nincs mentésre jelölt alkalmazás"
offboxSelectionChangedLine = "A kijelölés módosult az utolsó futás óta — a következő távoli mentés már tartalmazza."
)
// offboxWarningDisplay picks what the Távoli mentés page shows for the persisted
// Offbox.LastWarning. A zero-toggled run records "Sikeres — nincs mentésre jelölt
// alkalmazás…"; once the customer HAS toggled apps that line is stale and misleading —
// replace it with the honest "selection changed, the next run covers it" note.
// Pure display logic: the persisted LastWarning is never touched, and every other
// warning (quota, partial failure) passes through verbatim.
func offboxWarningDisplay(lastWarning string, toggledCount int) string {
if toggledCount >= 1 && strings.Contains(lastWarning, offboxStaleWarningMarker) {
return offboxSelectionChangedLine
}
return lastWarning
}
// backupsHandler renders the Áttekintés page: storage overview, whole-guest Rendszermentés and
// the status stat cards.
func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
@@ -730,6 +757,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 +775,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 +784,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 +898,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,
+48
View File
@@ -0,0 +1,48 @@
package web
// Infra-stack display metadata (v0.126.0 Part B). The protected base stacks (infra.go:
// traefik, cloudflared, filebrowser) carry no catalog metadata, so they rendered as bare
// directory names on the dashboard and app cards. This map gives them curated Hungarian
// identity — DISPLAY ONLY, keyed by stack name; it never affects protection, deploy or
// backup logic.
//
// Linked marks the ONLY infra stack with a customer-facing UI (filebrowser →
// files.<domain>). cloudflared and traefik have no customer UI — rendering an open link
// for them is the guarded-wrong outcome (render test asserts absence).
// InfraMeta is one protected infra stack's customer-facing identity.
type InfraMeta struct {
DisplayName string
Description string
Linked bool // has a customer-facing URL (subdomain via protectedStackSubdomains)
}
var infraMetaMap = map[string]InfraMeta{
"cloudflared": {
DisplayName: "Cloudflare Tunnel",
Description: "Biztonságos internetkapcsolat — a szerver portnyitás nélkül érhető el kívülről.",
},
"traefik": {
DisplayName: "Traefik",
Description: "Forgalomirányító (reverse proxy) — a kéréseket a megfelelő alkalmazáshoz irányítja.",
},
"filebrowser": {
DisplayName: "FileBrowser",
Description: "Fájlkezelő — a tárhely fájljainak böngészése a böngészőből.",
Linked: true,
},
}
// infraMetaFor returns the curated metadata for a protected infra stack, nil for
// everything else (templates branch on the nil).
func infraMetaFor(stackName string) *InfraMeta {
if m, ok := infraMetaMap[stackName]; ok {
return &m
}
return nil
}
// InfraLogoSVG is the shared generic infra icon (Lucide-style "server", monochrome,
// --text-2 stroke) — the last-resort icon for infra stacks whose logo asset is not
// synced. Served at /static/infra-logo.svg; consumed via the app-row FallbackIcon slot.
const InfraLogoSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#94A6BF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`
+103
View File
@@ -0,0 +1,103 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// v0.126.0 Part B — infra stacks carry curated identity (name + description + Védett chip),
// and the guarded-wrong outcome: NO customer link for cloudflared/traefik (filebrowser is
// the ONLY Linked infra stack).
// COMPANION red-proof (recorded in REPORT): set Linked:true on cloudflared → the
// single-Megnyitás assertions below FAIL.
func infraStacksTestSet() []stacks.Stack {
return []stacks.Stack{
{Name: "cloudflared", Protected: true, State: stacks.StateRunning,
Meta: stacks.Metadata{Slug: "cloudflared", DisplayName: "Cloudflared"}},
{Name: "traefik", Protected: true, State: stacks.StateRunning,
Meta: stacks.Metadata{Slug: "traefik", DisplayName: "Traefik"}},
{Name: "filebrowser", Protected: true, State: stacks.StateRunning,
Meta: stacks.Metadata{Slug: "filebrowser", DisplayName: "Filebrowser"}},
}
}
func infraPageData(page string) map[string]interface{} {
return map[string]interface{}{
"Page": page, "Title": "t",
"Stacks": infraStacksTestSet(),
"MissingStorage": map[string]string{},
"NetworkWarnings": map[string]string{},
"NetworkStubs": map[string]string{},
"StorageLabels": map[string]string{},
"Subdomains": map[string]string{"filebrowser": "files"},
"RunningCount": 3, "StoppedCount": 0, "TotalCount": 3,
"SystemInfo": system.SystemInfo{},
"BackupEnabled": false,
"Domain": "demo-felhom.eu",
}
}
func assertInfraIdentity(t *testing.T, surface, html string) {
t.Helper()
for _, want := range []string{
"Cloudflare Tunnel",
"Biztonságos internetkapcsolat — a szerver portnyitás nélkül érhető el kívülről.",
"Traefik",
"Forgalomirányító (reverse proxy) — a kéréseket a megfelelő alkalmazáshoz irányítja.",
"FileBrowser",
"Fájlkezelő — a tárhely fájljainak böngészése a böngészőből.",
} {
if !strings.Contains(html, want) {
t.Errorf("%s: infra identity %q missing", surface, want)
}
}
if !strings.Contains(html, "Védett") {
t.Errorf("%s: the Védett chip is missing", surface)
}
if !strings.Contains(html, `https://files.demo-felhom.eu`) {
t.Errorf("%s: filebrowser must link files.<domain>", surface)
}
// The guarded WRONG outcome: exactly ONE customer link (filebrowser) — a second
// https:// link would mean cloudflared/traefik got one.
if n := strings.Count(html, `https://`); n != 1 {
t.Errorf("%s: expected exactly 1 customer link (filebrowser), found %d https:// occurrences", surface, n)
}
}
func TestInfraMeta_DashboardRows(t *testing.T) {
html := renderBackupPage(t, "dashboard", infraPageData("dashboard"))
assertInfraIdentity(t, "dashboard", html)
if n := strings.Count(html, "Megnyitás"); n != 1 {
t.Errorf("dashboard: expected exactly 1 Megnyitás (filebrowser), found %d", n)
}
if n := strings.Count(html, `data-fallback="/static/infra-logo.svg"`); n != 3 {
t.Errorf("dashboard: all 3 infra rows must carry the generic-icon fallback, found %d", n)
}
}
func TestInfraMeta_AppCards(t *testing.T) {
html := renderBackupPage(t, "stacks", infraPageData("stacks"))
assertInfraIdentity(t, "stacks", html)
if !strings.Contains(html, "Védett rendszerkomponens") {
t.Error("stacks: protected card chip missing")
}
if n := strings.Count(html, `data-fallback="/static/infra-logo.svg"`); n != 3 {
t.Errorf("stacks: all 3 infra cards must carry the generic-icon fallback, found %d", n)
}
}
func TestInfraMetaFor_RegularAppIsNil(t *testing.T) {
if infraMetaFor("radarr") != nil {
t.Error("regular apps must have no infra metadata")
}
if m := infraMetaFor("cloudflared"); m == nil || m.Linked {
t.Error("cloudflared must exist and must NOT be Linked (no customer UI)")
}
if m := infraMetaFor("filebrowser"); m == nil || !m.Linked {
t.Error("filebrowser must be the Linked infra stack")
}
}
@@ -0,0 +1,73 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// v0.126.0 Part E — the stale zero-toggle warning line tells the truth. Display-pick only:
// the persisted LastWarning is never mutated; the page swaps the stale "nincs mentésre
// jelölt alkalmazás" run-result for the selection-changed note once ≥1 app is toggled.
// COMPANION red-proof (recorded in REPORT): make offboxWarningDisplay return lastWarning
// unconditionally → TestOffboxWarningDisplay_Pick's 1-enabled case FAILS.
const staleZeroToggleWarning = "Sikeres — nincs mentésre jelölt alkalmazás volt a futáskor."
func TestOffboxWarningDisplay_Pick(t *testing.T) {
// warning + ≥1 enabled → the replacement line
if got := offboxWarningDisplay(staleZeroToggleWarning, 1); got != offboxSelectionChangedLine {
t.Errorf("stale warning + 1 toggled: got %q, want the selection-changed line", got)
}
// warning + 0 enabled → the original line, verbatim (the v0.123.0 honesty stays)
if got := offboxWarningDisplay(staleZeroToggleWarning, 0); got != staleZeroToggleWarning {
t.Errorf("stale warning + 0 toggled: got %q, want the original warning unchanged", got)
}
// any OTHER warning passes through regardless of toggles (quota, partial failure)
quota := "A tároló a keret 84%-át használja (42/50 GB)."
if got := offboxWarningDisplay(quota, 3); got != quota {
t.Errorf("non-stale warning must pass through verbatim, got %q", got)
}
// no warning → no line
if got := offboxWarningDisplay("", 2); got != "" {
t.Errorf("empty warning must stay empty, got %q", got)
}
}
func TestOffboxWarningDisplay_RemotePageRender(t *testing.T) {
// ≥1 toggled: the page shows the replacement, NOT the stale line.
data := appRowSplitData()
data["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed",
LastWarning: staleZeroToggleWarning,
}
data["OffboxWarningDisplay"] = offboxWarningDisplay(staleZeroToggleWarning, 1)
html := renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, offboxSelectionChangedLine) {
t.Error("selection-changed note missing with 1 app toggled")
}
if strings.Contains(html, staleZeroToggleWarning) {
t.Error("the stale zero-toggle line still renders alongside the replacement")
}
// 0 toggled: the original warning + the v0.123.0 zero-toggle hint, unchanged.
data = appRowSplitData()
data["OffboxApps"] = []OffboxAppRow{{Name: "calibre-web", DisplayName: "Calibre-Web", Slug: "calibre-web", Enabled: false}}
data["OffboxToggledCount"] = 0
data["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", LastStatus: "ok", EscrowState: "escrowed",
LastWarning: staleZeroToggleWarning,
}
data["OffboxWarningDisplay"] = offboxWarningDisplay(staleZeroToggleWarning, 0)
html = renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, staleZeroToggleWarning) {
t.Error("0 toggled: the original run warning must render unchanged")
}
if strings.Contains(html, offboxSelectionChangedLine) {
t.Error("0 toggled: the selection-changed note must NOT render")
}
if !strings.Contains(html, "Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.") {
t.Error("0 toggled: the v0.123.0 hint must stay")
}
}
+4
View File
@@ -398,6 +398,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.serveLogoHandler(w, r)
case path == "/static/favicon.svg":
s.serveFaviconHandler(w, r)
case path == "/static/infra-logo.svg":
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=86400")
fmt.Fprint(w, InfraLogoSVG)
case strings.HasPrefix(path, "/static/assets/"):
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
case strings.HasPrefix(path, "/apps/"):
@@ -49,7 +49,7 @@
<div id="passwordPrompt" style="display:none;margin-bottom:1rem">
<p>Ez a csomag jelszóval védett. Kérlek add meg a jelszót:</p>
<div style="display:flex;gap:.5rem">
<input type="password" id="importPassword" placeholder="Jelszó" style="flex:1;padding:.5rem">
<input type="password" id="importPassword" class="form-input" autocomplete="off" placeholder="Jelszó" style="flex:1">
<button class="btn btn-primary" onclick="loadManifest()">Megnyitás</button>
</div>
<div id="passwordError" style="display:none;color:var(--crit);margin-top:.5rem"></div>
@@ -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}}
<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)">
<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>
<div class="app-backup-row-meta">
{{if .DriveDisconnected}}
@@ -151,6 +155,7 @@
{{else}}
<span class="meta-badge">Konfig{{if .HasDB}} + DB{{end}}</span>
{{end}}
<span class="status-dot status-{{.Status}}" title="{{.StatusText}}"></span>
</div>
<span class="expand-icon"></span>
</div>
@@ -50,7 +50,9 @@
</div>
{{end}}
{{if .Offbox.LastError}}<p class="form-hint" style="color:var(--crit)">Utolsó hiba: {{.Offbox.LastError}}</p>{{end}}
{{if .Offbox.LastWarning}}<p class="form-hint" style="color:var(--warn)">{{.Offbox.LastWarning}}</p>{{end}}
{{/* Part E: display pick — a stale zero-toggle warning is replaced once the selection
changed (neutral color: the replacement is reassurance, not a deviation). */}}
{{if .OffboxWarningDisplay}}<p class="form-hint"{{if eq .OffboxWarningDisplay .Offbox.LastWarning}} style="color:var(--warn)"{{end}}>{{.OffboxWarningDisplay}}</p>{{end}}
{{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}}
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
<p class="form-hint" style="color:var(--warn);margin:0 0 .5rem">A távoli mentés a kulcs letétbe helyezésére vár — a mentés addig nem fut (így nem keletkezik visszaállíthatatlan másolat). Futtasd a letéti szertartást, majd erősítsd meg.</p>
@@ -70,20 +72,15 @@
<p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — jelölj ki legalább egyet.</p>
{{end}}
{{if .OffboxApps}}
<div class="storage-paths-list">
<div class="app-row-list">
{{range .OffboxApps}}
<div class="storage-path-item">
<div class="storage-path-header">
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
<div class="storage-path-actions">
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
<form method="POST" action="/backup/offbox/toggle" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<input type="hidden" name="enabled" value="{{if .Enabled}}false{{else}}true{{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>
{{template "app_list_row_end"}}
{{end}}
</div>
{{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>
<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}}
<div class="storage-paths-list">
<div class="app-row-list">
{{range .OffboxApps}}
{{if .Enabled}}
<div class="storage-path-item">
<div class="storage-path-header">
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
<div class="storage-path-actions">
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<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>
{{template "app_list_row_end"}}
{{end}}
{{end}}
</div>
@@ -99,18 +94,14 @@
<p class="form-hint" style="margin:-0.25rem 0 1rem">Hordozható pillanatfelvétel — bárhol tárolhatod, és bármikor visszatöltheted egy meghajtóról. A folyamatos védelmet az 13. szintű mentés adja.</p>
{{if .OffboxApps}}
<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="Opcionális jelszó">
<span class="form-hint">Üresen hagyva a csomag titkosítás nélkül készül.</span>
</div>
<div class="storage-paths-list">
<div class="app-row-list">
{{range .OffboxApps}}
<div class="storage-path-item">
<div class="storage-path-header">
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
<div class="storage-path-actions">
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
<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>
{{template "app_list_row_end"}}
{{end}}
</div>
<div class="schedule-actions" style="margin-top:.75rem">
@@ -146,16 +146,12 @@
<div class="stack-list">
{{range .Stacks}}
<div class="stack-card stack-state-{{stateColor .State}}"{{if .Meta.Slug}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
<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">
{{$im := infraMeta .Name}}
{{if $im}}
{{template "app_list_row" dict "Slug" .Meta.Slug "Name" $im.DisplayName "Secondary" $im.Description "RowClass" (printf "stack-state-%s" (stateColor .State)) "Href" (appHref .Meta.Slug) "FallbackIcon" "/static/infra-logo.svg"}}
{{else}}
{{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)}}
{{end}}
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
{{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}}
@@ -165,6 +161,12 @@
{{if .Protected}}
<span class="tag"><svg class="ico ico-sm"><use href="#i-lock"/></svg>Védett</span>
{{/* Customer link ONLY for Linked infra (filebrowser → files.<domain>) —
cloudflared/traefik have no customer UI (guarded by render test). */}}
{{if and $im $im.Linked}}
{{$subdomain := index $.Subdomains .Name}}
{{if $subdomain}}<a href="https://{{$subdomain}}.{{$.Domain}}{{.Meta.OpenPath}}" target="_blank" class="btn btn-sm btn-outline" onclick="event.stopPropagation()">Megnyitás <svg class="ico ico-sm"><use href="#i-external-link"/></svg></a>{{end}}
{{end}}
{{if isOperational .State}}
<button class="btn btn-sm btn-warning" onclick="stackAction(event, '{{.Name}}', 'restart')" title="Újraindítás"><svg class="ico ico-sm"><use href="#i-rotate-cw"/></svg></button>
{{end}}
@@ -183,8 +185,7 @@
<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}}
{{end}}
</div>
</div>
{{template "app_list_row_end"}}
{{else}}
<div class="empty-state">
<p>Nincs elérhető alkalmazás.</p>
@@ -108,7 +108,7 @@
</main>
<script>
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]');
if (card) window.location.href = card.dataset.href;
});
@@ -18,13 +18,14 @@
<div class="stack-grid">
{{range .Stacks}}
{{$im := infraMeta .Name}}
<div class="stack-detail-card stack-state-{{stateColor .State}}" data-filter-state="{{filterCategory .State .Deployed}}"{{if .Meta.Slug}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
<div class="stack-detail-header">
<div class="stack-title-row">
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}"
alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}" alt=""{{if $im}} data-fallback="/static/infra-logo.svg"{{end}}
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Meta.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>
<h3>{{.Meta.DisplayName}}</h3>
<h3>{{if $im}}{{$im.DisplayName}}{{else}}{{.Meta.DisplayName}}{{end}}</h3>
{{$subdomain := index $.Subdomains .Name}}
{{if and $subdomain (or .Deployed .Protected)}}
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank">
@@ -43,7 +44,9 @@
{{$nw := index $.NetworkWarnings .Name}}{{if $nw}}<span class="tag tag-warn" title="A hálózati tárhely (NAS) jelenleg nem érhető el. Az alkalmazás fut; az adatok elérése a NAS visszatértével helyreáll."><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg>Hálózati tárhely nem elérhető: {{$nw}}</span>{{end}}
</div>
{{if .Meta.Description}}
{{if $im}}
<p class="stack-detail-desc">{{$im.Description}}</p>
{{else if .Meta.Description}}
<p class="stack-detail-desc">{{.Meta.Description}}</p>
{{end}}
+74 -38
View File
@@ -287,47 +287,89 @@ h3 {
.meter.crit .meter-flag { color: var(--crit); background: var(--crit-dim); }
.meter-flag .ico { width: 13px; height: 13px; }
/* Stack list (dashboard) — one panel, hairline-separated rows, 2px state edge. */
.stack-list {
background: var(--bg-1);
/* Shared app-list row (v0.126.0)
THE canonical list grammar (app_row.html partial): icon + name left, caller action
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-radius: var(--radius);
}
.stack-card {
position: relative;
padding: .85rem 1.25rem;
border-top: 1px solid var(--line-soft);
.app-row-icon {
width: 24px;
height: 24px;
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;
justify-content: space-between;
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; }
.stack-card:hover {
background: rgba(0,131,216,.04);
}
.stack-card::before {
/* 2px state edge (dashboard rows carry stack-state-<color> as RowClass). */
.app-row::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 2px;
background: transparent;
}
.stack-card.stack-state-run::before,
.stack-card.stack-state-progress::before { background: var(--blue); }
.stack-card.stack-state-warn::before { background: var(--warn); }
.stack-info {
display: flex;
align-items: center;
gap: .75rem;
}
.stack-logo {
width: 32px;
height: 32px;
.app-row.stack-state-run::before,
.app-row.stack-state-progress::before { background: var(--blue); }
.app-row.stack-state-warn::before { background: var(--warn); }
/* Stack list (dashboard) — one panel, hairline-separated shared rows. */
.stack-list {
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: var(--radius);
object-fit: contain;
background: var(--bg-2);
padding: 4px;
}
.stack-list .app-row {
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 {
width: 48px;
@@ -337,13 +379,6 @@ h3 {
background: var(--bg-2);
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 {
font-size: .8rem;
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); }
.content { margin-left: 0; padding: 1rem; }
body { flex-direction: column; }
.stack-card { flex-direction: column; align-items: flex-start; gap: .75rem; }
.stack-actions { width: 100%; justify-content: flex-end; }
.app-row { flex-wrap: wrap; }
.app-row-actions { width: 100%; justify-content: flex-end; }
.stack-grid { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: repeat(3, 1fr); }
.deploy-info { flex-direction: column; }
@@ -2694,7 +2729,8 @@ a.stat-card:hover {
display: flex;
align-items: center;
gap: .75rem;
padding: .65rem 1rem;
padding: .5rem 1rem;
min-height: 44px; /* same compact height as the shared .app-row */
cursor: pointer;
user-select: none;
}
+82
View File
@@ -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))
@@ -1,77 +0,0 @@
# -*- coding: utf-8 -*-
"""Backups IA-split move check (v0.124.0, one-shot) — the split MOVED the v0.123.0 sections,
it did not rewrite them: every moved block from the pre-split backups.html (commit df7ad37)
must appear, whitespace-normalized, on EXACTLY its mapped page. Allowed divergences are encoded
explicitly (the tier-3 anchor retarget; the restore-to-verify form's relocation).
Run from controller/: python scripts/backups_split_move_check.py
"""
import io, os, re, subprocess, sys
BASELINE = "df7ad37"
OLD_PATH = "controller/internal/web/templates/backups.html"
TPL = os.path.join("internal", "web", "templates")
# (name, 1-indexed start, end inclusive, target template file, normalizer applied to the NEW page)
BLOCKS = [
("storage-overview", 32, 77, "backups.html", None),
("whole-guest", 79, 124, "backups.html", None),
("stat-cards", 227, 271, "backups.html", None),
("offbox-pre", 127, 186, "backups_remote.html", None),
("offbox-post", 193, 221, "backups_remote.html", None),
("apps-divider", 224, 225, "backups_apps.html", None),
("schedule", 273, 302, "backups_apps.html", None),
("databases", 304, 376, "backups_apps.html", None),
# the tier-3 action anchors were retargeted cross-page — normalize them back before comparing
("per-app-rows", 378, 541, "backups_apps.html",
lambda s: s.replace('href="/backups/remote#offbox-section"', 'href="#offbox-section"')),
("restore-panel", 543, 586, "backups_restore.html", None),
("offbox-verify-form", 188, 191, "backups_restore.html", None),
("banner-js", 591, 627, "backups_shared.html", None),
("apps-js", 628, 674, "backups_apps.html", None),
("guest-js", 676, 712, "backups.html", None),
("restore-js", 714, 841, "backups_restore.html", None),
]
PAGES = ["backups.html", "backups_remote.html", "backups_apps.html", "backups_restore.html",
"backups_shared.html"]
def norm(s):
return re.sub(r"\s+", " ", s).strip()
def main():
old = subprocess.run(["git", "show", BASELINE + ":" + OLD_PATH],
capture_output=True, text=True, encoding="utf-8")
if old.returncode != 0:
print("cannot read baseline %s:%s%s" % (BASELINE, OLD_PATH, old.stderr.strip()))
sys.exit(2)
old_lines = old.stdout.split("\n")
pages = {}
for fn in PAGES:
pages[fn] = io.open(os.path.join(TPL, fn), encoding="utf-8").read()
failed = 0
for name, a, b, target, fix in BLOCKS:
block = norm("\n".join(old_lines[a - 1:b]))
hits = []
for fn in PAGES:
content = pages[fn]
if fix:
content = fix(content)
if block in norm(content):
hits.append(fn)
if hits != [target]:
failed += 1
print("MOVED-BLOCK MISMATCH %-18s (old L%d-%d): expected [%s], found %s"
% (name, a, b, target, hits))
if failed:
print("MOVE CHECK FAILED: %d block(s) rewritten, duplicated or lost" % failed)
sys.exit(1)
print("move check OK — all %d v0.123.0 blocks moved verbatim to their mapped page" % len(BLOCKS))
if __name__ == "__main__":
main()
+73
View File
@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""v0.126.0 mojibake gate — double-encoded UTF-8 can never enter the source again.
A bash-era in-place sweep once mangled Hungarian text by re-encoding already-encoded
UTF-8 (the "Tárhely"-class defect: á á). Legitimate Hungarian UTF-8 NEVER decodes
to the signature characters below, so any hit is a defect. The allowlist is ZERO by
design fix the text, don't allowlist it. (Windows grep false-negatives multibyte —
this gate is Python by the multibyte rule, like emoji_gate.)
Scans every template (web + setup) and every Go source file:
- the file must decode as strict UTF-8 (undecodable bytes = broken already), and
- the decoded text must contain none of the double-encoding signature characters:
à (U+00C3)  (U+00C2) Ă (U+0102) ă (U+0103) ˘ (U+02D8) ˇ (U+02C7)
Run from controller/: python scripts/mojibake_gate.py
Exit 1 on any hit.
"""
import io, os, sys
ROOTS = [
os.path.join("internal", "web", "templates"),
os.path.join("internal", "setup", "templates"),
"internal", # every .go string literal / comment
"cmd",
]
SIGNATURE = {
"Ã": "Ã (a-tilde — á/é/í/ó/ö/ő/ú/ü/ű double-encoded)",
"Â": "Â (a-circumflex — NBSP/degree-sign double-encoded)",
"Ă": "Ă (a-breve — cp1250 round-trip)",
"ă": "ă (a-breve lowercase — cp1250 round-trip)",
"˘": "˘ (breve — cp1250 round-trip)",
"ˇ": "ˇ (caron — cp1250 round-trip)",
}
EXTS = (".html", ".css", ".js", ".go")
def files():
seen = set()
for root in ROOTS:
if not os.path.isdir(root):
continue
for dirpath, dirnames, names in os.walk(root):
for n in names:
p = os.path.join(dirpath, n)
if p in seen or not n.endswith(EXTS):
continue
seen.add(p)
yield p
failures = []
count = 0
for path in files():
count += 1
raw = io.open(path, "rb").read()
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as e:
failures.append("%s: not valid UTF-8 (%s)" % (path, e))
continue
for ln, line in enumerate(text.split("\n"), 1):
for ch, why in SIGNATURE.items():
if ch in line:
failures.append("%s:%d: mojibake signature %s in %r" % (path, ln, why, line.strip()[:120]))
if failures:
print("mojibake_gate: FAIL (allowlist is zero — fix the text)")
for f in failures:
print(" - " + f)
sys.exit(1)
print("mojibake_gate: OK (%d files clean — no double-encoding signatures)" % count)