v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
gates / gates (push) Failing after 47s

An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.

  settings  Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
            household + the install-time and optional settings. The default for an installed app,
            RENDERED at the bare URL and never redirected to.
  data      the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
  guide     tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
            link — and the WHOLE page for an app that is not installed
  logs      the log, and the only tab whose poller runs

The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.

The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.

A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.

New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.

The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).

template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
This commit is contained in:
2026-10-15 18:37:50 +02:00
parent 85f65c70d8
commit e66d4669d6
120 changed files with 16239 additions and 9866 deletions
+122 -22
View File
@@ -251,7 +251,7 @@ func i18nCases() []i18nCase {
d["DBSectionState"] = "dumps"
return d
}},
{"app_info_installable", "app_info", func() map[string]interface{} {
{"app_guide_installable", "app_guide", func() map[string]interface{} {
d := i18nLayoutData("stacks", "PrivateBin")
st := stacks.Stack{Name: "privatebin", Deployed: false, State: stacks.StateNotDeployed}
st.Meta = stacks.Metadata{DisplayName: "PrivateBin", Slug: "privatebin", Category: "Hasznos eszközök",
@@ -261,7 +261,7 @@ func i18nCases() []i18nCase {
d["AppInfo"] = st.Meta.AppInfo
return d
}},
{"app_info_not_checked", "app_info", func() map[string]interface{} {
{"app_settings_not_checked", "app_settings", func() map[string]interface{} {
// R-772: the declared health check could not run — the page says so.
d := i18nLayoutData("stacks", "Karakeep")
st := stacks.Stack{Name: "karakeep", Deployed: true, State: stacks.StateDegraded,
@@ -273,7 +273,7 @@ func i18nCases() []i18nCase {
d["AppInfo"] = st.Meta.AppInfo
return d
}},
{"app_info_deployed", "app_info", func() map[string]interface{} {
{"app_settings_deployed", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "PrivateBin")
st := stacks.Stack{Name: "privatebin", Deployed: true, State: stacks.StateRunning, HoldReason: "A frissítés megállt: nincs friss mentés."}
st.Meta = stacks.Metadata{DisplayName: "PrivateBin", Slug: "privatebin", Category: "Hasznos eszközök", Description: "Titkosított jegyzetek.",
@@ -292,7 +292,7 @@ func i18nCases() []i18nCase {
d["InitialCredsHasPassword"] = true
return d
}},
{"app_info_available", "app_info", func() map[string]interface{} {
{"app_guide_available", "app_guide", func() map[string]interface{} {
d := i18nLayoutData("stacks", "PrivateBin")
st := stacks.Stack{Name: "privatebin", Deployed: false, State: stacks.StateNotDeployed}
st.Meta = stacks.Metadata{DisplayName: "PrivateBin", Slug: "privatebin", Category: "Hasznos eszközök", Description: "Titkosított jegyzetek.",
@@ -303,7 +303,7 @@ func i18nCases() []i18nCase {
d["HasAppInfo"] = false
return d
}},
{"app_info_operator_creds", "app_info", func() map[string]interface{} {
{"app_settings_operator_creds", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Fájlkezelő app")
st := stacks.Stack{Name: "filebrowser", Deployed: true, State: stacks.StateStopped, Orphaned: true, Updating: false, UpdateError: "A frissítés sikertelen."}
st.Meta = stacks.Metadata{DisplayName: "Fájlkezelő app", Slug: "filebrowser"}
@@ -315,7 +315,7 @@ func i18nCases() []i18nCase {
d["InitialCredsOperatorSet"] = true
return d
}},
{"app_info_updating", "app_info", func() map[string]interface{} {
{"app_settings_updating", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "PrivateBin")
st := stacks.Stack{Name: "privatebin", Deployed: true, State: stacks.StateDeploying, Updating: true, UpdatePhase: "pull", UpdatePhaseLabel: "Letöltés folyamatban…"}
st.Meta = stacks.Metadata{DisplayName: "PrivateBin", Slug: "privatebin"}
@@ -358,7 +358,7 @@ func i18nCases() []i18nCase {
// v0.280.0 (`09` §3 decision 46, R-710): the setup gate's card (button / probe) and "I changed it" — captured
// when born.
gateCase := func(name string, probe bool) i18nCase {
return i18nCase{name, "app_info", func() map[string]interface{} {
return i18nCase{name, "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Immich")
st := stacks.Stack{Name: "immich", Deployed: true, State: stacks.StateRunning}
st.Meta = stacks.Metadata{DisplayName: "Immich", Slug: "immich"}
@@ -370,9 +370,9 @@ func i18nCases() []i18nCase {
return d
}}
}
base = append(base, gateCase("app_info_setup_gate_button", false), gateCase("app_info_setup_gate_probe", true))
base = append(base, gateCase("app_settings_setup_gate_button", false), gateCase("app_settings_setup_gate_probe", true))
// v0.313.0 (R-1028): the Plex claim box inside a closed Plex gate — captured when born.
base = append(base, i18nCase{"app_info_plex_claim", "app_info", func() map[string]interface{} {
base = append(base, i18nCase{"app_settings_after_start", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Plex")
st := stacks.Stack{Name: "plex", Deployed: true, State: stacks.StateRunning}
st.Meta = stacks.Metadata{DisplayName: "Plex", Slug: "plex"}
@@ -381,11 +381,15 @@ func i18nCases() []i18nCase {
d["AppInfo"] = st.Meta.AppInfo
d["SetupGateClosed"] = true
d["SetupGateHasProbe"] = true
d["PlexClaim"] = true
// v0.315.0: the claim box is the GENERIC after-start card. What makes it render is the field
// being in the page data at all — afterStartFields puts it there only when the app has a
// registered Go action for it and that action says the value is still wanted.
d["AfterStartFields"] = []stacks.DeployField{{EnvVar: "PLEX_CLAIM", Label: "Plex igénylő kód",
Description: "Illeszd be a plex.tv-n kapott kódot.", Placeholder: "claim-xxxxxxxxxxxxxxxxxxxx", AfterStart: true}}
return d
}})
// R-741: the install hold's card, while an after_install app waits for its first-login change.
base = append(base, i18nCase{"app_info_install_hold", "app_info", func() map[string]interface{} {
base = append(base, i18nCase{"app_settings_install_hold", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Calibre")
st := stacks.Stack{Name: "calibre-web", Deployed: true, State: stacks.StateRunning}
st.Meta = stacks.Metadata{DisplayName: "Calibre", Slug: "calibre-web"}
@@ -397,7 +401,7 @@ func i18nCases() []i18nCase {
}})
// v0.281.0 (decision 47): the sign-up card, closed (with the app's how-to) and open for the household's window.
signupCase := func(name string, closed bool, until string) i18nCase {
return i18nCase{name, "app_info", func() map[string]interface{} {
return i18nCase{name, "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Opengist")
st := stacks.Stack{Name: "opengist", Deployed: true, State: stacks.StateRunning}
st.Meta = stacks.Metadata{DisplayName: "Opengist", Slug: "opengist", AppInfo: stacks.AppInfo{AddPeople: "Admin panel → Users."}}
@@ -411,34 +415,34 @@ func i18nCases() []i18nCase {
return d
}}
}
base = append(base, signupCase("app_info_signup_closed", true, ""), signupCase("app_info_signup_window", false, "14:05"))
base = append(base, signupCase("app_settings_signup_closed", true, ""), signupCase("app_settings_signup_window", false, "14:05"))
// v0.282.0 (decision 49): "close sign-up now"; the app's own switch (restart line, failed line).
base = append(base, i18nCase{"app_info_close_signup", "app_info", func() map[string]interface{} {
base = append(base, i18nCase{"app_settings_close_signup", "app_settings", func() map[string]interface{} {
d := signupCase("x", false, "").data()
d["CloseSignupOffered"] = true
return d
}}, i18nCase{"app_info_signup_native", "app_info", func() map[string]interface{} {
}}, i18nCase{"app_settings_signup_native", "app_settings", func() map[string]interface{} {
d := signupCase("x", true, "").data()
d["SignupNative"], d["SignupNativeFailed"] = true, true
return d
}}, i18nCase{"app_info_signup_native_open_failed", "app_info", func() map[string]interface{} {
}}, i18nCase{"app_settings_signup_native_open_failed", "app_settings", func() map[string]interface{} {
d := signupCase("x", true, "").data()
d["SignupNativeOpenFailed"] = true // R-717: the window could not open the app's own switch
return d
}})
// R-1007: the lost-files note after an unclean boot (name, date taken, the boot time, the dismiss button).
base = append(base, i18nCase{"app_info_lost_files", "app_info", func() map[string]interface{} {
base = append(base, i18nCase{"app_settings_lost_files", "app_settings", func() map[string]interface{} {
d := signupCase("x", false, "").data()
d["LostFiles"] = []stacks.LostFile{{Name: "IMG_0001.jpg", Taken: "2026-10-14 10:41"}}
d["LostBootAt"] = "2026-10-14 14:07"
return d
}})
// R-718: the close card and the gate card say the app restarts once where the app has its own switch.
base = append(base, i18nCase{"app_info_close_signup_native", "app_info", func() map[string]interface{} {
base = append(base, i18nCase{"app_settings_close_signup_native", "app_settings", func() map[string]interface{} {
d := signupCase("x", false, "").data()
d["CloseSignupOffered"], d["SignupNative"] = true, true
return d
}}, i18nCase{"app_info_setup_gate_native", "app_info", func() map[string]interface{} {
}}, i18nCase{"app_settings_setup_gate_native", "app_settings", func() map[string]interface{} {
d := gateCase("x", false).data()
d["SignupNative"] = true
return d
@@ -471,7 +475,7 @@ func i18nCases() []i18nCase {
}
return d
}})
base = append(base, i18nCase{"app_info_known_login_changed", "app_info", func() map[string]interface{} {
base = append(base, i18nCase{"app_settings_known_login_changed", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Calibre-Web")
st := stacks.Stack{Name: "calibre-web", Deployed: true, State: stacks.StateRunning}
st.Meta = stacks.Metadata{DisplayName: "Calibre-Web", Slug: "calibre-web", AppInfo: stacks.AppInfo{DefaultCreds: "admin / admin123"}}
@@ -483,7 +487,7 @@ func i18nCases() []i18nCase {
return d
}})
// v0.268.0 (`09` §3 decision 14): the ladder's "steps remaining" line, captured when it was born.
base = append(base, i18nCase{"app_info_ladder_steps", "app_info", func() map[string]interface{} {
base = append(base, i18nCase{"app_settings_ladder_steps", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "RomM")
st := stacks.Stack{Name: "romm", Deployed: true, State: stacks.StateRunning, LadderStepsLeft: 2}
st.Meta = stacks.Metadata{DisplayName: "RomM", Slug: "romm"}
@@ -493,7 +497,7 @@ func i18nCases() []i18nCase {
return d
}})
// R-621: the hold panel's "the app's own log was kept" line and its Napló link, captured when born.
base = append(base, i18nCase{"app_info_hold_log", "app_info", func() map[string]interface{} {
base = append(base, i18nCase{"app_settings_hold_log", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "AdventureLog")
st := stacks.Stack{Name: "adventurelog", Deployed: true, State: stacks.StateStopped,
HoldReason: "Az AdventureLog frissítése nem sikerült, az alkalmazás leállítva marad."}
@@ -504,13 +508,106 @@ func i18nCases() []i18nCase {
d["HoldLogKept"] = true
return d
}})
// v0.315.0 — THE APP PAGE'S OWN TABS. Three cases, each for a tab whose content no earlier case
// covered: the data tab's folder cards and move offer, the guide tab with a full guide, and the
// guide tab's „no guide yet" line. Captured when born.
base = append(base, i18nCase{"app_data_deployed", "app_data", func() map[string]interface{} {
d := i18nLayoutData("stacks", "PrivateBin")
st := i18nStack("privatebin", "PrivateBin", stacks.StateRunning, true)
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
d["DisplayName"], d["StackName"] = "PrivateBin", "privatebin"
d["DataPathCards"] = []DataPathCard{{Label: "Feltöltött fájlok", Link: "/files/x",
Consequence: "Mentve minden éjjel.", IsImport: true, FreeSpace: "12 GB szabad"}}
d["MigrateTargets"] = []map[string]interface{}{{"Path": "/mnt/hdd2", "Label": "HDD kettő"}}
d["MigrateCurrent"] = "/mnt/hdd1"
d["Tier2"] = map[string]interface{}{"IsHDDApp": true, "Disabled": false, "NoTarget": false,
"EffectiveLabel": "HDD kettő", "EffectiveIsSSD": false, "Preferred": "", "EffectiveDesc": "a legnagyobb szabad hely"}
d["ExportOffered"] = true
d["Drives"] = []map[string]interface{}{{"Path": "/mnt/hdd1", "Label": "HDD egy"}}
return d
}}, i18nCase{"app_guide_full", "app_guide", func() map[string]interface{} {
d := i18nLayoutData("stacks", "PrivateBin")
st := i18nStack("privatebin", "PrivateBin", stacks.StateRunning, true)
st.Meta.AppInfo = stacks.AppInfo{
Tagline: "Titkosított szövegmegosztás.",
UseCases: []string{"Jelszó átadása"},
FirstSteps: []string{"Nyisd meg a címet"},
Prerequisites: []string{"Semmi különös"},
DocsURL: "https://privatebin.info",
Guide: stacks.AppGuide{
CheckedVersion: "2.0.6",
Sections: []stacks.GuideSection{
{Title: "Első bekapcsolás", Intro: "Nyisd meg a böngészőben.", Steps: []string{"Kattints a Fiók menüre", "Add meg a nevedet"}},
{Title: "Megosztás másokkal", Steps: []string{"Válaszd ki a mappát"}},
},
FAQ: []stacks.GuideFAQ{
{Q: "Hol vannak a fájlok?", A: "A megadott mappában."},
{Q: "Lehet több felhasználó?", A: "Igen, meghívóval."},
},
},
}
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
d["HasAppInfo"] = true
return d
}}, i18nCase{"app_guide_no_guide", "app_guide", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Grocy")
st := i18nStack("grocy", "Grocy", stacks.StateNotDeployed, false)
st.Meta.AppInfo = stacks.AppInfo{Tagline: "Házi készletnyilvántartó."}
d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
return d
}})
return base
}
// i18nAppTabTemplates maps each of the app page's four tab templates to the tab it renders. A case
// on one of them gets the four shell keys appPageHandler sets, derived here from the SAME fields the
// handler derives them from — so a fixture shows the tab bar the household actually sees, in the
// language being rendered, instead of a page with no tabs that nobody is served.
var i18nAppTabTemplates = map[string]string{
"app_settings": appTabSettings,
"app_data": appTabData,
"app_guide": appTabGuide,
"app_logs": appTabLogs,
}
// fillI18nAppShell is appPageHandler's shell block, for the harness. A case may set any of these
// four itself (a not-deployed app on the guide tab does); what it sets is left alone.
func fillI18nAppShell(s *Server, lang, tmpl string, d map[string]interface{}) {
tab, ok := i18nAppTabTemplates[tmpl]
if !ok {
return
}
deployed, slug := false, ""
var state stacks.ContainerState
switch st := d["Stack"].(type) {
case stacks.Stack:
deployed, state, slug = st.Deployed, st.State, st.Meta.Slug
case *stacks.Stack:
if st != nil {
deployed, state, slug = st.Deployed, st.State, st.Meta.Slug
}
}
if v, set := d["Deployed"]; set {
deployed, _ = v.(bool)
} else {
d["Deployed"] = deployed
}
if _, set := d["AppOperational"]; !set {
d["AppOperational"] = deployed && isOperationalState(state)
}
if _, set := d["ActiveTab"]; !set {
d["ActiveTab"] = tab
}
if _, set := d["AppTabs"]; !set {
d["AppTabs"] = s.appTabLinksLang(lang, slug, deployed, tab)
}
}
func renderI18nCase(t *testing.T, s *Server, lang string, c i18nCase) string {
t.Helper()
var buf bytes.Buffer
data := c.data()
fillI18nAppShell(s, lang, c.tmpl, data)
r := httptest.NewRequest("GET", "/i18n-fixture", nil)
// TWO production paths, and the fixture has to be rendered through the RIGHT one or it is a
// picture of something no visitor sees (v0.254.0, R-557 release C).
@@ -833,6 +930,9 @@ func TestI18nParityCoversEveryMarker(t *testing.T) {
var all strings.Builder
for _, c := range i18nCases() {
data := c.data()
// The app page's shell keys, exactly as renderI18nCase fills them: without them the tab bar
// and the Megnyitas link are absent from every render and their markers read as dead.
fillI18nAppShell(s, i18n.Default, c.tmpl, data)
s.addLanguageData(data, httptest.NewRequest("GET", "/i18n-fixture", nil), i18n.Default)
if err := probe.ExecuteTemplate(&all, c.tmpl, data); err != nil {
t.Fatalf("%s: probe render: %v", c.name, err)