Compare commits
7 Commits
e0618af438
...
912e6a62b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 912e6a62b6 | |||
| 02c84e1ce2 | |||
| 458cf4a4c2 | |||
| 67494f9890 | |||
| 4f06149abc | |||
| 3eacb6c326 | |||
| 7b3e7f2e71 |
@@ -1,5 +1,50 @@
|
|||||||
## Changelog
|
## 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
|
### 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
|
Both .fab volume legs streamed via `docker run -v <controller-temp-path>` host mounts — correct
|
||||||
|
|||||||
+20
-1
@@ -7,7 +7,26 @@
|
|||||||
>
|
>
|
||||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
> 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).
|
> **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
|
> MinAgent 0.81 unchanged; floor may advance to 0.125.0 next train (must NOT halt above 0.124.0
|
||||||
|
|||||||
@@ -207,10 +207,11 @@ Cross-repo edges:
|
|||||||
- **New REST endpoint**: path dispatch in `Router.ServeHTTP` (controller/internal/api/router.go); use `writeJSON` + `limitBody`.
|
- **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 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 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).
|
- **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).
|
- **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.
|
- **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 hub event**: typed `Notify*` wrapper on Notifier + hub allowlist entry (cross-repo).
|
||||||
- **New app integration**: `integrations.Manager.RegisterHandler` with `IntegrationKey(provider, target)`.
|
- **New app integration**: `integrations.Manager.RegisterHandler` with `IntegrationKey(provider, target)`.
|
||||||
|
|||||||
+15
-4
@@ -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";
|
(`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
|
form buttons opt in via `data-confirm="…"`) — never native `confirm()`/`prompt()` (OS-modals
|
||||||
freeze browser automation; drill F-11).
|
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`
|
**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
|
(no emoji) + `scripts/native_confirm_gate.py` (zero native confirm/prompt) +
|
||||||
template change; the Go `TestNoEmojiInTemplates` mirrors the emoji gate.
|
`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.
|
- **Europe/Budapest timezone** — All scheduled jobs, timestamps, and UI labels use Hungarian timezone.
|
||||||
|
|
||||||
### Module Map
|
### Module Map
|
||||||
@@ -361,13 +371,14 @@ self-sufficient backup** — any single tier can fully restore an app.
|
|||||||
| Route | Page | Sections |
|
| Route | Page | Sections |
|
||||||
|-------|------|----------|
|
|-------|------|----------|
|
||||||
| `/backups` | Áttekintés | storage overview, whole-guest Rendszermentés, status stat cards, single-copy warning |
|
| `/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/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 |
|
| `/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`
|
Shared data builders: `backupsCommonData` (chrome + full-status + flash) + `backupsOffboxData`
|
||||||
(offbox target/toggles) in `handlers.go`; shared partials in `templates/backups_shared.html`.
|
(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? |
|
| Tier | Contents | Location | Can fully restore? |
|
||||||
|------|----------|----------|--------------------|
|
|------|----------|----------|--------------------|
|
||||||
|
|||||||
@@ -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,33 @@ 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)
|
||||||
|
},
|
||||||
|
// 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.
|
// 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 {
|
||||||
|
|||||||
@@ -653,10 +653,37 @@ func (s *Server) backupsOffboxData(data map[string]interface{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
data["OffboxToggledCount"] = offboxToggled
|
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).
|
// SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model).
|
||||||
data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt)
|
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
|
// backupsHandler renders the Áttekintés page: storage overview, whole-guest Rendszermentés and
|
||||||
// the status stat cards.
|
// the status stat cards.
|
||||||
func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
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 {
|
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 +775,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 +784,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 +898,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,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>`
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -398,6 +398,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.serveLogoHandler(w, r)
|
s.serveLogoHandler(w, r)
|
||||||
case path == "/static/favicon.svg":
|
case path == "/static/favicon.svg":
|
||||||
s.serveFaviconHandler(w, r)
|
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/"):
|
case strings.HasPrefix(path, "/static/assets/"):
|
||||||
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
|
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
|
||||||
case strings.HasPrefix(path, "/apps/"):
|
case strings.HasPrefix(path, "/apps/"):
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<div id="passwordPrompt" style="display:none;margin-bottom:1rem">
|
<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>
|
<p>Ez a csomag jelszóval védett. Kérlek add meg a jelszót:</p>
|
||||||
<div style="display:flex;gap:.5rem">
|
<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>
|
<button class="btn btn-primary" onclick="loadManifest()">Megnyitás</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="passwordError" style="display:none;color:var(--crit);margin-top:.5rem"></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}}
|
{{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>
|
||||||
|
|||||||
@@ -50,7 +50,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Offbox.LastError}}<p class="form-hint" style="color:var(--crit)">Utolsó hiba: {{.Offbox.LastError}}</p>{{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")}}
|
{{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}}
|
||||||
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
|
<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>
|
<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>
|
<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">
|
|
||||||
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
|
|
||||||
<div class="storage-path-actions">
|
|
||||||
<form method="POST" action="/backup/offbox/toggle" style="display:inline">{{$.CSRFField}}
|
<form method="POST" action="/backup/offbox/toggle" style="display:inline">{{$.CSRFField}}
|
||||||
<input type="hidden" name="app" value="{{.Name}}">
|
<input type="hidden" name="app" value="{{.Name}}">
|
||||||
<input type="hidden" name="enabled" value="{{if .Enabled}}false{{else}}true{{end}}">
|
<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>
|
<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>
|
</form>
|
||||||
</div>
|
{{template "app_list_row_end"}}
|
||||||
</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">
|
|
||||||
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
|
|
||||||
<div class="storage-path-actions">
|
|
||||||
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
|
<form method="POST" action="/backup/offbox/restore" style="display:inline">{{$.CSRFField}}
|
||||||
<input type="hidden" name="app" value="{{.Name}}">
|
<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>
|
<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>
|
</form>
|
||||||
</div>
|
{{template "app_list_row_end"}}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</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 1–3. szintű mentés adja.</p>
|
<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 1–3. szintű mentés adja.</p>
|
||||||
{{if .OffboxApps}}
|
{{if .OffboxApps}}
|
||||||
<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="Opcionális jelszó">
|
||||||
|
<span class="form-hint">Üresen hagyva a csomag titkosítás nélkül készül.</span>
|
||||||
</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">
|
|
||||||
<div class="storage-path-info"><span class="storage-path-label">{{.DisplayName}}</span></div>
|
|
||||||
<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>
|
<button type="button" class="btn btn-xs btn-outline fab-dl-btn" data-stack="{{.Name}}" onclick="fabDownload(this)">Letöltés (.fab)</button>
|
||||||
</div>
|
{{template "app_list_row_end"}}
|
||||||
</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,12 @@
|
|||||||
|
|
||||||
<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}}>
|
{{$im := infraMeta .Name}}
|
||||||
<div class="stack-info">
|
{{if $im}}
|
||||||
<img class="stack-logo" src="{{logoURL .Meta.Slug}}"
|
{{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"}}
|
||||||
alt="{{.Meta.DisplayName}}" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
{{else}}
|
||||||
<div>
|
{{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)}}
|
||||||
<strong class="stack-name">{{.Meta.DisplayName}}</strong>
|
{{end}}
|
||||||
{{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}}
|
||||||
@@ -165,6 +161,12 @@
|
|||||||
|
|
||||||
{{if .Protected}}
|
{{if .Protected}}
|
||||||
<span class="tag"><svg class="ico ico-sm"><use href="#i-lock"/></svg>Védett</span>
|
<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}}
|
{{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>
|
<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}}
|
{{end}}
|
||||||
@@ -183,8 +185,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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,13 +18,14 @@
|
|||||||
|
|
||||||
<div class="stack-grid">
|
<div class="stack-grid">
|
||||||
{{range .Stacks}}
|
{{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-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-detail-header">
|
||||||
<div class="stack-title-row">
|
<div class="stack-title-row">
|
||||||
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}"
|
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}" alt=""{{if $im}} data-fallback="/static/infra-logo.svg"{{end}}
|
||||||
alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
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>
|
<div>
|
||||||
<h3>{{.Meta.DisplayName}}</h3>
|
<h3>{{if $im}}{{$im.DisplayName}}{{else}}{{.Meta.DisplayName}}{{end}}</h3>
|
||||||
{{$subdomain := index $.Subdomains .Name}}
|
{{$subdomain := index $.Subdomains .Name}}
|
||||||
{{if and $subdomain (or .Deployed .Protected)}}
|
{{if and $subdomain (or .Deployed .Protected)}}
|
||||||
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank">
|
<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}}
|
{{$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>
|
</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>
|
<p class="stack-detail-desc">{{.Meta.Description}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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()
|
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user