v0.283.0: apps go off-site by themselves (decision 50) with a size warning; a Stop holds during a backup (R-721); page slips (R-724/R-725)
gates / gates (push) Successful in 25s
gates / gates (push) Successful in 25s
Red-proofs RP31-RP38. MinAgent 0.131.0 (unchanged). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -117,6 +117,10 @@ type Manager struct {
|
||||
// offboxSizer (3a) — the mandatory-set byte estimator for the pre-push enlargement gate, overridable
|
||||
// in tests so the gate is unit-testable without a real du. Nil → the real dirSizeBytes (du -sb).
|
||||
offboxSizer func(path string) int64
|
||||
// offsiteFit (decision 50, v0.283.0) — the last off-site size estimate against the quota, for the page.
|
||||
offsiteFitMu sync.Mutex
|
||||
offsiteFit OffsiteFit
|
||||
offsiteFitRunning bool
|
||||
// offboxNow (v0.206.0, R-241) is the abandonment countdown's clock. Nil → time.Now.
|
||||
//
|
||||
// IT EXISTS SO THE TERMINAL STEP IS TESTABLE WITHOUT SHORTENING A LIVE TIMER (§7.4). The sweep is
|
||||
@@ -958,6 +962,12 @@ func (m *Manager) DumpAppVolumesSafe(stackName string) error {
|
||||
|
||||
dumpErr := m.DumpAppVolumes(stackName)
|
||||
|
||||
if hs, ok := m.stackProvider.(interface{ WantsStopped(string) bool }); ok && hs.WantsStopped(stackName) {
|
||||
// R-721: the household pressed Stop while the dump had the app down — it stays stopped.
|
||||
m.logger.Printf("[INFO] [backup] %s NOT restarted after the volume dump: the household stopped it meanwhile", stackName)
|
||||
m.appStop.End() // nothing is owed a restart — the household wants it stopped
|
||||
return dumpErr
|
||||
}
|
||||
m.logger.Printf("[INFO] [backup] Restarting %s after volume dump", stackName)
|
||||
startErr := m.stackProvider.StartStack(stackName)
|
||||
if startErr != nil {
|
||||
|
||||
@@ -1301,6 +1301,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
|
||||
// Pre-run hygiene: clear any lock restic can prove stale before we start (cheap; the --remove-all
|
||||
// crash-lock escalation lives in resticStep for the locks restic can't self-detect).
|
||||
m.unlockStale(ctx, base, env)
|
||||
m.RefreshOffsiteFit() // decision 50: the page's size estimate is taken before every push
|
||||
var firstErr error
|
||||
for _, stack := range apps {
|
||||
src, ok := m.discoverOffboxUnit(stack)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Decision 50 (2026-09-30, R-720): will the apps fit the off-site quota? Say so BEFORE a push ──────────
|
||||
//
|
||||
// With every new app in the off-site copy by default, a household with a big photo library can select more
|
||||
// than the customer's quota holds. What the box already does is SAFE and stays (measured 2026-09-30):
|
||||
// * over the quota, NEW pushes are refused and only the ruled retention runs (`forget --keep-daily 7
|
||||
// --keep-weekly 4 --keep-monthly 6 --prune`, the same policy as every night — pinned by
|
||||
// TestDecision50_OverQuotaDeletesNothingExtra), so no history is deleted to make room;
|
||||
// * an app whose files would cross the quota is pushed UNIT-ONLY (settings + database), with a warning.
|
||||
// What was missing is the page saying it BEFOREHAND, with names and sizes, so the household chooses which
|
||||
// apps stay off-site. The estimate is taken at the start of every off-site run and, in the background, when
|
||||
// the page is opened and the last one is older than offsiteFitMaxAge; the page only ever reads the cache
|
||||
// (a `du` over a photo library is never run in a page request).
|
||||
|
||||
// OffsiteAppSize is one app's estimated off-site size: its recovery unit plus its mandatory files.
|
||||
type OffsiteAppSize struct {
|
||||
Stack string
|
||||
Bytes int64
|
||||
}
|
||||
|
||||
// OffsiteFit is the estimate the page shows.
|
||||
type OffsiteFit struct {
|
||||
At time.Time
|
||||
Apps []OffsiteAppSize // largest first
|
||||
TotalBytes int64
|
||||
QuotaBytes int64 // 0 = no quota (dedicated box or the household's own NAS) → always fits
|
||||
Fits bool
|
||||
}
|
||||
|
||||
const offsiteFitMaxAge = 6 * time.Hour
|
||||
|
||||
// estimateOffsiteFit is the pure rule: sum the apps' sizes and compare against the quota. An estimate of 0
|
||||
// for an app (no unit yet) counts as 0 — it cannot make the verdict "does not fit" on its own.
|
||||
func estimateOffsiteFit(apps []string, sizeOf func(stack string) int64, quotaGB int, now time.Time) OffsiteFit {
|
||||
f := OffsiteFit{At: now, QuotaBytes: int64(quotaGB) * offboxGiB}
|
||||
for _, a := range apps {
|
||||
b := sizeOf(a)
|
||||
f.Apps = append(f.Apps, OffsiteAppSize{Stack: a, Bytes: b})
|
||||
f.TotalBytes += b
|
||||
}
|
||||
sort.SliceStable(f.Apps, func(i, j int) bool { return f.Apps[i].Bytes > f.Apps[j].Bytes })
|
||||
f.Fits = f.QuotaBytes <= 0 || f.TotalBytes <= f.QuotaBytes
|
||||
return f
|
||||
}
|
||||
|
||||
// offsiteAppBytes: the recovery unit on disk + the mandatory off-site capture set (what a push carries).
|
||||
func (m *Manager) offsiteAppBytes(stack string) int64 {
|
||||
var n int64
|
||||
if src, ok := m.discoverOffboxUnit(stack); ok {
|
||||
n += m.offboxSize()(src)
|
||||
}
|
||||
extra, _, _ := m.offboxCaptureSet(stack)
|
||||
for _, p := range extra {
|
||||
n += m.offboxSize()(p)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// RefreshOffsiteFit measures now (blocking) and caches the result.
|
||||
func (m *Manager) RefreshOffsiteFit() OffsiteFit {
|
||||
t := m.settings.GetOffboxTarget()
|
||||
quota := 0
|
||||
if t != nil {
|
||||
quota = t.QuotaGB
|
||||
}
|
||||
f := estimateOffsiteFit(m.settings.GetOffboxApps(), m.offsiteAppBytes, quota, time.Now())
|
||||
m.offsiteFitMu.Lock()
|
||||
m.offsiteFit = f
|
||||
m.offsiteFitMu.Unlock()
|
||||
if !f.Fits {
|
||||
m.logger.Printf("[WARN] [offbox] the apps selected for off-site (~%s) do not fit the quota (%d GB) — the page asks the household to choose",
|
||||
humanizeBytes(f.TotalBytes), quota)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// OffsiteFitForPage returns the cached estimate and, when it is missing or older than offsiteFitMaxAge,
|
||||
// starts ONE background refresh. The page never waits on a measurement.
|
||||
func (m *Manager) OffsiteFitForPage() OffsiteFit {
|
||||
m.offsiteFitMu.Lock()
|
||||
f := m.offsiteFit
|
||||
stale := f.At.IsZero() || time.Since(f.At) > offsiteFitMaxAge
|
||||
start := stale && !m.offsiteFitRunning
|
||||
if start {
|
||||
m.offsiteFitRunning = true
|
||||
}
|
||||
m.offsiteFitMu.Unlock()
|
||||
if start {
|
||||
go func() {
|
||||
defer func() { m.offsiteFitMu.Lock(); m.offsiteFitRunning = false; m.offsiteFitMu.Unlock() }()
|
||||
m.RefreshOffsiteFit()
|
||||
}()
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// Decision 50 / R-95, measured 2026-09-30: over the quota the box deletes NOTHING beyond the ruled retention.
|
||||
// The consequence asserted: the forget an over-quota run executes carries EXACTLY the retention arguments of a
|
||||
// normal run — no stricter keep, no --keep-last, no extra forget. (If someone "helpfully" made the over-quota
|
||||
// path prune harder to get the household back under quota, it would delete history nobody chose to delete.)
|
||||
// COMPANION RED-PROOF: change offboxPruneOnly's "--keep-daily", "7" to "1" → this test fails with the two
|
||||
// argument lists printed.
|
||||
func TestDecision50_OverQuotaDeletesNothingExtra(t *testing.T) {
|
||||
forgetArgs := func(over bool) []string {
|
||||
m, sett := newOffboxManager(t)
|
||||
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.QuotaGB = 50
|
||||
if over {
|
||||
o.RepoSizeBytes = 51 << 30
|
||||
} else {
|
||||
o.RepoSizeBytes = 1 << 30
|
||||
}
|
||||
})
|
||||
var got []string
|
||||
n := 0
|
||||
m.SetOffboxSizer(func(string) int64 { return 0 })
|
||||
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
||||
switch {
|
||||
case contains(args, "cat") && contains(args, "config"):
|
||||
return []byte(`{}`), nil
|
||||
case contains(args, "forget"):
|
||||
n++
|
||||
for i, a := range args {
|
||||
if a == "forget" {
|
||||
got = append([]string{}, args[i:]...)
|
||||
}
|
||||
}
|
||||
case contains(args, "stats"):
|
||||
return []byte(`{"total_size":123}`), nil
|
||||
case contains(args, "snapshots"):
|
||||
return []byte(`[]`), nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
_ = m.RunOffboxBackup(context.Background())
|
||||
if n != 1 {
|
||||
t.Fatalf("over=%v: expected exactly one forget, got %d", over, n)
|
||||
}
|
||||
return got
|
||||
}
|
||||
normal, over := forgetArgs(false), forgetArgs(true)
|
||||
if strings.Join(normal, " ") != strings.Join(over, " ") {
|
||||
t.Fatalf("the over-quota forget differs from the normal retention — it would delete history nobody chose to delete:\n normal: %v\n over: %v", normal, over)
|
||||
}
|
||||
}
|
||||
|
||||
// The size rule: sum against the quota, largest first; no quota → always fits.
|
||||
// COMPANION RED-PROOF: compare with < instead of <= (or drop the QuotaBytes<=0 arm) → a case fails.
|
||||
func TestDecision50_EstimateOffsiteFit(t *testing.T) {
|
||||
sizes := map[string]int64{"immich": 80 << 30, "nextcloud": 30 << 30, "bookstack": 1 << 20}
|
||||
sizeOf := func(s string) int64 { return sizes[s] }
|
||||
now := time.Now()
|
||||
f := estimateOffsiteFit([]string{"bookstack", "immich", "nextcloud"}, sizeOf, 100, now)
|
||||
if f.Fits {
|
||||
t.Fatalf("110 GiB into 100 GiB reported as fitting: %+v", f)
|
||||
}
|
||||
if f.Apps[0].Stack != "immich" || f.Apps[1].Stack != "nextcloud" {
|
||||
t.Fatalf("largest first expected: %+v", f.Apps)
|
||||
}
|
||||
if g := estimateOffsiteFit([]string{"immich"}, func(string) int64 { return 100 << 30 }, 100, now); !g.Fits {
|
||||
t.Fatal("exactly the quota must fit")
|
||||
}
|
||||
if g := estimateOffsiteFit([]string{"immich"}, sizeOf, 0, now); !g.Fits {
|
||||
t.Fatal("no quota (own NAS / dedicated box) must always fit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package backup
|
||||
|
||||
import "testing"
|
||||
|
||||
// stopDuringDump is the real DumpAppVolumesSafe's provider: the household presses Stop while the dump holds
|
||||
// the app down (between StopStack and the restart).
|
||||
type stopDuringDump struct {
|
||||
suppressWatchProvider
|
||||
wantsStopped bool
|
||||
started int
|
||||
}
|
||||
|
||||
func (p *stopDuringDump) StopStack(n string) error {
|
||||
p.wantsStopped = true // the household's Stop lands during the dump
|
||||
return p.suppressWatchProvider.StopStack(n)
|
||||
}
|
||||
func (p *stopDuringDump) StartStack(n string) error {
|
||||
p.started++
|
||||
return p.suppressWatchProvider.StartStack(n)
|
||||
}
|
||||
func (p *stopDuringDump) WantsStopped(string) bool { return p.wantsStopped }
|
||||
|
||||
// R-721 (v0.283.0): the nightly volume dump does not start an app the household stopped while it was down.
|
||||
// COMPANION RED-PROOF: remove the WantsStopped check in DumpAppVolumesSafe → started=1.
|
||||
func TestR721_VolumeDumpKeepsTheHouseholdsStop(t *testing.T) {
|
||||
p := &stopDuringDump{}
|
||||
m := newSuppressManager(t, &p.suppressWatchProvider)
|
||||
m.stackProvider = p
|
||||
if err := m.DumpAppVolumesSafe("actualbudget"); err != nil {
|
||||
t.Fatalf("DumpAppVolumesSafe: %v", err)
|
||||
}
|
||||
if p.started != 0 {
|
||||
t.Fatalf("the dump started the app %d time(s) after the household stopped it (R-721)", p.started)
|
||||
}
|
||||
// The crash marker is what a restart after a controller crash reads: it must owe nothing. (The short
|
||||
// alarm-grace set is a different thing and stays by design.)
|
||||
if got := m.appStop.HeldStacks(); len(got) != 0 {
|
||||
t.Fatalf("the stop marker still holds %v — the next startup would restart an app the household stopped", got)
|
||||
}
|
||||
}
|
||||
@@ -224,7 +224,7 @@
|
||||
"backups.meret_cel": "Size / target",
|
||||
"backups.naprakesz": "Up to date",
|
||||
"backups.nincs_beallitva": "not set up",
|
||||
"backups.oraja_a_mentesi_ablakon_belul": "{{.AgeHours}} {{if eq .AgeHours 1}}hour{{else}}hours{{end}} ago — within the backup window",
|
||||
"backups.oraja_a_mentesi_ablakon_belul": "the last one was {{.AgeHours}} {{if eq .AgeHours 1}}hour{{else}}hours{{end}} ago — within the backup window",
|
||||
"backups.rendszermentes_teljes_mentes": "System backup (full backup)",
|
||||
"backups.sikertelen": "✗ {{fmtTime .FailedAt}} — failed",
|
||||
"backups.tarhely_attekintes": "Storage overview",
|
||||
@@ -2494,5 +2494,14 @@
|
||||
"app_info.close_signup_btn": "Close sign-up now",
|
||||
"app_info.signup_native_failed": "The app's own sign-up switch could not be closed. The address block still protects it.",
|
||||
"app_info.signup_window_restart": "The app restarts for this, and once more when the 15 minutes end.",
|
||||
"err.setup_gate.close_signup_not_offered": "There is nothing to close on this app."
|
||||
"err.setup_gate.close_signup_not_offered": "There is nothing to close on this app.",
|
||||
"backups_offsite.offer_text": "Some apps have no off-site copy. Turn it on for all of them?",
|
||||
"backups_offsite.offer_all": "Yes, all of them",
|
||||
"backups_offsite.offer_no": "Not now",
|
||||
"backups_offsite.fit_head": "The selected apps do not fit in the off-site storage.",
|
||||
"backups_offsite.fit_total": "Estimated size:",
|
||||
"backups_offsite.fit_quota": "storage limit:",
|
||||
"backups_offsite.fit_largest": "The largest:",
|
||||
"backups_offsite.fit_choose": "Turn off the off-site copy for the apps that do not need one outside the house. Until then the largest get only their settings and database uploaded, and once the limit is full no new off-site backup is made. The box does not delete old backups because of this.",
|
||||
"flash.offbox.enabled_all": "Off-site backup is on for every app."
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
"backups.meret_cel": "Méret / cél",
|
||||
"backups.naprakesz": "Naprakész",
|
||||
"backups.nincs_beallitva": "nincs beállítva",
|
||||
"backups.oraja_a_mentesi_ablakon_belul": "{{.AgeHours}} órája — a mentési ablakon belül",
|
||||
"backups.oraja_a_mentesi_ablakon_belul": "az előző {{.AgeHours}} órája készült — a mentési időn belül",
|
||||
"backups.rendszermentes_teljes_mentes": "Rendszermentés (teljes mentés)",
|
||||
"backups.sikertelen": "✗ {{fmtTime .FailedAt}} — sikertelen",
|
||||
"backups.tarhely_attekintes": "Tárhely áttekintés",
|
||||
@@ -312,17 +312,17 @@
|
||||
"backups_escrow.a_dr_szint_nincs_alkalmazva": "a DR-szint nincs alkalmazva ezen a gépen",
|
||||
"backups_escrow.a_folyamat_a_piros_feltetelek": "A folyamat a piros feltételek teljesüléséig nem indítható.",
|
||||
"backups_escrow.a_folyamat_inditasa_nem_sikerult": "A folyamat indítása nem sikerült.",
|
||||
"backups_escrow.a_folytatashoz_adja_meg_a": "A folytatáshoz adja meg a bejelentkezési jelszavát",
|
||||
"backups_escrow.a_folytatashoz_adja_meg_a": "A folytatáshoz add meg a bejelentkezési jelszavadat",
|
||||
"backups_escrow.a_funkciohoz_az_ugynok_frissitese": "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik. Próbálja újra később.",
|
||||
"backups_escrow.a_funkciohoz_az_ugynok_frissitese_2": "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.",
|
||||
"backups_escrow.a_helyreallitasi_kod_a_mentesei": "A helyreállítási kód a mentései utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer\n sehol nem tárolja, és a Felhom sem ismeri. Ha a szerver megsemmisül, a távoli mentések CSAK\n ezzel a kóddal állíthatók vissza. Írja fel papírra vagy mentse jelszókezelőbe — de NE ezen a\n szerveren tárolja.",
|
||||
"backups_escrow.a_helyreallitasi_kod_a_mentesei": "A helyreállítási kód a mentéseid utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer\n sehol nem tárolja, és a Felhom sem ismeri. Ha a szerver megsemmisül, a távoli mentések CSAK\n ezzel a kóddal állíthatók vissza. Írd fel papírra, vagy mentsd jelszókezelőbe — de NE ezen a\n szerveren tárold.",
|
||||
"backups_escrow.a_kod_lekerese_nem_sikerult": "A kód lekérése nem sikerült.",
|
||||
"backups_escrow.a_kod_letrehozasa_folyamatban_altalaban": "A kód létrehozása folyamatban… (általában néhány másodperc)",
|
||||
"backups_escrow.a_kod_letrejott_de_nem": "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le.\n Indítsa újra a folyamatot: az új kód a régit érvényteleníti.",
|
||||
"backups_escrow.a_kod_pontosan_egyszer_jelenitheto": "A kód pontosan egyszer jeleníthető meg. Készítsen elő papírt és tollat, mielőtt megnyomja a gombot.",
|
||||
"backups_escrow.a_kod_letrejott_de_nem": "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le.\n Indítsd újra a folyamatot: az új kód a régit érvényteleníti.",
|
||||
"backups_escrow.a_kod_pontosan_egyszer_jelenitheto": "A kód pontosan egyszer jelenik meg. Készíts elő papírt és tollat, mielőtt megnyomod a gombot.",
|
||||
"backups_escrow.a_kozponti_feltoltes_nincs_beallitva": "a központi feltöltés nincs beállítva",
|
||||
"backups_escrow.a_letrehozas_nem_sikerult": "A létrehozás nem sikerült.",
|
||||
"backups_escrow.a_megadott_szavak_nem_egyeznek": "A megadott szavak nem egyeznek — ellenőrizze a felírt kódot.",
|
||||
"backups_escrow.a_megadott_szavak_nem_egyeznek": "A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.",
|
||||
"backups_escrow.a_rendszerjogosultsag_hianyzik_az_ugynok": "a rendszerjogosultság hiányzik — az ügynök frissítése szükséges",
|
||||
"backups_escrow.a_titkosito_eszkoz_age_nem": "a titkosító eszköz (age) nem található",
|
||||
"backups_escrow.az_elofeltetelek_ellenorzese_nem_sikerul": "Az előfeltételek ellenőrzése nem sikerült:",
|
||||
@@ -333,7 +333,7 @@
|
||||
"backups_escrow.elokeszitve": "előkészítve",
|
||||
"backups_escrow.elrejtes": "Elrejtés",
|
||||
"backups_escrow.es": ". és",
|
||||
"backups_escrow.ez_mostantol_az_elo_helyreallitasi": "Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentse el most: a kód többé nem jeleníthető meg.",
|
||||
"backups_escrow.ez_mostantol_az_elo_helyreallitasi": "Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentsd el most: a kód többé nem jelenik meg.",
|
||||
"backups_escrow.felirtam_a_kodot_es_biztonsagos": "Felírtam a kódot, és biztonságos helyen — nem ezen a szerveren — tárolom.",
|
||||
"backups_escrow.helyreallitasi_dr_szint_aktiv": "Helyreállítási (DR) szint aktív",
|
||||
"backups_escrow.helyreallitasi_kod": "Helyreállítási kód",
|
||||
@@ -342,8 +342,8 @@
|
||||
"backups_escrow.kod_letrehozasa": "Kód létrehozása",
|
||||
"backups_escrow.kod_megjelenitese": "Kód megjelenítése",
|
||||
"backups_escrow.kozponti_feltoltes_beallitva": "Központi feltöltés beállítva",
|
||||
"backups_escrow.megerosites_irja_be_a_kod": "Megerősítés: írja be a kód szavait",
|
||||
"backups_escrow.megerosites_irja_be_a_kod_2": "Megerősítés: írja be a kód",
|
||||
"backups_escrow.megerosites_irja_be_a_kod": "Megerősítés: írd be a kód szavait",
|
||||
"backups_escrow.megerosites_irja_be_a_kod_2": "Megerősítés: írd be a kód",
|
||||
"backups_escrow.megjelenites": "Megjelenítés",
|
||||
"backups_escrow.megsem": "Mégsem",
|
||||
"backups_escrow.mentesi_tarolo_pbs_beallitva": "Mentési tároló (PBS) beállítva",
|
||||
@@ -2482,5 +2482,14 @@
|
||||
"app_info.close_signup_btn": "Regisztráció lezárása most",
|
||||
"app_info.signup_native_failed": "Az alkalmazás saját regisztrációs kapcsolóját nem sikerült lezárni. A cím zárolása így is véd.",
|
||||
"app_info.signup_window_restart": "Ehhez az alkalmazás újraindul, és a 15 perc végén még egyszer.",
|
||||
"err.setup_gate.close_signup_not_offered": "Ennél az alkalmazásnál nincs mit lezárni."
|
||||
"err.setup_gate.close_signup_not_offered": "Ennél az alkalmazásnál nincs mit lezárni.",
|
||||
"backups_offsite.offer_text": "Van alkalmazás, amelyről nem készül távoli mentés. Bekapcsolod mindegyikre?",
|
||||
"backups_offsite.offer_all": "Igen, mindegyikre",
|
||||
"backups_offsite.offer_no": "Nem most",
|
||||
"backups_offsite.fit_head": "A kijelölt alkalmazások nem férnek el a távoli tárhelyen.",
|
||||
"backups_offsite.fit_total": "Becsült méret:",
|
||||
"backups_offsite.fit_quota": "tárhelykeret:",
|
||||
"backups_offsite.fit_largest": "A legnagyobbak:",
|
||||
"backups_offsite.fit_choose": "Kapcsold ki a távoli mentést annál, amelyiknek nem kell a házon kívül is lennie. Addig a legnagyobbaknak csak a beállításai és az adatbázisa kerül fel, és ha a keret betelik, új távoli mentés nem készül. Régi mentést a doboz ezért nem töröl.",
|
||||
"flash.offbox.enabled_all": "A távoli mentés be van kapcsolva minden alkalmazásra."
|
||||
}
|
||||
|
||||
@@ -744,9 +744,21 @@ func within(p, start, span int) bool {
|
||||
// no caller could learn that a customer's app had not come back — and the classifier downstream
|
||||
// therefore had nothing to key on. A restart failure is the single most important fact this loop
|
||||
// produces; it must leave the function.
|
||||
// householdStopped is the optional question a Stacks implementation answers (R-721): did the household
|
||||
// press Stop on this app while we had it down? The stacks manager implements it; test fakes may not.
|
||||
type householdStopped interface {
|
||||
WantsStopped(name string) bool
|
||||
}
|
||||
|
||||
func (l *Loop) restartAll(stacks []string) []string {
|
||||
var failed []string
|
||||
hs, _ := l.stacks.(householdStopped)
|
||||
for _, s := range stacks {
|
||||
if hs != nil && hs.WantsStopped(s) {
|
||||
// R-721: a Stop pressed while the backup had the app down holds — the backup does not undo it.
|
||||
l.logger.Printf("[INFO] [quiesce] %s NOT restarted: the household stopped it during the backup", s)
|
||||
continue
|
||||
}
|
||||
if err := l.stacks.StartStack(s); err != nil {
|
||||
l.logger.Printf("[ERROR] [quiesce] restart %s: %v", s, err)
|
||||
failed = append(failed, s)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package quiesce
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// householdStacks is fakeStacks plus the household's recorded intent (the stacks manager's WantsStopped).
|
||||
type householdStacks struct {
|
||||
fakeStacks
|
||||
mu2 sync.Mutex
|
||||
stopped map[string]bool
|
||||
}
|
||||
|
||||
func (h *householdStacks) WantsStopped(name string) bool {
|
||||
h.mu2.Lock()
|
||||
defer h.mu2.Unlock()
|
||||
return h.stopped[name]
|
||||
}
|
||||
|
||||
// pressStopDuringBackup is a backend that, on its first status poll (the backup is running, the apps are
|
||||
// down for it), lets the household press Stop on one app — the 2026-09-29 19:37:16 shape.
|
||||
type pressStopDuringBackup struct {
|
||||
*fakeBackend
|
||||
st *householdStacks
|
||||
app string
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (b *pressStopDuringBackup) BackupStatus(ctx context.Context) (string, error) {
|
||||
b.once.Do(func() { b.st.mu2.Lock(); b.st.stopped[b.app] = true; b.st.mu2.Unlock() })
|
||||
return b.fakeBackend.BackupStatus(ctx)
|
||||
}
|
||||
|
||||
// R-721 (v0.283.0). The CONSEQUENCE asserted: after the backup's resume, the app the household stopped during
|
||||
// the backup was NOT started again; the other one was.
|
||||
// COMPANION RED-PROOF: remove the WantsStopped skip in restartAll → this test fails with actualbudget in the
|
||||
// started list (the measured 2026-09-29 defect).
|
||||
func TestR721_AStopPressedDuringTheBackupHolds(t *testing.T) {
|
||||
st := &householdStacks{fakeStacks: fakeStacks{running: []string{"actualbudget", "bookstack"}}, stopped: map[string]bool{}}
|
||||
be := &pressStopDuringBackup{fakeBackend: &fakeBackend{due: true, phases: []string{"running", "snapshotted", "done"}}, st: st, app: "actualbudget"}
|
||||
l := testLoop(t, be, st)
|
||||
if err := l.runOnce(context.Background()); err != nil {
|
||||
t.Fatalf("runOnce: %v", err)
|
||||
}
|
||||
if got := st.startedNames(); !reflect.DeepEqual(got, []string{"bookstack"}) {
|
||||
t.Fatalf("the backup's resume started %v — the household's Stop on actualbudget must hold (R-721)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Control: with no Stop pressed, both come back (the skip must not swallow a normal resume).
|
||||
func TestR721_ControlBothResumeWithoutAStop(t *testing.T) {
|
||||
st := &householdStacks{fakeStacks: fakeStacks{running: []string{"actualbudget", "bookstack"}}, stopped: map[string]bool{}}
|
||||
l := testLoop(t, &fakeBackend{due: true, phases: []string{"running", "done"}}, st)
|
||||
if err := l.runOnce(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := st.startedNames(); !reflect.DeepEqual(got, []string{"actualbudget", "bookstack"}) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package settings
|
||||
|
||||
// ── Decision 50 (2026-09-30, R-720): a new app is in the off-site copy by default ──────────────────────
|
||||
//
|
||||
// The 2026-09-16 ruling turned the off-site tier ON for every new customer, but each app's own switch
|
||||
// (`app_backup[<app>].offbox`) started OFF and only the backups page's „Bekapcsolás" set it. Measured on a
|
||||
// fresh box 2026-09-29: three apps installed, every one „3. mentés Kikapcsolva", the night's off-site run
|
||||
// „nincs kijelölt alkalmazás". The household was never told.
|
||||
//
|
||||
// DefaultOffboxOnForNewApp is called by the deploy-done hook (the one place a fresh install ends). It switches
|
||||
// the app ON when, and only when:
|
||||
// - an off-site target exists and is enabled (the customer has off-site — hub-provisioned or the
|
||||
// household's own NAS), and
|
||||
// - the app has NO per-app backup record yet. A record means someone already chose: an app removed
|
||||
// WITHOUT deleting its backups keeps its record (R-474), so a reinstall keeps the household's earlier
|
||||
// OFF. An app removed WITH its backups had its record deleted, so it is a new app again.
|
||||
//
|
||||
// It never turns anything OFF and never touches an app that is already installed (a release switches
|
||||
// nothing by itself — the 2026-09-29 Part 0 rule).
|
||||
// Pinned by internal/settings/offbox_default_test.go.
|
||||
func (s *Settings) DefaultOffboxOnForNewApp(stackName string) (bool, string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.Offbox == nil || !s.Offbox.Enabled || s.Offbox.Host == "" {
|
||||
return false, "no off-site target"
|
||||
}
|
||||
if _, chosen := s.AppBackup[stackName]; chosen {
|
||||
return false, "the app already has a backup choice"
|
||||
}
|
||||
if s.AppBackup == nil {
|
||||
s.AppBackup = make(map[string]AppBackupPrefs)
|
||||
}
|
||||
s.AppBackup[stackName] = AppBackupPrefs{Offbox: true}
|
||||
if err := s.save(); err != nil {
|
||||
delete(s.AppBackup, stackName)
|
||||
return false, "save failed: " + err.Error()
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// AppsWithoutOffbox lists the given installed apps that are NOT in the off-site copy, when an off-site target
|
||||
// exists (decision 50: the one-press offer for apps installed before the release). Empty when there is no
|
||||
// target, or every app is on.
|
||||
func (s *Settings) AppsWithoutOffbox(installed []string) []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.Offbox == nil || !s.Offbox.Enabled || s.Offbox.Host == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, n := range installed {
|
||||
if !s.AppBackup[n].Offbox {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EnableOffboxFor switches off-site ON for each named app in one save (the one-press offer).
|
||||
func (s *Settings) EnableOffboxFor(apps []string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.AppBackup == nil {
|
||||
s.AppBackup = make(map[string]AppBackupPrefs)
|
||||
}
|
||||
for _, a := range apps {
|
||||
p := s.AppBackup[a]
|
||||
p.Offbox = true
|
||||
s.AppBackup[a] = p
|
||||
}
|
||||
s.OffboxOfferDismissed = true // answered
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// DismissOffboxOffer records the household's „Nem most".
|
||||
func (s *Settings) DismissOffboxOffer() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.OffboxOfferDismissed = true
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// OffboxOfferDismissedValue reports whether the offer was answered.
|
||||
func (s *Settings) OffboxOfferDismissedValue() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.OffboxOfferDismissed
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadTmp(t *testing.T) (*Settings, string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
s, err := Load(p, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, p
|
||||
}
|
||||
|
||||
// Decision 50 (R-720). The CONSEQUENCE is asserted: after a fresh install on a box with off-site, the app is in
|
||||
// the list the off-site run reads (GetOffboxApps), and it survives a reload from disk.
|
||||
// COMPANION RED-PROOF: make DefaultOffboxOnForNewApp return (false, "") at the top → this test fails with
|
||||
// „the new app is not in the off-site run".
|
||||
func TestDecision50_NewAppIsOffsiteByDefault(t *testing.T) {
|
||||
s, p := loadTmp(t)
|
||||
if err := s.SetOffboxTarget(&OffboxTarget{Enabled: true, Host: "u1.your-storagebox.de", Port: 23, User: "u1", RepoPath: "/home/r"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
on, why := s.DefaultOffboxOnForNewApp("bookstack")
|
||||
if !on {
|
||||
t.Fatalf("switched=false (%s)", why)
|
||||
}
|
||||
s2, err := Load(p, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s2.GetOffboxApps(); !reflect.DeepEqual(got, []string{"bookstack"}) {
|
||||
t.Fatalf("the new app is not in the off-site run after a reload: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// No off-site target → nothing is switched (a box without off-site keeps no phantom choice).
|
||||
func TestDecision50_NoTargetSwitchesNothing(t *testing.T) {
|
||||
for name, tgt := range map[string]*OffboxTarget{
|
||||
"none": nil,
|
||||
"disabled": {Enabled: false, Host: "h"},
|
||||
"no host": {Enabled: true},
|
||||
} {
|
||||
s, _ := loadTmp(t)
|
||||
if tgt != nil {
|
||||
_ = s.SetOffboxTarget(tgt)
|
||||
}
|
||||
if on, _ := s.DefaultOffboxOnForNewApp("bookstack"); on {
|
||||
t.Fatalf("%s: switched an app on with no usable off-site target", name)
|
||||
}
|
||||
if len(s.GetOffboxApps()) != 0 || s.AppsWithoutOffbox([]string{"bookstack"}) != nil {
|
||||
t.Fatalf("%s: phantom off-site state", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A household's earlier OFF survives a reinstall: an app removed WITHOUT deleting its backups keeps its record
|
||||
// (R-474), and the default never overrides a record.
|
||||
// COMPANION RED-PROOF: drop the „already has a backup choice" check → this test fails.
|
||||
func TestDecision50_EarlierChoiceIsKept(t *testing.T) {
|
||||
s, _ := loadTmp(t)
|
||||
_ = s.SetOffboxTarget(&OffboxTarget{Enabled: true, Host: "h"})
|
||||
_ = s.SetAppOffbox("immich", false) // the household switched it off (e.g. too big)
|
||||
if on, _ := s.DefaultOffboxOnForNewApp("immich"); on {
|
||||
t.Fatal("the default overrode the household's own OFF")
|
||||
}
|
||||
if len(s.GetOffboxApps()) != 0 {
|
||||
t.Fatalf("immich is off-site against the household's choice: %v", s.GetOffboxApps())
|
||||
}
|
||||
}
|
||||
|
||||
// The one-press offer lists exactly the installed apps that are off.
|
||||
func TestDecision50_AppsWithoutOffbox(t *testing.T) {
|
||||
s, _ := loadTmp(t)
|
||||
_ = s.SetOffboxTarget(&OffboxTarget{Enabled: true, Host: "h"})
|
||||
_ = s.SetAppOffbox("bookstack", true)
|
||||
got := s.AppsWithoutOffbox([]string{"actualbudget", "bookstack", "vikunja"})
|
||||
if !reflect.DeepEqual(got, []string{"actualbudget", "vikunja"}) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,9 @@ type Settings struct {
|
||||
|
||||
// Per-app backup preferences
|
||||
AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
|
||||
// OffboxOfferDismissed (decision 50): the household answered „Nem most" to the one-press offer that
|
||||
// switches off-site on for apps installed before v0.283.0. The offer is shown once, not forever.
|
||||
OffboxOfferDismissed bool `json:"offbox_offer_dismissed,omitempty"`
|
||||
|
||||
// OffsiteDataAt (v0.275.0, R-696) — per app, the DATA time of the recovery unit this box last pushed
|
||||
// off-site, and when. An off-site snapshot's own time is when it was TAKEN; a run whose dump leg failed
|
||||
|
||||
@@ -156,3 +156,16 @@ func isObservedUp(s Stack) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// WantsStopped (R-721, v0.283.0) reports whether the household's own recorded intent for the stack is
|
||||
// „stopped". The machines that stop an app for their own purposes and start it again afterwards — the
|
||||
// whole-guest backup's quiesce, the nightly volume dump — ask this before their restart, so a Stop the
|
||||
// household pressed WHILE the app was down for them holds. Measured 2026-09-29 on a fresh box: Stop at
|
||||
// 19:37:16 recorded `desired state … stopped`, and the backup's resume started the app the same second.
|
||||
func (m *Manager) WantsStopped(name string) bool {
|
||||
st, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return DesiredStateOf(*st) == DesiredStateStopped
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package stacks
|
||||
|
||||
import "testing"
|
||||
|
||||
// R-721 (v0.283.0): the nightly update leg never presses an app the household stopped — an update brings the
|
||||
// app up with its new images, which would undo the Stop. Measured from source 2026-09-30: legCandidate had no
|
||||
// desired-state check at all.
|
||||
// COMPANION RED-PROOF: remove the DesiredStateStopped check in legCandidate → the step is pressed.
|
||||
func TestR721_UpdateLegLeavesAStoppedAppAlone(t *testing.T) {
|
||||
m, dir, _, ups, _ := legManager(t)
|
||||
writeLadder(t, m, ladderLine(ladderA, ladderB), ladderLine(ladderB, ladderC))
|
||||
legOpts(m, nil)
|
||||
if err := m.SetDesiredState("nextcloud", DesiredStateStopped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := mustLeg(t, m)
|
||||
if len(*ups) != 0 || pinOf(t, dir) != ladderA {
|
||||
t.Fatalf("the leg updated an app the household stopped (ups=%v)", *ups)
|
||||
}
|
||||
if st := legStepFor(s, "nextcloud"); st.Reason != LegSkipStoppedByHousehold {
|
||||
t.Fatalf("skip reason %+v, want %q", st, LegSkipStoppedByHousehold)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,9 @@ const (
|
||||
LegSkipWindowEnd = "window_end"
|
||||
LegSkipSwitchedOff = "switched_off"
|
||||
LegSkipCancelled = "cancelled"
|
||||
legCurrent = "current" // not a skip: nothing to do
|
||||
// LegSkipStoppedByHousehold (R-721, v0.283.0): the household stopped the app; an update would start it.
|
||||
LegSkipStoppedByHousehold = "stopped_by_household"
|
||||
legCurrent = "current" // not a skip: nothing to do
|
||||
|
||||
LegOutcomeDone = "done"
|
||||
LegOutcomeUndone = "undone"
|
||||
@@ -395,6 +397,9 @@ func (m *Manager) legCandidate(ctx context.Context, name string, o *UpdateLegOpt
|
||||
if st.HoldReason != "" || st.updateHeld {
|
||||
return LadderEntry{}, LegSkipHeld
|
||||
}
|
||||
if DesiredStateOf(*st) == DesiredStateStopped {
|
||||
return LadderEntry{}, LegSkipStoppedByHousehold
|
||||
}
|
||||
if g := m.guards(); g != nil {
|
||||
if held, _ := g.HoldFor(name); held {
|
||||
return LadderEntry{}, LegSkipHeld
|
||||
|
||||
@@ -111,6 +111,10 @@ type guestBackupView struct {
|
||||
RestorePass bool
|
||||
RestoreVerified string
|
||||
RestoreTestedAt time.Time
|
||||
// RestoreTier (R-727, v0.283.0) names the tier the last restore test read from, so a ✗ says WHERE —
|
||||
// measured 2026-09-30: a bare ✗ above the local tier's heading read as „the local tier failed" while
|
||||
// the PBS tier had.
|
||||
RestoreTier string
|
||||
|
||||
CanTrigger bool // a backup trigger (quiesce loop) is wired
|
||||
|
||||
@@ -277,6 +281,13 @@ func (s *Server) loadGuestBackup(ctx context.Context, lang string) *guestBackupV
|
||||
v.HasRestoreTest = true
|
||||
v.RestorePass = rt.Pass
|
||||
v.RestoreVerified = rt.Verified
|
||||
switch strings.ToLower(rt.SourceTier) {
|
||||
case "":
|
||||
case "pbs", "felhom-pbs":
|
||||
v.RestoreTier = msg("backup.tier.pbs")
|
||||
default:
|
||||
v.RestoreTier = msg("backup.tier.local", rt.SourceTier)
|
||||
}
|
||||
if t, perr := time.Parse(time.RFC3339, rt.TestedAt); perr == nil {
|
||||
v.RestoreTestedAt = t
|
||||
}
|
||||
|
||||
@@ -358,13 +358,13 @@ func TestEscrowTemplates_Render(t *testing.T) {
|
||||
html := renderBackupPage(t, "backups_escrow", base())
|
||||
for _, want := range []string{
|
||||
"Előfeltételek ellenőrzése",
|
||||
"a mentései utolsó kulcsa",
|
||||
"A folytatáshoz adja meg a bejelentkezési jelszavát",
|
||||
"a mentéseid utolsó kulcsa",
|
||||
"A folytatáshoz add meg a bejelentkezési jelszavadat",
|
||||
"Kód létrehozása",
|
||||
"Kód megjelenítése",
|
||||
// v0.127.3 (Viktor): the reveal states EXPLICITLY that the shown code is already the
|
||||
// live one (the supersede happened at upload, before display).
|
||||
"Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentse el most: a kód többé nem jeleníthető meg.",
|
||||
"Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentsd el most: a kód többé nem jelenik meg.",
|
||||
// The pre-generation cancel must stay next to the start button (nothing runs until then).
|
||||
`>Mégsem</a>`,
|
||||
"nem ezen a szerveren",
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -1013,6 +1014,31 @@ func (s *Server) backupsOffboxData(data map[string]interface{}, lang string) {
|
||||
}
|
||||
}
|
||||
data["OffboxBlockedSet"] = blocked
|
||||
// Decision 50 (v0.283.0): the one-press offer for apps installed before apps went off-site by default,
|
||||
// shown until answered; and the size card when the selected apps will not fit the quota.
|
||||
if s.backupMgr != nil && s.backupMgr.OffboxConfigured() && !s.settings.OffboxOfferDismissedValue() {
|
||||
if off := s.appsWithoutOffbox(); len(off) > 0 {
|
||||
data["OffboxOfferCount"] = len(off)
|
||||
}
|
||||
}
|
||||
if s.backupMgr != nil && offboxTgt != nil && offboxTgt.QuotaGB > 0 && s.backupMgr.OffboxConfigured() {
|
||||
if f := s.backupMgr.OffsiteFitForPage(); !f.At.IsZero() && !f.Fits {
|
||||
data["OffsiteFitTotal"] = appbackup.HumanizeBytes(f.TotalBytes)
|
||||
data["OffsiteFitQuota"] = offboxTgt.QuotaGB
|
||||
var top []string
|
||||
for i, a := range f.Apps {
|
||||
if i == 3 || a.Bytes == 0 {
|
||||
break
|
||||
}
|
||||
name := a.Stack
|
||||
if st, ok := s.stackMgr.GetStack(a.Stack); ok && st.Meta.DisplayName != "" {
|
||||
name = st.Meta.DisplayName
|
||||
}
|
||||
top = append(top, name+" ("+appbackup.HumanizeBytes(a.Bytes)+")")
|
||||
}
|
||||
data["OffsiteFitTop"] = strings.Join(top, ", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// offboxStaleWarningMarker is the substring the zero-toggled offbox run wrote into LastWarning before
|
||||
@@ -2244,8 +2270,14 @@ func (s *Server) systemPageData() map[string]interface{} {
|
||||
data["GitRepoURL"] = s.cfg.Git.RepoURL
|
||||
data["GitSyncInterval"] = s.cfg.Git.SyncInterval
|
||||
data["BackupEnabled"] = s.cfg.Backup.Enabled
|
||||
data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule
|
||||
data["ResticSchedule"] = s.cfg.Backup.ResticSchedule
|
||||
// R-724 (v0.283.0): the schedule the box really runs — the household's backup window and its derived
|
||||
// legs, the same numbers the backups page shows — not the controller.yaml seed (measured 2026-09-29:
|
||||
// Beállítások said „02:30 / 03:00" while the backups page said 02:30 / 03:30 / 04:15).
|
||||
{
|
||||
db, tier2, offbox := backupwindow.LegTimes(s.effectiveBackupWindow())
|
||||
data["DBDumpSchedule"] = db
|
||||
data["ResticSchedule"] = tier2 + " / " + offbox
|
||||
}
|
||||
data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
|
||||
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
|
||||
data["HubEnabled"] = s.cfg.Hub.Enabled
|
||||
@@ -2264,7 +2296,11 @@ func (s *Server) systemPageData() map[string]interface{} {
|
||||
if status.LastCheck != nil {
|
||||
data["UpdateAvailable"] = status.LastCheck.UpdateAvailable
|
||||
data["LatestVersion"] = status.LastCheck.LatestVersion
|
||||
// R-724: the box's local time, not a raw RFC3339 stamp.
|
||||
data["LastCheckTime"] = status.LastCheck.CheckedAt
|
||||
if t, err := time.Parse(time.RFC3339, status.LastCheck.CheckedAt); err == nil {
|
||||
data["LastCheckTime"] = t.In(getTimezone()).Format("2006-01-02 15:04")
|
||||
}
|
||||
data["LastCheckError"] = status.LastCheck.Error
|
||||
}
|
||||
if status.LastState != nil {
|
||||
|
||||
@@ -356,6 +356,18 @@ func i18nCases() []i18nCase {
|
||||
d["SignupNative"], d["SignupNativeFailed"] = true, true
|
||||
return d
|
||||
}})
|
||||
// v0.283.0 (decision 50): the one-press off-site offer on both backup pages, and the size card.
|
||||
base = append(base, i18nCase{"backups_remote_offsite_offer_fit", "backups_remote", func() map[string]interface{} {
|
||||
return i18nRemoteData(func(d, o m) {
|
||||
d["EscrowAgentOK"] = true
|
||||
d["OffboxOfferCount"] = 2
|
||||
d["OffsiteFitTotal"], d["OffsiteFitQuota"], d["OffsiteFitTop"] = "130.0 GB", 100, "Immich (95.0 GB), Nextcloud (34.0 GB)"
|
||||
})
|
||||
}}, i18nCase{"backups_apps_offsite_offer", "backups_apps", func() map[string]interface{} {
|
||||
d := i18nAppsData("ok", false, true, "dumps")
|
||||
d["OffboxOfferCount"] = 2
|
||||
return d
|
||||
}})
|
||||
base = append(base, i18nCase{"app_info_known_login_changed", "app_info", func() map[string]interface{} {
|
||||
d := i18nLayoutData("stacks", "Calibre-Web")
|
||||
st := stacks.Stack{Name: "calibre-web", Deployed: true, State: stacks.StateRunning}
|
||||
|
||||
@@ -224,6 +224,37 @@ func (s *Server) offboxToggleHandler(w http.ResponseWriter, r *http.Request) {
|
||||
offboxRedirect(w, r, "flash.offbox.app_setting_updated", false)
|
||||
}
|
||||
|
||||
// offboxEnableAllHandler (decision 50): the one press that puts every installed app that is not yet
|
||||
// off-site into the off-site copy. Redirects back to the page it came from (the remote or the apps page).
|
||||
func (s *Server) offboxEnableAllHandler(w http.ResponseWriter, r *http.Request) {
|
||||
off := s.appsWithoutOffbox()
|
||||
if err := s.settings.EnableOffboxFor(off); err != nil {
|
||||
offboxRedirect(w, r, "flash.offbox.save_failed", true)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] off-site switched ON for %d app(s) by the household's one press: %v", len(off), off)
|
||||
s.reportTriggerNow()
|
||||
offboxRedirect(w, r, "flash.offbox.enabled_all", false)
|
||||
}
|
||||
|
||||
// offboxOfferDismissHandler records „Nem most" — the offer is not shown again.
|
||||
func (s *Server) offboxOfferDismissHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.settings.DismissOffboxOffer(); err != nil {
|
||||
offboxRedirect(w, r, "flash.offbox.save_failed", true)
|
||||
return
|
||||
}
|
||||
offboxRedirect(w, r, "flash.offbox.app_setting_updated", false)
|
||||
}
|
||||
|
||||
// appsWithoutOffbox: the installed apps (off-site-selectable, as the page lists them) that are not off-site.
|
||||
func (s *Server) appsWithoutOffbox() []string {
|
||||
var names []string
|
||||
for _, a := range s.buildOffboxApps() {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
return s.settings.AppsWithoutOffbox(names)
|
||||
}
|
||||
|
||||
// offboxRunHandler triggers an off-box backup now (async — it can run for minutes).
|
||||
func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
||||
|
||||
@@ -731,6 +731,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.offboxConfigHandler(w, r)
|
||||
case path == "/backup/offbox/toggle" && r.Method == http.MethodPost:
|
||||
s.offboxToggleHandler(w, r)
|
||||
case path == "/backup/offbox/enable-all" && r.Method == http.MethodPost: // decision 50
|
||||
s.offboxEnableAllHandler(w, r)
|
||||
case path == "/backup/offbox/offer-dismiss" && r.Method == http.MethodPost: // decision 50
|
||||
s.offboxOfferDismissHandler(w, r)
|
||||
case path == "/backup/offbox/run" && r.Method == http.MethodPost:
|
||||
s.offboxRunHandler(w, r)
|
||||
case path == "/backup/offbox/reset" && r.Method == http.MethodPost:
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{if .HasRestoreTest}}{{if .RestorePass}}✓{{else}}✗{{end}}{{else}}–{{end}}</div>
|
||||
<div class="stat-label">{{T "backups.visszaallitas_ellenorizve"}}
|
||||
{{if .HasRestoreTest}}<br><span class="relative-time">{{fmtTime .RestoreTestedAt}}</span>{{else}}<br><span class="relative-time">{{T "backups.meg_nem_futott"}}</span>{{end}}
|
||||
{{if .HasRestoreTest}}<br><span class="relative-time">{{fmtTime .RestoreTestedAt}}{{if .RestoreTier}} · {{.RestoreTier}}{{end}}</span>{{else}}<br><span class="relative-time">{{T "backups.meg_nem_futott"}}</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
{{template "backups_empty" .}}
|
||||
{{else}}
|
||||
|
||||
<h3 class="backup-tier-divider">{{T "backups_apps.alkalmazas_mentesek_adatbazis_konfigurac"}}</h3>
|
||||
{{if .OffboxOfferCount}}
|
||||
<div class="alert alert-info" style="margin-top:.75rem" data-offsite-offer="true">
|
||||
<span>{{T "backups_offsite.offer_text"}} ({{.OffboxOfferCount}})</span>
|
||||
<form method="POST" action="/backup/offbox/enable-all" style="display:inline;margin-left:.5rem">{{$.CSRFField}}<button type="submit" class="btn btn-xs btn-primary">{{T "backups_offsite.offer_all"}}</button></form>
|
||||
<form method="POST" action="/backup/offbox/offer-dismiss" style="display:inline">{{$.CSRFField}}<button type="submit" class="btn btn-xs btn-outline">{{T "backups_offsite.offer_no"}}</button></form>
|
||||
</div>
|
||||
{{end}}<h3 class="backup-tier-divider">{{T "backups_apps.alkalmazas_mentesek_adatbazis_konfigurac"}}</h3>
|
||||
<p class="form-hint" style="margin:-0.25rem 0 1rem">{{T "backups_apps.az_egyes_alkalmazasok_reszletes_granulal"}}</p>
|
||||
|
||||
<!-- Section 2: Schedule -->
|
||||
|
||||
@@ -211,7 +211,20 @@
|
||||
<div id="offbox-progress-bar" class="progress-fill" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 style="margin-top:1.25rem">{{T "backups_remote.mely_alkalmazasok_mentodnek_a_tavoli"}}</h4>
|
||||
{{if .OffboxOfferCount}}
|
||||
<div class="alert alert-info" style="margin-top:.75rem" data-offsite-offer="true">
|
||||
<span>{{T "backups_offsite.offer_text"}} ({{.OffboxOfferCount}})</span>
|
||||
<form method="POST" action="/backup/offbox/enable-all" style="display:inline;margin-left:.5rem">{{$.CSRFField}}<button type="submit" class="btn btn-xs btn-primary">{{T "backups_offsite.offer_all"}}</button></form>
|
||||
<form method="POST" action="/backup/offbox/offer-dismiss" style="display:inline">{{$.CSRFField}}<button type="submit" class="btn btn-xs btn-outline">{{T "backups_offsite.offer_no"}}</button></form>
|
||||
</div>
|
||||
{{end}}{{if .OffsiteFitTotal}}
|
||||
<div class="alert alert-warning" style="margin-top:.75rem" data-offsite-fit="true">
|
||||
<strong>{{T "backups_offsite.fit_head"}}</strong>
|
||||
<div>{{T "backups_offsite.fit_total"}} {{.OffsiteFitTotal}} · {{T "backups_offsite.fit_quota"}} {{.OffsiteFitQuota}} GB</div>
|
||||
<div>{{T "backups_offsite.fit_largest"}} {{.OffsiteFitTop}}</div>
|
||||
<div class="form-hint">{{T "backups_offsite.fit_choose"}}</div>
|
||||
</div>
|
||||
{{end}}<h4 style="margin-top:1.25rem">{{T "backups_remote.mely_alkalmazasok_mentodnek_a_tavoli"}}</h4>
|
||||
{{if and .OffboxApps (eq .Offbox.EscrowState "escrowed") (eq .OffboxToggledCount 0)}}
|
||||
<p class="form-hint">{{T "backups_remote.nincs_tavoli_mentesre_jelolt_alkalmazas"}}</p>
|
||||
{{end}}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<span class="backup-label">{{T "dashboard.utolso_mentes"}}</span>
|
||||
<span class="backup-value">
|
||||
{{if .BackupStatus.Success}}
|
||||
<span>{{.BackupStatus.LastRun.Format "2006-01-02 15:04"}}</span>
|
||||
<span>{{fmtTime .BackupStatus.LastRun}}</span>
|
||||
{{else}}
|
||||
<span class="backup-status-fail">{{T "dashboard.sikertelen"}}</span>
|
||||
{{end}}
|
||||
|
||||
+1602
File diff suppressed because it is too large
Load Diff
@@ -284,7 +284,7 @@
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size:1.15rem">Naprakész</div>
|
||||
<div class="stat-label">Következő mentés
|
||||
<br><span class="relative-time"># órája — a mentési ablakon belül</span>
|
||||
<br><span class="relative-time">az előző # órája készült — a mentési időn belül</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
|
||||
+704
@@ -0,0 +1,704 @@
|
||||
|
||||
|
||||
<!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>Távoli mentés beállítása — 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=0.247.0">
|
||||
<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}:{};}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>')+'</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="active">Távoli mentés</a></li>
|
||||
<li><a href="/backups/apps" class="">Alkalmazások</a></li>
|
||||
<li><a href="/backups/restore" class="">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>Debug</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>
|
||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="page-header">
|
||||
<h2>Biztonsági mentés — Távoli mentés</h2>
|
||||
<span class="domain-badge">example.hu</span>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<h3 id="offbox-section" class="backup-tier-divider">Távoli mentés (3. mentés) — titkosított, offsite</h3>
|
||||
<p class="form-hint" style="margin:-0.25rem 0 1rem">Az alkalmazás-mentések titkosított másolata egy távoli tárolóra — saját NAS vagy Felhom offsite tárhely — restic + SFTP kapcsolaton. A tároló csak titkosított adatot lát. Ez a 3-2-1 szabály „1 off-site" lába — független a helyi másodpéldánytól és a teljes rendszermentéstől.</p>
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
|
||||
<div class="stats-grid backup-page-cards">
|
||||
<div class="stat-card ">
|
||||
<div class="stat-value" id="offbox-status-value" data-status="ok" style="font-size:1.15rem">✓ Rendben</div>
|
||||
<div class="stat-label">Utolsó távoli mentés<br><span class="relative-time"># napja</span></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size:1.15rem">5 GB</div>
|
||||
<div class="stat-label">Tároló méret · 10 pillanatkép</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" style="font-size:1.05rem">felhom@nas.example</div>
|
||||
<div class="stat-label">/srv/repo</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div id="offbox-quota-bar" style="max-width:560px;margin:.5rem 0">
|
||||
<div class="stat-label" style="margin-bottom:.25rem">Tárhelykeret: 5 GB / 100 GB (5%)</div>
|
||||
<div style="background:var(--border,#334);border-radius:4px;height:8px;overflow:hidden">
|
||||
<div style="height:8px;border-radius:4px;width:5%;background:var(--ok,#30a46c)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="margin:.5rem 0">
|
||||
<p class="form-hint" style="margin:0 0 .4rem">A helyreállítási kód letétbe helyezve.</p>
|
||||
<a href="/backup/escrow" class="btn btn-sm btn-outline">Új helyreállítási kód készítése</a>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="schedule-actions" style="margin-top:1rem">
|
||||
<form method="POST" action="/backup/offbox/run" style="display:inline">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Távoli mentés most</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="offbox-progress" class="alert alert-info" style="display:none;margin-top:.75rem">
|
||||
<span id="offbox-progress-text"></span>
|
||||
<div class="progress-bar-task" style="margin-top:.5rem">
|
||||
<div id="offbox-progress-bar" class="progress-fill" style="width:0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" style="margin-top:.75rem" data-offsite-offer="true">
|
||||
<span>Van alkalmazás, amelyről nem készül távoli mentés. Bekapcsolod mindegyikre? (2)</span>
|
||||
<form method="POST" action="/backup/offbox/enable-all" style="display:inline;margin-left:.5rem"><button type="submit" class="btn btn-xs btn-primary">Igen, mindegyikre</button></form>
|
||||
<form method="POST" action="/backup/offbox/offer-dismiss" style="display:inline"><button type="submit" class="btn btn-xs btn-outline">Nem most</button></form>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" style="margin-top:.75rem" data-offsite-fit="true">
|
||||
<strong>A kijelölt alkalmazások nem férnek el a távoli tárhelyen.</strong>
|
||||
<div>Becsült méret: 130.0 GB · tárhelykeret: 100 GB</div>
|
||||
<div>A legnagyobbak: Immich (95.0 GB), Nextcloud (34.0 GB)</div>
|
||||
<div class="form-hint">Kapcsold ki a távoli mentést annál, amelyiknek nem kell a házon kívül is lennie. Addig a legnagyobbaknak csak a beállításai és az adatbázisa kerül fel, és ha a keret betelik, új távoli mentés nem készül. Régi mentést a doboz ezért nem töröl.</div>
|
||||
</div>
|
||||
<h4 style="margin-top:1.25rem">Mely alkalmazások mentődnek a távoli tárolóra?</h4>
|
||||
|
||||
<p class="form-hint">Nincs telepített alkalmazás.</p>
|
||||
|
||||
|
||||
|
||||
<details style="margin-top:1rem">
|
||||
<summary class="btn btn-xs btn-primary" style="cursor:pointer;display:inline-block">Távoli mentési cél beállítása</summary>
|
||||
<form method="POST" action="/backup/offbox/config" style="margin-top:.75rem;padding:1rem;border:1px solid var(--border,#ddd);border-radius:6px;max-width:560px">
|
||||
<div class="form-row"><label>Cél címe (IP vagy hosztnév — NAS vagy SFTP-kiszolgáló)</label><input type="text" name="host" class="form-input" value="nas.example" placeholder="pl. 192.168.0.10"></div>
|
||||
<div class="form-row"><label>SSH port</label><input type="number" name="port" class="form-input" value="22"></div>
|
||||
<div class="form-row"><label>Felhasználó</label><input type="text" name="user" class="form-input" value="felhom" placeholder="pl. felhom"></div>
|
||||
<div class="form-row"><label>Tároló útvonala a célgépen</label><input type="text" name="repo_path" class="form-input" value="/srv/repo" placeholder="pl. /volume1/felhom-backup/repo"></div>
|
||||
<div class="form-row"><label><input type="checkbox" name="enabled" checked> Napi automatikus távoli mentés engedélyezve</label></div>
|
||||
<div class="form-row"><label>SSH privát kulcs (üresen hagyva változatlan)</label>
|
||||
<textarea name="ssh_key" class="form-input" rows="4" autocomplete="off" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
|
||||
<span class="form-hint">A kulcsot 0600-as fájlba írjuk; sosem naplózzuk és nem tároljuk a beállításokban.</span></div>
|
||||
<div class="form-row"><label>A célgép ismert-host sora (known_hosts) (üresen hagyva változatlan)</label>
|
||||
<textarea name="known_hosts" class="form-input" rows="2" autocomplete="off" placeholder="nas.local ssh-ed25519 AAAA…"></textarea>
|
||||
<span class="form-hint">A host-kulcs rögzítése (no blind TOFU). Lekérdezhető: <span class="mono">ssh-keyscan -p <port> <host></span></span></div>
|
||||
<button type="submit" class="btn btn-sm btn-primary" style="margin-top:.5rem">Mentés</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
(function(){
|
||||
var v = document.getElementById('offbox-status-value');
|
||||
var box = document.getElementById('offbox-progress');
|
||||
var text = document.getElementById('offbox-progress-text');
|
||||
var bar = document.getElementById('offbox-progress-bar');
|
||||
var GRACE = 5;
|
||||
var quiet = 0;
|
||||
|
||||
|
||||
|
||||
var sawRunning = (v && v.dataset.status === 'running');
|
||||
|
||||
var timer = setInterval(function(){
|
||||
fetch('/backup/offbox/status',{headers:{'Accept':'application/json'}})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(!d) return;
|
||||
var p = d.progress || {};
|
||||
var running = (d.status === 'running') || p.active;
|
||||
|
||||
if(running){
|
||||
sawRunning = true; quiet = 0;
|
||||
if(p.active && box){
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if(p.phase === 'dump'){
|
||||
text.textContent = 'Adatbázisok mentése a pillanatképhez…';
|
||||
bar.style.width = '100%';
|
||||
box.className = 'alert alert-info'; box.style.display = '';
|
||||
return;
|
||||
}
|
||||
if(p.phase === 'retention'){
|
||||
text.textContent = 'Karbantartás: régi mentések rendezése a távoli tárolón…';
|
||||
bar.style.width = '100%';
|
||||
box.className = 'alert alert-info'; box.style.display = '';
|
||||
return;
|
||||
}
|
||||
if(p.phase === 'shares'){
|
||||
text.textContent = 'Megosztott mappák mentése…';
|
||||
bar.style.width = '100%';
|
||||
box.className = 'alert alert-info'; box.style.display = '';
|
||||
return;
|
||||
}
|
||||
var label = p.current_app ? ('Mentés: ' + p.current_app) : 'Mentés folyamatban';
|
||||
if(p.bytes_done > 0){
|
||||
|
||||
label += ' — ' + Math.round(p.percent) + '% (' + p.done_human + ' / ' + p.total_human + ')';
|
||||
bar.style.width = Math.max(0, Math.min(100, p.percent)) + '%';
|
||||
} else if(p.total_files > 0){
|
||||
|
||||
|
||||
var fpct = Math.round((p.files_done / p.total_files) * 100);
|
||||
label += ' — ' + p.files_done + ' / ' + p.total_files + ' fájl'
|
||||
+ (p.total_human ? ' (' + p.total_human + ')' : '');
|
||||
if(p.current_file){
|
||||
var base = p.current_file.split('/').pop();
|
||||
label += ' · feldolgozás alatt: ' + base;
|
||||
}
|
||||
if(p.elapsed_sec > 0){ label += ' · ' + p.elapsed_sec + ' mp'; }
|
||||
bar.style.width = Math.max(0, Math.min(100, fpct)) + '%';
|
||||
} else {
|
||||
label += ' — a mentendő adatok felmérése…';
|
||||
bar.style.width = '0%';
|
||||
}
|
||||
text.textContent = label;
|
||||
box.style.display = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if(sawRunning){ clearInterval(timer); location.reload(); return; }
|
||||
if(++quiet >= GRACE){ clearInterval(timer); }
|
||||
})
|
||||
.catch(function(){ });
|
||||
}, 3000);
|
||||
})();
|
||||
</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>';
|
||||
}
|
||||
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>';
|
||||
}
|
||||
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íti az alkalmazást, az adatokat újra importálnia 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>';
|
||||
}
|
||||
}
|
||||
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.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>
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
<span class="backup-label">Utolsó mentés:</span>
|
||||
<span class="backup-value">
|
||||
|
||||
<span>2026-09-14 03:12</span>
|
||||
<span>2026-09-14 05:12</span>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+10
-10
@@ -190,16 +190,16 @@
|
||||
<div class="settings-card" id="warn-card" style="display:none">
|
||||
<h3>2. Fontos tudnivalók</h3>
|
||||
<div class="alert alert-warning" style="margin-bottom:.75rem">
|
||||
A helyreállítási kód a mentései utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer
|
||||
A helyreállítási kód a mentéseid utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer
|
||||
sehol nem tárolja, és a Felhom sem ismeri. Ha a szerver megsemmisül, a távoli mentések CSAK
|
||||
ezzel a kóddal állíthatók vissza. Írja fel papírra vagy mentse jelszókezelőbe — de NE ezen a
|
||||
szerveren tárolja.
|
||||
ezzel a kóddal állíthatók vissza. Írd fel papírra, vagy mentsd jelszókezelőbe — de NE ezen a
|
||||
szerveren tárold.
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:1.25rem">3. Megerősítés</h3>
|
||||
<form id="start-form" onsubmit="return startCeremony(event)">
|
||||
<div class="form-group">
|
||||
<label for="reauth-password">A folytatáshoz adja meg a bejelentkezési jelszavát</label>
|
||||
<label for="reauth-password">A folytatáshoz add meg a bejelentkezési jelszavadat</label>
|
||||
<input type="password" id="reauth-password" class="form-control" autocomplete="current-password" required style="max-width:280px">
|
||||
</div>
|
||||
<div id="start-error" class="alert alert-error" style="display:none;margin-bottom:.75rem"></div>
|
||||
@@ -221,22 +221,22 @@
|
||||
<div class="settings-card" id="reveal-card" style="display:none">
|
||||
<h3>5. A helyreállítási kód</h3>
|
||||
<div id="reveal-gate">
|
||||
<p class="form-hint">A kód pontosan egyszer jeleníthető meg. Készítsen elő papírt és tollat, mielőtt megnyomja a gombot.</p>
|
||||
<p class="form-hint">A kód pontosan egyszer jelenik meg. Készíts elő papírt és tollat, mielőtt megnyomod a gombot.</p>
|
||||
<button class="btn btn-primary" id="reveal-btn" onclick="claimCode()">Kód megjelenítése</button>
|
||||
<div id="reveal-error" class="alert alert-error" style="display:none;margin-top:.75rem"></div>
|
||||
</div>
|
||||
<div id="reveal-shown" style="display:none">
|
||||
<div class="alert alert-warning" style="margin-bottom:.5rem">Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentse el most: a kód többé nem jeleníthető meg.</div>
|
||||
<div class="alert alert-warning" style="margin-bottom:.5rem">Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentsd el most: a kód többé nem jelenik meg.</div>
|
||||
<pre class="mono" id="code-block" style="white-space:pre-wrap;word-break:break-all;background:var(--bg-0);border:1px solid var(--line);border-radius:var(--radius);padding:1rem;font-size:1.05rem"></pre>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="code-blur-toggle" onclick="toggleCodeBlur()">Elrejtés</button>
|
||||
<h3 style="margin-top:1.25rem">6. Ellenőrzés</h3>
|
||||
<div class="form-group">
|
||||
<label id="verify-label" for="verify-w1">Megerősítés: írja be a kód szavait</label>
|
||||
<label id="verify-label" for="verify-w1">Megerősítés: írd be a kód szavait</label>
|
||||
<div style="display:flex;gap:.5rem">
|
||||
<input type="text" id="verify-w1" class="form-control" autocomplete="off" style="max-width:180px">
|
||||
<input type="text" id="verify-w2" class="form-control" autocomplete="off" style="max-width:180px">
|
||||
</div>
|
||||
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizze a felírt kódot.</div>
|
||||
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">Szavak ellenőrzése</button>
|
||||
<div id="finish-block" style="display:none;margin-top:1rem">
|
||||
@@ -257,7 +257,7 @@
|
||||
|
||||
<div class="settings-card" id="void-card" style="display:none">
|
||||
<div class="alert alert-warning">A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le.
|
||||
Indítsa újra a folyamatot: az új kód a régit érvényteleníti.</div>
|
||||
Indítsd újra a folyamatot: az új kód a régit érvényteleníti.</div>
|
||||
<a href="/backup/escrow" class="btn btn-sm btn-primary">Újraindítás</a>
|
||||
</div>
|
||||
|
||||
@@ -411,7 +411,7 @@ async function claimCode(){
|
||||
});
|
||||
document.getElementById('reveal-gate').style.display='none';
|
||||
document.getElementById('reveal-shown').style.display='block';
|
||||
document.getElementById('verify-label').textContent='Megerősítés: írja be a kód '+verifyIdx[0]+'. és '+verifyIdx[1]+'. szavát';
|
||||
document.getElementById('verify-label').textContent='Megerősítés: írd be a kód '+verifyIdx[0]+'. és '+verifyIdx[1]+'. szavát';
|
||||
}catch(e){ errEl.textContent='Hiba: '+e.message; errEl.style.display='block'; btn.disabled=false; }
|
||||
}
|
||||
|
||||
|
||||
@@ -190,10 +190,10 @@
|
||||
<div class="settings-card" id="warn-card" style="display:none">
|
||||
<h3>2. Fontos tudnivalók</h3>
|
||||
<div class="alert alert-warning" style="margin-bottom:.75rem">
|
||||
A helyreállítási kód a mentései utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer
|
||||
A helyreállítási kód a mentéseid utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer
|
||||
sehol nem tárolja, és a Felhom sem ismeri. Ha a szerver megsemmisül, a távoli mentések CSAK
|
||||
ezzel a kóddal állíthatók vissza. Írja fel papírra vagy mentse jelszókezelőbe — de NE ezen a
|
||||
szerveren tárolja.
|
||||
ezzel a kóddal állíthatók vissza. Írd fel papírra, vagy mentsd jelszókezelőbe — de NE ezen a
|
||||
szerveren tárold.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" style="margin-bottom:.75rem">
|
||||
@@ -204,7 +204,7 @@
|
||||
<h3 style="margin-top:1.25rem">3. Megerősítés</h3>
|
||||
<form id="start-form" onsubmit="return startCeremony(event)">
|
||||
<div class="form-group">
|
||||
<label for="reauth-password">A folytatáshoz adja meg a bejelentkezési jelszavát</label>
|
||||
<label for="reauth-password">A folytatáshoz add meg a bejelentkezési jelszavadat</label>
|
||||
<input type="password" id="reauth-password" class="form-control" autocomplete="current-password" required style="max-width:280px">
|
||||
</div>
|
||||
<div id="start-error" class="alert alert-error" style="display:none;margin-bottom:.75rem"></div>
|
||||
@@ -226,22 +226,22 @@
|
||||
<div class="settings-card" id="reveal-card" style="display:none">
|
||||
<h3>5. A helyreállítási kód</h3>
|
||||
<div id="reveal-gate">
|
||||
<p class="form-hint">A kód pontosan egyszer jeleníthető meg. Készítsen elő papírt és tollat, mielőtt megnyomja a gombot.</p>
|
||||
<p class="form-hint">A kód pontosan egyszer jelenik meg. Készíts elő papírt és tollat, mielőtt megnyomod a gombot.</p>
|
||||
<button class="btn btn-primary" id="reveal-btn" onclick="claimCode()">Kód megjelenítése</button>
|
||||
<div id="reveal-error" class="alert alert-error" style="display:none;margin-top:.75rem"></div>
|
||||
</div>
|
||||
<div id="reveal-shown" style="display:none">
|
||||
<div class="alert alert-warning" style="margin-bottom:.5rem">Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentse el most: a kód többé nem jeleníthető meg.</div>
|
||||
<div class="alert alert-warning" style="margin-bottom:.5rem">Ez mostantól az élő helyreállítási kód — a korábbi kód érvényét vesztette. Mentsd el most: a kód többé nem jelenik meg.</div>
|
||||
<pre class="mono" id="code-block" style="white-space:pre-wrap;word-break:break-all;background:var(--bg-0);border:1px solid var(--line);border-radius:var(--radius);padding:1rem;font-size:1.05rem"></pre>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="code-blur-toggle" onclick="toggleCodeBlur()">Elrejtés</button>
|
||||
<h3 style="margin-top:1.25rem">6. Ellenőrzés</h3>
|
||||
<div class="form-group">
|
||||
<label id="verify-label" for="verify-w1">Megerősítés: írja be a kód szavait</label>
|
||||
<label id="verify-label" for="verify-w1">Megerősítés: írd be a kód szavait</label>
|
||||
<div style="display:flex;gap:.5rem">
|
||||
<input type="text" id="verify-w1" class="form-control" autocomplete="off" style="max-width:180px">
|
||||
<input type="text" id="verify-w2" class="form-control" autocomplete="off" style="max-width:180px">
|
||||
</div>
|
||||
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizze a felírt kódot.</div>
|
||||
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.</div>
|
||||
</div>
|
||||
<button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">Szavak ellenőrzése</button>
|
||||
<div id="finish-block" style="display:none;margin-top:1rem">
|
||||
@@ -262,7 +262,7 @@
|
||||
|
||||
<div class="settings-card" id="void-card" style="display:none">
|
||||
<div class="alert alert-warning">A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le.
|
||||
Indítsa újra a folyamatot: az új kód a régit érvényteleníti.</div>
|
||||
Indítsd újra a folyamatot: az új kód a régit érvényteleníti.</div>
|
||||
<a href="/backup/escrow" class="btn btn-sm btn-primary">Újraindítás</a>
|
||||
</div>
|
||||
|
||||
@@ -416,7 +416,7 @@ async function claimCode(){
|
||||
});
|
||||
document.getElementById('reveal-gate').style.display='none';
|
||||
document.getElementById('reveal-shown').style.display='block';
|
||||
document.getElementById('verify-label').textContent='Megerősítés: írja be a kód '+verifyIdx[0]+'. és '+verifyIdx[1]+'. szavát';
|
||||
document.getElementById('verify-label').textContent='Megerősítés: írd be a kód '+verifyIdx[0]+'. és '+verifyIdx[1]+'. szavát';
|
||||
}catch(e){ errEl.textContent='Hiba: '+e.message; errEl.style.display='block'; btn.disabled=false; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user