From e66d4669d6f1a3fd526f32c64f6edb67566bca8f Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 15 Oct 2026 18:37:50 +0200 Subject: [PATCH] v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app was six pages — details, install/settings, logs, export, the per-app backup panel and a details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/, one shared header and one set of banners. settings Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the household + the install-time and optional settings. The default for an installed app, RENDERED at the bare URL and never redirected to. data the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export guide tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs link — and the WHOLE page for an app that is not installed logs the log, and the only tab whose poller runs The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons. The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser caches beyond anyone's reach — to the tab that now holds them, query string carried through, with name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs. A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true` and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a POST for an undeclared field then reached Plex and opened the setup gate. TestAfterStart_RegistryAloneIsNotAnInvitation pins it. New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by field and rendered with the household's own address filled in. It renders only when checked_version names the image tag its author clicked through. No template carries one yet. The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box on 0.314.0 would read "csak x86" on every app (R-1067). template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not become vacuous. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y --- CHANGELOG.md | 44 + CONTEXT.md | 19 +- REPORT.md | 204 +++- REUSE.md | 3 + controller/README.md | 65 +- controller/internal/i18n/locales/en.json | 18 +- controller/internal/i18n/locales/hu.json | 18 +- controller/internal/stacks/appinfo_address.go | 18 + controller/internal/stacks/guide_test.go | 260 ++++ controller/internal/stacks/manager.go | 1 + controller/internal/stacks/metadata.go | 67 ++ controller/internal/stacks/metadata_i18n.go | 128 +- controller/internal/web/after_start.go | 170 +++ .../internal/web/app_export_domain_test.go | 13 +- .../web/app_initcreds_exposure_test.go | 2 +- controller/internal/web/app_page.go | 314 +++++ controller/internal/web/app_page_test.go | 580 +++++++++ .../internal/web/app_update_switch_test.go | 7 +- .../internal/web/d28_unhealthy_page_test.go | 2 +- controller/internal/web/datapath_card_test.go | 4 +- .../internal/web/filebrowser_login_test.go | 2 +- controller/internal/web/guide_render_test.go | 179 +++ controller/internal/web/handler_export.go | 27 - controller/internal/web/handlers.go | 57 +- controller/internal/web/i18n_cases_a_test.go | 19 +- controller/internal/web/i18n_cases_b_test.go | 45 +- controller/internal/web/i18n_parity_test.go | 144 ++- controller/internal/web/i18n_wiring_test.go | 10 +- .../internal/web/launcher_polish_test.go | 2 +- controller/internal/web/lifecycle_test.go | 9 +- controller/internal/web/media_events.go | 34 +- .../internal/web/meta_copy_allowlist_test.go | 18 +- controller/internal/web/networkstub_test.go | 10 +- controller/internal/web/plex_claim_test.go | 31 +- .../internal/web/r499_system_backup_test.go | 2 +- .../internal/web/r606_update_pages_test.go | 6 +- .../internal/web/r621_hold_panel_test.go | 8 +- .../internal/web/r625_r647_held_test.go | 6 +- .../internal/web/r659_held_page_test.go | 4 +- .../web/r772_not_checked_page_test.go | 2 +- controller/internal/web/server.go | 55 +- controller/internal/web/setup_gate.go | 2 +- .../internal/web/setup_gate_page_test.go | 4 +- .../{app_export.html => app_data.html} | 193 ++- .../internal/web/templates/app_fields.html | 181 +++ .../internal/web/templates/app_guide.html | 109 ++ .../internal/web/templates/app_import.html | 9 + .../internal/web/templates/app_info.html | 405 ------- .../templates/{logs.html => app_logs.html} | 20 +- .../internal/web/templates/app_settings.html | 715 +++++++++++ .../internal/web/templates/app_shell.html | 137 +++ .../internal/web/templates/backups_apps.html | 10 +- .../internal/web/templates/dashboard.html | 4 +- controller/internal/web/templates/deploy.html | 685 +---------- controller/internal/web/templates/stacks.html | 5 +- .../internal/web/templates/tier2_config.html | 99 -- .../i18n_parity/app_data_deployed.html | 1061 +++++++++++++++++ .../{app_export.html => app_data_export.html} | 107 +- .../i18n_parity/app_data_not_hdd.html | 654 ++++++++++ ...nged.html => app_data_not_hdd_absent.html} | 158 +-- ...m.html => app_data_not_hdd_protected.html} | 167 +-- ...e.html => app_data_not_hdd_same_disk.html} | 162 ++- ...d.html => app_data_notarget_disabled.html} | 115 +- ...et_auto.html => app_data_target_auto.html} | 115 +- ...rget_ssd.html => app_data_target_ssd.html} | 115 +- ...vailable.html => app_guide_available.html} | 85 +- ...e_open_failed.html => app_guide_full.html} | 185 ++- ...llable.html => app_guide_installable.html} | 79 +- ...se_signup.html => app_guide_no_guide.html} | 136 ++- .../i18n_parity/app_info_ladder_steps.html | 589 --------- .../i18n_parity/app_info_not_checked.html | 590 --------- .../i18n_parity/app_info_signup_window.html | 594 --------- .../i18n_parity/app_info_updating.html | 591 --------- .../i18n_parity/{logs.html => app_logs.html} | 61 +- .../i18n_parity/app_settings_after_start.html | 794 ++++++++++++ .../app_settings_close_signup.html | 781 ++++++++++++ .../app_settings_close_signup_native.html | 782 ++++++++++++ ...ployed.html => app_settings_deployed.html} | 342 ++++-- ...ld_log.html => app_settings_hold_log.html} | 237 +++- ...ld.html => app_settings_install_hold.html} | 236 +++- .../app_settings_known_login_changed.html | 783 ++++++++++++ .../app_settings_ladder_steps.html | 775 ++++++++++++ ...iles.html => app_settings_lost_files.html} | 261 +++- .../i18n_parity/app_settings_not_checked.html | 776 ++++++++++++ ....html => app_settings_operator_creds.html} | 244 +++- ....html => app_settings_restored_login.html} | 543 ++------- ...running.html => app_settings_running.html} | 543 ++------- ...ml => app_settings_setup_gate_button.html} | 261 +++- ...ml => app_settings_setup_gate_native.html} | 261 +++- ...tml => app_settings_setup_gate_probe.html} | 261 +++- ...d.html => app_settings_signup_closed.html} | 261 +++- ...e.html => app_settings_signup_native.html} | 261 +++- ...pp_settings_signup_native_open_failed.html | 783 ++++++++++++ .../app_settings_signup_window.html | 780 ++++++++++++ ...stopped.html => app_settings_stopped.html} | 545 ++------- .../i18n_parity/app_settings_updating.html | 776 ++++++++++++ .../i18n_parity/backups_apps_error.html | 24 +- .../backups_apps_interrupted_run.html | 24 +- .../backups_apps_offsite_offer.html | 24 +- .../testdata/i18n_parity/backups_apps_ok.html | 24 +- .../i18n_parity/backups_apps_other.html | 24 +- .../i18n_parity/backups_apps_running.html | 24 +- .../i18n_parity/backups_apps_stale_copy.html | 24 +- .../testdata/i18n_parity/dashboard_full.html | 8 +- .../web/testdata/i18n_parity/deploy_new.html | 504 +------- .../i18n_parity/deploy_new_blocked.html | 499 +------- .../deploy_new_memory_blocked.html | 504 +------- .../web/testdata/i18n_parity/stacks_full.html | 22 +- .../stacks_install_interrupted.html | 3 +- .../i18n_parity/stacks_unhealthy_stop.html | 3 +- .../testdata/i18n_parity/tier2_not_hdd.html | 553 --------- .../i18n_parity/tier2_not_hdd_absent.html | 553 --------- .../i18n_parity/tier2_not_hdd_protected.html | 555 --------- .../i18n_parity/tier2_not_hdd_same_disk.html | 554 --------- .../internal/web/tier2_config_handler.go | 44 +- controller/internal/web/undo_page_test.go | 6 +- controller/internal/web/updatebadge_test.go | 5 +- controller/scripts/i18n_go_keys.json | 12 +- controller/scripts/template_id_gate.py | 66 +- controller/scripts/test_gate_decoys.py | 11 + 120 files changed, 16239 insertions(+), 9866 deletions(-) create mode 100644 controller/internal/stacks/guide_test.go create mode 100644 controller/internal/web/after_start.go create mode 100644 controller/internal/web/app_page.go create mode 100644 controller/internal/web/app_page_test.go create mode 100644 controller/internal/web/guide_render_test.go rename controller/internal/web/templates/{app_export.html => app_data.html} (58%) create mode 100644 controller/internal/web/templates/app_fields.html create mode 100644 controller/internal/web/templates/app_guide.html delete mode 100644 controller/internal/web/templates/app_info.html rename controller/internal/web/templates/{logs.html => app_logs.html} (83%) create mode 100644 controller/internal/web/templates/app_settings.html create mode 100644 controller/internal/web/templates/app_shell.html delete mode 100644 controller/internal/web/templates/tier2_config.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_data_deployed.html rename controller/internal/web/testdata/i18n_parity/{app_export.html => app_data_export.html} (93%) create mode 100644 controller/internal/web/testdata/i18n_parity/app_data_not_hdd.html rename controller/internal/web/testdata/i18n_parity/{app_info_known_login_changed.html => app_data_not_hdd_absent.html} (92%) rename controller/internal/web/testdata/i18n_parity/{app_info_plex_claim.html => app_data_not_hdd_protected.html} (91%) rename controller/internal/web/testdata/i18n_parity/{app_info_close_signup_native.html => app_data_not_hdd_same_disk.html} (91%) rename controller/internal/web/testdata/i18n_parity/{tier2_notarget_disabled.html => app_data_notarget_disabled.html} (90%) rename controller/internal/web/testdata/i18n_parity/{tier2_target_auto.html => app_data_target_auto.html} (90%) rename controller/internal/web/testdata/i18n_parity/{tier2_target_ssd.html => app_data_target_ssd.html} (90%) rename controller/internal/web/testdata/i18n_parity/{app_info_available.html => app_guide_available.html} (95%) rename controller/internal/web/testdata/i18n_parity/{app_info_signup_native_open_failed.html => app_guide_full.html} (90%) rename controller/internal/web/testdata/i18n_parity/{app_info_installable.html => app_guide_installable.html} (95%) rename controller/internal/web/testdata/i18n_parity/{app_info_close_signup.html => app_guide_no_guide.html} (94%) delete mode 100644 controller/internal/web/testdata/i18n_parity/app_info_ladder_steps.html delete mode 100644 controller/internal/web/testdata/i18n_parity/app_info_not_checked.html delete mode 100644 controller/internal/web/testdata/i18n_parity/app_info_signup_window.html delete mode 100644 controller/internal/web/testdata/i18n_parity/app_info_updating.html rename controller/internal/web/testdata/i18n_parity/{logs.html => app_logs.html} (95%) create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_after_start.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_close_signup.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_close_signup_native.html rename controller/internal/web/testdata/i18n_parity/{app_info_deployed.html => app_settings_deployed.html} (85%) rename controller/internal/web/testdata/i18n_parity/{app_info_hold_log.html => app_settings_hold_log.html} (84%) rename controller/internal/web/testdata/i18n_parity/{app_info_install_hold.html => app_settings_install_hold.html} (84%) create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_known_login_changed.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_ladder_steps.html rename controller/internal/web/testdata/i18n_parity/{app_info_lost_files.html => app_settings_lost_files.html} (85%) create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_not_checked.html rename controller/internal/web/testdata/i18n_parity/{app_info_operator_creds.html => app_settings_operator_creds.html} (85%) rename controller/internal/web/testdata/i18n_parity/{deploy_deployed_restored_login.html => app_settings_restored_login.html} (76%) rename controller/internal/web/testdata/i18n_parity/{deploy_deployed_running.html => app_settings_running.html} (76%) rename controller/internal/web/testdata/i18n_parity/{app_info_setup_gate_button.html => app_settings_setup_gate_button.html} (85%) rename controller/internal/web/testdata/i18n_parity/{app_info_setup_gate_native.html => app_settings_setup_gate_native.html} (85%) rename controller/internal/web/testdata/i18n_parity/{app_info_setup_gate_probe.html => app_settings_setup_gate_probe.html} (85%) rename controller/internal/web/testdata/i18n_parity/{app_info_signup_closed.html => app_settings_signup_closed.html} (85%) rename controller/internal/web/testdata/i18n_parity/{app_info_signup_native.html => app_settings_signup_native.html} (85%) create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_signup_native_open_failed.html create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_signup_window.html rename controller/internal/web/testdata/i18n_parity/{deploy_deployed_stopped.html => app_settings_stopped.html} (75%) create mode 100644 controller/internal/web/testdata/i18n_parity/app_settings_updating.html delete mode 100644 controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html delete mode 100644 controller/internal/web/testdata/i18n_parity/tier2_not_hdd_absent.html delete mode 100644 controller/internal/web/testdata/i18n_parity/tier2_not_hdd_protected.html delete mode 100644 controller/internal/web/testdata/i18n_parity/tier2_not_hdd_same_disk.html 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}} {{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 @@ -