diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index fa2b34b..33ad86d 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -114,6 +114,9 @@ "app_import.tarolo": "Storage", "app_import.titkos": "Encrypted", "app_import.visszaallitas_inditasa": "Start restore", + "app_page.address_and_keys": "Address and keys", + "app_page.address": "Address", + "app_page.installed_readonly": "These were created at install time and cannot be changed.", "app_page.after_start_btn": "Save", "app_page.after_start_title": "One more step", "app_page.faq": "Frequently asked questions", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 347edd9..2f5d4c0 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -110,6 +110,9 @@ "app_import.tarolo": "Tároló", "app_import.titkos": "Titkos", "app_import.visszaallitas_inditasa": "Visszaállítás indítása", + "app_page.address_and_keys": "Cím és kulcsok", + "app_page.address": "Cím", + "app_page.installed_readonly": "Ezek telepítéskor jöttek létre, és nem módosíthatók.", "app_page.after_start_btn": "Mentés", "app_page.after_start_title": "Még egy lépés", "app_page.faq": "Gyakori kérdések", diff --git a/controller/internal/web/app_layout_test.go b/controller/internal/web/app_layout_test.go new file mode 100644 index 0000000..479823f --- /dev/null +++ b/controller/internal/web/app_layout_test.go @@ -0,0 +1,162 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// THE ONE-COLUMN LAYOUT (v0.316.0, the operator-approved mockup of 2026-10-15). +// +// These assert STRUCTURE, not strings: the rules the mockup is made of are "one column", "sections +// are hairlines, not cards" and "a box means something". Each is checkable in the rendered HTML, +// and each had a counter-example in v0.315.0 — which looked, in the operator's words, messy. + +// TestAppPage_OneColumnLayout — per tab: one column, exactly one current tab, the auto-update +// SUCCESS banner gone and its date in the meta line instead, and no section drawn as a card. +// +// RED-PROOF (REPORT.md): put the success banner back in app_shell.html -> the banner assertion +// fails; drop .app-page from a tab -> the column assertion fails. Both reverted. +func TestAppPage_OneColumnLayout(t *testing.T) { + s := appPageHarness(t) + for _, tab := range []string{"", "/settings", "/data", "/guide", "/logs"} { + body := appGet(t, s, "/apps/adventurelog"+tab).Body.String() + where := "/apps/adventurelog" + tab + + // ONE COLUMN: the content sits in exactly one .app-page wrapper. + if n := strings.Count(body, `class="app-page`); n != 1 { + t.Errorf("%s: %d .app-page wrappers, want exactly 1 — the column is what makes the "+ + "widths agree", where, n) + } + + // EXACTLY ONE current tab. Two would be a lie about where the household is; none would + // leave the bar looking like a row of links, which is what v0.315.0 shipped. + if n := strings.Count(body, `aria-current="page"`); n != 1 { + t.Errorf("%s: %d aria-current=\"page\", want exactly 1", where, n) + } + + // THE SUCCESS BANNER IS NEVER A BANNER. (Whether this app HAS one is the sub-test below; + // here the rule is that no tab ever renders it as an alert.) + if strings.Contains(body, `class="alert alert-info" style="margin-top:1rem" data-auto-update="done"`) { + t.Errorf("%s: the auto-update SUCCESS banner is back", where) + } + + // NO SECTION IS A CARD. .app-info-card carries a background, a border and a radius; inside + // the app page nothing may use it — a section is a hairline. + if strings.Contains(body, "app-info-card") { + t.Errorf("%s: a section is still drawn as a card (app-info-card)", where) + } + + // The tab bar and the hero are on every tab, and the hero is not a box. + for _, marker := range []string{`class="app-back"`, `class="app-hero"`, `class="app-tabs"`} { + if !strings.Contains(body, marker) { + t.Errorf("%s: missing %s", where, marker) + } + } + if strings.Contains(body, `class="app-info-hero"`) { + t.Errorf("%s: the hero is still the boxed one", where) + } + } +} + +// TestAppPage_AutoUpdateSuccessIsMetaNotBanner — the other half of the rule, on a fixture that +// actually has an automatic update to report. v0.315.0 gave it a full-width blue banner above the +// tabs, level with "the update failed" and "your files are missing"; a success that asked nothing +// of the household is a date. +// +// RED-PROOF (REPORT.md): restore the `{{else if .AutoUpdateLine}}` banner branch in +// app_shell.html -> this fails on the banner assertion. Reverted. +func TestAppPage_AutoUpdateSuccessIsMetaNotBanner(t *testing.T) { + st := i18nStack("privatebin", "PrivateBin", stacks.StateRunning, true) + body := renderBackupPage(t, "app_settings", map[string]interface{}{ + "Page": "stacks", "Title": "PrivateBin", "Domain": "example.hu", "CSRFToken": "tok", + "Stack": st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo, + "Deployed": true, "AppOperational": true, + "AutoUpdateLine": "A frissites 2026. 09. 28-kor sikerult.", + // An installed app has a tab bar, and the point of this test is WHERE the line sits + // relative to it. + "ActiveTab": appTabSettings, + "AppTabs": []AppTabLink{ + {Key: appTabSettings, Href: "/apps/privatebin/settings", Label: "Beallitasok", Active: true}, + {Key: appTabData, Href: "/apps/privatebin/data", Label: "Adatok"}, + }, + }) + if !strings.Contains(body, `class="app-meta" data-auto-update="done"`) { + t.Error("the auto-update success line is not the meta line") + } + if strings.Contains(body, `class="alert alert-info" style="margin-top:1rem" data-auto-update="done"`) { + t.Error("the auto-update success is still a banner") + } + // It sits in the hero, above the tabs — not in the banner strip under it. + meta, tabs := strings.Index(body, `data-auto-update="done"`), strings.Index(body, `class="app-tabs"`) + if meta < 0 || tabs < 0 || meta > tabs { + t.Errorf("the meta line must be in the hero, above the tab bar (meta=%d tabs=%d)", meta, tabs) + } +} + +// TestAppPage_CalloutsOnlyForAskAndWarn (Scenario E) — the two boxes, and nothing else. +// +// RED-PROOF (REPORT.md): draw an informational section as a callout -> the count assertions fail. +func TestAppPage_CalloutsOnlyForAskAndWarn(t *testing.T) { + // An app WAITING for the household: exactly one blue callout, and it is the after-start card. + t.Run("waiting for a value -> one blue callout", func(t *testing.T) { + s := afterStartHarness(t, "plex", "PLEX_CLAIM", true) + body := appGet(t, s, "/apps/plex").Body.String() + if n := strings.Count(body, "app-callout-ask"); n != 1 { + t.Errorf("%d blue callouts, want exactly 1 (the after-start card)", n) + } + if n := strings.Count(body, "app-callout-warn"); n != 0 { + t.Errorf("%d amber callouts on an app with nothing wrong, want 0 — amber is for a "+ + "deviation, and being asked for a code is not one", n) + } + // It is the FIRST section: the app is waiting, so nothing else goes above it. + ask := strings.Index(body, "app-callout-ask") + run := strings.Index(body, `data-run-card="1"`) + if ask < 0 || run < 0 || ask > run { + t.Errorf("the after-start callout must come before the Mukodes card (ask=%d run=%d)", ask, run) + } + }) + + // An app with nothing to ask: no blue callout anywhere. + t.Run("nothing to ask -> no blue callout", func(t *testing.T) { + s := appPageHarness(t) + for _, tab := range []string{"", "/settings", "/data", "/guide", "/logs"} { + body := appGet(t, s, "/apps/adventurelog"+tab).Body.String() + if n := strings.Count(body, "app-callout-ask"); n != 0 { + t.Errorf("/apps/adventurelog%s: %d blue callouts, want 0", tab, n) + } + } + }) + + // The amber callout is reserved for the risky fact — the app's data sharing a disk with its + // backup. Rendered straight, because the harness app is not an HDD app. + t.Run("backup on the same disk -> one amber callout", func(t *testing.T) { + st := i18nStack("privatebin", "PrivateBin", stacks.StateRunning, true) + d := map[string]interface{}{ + "Page": "stacks", "Title": "PrivateBin", "Domain": "example.hu", "CSRFToken": "tok", + "Stack": st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo, + "Deployed": true, "AppOperational": true, + "StackName": "privatebin", "DisplayName": "PrivateBin", + "Tier2": map[string]interface{}{"IsHDDApp": false}, + "SystemBackup": "same_disk", + } + body := renderBackupPage(t, "app_data", d) + if n := strings.Count(body, "app-callout-warn"); n != 1 { + t.Errorf("%d amber callouts, want exactly 1 (the same-disk warning)", n) + } + if n := strings.Count(body, "app-callout-ask"); n != 0 { + t.Errorf("%d blue callouts on the data tab, want 0", n) + } + if !strings.Contains(body, `data-system-backup="same_disk"`) { + t.Error("the same-disk marker is gone") + } + // The honest branch when the whole-system backup IS on its own drive is NOT a warning. + d["SystemBackup"] = "protected" + ok := renderBackupPage(t, "app_data", d) + if n := strings.Count(ok, "app-callout-warn"); n != 0 { + t.Errorf("a protected whole-system backup drew %d amber callouts, want 0 — amber is a "+ + "deviation, and this is the good case", n) + } + }) +} diff --git a/controller/internal/web/app_page_test.go b/controller/internal/web/app_page_test.go index 991164d..545a4e8 100644 --- a/controller/internal/web/app_page_test.go +++ b/controller/internal/web/app_page_test.go @@ -149,15 +149,15 @@ func TestAppPage_DeployedDefaultsToSettings(t *testing.T) { // The header does not. This is the whole point of the Mukodes card: the three buttons used to // sit in the page header and read as menu items. - // The header is everything before the hero block; app-info-hero is the design system's existing - // hero class, which the shell reuses rather than inventing one with no rules behind it. - hi, hj := strings.Index(body, `class="page-header"`), strings.Index(body, `class="app-info-hero"`) + // v0.316.0: the header IS the hero — a back link, the name, the chips and (at most) „Megnyitas". + // Everything above the tab bar is therefore what must carry no state-change button. + hi, hj := strings.Index(body, `class="app-back"`), strings.Index(body, `class="app-tabs"`) if hi < 0 || hj < 0 || hj < hi { - t.Fatalf("the shell's header/hero markers are gone (page-header=%d app-info-hero=%d)", hi, hj) + t.Fatalf("the shell's hero/tab markers are gone (app-back=%d app-tabs=%d)", hi, hj) } header := body[hi:hj] if strings.Contains(header, "stackAction(") { - t.Errorf("the page header must carry no state-change button, got: %q", header) + t.Errorf("the hero must carry no state-change button — they belong in the Mukodes card, got: %q", header) } // Part 3: Raspberry Pi is no longer supported, so neither badge may appear. diff --git a/controller/internal/web/templates/app_data.html b/controller/internal/web/templates/app_data.html index 6d461c2..ae416dd 100644 --- a/controller/internal/web/templates/app_data.html +++ b/controller/internal/web/templates/app_data.html @@ -1,5 +1,6 @@ {{define "app_data"}} {{template "layout_start" .}} +
{{T "app_info.ezeket_a_mappakat_a_fajlkezeloben"}}
{{T "app_info.ennek_az_alkalmazasnak_az_adatait"}}
{{T "tier2_config.a_2_mentes_egy_masik"}} @@ -106,19 +109,19 @@ function appMigrate(btn,app,label){ {{if not .IsHDDApp}} {{if eq $.SystemBackup "protected"}} -