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)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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 `<name>.felhom-<ts>`), 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/<key>.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
|
||||
|
||||
@@ -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>_<key>`, 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 |
|
||||
|
||||
@@ -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-<ts>`) 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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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/<rel>, userdata/<rel>). 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 `<name>.felhom-<ts>`.
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <strong>{{.RecoveryAbandonDays}} {{if eq .RecoveryAbandonDays 1}}day{{else}}days{{end}}</strong> ({{.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 <em><app>@felhom.eu</em> address.",
|
||||
|
||||
@@ -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 <strong>{{.RecoveryAbandonDays}} nap múlva</strong> ({{.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 <em><alkalmazás>@felhom.eu</em> címe lesz.",
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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/<key>.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/<key>.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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
|
||||
{{if .Stack.Updating}}
|
||||
<div class="alert alert-info" style="margin-top:1rem" data-update-phase="{{.Stack.UpdatePhase}}">{{updatePhaseText .Stack}}</div>
|
||||
{{else if and .Stack.HoldReason (eq .Stack.HoldKind "unhealthy_stop")}}
|
||||
<div class="alert alert-warning" style="margin-top:1rem" data-unhealthy-stop="true">{{holdText .Stack}}</div>
|
||||
{{else if .Stack.HoldReason}}
|
||||
<div class="alert alert-error" style="margin-top:1rem" data-held="true">{{holdText .Stack}}{{if not .Stack.HoldNoWholeCopy}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "app_info.mentesek"}}</a>{{end}}</div>
|
||||
{{else if .Stack.UpdateError}}
|
||||
|
||||
@@ -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 += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">{{T "layout.remove_keep_data_only"}}</div>';
|
||||
}
|
||||
// Section 1: Always removed
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>{{T "layout.mindig_torlodik"}}</strong>' +
|
||||
@@ -436,7 +441,7 @@
|
||||
'</ul></div>';
|
||||
// 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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : '{{T "layout.nem_letezik"}}') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
<input type="checkbox" name="event_app_update_held" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_update_held"}}checked{{end}}{{end}}{{end}}>
|
||||
<span class="toggle-label">{{T "settings_notifications.app_update_held"}}</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="event_app_stopped_unhealthy" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "app_stopped_unhealthy"}}checked{{end}}{{end}}{{end}}>
|
||||
<span class="toggle-label">{{T "settings_notifications.app_stopped_unhealthy"}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -92,6 +92,9 @@
|
||||
button; a held app offers none that would start it, only the way back. */}}
|
||||
{{if .Updating}}
|
||||
<span class="tag tag-progress" data-update-phase="{{.UpdatePhase}}"><span class="dot"></span>{{updatePhaseText .}}</span>
|
||||
{{else if and .HoldReason (eq .HoldKind "unhealthy_stop")}}
|
||||
<div class="alert alert-warning" data-unhealthy-stop="true">{{holdText .}}</div>
|
||||
{{if not .Orphaned}}<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">{{T "stacks.inditas"}}</button>{{end}}
|
||||
{{else if .HoldReason}}
|
||||
<div class="alert alert-error" data-held="true">{{holdText .}}{{if not .HoldNoWholeCopy}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "stacks.mentesek"}}</a>{{end}}</div>
|
||||
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">{{T "common.eltavolitas"}}</button>{{end}}
|
||||
|
||||
@@ -698,6 +698,11 @@ function showDone(data) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -707,7 +712,7 @@ function showDone(data) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -821,6 +821,11 @@ async function fabStart(file) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -830,7 +835,7 @@ async function fabStart(file) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -790,6 +790,11 @@ async function fabStart(file) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -799,7 +804,7 @@ async function fabStart(file) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -445,6 +445,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -454,7 +459,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -642,6 +642,11 @@ function icCopyPw(btn) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -651,7 +656,7 @@ function icCopyPw(btn) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -440,6 +440,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -449,7 +454,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -448,6 +448,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -457,7 +462,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -534,6 +534,11 @@ function icCopyPw(btn) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -543,7 +548,7 @@ function icCopyPw(btn) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -450,6 +450,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -459,7 +464,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -506,6 +506,11 @@ function startBackupPolling() {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -515,7 +520,7 @@ function startBackupPolling() {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -1451,6 +1451,11 @@ startBackupPolling();
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1460,7 +1465,7 @@ startBackupPolling();
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -1478,6 +1478,11 @@ function startBackupPolling() {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1487,7 +1492,7 @@ function startBackupPolling() {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -1392,6 +1392,11 @@ function startBackupPolling() {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1401,7 +1406,7 @@ function startBackupPolling() {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -1392,6 +1392,11 @@ function startBackupPolling() {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1401,7 +1406,7 @@ function startBackupPolling() {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -1478,6 +1478,11 @@ function startBackupPolling() {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1487,7 +1492,7 @@ function startBackupPolling() {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -558,6 +558,11 @@ function pollGuestBackup(out, btn) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -567,7 +572,7 @@ function pollGuestBackup(out, btn) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -487,6 +487,11 @@ function pollGuestBackup(out, btn) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -496,7 +501,7 @@ function pollGuestBackup(out, btn) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -694,6 +694,11 @@ function pollGuestBackup(out, btn) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -703,7 +708,7 @@ function pollGuestBackup(out, btn) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -579,6 +579,11 @@ function pollGuestBackup(out, btn) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -588,7 +593,7 @@ function pollGuestBackup(out, btn) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -495,6 +495,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -504,7 +509,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -570,6 +570,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -579,7 +584,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -573,6 +573,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -582,7 +587,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -671,6 +671,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -680,7 +685,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
+7
-2
@@ -585,6 +585,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -594,7 +599,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -521,6 +521,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -530,7 +535,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
+7
-2
@@ -522,6 +522,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -531,7 +536,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
+7
-2
@@ -576,6 +576,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -585,7 +590,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
+7
-2
@@ -576,6 +576,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -585,7 +590,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -590,6 +590,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -599,7 +604,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -574,6 +574,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -583,7 +588,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -574,6 +574,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -583,7 +588,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -654,6 +654,11 @@ function submitRestore() {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -663,7 +668,7 @@ function submitRestore() {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -883,6 +883,11 @@ function submitRestore() {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -892,7 +897,7 @@ function submitRestore() {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
+7
-2
@@ -742,6 +742,11 @@ function submitRestore() {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -751,7 +756,7 @@ function submitRestore() {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -730,6 +730,11 @@ function submitRestore() {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -739,7 +744,7 @@ function submitRestore() {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -602,6 +602,11 @@ function pollGuestBackup(out, btn) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -611,7 +616,7 @@ function pollGuestBackup(out, btn) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -478,6 +478,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -487,7 +492,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -719,6 +719,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -728,7 +733,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -439,6 +439,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -448,7 +453,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -498,6 +498,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -507,7 +512,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
+7
-2
@@ -1193,6 +1193,11 @@ function escapeHtml(s) {
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1202,7 +1207,7 @@ function escapeHtml(s) {
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -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 += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1561,7 +1566,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -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 += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1488,7 +1493,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -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 += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1537,7 +1542,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -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 += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1486,7 +1491,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
+7
-2
@@ -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 += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -1516,7 +1521,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -398,6 +398,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -407,7 +412,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -667,6 +667,11 @@ loadPreflight();
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -676,7 +681,7 @@ loadPreflight();
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -413,6 +413,11 @@
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -422,7 +427,7 @@
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -672,6 +672,11 @@ loadPreflight();
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -681,7 +686,7 @@ loadPreflight();
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -486,6 +486,11 @@ function nativeShare(){
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -495,7 +500,7 @@ function nativeShare(){
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -551,6 +551,11 @@ function nativeShare(){
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -560,7 +565,7 @@ function nativeShare(){
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -532,6 +532,11 @@ function nativeShare(){
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -541,7 +546,7 @@ function nativeShare(){
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -470,6 +470,11 @@ function nativeShare(){
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -479,7 +484,7 @@ function nativeShare(){
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -470,6 +470,11 @@ function nativeShare(){
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -479,7 +484,7 @@ function nativeShare(){
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
@@ -470,6 +470,11 @@ function nativeShare(){
|
||||
]);
|
||||
var sections = '';
|
||||
|
||||
var keepOnly = hddResp.ok && hddResp.data && hddResp.data.keep_data_only;
|
||||
if (keepOnly) {
|
||||
sections += '<div class="alert alert-info" style="margin-bottom:.75rem" data-remove-keep-only="true">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.</div>';
|
||||
}
|
||||
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-2);font-size:.85rem">' +
|
||||
@@ -479,7 +484,7 @@ function nativeShare(){
|
||||
'</ul></div>';
|
||||
|
||||
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 += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
@@ -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 += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user