D62: restore copies kept 30 days, listed with their date, deletable by hand (typed name, never a needed copy, operator event); D63: a test-build-only fault switch for the R-1256 H4/H5 live proofs — tests red first
gates / gates (push) Successful in 33s

This commit is contained in:
admin committed 2026-10-29 08:50:59 +01:00
1 parent a815b37fbd
commit e3fce09d96
24 files changed
+1992 -11

No files matched your search

@@ -0,0 +1,185 @@
package backup
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// D62 (operator ruling 2026-10-29): an unused restore copy is kept 30 days; the household sees each copy (app, the
// restore's date, size, the day it goes) and may delete one earlier. A copy a hold or a pending put-back needs is never
// deleted, by the sweep or by hand.
// RED-PROOF: with the age at 14 days the 20-day copy is swept → FAILS; at 30 days only the 31-day copy goes.
func TestD62_AnUnusedCopyIsKept30Days(t *testing.T) {
m, _ := newOffboxManager(t)
now := time.Now()
day29 := r1256Copy(t, m, "a", 29*24*time.Hour, now)
day20 := r1256Copy(t, m, "b", 20*24*time.Hour, now)
day31 := r1256Copy(t, m, "c", 31*24*time.Hour, now)
got := m.SweepUnusedRestoreCopies(now)
if len(got) != 1 || got[0] != day31 {
t.Fatalf("swept %v — want only the 31-day copy %s", got, day31)
}
if !r1256Exists(day29) || !r1256Exists(day20) {
t.Fatal("a copy younger than 30 days was swept")
}
if restoreCopyUnusedAge != 30*24*time.Hour {
t.Fatalf("restoreCopyUnusedAge = %s, D62 says 30 days", restoreCopyUnusedAge)
}
}
// The list shows each copy with its restore date, its size and the day it goes; a needed copy has no such day and
// says why. RED-PROOF: drop the hold check from restoreCopyNeededFor → the held copy is offered → FAILS.
func TestD62_TheListShowsDateSizeAndWhyACopyIsNeeded(t *testing.T) {
m, sett := newOffboxManager(t)
now := time.Now().UTC().Truncate(time.Second)
free := r1256Copy(t, m, "free", 2*24*time.Hour, now)
r1256Copy(t, m, "held", 2*24*time.Hour, now)
r1256Copy(t, m, "waiting", 2*24*time.Hour, now)
r1256Copy(t, m, "named", 2*24*time.Hour, now)
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "held", At: now.Format(time.RFC3339), Reason: settings.HoldReasonRestoreMixed}); err != nil {
t.Fatal(err)
}
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "waiting", At: now.Format(time.RFC3339), Reason: settings.HoldReasonPutBackPending}); err != nil {
t.Fatal(err)
}
if err := m.appStop.Begin("unit-restore:named", ReasonUnitRestore, []string{"named"}); err != nil {
t.Fatal(err)
}
list := m.RestoreCopies()
if len(list) != 4 {
t.Fatalf("listed %d copies, want 4: %+v", len(list), list)
}
want := map[string]string{"free": "", "held": RestoreCopyNeededHold, "waiting": RestoreCopyNeededPutBack, "named": RestoreCopyNeededPutBack}
for _, c := range list {
if c.Needed != want[c.App] {
t.Errorf("%s: needed %q, want %q", c.App, c.Needed, want[c.App])
}
if !c.At.Equal(now.Add(-2 * 24 * time.Hour)) {
t.Errorf("%s: restore date %s, want the stamp's %s", c.App, c.At, now.Add(-2*24*time.Hour))
}
if c.SizeBytes != int64(len("LIVE")) {
t.Errorf("%s: size %d", c.App, c.SizeBytes)
}
if c.Needed == "" && !c.DeleteOn.Equal(c.At.Add(30*24*time.Hour)) {
t.Errorf("%s: deleted on %s, want 30 days after the restore", c.App, c.DeleteOn)
}
if c.Needed != "" && !c.DeleteOn.IsZero() {
t.Errorf("%s: a needed copy shows a delete day %s — the sweep never takes it", c.App, c.DeleteOn)
}
if c.App == "free" && c.Path != free {
t.Errorf("path %s, want %s", c.Path, free)
}
}
}
// A held or waiting copy cannot be deleted by hand. RED-PROOF: drop the Needed check in DeleteRestoreCopy → FAILS.
func TestD62_AHeldCopyCannotBeDeleted(t *testing.T) {
m, sett := newOffboxManager(t)
now := time.Now()
for _, tc := range []struct{ app, reason string }{
{"held", settings.HoldReasonRestoreMixed},
{"waiting", settings.HoldReasonPutBackPending},
{"failed", settings.HoldReasonRestoreFailed},
} {
dir := r1256Copy(t, m, tc.app, time.Hour, now)
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: tc.app, At: now.Format(time.RFC3339), Reason: tc.reason}); err != nil {
t.Fatal(err)
}
if _, err := m.DeleteRestoreCopy(tc.app, filepath.Base(dir)); !errors.Is(err, ErrRestoreCopyNeeded) {
t.Errorf("%s: err %v, want ErrRestoreCopyNeeded", tc.app, err)
}
if !r1256Exists(dir) {
t.Errorf("%s: a held copy was deleted", tc.app)
}
}
// a copy a cut restore names (the marker) is needed too
named := r1256Copy(t, m, "named", time.Hour, now)
if err := m.appStop.Begin("unit-restore:named", ReasonUnitRestore, []string{"named"}); err != nil {
t.Fatal(err)
}
if _, err := m.DeleteRestoreCopy("named", filepath.Base(named)); !errors.Is(err, ErrRestoreCopyNeeded) || !r1256Exists(named) {
t.Errorf("a copy the marker names: err %v, exists %v", err, r1256Exists(named))
}
// an unreadable marker offers nothing
mustWrite(t, m.appStop.path, "{not json")
free := r1256Copy(t, m, "free", time.Hour, now)
if _, err := m.DeleteRestoreCopy("free", filepath.Base(free)); !errors.Is(err, ErrRestoreCopyNeeded) || !r1256Exists(free) {
t.Errorf("an unreadable marker: err %v, exists %v", err, r1256Exists(free))
}
}
// A delete removes ONLY that copy: the app's other copy, another app's copy and anything outside the copy tree stay;
// a name that is not one path element, or a link in the copy's place, is refused.
// RED-PROOF: remove the validCopyName check → "../.." reaches outside → FAILS; remove the Lstat link check → the
// link is followed/removed → FAILS.
func TestD62_ADeleteRemovesOnlyThatCopy(t *testing.T) {
m, _ := newOffboxManager(t)
now := time.Now()
one := r1256Copy(t, m, "app", 3*time.Hour, now)
two := r1256Copy(t, m, "app", 2*time.Hour, now)
other := r1256Copy(t, m, "other", 3*time.Hour, now)
got, err := m.DeleteRestoreCopy("app", filepath.Base(one))
if err != nil {
t.Fatal(err)
}
if got.Path != one || got.SizeBytes != int64(len("LIVE")) || got.App != "app" {
t.Fatalf("returned %+v", got)
}
if r1256Exists(one) || !r1256Exists(two) || !r1256Exists(other) {
t.Fatalf("one gone=%v, two kept=%v, other kept=%v", !r1256Exists(one), r1256Exists(two), r1256Exists(other))
}
// path guard
outside := filepath.Join(m.namespaceRoot(m.systemDataPath), "backups", "primary", "keep.txt")
mustWrite(t, outside, "UNIT")
for _, bad := range [][2]string{{"app", ".."}, {"..", "primary"}, {"app", "../../primary"}, {"", "x"}, {"app", ""}, {"app/x", "y"}} {
if _, err := m.DeleteRestoreCopy(bad[0], bad[1]); !errors.Is(err, ErrRestoreCopyNotFound) {
t.Errorf("DeleteRestoreCopy(%q,%q) = %v, want not found", bad[0], bad[1], err)
}
}
if !r1256Exists(outside) || !r1256Exists(two) {
t.Fatal("a bad name reached outside the copy")
}
// a link in a copy's place is not a copy
target := t.TempDir()
mustWrite(t, filepath.Join(target, "precious"), "X")
link := filepath.Join(filepath.Dir(two), "20260101T000000Z")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if _, err := m.DeleteRestoreCopy("app", "20260101T000000Z"); !errors.Is(err, ErrRestoreCopyNotFound) {
t.Errorf("a link: %v, want not found", err)
}
if !r1256Exists(filepath.Join(target, "precious")) {
t.Fatal("the link's target was removed")
}
if _, err := m.DeleteRestoreCopy("app", "20990101T000000Z"); !errors.Is(err, ErrRestoreCopyNotFound) {
t.Errorf("a missing copy: %v", err)
}
}
// While a backup or restore runs, a delete is refused and nothing moves. RED-PROOF: drop the busy check → FAILS.
func TestD62_NoDeleteWhileAnOperationRuns(t *testing.T) {
m, _ := newOffboxManager(t)
c := r1256Copy(t, m, "app", time.Hour, time.Now())
m.BeginRestoreOp("restore", "other")
if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); !errors.Is(err, ErrRestoreCopyBusy) || !r1256Exists(c) {
t.Fatalf("during a restore: err %v, exists %v", err, r1256Exists(c))
}
m.EndRestoreOp(true, "ok")
if err := m.acquireRunning(); err != nil {
t.Fatalf("could not take the backup single-flight: %v", err)
}
if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); !errors.Is(err, ErrRestoreCopyBusy) || !r1256Exists(c) {
t.Fatalf("during a backup run: err %v, exists %v", err, r1256Exists(c))
}
m.ReleaseRunningForTest()
if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); err != nil || r1256Exists(c) {
t.Fatalf("control: with nothing running the delete goes through: %v", err)
}
}
@@ -300,7 +300,7 @@ func r1256Exists(p string) bool { _, err := os.Stat(p); return err == nil }
func TestR1256_H8_OnlyAnUnusedOldCopyIsRemoved(t *testing.T) {
m, sett := newOffboxManager(t)
now := time.Now()
old := 20 * 24 * time.Hour
old := 40 * 24 * time.Hour
unused := r1256Copy(t, m, "unused", old, now)
young := r1256Copy(t, m, "young", 24*time.Hour, now)
held := r1256Copy(t, m, "held", old, now)
@@ -339,7 +339,7 @@ func TestR1256_H8_OnlyAnUnusedOldCopyIsRemoved(t *testing.T) {
func TestR1256_H8_NothingIsRemovedDuringARestoreOrWithAnUnreadableMarker(t *testing.T) {
m, _ := newOffboxManager(t)
now := time.Now()
c := r1256Copy(t, m, "app", 20*24*time.Hour, now)
c := r1256Copy(t, m, "app", 40*24*time.Hour, now)
m.BeginRestoreOp("restore", "other")
if got := m.SweepUnusedRestoreCopies(now); len(got) != 0 || !r1256Exists(c) {
t.Fatalf("removed %v during a restore", got)
@@ -0,0 +1,182 @@
package backup
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// D62 (operator ruling 2026-10-29; `07` §6.3): the household sees its restore copies in the kept-copies list
// („Megőrzött másolatok") on the restore page — the app, the date of the restore the copy protects, its size, and the
// day the box deletes it by itself (restoreCopyUnusedAge after the restore) — and may delete one earlier with
// „Törlés most", after a warning and the app's name typed.
//
// A copy a hold or a pending put-back needs is NEVER deletable (the same three states the sweep leaves alone, R-1256
// H8): the row says why in one line and the button is off; the handler refuses it too. While any backup or restore
// operation runs on the box, a delete is refused (409). The check and the removal are ONE step under m.mu, so a
// restore cannot begin in between (the R-1256 H6 shape). Pinned by TestD62_*.
// Why a restore copy is needed — the row's one line, and the refusal's reason. "" = not needed: deletable.
const (
RestoreCopyNeededHold = "hold" // the app is held (a failed put-back keeps its copy, D20) — support decides
RestoreCopyNeededPutBack = "putback" // a put-back waits for its drive (R-1251) or a cut restore names it (D22)
RestoreCopyNeededUnknown = "unknown" // the app-stop marker cannot be read: nothing is offered (fail closed)
)
var (
ErrRestoreCopyNotFound = errors.New("restore copy not found")
ErrRestoreCopyNeeded = errors.New("restore copy is needed by a hold or a pending put-back")
ErrRestoreCopyBusy = errors.New("a backup or restore operation runs")
)
// RestoreCopy is one restore copy (<namespace>/backups/restore-undo/<app>/<stamp>).
type RestoreCopy struct {
App string
Stamp string // the folder's name; with App, the copy's identity on the page
At time.Time // when the restore began (the stamp; the folder's time when the stamp does not parse)
SizeBytes int64
Path string
Needed string // "" or one of RestoreCopyNeeded*
DeleteOn time.Time // when the sweep removes it; zero when Needed != "" (a needed copy is never swept)
}
// restoreCopyAt is a copy's restore time: its stamp, or the folder's time when the stamp does not parse.
func restoreCopyAt(dir, name string) (time.Time, bool) {
if at, err := time.Parse(restoreCopyStampLayout, name); err == nil {
return at, true
}
fi, err := os.Stat(dir)
if err != nil {
return time.Time{}, false
}
return fi.ModTime(), true
}
// restoreCopyNeededFor is why app's copies are needed ("" = deletable). named is appStopNamedApps' answer, ok its ok.
func (m *Manager) restoreCopyNeededFor(app string, named map[string]bool, ok bool) string {
if !ok || m.settings == nil {
return RestoreCopyNeededUnknown
}
if named[app] {
return RestoreCopyNeededPutBack
}
if h, held := m.settings.GetRestoreHold(app); held {
if h.Reason == settings.HoldReasonPutBackPending {
return RestoreCopyNeededPutBack
}
return RestoreCopyNeededHold
}
return ""
}
func dirSize(dir string) int64 {
var n int64
_ = filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
if err == nil && d.Type().IsRegular() {
if fi, e := d.Info(); e == nil {
n += fi.Size()
}
}
return nil
})
return n
}
// RestoreCopies lists every restore copy on the box's drives, oldest first.
func (m *Manager) RestoreCopies() []RestoreCopy {
named, ok := m.appStopNamedApps()
var out []RestoreCopy
for _, nsRoot := range m.primaryUnitRoots() {
base := filepath.Join(nsRoot, "backups", "restore-undo")
apps, err := os.ReadDir(base)
if err != nil {
continue
}
for _, a := range apps {
if !a.IsDir() {
continue
}
app := a.Name()
needed := m.restoreCopyNeededFor(app, named, ok)
copies, err := os.ReadDir(filepath.Join(base, app))
if err != nil {
continue
}
for _, c := range copies {
if !c.IsDir() {
continue
}
dir := filepath.Join(base, app, c.Name())
at, ok := restoreCopyAt(dir, c.Name())
if !ok {
continue
}
rc := RestoreCopy{App: app, Stamp: c.Name(), At: at, SizeBytes: dirSize(dir), Path: dir, Needed: needed}
if needed == "" {
rc.DeleteOn = at.Add(restoreCopyUnusedAge)
}
out = append(out, rc)
}
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
return out
}
// validCopyName is a single path element: no separator, not empty, not "." or "..".
func validCopyName(s string) bool {
return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, `/\`) && filepath.Base(s) == s
}
// DeleteRestoreCopy removes ONE restore copy, named by its app and its folder's name, and nothing else (the path
// guard: both names are single path elements, the folder is a real directory — not a link — directly under
// <namespace>/backups/restore-undo/<app>/). Refused while any backup or restore operation runs (ErrRestoreCopyBusy)
// and for a copy a hold or a pending put-back needs (ErrRestoreCopyNeeded). The check and the removal hold m.mu, so
// no restore can begin in between. Returns the copy as it was (for the log line and the operator event).
func (m *Manager) DeleteRestoreCopy(app, stamp string) (RestoreCopy, error) {
if !validCopyName(app) || !validCopyName(stamp) {
return RestoreCopy{}, ErrRestoreCopyNotFound
}
m.mu.Lock()
defer m.mu.Unlock()
if busy, _ := m.restoreBusyLocked(); busy != "" {
return RestoreCopy{}, ErrRestoreCopyBusy
}
var found *RestoreCopy
for _, nsRoot := range m.primaryUnitRoots() {
appDir := filepath.Join(nsRoot, "backups", "restore-undo", app)
dir := filepath.Join(appDir, stamp)
fi, err := os.Lstat(dir)
if err != nil || !fi.IsDir() || fi.Mode()&os.ModeSymlink != 0 {
continue
}
if filepath.Dir(dir) != appDir || filepath.Dir(appDir) != filepath.Join(nsRoot, "backups", "restore-undo") {
continue
}
at, ok := restoreCopyAt(dir, stamp)
if !ok {
continue
}
found = &RestoreCopy{App: app, Stamp: stamp, At: at, SizeBytes: dirSize(dir), Path: dir}
break
}
if found == nil {
return RestoreCopy{}, ErrRestoreCopyNotFound
}
named, ok := m.appStopNamedApps()
if found.Needed = m.restoreCopyNeededFor(app, named, ok); found.Needed != "" {
return *found, ErrRestoreCopyNeeded
}
if err := os.RemoveAll(found.Path); err != nil {
return *found, fmt.Errorf("removing the restore copy %s: %w", found.Path, err)
}
_ = os.Remove(filepath.Dir(found.Path)) // the per-app folder, when it is now empty
return *found, nil
}
@@ -15,7 +15,8 @@ import (
// stable start (R-1270). Every one of those is still needed. A copy outside all of them — the app runs again, or was
// removed, and nothing names the copy — is disk the household cannot see or use.
//
// The rule (safest reading, decided in the 2026-10-28 night run; operator may change the age): a copy is removed only
// The rule (the age: operator ruling D62, 2026-10-29 — 30 days, and the household may delete a copy earlier with
// „Törlés most", restore_copies.go): a copy is removed only
// when ALL of these hold, and the sweep removes NOTHING when it cannot answer one of them:
// - it is older than restoreCopyUnusedAge (its folder's stamp; the folder's time when the stamp does not parse);
// - the app has no restore hold of any kind (held apps keep every copy — support decides);
@@ -24,8 +25,8 @@ import (
//
// Run every 6 hours from main.go (TestR1256_H8_TheSweepIsWiredAtStartup). Pinned by TestR1256_H8_*.
// restoreCopyUnusedAge is how long an unused copy stays for support after its restore.
const restoreCopyUnusedAge = 14 * 24 * time.Hour
// restoreCopyUnusedAge is how long an unused copy stays after its restore (D62: 30 days; TestD62_AnUnusedCopyIsKept30Days).
const restoreCopyUnusedAge = 30 * 24 * time.Hour
// restoreCopyStampLayout is copyLiveVolumes' folder name.
const restoreCopyStampLayout = "20060102T150405Z"
+13
View File
@@ -2634,6 +2634,19 @@
"kept.delete.type": "To delete it, type: %s",
"kept.delete.mismatch": "The name you typed does not match (%s), so nothing was deleted.",
"kept.deleted": "The kept data of %s is deleted.",
"restore_copy.title": "Copies from before a restore",
"restore_copy.hint": "When you restore an app, the box first saves the app’s data as it was. It deletes that copy by itself 30 days after the restore.",
"restore_copy.restored_on": "restore on %s",
"restore_copy.auto_delete": "automatically deleted on %s",
"restore_copy.needed.hold": "Cannot be deleted: the app is stopped, and this copy is its way back. Support decides about it.",
"restore_copy.needed.putback": "Cannot be deleted: an interrupted restore puts the data back from this copy.",
"restore_copy.needed.unknown": "Cannot be deleted now: the box cannot tell whether this copy is still needed.",
"restore_copy.delete": "Delete now",
"restore_copy.warning": "This is the copy of %s’s data from before the restore on %s. If you delete it, the restore can no longer be undone, and this copy cannot be brought back.",
"restore_copy.app_busy": "Cannot be deleted now: an operation runs on the app. Try again when it has ended.",
"restore_copy.not_found": "This copy is no longer there, so nothing was deleted.",
"restore_copy.failed": "The copy could not be deleted: %s",
"flash.restore_copy.deleted": "The copy of %s from before its restore is deleted.",
"kept.page.title": "Kept data",
"kept.page.back": "Back",
"kept.page.intro": "Data that removed apps left on the drives. You can look at it, load it into a new install, or delete it. The server never deletes any of it by itself.",
+13
View File
@@ -2622,6 +2622,19 @@
"kept.delete.type": "A törléshez írd be: %s",
"kept.delete.mismatch": "A beírt név nem egyezik (%s), ezért nem töröltünk semmit.",
"kept.deleted": "A(z) %s megőrzött adatai törölve.",
"restore_copy.title": "Visszaállítás előtti másolatok",
"restore_copy.hint": "Visszaállításkor a doboz előbb elmenti az alkalmazás akkori adatait. Ezt a másolatot a visszaállítás után 30 nappal magától törli.",
"restore_copy.restored_on": "visszaállítás: %s",
"restore_copy.auto_delete": "automatikusan törlődik: %s",
"restore_copy.needed.hold": "Nem törölhető: az alkalmazás le van állítva, és ez a másolat az út vissza. Az ügyfélszolgálat dönt róla.",
"restore_copy.needed.putback": "Nem törölhető: egy félbeszakadt visszaállítás ebből a másolatból teszi vissza az adatokat.",
"restore_copy.needed.unknown": "Most nem törölhető: a doboz nem tudja eldönteni, kell-e még ez a másolat.",
"restore_copy.delete": "Törlés most",
"restore_copy.warning": "Ez a(z) %s adatainak másolata a %s-i visszaállítás előttről. Ha törlöd, a visszaállítás többé nem vonható vissza, és ez a másolat nem hozható vissza.",
"restore_copy.app_busy": "Most nem törölhető: az alkalmazáson éppen egy művelet fut. Próbáld újra, ha véget ért.",
"restore_copy.not_found": "Ez a másolat már nincs meg, ezért nem töröltünk semmit.",
"restore_copy.failed": "A másolatot nem sikerült törölni: %s",
"flash.restore_copy.deleted": "A(z) %s visszaállítás előtti másolata törölve.",
"kept.page.title": "Megőrzött adatok",
"kept.page.back": "Vissza",
"kept.page.intro": "Az eltávolított alkalmazások lemezen maradt adatai. Megnézheted őket, betöltheted egy új telepítésbe, vagy törölheted. Magától a szerver soha nem töröl közülük semmit.",
@@ -0,0 +1,34 @@
package notify
import (
"encoding/json"
"strings"
"testing"
)
// D62 — restore_copy_deleted: info (the hub's vocabulary), the operator message only (no household sentence — the hub
// bars the type from customers too), and the details carry who, app, the restore's date and the size.
func TestD62_RestoreCopyDeletedEvent(t *testing.T) {
n := &Notifier{}
var et, sev, msg, cust string
var det []byte
n.pushFn = func(eventType, severity, message, customer string, details interface{}) {
et, sev, msg, cust = eventType, severity, message, customer
det, _ = json.Marshal(details)
}
n.NotifyRestoreCopyDeleted(RestoreCopyDeletedDetails{App: "vikunja", RestoreAt: "2026-10-29T08:00:00Z", SizeBytes: 4096,
Who: "the household (dashboard sign-in) from 192.168.0.5", Path: "/x/backups/restore-undo/vikunja/20261029T080000Z"})
if et != "restore_copy_deleted" || sev != "info" || cust != "" {
t.Fatalf("type=%q severity=%q customer=%q", et, sev, cust)
}
for _, want := range []string{"vikunja", "2026-10-29T08:00:00Z", "4096", "the household"} {
if !strings.Contains(msg, want) {
t.Fatalf("message %q lacks %q", msg, want)
}
}
for _, want := range []string{`"app":"vikunja"`, `"restore_at":"2026-10-29T08:00:00Z"`, `"size_bytes":4096`, `"who":"the household`} {
if !strings.Contains(string(det), want) {
t.Fatalf("details %s lack %s", det, want)
}
}
}
+19
View File
@@ -1585,6 +1585,25 @@ func (n *Notifier) NotifyAppDefinitionRefused(app, detail string) {
n.pushEventBoth("app_definition_refused", "warning", msg, "", AppDefinitionRefusedDetails{App: app, Refused: detail})
}
// RestoreCopyDeletedDetails is restore_copy_deleted's record: who deleted which app's copy, the restore it protected,
// and its size.
type RestoreCopyDeletedDetails struct {
App string `json:"app"`
RestoreAt string `json:"restore_at"` // RFC 3339, UTC
SizeBytes int64 `json:"size_bytes"`
Who string `json:"who"`
Path string `json:"path"`
}
// NotifyRestoreCopyDeleted (D62, operator 2026-10-29): the household deleted a restore copy with „Törlés most" before
// its 30 days. Info, operator-only on the hub (registered in both of its lists, hub D62): an audit line for support,
// never a household mail. Pinned by TestD62_RestoreCopyDeletedEvent.
func (n *Notifier) NotifyRestoreCopyDeleted(d RestoreCopyDeletedDetails) {
msg := fmt.Sprintf("%s: the restore copy from before the restore of %s (%d bytes) was deleted by hand — %s. That restore can no longer be undone from this box.",
d.App, d.RestoreAt, d.SizeBytes, d.Who)
n.pushEventBoth("restore_copy_deleted", "info", msg, "", d)
}
// healthSeverity is the event severity a health status would be sent at (R-647): the disabled path
// names what it drops, and "warn" is a health STATUS, not a severity the hub knows.
func healthSeverity(status string) string {
@@ -0,0 +1,233 @@
package web
import (
"encoding/json"
"html/template"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// D62 (operator ruling 2026-10-29): restore copies in the kept-copies list with „Törlés most". Real settings, manager,
// disk and a hub stand-in that records the event — the handler is the endpoint the page's form calls.
type d62Fixture struct {
s *Server
sett *settings.Settings
nsRoot string
events chan map[string]interface{}
}
func newD62Fixture(t *testing.T) *d62Fixture {
t.Helper()
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)
}
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
cfg.Paths.SystemDataPath = filepath.Join(tmp, "sys")
events := make(chan map[string]interface{}, 8)
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/event" {
var ev map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&ev)
events <- ev
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(hub.Close)
n := notify.New(hub.URL, "test-key", "c1", sett, lg, false)
s := &Server{cfg: cfg, settings: sett, backupMgr: backup.NewManager(cfg, sett, lg), notifier: n, logger: lg}
return &d62Fixture{s: s, sett: sett, nsRoot: backup.NamespaceRootFor(cfg.Paths.SystemDataPath, cfg.Paths.SystemDataPath), events: events}
}
// plant writes one restore copy of app taken at `at`; returns its folder.
func (f *d62Fixture) plant(t *testing.T, app string, at time.Time) string {
t.Helper()
dir := filepath.Join(backup.RestoreUndoDir(f.nsRoot, app), at.UTC().Format("20060102T150405Z"))
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, app+"_data.tar"), []byte("LIVE-DATA"), 0o600); err != nil {
t.Fatal(err)
}
return dir
}
func (f *d62Fixture) post(form string) (int, map[string]interface{}) {
rr := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/backup/restore-copy/delete", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = "192.168.0.5:5555"
f.s.restoreCopyDeleteHandler(rr, req)
var body map[string]interface{}
_ = json.Unmarshal(rr.Body.Bytes(), &body)
return rr.Code, body
}
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
// The list shows the restore's date, the size and „automatikusan törlődik: <30 days later>"; a held copy shows why and
// offers no warning. RED-PROOF: drop the auto-delete part from restoreCopyRows → FAILS.
func TestD62_TheListShowsTheDateAndTheAutoDeleteDay(t *testing.T) {
f := newD62Fixture(t)
at := time.Date(2026, 10, 29, 8, 0, 0, 0, time.UTC)
f.plant(t, "vikunja", at)
f.plant(t, "immich", at)
if err := f.sett.SetRestoreHold(settings.RestoreHold{Stack: "immich", At: at.Format(time.RFC3339), Reason: settings.HoldReasonRestoreMixed}); err != nil {
t.Fatal(err)
}
rows := f.s.restoreCopyRows("hu")
if len(rows) != 2 {
t.Fatalf("rows %+v", rows)
}
var free, held restoreCopyRow
for _, r := range rows {
if r.App == "vikunja" {
free = r
} else {
held = r
}
}
for _, want := range []string{"visszaállítás: 2026. 10. 29. 09:00", "9 B", "automatikusan törlődik: 2026. 11. 28."} {
if !strings.Contains(free.Line, want) {
t.Errorf("free row line %q lacks %q", free.Line, want)
}
}
if !strings.Contains(free.Warning, "Ez a(z) vikunja adatainak másolata a 2026. 10. 29-i visszaállítás előttről") ||
!strings.Contains(free.Warning, "a visszaállítás többé nem vonható vissza, és ez a másolat nem hozható vissza") {
t.Errorf("warning %q", free.Warning)
}
if free.NeededText != "" || free.TypePrompt == "" {
t.Errorf("free row: needed %q prompt %q", free.NeededText, free.TypePrompt)
}
if held.NeededText == "" || held.Warning != "" || strings.Contains(held.Line, "automatikusan") {
t.Errorf("held row offers a delete or an auto-delete day: %+v", held)
}
en := f.s.restoreCopyRows("en")
for _, r := range en {
if r.App == "vikunja" && (!strings.Contains(r.Line, "automatically deleted on 2026-11-28") ||
!strings.Contains(r.Warning, "This is the copy of vikunja’s data from before the restore on 2026-10-29. If you delete it, the restore can no longer be undone, and this copy cannot be brought back.")) {
t.Errorf("English row %+v", r)
}
}
}
// The page: a deletable copy has the form (app, stamp, the typed name, CSRF); a held one has the button OFF and the
// reason, and no form. RED-PROOF: render the form for a needed copy too → FAILS.
func TestD62_ThePageOffersTheFormOnlyForADeletableCopy(t *testing.T) {
d := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
d["Backup"] = i18nBackupStatus(false, false)
d["RestoreCopies"] = []restoreCopyRow{
{App: "vikunja", Stamp: "20261029T080000Z", Name: "Vikunja", Line: "L1", Warning: "W1", TypePrompt: "P1"},
{App: "immich", Stamp: "20261020T080000Z", Name: "Immich", Line: "L2", NeededText: "HELD-REASON"},
}
d["CSRFField"] = template.HTML(`<input type="hidden" name="_csrf" value="TOKEN-D62">`)
html := renderBackupPage(t, "backups_restore", d)
for _, want := range []string{"Visszaállítás előtti másolatok", `action="/backup/restore-copy/delete"`, `name="app" value="vikunja"`,
`name="stamp" value="20261029T080000Z"`, `name="confirm"`, "W1", "P1", "Törlés most", `data-restore-copy-off="true"`, "HELD-REASON"} {
if !strings.Contains(html, want) {
t.Errorf("page lacks %q", want)
}
}
if strings.Contains(html, `name="app" value="immich"`) || strings.Contains(html, `data-restore-copy="immich/`) {
t.Error("a held copy got a delete form")
}
form := html[strings.Index(html, `action="/backup/restore-copy/delete"`):]
if !strings.Contains(form[:strings.Index(form, "</form>")], `value="TOKEN-D62"`) {
t.Error("the delete form carries no CSRF field")
}
none := i18nLayoutData("backups-restore", "Visszaállítás a mentésből")
none["Backup"] = i18nBackupStatus(false, false)
if html := renderBackupPage(t, "backups_restore", none); strings.Contains(html, "Megőrzött másolatok") || strings.Contains(html, "restore-copy") {
t.Error("a page with no copy shows the section")
}
}
// A wrong typed name is refused (400) and nothing is deleted. RED-PROOF: drop the name check → FAILS.
func TestD62_AWrongNameIsRefused(t *testing.T) {
f := newD62Fixture(t)
c := f.plant(t, "vikunja", time.Now().Add(-time.Hour))
for _, typed := range []string{"", "vikunj", "immich", "VIKUNJA "} {
code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=" + typed)
if code != http.StatusBadRequest || body["ok"] != false || !exists(c) {
t.Fatalf("typed %q: code %d body %v exists %v", typed, code, body, exists(c))
}
}
}
// A held copy is refused by the handler too (409, the reason), and stays. RED-PROOF: drop the Needed check in the
// handler AND in DeleteRestoreCopy → FAILS (either alone holds it).
func TestD62_AHeldCopyIsRefusedByTheHandler(t *testing.T) {
f := newD62Fixture(t)
c := f.plant(t, "immich", time.Now().Add(-time.Hour))
if err := f.sett.SetRestoreHold(settings.RestoreHold{Stack: "immich", At: time.Now().Format(time.RFC3339), Reason: settings.HoldReasonPutBackPending}); err != nil {
t.Fatal(err)
}
code, body := f.post("app=immich&stamp=" + filepath.Base(c) + "&confirm=immich")
if code != http.StatusConflict || !exists(c) || !strings.Contains(body["error"].(string), "félbeszakadt visszaállítás") {
t.Fatalf("code %d body %v exists %v", code, body, exists(c))
}
}
// While a restore runs, a delete is 409 with a sentence, and nothing moves. RED-PROOF: drop the busy check → FAILS.
func TestD62_NoDeleteWhileAnOperationRuns(t *testing.T) {
f := newD62Fixture(t)
c := f.plant(t, "vikunja", time.Now().Add(-time.Hour))
f.s.backupMgr.BeginRestoreOp("restore", "other")
code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja")
if code != http.StatusConflict || !exists(c) || body["error"] == "" {
t.Fatalf("code %d body %v exists %v", code, body, exists(c))
}
f.s.backupMgr.EndRestoreOp(true, "ok")
}
// The right name: the copy is gone, ONLY it (the app's other copy and another app's copy stay), the answer leads back
// to the page with the flash, and the operator event carries who, app, the restore's date and size.
// RED-PROOF: drop the NotifyRestoreCopyDeleted call → no event → FAILS.
func TestD62_ADeleteRemovesOnlyThatCopyAndStoresTheEvent(t *testing.T) {
f := newD62Fixture(t)
at := time.Date(2026, 10, 29, 8, 0, 0, 0, time.UTC)
c := f.plant(t, "vikunja", at)
sibling := f.plant(t, "vikunja", at.Add(time.Hour))
other := f.plant(t, "immich", at)
code, body := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja")
if code != http.StatusOK || body["ok"] != true || !strings.Contains(body["redirect"].(string), "flash.restore_copy.deleted") {
t.Fatalf("code %d body %v", code, body)
}
if exists(c) || !exists(sibling) || !exists(other) {
t.Fatalf("gone=%v sibling=%v other=%v", !exists(c), exists(sibling), exists(other))
}
select {
case ev := <-f.events:
if ev["event_type"] != "restore_copy_deleted" || ev["severity"] != "info" {
t.Fatalf("event %v", ev)
}
det, _ := json.Marshal(ev["details"])
for _, want := range []string{`"app":"vikunja"`, `"restore_at":"2026-10-29T08:00:00Z"`, `"size_bytes":9`, `"who":"the household (dashboard sign-in) from 192.168.0.5"`} {
if !strings.Contains(string(det), want) {
t.Errorf("details %s lack %s", det, want)
}
}
case <-time.After(5 * time.Second):
t.Fatal("no restore_copy_deleted event reached the hub")
}
// a second press: the copy is gone → 404, no second event
if code, _ := f.post("app=vikunja&stamp=" + filepath.Base(c) + "&confirm=vikunja"); code != http.StatusNotFound {
t.Fatalf("second press code %d", code)
}
}
+1
View File
@@ -1346,6 +1346,7 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
// D25: second-drive copies whose source is gone — kept until the household removes one.
rows := s.keptCopyRows(s.langFor(r))
data["KeptCopies"] = rows
data["RestoreCopies"] = s.restoreCopyRows(s.langFor(r)) // D62: restore copies in the same list
for _, k := range rows {
if k.Restorable {
data["KeptUnitRestorable"] = true // D47: the confirm script only when a row offers „Visszaállítás"
@@ -255,6 +255,21 @@ func i18nCasesB() []i18nCase {
d["KeptUnitRestorable"] = true
return d
}},
// D62 (2026-10-29): restore copies in the kept-copies list — one deletable (the warning and the typed name), one a
// hold needs (the button off, the reason). A NEW case on a box without off-site (the second call site), so no
// existing fixture changes. Line, Warning, TypePrompt and NeededText are composed in Go in the request's language;
// the fixture carries the same data in both renders.
{"backups_restore_restore_copies", "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"] = false
d["RestoreCopies"] = []restoreCopyRow{
{App: "vikunja", Stamp: "20261029T080000Z", Name: "Vikunja", Line: "2026-10-29 09:00 · 12 MB · 2026-11-28",
Warning: "Vikunja 2026-10-29", TypePrompt: "Vikunja"},
{App: "immich", Stamp: "20261020T080000Z", Name: "Immich Photos", Line: "2026-10-20 10:00 · 2 GB", NeededText: "hold: support decides"},
}
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)
+135
View File
@@ -1,12 +1,16 @@
package web
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
)
// D25 (operator ruling 2026-10-21; R-1175 + R-1181): the restore page lists the second-drive copies whose source is
@@ -121,3 +125,134 @@ func (s *Server) keptUnitRestoreHandler(w http.ResponseWriter, r *http.Request)
}()
http.Redirect(w, r, "/backups/restore?"+flashQuery("flash", "flash.restore.started"), http.StatusFound)
}
// ── D62 (operator ruling 2026-10-29): restore copies in the same list ───────────────────────────────────────────────
//
// Each restore copy shows the app, the date of the restore it protects, its size and the day the box deletes it by
// itself (30 days after the restore). „Törlés most" deletes it earlier: a warning that names the app and the date,
// and the app's name typed. A copy a hold or a pending put-back needs shows why in one line and has no button — and
// the handler refuses it too. Pinned by TestD62_* (web).
// restoreCopyRow is one restore copy on the page.
type restoreCopyRow struct {
App string
Stamp string
Name string // the app's display name — what the household types
Line string // „visszaállítás: <date> · <size> · automatikusan törlődik: <date>"
NeededText string // why it cannot be deleted; "" = the button is offered
Warning string // the confirm step's sentence (app and date)
TypePrompt string
}
// restoreCopyAppName is the app's display name, or its slug when the app is gone.
func (s *Server) restoreCopyAppName(app string) string {
if s.stackMgr != nil {
if st, ok := s.stackMgr.GetStack(app); ok && st.Meta.DisplayName != "" {
return st.Meta.DisplayName
}
}
return app
}
func (s *Server) restoreCopyRows(lang string) []restoreCopyRow {
if s.backupMgr == nil {
return nil
}
var out []restoreCopyRow
for _, c := range s.backupMgr.RestoreCopies() {
name := s.restoreCopyAppName(c.App)
at := c.At.In(getTimezone())
line := s.msgLang(lang, "restore_copy.restored_on", fmtLocalTime(lang, c.At)) + " · " +
i18n.LocalizeDecimals(lang, backup.HumanizeBytes(c.SizeBytes))
row := restoreCopyRow{App: c.App, Stamp: c.Stamp, Name: name}
if c.Needed == "" {
line += " · " + s.msgLang(lang, "restore_copy.auto_delete", i18n.FormatDate(lang, c.DeleteOn.In(getTimezone())))
row.Warning = s.msgLang(lang, "restore_copy.warning", name, strings.TrimSuffix(i18n.FormatDate(lang, at), "."))
row.TypePrompt = s.msgLang(lang, "kept.delete.type", name)
} else {
row.NeededText = s.msgLang(lang, "restore_copy.needed."+c.Needed)
}
row.Line = line
out = append(out, row)
}
return out
}
// restoreCopyJSON is the delete's answer: ok with the page to go to, or the refusal's sentence.
func restoreCopyJSON(w http.ResponseWriter, code int, ok bool, msg, redirect string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": ok, "error": msg, "redirect": redirect})
}
// restoreCopyDeleteHandler is „Törlés most": POST /backup/restore-copy/delete (app, stamp, confirm). JSON answer:
// 200 deleted; 400 the typed name does not match; 404 no such copy; 409 the copy is needed, or an operation runs on
// the box or the app; 500 the removal failed. CSRF as every state-changing form (CsrfProtect).
func (s *Server) restoreCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
lang := s.langFor(r)
if s.backupMgr == nil {
restoreCopyJSON(w, http.StatusServiceUnavailable, false, s.msgLang(lang, "flash.offbox.mgr_unreachable"), "")
return
}
_ = r.ParseForm()
app, stamp := strings.TrimSpace(r.FormValue("app")), strings.TrimSpace(r.FormValue("stamp"))
var found *backup.RestoreCopy
for _, c := range s.backupMgr.RestoreCopies() {
if c.App == app && c.Stamp == stamp {
c := c
found = &c
break
}
}
if found == nil {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED: %q/%q is not a listed copy (from %s)", app, stamp, clientIP(r))
restoreCopyJSON(w, http.StatusNotFound, false, s.msgLang(lang, "restore_copy.not_found"), "")
return
}
if found.Needed != "" {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: the copy is needed (%s)", found.Path, found.Needed)
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.needed."+found.Needed), "")
return
}
name := s.restoreCopyAppName(app)
if strings.TrimSpace(r.FormValue("confirm")) != name {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: the typed confirmation did not match", found.Path)
restoreCopyJSON(w, http.StatusBadRequest, false, s.msgLang(lang, "kept.delete.mismatch", name), "")
return
}
if s.stackMgr != nil && s.stackMgr.IsUpdating(app) {
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: an update of %s runs", found.Path, app)
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.app_busy"), "")
return
}
who := "the household (dashboard sign-in) from " + clientIP(r)
c, err := s.backupMgr.DeleteRestoreCopy(app, stamp)
switch {
case errors.Is(err, backup.ErrRestoreCopyBusy):
busy, mirror := s.backupMgr.RestoreBusy()
msg, blocked := s.restoreBusyMessage(busy, mirror)
if !blocked {
msg = s.msgLang(lang, "restore_copy.app_busy")
}
s.logger.Printf("[WARN] [web] restore-copy delete REFUSED for %s: a backup or restore operation runs", found.Path)
restoreCopyJSON(w, http.StatusConflict, false, msg, "")
return
case errors.Is(err, backup.ErrRestoreCopyNeeded):
restoreCopyJSON(w, http.StatusConflict, false, s.msgLang(lang, "restore_copy.needed."+c.Needed), "")
return
case errors.Is(err, backup.ErrRestoreCopyNotFound):
restoreCopyJSON(w, http.StatusNotFound, false, s.msgLang(lang, "restore_copy.not_found"), "")
return
case err != nil:
s.logger.Printf("[ERROR] [web] restore-copy delete of %s failed: %v", found.Path, err)
restoreCopyJSON(w, http.StatusInternalServerError, false, s.msgLang(lang, "restore_copy.failed", err.Error()), "")
return
}
restoreAt := c.At.UTC().Format(time.RFC3339)
s.logger.Printf("[WARN] [web] restore copy DELETED by hand (D62): app=%s restore_at=%s size=%d path=%s who=%s",
app, restoreAt, c.SizeBytes, c.Path, who)
if s.notifier != nil {
s.notifier.NotifyRestoreCopyDeleted(notify.RestoreCopyDeletedDetails{App: app, RestoreAt: restoreAt, SizeBytes: c.SizeBytes, Who: who, Path: c.Path})
}
restoreCopyJSON(w, http.StatusOK, true, "", "/backups/restore?"+flashQuery("flash", "flash.restore_copy.deleted", name))
}
+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)
// D62 (2026-10-29): „Törlés most" of ONE restore copy (typed name; never a copy a hold or a put-back needs).
case path == "/backup/restore-copy/delete" && r.Method == http.MethodPost:
s.restoreCopyDeleteHandler(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)
@@ -429,9 +429,9 @@ function submitRestore() {
{{/* D25 + D47: the kept copies (second-drive copies whose source is gone; set-aside units of an earlier install). Shown
inside the off-site card when the box has off-site, and after it when it has none — before 2026-10-25 it sat only inside
the off-site block, so a box without off-site never listed its kept copies. One body, two call sites. */}}
{{define "kept_copies_section"}}{{if .KeptCopies}}
{{define "kept_copies_section"}}{{if or .KeptCopies .RestoreCopies}}
<div class="backup-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">{{T "backups_restore.megorzott_masolatok"}}</h3>
<h3 style="margin-bottom:.5rem">{{T "backups_restore.megorzott_masolatok"}}</h3>{{if .KeptCopies}}
<p class="form-hint" style="margin-bottom:.75rem">{{T "backups_restore.megorzott_masolatok_hint"}}</p>
<div class="app-row-list">
{{range .KeptCopies}}
@@ -467,4 +467,43 @@ the off-site block, so a box without off-site never listed its kept copies. One
});
}
</script>
{{end}}{{end}}
{{end}}{{if .RestoreCopies}}{{/* D62 (operator ruling 2026-10-29): restore copies — the app, the restore's date, the size, the day the box deletes
it; „Törlés most" after the warning and the app's name typed. A needed copy: the button is off, the reason in one line. */}}
<h4 style="margin:1rem 0 .5rem">{{T "restore_copy.title"}}</h4>
<p class="form-hint" style="margin-bottom:.75rem">{{T "restore_copy.hint"}}</p>
<div class="app-row-list" data-restore-copies="true">
{{range .RestoreCopies}}
{{template "app_list_row" dict "Slug" .App "Name" .Name "Secondary" .Line}}{{if .NeededText}}
<button type="button" class="btn btn-xs btn-danger-outline" disabled data-restore-copy-off="true">{{T "restore_copy.delete"}}</button>{{end}}
{{template "app_list_row_end"}}{{if .NeededText}}
<p class="form-hint" data-restore-copy-needed="true">{{.NeededText}}</p>{{else}}
<details class="kept-delete" data-restore-copy="{{.App}}/{{.Stamp}}">
<summary class="btn btn-xs btn-danger-outline">{{T "restore_copy.delete"}}</summary>
<form method="POST" action="/backup/restore-copy/delete" class="kept-delete-form" onsubmit="return deleteRestoreCopy(event, this)">
{{$.CSRFField}}
<input type="hidden" name="app" value="{{.App}}">
<input type="hidden" name="stamp" value="{{.Stamp}}">
<p class="kept-delete-text">{{.Warning}}</p>
<label class="form-hint">{{.TypePrompt}}</label>
<input type="text" name="confirm" autocomplete="off" required>
<button type="submit" class="btn btn-sm btn-danger">{{T "restore_copy.delete"}}</button>
<p class="kept-delete-text" data-restore-copy-error="true" hidden></p>
</form>
</details>{{end}}
{{end}}
</div>
<script>
function deleteRestoreCopy(ev, f){
ev.preventDefault();
var err = f.querySelector('[data-restore-copy-error]');
fetch(f.action, {method: 'POST', headers: Object.assign({'Accept': 'application/json'}, csrfHeaders()), body: new URLSearchParams(new FormData(f))})
.then(function(r){ return r.json(); })
.then(function(d){
if (d.ok) { location.href = d.redirect; return; }
err.textContent = d.error || ''; err.hidden = false;
})
.catch(function(e){ err.textContent = '{{T "layout.halozati_hiba"}} ' + e; err.hidden = false; });
return false;
}
</script>
{{end}}{{end}}{{end}}
@@ -0,0 +1,915 @@
<!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-tier-divider" style="margin-top:1.5rem"></div>
<h3 style="margin-bottom:.5rem">Megőrzött másolatok</h3>
<h4 style="margin:1rem 0 .5rem">Visszaállítás előtti másolatok</h4>
<p class="form-hint" style="margin-bottom:.75rem">Visszaállításkor a doboz előbb elmenti az alkalmazás akkori adatait. Ezt a másolatot a visszaállítás után 30 nappal magától törli.</p>
<div class="app-row-list" data-restore-copies="true">
<div class="app-row">
<img class="app-row-icon" src="/static/assets/vikunja-logo.svg" alt="" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/vikunja-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">Vikunja</span>
<span class="app-row-secondary">2026-10-29 09:00 · 12 MB · 2026-11-28</span>
</div>
<div class="app-row-actions">
</div>
</div>
<details class="kept-delete" data-restore-copy="vikunja/20261029T080000Z">
<summary class="btn btn-xs btn-danger-outline">Törlés most</summary>
<form method="POST" action="/backup/restore-copy/delete" class="kept-delete-form" onsubmit="return deleteRestoreCopy(event, this)">
<input type="hidden" name="app" value="vikunja">
<input type="hidden" name="stamp" value="20261029T080000Z">
<p class="kept-delete-text">Vikunja 2026-10-29</p>
<label class="form-hint">Vikunja</label>
<input type="text" name="confirm" autocomplete="off" required>
<button type="submit" class="btn btn-sm btn-danger">Törlés most</button>
<p class="kept-delete-text" data-restore-copy-error="true" hidden></p>
</form>
</details>
<div class="app-row">
<img class="app-row-icon" src="/static/assets/immich-logo.svg" alt="" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/immich-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">Immich Photos</span>
<span class="app-row-secondary">2026-10-20 10:00 · 2 GB</span>
</div>
<div class="app-row-actions">
<button type="button" class="btn btn-xs btn-danger-outline" disabled data-restore-copy-off="true">Törlés most</button>
</div>
</div>
<p class="form-hint" data-restore-copy-needed="true">hold: support decides</p>
</div>
<script>
function deleteRestoreCopy(ev, f){
ev.preventDefault();
var err = f.querySelector('[data-restore-copy-error]');
fetch(f.action, {method: 'POST', headers: Object.assign({'Accept': 'application/json'}, csrfHeaders()), body: new URLSearchParams(new FormData(f))})
.then(function(r){ return r.json(); })
.then(function(d){
if (d.ok) { location.href = d.redirect; return; }
err.textContent = d.error || ''; err.hidden = false;
})
.catch(function(e){ err.textContent = 'Hálózati hiba: ' + e; err.hidden = false; });
return false;
}
</script>
<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>