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" .}} - {{else}} {{T "backups_apps.1_mentes_auto"}} @@ -319,7 +319,7 @@ {{.Tier2LastError}} {{end}}
- {{T "backups_apps.beallitas"}} + {{T "backups_apps.beallitas"}}
{{end}} 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. */}} + +
{{if .FlashSuccess}}
{{.FlashSuccess}}
{{end}} {{if .FlashError}}
{{.FlashError}}
{{end}} @@ -34,7 +32,6 @@ {{if .Meta.Description}}

{{.Meta.Description}}

{{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}}
@@ -44,395 +41,6 @@
- {{if .AlreadyDeployed}} -
- {{T "deploy.ez_az_alkalmazas_mar_telepitve"}} -
- {{if .StorageInfo}} -
-

{{T "deploy.adattarolas"}}

-
-
- {{T "deploy.tarhely"}} - {{.StorageInfo.Label}} ({{.StorageInfo.Path}}) -
- {{if .StorageInfo.DataSizeHuman}} -
- {{T "deploy.adatmeret"}} - {{numText .StorageInfo.DataSizeHuman}} -
- {{end}} - {{if .StorageInfo.FreeHuman}} -
- {{T "deploy.szabad_hely"}} - {{T "deploy.szabad"}} -
- {{end}} -
- {{if .OtherStoragePaths}} - - {{T "deploy.mozgatas_masik_tarolora"}} - - {{end}} -
- {{end}} - {{if .StaleData}} -
-

{{T "deploy.korabbi_adatok"}}

-

- {{T "deploy.az_alkalmazas_adatainak_masolata_megtala"}} -

- {{range .StaleData}} -
-
-
- {{T "deploy.tarhely"}} - {{.Label}} ({{.Path}}) -
-
- {{T "deploy.meret"}} - {{numText .SizeHuman}} -
-
- {{T "deploy.mappak"}} - {{range .Mounts}}{{.}}
{{end}}
-
-
- -
- {{end}} -
- {{end}} - {{end}} - - {{if .AlreadyDeployed}} -
-

{{T "deploy.biztonsagi_mentes"}}

- -
- - {{T "deploy.az_alkalmazas_adatbazisa_es_docker"}} - -
-
- {{end}} - - {{if .HasIntegrations}} -
-

{{T "deploy.integraciok"}}

-

- {{T "deploy.mas_telepitett_alkalmazasokkal_valo_ossz"}} -

- - {{range .Integrations}} -
-
- {{.Label}} -

{{.Description}}

- {{if not .TargetDeployed}} - {{T "deploy.nincs_telepitve"}} - {{else if not .TargetRunning}} - {{T "deploy.celalkalmazas_leallitva"}} - {{else if eq .Status "error"}} - {{T "common.hiba"}} - {{else if .Enabled}} - {{T "deploy.aktiv"}} - {{end}} -
- -
- {{end}} -
- - - {{end}} - - {{if .GeoGlobalEnabled}} -
-

{{T "deploy.foldrajzi_korlatozas"}}

-

- {{T "deploy.az_alkalmazas_egyeni_orszagkorlatozas_ne"}} -

- - - -
-
- -
- -
-
-
-
-
- - -
-
-
- - - {{end}} - - {{if .HasOptionalConfig}} -
-

{{T "deploy.opcionalis_beallitasok"}}

- {{range .OptionalConfig}} -
-

{{.Group}}

- {{if .Description}}

{{.Description}}

{{end}} - -
- {{range .Fields}} -
- - {{if .HelpText}}

{{.HelpText}}

{{end}} - {{if .HelpURL}}

{{T "deploy.regisztracios_utmutato"}}

{{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 $set}}{{end}} -
- {{else}} - - {{end}} -
- {{end}} -
-
- {{end}} - -
- - -
-
- - - {{end}} - - {{if .AppEmailSupported}} -
-

{{T "deploy.email_kuldes"}}

-

- {{T "deploy.ez_az_alkalmazas_tud_emailt"}} -

- {{if not .AppEmailGlobalOn}} -

- {{T "deploy.az_alkalmazas_email_jelenleg_ki"}} -

- {{end}} -
- {{$.CSRFField}} - -
- - {{T "deploy.a_mentes_ujrainditja_az_alkalmazast"}} -
-
-
- {{end}} - {{if and (not .AlreadyDeployed) .MemoryInfo}} {{with .MemoryInfo}} {{if .Available}} @@ -497,177 +105,13 @@ {{T "deploy.nincs_eleg_szabad_tarhely_a"}} {{end}} - {{if .AutoFields}} -
-

{{T "deploy.automatikusan_generalt_ertekek"}}

- {{if .AlreadyDeployed}} -

{{T "deploy.ezek_az_ertekek_automatikusan_jottek"}}

- {{else}} -

{{T "deploy.ezek_az_ertekek_a_telepitessel"}}

- {{end}} - {{$autoValues := .AutoFieldValues}} - {{$stackName := .Stack.Name}} - {{$isDeployed := .AlreadyDeployed}} - {{range .AutoFields}} - {{$val := index $autoValues .EnvVar}} -
- - {{if $val}} - {{if eq .Type "secret"}} - {{if $isDeployed}} - -
- - -
- {{else}} -
- - -
- {{end}} - {{else}} - - {{end}} - {{if and (not $isDeployed) (eq .Type "secret")}} - - {{end}} - {{end}} -
- {{end}} -
- {{end}} - {{if .UserFields}} -
-

{{T "deploy.beallitasok"}}

- {{range .UserFields}} -
- - - {{if eq .Type "subdomain"}} -
- - .{{$.Domain}} -
- {{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 $.AlreadyDeployed}} - {{if and $.RestoredLogins (index $.RestoredLogins .EnvVar)}}{{T "deploy.login_from_backup"}}{{else}}{{T "deploy.telepiteskor_beallitott_kezdeti_jelszo_h"}}{{end}} - {{else}} -
- -
- {{end}} - {{else if eq .Type "boolean"}} - - {{else if eq .Type "path"}} - {{if $.StoragePaths}} - - -
- {{T "deploy.a_kivalasztott_meghajton_az_alkalmazas"}} -
- {{else}} - - {{T "deploy.nincs_regisztralt_adattarolo_adja_meg"}} - {{end}} - {{else}} - - {{end}} - - {{if and .Description (ne .Type "subdomain")}} - {{.Description}} - {{end}} -
- {{end}} -
- {{end}} + {{template "app_deploy_fields" .}} {{if not .AlreadyDeployed}}
- {{T "common.megsem"}} + {{T "common.megsem"}}
{{end}} @@ -808,7 +252,7 @@ function buildPostDeployCard(stackName) { // Action buttons html += '
' + - '{{T "deploy.beallitasok_es_jelszavak_megtekintese"}}' + + '{{T "deploy.beallitasok_es_jelszavak_megtekintese"}}' + '{{T "deploy.alkalmazasok"}}' + '
'; @@ -831,56 +275,6 @@ document.addEventListener('DOMContentLoaded', function() { // R-254 site two: on an already-deployed app the value is NOT in this page — it is fetched on // demand, and the server records the act. Nothing caches it between presses. // R-709 (v0.280.0): an installed app's `type: password` value, fetched on demand like a secret. -function revealPasswordField(stackName, envVar, btn) { - var el = document.getElementById('field-' + envVar); - if (!el) return; - if (btn.dataset.shown === '1') { - el.value = ''; - el.type = 'password'; - btn.dataset.shown = ''; - return; - } - btn.disabled = true; - fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', { - method: 'POST', - headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), - credentials: 'same-origin', - body: 'env_var=' + encodeURIComponent(envVar) - }).then(function (r) { return r.json(); }).then(function (j) { - btn.disabled = false; - if (!j.ok) { showAlert(j.error || '{{T "deploy.a_lekeres_nem_sikerult"}}'); return; } - el.value = j.data.value; - el.type = 'text'; - el.dataset.revealed = '1'; - btn.dataset.shown = '1'; - }).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); }); -} -function revealAutoField(stackName, envVar, btn, elId) { - var el = document.getElementById(elId || ('auto-field-' + envVar)); - if (!el) return; - if (btn.dataset.shown === '1') { - el.value = ''; - el.type = 'password'; - el.dataset.revealed = ''; - btn.dataset.shown = ''; - btn.textContent = '{{T "deploy.megjelenites"}}'; - return; - } - btn.disabled = true; - fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', { - method: 'POST', - headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()), - credentials: 'same-origin', - body: 'env_var=' + encodeURIComponent(envVar) - }).then(function (r) { return r.json(); }).then(function (j) { - btn.disabled = false; - if (!j.ok) { showAlert(j.error || '{{T "deploy.a_lekeres_nem_sikerult"}}'); return; } - el.value = j.data.value; - el.type = 'text'; - btn.dataset.shown = '1'; - btn.textContent = '{{T "deploy.elrejtes"}}'; - }).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); }); -} function toggleAutoField(fieldId, btn) { var el = document.getElementById(fieldId); if (!el) return; @@ -947,53 +341,6 @@ function copyPostDeployCred(btn, val) { } } -function deleteStaleData(stackName, stalePath, btn) { - // Two-step inline confirm (the irreversible delete keeps its double acknowledgement). - felhomConfirm(btn, '{{T "deploy.biztosan_torolni_szeretned_a_korabbi"}} ' + stalePath + ' {{T "deploy.figyelem_a_muvelet_visszavonhatatlan_elo"}}', function () { - felhomConfirm(btn, '{{T "deploy.utolso_figyelmeztetes_a_torles_visszavon"}}', function () { - deleteStaleDataRun(stackName, stalePath, btn); - }); - }); -} - -function deleteStaleDataRun(stackName, stalePath, btn) { - btn.disabled = true; - btn.textContent = '{{T "deploy.torles_folyamatban"}}'; - - fetch('/api/storage/stale-cleanup', { - method: 'POST', - headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), - body: JSON.stringify({stack_name: stackName, stale_path: stalePath}) - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (!data.ok) { - showAlert('{{T "common.hiba_kettospont"}} ' + (data.error || '{{T "deploy.ismeretlen_hiba"}}')); - btn.disabled = false; - btn.textContent = '{{T "deploy.korabbi_adatok_torlese"}}'; - return; - } - var msg = '{{T "deploy.korabbi_adatok_torolve_n_nfelszabaditott"}} ' + (data.freed_human || '?'); - if (data.errors && data.errors.length > 0) { - msg += '{{T "deploy.n_nnehany_hiba_tortent_n"}}' + data.errors.join('\n'); - } - showAlert(msg); - // Remove the stale data card from DOM - var item = btn.closest('.stale-data-item'); - if (item) item.remove(); - // If no more stale items, remove the whole section - var container = document.querySelector('.deploy-stale-data'); - if (container && container.querySelectorAll('.stale-data-item').length === 0) { - container.remove(); - } - }) - .catch(function(e) { - showAlert('{{T "deploy.halozati_hiba_2"}} ' + e.message); - btn.disabled = false; - btn.textContent = '{{T "deploy.korabbi_adatok_torlese"}}'; - }); -} - document.getElementById('deploy-form').addEventListener('submit', async function(e) { e.preventDefault(); @@ -1143,7 +490,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function progressEl.querySelector('h3').textContent = '{{T "deploy.telepites_sikertelen"}}'; resultEl.innerHTML = '
' + '{{T "deploy.a_telepites_nem_sikerult"}} ' + deployError + - '
{{T "deploy.naplok_megtekintese"}}' + + '{{T "deploy.naplok_megtekintese"}}' + ' {{T "deploy.alkalmazasok_2"}}'; resultEl.style.display = 'block'; } else if (state === 'deploying') { @@ -1178,7 +525,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function progressEl.querySelector('h3').textContent = '{{T "deploy.telepites_sikertelen"}}'; resultEl.innerHTML = '
' + '{{T "deploy.a_kontener_leallt_ellenorizze_a"}}' + - '
{{T "deploy.naplok_megtekintese"}}' + + '{{T "deploy.naplok_megtekintese"}}' + ' {{T "deploy.alkalmazasok_2"}}'; resultEl.style.display = 'block'; } diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index 570ed1d..97f1092 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -56,7 +56,6 @@
{{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}}
{{T "stacks.install_interrupted"}}
{{end}}{{if canInstall .Meta}}{{T "stacks.telepites"}}{{end}} + {{if .InstallInterrupted}}
{{T "stacks.install_interrupted"}}
{{end}}{{if canInstall .Meta}}{{T "stacks.telepites"}}{{end}} {{T "stacks.reszletek"}} {{else}} {{/* Slice 4 (v0.238.0): the UPDATING and HELD checks come BEFORE isOperational. That @@ -110,7 +109,7 @@ {{if not .Orphaned}}{{end}} {{end}} - {{T "stacks.naplok"}} + {{T "stacks.naplok"}} {{if not .Orphaned}}{{T "stacks.reszletek"}}{{end}} {{if .Orphaned}}{{end}} {{end}} diff --git a/controller/internal/web/templates/tier2_config.html b/controller/internal/web/templates/tier2_config.html deleted file mode 100644 index 7b99556..0000000 --- a/controller/internal/web/templates/tier2_config.html +++ /dev/null @@ -1,99 +0,0 @@ -{{define "tier2_config"}} -{{template "layout_start" .}} - - - -{{if .Flash}}
{{.Flash}}
{{end}} -{{if .FlashError}}
{{.FlashError}}
{{end}} - -
-{{with .Tier2}} -

- {{T "tier2_config.a_2_mentes_egy_masik"}} -

- - {{if not .IsHDDApp}} - {{if eq $.SystemBackup "protected"}} -
- {{T "tier2_config.rendszerlemez.vedett"}} -
- {{else if eq $.SystemBackup "same_disk"}} -
- {{T "tier2_config.rendszerlemez.ugyanaz"}} -
- {{else if eq $.SystemBackup "absent"}} -
- {{T "tier2_config.rendszerlemez.hianyzik"}} -
- {{else}} -
- {{T "tier2_config.rendszerlemez.ismeretlen"}} -
- {{end}} - {{else}} -

{{T "tier2_config.jelenlegi_allapot"}}

-
-
- {{T "tier2_config.2_mentes"}} - {{if .Disabled}}{{T "tier2_config.kikapcsolva"}}{{else}}{{T "tier2_config.bekapcsolva"}}{{end}} -
- {{if .NoTarget}} -
- {{T "tier2_config.cel"}} - {{T "tier2_config.nincs_elerheto_off_drive_cel"}} -
-
- {{T "tier2_config.megjegyzes"}} - {{.NoTargetReason}} -
- {{else}} -
- {{T "tier2_config.cel_meghajto"}} - {{.EffectiveLabel}}{{if .EffectiveIsSSD}} {{T "tier2_config.csak_db_konfiguracio"}}{{end}} -
-
- {{T "tier2_config.kivalasztas_modja"}} - {{if .Preferred}}{{T "tier2_config.kezi_valasztas"}}{{else}}{{T "tier2_config.automatikus_kisbetu"}}{{end}} — {{.EffectiveDesc}} -
- {{end}} -
- - {{if .EffectiveIsSSD}} -
- {{T "tier2_config.jelenleg_csak_a_belso_ssd"}} -
- {{end}} - -
- {{$.CSRFField}} -
- -
-
- - - {{if not .Alternatives}} -
- {{T "tier2_config.nincs_masik_adatmeghajto_automatikus_cel"}} -
- {{end}} -
- -
- {{end}} -{{end}} -
- -{{template "layout_end" .}} -{{end}} diff --git a/controller/internal/web/testdata/i18n_parity/app_data_deployed.html b/controller/internal/web/testdata/i18n_parity/app_data_deployed.html new file mode 100644 index 0000000..93062ba --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/app_data_deployed.html @@ -0,0 +1,1061 @@ + + + + + + + + PrivateBin — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + +
+ +
+

Egy teszt alkalmazás leírása

+
+ ~128M RAM + + HDD szükséges + Magyar felület +
+
+
+ + + + + + + + + + + + +
+ + + + +
+

Hova tegyem a fájlokat?

+

Ezeket a mappákat a Fájlkezelőben éred el. Előfordulhat, hogy először be kell jelentkezned a Fájlkezelőbe.

+
+ +
+
+ Feltöltött fájlok ↗ + 12 GB szabad +
+

Mentve minden éjjel.

+
+ +
+
+ + + +
+

Áthelyezés másik tárhelyre

+

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 +
+ +
+ + + +
+ +
+ +
+
+ + + +
+ Nincs másik adatmeghajtó — automatikus cél a belső SSD (csak DB/konfiguráció). Egy 2. + adatmeghajtó hozzáadásával a teljes adat is off-drive menthető. +
+ +
+ +
+ + +
+ + + +

PrivateBin — Exportálás

+
+

Mentés helye

+ + + + + + +

Jelszó (opcionális)

+
+ + +
+ + + + +
+ + + + + + + + +
+ + + + + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_export.html b/controller/internal/web/testdata/i18n_parity/app_data_export.html similarity index 93% rename from controller/internal/web/testdata/i18n_parity/app_export.html rename to controller/internal/web/testdata/i18n_parity/app_data_export.html index dedb73e..9de2192 100644 --- a/controller/internal/web/testdata/i18n_parity/app_export.html +++ b/controller/internal/web/testdata/i18n_parity/app_data_export.html @@ -174,11 +174,80 @@ +
+ +
+

Egy teszt alkalmazás leírása

+
+ ~128M RAM + + HDD szükséges + Magyar felület +
+
+
+ + + + + + + + + + + + +
+ + + + + + + + +
+

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 → + +
+
+ + + + +

Private Bin — Exportálás

Mentés helye

+
  • + + + Kijelentkezés ↗ +
    +
    + +
    + + + + + + + + + + +
    + +
    +

    Egy teszt alkalmazás leírása

    +
    + ~128M RAM + + HDD szükséges + Magyar felület +
    +
    +
    + + + + + + + + + + + + +
    +
    A beállítás elmentve rendben.
    +
    A mentés nem sikerült most.
    + + + + + + +
    +

    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 — 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. +
    + + + +
    + + + +
    + + + + + +
    + + + + diff --git a/controller/internal/web/testdata/i18n_parity/app_info_known_login_changed.html b/controller/internal/web/testdata/i18n_parity/app_data_not_hdd_absent.html similarity index 92% rename from controller/internal/web/testdata/i18n_parity/app_info_known_login_changed.html rename to controller/internal/web/testdata/i18n_parity/app_data_not_hdd_absent.html index 41abc27..abc5439 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_known_login_changed.html +++ b/controller/internal/web/testdata/i18n_parity/app_data_not_hdd_absent.html @@ -5,7 +5,7 @@ - Calibre-Web — Felhom.eu + Private Bin — Felhom.eu @@ -175,7 +175,7 @@ - - - - - -
    - -
    - -

    - +
    + +
    +

    Egy teszt alkalmazás leírása

    - ~ RAM + ~128M RAM - - Csak x86 - + HDD szükséges + Magyar felület
    -
    -
    - - - -
    + + + + + - -
    +
    + - + + + + + +
    +

    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 — 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). +

    - - - -
    -

    Alapértelmezett belépés

    -

    admin / admin123

    -

    Ez az alkalmazás egy ismert, közös jelszóval indul: admin / admin123. Telepítés után azonnal változtasd meg.

    - - +
    + Ennek az alkalmazásnak az adatai a belső rendszerlemezen vannak. A teljes rendszermentés meghajtója + most nem érhető el — amíg vissza nem csatlakoztatod, erről az alkalmazásról sem készül friss rendszermentés.
    - +
    +
    + + + @@ -609,28 +652,3 @@ - diff --git a/controller/internal/web/testdata/i18n_parity/app_info_plex_claim.html b/controller/internal/web/testdata/i18n_parity/app_data_not_hdd_protected.html similarity index 91% rename from controller/internal/web/testdata/i18n_parity/app_info_plex_claim.html rename to controller/internal/web/testdata/i18n_parity/app_data_not_hdd_protected.html index c94eb6d..e91fa06 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_plex_claim.html +++ b/controller/internal/web/testdata/i18n_parity/app_data_not_hdd_protected.html @@ -5,7 +5,7 @@ - Plex — Felhom.eu + Private Bin — Felhom.eu @@ -175,7 +175,7 @@ - - - - - -
    - -
    - -

    - +
    + +
    +

    Egy teszt alkalmazás leírása

    - ~ RAM + ~128M RAM - - Csak x86 - + HDD szükséges + Magyar felület
    -
    -
    - - - -
    -
    -

    Első beállítás

    -

    Most csak te éred el ezt az alkalmazást, amíg be vagy jelentkezve a vezérlőpultba. Így más nem hozhatja létre az első admin fiókot. Nyisd meg, és végezd el az első beállítást.

    -
    -

    Nyisd meg a https://plex.tv/claim oldalt a Plex-fiókoddal, másold ki a kódot, és illeszd be ide. A kód 4 percig érvényes, de a Plex már fut, így nem kell sietned a telepítéssel.

    -
    - - + + + + + + + + + + +
    + + + + + + + + +
    +

    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 → +
    -
    -

    Ha kész, a doboz magától észreveszi, és mindenkinek megnyitja az alkalmazást.

    - - + + +

    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, amelyek + szerepelnek a teljes rendszermentésben, és az egy külön meghajtóra készül. A 2. (off-drive) másolat + kiegészítő, és elsősorban a külső adatmeghajtón tárolt alkalmazásokhoz készül — ehhez az + alkalmazáshoz nincs külön teendő. +
    + + +
    +
    + @@ -602,28 +654,3 @@ - diff --git a/controller/internal/web/testdata/i18n_parity/app_info_close_signup_native.html b/controller/internal/web/testdata/i18n_parity/app_data_not_hdd_same_disk.html similarity index 91% rename from controller/internal/web/testdata/i18n_parity/app_info_close_signup_native.html rename to controller/internal/web/testdata/i18n_parity/app_data_not_hdd_same_disk.html index efecc6e..1b3d7d8 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_close_signup_native.html +++ b/controller/internal/web/testdata/i18n_parity/app_data_not_hdd_same_disk.html @@ -5,7 +5,7 @@ - Opengist — Felhom.eu + Private Bin — Felhom.eu @@ -175,7 +175,7 @@ - - - - - -
    - -
    - -

    - +
    + +
    +

    Egy teszt alkalmazás leírása

    - ~ RAM + ~128M RAM - - Csak x86 - + HDD szükséges + Magyar felület
    -
    -
    - - - -
    -
    -

    Regisztráció

    -

    Ennél az alkalmazásnál bárki regisztrálhat, aki megtalálja a címét. Zárd le a regisztrációt most — a családtagjaidat utána is fel tudod venni.

    -

    Ehhez az alkalmazás egyszer újraindul, és közben rövid ideig nem érhető el.

    - - + + + + + + + + + + +
    + + + + + + + + +
    +

    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 — 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. A teljes rendszermentés + jelenleg ugyanerre a lemezre készül — így hibás fájlok ellen véd, lemezhiba ellen nem. + Ha csatlakoztatsz egy második meghajtót, és a Mentések oldalon kijelölöd a rendszermentés helyéül, ez az alkalmazás is védett lesz. +
    + + +
    +
    + @@ -594,28 +653,3 @@ - diff --git a/controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html b/controller/internal/web/testdata/i18n_parity/app_data_notarget_disabled.html similarity index 90% rename from controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html rename to controller/internal/web/testdata/i18n_parity/app_data_notarget_disabled.html index a954b98..f7d170e 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html +++ b/controller/internal/web/testdata/i18n_parity/app_data_notarget_disabled.html @@ -5,7 +5,7 @@ - Második mentés beállítása — Felhom.eu + Private Bin — Felhom.eu @@ -115,7 +115,7 @@ -