D47 / R-1269 (P2): a new install never writes into an earlier install's backup — every install has an identity (install_id), the unit records it, every unit writer asks first; another install's unit is set aside whole (backups/set-aside/<app>/<stamp>, a D25 kept copy, „Visszaállítás” + „Eltávolítás” on the restore page); a move that fails refuses the write. Red-proved. Commit only.
gates / gates (push) Successful in 1m33s

This commit is contained in:
2026-10-25 09:27:48 +01:00
parent e1613aea8d
commit 02daebe9c5
30 changed files with 1990 additions and 17 deletions
+12
View File
@@ -1680,6 +1680,18 @@ present folder is not mirrored over a copy that holds files: the run warns and k
never chosen as the target (R-1179). Code: `internal/backup/tier2_kept.go`, `internal/settings/kept_copies.go`,
`internal/web/kept_copies.go`.
### An earlier install's backup is set aside, never rewritten (D47, operator ruling 2026-10-25; R-1269)
Every install has an identity (`app.yaml` `install_id:` — minted at a fresh install and at an import, taken from the unit
by a restore, minted once for an older app). The recovery unit records it. Before any backup writes into an app's unit —
the capture and its five-minute refresh, the database and volume dumps, the update's pre-backup, the second drive's unit
copy, the stale-drive prune — it asks whose unit it is. A unit of another install (a reinstall after „remove, keep
backups") is moved whole to `<namespace>/backups/set-aside/<app>/<UTC stamp>/` and listed on the restore page under
„Megőrzött másolatok" with „Visszaállítás" (it restores with its own database password) and „Eltávolítás"; the new
install then gets its own unit. A move that cannot be done refuses the write and leaves the old unit as it is (an ERROR
line names both identities). Remove with „delete its backups too" deletes the set-aside units as well. Code:
`internal/backup/unit_owner.go`, `internal/web/kept_copies.go`.
### Off-site content proof (v0.231.0, R-87)
**What it is:** every night the box restores ONE app's newest off-site snapshot into a throwaway
+14
View File
@@ -3286,9 +3286,23 @@ func (a *stackAdapter) GetStackRecoveryInfo(name string) (backup.RecoveryInfo, b
InstalledImages: installedRefs(s.AppConfig),
// R-1057 (v0.314.0): the gate as it stands at this backup — read from the file, the record of truth.
SetupGate: gateAtCapture(&meta, stackDir),
InstallID: installIDOf(stackDir), // D47: whose data this is — the unit records it
}, true
}
// installIDOf reads the app's install identity (D47) from its app.yaml on disk; "" when absent or unreadable.
func installIDOf(stackDir string) string {
if cfg := stacks.LoadAppConfig(stackDir); cfg != nil {
return cfg.InstallID
}
return ""
}
// EnsureInstallID (D47) mints and saves an install identity for an app installed before D47 (backup.installIdentity).
func (a *stackAdapter) EnsureInstallID(name string) (string, error) {
return a.mgr.EnsureInstallID(name)
}
// gateAtCapture reads the app's setup-gate record from its app.yaml on disk for the backup (stacks.GateCapture).
func gateAtCapture(meta *stacks.Metadata, stackDir string) *backup.GateState {
var rec *stacks.SetupGateRecord
+6 -1
View File
@@ -1050,13 +1050,15 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
// volume tars) and its Tier-2 mirror, not only db-dumps. The mirror is on another drive, outside
// RemoveStack's per-app base, so the backup manager removes it — and its location is read NOW,
// before the cross-drive record is cleared below. Off-site snapshots are not touched by this path.
var backupPaths, mirrorDirs []string
var backupPaths, mirrorDirs, setAsideDirs []string
if body.RemoveBackups && r.backupMgr != nil {
nsRoot := r.backupMgr.AppNamespaceRoot(name)
if nsRoot != "" {
backupPaths = append(backupPaths, backup.RecoveryUnitPath(nsRoot, name), backup.AppDBDumpPath(nsRoot, name))
}
mirrorDirs = r.backupMgr.Tier2MirrorDirsForApp(name)
// D47: the units of earlier installs set aside for this app are its backups too (the D25 kept-copy rule).
setAsideDirs = r.backupMgr.SetAsideDirsForApp(name)
}
resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths)
@@ -1086,6 +1088,9 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
if body.RemoveBackups && r.backupMgr != nil && len(mirrorDirs) > 0 {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveTier2Mirrors(name, mirrorDirs)...)
}
if body.RemoveBackups && r.backupMgr != nil && len(setAsideDirs) > 0 {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.backupMgr.RemoveSetAsideUnits(name, setAsideDirs)...)
}
if body.RemoveBackups {
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, r.removeVerificationCopy(name)...)
}
+3
View File
@@ -91,6 +91,9 @@ type RecoveryInfo struct {
// „open" (and by whom). Nil for an app whose template has no gate. The restore applies it, so a backup made before
// the household's first setup never comes back open (`09` §3 decision 253).
SetupGate *GateState
// InstallID (D47, R-1269) is the install the app's data belongs to (app.yaml `install_id:`); "" for an app installed
// before D47. The unit records it, and a backup of another install never writes into that unit (backup/unit_owner.go).
InstallID string
}
// GateState is the setup gate as a backup records it (the unit's app.yaml `setup_gate:`). The same two keys as the
+21
View File
@@ -663,6 +663,13 @@ func (m *Manager) runDBDumpsInternal(ctx context.Context) error {
continue
}
// D47 (R-1269): never a dump into another install's unit — it is set aside whole first, or nothing is written.
if gErr := m.guardUnitWrite(db.StackName, m.namespaceRoot(drivePath), "database dump"); gErr != nil {
allOK = false
summary = append(summary, fmt.Sprintf("FAIL %s: %v", db.ContainerName, gErr))
m.noteFailure(db.StackName, "database dump", gErr.Error())
continue
}
dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), db.StackName)
result := m.dumpOneOrDefault(ctx, db, dumpDir)
@@ -870,6 +877,9 @@ func (m *Manager) DumpAppVolumes(stackName string) error {
return fmt.Errorf("cannot determine drive path for %s", stackName)
}
if err := m.guardUnitWrite(stackName, m.namespaceRoot(drivePath), "volume dump"); err != nil { // D47 (R-1269)
return err
}
dumpDir := AppVolumeDumpPath(m.namespaceRoot(drivePath), stackName)
if err := os.MkdirAll(dumpDir, 0755); err != nil {
return fmt.Errorf("creating volume dump dir: %w", err)
@@ -1142,6 +1152,9 @@ func (m *Manager) DumpStackDB(ctx context.Context, stackName string) error {
if drivePath == "" || !filepath.IsAbs(drivePath) {
return fmt.Errorf("cannot determine absolute drive path for %s (systemDataPath not configured?)", stackName)
}
if err := m.guardUnitWrite(stackName, m.namespaceRoot(drivePath), "pre-backup database dump"); err != nil { // D47
return err
}
dumpDir := AppDBDumpPath(m.namespaceRoot(drivePath), stackName)
m.logger.Printf("[INFO] [backup] Running pre-backup DB dump for %s (%d database(s)) → %s", stackName, len(stackDBs), dumpDir)
@@ -1570,6 +1583,14 @@ func (m *Manager) pruneStalePrimaryDirs() {
filepath.Clean(primaryDir)+string(filepath.Separator)) {
continue
}
// D47: a unit of ANOTHER install on the old drive is not this app's moved unit — it is set aside whole
// (never deleted); a guard that cannot decide leaves it as it is.
if gErr := m.guardUnitDir(app, stalePath, nsRoot, "stale-drive prune"); gErr != nil {
continue
}
if _, sErr := os.Stat(stalePath); os.IsNotExist(sErr) {
continue // set aside just now
}
if err := os.RemoveAll(stalePath); err != nil {
m.logger.Printf("[WARN] [backup] F5: could not remove stale primary dir for %s on old drive: %v", app, err)
} else {
@@ -494,6 +494,14 @@ func (m *Manager) captureLiveDefinition(stack string) (*liveDefinition, error) {
for k, v := range m.stackProvider.RecoverStackSecrets(stack, info.SecretEnvVars) {
d.env[k] = v
}
// D47: a put-back writes the live definition back through the same writer as a restore, which takes the
// install identity from the folder's app.yaml — so the live install keeps its own (no env, no secret here).
if info.InstallID != "" {
if wErr := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("install_id: "+info.InstallID+"\n"), 0o600); wErr != nil {
d.cleanup()
return nil, wErr
}
}
}
return d, nil
}
+20 -2
View File
@@ -100,7 +100,10 @@ func (m *Manager) SetTier2Notifier(fn func(stackName, destLabel string, dur time
//
// v0.275.0 (R-696): CaptureRecoveryUnit is the capture AFTER a data run (it follows the legs in
// RunAppBackupNow). The periodic refresh calls captureRecoveryUnit(…, false), which never moves the
// unit's data time and never rewrites the definition away from the data it belongs to.
// unit's data time and never rewrites the definition away from the data it belongs to — for the SAME install
// (the pins below). A unit of ANOTHER install (a reinstall after „remove, keep backups") is never written at
// all: guardUnitWrite sets it aside whole first (D47, R-1269 — measured: the refresh wrote the new install's
// password beside the old data). Pinned by TestD47_* (unit_owner_test.go).
func (m *Manager) CaptureRecoveryUnit(stackName string) error {
return m.captureRecoveryUnit(stackName, true)
}
@@ -118,6 +121,11 @@ func (m *Manager) captureRecoveryUnit(stackName string, dataRun bool) error {
return fmt.Errorf("cannot determine absolute drive path for %s", stackName)
}
nsRoot := m.namespaceRoot(drivePath)
// D47 (R-1269): the unit of another install is set aside whole before this install writes its own — or this
// capture writes nothing. Before the in-memory build, so the frozen/current checks below read THIS install's unit.
if err := m.guardUnitWrite(stackName, nsRoot, "recovery-unit capture"); err != nil {
return err
}
// Build the captured config CONTENT in memory (no writes yet) so we can checksum-compare.
type capFile struct {
@@ -463,6 +471,14 @@ func (m *Manager) captureAllRecoveryUnits(dataRun bool) {
if m.isHeld(stack.Name) {
continue
}
// D47: a restore of this app is reading its unit (and may be bringing back another install's) — the refresh
// waits for it; the restore's own end decides whose unit the app writes next.
if !dataRun && m.restoreRunningFor(stack.Name) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [backup] recovery-unit refresh skipped for %s — a restore of it runs", stack.Name)
}
continue
}
// R-645 (09 §3 decision 142): the unit of an app that is not running its pinned version stays
// as it is — the measured case wrote a just-failed definition over the good unit within seconds
// of a hand-lifted hold. Said at WARN on a data run; the periodic refresh repeats it only at DEBUG.
@@ -506,6 +522,8 @@ type strippedAppYaml struct {
// SetupGate (v0.314.0, R-1057): the gate at this backup; absent for an app with no gate, so an ungated app's
// unit bytes are unchanged (the checksum-skip guard).
SetupGate *GateState `yaml:"setup_gate,omitempty"`
// InstallID (D47): the install this unit's data belongs to — the unit's owner. Absent in a unit written before D47.
InstallID string `yaml:"install_id,omitempty"`
}
// buildUnitAppYaml renders the unit's app.yaml as bytes: the non-secret env PLUS the portable secret
@@ -524,7 +542,7 @@ func buildUnitAppYaml(info RecoveryInfo) []byte {
for k, v := range info.PortableSecrets {
env[k] = v
}
body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: env, SetupGate: info.SetupGate})
body, err := yaml.Marshal(strippedAppYaml{Deployed: true, Env: env, SetupGate: info.SetupGate, InstallID: info.InstallID})
if err != nil {
body = []byte("deployed: true\nenv: {}\n")
}
+4
View File
@@ -449,6 +449,10 @@ func (m *Manager) RunTier2(stackName string) error {
} else if m.restoreRunningFor(stackName) {
m.logger.Printf("[WARN] [backup] Tier 2 %s stopped before the unit leg — a restore of it began (R-1180)", stackName)
return nil
} else if gErr := m.guardUnitDir(stackName, destUnit, target.NamespaceRoot, "second-drive copy of the unit"); gErr != nil {
// D47: the second drive's copy of an EARLIER install's unit is set aside whole before this install's is
// mirrored — or the leg is skipped and that copy stays as it is.
warns = append(warns, m.note("note.tier2.unit_other_install_kept"))
} else if err := mirror(unitDir, destUnit); err != nil {
m.recordTier2Failure(stackName, target, err)
if m.tier2Notify != nil {
+1 -1
View File
@@ -39,7 +39,7 @@ func (m *Manager) keepCopy(kind, source, drive, reason, destBase, path string) {
m.logger.Printf("[WARN] [backup] kept copy %s could not be recorded (the copy itself is kept): %v", path, err)
return
}
m.logger.Printf("[INFO] [backup] second-drive copy KEPT (source gone: %s): %s %q at %s", reason, kind, source, path)
m.logger.Printf("[INFO] [backup] copy KEPT (%s): %s %q at %s", reason, kind, source, path)
}
// releaseKeptCopy forgets the record of path because its source is back (the run mirrored it again). Data untouched.
+319
View File
@@ -0,0 +1,319 @@
package backup
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gopkg.in/yaml.v3"
)
// ── D47 (operator ruling 2026-10-25 08:38; R-1269): a new install never writes into an earlier install's unit ──────
//
// MEASURED BEFORE (catalog sweep 2026-10-24, three times; `audits/catalog-sweep-2026-10-24/_unitprobe/tandoor/`):
// install Tandoor, one account, „Mentés most", remove keeping data and backups, install again. Five minutes later the
// periodic refresh (captureRecoveryUnit, dataRun=false) rewrote ONLY the unit's compose/app.yaml — the new install's
// database password — beside the OLD install's data. Its one „same definition" test is the pins (`frozen` below), and
// a reinstall of the same version has the same pins, so it passed. A restore then paired the new password with the old
// data directory: „password authentication failed", the app never started, the account gone from view. The next
// night's data legs would have overwritten the old data too.
//
// THE RULE. A unit carries the identity of the install its data belongs to (its app.yaml `install_id:`, minted at a
// fresh install, taken over by a restore — stacks.AppConfig.InstallID). EVERY writer of a unit — the capture and
// refresh, the database dump legs, the volume dump leg, the update's pre-backup dump, the second drive's mirror, the
// stale-drive prune — asks guardUnitWrite first. A unit of ANOTHER install is SET ASIDE WHOLE (a rename on the same
// drive, never a copy, never an edit) into <namespace>/backups/set-aside/<app>/<UTC stamp>/, recorded as a kept copy
// (D25's record: listed on the restore page with „Visszaállítás" and „Eltávolítás"), and the writer then writes the new
// install's own unit. A set-aside that cannot be done REFUSES the write and leaves the old unit exactly as it was, with
// an ERROR line naming the app and both identities. Nothing deletes a set-aside unit but the household: its
// „Eltávolítás", or the app's remove with „delete its backups too" (the D25 / R-1252 H9 rule for kept copies).
//
// A UNIT WRITTEN BEFORE D47 records no install. Then the secrets decide: a reinstall mints new ones, a restore writes
// the unit's into the app, so a carried secret that differs on both sides means another install. With no differing
// secret it is this install's (the old behaviour) and the next write records the identity.
// setAsideDirName is the folder beside backups/primary that holds the set-aside units.
const setAsideDirName = "set-aside"
// KeptReasonEarlierInstall is the kept-copy reason of a set-aside unit (the page's „kept.reason.earlier_install").
const KeptReasonEarlierInstall = "earlier_install"
// KeptKindUnit is the kept-copy kind of a set-aside recovery unit (restorable from the restore page).
const KeptKindUnit = "unit"
// errUnitOfAnotherInstall is the refusal of a write into another install's unit that could not be set aside.
var errUnitOfAnotherInstall = errors.New("the recovery unit belongs to another install and could not be set aside")
// setAsideRoot is <nsRoot>/backups/set-aside.
func setAsideRoot(nsRoot string) string {
return filepath.Join(nsRoot, "backups", setAsideDirName)
}
// installIdentity is the optional provider method that mints an identity for an app installed before D47.
type installIdentity interface {
EnsureInstallID(name string) (string, error)
}
type unitOwner int
const (
ownerNoUnit unitOwner = iota // nothing to protect
ownerSame // this install's unit (or no signal that it is not)
ownerOther // another install's unit — set it aside before writing
)
// unitOwnerVerdict is the rule as a pure function. Both identities known → they decide. Otherwise (a unit or an app
// from before D47) the carried secrets decide: a name both sides hold with different values means another install.
// why names the reason, never a value.
func unitOwnerVerdict(unitID, liveID string, unitSecrets, liveSecrets map[string]string) (unitOwner, string) {
if unitID != "" && liveID != "" {
if unitID == liveID {
return ownerSame, "same install id"
}
return ownerOther, "the install ids differ"
}
var differ []string
for n, uv := range unitSecrets {
if lv := liveSecrets[n]; uv != "" && lv != "" && uv != lv {
differ = append(differ, n)
}
}
if len(differ) > 0 {
sort.Strings(differ)
return ownerOther, "carried secret(s) " + strings.Join(differ, ", ") + " differ (a unit from before D47)"
}
return ownerSame, "no install id on one side and no differing secret"
}
// unitOwnerOf reads a unit's recorded install and the secrets it carries (names from its manifest). exists=false when
// the directory holds nothing a restore could use (no manifest, no compose/, no data folder).
func unitOwnerOf(unitDir string) (exists bool, installID string, secrets map[string]string) {
secrets = map[string]string{}
any := false
for _, p := range []string{UnitManifestFile(unitDir), UnitComposeDir(unitDir), UnitDBDumpDir(unitDir), UnitVolumeDumpDir(unitDir)} {
if _, err := os.Stat(p); err == nil {
any = true
break
}
}
if !any {
return false, "", secrets
}
var portable []string
if man := readManifest(UnitManifestFile(unitDir)); man != nil {
portable = man.PortableSecretEnvVars
}
appy := filepath.Join(UnitComposeDir(unitDir), "app.yaml")
_, secrets = readUnitEnv(appy, portable)
if b, err := os.ReadFile(appy); err == nil {
var doc struct {
InstallID string `yaml:"install_id"`
}
if yaml.Unmarshal(b, &doc) == nil {
installID = doc.InstallID
}
}
return true, installID, secrets
}
// liveInstall is the app's identity and carried secrets now; it mints an identity for an app from before D47 when the
// provider can. err = the identity could not be read or written (the caller refuses to decide).
func (m *Manager) liveInstall(stack string) (string, map[string]string, error) {
info, ok := m.stackProvider.GetStackRecoveryInfo(stack)
if !ok {
// No record to compare at all (a provider that does not know the app): no signal — the old behaviour.
return "", nil, nil
}
id := info.InstallID
if id == "" {
if e, ok := m.stackProvider.(installIdentity); ok {
got, err := e.EnsureInstallID(stack)
if err != nil {
return "", nil, err
}
id = got
}
}
return id, info.PortableSecrets, nil
}
// guardUnitWrite is asked by EVERY writer before it writes into <nsRoot>/backups/primary/<stack> (what names the
// writer, for the log). nil = write; an error = do not write — the unit is untouched.
func (m *Manager) guardUnitWrite(stack, nsRoot, what string) error {
if m.stackProvider == nil {
return nil
}
return m.guardUnitDir(stack, RecoveryUnitPath(nsRoot, stack), nsRoot, what)
}
// guardUnitDir is guardUnitWrite for any unit directory (the second drive's mirror too); setAsideNs is the namespace
// root whose backups/set-aside receives it (the unit's own drive, so the move is a rename).
func (m *Manager) guardUnitDir(stack, unitDir, setAsideNs, what string) error {
exists, unitID, unitSecrets := unitOwnerOf(unitDir)
if !exists {
return nil
}
liveID, liveSecrets, err := m.liveInstall(stack)
if err != nil {
m.logger.Printf("[ERROR] [backup] %s: the %s is NOT written into %s — this install's identity cannot be read (%v); the unit is untouched (D47)", stack, what, unitDir, err)
return fmt.Errorf("%w: %v", errUnitOfAnotherInstall, err)
}
verdict, why := unitOwnerVerdict(unitID, liveID, unitSecrets, liveSecrets)
if verdict != ownerOther {
return nil
}
// A restore of this app reads that unit right now (its install becomes the unit's when the definition is written):
// never move it under the restore — refuse this write instead.
if m.restoreRunningFor(stack) {
m.logger.Printf("[ERROR] [backup] %s: the %s is NOT written into the unit of install %s (this install %s): a restore of the app runs — the unit is untouched (D47)", stack, what, orUnknown(unitID), orUnknown(liveID))
return errUnitOfAnotherInstall
}
dest, err := m.setAsideUnit(stack, unitDir, setAsideNs)
if err != nil {
m.logger.Printf("[ERROR] [backup] %s: REFUSED to write the %s into the recovery unit of another install (unit install %s, this install %s; %s): it could not be set aside: %v — the unit is untouched (D47)",
stack, what, orUnknown(unitID), orUnknown(liveID), why, err)
return fmt.Errorf("%w: %v", errUnitOfAnotherInstall, err)
}
m.logger.Printf("[INFO] [backup] %s: the recovery unit of an earlier install (%s; %s) is SET ASIDE whole → %s; this install (%s) gets its own unit from its first backup (D47)",
stack, orUnknown(unitID), why, dest, orUnknown(liveID))
return nil
}
func orUnknown(id string) string {
if id == "" {
return "unrecorded"
}
return id
}
// renameUnitFn is the set-aside's move (tests inject a failure); production is os.Rename.
var renameUnitFn = os.Rename
// setAsideUnit MOVES unitDir whole to <setAsideNs>/backups/set-aside/<stack>/<UTC stamp> and records it as a kept
// copy. A rename only: never a copy, never across drives (EXDEV refuses, nothing moved).
func (m *Manager) setAsideUnit(stack, unitDir, setAsideNs string) (string, error) {
base := filepath.Join(setAsideRoot(setAsideNs), stack)
if err := os.MkdirAll(base, 0o755); err != nil {
return "", err
}
dest := filepath.Join(base, time.Now().UTC().Format("20060102T150405Z"))
for i := 1; ; i++ {
if _, err := os.Lstat(dest); os.IsNotExist(err) {
break
}
dest = filepath.Join(base, fmt.Sprintf("%s-%d", time.Now().UTC().Format("20060102T150405Z"), i))
}
if err := renameUnitFn(unitDir, dest); err != nil {
_ = os.Remove(base) // only when empty
return "", err
}
m.keepCopy(KeptKindUnit, stack, m.storageLabel(m.driveOfNamespace(setAsideNs)), KeptReasonEarlierInstall, setAsideRoot(setAsideNs), dest)
return dest, nil
}
// driveOfNamespace names the drive a namespace root sits on (for the record's label): the registered storage path
// whose namespace it is, else the namespace root itself.
func (m *Manager) driveOfNamespace(nsRoot string) string {
if m.settings != nil {
for _, sp := range m.settings.GetStoragePaths() {
if sp.Path != "" && filepath.Clean(NamespaceRootFor(sp.Path, m.systemDataPath)) == filepath.Clean(nsRoot) {
return sp.Path
}
}
}
if m.systemDataPath != "" && filepath.Clean(NamespaceRootFor(m.systemDataPath, m.systemDataPath)) == filepath.Clean(nsRoot) {
return m.systemDataPath
}
return nsRoot
}
// SetAsideDirsForApp lists the app's set-aside folders (<root>/backups/set-aside/<stack>) that exist, on every
// namespace root a unit of it can sit on.
func (m *Manager) SetAsideDirsForApp(stack string) []string {
if m == nil || !validMirrorStackName(stack) {
return nil
}
var out []string
for _, root := range m.tier2MirrorRoots(stack) {
d := filepath.Join(setAsideRoot(root), stack)
if fi, err := os.Stat(d); err == nil && fi.IsDir() {
out = append(out, d)
}
}
return out
}
// RemoveSetAsideUnits is the app's remove with „delete its backups too": each dir only if it is exactly
// <root>/backups/set-aside/<stack> for one of the app's roots, and the kept-copy records inside it are dropped.
func (m *Manager) RemoveSetAsideUnits(stack string, dirs []string) []string {
if m == nil || !validMirrorStackName(stack) {
return nil
}
allowed := map[string]bool{}
for _, root := range m.tier2MirrorRoots(stack) {
allowed[filepath.Join(setAsideRoot(root), stack)] = true
}
var removed []string
for _, d := range dirs {
if !allowed[d] {
m.logger.Printf("[WARN] [backup] remove %s: refusing to delete %q — not this app's set-aside folder", stack, d)
continue
}
size := humanizeBytes(dirSizeBytes(d))
if err := os.RemoveAll(d); err != nil {
m.logger.Printf("[ERROR] [backup] remove %s: deleting the set-aside units %s failed: %v", stack, d, err)
continue
}
m.logger.Printf("[INFO] [backup] remove %s: the set-aside units of earlier installs deleted with its backups: %s (%s)", stack, d, size)
m.dropKeptCopiesUnder(d)
removed = append(removed, fmt.Sprintf("%s (%s)", d, size))
}
return removed
}
// SetAsideUnitFor returns the kept-copy record of a set-aside unit by id, still the recorded directory (device +
// inode) and still inside its set-aside tree — the only way the restore page names one.
func (m *Manager) SetAsideUnitFor(id string) (settings.KeptCopy, error) {
if m.settings == nil {
return settings.KeptCopy{}, ErrKeptCopyNotFound
}
for _, k := range m.settings.GetKeptCopies() {
if k.ID != id {
continue
}
if k.Kind != KeptKindUnit || !validMirrorStackName(k.Source) {
return settings.KeptCopy{}, ErrKeptCopyNotFound
}
rel, err := filepath.Rel(filepath.Clean(k.DestBase), filepath.Clean(k.Path))
if err != nil || rel == "." || strings.HasPrefix(rel, "..") || filepath.Base(k.DestBase) != setAsideDirName {
return settings.KeptCopy{}, ErrKeptCopyChanged
}
st, err := os.Lstat(k.Path)
if err != nil || !st.IsDir() {
return settings.KeptCopy{}, ErrKeptCopyChanged
}
if dev, ino := statIdentity(st); dev != k.Dev || ino != k.Ino {
return settings.KeptCopy{}, ErrKeptCopyChanged
}
return k, nil
}
return settings.KeptCopy{}, ErrKeptCopyNotFound
}
// RestoreSetAsideUnit restores the app from a set-aside unit (the restore page's „Visszaállítás" on a kept copy):
// the one unit-restore body, with the unit named explicitly. The data then belongs to that unit's install, so its
// backups follow it (the app's own unit, of the install it replaced, is set aside at the next backup — never lost).
func (m *Manager) RestoreSetAsideUnit(id string) (string, UnitRestoreResult, error) {
k, err := m.SetAsideUnitFor(id)
if err != nil {
return "", UnitRestoreResult{}, err
}
m.logger.Printf("[INFO] [backup] restore %s from the set-aside unit %s (an earlier install, D47)", k.Source, k.Path)
res, err := m.RestoreFromRecoveryUnitAt(k.Source, k.Path)
return k.Source, res, err
}
@@ -0,0 +1,304 @@
package backup
import (
"bytes"
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// D47 (operator ruling 2026-10-25 08:38; R-1269) — a new install never writes into an earlier install's unit.
//
// These tests replay the catalog sweep's order (`audits/catalog-sweep-2026-10-24/_unitprobe/tandoor/`): install A,
// a backup, „remove — keep backups", install B of the same version (new database password), then the five-minute
// refresh — and assert the CONSEQUENCE: the old unit's bytes, where they are, what a restore of it gets.
//
// COMPANION RED-PROOF (REPORT.md): on `e1613ae` (no guardUnitWrite) TestD47_TheSweepOrderNeverRewritesTheKeptUnit
// fails — the primary unit's app.yaml carries install B's password beside install A's data, and nothing is set aside.
const (
d47PwA = "PASSWORD-OF-INSTALL-A-the-data-was-made-with"
d47PwB = "PASSWORD-OF-INSTALL-B-a-fresh-reinstall"
)
// d47Info is the recovery info of one install of "tandoor" (a Postgres app on the system drive).
func d47Info(stackDir, drive, installID, pw string) RecoveryInfo {
return RecoveryInfo{
StackDir: stackDir,
DisplayName: "Tandoor",
ImagePins: []string{"example/tandoor:2.6.15"},
NonSecretEnv: map[string]string{"SUBDOMAIN": "recipes"},
SecretEnvVars: []string{"DB_PASSWORD"},
PortableSecretEnvVars: []string{"DB_PASSWORD"},
PortableSecrets: map[string]string{"DB_PASSWORD": pw},
InstallID: installID,
}
}
type d47Rig struct {
m *Manager
prov *fakeRecoveryProvider
drive string
stackDir string
logs *bytes.Buffer
sett *settings.Settings
}
// d47Setup: install A with a database dump and a volume tar, captured after its data run (the „Mentés most" order).
func d47Setup(t *testing.T, idA string) *d47Rig {
t.Helper()
tmp := t.TempDir()
r := &d47Rig{stackDir: filepath.Join(tmp, "stack"), drive: filepath.Join(tmp, "drive"), logs: &bytes.Buffer{}}
mustWrite(t, filepath.Join(r.stackDir, "docker-compose.yml"),
"services:\n tandoor:\n image: example/tandoor:2.6.15\n tandoor-postgres:\n image: postgres:17-alpine\n")
mustWrite(t, filepath.Join(r.stackDir, ".felhom.yml"), "display_name: Tandoor\n")
mustWrite(t, filepath.Join(r.stackDir, "app.yaml"), "deployed: true\nenv:\n SUBDOMAIN: recipes\n")
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
r.sett = sett
r.prov = &fakeRecoveryProvider{info: d47Info(r.stackDir, r.drive, idA, d47PwA), hdd: r.drive, running: true,
secrets: map[string]string{"DB_PASSWORD": d47PwA}}
r.m = &Manager{
logger: log.New(r.logs, "", 0),
systemDataPath: filepath.Join(tmp, "system"),
stackProvider: r.prov,
settings: sett,
version: "vtest",
}
mustWrite(t, filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql"), pgDump(1)+"-- the household's account\n")
mustWrite(t, filepath.Join(AppVolumeDumpPath(r.drive, "tandoor"), "tandoor_postgres_data.tar"), "DATA DIRECTORY OF INSTALL A")
if err := r.m.CaptureRecoveryUnit("tandoor"); err != nil {
t.Fatalf("install A's capture: %v", err)
}
return r
}
// reinstall is „remove — keep backups" + a fresh install of the same version: a new identity and a new password.
func (r *d47Rig) reinstall(idB string) {
r.prov.info = d47Info(r.stackDir, r.drive, idB, d47PwB)
r.prov.secrets = map[string]string{"DB_PASSWORD": d47PwB}
}
func (r *d47Rig) unitDir() string { return RecoveryUnitPath(r.drive, "tandoor") }
func (r *d47Rig) setAside(t *testing.T) []string {
t.Helper()
entries, _ := os.ReadDir(filepath.Join(setAsideRoot(r.drive), "tandoor"))
var out []string
for _, e := range entries {
out = append(out, filepath.Join(setAsideRoot(r.drive), "tandoor", e.Name()))
}
return out
}
// The sweep's measured order. Today (`e1613ae`) the refresh rewrites the kept unit's app.yaml with install B's
// password beside install A's data. After D47 the kept unit is set aside byte for byte, it is listed, and the
// primary place holds install B's own unit with none of A's data.
func TestD47_TheSweepOrderNeverRewritesTheKeptUnit(t *testing.T) {
r := d47Setup(t, "aaaa000000000001")
before := treeFingerprint(t, r.unitDir())
r.reinstall("bbbb000000000002")
if err := r.m.captureRecoveryUnit("tandoor", false); err != nil { // the five-minute refresh
t.Fatalf("refresh: %v", err)
}
aside := r.setAside(t)
if len(aside) != 1 {
t.Fatalf("install A's unit must be set aside whole (one folder), found %v; primary app.yaml now: %q",
aside, mustRead(t, filepath.Join(RecoveryUnitComposePath(r.drive, "tandoor"), "app.yaml")))
}
if after := treeFingerprint(t, aside[0]); after != before {
t.Fatalf("the set-aside unit must be install A's unit byte for byte\nbefore:\n%s\nafter:\n%s", before, after)
}
primaryAppYaml := mustRead(t, filepath.Join(RecoveryUnitComposePath(r.drive, "tandoor"), "app.yaml"))
if strings.Contains(primaryAppYaml, d47PwA) || !strings.Contains(primaryAppYaml, "bbbb000000000002") {
t.Fatalf("the primary place must hold install B's own unit: %q", primaryAppYaml)
}
if fileExists(filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql")) {
t.Fatal("install A's database copy must not stay under install B's unit")
}
var rec *settings.KeptCopy
for _, k := range r.sett.GetKeptCopies() {
if k.Path == aside[0] {
kk := k
rec = &kk
}
}
if rec == nil || rec.Kind != KeptKindUnit || rec.Reason != KeptReasonEarlierInstall || rec.Source != "tandoor" {
t.Fatalf("the set-aside unit must be recorded as a kept copy (listed, removable only by the household): %+v", r.sett.GetKeptCopies())
}
}
// A unit written BEFORE D47 records no install: the carried database password decides (a reinstall mints a new one).
func TestD47_AUnitFromBeforeD47IsSetAsideWhenItsPasswordDiffers(t *testing.T) {
r := d47Setup(t, "")
before := treeFingerprint(t, r.unitDir())
r.reinstall("")
if err := r.m.captureRecoveryUnit("tandoor", false); err != nil {
t.Fatalf("refresh: %v", err)
}
aside := r.setAside(t)
if len(aside) != 1 || treeFingerprint(t, aside[0]) != before {
t.Fatalf("an unrecorded unit with another install's password must be set aside whole, found %v", aside)
}
}
// The set-aside unit restores with ITS OWN password (the household's „Visszaállítás" on it), never install B's.
func TestD47_TheSetAsideUnitRestoresWithItsOwnPassword(t *testing.T) {
r := d47Setup(t, "aaaa000000000001")
r.reinstall("bbbb000000000002")
if err := r.m.captureRecoveryUnit("tandoor", false); err != nil {
t.Fatal(err)
}
ks := r.sett.GetKeptCopies()
if len(ks) != 1 {
t.Fatalf("one kept copy expected, got %+v", ks)
}
r.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{{StackName: "tandoor", ContainerName: "tandoor-postgres", DBType: DBTypePostgres}}, nil
}
var imported []string
r.m.importDBDump = func(_ context.Context, _ DiscoveredDB, p string) error { imported = append(imported, p); return nil }
r.m.volumeReplayFrom = func(string, string) (int, error) { return 1, nil }
r.m.undoVolumeProbeFn = func(string) (int64, bool, error) { return 10, true, nil }
r.m.undoVolumeTarFn = func(vol, dir string) error {
return os.WriteFile(filepath.Join(dir, vol+".tar"), []byte("live"), 0o600)
}
app, res, err := r.m.RestoreSetAsideUnit(ks[0].ID)
if err != nil || app != "tandoor" {
t.Fatalf("restore of the set-aside unit: app=%q err=%v", app, err)
}
if got := r.prov.gotEnv["DB_PASSWORD"]; got != d47PwA {
t.Fatalf("the restored app must get install A's password (the one its data opens with), got %q", got)
}
if res.DBsReplayed != 1 || len(imported) != 1 || !strings.Contains(imported[0], filepath.Join(setAsideDirName, "tandoor")) {
t.Fatalf("the database copy must come from the set-aside unit: %v (replayed %d)", imported, res.DBsReplayed)
}
}
// The guard is in the WRITER: a set-aside that cannot be done refuses the write, the unit stays byte for byte, and the
// ERROR line names the app and both identities.
func TestD47_TheWriterRefusesWhenTheUnitCannotBeSetAside(t *testing.T) {
r := d47Setup(t, "aaaa000000000001")
before := treeFingerprint(t, r.unitDir())
r.reinstall("bbbb000000000002")
renameUnitFn = func(string, string) error { return errors.New("injected: rename refused") }
defer func() { renameUnitFn = os.Rename }()
err := r.m.captureRecoveryUnit("tandoor", false)
if !errors.Is(err, errUnitOfAnotherInstall) {
t.Fatalf("the capture must refuse, got %v", err)
}
if after := treeFingerprint(t, r.unitDir()); after != before {
t.Fatalf("a refused write must leave install A's unit untouched\nbefore:\n%s\nafter:\n%s", before, after)
}
line := r.logs.String()
if !strings.Contains(line, "[ERROR]") || !strings.Contains(line, "tandoor") || !strings.Contains(line, "aaaa000000000001") || !strings.Contains(line, "bbbb000000000002") {
t.Fatalf("the ERROR line must name the app and both identities: %s", line)
}
if strings.Contains(line, d47PwA) || strings.Contains(line, d47PwB) {
t.Fatal("SECRET LEAK: a password value reached the log")
}
}
// The data legs ask too: install B's night database dump never lands in install A's unit.
func TestD47_TheNightDatabaseDumpSetsTheEarlierUnitAsideFirst(t *testing.T) {
r := d47Setup(t, "aaaa000000000001")
before := treeFingerprint(t, r.unitDir())
r.reinstall("bbbb000000000002")
r.m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) {
return []DiscoveredDB{{StackName: "tandoor", ContainerName: "tandoor-postgres", DBType: DBTypePostgres}}, nil
}
r.m.dumpOne = func(_ context.Context, db DiscoveredDB, dir string, _ *log.Logger, _ bool) DumpResult {
p := filepath.Join(dir, "tandoor-postgres.sql")
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(p, []byte(pgDump(1)+"-- install B, empty\n"), 0o644)
return DumpResult{DB: db, FilePath: p, Size: 10}
}
if err := r.m.guardUnitWrite("tandoor", r.drive, "database dump"); err != nil {
t.Fatal(err)
}
res := r.m.dumpOneOrDefault(context.Background(), r.m.mustDB(t), AppDBDumpPath(r.drive, "tandoor"))
if res.Error != nil {
t.Fatal(res.Error)
}
aside := r.setAside(t)
if len(aside) != 1 || treeFingerprint(t, aside[0]) != before {
t.Fatalf("install A's unit must be set aside whole before install B's dump is written, found %v", aside)
}
}
func (m *Manager) mustDB(t *testing.T) DiscoveredDB {
t.Helper()
dbs, err := m.discoverDBs(context.Background())
if err != nil || len(dbs) == 0 {
t.Fatal("no db")
}
return dbs[0]
}
// The other side: a normal backup of the SAME install still refreshes the unit whole, and nothing is set aside.
func TestD47_ASameInstallBackupStillRefreshesTheUnit(t *testing.T) {
r := d47Setup(t, "aaaa000000000001")
mustWrite(t, filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql"), pgDump(1)+"-- tonight's rows\n")
if err := r.m.CaptureRecoveryUnit("tandoor"); err != nil {
t.Fatal(err)
}
if aside := r.setAside(t); len(aside) != 0 {
t.Fatalf("a backup of the same install must not set anything aside: %v", aside)
}
appy := mustRead(t, filepath.Join(RecoveryUnitComposePath(r.drive, "tandoor"), "app.yaml"))
if !strings.Contains(appy, d47PwA) || !strings.Contains(appy, "install_id: aaaa000000000001") {
t.Fatalf("the same install's unit carries its own password and identity: %q", appy)
}
if got := mustRead(t, filepath.Join(AppDBDumpPath(r.drive, "tandoor"), "tandoor-postgres.sql")); !strings.Contains(got, "tonight's rows") {
t.Fatal("tonight's dump must be in the unit")
}
}
// A restore of the app runs: the refresh never moves the unit it reads (refused, untouched).
func TestD47_NoSetAsideWhileTheAppIsRestored(t *testing.T) {
r := d47Setup(t, "aaaa000000000001")
before := treeFingerprint(t, r.unitDir())
r.reinstall("bbbb000000000002")
r.m.BeginRestoreOp("restore", "tandoor")
if err := r.m.guardUnitWrite("tandoor", r.drive, "database dump"); !errors.Is(err, errUnitOfAnotherInstall) {
t.Fatalf("a write during the app's restore must refuse, got %v", err)
}
if after := treeFingerprint(t, r.unitDir()); after != before {
t.Fatal("the unit under a restore must stay as it is")
}
}
// The verdict as a pure function: ids decide when both are known; otherwise a differing carried secret.
func TestD47_OwnerVerdict(t *testing.T) {
cases := []struct {
name string
unitID, id string
us, ls map[string]string
want unitOwner
}{
{"same id", "a", "a", nil, nil, ownerSame},
{"other id", "a", "b", map[string]string{"P": "x"}, map[string]string{"P": "x"}, ownerOther},
{"unrecorded unit, same password", "", "b", map[string]string{"P": "x"}, map[string]string{"P": "x"}, ownerSame},
{"unrecorded unit, other password", "", "b", map[string]string{"P": "x"}, map[string]string{"P": "y"}, ownerOther},
{"unrecorded unit, no secrets", "", "b", nil, nil, ownerSame},
{"empty value never decides", "", "", map[string]string{"P": ""}, map[string]string{"P": "y"}, ownerSame},
}
for _, c := range cases {
if got, _ := unitOwnerVerdict(c.unitID, c.id, c.us, c.ls); got != c.want {
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
}
}
}
@@ -406,6 +406,9 @@ func (m *Manager) RunAppBackupNow(ctx context.Context, stackName string) error {
if err != nil {
return util.MsgError("err.backup.adatbazis_felderites_sikertelen", err)
}
if gErr := m.guardUnitWrite(stackName, nsRoot, "update pre-backup"); gErr != nil { // D47 (R-1269)
return util.MsgError("err.backup.adatbazis_mentes_sikertelen", stackName, gErr)
}
dumped := 0
for _, db := range dbs {
if db.StackName != stackName {
+5 -1
View File
@@ -2856,5 +2856,9 @@
"flash.kept.remove_failed": "The kept copy could not be removed: %s",
"deploy.memory_overcommit_slow": "The apps' peak load can exceed the available memory. The box can then slow down during large uploads and may not answer for minutes. If this happens often, remove an app.",
"deploy.memory_swap_short": "The machine has too little swap space, so during large uploads the box can slow down and not answer for minutes. Tell Felhom; we will fix it.",
"err.stacks.memory_overcommit_slow": "The peak load of the apps can exceed the available memory. The box can then slow down during large uploads and may not answer for minutes. If this happens often, remove an app."
"err.stacks.memory_overcommit_slow": "The peak load of the apps can exceed the available memory. The box can then slow down during large uploads and may not answer for minutes. If this happens often, remove an app.",
"kept.reason.earlier_install": "the backup of an earlier install (made before a reinstall)",
"backups_restore.megorzott_visszaallitas_megerosites": "This loads the backup of the earlier install into the app. It replaces the data the app has now. Continue?",
"flash.kept.restore_needs_confirmation": "Confirm the question to restore it.",
"note.tier2.unit_other_install_kept": "The second drive holds the data package of an earlier install, and we could not set it aside now, so we did not overwrite it. The data package of the copy is therefore older than this backup."
}
+5 -1
View File
@@ -2844,5 +2844,9 @@
"flash.kept.remove_failed": "A megőrzött másolatot nem sikerült eltávolítani: %s",
"deploy.memory_overcommit_slow": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Ilyenkor a doboz nagy feltöltések alatt lelassulhat, és akár percekig sem válaszol. Ha ez gyakran előfordul, távolíts el egy alkalmazást.",
"deploy.memory_swap_short": "A gépen nincs elég cserehely (swap), ezért nagy feltöltéskor a doboz lelassulhat, és percekig sem válaszolhat. Szólj a Felhomnak, mi rendbe tesszük.",
"err.stacks.memory_overcommit_slow": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Ilyenkor a doboz nagy feltöltések alatt lelassulhat, és akár percekig sem válaszol. Ha ez gyakran előfordul, távolíts el egy alkalmazást."
"err.stacks.memory_overcommit_slow": "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. Ilyenkor a doboz nagy feltöltések alatt lelassulhat, és akár percekig sem válaszol. Ha ez gyakran előfordul, távolíts el egy alkalmazást.",
"kept.reason.earlier_install": "egy korábbi telepítés mentése (újratelepítés előtt készült)",
"backups_restore.megorzott_visszaallitas_megerosites": "Ezzel a korábbi telepítés mentését töltjük vissza az alkalmazásba. A mostani adatai helyére ez kerül. Folytatod?",
"flash.kept.restore_needs_confirmation": "A visszaállításhoz erősítsd meg a kérdést.",
"note.tier2.unit_other_install_kept": "A második meghajtón egy korábbi telepítés adatcsomagja van, és most nem tudtuk félretenni, ezért nem írtuk felül. A másolat adatcsomagja ezért régebbi, mint ez a mentés."
}
+60
View File
@@ -201,6 +201,21 @@ type AppConfig struct {
AfterSetup *AfterInstallRecord `yaml:"after_setup,omitempty" json:"after_setup,omitempty"`
// AfterUncleanBoot (R-1007) is what the template's after_unclean_boot last found and did (after_unclean_boot.go).
AfterUncleanBoot *AfterUncleanBootRecord `yaml:"after_unclean_boot,omitempty" json:"after_unclean_boot,omitempty"`
// InstallID (D47, operator ruling 2026-10-25; R-1269) names the install the app's DATA belongs to: minted at a
// fresh install and at an import, taken from the unit at a restore (the data is that unit's now), and carried by
// every load-then-save. The recovery unit records it (its app.yaml `install_id:`), and a backup of another install
// never writes into that unit (internal/backup/unit_owner.go). Absent = an app installed before D47 (minted at
// its first backup).
InstallID string `yaml:"install_id,omitempty" json:"install_id,omitempty"`
}
// NewInstallID mints an install identity (D47): 16 random hex characters. Never a secret, never derived from one.
func NewInstallID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("t%015x", time.Now().UnixNano()&0xfffffffffffffff) // a clock fallback, still unique per box
}
return hex.EncodeToString(b)
}
// DefaultLoginRecord is app.yaml's `default_login:`.
@@ -541,6 +556,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
SetupGate: gate,
InstallHold: hold,
FamilyGate: family,
InstallID: NewInstallID(), // D47: a fresh install is a new install — its backups are its own
}
diskCfg := *appCfg
@@ -841,6 +857,18 @@ func (m *Manager) PersistUnitRedeployConfigGate(name string, env map[string]stri
cfg.RestoredLogins = restoredLoginFields(name, meta, prior, env)
priorRaw := LoadAppConfig(stackDir)
carryLifeRecords(m.logger, name, priorRaw, cfg)
// D47 (R-1269): the app now holds the data of the unit it was restored from, so it IS that unit's install — a
// later backup then writes into that unit, and a unit of the install it replaced is set aside, never rewritten.
// A unit that records no install (written before D47) keeps the app's own (carried above), else a new one.
switch {
case gate.InstallID != "":
if priorRaw != nil && priorRaw.InstallID != "" && priorRaw.InstallID != gate.InstallID {
m.logger.Printf("[INFO] [stacks] %s: the restored data belongs to install %s (was %s) — its backups follow it (D47)", name, gate.InstallID, priorRaw.InstallID)
}
cfg.InstallID = gate.InstallID
case cfg.InstallID == "":
cfg.InstallID = NewInstallID()
}
reopen, err := m.applyGateAtBackup(name, stack.ComposePath, env, &meta, cfg, priorRaw == nil, gate)
if err != nil {
return err
@@ -1623,6 +1651,38 @@ func restoredLoginFields(app string, meta Metadata, prior *AppConfig, env map[st
return out
}
// EnsureInstallID (D47) returns the app's install identity, minting and saving one (load-then-save, nothing else
// changes) for an app installed before D47. "" with an error when the record cannot be read or written — the caller
// (the backup) then refuses to decide whose unit it would write.
func (m *Manager) EnsureInstallID(name string) (string, error) {
stack, ok := m.GetStack(name)
if !ok {
return "", fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(stack.ComposePath)
m.installIDMu.Lock()
defer m.installIDMu.Unlock()
cfg := LoadAppConfig(stackDir)
if cfg == nil {
return "", fmt.Errorf("app.yaml of %s not readable", name)
}
if cfg.InstallID != "" {
return cfg.InstallID, nil
}
meta := LoadMetadata(stackDir)
cfg.InstallID = NewInstallID()
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return "", fmt.Errorf("saving the install id of %s: %w", name, err)
}
m.mu.Lock()
if s, ok := m.stacks[name]; ok {
s.AppConfig = LoadAppConfig(stackDir)
}
m.mu.Unlock()
m.logger.Printf("[INFO] [stacks] %s: install id %s recorded (an app installed before D47)", name, cfg.InstallID)
return cfg.InstallID, nil
}
// SetStackSecretValues (R-1106) writes the named values into an installed app's app.yaml and NOTHING else: load the
// file as it is (records, pin, gates, locked fields all kept — the R-697 lesson: a non-install writer loads, then
// saves), replace the named env values, save with the sensitive fields encrypted. Used by the off-site restore to put
@@ -40,6 +40,7 @@ func (m *Manager) SaveImportedAppConfig(stackDir string, env map[string]string)
}
}
carryLifeRecords(m.logger, name, priorRaw, cfg)
cfg.InstallID = NewInstallID() // D47: the bundle's data is no existing unit's data — a new install (after the carry)
if priorRaw != nil {
cfg.PinnedImages = priorRaw.PinnedImages
cfg.InstalledImages = priorRaw.InstalledImages
@@ -0,0 +1,71 @@
package stacks
import (
"os"
"path/filepath"
"testing"
)
// D47 (operator ruling 2026-10-25; R-1269) — the app's record names the install its data belongs to. Through the
// PRODUCTION writers: the restore write (PersistUnitRedeployConfigGate), a later load-then-save, EnsureInstallID.
// COMPANION RED-PROOF: drop the `case gate.InstallID != ""` branch in PersistUnitRedeployConfigGate →
// TestD47_ARestoreTakesTheUnitsInstall fails (the app keeps the replaced install's id, and its next backup would
// set aside the very unit it was restored from).
// A restore from a unit that records an install: the app now holds that unit's data, so it IS that install.
func TestD47_ARestoreTakesTheUnitsInstall(t *testing.T) {
m := gateManager(t, gatedYml)
writeAppYaml(t, gappDir(m), "deployed: true\ninstall_id: bbbb000000000002\nenv:\n SUBDOMAIN: gapp\n")
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{InstallID: "aaaa000000000001"}))
if got := LoadAppConfig(gappDir(m)); got == nil || got.InstallID != "aaaa000000000001" {
t.Fatalf("the restored app must be the unit's install, got %+v", got)
}
}
// A unit that records no install (before D47): the app keeps its own identity — a restore is not a new install.
func TestD47_ARestoreOfAnUnrecordedUnitKeepsTheAppsInstall(t *testing.T) {
m := gateManager(t, gatedYml)
writeAppYaml(t, gappDir(m), "deployed: true\ninstall_id: bbbb000000000002\nenv:\n SUBDOMAIN: gapp\n")
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{}))
if got := LoadAppConfig(gappDir(m)); got == nil || got.InstallID != "bbbb000000000002" {
t.Fatalf("an unrecorded unit's restore keeps the app's own install, got %+v", got)
}
}
// A removed app restored from an unrecorded unit gets an identity (never empty after a restore).
func TestD47_ARemovedAppRestoredGetsAnInstall(t *testing.T) {
m := gateManager(t, gatedYml)
must(t, m.PersistUnitRedeployConfigGate("gapp", gateEnv, GateAtBackup{}))
if got := LoadAppConfig(gappDir(m)); got == nil || len(got.InstallID) != 16 {
t.Fatalf("a restored app must have an install id, got %+v", got)
}
}
// An app installed before D47 gets one identity once; a second call returns the same one.
func TestD47_EnsureInstallIDMintsOnce(t *testing.T) {
m := gateManager(t, gatedYml)
writeAppYaml(t, gappDir(m), "deployed: true\nenv:\n SUBDOMAIN: gapp\ndesired_state: running\n")
m.mu.Lock()
m.stacks["gapp"].Deployed = true
m.mu.Unlock()
a, err := m.EnsureInstallID("gapp")
must(t, err)
b, err := m.EnsureInstallID("gapp")
must(t, err)
if len(a) != 16 || a != b {
t.Fatalf("one identity, minted once: %q then %q", a, b)
}
if got := LoadAppConfig(gappDir(m)); got.DesiredState != DesiredStateRunning {
t.Fatalf("minting the identity must change nothing else: %+v", got)
}
}
func writeAppYaml(t *testing.T, dir, body string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
@@ -40,6 +40,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) {
cfg.InstallHold = prior.InstallHold // R-741: the loop opens it when the restored record says the login was replaced
cfg.DefaultLogin = prior.DefaultLogin
cfg.AfterSetup = prior.AfterSetup
cfg.InstallID = prior.InstallID // D47: a rewrite of the record is not a new install (a restore then sets the unit's)
if n := len(prior.EarlierConversionCopies); prior.ConversionCopy != nil || n > 0 {
cur := ""
if prior.ConversionCopy != nil {
+1
View File
@@ -259,6 +259,7 @@ type Manager struct {
// Migration engine (B1): single-flight + live job + deps wired via SetMigrationDeps.
migrateMu sync.Mutex
installIDMu sync.Mutex // D47: one EnsureInstallID at a time (two backups racing must not mint two ids)
migrating bool
migJob *MigrationJob
settings *settings.Settings
+9 -2
View File
@@ -29,6 +29,9 @@ type GateAtBackup struct {
Known bool // the backup carries a record
Closed bool // the gate was closed (the first setup not done)
OpenedBy string // who opened it, when open
// InstallID (D47) is the install the backup's data belongs to — the unit's app.yaml `install_id:`. "" = not
// recorded (a unit written before D47). Independent of Known (which is about the gate).
InstallID string
}
// GateCapture is the record a backup writes for an app: nil for a template with no gate. An app whose template gates
@@ -53,11 +56,15 @@ func ReadGateAtBackup(appYamlPath string) GateAtBackup {
}
var doc struct {
SetupGate *appbackup.GateState `yaml:"setup_gate"`
InstallID string `yaml:"install_id"`
}
if yaml.Unmarshal(b, &doc) != nil || doc.SetupGate == nil {
if yaml.Unmarshal(b, &doc) != nil {
return GateAtBackup{}
}
return GateAtBackup{Known: true, Closed: doc.SetupGate.State != SetupGateOpen, OpenedBy: doc.SetupGate.OpenedBy}
if doc.SetupGate == nil {
return GateAtBackup{InstallID: doc.InstallID}
}
return GateAtBackup{Known: true, Closed: doc.SetupGate.State != SetupGateOpen, OpenedBy: doc.SetupGate.OpenedBy, InstallID: doc.InstallID}
}
// applyGateAtBackup decides the restored app's gate (R-1057). removed = the app had no app.yaml before this restore.
+13 -1
View File
@@ -1342,7 +1342,13 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr != nil {
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
// D25: second-drive copies whose source is gone — kept until the household removes one.
data["KeptCopies"] = s.keptCopyRows(s.langFor(r))
rows := s.keptCopyRows(s.langFor(r))
data["KeptCopies"] = rows
for _, k := range rows {
if k.Restorable {
data["KeptUnitRestorable"] = true // D47: the confirm script only when a row offers „Visszaállítás"
}
}
// R-487: removed apps whose unit was kept join the local restore picker.
data["RemovedApps"] = s.backupMgr.ListRemovedAppUnits()
}
@@ -1909,6 +1915,12 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
// restoreFailedNote is the household's sentence for a restore that returned an error: key's „…sikertelen: %s"
// around the error's own sentence.
func (s *Server) restoreFailedNote(key string, err error) string {
return s.note(key, s.noteErr(err))
}
// unitRestoreOutcomeMsg builds the OUTCOME sentence for a completed LOCAL recovery-unit restore. Pure,
// so the wording is unit-testable — this string is the customer's only evidence that the operation did
// what its label promised.
@@ -244,6 +244,17 @@ func i18nCasesB() []i18nCase {
d["KeptCopies"] = []keptCopyRow{{ID: "0123456789abcdef", Source: "csaladi_fotok", Line: "hdd_3", SizeHuman: "1.2 GB", Since: "2026-10-21T12:00:00Z"}}
return d
}},
// D47 (2026-10-25): a set-aside unit of an earlier install — the row with „Visszaállítás" (a NEW case, so no existing
// fixture changes).
{"backups_restore_kept_unit", "backups_restore", func() map[string]interface{} {
d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
d["Backup"] = i18nBackupStatus(false, false)
d["OffboxConfigured"] = true
d["OffsiteStoreState"] = "known"
d["KeptCopies"] = []keptCopyRow{{ID: "fedcba9876543210", Source: "tandoor", Line: "belso", SizeHuman: "150 MB", Since: "2026-10-25T08:00:00Z", Restorable: true}}
d["KeptUnitRestorable"] = true
return d
}},
{"backups_restore_known_empty", "backups_restore", func() map[string]interface{} {
d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
d["Backup"] = i18nBackupStatus(false, false)
+55 -5
View File
@@ -3,6 +3,7 @@ package web
import (
"errors"
"net/http"
"net/url"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
@@ -18,6 +19,8 @@ type keptCopyRow struct {
Line string // „a(z) <drive> meghajtóról megőrzött másolat · <why>"
SizeHuman string
Since string
// Restorable (D47): a set-aside recovery unit of an earlier install — the row offers „Visszaállítás" too.
Restorable bool
}
func (s *Server) keptCopyRows(lang string) []keptCopyRow {
@@ -28,11 +31,12 @@ func (s *Server) keptCopyRows(lang string) []keptCopyRow {
for _, k := range s.backupMgr.KeptCopies() {
why := s.msgLang(lang, "kept.reason."+k.Reason)
out = append(out, keptCopyRow{
ID: k.ID,
Source: k.Source,
Line: s.msgLang(lang, "kept.from_drive", k.Drive) + " · " + why,
SizeHuman: backup.HumanizeBytes(k.SizeBytes),
Since: k.Since,
ID: k.ID,
Source: k.Source,
Line: s.msgLang(lang, "kept.from_drive", k.Drive) + " · " + why,
SizeHuman: backup.HumanizeBytes(k.SizeBytes),
Since: k.Since,
Restorable: k.Kind == backup.KeptKindUnit,
})
}
return out
@@ -68,3 +72,49 @@ func (s *Server) keptCopyRemoveHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.remove_failed", true, err.Error())
}
}
// keptUnitRestoreHandler is „Visszaállítás" on a set-aside unit (D47): POST /backup/kept/restore (id, confirm=1). The
// unit is named only by its record (backup.SetAsideUnitFor: the recorded folder, inside its set-aside tree, same device
// + inode), and restored through the one unit-restore body, in the background like every restore.
func (s *Server) keptUnitRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr == nil {
offboxRedirectTo(w, r, "/backups/restore", "flash.offbox.mgr_unreachable", true)
return
}
_ = r.ParseForm()
id := strings.TrimSpace(r.FormValue("id"))
if r.FormValue("confirm") != "1" {
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.restore_needs_confirmation", true)
return
}
if msg, blocked := s.restoreOpBlocked(); blocked {
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
k, err := s.backupMgr.SetAsideUnitFor(id)
switch {
case errors.Is(err, backup.ErrKeptCopyNotFound):
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.not_found", true)
return
case err != nil:
offboxRedirectTo(w, r, "/backups/restore", "flash.kept.changed", true)
return
}
if legs := s.backupMgr.DeclaredDriveFileLegs(k.Source); len(legs) > 0 {
msg := s.missingFileLegsRefusal(r.Context(), s.langFor(r), k.Source)
http.Redirect(w, r, "/backups/restore?flash_error="+url.QueryEscape(msg), http.StatusFound)
return
}
s.logger.Printf("[WARN] [web] Restore of %s from a set-aside unit requested (async): %s from %s", k.Source, k.Path, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("restore", k.Source)
go func() {
app, res, err := s.backupMgr.RestoreSetAsideUnit(id)
if err != nil {
s.logger.Printf("[ERROR] [web] Restore from the set-aside unit failed (async): stack=%s: %v", app, err)
s.backupMgr.EndRestoreOp(false, s.restoreFailedNote("note.restore.unit_failed", err))
return
}
s.backupMgr.EndRestoreOp(true, s.restoredVersionPrefix(app, res.VersionChanged, res.DataPins, res.DataAt)+s.unitRestoreOutcomeMsg(app, res))
}()
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
@@ -95,3 +95,71 @@ func TestD25_RemoveHandlerRemovesOnlyTheRecordedCopy(t *testing.T) {
t.Fatalf("another copy was touched: %v", err)
}
}
// D47 (2026-10-25): a set-aside unit of an earlier install is a kept copy with „Visszaállítás" beside „Eltávolítás";
// a second-drive copy is not restorable from this list (render test per branch of {{if .Restorable}}).
func TestD47_RestorePageOffersRestoreOnlyForASetAsideUnit(t *testing.T) {
d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
d["Backup"] = i18nBackupStatus(false, false)
d["OffboxConfigured"] = true
d["OffsiteStoreState"] = "known"
d["KeptCopies"] = []keptCopyRow{
{ID: "aaaaaaaaaaaaaaaa", Source: "tandoor", Line: "a(z) belso meghajtóról megőrzött másolat · egy korábbi telepítés mentése (újratelepítés előtt készült)", SizeHuman: "150 MB", Since: "2026-10-25T08:00:00Z", Restorable: true},
{ID: "bbbbbbbbbbbbbbbb", Source: "csaladi_fotok", Line: "a(z) hdd_3 meghajtóról megőrzött másolat · a meghajtó nincs csatlakoztatva", SizeHuman: "1.2 GB", Since: "2026-10-21T12:00:00Z"},
}
d["KeptUnitRestorable"] = true
html := renderBackupPage(t, "backups_restore", d)
if n := strings.Count(html, `action="/backup/kept/restore"`); n != 1 {
t.Fatalf("exactly the set-aside unit's row offers the restore, found %d", n)
}
i := strings.Index(html, `action="/backup/kept/restore"`)
if !strings.Contains(html[i:i+300], `value="aaaaaaaaaaaaaaaa"`) || !strings.Contains(html, "confirmRestoreKeptUnit(this)") {
t.Fatal("the restore form must name the set-aside unit's record and ask first")
}
if strings.Count(html, `action="/backup/kept/remove"`) != 2 {
t.Fatal("both rows keep the remove button")
}
}
// The restore handler names a set-aside unit ONLY by its record: no confirm → refused; an unknown id → not found; a
// second-drive copy's id is not a unit → not found (nothing starts).
func TestD47_KeptUnitRestoreHandlerRefusals(t *testing.T) {
tmp := t.TempDir()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
share := filepath.Join(tmp, "hdd_1", "backups", "secondary", "_shares", "hdd_3", "csaladi_fotok")
if err := os.MkdirAll(share, 0o755); err != nil {
t.Fatal(err)
}
st, _ := os.Stat(share)
sys := st.Sys().(*syscall.Stat_t)
if err := sett.UpsertKeptCopy(settings.KeptCopy{Kind: "share", Source: "csaladi_fotok", Drive: "hdd_3", Reason: "drive_away",
DestBase: filepath.Dir(filepath.Dir(share)), Path: share, Dev: uint64(sys.Dev), Ino: sys.Ino}); err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
s := &Server{cfg: cfg, backupMgr: backup.NewManager(cfg, sett, lg), logger: lg}
post := func(form string) string {
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/backup/kept/restore", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
s.keptUnitRestoreHandler(rr, req)
return rr.Header().Get("Location")
}
if loc := post("id=x"); !strings.Contains(loc, "flash.kept.restore_needs_confirmation") {
t.Fatalf("no confirm → %q", loc)
}
if loc := post("id=ffffffffffffffff&confirm=1"); !strings.Contains(loc, "flash.kept.not_found") {
t.Fatalf("unknown id → %q", loc)
}
if loc := post("id=" + settings.KeptCopyID(share) + "&confirm=1"); !strings.Contains(loc, "flash.kept.not_found") {
t.Fatalf("a second-drive copy is not a restorable unit → %q", loc)
}
if s.backupMgr.RestoreStatus().Running {
t.Fatal("a refusal must start nothing")
}
}
+3
View File
@@ -844,6 +844,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// D25 (2026-10-21): „Eltávolítás" of ONE kept second-drive copy whose source is gone.
case path == "/backup/kept/remove" && r.Method == http.MethodPost:
s.keptCopyRemoveHandler(w, r)
// D47 (2026-10-25): „Visszaállítás" from a set-aside recovery unit of an earlier install.
case path == "/backup/kept/restore" && r.Method == http.MethodPost:
s.keptUnitRestoreHandler(w, r)
// R-7b: „Megosztások" restore — a sibling of the per-app pair above.
case path == "/backup/shares/restore" && r.Method == http.MethodPost:
s.sharesRestoreHandler(w, r)
@@ -173,7 +173,14 @@
<p class="form-hint" style="margin-bottom:.75rem">{{T "backups_restore.megorzott_masolatok_hint"}}</p>
<div class="app-row-list">
{{range .KeptCopies}}
{{template "app_list_row" dict "Slug" "" "Name" .Source "Secondary" (printf "%s · %s · %s" .Line (numText .SizeHuman) (fmtTimeStr .Since))}}
{{template "app_list_row" dict "Slug" "" "Name" .Source "Secondary" (printf "%s · %s · %s" .Line (numText .SizeHuman) (fmtTimeStr .Since))}}{{if .Restorable}}
<form method="POST" action="/backup/kept/restore" style="display:inline">
{{$.CSRFField}}
<input type="hidden" name="id" value="{{.ID}}">
<input type="hidden" name="confirm" value="1">
<button type="button" class="btn btn-xs btn-outline"
onclick="confirmRestoreKeptUnit(this)">{{T "backups_restore.visszaallitas"}}</button>
</form>{{end}}
<form method="POST" action="/backup/kept/remove" style="display:inline">
{{$.CSRFField}}
<input type="hidden" name="id" value="{{.ID}}">
@@ -185,7 +192,13 @@
{{end}}
</div>
<script>
function confirmRemoveKeptCopy(btn){
{{if .KeptUnitRestorable}}function confirmRestoreKeptUnit(btn){
felhomConfirm(btn, '{{T "backups_restore.megorzott_visszaallitas_megerosites"}}', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
}
{{end}}function confirmRemoveKeptCopy(btn){
felhomConfirm(btn, '{{T "backups_restore.megorzott_eltavolitas_megerosites"}}', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
@@ -0,0 +1,921 @@
<!DOCTYPE html>
<html lang="hu" class="no-js">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visszaállítás a mentésből — Felhom.eu</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=0.247.0">
<link rel="stylesheet" href="/static/style.css?v=ASSET">
<meta name="csrf-token" content="tok">
<script>
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
function felhomConfirm(el,question,onYes){
if(!el||el.dataset.fcOpen)return;
el.dataset.fcOpen='1';
var wrap=document.createElement('span');wrap.className='inline-confirm';
var q=document.createElement('span');q.className='inline-confirm-q';q.textContent=question;
var yes=document.createElement('button');yes.type='button';yes.className='btn btn-xs btn-danger';yes.textContent='Igen';
var no=document.createElement('button');no.type='button';no.className='btn btn-xs btn-outline';no.textContent='Mégse';
wrap.appendChild(q);wrap.appendChild(yes);wrap.appendChild(no);
el.style.display='none';el.parentNode.insertBefore(wrap,el.nextSibling);
function close(){wrap.remove();el.style.display='';delete el.dataset.fcOpen;}
no.addEventListener('click',close);
yes.addEventListener('click',function(){close();onYes();});
}
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('[data-confirm]'):null;
if(!btn)return;
e.preventDefault();
felhomConfirm(btn,btn.getAttribute('data-confirm'),function(){
var form=btn.closest('form');
if(form){if(form.requestSubmit)form.requestSubmit(btn);else form.submit();}
});
});
document.addEventListener('click',function(e){
var btn=e.target.closest?e.target.closest('.nav-group-toggle'):null;
if(!btn)return;
var group=btn.closest('.nav-group');
if(!group)return;
var willOpen=!group.classList.contains('is-open');
document.querySelectorAll('.nav-group.is-open').forEach(function(g){
g.classList.remove('is-open');
var t=g.querySelector('.nav-group-toggle');
if(t)t.setAttribute('aria-expanded','false');
});
if(willOpen){group.classList.add('is-open');btn.setAttribute('aria-expanded','true');}
});
function showAlert(msg){var o=document.createElement('div');o.className='modal-overlay';o.id='alert-modal';o.addEventListener('click',function(e){if(e.target===o)o.remove();});var c=document.createElement('div');c.className='modal-card';c.innerHTML='<h3>Üzenet</h3><pre style="white-space:pre-wrap;word-break:break-word;background:var(--bg-2);padding:.75rem;border-radius:.375rem;font-size:.85rem;max-height:60vh;overflow-y:auto;user-select:text;cursor:text">'+msg.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</pre><div class="modal-actions"><button class="btn btn-primary" onclick="document.getElementById(\'alert-modal\').remove()">OK</button></div>';o.appendChild(c);document.body.appendChild(o);}
</script>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">
<symbol id="i-hard-drive" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 16h.01" /> <path d="M2.212 11.577a2 2 0 0 0-.212.896V18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-5.527a2 2 0 0 0-.212-.896L18.55 5.11A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" /> <path d="M21.946 12.013H2.054" /> <path d="M6 16h.01" /></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v2" /> <path d="M12 2v2" /> <path d="M17 20v2" /> <path d="M17 2v2" /> <path d="M2 12h2" /> <path d="M2 17h2" /> <path d="M2 7h2" /> <path d="M20 12h2" /> <path d="M20 17h2" /> <path d="M20 7h2" /> <path d="M7 20v2" /> <path d="M7 2v2" /> <rect x="4" y="4" width="16" height="16" rx="2" /> <rect x="8" y="8" width="8" height="8" rx="1" /></symbol>
<symbol id="i-memory-stick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12v-2" /> <path d="M12 18v-2" /> <path d="M16 12v-2" /> <path d="M16 18v-2" /> <path d="M2 11h1.5" /> <path d="M20 18v-2" /> <path d="M20.5 11H22" /> <path d="M4 18v-2" /> <path d="M8 12v-2" /> <path d="M8 18v-2" /> <rect x="2" y="6" width="20" height="10" rx="2" /></symbol>
<symbol id="i-thermometer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" /></symbol>
<symbol id="i-triangle-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /> <path d="M12 9v4" /> <path d="M12 17h.01" /></symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" /></symbol>
<symbol id="i-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>
<symbol id="i-square" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /></symbol>
<symbol id="i-rotate-cw" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" /> <path d="M21 3v5h-5" /></symbol>
<symbol id="i-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /> <path d="M14 2v5a1 1 0 0 0 1 1h5" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /></symbol>
<symbol id="i-external-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6" /> <path d="M10 14 21 3" /> <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /></symbol>
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></symbol>
<symbol id="i-pencil" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /> <path d="m15 5 4 4" /></symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /> <path d="M12 16v-4" /> <path d="M12 8h.01" /></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15V3" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> <path d="m7 10 5 5 5-5" /></symbol>
<symbol id="i-upload" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12" /> <path d="m17 8-5-5-5 5" /> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /></symbol>
<symbol id="i-trash-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11v6" /> <path d="M14 11v6" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /> <path d="M3 6h18" /> <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></symbol>
<symbol id="i-wrench" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" /></symbol>
<symbol id="i-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /> <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34" /> <circle cx="11" cy="11" r="8" /></symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14" /> <path d="M12 5v14" /></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></symbol>
<symbol id="i-play" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z" /></symbol>
<symbol id="i-pause" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="14" y="3" width="5" height="18" rx="1" /> <rect x="5" y="3" width="5" height="18" rx="1" /></symbol>
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h16" /> <path d="M4 6h16" /> <path d="M4 18h16" /></symbol>
</svg>
<header class="mobile-topbar">
<a href="/" class="mobile-topbar-logo"><img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu"></a>
<button type="button" class="nav-burger" aria-expanded="false" aria-controls="sidebar" aria-label="Menü">
<svg class="ico"><use href="#i-menu"/></svg>
</button>
</header>
<div class="nav-backdrop" hidden></div>
<nav class="sidebar" id="sidebar">
<div class="sidebar-header">
<img src="/static/felhom-logo.svg?v=0.247.0" alt="Felhom.eu" class="sidebar-logo">
</div>
<ul class="nav-links">
<li><a href="/launcher" class=""><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
<li><a href="/dashboard" class=""><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
<li><a href="/stacks" class=""><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-storage"><svg class="ico"><use href="#i-hard-drive"/></svg>Tárhely<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-storage" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/storage" class="">Meghajtók</a></li>
<li><a href="/storage/network" class="">Hálózati tárhely</a></li>
</ul>
</li>
<li class="nav-group is-open">
<button type="button" class="nav-group-toggle active" aria-expanded="true" aria-controls="nav-group-backups"><svg class="ico"><use href="#i-shield"/></svg>Biztonsági mentés<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-backups" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/backups" class="">Áttekintés</a></li>
<li><a href="/backups/remote" class="">Távoli mentés</a></li>
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
<li><a href="/backups/restore" class="active">Visszaállítás</a></li>
</ul>
</li>
<li class="nav-group">
<button type="button" class="nav-group-toggle" aria-expanded="false" aria-controls="nav-group-sharing"><svg class="ico"><use href="#i-share"/></svg>Megosztás<svg class="ico nav-chevron"><use href="#i-chevron-down"/></svg></button>
<ul id="nav-group-sharing" class="nav-links nav-links-sub nav-links-nested">
<li><a href="/sharing" class="">Hálózati megosztás</a></li>
</ul>
</li>
<li><a href="/monitoring" class=""><svg class="ico"><use href="#i-cpu"/></svg>Rendszermonitor</a></li>
<li><a href="/debug" class=""><svg class="ico"><use href="#i-wrench"/></svg>Hibakeresés</a></li>
</ul>
<div class="sidebar-bottom">
<div class="nav-group-label">Beállítások</div>
<ul class="nav-links nav-links-sub">
<li><a href="/settings" class=""><svg class="ico"><use href="#i-settings"/></svg>Rendszer</a></li>
<li><a href="/settings/notifications" class=""><svg class="ico"><use href="#i-bell"/></svg>Értesítések</a></li>
<li><a href="/settings/security" class=""><svg class="ico"><use href="#i-lock"/></svg>Biztonság és hozzáférés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">0.247.0</span><details class="lang-globe">
<summary class="lang-globe-btn" aria-label="Nyelv" title="Nyelv"><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /><path d="M2 12h20" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg></summary>
<ul class="lang-globe-menu">
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="hu" lang="hu" class="lang-globe-item current" aria-current="true">Magyar</button></form></li>
<li><form method="POST" action="/settings/language"><input type="hidden" name="_csrf" value="CSRF"><input type="hidden" name="back" value="/i18n-fixture"><button type="submit" name="lang" value="en" lang="en" class="lang-globe-item">English</button></form></li>
</ul>
</details>
<form method="post" action="/logout" class="logout-form"><button type="submit" class="logout-link">Kijelentkezés ↗</button></form>
</div>
</div>
</nav>
<main class="content">
<div class="page-header">
<h2>Biztonsági mentés — Visszaállítás</h2>
<span class="domain-badge">example.hu</span>
</div>
<div id="restore-banner" class="flash" style="display:none"></div>
<div class="backup-section-card">
<h3>Visszaállítás</h3>
<div class="restore-section">
<div class="restore-form-row">
<label class="restore-label">Alkalmazás:</label>
<select id="restore-app" class="restore-select" onchange="onRestoreAppChange()">
<option value="">— Válassz —</option>
<option value="privatebin" data-has-hdd="" data-has-db="" data-has-volumes=""></option>
</select>
</div>
<div class="restore-form-row">
<label class="restore-label">Pillanatkép:</label>
<select id="restore-snapshot" class="restore-select" onchange="onRestoreConfirmChange()">
<option value="">— Válassz alkalmazást —</option>
</select>
</div>
<div id="restore-type-info" class="restore-info" style="display:none;margin-bottom:0.5rem">
</div>
<div id="restore-no-snapshots" class="restore-warning" style="display:none;">
Még nincs mentés felhasználói adattal.
</div>
<div class="restore-warning">
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A visszaállítás felülírja az alkalmazás jelenlegi adatait a kiválasztott mentés állapotával.
Az alkalmazás a folyamat során automatikusan leáll és újraindul.
</div>
<div class="restore-confirm">
<label>
<input type="checkbox" id="restore-confirm-cb" onchange="onRestoreConfirmChange()">
Megértettem, visszaállítás indítása.
</label>
</div>
<div class="restore-actions">
<button type="button" class="btn btn-sm btn-danger" id="restore-btn" disabled onclick="submitRestore()">Visszaállítás indítása</button>
</div>
<div style="margin-top: 1rem; text-align: center; border-top: 1px solid var(--line); padding-top: 1rem;">
<a href="/import" class="btn btn-sm btn-outline">Importálás mentett csomagból (.fab)</a>
</div>
</div>
</div>
<div class="backup-section-card">
<h3>Visszaállítás a távoli tárolóból</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">Válaszd ki az alkalmazást, és a következő oldalon döntsd el, mit szeretnél: ellenőrzést külön mappába, csak a hiányzó fájlok visszahozását, vagy teljes visszaállítást. Egyik sem indul el kérdés nélkül.</p>
<p class="form-hint">A távoli tároló üres — nincs mit visszaállítani.</p>
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">Megőrzött másolatok</h3>
<p class="form-hint" style="margin-bottom:.75rem">Ezek a második meghajtón lévő másolatok olyan mappákhoz tartoznak, amelyek most nem érhetők el: a meghajtó nincs csatlakoztatva, már nincs a gépen, vagy a mappa hiányzik. Megőrizzük őket, amíg te el nem távolítod őket.</p>
<div class="app-row-list">
<div class="app-row">
<img class="app-row-icon" src="/static/assets/-logo.svg" alt="" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<div class="app-row-text">
<span class="app-row-name">tandoor</span>
<span class="app-row-secondary">belso · 150 MB · 2026. 10. 25. 09:00</span>
</div>
<div class="app-row-actions">
<form method="POST" action="/backup/kept/restore" style="display:inline">
<input type="hidden" name="id" value="fedcba9876543210">
<input type="hidden" name="confirm" value="1">
<button type="button" class="btn btn-xs btn-outline"
onclick="confirmRestoreKeptUnit(this)">Visszaállítás</button>
</form>
<form method="POST" action="/backup/kept/remove" style="display:inline">
<input type="hidden" name="id" value="fedcba9876543210">
<input type="hidden" name="confirm" value="1">
<button type="button" class="btn btn-xs btn-danger-outline"
onclick="confirmRemoveKeptCopy(this)">Eltávolítás</button>
</form>
</div>
</div>
</div>
<script>
function confirmRestoreKeptUnit(btn){
felhomConfirm(btn, 'Ezzel a korábbi telepítés mentését töltjük vissza az alkalmazásba. A mostani adatai helyére ez kerül. Folytatod?', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
}
function confirmRemoveKeptCopy(btn){
felhomConfirm(btn, 'Biztosan eltávolítod ezt a megőrzött másolatot? A második meghajtóról véglegesen törlődik.', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
}
</script>
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">Meglévő ellenőrző másolatok</h3>
<p class="form-hint">Nincs ellenőrző másolat a meghajtón.</p>
</div>
<div class="backup-section-card">
<h3>Hordozható mentéscsomag (.fab)</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">Hordozható pillanatfelvétel — bárhol tárolhatod, és bármikor visszatöltheted egy meghajtóról. A folyamatos védelmet az 1–3. szintű mentés adja.</p>
<p class="form-hint">Nincs telepített alkalmazás.</p>
</div>
<script>
(function(){
var banner = document.getElementById('restore-banner');
if (!banner) return;
var sawRunning = false;
function opLabel(op){ return op === 'tier2-restore' ? 'Fájl-visszaállítás'
: op === 'offbox-restore' ? 'Távoli visszaállítás' : 'Visszaállítás'; }
function render(st){
if (st.running) {
sawRunning = true;
banner.className = 'flash';
banner.style.display = 'block';
banner.textContent = opLabel(st.op) + ' folyamatban' + (st.stack ? ': ' + st.stack : '') + '…';
return;
}
if (st.last && (sawRunning || st.last_recent)) {
banner.style.display = 'block';
if (st.last.ok) {
banner.className = 'flash flash-success';
banner.textContent = st.last.message || (opLabel(st.last.op) + ' kész.');
} else {
banner.className = 'flash flash-error';
banner.textContent = st.last.message || (opLabel(st.last.op) + ' sikertelen.');
}
}
}
function poll(){
fetch('/api/backup/restore-status', {headers: {'Accept':'application/json'}})
.then(function(r){ return r.json(); })
.then(function(j){ if (j && j.data) render(j.data); })
.catch(function(){});
}
poll();
setInterval(poll, 3000);
})();
var fabQueue = [];
function fabSetStatus(msg){ document.getElementById('fab-dl-status').textContent = msg; }
function fabPassword(){ var el = document.getElementById('fab-dl-password'); return el ? el.value : ''; }
function fabDownload(btn){
var stack = btn.getAttribute('data-stack');
fetch('/api/export/download/estimate?stack=' + encodeURIComponent(stack))
.then(function(r){ return r.json(); })
.then(function(j){
if (!j.ok) { fabSetStatus('Hiba: ' + (j.error || 'a becslés sikertelen')); return; }
var size = (j.data && j.data.total_size_human) || '?';
felhomConfirm(btn, 'A csomag becsült mérete: ' + size + '. Elindítod a letöltést? (Pillanatfelvétel a mostani állapotról.)', function(){
fabStart(stack, null);
});
})
.catch(function(){ fabSetStatus('Hiba: a becslés nem érhető el.'); });
}
function confirmDeleteVerifyCopy(btn){
var path = btn.getAttribute('data-copy-path') || '';
felhomConfirm(btn, 'Biztosan törlöd ezt az ellenőrző másolatot? ' + path + ' — a tényleges adataid változatlanok maradnak.', function(){
felhomConfirm(btn, 'UTOLSÓ MEGERŐSÍTÉS: a másolat véglegesen törlődik.', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
});
}
function fabStart(stack, next){
fetch('/api/export/download/start', {method:'POST', headers:Object.assign({'Content-Type':'application/json'}, csrfHeaders()), body: JSON.stringify({stack_name: stack, password: fabPassword()})})
.then(function(r){ return r.json(); })
.then(function(j){
if (!j.ok) { fabSetStatus('Hiba (' + stack + '): ' + (j.error || 'az export nem indult el')); if (next) next(); return; }
fabSetStatus('Csomag készítése: ' + stack + '…');
fabPoll(stack, next);
})
.catch(function(){ fabSetStatus('Hiba: az export nem indult el.'); if (next) next(); });
}
function fabPoll(stack, next){
var iv = setInterval(function(){
fetch('/api/export/status').then(function(r){ return r.json(); }).then(function(j){
if (!j || j.running || !j.done) return;
clearInterval(iv);
if (j.error) { fabSetStatus('Hiba (' + stack + '): ' + j.error); if (next) next(); return; }
var base = (j.output_path || '').split('/').pop();
if (!base) { fabSetStatus('Hiba: a csomag útvonala hiányzik.'); if (next) next(); return; }
fabSetStatus('Letöltés: ' + base + (j.output_size ? ' (' + j.output_size + ')' : ''));
window.location.href = '/api/export/download?file=' + encodeURIComponent(base);
if (next) setTimeout(next, 3000);
}).catch(function(){});
}, 2000);
}
function fabDownloadAll(){
var btns = document.querySelectorAll('.fab-dl-btn');
fabQueue = Array.prototype.map.call(btns, function(b){ return b.getAttribute('data-stack'); });
fabRunQueue();
}
function fabRunQueue(){
var stack = fabQueue.shift();
if (!stack) { fabSetStatus('Minden csomag elkészült.'); return; }
fabStart(stack, fabRunQueue);
}
var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek', 'szombat'];
function formatSnapshot(s) {
var t = new Date(s.time);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var label = fhIsoDate(t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate())) +
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
' (' + s.short_id + ')';
var tierLabel = s.tier === 2 ? '2. szint' : '1. szint';
if (s.drive_label) {
label += ' — ' + tierLabel + ', ' + s.drive_label;
} else {
label += ' — ' + tierLabel;
}
return label;
}
function onRestoreAppChange() {
var sel = document.getElementById('restore-app');
var appName = sel.value;
var snapSel = document.getElementById('restore-snapshot');
var noSnaps = document.getElementById('restore-no-snapshots');
var typeInfo = document.getElementById('restore-type-info');
document.getElementById('restore-confirm-cb').checked = false;
document.getElementById('restore-btn').disabled = true;
noSnaps.style.display = 'none';
typeInfo.style.display = 'none';
if (!appName) {
snapSel.innerHTML = '<option value="">— Válassz alkalmazást —</option>';
return;
}
var opt = sel.options[sel.selectedIndex];
var hasHDD = opt.getAttribute('data-has-hdd') === 'true';
var hasDB = opt.getAttribute('data-has-db') === 'true';
var hasVolumes = opt.getAttribute('data-has-volumes') === 'true';
if (hasHDD || hasVolumes) {
typeInfo.innerHTML = 'Teljes visszaállítás: adatbázis + konfiguráció + felhasználói adatok a kiválasztott pillanatképből.';
typeInfo.className = 'restore-info';
} else if (hasDB) {
typeInfo.innerHTML = 'Adatbázis és konfiguráció visszaállítása — az alkalmazásnak nincs külön felhasználói adata.';
typeInfo.className = 'restore-info restore-info-partial';
} else {
typeInfo.innerHTML = 'Csak konfiguráció visszaállítása (compose fájlok, beállítások).';
typeInfo.className = 'restore-info restore-info-partial';
}
typeInfo.style.display = 'block';
snapSel.innerHTML = '<option value="">— Betöltés... —</option>';
fetch('/api/backup/snapshots?stack=' + encodeURIComponent(appName))
.then(function(r) { return r.json(); })
.then(function(data) {
snapSel.innerHTML = '<option value="">— Válassz —</option>';
if (data.ok && data.data && data.data.length > 0) {
var tier1 = data.data.filter(function(s) { return s.tier !== 2; });
var tier2 = data.data.filter(function(s) { return s.tier === 2; });
if (tier1.length > 0) {
var grp1 = document.createElement('optgroup');
grp1.label = '1. szint — Helyi mentés (ajánlott)';
tier1.forEach(function(s) {
var o = document.createElement('option');
o.value = s.short_id;
o.textContent = formatSnapshot(s);
grp1.appendChild(o);
});
snapSel.appendChild(grp1);
}
if (tier2.length > 0) {
var grp2 = document.createElement('optgroup');
grp2.label = '2. szint — Másodlagos másolat';
tier2.forEach(function(s) {
var o = document.createElement('option');
o.value = s.short_id;
o.textContent = formatSnapshot(s);
grp2.appendChild(o);
});
snapSel.appendChild(grp2);
}
} else {
snapSel.innerHTML = '<option value="">— Nincs elérhető mentés —</option>';
noSnaps.style.display = 'block';
}
});
}
function onRestoreConfirmChange() {
var cb = document.getElementById('restore-confirm-cb');
var app = document.getElementById('restore-app').value;
var snap = document.getElementById('restore-snapshot').value;
document.getElementById('restore-btn').disabled = !(cb.checked && app && snap);
}
function submitRestore() {
var app = document.getElementById('restore-app').value;
var snap = document.getElementById('restore-snapshot').value;
if (!app || !snap) return;
var btn = document.getElementById('restore-btn');
btn.disabled = true;
btn.textContent = 'Visszaállítás folyamatban...';
var form = document.createElement('form');
form.method = 'POST';
form.action = '/backup/restore';
var fc = document.createElement('input');
fc.type = 'hidden'; fc.name = '_csrf';
fc.value = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
form.appendChild(fc);
var f1 = document.createElement('input');
f1.type = 'hidden'; f1.name = 'stack_name'; f1.value = app;
form.appendChild(f1);
var f2 = document.createElement('input');
f2.type = 'hidden'; f2.name = 'snapshot_id'; f2.value = snap;
form.appendChild(f2);
document.body.appendChild(form);
form.submit();
}
</script>
</main>
<script>
(function(){
var burger=document.querySelector('.nav-burger');
var sidebar=document.getElementById('sidebar');
var backdrop=document.querySelector('.nav-backdrop');
if(!burger||!sidebar||!backdrop)return;
function setOpen(open){
sidebar.classList.toggle('is-open',open);
document.body.classList.toggle('nav-open',open);
backdrop.hidden=!open;
burger.setAttribute('aria-expanded',open?'true':'false');
}
burger.addEventListener('click',function(){setOpen(!sidebar.classList.contains('is-open'));});
backdrop.addEventListener('click',function(){setOpen(false);});
document.addEventListener('keydown',function(e){
if(e.key==='Escape'&&sidebar.classList.contains('is-open'))setOpen(false);
});
})();
document.addEventListener('click', function(e) {
if (e.target.closest('a, button, .btn, input, select, textarea, .app-row-actions, .stack-detail-actions')) return;
var card = e.target.closest('[data-href]');
if (card) window.location.href = card.dataset.href;
});
async function checkBeforeDeploy(e, name) {
try {
var resp = await fetch('/api/stacks/' + name);
var data = await resp.json();
if (data.ok && data.data && data.data.deployed) {
e.preventDefault();
showAlert('Ez az alkalmazás már telepítve van.');
window.location.reload();
return false;
}
} catch(err) {}
return true;
}
async function syncTemplates() {
const btn = document.getElementById('sync-btn');
const toast = document.getElementById('sync-toast');
if (!btn) return;
const origText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '↻ Frissítés...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/sync', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (toast) {
toast.textContent = data.ok ? (data.message || 'Sablonok frissítve') : ('Hiba: ' + (data.error || 'Ismeretlen hiba'));
toast.className = 'sync-toast ' + (data.ok ? 'sync-toast-ok' : 'sync-toast-err');
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
if (data.ok && data.data && (data.data.new_apps && data.data.new_apps.length > 0 || data.data.updated && data.data.updated.length > 0)) {
setTimeout(function() { window.location.reload(); }, 1500);
}
} catch (err) {
if (toast) {
toast.textContent = 'Hálózati hiba: ' + err.message;
toast.className = 'sync-toast sync-toast-err';
toast.style.display = 'block';
setTimeout(function() { toast.style.display = 'none'; }, 5000);
}
}
btn.innerHTML = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
async function stackAction(event, name, action) {
const btn = event.currentTarget;
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Folyamatban...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/stacks/' + name + '/' + action, {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders())
});
const data = await resp.json();
if (!data.ok) {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
return;
}
if (action === 'update') {
followUpdate(name, btn);
return;
}
window.location.reload();
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
}
function followUpdate(name, btn) {
var started = Date.now();
var timer = setInterval(async function() {
if (Date.now() - started > 30 * 60 * 1000) { clearInterval(timer); window.location.reload(); return; }
try {
var r = await fetch('/api/stacks/' + name);
var d = await r.json();
if (!d.ok || !d.data) return;
if (d.data.update_phase_label) btn.textContent = d.data.update_phase_label;
if (!d.data.updating) { clearInterval(timer); window.location.reload(); }
} catch (e) {}
}, 3000);
}
async function deleteOrphanStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'delete-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeDeleteModal(); });
document.body.appendChild(modal);
try {
var resp = await fetch('/api/stacks/' + name + '/hdd-data');
var data = await resp.json();
var hddInfo = '';
var checkboxHTML = '';
if (data.ok && data.data && data.data.has_hdd_data) {
hddInfo = '<div class="modal-hdd-info"><strong>Felhasználói adatok a merevlemezen:</strong>';
data.data.hdd_paths.forEach(function(p) {
hddInfo += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
});
hddInfo += '</div>';
checkboxHTML = '<label class="modal-checkbox"><input type="checkbox" id="delete-hdd-check"> Felhasználói adatok törlése a merevlemezről</label>';
}
if (data.ok && data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
var udPaths = '';
data.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
hddInfo += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás törlése: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Ez a művelet eltávolítja a konténereket, a köteteket és a konfigurációs fájlokat.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
hddInfo + checkboxHTML +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeDeleteModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-delete-btn" onclick="confirmDelete(\'' + name + '\')">Törlés</button>' +
'</div>';
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeDeleteModal()">Bezárás</button></div>';
}
}
function closeDeleteModal() {
var modal = document.getElementById('delete-modal');
if (modal) modal.remove();
}
async function confirmDelete(name) {
var btn = document.getElementById('confirm-delete-btn');
var checkbox = document.getElementById('delete-hdd-check');
var removeHDD = checkbox ? checkbox.checked : false;
btn.disabled = true;
btn.textContent = 'Törlés folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name, {
method: 'DELETE',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('delete-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen törölve!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') törölve lett.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Törlés';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Törlés';
}
}
async function removeStack(name) {
var modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.id = 'remove-modal';
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
document.body.appendChild(modal);
try {
var [hddResp, backupResp] = await Promise.all([
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
]);
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">' +
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
'<li>Telepítési konfiguráció (app.yaml)</li>' +
'<li>Másodlagos mentés ütemezése</li>' +
'</ul></div>';
var hddCheckbox = '';
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>';
});
sections += '<div class="modal-section">' +
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
'Ha újratelepíted az alkalmazást, az adatokat újra importálnod kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
'</div></div>';
hddCheckbox = '\x3Cscript>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
}
var backupCheckbox = '';
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>';
});
if (bkPaths) {
sections += '<div class="modal-section">' +
'<strong>Mentési adatok:</strong>' + bkPaths +
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
'</div></div>';
}
}
if (hddResp.ok && hddResp.data && hddResp.data.userdata_kept && hddResp.data.userdata_kept.length > 0) {
var udPaths = '';
hddResp.data.userdata_kept.forEach(function(p) { udPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>'; });
sections += '<div class="modal-section" data-userdata-kept="true"><strong>A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket:</strong>' + udPaths + '</div>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
'<p style="color:var(--text-2);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
sections +
'<div class="modal-actions">' +
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
'</div>' + hddCheckbox;
} catch (err) {
modal.querySelector('.modal-card').innerHTML =
'<h3>Hiba</h3><p style="color:var(--text-2)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
}
}
function closeRemoveModal() {
var modal = document.getElementById('remove-modal');
if (modal) modal.remove();
}
async function confirmRemoveStack(name) {
var btn = document.getElementById('confirm-remove-btn');
var hddCheck = document.getElementById('remove-hdd-check');
var backupCheck = document.getElementById('remove-backup-check');
var removeHDD = hddCheck ? hddCheck.checked : false;
var removeBackups = backupCheck ? backupCheck.checked : false;
btn.disabled = true;
btn.textContent = 'Eltávolítás folyamatban...';
try {
var resp = await fetch('/api/stacks/' + name + '/remove', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
});
var data = await resp.json();
if (data.ok) {
var modal = document.getElementById('remove-modal');
var removedInfo = '';
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
}
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
removedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
}
var preservedInfo = '';
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
preservedInfo = '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
}
if (data.data && data.data.hdd_note) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">' + data.data.hdd_note + '</p>';
}
if (data.data && data.data.userdata_kept && data.data.userdata_kept.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem" data-userdata-kept="true">A fájljaid ezekben a mappákban megmaradnak — a fájlböngészőben látod őket: ' + data.data.userdata_kept.join(', ') + '</p>';
}
if (data.data && data.data.backup_paths_refused && data.data.backup_paths_refused.length > 0) {
preservedInfo += '<p style="color:var(--text-2);font-size:.85rem;margin-top:.5rem">Nem törölt mentések: ' + data.data.backup_paths_refused.join('; ') + '</p>';
}
modal.querySelector('.modal-card').innerHTML =
'<h3>Sikeresen eltávolítva!</h3>' +
'<p style="color:var(--text-2)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
removedInfo + preservedInfo +
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
} else {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.disabled = false;
btn.textContent = 'Eltávolítás';
}
}
</script>
</body>
</html>
+3 -1
View File
@@ -320,7 +320,9 @@
"note.offsite.fail_apps": "BORN AS A KEY (W25, rehearsal word table, 2026-10-21) -- the household's sentence after a failed off-site run, built from the failure classifier (07 §6.7) and the run's result; the raw error stays in the log and the operator's event. A NEW sentence, never a Go literal. Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold.",
"note.offsite.others_ok": "BORN AS A KEY (W25, rehearsal word table, 2026-10-21) -- the household's sentence after a failed off-site run, built from the failure classifier (07 §6.7) and the run's result; the raw error stays in the log and the operator's event. A NEW sentence, never a Go literal. Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold.",
"note.offsite.retry_next_night": "BORN AS A KEY (W25, rehearsal word table, 2026-10-21) -- the household's sentence after a failed off-site run, built from the failure classifier (07 §6.7) and the run's result; the raw error stays in the log and the operator's event. A NEW sentence, never a Go literal. Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold.",
"note.offsite.shares_name": "BORN AS A KEY (W25, rehearsal word table, 2026-10-21) -- the household's sentence after a failed off-site run, built from the failure classifier (07 §6.7) and the run's result; the raw error stays in the log and the operator's event. A NEW sentence, never a Go literal. Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold."
"note.offsite.shares_name": "BORN AS A KEY (W25, rehearsal word table, 2026-10-21) -- the household's sentence after a failed off-site run, built from the failure classifier (07 §6.7) and the run's result; the raw error stays in the log and the operator's event. A NEW sentence, never a Go literal. Pinned by TestW25_FailedOffsiteRunSpeaksToTheHousehold.",
"flash.kept.restore_needs_confirmation": "BORN AS A KEY (D47, operator ruling 2026-10-25; R-1269) -- the unit of an earlier install is set aside whole and restorable from the restore page; its confirm flash and the second-drive note when it cannot be set aside. Pinned by internal/web/kept_copies_test.go TestD47_* and internal/backup/unit_owner_test.go.",
"note.tier2.unit_other_install_kept": "BORN AS A KEY (D47, operator ruling 2026-10-25; R-1269) -- the unit of an earlier install is set aside whole and restorable from the restore page; its confirm flash and the second-drive note when it cannot be set aside. Pinned by internal/web/kept_copies_test.go TestD47_* and internal/backup/unit_owner_test.go."
},
"flash.share.already_on": "A megosztás már be van kapcsolva.",
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",