diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca69c81..b5dfe42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,47 @@
+## v0.315.0 — one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge (2026-10-15)
+
+**MinAgent: 0.155.0** (unchanged — no new agent call). No golden (`09` §3 decision 250). Design: felhom.eu
+`architecture/02-controller-module-map.md` „The app page", `10-localisation.md` §7 (the guide in the copy model).
+Operator decisions for this release: D1 — Export lives on the data tab; D2 — after-start fields are rendered generically
+from the catalog and their ACTION is Go code; the FAQ is a section of the guide, not a tab; the guide tab replaces the
+details page.
+
+- **One app page, four tabs.** An app was six pages (details, install/settings, logs, export, per-app backup, and a
+ details card for moving data) and every „Vissza" went to the app LIST, so there was no way back to the app. It is now
+ one page with four tab URLs under `/apps/{slug}/`, one shared header and one set of banners:
+ `settings` (the default for an installed app), `data`, `guide`, `logs`, plus `/install` for the wizard. The tabs are
+ LINKS, not panels — an alert's link has to land on a tab, and the log poller must not run on the other three.
+- **Start / Restart / Stop left the page header.** They sat beside the navigation and read as menu buttons; they are the
+ first card („Működés") of the settings tab, where a state change belongs. Stop asks first.
+- **The old URLs still work, reads only.** `/stacks/{name}/{deploy,logs,export,backup}` GET answer **302** (never 301 —
+ a cached 301 cannot be withdrawn) to the tab that now holds them, query string carried through, `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 and their handlers.
+- **A guide the catalog can carry.** `app_info.guide` — `sections` (title, optional intro, steps) and `faq` (q, a) —
+ is parsed, localised and rendered on the guide tab, with the household's own address filled into every string
+ (R-498). It renders ONLY when `checked_version` names the image tag its author clicked through: a guide walking the
+ household through a screen two releases old is worse than none, and the page says one is still to come. Plain text
+ through `html/template`; no markdown, no raw HTML. English overlays field by field, sections and FAQ matched by
+ index (the one list in the copy model that is, because a section's title is the thing being translated); an
+ oversized translation is dropped past the Hungarian count with one WARN. **No catalog template carries a guide yet**
+ — the content is the companion task.
+- **„Még egy lépés" is now generic.** Plex's claim box was markup behind a Plex-only flag. Any catalog field marked
+ `after_start: true` gets the card, driven by a Go registry keyed by slug and env var
+ (`internal/web/after_start.go`). **The catalog declares THAT a value is asked and what to call it; it can never
+ declare what runs** — a declared field with no registered Go action renders nothing, POSTs 404 and logs one WARN with
+ the env var KEY (never the value). `POST /apps/{slug}/after-start/{ENV}`; `/apps/plex/plex-claim` stays and runs the
+ same entry, so a tab open across the update still works.
+- **The Raspberry Pi badge is gone**, and with it the „csak x86" tag every other app wore: Pi is no longer supported, so
+ both were noise. The `PiCompatible` field and the catalog's `pi_compatible:` key stay this release — removing them now
+ would make every app read „csak x86" on a box still on 0.314.0.
+- Tests: seven new groups through the production router (`app_page_test.go`, `guide_render_test.go`,
+ `internal/stacks/guide_test.go`); 11 red-proofs recorded in REPORT.md. One of them found an unpinned invariant: the
+ handler's „the catalog must have DECLARED this field" check could be deleted with the suite staying green, and a POST
+ for an undeclared field then reached Plex and opened the setup gate. `TestAfterStart_RegistryAloneIsNotAnInvitation`
+ pins it.
+- `scripts/template_id_gate.py` now follows `{{template}}` includes, because a page's ids are the RENDERED page's, not
+ one file's. It ships with a scope decoy proving the walk did not become vacuous.
+
## v0.314.0 — a restore puts the setup gate back as it was; a test build moves with the floor; Prowlarr links itself; the box claims Plex again (R-1057, R-1053, R-1058, R-1060) (2026-10-15)
**MinAgent: 0.155.0** (unchanged — no new agent call). No golden: the first release under `09` §3 decision 250 (the floor
diff --git a/CONTEXT.md b/CONTEXT.md
index 8e3606e..c5811df 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -7,7 +7,24 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
-Last updated: 2026-10-15 (v0.314.0 — the gate on restore, test builds and the floor, Prowlarr's link, Plex by address)
+Last updated: 2026-10-15 (v0.315.0 — one app page with four tabs, the catalog guide, no Pi badge)
+
+> **2026-10-15 (evening) — v0.315.0, ONE APP PAGE WITH FOUR TABS.** An app was six pages and every „Vissza" went to the
+> app LIST; it is now `/apps/{slug}/{settings,data,guide,logs}` plus `/apps/{slug}/install`, one shared header and one
+> set of banners (`internal/web/app_page.go`, `templates/app_shell.html`). Operator decisions recorded for this release:
+> **D1** — Export lives on „Adatok és mentés", not under Beállítások. **D2** — an after-start field's CARD is rendered
+> generically from the catalog's `after_start: true`, and its ACTION is Go code in a registry keyed by slug + env var
+> (`internal/web/after_start.go`), so customer text can never reach a catalog-defined command. **The FAQ is a section of
+> the guide, not a tab**, and **the guide tab replaces the details page**. **302, never 301**, for every old
+> `/stacks/{name}/…` GET — a cached 301 cannot be withdrawn; every POST on those paths is untouched.
+> New catalog block `app_info.guide` (sections + faq + `checked_version`), rendered only when `checked_version` names the
+> image tag its author checked it against; no template carries one yet (the content is the companion task). 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.
+> **Found by a red-proof, and worth remembering:** the after-start handler's „the catalog must have DECLARED this field"
+> check could be deleted with the whole suite green — and a POST for an undeclared field then reached Plex and opened the
+> setup gate. Two guards that look like one are two tests.
+> Not yet validated: the operator's own look at the page in a browser, and a real Plex claim through the new card.
> **2026-10-15 (evening) — v0.314.0, the floor since 13:11 UTC (raised by CC's token after the three test boxes; no golden).**
> A restore puts the setup gate back as it was (`internal/stacks/restore_gate.go`, felhom.eu `09` §3 decision 253, R-1057);
diff --git a/REPORT.md b/REPORT.md
index d9dd1c7..c5ca15f 100644
--- a/REPORT.md
+++ b/REPORT.md
@@ -1,15 +1,193 @@
-# REPORT — v0.314.0: the gate on restore, test builds and the floor, Prowlarr's link, Plex by address (2026-10-15)
+# REPORT — v0.315.0: one app page with four tabs (2026-10-15)
-Baseline `e209a8d` (0.313.0). Design: felhom.eu `architecture/09-update-architecture.md` §3 decisions 46–49, 250–253;
-`07-backup-architecture.md`; `15-media-library.md` §6. Evidence: felhom.eu `documentation/audits/gate-restore-2026-10-15/`.
+## 1. Baselines
-- **R-1057:** the per-app backup records the setup gate; a restore puts it back as it was. Ten mutations red-proved.
- Live on 9202: a stranger met the gate after the restore of a pre-setup backup (before: 200).
-- **R-1053:** `X.Y.Z-rc.N` is below `X.Y.Z` for the floor; the button never offers it; the skip is a WARN. Live: Tester 1
- on rc.1 was moved to 0.314.0 by the floor by itself. Six older floor tests now wait for the background update.
-- **R-1058:** Prowlarr's own start runs the wiring. Live on 9202: linked ~40 s after Prowlarr ran, no button.
-- **R-1060:** every Plex call by address. Live on 9201 with a fake code: Plex took it as local (500 from plex.tv, before
- 401). The real claim waits on the operator's code.
-- **Release:** MinAgent 0.155.0. Three test boxes via per-customer floors (token), box walk green, global floor 0.314.0 at
- 13:11 UTC by CC's token, read back from the hub DB with its WAL; per-customer floors cleared. No golden (decision 250).
-- Gates: `controller_gates.py` OK; `go test ./...` green.
+| Repo | `main` at start | Version | → |
+|---|---|---|---|
+| felhom-controller | `85f65c7` | v0.314.0 | **v0.315.0** |
+| felhom.eu | `06e7cf8c` (the task said `3263bb2`; it had moved — two other sessions are pushing) | docs only | — |
+| app-catalog-felhom.eu | `e67aad4` | read only | — |
+
+Architecture read before any claim: `02-controller-module-map.md` (`web/`, the R-571 alert-placement
+rule), `10-localisation.md` §7, `15-media-library.md` (the Plex claim path), and the controller's
+`CLAUDE.md`, `REUSE.md` and `.claude/rules/{ui-hungarian,gates,unprompted-work}.md`.
+
+**Where the work ran.** Edited on the Windows workstation; **built and tested on DooPlex** in an
+isolated scratch clone (`/mnt/5_hdd/felhom.eu/scratch/cc-apptabs`) so the shared DooPlex checkout —
+which two other sessions are using tonight — was never dirtied. The sync normalises line endings to
+LF and restores any file it did not mean to touch, because this checkout has `core.autocrlf=true`
+while six files (`.gitignore`, `Dockerfile`, `Makefile`, the three infra templates) are stored in git
+WITH CRLF; a blanket normalisation rewrote all six on the first attempt.
+
+## 2. Files created / modified
+
+**Created (11)**
+`controller/internal/web/app_page.go` · `after_start.go` · `app_page_test.go` · `guide_render_test.go` ·
+`controller/internal/stacks/guide_test.go` ·
+`templates/app_shell.html` · `app_settings.html` · `app_data.html` · `app_guide.html` · `app_logs.html` · `app_fields.html`
+
+**Deleted (4 templates)** `templates/app_info.html` · `logs.html` · `app_export.html` · `tier2_config.html`
+— with the three page handlers that rendered them (`logsHandler`, `exportPageHandler`,
+`tier2ConfigPageHandler`). The POST handlers beside them (`tier2ConfigSaveHandler`,
+`appEmailToggleHandler`) stay, with their URLs.
+
+**Modified (main ones)** `web/server.go` (routes + the GET-only redirects) · `web/handlers.go`
+(`appDetailHandler` → `appCommonData`; `deployHandler` → `deployPageData` + a thin renderer) ·
+`web/media_events.go` (`applyPlexClaim` extracted) · `web/tier2_config_handler.go` (redirect targets) ·
+`templates/deploy.html` (install-only; 1197 → 544 lines) · `stacks.html`, `dashboard.html`,
+`backups_apps.html`, `app_import.html` (links) · `stacks/metadata.go`, `metadata_i18n.go`,
+`appinfo_address.go`, `manager.go` (the guide) · both locale files · `scripts/template_id_gate.py`,
+`scripts/test_gate_decoys.py`, `scripts/i18n_go_keys.json`.
+
+## 3. Commits on `main`
+
+| Commit | What |
+|---|---|
+| (see `git log`) | v0.315.0 — the whole slice in one commit, because the template split, the routes and the fixtures cannot be green apart |
+
+## 4. Tests and red-proofs
+
+**New test groups (9 functions across 3 files), all through the production router where routing is
+the subject.**
+
+| Test | Asserts |
+|---|---|
+| `TestAppPage_DeployedDefaultsToSettings` | `/apps/{slug}` renders the settings tab (`data-tab="settings"` + `aria-selected="true"`), all four tabs present, Mukodes offers restart+stop and not start, **the header contains no `stackAction(`**, no Pi/x86 text |
+| `TestAppPage_NotDeployedShowsGuideOnly` | no tab bar, `data-install-cta`, every tab suffix → 302 `/apps/grocy`; unknown app 404; unknown suffix 404 |
+| `TestAppPage_OldURLsRedirectGETOnly` | the five old GETs → 302 with the right Location, query carried, `?raw=1` still text/plain, unknown name 404, **POST to `/backup` and `/app-email` reaches its handler** |
+| `TestAfterStart_PlexThroughRouter` | POST `/apps/plex/after-start/PLEX_CLAIM` → the real registry → a fake Plex, by ADDRESS; the gate moves; the card disappears |
+| `TestAfterStart_NoActionNoCard` | an unregistered field: no card, POST 404, **zero calls to the fake** |
+| `TestAfterStart_RegistryAloneIsNotAnInvitation` | a REGISTERED action whose field the catalog does not declare: 404, zero calls, the gate untouched |
+| `TestAfterStart_CardHiddenWhenNotNeeded` | registered + `Needed()==false` → no card |
+| `TestAfterStart_PlexClaimOldRouteStillWorks` | `/apps/plex/plex-claim` runs the same entry |
+| `TestInstallPage_BackGoesToApp` | Vissza and Mégsem href `= /apps/grocy`; the post-install links point at the tabs; unknown app 404 |
+| `TestAppPage_EachTabRendersItsOwnContent` | one marker per tab, and **the log poller on the log tab only** |
+| `TestGuide_RenderRules` | checked renders / empty does not / absent says so / ``}
+ ai.Guide.FAQ[0].A = ``
+ h := renderGuideTab(t, ai)
+
+ if strings.Contains(h, "") {
+ t.Error("a catalog step was rendered as HTML — catalog copy is DATA, never markup")
+ }
+ if strings.Contains(h, "") {
+ t.Error("a FAQ answer was rendered as HTML")
+ }
+ if !strings.Contains(h, "<script>") {
+ t.Error("the step's characters must appear, escaped, as the author typed them")
+ }
+ })
+}
+
+// TestGuide_EnglishFallsBackFieldByField — the half-translated state the catalog is allowed to ship,
+// through the real localisation path and the real template: English FAQ, Hungarian sections.
+func TestGuide_EnglishFallsBackFieldByField(t *testing.T) {
+ en := "en"
+ meta := stacks.Metadata{DisplayName: "TestApp", Slug: "testapp", AppInfo: guideAppInfo("1.2.3")}
+ meta.I18n = map[string]stacks.MetadataOverlay{
+ en: {AppInfo: &stacks.AppInfoOverlay{Guide: &stacks.AppGuideOverlay{
+ // Section 1: the title translated, the intro left BLANK (= absent, operator ruling 3).
+ Sections: []stacks.GuideSectionOverlay{{Title: sptr("First start"), Intro: sptr("")}},
+ FAQ: []stacks.GuideFAQOverlay{
+ {Q: sptr("Where are the files?"), A: sptr("In the folder you chose.")},
+ {Q: sptr("Can several people use it?"), A: sptr("Yes.")},
+ },
+ }}},
+ }
+
+ h := renderGuideTab(t, meta.For(en).AppInfo.WithAddress("jegyzet", "example.hu"))
+
+ if !strings.Contains(h, "First start") {
+ t.Error("the translated section title did not reach the English page")
+ }
+ if !strings.Contains(h, "Where are the files?") || !strings.Contains(h, "In the folder you chose.") {
+ t.Error("the English FAQ did not reach the English page")
+ }
+ // A BLANK English intro is absent, so the Hungarian one stays — with the address filled.
+ if !strings.Contains(h, "Nyisd meg a jegyzet.example.hu oldalt.") {
+ t.Error("a blank English intro must fall back to the Hungarian one, not render empty")
+ }
+ // Section 2 had no English entry at all: wholly Hungarian.
+ if !strings.Contains(h, "Megosztas") || !strings.Contains(h, "Valaszd ki a mappat") {
+ t.Error("an untranslated section must render in Hungarian")
+ }
+ // And the gate is not copy: it survived the overlay.
+ if !strings.Contains(h, `data-guide-checked="1.2.3"`) {
+ t.Error("the checked version did not survive the English overlay")
+ }
+
+ // The Hungarian page is unchanged by the presence of the block.
+ hu := renderGuideTab(t, meta.For("hu").AppInfo.WithAddress("jegyzet", "example.hu"))
+ if strings.Contains(hu, "First start") || strings.Contains(hu, "Where are the files?") {
+ t.Error("English copy reached the Hungarian page")
+ }
+ if !strings.Contains(hu, "Elso bekapcsolas") {
+ t.Error("the Hungarian section title is gone")
+ }
+}
diff --git a/controller/internal/web/handler_export.go b/controller/internal/web/handler_export.go
index 6b2b2ee..26d300e 100644
--- a/controller/internal/web/handler_export.go
+++ b/controller/internal/web/handler_export.go
@@ -84,33 +84,6 @@ func (s *Server) ServeExportAPI(w http.ResponseWriter, r *http.Request) {
}
}
-// exportPageHandler renders the export form for a specific app.
-func (s *Server) exportPageHandler(w http.ResponseWriter, r *http.Request, name string) {
- if s.appExporter == nil {
- http.Error(w, "App export not available", http.StatusServiceUnavailable)
- return
- }
-
- stack, ok := s.stackMgr.GetStack(name)
- if !ok || !stack.Deployed {
- http.NotFound(w, r)
- return
- }
-
- // Build drive list for the dropdown
- drives := s.storageDriveList()
-
- data := map[string]interface{}{
- "Stack": stack,
- "Drives": drives,
- // R-53: the page builds the app's public URL from the customer domain. This handler does not
- // go through baseData (which is where every other page gets "Domain"), so it must supply the
- // key itself — the template used to substitute the CSRF token here instead.
- "Domain": s.cfg.Customer.Domain,
- }
- s.executeTemplate(w, r, "app_export", data)
-}
-
// importPageHandler renders the import page (standalone, not tied to a stack).
func (s *Server) importPageHandler(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go
index 25a9e20..2dcf076 100644
--- a/controller/internal/web/handlers.go
+++ b/controller/internal/web/handlers.go
@@ -401,43 +401,32 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
s.executeTemplate(w, r, "stacks", data)
}
-func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) {
- stack, ok := s.stackMgr.GetStack(name)
+func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
+ data, ok := s.deployPageData(w, r, name)
if !ok {
- http.NotFound(w, r)
return
}
-
- logs, err := s.stackMgr.GetLogs(name, 200)
- if err != nil {
- logs = s.msg(r, "logs.fetch_failed", err)
- }
-
- // Raw mode: return plain text for AJAX polling
- if r.URL.Query().Get("raw") == "1" {
- w.Header().Set("Content-Type", "text/plain; charset=utf-8")
- fmt.Fprint(w, logs)
- return
- }
-
- data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
- data["TitleKey"], data["TitleArgs"] = "page.title.logs", []interface{}{stack.Meta.DisplayName} // i18n: the Hungarian title above is what hu renders
- data["Stack"] = stacks.LocalizeStackPtr(stack, s.langFor(r)) // R-560
- data["Logs"] = logs
- s.executeTemplate(w, r, "logs", data)
+ s.executeTemplate(w, r, "deploy", data)
}
-func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
+// deployPageData assembles the install/settings form for one stack: the generated values, the
+// install-time fields, the optional settings, the integrations, the geo rule, the app-email toggle
+// and (for a new install) the memory and headroom summaries. It writes a 404 and reports false when
+// the stack is unknown.
+//
+// It serves TWO surfaces since v0.315.0 — the install page and the app page's settings tab — and the
+// `AlreadyDeployed` flag it sets is what each of them branches on, exactly as the one page did.
+func (s *Server) deployPageData(w http.ResponseWriter, r *http.Request, name string) (map[string]interface{}, bool) {
if s.isDebug() {
- s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s method=%s", name, r.Method)
+ s.logger.Printf("[DEBUG] [web] deployPageData: stack=%s method=%s", name, r.Method)
}
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
if err != nil {
if s.isDebug() {
- s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s not found: %v", name, err)
+ s.logger.Printf("[DEBUG] [web] deployPageData: stack=%s not found: %v", name, err)
}
http.NotFound(w, r)
- return
+ return nil, false
}
stack, _ := s.stackMgr.GetStack(name)
@@ -735,10 +724,16 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
data["FlashError"] = flashErr
}
- s.executeTemplate(w, r, "deploy", data)
+ return data, true
}
-func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
+// appCommonData resolves an app by its slug and assembles everything EVERY tab of its page needs:
+// the identity the shared shell renders (badges, address, banners), the "needs you" cards, the data
+// folders and the migrate offer. It was appDetailHandler's whole body until v0.315.0, when the six
+// separate app pages became one page with four tabs — the assembly did not change, only who calls it.
+//
+// It writes a 404 and reports false when no stack carries this slug, so a caller can `return` on it.
+func (s *Server) appCommonData(w http.ResponseWriter, r *http.Request, slug string) (*stacks.Stack, map[string]interface{}, bool) {
var found *stacks.Stack
for _, stack := range s.stackMgr.GetStacks() {
if stack.Meta.Slug == slug {
@@ -748,7 +743,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
}
if found == nil {
http.NotFound(w, r)
- return
+ return nil, nil, false
}
// Determine effective subdomain (stored env > metadata fallback)
@@ -810,7 +805,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
data["EffectiveSubdomain"] = effectiveSubdomain
// „Hova tegyem a fájlokat?" (R-75) — deployed apps that declare data_paths only. Set EXPLICITLY,
- // like every other key here: appDetailHandler does not funnel through baseData, and the v0.150.0
+ // like every other key here: appCommonData does not funnel through baseData, and the v0.150.0
// app_export.html bug (a CSRF token rendered where the domain belonged) came from assuming it did.
if cards := s.buildDataPathCards(found, s.langFor(r)); len(cards) > 0 {
data["DataPathCards"] = cards
@@ -915,7 +910,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
data["HoldLogKept"] = s.stackMgr.HoldLogKept(found.Name)
}
- s.executeTemplate(w, r, "app_info", data)
+ return found, data, true
}
func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) {
@@ -2656,7 +2651,7 @@ func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Req
escrowJSON(w, http.StatusServiceUnavailable, nil, s.msg(r, "escrow.stack_mgr_unavailable"))
return
}
- // Resolved EXACTLY as appDetailHandler resolves it — same loop, same field. A second definition
+ // Resolved EXACTLY as appCommonData resolves it — same loop, same field. A second definition
// of "which app is this slug" is how a reveal ends up answering for a different app than the page
// the customer is looking at.
var found *stacks.Stack
diff --git a/controller/internal/web/i18n_cases_a_test.go b/controller/internal/web/i18n_cases_a_test.go
index 47d30d0..acef2a2 100644
--- a/controller/internal/web/i18n_cases_a_test.go
+++ b/controller/internal/web/i18n_cases_a_test.go
@@ -169,11 +169,12 @@ func i18nCasesA() []i18nCase {
}
return d
}},
- {"app_export", "app_export", func() map[string]interface{} {
+ {"app_data_export", "app_data", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Private Bin exportálása")
st := i18nStack("privatebin", "Private Bin", stacks.StateRunning, true)
st.Meta.Subdomain = "paste"
- d["Stack"] = st
+ d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
+ d["ExportOffered"] = true
d["Drives"] = []map[string]interface{}{{"Path": "/mnt/hdd1", "Label": "HDD egy"}, {"Path": "/mnt/hdd2", "Label": ""}}
return d
}},
@@ -186,14 +187,14 @@ func i18nCasesA() []i18nCase {
{"deploy_new_memory_blocked", "deploy", func() map[string]interface{} {
return i18nDeployData(false, stacks.StateNotDeployed, 3)
}},
- {"deploy_deployed_running", "deploy", func() map[string]interface{} {
+ {"app_settings_running", "app_settings", func() map[string]interface{} {
return i18nDeployData(true, stacks.StateRunning, 1)
}},
- {"deploy_deployed_stopped", "deploy", func() map[string]interface{} {
+ {"app_settings_stopped", "app_settings", func() map[string]interface{} {
return i18nDeployData(true, stacks.StateStopped, 2)
}},
// v0.275.0 (R-694): a login the restore generated — no value, the "use your old password" hint.
- {"deploy_deployed_restored_login", "deploy", func() map[string]interface{} {
+ {"app_settings_restored_login", "app_settings", func() map[string]interface{} {
d := i18nDeployData(true, stacks.StateRunning, 1)
d["DeployedFieldValues"] = map[string]string{"SUBDOMAIN": "paste"}
d["RestoredLogins"] = map[string]bool{"ADMIN_PASSWORD": true}
@@ -293,10 +294,10 @@ func i18nCasesA() []i18nCase {
d["HubEnabled"] = false
return d
}},
- {"logs", "logs", func() map[string]interface{} {
+ {"app_logs", "app_logs", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Private Bin naplók")
st := i18nStack("privatebin", "Private Bin", stacks.StateRunning, true)
- d["Stack"] = st
+ d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
d["Logs"] = "2026-09-14 line one\nline two"
return d
}},
@@ -313,6 +314,10 @@ func i18nDeployData(deployed bool, st stacks.ContainerState, variant int) map[st
stack.Meta.AppInfo = stacks.AppInfo{FirstSteps: []string{"Nyisd meg a címet"}, DefaultCreds: "nincs ilyen", DocsURL: "https://example.org/docs"}
d["Stack"] = stack
d["Meta"] = stack.Meta
+ // v0.315.0: the settings TAB renders these same keys inside the app page's shell, which shows
+ // the app's tagline and badges. The install page ignores AppInfo, so one data builder still
+ // serves both surfaces.
+ d["AppInfo"] = stack.Meta.AppInfo
d["AlreadyDeployed"] = deployed
d["LogoURL"], d["LogoPNGURL"], d["AppPageURL"] = "/logo.svg", "/logo.png", "/apps/privatebin"
d["EffectiveSubdomain"] = "paste"
diff --git a/controller/internal/web/i18n_cases_b_test.go b/controller/internal/web/i18n_cases_b_test.go
index 3ff39f8..684b8a9 100644
--- a/controller/internal/web/i18n_cases_b_test.go
+++ b/controller/internal/web/i18n_cases_b_test.go
@@ -4,6 +4,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
+ "gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// ── i18n slice 1, release B (v0.249.0) — parity cases for the backup pages ─────────────────────────
@@ -289,8 +290,9 @@ func i18nCasesB() []i18nCase {
d["AgentSupported"] = true
return d
}},
- {"tier2_not_hdd", "tier2_config", func() map[string]interface{} {
- d := i18nLayoutData("backups", "Második mentés beállítása")
+ {"app_data_not_hdd", "app_data", func() map[string]interface{} {
+ d := i18nLayoutData("stacks", "Private Bin")
+ i18nAppDataShell(d)
d["DisplayName"], d["StackName"] = "Private Bin", "privatebin"
d["Flash"], d["FlashError"] = "A beállítás elmentve rendben.", "A mentés nem sikerült most."
d["Tier2"] = m{"IsHDDApp": false, "Disabled": false, "NoTarget": true}
@@ -298,42 +300,48 @@ func i18nCasesB() []i18nCase {
}},
// R-499 (v0.267.0): the system-disk sentence has one branch per whole-system backup target
// state. tier2_not_hdd above carries no SystemBackup, which renders the `unknown` branch.
- {"tier2_not_hdd_protected", "tier2_config", func() map[string]interface{} {
- d := i18nLayoutData("backups", "Második mentés beállítása")
+ {"app_data_not_hdd_protected", "app_data", func() map[string]interface{} {
+ d := i18nLayoutData("stacks", "Private Bin")
+ i18nAppDataShell(d)
d["DisplayName"], d["StackName"] = "Private Bin", "privatebin"
d["Tier2"] = m{"IsHDDApp": false}
d["SystemBackup"] = "protected"
return d
}},
- {"tier2_not_hdd_same_disk", "tier2_config", func() map[string]interface{} {
- d := i18nLayoutData("backups", "Második mentés beállítása")
+ {"app_data_not_hdd_same_disk", "app_data", func() map[string]interface{} {
+ d := i18nLayoutData("stacks", "Private Bin")
+ i18nAppDataShell(d)
d["DisplayName"], d["StackName"] = "Private Bin", "privatebin"
d["Tier2"] = m{"IsHDDApp": false}
d["SystemBackup"] = "same_disk"
return d
}},
- {"tier2_not_hdd_absent", "tier2_config", func() map[string]interface{} {
- d := i18nLayoutData("backups", "Második mentés beállítása")
+ {"app_data_not_hdd_absent", "app_data", func() map[string]interface{} {
+ d := i18nLayoutData("stacks", "Private Bin")
+ i18nAppDataShell(d)
d["DisplayName"], d["StackName"] = "Private Bin", "privatebin"
d["Tier2"] = m{"IsHDDApp": false}
d["SystemBackup"] = "absent"
return d
}},
- {"tier2_notarget_disabled", "tier2_config", func() map[string]interface{} {
- d := i18nLayoutData("backups", "Második mentés beállítása")
+ {"app_data_notarget_disabled", "app_data", func() map[string]interface{} {
+ d := i18nLayoutData("stacks", "Private Bin")
+ i18nAppDataShell(d)
d["DisplayName"], d["StackName"] = "Immich Photos", "immich"
d["Tier2"] = m{"IsHDDApp": true, "Disabled": true, "NoTarget": true, "NoTargetReason": "Nincs második meghajtó csatlakoztatva"}
return d
}},
- {"tier2_target_ssd", "tier2_config", func() map[string]interface{} {
- d := i18nLayoutData("backups", "Második mentés beállítása")
+ {"app_data_target_ssd", "app_data", func() map[string]interface{} {
+ d := i18nLayoutData("stacks", "Private Bin")
+ i18nAppDataShell(d)
d["DisplayName"], d["StackName"] = "Immich Photos", "immich"
d["Tier2"] = m{"IsHDDApp": true, "Disabled": false, "NoTarget": false, "EffectiveLabel": "SSD meghajtó egy", "EffectiveIsSSD": true,
"Preferred": "/mnt/ssd", "EffectiveDesc": "a leggyorsabb meghajtó", "Alternatives": []m{{"Path": "/mnt/ssd", "Label": "SSD meghajtó egy"}}}
return d
}},
- {"tier2_target_auto", "tier2_config", func() map[string]interface{} {
- d := i18nLayoutData("backups", "Második mentés beállítása")
+ {"app_data_target_auto", "app_data", func() map[string]interface{} {
+ d := i18nLayoutData("stacks", "Private Bin")
+ i18nAppDataShell(d)
d["DisplayName"], d["StackName"] = "Immich Photos", "immich"
d["Tier2"] = m{"IsHDDApp": true, "Disabled": false, "NoTarget": false, "EffectiveLabel": "HDD kettő", "EffectiveIsSSD": false,
"Preferred": "", "EffectiveDesc": "a legnagyobb szabad hely"}
@@ -405,3 +413,12 @@ func i18nWizardData(phase, step string) map[string]interface{} {
d["Pair"] = m{"DumpsAt": time.Time{}}
return d
}
+
+// i18nAppDataShell gives a Tier-2 scenario the app it now belongs to. The per-app backup panel was a
+// page of its own until v0.315.0; it is a section of the app page's „Adatok es mentes" tab, so the
+// shell above it needs the app's identity. One app for all seven scenarios: what they vary is the
+// backup state, not the app.
+func i18nAppDataShell(d map[string]interface{}) {
+ st := i18nStack("privatebin", "Private Bin", stacks.StateRunning, true)
+ d["Stack"], d["Meta"], d["AppInfo"] = st, st.Meta, st.Meta.AppInfo
+}
diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go
index f223cb4..10c3cfe 100644
--- a/controller/internal/web/i18n_parity_test.go
+++ b/controller/internal/web/i18n_parity_test.go
@@ -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)
diff --git a/controller/internal/web/i18n_wiring_test.go b/controller/internal/web/i18n_wiring_test.go
index ad22952..7d3de8f 100644
--- a/controller/internal/web/i18n_wiring_test.go
+++ b/controller/internal/web/i18n_wiring_test.go
@@ -404,13 +404,16 @@ func TestHandlerTitleKeysMatchHungarianTitle(t *testing.T) {
// title, titleKey := "Title", "key"
regexp.MustCompile(`title, titleKey :?= "([^"]+)", "([^"]+)"`),
}
- // R-566, v0.252.0 — the three titles built around an app name, plus the tier-2 one. Their
+ // R-566, v0.252.0 — the titles built around an app name. v0.315.0: the tier-2 one went with
+ // its page (the panel is now a section of the app page's data tab) and the two new tab titles
+ // took its place. Their
// Hungarian is a CONCATENATION, so there is no single literal to compare with; the pair is pinned
// instead by TestParameterisedPageTitles (the rendered title) and by scripts/i18n_go_parity.py
// (the key's text against the base-commit fragment). Collected here so the "every page.title.* key
// has a handler" half below still accounts for them.
withArgs := regexp.MustCompile(`data\["TitleKey"\], data\["TitleArgs"\] = (?:pageTitleKey|"([^"]+)")`)
- argKeys := []string{"page.title.logs", "page.title.deploy", "page.title.app_settings", "page.title.tier2_config"}
+ argKeys := []string{"page.title.logs", "page.title.deploy", "page.title.app_settings",
+ "page.title.app_data", "page.title.app_guide"}
files, _ := filepath.Glob("*.go")
seen := map[string]bool{}
for _, f := range files {
@@ -583,7 +586,8 @@ func TestParameterisedPageTitles(t *testing.T) {
{"page.title.logs", app + " — Naplók"},
{"page.title.deploy", app + " — Telepítés"},
{"page.title.app_settings", app + " — Beállítások"},
- {"page.title.tier2_config", "2. mentés beállítása — " + app},
+ {"page.title.app_data", app + " — Adatok és mentés"},
+ {"page.title.app_guide", app + " — Útmutató"},
}
for _, c := range cases {
if got := b.Msgf(i18n.Default, c.key, app); got != c.wantHU {
diff --git a/controller/internal/web/launcher_polish_test.go b/controller/internal/web/launcher_polish_test.go
index d1d6b5c..2840e3e 100644
--- a/controller/internal/web/launcher_polish_test.go
+++ b/controller/internal/web/launcher_polish_test.go
@@ -108,7 +108,7 @@ func TestAppInfo_HeroPlaceholder_ScreenshotsUntouched(t *testing.T) {
"HasAppInfo": st.Meta.HasAppInfo(), "EffectiveSubdomain": st.Meta.Subdomain,
"Domain": "demo-felhom.eu",
}
- html := renderBackupPage(t, "app_info", data)
+ html := renderBackupPage(t, "app_guide", data)
if !strings.Contains(html, `data-fallback="/static/app-placeholder.svg"`) {
t.Error("app_info hero logo must default to the app placeholder")
}
diff --git a/controller/internal/web/lifecycle_test.go b/controller/internal/web/lifecycle_test.go
index 4567890..1fab732 100644
--- a/controller/internal/web/lifecycle_test.go
+++ b/controller/internal/web/lifecycle_test.go
@@ -137,7 +137,7 @@ func TestAvailableAppHasNoBadgeAndKeepsInstallButton(t *testing.T) {
}
}
-// appInfoData mirrors appDetailHandler's data map — CRUCIALLY including `"Meta": st.Meta` as a
+// appInfoData mirrors appCommonData's data map — CRUCIALLY including `"Meta": st.Meta` as a
// stacks.Metadata VALUE inside a map[string]interface{}, which is what the handler really does.
// That detail is the entire point of this test: html/template cannot call a POINTER-receiver method
// on a non-addressable value, so `{{if .Meta.IsAbandoned}}` renders fine in a unit test that passes
@@ -148,6 +148,9 @@ func appInfoData(st stacks.Stack) map[string]interface{} {
"Stack": &st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo,
"HasAppInfo": st.Meta.HasAppInfo(), "EffectiveSubdomain": st.Meta.Subdomain,
"Domain": "demo-felhom.eu",
+ // v0.315.0: the shell asks whether the app is installed (the tab bar, the Megnyitas link)
+ // and the guide tab asks it again for the Telepites button.
+ "Deployed": st.Deployed, "AppOperational": st.Deployed,
}
}
@@ -162,7 +165,7 @@ func TestAppInfoRendersForEveryLifecycle(t *testing.T) {
t.Run("lifecycle="+lc, func(t *testing.T) {
st := lcStack("someapp", lc, true)
st.State = stacks.StateRunning
- html := renderBackupPage(t, "app_info", appInfoData(st))
+ html := renderBackupPage(t, "app_guide", appInfoData(st))
if len(html) < 500 {
t.Fatalf("app_info rendered only %d bytes — the page is broken", len(html))
}
@@ -187,7 +190,7 @@ func TestAppInfoHidesInstallButtonForWithdrawnApp(t *testing.T) {
wantBtn bool
}{{"", true}, {"available", true}, {"hidden", false}, {"abandoned", false}} {
st := lcStack("someapp", c.lifecycle, false)
- html := renderBackupPage(t, "app_info", appInfoData(st))
+ html := renderBackupPage(t, "app_guide", appInfoData(st))
got := strings.Contains(html, "Telepítés")
if got != c.wantBtn {
t.Errorf("lifecycle %q: Telepítés button present=%v, want %v", c.lifecycle, got, c.wantBtn)
diff --git a/controller/internal/web/media_events.go b/controller/internal/web/media_events.go
index d9da00d..da07fab 100644
--- a/controller/internal/web/media_events.go
+++ b/controller/internal/web/media_events.go
@@ -374,28 +374,48 @@ func (s *Server) appPlexClaimHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Code string `json:"code"`
}
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil || !plexClaimCode.MatchString(strings.TrimSpace(req.Code)) {
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "err.plex_claim.bad_code"))
return
}
- code, err := plexClaim(strings.TrimSpace(req.Code))
- s.logger.Printf("[INFO] [web] plex: the household's claim code given to the running server -> HTTP %d (err %v; the code is not logged)", code, err)
- if err != nil || code != http.StatusOK {
+ if err := s.applyPlexClaim(strings.TrimSpace(req.Code)); err != nil {
+ var ae *afterStartError
+ if errors.As(err, &ae) {
+ escrowJSON(w, ae.Status, nil, s.msg(r, ae.MsgKey))
+ return
+ }
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.refused"))
return
}
+ escrowJSON(w, http.StatusOK, map[string]any{"claimed": true}, "")
+}
+
+// applyPlexClaim is the claim itself, unchanged in behaviour since v0.314.0 and now reachable two
+// ways: the after-start registry's „plex/PLEX_CLAIM" entry (the app page's card) and the original
+// /apps/plex/plex-claim route, which the page kept so an open tab from before the update still works.
+// ONE body, so the two cannot drift — the gate still opens only when Plex ITSELF says claimed.
+//
+// The code is a credential and is never logged, here or in the caller.
+func (s *Server) applyPlexClaim(code string) error {
+ if !plexClaimCode.MatchString(code) {
+ return &afterStartError{Status: http.StatusBadRequest, MsgKey: "err.plex_claim.bad_code"}
+ }
+ status, err := plexClaim(code)
+ s.logger.Printf("[INFO] [web] plex: the household's claim code given to the running server -> HTTP %d (err %v; the code is not logged)", status, err)
+ if err != nil || status != http.StatusOK {
+ return &afterStartError{Status: http.StatusConflict, MsgKey: "err.plex_claim.refused"}
+ }
for i := 0; i < 10; i++ {
if plexClaimed() {
if oerr := s.stackMgr.OpenSetupGate("plex", stacks.SetupGateByProbe); oerr != nil && !errors.Is(oerr, stacks.ErrSetupGateNotClosed) {
s.logger.Printf("[WARN] [web] plex: claimed, but opening the setup gate failed: %v", oerr)
}
s.logger.Printf("[INFO] [web] plex: claimed — the setup gate is open (R-1028)")
- escrowJSON(w, http.StatusOK, map[string]any{"claimed": true}, "")
- return
+ return nil
}
time.Sleep(time.Second)
}
- escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.plex_claim.not_claimed"))
+ return &afterStartError{Status: http.StatusConflict, MsgKey: "err.plex_claim.not_claimed"}
}
// plexHTTP is the client for the box's own calls to Plex (a seam: tests dial a fake that refuses the name „plex").
diff --git a/controller/internal/web/meta_copy_allowlist_test.go b/controller/internal/web/meta_copy_allowlist_test.go
index a45497a..f8642f7 100644
--- a/controller/internal/web/meta_copy_allowlist_test.go
+++ b/controller/internal/web/meta_copy_allowlist_test.go
@@ -32,16 +32,26 @@ import (
// RED-PROOF (2026-09-20): adding `_ = stack.Meta.Description` to handlers.go failed this test
// naming handlers.go and Description; removing it went green.
var metaCopyReadAllowlist = map[string]string{
- // The app page localises `found` in one place at the top of appDetailHandler and every read
+ // The app page localises `found` in one place at the top of appCommonData and every read
// below it — these included — is of that localised value.
- "handlers.go:AppInfo": "appDetailHandler reads it off the LocalizeStackPtr'd `found`",
- "handlers.go:InitialCreds": "appDetailHandler: the nil check and the note, both off localised `found`",
+ "handlers.go:AppInfo": "appCommonData reads it off the LocalizeStackPtr'd `found`",
+ "handlers.go:InitialCreds": "appCommonData: the nil check and the note, both off localised `found`",
// buildDataPathCards is called with the same localised `found`; the labels it renders are the
// English ones when the household is on English.
- "datapath_card.go:DataPaths": "buildDataPathCards is handed the localised stack by appDetailHandler",
+ "datapath_card.go:DataPaths": "buildDataPathCards is handed the localised stack by appCommonData",
// R-255 (v0.308.0): appAutoFieldRevealHandler reads only EnvVar and Type of each optional field, to
// AUTHORISE a reveal; no label or help text reaches a page from here.
"handlers.go:OptionalConfig": "appAutoFieldRevealHandler: EnvVar/Type authorisation only, no copy rendered",
+ // v0.315.0, TWO reads in after_start.go, and they are different in kind:
+ //
+ // afterStartFields — DOES render copy (the field's label, description and placeholder go
+ // into the card). It is handed the localised `found` by
+ // renderAppSettingsTab, which is the same stack appCommonData localised
+ // at the top, so the card is in the household's language.
+ // appAfterStartHandler — reads AfterStart and EnvVar only, to decide whether the catalog
+ // DECLARED this field before the registry is allowed to run anything.
+ // Configuration, not copy; nothing from it reaches a page.
+ "after_start.go:DeployFields": "afterStartFields renders copy off the localised stack; appAfterStartHandler reads AfterStart/EnvVar as authorisation only",
}
// metaCopyFields are the Metadata fields that carry customer-facing TEXT. Everything else on
diff --git a/controller/internal/web/networkstub_test.go b/controller/internal/web/networkstub_test.go
index 847f2bd..38628a7 100644
--- a/controller/internal/web/networkstub_test.go
+++ b/controller/internal/web/networkstub_test.go
@@ -191,9 +191,11 @@ func optionTag(t *testing.T, body, path string) string {
// selected instead → both assertions FAIL (the exact S-C lie from the RCA).
func TestDeployPage_DeployedSelectShowsStoredHDDPath(t *testing.T) {
s := testDeployPageServer(t, "/mnt/felhom-drives/nas-media")
- rec := getPage(t, s, "/stacks/testapp/deploy")
+ // v0.315.0: an installed app's fields are the app page's settings TAB. The old /stacks/…/deploy
+ // URL answers 302 here, which TestAppPage_OldURLsRedirectGETOnly pins separately.
+ rec := getPage(t, s, "/apps/testapp/settings")
if rec.Code != 200 {
- t.Fatalf("GET deploy page = %d: %s", rec.Code, rec.Body.String())
+ t.Fatalf("GET settings tab = %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if nas := optionTag(t, body, "/mnt/felhom-drives/nas-media"); !strings.Contains(nas, "selected") {
@@ -208,9 +210,9 @@ func TestDeployPage_DeployedSelectShowsStoredHDDPath(t *testing.T) {
// view must never silently show a different storage than app.yaml).
func TestDeployPage_MissingStoredPathRendersTruthOption(t *testing.T) {
s := testDeployPageServer(t, "/mnt/felhom-drives/gone")
- rec := getPage(t, s, "/stacks/testapp/deploy")
+ rec := getPage(t, s, "/apps/testapp/settings")
if rec.Code != 200 {
- t.Fatalf("GET deploy page = %d: %s", rec.Code, rec.Body.String())
+ t.Fatalf("GET settings tab = %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "/mnt/felhom-drives/gone (nem elérhető)") {
diff --git a/controller/internal/web/plex_claim_test.go b/controller/internal/web/plex_claim_test.go
index 0d7c69a..20d2fe2 100644
--- a/controller/internal/web/plex_claim_test.go
+++ b/controller/internal/web/plex_claim_test.go
@@ -11,22 +11,31 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
-// R-1028: the Plex claim box renders only for a closed Plex gate — render test per branch (the seam-wiring rule).
-// COMPANION RED-PROOF: remove the {{if .PlexClaim}} block → the first case fails. Restored.
+// R-1028: the claim box renders only when the household still has to give the code — render test
+// per branch (the seam-wiring rule).
+//
+// v0.315.0: the box is no longer Plex-specific markup behind a `.PlexClaim` flag. It is the GENERIC
+// after-start card, and the thing it branches on is whether afterStartFields put the field in the
+// page data at all — which is where the „registered action AND Needed()" decision now lives.
+// COMPANION RED-PROOF: render the card unconditionally (drop the range over .AfterStartFields) →
+// the two negative cases fail. Restored.
func TestPlexClaimBox_RendersPerBranch(t *testing.T) {
st := lcStack("plex", "", true)
st.State = stacks.StateRunning
+ claimField := stacks.DeployField{EnvVar: "PLEX_CLAIM", Label: "Plex kod", AfterStart: true,
+ Placeholder: "claim-xxxxxxxxxxxxxxxxxxxx"}
for _, c := range []struct {
- name string
- plexClaim bool
- gateClosed bool
- want bool
- }{{"closed plex", true, true, true}, {"gate open", false, false, false}, {"other app closed", false, true, false}} {
+ name string
+ fields []stacks.DeployField
+ want bool
+ }{
+ {"the code is still wanted", []stacks.DeployField{claimField}, true},
+ {"nothing is wanted", nil, false},
+ } {
d := appInfoData(st)
- d["SetupGateClosed"] = c.gateClosed
- d["PlexClaim"] = c.plexClaim
- h := renderBackupPage(t, "app_info", d)
- if got := strings.Contains(h, `data-plex-claim="true"`) && strings.Contains(h, `id="plex-claim-code"`); got != c.want {
+ d["AfterStartFields"] = c.fields
+ h := renderBackupPage(t, "app_settings", d)
+ if got := strings.Contains(h, `data-afterstart="PLEX_CLAIM"`) && strings.Contains(h, `id="after-start-PLEX_CLAIM"`); got != c.want {
t.Errorf("%s: claim box shown=%v, want %v", c.name, got, c.want)
}
}
diff --git a/controller/internal/web/r499_system_backup_test.go b/controller/internal/web/r499_system_backup_test.go
index f09c5e4..e684acf 100644
--- a/controller/internal/web/r499_system_backup_test.go
+++ b/controller/internal/web/r499_system_backup_test.go
@@ -60,7 +60,7 @@ func tier2PageServer(t *testing.T, tiers func(context.Context) (agentapi.TiersRe
}
s.loadTemplates()
w := httptest.NewRecorder()
- s.tier2ConfigPageHandler(w, httptest.NewRequest("GET", "/stacks/privatebin/backup", nil), "privatebin")
+ s.appPageHandler(w, httptest.NewRequest("GET", "/apps/privatebin/data", nil), "privatebin", appTabData)
if w.Code != 200 {
t.Fatalf("page answered %d", w.Code)
}
diff --git a/controller/internal/web/r606_update_pages_test.go b/controller/internal/web/r606_update_pages_test.go
index e6ced67..5cc4f51 100644
--- a/controller/internal/web/r606_update_pages_test.go
+++ b/controller/internal/web/r606_update_pages_test.go
@@ -47,19 +47,19 @@ func TestR606_UpdateSentencesFollowTheReader(t *testing.T) {
}
return d
}},
- {"r606_app_error", "app_info", func() map[string]interface{} {
+ {"r606_app_error", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Go Kapi")
st := errStack
d["Stack"], d["Meta"], d["AppInfo"] = &st, st.Meta, st.Meta.AppInfo
return d
}},
- {"r606_app_held", "app_info", func() map[string]interface{} {
+ {"r606_app_held", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Kimai")
st := held
d["Stack"], d["Meta"], d["AppInfo"] = &st, st.Meta, st.Meta.AppInfo
return d
}},
- {"r606_app_updating", "app_info", func() map[string]interface{} {
+ {"r606_app_updating", "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "RomM")
st := upd
d["Stack"], d["Meta"], d["AppInfo"] = &st, st.Meta, st.Meta.AppInfo
diff --git a/controller/internal/web/r621_hold_panel_test.go b/controller/internal/web/r621_hold_panel_test.go
index ea55791..f93a8d5 100644
--- a/controller/internal/web/r621_hold_panel_test.go
+++ b/controller/internal/web/r621_hold_panel_test.go
@@ -29,10 +29,10 @@ func (heldGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestoreP
}
// R-621 (the last half): the app page's HOLD PANEL points at the log the hold kept. Driven through the
-// REAL handler (appDetailHandler) and the REAL template, both languages, with the kept-log file present
+// REAL handler (appCommonData) and the REAL template, both languages, with the kept-log file present
// and absent — a render per branch of the gate (seam-wiring rule).
//
-// COMPANION RED-PROOF: drop the HoldLogKept block from appDetailHandler — the "kept" branch fails.
+// COMPANION RED-PROOF: drop the HoldLogKept block from appCommonData — the "kept" branch fails.
func TestR621_HoldPanelPointsAtTheKeptLog(t *testing.T) {
s, _ := credsHarness(t)
s.stackMgr.SetUpdateGuards(heldGuards{})
@@ -41,10 +41,10 @@ func TestR621_HoldPanelPointsAtTheKeptLog(t *testing.T) {
render := func(lang string) string {
rr := httptest.NewRecorder()
- s.appDetailHandler(rr, httptest.NewRequest("GET", "/apps/crafty?lang="+lang, nil), "crafty")
+ s.appPageHandler(rr, httptest.NewRequest("GET", "/apps/crafty?lang="+lang, nil), "crafty", "")
return html.UnescapeString(rr.Body.String())
}
- const link = ``
+ const link = ``
const huLine, enLine = "Az alkalmazás saját naplója megmaradt", "The app's own log from before it was stopped was kept"
// No kept log: the panel is there (positive control) and says nothing about a log.
diff --git a/controller/internal/web/r625_r647_held_test.go b/controller/internal/web/r625_r647_held_test.go
index 56e1b7f..28ae8c9 100644
--- a/controller/internal/web/r625_r647_held_test.go
+++ b/controller/internal/web/r625_r647_held_test.go
@@ -71,7 +71,7 @@ func TestR625_AHeldAppSaysStoppedRestoreNeeded(t *testing.T) {
{"hu", "Megállítva — visszaállítás szükséges", "Frissítés elérhető", "A(z) kimai frissítése"},
{"en", "Stopped — restore needed", "Update available", "The update of kimai"},
} {
- page := renderHeld(t, s, st, c.lang, "app_info")
+ page := renderHeld(t, s, st, c.lang, "app_settings")
if !strings.Contains(page, ` 0 && !strings.Contains(page[max(0, i-400):i], c.title) {
t.Errorf("%s: the badge title must open with the hold sentence (%q)", c.lang, c.title)
}
- for _, tmpl := range []string{"app_info", "stacks"} {
+ for _, tmpl := range []string{"app_settings", "stacks"} {
if strings.Contains(renderHeld(t, s, st, c.lang, tmpl), `'kimai', 'update')`) {
t.Errorf("%s %s: the Update button is rendered for a HELD app", c.lang, tmpl)
}
@@ -108,7 +108,7 @@ func TestR647_AReaderInTheOtherLanguageReadsTheHoldInTheirs(t *testing.T) {
t.Fatalf("fixture: an English box must store the English hold, got %q", st.HoldReason)
}
const huWant, enWant = "A(z) kimai frissítése 2026-09-23 10:00-kor nem sikerült", "The update of kimai at 2026-09-23 10:00 did not succeed"
- for _, tmpl := range []string{"app_info", "stacks"} {
+ for _, tmpl := range []string{"app_settings", "stacks"} {
hu := renderHeld(t, s, st, "hu", tmpl)
if !strings.Contains(hu, huWant) || strings.Contains(hu, enWant) {
t.Errorf("%s: the Hungarian reader of an English box must read the hold in Hungarian only", tmpl)
diff --git a/controller/internal/web/r659_held_page_test.go b/controller/internal/web/r659_held_page_test.go
index f42c147..5a00864 100644
--- a/controller/internal/web/r659_held_page_test.go
+++ b/controller/internal/web/r659_held_page_test.go
@@ -21,12 +21,12 @@ func TestR659_HeldPage_NoWholeCopyOffersNoRestoreButton(t *testing.T) {
st.State = stacks.StateStopped
st.HoldReason = "HOLD SENTENCE"
st.HoldNoWholeCopy = noWhole
- for _, page := range []string{"stacks", "app_info"} {
+ for _, page := range []string{"stacks", "app_settings"} {
var h string
if page == "stacks" {
h = renderBackupPage(t, "stacks", ubStacksData(st))
} else {
- h = renderBackupPage(t, "app_info", appInfoData(st))
+ h = renderBackupPage(t, "app_settings", appInfoData(st))
}
got := heldButton.MatchString(h)
if noWhole && got {
diff --git a/controller/internal/web/r772_not_checked_page_test.go b/controller/internal/web/r772_not_checked_page_test.go
index 1d796d8..afa04c0 100644
--- a/controller/internal/web/r772_not_checked_page_test.go
+++ b/controller/internal/web/r772_not_checked_page_test.go
@@ -14,7 +14,7 @@ import (
func TestR772_AppPageSaysTheCheckDidNotRun(t *testing.T) {
s := i18nTestServer(t)
page := func(name string, hp *stacks.HealthProbeResult) i18nCase {
- return i18nCase{name, "app_info", func() map[string]interface{} {
+ return i18nCase{name, "app_settings", func() map[string]interface{} {
d := i18nLayoutData("stacks", "Karakeep")
st := i18nStack("karakeep", "Karakeep", stacks.StateDegraded, true)
st.HealthProbe = hp
diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go
index ae6730e..d2b70d1 100644
--- a/controller/internal/web/server.go
+++ b/controller/internal/web/server.go
@@ -814,21 +814,26 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/auto-field/reveal") && r.Method == http.MethodPost:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/auto-field/reveal")
s.appAutoFieldRevealHandler(w, r, name)
- case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
- name := strings.TrimPrefix(path, "/stacks/")
- name = strings.TrimSuffix(name, "/export")
- s.exportPageHandler(w, r, name)
- case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
- name := strings.TrimPrefix(path, "/stacks/")
- name = strings.TrimSuffix(name, "/logs")
- s.logsHandler(w, r, name)
- case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/deploy"):
- name := strings.TrimPrefix(path, "/stacks/")
- name = strings.TrimSuffix(name, "/deploy")
- s.deployHandler(w, r, name)
+ // v0.315.0 — THE OLD APP URLS. Each of these four was a page of its own until the app page took
+ // their content into a tab. An alert e-mail, a bookmark or a tab open since before the update
+ // still carries them, so each GET answers 302 to the tab that now holds it; the query string
+ // travels too (`?raw=1` is a logs poller, `?flash=…` is a save coming back).
+ //
+ // 302 AND NOT 301, deliberately: a 301 lives in the browser's cache beyond anyone's reach, and
+ // these paths are a bridge for one release, not an address.
+ //
+ // The METHOD CHECK is the load-bearing half. `/stacks/{name}/backup` POST is the backup form,
+ // `/app-email` POST is the toggle, `/auto-field/reveal` POST reads a secret — redirecting any of
+ // them would turn a save into a GET of a page and lose the household's input. Each POST case
+ // below is therefore matched on its method, and the GET redirects never see one.
+ case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export") && r.Method == http.MethodGet:
+ s.redirectStackGETToAppTab(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/export"), appTabData)
+ case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs") && r.Method == http.MethodGet:
+ s.redirectStackGETToAppTab(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/logs"), appTabLogs)
+ case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/deploy") && r.Method == http.MethodGet:
+ s.redirectStackDeployGET(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/deploy"))
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodGet:
- name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
- s.tier2ConfigPageHandler(w, r, name)
+ s.redirectStackGETToAppTab(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup"), appTabData)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/backup") && r.Method == http.MethodPost:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/backup")
s.tier2ConfigSaveHandler(w, r, name)
@@ -878,9 +883,29 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/initial-credentials/reveal") && r.Method == http.MethodPost:
slug := strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/initial-credentials/reveal")
s.appInitialCredsRevealHandler(w, r, slug)
+ // v0.315.0: the household's answer to an after-start field (Part 4). POST, so CsrfProtect covers
+ // it; the registry decides what runs, and an unregistered field is a 404 that executes nothing.
+ case strings.HasPrefix(path, "/apps/") && strings.Contains(path, "/after-start/") && r.Method == http.MethodPost:
+ rest := strings.TrimPrefix(path, "/apps/")
+ slug, env, _ := strings.Cut(rest, "/after-start/")
+ s.appAfterStartHandler(w, r, slug, env)
+ // v0.315.0: the app page and its tabs. Placed after every /apps/ POST case above and before the
+ // catch-all, so a more specific path always wins.
+ case strings.HasPrefix(path, "/apps/") && r.Method == http.MethodGet:
+ slug, tab, known := appTabPathSuffix(strings.TrimPrefix(path, "/apps/"))
+ switch {
+ case !known:
+ // An unknown suffix is not a tab, and it is not part of the slug either.
+ s.logger.Printf("[WARN] [web] 404 Not Found: %s %s (no such app tab)", r.Method, path)
+ http.NotFound(w, r)
+ case tab == appTabInstall:
+ s.appInstallPageHandler(w, r, slug)
+ default:
+ s.appPageHandler(w, r, slug, tab)
+ }
case strings.HasPrefix(path, "/apps/"):
slug := strings.TrimPrefix(path, "/apps/")
- s.appDetailHandler(w, r, slug)
+ s.appPageHandler(w, r, slug, "")
case path == "/debug":
// v0.116.1: available at ANY logging.level (the ring always captures; see ServeDebugAPI).
s.debugPageHandler(w, r)
diff --git a/controller/internal/web/setup_gate.go b/controller/internal/web/setup_gate.go
index 0a627fc..1b682d6 100644
--- a/controller/internal/web/setup_gate.go
+++ b/controller/internal/web/setup_gate.go
@@ -330,7 +330,7 @@ func (s *Server) appDefaultLoginChangedHandler(w http.ResponseWriter, r *http.Re
escrowJSON(w, http.StatusOK, map[string]any{"recorded": true}, "")
}
-// stackBySlug resolves a page slug exactly as appDetailHandler does.
+// stackBySlug resolves a page slug exactly as appCommonData does.
func (s *Server) stackBySlug(slug string) *stacks.Stack {
if s.stackMgr == nil {
return nil
diff --git a/controller/internal/web/setup_gate_page_test.go b/controller/internal/web/setup_gate_page_test.go
index aab9be1..d8eb077 100644
--- a/controller/internal/web/setup_gate_page_test.go
+++ b/controller/internal/web/setup_gate_page_test.go
@@ -20,12 +20,14 @@ func renderAppInfoWith(t *testing.T, extra map[string]interface{}) string {
"Meta": stacks.Metadata{DisplayName: "G", Slug: "gapp"},
"AppInfo": stacks.AppInfo{Tagline: "t", DefaultCreds: "admin / admin123"}, "HasAppInfo": true,
"CSRFToken": "tok",
+ // v0.315.0: these cards are the app page's „Beallitasok" tab.
+ "Deployed": true, "AppOperational": true,
}
for k, v := range extra {
data[k] = v
}
var buf bytes.Buffer
- if err := s.tmpl.ExecuteTemplate(&buf, "app_info", data); err != nil {
+ if err := s.tmpl.ExecuteTemplate(&buf, "app_settings", data); err != nil {
t.Fatalf("render: %v", err)
}
return buf.String()
diff --git a/controller/internal/web/templates/app_export.html b/controller/internal/web/templates/app_data.html
similarity index 58%
rename from controller/internal/web/templates/app_export.html
rename to controller/internal/web/templates/app_data.html
index da88118..6d461c2 100644
--- a/controller/internal/web/templates/app_export.html
+++ b/controller/internal/web/templates/app_data.html
@@ -1,13 +1,192 @@
-{{define "app_export"}}
+{{define "app_data"}}
{{template "layout_start" .}}
+{{template "app_shell_start" .}}
-
+{{/* „ADATOK ES MENTES" — everything about this app's FILES, in the order the household asks it:
+
+ where are they -> „Hova tegyem a fajlokat?" and the move-to-another-drive offer
+ are they safe -> the nightly copy, and the 2nd-copy setting that used to be reachable only
+ from the backup page's „Beallitas" button
+ how do I get them out -> the export, which was a page of its own
+
+ Both forms keep the URLs they always POSTed to (`/stacks/{name}/backup` and the export API), so
+ nothing about saving changed — only where the household finds the controls. */}}
+
+
+
+{{template "app_press_script" .}}
{{template "layout_end" .}}
{{end}}
diff --git a/controller/internal/web/templates/app_fields.html b/controller/internal/web/templates/app_fields.html
new file mode 100644
index 0000000..8b9b80f
--- /dev/null
+++ b/controller/internal/web/templates/app_fields.html
@@ -0,0 +1,181 @@
+{{/* THE APP'S CONFIGURATION FIELDS — shared by the install wizard and the settings tab.
+
+ ONE copy of this markup, rendered by two surfaces, because they are the same fields: the
+ install page submits them, the settings tab shows what was submitted. `.AlreadyDeployed` is
+ the single flag every branch below reads, exactly as it did when both surfaces were one page —
+ that is what keeps the install form and the settings view from drifting apart.
+
+ The secret rules are unchanged and load-bearing:
+ R-254 — an INSTALLED app's `type: secret` value is not in the page; the eye fetches it.
+ R-709 — nor is a `type: password` one.
+ The PRE-DEPLOY hidden input is deliberate: the form must carry what it submits, so the value
+ saved is the one the household was shown (controller/README §318). */}}
+
+{{define "app_deploy_fields"}}
+ {{if .AutoFields}}
+
+ {{if not $.AlreadyDeployed}}
+ {{T "deploy.az_aldomain_telepites_utan_nem"}}
+ {{end}}
+ {{else if eq .Type "select"}}
+
+ {{else if eq .Type "password"}}
+
+ {{- if $.AlreadyDeployed}}
+ {{- /* R-709 (v0.280.0): an installed app's password is never in this page; the eye fetches it. */}}
+
+ {{- if not (and $.RestoredLogins (index $.RestoredLogins .EnvVar))}}
+
+ {{- end}}
+ {{- else}}
+
+
+
+ {{- end}}
+
+ {{end}}
+{{end}}
diff --git a/controller/internal/web/templates/app_guide.html b/controller/internal/web/templates/app_guide.html
new file mode 100644
index 0000000..4cc523e
--- /dev/null
+++ b/controller/internal/web/templates/app_guide.html
@@ -0,0 +1,109 @@
+{{define "app_guide"}}
+{{template "layout_start" .}}
+{{template "app_shell_start" .}}
+
+{{/* „UTMUTATO" — the app's own copy, and the WHOLE page for an app that is not installed yet.
+ It replaces the old details page: description and screenshots, what it is for, the first steps,
+ the guide the catalog carries, the questions people ask, and the official documentation.
+
+ THE GUIDE RENDERS ONLY WHEN ITS AUTHOR NAMED THE VERSION THEY CHECKED IT AGAINST
+ (AppGuide.Rendered). A guide walking the household through a screen two releases old is worse
+ than no guide, so an unchecked one is not shown and the page says a guide is still to come.
+
+ Everything here is PLAIN TEXT through html/template. No markdown, no raw HTML: a step that
+ contains a tag is shown as the characters the author typed. */}}
+
+
+
+
+
+
+
+
+{{if not .Deployed}}
+{{/* The install button lives on this tab, because this tab IS the page for a not-installed app.
+ The server refuses a non-installable template regardless; hiding the button keeps the page
+ from offering an action that would be rejected. */}}
+
+
+{{template "app_press_script" .}}
+{{template "layout_end" .}}
+{{end}}
diff --git a/controller/internal/web/templates/app_import.html b/controller/internal/web/templates/app_import.html
index 9d5e7cb..c8f72b6 100644
--- a/controller/internal/web/templates/app_import.html
+++ b/controller/internal/web/templates/app_import.html
@@ -268,6 +268,15 @@ function pollStatus() {
document.getElementById('progressCard').style.display = 'none';
document.getElementById('doneCard').style.display = 'block';
if (data.stack_name) {
+ {{- /* v0.315.0: the browser knows the imported app's stack NAME and not its
+ slug, and the name->slug map lives on the server. This is the one link
+ that keeps the old URL on purpose: its 302 resolves the slug and lands on
+ the settings tab.
+
+ The leading `{{-` is deliberate: a comment action renders nothing, but the
+ indentation on its own line does. Without the trim this page's bytes
+ change and its parity fixture — a page this task does not redesign — would
+ have to be regenerated for a comment. */}}
document.getElementById('doneLink').href = '/stacks/' + data.stack_name + '/deploy';
document.getElementById('doneLink').textContent = '{{T "app_import.alkalmazas_megtekintese"}}';
}
diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html
deleted file mode 100644
index fa3a286..0000000
--- a/controller/internal/web/templates/app_info.html
+++ /dev/null
@@ -1,405 +0,0 @@
-{{define "app_info"}}
-{{template "layout_start" .}}
-
-
-{{end}}{{if and .Stack.HealthProbe .Stack.HealthProbe.NotChecked}}{{/* R-772: the declared health check could not run — said, never shown as healthy. */}}
-
+{{template "app_shell_start" .}}
+
+{{/* „NAPLO" — today's logs page inside the shell. Its live poller runs ONLY on this tab, which is
+ the reason the tabs are four URLs and not four hidden panels: a poller on the settings tab
+ would ask the box for 200 log lines every three seconds for nothing. */}}
+
+
{{.Logs}}
@@ -22,14 +25,14 @@
var toggleBtn = document.getElementById('logs-toggle');
var live = true;
var timer = null;
- var stackName = '{{.Stack.Name}}';
+ var appSlug = '{{.Meta.Slug}}';
function isAtBottom() {
return container.scrollHeight - container.scrollTop - container.clientHeight < 50;
}
window.fetchLogs = function() {
- fetch('/stacks/' + stackName + '/logs?raw=1')
+ fetch('/apps/' + appSlug + '/logs?raw=1')
.then(function(r) { return r.text(); })
.then(function(text) {
var wasAtBottom = isAtBottom();
@@ -67,6 +70,7 @@
container.scrollTop = container.scrollHeight;
startPolling();
})();
-
+
+
{{template "layout_end" .}}
{{end}}
diff --git a/controller/internal/web/templates/app_settings.html b/controller/internal/web/templates/app_settings.html
new file mode 100644
index 0000000..8e73781
--- /dev/null
+++ b/controller/internal/web/templates/app_settings.html
@@ -0,0 +1,715 @@
+{{define "app_settings"}}
+{{template "layout_start" .}}
+{{template "app_shell_start" .}}
+
+{{/* „BEALLITASOK" — the tab an installed app opens on.
+
+ Order, and each step of it is a decision:
+ 1. Mukodes — Start / Restart / Stop. They used to sit in the page header and read as
+ menu buttons; a state change is an action on the app, so it is a card.
+ 2. what the app still wants FROM the household — the setup gate, an after-start value, the
+ sign-up window, the first login. Above the form, because an app that is waiting on the
+ household is not asking them to review settings.
+ 3. the settings themselves — generated values, install-time values (read-only), the optional
+ ones, e-mail, integrations, geo.
+
+ „Adatok es mentes" holds everything about the FILES, including the backup card that used to
+ sit on this page. */}}
+
+
+ {{if .FlashSuccess}}
{{.FlashSuccess}}
{{end}}
+ {{if .FlashError}}
{{.FlashError}}
{{end}}
+
+ {{/* 1. MUKODES. Stop asks first — it takes the app away from everyone in the house, and the
+ protected stacks refuse it server-side regardless (traefik, cloudflared, the controller
+ itself and always samba). */}}
+
+
+{{/* 2. WHAT THE APP STILL WANTS FROM THE HOUSEHOLD. */}}
+{{- if .InstallHoldClosed}}
+
+
{{T "app_info.install_hold_title"}}
+
{{T "app_info.install_hold_closed"}}
+
+{{- end}}
+{{- if .SetupGateClosed}}
+
+
{{T "app_info.setup_gate_title"}}
+
{{T "app_info.setup_gate_closed"}}
{{- if .SignupNative}}
+
{{T "app_info.restarts_once"}}
{{- end}}
+ {{- if .SetupGateHasProbe}}
+
{{T "app_info.setup_gate_probe"}}
+
+ {{- else}}
+
{{T "app_info.setup_gate_button_hint"}}
+
+ {{- end}}
+
+
+{{- end}}
+
+ {{/* The generic after-start card (Part 4, D2 = A). The catalog says a value is asked after the
+ app starts and supplies its label; the ACTION is Go code in after_start.go, keyed by slug and
+ env var. A field with no registered action renders nothing and POSTs 404 — the catalog
+ cannot make the box run anything. */}}
+ {{- range .AfterStartFields}}
+
+
{{T "app_page.after_start_title"}}
+ {{if .Description}}
{{.Description}}
{{end}}
+
+
+
+
+
+
+ {{- end}}
+{{- if .CloseSignupOffered}}
+
+
{{T "app_info.signup_title"}}
+
{{T "app_info.close_signup_text"}}
{{- if .SignupNative}}
+
{{T "app_info.restarts_once"}}
{{- end}}
+
+
+
+{{- end}}
+{{- if or .SignupClosed .SignupOpenUntil}}
+
{{end}}
+ {{if eq .Type "secret_input"}}
+ {{/* R-255 (v0.308.0): a stored secret is NOT in the page; it is fetched on demand like the
+ auto-generated ones (R-254). A blank field that was never revealed is not sent, so
+ saving other fields keeps it. */}}
+ {{$set := index $.CurrentValues .EnvVar}}
+
+
+{{if .InitialCreds}}
+
+{{end}}
+
+{{template "app_press_script" .}}
+
+
+{{template "layout_end" .}}
+{{end}}
diff --git a/controller/internal/web/templates/app_shell.html b/controller/internal/web/templates/app_shell.html
new file mode 100644
index 0000000..76dbca1
--- /dev/null
+++ b/controller/internal/web/templates/app_shell.html
@@ -0,0 +1,137 @@
+{{/* THE APP PAGE'S SHARED SHELL (v0.315.0).
+
+ One header and one set of banners for all four tabs, so a held app says it is held on the log
+ tab too and the household never has to go back to the details page to find out why.
+
+ Order is load-bearing and is the alert-placement rule (R-571), unchanged: the page-level
+ banners sit ABOVE the tab bar, because they are about the APP, not about the tab. An inline
+ alert belongs inside the tab that owns it.
+
+ The header carries NO Start/Restart/Stop. They used to sit here and read as menu buttons; they
+ are now the first card of the „Beallitasok" tab, where a state change belongs. */}}
+
+{{define "app_shell_start"}}
+
+
+{{template "app_banners" .}}
+{{template "app_tab_bar" .}}
+{{end}}
+
+{{/* app_banners — every page-level alert the app can carry, on EVERY tab. Each keeps the marker it
+ had on the details page, so the tests and the operator's greps still find it. */}}
+{{define "app_banners"}}
+{{- if .Meta.IsAbandoned}}
+
+{{end}}{{if and .Stack.HealthProbe .Stack.HealthProbe.NotChecked}}{{/* R-772: the declared health check could not run — said, never shown as healthy. */}}
+
+{{end}}
+{{end}}
+
+{{/* app_tab_bar — plain links, no JavaScript. An alert's link must land ON a tab, and the log
+ poller must not run on the other three; hidden panels give neither. */}}
+{{define "app_tab_bar"}}
+{{- if .AppTabs}}
+
+{{- end}}
+{{end}}
+
+{{/* app_press_script — the shared POST-and-reload used by every card button on the page. It was the
+ details page's own script; the cards moved to tabs and it came with them. */}}
+{{define "app_press_script"}}
+
+{{end}}
diff --git a/controller/internal/web/templates/backups_apps.html b/controller/internal/web/templates/backups_apps.html
index 50e4d06..b76934d 100644
--- a/controller/internal/web/templates/backups_apps.html
+++ b/controller/internal/web/templates/backups_apps.html
@@ -224,7 +224,7 @@
{{if .Tier2UserDisabled}}
{{T "backups_apps.2_mentes_kikapcsolva"}}
diff --git a/controller/internal/web/templates/dashboard.html b/controller/internal/web/templates/dashboard.html
index 09538d3..5e3de62 100644
--- a/controller/internal/web/templates/dashboard.html
+++ b/controller/internal/web/templates/dashboard.html
@@ -207,7 +207,7 @@
{{end}}
{{else if not .Deployed}}
- {{T "dashboard.telepites"}}
+ {{T "dashboard.telepites"}}
{{else}}
{{if isOperational .State}}
{{$subdomain := index $.Subdomains .Name}}
@@ -218,7 +218,7 @@
{{if not .Orphaned}}{{end}}
{{end}}
- {{T "dashboard.naplo"}}
+ {{T "dashboard.naplo"}}
{{if .Orphaned}}{{end}}
{{end}}
{{template "app_list_row_end"}}
diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html
index b8d8e37..754918e 100644
--- a/controller/internal/web/templates/deploy.html
+++ b/controller/internal/web/templates/deploy.html
@@ -1,27 +1,25 @@
{{define "deploy"}}
{{template "layout_start" .}}
+{{/* THE INSTALL WIZARD. v0.315.0: this page is the install flow ONLY. An installed app's settings
+ are the app page's „Beallitasok" tab (app_settings.html), which renders the same form fields
+ from the shared `app_deploy_fields` partial with .AlreadyDeployed true — so the two cannot
+ drift apart.
+
+ Vissza and Megsem go to the APP, not to the list: the household got here from the app's page
+ and that is where cancelling belongs. */}}
+
{{end}}
- {{if eq .Type "secret_input"}}
- {{/* R-255 (v0.308.0): a stored secret is NOT in the page; it is fetched on demand like the
- auto-generated ones (R-254). A blank field that was never revealed is not sent, so
- saving other fields keeps it. */}}
- {{$set := index $.CurrentValues .EnvVar}}
-
- {{if not $.AlreadyDeployed}}
- {{T "deploy.az_aldomain_telepites_utan_nem"}}
- {{end}}
- {{else if eq .Type "select"}}
-
- {{else if eq .Type "password"}}
-
- {{- if $.AlreadyDeployed}}
- {{- /* R-709 (v0.280.0): an installed app's password is never in this page; the eye fetches it. */}}
-
- {{- if not (and $.RestoredLogins (index $.RestoredLogins .EnvVar))}}
-
- {{- end}}
- {{- else}}
-
-
-
- {{- end}}
-
{{if .Meta.Resources.MemRequest}}~{{.Meta.Resources.MemRequest}}{{end}}
- {{if .Meta.Resources.PiCompatible}}{{T "common.pi_kompatibilis"}}{{end}}
{{if .Meta.Resources.NeedsHDD}}{{T "common.hdd_szukseges"}}{{end}}
{{if .Meta.Resources.HungarianUI}}{{T "common.magyar_felulet"}}{{end}}
{{if and .Deployed (index $.StorageLabels .Name)}}{{index $.StorageLabels .Name}}{{end}}
@@ -85,7 +84,7 @@
{{else if not .Deployed}}
{{/* The endpoint refuses a non-installable template server-side; hiding the button
here keeps the two consistent rather than offering an action that will fail. */}}
- {{if .InstallInterrupted}}
Ennek az alkalmazásnak az adatait másik csatlakoztatott tárhelyre helyezheted át. Az alkalmazás az áthelyezés alatt rövid időre leáll, az adatok pedig csak az ellenőrzés és a sikeres újraindítás után törlődnek a régi helyről.
+
+
+
+
+
+
+
+
+
+
+
+
Biztonsági mentés
+
+
+
+ Az alkalmazás adatbázisa és Docker kötetei automatikusan bekerülnek az éjszakai biztonsági mentésbe.
+ Mentési állapot → · Mentési beállítások →
+
+
+
+
+
+
2. mentés beállítása — PrivateBin
+
+
+
+ A 2. mentés egy másik fizikai meghajtóra készít másolatot az alkalmazás
+ helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető
+ felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).
+
+
+
+
Jelenlegi állapot
+
+
+ 2. mentés
+ Bekapcsolva
+
+
+
+ Cél meghajtó
+ HDD kettő
+
+
+ Kiválasztás módja
+ automatikus — a legnagyobb szabad hely
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PrivateBin — Exportálás
+
+
Mentés helye
+
+
+
+
+ Konfiguráció:
+ -
+
+
+ Felhasználói adatok:
+ -
+
+
+ Összesen:
+ -(szabad: -)
+
+
+ Becsült idő:
+ -
+
+
+
+
+
+
+
Mindig része a mentésnek:
+
+
+
+
Választható tartalom:
+
+
+
+
+
A kihagyott mappák tartalma a Fájlkezelőben bármikor elérhető, és külön is lementhető.
+
+ Az alkalmazás adatbázisa és Docker kötetei automatikusan bekerülnek az éjszakai biztonsági mentésbe.
+ Mentési állapot → · Mentési beállítások →
+
+
+
+ Az alkalmazás adatbázisa és Docker kötetei automatikusan bekerülnek az éjszakai biztonsági mentésbe.
+ Mentési állapot → · Mentési beállítások →
+
+
+
+
+
+
2. mentés beállítása — Private Bin
+
+
+
+ A 2. mentés egy másik fizikai meghajtóra készít másolatot az alkalmazás
+ helyreállítási csomagjáról és adatairól. Ez az egyetlen off-drive védelem a böngészhető
+ felhasználói fájlokhoz (a teljes rendszermentés/PBS nem éri el ezeket).
+
+
+
+
+
+ Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak, és a teljes rendszermentés része.
+ Hogy a rendszermentés most hová készül, azt nem tudtuk lekérdezni — a Mentések oldalon láthatod.
+