From 3c6b49b31c1ec6066c547faa386b5d12daf478a9 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 24 Sep 2026 12:18:39 +0200 Subject: [PATCH] controller v0.269.0: whole restore from the second drive; crash loops stopped; exact image digests; steps judged by their own .felhom.yml (decisions 26-28, R-661 R-666 R-667 R-668 R-664 R-665 R-662, 09 6.4 part 6) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 30 + REUSE.md | 2 + controller/README.md | 12 + .../cmd/controller/d28_unhealthy_stop_test.go | 90 +++ controller/cmd/controller/main.go | 91 ++- .../internal/api/r666_keep_data_test.go | 67 ++ controller/internal/api/router.go | 17 + controller/internal/backup/backup.go | 14 + .../backup/d28_unhealthy_hold_test.go | 50 ++ .../internal/backup/offbox_reconstitute.go | 6 + .../internal/backup/r102_tier2_unit_test.go | 4 + .../internal/backup/r659_whole_copy_test.go | 4 +- .../internal/backup/r668_missing_path_test.go | 43 ++ controller/internal/backup/tier2_test.go | 7 +- controller/internal/backup/tier2_whole.go | 353 ++++++++++ .../internal/backup/tier2_whole_test.go | 233 +++++++ controller/internal/backup/update_guard.go | 71 +- controller/internal/i18n/locales/en.json | 13 +- controller/internal/i18n/locales/hu.json | 13 +- controller/internal/notify/notifier.go | 21 + controller/internal/settings/settings.go | 75 +++ controller/internal/stacks/delete.go | 15 + controller/internal/stacks/digest.go | 115 ++++ controller/internal/stacks/digest_test.go | 80 +++ controller/internal/stacks/installed.go | 17 +- controller/internal/stacks/ladder.go | 45 +- controller/internal/stacks/ladder_test.go | 10 +- controller/internal/stacks/manager.go | 27 +- controller/internal/stacks/pin.go | 13 +- .../internal/stacks/r665_verify_meta_test.go | 59 ++ controller/internal/stacks/unhealthy.go | 190 ++++++ controller/internal/stacks/unhealthy_test.go | 102 +++ controller/internal/stacks/update.go | 55 +- controller/internal/stacks/updateorder.go | 28 + .../internal/sync/digest_render_test.go | 42 ++ controller/internal/sync/sync.go | 32 +- .../internal/web/d28_unhealthy_page_test.go | 35 + controller/internal/web/handlers.go | 45 +- controller/internal/web/i18n_parity_test.go | 13 + controller/internal/web/i18n_web.go | 4 +- .../internal/web/templates/app_info.html | 2 + controller/internal/web/templates/layout.html | 9 +- .../web/templates/settings_notifications.html | 4 + controller/internal/web/templates/stacks.html | 3 + .../web/testdata/i18n_parity/app_export.html | 9 +- .../i18n_parity/app_import_bundles.html | 9 +- .../i18n_parity/app_import_empty.html | 9 +- .../i18n_parity/app_info_available.html | 9 +- .../i18n_parity/app_info_deployed.html | 9 +- .../i18n_parity/app_info_installable.html | 9 +- .../i18n_parity/app_info_ladder_steps.html | 9 +- .../i18n_parity/app_info_operator_creds.html | 9 +- .../i18n_parity/app_info_updating.html | 9 +- .../i18n_parity/backups_apps_empty.html | 9 +- .../i18n_parity/backups_apps_error.html | 9 +- .../testdata/i18n_parity/backups_apps_ok.html | 9 +- .../i18n_parity/backups_apps_other.html | 9 +- .../i18n_parity/backups_apps_running.html | 9 +- .../i18n_parity/backups_apps_stale_copy.html | 9 +- .../i18n_parity/backups_degraded.html | 9 +- .../testdata/i18n_parity/backups_empty.html | 9 +- .../testdata/i18n_parity/backups_full.html | 9 +- .../i18n_parity/backups_nobackup_yet.html | 9 +- .../i18n_parity/backups_remote_empty.html | 9 +- .../i18n_parity/backups_remote_error.html | 9 +- .../i18n_parity/backups_remote_escrowed.html | 9 +- .../i18n_parity/backups_remote_full.html | 9 +- .../backups_remote_incomplete.html | 9 +- .../i18n_parity/backups_remote_notconf.html | 9 +- .../backups_remote_notconf_hub.html | 9 +- .../backups_remote_pending_agent.html | 9 +- .../backups_remote_pending_old.html | 9 +- .../i18n_parity/backups_remote_running.html | 9 +- .../i18n_parity/backups_remote_stale.html | 9 +- .../i18n_parity/backups_remote_stale_old.html | 9 +- .../i18n_parity/backups_restore_empty.html | 9 +- .../i18n_parity/backups_restore_full.html | 9 +- .../backups_restore_known_empty.html | 9 +- .../i18n_parity/backups_restore_notarget.html | 9 +- .../i18n_parity/backups_tier_due.html | 9 +- .../i18n_parity/dashboard_diskcrit.html | 9 +- .../testdata/i18n_parity/dashboard_full.html | 9 +- .../i18n_parity/dashboard_nobackup.html | 9 +- .../i18n_parity/dashboard_sparse.html | 9 +- .../web/testdata/i18n_parity/debug.html | 9 +- .../i18n_parity/deploy_deployed_running.html | 9 +- .../i18n_parity/deploy_deployed_stopped.html | 9 +- .../web/testdata/i18n_parity/deploy_new.html | 9 +- .../i18n_parity/deploy_new_blocked.html | 9 +- .../deploy_new_memory_blocked.html | 9 +- .../i18n_parity/escrow_agent_old.html | 9 +- .../testdata/i18n_parity/escrow_first.html | 9 +- .../i18n_parity/escrow_not_ready.html | 9 +- .../i18n_parity/escrow_receremony.html | 9 +- .../testdata/i18n_parity/launcher_empty.html | 9 +- .../testdata/i18n_parity/launcher_full.html | 9 +- .../i18n_parity/launcher_nopassword.html | 9 +- .../i18n_parity/launcher_reminder_tier0.html | 9 +- .../i18n_parity/launcher_reminder_tier3.html | 9 +- .../i18n_parity/launcher_reminder_tiers.html | 9 +- .../web/testdata/i18n_parity/logs.html | 9 +- .../i18n_parity/monitoring_hub_down.html | 9 +- .../i18n_parity/monitoring_hub_off.html | 9 +- .../i18n_parity/monitoring_hub_ok.html | 9 +- .../i18n_parity/restore_wizard_blocked.html | 9 +- .../i18n_parity/restore_wizard_exec.html | 9 +- .../restore_wizard_nodumptime.html | 9 +- .../restore_wizard_prepare_confirm.html | 9 +- .../i18n_parity/restore_wizard_ready.html | 9 +- .../settings_notifications_hub.html | 13 +- .../settings_notifications_nohub.html | 9 +- .../i18n_parity/settings_security_full.html | 9 +- .../i18n_parity/settings_security_noauth.html | 9 +- .../i18n_parity/settings_system_failed.html | 9 +- .../i18n_parity/settings_system_full.html | 9 +- .../i18n_parity/settings_system_nomemory.html | 9 +- .../i18n_parity/settings_system_pending.html | 9 +- .../i18n_parity/sharing_error_skipped.html | 9 +- .../testdata/i18n_parity/sharing_full.html | 9 +- .../i18n_parity/sharing_no_offsite_yet.html | 9 +- .../i18n_parity/sharing_notarget_blocked.html | 9 +- .../web/testdata/i18n_parity/sharing_off.html | 9 +- .../i18n_parity/sharing_other_none.html | 9 +- .../web/testdata/i18n_parity/stacks_full.html | 9 +- .../i18n_parity/stacks_unhealthy_stop.html | 621 ++++++++++++++++++ .../testdata/i18n_parity/storage_attach.html | 9 +- .../testdata/i18n_parity/storage_empty.html | 9 +- .../testdata/i18n_parity/storage_full.html | 9 +- .../testdata/i18n_parity/storage_init.html | 9 +- .../i18n_parity/storage_network_full.html | 9 +- .../storage_network_nosupport.html | 9 +- .../testdata/i18n_parity/tier2_not_hdd.html | 9 +- .../i18n_parity/tier2_not_hdd_absent.html | 9 +- .../i18n_parity/tier2_not_hdd_protected.html | 9 +- .../i18n_parity/tier2_not_hdd_same_disk.html | 9 +- .../i18n_parity/tier2_notarget_disabled.html | 9 +- .../i18n_parity/tier2_target_auto.html | 9 +- .../i18n_parity/tier2_target_ssd.html | 9 +- controller/internal/web/updatebadge.go | 3 +- controller/scripts/i18n_go_keys.json | 9 + controller/scripts/i18n_missing_gate.py | 3 +- 141 files changed, 3401 insertions(+), 237 deletions(-) create mode 100644 controller/cmd/controller/d28_unhealthy_stop_test.go create mode 100644 controller/internal/api/r666_keep_data_test.go create mode 100644 controller/internal/backup/d28_unhealthy_hold_test.go create mode 100644 controller/internal/backup/r668_missing_path_test.go create mode 100644 controller/internal/backup/tier2_whole.go create mode 100644 controller/internal/backup/tier2_whole_test.go create mode 100644 controller/internal/stacks/digest.go create mode 100644 controller/internal/stacks/digest_test.go create mode 100644 controller/internal/stacks/r665_verify_meta_test.go create mode 100644 controller/internal/stacks/unhealthy.go create mode 100644 controller/internal/stacks/unhealthy_test.go create mode 100644 controller/internal/sync/digest_render_test.go create mode 100644 controller/internal/web/d28_unhealthy_page_test.go create mode 100644 controller/internal/web/testdata/i18n_parity/stacks_unhealthy_stop.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 44a5409..e8919fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## v0.269.0 — the second drive brings a file app back whole; a crash loop is stopped; exact image fingerprints; each step judged by its own file (2026-09-24 night, `09` §3 decisions 26–28, R-661, R-666, R-667, R-668, R-664, R-665, R-662, §6.4 part 6) + +**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 for `app_stopped_unhealthy` (older hubs answer 400 and the +event is lost; the stop itself does not depend on it). New strings: yes (hu + en). + +- **Decision 26 (R-661):** `backup.RestoreTier2Whole` — the second drive's „Teljes visszaállítás" for a file app + brings back the drive files by four rules (never delete; never overwrite a newer live file; bring back every + missing one; an older differing live file is replaced and KEPT beside as `.felhom-`), then the unit + (settings + database) from the mirror. Refuses before anything moves without a proven, openable mirror with + file legs, or below the 2 GB floor. `WholeOnTier` counts the second drive as whole for such an app. R-538's + guard stays for every other caller. +- **R-668:** the same-disk check fails CLOSED — a storage path it cannot read is the SAME disk (a removed folder + became a file app's "second drive" on its own disk, measured on 9202). +- **Decision 27 (R-666):** while a held app's page says support is informed, the remove dialog offers only + "keep my data" and the API refuses data deletion (409). The no-whole-copy sentence is informal now. +- **Decision 28 + R-667:** crash loop = ≥ 6 restarts in 10 min (Docker's back-off caps a steady loop at ~1/min — + gokapi measured 7 in 7 min), counted from `RestartCount`; OOM storm = ≥ 20 kernel kills in 30 min. The box stops + the app, records an `unhealthy_stop` hold (same store as every hold), shows the sentence with a Start button, + and sends `app_stopped_unhealthy`. Start lifts it (one more try); a second stop within 24 h says support is + informed. Never inside a deploy, an update, a restore, a backup's stop or a quiesce. Default-on event, seeded + add-only. +- **R-664 / R-665:** the update judges the new version by ITS OWN `.felhom.yml` — the ladder step's + `steps/.felhom.yml` or the catalog's, journaled — never the stack dir's, which a restore rewrites. The + step's memory request and applied record come from the same file. +- **R-662:** the dead „database and settings only" second step is removed (decision 25 ruled that restore out). +- **`09` §6.4 part 6, box half:** the compose the app runs pins `name:tag@sha256:…` from the ladder entry that + tested those refs (update and sync); pins and records stay digest-free; the badge reads „Frissítés elérhető" + for a floating tag only when the catalog's tested digest differs AND was tested after the install. +- Red-proofs: fourteen, each seen failing (REPORT). + ## v0.268.0 — the undo finds the storage after a restore; a held app's page tells the truth; one press = one tested step (2026-09-24, R-658, R-659, R-660, R-651; `09` §6.4 part 5) **MinAgent: 0.131.0** (unchanged). Needs hub v0.122.0 for `app_hold_no_whole_copy` (older hubs answer 400 and diff --git a/REUSE.md b/REUSE.md index b97ae69..b140aff 100644 --- a/REUSE.md +++ b/REUSE.md @@ -117,6 +117,8 @@ |---|---|---|---|---| | `Manager.DeployStack` | controller/internal/stacks/deploy.go | `(req DeployRequest) (string, error)` | Full deploy flow | Sets in-memory `Deployed` BEFORE compose up (slow-pull race), reverts on failure | | `DeclaredVolumeNames` (R-658, v0.268.0) | controller/internal/stacks/undo.go | `(composePath) (own, external []string, err)` | THE app's named volumes as Docker names them: `name:` else `_`, project = top-level `name:` else the stack dir | **Never select an app's volumes by the `com.docker.compose.project` label** — a restore before v0.268.0 made volumes without it, and the undo then copied nothing (R-658). The label is a cross-check only | +| `renderDigests` / `RenderWithLadderDigests` / `StripDigest` (v0.269.0) | controller/internal/stacks/digest.go | compose bytes → the same with `ref@sha256` from the ladder entry for its refs | writing a compose that RUNS (update, sync) | Pins and records are digest-free by construction (`parseComposeImagesBytes` and the installed record strip) — never compare a raw `.Config.Image` against a pin | +| `mergeRestoreFiles` / `RestoreTier2Whole` (v0.269.0) | controller/internal/backup/tier2_whole.go | mirror subtree → live subtree, four rules, counts | ANY restore that brings files back over live ones | Never `rsyncMirror` (--delete) in the restore direction; never overwrite a newer live file; a replaced file's old copy stays beside | | `nextLadderStep` / `StepKey` / `StepFile` (v0.268.0) | controller/internal/stacks/ladder.go | `(templateDir, pinned) (LadderStep, error)` | which definition ONE guarded-update press pins (`09` §3 decision 14) | `StepKey` must equal the catalog's `ladder.step_key` (TestLadder_StepKeyMatchesTheCatalog). A missing/wrong step file is an ERROR, never a jump | | `Manager.RedeployFromEnv` | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | Re-up with changed env (migration flip, config edits) | `compose up -d`, never `restart` (restart won't pick up images/env) | | `Manager.PersistUnitRedeployConfig` (R-47, v0.153.0) | controller/internal/stacks/deploy.go | `(name, env map[string]string) error` | the PERSIST half of `RedeployFromEnv` — app.yaml + locked fields + in-memory flags, **starts nothing** | **TRAP: the restore paths must use THIS, never `RedeployFromEnv`.** RedeployFromEnv ends in a full `up -d`, which before the replay IS the H4 race. RedeployFromEnv is now literally this + the unchanged up-and-report tail | diff --git a/controller/README.md b/controller/README.md index 4f3e437..e754eb5 100644 --- a/controller/README.md +++ b/controller/README.md @@ -704,6 +704,18 @@ unit restore both refuse it (R-538) — with what it holds. With none, the sente app raises no `app_start_failed` (R-660). A removed app leaves no `applied-compose.yml` / `applied-meta/` (R-651). +**The second drive brings a file app back whole (v0.269.0, decision 26).** For an app with drive files, the +second drive's „Teljes visszaállítás" merges the files (never deletes, never overwrites a newer live file, keeps +a replaced older one beside as `.felhom-`) and then restores the unit from the mirror. + +**A crash loop is stopped (v0.269.0, decision 28).** ≥ 6 container restarts in 10 min (from `RestartCount`) or +≥ 20 OOM kills in 30 min → the box stops the app, holds it (`unhealthy_stop`), shows Start, and sends +`app_stopped_unhealthy`. Start gives one more try; a repeat within 24 h says support is informed. + +**Exact images (v0.269.0, `09` §6.4 part 6).** The compose that runs pins `tag@sha256` from the ladder entry that +tested it; pins stay plain; a floating tag reads Behind only for a newer TESTED digest. An update judges the new +version by its own `.felhom.yml` (the step's, or the catalog's). A stranded app's Remove keeps the data. + **Start/restart never answer "completed" (v0.263.0, R-642)** — they answer what was requested and the state the containers are in at that moment; whether the app works is the health probe's to say. diff --git a/controller/cmd/controller/d28_unhealthy_stop_test.go b/controller/cmd/controller/d28_unhealthy_stop_test.go new file mode 100644 index 0000000..9025a29 --- /dev/null +++ b/controller/cmd/controller/d28_unhealthy_stop_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "go/ast" + "io" + "log" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// `09` §3 decision 28 (v0.269.0) — the box stops a crash loop / OOM storm; never inside a backup's, +// a quiesce's or an update's own stop, never twice, never an app already held. +func TestD28_StopHoldTellInOrder_AndTheSkips(t *testing.T) { + var calls []string + d := unhealthyDeps{ + verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict { + return []stacks.UnhealthyVerdict{ + {Stack: "gokapi", Kind: stacks.UnhealthyCrashLoop, Count: 7, Window: 10 * time.Minute}, + {Stack: "quiesced", Kind: stacks.UnhealthyCrashLoop, Count: 7}, + {Stack: "busy", Kind: stacks.UnhealthyCrashLoop, Count: 7}, + {Stack: "held", Kind: stacks.UnhealthyOOMStorm, Count: 20}, + } + }, + suppressed: func() map[string]bool { return map[string]bool{"quiesced": true} }, + busy: func(n string) (bool, string) { return n == "busy", "a restore is running" }, + holdKind: func(n string) string { + if n == "held" { + return "update_failed" + } + return "" + }, + stop: func(n string) error { calls = append(calls, "stop:"+n); return nil }, + hold: func(n, k string, _ time.Time) (int, error) { calls = append(calls, "hold:"+n+":"+k); return 1, nil }, + notify: func(v stacks.UnhealthyVerdict, trip int) { calls = append(calls, "notify:"+v.Stack) }, + } + stopped := stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now()) + if strings.Join(calls, ",") != "stop:gokapi,hold:gokapi:crash_loop,notify:gokapi" { + t.Fatalf("calls = %v — want gokapi stopped, held, told; the quiesced, busy and held apps untouched", calls) + } + if len(stopped) != 1 || stopped[0] != "gokapi" { + t.Fatalf("stopped = %v", stopped) + } +} + +// A failed stop is neither held nor announced (the household would read "we stopped it" over a running app). +func TestD28_AFailedStopIsNotAnnounced(t *testing.T) { + var calls []string + d := unhealthyDeps{ + verdicts: func(time.Time, []stacks.OOMContainer) []stacks.UnhealthyVerdict { + return []stacks.UnhealthyVerdict{{Stack: "x", Kind: "crash_loop"}} + }, + suppressed: func() map[string]bool { return nil }, + busy: func(string) (bool, string) { return false, "" }, + holdKind: func(string) string { return "" }, + stop: func(string) error { return io.ErrUnexpectedEOF }, + hold: func(string, string, time.Time) (int, error) { calls = append(calls, "hold"); return 1, nil }, + notify: func(stacks.UnhealthyVerdict, int) { calls = append(calls, "notify") }, + } + stopUnhealthyApps(log.New(io.Discard, "", 0), d, nil, time.Now()) + if len(calls) != 0 { + t.Fatalf("calls = %v after a failed stop", calls) + } +} + +// The wiring: main's dead-app loop calls stopUnhealthyApps with the detector. +func TestD28_TheLoopStopsUnhealthyApps(t *testing.T) { + found, withDetector := false, false + ast.Inspect(mainBody(t), func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "stopUnhealthyApps" { + found = true + ast.Inspect(call, func(m ast.Node) bool { + if sel, ok := m.(*ast.SelectorExpr); ok && sel.Sel.Name == "ObserveUnhealthy" { + withDetector = true + } + return true + }) + } + return true + }) + if !found || !withDetector { + t.Fatalf("main calls stopUnhealthyApps=%v with ObserveUnhealthy=%v — decision 28 is built and never run", found, withDetector) + } +} diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 4a73b64..fcb5369 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -864,7 +864,8 @@ func main() { alertMgr.SetDeadAppAlerts(dead) notifier.NotifyAppStartFailures(states) // R-514: a worker OOM-killed inside a running container leaves the app „Fut". Surface it. - if ooms, oerr := stackMgr.ScanOOMKilled(); oerr != nil { + ooms, oerr := stackMgr.ScanOOMKilled() + if oerr != nil { logger.Printf("[WARN] [deadapp] OOM scan failed: %v", oerr) } else { for _, o := range ooms { @@ -872,6 +873,35 @@ func main() { notifier.NotifyAppOOM(o.Stack, o.Container, o.StartedAt, o.Kills, o.MemLimit, o.Peak) } } + // v0.269.0 (`09` §3 decision 28, R-667): a crash loop or an OOM storm is STOPPED by the box. + stopUnhealthyApps(logger, unhealthyDeps{ + verdicts: stackMgr.ObserveUnhealthy, + suppressed: func() map[string]bool { + return unionSuppressed(unionSuppressed(quiesceLoop.SuppressedStacks(), appStopGuard.SuppressedStacks()), stackMgr.UpdatingStacks()) + }, + busy: func(name string) (bool, string) { + if backupMgr == nil { + return false, "" + } + return backupMgr.UpdateBusy(name) + }, + holdKind: func(name string) string { + if backupMgr == nil { + return "" + } + return backupMgr.HoldKind(name) + }, + stop: stackMgr.StopStack, + hold: func(name, kind string, at time.Time) (int, error) { + if backupMgr == nil { + return 0, fmt.Errorf("backup is not enabled on this box — the stop cannot be held") + } + return backupMgr.HoldUnhealthy(name, kind, at) + }, + notify: func(v stacks.UnhealthyVerdict, trip int) { + notifier.NotifyAppStoppedUnhealthy(v.Stack, v.Kind, v.Count, v.Window, trip) + }, + }, ooms, time.Now()) deadAppScans++ noteDeadAppScan(logger, deadAppScans, len(states), len(dead)) noteUnknownIntentSuppressions(logger, states) @@ -3586,6 +3616,14 @@ func (a *updateGuardsAdapter) HoldAfterFailedUpdate(name string, at time.Time, r return err } +// HoldKind names the hold in force (v0.269.0) — the page offers Start for an unhealthy stop. +func (a *updateGuardsAdapter) HoldKind(name string) string { + if a.b == nil { + return "" + } + return a.b.HoldKind(name) +} + // HoldNoWholeCopy is the page's half of R-659: a hold naming no copy gets no restore button. func (a *updateGuardsAdapter) HoldNoWholeCopy(name string) bool { if a.b == nil { @@ -3593,3 +3631,54 @@ func (a *updateGuardsAdapter) HoldNoWholeCopy(name string) bool { } return a.b.HoldNoWholeCopy(name) } + +// ── Decision 28 (v0.269.0): the box stops an app in a crash loop or an out-of-memory storm ────────── + +// unhealthyDeps are the seams of stopUnhealthyApps — every one wired in main to the real component. +type unhealthyDeps struct { + verdicts func(now time.Time, ooms []stacks.OOMContainer) []stacks.UnhealthyVerdict + suppressed func() map[string]bool + busy func(name string) (bool, string) + holdKind func(name string) string + stop func(name string) error + hold func(name, kind string, at time.Time) (int, error) + notify func(v stacks.UnhealthyVerdict, trip int) +} + +// stopUnhealthyApps acts on the detector's verdicts. NEVER during a deploy or an update (the detector +// skips those), a backup's or a restore's own stop, an app-data operation or a whole-guest quiesce (the +// suppression sets, `08` §5), or on an app already held. Order: stop, then the hold (so no start path +// revives it), then the events. Returns the apps stopped. +func stopUnhealthyApps(logger *log.Logger, d unhealthyDeps, ooms []stacks.OOMContainer, now time.Time) []string { + var stopped []string + vs := d.verdicts(now, ooms) + if len(vs) == 0 { + return nil + } + sup := d.suppressed() + for _, v := range vs { + switch { + case sup[v.Stack]: + logger.Printf("[INFO] [deadapp] %s crossed the %s threshold (%d in %s) while a backup/quiesce/update holds it — NOT stopped", v.Stack, v.Kind, v.Count, v.Window) + continue + case d.holdKind(v.Stack) != "": + continue + } + if busy, why := d.busy(v.Stack); busy { + logger.Printf("[INFO] [deadapp] %s crossed the %s threshold but %s — NOT stopped", v.Stack, v.Kind, why) + continue + } + logger.Printf("[WARN] [deadapp] %s: %s — %d in %s; STOPPING it (decision 28)", v.Stack, v.Kind, v.Count, v.Window) + if err := d.stop(v.Stack); err != nil { + logger.Printf("[ERROR] [deadapp] stopping %s failed: %v — not held, not announced", v.Stack, err) + continue + } + trip, err := d.hold(v.Stack, v.Kind, now) + if err != nil { + logger.Printf("[ERROR] [deadapp] %s: %v", v.Stack, err) + } + d.notify(v, trip) + stopped = append(stopped, v.Stack) + } + return stopped +} diff --git a/controller/internal/api/r666_keep_data_test.go b/controller/internal/api/r666_keep_data_test.go new file mode 100644 index 0000000..249e697 --- /dev/null +++ b/controller/internal/api/r666_keep_data_test.go @@ -0,0 +1,67 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// v0.269.0 (`09` §3 decision 27, R-666) — while a held app's page says support is informed, the remove +// API allows only "remove the app, keep my data". Through the production wiring (NewManager + ScanStacks, +// the hold read through UpdateGuards as main.go wires it). +type heldGuards struct{ noWhole bool } + +func (g heldGuards) HoldFor(string) (bool, string) { return true, "HELD" } +func (g heldGuards) HoldNoWholeCopy(string) bool { return g.noWhole } +func (g heldGuards) Busy(string) (bool, string) { return false, "" } +func (g heldGuards) CanBackUp(string) (bool, string) { return true, "" } +func (g heldGuards) BackupNow(context.Context, string) error { return nil } +func (g heldGuards) SafetyDump(context.Context, string) ([]string, error) { return nil, nil } +func (g heldGuards) RestorePoints(context.Context, string, func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) { + return stacks.UpdateRestorePoint{}, false, nil +} +func (g heldGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error { + return nil +} + +// COMPANION RED-PROOF (REPORT): delete the decision-27 block in removeStack — the data-deleting remove +// then reaches RemoveStack and this test fails at "was not refused". +func TestR666_HeldWithNoWholeCopyRemovesOnlyKeepingData(t *testing.T) { + for _, body := range []string{`{"remove_hdd_data":true,"remove_backups":false}`, `{"remove_hdd_data":false,"remove_backups":true}`} { + r, dir := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n") + r.stackMgr.SetUpdateGuards(heldGuards{noWhole: true}) + code, resp := postRemove(t, r, body) + if code != http.StatusConflict || resp.OK { + t.Fatalf("%s: HTTP %d ok=%v — a data-deleting remove of a stranded app was not refused", body, code, resp.OK) + } + if !strings.Contains(resp.Error, "ügyfélszolgálat") { + t.Fatalf("error = %q, want the support sentence", resp.Error) + } + if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil { + t.Fatal("app.yaml gone — something was removed by a refused request") + } + } + // The dialog is told to offer keep-data only … + r, _ := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n") + r.stackMgr.SetUpdateGuards(heldGuards{noWhole: true}) + w := httptest.NewRecorder() + r.getStackHDDData(w, httptest.NewRequest(http.MethodGet, "/api/stacks/app/hdd-data", nil), "app") + if !strings.Contains(w.Body.String(), `"keep_data_only":true`) { + t.Fatalf("hdd-data does not tell the dialog: %s", w.Body.String()) + } + // … and a hold that NAMES a whole copy keeps the full dialog (positive control). + r2, _ := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: /mnt/felhom-drives/x\n") + r2.stackMgr.SetUpdateGuards(heldGuards{noWhole: false}) + w2 := httptest.NewRecorder() + r2.getStackHDDData(w2, httptest.NewRequest(http.MethodGet, "/api/stacks/app/hdd-data", nil), "app") + if strings.Contains(w2.Body.String(), "keep_data_only") { + t.Fatalf("a hold with a whole copy must keep the full dialog: %s", w2.Body.String()) + } +} diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index e5a6593..ffdf569 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -607,6 +607,14 @@ func (r *Router) actionStack(w http.ResponseWriter, req *http.Request, action, n // R-439 (slice 4): `update` is in this list. It was not until v0.237.0, so a held app could be // updated — the one action most likely to make a held app's data worse. Pinned by // TestR439_UpdateOfAHeldAppIsRefused. + // v0.269.0 (`09` §3 decision 28): an app the BOX stopped for a crash loop / OOM storm is started + // again by the household's Start — the hold is lifted and the app gets one more try. Restart and + // Update stay refused while it holds; any other hold kind is untouched. + if action == "start" && r.backupMgr != nil && r.backupMgr.HoldKind(name) == settings.HoldReasonUnhealthyStop { + if r.backupMgr.LiftUnhealthyStop(name) { + r.logger.Printf("[INFO] [api] %s: the unhealthy stop is LIFTED by Start — one more try (decision 28)", name) + } + } if action == "start" || action == "restart" || action == "update" { if held, why := r.restoreHoldFor(name); held { // `reason` (v0.261.0) — THIS LINE FIRES BEFORE UpdatePreflight, so without it a held app @@ -862,6 +870,7 @@ func (r *Router) getStackHDDData(w http.ResponseWriter, req *http.Request, name writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: r.errText(req, err)}) return } + resp.KeepDataOnly = r.stackMgr.RemoveKeepsDataOnly(name) // decision 27: the dialog offers keep-data only writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp}) } @@ -908,6 +917,14 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri } r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups) + // v0.269.0 (`09` §3 decision 27, R-666): while a held app's page says support is informed, only + // "remove the app, keep my data" is allowed. Refused BEFORE anything is removed (live-probes rule). + if (body.RemoveHDDData || body.RemoveBackups) && r.stackMgr.RemoveKeepsDataOnly(name) { + r.logger.Printf("[WARN] [api] Remove of %s with data REFUSED: the app is held with no whole copy (support is informed) — only keep-data is allowed (decision 27)", name) + writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: r.errText(req, stacks.ErrRemoveKeepDataWhileSupport)}) + return + } + // Compute backup paths to remove if requested. Disk-tier (cross-drive rsync) // backup has moved to the host agent; only the app-data DB-dump path is removed here. // diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index 8b42aae..0ba603a 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -181,6 +181,8 @@ type Manager struct { updateTier2PointFn func(stackName string) (Tier2RestorePoint, error) updateTier1PointsFn func(stackName string) ([]RestorePoint, bool) updateOffsiteTimesFn func(ctx context.Context) (map[string]time.Time, error) + // freeBytesFn (v0.269.0, decision 26): the whole restore's disk-floor check; nil → statfs. + freeBytesFn func(path string) int64 // R-354 volume-REPLAY seam — the mirror of the F17 DB seams above, so the off-site path's new // volume leg is unit-testable without Docker. Nil → the real restoreDockerVolumesFrom. @@ -1264,6 +1266,18 @@ func (m *Manager) sameDevice(a, b string) bool { if m.samePhysicalDevice != nil { return m.samePhysicalDevice(a, b) } + // R-668 (v0.269.0): FAIL CLOSED. system.SamePhysicalDevice answers "different" when either path cannot + // be stat'd, and a Tier-2 target chosen on that answer is written wherever the path gets re-created — + // measured on 9202 2026-09-24: a removed folder on nextcloud's OWN disk became its "second drive". A + // path we cannot read is treated as the SAME disk: no second copy is claimed on it. + if _, err := os.Stat(a); err != nil { + m.logger.Printf("[WARN] [backup] same-disk check: %s cannot be read (%v) — treated as the same disk", a, err) + return true + } + if _, err := os.Stat(b); err != nil { + m.logger.Printf("[WARN] [backup] same-disk check: %s cannot be read (%v) — treated as the same disk", b, err) + return true + } return system.SamePhysicalDevice(a, b) } diff --git a/controller/internal/backup/d28_unhealthy_hold_test.go b/controller/internal/backup/d28_unhealthy_hold_test.go new file mode 100644 index 0000000..e0fc0e9 --- /dev/null +++ b/controller/internal/backup/d28_unhealthy_hold_test.go @@ -0,0 +1,50 @@ +package backup + +import ( + "io" + "log" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Decision 28: the stop is a hold (same store — no start path revives it), Start lifts ONLY that kind, +// and a second stop within 24 h says support is informed. +func TestD28_HoldTripsAndLift(t *testing.T) { + sett := slice4Settings(t) + m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett} + t0 := time.Date(2026, 9, 24, 22, 0, 0, 0, time.UTC) + trip, err := m.HoldUnhealthy("gokapi", "crash_loop", t0) + if err != nil || trip != 1 { + t.Fatalf("trip=%d err=%v", trip, err) + } + held, why := m.RestoreHoldForLang("gokapi", "hu") + if !held || !strings.Contains(why, "Indítás gombbal") || strings.Contains(why, "gyfélszolgálat") { + t.Fatalf("first stop: held=%v %q", held, why) + } + if !m.UpdateHeldStacks()["gokapi"] { + t.Fatal("an unhealthy stop is a product stop — it must be in the app-down suppression set") + } + if m.HoldKind("gokapi") != settings.HoldReasonUnhealthyStop || !m.LiftUnhealthyStop("gokapi") { + t.Fatal("Start could not lift the unhealthy stop") + } + if held, _ := m.RestoreHoldFor("gokapi"); held { + t.Fatal("still held after Start") + } + trip, _ = m.HoldUnhealthy("gokapi", "oom_storm", t0.Add(3*time.Hour)) + _, why = m.RestoreHoldForLang("gokapi", "en") + if trip != 2 || !strings.Contains(why, "support has been told") { + t.Fatalf("second stop within 24 h: trip=%d %q", trip, why) + } + m.LiftUnhealthyStop("gokapi") + if trip, _ = m.HoldUnhealthy("gokapi", "crash_loop", t0.Add(30*time.Hour)); trip != 1 { + t.Fatalf("a stop 27 h after the last is a first stop again, got trip %d", trip) + } + // Start never lifts another kind. + _ = sett.SetRestoreHold(settings.RestoreHold{Stack: "nextcloud", At: t0.Format(time.RFC3339), Reason: settings.HoldReasonUpdateFailed}) + if m.LiftUnhealthyStop("nextcloud") { + t.Fatal("Start lifted an UPDATE hold") + } +} diff --git a/controller/internal/backup/offbox_reconstitute.go b/controller/internal/backup/offbox_reconstitute.go index a1596d7..8d6b34e 100644 --- a/controller/internal/backup/offbox_reconstitute.go +++ b/controller/internal/backup/offbox_reconstitute.go @@ -346,6 +346,12 @@ func (m *Manager) RestoreHoldForLang(stack, lang string) (bool, string) { } // Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a // restore hold names nothing, because the restore it refers to already consumed the copy. + if h.Reason == settings.HoldReasonUnhealthyStop { // v0.269.0, decision 28 + if h.Trip >= 2 { + return true, util.Text(lang, "hold.unhealthy_stop.repeat") + } + return true, util.Text(lang, "hold.unhealthy_stop") + } if h.Reason == settings.HoldReasonUpdateFailed { if h.NoWholeCopy { // R-659: the sentence carries the undo's failure itself and names no copy return true, util.Text(lang, "hold.update.no_whole_copy", stack) diff --git a/controller/internal/backup/r102_tier2_unit_test.go b/controller/internal/backup/r102_tier2_unit_test.go index f720625..40f2936 100644 --- a/controller/internal/backup/r102_tier2_unit_test.go +++ b/controller/internal/backup/r102_tier2_unit_test.go @@ -51,6 +51,10 @@ func r102Tier2Fixture(t *testing.T, volTars []string, dbDump string) *r102T2 { if err != nil { t.Fatal(err) } + // A registered drive is a directory that EXISTS (R-668, v0.269.0: a missing one is no candidate). + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatal(err) + } if err := sett.AddStoragePath(settings.StoragePath{Path: dest, Label: "flash", Schedulable: true}); err != nil { t.Fatal(err) } diff --git a/controller/internal/backup/r659_whole_copy_test.go b/controller/internal/backup/r659_whole_copy_test.go index bbb3265..f08ffa1 100644 --- a/controller/internal/backup/r659_whole_copy_test.go +++ b/controller/internal/backup/r659_whole_copy_test.go @@ -123,11 +123,11 @@ func TestR659_SentenceBothLanguages(t *testing.T) { t.Fatal(err) } _, en := m.RestoreHoldForLang("nextcloud", "en") - if en != "The update of nextcloud did not work, and neither did the automatic undo. This box has no copy that can bring the app back together with its files. Felhom support has been told — until then, do not restart or remove the app." { + if en != "The update of nextcloud did not work, and neither did the automatic undo. This box has no copy that can bring the app back together with its files. Felhom support has been told — until then, do not restart the app or delete its data." { t.Fatalf("en = %q", en) } _, hu := m.RestoreHoldForLang("nextcloud", "hu") - if hu != "A(z) nextcloud frissítése nem sikerült, és az automatikus visszaállítás sem. Ezen a dobozon nincs olyan másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. A Felhom ügyfélszolgálatát értesítettük — kérjük, addig ne indítsa újra és ne törölje az alkalmazást." { + if hu != "A(z) nextcloud frissítése nem sikerült, és az automatikus visszaállítás sem. Ezen a dobozon nincs olyan másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. A Felhom ügyfélszolgálatát értesítettük — addig ne indítsd újra, és ne töröld az adatait." { t.Fatalf("hu = %q", hu) } } diff --git a/controller/internal/backup/r668_missing_path_test.go b/controller/internal/backup/r668_missing_path_test.go new file mode 100644 index 0000000..abf0adf --- /dev/null +++ b/controller/internal/backup/r668_missing_path_test.go @@ -0,0 +1,43 @@ +package backup + +import ( + "io" + "log" + "os" + "path/filepath" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-668 (v0.269.0) — a registered storage path whose folder does not exist is NOT a second drive. +// Measured on 9202 2026-09-24: `scratch_hdd/userdata/romm` (removed that morning) was chosen as +// nextcloud's Tier-2 target — the stat in the same-disk check failed, which read as "another disk" — and +// the copy was written into a re-created folder on nextcloud's OWN disk. Runs the PRODUCTION same-disk +// check (no seam). +// +// COMPANION RED-PROOF (REPORT): remove the fail-closed stats in Manager.sameDevice — this test then fails +// at "chose the missing path". +func TestR668_MissingStoragePathIsNotASecondDrive(t *testing.T) { + tmp := t.TempDir() + live := filepath.Join(tmp, "drive") + missing := filepath.Join(tmp, "drive-gone") + if err := os.MkdirAll(live, 0o755); err != nil { + t.Fatal(err) + } + sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + if err := sett.AddStoragePath(settings.StoragePath{Path: missing, Label: "gone", Schedulable: true}); err != nil { + t.Fatal(err) + } + m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett, systemDataPath: filepath.Join(tmp, "no-sys")} + tgt, err := m.selectTier2TargetFrom("app", live, 1, 1) + if err == nil && tgt != nil && tgt.NamespaceRoot == NamespaceRoot(missing, true) { + t.Fatalf("chose the missing path %s as the second drive", missing) + } + if !m.sameDevice(live, missing) { + t.Fatal("an unreadable path must count as the SAME disk") + } +} diff --git a/controller/internal/backup/tier2_test.go b/controller/internal/backup/tier2_test.go index 31d3c72..69aef83 100644 --- a/controller/internal/backup/tier2_test.go +++ b/controller/internal/backup/tier2_test.go @@ -21,7 +21,12 @@ func newTestManager(t *testing.T, systemDataPath string) (*Manager, *settings.Se } cfg := &config.Config{} cfg.Paths.SystemDataPath = systemDataPath - return NewManager(cfg, sett, logger), sett + m := NewManager(cfg, sett, logger) + // These tests name paths that do not exist (/mnt/a, /srv/sys). Until v0.269.0 they passed only because + // the production same-disk check answered "different" on a failed stat — the R-668 defect. They now + // say what they mean: every distinct path is its own disk. + m.samePhysicalDevice = func(a, b string) bool { return a == b } + return m, sett } // A customer-pinned PreferredTarget must win over the auto-pick (which would take the first diff --git a/controller/internal/backup/tier2_whole.go b/controller/internal/backup/tier2_whole.go new file mode 100644 index 0000000..ebbcc28 --- /dev/null +++ b/controller/internal/backup/tier2_whole.go @@ -0,0 +1,353 @@ +package backup + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "syscall" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/util" +) + +// ── The WHOLE restore from the second drive (`09` §3 decision 26, R-661; controller v0.269.0) ──── +// +// WHY IT EXISTS. For an app whose files live on the data drive (DeclaredDriveFileLegs — nextcloud, +// immich, paperless-ngx, calibre-web) the Tier-2 mirror holds BOTH halves: the recovery unit (settings + +// database) and the drive files (hdd/, userdata/). Until v0.269.0 no single action brought +// the app back from it: the unit restore refuses such an app (R-538 — it will not replay a database over +// files it cannot also restore), and the file restore only ADDS missing files and replays no database. +// Measured from source 2026-09-24. So after a failed update and a failed undo, a box with a second drive +// but no off-site tier still stranded the app (R-659's case). The operator ruled (decision 26): one +// action brings the app back WHOLE from the second drive. R-538's guard STAYS — this is a new action +// beside it; it passes AcceptMissingFiles only because it has just put the files back itself. +// +// THE FILE RULES, in this order of importance (the operator's brief, verbatim in spirit): +// +// 1. NEVER DELETE a live file. Nothing here removes anything the household has. +// 2. NEVER OVERWRITE a file whose live copy is NEWER than the mirror's (mtime). +// 3. BRING BACK every file that is missing. +// 4. A file that DIFFERS and is OLDER (or equally old) on the live side is replaced from the mirror, and +// the live copy is KEPT BESIDE it as `.felhom-`. +// +// rsyncMirror carries --delete and is NEVER used here; rsyncRestoreMissing is additive but cannot do +// rule 4 or count it, so the merge is done file by file, and every decision is counted. + +// WholeRestoreCounts is what the whole restore did to the drive files, per rule. +type WholeRestoreCounts struct { + Restored int // rule 3: missing live, copied back + Replaced int // rule 4: live older + different → mirror copy in place, live kept beside + KeptNewer int // rule 2: live newer → left alone + Unchanged int // same content → nothing to do + BytesTotal int64 +} + +// wholeRestoreSuffix names the copy rule 4 keeps beside a replaced file. +func wholeRestoreSuffix(ts time.Time) string { return ".felhom-" + ts.UTC().Format("20060102T150405Z") } + +func sameContent(a, b string) (bool, error) { + ha, err := fileSHA(a) + if err != nil { + return false, err + } + hb, err := fileSHA(b) + if err != nil { + return false, err + } + return bytes.Equal(ha, hb), nil +} + +func fileSHA(p string) ([]byte, error) { + f, err := os.Open(p) + if err != nil { + return nil, err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return nil, err + } + return h.Sum(nil), nil +} + +// copyFilePreserving copies src to dst (which must not exist), keeping mode, mtime and — when possible — +// the owner, so a restored file is readable by the app exactly as the mirrored one was. +func copyFilePreserving(src, dst string, fi fs.FileInfo) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + tmp := dst + ".felhom-restoring" + out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, fi.Mode().Perm()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(tmp) + return err + } + if err := out.Sync(); err != nil { + out.Close() + os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + os.Remove(tmp) + return err + } + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + _ = os.Lchown(tmp, int(st.Uid), int(st.Gid)) + } + _ = os.Chmod(tmp, fi.Mode().Perm()) + _ = os.Chtimes(tmp, fi.ModTime(), fi.ModTime()) + // Link, not rename: rename would silently REPLACE a dst that appeared meanwhile (rule 1/2). + if err := os.Link(tmp, dst); err != nil { + os.Remove(tmp) + return err + } + return os.Remove(tmp) +} + +// mergeRestoreFiles applies the four rules from the mirror subtree src onto the live subtree dst. It +// never deletes and never follows a symlink out of either tree. Directories missing live are created +// with the mirror's mode and owner. +func mergeRestoreFiles(src, dst string, ts time.Time) (WholeRestoreCounts, error) { + var c WholeRestoreCounts + suffix := wholeRestoreSuffix(ts) + err := filepath.WalkDir(src, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(src, p) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + fi, err := d.Info() + if err != nil { + return err + } + switch { + case d.IsDir(): + if _, err := os.Lstat(target); errors.Is(err, fs.ErrNotExist) { + if err := os.MkdirAll(target, fi.Mode().Perm()); err != nil { + return err + } + if st, ok := fi.Sys().(*syscall.Stat_t); ok { + _ = os.Lchown(target, int(st.Uid), int(st.Gid)) + } + } + return nil + case !fi.Mode().IsRegular(): + return nil // symlinks, sockets, devices: never materialised by a restore + } + c.BytesTotal += fi.Size() + live, err := os.Lstat(target) + if errors.Is(err, fs.ErrNotExist) { + if err := copyFilePreserving(p, target, fi); err != nil { + return fmt.Errorf("restoring %s: %w", rel, err) + } + c.Restored++ + return nil + } + if err != nil { + return err + } + if !live.Mode().IsRegular() { + c.KeptNewer++ // something else lives there now (a dir, a link): never touched (rule 1) + return nil + } + if live.Size() == fi.Size() { + same, err := sameContent(p, target) + if err != nil { + return err + } + if same { + c.Unchanged++ + return nil + } + } + if live.ModTime().After(fi.ModTime()) { + c.KeptNewer++ // rule 2: the household's newer copy wins + return nil + } + // rule 4: keep the live copy beside, then put the mirror's in place. + kept := target + suffix + if err := os.Link(target, kept); err != nil { + return fmt.Errorf("keeping the live copy of %s: %w", rel, err) + } + if err := os.Remove(target); err != nil { + return fmt.Errorf("moving the live copy of %s aside: %w", rel, err) + } + if err := copyFilePreserving(p, target, fi); err != nil { + // put the live copy back where it was; the kept link stays either way + _ = os.Link(kept, target) + return fmt.Errorf("replacing %s: %w", rel, err) + } + c.Replaced++ + return nil + }) + return c, err +} + +// planWholeRestoreBytes is how many bytes the file half would ADD to the live drive (rule 3 and rule 4 +// copies — rule 4 keeps the old copy, so it adds its full size). Read-only. +func planWholeRestoreBytes(src, dst string) (int64, error) { + var need int64 + err := filepath.WalkDir(src, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil || d.IsDir() { + return walkErr + } + fi, err := d.Info() + if err != nil || !fi.Mode().IsRegular() { + return err + } + rel, _ := filepath.Rel(src, p) + live, err := os.Lstat(filepath.Join(dst, rel)) + if err != nil || live.Size() != fi.Size() || !live.ModTime().After(fi.ModTime()) { + need += fi.Size() + } + return nil + }) + return need, err +} + +// wholeRestoreFloorBytes is the free space the live drive must keep after the file half. The same 2 GB +// the update keeps on the Docker root (stacks.updateDiskFloorGiB). +const wholeRestoreFloorBytes = int64(2) << 30 + +var ( + // ErrWholeRestoreNotFileApp — the app keeps no files on the drive; its whole restore is the unit + // restore itself („Teljes visszaállítás"). + ErrWholeRestoreNotFileApp = util.MsgError("err.backup.whole_restore_not_file_app") + // ErrWholeRestoreNoProvenCopy — the mirror's unit is not a proven, openable package, or the mirror + // carries no file leg: nothing moves. + ErrWholeRestoreNoProvenCopy = util.MsgError("err.backup.whole_restore_no_proven_copy") +) + +// wholeRestoreSpace is the refusal when the file half would leave less than the floor free. +type wholeRestoreSpace struct{ need, free int64 } + +func (e *wholeRestoreSpace) Error() string { + return util.Text("hu", "err.backup.whole_restore_space", float64(e.need)/(1<<30), float64(e.free)/(1<<30)) +} + +// WholeRestoreResult is what a whole restore returns. +type WholeRestoreResult struct { + Files WholeRestoreCounts + Unit UnitRestoreResult +} + +// RestoreTier2Whole brings a FILE app back whole from its recorded Tier-2 copy: the drive files by the +// four rules, then the unit (settings + database) — with the app stopped throughout. Every refusal +// happens before anything moves. +func (m *Manager) RestoreTier2Whole(stackName string) (WholeRestoreResult, error) { + var res WholeRestoreResult + if m.stackProvider == nil { + return res, fmt.Errorf("stack provider not configured") + } + if !m.HasDriveFileLegs(stackName) { + return res, ErrWholeRestoreNotFileApp + } + destBase, err := m.tier2RecordedCopyDir(stackName) + if err != nil { + return res, err + } + cov := tier2CoverageAt(destBase) + rp, rerr := m.Tier2UnitRestorePoint(stackName) + _, proven := rp.ProvenCopyTime() + if rerr != nil || !cov.CanRestoreUnit() || !cov.CanRestore() || !proven { + m.logger.Printf("[WARN] [backup] whole restore REFUSED for %s: unit_openable=%v legs=%v proven=%v (%v) — nothing moved", + stackName, cov.CanRestoreUnit(), cov.Legs, proven, rerr) + return res, ErrWholeRestoreNoProvenCopy + } + drive := m.GetAppDrivePath(stackName) + if drive == "" || !filepath.IsAbs(drive) { + return res, fmt.Errorf("cannot determine drive path for %s", stackName) + } + if m.settings != nil && (m.settings.IsDisconnected(drive) || m.settings.IsDecommissioned(drive)) { + return res, fmt.Errorf("%w (%s)", errLiveDriveGone, drive) + } + liveNsRoot := m.namespaceRoot(drive) + merges := []struct{ src, dst string }{ + {filepath.Join(destBase, "hdd"), liveNsRoot}, + {filepath.Join(destBase, "userdata"), filepath.Join(liveNsRoot, "userdata")}, + } + var need int64 + for _, mg := range merges { + if _, err := os.Stat(mg.src); err != nil { + continue + } + n, err := planWholeRestoreBytes(mg.src, mg.dst) + if err != nil { + return res, fmt.Errorf("planning the file half: %w", err) + } + need += n + } + free := m.freeBytes(liveNsRoot) + if free >= 0 && free-need < wholeRestoreFloorBytes { + m.logger.Printf("[WARN] [backup] whole restore REFUSED for %s: the file half needs %d B and %d B is free (floor %d B) — nothing moved", stackName, need, free, wholeRestoreFloorBytes) + return res, &wholeRestoreSpace{need: need, free: free} + } + + // THE FILE HALF, under the backup single-flight, with the app stopped. + if err := m.acquireRunning(); err != nil { + return res, err + } + m.logger.Printf("[WARN] [backup] WHOLE restore for %s from the second drive %s: files by the four rules (never delete, never overwrite newer), then the unit", stackName, destBase) + if stopErr := m.stackProvider.StopStack(stackName); stopErr != nil { + m.logger.Printf("[WARN] [backup] could not stop %s before the whole restore: %v (continuing)", stackName, stopErr) + } + ts := time.Now() + var mergeErr error + for _, mg := range merges { + if _, err := os.Stat(mg.src); err != nil { + continue + } + c, err := mergeRestoreFiles(mg.src, mg.dst, ts) + res.Files.Restored += c.Restored + res.Files.Replaced += c.Replaced + res.Files.KeptNewer += c.KeptNewer + res.Files.Unchanged += c.Unchanged + res.Files.BytesTotal += c.BytesTotal + if err != nil { + mergeErr = err + break + } + } + m.releaseRunning() + m.logger.Printf("[INFO] [backup] whole restore %s: files restored=%d replaced=%d (live kept beside as *%s) kept-newer=%d unchanged=%d", + stackName, res.Files.Restored, res.Files.Replaced, wholeRestoreSuffix(ts), res.Files.KeptNewer, res.Files.Unchanged) + if mergeErr != nil { + // The unit is NOT replayed over a half-merged file tree; nothing was deleted, the app is restarted. + if startErr := m.stackProvider.StartStack(stackName); startErr != nil { + m.logger.Printf("[ERROR] [backup] restarting %s after a failed file half also failed: %v", stackName, startErr) + } + return res, util.MsgError("err.backup.fajlmasolas_sikertelen", mergeErr) + } + + // THE UNIT HALF — the existing restore, told that the files are handled (R-538 stays for every other caller). + unitDir := tier2UnitDir(destBase) + res.Unit, err = m.RestoreFromRecoveryUnitAtWith(stackName, unitDir, UnitRestoreOptions{AcceptMissingFiles: true}) + m.rehydratePrimaryUnit(stackName, unitDir, err) + return res, err +} + +// freeBytes is the free space under p, -1 when unknown. A seam for tests. +func (m *Manager) freeBytes(p string) int64 { + if m.freeBytesFn != nil { + return m.freeBytesFn(p) + } + var st syscall.Statfs_t + if err := syscall.Statfs(p, &st); err != nil { + return -1 + } + return int64(st.Bavail) * int64(st.Bsize) +} diff --git a/controller/internal/backup/tier2_whole_test.go b/controller/internal/backup/tier2_whole_test.go new file mode 100644 index 0000000..1f11cf4 --- /dev/null +++ b/controller/internal/backup/tier2_whole_test.go @@ -0,0 +1,233 @@ +package backup + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" +) + +// v0.269.0 — the WHOLE restore from the second drive (`09` §3 decision 26, R-661). The file rules are +// asserted as CONSEQUENCES over the whole tree: every live file is fingerprinted before and after, and +// nothing the household had may disappear or be silently overwritten. + +func writeAt(t *testing.T, p, body string, mt time.Time) { + t.Helper() + mustWrite(t, p, body) + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read %s: %v", p, err) + } + return string(b) +} + +// fingerprint maps every regular file under root to its content. +func fingerprint(t *testing.T, root string) map[string]string { + t.Helper() + out := map[string]string{} + _ = filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { + if err == nil && fi.Mode().IsRegular() { + rel, _ := filepath.Rel(root, p) + out[rel] = readFile(t, p) + } + return nil + }) + return out +} + +// TestWhole_TheFourFileRules — one mirror, one live tree, every rule exercised at once. +// +// COMPANION RED-PROOFS (REPORT): +// - rule 2 removed (no newer-check): "b.txt, the household's NEWER copy, was overwritten"; +// - rule 4 without keeping the live copy: "the older live copy of c.txt is gone"; +// - rule 3 removed: "a.txt, missing live, was not brought back". +func TestWhole_TheFourFileRules(t *testing.T) { + tmp := t.TempDir() + mirror, live := filepath.Join(tmp, "mirror"), filepath.Join(tmp, "live") + old := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC) + mid := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + newer := time.Date(2026, 9, 24, 10, 0, 0, 0, time.UTC) + + writeAt(t, filepath.Join(mirror, "photos", "a.txt"), "A from the copy", mid) // live: missing + writeAt(t, filepath.Join(mirror, "photos", "b.txt"), "B from the copy", mid) // live: newer + writeAt(t, filepath.Join(mirror, "photos", "c.txt"), "C from the copy", mid) // live: older, different + writeAt(t, filepath.Join(mirror, "photos", "d.txt"), "D same", mid) // live: same + writeAt(t, filepath.Join(mirror, "docs", "deep", "e.txt"), "E from the copy", mid) // live: whole dir missing + writeAt(t, filepath.Join(live, "photos", "b.txt"), "B edited by the household", newer) // newer live + writeAt(t, filepath.Join(live, "photos", "c.txt"), "C broken by the update", old) // older live + writeAt(t, filepath.Join(live, "photos", "d.txt"), "D same", old) // same content + writeAt(t, filepath.Join(live, "photos", "only-live.txt"), "not in the copy", old) // must survive + before := fingerprint(t, live) + + ts := time.Date(2026, 9, 24, 21, 0, 0, 0, time.UTC) + c, err := mergeRestoreFiles(mirror, live, ts) + if err != nil { + t.Fatal(err) + } + after := fingerprint(t, live) + + // Rule 1 — nothing the household had is gone: every file present before still exists, or (c.txt) + // its content survives beside it. + kept := "photos/c.txt" + wholeRestoreSuffix(ts) + for rel, body := range before { + if got, ok := after[rel]; ok && (got == body || rel == "photos/c.txt") { + continue + } + t.Fatalf("live file %s (%q) is gone or changed — rule 1", rel, body) + } + if after[kept] != "C broken by the update" { + t.Fatalf("the older live copy of c.txt is gone — rule 4 must keep it beside as %s; files: %v", kept, keys(after)) + } + // Rule 2 + if after["photos/b.txt"] != "B edited by the household" { + t.Fatalf("b.txt, the household's NEWER copy, was overwritten: %q", after["photos/b.txt"]) + } + // Rule 3 + if after["photos/a.txt"] != "A from the copy" || after["docs/deep/e.txt"] != "E from the copy" { + t.Fatalf("a.txt / e.txt, missing live, were not brought back: %q %q", after["photos/a.txt"], after["docs/deep/e.txt"]) + } + // Rule 4 + if after["photos/c.txt"] != "C from the copy" { + t.Fatalf("c.txt (older, different) was not replaced from the copy: %q", after["photos/c.txt"]) + } + if after["photos/only-live.txt"] != "not in the copy" { + t.Fatal("a live file absent from the copy was touched") + } + if c.Restored != 2 || c.Replaced != 1 || c.KeptNewer != 1 || c.Unchanged != 1 { + t.Fatalf("counts = %+v, want restored 2, replaced 1, kept-newer 1, unchanged 1", c) + } + // mtime of a restored file is the copy's, so a later restore's rule 2 compares like with like. + if fi, _ := os.Stat(filepath.Join(live, "photos", "a.txt")); !fi.ModTime().Equal(mid) { + t.Fatalf("a.txt mtime %v, want the copy's %v", fi.ModTime(), mid) + } + // Idempotent: a second run changes nothing and deletes nothing. + c2, err := mergeRestoreFiles(mirror, live, ts.Add(time.Hour)) + if err != nil { + t.Fatal(err) + } + if c2.Restored != 0 || c2.Replaced != 0 { + t.Fatalf("a second run moved files again: %+v", c2) + } +} + +func keys(m map[string]string) []string { + var out []string + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// fileAppProvider makes the R-102 fixture's app a FILE app: a mandatory drive leg. +type fileAppProvider struct{ *fakeRecoveryProvider } + +func (f fileAppProvider) GetStackClassifiedBinds(string) ([]ClassifiedBind, bool) { + return []ClassifiedBind{mandatoryHDD("appdata/app")}, true +} + +// wholeFixture: the R-102 fixture (a real RunTier2 mirror + unit restore seams), turned into a file app +// with photos in the mirror's hdd leg and a damaged live tree. +func wholeFixture(t *testing.T) (*r102T2, string, string) { + f := r102Tier2Fixture(t, []string{"app_data.tar"}, pgDump(1)) + f.m.stackProvider = fileAppProvider{f.fake} + mid := time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC) + mirrorLeg := filepath.Join(f.destBase, "hdd", "appdata", "app") + liveLeg := filepath.Join(f.liveDrive, "appdata", "app") + writeAt(t, filepath.Join(mirrorLeg, "p1.jpg"), "P1", mid) + writeAt(t, filepath.Join(mirrorLeg, "p2.jpg"), "P2", mid) + writeAt(t, filepath.Join(liveLeg, "p2.jpg"), "P2 newer", mid.Add(48*time.Hour)) + return f, mirrorLeg, liveLeg +} + +// TestWhole_BringsTheFilesAndTheUnitBack — the whole action: files merged by the rules, then the unit +// restore FROM THE MIRROR (not the primary), with the app stopped throughout. +func TestWhole_BringsTheFilesAndTheUnitBack(t *testing.T) { + f, _, liveLeg := wholeFixture(t) + res, err := f.m.RestoreTier2Whole("app") + if err != nil { + t.Fatalf("whole restore: %v", err) + } + if readFile(t, filepath.Join(liveLeg, "p1.jpg")) != "P1" || readFile(t, filepath.Join(liveLeg, "p2.jpg")) != "P2 newer" { + t.Fatal("the file half did not follow the rules") + } + if res.Files.Restored != 1 || res.Files.KeptNewer != 1 { + t.Fatalf("file counts %+v", res.Files) + } + if len(*f.dbPaths) == 0 || !strings.HasPrefix((*f.dbPaths)[0], f.destBase) { + t.Fatalf("the database was not replayed from the MIRROR: %v", *f.dbPaths) + } + if !f.fake.stopped { + t.Fatal("the app was not stopped") + } +} + +// TestWhole_RefusesBeforeAnythingMoves — no proven copy, no room, not a file app: refused, the app never +// stopped, not one file written. +func TestWhole_RefusesBeforeAnythingMoves(t *testing.T) { + t.Run("no room", func(t *testing.T) { + f, _, liveLeg := wholeFixture(t) + f.m.freeBytesFn = func(string) int64 { return 1 << 20 } + _, err := f.m.RestoreTier2Whole("app") + var sp *wholeRestoreSpace + if !errors.As(err, &sp) || f.fake.stopped { + t.Fatalf("err=%v stopped=%v — want the space refusal before the stop", err, f.fake.stopped) + } + if _, e := os.Stat(filepath.Join(liveLeg, "p1.jpg")); e == nil { + t.Fatal("a file was written by a refused restore") + } + }) + t.Run("unit not openable", func(t *testing.T) { + f, _, _ := wholeFixture(t) + if err := os.Remove(UnitManifestFile(tier2UnitDir(f.destBase))); err != nil { + t.Fatal(err) + } + if _, err := f.m.RestoreTier2Whole("app"); !errors.Is(err, ErrWholeRestoreNoProvenCopy) || f.fake.stopped { + t.Fatalf("err=%v stopped=%v", err, f.fake.stopped) + } + }) + t.Run("not a file app", func(t *testing.T) { + f := r102Tier2Fixture(t, []string{"app_data.tar"}, pgDump(1)) + if _, err := f.m.RestoreTier2Whole("app"); !errors.Is(err, ErrWholeRestoreNotFileApp) || f.fake.stopped { + t.Fatalf("err=%v stopped=%v", err, f.fake.stopped) + } + }) +} + +// TestWhole_R538StillRefusesTheUnitOnlyRestore — decision 26 is a NEW action beside R-538, not its +// removal: the unit-only restore of a file app (own unit AND the second drive's unit) still refuses. +func TestWhole_R538StillRefusesTheUnitOnlyRestore(t *testing.T) { + f, _, _ := wholeFixture(t) + _, err := f.m.RestoreTier2Unit("app") + var refusal *ErrUnitLacksFileLegs + if !errors.As(err, &refusal) { + t.Fatalf("the unit-only restore of a file app must still refuse (R-538): %v", err) + } +} + +// TestWhole_SecondDriveIsWholeForAFileAppWithAMirror — decision 25's table gains the second drive. +func TestWhole_SecondDriveIsWholeForAFileAppWithAMirror(t *testing.T) { + f, _, _ := wholeFixture(t) + if !f.m.WholeOnTier("app", UpdateTierSecondDrive) { + t.Fatal("a file app with a mirrored unit AND file legs must be whole on the second drive") + } + if f.m.WholeOnTier("app", UpdateTierLocal) { + t.Fatal("the own unit is still NOT whole for a file app") + } + if err := os.RemoveAll(filepath.Join(f.destBase, "hdd")); err != nil { + t.Fatal(err) + } + if f.m.WholeOnTier("app", UpdateTierSecondDrive) { + t.Fatal("a mirror without the file leg is not whole") + } +} diff --git a/controller/internal/backup/update_guard.go b/controller/internal/backup/update_guard.go index f6e5677..64a3f0b 100644 --- a/controller/internal/backup/update_guard.go +++ b/controller/internal/backup/update_guard.go @@ -620,7 +620,9 @@ func (m *Manager) UpdateHeldStacks() map[string]bool { } var out map[string]bool for _, h := range m.settings.ListRestoreHolds() { - if h.Reason != settings.HoldReasonUpdateFailed { + // v0.269.0 (decision 28): an app the box stopped for a crash loop / OOM storm is stopped BY THE + // PRODUCT and has its own event (app_stopped_unhealthy) — the same class as an update hold. + if h.Reason != settings.HoldReasonUpdateFailed && h.Reason != settings.HoldReasonUnhealthyStop { continue } if out == nil { @@ -659,8 +661,16 @@ func (m *Manager) WholeOnTier(stackName string, tier int) bool { switch tier { case UpdateTierOffsite: return true - case UpdateTierLocal, UpdateTierSecondDrive: + case UpdateTierLocal: return !m.HasDriveFileLegs(stackName) + case UpdateTierSecondDrive: + if !m.HasDriveFileLegs(stackName) { + return true + } + // v0.269.0 (decision 26): a file app is whole on the second drive when the mirror holds BOTH an + // openable unit and its file legs — RestoreTier2Whole brings back both. + cov, err := m.Tier2RestoreCoverage(stackName) + return err == nil && cov.CanRestoreUnit() && cov.CanRestore() } return false } @@ -737,3 +747,60 @@ func (m *Manager) UpdateHold(stackName string) (settings.RestoreHold, bool) { } return h, true } + +// ── Decision 28 (v0.269.0): the box stops an app in a crash loop or an out-of-memory storm ───────── + +// UnhealthyRepeatWindow is how soon a second stop counts as a repeat: the sentence then says support is +// informed. +const UnhealthyRepeatWindow = 24 * time.Hour + +// HoldUnhealthy records that the box stopped `stack` (kind "crash_loop" or "oom_storm") and returns the +// trip number: 1, or 2 when the previous stop was less than 24 h ago. Same store as every hold, so no +// start path — the boot sweep, the drive gate, the nightly legs — revives it silently. +func (m *Manager) HoldUnhealthy(stack, kind string, at time.Time) (int, error) { + if m == nil || m.settings == nil { + return 0, fmt.Errorf("no settings wired — the unhealthy stop of %s cannot be recorded", stack) + } + trip := 1 + if last, ok := m.settings.LastUnhealthyStop(stack); ok && at.Sub(last) < UnhealthyRepeatWindow { + trip = 2 + } + h := settings.RestoreHold{Stack: stack, At: at.UTC().Format(time.RFC3339), Reason: settings.HoldReasonUnhealthyStop, + UnhealthyKind: kind, Trip: trip} + if err := m.settings.SetRestoreHold(h); err != nil { + return trip, fmt.Errorf("persisting the unhealthy stop of %s: %w", stack, err) + } + if err := m.settings.RecordUnhealthyStop(stack, at); err != nil { + m.logger.Printf("[WARN] [backup] %s: recording the stop time failed: %v", stack, err) + } + m.logger.Printf("[WARN] [backup] %s is STOPPED by the box: %s (trip %d within %s) — Start gives it one more try (decision 28)", stack, kind, trip, UnhealthyRepeatWindow) + return trip, nil +} + +// LiftUnhealthyStop is the Start button's half: an unhealthy-stop hold is lifted, any other kind stays. +func (m *Manager) LiftUnhealthyStop(stack string) bool { + if m == nil || m.settings == nil { + return false + } + ok, err := m.settings.ClearUnhealthyStopHold(stack) + if err != nil { + m.logger.Printf("[ERROR] [backup] lifting the unhealthy stop of %s failed: %v", stack, err) + return false + } + return ok +} + +// HoldKind names the kind of hold in force ("" when none): update_failed, unhealthy_stop, or "restore". +func (m *Manager) HoldKind(stack string) string { + if m == nil || m.settings == nil { + return "" + } + h, ok := m.settings.GetRestoreHold(stack) + if !ok { + return "" + } + if h.Reason == "" { + return "restore" + } + return h.Reason +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index d324baf..435499a 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -1159,12 +1159,16 @@ "disk.err.target_required": "a target storage is required for the move", "disk.err.unknown_mode": "unknown mode (migrate or anyway)", "disk.err.wipe_failed": "the erase failed: %s", + "err.stacks.remove_keep_data_while_support": "While support is working on this app, its data cannot be deleted — you can remove the app and keep its data.", "err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0": "the backup holds no app data (0 data folders, 0 volumes for an app that stores data)", "err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a": "the contents of data folder %q are missing from the package", "err.appexport.a_z_kotet_adata_hianyzik_a": "the data of volume %q is missing from the package", "err.appexport.jelszo_hibas_vagy_a_fajl_serult": "wrong password, or the file is damaged", "err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es": "two data folders end in the same name (%q: %s and %s) — the package could not tell them apart", "err.appexport.visszafejtes_sikertelen": "decryption failed: %s", + "err.backup.whole_restore_space": "There is not enough room on the drive to bring the files back: %.1f GB needed, %.1f GB free. Nothing was changed.", + "err.backup.whole_restore_no_proven_copy": "The second drive holds no checked copy that can bring the app back together with its files. Nothing was changed.", + "err.backup.whole_restore_not_file_app": "This app keeps no files on the drive — its full restore is the „Full restore” button.", "err.backup.a_2_mentes_regi_formatumu_futtass": "The second backup is in the old format — run a new second backup first.", "err.backup.a_biztonsagi_mentes_elott_nem_sikerult": "the databases could not be found before the backup: %s", "err.backup.a_biztonsagi_mentes_konyvtara_nem_hozhato": "the backup folder could not be created: %s", @@ -1360,6 +1364,8 @@ "event.app_deploy_started": "App install started: %s", "event.app_deployed": "App installed: %s", "event.app_removed": "App removed: %s", + "event.app_stopped_unhealthy.repeat": "We stopped %s again because it kept crashing (or ran out of memory). Felhom support has been told.", + "event.app_stopped_unhealthy": "We stopped %s because it kept crashing (or ran out of memory). Press Start to try again.", "event.app_update_undone": "The update of %s at %s did not work. The box put back the previous version and its data automatically — nothing was lost, and there is nothing you need to do.", "event.backup_target_absent": "The whole-system backup drive is not available: %s (%s)", "event.backup_target_restored": "The whole-system backup drive is available again: %s (%s)", @@ -1485,7 +1491,9 @@ "hold.copy_holds.db_only": "holds only the settings and the database, not the files", "hold.copy_holds.files": "holds the settings, the database and the files", "hold.copy_holds.volumes": "holds the settings, the database and the data volumes", - "hold.update.no_whole_copy": "The update of %s did not work, and neither did the automatic undo. This box has no copy that can bring the app back together with its files. Felhom support has been told — until then, do not restart or remove the app.", + "hold.unhealthy_stop.repeat": "We stopped the app again because it kept crashing (or ran out of memory). Felhom support has been told. Press Start to try again.", + "hold.unhealthy_stop": "We stopped the app because it kept crashing (or ran out of memory). Press Start to try again.", + "hold.update.no_whole_copy": "The update of %s did not work, and neither did the automatic undo. This box has no copy that can bring the app back together with its files. Felhom support has been told — until then, do not restart the app or delete its data.", "hold.update.sentence": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from this backup: %s, %s — this copy %s.", "hold.update.sentence_legacy": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from the backup of %s.", "hold.update.sentence_tier": "The update of %s at %s did not succeed, and the app did not start on the new version. The app stays stopped for safety, so that its data is not damaged. It can be restored on the Backups page from this backup: %s, %s.", @@ -1526,6 +1534,7 @@ "launcher_share_password.jelszo": "Password", "launcher_shared.inditopult": "Launcher", "launcher_shared.jelenleg_nincs_elerheto_alkalmazas": "No app available right now.", + "layout.remove_keep_data_only": "Support is working on this app, so its data stays: for now you can remove only the app itself.", "layout.a_korabbi_tavoli_menteseid_megvannak": "Your earlier remote backups are safe — you need your recovery code to open them.", "layout.a_korabbi_tavoli_menteseid_megvannak_2": "Your earlier remote backups are safe, but this device needs your recovery code for them.", "layout.a_korabbi_tavoli_menteseidet_nap": "We will permanently delete your earlier remote backups in {{.RecoveryAbandonDays}} {{if eq .RecoveryAbandonDays 1}}day{{else}}days{{end}} ({{.RecoveryAbandonDate}}), as you asked.", @@ -1678,6 +1687,7 @@ "note.offsite.whole_unit_gap": "(the whole app — it has no local backup unit)", "note.reconstitute.copy_latest": "latest", "note.reconstitute.held": "restoring the data of %s stopped at %s, and the earlier state could not be put back either. The app stays stopped, for safety, so your data cannot be damaged further. Get in touch with us", + "note.restore.whole_files": "The drive's files: %d brought back, %d replaced (the older live copy was kept beside it, ending in .felhom-…), %d newer copies left untouched, %d unchanged. No file was deleted.", "note.restore.all_files_present": "Every file that was checked is in place.", "note.restore.and_database": " and the database", "note.restore.and_volumes": " and %d data volumes", @@ -1823,6 +1833,7 @@ "settings_notifications.alkalmazas_email": "App e-mail", "settings_notifications.alkalmazas_email_engedelyezese": "Allow app e-mail", "settings_notifications.alkalmazas_nem_fut": "App not running", + "settings_notifications.app_stopped_unhealthy": "The app was stopped because it kept crashing", "settings_notifications.app_update_held": "Update failed, the app is stopped", "settings_notifications.app_update_undone": "Update undone (the app runs on its previous version)", "settings_notifications.az_alkalmazasok_a_felhom_on": "Apps can send e-mail through Felhom (for example password resets, invitations),\n without setting up a separate e-mail provider. Each app’s sender address is its own\n <app>@felhom.eu address.", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 01ba69b..1a03e34 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -1154,12 +1154,16 @@ "disk.err.target_required": "céltároló kötelező az áthelyezéshez", "disk.err.unknown_mode": "ismeretlen mód (migrate vagy anyway)", "disk.err.wipe_failed": "törlés sikertelen: %s", + "err.stacks.remove_keep_data_while_support": "Amíg az ügyfélszolgálat foglalkozik az alkalmazással, az adatai nem törölhetők — az alkalmazást az adatai megtartásával eltávolíthatod.", "err.appexport.a_mentes_nem_tartalmaz_alkalmazasadatot_0": "a mentés nem tartalmaz alkalmazásadatot (0 adatkönyvtár, 0 kötet egy adattárolós alkalmazásnál)", "err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a": "a(z) %q adatkönyvtár tartalma hiányzik a csomagból", "err.appexport.a_z_kotet_adata_hianyzik_a": "a(z) %q kötet adata hiányzik a csomagból", "err.appexport.jelszo_hibas_vagy_a_fajl_serult": "jelszó hibás vagy a fájl sérült", "err.appexport.ket_adatkonyvtar_azonos_nevvel_vegzodik_es": "két adatkönyvtár azonos névvel végződik (%q: %s és %s) — a csomag nem tudná megkülönböztetni őket", "err.appexport.visszafejtes_sikertelen": "visszafejtés sikertelen: %s", + "err.backup.whole_restore_space": "Nincs elég hely a meghajtón a fájlok visszaállításához: %.1f GB kellene, %.1f GB szabad. Semmi nem változott.", + "err.backup.whole_restore_no_proven_copy": "A második meghajtón nincs olyan ellenőrzött másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. Semmi nem változott.", + "err.backup.whole_restore_not_file_app": "Ennek az alkalmazásnak nincsenek fájljai a meghajtón — a teljes visszaállítás a „Teljes visszaállítás” gomb.", "err.backup.a_2_mentes_regi_formatumu_futtass": "A 2. mentés régi formátumú — futtass előbb egy új másodlagos mentést.", "err.backup.a_biztonsagi_mentes_elott_nem_sikerult": "a biztonsági mentés előtt nem sikerült felderíteni az adatbázisokat: %s", "err.backup.a_biztonsagi_mentes_konyvtara_nem_hozhato": "a biztonsági mentés könyvtára nem hozható létre: %s", @@ -1351,6 +1355,8 @@ "event.app_deploy_started": "Alkalmazás telepítése elindult: %s", "event.app_deployed": "Alkalmazás telepítve: %s", "event.app_removed": "Alkalmazás eltávolítva: %s", + "event.app_stopped_unhealthy.repeat": "A(z) %s alkalmazást ismét leállítottuk, mert újra és újra összeomlott (vagy elfogyott a memóriája). A Felhom ügyfélszolgálatát értesítettük.", + "event.app_stopped_unhealthy": "A(z) %s alkalmazást leállítottuk, mert újra és újra összeomlott (vagy elfogyott a memóriája). Az Indítás gombbal újra megpróbálhatod.", "event.app_update_undone": "A(z) %s frissítése %s-kor nem sikerült. A doboz automatikusan visszaállította az előző változatot és az adatokat — semmi nem veszett el, nincs teendőd.", "event.backup_target_absent": "A rendszermentés meghajtója nem érhető el: %s (%s)", "event.backup_target_restored": "A rendszermentés meghajtója újra elérhető: %s (%s)", @@ -1473,7 +1479,9 @@ "hold.copy_holds.db_only": "csak a beállításokat és az adatbázist tartalmazza, a fájlokat nem", "hold.copy_holds.files": "a beállításokat, az adatbázist és a fájlokat tartalmazza", "hold.copy_holds.volumes": "a beállításokat, az adatbázist és az adatköteteket tartalmazza", - "hold.update.no_whole_copy": "A(z) %s frissítése nem sikerült, és az automatikus visszaállítás sem. Ezen a dobozon nincs olyan másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. A Felhom ügyfélszolgálatát értesítettük — kérjük, addig ne indítsa újra és ne törölje az alkalmazást.", + "hold.unhealthy_stop.repeat": "Az alkalmazást ismét leállítottuk, mert újra és újra összeomlott (vagy elfogyott a memóriája). A Felhom ügyfélszolgálatát értesítettük. Az Indítás gombbal újra megpróbálhatod.", + "hold.unhealthy_stop": "Az alkalmazást leállítottuk, mert újra és újra összeomlott (vagy elfogyott a memóriája). Az Indítás gombbal újra megpróbálhatod.", + "hold.update.no_whole_copy": "A(z) %s frissítése nem sikerült, és az automatikus visszaállítás sem. Ezen a dobozon nincs olyan másolat, amely az alkalmazást a fájljaival együtt vissza tudná hozni. A Felhom ügyfélszolgálatát értesítettük — addig ne indítsd újra, és ne töröld az adatait.", "hold.update.sentence": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s — ez a másolat %s.", "hold.update.sentence_legacy": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a(z) %s-i biztonsági mentésből a Mentések oldalon.", "hold.update.sentence_tier": "A(z) %s frissítése %s-kor nem sikerült, és az alkalmazás nem indult el az új verzióval. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek. Visszaállítható a Mentések oldalon ebből a biztonsági mentésből: %s, %s.", @@ -1514,6 +1522,7 @@ "launcher_share_password.jelszo": "Jelszó", "launcher_shared.inditopult": "Indítópult", "launcher_shared.jelenleg_nincs_elerheto_alkalmazas": "Jelenleg nincs elérhető alkalmazás.", + "layout.remove_keep_data_only": "Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.", "layout.a_korabbi_tavoli_menteseid_megvannak": "A korábbi távoli mentéseid megvannak — a megnyitásukhoz a helyreállítási kódod szükséges.", "layout.a_korabbi_tavoli_menteseid_megvannak_2": "A korábbi távoli mentéseid megvannak, de ehhez a géphez a helyreállítási kódod szükséges.", "layout.a_korabbi_tavoli_menteseidet_nap": "A korábbi távoli mentéseidet {{.RecoveryAbandonDays}} nap múlva ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint.", @@ -1666,6 +1675,7 @@ "note.offsite.whole_unit_gap": "(a teljes alkalmazás — nincs helyi mentési egysége)", "note.reconstitute.copy_latest": "legutóbbi", "note.reconstitute.held": "a(z) %s adatainak visszaállítása %s-kor megszakadt, és a korábbi állapotot sem sikerült visszatölteni. Az alkalmazás biztonsági okból leállítva marad, hogy az adatai ne sérüljenek tovább. Vedd fel velünk a kapcsolatot", + "note.restore.whole_files": "A meghajtó fájljai: %d visszahozva, %d kicserélve (a régebbi élő példány megmaradt mellette, .felhom-… végződéssel), %d újabb példány érintetlenül hagyva, %d változatlan. Fájl nem lett törölve.", "note.restore.all_files_present": "Minden vizsgált fájl megvan a helyén.", "note.restore.and_database": " és az adatbázis", "note.restore.and_volumes": " és %d adatkötet", @@ -1811,6 +1821,7 @@ "settings_notifications.alkalmazas_email": "Alkalmazás-email", "settings_notifications.alkalmazas_email_engedelyezese": "Alkalmazás-email engedélyezése", "settings_notifications.alkalmazas_nem_fut": "Alkalmazás nem fut", + "settings_notifications.app_stopped_unhealthy": "Az alkalmazást leállítottuk, mert újra és újra összeomlott", "settings_notifications.app_update_held": "Frissítés sikertelen, az alkalmazás leállítva", "settings_notifications.app_update_undone": "Frissítés visszavonva (az alkalmazás a korábbi változattal fut)", "settings_notifications.az_alkalmazasok_a_felhom_on": "Az alkalmazások a Felhom-on keresztül küldhetnek emailt (pl. jelszó-visszaállítás, meghívók),\n külön email-szolgáltató beállítása nélkül. A feladó címe minden alkalmazásnál a saját\n <alkalmazás>@felhom.eu címe lesz.", diff --git a/controller/internal/notify/notifier.go b/controller/internal/notify/notifier.go index 072040e..160e211 100644 --- a/controller/internal/notify/notifier.go +++ b/controller/internal/notify/notifier.go @@ -1304,6 +1304,27 @@ func (n *Notifier) NotifyAppUpdateHeld(d AppUpdateDetails, sentence func(lang st n.pushEventBoth("app_update_held", "error", hu, household, d) } +// AppStoppedUnhealthyDetails is the payload of app_stopped_unhealthy (v0.269.0, decision 28). +type AppStoppedUnhealthyDetails struct { + App string `json:"app"` + StackName string `json:"stack_name"` + Kind string `json:"kind"` // crash_loop / oom_storm + Count int64 `json:"count"` + WindowMin int `json:"window_min"` + Trip int `json:"trip"` +} + +// NotifyAppStoppedUnhealthy (v0.269.0, `09` §3 decision 28): the box stopped an app that kept crashing +// or kept running out of memory. Household event (warning), per-app cooldown on the hub (v0.123.0). +func (n *Notifier) NotifyAppStoppedUnhealthy(app, kind string, count int64, window time.Duration, trip int) { + key := "event.app_stopped_unhealthy" + if trip >= 2 { + key = "event.app_stopped_unhealthy.repeat" + } + n.pushEventMsg("app_stopped_unhealthy", "warning", key, + AppStoppedUnhealthyDetails{App: app, StackName: app, Kind: kind, Count: count, WindowMin: int(window.Minutes()), Trip: trip}, app) +} + // AppHoldNoWholeCopyDetails is the payload of app_hold_no_whole_copy (v0.268.0, R-659). type AppHoldNoWholeCopyDetails struct { App string `json:"app"` diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index f65212f..c6c8596 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -78,6 +78,9 @@ type Settings struct { // AppUpdateEventsSeeded (v0.264.0) guards the ONE-TIME add of app_update_undone / app_update_held // into an existing household's stored prefs (seedAppUpdateEvents). AppUpdateEventsSeeded bool `json:"app_update_events_seeded,omitempty"` + // UnhealthyStopEventSeeded (v0.269.0) guards the ONE-TIME add of app_stopped_unhealthy (the + // seedAppUpdateEvents shape). + UnhealthyStopEventSeeded bool `json:"unhealthy_stop_event_seeded,omitempty"` // HubEscrowIdentityPresent (v0.199.0, R-204 item 4 / R-193) caches the report ACK's // `escrow.identity_blob_present` — whether the HUB is holding a sealed recovery package for this @@ -201,6 +204,10 @@ type Settings struct { // leaving the marker active would have Recover() start the broken app at the next controller // boot, hours later and quietly, which is the outcome the hold exists to prevent. RestoreHolds map[string]RestoreHold `json:"restore_holds,omitempty"` + // UnhealthyStops (v0.269.0, decision 28) — when the box last stopped each app for a crash loop or an + // out-of-memory storm (RFC3339). Survives the hold being lifted by Start, so a second stop within + // 24 h is known to be a second one. + UnhealthyStops map[string]string `json:"unhealthy_stops,omitempty"` // Cross-drive restic repo password (auto-generated on first use) CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"` @@ -611,6 +618,8 @@ var DefaultEnabledEvents = []string{ // (or its undo) failed and the app is held. On by default; seeded into existing prefs once below. "app_update_undone", "app_update_held", + // v0.269.0 (`09` §3 decision 28): the box stopped an app that kept crashing / ran out of memory. + "app_stopped_unhealthy", } // PendingEvent is an event queued for the next Hub push cycle. @@ -705,6 +714,7 @@ func Load(path string, logger *log.Logger) (*Settings, error) { s.migrateResticToRsync() s.seedOffboxEnlargeNotice() s.seedAppUpdateEvents() + s.seedUnhealthyStopEvent() return s, nil } @@ -749,6 +759,25 @@ func (s *Settings) seedAppUpdateEvents() { } } +// seedUnhealthyStopEvent runs ONCE (v0.269.0, decision 28) — the seedAppUpdateEvents shape, ADD-ONLY: +// a household whose stored prefs predate app_stopped_unhealthy gets it; a later opt-out sticks. The hub +// (v0.123.0) runs the same one-time add on its side, because this list is pushed to it on every save. +func (s *Settings) seedUnhealthyStopEvent() { + if s.UnhealthyStopEventSeeded { + return + } + s.UnhealthyStopEventSeeded = true + if s.Notifications != nil && s.Notifications.EnabledEvents != nil { + s.Notifications.EnabledEvents = appendIfAbsent(s.Notifications.EnabledEvents, "app_stopped_unhealthy") + if s.log != nil { + s.log.Printf("[INFO] [settings] app_stopped_unhealthy added to the household's notification prefs (one-time, add-only)") + } + } + if err := s.save(); err != nil && s.log != nil { + s.log.Printf("[ERROR] [settings] Failed to save the unhealthy-stop event seed: %v", err) + } +} + // migrateResticToRsync converts any cross-drive backup configs using restic to rsync. // Called once during Load() before the mutex is exposed. func (s *Settings) migrateResticToRsync() { @@ -1758,12 +1787,19 @@ type RestoreHold struct { // says support is informed. CopiesSeen lists what WAS there ("tier N at RFC3339"), for support. NoWholeCopy bool `json:"no_whole_copy,omitempty"` CopiesSeen []string `json:"copies_seen,omitempty"` + // UnhealthyKind / Trip (v0.269.0, decision 28): "crash_loop" or "oom_storm", and whether this is the + // first stop or a repeat within 24 h (Trip >= 2 → the sentence says support is informed). + UnhealthyKind string `json:"unhealthy_kind,omitempty"` + Trip int `json:"trip,omitempty"` } // Hold reasons. See RestoreHold.Reason. const ( HoldReasonRestoreFailed = "" // R-379/R-380: a restore AND its rollback failed HoldReasonUpdateFailed = "update_failed" // slice 4: the new version did not come up healthy + // HoldReasonUnhealthyStop (v0.269.0, `09` §3 decision 28): the box stopped an app in a crash loop or + // an out-of-memory storm. Lifted by the household's Start (one more try); nothing else starts it. + HoldReasonUnhealthyStop = "unhealthy_stop" ) // SetRestoreHold records a hold. Modelled on SetDisconnected: a condition, plus what it is holding. @@ -2603,3 +2639,42 @@ func (s *Settings) OptOutRecoveryRemindersForEpoch() error { s.RecoveryRemindOptOutEpoch = s.RecoveryOfferEpoch return s.save() } + +// LastUnhealthyStop returns when the box last stopped `stack` for being unhealthy (decision 28). +func (s *Settings) LastUnhealthyStop(stack string) (time.Time, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + v, ok := s.UnhealthyStops[stack] + if !ok { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339, v) + return t, err == nil +} + +// RecordUnhealthyStop stores the time of a stop (decision 28). +func (s *Settings) RecordUnhealthyStop(stack string, at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.UnhealthyStops == nil { + s.UnhealthyStops = map[string]string{} + } + s.UnhealthyStops[stack] = at.UTC().Format(time.RFC3339) + return s.save() +} + +// ClearUnhealthyStopHold lifts an app's hold ONLY when it is an unhealthy stop (the Start button's +// one more try). Any other hold stays. Returns whether one was lifted. +func (s *Settings) ClearUnhealthyStopHold(stack string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + h, ok := s.RestoreHolds[stack] + if !ok || h.Reason != HoldReasonUnhealthyStop { + return false, nil + } + delete(s.RestoreHolds, stack) + if s.log != nil { + s.log.Printf("[INFO] [settings] unhealthy-stop hold LIFTED for %s (Start)", stack) + } + return true, s.save() +} diff --git a/controller/internal/stacks/delete.go b/controller/internal/stacks/delete.go index 9833ba1..40b7ef6 100644 --- a/controller/internal/stacks/delete.go +++ b/controller/internal/stacks/delete.go @@ -1,6 +1,8 @@ package stacks import ( + "gitea.dooplex.hu/admin/felhom-controller/internal/util" + "bufio" "context" "fmt" @@ -171,6 +173,19 @@ type HDDDataResponse struct { Stack string `json:"stack"` HDDPaths []HDDPath `json:"hdd_paths"` HasHDDData bool `json:"has_hdd_data"` + // KeepDataOnly (v0.269.0, `09` §3 decision 27, R-666): the app is held and its page says support is + // informed — the remove dialog offers only "remove the app, keep my data", and the API refuses the rest. + KeepDataOnly bool `json:"keep_data_only,omitempty"` +} + +// ErrRemoveKeepDataWhileSupport is the refusal of a data-deleting remove while support is informed. +var ErrRemoveKeepDataWhileSupport = util.MsgError("err.stacks.remove_keep_data_while_support") + +// RemoveKeepsDataOnly reports decision 27's condition for one app: held, and the hold names no whole +// copy (the page says support is informed). +func (m *Manager) RemoveKeepsDataOnly(name string) bool { + st, ok := m.GetStack(name) + return ok && st.HoldReason != "" && st.HoldNoWholeCopy } // HDDPath represents a single HDD bind mount path and its status. diff --git a/controller/internal/stacks/digest.go b/controller/internal/stacks/digest.go new file mode 100644 index 0000000..d240d83 --- /dev/null +++ b/controller/internal/stacks/digest.go @@ -0,0 +1,115 @@ +package stacks + +import ( + "path/filepath" + "regexp" + "strings" + "time" +) + +// ── Exact image fingerprints on the box (`09` §6.4 part 6, box half; §3 decision 17; v0.269.0) ──── +// +// The catalog records, per ladder entry, the registry digest of every `to` ref at the moment the step was +// tested (`scripts/image_digest.py`). The box: +// +// 1. RENDERS `name:tag@sha256:…` into the compose file it runs whenever the entry for exactly those refs +// carries a digest — so a pull fetches the TESTED image, not whatever the tag points at today. +// Docker and Compose accept the form and refuse a digest that does not exist (measured on 9202, +// 2026-09-23, `audits/update-rulings-2026-09-23/70-…`). +// 2. Keeps every PIN and every RECORD digest-free: ParseComposeImages strips `@…`, and the installed +// record's Ref is stripped too (its Digest is a field of its own). One strip at each door, so the +// pin, the ladder, the badge and the syncer all compare plain `name:tag`. +// 3. Reads the badge from the TESTED digest only — never a registry query (`09` §8.1): same tag, a +// different installed digest, and a catalog test NEWER than the install → „Frissítés elérhető". + +var digestRe = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// StripDigest removes a `@sha256:…` suffix from an image reference. +func StripDigest(ref string) string { + if at := strings.LastIndex(ref, "@"); at >= 0 { + return ref[:at] + } + return ref +} + +var serviceLineRe = regexp.MustCompile(`^ ([A-Za-z0-9_-]+):\s*$`) +var imageLineRe = regexp.MustCompile(`^(\s+image:\s*)["']?([^\s"'#]+)["']?(.*)$`) + +// renderDigests rewrites each service's own `image:` line to `ref@digest` when digests carries a valid +// one for that service. Line-based (the catalog's own reading, `ladder.images_in`), so comments and +// every other byte are kept. A service with no valid digest keeps its line. +func renderDigests(compose []byte, digests map[string]string) []byte { + if len(digests) == 0 { + return compose + } + lines := strings.Split(string(compose), "\n") + svc := "" + done := map[string]bool{} + inServices := false + for i, l := range lines { + if strings.HasPrefix(l, "services:") { + inServices = true + continue + } + if l != "" && !strings.HasPrefix(l, " ") && !strings.HasPrefix(l, "#") { + inServices = false + } + if !inServices { + continue + } + if m := serviceLineRe.FindStringSubmatch(l); m != nil { + svc = m[1] + continue + } + m := imageLineRe.FindStringSubmatch(l) + if m == nil || svc == "" || done[svc] { + continue + } + d := digests[svc] + if !digestRe.MatchString(d) { + continue + } + lines[i] = m[1] + StripDigest(m[2]) + "@" + d + m[3] + done[svc] = true + } + return []byte(strings.Join(lines, "\n")) +} + +// ladderEntryFor returns the NEWEST ladder entry whose `to` is exactly these (digest-free) refs. +func ladderEntryFor(templateDir string, refs map[string]string) (LadderEntry, bool) { + ladder, err := LoadLadder(filepath.Join(templateDir, ".felhom.yml")) + if err != nil { + return LadderEntry{}, false + } + for i := len(ladder) - 1; i >= 0; i-- { + if sameRefs(ladder[i].To, refs) { + return ladder[i], true + } + } + return LadderEntry{}, false +} + +// RenderWithLadderDigests is what the syncer and the update write: the compose bytes with the tested +// digests of the ladder entry for exactly its refs. No entry, or no digest → the bytes unchanged. +func RenderWithLadderDigests(templateDir string, compose []byte) []byte { + refs, err := parseComposeImagesBytes(compose) + if err != nil || len(refs) == 0 { + return compose + } + e, ok := ladderEntryFor(templateDir, refs) + if !ok { + return compose + } + return renderDigests(compose, e.Digest) +} + +// catalogTestedDigests is the badge's input: the tested digest per service of the catalog's current +// refs, and when that test ran. Empty when the ladder has no entry for them. +func catalogTestedDigests(templateDir string, catalogRefs map[string]string) (map[string]string, time.Time) { + e, ok := ladderEntryFor(templateDir, catalogRefs) + if !ok { + return nil, time.Time{} + } + t, _ := time.Parse(time.RFC3339, e.TestedAt) + return e.Digest, t +} diff --git a/controller/internal/stacks/digest_test.go b/controller/internal/stacks/digest_test.go new file mode 100644 index 0000000..8c92466 --- /dev/null +++ b/controller/internal/stacks/digest_test.go @@ -0,0 +1,80 @@ +package stacks + +import ( + "path/filepath" + "strings" + "testing" + "time" +) + +var dA = "sha256:" + strings.Repeat("a", 64) +var dB = "sha256:" + strings.Repeat("b", 64) + +func TestDigest_RenderAndStrip(t *testing.T) { + in := "# header\nservices:\n web:\n image: redis:7-alpine # pinned\n restart: unless-stopped\n db:\n image: \"mariadb:11.8\"\nvolumes:\n x:\n" + out := string(renderDigests([]byte(in), map[string]string{"web": dA, "db": "not-a-digest"})) + if !strings.Contains(out, " image: redis:7-alpine@"+dA+" # pinned") { + t.Fatalf("web not rendered:\n%s", out) + } + if !strings.Contains(out, " image: \"mariadb:11.8\"") { + t.Fatalf("an invalid digest must leave the line alone:\n%s", out) + } + again := string(renderDigests([]byte(out), map[string]string{"web": dB})) + if strings.Count(again, "@sha256:") != 1 || !strings.Contains(again, "redis:7-alpine@"+dB) { + t.Fatalf("re-render must replace, not stack, the digest:\n%s", again) + } + imgs, err := parseComposeImagesBytes([]byte(again)) + if err != nil || imgs["web"] != "redis:7-alpine" { + t.Fatalf("the parsed ref must be digest-free: %v %v", imgs, err) + } +} + +// TestDigest_FloatingTagBehindOnlyForANewerTestedDigest — the badge rule, every arm. +// +// COMPANION RED-PROOF (REPORT): make digestBehind return false — the "newer test" case then reads +// Current and this test fails. +func TestDigest_FloatingTagBehindOnlyForANewerTestedDigest(t *testing.T) { + installAt := time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC) + mk := func(instDigest, instAt string, cat map[string]string, testedAt time.Time) Stack { + return Stack{Deployed: true, CatalogImages: map[string]string{"web": "redis:7-alpine"}, + CatalogDigests: cat, CatalogTestedAt: testedAt, + AppConfig: &AppConfig{InstalledImages: map[string]InstalledImage{"web": {Ref: "redis:7-alpine", Digest: instDigest, At: instAt}}}} + } + cases := []struct { + name string + s Stack + want UpdateOrder + }{ + {"same digest", mk(dA, installAt.Format(time.RFC3339), map[string]string{"web": dA}, installAt.Add(time.Hour)), UpdateOrderCurrent}, + {"newer tested digest", mk(dA, installAt.Format(time.RFC3339), map[string]string{"web": dB}, installAt.Add(time.Hour)), UpdateOrderBehind}, + {"test older than the install", mk(dA, installAt.Format(time.RFC3339), map[string]string{"web": dB}, installAt.Add(-time.Hour)), UpdateOrderCurrent}, + {"install time unknown", mk(dA, "", map[string]string{"web": dB}, installAt.Add(time.Hour)), UpdateOrderCurrent}, + {"installed digest unknown", mk("", installAt.Format(time.RFC3339), map[string]string{"web": dB}, installAt.Add(time.Hour)), UpdateOrderCurrent}, + {"no tested digest", mk(dA, installAt.Format(time.RFC3339), nil, time.Time{}), UpdateOrderCurrent}, + } + for _, c := range cases { + if got := CatalogOrder(c.s); got != c.want { + t.Errorf("%s: %v, want %v", c.name, got, c.want) + } + } +} + +// The update runs EXACTLY the tested image: the pinned step's compose carries the entry's digest, while +// the pin itself stays a plain ref. +func TestDigest_TheUpdateRendersTheStepsTestedDigest(t *testing.T) { + m, dir, _, _, _ := ladderManager(t, true) + catDir := filepath.Dir(m.CatalogTemplatePath("nextcloud", "docker-compose.yml")) + // the ladder fixture's digests are sha256:aaaa… + if err := m.StartGuardedUpdate("nextcloud"); err != nil { + t.Fatal(err) + } + waitUpdateDone(t, m, "nextcloud") + body := fileBody(t, ComposePathIn(dir)) + if !strings.Contains(body, "image: "+ladderB+"@"+dA) { + t.Fatalf("the live compose does not pin the tested digest:\n%s", body) + } + if pinOf(t, dir) != ladderB { + t.Fatalf("the pin must stay a plain ref, got %q", pinOf(t, dir)) + } + _ = catDir +} diff --git a/controller/internal/stacks/installed.go b/controller/internal/stacks/installed.go index 6afd0a8..566fdc6 100644 --- a/controller/internal/stacks/installed.go +++ b/controller/internal/stacks/installed.go @@ -92,16 +92,27 @@ func ParseComposeImages(composePath string) (map[string]string, error) { if err != nil { return nil, fmt.Errorf("reading compose file: %w", err) } + out, err := parseComposeImagesBytes(data) + if err != nil { + return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err) + } + return out, nil +} + +// parseComposeImagesBytes is ParseComposeImages over bytes. v0.269.0 (`09` §6.4 part 6): a rendered +// `name:tag@sha256:…` is returned WITHOUT its digest — pins, the ladder, the badge and the syncer compare +// plain refs; the digest lives only in the compose file that runs (digest.go). +func parseComposeImagesBytes(data []byte) (map[string]string, error) { var doc composeImagesDoc if err := yaml.Unmarshal(data, &doc); err != nil { - return nil, fmt.Errorf("parsing compose file %s: %w", composePath, err) + return nil, err } out := make(map[string]string, len(doc.Services)) for svc, def := range doc.Services { if strings.TrimSpace(def.Image) == "" { continue } - out[svc] = strings.TrimSpace(def.Image) + out[svc] = StripDigest(strings.TrimSpace(def.Image)) } return out, nil } @@ -212,7 +223,7 @@ func (m *Manager) observeInstalledImages(stackDir string, env []string) (map[str continue } out[svc] = InstalledImage{ - Ref: f.ref, + Ref: StripDigest(f.ref), // v0.269.0: a digest-pinned run records the plain ref; the digest is Digest Digest: pickDigest(f.ref, digests[f.imageID]), At: now, } diff --git a/controller/internal/stacks/ladder.go b/controller/internal/stacks/ladder.go index e28a9aa..dc617b2 100644 --- a/controller/internal/stacks/ladder.go +++ b/controller/internal/stacks/ladder.go @@ -42,6 +42,8 @@ type LadderEntry struct { To map[string]string `yaml:"to" json:"to"` Digest map[string]string `yaml:"digest" json:"digest"` Verdict string `yaml:"verdict" json:"verdict"` + // TestedAt is when the step was proven (RFC3339) — the badge compares it with the install (v0.269.0). + TestedAt string `yaml:"tested_at" json:"tested_at"` } type ladderDoc struct { @@ -80,6 +82,11 @@ func StepFile(templateDir string, to map[string]string) string { return filepath.Join(templateDir, "steps", StepKey(to)+".yml") } +// StepMetaFile is the step's own `.felhom.yml` (R-664, v0.269.0): its probe, memory and applied record. +func StepMetaFile(templateDir string, to map[string]string) string { + return filepath.Join(templateDir, "steps", StepKey(to)+".felhom.yml") +} + func sameRefs(a, b map[string]string) bool { if len(a) != len(b) { return false @@ -100,6 +107,10 @@ type LadderStep struct { Left int // Source is the compose file the step pins: a steps/ file, or the template's docker-compose.yml. Source string + // Meta is the `.felhom.yml` that belongs to Source (R-664): steps/.felhom.yml when the catalog + // carries it, else the template's own — never the stack dir's, which a restore may have rewritten + // with an older one (R-665). + Meta string // Why is one operator-English sentence for the log. Why string } @@ -109,12 +120,13 @@ type LadderStep struct { // update refuses before anything moves (a jump past a tested step is the thing this exists to stop). func nextLadderStep(templateDir string, pinned map[string]string) (LadderStep, error) { current := filepath.Join(templateDir, "docker-compose.yml") + currentMeta := filepath.Join(templateDir, ".felhom.yml") ladder, err := LoadLadder(filepath.Join(templateDir, ".felhom.yml")) if err != nil && !os.IsNotExist(err) { return LadderStep{}, err } if len(ladder) == 0 { - return LadderStep{Index: -1, Source: current, Why: "the template carries no update_ladder — the catalog's current definition"}, nil + return LadderStep{Index: -1, Source: current, Meta: currentMeta, Why: "the template carries no update_ladder — the catalog's current definition"}, nil } idx := -1 for i := len(ladder) - 1; i >= 0; i-- { // the NEWEST entry whose `from` is what runs @@ -124,12 +136,12 @@ func nextLadderStep(templateDir string, pinned map[string]string) (LadderStep, e } } if idx < 0 { - return LadderStep{Index: -1, Source: current, + return LadderStep{Index: -1, Source: current, Meta: currentMeta, Why: fmt.Sprintf("the installed version %s matches no update_ladder entry (%d entries) — an app older than the ladder has no record to climb; the catalog's current definition", summarisePin(pinned), len(ladder))}, nil } left := len(ladder) - idx if idx == len(ladder)-1 { - return LadderStep{Index: idx, Left: left, Source: current, + return LadderStep{Index: idx, Left: left, Source: current, Meta: currentMeta, Why: fmt.Sprintf("the last step (%d of %d) — the catalog's current definition", idx+1, len(ladder))}, nil } src := StepFile(templateDir, ladder[idx].To) @@ -140,8 +152,12 @@ func nextLadderStep(templateDir string, pinned map[string]string) (LadderStep, e if !sameRefs(imgs, ladder[idx].To) { return LadderStep{}, fmt.Errorf("step %d of %d: %s names %s, the ladder says %s", idx+1, len(ladder), src, summarisePin(imgs), summarisePin(ladder[idx].To)) } - return LadderStep{Index: idx, Left: left, Source: src, - Why: fmt.Sprintf("step %d of %d: %s → %s, from %s", idx+1, len(ladder), summarisePin(ladder[idx].From), summarisePin(ladder[idx].To), filepath.Base(src))}, nil + meta := StepMetaFile(templateDir, ladder[idx].To) + if _, err := os.Stat(meta); err != nil { + meta = currentMeta // a step written before R-664: the template's own, said in the log + } + return LadderStep{Index: idx, Left: left, Source: src, Meta: meta, + Why: fmt.Sprintf("step %d of %d: %s → %s, from %s (probe from %s)", idx+1, len(ladder), summarisePin(ladder[idx].From), summarisePin(ladder[idx].To), filepath.Base(src), filepath.Base(meta))}, nil } // ladderStepsLeft is the page's count: how many tested steps separate this pin from the catalog's @@ -161,3 +177,22 @@ func ladderStepsLeft(templateDir string, pinned map[string]string) int { } return 0 } + +// loadMetadataFile reads a `.felhom.yml` that is not named `.felhom.yml` (a step's +// `steps/.felhom.yml`) through LoadMetadata — the ONE validating reader — by giving it a scratch +// directory. Only the health check and the resources are read from the result. +func loadMetadataFile(path string) (Metadata, error) { + data, err := os.ReadFile(path) + if err != nil { + return Metadata{}, err + } + tmp, err := os.MkdirTemp("", "felhom-step-meta-") + if err != nil { + return Metadata{}, err + } + defer os.RemoveAll(tmp) + if err := os.WriteFile(filepath.Join(tmp, ".felhom.yml"), data, 0o600); err != nil { + return Metadata{}, err + } + return LoadMetadata(tmp), nil +} diff --git a/controller/internal/stacks/ladder_test.go b/controller/internal/stacks/ladder_test.go index 792b56d..92dbc25 100644 --- a/controller/internal/stacks/ladder_test.go +++ b/controller/internal/stacks/ladder_test.go @@ -82,10 +82,12 @@ func TestLadder_TwoPressesTwoSteps(t *testing.T) { if got := pinOf(t, dir); got != ladderB { t.Fatalf("press 1 pinned %s, want the tested step B %s — one press must be one step", got, ladderB) } - if body := fileBody(t, ComposePathIn(dir)); body != ladderBDef { - t.Fatalf("press 1 rendered\n%s\nwant step B's OWN definition (with STEP_B_OWN_DEFINITION)", body) + // v0.269.0: the step's own definition, run with its TESTED digest (`09` §6.4 part 6) + wantB := string(renderDigests([]byte(ladderBDef), map[string]string{"web": "sha256:" + strings.Repeat("a", 64)})) + if body := fileBody(t, ComposePathIn(dir)); body != wantB { + t.Fatalf("press 1 rendered\n%s\nwant step B's OWN definition (with STEP_B_OWN_DEFINITION) and its digest", body) } - if body := fileBody(t, AppliedComposePath(dir)); body != ladderBDef { + if body := fileBody(t, AppliedComposePath(dir)); body != wantB { t.Fatal("the stored applied definition is not step B's — the sync would render the wrong file while the app sits at B") } if n := ladderStepsLeft(catDir, map[string]string{"web": ladderB}); n != 1 { @@ -99,7 +101,7 @@ func TestLadder_TwoPressesTwoSteps(t *testing.T) { if st.UpdatePhase != UpdatePhaseDone || pinOf(t, dir) != ladderC { t.Fatalf("press 2 ended %q on %s, want done on C", st.UpdatePhase, pinOf(t, dir)) } - if body := fileBody(t, ComposePathIn(dir)); body != pinTplNew { + if body := fileBody(t, ComposePathIn(dir)); body != string(renderDigests([]byte(pinTplNew), map[string]string{"web": "sha256:" + strings.Repeat("a", 64)})) { t.Fatalf("press 2 must render the catalog's current definition; got\n%s", body) } if strings.Join(*ups, ",") != ladderB+","+ladderC { diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 9215c92..d2a883e 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -170,9 +170,12 @@ type Stack struct { HoldReason string `json:"hold_reason,omitempty"` // HoldNoWholeCopy (v0.268.0, R-659): the hold names NO copy — none on this box brings the app back // whole — so the page offers no restore button beside the sentence (the restore would refuse). - HoldNoWholeCopy bool `json:"hold_no_whole_copy,omitempty"` - HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result - LastUpdated time.Time `json:"last_updated"` + HoldNoWholeCopy bool `json:"hold_no_whole_copy,omitempty"` + // HoldKind (v0.269.0): which hold — "update_failed", "unhealthy_stop" (decision 28: the page offers + // Start, not a restore), "restore"; "" when none. + HoldKind string `json:"hold_kind,omitempty"` + HealthProbe *HealthProbeResult `json:"health_probe,omitempty"` // controller-side probe result + LastUpdated time.Time `json:"last_updated"` // RestartingSince (C9-F2) is when this stack was FIRST observed in StateRestarting during the // current restarting run; zero whenever the stack is in any other state. It is what turns a brief // restart (normal: deploy, update, quiesce restart) into a distinguishable crash loop — see @@ -205,10 +208,22 @@ type Stack struct { // pin and the catalog's head; one press climbs one. 0 = unknown or none. The page shows it while // the badge says „Frissítés elérhető". LadderStepsLeft int `json:"ladder_steps_left,omitempty"` + + // CatalogDigests / CatalogTestedAt (v0.269.0, `09` §6.4 part 6): the TESTED digest per service of the + // catalog's current refs and when that test ran — the badge's input for a floating tag (never a + // registry query). Empty when the ladder has no entry for them. + CatalogDigests map[string]string `json:"catalog_digests,omitempty"` + CatalogTestedAt time.Time `json:"catalog_tested_at,omitempty"` } // Manager handles all docker compose stack operations. type Manager struct { + // lastVerifyMeta (v0.269.0) is the .felhom.yml the last update verify judged by — read by a test. + lastVerifyMeta string + // updateHealthMetaFn (test seam, v0.269.0): the verify with the chosen .felhom.yml; nil → the real wait. + updateHealthMetaFn func(ctx context.Context, name string, timeout time.Duration, meta *Metadata) (bool, string) + // unhealthy (v0.269.0, decision 28): RestartCount / OOM-kill samples per app for the crash-loop stop. + unhealthy unhealthyWatch // selfUpdating (v0.261.0) reports whether the CONTROLLER is swapping itself. Set by // SetSelfUpdatingCheck; nil means no gate. See update.go. selfUpdating func() bool @@ -611,6 +626,8 @@ func (m *Manager) ScanStacks() error { // catalog template by definition, and warning once per app per scan would be noise. var catImages map[string]string stepsLeft := 0 + var catDigests map[string]string + var catTestedAt time.Time if deployed && !m.cfg.IsProtectedStack(name) { catPath := m.CatalogTemplatePath(name, "docker-compose.yml") if appCfg != nil { @@ -618,6 +635,7 @@ func (m *Manager) ScanStacks() error { } if imgs, cerr := ParseComposeImages(catPath); cerr == nil { catImages = imgs + catDigests, catTestedAt = catalogTestedDigests(filepath.Dir(catPath), imgs) } else if m.isDebug() { m.logger.Printf("[DEBUG] [stacks] ScanStacks: no readable catalog template for %s (%v) — the update badge will render nothing", name, cerr) } @@ -635,6 +653,7 @@ func (m *Manager) ScanStacks() error { existing.TemplateImages = tplImages existing.CatalogImages = catImages existing.LadderStepsLeft = stepsLeft + existing.CatalogDigests, existing.CatalogTestedAt = catDigests, catTestedAt } } else { m.stacks[name] = &Stack{ @@ -648,6 +667,8 @@ func (m *Manager) ScanStacks() error { TemplateImages: tplImages, CatalogImages: catImages, LadderStepsLeft: stepsLeft, + CatalogDigests: catDigests, + CatalogTestedAt: catTestedAt, } } } diff --git a/controller/internal/stacks/pin.go b/controller/internal/stacks/pin.go index 76cbe87..4978b6c 100644 --- a/controller/internal/stacks/pin.go +++ b/controller/internal/stacks/pin.go @@ -328,12 +328,12 @@ func (m *Manager) CatalogTemplatePath(appName, filename string) string { // the syncer has already copied the catalog verbatim into its stack dir, so `pull` + `up -d` do // today's job with no help from here. func (m *Manager) advancePinToCatalog(name, stackDir string) error { - return m.advancePinTo(name, stackDir, m.CatalogTemplatePath(name, "docker-compose.yml")) + return m.advancePinTo(name, stackDir, m.CatalogTemplatePath(name, "docker-compose.yml"), m.CatalogTemplatePath(name, ".felhom.yml")) } // advancePinTo is advancePinToCatalog with the definition named: the catalog's current compose file, // or — on a ladder (v0.268.0, `09` §3 decision 14) — one step's own definition from `steps/`. -func (m *Manager) advancePinTo(name, stackDir, src string) error { +func (m *Manager) advancePinTo(name, stackDir, src, metaSrc string) error { cfg := LoadAppConfig(stackDir) if cfg == nil || len(cfg.PinnedImages) == 0 { return nil // unpinned — today's behaviour, unchanged @@ -346,6 +346,8 @@ func (m *Manager) advancePinTo(name, stackDir, src string) error { return fmt.Errorf("cannot read the catalog's current definition for %s (%s): %w", name, src, err) } + // v0.269.0 (`09` §6.4 part 6): run EXACTLY the tested images — the ladder entry's digests for these refs. + data = RenderWithLadderDigests(filepath.Dir(m.CatalogTemplatePath(name, "docker-compose.yml")), data) live := ComposePathIn(stackDir) if err := StoreAppliedDefinition(stackDir, data); err != nil { return fmt.Errorf("storing the new applied definition for %s: %w", name, err) @@ -365,8 +367,9 @@ func (m *Manager) advancePinTo(name, stackDir, src string) error { } m.mu.Unlock() - // v0.263.2: the new version's own .felhom.yml becomes the pinned version's record. - m.storeAppliedMetaFrom(name, stackDir, m.CatalogTemplatePath(name, ".felhom.yml")) - m.logger.Printf("[INFO] [stacks] update %s: pin advanced to the catalog's current definition (%s)", name, summarisePin(pin)) + // v0.263.2: the new version's own .felhom.yml becomes the pinned version's record — the STEP's own + // when the ladder carries one (R-664, v0.269.0). + m.storeAppliedMetaFrom(name, stackDir, metaSrc) + m.logger.Printf("[INFO] [stacks] update %s: pin advanced to %s (%s)", name, src, summarisePin(pin)) return nil } diff --git a/controller/internal/stacks/r665_verify_meta_test.go b/controller/internal/stacks/r665_verify_meta_test.go new file mode 100644 index 0000000..ff97660 --- /dev/null +++ b/controller/internal/stacks/r665_verify_meta_test.go @@ -0,0 +1,59 @@ +package stacks + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +// R-665 + R-664 (v0.269.0) — the new version is judged by ITS OWN .felhom.yml: the catalog's, or the +// ladder step's; never the stack dir's, which a restore rewrites with the unit's older file. +// +// COMPANION RED-PROOF (REPORT): make updateHealthFor ignore metaFile (the v0.268.0 behaviour) — the probe +// port read is then the RESTORED 1111 and both cases fail. +func probePortSeen(t *testing.T, m *Manager) *int { + t.Helper() + port := new(int) + m.updateHealthMetaFn = func(_ context.Context, _ string, _ time.Duration, meta *Metadata) (bool, string) { + if meta != nil && meta.HealthCheck != nil && len(meta.HealthCheck.Checks) > 0 { + *port = meta.HealthCheck.Checks[0].Port + } + return true, "fake" + } + return port +} + +func TestR665_TheVerifyUsesTheNewVersionsOwnFile(t *testing.T) { + m, dir, _, _ := newSlice4Manager(t) + mustWrite(t, filepath.Join(dir, ".felhom.yml"), "healthcheck:\n checks:\n - type: http\n port: 1111\n") // what a restore wrote + mustWrite(t, m.CatalogTemplatePath("nextcloud", ".felhom.yml"), "healthcheck:\n checks:\n - type: http\n port: 2222\n") + port := probePortSeen(t, m) + if err := m.StartGuardedUpdate("nextcloud"); err != nil { + t.Fatal(err) + } + if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseDone { + t.Fatalf("ended %q", st.UpdatePhase) + } + if *port != 2222 { + t.Fatalf("verified with probe port %d, want the catalog's 2222 (1111 = the restored file, R-665)", *port) + } +} + +func TestR664_AStepIsVerifiedWithItsOwnFile(t *testing.T) { + m, dir, _, _, _ := ladderManager(t, true) + catDir := filepath.Dir(m.CatalogTemplatePath("nextcloud", "docker-compose.yml")) + mustWrite(t, filepath.Join(dir, ".felhom.yml"), "healthcheck:\n checks:\n - type: http\n port: 1111\n") + mustWriteMk(t, StepMetaFile(catDir, map[string]string{"web": ladderB}), "healthcheck:\n checks:\n - type: http\n port: 3333\n") + port := probePortSeen(t, m) + if err := m.StartGuardedUpdate("nextcloud"); err != nil { + t.Fatal(err) + } + waitUpdateDone(t, m, "nextcloud") + if *port != 3333 { + t.Fatalf("step B verified with probe port %d, want its own 3333", *port) + } + if got := fileBody(t, filepath.Join(dir, appliedMetaDir, ".felhom.yml")); got != "healthcheck:\n checks:\n - type: http\n port: 3333\n" { + t.Fatalf("applied-meta after step B is not the step's own file: %q", got) + } +} diff --git a/controller/internal/stacks/unhealthy.go b/controller/internal/stacks/unhealthy.go new file mode 100644 index 0000000..e04c9b2 --- /dev/null +++ b/controller/internal/stacks/unhealthy.go @@ -0,0 +1,190 @@ +package stacks + +import ( + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// ── A crash loop and an out-of-memory storm are stopped by the box (`09` §3 decision 28, R-667) ──── +// +// WHY THE OLD DETECTOR MISSED IT. Stack.CrashLooping asked for crashLoopAfter (5 min) of UNINTERRUPTED +// `restarting`, measured by RestartingSince — a clock the status pass resets whenever it catches the +// container `running` between two crashes. Measured on 9202 2026-09-24: gokapi at 385 → 546 restarts, +// `restarting_since` a minute old at every look, „0 currently down". +// +// SO THIS COUNTS WHAT DOCKER COUNTS: each container's RestartCount, summed per app, sampled every scan. +// RestartCount only grows for one container run and resets when compose RECREATES the container — a +// drop is a reset and starts the window again, never a negative count. +// +// THE THRESHOLDS, from evidence (`audits/night-2026-09-24/A3/`): +// - crash loop: >= CrashLoopRestarts (6) restarts within CrashLoopWindow (10 min). Docker's restart +// back-off caps a steady crash loop at about ONE restart a minute (gokapi: 539 → 546 in 7 min), so +// the brief's "10 in 10 minutes" sits on the edge and can miss a steady loop. Across all 40 +// containers of both demo boxes and 9202 no healthy container had restarted more than ONCE. +// - out-of-memory storm: the existing app_oom_storm rule — >= 20 kernel OOM kills within 30 min. + +const ( + CrashLoopRestarts = 6 + CrashLoopWindow = 10 * time.Minute + OOMStormKills = 20 + OOMStormWindow = 30 * time.Minute + + UnhealthyCrashLoop = "crash_loop" + UnhealthyOOMStorm = "oom_storm" +) + +type unhealthySample struct { + at time.Time + restarts int64 + kills int64 // -1: unknown +} + +// UnhealthyVerdict is one app that crossed a threshold in the latest observation. +type UnhealthyVerdict struct { + Stack string + Kind string // UnhealthyCrashLoop / UnhealthyOOMStorm + Count int64 // restarts or kills inside the window + Window time.Duration +} + +type unhealthyWatch struct { + mu sync.Mutex + samples map[string][]unhealthySample +} + +// judgeUnhealthy is the pure verdict over one app's samples (oldest first, the last one = now). +func judgeUnhealthy(samples []unhealthySample) (kind string, count int64, window time.Duration) { + if len(samples) < 2 { + return "", 0, 0 + } + last := samples[len(samples)-1] + // A drop in either counter is a reset (the container was recreated): only samples after it count. + start := 0 + for i := 1; i < len(samples); i++ { + if samples[i].restarts < samples[i-1].restarts { + start = i + } + } + for i := start; i < len(samples); i++ { + if last.at.Sub(samples[i].at) <= CrashLoopWindow { + if d := last.restarts - samples[i].restarts; d >= CrashLoopRestarts { + return UnhealthyCrashLoop, d, CrashLoopWindow + } + break + } + } + if last.kills >= 0 { + kstart := 0 + for i := 1; i < len(samples); i++ { + if samples[i].kills >= 0 && samples[i-1].kills >= 0 && samples[i].kills < samples[i-1].kills { + kstart = i + } + } + for i := kstart; i < len(samples); i++ { + if samples[i].kills < 0 { + continue + } + if last.at.Sub(samples[i].at) <= OOMStormWindow { + if d := last.kills - samples[i].kills; d >= OOMStormKills { + return UnhealthyOOMStorm, d, OOMStormWindow + } + break + } + } + } + return "", 0, 0 +} + +// ObserveUnhealthy takes one sample per deployed app — RestartCount summed over its containers (one +// `docker inspect` for all of them) and the kernel OOM kills from the latest ScanOOMKilled — and returns +// the apps whose window crossed a threshold. An app that is deploying, updating or already held is not +// sampled and its history is dropped (a deploy or an update recreates containers on purpose). +func (m *Manager) ObserveUnhealthy(now time.Time, ooms []OOMContainer) []UnhealthyVerdict { + type appC struct{ names []string } + m.mu.RLock() + apps := map[string]*appC{} + owner := map[string]string{} + var all []string + skip := map[string]bool{} + for name, st := range m.stacks { + if !st.Deployed || st.Protected { + continue + } + if st.Deploying || st.Updating || st.HoldReason != "" || st.updateHeld { + skip[name] = true + continue + } + a := &appC{} + for _, c := range st.Containers { + a.names = append(a.names, c.Name) + owner[c.Name] = name + all = append(all, c.Name) + } + apps[name] = a + } + m.mu.RUnlock() + + restarts := map[string]int64{} + if len(all) > 0 { + sort.Strings(all) + args := append([]string{"inspect", "-f", "{{.Name}}|{{.RestartCount}}"}, all...) + out, _ := m.execCommand("docker", args...) // a vanished container fails its own line only + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + f := strings.SplitN(strings.TrimSpace(line), "|", 2) + if len(f) != 2 { + continue + } + n, err := strconv.ParseInt(strings.TrimSpace(f[1]), 10, 64) + if err != nil { + continue + } + if app, ok := owner[strings.TrimPrefix(f[0], "/")]; ok { + restarts[app] += n + } + } + } + kills := map[string]int64{} + for _, o := range ooms { + if o.Kills >= 0 { + kills[o.Stack] += o.Kills + } + } + + m.unhealthy.mu.Lock() + defer m.unhealthy.mu.Unlock() + if m.unhealthy.samples == nil { + m.unhealthy.samples = map[string][]unhealthySample{} + } + for name := range m.unhealthy.samples { + if _, live := apps[name]; !live { + delete(m.unhealthy.samples, name) // removed, deploying, updating or held: history dropped + } + } + var out []UnhealthyVerdict + for name := range apps { + if skip[name] { + continue + } + k := int64(-1) + if v, ok := kills[name]; ok { + k = v + } else { + k = 0 + } + ss := append(m.unhealthy.samples[name], unhealthySample{at: now, restarts: restarts[name], kills: k}) + // keep 35 minutes of history — the longer window plus a margin + for len(ss) > 1 && now.Sub(ss[0].at) > OOMStormWindow+5*time.Minute { + ss = ss[1:] + } + m.unhealthy.samples[name] = ss + if kind, n, win := judgeUnhealthy(ss); kind != "" { + out = append(out, UnhealthyVerdict{Stack: name, Kind: kind, Count: n, Window: win}) + delete(m.unhealthy.samples, name) // one verdict per episode + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Stack < out[j].Stack }) + return out +} diff --git a/controller/internal/stacks/unhealthy_test.go b/controller/internal/stacks/unhealthy_test.go new file mode 100644 index 0000000..739cc24 --- /dev/null +++ b/controller/internal/stacks/unhealthy_test.go @@ -0,0 +1,102 @@ +package stacks + +import ( + "fmt" + "io" + "log" + "strings" + "testing" + "time" +) + +var uT0 = time.Date(2026, 9, 24, 9, 22, 0, 0, time.UTC) + +// gokapi's measured shape: Docker's back-off caps a steady crash loop at ~1 restart a minute (539 → 546 +// in 7 min on 9202). Sampled every 30 s, as the dead-app loop does. +func gokapiSamples(minutes int) []unhealthySample { + var ss []unhealthySample + for i := 0; i <= minutes*2; i++ { + ss = append(ss, unhealthySample{at: uT0.Add(time.Duration(i) * 30 * time.Second), restarts: 539 + int64(i/2), kills: 0}) + } + return ss +} + +// TestR667_TheMeasuredCrashLoopTrips — the shape the old detector never saw. +// +// COMPANION RED-PROOF (REPORT): make judgeUnhealthy compare only the LAST TWO samples (the "clock that +// resets at every look" shape of RestartingSince) — this test then fails at "gokapi's loop did not trip". +func TestR667_TheMeasuredCrashLoopTrips(t *testing.T) { + if k, _, _ := judgeUnhealthy(gokapiSamples(5)); k != "" { + t.Fatalf("5 minutes of 1/min restarts tripped already (%s) — the threshold is 6 in 10 min", k) + } + k, n, w := judgeUnhealthy(gokapiSamples(7)) + if k != UnhealthyCrashLoop || n < CrashLoopRestarts || w != CrashLoopWindow { + t.Fatalf("gokapi's loop did not trip: kind=%q n=%d", k, n) + } +} + +func TestR667_SlowStartsAndResetsDoNotTrip(t *testing.T) { + // the worst healthy container measured on the demo boxes: one restart at first start + one := []unhealthySample{{at: uT0, restarts: 0}, {at: uT0.Add(30 * time.Second), restarts: 1}, {at: uT0.Add(9 * time.Minute), restarts: 1}} + if k, _, _ := judgeUnhealthy(one); k != "" { + t.Fatalf("a single first-start restart tripped: %s", k) + } + // compose recreated the container: RestartCount 40 → 0 → 3 is a reset, not -37 or +43 + reset := []unhealthySample{{at: uT0, restarts: 40}, {at: uT0.Add(time.Minute), restarts: 0}, {at: uT0.Add(2 * time.Minute), restarts: 3}} + if k, _, _ := judgeUnhealthy(reset); k != "" { + t.Fatalf("a recreate read as a crash loop: %s", k) + } + // restarts spread over more than the window: 6 restarts in 30 min is not a loop + var spread []unhealthySample + for i := 0; i <= 6; i++ { + spread = append(spread, unhealthySample{at: uT0.Add(time.Duration(i) * 5 * time.Minute), restarts: int64(i)}) + } + if k, _, _ := judgeUnhealthy(spread); k != "" { + t.Fatalf("6 restarts in 30 min tripped: %s", k) + } +} + +func TestR667_OOMStormTrips(t *testing.T) { + ss := []unhealthySample{{at: uT0, kills: 3}, {at: uT0.Add(10 * time.Minute), kills: 12}, {at: uT0.Add(25 * time.Minute), kills: 23}} + if k, n, _ := judgeUnhealthy(ss); k != UnhealthyOOMStorm || n != 20 { + t.Fatalf("kind=%q n=%d, want an OOM storm of 20", k, n) + } + calm := []unhealthySample{{at: uT0, kills: 3}, {at: uT0.Add(25 * time.Minute), kills: 10}} + if k, _, _ := judgeUnhealthy(calm); k != "" { + t.Fatalf("7 kills tripped: %s", k) + } +} + +// ObserveUnhealthy through its docker seam: RestartCount summed per app, one verdict per episode, and an +// app that is updating is never judged. +func TestR667_ObserveSumsPerAppAndSkipsUpdating(t *testing.T) { + m := &Manager{logger: log.New(io.Discard, "", 0), stacks: map[string]*Stack{ + "gokapi": {Name: "gokapi", Deployed: true, Containers: []ContainerInfo{{Name: "gokapi"}}}, + "romm": {Name: "romm", Deployed: true, Updating: true, Containers: []ContainerInfo{{Name: "romm"}}}, + }} + count := int64(539) + m.execFn = func(name string, args ...string) (string, error) { + a := strings.Join(args, " ") + out := "" + if strings.Contains(a, "gokapi") { + out += fmt.Sprintf("/gokapi|%d\n", count) + } + if strings.Contains(a, "romm") { + out += "/romm|999\n" + } + return out, nil + } + var got []UnhealthyVerdict + for i := 0; i <= 16 && len(got) == 0; i++ { + got = m.ObserveUnhealthy(uT0.Add(time.Duration(i)*30*time.Second), nil) + if i%2 == 1 { + count++ + } + } + if len(got) != 1 || got[0].Stack != "gokapi" || got[0].Kind != UnhealthyCrashLoop { + t.Fatalf("verdicts = %+v, want exactly gokapi's crash loop", got) + } + if again := m.ObserveUnhealthy(uT0.Add(9*time.Minute), nil); len(again) != 0 { + t.Fatalf("the same episode tripped twice: %+v", again) + } +} diff --git a/controller/internal/stacks/update.go b/controller/internal/stacks/update.go index 6bdd660..6374407 100644 --- a/controller/internal/stacks/update.go +++ b/controller/internal/stacks/update.go @@ -295,6 +295,9 @@ func fillHoldReason(g UpdateGuards, st *Stack) { if nw, ok := g.(holdWholeCopy); ok { st.HoldNoWholeCopy = nw.HoldNoWholeCopy(st.Name) } + if hk, ok := g.(holdKinder); ok { + st.HoldKind = hk.HoldKind(st.Name) + } } } // R-480: an update that ended HELD carries the hold's sentence as its UpdateError. Once that hold @@ -307,6 +310,11 @@ func fillHoldReason(g UpdateGuards, st *Stack) { } } +// holdKinder is the OPTIONAL half of UpdateGuards that names the hold's kind (v0.269.0). +type holdKinder interface { + HoldKind(name string) string +} + // holdWholeCopy is the OPTIONAL half of UpdateGuards that says a hold names no copy (R-659). type holdWholeCopy interface { HoldNoWholeCopy(name string) bool @@ -445,6 +453,14 @@ func (m *Manager) updateMemoryRefusal(name string, st *Stack) *UpdateRefusal { return nil } newMeta := LoadMetadata(filepath.Dir(catPath)) + // R-664 (v0.269.0): on a ladder the NEXT STEP's own memory request is what this press installs. + if st.AppConfig != nil && len(st.AppConfig.PinnedImages) > 0 { + if step, err := nextLadderStep(filepath.Dir(catPath), st.AppConfig.PinnedImages); err == nil && step.Meta != catPath { + if mm, merr := loadMetadataFile(step.Meta); merr == nil { + newMeta = mm + } + } + } newReq, newLim := ParseMemoryMB(newMeta.Resources.MemRequest), ParseMemoryMB(newMeta.Resources.MemLimit) if newReq == 0 { m.logger.Printf("[WARN] [stacks] update %s: the new template declares no memory request — proceeding without the memory check", name) @@ -657,9 +673,32 @@ func (m *Manager) updateCompose(dir string, env []string, args ...string) (strin } func (m *Manager) updateHealth(ctx context.Context, name string, timeout time.Duration) (bool, string) { - if m.updateHealthFn != nil { + return m.updateHealthFor(ctx, name, timeout, "") +} + +// updateHealthFor is the verify with the NEW version's own .felhom.yml (R-665/R-664): metaFile is the +// catalog's (or the step's) file journaled at the start of the job; "" = the stack dir's (an update +// journaled by an older controller). +func (m *Manager) updateHealthFor(ctx context.Context, name string, timeout time.Duration, metaFile string) (bool, string) { + if m.updateHealthFn != nil && m.updateHealthMetaFn == nil { return m.updateHealthFn(ctx, name, timeout) } + if metaFile != "" { + if _, err := os.Stat(metaFile); err == nil { + meta := LoadMetadata(filepath.Dir(metaFile)) + if filepath.Base(metaFile) != ".felhom.yml" { + if mm, err := loadMetadataFile(metaFile); err == nil { + meta = mm + } + } + m.lastVerifyMeta = metaFile + if m.updateHealthMetaFn != nil { + return m.updateHealthMetaFn(ctx, name, timeout, &meta) + } + return m.waitUpdateHealthyMeta(ctx, name, timeout, &meta) + } + m.logger.Printf("[WARN] [stacks] update %s: the new version's .felhom.yml %s is gone — judging with the stack dir's", name, metaFile) + } return m.waitUpdateHealthy(ctx, name, timeout) } @@ -712,15 +751,20 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) { // first, before anything moves, so a step the catalog promises and does not carry refuses here // rather than jumping past it. An unpinned app is left to today's behaviour (advancePinTo no-ops). stepSrc := m.CatalogTemplatePath(name, "docker-compose.yml") + stepMeta := m.CatalogTemplatePath(name, ".felhom.yml") if cfg := LoadAppConfig(dir); cfg != nil && len(cfg.PinnedImages) > 0 { step, serr := nextLadderStep(filepath.Dir(stepSrc), cfg.PinnedImages) if serr != nil { fail("update.error.pin_failed", "update ladder: "+serr.Error()) return } - stepSrc = step.Source + stepSrc, stepMeta = step.Source, step.Meta m.logger.Printf("[INFO] [stacks] update %s: ladder — %s", name, step.Why) } + // R-665 (v0.269.0): the new version is judged by ITS OWN .felhom.yml, journaled so a resumed verify + // uses it too — never by the stack dir's, which a restore rewrites with the unit's older file until + // the next catalog sync (measured on 9202 2026-09-24: a failing edge passed on the restored probe). + entry.NewMeta = stepMeta // R-475: the precondition is a copy on ANY tier, chosen in the order 2, 1, 3, and the age rule // applies to whichever tier is chosen. The first FRESH copy wins — not merely the first copy — so a // stale second-drive mirror never forces a backup while the app's own unit is minutes old. @@ -813,7 +857,7 @@ func (m *Manager) runGuardedUpdate(ctx context.Context, name string) { fail("update.error.journal_failed", "journal write failed") return } - if err := m.advancePinTo(name, dir, stepSrc); err != nil { + if err := m.advancePinTo(name, dir, stepSrc, stepMeta); err != nil { m.pinBack(name, dir, entry) fail("update.error.pin_failed", "advancing the pin: "+err.Error()) return @@ -876,7 +920,7 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [ } timeout := m.healthTimeout() waitStart := m.now() - healthy, detail := m.updateHealth(ctx, name, timeout) + healthy, detail := m.updateHealthFor(ctx, name, timeout, entry.NewMeta) if !healthy { m.failAndHold(ctx, name, dir, env, rp, "not healthy: "+detail, entry) return @@ -1131,6 +1175,9 @@ type updateJournalEntry struct { Copied bool `json:"copied,omitempty"` PrevMeta string `json:"prev_meta,omitempty"` NewPin map[string]string `json:"new_pin,omitempty"` + // NewMeta (v0.269.0, R-665/R-664) is the NEW version's own .felhom.yml — the catalog's, or the + // ladder step's — used for the verify, so a resumed verify judges by the same file. + NewMeta string `json:"new_meta,omitempty"` } type updateJournal struct { diff --git a/controller/internal/stacks/updateorder.go b/controller/internal/stacks/updateorder.go index 045e60d..d3049f1 100644 --- a/controller/internal/stacks/updateorder.go +++ b/controller/internal/stacks/updateorder.go @@ -2,6 +2,7 @@ package stacks import ( "strings" + "time" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) @@ -79,11 +80,38 @@ func CatalogOrder(s Stack) UpdateOrder { } } if differing == 0 { + // v0.269.0 (`09` §6.4 part 6): the same TAG can be a different image. Behind only when the catalog + // holds a TESTED digest for the service, the installed digest is known and differs, and the test + // is NEWER than this install — never from a registry query, never on an unknown. + if digestBehind(s) { + return UpdateOrderBehind + } return UpdateOrderCurrent } return UpdateOrderAhead } +// digestBehind: see CatalogOrder. Pinned by TestDigest_FloatingTagBehindOnlyForANewerTestedDigest. +func digestBehind(s Stack) bool { + if len(s.CatalogDigests) == 0 || s.CatalogTestedAt.IsZero() { + return false + } + for svc, want := range s.CatalogDigests { + got, ok := s.AppConfig.InstalledImages[svc] + if !ok || got.Digest == "" || !digestRe.MatchString(want) || got.Digest == want { + continue + } + at, err := time.Parse(time.RFC3339, got.At) + if err != nil { + continue // when the install happened is unknown → never "behind" on it + } + if s.CatalogTestedAt.After(at) { + return true + } + } + return false +} + // CompareImageRefs orders two image references the way a human reads them: -1 when a is older than // b, 0 when they are the same version, 1 when a is newer. The second return is the whole point — // FALSE means "these two cannot be ordered", and every caller must treat that as "do not know" diff --git a/controller/internal/sync/digest_render_test.go b/controller/internal/sync/digest_render_test.go new file mode 100644 index 0000000..95d1e33 --- /dev/null +++ b/controller/internal/sync/digest_render_test.go @@ -0,0 +1,42 @@ +package sync + +import ( + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// v0.269.0 (`09` §6.4 part 6) — the syncer writes the catalog's compose WITH the tested digests of the +// ladder entry for exactly its refs, for an undeployed app and for a pinned app the catalog still matches; +// and the stored definition is refreshed with the same bytes. +// +// COMPANION RED-PROOF (REPORT): make RenderWithLadderDigests return its input — the image line stays a bare +// tag and this test fails at "no digest in the rendered compose". +func TestDigest_SyncerRendersTheTestedDigest(t *testing.T) { + s, stackDir, catDir := renderFixture(t, tplOld) + write(t, filepath.Join(catDir, ".felhom.yml"), "display_name: Nextcloud\nupdate_ladder:\n"+ + ` - {"from": {"web": "nextcloud:30.0.0-apache"}, "to": {"web": "nextcloud:31.0.14-apache"}, "digest": {"web": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, "verdict": "proven", "tested_at": "2026-09-24T01:00:00Z"}`+"\n") + for _, plan := range []func(string) stacks.RenderPlan{ + func(string) stacks.RenderPlan { return stacks.RenderPlan{} }, // not deployed + func(string) stacks.RenderPlan { + p := pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, false)("") + p.StackDir = stackDir + return p + }, + } { + write(t, filepath.Join(stackDir, "docker-compose.yml"), "services: {}\n") // force a change each pass + s.SetRenderPlanFn(plan) + if _, _, err := s.copyTemplates(); err != nil { + t.Fatal(err) + } + got := readFile(t, filepath.Join(stackDir, "docker-compose.yml")) + if !strings.Contains(got, "image: nextcloud:31.0.14-apache@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") { + t.Fatalf("no digest in the rendered compose:\n%s", got) + } + } + if !strings.Contains(readFile(t, stacks.AppliedComposePath(stackDir)), "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") { + t.Fatal("the stored definition was not refreshed with the digest") + } +} diff --git a/controller/internal/sync/sync.go b/controller/internal/sync/sync.go index c82842c..ea62712 100644 --- a/controller/internal/sync/sync.go +++ b/controller/internal/sync/sync.go @@ -386,7 +386,20 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error) src, refreshAppliedIn = renderedSrc, refresh } - changed, err := copyIfChanged(src, dst) + var changed bool + var err error + var rendered []byte // v0.269.0: the catalog compose with its TESTED digests (`09` §6.4 part 6) + if filename == "docker-compose.yml" && filepath.Dir(src) == srcDir { + raw, rerr := os.ReadFile(src) + if rerr != nil { + s.logger.Printf("[WARN] [sync] Failed to read catalog file %s/%s: %v", appName, filename, rerr) + continue + } + rendered = stacks.RenderWithLadderDigests(srcDir, raw) + changed, err = writeIfChanged(rendered, dst) + } else { + changed, err = copyIfChanged(src, dst) + } if err != nil { s.logger.Printf("[WARN] [sync] Failed to copy catalog file %s/%s: %v", appName, filename, err) continue @@ -401,7 +414,11 @@ func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error) // The IMAGES are unchanged here by construction (this branch only runs when the // catalog's images equal the pin), so no version moves and no intent is rewritten. if refreshAppliedIn != "" { - if data, rerr := os.ReadFile(src); rerr != nil { + if rendered != nil { + if serr := stacks.StoreAppliedDefinition(refreshAppliedIn, rendered); serr != nil { + s.logger.Printf("[WARN] [sync] %s: could not refresh the stored definition: %v", appName, serr) + } + } else if data, rerr := os.ReadFile(src); rerr != nil { s.logger.Printf("[WARN] [sync] %s: could not re-read the template to refresh its stored definition: %v", appName, rerr) } else if serr := stacks.StoreAppliedDefinition(refreshAppliedIn, data); serr != nil { s.logger.Printf("[WARN] [sync] %s: could not refresh the stored definition: %v", appName, serr) @@ -541,6 +558,17 @@ func (s *Syncer) logFileHashes(appName, filename, src, dst string) { s.logger.Printf("[DEBUG] [sync] %s/%s: src=%s, dst=%s (changed)", appName, filename, hex.EncodeToString(srcHash[:8]), hex.EncodeToString(dstHash[:8])) } +// writeIfChanged writes data to dst only if the content differs. Returns true if written. +func writeIfChanged(data []byte, dst string) (bool, error) { + if cur, err := os.ReadFile(dst); err == nil && sha256.Sum256(cur) == sha256.Sum256(data) { + return false, nil + } + if err := os.WriteFile(dst, data, 0644); err != nil { + return false, fmt.Errorf("writing %s: %w", dst, err) + } + return true, nil +} + // copyIfChanged copies src to dst only if the content differs. // Returns true if the file was actually written. func copyIfChanged(src, dst string) (bool, error) { diff --git a/controller/internal/web/d28_unhealthy_page_test.go b/controller/internal/web/d28_unhealthy_page_test.go new file mode 100644 index 0000000..1b036ea --- /dev/null +++ b/controller/internal/web/d28_unhealthy_page_test.go @@ -0,0 +1,35 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// Decision 28 on the page: an unhealthy stop offers START (its way back) and no Mentések; the badge +// does not say "restore needed". Render test per branch. +func TestD28_UnhealthyStopPageOffersStart(t *testing.T) { + st := ubStack(nil, map[string]string{"web": "nginx:1.27"}, "2026-07-18") + st.Name = "bookstack" + st.State = stacks.StateStopped + st.HoldReason = "STOPPED BY THE BOX" + st.HoldKind = "unhealthy_stop" + h := renderBackupPage(t, "stacks", ubStacksData(st)) + if !strings.Contains(h, `data-unhealthy-stop="true"`) || !strings.Contains(h, `stackAction(event, 'bookstack', 'start')`) { + t.Fatal("the unhealthy stop must show its sentence and a Start button") + } + if strings.Contains(h, `data-held="true"`) || strings.Contains(h, "visszaállítás szükséges") { + t.Fatal("an unhealthy stop is not a restore case") + } + a := renderBackupPage(t, "app_info", appInfoData(st)) + if !strings.Contains(a, `data-unhealthy-stop="true"`) || strings.Contains(a, `data-held="true"`) { + t.Fatal("app page: wrong branch for an unhealthy stop") + } + // positive control: an UPDATE hold keeps the restore branch + st.HoldKind = "update_failed" + h2 := renderBackupPage(t, "stacks", ubStacksData(st)) + if !strings.Contains(h2, `data-held="true"`) || strings.Contains(h2, `stackAction(event, 'bookstack', 'start')`) { + t.Fatal("an update hold must keep its restore branch and offer no Start") + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 23f870b..f6f0787 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -1685,17 +1685,15 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) { // customer gets the route that CAN return their files instead of a success message over an app // listing photos it cannot open. // - // `accept_missing_files=1` is the explicit, separately-worded second step („csak az adatbázist és - // a beállításokat"). It is deliberately not a sibling of the main button: two controls whose - // difference is "your data comes back" are never siblings (R-48). - acceptMissingFiles := r.FormValue("accept_missing_files") == "1" - if !acceptMissingFiles { - if legs := s.backupMgr.DeclaredDriveFileLegs(stackName); len(legs) > 0 { - msg := s.missingFileLegsRefusal(r.Context(), stackName) - s.logger.Printf("[WARN] [web] restore refused for %s: unit carries no file leg (%d drive path(s))", stackName, len(legs)) - http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound) - return - } + // R-662 (v0.269.0): the „csak az adatbázist és a beállításokat" second step that used to be read here + // (`accept_missing_files=1`) is REMOVED. No page ever sent it, the backup layer refused it anyway, and + // it is the database-only restore under existing files that `09` §3 decision 25 ruled out (option B). + // The way back for such an app is the second drive's whole restore (decision 26) or the off-site one. + if legs := s.backupMgr.DeclaredDriveFileLegs(stackName); len(legs) > 0 { + msg := s.missingFileLegsRefusal(r.Context(), stackName) + s.logger.Printf("[WARN] [web] restore refused for %s: unit carries no file leg (%d drive path(s))", stackName, len(legs)) + http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound) + return } s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr) @@ -2058,6 +2056,30 @@ func (s *Server) backupTier2UnitRestoreHandler(w http.ResponseWriter, r *http.Re return } + // v0.269.0 (`09` §3 decision 26, R-661): for an app whose files live on the drive, this button is the + // WHOLE restore — files by the four rules, then the unit. The unit-only restore below would refuse it + // (R-538), which is what left such an app with no way back from the second drive. + if s.backupMgr.HasDriveFileLegs(stackName) { + s.logger.Printf("[WARN] [web] Tier-2 WHOLE restore requested (async, files + database): stack=%s from %s", stackName, r.RemoteAddr) + s.backupMgr.BeginRestoreOp("tier2-whole-restore", stackName) + go func() { + start := time.Now() + res, err := s.backupMgr.RestoreTier2Whole(stackName) + files := s.note("note.restore.whole_files", res.Files.Restored, res.Files.Replaced, res.Files.KeptNewer, res.Files.Unchanged) + if err != nil { + s.logger.Printf("[ERROR] [web] Tier-2 whole restore failed (async): stack=%s: %v", stackName, err) + s.backupMgr.EndRestoreOp(false, s.note("note.restore.full_unit_failed", s.noteErr(err))+" "+files) + return + } + s.logger.Printf("[INFO] [web] Tier-2 whole restore completed (async): stack=%s in %s (files restored %d, replaced %d, kept-newer %d, unchanged %d; volumes %d/%d, dbs %d/%d)", + stackName, time.Since(start), res.Files.Restored, res.Files.Replaced, res.Files.KeptNewer, res.Files.Unchanged, + res.Unit.VolumesReplayed, res.Unit.ManifestVolumes, res.Unit.DBsReplayed, res.Unit.ManifestDBs) + s.backupMgr.EndRestoreOp(true, s.unitRestoreOutcomeMsg(stackName, res.Unit)+" "+files) + }() + http.Redirect(w, r, "/backups/apps?"+flashQuery("flash", "flash.restore.full_started"), http.StatusFound) + return + } + // Pre-flight, before any op is begun and before the app is stopped: does this copy hold a unit // this restore can actually open? Only the no-unit case is pre-flighted; every other refusal keeps // its existing async path, so this cannot alter behaviour anywhere else. @@ -2659,6 +2681,7 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req // v0.264.0: the update outcomes (default ON). A type the page cannot render is a type every // Save drops — so they are listed here AND carry a checkbox. "app_update_undone", "app_update_held", + "app_stopped_unhealthy", // v0.269.0, decision 28 "storage_reconnected", "health_recovered", } { if r.FormValue("event_"+evt) == "on" { diff --git a/controller/internal/web/i18n_parity_test.go b/controller/internal/web/i18n_parity_test.go index d980be5..7598523 100644 --- a/controller/internal/web/i18n_parity_test.go +++ b/controller/internal/web/i18n_parity_test.go @@ -286,6 +286,19 @@ func i18nCases() []i18nCase { base = append(base, i18nCasesA()...) base = append(base, i18nCasesB()...) base = append(base, i18nCasesC()...) + // v0.269.0 (`09` §3 decision 28): an app the box stopped for crashing — its Start button, captured when born. + base = append(base, i18nCase{"stacks_unhealthy_stop", "stacks", func() map[string]interface{} { + d := i18nLayoutData("stacks", "Alkalmazások") + st := i18nStack("gokapi", "Go Kapi", stacks.StateStopped, true) + st.HoldReason, st.HoldKind = "Az alkalmazást leállítottuk.", "unhealthy_stop" + d["Stacks"] = []stacks.Stack{st} + d["Subdomains"] = map[string]string{} + d["MissingStorage"] = map[string]string{} + d["NetworkStubs"] = map[string]string{} + d["NetworkWarnings"] = map[string]string{} + d["StorageLabels"] = map[string]string{} + 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{} { d := i18nLayoutData("stacks", "RomM") diff --git a/controller/internal/web/i18n_web.go b/controller/internal/web/i18n_web.go index d523d41..e480b10 100644 --- a/controller/internal/web/i18n_web.go +++ b/controller/internal/web/i18n_web.go @@ -376,7 +376,7 @@ func (s *Server) localeFuncs(lang string) template.FuncMap { // Found live on 2026-09-20: on an English app page the only Felhom-authored Hungarian left // was this badge, „Naprakész", with a Hungarian tooltip under it. "updateBadge": func(st stacks.Stack) *MetaBadge { - if st.HoldReason != "" { // R-625's twin — see updatebadge.go + if st.HoldReason != "" && st.HoldKind != "unhealthy_stop" { // R-625's twin — see updatebadge.go (v0.269.0: not for an unhealthy stop) return &MetaBadge{ Label: b.Msg(lang, "badge.update.held"), Class: "tag-error", @@ -582,7 +582,7 @@ func (s *Server) readerFuncs(lang string, prev template.FuncMap) template.FuncMa if base, ok := prev["updateBadge"].(func(stacks.Stack) *MetaBadge); ok { out["updateBadge"] = func(st stacks.Stack) *MetaBadge { bd := base(st) - if bd != nil && st.HoldReason != "" { // R-625: the title is the hold's opening, for THIS reader + if bd != nil && st.HoldReason != "" && st.HoldKind != "unhealthy_stop" { // R-625: the title is the hold's opening, for THIS reader bd.Title = firstSentence(holdFor(st)) } return bd diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index 45eb15d..b01bab5 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -30,6 +30,8 @@ {{if .Stack.Updating}}
{{updatePhaseText .Stack}}
+{{else if and .Stack.HoldReason (eq .Stack.HoldKind "unhealthy_stop")}} +
{{holdText .Stack}}
{{else if .Stack.HoldReason}}
{{holdText .Stack}}{{if not .Stack.HoldNoWholeCopy}} {{T "app_info.mentesek"}}{{end}}
{{else if .Stack.UpdateError}} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index aef18cf..494e028 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -426,6 +426,11 @@ fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); }) ]); var sections = ''; + // v0.269.0 (decision 27): a held app whose page says support is informed — keep-data only. + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
{{T "layout.remove_keep_data_only"}}
'; + } // Section 1: Always removed sections += ''; // Section 2: HDD data var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -451,7 +456,7 @@ } // Section 3: Backup data var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/templates/settings_notifications.html b/controller/internal/web/templates/settings_notifications.html index fac996c..bc55c52 100644 --- a/controller/internal/web/templates/settings_notifications.html +++ b/controller/internal/web/templates/settings_notifications.html @@ -82,6 +82,10 @@ {{T "settings_notifications.app_update_held"}} +
diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index 5c14a3e..1294045 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -92,6 +92,9 @@ button; a held app offers none that would start it, only the way back. */}} {{if .Updating}} {{updatePhaseText .}} + {{else if and .HoldReason (eq .HoldKind "unhealthy_stop")}} +
{{holdText .}}
+ {{if not .Orphaned}}{{end}} {{else if .HoldReason}}
{{holdText .}}{{if not .HoldNoWholeCopy}} {{T "stacks.mentesek"}}{{end}}
{{if not .Orphaned}}{{end}} diff --git a/controller/internal/web/testdata/i18n_parity/app_export.html b/controller/internal/web/testdata/i18n_parity/app_export.html index 48e4b02..b422483 100644 --- a/controller/internal/web/testdata/i18n_parity/app_export.html +++ b/controller/internal/web/testdata/i18n_parity/app_export.html @@ -698,6 +698,11 @@ function showDone(data) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -722,7 +727,7 @@ function showDone(data) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/app_import_bundles.html b/controller/internal/web/testdata/i18n_parity/app_import_bundles.html index 08dd829..e1b5e5a 100644 --- a/controller/internal/web/testdata/i18n_parity/app_import_bundles.html +++ b/controller/internal/web/testdata/i18n_parity/app_import_bundles.html @@ -821,6 +821,11 @@ async function fabStart(file) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -845,7 +850,7 @@ async function fabStart(file) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/app_import_empty.html b/controller/internal/web/testdata/i18n_parity/app_import_empty.html index b2dfee9..9760025 100644 --- a/controller/internal/web/testdata/i18n_parity/app_import_empty.html +++ b/controller/internal/web/testdata/i18n_parity/app_import_empty.html @@ -790,6 +790,11 @@ async function fabStart(file) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -814,7 +819,7 @@ async function fabStart(file) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/app_info_available.html b/controller/internal/web/testdata/i18n_parity/app_info_available.html index 6d700d8..3688917 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_available.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_available.html @@ -445,6 +445,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -469,7 +474,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/app_info_deployed.html b/controller/internal/web/testdata/i18n_parity/app_info_deployed.html index 4e24bac..02b70df 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_deployed.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_deployed.html @@ -642,6 +642,11 @@ function icCopyPw(btn) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -666,7 +671,7 @@ function icCopyPw(btn) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/app_info_installable.html b/controller/internal/web/testdata/i18n_parity/app_info_installable.html index 982d51c..d193583 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_installable.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_installable.html @@ -440,6 +440,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -464,7 +469,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/app_info_ladder_steps.html b/controller/internal/web/testdata/i18n_parity/app_info_ladder_steps.html index a6d29a3..1aacbbd 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_ladder_steps.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_ladder_steps.html @@ -448,6 +448,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -472,7 +477,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/app_info_operator_creds.html b/controller/internal/web/testdata/i18n_parity/app_info_operator_creds.html index 66d6ef7..949be63 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_operator_creds.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_operator_creds.html @@ -534,6 +534,11 @@ function icCopyPw(btn) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -558,7 +563,7 @@ function icCopyPw(btn) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/app_info_updating.html b/controller/internal/web/testdata/i18n_parity/app_info_updating.html index 5f06a76..62f99ef 100644 --- a/controller/internal/web/testdata/i18n_parity/app_info_updating.html +++ b/controller/internal/web/testdata/i18n_parity/app_info_updating.html @@ -450,6 +450,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -474,7 +479,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_empty.html b/controller/internal/web/testdata/i18n_parity/backups_apps_empty.html index afdff7e..118cc3b 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_empty.html @@ -506,6 +506,11 @@ function startBackupPolling() { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -530,7 +535,7 @@ function startBackupPolling() { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_error.html b/controller/internal/web/testdata/i18n_parity/backups_apps_error.html index 8b84e52..2913512 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_error.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_error.html @@ -1451,6 +1451,11 @@ startBackupPolling(); ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1475,7 +1480,7 @@ startBackupPolling(); } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_ok.html b/controller/internal/web/testdata/i18n_parity/backups_apps_ok.html index bf10234..a8e7150 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_ok.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_ok.html @@ -1478,6 +1478,11 @@ function startBackupPolling() { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1502,7 +1507,7 @@ function startBackupPolling() { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_other.html b/controller/internal/web/testdata/i18n_parity/backups_apps_other.html index 1a6aca2..e2cad2a 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_other.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_other.html @@ -1392,6 +1392,11 @@ function startBackupPolling() { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1416,7 +1421,7 @@ function startBackupPolling() { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_running.html b/controller/internal/web/testdata/i18n_parity/backups_apps_running.html index 1bb4409..01b6c2c 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_running.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_running.html @@ -1392,6 +1392,11 @@ function startBackupPolling() { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1416,7 +1421,7 @@ function startBackupPolling() { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_apps_stale_copy.html b/controller/internal/web/testdata/i18n_parity/backups_apps_stale_copy.html index 6bcedb8..c41f66d 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_apps_stale_copy.html +++ b/controller/internal/web/testdata/i18n_parity/backups_apps_stale_copy.html @@ -1478,6 +1478,11 @@ function startBackupPolling() { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1502,7 +1507,7 @@ function startBackupPolling() { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_degraded.html b/controller/internal/web/testdata/i18n_parity/backups_degraded.html index 74b6555..5c23f8c 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_degraded.html +++ b/controller/internal/web/testdata/i18n_parity/backups_degraded.html @@ -558,6 +558,11 @@ function pollGuestBackup(out, btn) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -582,7 +587,7 @@ function pollGuestBackup(out, btn) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_empty.html b/controller/internal/web/testdata/i18n_parity/backups_empty.html index b099f8c..4bb743c 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_empty.html @@ -487,6 +487,11 @@ function pollGuestBackup(out, btn) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -511,7 +516,7 @@ function pollGuestBackup(out, btn) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_full.html b/controller/internal/web/testdata/i18n_parity/backups_full.html index 5ccef05..5076188 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_full.html +++ b/controller/internal/web/testdata/i18n_parity/backups_full.html @@ -694,6 +694,11 @@ function pollGuestBackup(out, btn) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -718,7 +723,7 @@ function pollGuestBackup(out, btn) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html b/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html index 8f7181e..d382756 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html +++ b/controller/internal/web/testdata/i18n_parity/backups_nobackup_yet.html @@ -579,6 +579,11 @@ function pollGuestBackup(out, btn) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -603,7 +608,7 @@ function pollGuestBackup(out, btn) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_empty.html b/controller/internal/web/testdata/i18n_parity/backups_remote_empty.html index f3bad67..8c6bbce 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_empty.html @@ -495,6 +495,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -519,7 +524,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_error.html b/controller/internal/web/testdata/i18n_parity/backups_remote_error.html index 083086b..0c9e423 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_error.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_error.html @@ -570,6 +570,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -594,7 +599,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html b/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html index 307e29b..045f55f 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html @@ -573,6 +573,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -597,7 +602,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_full.html b/controller/internal/web/testdata/i18n_parity/backups_remote_full.html index 28be3fe..a88025a 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_full.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_full.html @@ -671,6 +671,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -695,7 +700,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html b/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html index 6d809db..94d653c 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html @@ -585,6 +585,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -609,7 +614,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html index 1ed113a..b561d43 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html @@ -521,6 +521,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -545,7 +550,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html index 5eeea00..6e32371 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html @@ -522,6 +522,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -546,7 +551,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html index 04a9a85..e1657af 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html @@ -576,6 +576,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -600,7 +605,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html index 0080984..29a75b9 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html @@ -576,6 +576,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -600,7 +605,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_running.html b/controller/internal/web/testdata/i18n_parity/backups_remote_running.html index 7373587..c3a6dae 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_running.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_running.html @@ -590,6 +590,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -614,7 +619,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html b/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html index 7989cf7..404a99f 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html @@ -574,6 +574,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -598,7 +603,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html b/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html index 58397bf..149e9e6 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html @@ -574,6 +574,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -598,7 +603,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_restore_empty.html b/controller/internal/web/testdata/i18n_parity/backups_restore_empty.html index cf0ed9d..36e87b1 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_restore_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_restore_empty.html @@ -654,6 +654,11 @@ function submitRestore() { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -678,7 +683,7 @@ function submitRestore() { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_restore_full.html b/controller/internal/web/testdata/i18n_parity/backups_restore_full.html index 5cf7b34..dc7c638 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_restore_full.html +++ b/controller/internal/web/testdata/i18n_parity/backups_restore_full.html @@ -883,6 +883,11 @@ function submitRestore() { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -907,7 +912,7 @@ function submitRestore() { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_restore_known_empty.html b/controller/internal/web/testdata/i18n_parity/backups_restore_known_empty.html index c89e804..49f664a 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_restore_known_empty.html +++ b/controller/internal/web/testdata/i18n_parity/backups_restore_known_empty.html @@ -742,6 +742,11 @@ function submitRestore() { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -766,7 +771,7 @@ function submitRestore() { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_restore_notarget.html b/controller/internal/web/testdata/i18n_parity/backups_restore_notarget.html index fd4c2ed..b2988dc 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_restore_notarget.html +++ b/controller/internal/web/testdata/i18n_parity/backups_restore_notarget.html @@ -730,6 +730,11 @@ function submitRestore() { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -754,7 +759,7 @@ function submitRestore() { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/backups_tier_due.html b/controller/internal/web/testdata/i18n_parity/backups_tier_due.html index 1c8a412..78c6637 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_tier_due.html +++ b/controller/internal/web/testdata/i18n_parity/backups_tier_due.html @@ -602,6 +602,11 @@ function pollGuestBackup(out, btn) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -626,7 +631,7 @@ function pollGuestBackup(out, btn) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/dashboard_diskcrit.html b/controller/internal/web/testdata/i18n_parity/dashboard_diskcrit.html index 32a3d7f..52e7853 100644 --- a/controller/internal/web/testdata/i18n_parity/dashboard_diskcrit.html +++ b/controller/internal/web/testdata/i18n_parity/dashboard_diskcrit.html @@ -478,6 +478,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -502,7 +507,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/dashboard_full.html b/controller/internal/web/testdata/i18n_parity/dashboard_full.html index 49d5647..6405740 100644 --- a/controller/internal/web/testdata/i18n_parity/dashboard_full.html +++ b/controller/internal/web/testdata/i18n_parity/dashboard_full.html @@ -719,6 +719,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -743,7 +748,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/dashboard_nobackup.html b/controller/internal/web/testdata/i18n_parity/dashboard_nobackup.html index 10e6c8e..fcf1bbc 100644 --- a/controller/internal/web/testdata/i18n_parity/dashboard_nobackup.html +++ b/controller/internal/web/testdata/i18n_parity/dashboard_nobackup.html @@ -439,6 +439,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -463,7 +468,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/dashboard_sparse.html b/controller/internal/web/testdata/i18n_parity/dashboard_sparse.html index 659f35a..1b85e06 100644 --- a/controller/internal/web/testdata/i18n_parity/dashboard_sparse.html +++ b/controller/internal/web/testdata/i18n_parity/dashboard_sparse.html @@ -498,6 +498,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -522,7 +527,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/debug.html b/controller/internal/web/testdata/i18n_parity/debug.html index 2bddeab..ab80e85 100644 --- a/controller/internal/web/testdata/i18n_parity/debug.html +++ b/controller/internal/web/testdata/i18n_parity/debug.html @@ -1193,6 +1193,11 @@ function escapeHtml(s) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1217,7 +1222,7 @@ function escapeHtml(s) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html b/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html index 3477bcd..ec539e5 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_deployed_running.html @@ -1552,6 +1552,11 @@ document.getElementById('deploy-form').addEventListener('submit', async function ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1576,7 +1581,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html b/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html index 9d8aabf..d83718e 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_deployed_stopped.html @@ -1479,6 +1479,11 @@ document.getElementById('deploy-form').addEventListener('submit', async function ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1503,7 +1508,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new.html b/controller/internal/web/testdata/i18n_parity/deploy_new.html index 5cb0635..9de693e 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new.html @@ -1528,6 +1528,11 @@ document.getElementById('deploy-form').addEventListener('submit', async function ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1552,7 +1557,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html b/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html index 9c43f17..5c94a01 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new_blocked.html @@ -1477,6 +1477,11 @@ document.getElementById('deploy-form').addEventListener('submit', async function ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1501,7 +1506,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html b/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html index 3203c83..8bc9ed0 100644 --- a/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/deploy_new_memory_blocked.html @@ -1507,6 +1507,11 @@ document.getElementById('deploy-form').addEventListener('submit', async function ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1531,7 +1536,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/escrow_agent_old.html b/controller/internal/web/testdata/i18n_parity/escrow_agent_old.html index d935935..54e762e 100644 --- a/controller/internal/web/testdata/i18n_parity/escrow_agent_old.html +++ b/controller/internal/web/testdata/i18n_parity/escrow_agent_old.html @@ -398,6 +398,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -422,7 +427,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/escrow_first.html b/controller/internal/web/testdata/i18n_parity/escrow_first.html index eb68c82..8cd9bd3 100644 --- a/controller/internal/web/testdata/i18n_parity/escrow_first.html +++ b/controller/internal/web/testdata/i18n_parity/escrow_first.html @@ -667,6 +667,11 @@ loadPreflight(); ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -691,7 +696,7 @@ loadPreflight(); } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/escrow_not_ready.html b/controller/internal/web/testdata/i18n_parity/escrow_not_ready.html index 86420cd..9cfe717 100644 --- a/controller/internal/web/testdata/i18n_parity/escrow_not_ready.html +++ b/controller/internal/web/testdata/i18n_parity/escrow_not_ready.html @@ -413,6 +413,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -437,7 +442,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/escrow_receremony.html b/controller/internal/web/testdata/i18n_parity/escrow_receremony.html index 206cf21..c6a5d09 100644 --- a/controller/internal/web/testdata/i18n_parity/escrow_receremony.html +++ b/controller/internal/web/testdata/i18n_parity/escrow_receremony.html @@ -672,6 +672,11 @@ loadPreflight(); ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -696,7 +701,7 @@ loadPreflight(); } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/launcher_empty.html b/controller/internal/web/testdata/i18n_parity/launcher_empty.html index de2ccd0..4a5def9 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_empty.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_empty.html @@ -486,6 +486,11 @@ function nativeShare(){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -510,7 +515,7 @@ function nativeShare(){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/launcher_full.html b/controller/internal/web/testdata/i18n_parity/launcher_full.html index 2ac266c..d8b9501 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_full.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_full.html @@ -551,6 +551,11 @@ function nativeShare(){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -575,7 +580,7 @@ function nativeShare(){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/launcher_nopassword.html b/controller/internal/web/testdata/i18n_parity/launcher_nopassword.html index 86c0cb4..af446ed 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_nopassword.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_nopassword.html @@ -532,6 +532,11 @@ function nativeShare(){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -556,7 +561,7 @@ function nativeShare(){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier0.html b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier0.html index 8cb1680..81126c1 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier0.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier0.html @@ -470,6 +470,11 @@ function nativeShare(){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -494,7 +499,7 @@ function nativeShare(){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier3.html b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier3.html index cb10b41..74b807c 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier3.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tier3.html @@ -470,6 +470,11 @@ function nativeShare(){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -494,7 +499,7 @@ function nativeShare(){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tiers.html b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tiers.html index e6537f6..0d8abc8 100644 --- a/controller/internal/web/testdata/i18n_parity/launcher_reminder_tiers.html +++ b/controller/internal/web/testdata/i18n_parity/launcher_reminder_tiers.html @@ -470,6 +470,11 @@ function nativeShare(){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -494,7 +499,7 @@ function nativeShare(){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/logs.html b/controller/internal/web/testdata/i18n_parity/logs.html index 4e84bdb..d57a308 100644 --- a/controller/internal/web/testdata/i18n_parity/logs.html +++ b/controller/internal/web/testdata/i18n_parity/logs.html @@ -453,6 +453,11 @@ line two ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -477,7 +482,7 @@ line two } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html b/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html index f3fc1b3..a949cd1 100644 --- a/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html +++ b/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html @@ -1198,6 +1198,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1222,7 +1227,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html b/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html index 06b8cc2..dd7fe3b 100644 --- a/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html +++ b/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html @@ -1184,6 +1184,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1208,7 +1213,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html b/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html index 023d856..7bda463 100644 --- a/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html +++ b/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html @@ -1241,6 +1241,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1265,7 +1270,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/restore_wizard_blocked.html b/controller/internal/web/testdata/i18n_parity/restore_wizard_blocked.html index e37dda3..f31565b 100644 --- a/controller/internal/web/testdata/i18n_parity/restore_wizard_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/restore_wizard_blocked.html @@ -521,6 +521,11 @@ function confirmFullRestore(btn){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -545,7 +550,7 @@ function confirmFullRestore(btn){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/restore_wizard_exec.html b/controller/internal/web/testdata/i18n_parity/restore_wizard_exec.html index 9923a29..cc0aca9 100644 --- a/controller/internal/web/testdata/i18n_parity/restore_wizard_exec.html +++ b/controller/internal/web/testdata/i18n_parity/restore_wizard_exec.html @@ -501,6 +501,11 @@ function confirmFullRestore(btn){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -525,7 +530,7 @@ function confirmFullRestore(btn){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/restore_wizard_nodumptime.html b/controller/internal/web/testdata/i18n_parity/restore_wizard_nodumptime.html index 41e78e7..1f11313 100644 --- a/controller/internal/web/testdata/i18n_parity/restore_wizard_nodumptime.html +++ b/controller/internal/web/testdata/i18n_parity/restore_wizard_nodumptime.html @@ -532,6 +532,11 @@ function confirmFullRestore(btn){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -556,7 +561,7 @@ function confirmFullRestore(btn){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/restore_wizard_prepare_confirm.html b/controller/internal/web/testdata/i18n_parity/restore_wizard_prepare_confirm.html index d1b4db3..2b69b82 100644 --- a/controller/internal/web/testdata/i18n_parity/restore_wizard_prepare_confirm.html +++ b/controller/internal/web/testdata/i18n_parity/restore_wizard_prepare_confirm.html @@ -506,6 +506,11 @@ function confirmFullRestore(btn){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -530,7 +535,7 @@ function confirmFullRestore(btn){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/restore_wizard_ready.html b/controller/internal/web/testdata/i18n_parity/restore_wizard_ready.html index 113e098..b908c49 100644 --- a/controller/internal/web/testdata/i18n_parity/restore_wizard_ready.html +++ b/controller/internal/web/testdata/i18n_parity/restore_wizard_ready.html @@ -541,6 +541,11 @@ function confirmFullRestore(btn){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -565,7 +570,7 @@ function confirmFullRestore(btn){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/settings_notifications_hub.html b/controller/internal/web/testdata/i18n_parity/settings_notifications_hub.html index 6abcef0..6285cb4 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_notifications_hub.html +++ b/controller/internal/web/testdata/i18n_parity/settings_notifications_hub.html @@ -248,6 +248,10 @@ Frissítés sikertelen, az alkalmazás leállítva +
@@ -532,6 +536,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -556,7 +565,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/settings_notifications_nohub.html b/controller/internal/web/testdata/i18n_parity/settings_notifications_nohub.html index b99d1de..890d551 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_notifications_nohub.html +++ b/controller/internal/web/testdata/i18n_parity/settings_notifications_nohub.html @@ -401,6 +401,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -425,7 +430,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/settings_security_full.html b/controller/internal/web/testdata/i18n_parity/settings_security_full.html index 97b7be7..7d52d80 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_security_full.html +++ b/controller/internal/web/testdata/i18n_parity/settings_security_full.html @@ -851,6 +851,11 @@ function revealRetrievalPw() { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -875,7 +880,7 @@ function revealRetrievalPw() { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html b/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html index 6784a83..9dffe4d 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html +++ b/controller/internal/web/testdata/i18n_parity/settings_security_noauth.html @@ -712,6 +712,11 @@ function openDialog(opts){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -736,7 +741,7 @@ function openDialog(opts){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_failed.html b/controller/internal/web/testdata/i18n_parity/settings_system_failed.html index 792626d..b68498b 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_failed.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_failed.html @@ -722,6 +722,11 @@ function pollRestart(attempt) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -746,7 +751,7 @@ function pollRestart(attempt) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_full.html b/controller/internal/web/testdata/i18n_parity/settings_system_full.html index 67c2867..3ccbab8 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_full.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_full.html @@ -786,6 +786,11 @@ function pollRestart(attempt) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -810,7 +815,7 @@ function pollRestart(attempt) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html b/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html index 2422d75..859d089 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_nomemory.html @@ -671,6 +671,11 @@ function pollRestart(attempt) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -695,7 +700,7 @@ function pollRestart(attempt) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_pending.html b/controller/internal/web/testdata/i18n_parity/settings_system_pending.html index 03f538e..331ada2 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_pending.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_pending.html @@ -712,6 +712,11 @@ function pollRestart(attempt) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -736,7 +741,7 @@ function pollRestart(attempt) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html b/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html index a35a571..634acce 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_error_skipped.html @@ -714,6 +714,11 @@ function shareBrowseLoad(p){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -738,7 +743,7 @@ function shareBrowseLoad(p){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/sharing_full.html b/controller/internal/web/testdata/i18n_parity/sharing_full.html index 7733136..48ef12e 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_full.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_full.html @@ -750,6 +750,11 @@ function shareBrowseLoad(p){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -774,7 +779,7 @@ function shareBrowseLoad(p){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html b/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html index 51974f8..649a728 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_no_offsite_yet.html @@ -713,6 +713,11 @@ function shareBrowseLoad(p){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -737,7 +742,7 @@ function shareBrowseLoad(p){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html b/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html index e23b9e1..89aadb2 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_notarget_blocked.html @@ -712,6 +712,11 @@ function shareBrowseLoad(p){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -736,7 +741,7 @@ function shareBrowseLoad(p){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/sharing_off.html b/controller/internal/web/testdata/i18n_parity/sharing_off.html index a038248..d3eab03 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_off.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_off.html @@ -623,6 +623,11 @@ function shareBrowseLoad(p){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -647,7 +652,7 @@ function shareBrowseLoad(p){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/sharing_other_none.html b/controller/internal/web/testdata/i18n_parity/sharing_other_none.html index 3347b87..eaec4ad 100644 --- a/controller/internal/web/testdata/i18n_parity/sharing_other_none.html +++ b/controller/internal/web/testdata/i18n_parity/sharing_other_none.html @@ -712,6 +712,11 @@ function shareBrowseLoad(p){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -736,7 +741,7 @@ function shareBrowseLoad(p){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/stacks_full.html b/controller/internal/web/testdata/i18n_parity/stacks_full.html index 648a50d..c20fc9e 100644 --- a/controller/internal/web/testdata/i18n_parity/stacks_full.html +++ b/controller/internal/web/testdata/i18n_parity/stacks_full.html @@ -866,6 +866,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -890,7 +895,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/stacks_unhealthy_stop.html b/controller/internal/web/testdata/i18n_parity/stacks_unhealthy_stop.html new file mode 100644 index 0000000..640823a --- /dev/null +++ b/controller/internal/web/testdata/i18n_parity/stacks_unhealthy_stop.html @@ -0,0 +1,621 @@ + + + + + + + + Alkalmazások — Felhom.eu + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + +
+ + + + +
+ +
+ + +
+
+
+ +
+

Go Kapi

+ + +
+
+ Leállítva + + + + + + +
+ + +

Egy teszt alkalmazás leírása

+ + +
+ ~128M + Pi kompatibilis + HDD szükséges + Magyar felület + +
+ + + +
+ + + +
Az alkalmazást leállítottuk.
+ + + Naplók + Részletek + + +
+
+ +
+ + + + +
+ + + + diff --git a/controller/internal/web/testdata/i18n_parity/storage_attach.html b/controller/internal/web/testdata/i18n_parity/storage_attach.html index c6cd2bf..9dc560a 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_attach.html +++ b/controller/internal/web/testdata/i18n_parity/storage_attach.html @@ -521,6 +521,11 @@ loadDisks(); ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -545,7 +550,7 @@ loadDisks(); } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/storage_empty.html b/controller/internal/web/testdata/i18n_parity/storage_empty.html index 2d372ee..781d012 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_empty.html +++ b/controller/internal/web/testdata/i18n_parity/storage_empty.html @@ -868,6 +868,11 @@ function cancelEditLabel(path, label) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -892,7 +897,7 @@ function cancelEditLabel(path, label) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/storage_full.html b/controller/internal/web/testdata/i18n_parity/storage_full.html index 1010bbc..71ef6dd 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_full.html +++ b/controller/internal/web/testdata/i18n_parity/storage_full.html @@ -1168,6 +1168,11 @@ function cancelEditLabel(path, label) { ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -1192,7 +1197,7 @@ function cancelEditLabel(path, label) { } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/storage_init.html b/controller/internal/web/testdata/i18n_parity/storage_init.html index c1ebd86..a23079c 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_init.html +++ b/controller/internal/web/testdata/i18n_parity/storage_init.html @@ -581,6 +581,11 @@ loadDisks(); ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -605,7 +610,7 @@ loadDisks(); } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/storage_network_full.html b/controller/internal/web/testdata/i18n_parity/storage_network_full.html index 2551631..e26b2b1 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_network_full.html +++ b/controller/internal/web/testdata/i18n_parity/storage_network_full.html @@ -698,6 +698,11 @@ function openDialog(opts){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -722,7 +727,7 @@ function openDialog(opts){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/storage_network_nosupport.html b/controller/internal/web/testdata/i18n_parity/storage_network_nosupport.html index 1cb99ef..3d24a89 100644 --- a/controller/internal/web/testdata/i18n_parity/storage_network_nosupport.html +++ b/controller/internal/web/testdata/i18n_parity/storage_network_nosupport.html @@ -553,6 +553,11 @@ function openDialog(opts){ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -577,7 +582,7 @@ function openDialog(opts){ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html index 671eb58..634f029 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd.html @@ -412,6 +412,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -436,7 +441,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_absent.html b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_absent.html index 2ecef54..dfbc976 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_absent.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_absent.html @@ -412,6 +412,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -436,7 +441,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_protected.html b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_protected.html index 7b425f7..0644223 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_protected.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_protected.html @@ -414,6 +414,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -438,7 +443,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_same_disk.html b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_same_disk.html index 1cd8101..476eaa4 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_same_disk.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_not_hdd_same_disk.html @@ -413,6 +413,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -437,7 +442,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html b/controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html index 2a15e53..3371a16 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_notarget_disabled.html @@ -449,6 +449,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -473,7 +478,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/tier2_target_auto.html b/controller/internal/web/testdata/i18n_parity/tier2_target_auto.html index c3e1f75..84b3a98 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_target_auto.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_target_auto.html @@ -449,6 +449,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -473,7 +478,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/testdata/i18n_parity/tier2_target_ssd.html b/controller/internal/web/testdata/i18n_parity/tier2_target_ssd.html index 465fae2..6691a1e 100644 --- a/controller/internal/web/testdata/i18n_parity/tier2_target_ssd.html +++ b/controller/internal/web/testdata/i18n_parity/tier2_target_ssd.html @@ -452,6 +452,11 @@ ]); var sections = ''; + var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only; + if (keepOnly) { + sections += '
Az ügyfélszolgálat foglalkozik ezzel az alkalmazással, ezért az adatai megmaradnak: most csak magát az alkalmazást távolíthatod el.
'; + } + sections += ''; var hddCheckbox = ''; - if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { + if (!keepOnly && hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) { var hddPaths = ''; hddResp.data.hdd_paths.forEach(function(p) { hddPaths += ''; @@ -476,7 +481,7 @@ } var backupCheckbox = ''; - if (backupResp.ok && backupResp.data && backupResp.data.has_backups) { + if (!keepOnly && backupResp.ok && backupResp.data && backupResp.data.has_backups) { var bkPaths = ''; backupResp.data.backup_paths.forEach(function(p) { if (p.exists) bkPaths += ''; diff --git a/controller/internal/web/updatebadge.go b/controller/internal/web/updatebadge.go index 96dda11..0b7b0b2 100644 --- a/controller/internal/web/updatebadge.go +++ b/controller/internal/web/updatebadge.go @@ -79,7 +79,8 @@ func updateBadgeAt(s stacks.Stack, now time.Time) *MetaBadge { // 409 was two verdicts disagreeing. One verdict, read by the badge and by the button (stacks.html // renders no Update button while HoldReason is set), exactly as R-524 did it for Ahead. True for // both hold kinds: a failed restore holds the app stopped too, and the way back is also a restore. - if s.HoldReason != "" { + // v0.269.0: an UNHEALTHY stop (decision 28) is not "restore needed" — its way back is Start. + if s.HoldReason != "" && s.HoldKind != "unhealthy_stop" { return &MetaBadge{ Label: "Megállítva — visszaállítás szükséges", Class: "tag-error", diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index e4c2ba1..4a5fb7c 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -15,6 +15,15 @@ "app_info.update_undone": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", "err.stacks.update_undo_copy_failed": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", "err.stacks.update_undo_space": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", + "note.restore.whole_files": "BORN AS A KEY, v0.269.0 (`09` §3 decisions 26-28) -- a NEW sentence, never a Go literal.", + "err.stacks.remove_keep_data_while_support": "BORN AS A KEY, v0.269.0 (`09` §3 decisions 26-28) -- a NEW sentence, never a Go literal.", + "err.backup.whole_restore_space": "BORN AS A KEY, v0.269.0 (`09` §3 decisions 26-28) -- a NEW sentence, never a Go literal.", + "err.backup.whole_restore_not_file_app": "BORN AS A KEY, v0.269.0 (`09` §3 decisions 26-28) -- a NEW sentence, never a Go literal.", + "err.backup.whole_restore_no_proven_copy": "BORN AS A KEY, v0.269.0 (`09` §3 decisions 26-28) -- a NEW sentence, never a Go literal.", + "hold.unhealthy_stop.repeat": "BORN AS A KEY, v0.269.0 (`09` §3 decision 28) -- a NEW sentence, never a Go literal.", + "hold.unhealthy_stop": "BORN AS A KEY, v0.269.0 (`09` §3 decision 28) -- a NEW sentence, never a Go literal.", + "event.app_stopped_unhealthy.repeat": "BORN AS A KEY, v0.269.0 (`09` §3 decision 28) -- a NEW sentence, never a Go literal.", + "event.app_stopped_unhealthy": "BORN AS A KEY, v0.269.0 (`09` §3 decision 28) -- a NEW sentence, never a Go literal.", "hold.update.no_whole_copy": "BORN AS A KEY, v0.268.0 (R-659; operator ruling 2026-09-24, option A, the operator's own copy) -- a NEW sentence, never a Go literal. Pinned by internal/backup/r659_whole_copy_test.go.", "hold.update.undo_failed": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", "hold.update.undo_state.half": "BORN AS A KEY, v0.263.0 (R-637, the undo) -- a NEW sentence, never a Go literal. Pinned by internal/stacks/undo_test.go and internal/web/undo_page_test.go.", diff --git a/controller/scripts/i18n_missing_gate.py b/controller/scripts/i18n_missing_gate.py index abdcc5c..d72b8ab 100644 --- a/controller/scripts/i18n_missing_gate.py +++ b/controller/scripts/i18n_missing_gate.py @@ -38,7 +38,7 @@ TEMPLATE_ROOTS = [os.path.join(CTRL, "internal", "web", "templates")] MARKER = re.compile(r'\{\{\s*T\s+"([A-Za-z0-9_.\-]+)"\s*\}\}') EN_MISSING_CEILING = 0 -HU_FORMAL_CEILING = 19 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A) -> 19 (v0.268.0): the count follows the +HU_FORMAL_CEILING = 18 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A) -> 19 (v0.268.0) -> 18 (v0.269.0): the count follows the # conversion, it is not a judgement. Moving a Go literal into hu.json makes an „ön" form the gate could # not see before VISIBLE to it -- the two that arrived are `flash.login.password_changed` („kérjük") # and `alert.deadapp.group` („nézze meg"), both word-for-word what the Go code already said. Slice 2 may @@ -47,6 +47,7 @@ HU_FORMAL_CEILING = 19 # 6 -> 10 -> 12 -> 16 (slice 1) -> 18 (slice 2 release A # 19 (v0.268.0, R-659): `hold.update.no_whole_copy` („kérjük … ne indítsa újra és ne törölje") is the # OPERATOR's copy, given word for word in the brief of 2026-09-24 with its ruling (option A) — not reworded # here. The register question (ön vs te) stays R-516's. +# 18 again (v0.269.0, `09` §3 decision 27): the operator moved that sentence to the informal voice. EN_FORBIDDEN = re.compile(r"\b(please|kindly)\b", re.I)