v0.308.0: controller batch 2026-10-12 — clear-address warning (R-922), secret sweep over every page (R-255), read_failed (R-266), no NVMe heat band (R-333), share globe (R-577), Hungarian dates and numbers (R-562), lost-drive option pinned (R-521)

Operator rulings 2026-10-12, 09 §3 decisions 196, 204-207, 223, 227. MinAgent 0.131.0 unchanged.
R-255's sweep found one leak, fixed here: an optional-config secret_input was rendered into its
settings page; it is now revealed on demand. Hungarian parity fixtures re-captured once for R-562.

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:
2026-10-12 15:13:59 +02:00
parent dcd89364a4
commit fd28d70bff
183 changed files with 2671 additions and 411 deletions
@@ -16,7 +16,7 @@ func TestA3_RestoredVersionSentence(t *testing.T) {
s.versionPosition = func(string) (bool, bool) { return true, true }
got := s.restoredVersionPrefix("docmost", true, pins, at)
for _, want := range []string{"A(z) docmost visszaállt a(z) 2026-09-26 ", "-i mentésből", "docmost:0.96.0, postgres:16-alpine, redis:7-alpine verzióra", "lépésenként hozza naprakészre"} {
for _, want := range []string{"A(z) docmost visszaállt a(z) 2026. 09. 26. ", "-i mentésből", "docmost:0.96.0, postgres:16-alpine, redis:7-alpine verzióra", "lépésenként hozza naprakészre"} {
if !strings.Contains(got, want) {
t.Errorf("hu: missing %q in %q", want, got)
}
@@ -81,7 +81,7 @@ func TestBackupsRemote_KnownNonEmptyStoreRendersTheNumbers(t *testing.T) {
if !strings.Contains(html, "2 pillanatkép") {
t.Fatalf("a measured count must render")
}
if !strings.Contains(html, "12.0 MB / 50 GB") {
if !strings.Contains(html, "12,0 MB / 50 GB") { // R-562: decimal comma on a Hungarian page
t.Fatalf("a measured size must render against the quota")
}
if strings.Contains(html, "még ismeretlen") || strings.Contains(html, "még nem tudjuk") {
@@ -113,7 +113,7 @@ func TestDashboardBackupCard_ShowsLastRun(t *testing.T) {
}
card := backupCard(t, rec.Body.String())
want := dumpAt.Format("2006-01-02 15:04")
want := dumpAt.Format("2006. 01. 02. 15:04") // R-562: the Hungarian date
if !strings.Contains(card, want) {
t.Errorf("card must show the real last run %q, got:\n%s", want, card)
}
@@ -90,7 +90,8 @@ func TestDiskMeter_KnownIsUnchanged(t *testing.T) {
out := renderDiskMeter(t, healthyDisk())
// fmtGB: >=100 renders whole, >=10 renders one decimal (funcmap.go:208-215)
for _, want := range []string{"42.0 GB", "100 GB", "(42%)", "meter-fill", "nominal"} {
for _, want := range []string{"42,0 GB", "100 GB", // R-562: decimal comma on a Hungarian page
"(42%)", "meter-fill", "nominal"} {
if !strings.Contains(out, want) {
t.Errorf("a healthy box lost %q from its meter — this change must be invisible on a "+
"machine that is fine.\n%s", want, out)
+1 -1
View File
@@ -238,7 +238,7 @@ func diskAlertKindFor(sm *agentapi.SmartSummary, v agentapi.DiskVerdict, worsene
switch {
case sm != nil && sm.Health == agentapi.SmartFailing:
return notify.DiskAlertFailSelfReported
case sm != nil && sm.TemperatureC != nil && *sm.TemperatureC >= agentapi.TemperatureFailC:
case sm != nil && !agentapi.IsNVMe(sm) && sm.TemperatureC != nil && *sm.TemperatureC >= agentapi.TemperatureFailC:
return notify.DiskAlertFailTemperature
case agentapi.UncorrectableSectors(sm) > 0 && worsened:
return notify.DiskAlertFailWorsened
+105 -77
View File
@@ -11,6 +11,7 @@ import (
"unicode"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
@@ -99,7 +100,7 @@ func isOperationalState(state stacks.ContainerState) bool {
func (s *Server) templateFuncMap() template.FuncMap {
loc := getTimezone()
return template.FuncMap{
fm := template.FuncMap{
// stateColor maps container states to design-system-v2 semantic tokens,
// consumed as class suffixes (tag-run, stack-state-run, state-text-run...).
// Exception-color principle: a customer-stopped app is neutral, NOT red —
@@ -200,25 +201,6 @@ func (s *Server) templateFuncMap() template.FuncMap {
}
return "nominal"
},
"fmtMB": func(mb uint64) string {
if mb >= 1024 {
gb := float64(mb) / 1024.0
if gb >= 10 {
return fmt.Sprintf("%.0f GB", gb)
}
return fmt.Sprintf("%.1f GB", gb)
}
return fmt.Sprintf("%d MB", mb)
},
"fmtGB": func(gb float64) string {
if gb >= 100 {
return fmt.Sprintf("%.0f GB", gb)
}
if gb >= 10 {
return fmt.Sprintf("%.1f GB", gb)
}
return fmt.Sprintf("%.2f GB", gb)
},
"subtract": func(a, b int) int {
r := a - b
if r < 0 {
@@ -249,9 +231,6 @@ func (s *Server) templateFuncMap() template.FuncMap {
"fmtTemp": func(celsius float64) string {
return fmt.Sprintf("%.0f°C", celsius)
},
"fmtLoad": func(load float64) string {
return fmt.Sprintf("%.2f", load)
},
"filterCategory": func(state stacks.ContainerState, deployed bool) string {
switch state {
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
@@ -309,37 +288,8 @@ func (s *Server) templateFuncMap() template.FuncMap {
return fmt.Sprintf("%d napja", int(d.Hours()/24))
}
},
// fmtTimeStr renders an RFC3339 STRING as an absolute Budapest-local date-time. R-101: the
// Tier-2 restore confirm dialog printed a raw RFC3339 stamp ("2026-07-28T01:30:00Z") at the
// moment the customer decides whether to restore — a UTC machine timestamp is not something a
// customer can reason about. Absolute rather than relative here on purpose: "3 napja" is fine on
// a status card, but a restore decision deserves the actual date.
//
// R-102: it delegates to the package-level fmtRFC3339Local so a HANDLER can render the same
// date the same way. The Tier-2 unit restore names the copy's date in its confirm (template)
// and again in its outcome (Go); two renderings of one decision that could disagree is how a
// customer ends up confirming one date and being told another.
"fmtTimeStr": fmtRFC3339Local,
"fmtTime": func(t time.Time) string {
if t.IsZero() {
return "–"
}
return t.In(loc).Format("2006-01-02 15:04")
},
// humanBytes (v0.200.0, R-193) renders a size the same way every other surface does — the
// recovery page's listing shares the backup package's formatter rather than growing a second one.
"humanBytes": backup.HumanizeBytes,
"fmtTimeShort": func(t time.Time) string {
if t.IsZero() {
return "–"
}
lt := t.In(loc)
now := time.Now().In(loc)
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() {
return lt.Format("15:04")
}
return lt.Format("01-02 15:04")
},
// fmtTime / fmtTimeStr / fmtTimeShort / fmtMB / fmtGB / fmtBytes / fmtLoad / humanBytes:
// formatFuncs below (R-562 — per language).
"dbTypeLabel": func(t backup.DBType) string {
switch t {
case backup.DBTypePostgres:
@@ -363,7 +313,7 @@ func (s *Server) templateFuncMap() template.FuncMap {
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay()+1 {
return "holnap " + timeStr
}
return lt.Format("2006-01-02") + " " + timeStr
return i18n.FormatDate(i18n.Default, lt) + " " + timeStr
},
// R-572 (v0.258.0) — `pruneLabel` and `nextPruneLabel` WERE HERE, and they are deleted
// rather than translated. The row said "a template calling either renders Hungarian on an
@@ -384,23 +334,6 @@ func (s *Server) templateFuncMap() template.FuncMap {
}
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
},
"fmtBytes": func(b int64) string {
const (
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
)
switch {
case b >= int64(gb):
return fmt.Sprintf("%.1f GB", float64(b)/float64(gb))
case b >= int64(mb):
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
case b >= int64(kb):
return fmt.Sprintf("%.1f KB", float64(b)/float64(kb))
default:
return fmt.Sprintf("%d B", b)
}
},
"shortID": func(id string) string {
if len(id) > 8 {
return id[:8]
@@ -485,17 +418,112 @@ func (s *Server) templateFuncMap() template.FuncMap {
return false
},
}
// R-562: the date, size and number formatters, in Hungarian here; loadTemplates swaps in each
// language's own set (formatFuncs).
for name, fn := range formatFuncs(i18n.Default) {
fm[name] = fn
}
return fm
}
// fmtRFC3339Local renders an RFC3339 STRING as an absolute Budapest-local date-time, returning the
// input unchanged when it does not parse. The ONE implementation behind the `fmtTimeStr` template
// helper and every Go-side caller. See the comment on the funcmap entry for why it is shared.
func fmtRFC3339Local(s string) string {
// fmtRFC3339Local renders an RFC3339 STRING as an absolute Budapest-local date-time in lang (R-562),
// returning the input unchanged when it does not parse. The ONE implementation behind the
// `fmtTimeStr` template helper and every Go-side caller.
//
// R-101: the Tier-2 restore confirm dialog printed a raw RFC3339 stamp at the moment the customer
// decides whether to restore — a UTC machine timestamp is not something a customer can reason about.
// R-102: a HANDLER renders the same date the same way (the confirm in the template and the outcome in
// Go name one copy's date; two renderings that could disagree is how a customer confirms one date and
// is told another).
func fmtRFC3339Local(lang, s string) string {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
// A date another package already wrote as "2006-01-02 15:04" (appexport's bundle list) is
// re-laid in lang, its clock untouched.
if lt, err2 := time.Parse("2006-01-02 15:04", s); err2 == nil {
return i18n.FormatDateTime(lang, lt)
}
return s
}
return t.In(getTimezone()).Format("2006-01-02 15:04")
return i18n.FormatDateTime(lang, t.In(getTimezone()))
}
// fmtLocalTime renders a time as a Budapest-local date-time in lang (R-562). "–" for a zero time.
func fmtLocalTime(lang string, t time.Time) string {
if t.IsZero() {
return "–"
}
return i18n.FormatDateTime(lang, t.In(getTimezone()))
}
// fmtSizeBytes is the dashboard's B/KB/MB/GB size, one decimal, in lang's decimal separator.
func fmtSizeBytes(lang string, b int64) string {
const (
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
)
switch {
case b >= int64(gb):
return i18n.FormatDecimal(lang, float64(b)/float64(gb), 1) + " GB"
case b >= int64(mb):
return i18n.FormatDecimal(lang, float64(b)/float64(mb), 1) + " MB"
case b >= int64(kb):
return i18n.FormatDecimal(lang, float64(b)/float64(kb), 1) + " KB"
default:
return fmt.Sprintf("%d B", b)
}
}
// formatFuncs (R-562, `09` §3 decision 223) — every template func that prints a date, a size or a
// decimal number, for ONE language: Hungarian „2026. 10. 10. 15:04" and a decimal comma, English
// „2026-10-10 15:04" and a decimal point. The layouts live in internal/i18n (FormatDateTime and
// siblings); this map only binds them to the names the templates call. Pinned by
// TestFormatFuncs_PerLanguage.
func formatFuncs(lang string) template.FuncMap {
loc := getTimezone()
return template.FuncMap{
"fmtMB": func(mb uint64) string {
if mb >= 1024 {
gb := float64(mb) / 1024.0
if gb >= 10 {
return i18n.FormatDecimal(lang, gb, 0) + " GB"
}
return i18n.FormatDecimal(lang, gb, 1) + " GB"
}
return fmt.Sprintf("%d MB", mb)
},
"fmtGB": func(gb float64) string {
if gb >= 100 {
return i18n.FormatDecimal(lang, gb, 0) + " GB"
}
if gb >= 10 {
return i18n.FormatDecimal(lang, gb, 1) + " GB"
}
return i18n.FormatDecimal(lang, gb, 2) + " GB"
},
"fmtLoad": func(load float64) string { return i18n.FormatDecimal(lang, load, 2) },
"fmtTimeStr": func(s string) string { return fmtRFC3339Local(lang, s) },
"fmtTime": func(t time.Time) string { return fmtLocalTime(lang, t) },
// humanBytes (v0.200.0, R-193) shares the backup package's formatter so the recovery page's
// listing reads like every other size; its decimal point follows the language here.
"humanBytes": func(n int64) string { return i18n.LocalizeDecimals(lang, backup.HumanizeBytes(n)) },
"fmtTimeShort": func(t time.Time) string {
if t.IsZero() {
return "–"
}
lt := t.In(loc)
now := time.Now().In(loc)
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() {
return lt.Format("15:04")
}
return i18n.FormatMonthDayTime(lang, lt)
},
"fmtBytes": func(b int64) string { return fmtSizeBytes(lang, b) },
// numText rewrites the decimal point of a size another package already formatted („1.5 GB",
// the *Human fields) into this language's separator.
"numText": func(s string) string { return i18n.LocalizeDecimals(lang, s) },
}
}
// stackOf lets the update-path template funcs take a stack the way each page holds it (app_info: a
+51 -25
View File
@@ -567,7 +567,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
dp.NotAllowedNote = s.msg(r, "deploy.path_not_allowed")
}
if di := system.GetDiskUsage(sp.Path); di != nil {
dp.FreeHuman = formatFreeSpace(di.AvailGB)
dp.FreeHuman = formatFreeSpace(s.langFor(r), di.AvailGB)
if di.TotalGB > 0 {
dp.FreePercent = 100 - system.DFUsedPercent(di.UsedGB, di.AvailGB) // R-889: df's number
}
@@ -601,8 +601,8 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
if !alreadyDeployed {
if hr := system.GetDockerVolumeHeadroom(); hr.OK {
data["DockerBelowReserve"] = hr.BelowReserve
data["DockerFreeHuman"] = formatFreeSpace(hr.AvailGB)
data["DockerReserveHuman"] = formatFreeSpace(hr.ReserveGB)
data["DockerFreeHuman"] = formatFreeSpace(s.langFor(r), hr.AvailGB)
data["DockerReserveHuman"] = formatFreeSpace(s.langFor(r), hr.ReserveGB)
}
}
@@ -889,7 +889,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
// in the REQUEST's language (a finished sentence passed as page data must be rendered per request,
// never taken from a Hungarian store — the composed-sentence class, R-573/R-590/R-596/R-598).
if found.Deployed && found.AppConfig != nil && found.AppConfig.LastUpdateUndone != nil {
data["UpdateUndoneLine"] = s.msg(r, "app_info.update_undone", found.Name, undoneWhen(found.AppConfig.LastUpdateUndone.At))
data["UpdateUndoneLine"] = s.msg(r, "app_info.update_undone", found.Name, undoneWhen(s.langFor(r), found.AppConfig.LastUpdateUndone.At))
}
// v0.271.0 (`09` §6.4 part 7): a SUCCESSFUL automatic update is told on the page only — no mail.
// An undone or held automatic step keeps the lines above (they already say it), so this line is for
@@ -897,7 +897,7 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
// a restore makes it history, and the page stops saying it).
if found.Deployed && found.AppConfig != nil && found.AppConfig.LastAutoUpdate != nil &&
found.AppConfig.LastAutoUpdate.Outcome == stacks.LegOutcomeDone && sameImageMap(found.AppConfig.LastAutoUpdate.To, found.AppConfig.PinnedImages) {
data["AutoUpdateLine"] = s.msg(r, "app_info.auto_update_done", undoneWhen(found.AppConfig.LastAutoUpdate.At))
data["AutoUpdateLine"] = s.msg(r, "app_info.auto_update_done", undoneWhen(s.langFor(r), found.AppConfig.LastAutoUpdate.At))
}
// R-621: a held app's own log, kept by the hold before its containers were removed, is reachable
@@ -1037,7 +1037,7 @@ func (s *Server) backupsOffboxData(data map[string]interface{}, lang string) {
}
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["OffsiteFitTotal"] = i18n.LocalizeDecimals(lang, appbackup.HumanizeBytes(f.TotalBytes))
data["OffsiteFitQuota"] = offboxTgt.QuotaGB
var top []string
for i, a := range f.Apps {
@@ -1048,7 +1048,7 @@ func (s *Server) backupsOffboxData(data map[string]interface{}, lang string) {
if st, ok := s.stackMgr.GetStack(a.Stack); ok && st.Meta.DisplayName != "" {
name = st.Meta.DisplayName
}
top = append(top, name+" ("+appbackup.HumanizeBytes(a.Bytes)+")")
top = append(top, name+" ("+i18n.LocalizeDecimals(lang, appbackup.HumanizeBytes(a.Bytes))+")")
}
data["OffsiteFitTop"] = strings.Join(top, ", ")
}
@@ -1164,7 +1164,7 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
if st := s.backupMgr.AbandonStatus(); st.Active {
data["AbandonActive"] = true
data["AbandonDaysLeft"] = st.DaysLeft
data["AbandonDate"] = st.DueAt.Format("2006-01-02")
data["AbandonDate"] = i18n.FormatDate(s.langFor(r), st.DueAt)
// R-365: DaysLeft is a ceiling, so 0 means the due moment has PASSED (the terminal step waits
// for the next daily sweep, up to ~29 h). The future-tense „… napján véglegesen töröljük" then
// named a date in the past; the overdue sentence says the deletion is due and when it runs.
@@ -1685,7 +1685,7 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus, lang string
row.Tier2CopyDate, row.Tier2CopyDateProven = pkgDate, rp.CopyDateProven
row.Tier2UnitConfirm = s.tier2UnitConfirmWithStaleness(pkgDate, row.Tier2CopyDateProven, stale)
if stale && pkgDate != "" {
row.Tier2UnitStaleNotice = s.msgLang(lang, tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate))
row.Tier2UnitStaleNotice = s.msgLang(lang, tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(lang, pkgDate))
}
}
switch cd.LastStatus {
@@ -1927,7 +1927,7 @@ func (s *Server) restoredVersionPrefix(app string, changed bool, pins []string,
if !climbable {
key = restoredOlderVersionNoStepKey
}
return s.note(key, app, at.In(getTimezone()).Format("2006-01-02 15:04"), backup.PinsVersion(pins)) + " "
return s.note(key, app, fmtLocalTime(s.boxLang(), at), backup.PinsVersion(pins)) + " "
}
const (
@@ -2065,9 +2065,9 @@ func (s *Server) tier2UnitConfirmWithStaleness(copyDate string, proven bool, sta
msg := s.note(tier2UnitConfirmBaseKey)
if copyDate != "" {
if proven {
msg += s.note(tier2UnitConfirmDateFmtKey, fmtRFC3339Local(copyDate))
msg += s.note(tier2UnitConfirmDateFmtKey, fmtRFC3339Local(s.boxLang(), copyDate))
} else {
msg += s.note(tier2UnitConfirmDateUnprovenFmtKey, fmtRFC3339Local(copyDate))
msg += s.note(tier2UnitConfirmDateUnprovenFmtKey, fmtRFC3339Local(s.boxLang(), copyDate))
}
}
msg += s.note(tier2UnitConfirmContrastKey)
@@ -2093,9 +2093,9 @@ func (s *Server) tier2UnitSourceMsg(cov backup.Tier2Coverage) string {
return ""
}
if proven {
return s.note(tier2UnitRestoreSourceKey, fmtRFC3339Local(date))
return s.note(tier2UnitRestoreSourceKey, fmtRFC3339Local(s.boxLang(), date))
}
return s.note(tier2UnitRestoreSourceUnprovenKey, fmtRFC3339Local(date))
return s.note(tier2UnitRestoreSourceUnprovenKey, fmtRFC3339Local(s.boxLang(), date))
}
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
@@ -2330,7 +2330,7 @@ func (s *Server) systemPageData() map[string]interface{} {
// 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["LastCheckTime"] = fmtLocalTime(s.boxLang(), t)
}
data["LastCheckError"] = status.LastCheck.Error
}
@@ -2593,6 +2593,18 @@ func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Reques
}
}
}
// R-255 (v0.308.0): an optional-config `secret_input` (a key the household typed in for the app, e.g. a
// metadata provider's API key) is no longer rendered into the settings page; it is revealed here on demand,
// behind the same session + CSRF as the auto-generated secrets.
if !allowed {
for _, g := range stack.Meta.OptionalConfig {
for _, f := range g.Fields {
if f.EnvVar == envVar && f.Type == "secret_input" {
allowed = true
}
}
}
}
if !allowed {
s.logger.Printf("[WARN] [web] auto-field reveal refused for %s/%s: not an auto-generated secret field", stackName, envVar)
escrowJSON(w, http.StatusForbidden, nil, s.msg(r, "escrow.field_not_revealable"))
@@ -2945,6 +2957,24 @@ func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Req
return
}
// R-922 warning (operator ruling 2026-10-12, S1, `09` §3 decision 196): clearing a STORED address is said
// first — „you will get no more notification mails" — and nothing is saved or sent until the household
// confirms with the page's own button (confirm_clear=1). Server-side, so it holds without JavaScript and
// for any client. A box whose address is already empty has nothing to warn about. Pinned by
// TestR922_ClearWarnsFirst.
if cur := s.settings.GetNotificationPrefs(); email == "" && cur != nil && cur.Email != "" && r.FormValue("confirm_clear") != "1" {
s.logger.Printf("[INFO] [web] Notification address clear: asking the household to confirm first (nothing saved)")
data := s.notificationsPageData()
data["NotificationPrefs"] = &settings.NotificationPrefs{
Email: email,
EnabledEvents: enabledEvents,
CooldownHours: cooldownHours,
}
data["NotificationClearConfirm"] = true
s.executeTemplate(w, r, "settings_notifications", data)
return
}
// R-922 (operator ruling 2026-10-09, option A): an empty address where one was stored is the household's
// deliberate clear — persisted, so this push AND every later one carry `email_cleared` while the address
// stays empty, and the hub deletes the address it holds. A box that never had one does not set it.
@@ -3278,7 +3308,7 @@ func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
}
}
if any {
detail.SizeHuman = humanizeDirBytes(total)
detail.SizeHuman = fmtSizeBytes(s.boxLang(), total)
}
details = append(details, detail)
}
@@ -3322,11 +3352,11 @@ func dirSizeHuman(path string) string {
return humanizeDirBytes(dirSizeBytesWalk(path))
}
func formatFreeSpace(gb float64) string {
func formatFreeSpace(lang string, gb float64) string {
if gb >= 1000 {
return fmt.Sprintf("%.1f TB", gb/1024)
return i18n.FormatDecimal(lang, gb/1024, 1) + " TB"
}
return fmt.Sprintf("%.1f GB", gb)
return i18n.FormatDecimal(lang, gb, 1) + " GB"
}
func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
@@ -3819,10 +3849,6 @@ func sameImageMap(a, b map[string]string) bool {
return true
}
func undoneWhen(rfc3339 string) string {
t, err := time.Parse(time.RFC3339, rfc3339)
if err != nil {
return rfc3339
}
return t.In(getTimezone()).Format("2006-01-02 15:04")
func undoneWhen(lang, rfc3339 string) string {
return fmtRFC3339Local(lang, rfc3339)
}
@@ -267,6 +267,14 @@ func i18nCasesA() []i18nCase {
d["AppEmailSuccess"], d["AppEmailError"] = "Az e-mail beállítás mentve.", "Az e-mail beállítás hibás."
return d
}},
// v0.308.0 (R-922 warning, decision 196): the clear-address warning and its confirm button, captured when born.
{"settings_notifications_clear_confirm", "settings_notifications", func() map[string]interface{} {
d := i18nLayoutData("settings-notifications", "Értesítések")
d["HubEnabled"] = true
d["NotificationClearConfirm"] = true
d["NotificationPrefs"] = map[string]interface{}{"Email": "", "EnabledEvents": []string{}, "CooldownHours": 6}
return d
}},
{"settings_notifications_nohub", "settings_notifications", func() map[string]interface{} {
d := i18nLayoutData("settings-notifications", "Értesítések")
d["HubEnabled"] = false
+1 -1
View File
@@ -356,7 +356,7 @@ func (s *Server) localeFuncs(lang string) template.FuncMap {
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay()+1 {
return fmt.Sprintf(b.Msg(lang, "func.time.tomorrow_at"), hm)
}
return lt.Format("2006-01-02") + " " + hm
return i18n.FormatDate(lang, lt) + " " + hm
},
// infraMeta: the protected infra stacks' curated identity (inframeta.go). Names that are product
// names stay; descriptions — and samba's display name — come from the bundle.
+5 -4
View File
@@ -13,6 +13,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
@@ -79,7 +80,7 @@ func (s *Server) keptBackupFor(lang string, it stacks.KeptItem, off map[string]b
// A dated folder's own unit is the copy taken with those files.
if it.UnitDir != "" {
if c, ok := s.backupMgr.KeptCopyAt(it.UnitDir, it.Drive, 1); ok {
return s.msgLang(lang, "kept.backup.with", c.Time.In(getTimezone()).Format("2006-01-02 15:04")), c, true
return s.msgLang(lang, "kept.backup.with", fmtLocalTime(lang, c.Time)), c, true
}
}
} else {
@@ -90,7 +91,7 @@ func (s *Server) keptBackupFor(lang string, it stacks.KeptItem, off map[string]b
if !ok {
return none, backup.KeptCopy{}, false
}
return s.msgLang(lang, backup.KeptCopyKey(c.Tier), c.Time.In(getTimezone()).Format("2006-01-02 15:04")), c, true
return s.msgLang(lang, backup.KeptCopyKey(c.Tier), fmtLocalTime(lang, c.Time)), c, true
}
// keptOffsite asks the off-site repository ONCE for every listed app.
@@ -117,8 +118,8 @@ func (s *Server) keptPageHandler(w http.ResponseWriter, r *http.Request) {
backupName, _, can := s.keptBackupFor(lang, it, off)
row := keptRow{
DisplayName: it.DisplayName, App: it.App, Path: it.Path, Drive: it.Drive,
Date: it.Date.In(getTimezone()).Format("2006-01-02 15:04"),
Size: appbackup.HumanizeBytes(it.SizeBytes), Backup: backupName, CanLoad: can && it.App != "",
Date: fmtLocalTime(lang, it.Date),
Size: i18n.LocalizeDecimals(lang, appbackup.HumanizeBytes(it.SizeBytes)), Backup: backupName, CanLoad: can && it.App != "",
LookURL: fileBrowserLink(s.cfg.Customer.Domain, s.msgLang(s.boxLang(), "kept.fb_source"), KeptViewName(it)),
}
row.DeleteText = s.msgLang(lang, "kept.delete.confirm", it.DisplayName, row.Size)
+63 -10
View File
@@ -254,27 +254,80 @@ func TestGlobeOnAnonymousShells(t *testing.T) {
}
}
// TestGuestSharePagesHaveNoGlobe — the pages a STRANGER meets. Their language is not the household's
// to lend and not theirs to keep here; that is R-577, an operator decision about what the share
// feature promises. Pinned so it stays a decision rather than an oversight.
func TestGuestSharePagesHaveNoGlobe(t *testing.T) {
// TestGuestSharePagesHaveGlobe — R-577 (operator ruling 2026-10-12, `09` §3 decision 207): the pages a
// STRANGER meets through a share link carry the language globe, and it is the VISITOR's: it posts to /lang
// (their own display cookie, no CSRF, never the household's setting). The catch-all page is not a share
// page and stays without one.
//
// RED-PROOF (REPORT.md): remove the globe line from launcher_shared.html → the launcher_shared_apps case
// fails with "carries no language globe".
func TestGuestSharePagesHaveGlobe(t *testing.T) {
cases := map[string]i18nCase{}
for _, c := range i18nCases() {
cases[c.name] = c
}
for _, name := range []string{"launcher_shared_apps", "launcher_share_password", "catchall_unknown"} {
c, ok := cases[name]
for _, tc := range []struct {
name string
globe bool
}{{"launcher_shared_apps", true}, {"launcher_share_password", true}, {"catchall_unknown", false}} {
c, ok := cases[tc.name]
if !ok {
t.Fatalf("no parity case %q — this test no longer covers what it names", name)
t.Fatalf("no parity case %q — this test no longer covers what it names", tc.name)
}
s := i18nTestServer(t)
var b strings.Builder
if err := s.executeTemplateLang(&b, langReq(http.MethodGet, "/x"), c.tmpl, c.data()); err != nil {
if err := s.executeTemplateLang(&b, langReq(http.MethodGet, "/s/tok"), c.tmpl, c.data()); err != nil {
t.Fatal(err)
}
if strings.Contains(b.String(), "lang-globe") {
t.Errorf("%s carries a language globe — R-577 is the decision that would put one there", name)
got := b.String()
if !tc.globe {
if strings.Contains(got, "lang-globe") {
t.Errorf("%s carries a language globe — it is not a share page", tc.name)
}
continue
}
if !strings.Contains(got, "lang-globe") {
t.Errorf("%s carries no language globe (R-577)", tc.name)
continue
}
if !strings.Contains(got, `action="/lang"`) || strings.Contains(got, `action="/settings/language"`) {
t.Errorf("%s: the globe does not post to the visitor's own switch (/lang)", tc.name)
}
if !strings.Contains(got, `name="back" value="/s/tok"`) {
t.Errorf("%s: the globe does not bring the visitor back to the share page", tc.name)
}
}
}
// R-577: a share visitor's own cookie decides the page's language, over the household's setting, on the
// real route (/s/<token> through the full mux). A Hungarian household; the visitor picked English.
func TestGuestSharePage_HonoursVisitorCookie(t *testing.T) {
s := shareTestServer(t)
if err := s.settings.SetLauncherShareToken(testShareToken); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
cookie, wantLang string
}{{"", "hu"}, {"en", "en"}, {"hu", "hu"}} {
req := httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil)
if tc.cookie != "" {
req.AddCookie(langCookie(tc.cookie))
}
rr := httptest.NewRecorder()
s.fullMux().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /s/<token> = %d", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, `<html lang="`+tc.wantLang+`"`) {
t.Errorf("cookie %q: the page is not in %q", tc.cookie, tc.wantLang)
}
if !strings.Contains(body, "lang-globe") {
t.Errorf("cookie %q: the share page carries no globe", tc.cookie)
}
}
if got := s.settings.GetLanguage(); got != "hu" {
t.Errorf("a share visitor's page changed the household's language to %q", got)
}
}
@@ -39,6 +39,9 @@ var metaCopyReadAllowlist = map[string]string{
// buildDataPathCards is called with the same localised `found`; the labels it renders are the
// English ones when the household is on English.
"datapath_card.go:DataPaths": "buildDataPathCards is handed the localised stack by appDetailHandler",
// R-255 (v0.308.0): appAutoFieldRevealHandler reads only EnvVar and Type of each optional field, to
// AUTHORISE a reveal; no label or help text reaches a page from here.
"handlers.go:OptionalConfig": "appAutoFieldRevealHandler: EnvVar/Type authorisation only, no copy rendered",
}
// metaCopyFields are the Metadata fields that carry customer-facing TEXT. Everything else on
@@ -114,6 +114,7 @@ func TestNotificationsGuard_AllowsEmptyEmailZeroEvents(t *testing.T) {
rr := postNotifications(t, s, url.Values{
"notification_email": {""},
"cooldown_hours": {"6"},
"confirm_clear": {"1"}, // R-922 warning: the household confirmed the clear (TestR922_ClearWarnsFirst)
}) // no event_* checkboxes
// The save PROCEEDED (guard did not fire): the email is intentionally cleared. (Note:
+1 -1
View File
@@ -568,7 +568,7 @@ const offsiteScratchIncompleteKey = "note.restore.scratch_incomplete"
func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult, lang string) string {
when := ""
if !res.DumpsAt.IsZero() {
when = util.Text(lang, "note.restore.dumps_at", res.DumpsAt.In(getTimezone()).Format("2006-01-02 15:04"))
when = util.Text(lang, "note.restore.dumps_at", fmtLocalTime(lang, res.DumpsAt))
}
// R-354 — WHAT ACTUALLY CAME BACK, NAMED. The volume leg is stated whenever it returned anything,
// because a restore that replayed an app's entire dataset and mentioned only its file count is
@@ -131,7 +131,7 @@ func TestR103_ConfirmStatesTheOverwrite(t *testing.T) {
// the screen. R-101 governs the wording when the date is an ATTEMPT rather than a proven copy.
func TestR103_ConfirmNamesTheCopyDate(t *testing.T) {
const stamp = "2026-08-25T03:30:00Z"
rendered := fmtRFC3339Local(stamp)
rendered := fmtRFC3339Local("hu", stamp)
if rendered == stamp {
t.Fatal("the stamp did not render as a local date — the assertion below would be vacuous")
}
@@ -355,7 +355,7 @@ func TestR103_HandlerPublishesTheOutcome(t *testing.T) {
if want := wantOutcome + " " + wantSource; st.Last.Message != want {
t.Errorf("published message =\n %q\nwant\n %q", st.Last.Message, want)
}
if !strings.Contains(st.Last.Message, fmtRFC3339Local(r103CopyStamp)) {
if !strings.Contains(st.Last.Message, fmtRFC3339Local("hu", r103CopyStamp)) {
t.Error("the outcome does not name the date of the copy it restored from")
}
}
@@ -0,0 +1,263 @@
package web
import (
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-255 (operator ruling 2026-10-12, S9, `09` §3 decision 204): ONE test renders EVERY dashboard page with
// planted secret values in the data it renders from, and fails if any planted value reaches the HTML.
//
// Why a render and not only scripts/secret_in_markup_gate.py: the gate reads TEMPLATES and is blind to a
// secret that arrives under a neutral page-data key (R-254 site two was exactly that shape). A rendered body
// cannot hide it.
//
// What is planted, per page and per parity case (i18nCases — the same data the parity fixtures render):
//
// 1. every STRING in the case's data whose field or map-key NAME says secret (secretNameRe: password,
// passphrase, secret, token, API key, private key, recovery code, credential, an env var ending _KEY)
// — at any depth: maps, slices, structs, pointers;
// 2. the secret-carrying keys handlers really set (RetrievalPassword, InitialCreds.Password — R-249/R-254)
// and the names a future handler would reach for (RecoveryCode, Passphrase, SharePassword, …).
//
// Each planted value is unique, so a hit names the field it came from. The ONE deliberate exception is the
// pre-deploy form of an app not yet installed: its generated secrets are SHOWN so the household can note them
// down, and carried in a hidden input so the saved value is the one they saw (README §318; R-254 site two
// keeps both, TestDeployPage_PreDeployForm_StillCarriesTheValue_Deliberately pins it). It is allowed only
// there, only in those two exact markups.
//
// EVERY PAGE: a page template is a file whose `{{define}}` carries its own base name (launcher.html defines
// "launcher"); the test derives that list from the embedded templates and fails if any page has no case —
// a new page cannot be added without joining this sweep.
//
// RED-PROOF (REPORT.md): plant `<span hidden>{{.RetrievalPassword}}</span>` into settings_security.html →
// this fails naming the page and the planted field.
var secretNameRe = regexp.MustCompile(`(?i)(pass(word|phrase)|secret|token|api_?key|private_?key|recovery_?code|credential|_key$)`)
// notSecretNames are names the pattern matches that carry no secret: the CSRF tokens a form must carry
// (session-bound, useless without the session) and a password-change ERROR MESSAGE.
var notSecretNames = map[string]bool{
"CSRFToken": true, "CSRFField": true, "FormToken": true, "LangCSRF": true, "PasswordError": true,
}
const plantedPrefix = "PLANTEDSECRET"
type planter struct {
n int
where map[string]string // sentinel -> field path
}
func (p *planter) next(path string) string {
p.n++
v := fmt.Sprintf("%s%03dX", plantedPrefix, p.n)
p.where[v] = path
return v
}
func isSecretName(name string) bool {
return !notSecretNames[name] && secretNameRe.MatchString(name)
}
// plant returns a copy of v in which every string under a secret-named field or key is a sentinel.
func (p *planter) plant(v reflect.Value, path string, secretCtx bool) reflect.Value {
if !v.IsValid() {
return v
}
switch v.Kind() {
case reflect.String:
if secretCtx {
out := reflect.New(v.Type()).Elem()
out.SetString(p.next(path))
return out
}
return v
case reflect.Interface:
if v.IsNil() {
return v
}
inner := p.plant(v.Elem(), path, secretCtx)
out := reflect.New(v.Type()).Elem()
out.Set(inner)
return out
case reflect.Ptr:
if v.IsNil() {
return v
}
inner := p.plant(v.Elem(), path, secretCtx)
out := reflect.New(v.Type().Elem())
out.Elem().Set(inner)
return out
case reflect.Map:
if v.IsNil() {
return v
}
out := reflect.MakeMapWithSize(v.Type(), v.Len())
for _, k := range v.MapKeys() {
name := fmt.Sprint(k.Interface())
out.SetMapIndex(k, p.plant(v.MapIndex(k), path+"."+name, isSecretName(name)))
}
return out
case reflect.Slice:
if v.IsNil() {
return v
}
out := reflect.MakeSlice(v.Type(), v.Len(), v.Len())
for i := 0; i < v.Len(); i++ {
out.Index(i).Set(p.plant(v.Index(i), fmt.Sprintf("%s[%d]", path, i), secretCtx))
}
return out
case reflect.Struct:
out := reflect.New(v.Type()).Elem()
out.Set(v)
for i := 0; i < v.NumField(); i++ {
f := v.Type().Field(i)
if f.PkgPath != "" { // unexported: cannot be set, and a template cannot read it either
continue
}
out.Field(i).Set(p.plant(v.Field(i), path+"."+f.Name, isSecretName(f.Name)))
}
return out
}
return v
}
// pageTemplates lists every template file that defines a template carrying its own base name.
func pageTemplates(t *testing.T) []string {
t.Helper()
names, err := fs.Glob(templateFS, "templates/*.html")
if err != nil {
t.Fatal(err)
}
var pages []string
for _, n := range names {
raw, err := templateFS.ReadFile(n)
if err != nil {
t.Fatal(err)
}
base := strings.TrimSuffix(path.Base(n), ".html")
src := string(raw)
// A PAGE is a whole document: its own <!DOCTYPE> shell, or the dashboard chrome. A partial that
// happens to carry its file's name (lang_globe, meta_badge) is rendered inside pages, not alone.
whole := strings.Contains(src, "<!DOCTYPE") || strings.Contains(src, `{{template "layout_start"`)
if whole && strings.Contains(src, `{{define "`+base+`"}}`) {
pages = append(pages, base)
}
}
sort.Strings(pages)
return pages
}
func TestR255_NoPlantedSecretReachesAnyPage(t *testing.T) {
pages := pageTemplates(t)
if len(pages) < 30 {
t.Fatalf("only %d page templates found — the page list is not being read", len(pages))
}
cases := i18nCases()
covered := map[string]int{}
for _, c := range cases {
covered[c.tmpl]++
}
for _, pg := range pages {
if covered[pg] == 0 {
t.Errorf("page template %q has no parity case, so this sweep never renders it — add one to i18nCases", pg)
}
}
s := i18nTestServer(t)
planted := 0
for _, c := range cases {
p := &planter{where: map[string]string{}}
data := p.plant(reflect.ValueOf(c.data()), c.name, false).Interface().(map[string]interface{})
// The secret-carrying keys handlers set (R-249, R-254) and the names a future one would use.
for _, k := range []string{"RetrievalPassword", "RecoveryCode", "Passphrase", "EscrowPassphrase",
"SharePassword", "HubAPIKey", "AgentToken", "ResticPassword", "SessionToken", "PrivateKey"} {
data[k] = p.next(c.name + "." + k)
}
data["InitialCreds"] = &stacks.ExtractedCreds{Available: true, Username: "admin", Password: p.next(c.name + ".InitialCreds.Password")}
planted += len(p.where)
for _, lang := range []string{"hu", "en"} {
html := renderI18nCase(t, s, lang, i18nCase{c.name, c.tmpl, func() map[string]interface{} { return data }})
for sentinel, from := range p.where {
if !strings.Contains(html, sentinel) {
continue
}
if deliberatePreDeployInput(c, data, html, sentinel) {
continue
}
t.Errorf("%s [%s] (page %q): a planted secret reached the HTML — from %s", c.name, lang, c.tmpl, from)
}
}
}
if planted < 200 {
t.Fatalf("only %d values were planted across %d cases — the planter is not reaching the data", planted, len(cases))
}
t.Logf("R-255 sweep: %d pages, %d cases, %d planted values, both languages", len(pages), len(cases), planted)
}
// deliberatePreDeployInput: the ONE place a secret value belongs in a page — the pre-deploy form's hidden
// input, which must submit the generated value the household was shown (README §318). Only on the deploy
// page, only for an app not yet deployed, only in that exact markup and nowhere else in the page.
func deliberatePreDeployInput(c i18nCase, data map[string]interface{}, html, sentinel string) bool {
if c.tmpl != "deploy" {
return false
}
if deployed, _ := data["AlreadyDeployed"].(bool); deployed {
return false
}
q := regexp.QuoteMeta(sentinel)
hidden := regexp.MustCompile(`<input type="hidden" name="[A-Z0-9_]+" value="` + q + `">`)
shown := regexp.MustCompile(`<input type="password" id="auto-field-[A-Z0-9_]+" class="form-control" value="` + q + `" readonly>`)
return strings.Count(html, sentinel) == len(hidden.FindAllString(html, -1))+len(shown.FindAllString(html, -1))
}
// R-255's finding, fixed in the same session: an optional-config `secret_input` (a key the household typed in
// for its app) was rendered into `value="…"` of its settings page. It is now revealed on demand. The reveal
// endpoint serves it to the household's session and still refuses a field that is not a secret_input.
//
// COMPANION RED-PROOF (REPORT.md): drop the secret_input branch in appAutoFieldRevealHandler → the first case
// 403s, and the household can no longer see the key it saved.
func TestR255_OptionalSecretIsRevealedOnDemand(t *testing.T) {
s := gateHarness(t)
app := filepath.Join(s.cfg.Paths.StacksDir, "gapp")
meta := "display_name: G\nslug: gapp\noptional_config:\n - group: Kulso\n fields:\n - env_var: META_API_KEY\n type: secret_input\n - env_var: META_REGION\n type: text\n"
if err := os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte(meta), 0o644); err != nil {
t.Fatal(err)
}
const key = "TESTONLY-optional-api-key-Q7"
if err := stacks.SaveAppConfig(app, &stacks.AppConfig{Deployed: true, Env: map[string]string{"META_API_KEY": key, "META_REGION": "eu"}}, nil, nil); err != nil {
t.Fatal(err)
}
if err := s.stackMgr.ScanStacks(); err != nil {
t.Fatal(err)
}
ask := func(field string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "/stacks/gapp/auto-field/reveal", strings.NewReader("env_var="+field))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.appAutoFieldRevealHandler(w, r, "gapp")
return w
}
if w := ask("META_API_KEY"); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), key) {
t.Fatalf("the saved optional key is not revealable: %d", w.Code)
}
if w := ask("META_REGION"); w.Code != http.StatusForbidden {
t.Fatalf("a non-secret optional field went through the secret reveal: %d", w.Code)
}
if w := ask("NOPE"); w.Code != http.StatusForbidden {
t.Fatalf("an unknown field: %d", w.Code)
}
}
@@ -36,7 +36,7 @@ func TestR365_OverdueCountdownIsNotFutureTense(t *testing.T) {
overdue := time.Now().Add(-20 * time.Hour)
html := render(t, overdue)
date := overdue.UTC().Format("2006-01-02")
date := overdue.UTC().Format("2006. 01. 02.") // R-562: the Hungarian date
if !strings.Contains(html, "óta esedékes") || !strings.Contains(html, date) {
t.Errorf("R-365: an overdue countdown does not say the deletion is due since %s", date)
}
@@ -43,7 +43,7 @@ func TestR365_BannerSaysDueAtZeroDays(t *testing.T) {
overdue := time.Now().Add(-20 * time.Hour)
bar := render(t, overdue)
if !strings.Contains(bar, "óta esedékes") || !strings.Contains(bar, overdue.UTC().Format("2006-01-02")) {
if !strings.Contains(bar, "óta esedékes") || !strings.Contains(bar, overdue.UTC().Format("2006. 01. 02.")) { // R-562
t.Errorf("R-365: at 0 days the bar does not say the deletion is due:\n%s", bar)
}
if strings.Contains(bar, "remind-optout") {
+7 -7
View File
@@ -32,8 +32,8 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) {
t.Error("the preserved-package notice is not on the page — a preserved copy renders as a fresh one")
}
// The PACKAGE's date is shown, not only the run's.
if !strings.Contains(html, fmtRFC3339Local(pkgDate)) {
t.Errorf("the package's own date %q is not on the page", fmtRFC3339Local(pkgDate))
if !strings.Contains(html, fmtRFC3339Local("hu", pkgDate)) {
t.Errorf("the package's own date %q is not on the page", fmtRFC3339Local("hu", pkgDate))
}
// NEGATIVE CONTROL: an ordinary row must NOT carry the notice, or D1 would pass on a page that
@@ -49,7 +49,7 @@ func TestR403_SkippedUnitLegIsNotRenderedAsFresh(t *testing.T) {
}
func staleNoticeFor(t *testing.T, pkgDate string) string {
return noteServer(t).note(tier2UnitStaleNoticeFmtKey, fmtRFC3339Local(pkgDate))
return noteServer(t).note(tier2UnitStaleNoticeFmtKey, fmtRFC3339Local("hu", pkgDate))
}
// D2 — TestR403_UnitRestoreOfferNamesTheOlderPackageDate.
@@ -64,7 +64,7 @@ func TestR403_UnitRestoreOfferNamesTheOlderPackageDate(t *testing.T) {
if !strings.Contains(staleConfirm, noteHU(t, tier2UnitStaleClauseKey)) {
t.Error("the confirm does not say the package is older than the newest run")
}
if !strings.Contains(staleConfirm, fmtRFC3339Local(pkgDate)) {
if !strings.Contains(staleConfirm, fmtRFC3339Local("hu", pkgDate)) {
t.Error("the confirm does not name the package's date")
}
// Everything the ordinary confirm promised is still promised.
@@ -114,8 +114,8 @@ func TestR403_OutcomeNamesThePackageDateNotTheRunDate(t *testing.T) {
UnitLegPreserved: true,
}
msg := noteServer(t).tier2UnitSourceMsg(cov)
pkg := fmtRFC3339Local("2026-08-31T09:43:41Z")
run := fmtRFC3339Local("2026-08-31T12:23:51Z")
pkg := fmtRFC3339Local("hu", "2026-08-31T09:43:41Z")
run := fmtRFC3339Local("hu", "2026-08-31T12:23:51Z")
if !strings.Contains(msg, pkg) {
t.Errorf("the outcome does not name the package's date %q: %q", pkg, msg)
}
@@ -131,7 +131,7 @@ func TestR403_OutcomeNamesThePackageDateNotTheRunDate(t *testing.T) {
// NEGATIVE CONTROL: with no preserved leg, the package date IS the fresh one and both agree on it.
fresh := backup.Tier2Coverage{CopyLastSuccess: "2026-08-31T12:23:51Z", UnitPackageDate: "2026-08-31T12:23:00Z"}
if !strings.Contains(noteServer(t).tier2UnitSourceMsg(fresh), fmtRFC3339Local("2026-08-31T12:23:00Z")) {
if !strings.Contains(noteServer(t).tier2UnitSourceMsg(fresh), fmtRFC3339Local("hu", "2026-08-31T12:23:00Z")) {
t.Error("the ordinary outcome stopped naming its own package date")
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ func TestR516_DisconnectTimeIsLocalNotRawUTC(t *testing.T) {
if strings.Contains(h, raw) {
t.Errorf("%s: the raw UTC timestamp reached the page", tc.lang)
}
if want := tc.prefix + fmtRFC3339Local(raw); !strings.Contains(h, want) {
if want := tc.prefix + fmtRFC3339Local(tc.lang, raw); !strings.Contains(h, want) {
t.Errorf("%s: the page must carry %q", tc.lang, want)
}
}
@@ -0,0 +1,69 @@
package web
import (
"encoding/json"
"net/url"
"strings"
"testing"
)
// R-521 controller half (operator ruling 2026-10-12, S32, `09` §3 decision 227): the household's own
// notification settings carry a lost-drive option (`storage_disconnected`), saved and sent to the hub in
// `enabled_events` exactly like the others — and ONLY when the household has it on. The hub mails a lost
// drive only when this setting carries it.
//
// RED-PROOF (REPORT.md): drop "storage_disconnected" from the single-event list in
// settingsNotificationsHandler → the "on" half fails (the option is silently not saved and not sent).
func TestR521_StorageDisconnectedOptionRoundTrips(t *testing.T) {
eventsOf := func(t *testing.T, body map[string]json.RawMessage) []string {
t.Helper()
var ev []string
if err := json.Unmarshal(body["enabled_events"], &ev); err != nil {
t.Fatalf("enabled_events is not a list: %s", body["enabled_events"])
}
return ev
}
has := func(list []string, e string) bool {
for _, x := range list {
if x == e {
return true
}
}
return false
}
// ON: saved, sent, and the page shows it ticked.
s, sett, hub := r922Server(t)
s.loadTemplates()
rr := postNotifications(t, s, url.Values{
"notification_email": {"household@example.hu"},
"cooldown_hours": {"6"},
"event_backup_failed": {"on"},
"event_storage_disconnected": {"on"},
})
if p := sett.GetNotificationPrefs(); !has(p.EnabledEvents, "storage_disconnected") {
t.Fatalf("on: storage_disconnected not saved (stored %v)", p.EnabledEvents)
}
if ev := eventsOf(t, hub.last(t)); !has(ev, "storage_disconnected") {
t.Fatalf("on: storage_disconnected not sent to the hub (enabled_events %v)", ev)
}
if !strings.Contains(rr.Body.String(), `name="event_storage_disconnected" checked`) {
t.Error("on: the saved option does not render ticked")
}
// OFF: the same household switches it off — not saved, not sent, not ticked.
rr = postNotifications(t, s, url.Values{
"notification_email": {"household@example.hu"},
"cooldown_hours": {"6"},
"event_backup_failed": {"on"},
})
if p := sett.GetNotificationPrefs(); has(p.EnabledEvents, "storage_disconnected") {
t.Fatalf("off: storage_disconnected still saved (stored %v)", p.EnabledEvents)
}
if ev := eventsOf(t, hub.last(t)); has(ev, "storage_disconnected") {
t.Fatalf("off: storage_disconnected still sent to the hub (enabled_events %v)", ev)
}
if strings.Contains(rr.Body.String(), `name="event_storage_disconnected" checked`) {
t.Error("off: the option still renders ticked")
}
}
@@ -0,0 +1,72 @@
package web
import (
"strings"
"testing"
"time"
)
// R-562 (`09` §3 decision 223): the dashboard prints dates and numbers the reader's way — Hungarian
// „2026. 09. 14. 05:12" and a decimal comma, English „2026-09-14 05:12" and a decimal point. Asserted
// on a RENDERED page in both languages (the backups page carries a date-time and a GB size), and on
// the func set the templates call.
//
// RED-PROOF (REPORT.md): make i18n.FormatDateTime / FormatDecimal ignore the language → the
// Hungarian half fails.
func TestR562_BackupsPageFormatsPerLanguage(t *testing.T) {
var c i18nCase
for _, x := range i18nCases() {
if x.name == "backups_full" {
c = x
}
}
if c.name == "" {
t.Fatal("parity case backups_full not found")
}
s := i18nTestServer(t)
hu := renderI18nCase(t, s, "hu", c)
en := renderI18nCase(t, s, "en", c)
for _, want := range []string{"2026. 09. 14. 05:12", "5,0 GB", "42,5 GB"} {
if !strings.Contains(hu, want) {
t.Errorf("hu: the page does not carry %q", want)
}
}
for _, bad := range []string{"2026-09-14 05:12", "5.0 GB"} {
if strings.Contains(hu, bad) {
t.Errorf("hu: the page still carries the ISO/point form %q", bad)
}
}
for _, want := range []string{"2026-09-14 05:12", "5.0 GB", "42.5 GB"} {
if !strings.Contains(en, want) {
t.Errorf("en: the page does not carry %q", want)
}
}
if strings.Contains(en, "2026. 09. 14.") || strings.Contains(en, "5,0 GB") {
t.Error("en: the page carries the Hungarian date or decimal comma")
}
}
func TestR562_FormatFuncsPerLanguage(t *testing.T) {
at := time.Date(2026, 10, 10, 13, 4, 0, 0, time.UTC) // 15:04 in Budapest (CEST)
hu, en := formatFuncs("hu"), formatFuncs("en")
check := func(name, got, want string) {
t.Helper()
if got != want {
t.Errorf("%s: got %q, want %q", name, got, want)
}
}
check("hu fmtTime", hu["fmtTime"].(func(time.Time) string)(at), "2026. 10. 10. 15:04")
check("en fmtTime", en["fmtTime"].(func(time.Time) string)(at), "2026-10-10 15:04")
check("hu fmtTimeStr", hu["fmtTimeStr"].(func(string) string)("2026-10-10T13:04:00Z"), "2026. 10. 10. 15:04")
check("en fmtTimeStr", en["fmtTimeStr"].(func(string) string)("2026-10-10T13:04:00Z"), "2026-10-10 15:04")
check("hu fmtGB", hu["fmtGB"].(func(float64) string)(42.5), "42,5 GB")
check("en fmtGB", en["fmtGB"].(func(float64) string)(42.5), "42.5 GB")
check("hu fmtBytes", hu["fmtBytes"].(func(int64) string)(1610612736), "1,5 GB")
check("en fmtBytes", en["fmtBytes"].(func(int64) string)(1610612736), "1.5 GB")
check("hu fmtLoad", hu["fmtLoad"].(func(float64) string)(0.25), "0,25")
check("en fmtLoad", en["fmtLoad"].(func(float64) string)(0.25), "0.25")
check("hu numText", hu["numText"].(func(string) string)("12.0 MB"), "12,0 MB")
check("en numText", en["numText"].(func(string) string)("12.0 MB"), "12.0 MB")
check("zero time", hu["fmtTime"].(func(time.Time) string)(time.Time{}), "–")
}
@@ -13,6 +13,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
@@ -69,8 +70,65 @@ func r922Server(t *testing.T) (*Server, *settings.Settings, *prefsHub) {
return s, sett, hub
}
// clearForm is a CONFIRMED clear: the household pressed the warning's own button (confirm_clear=1).
func clearForm() url.Values {
return url.Values{"notification_email": {""}, "cooldown_hours": {"6"}} // no event_* boxes
return url.Values{"notification_email": {""}, "cooldown_hours": {"6"}, "confirm_clear": {"1"}} // no event_* boxes
}
// R-922 warning (S1, `09` §3 decision 196): clearing a stored address is WARNED first. The first press
// saves nothing and sends nothing; the page says „no more notification mails" in the reader's language and
// offers the confirm button. Only the confirm clears.
//
// RED-PROOF (REPORT.md): delete the warning block in settingsNotificationsHandler → the first press clears
// the address and pushes to the hub, and this test fails on "the address was cleared before the warning".
func TestR922_ClearWarnsFirst(t *testing.T) {
for _, tc := range []struct{ lang, fragment string }{
{"hu", "nem kapsz t"}, // „nem kapsz több értesítő levelet" — ASCII fragment
{"en", "you will get no more notification e-mails"},
} {
s, sett, hub := r922Server(t)
if err := sett.SetLanguage(tc.lang); err != nil {
t.Fatal(err)
}
if err := sett.SetNotificationPrefs(&settings.NotificationPrefs{
Email: "household@example.hu", EnabledEvents: []string{"backup_failed"}, CooldownHours: 6,
}); err != nil {
t.Fatal(err)
}
s.loadTemplates()
rr := postNotifications(t, s, url.Values{"notification_email": {""}, "cooldown_hours": {"6"}})
if p := sett.GetNotificationPrefs(); p.Email != "household@example.hu" {
t.Fatalf("%s: the address was cleared before the warning (stored %q)", tc.lang, p.Email)
}
if hub.count() != 0 {
t.Fatalf("%s: a push reached the hub before the household confirmed", tc.lang)
}
body := rr.Body.String()
if !strings.Contains(body, `id="notification-clear-warning"`) || !strings.Contains(body, tc.fragment) {
t.Fatalf("%s: the warning is not on the page (want %q)", tc.lang, tc.fragment)
}
if !strings.Contains(body, `name="confirm_clear" value="1"`) {
t.Fatalf("%s: the warning offers no confirm button", tc.lang)
}
postNotifications(t, s, clearForm())
if p := sett.GetNotificationPrefs(); p.Email != "" {
t.Fatalf("%s: the confirmed clear did not clear (stored %q)", tc.lang, p.Email)
}
if present, v := flagOf(t, hub.last(t)); !present || !v {
t.Fatalf("%s: the confirmed clear did not send email_cleared", tc.lang)
}
}
// A box with no stored address is not warned: there is nothing to lose.
s, sett, _ := r922Server(t)
s.loadTemplates()
rr := postNotifications(t, s, url.Values{"notification_email": {""}, "cooldown_hours": {"6"}})
if strings.Contains(rr.Body.String(), `id="notification-clear-warning"`) {
t.Error("a box with no stored address was warned")
}
_ = sett
}
func flagOf(t *testing.T, body map[string]json.RawMessage) (present bool, value bool) {
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
@@ -687,7 +688,7 @@ func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request)
for _, mark := range backup.AbandonRemindAtDays {
if st.DaysLeft <= mark {
data["RecoveryAbandonDays"] = st.DaysLeft
data["RecoveryAbandonDate"] = st.DueAt.Format("2006-01-02")
data["RecoveryAbandonDate"] = i18n.FormatDate(s.langFor(r), st.DueAt)
// R-365 follow-up: DaysLeft is a ceiling, so 0 means the due moment has PASSED and the
// sweep has not run yet. The template's `{{if .RecoveryAbandonDays}}` is false at 0, so the
// bar fell back to the undecided-box reminder text — and offered the reminder opt-out over
+3
View File
@@ -340,6 +340,9 @@ func (s *Server) loadTemplates() {
for name, fn := range s.localeFuncs(lang) {
funcs[name] = fn
}
for name, fn := range formatFuncs(lang) { // R-562: dates and numbers in this language
funcs[name] = fn
}
for name, fn := range s.readerFuncs(lang, funcs) {
funcs[name] = fn
}
+1 -1
View File
@@ -121,7 +121,7 @@ func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Reques
}
if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil {
s.shareRegisterFailure(ip)
s.renderSharePasswordPage(w, r, "Hibás jelszó")
s.renderSharePasswordPage(w, r, s.msg(r, "flash.share.wrong_password")) // R-577: in the visitor's language
return
}
s.shareClearFailures(ip)
@@ -148,9 +148,9 @@ async function loadEstimate() {
// Task 4: the class-scoped selection UI (classified apps only; legacy apps see the plain estimate).
var lastEst = null;
function humanBytes(b) {
if (b >= 1073741824) return (b/1073741824).toFixed(1) + ' GB';
if (b >= 1048576) return (b/1048576).toFixed(1) + ' MB';
if (b >= 1024) return (b/1024).toFixed(1) + ' KB';
if (b >= 1073741824) return fhNum(b/1073741824, 1) + ' GB';
if (b >= 1048576) return fhNum(b/1048576, 1) + ' MB';
if (b >= 1024) return fhNum(b/1024, 1) + ' KB';
return b + ' B';
}
function renderClassSelect(est) {
@@ -49,8 +49,8 @@
{{range .Bundles}}
<tr>
<td><strong>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</strong></td>
<td>{{.ExportedAt}}</td>
<td>{{.SizeHuman}}</td>
<td>{{fmtTimeStr .ExportedAt}}</td>
<td>{{numText .SizeHuman}}</td>
<td>{{if .DriveLabel}}{{.DriveLabel}}{{else}}{{.DrivePath}}{{end}}</td>
<td>{{if .Encrypted}}&#128274;{{end}}</td>
<td><button class="btn btn-sm btn-outline" onclick="showPreview('{{.Path}}', {{.Encrypted}})">{{T "app_import.reszletek"}}</button></td>
@@ -195,7 +195,7 @@ function showManifest(m) {
document.getElementById('previewTitle').textContent = (m.display_name || m.app_name) + ' {{T "app_import.csomag_reszletei_2"}}';
document.getElementById('mfName').textContent = m.display_name || m.app_name;
document.getElementById('mfDate').textContent = m.exported_at ? new Date(m.exported_at).toLocaleString('hu-HU') : '-';
document.getElementById('mfDate').textContent = m.exported_at ? fhDateTime(new Date(m.exported_at)) : '-';
document.getElementById('mfVersion').textContent = m.controller_version || '-';
if (m.has_database && m.db_type) {
@@ -212,9 +212,9 @@ function showManifest(m) {
// Format size
var bytes = m.total_size_bytes || 0;
var sizeStr = bytes > 1073741824 ? (bytes / 1073741824).toFixed(1) + ' GB' :
bytes > 1048576 ? (bytes / 1048576).toFixed(1) + ' MB' :
bytes > 1024 ? (bytes / 1024).toFixed(1) + ' KB' : bytes + ' B';
var sizeStr = bytes > 1073741824 ? fhNum(bytes / 1073741824, 1) + ' GB' :
bytes > 1048576 ? fhNum(bytes / 1048576, 1) + ' MB' :
bytes > 1024 ? fhNum(bytes / 1024, 1) + ' KB' : bytes + ' B';
document.getElementById('mfSize').textContent = sizeStr;
// Check if app already exists — show overwrite warning
@@ -317,7 +317,7 @@ function fabPick() {
}
function fabGB(b) {
return (b / 1073741824).toFixed(2);
return fhNum(b / 1073741824, 2);
}
function fabProgress(done, total) {
@@ -175,7 +175,7 @@
<div id="app-migrate-progress" style="display:none;margin-top:.75rem;padding:.75rem;border:1px solid var(--blue);border-radius:6px;background:rgba(0,136,204,0.06)"></div>
</div>
<script>
function appMigFmtGB(b){ return (Number(b||0)/1e9).toFixed(1)+' GB'; }
function appMigFmtGB(b){ return fhNum(Number(b||0)/1e9, 1)+' GB'; }
function appMigRender(job){
var names={stop:'{{T "app_info.leallitas"}}',copy:'{{T "app_info.adatok_masolasa"}}',verify:'{{T "app_info.ellenorzes"}}',flip:'{{T "app_info.ujratelepites"}}',redeploy:'{{T "app_info.ujratelepites"}}',cleanup:'{{T "app_info.regi_adatok_torlese"}}'};
var s=names[job.phase]||job.phase;
@@ -164,7 +164,7 @@
<span class="tag tag-warn">{{T "backups_apps.meghajto_levalasztva"}}</span>
{{else if .HasHDDData}}
{{if .StorageLabel}}<span class="meta-badge meta-badge-storage">{{.StorageLabel}}</span>{{end}}
<span class="mono app-backup-size" style="font-size:.8rem">{{.HDDSizeHuman}}</span>
<span class="mono app-backup-size" style="font-size:.8rem">{{numText .HDDSizeHuman}}</span>
{{else if .HasVolumeData}}
<span class="meta-badge">{{T "backups_apps.konfig"}}{{if .HasDB}} + DB{{end}} {{T "backups_apps.adatok"}}</span>
{{else}}
@@ -277,7 +277,7 @@
<span class="{{if eq .Tier2LastStatus "error"}}text-error{{else}}text-muted{{end}}">{{.Tier2StatusBadge}}</span>
</span>
{{end}}
{{if .Tier2SizeHuman}}<span class="tier-size">{{.Tier2SizeHuman}}</span>{{end}}
{{if .Tier2SizeHuman}}<span class="tier-size">{{numText .Tier2SizeHuman}}</span>{{end}}
{{if .Tier2LastWarning}}<span class="layer-reason" style="color:var(--warn);opacity:.9">{{.Tier2LastWarning}}</span>{{end}}
{{/* R-403: a run that PRESERVED the copy's package instead of refreshing it must
not render as a plain fresh copy. The status line above is about the RUN;
@@ -47,7 +47,7 @@
<div class="stat-label">{{T "backups_remote.utolso_tavoli_mentes"}}{{if .Offbox.LastRun}}<br><span class="relative-time">{{timeAgoStr .Offbox.LastRun}}</span>{{end}}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:1.15rem">{{if and .Offbox.StatsKnown .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}–{{end}}</div>
<div class="stat-value" style="font-size:1.15rem">{{if and .Offbox.StatsKnown .Offbox.RepoSizeHuman}}{{numText .Offbox.RepoSizeHuman}}{{else}}–{{end}}</div>
<div class="stat-label">{{T "backups_remote.tarolo_meret"}} {{if .Offbox.StatsKnown}}{{T "backups_remote.pillanatkep"}}{{else}}{{T "backups_remote.a_pillanatkepek_szama_meg_ismeretlen"}}{{end}}</div>
</div>
<div class="stat-card">
@@ -60,7 +60,7 @@
picture of emptiness, and a picture is a claim. -->
<!-- SLICE 4: soft-quota usage bar (shared model; quota_gb from the hub descriptor). -->
<div id="offbox-quota-bar" style="max-width:560px;margin:.5rem 0">
<div class="stat-label" style="margin-bottom:.25rem">{{T "backups_remote.tarhelykeret"}} {{if .Offbox.StatsKnown}}{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%){{else}}{{T "backups_remote.meg_nem_tudjuk_mennyi_van"}}{{end}}</div>
<div class="stat-label" style="margin-bottom:.25rem">{{T "backups_remote.tarhelykeret"}} {{if .Offbox.StatsKnown}}{{if .Offbox.RepoSizeHuman}}{{numText .Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%){{else}}{{T "backups_remote.meg_nem_tudjuk_mennyi_van"}}{{end}}</div>
{{if .Offbox.StatsKnown}}<div style="background:var(--border,#334);border-radius:4px;height:8px;overflow:hidden">
<div style="height:8px;border-radius:4px;width:{{.OffboxQuotaPct}}%;background:{{if ge .OffboxQuotaPct 100}}var(--crit,#e5484d){{else if ge .OffboxQuotaPct 80}}var(--warn,#f5a524){{else}}var(--ok,#30a46c){{end}}"></div>
</div>{{end}}
@@ -174,7 +174,7 @@
<p class="form-hint" style="margin-bottom:.75rem">{{T "backups_restore.ezek_a_visszaallitott_masolatok_helyet"}}</p>
<div class="app-row-list">
{{range .OffsiteRestoreCopies}}
{{template "app_list_row" dict "Slug" .Stack "Name" .Stack "Secondary" (printf "%s · %s · %s" .SizeHuman (.Created.Format "2006. 01. 02. 15:04") .Path)}}
{{template "app_list_row" dict "Slug" .Stack "Name" .Stack "Secondary" (printf "%s · %s · %s" (numText .SizeHuman) (fmtTime .Created) .Path)}}
<form method="POST" action="/backup/offbox/verify-copy/delete" style="display:inline">
{{$.CSRFField}}
<input type="hidden" name="stack" value="{{.Stack}}">
@@ -299,7 +299,7 @@ var huDays = ['{{T "backups_restore.vasarnap"}}', '{{T "backups_restore.hetfo"}}
function formatSnapshot(s) {
var t = new Date(s.time);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var label = t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate()) +
var label = fhIsoDate(t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate())) +
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
' (' + s.short_id + ')';
var tierLabel = s.tier === 2 ? '{{T "backups_restore.2_szint"}}' : '{{T "backups_restore.1_szint"}}';
+22 -5
View File
@@ -59,7 +59,7 @@
{{if .StorageInfo.DataSizeHuman}}
<div class="settings-row">
<span class="settings-label">{{T "deploy.adatmeret"}}</span>
<span class="settings-value mono">{{.StorageInfo.DataSizeHuman}}</span>
<span class="settings-value mono">{{numText .StorageInfo.DataSizeHuman}}</span>
</div>
{{end}}
{{if .StorageInfo.FreeHuman}}
@@ -91,7 +91,7 @@
</div>
<div class="settings-row">
<span class="settings-label">{{T "deploy.meret"}}</span>
<span class="settings-value mono">{{.SizeHuman}}</span>
<span class="settings-value mono">{{numText .SizeHuman}}</span>
</div>
<div class="settings-row">
<span class="settings-label">{{T "deploy.mappak"}}</span>
@@ -328,13 +328,26 @@
<label for="opt-{{.EnvVar}}">{{.Label}}</label>
{{if .HelpText}}<p class="config-field-help">{{.HelpText}}</p>{{end}}
{{if .HelpURL}}<p class="config-field-help"><a href="{{.HelpURL}}" target="_blank">{{T "deploy.regisztracios_utmutato"}}</a></p>{{end}}
<input type="{{if eq .Type "secret_input"}}password{{else}}text{{end}}"
{{if eq .Type "secret_input"}}
{{/* R-255 (v0.308.0): a stored secret is NOT in the page; it is fetched on demand like the
auto-generated ones (R-254). A blank field that was never revealed is not sent, so
saving other fields keeps it. */}}
{{$set := index $.CurrentValues .EnvVar}}
<div class="input-with-button">
<input type="password" id="opt-{{.EnvVar}}" name="{{.EnvVar}}" class="config-input"
value="" placeholder="{{if $set}}••••••••••••{{else}}{{.Label}}{{end}}"
{{if $set}}data-secret-set="1" {{end}}autocomplete="off">
{{if $set}}<button type="button" class="btn btn-sm btn-outline" onclick="revealAutoField('{{$.Stack.Name}}','{{.EnvVar}}', this, 'opt-{{.EnvVar}}')">{{T "deploy.megjelenites"}}</button>{{end}}
</div>
{{else}}
<input type="text"
id="opt-{{.EnvVar}}"
name="{{.EnvVar}}"
class="config-input"
value="{{index $.CurrentValues .EnvVar}}"
placeholder="{{.Label}}"
autocomplete="off">
{{end}}
</div>
{{end}}
</div>
@@ -357,6 +370,8 @@
var values = {};
inputs.forEach(function(input) {
// R-255: a stored secret the household never revealed and left blank stays as it is.
if (input.dataset.secretSet === '1' && input.dataset.revealed !== '1' && input.value === '') return;
values[input.name] = input.value;
});
@@ -836,15 +851,17 @@ function revealPasswordField(stackName, envVar, btn) {
if (!j.ok) { showAlert(j.error || '{{T "deploy.a_lekeres_nem_sikerult"}}'); return; }
el.value = j.data.value;
el.type = 'text';
el.dataset.revealed = '1';
btn.dataset.shown = '1';
}).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); });
}
function revealAutoField(stackName, envVar, btn) {
var el = document.getElementById('auto-field-' + envVar);
function revealAutoField(stackName, envVar, btn, elId) {
var el = document.getElementById(elId || ('auto-field-' + envVar));
if (!el) return;
if (btn.dataset.shown === '1') {
el.value = '';
el.type = 'password';
el.dataset.revealed = '';
btn.dataset.shown = '';
btn.textContent = '{{T "deploy.megjelenites"}}';
return;
@@ -20,6 +20,8 @@
<input type="password" name="password" placeholder="{{T "launcher_share_password.jelszo"}}" autocomplete="current-password" autofocus>
<button type="submit" class="btn btn-primary">{{T "launcher_share_password.belepes"}}</button>
</form>
{{/* R-577 (decision 207): the guest picks their own language; the globe posts to /lang (their cookie). */}}
<div class="shell-lang">{{template "lang_globe" .}}</div>
</div>
</main>
</body>
@@ -37,6 +37,8 @@
<p>{{T "launcher_shared.jelenleg_nincs_elerheto_alkalmazas"}}</p>
</div>
{{end}}
{{/* R-577 (decision 207): the guest picks their own language; the globe posts to /lang (their cookie). */}}
<div class="shell-lang">{{template "lang_globe" .}}</div>
</main>
</body>
</html>
@@ -16,6 +16,14 @@
// mobile off-canvas drawer engages only when the drawer JS below can actually open it.
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}:{};}
{{/* R-562 (decision 223): numbers and dates in the page's language. The browser twin of internal/i18n
format.go: Hungarian 2026. 10. 10. 15:04 and a decimal comma, English 2026-10-10 15:04 and a point.
sv-SE is used only because it prints the ISO-like order; the Hungarian form is built from it. */ -}}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
// Inline two-step confirm (drill F-11): the trigger swaps IN PLACE to "<question> Igen/Mégse".
// Never native confirm() — it is an OS-modal that blocks browser automation — and no overlay:
// this is the LIGHT pattern for one-click consequential actions. Form buttons opt in with
@@ -363,7 +363,7 @@
if (isNaN(ts)) return '';
// ts should already be in ms (linear axis stores ms values)
var d = new Date(ts);
return d.toLocaleString('hu-HU', {timeZone: budaTZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'});
return fhDateTime(d);
}
function formatTimeLabel(ts) {
@@ -372,7 +372,7 @@
if (isNaN(ts)) return '';
var d = new Date(ts);
if (currentTickFormat === 'date') {
return d.toLocaleDateString('hu-HU', {timeZone: budaTZ, month: '2-digit', day: '2-digit'});
return fhMonthDay(d);
}
return d.toLocaleTimeString('hu-HU', {timeZone: budaTZ, hour: '2-digit', minute: '2-digit'});
}
@@ -586,7 +586,7 @@
var osMB = Math.max(0, usedMB - appTotal);
var freeMB = Math.max(0, totalMB - usedMB);
function fmtMB(mb) { return mb >= 1024 ? (mb/1024).toFixed(1) + ' GB' : Math.round(mb) + ' MB'; }
function fmtMB(mb) { return mb >= 1024 ? fhNum(mb/1024, 1) + ' GB' : Math.round(mb) + ' MB'; }
header.textContent = '{{T "monitoring.hasznalt"}} ' + fmtMB(usedMB) + ' / ' + fmtMB(totalMB) + ' (' + Math.round(usedMB/totalMB*100) + '%)';
@@ -696,9 +696,9 @@
function fmtBytesGB(bytes) {
if (!bytes || bytes <= 0) return '0 GB';
var gb = bytes / 1073741824;
if (gb >= 1024) return (gb / 1024).toFixed(2) + ' TB';
if (gb >= 1024) return fhNum(gb / 1024, 2) + ' TB';
if (gb >= 100) return Math.round(gb) + ' GB';
return gb.toFixed(1) + ' GB';
return fhNum(gb, 1) + ' GB';
}
function escapeHtml(s) {
@@ -720,7 +720,7 @@
// CPU % + load average
document.getElementById('host-cpu').textContent =
(h.cpu_percent != null ? h.cpu_percent.toFixed(1) : '–') + '%';
(h.cpu_percent != null ? fhNum(h.cpu_percent, 1) : '–') + '%';
var load = Array.isArray(h.loadavg) ? h.loadavg.join(' ') : '–';
document.getElementById('host-load').textContent = load || '–';
@@ -824,7 +824,7 @@
}
if (d.boot_time) {
var bt = new Date(d.boot_time);
document.getElementById('sysinfo-boot').textContent = bt.toLocaleString('hu-HU', {timeZone: budaTZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'});
document.getElementById('sysinfo-boot').textContent = fhDateTime(bt);
}
} catch(e) {
console.error('Failed to load sysinfo:', e);
@@ -12,7 +12,11 @@
{{if .NotificationSuccess}}<div class="alert alert-info">{{.NotificationSuccess}}</div>{{end}}
{{if .NotificationError}}<div class="alert alert-error">{{.NotificationError}}</div>{{end}}
<form method="POST" action="/settings/notifications">
{{.CSRFField}}
{{.CSRFField}}{{if .NotificationClearConfirm}}
<div class="alert alert-warning" id="notification-clear-warning">
<p>{{T "settings_notifications.clear_warning"}}</p>
<button type="submit" name="confirm_clear" value="1" class="btn btn-danger">{{T "settings_notifications.clear_confirm"}}</button>
</div>{{end}}
<div class="form-group">
<label for="notification_email">{{T "settings_notifications.e_mail_cim"}}</label>
<input type="email" id="notification_email" name="notification_email"
@@ -172,7 +172,7 @@
<strong>{{T "sharing.2_mentes_masik_meghajto"}}</strong>
{{if .SharesTier2Status}}
{{if eq .SharesTier2Status "ok"}}
{{T "sharing.rendben"}}{{if .SharesTier2LastRun}} — {{.SharesTier2LastRun}}{{end}}
{{T "sharing.rendben"}}{{if .SharesTier2LastRun}} — {{fmtTimeStr .SharesTier2LastRun}}{{end}}
{{if .SharesTier2Warning}}<span class="badge badge-warn">{{.SharesTier2Warning}}</span>{{end}}
{{else if eq .SharesTier2Status "no_target"}}
<span class="badge badge-warn">{{T "sharing.nincs_cel"}}</span>
@@ -187,7 +187,7 @@
<strong>{{T "sharing.tavoli_mentes_felho"}}</strong>
{{if .SharesOffsiteStatus}}
{{if eq .SharesOffsiteStatus "ok"}}
{{T "sharing.rendben_megosztas"}}{{if .SharesOffsiteLastRun}}, {{.SharesOffsiteLastRun}}{{end}}
{{T "sharing.rendben_megosztas"}}{{if .SharesOffsiteLastRun}}, {{fmtTimeStr .SharesOffsiteLastRun}}{{end}}
{{else if eq .SharesOffsiteStatus "blocked"}}
<span class="badge badge-warn">{{T "sharing.a_tarhelykeret_miatt_csak_a"}}</span>
{{else if eq .SharesOffsiteStatus "skipped"}}
@@ -72,7 +72,7 @@
{{if .DiskInfo}}
<div class="storage-path-disk">
<div class="system-info-header">
<span class="system-info-value">{{.DiskInfo.UsedHuman}} / {{.DiskInfo.TotalHuman}}</span>
<span class="system-info-value">{{numText .DiskInfo.UsedHuman}} / {{numText .DiskInfo.TotalHuman}}</span>
</div>
<div class="meter {{if ge .DiskInfo.UsedPercent 90.0}}crit{{else if ge .DiskInfo.UsedPercent 70.0}}warn{{else}}nominal{{end}}">
<div class="meter-track">
@@ -102,7 +102,7 @@
{{range .AppDetails}}
<div class="storage-app-row">
<a href="/apps/{{.Stack}}" class="storage-app-link">{{.Name}}</a>
{{if .SizeHuman}}<span class="mono form-hint">{{.SizeHuman}}</span>{{end}}
{{if .SizeHuman}}<span class="mono form-hint">{{numText .SizeHuman}}</span>{{end}}
<span class="btn btn-xs btn-outline" style="opacity:.45;cursor:not-allowed" title="{{T "storage.hamarosan"}}"><svg class="ico ico-sm"><use href="#i-upload"/></svg> {{T "storage.mozgatas"}}</span>
</div>
{{end}}
@@ -181,7 +181,7 @@
</div>
<script>
// Shared migration progress (used by both migrate-all here and per-app migrate on the app page).
function migFmtGB(b){ return (Number(b||0)/1e9).toFixed(1)+' GB'; }
function migFmtGB(b){ return fhNum(Number(b||0)/1e9, 1)+' GB'; }
function migRender(job){
var names={stop:'{{T "storage.alkalmazasok_leallitasa"}}',copy:'{{T "storage.adatok_masolasa"}}',verify:'{{T "storage.ellenorzes"}}',flip:'{{T "storage.ujratelepites"}}',redeploy:'{{T "storage.ujratelepites"}}',cleanup:'{{T "storage.forras_torlese"}}'};
var s=names[job.phase]||job.phase;
@@ -247,7 +247,7 @@
window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}{{end}}];
(function(){
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function hum(b){ if(!b||b<=0) return ''; var u=['B','KB','MB','GB','TB'],i=0,v=b; while(v>=1024&&i<u.length-1){v/=1024;i++;} return (v>=10||i===0?Math.round(v):v.toFixed(1))+' '+u[i]; }
function hum(b){ if(!b||b<=0) return ''; var u=['B','KB','MB','GB','TB'],i=0,v=b; while(v>=1024&&i<u.length-1){v/=1024;i++;} return (v>=10||i===0?Math.round(v):fhNum(v, 1))+' '+u[i]; }
function usageColorClass(p){ if(p>=85) return 'crit'; if(p>=70) return 'warn'; return 'nominal'; }
var LOCK_ICO='<svg class="ico ico-sm"><use href="#i-lock"/></svg>';
function classTag(d){
@@ -58,7 +58,7 @@ function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){retur
function fmtSize(b){
if(!b) return '';
var g=b/1073741824;
return g>=1 ? g.toFixed(g>=10?0:1)+' GB' : Math.round(b/1048576)+' MB';
return g>=1 ? fhNum(g, g>=10?0:1)+' GB' : Math.round(b/1048576)+' MB';
}
async function loadDisks(){
@@ -69,7 +69,7 @@ function dataBadge(d){
function fmtSize(b){
if(!b) return '';
var g=b/1073741824;
return g>=1 ? g.toFixed(g>=10?0:1)+' GB' : Math.round(b/1048576)+' MB';
return g>=1 ? fhNum(g, g>=10?0:1)+' GB' : Math.round(b/1048576)+' MB';
}
async function loadDisks(){
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -316,9 +321,9 @@ async function loadEstimate() {
var lastEst = null;
function humanBytes(b) {
if (b >= 1073741824) return (b/1073741824).toFixed(1) + ' GB';
if (b >= 1048576) return (b/1048576).toFixed(1) + ' MB';
if (b >= 1024) return (b/1024).toFixed(1) + ' KB';
if (b >= 1073741824) return fhNum(b/1073741824, 1) + ' GB';
if (b >= 1048576) return fhNum(b/1048576, 1) + ' MB';
if (b >= 1024) return fhNum(b/1024, 1) + ' KB';
return b + ' B';
}
function renderClassSelect(est) {
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -210,7 +215,7 @@
<tr>
<td><strong>Private Bin</strong></td>
<td>2026-09-14 03:12</td>
<td>2026. 09. 14. 03:12</td>
<td>12 MB</td>
<td>HDD egy</td>
<td>&#128274;</td>
@@ -219,7 +224,7 @@
<tr>
<td><strong>kimai</strong></td>
<td>2026-09-13 03:12</td>
<td>2026. 09. 13. 03:12</td>
<td>40 MB</td>
<td>/mnt/hdd2</td>
<td></td>
@@ -365,7 +370,7 @@ function showManifest(m) {
document.getElementById('previewTitle').textContent = (m.display_name || m.app_name) + ' — Csomag részletei';
document.getElementById('mfName').textContent = m.display_name || m.app_name;
document.getElementById('mfDate').textContent = m.exported_at ? new Date(m.exported_at).toLocaleString('hu-HU') : '-';
document.getElementById('mfDate').textContent = m.exported_at ? fhDateTime(new Date(m.exported_at)) : '-';
document.getElementById('mfVersion').textContent = m.controller_version || '-';
if (m.has_database && m.db_type) {
@@ -382,9 +387,9 @@ function showManifest(m) {
var bytes = m.total_size_bytes || 0;
var sizeStr = bytes > 1073741824 ? (bytes / 1073741824).toFixed(1) + ' GB' :
bytes > 1048576 ? (bytes / 1048576).toFixed(1) + ' MB' :
bytes > 1024 ? (bytes / 1024).toFixed(1) + ' KB' : bytes + ' B';
var sizeStr = bytes > 1073741824 ? fhNum(bytes / 1073741824, 1) + ' GB' :
bytes > 1048576 ? fhNum(bytes / 1048576, 1) + ' MB' :
bytes > 1024 ? fhNum(bytes / 1024, 1) + ' KB' : bytes + ' B';
document.getElementById('mfSize').textContent = sizeStr;
@@ -487,7 +492,7 @@ function fabPick() {
}
function fabGB(b) {
return (b / 1073741824).toFixed(2);
return fhNum(b / 1073741824, 2);
}
function fabProgress(done, total) {
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -334,7 +339,7 @@ function showManifest(m) {
document.getElementById('previewTitle').textContent = (m.display_name || m.app_name) + ' — Csomag részletei';
document.getElementById('mfName').textContent = m.display_name || m.app_name;
document.getElementById('mfDate').textContent = m.exported_at ? new Date(m.exported_at).toLocaleString('hu-HU') : '-';
document.getElementById('mfDate').textContent = m.exported_at ? fhDateTime(new Date(m.exported_at)) : '-';
document.getElementById('mfVersion').textContent = m.controller_version || '-';
if (m.has_database && m.db_type) {
@@ -351,9 +356,9 @@ function showManifest(m) {
var bytes = m.total_size_bytes || 0;
var sizeStr = bytes > 1073741824 ? (bytes / 1073741824).toFixed(1) + ' GB' :
bytes > 1048576 ? (bytes / 1048576).toFixed(1) + ' MB' :
bytes > 1024 ? (bytes / 1024).toFixed(1) + ' KB' : bytes + ' B';
var sizeStr = bytes > 1073741824 ? fhNum(bytes / 1073741824, 1) + ' GB' :
bytes > 1048576 ? fhNum(bytes / 1048576, 1) + ' MB' :
bytes > 1024 ? fhNum(bytes / 1024, 1) + ' KB' : bytes + ' B';
document.getElementById('mfSize').textContent = sizeStr;
@@ -456,7 +461,7 @@ function fabPick() {
}
function fabGB(b) {
return (b / 1073741824).toFixed(2);
return fhNum(b / 1073741824, 2);
}
function fabProgress(done, total) {
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -261,7 +266,7 @@
<div id="app-migrate-progress" style="display:none;margin-top:.75rem;padding:.75rem;border:1px solid var(--blue);border-radius:6px;background:rgba(0,136,204,0.06)"></div>
</div>
<script>
function appMigFmtGB(b){ return (Number(b||0)/1e9).toFixed(1)+' GB'; }
function appMigFmtGB(b){ return fhNum(Number(b||0)/1e9, 1)+' GB'; }
function appMigRender(job){
var names={stop:'Leállítás',copy:'Adatok másolása',verify:'Ellenőrzés',flip:'Újratelepítés',redeploy:'Újratelepítés',cleanup:'Régi adatok törlése'};
var s=names[job.phase]||job.phase;
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -193,7 +198,7 @@
<div class="schedule-row">
<span class="schedule-task">Adatbázis mentés</span>
<span class="schedule-time">02:30</span>
<span class="schedule-next">Következő: 2026-09-14 05:12</span>
<span class="schedule-next">Következő: 2026. 09. 14. 05:12</span>
</div>
</div>
<div class="schedule-summary">
@@ -234,8 +239,8 @@
<tr>
<td>bookstack</td>
<td><span class="db-type-badge db-type-mariadb">MariaDB</span></td>
<td class="mono">1.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">1,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-ok">12 tábla</span>
@@ -247,8 +252,8 @@
<tr>
<td>immich</td>
<td><span class="db-type-badge db-type-postgres">PostgreSQL</span></td>
<td class="mono">2.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">2,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-fail" title="no create table">Hiba</span>
@@ -260,8 +265,8 @@
<tr>
<td>romm</td>
<td><span class="db-type-badge db-type-postgres">PostgreSQL</span></td>
<td class="mono">3.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">3,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-na">–</span>
@@ -315,12 +320,12 @@
<span class="tier-label">Mentés</span>
<span class="layer-badge">Megtartva</span>
<span class="tier-location">helyi</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026-09-14 05:12.</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026. 09. 14. 05:12.</span>
<div class="layer-actions">
<form method="POST" action="/backup/restore" style="display:inline">
<input type="hidden" name="stack_name" value="removed">
<input type="hidden" name="snapshot_id" value="helyi">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026-09-14 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026. 09. 14. 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
</form>
</div>
</div>
@@ -820,7 +825,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="okrun">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026-09-14 05:12">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026. 09. 14. 05:12">Fájlok visszaállítása</button>
</form>
@@ -903,7 +908,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="oksucc">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-09-14 05:12.">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026. 09. 14. 05:12.">Fájlok visszaállítása</button>
</form>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -180,7 +185,7 @@
<div id="restore-banner" class="flash" style="display:none"></div>
<div class="alert alert-warning" data-interrupted-run="true">A legutóbbi mentés (2026-09-14 05:12) megszakadt, mert a doboz vagy a vezérlő újraindult. Amit nem fejezett be, annak a korábbi mentése maradt meg — minden visszaállítási pont annyira friss, amennyire a legrégebbi része. A következő teljes mentés után ez az üzenet eltűnik.</div>
<div class="alert alert-warning" data-interrupted-run="true">A legutóbbi mentés (2026. 09. 14. 05:12) megszakadt, mert a doboz vagy a vezérlő újraindult. Amit nem fejezett be, annak a korábbi mentése maradt meg — minden visszaállítási pont annyira friss, amennyire a legrégebbi része. A következő teljes mentés után ez az üzenet eltűnik.</div>
@@ -195,14 +200,14 @@
<div class="schedule-row">
<span class="schedule-task">Adatbázis mentés</span>
<span class="schedule-time">02:30</span>
<span class="schedule-next">Következő: 2026-09-14 05:12</span>
<span class="schedule-next">Következő: 2026. 09. 14. 05:12</span>
</div>
</div>
<div class="schedule-summary">
<div class="schedule-summary-row">
<span>Utolsó adatbázis mentés:</span>
<span class="schedule-summary-value">2026-09-14 05:12 (# napja)</span>
<span class="schedule-summary-value">2026. 09. 14. 05:12 (# napja)</span>
</div>
</div>
@@ -253,8 +258,8 @@
<tr>
<td>bookstack</td>
<td><span class="db-type-badge db-type-mariadb">MariaDB</span></td>
<td class="mono">1.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">1,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-ok">12 tábla</span>
@@ -270,8 +275,8 @@
<tr>
<td>immich</td>
<td><span class="db-type-badge db-type-postgres">PostgreSQL</span></td>
<td class="mono">2.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">2,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-fail" title="no create table">Hiba</span>
@@ -287,8 +292,8 @@
<tr>
<td>romm</td>
<td><span class="db-type-badge db-type-postgres">PostgreSQL</span></td>
<td class="mono">3.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">3,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-na" title="Az érvényesítés nem futott le">–</span>
@@ -346,12 +351,12 @@
<span class="tier-label">Mentés</span>
<span class="layer-badge">Megtartva</span>
<span class="tier-location">helyi</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026-09-14 05:12.</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026. 09. 14. 05:12.</span>
<div class="layer-actions">
<form method="POST" action="/backup/restore" style="display:inline">
<input type="hidden" name="stack_name" value="removed">
<input type="hidden" name="snapshot_id" value="helyi">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026-09-14 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026. 09. 14. 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
</form>
</div>
</div>
@@ -851,7 +856,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="okrun">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026-09-14 05:12">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026. 09. 14. 05:12">Fájlok visszaállítása</button>
</form>
@@ -934,7 +939,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="oksucc">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-09-14 05:12.">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026. 09. 14. 05:12.">Fájlok visszaállítása</button>
</form>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -199,14 +204,14 @@
<div class="schedule-row">
<span class="schedule-task">Adatbázis mentés</span>
<span class="schedule-time">02:30</span>
<span class="schedule-next">Következő: 2026-09-14 05:12</span>
<span class="schedule-next">Következő: 2026. 09. 14. 05:12</span>
</div>
</div>
<div class="schedule-summary">
<div class="schedule-summary-row">
<span>Utolsó adatbázis mentés:</span>
<span class="schedule-summary-value">2026-09-14 05:12 (# napja)</span>
<span class="schedule-summary-value">2026. 09. 14. 05:12 (# napja)</span>
</div>
</div>
@@ -257,8 +262,8 @@
<tr>
<td>bookstack</td>
<td><span class="db-type-badge db-type-mariadb">MariaDB</span></td>
<td class="mono">1.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">1,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-ok">12 tábla</span>
@@ -274,8 +279,8 @@
<tr>
<td>immich</td>
<td><span class="db-type-badge db-type-postgres">PostgreSQL</span></td>
<td class="mono">2.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">2,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-fail" title="no create table">Hiba</span>
@@ -291,8 +296,8 @@
<tr>
<td>romm</td>
<td><span class="db-type-badge db-type-postgres">PostgreSQL</span></td>
<td class="mono">3.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">3,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-na" title="Az érvényesítés nem futott le">–</span>
@@ -350,12 +355,12 @@
<span class="tier-label">Mentés</span>
<span class="layer-badge">Megtartva</span>
<span class="tier-location">helyi</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026-09-14 05:12.</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026. 09. 14. 05:12.</span>
<div class="layer-actions">
<form method="POST" action="/backup/restore" style="display:inline">
<input type="hidden" name="stack_name" value="removed">
<input type="hidden" name="snapshot_id" value="helyi">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026-09-14 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026. 09. 14. 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
</form>
</div>
</div>
@@ -855,7 +860,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="okrun">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026-09-14 05:12">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026. 09. 14. 05:12">Fájlok visszaállítása</button>
</form>
@@ -938,7 +943,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="oksucc">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-09-14 05:12.">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026. 09. 14. 05:12.">Fájlok visszaállítása</button>
</form>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -193,14 +198,14 @@
<div class="schedule-row">
<span class="schedule-task">Adatbázis mentés</span>
<span class="schedule-time">02:30</span>
<span class="schedule-next">Következő: 2026-09-14 05:12</span>
<span class="schedule-next">Következő: 2026. 09. 14. 05:12</span>
</div>
</div>
<div class="schedule-summary">
<div class="schedule-summary-row">
<span>Utolsó adatbázis mentés:</span>
<span class="schedule-summary-value">2026-09-14 05:12 (# napja)</span>
<span class="schedule-summary-value">2026. 09. 14. 05:12 (# napja)</span>
</div>
</div>
@@ -251,8 +256,8 @@
<tr>
<td>bookstack</td>
<td><span class="db-type-badge db-type-mariadb">MariaDB</span></td>
<td class="mono">1.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">1,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-ok">12 tábla</span>
@@ -268,8 +273,8 @@
<tr>
<td>immich</td>
<td><span class="db-type-badge db-type-postgres">PostgreSQL</span></td>
<td class="mono">2.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">2,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-fail" title="no create table">Hiba</span>
@@ -285,8 +290,8 @@
<tr>
<td>romm</td>
<td><span class="db-type-badge db-type-postgres">PostgreSQL</span></td>
<td class="mono">3.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">3,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-na" title="Az érvényesítés nem futott le">–</span>
@@ -344,12 +349,12 @@
<span class="tier-label">Mentés</span>
<span class="layer-badge">Megtartva</span>
<span class="tier-location">helyi</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026-09-14 05:12.</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026. 09. 14. 05:12.</span>
<div class="layer-actions">
<form method="POST" action="/backup/restore" style="display:inline">
<input type="hidden" name="stack_name" value="removed">
<input type="hidden" name="snapshot_id" value="helyi">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026-09-14 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026. 09. 14. 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
</form>
</div>
</div>
@@ -849,7 +854,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="okrun">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026-09-14 05:12">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026. 09. 14. 05:12">Fájlok visszaállítása</button>
</form>
@@ -932,7 +937,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="oksucc">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-09-14 05:12.">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026. 09. 14. 05:12.">Fájlok visszaállítása</button>
</form>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -193,7 +198,7 @@
<div class="schedule-row">
<span class="schedule-task">Adatbázis mentés</span>
<span class="schedule-time">02:30</span>
<span class="schedule-next">Következő: 2026-09-14 05:12</span>
<span class="schedule-next">Következő: 2026. 09. 14. 05:12</span>
</div>
</div>
<div class="schedule-summary">
@@ -258,12 +263,12 @@
<span class="tier-label">Mentés</span>
<span class="layer-badge">Megtartva</span>
<span class="tier-location">helyi</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026-09-14 05:12.</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026. 09. 14. 05:12.</span>
<div class="layer-actions">
<form method="POST" action="/backup/restore" style="display:inline">
<input type="hidden" name="stack_name" value="removed">
<input type="hidden" name="snapshot_id" value="helyi">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026-09-14 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026. 09. 14. 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
</form>
</div>
</div>
@@ -763,7 +768,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="okrun">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026-09-14 05:12">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026. 09. 14. 05:12">Fájlok visszaállítása</button>
</form>
@@ -846,7 +851,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="oksucc">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-09-14 05:12.">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026. 09. 14. 05:12.">Fájlok visszaállítása</button>
</form>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -193,7 +198,7 @@
<div class="schedule-row">
<span class="schedule-task">Adatbázis mentés</span>
<span class="schedule-time">02:30</span>
<span class="schedule-next">Következő: 2026-09-14 05:12</span>
<span class="schedule-next">Következő: 2026. 09. 14. 05:12</span>
</div>
</div>
<div class="schedule-summary">
@@ -258,12 +263,12 @@
<span class="tier-label">Mentés</span>
<span class="layer-badge">Megtartva</span>
<span class="tier-location">helyi</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026-09-14 05:12.</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026. 09. 14. 05:12.</span>
<div class="layer-actions">
<form method="POST" action="/backup/restore" style="display:inline">
<input type="hidden" name="stack_name" value="removed">
<input type="hidden" name="snapshot_id" value="helyi">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026-09-14 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026. 09. 14. 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
</form>
</div>
</div>
@@ -763,7 +768,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="okrun">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026-09-14 05:12">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026. 09. 14. 05:12">Fájlok visszaállítása</button>
</form>
@@ -846,7 +851,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="oksucc">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-09-14 05:12.">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026. 09. 14. 05:12.">Fájlok visszaállítása</button>
</form>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -193,14 +198,14 @@
<div class="schedule-row">
<span class="schedule-task">Adatbázis mentés</span>
<span class="schedule-time">02:30</span>
<span class="schedule-next">Következő: 2026-09-14 05:12</span>
<span class="schedule-next">Következő: 2026. 09. 14. 05:12</span>
</div>
</div>
<div class="schedule-summary">
<div class="schedule-summary-row">
<span>Utolsó adatbázis mentés:</span>
<span class="schedule-summary-value">2026-09-14 05:12 (# napja)</span>
<span class="schedule-summary-value">2026. 09. 14. 05:12 (# napja)</span>
</div>
</div>
@@ -251,8 +256,8 @@
<tr>
<td>bookstack</td>
<td><span class="db-type-badge db-type-mariadb">MariaDB</span></td>
<td class="mono">1.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">1,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-ok">12 tábla</span>
@@ -268,8 +273,8 @@
<tr>
<td>immich</td>
<td><span class="db-type-badge db-type-postgres">PostgreSQL</span></td>
<td class="mono">2.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">2,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-fail" title="no create table">Hiba</span>
@@ -285,8 +290,8 @@
<tr>
<td>romm</td>
<td><span class="db-type-badge db-type-postgres">PostgreSQL</span></td>
<td class="mono">3.0 MB</td>
<td class="mono">09-14 05:12</td>
<td class="mono">3,0 MB</td>
<td class="mono">09. 14. 05:12</td>
<td>
<span class="validation-badge validation-na" title="Az érvényesítés nem futott le">–</span>
@@ -344,12 +349,12 @@
<span class="tier-label">Mentés</span>
<span class="layer-badge">Megtartva</span>
<span class="tier-location">helyi</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026-09-14 05:12.</span>
<span class="tier-contents">Az alkalmazást eltávolítottad, a mentése megmaradt. Legutóbbi mentés: 2026. 09. 14. 05:12.</span>
<div class="layer-actions">
<form method="POST" action="/backup/restore" style="display:inline">
<input type="hidden" name="stack_name" value="removed">
<input type="hidden" name="snapshot_id" value="helyi">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026-09-14 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Újratelepíti a(z) App removed alkalmazást a megtartott mentésből (2026. 09. 14. 05:12)? Az adatok a mentésből állnak vissza.">Visszaállítás a mentésből</button>
</form>
</div>
</div>
@@ -849,7 +854,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="okrun">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026-09-14 05:12">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi másolat: 2026. 09. 14. 05:12">Fájlok visszaállítása</button>
</form>
@@ -932,7 +937,7 @@
<form method="POST" action="/backup/tier2/restore" style="display:inline">
<input type="hidden" name="stack_name" value="oksucc">
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026-09-14 05:12. Figyelem: a legutóbbi mentési kísérlet nem sikerült, ezért a visszaállított fájlok ennél régebbiek lehetnek.">Fájlok visszaállítása</button>
<button type="submit" class="btn btn-xs btn-outline" data-confirm="Visszaállítod a hiányzó fájlokat a másodlagos másolatból? A meglévő fájlok NEM módosulnak és NEM törlődnek. Az alkalmazás a művelet idejére leáll. Legutóbbi sikeres másolat: 2026. 09. 14. 05:12. Figyelem: a legutóbbi mentési kísérlet nem sikerült, ezért a visszaállított fájlok ennél régebbiek lehetnek.">Fájlok visszaállítása</button>
</form>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -224,7 +229,7 @@
<div class="storage-item meter nominal">
<div class="storage-header">
<span class="storage-label">Rendszer</span>
<span class="storage-value">42.5 GB / 100 GB (42%)</span>
<span class="storage-value">42,5 GB / 100 GB (42%)</span>
</div>
<div class="meter-track">
<div class="meter-fill" style="width:42.0%"></div>
@@ -274,11 +279,11 @@
<div class="stat-card stat-running">
<div class="stat-value">&#10003;</div>
<div class="stat-label">Utolsó teljes mentés
<br><span class="relative-time">2026-09-14 05:12 (# napja)</span>
<br><span class="relative-time">2026. 09. 14. 05:12 (# napja)</span>
</div>
</div>
<div class="stat-card stat-total">
<div class="stat-value">5.0 GB</div>
<div class="stat-value">5,0 GB</div>
<div class="stat-label">PBS (helyi)</div>
</div>
<div class="stat-card">
@@ -290,7 +295,7 @@
<div class="stat-card">
<div class="stat-value">&#10003;</div>
<div class="stat-label">Visszaállítás ellenőrizve
<br><span class="relative-time">2026-09-14 05:12</span>
<br><span class="relative-time">2026. 09. 14. 05:12</span>
</div>
</div>
</div>
@@ -302,11 +307,11 @@
<td style="padding:.5rem 0;vertical-align:top">
<div>&#10003; Utolsó sikeres mentés: 2026-09-14 05:12 (# napja) · 3.0 GB
<div>&#10003; Utolsó sikeres mentés: 2026. 09. 14. 05:12 (# napja) · 3,0 GB
<span class="tag tag-run" style="margin-left:.4rem">Naprakész</span></div>
<div style="color:var(--red);margin-top:.25rem">&#10007; 2026-09-14 05:12 — sikertelen</div>
<div style="color:var(--red);margin-top:.25rem">&#10007; 2026. 09. 14. 05:12 — sikertelen</div>
</td>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -177,7 +182,7 @@
<div class="alert alert-warning" data-interrupted-run="true">A legutóbbi mentés (2026-09-14 05:12) megszakadt, mert a doboz vagy a vezérlő újraindult. Amit nem fejezett be, annak a korábbi mentése maradt meg — minden visszaállítási pont annyira friss, amennyire a legrégebbi része. A következő teljes mentés után ez az üzenet eltűnik.</div>
<div class="alert alert-warning" data-interrupted-run="true">A legutóbbi mentés (2026. 09. 14. 05:12) megszakadt, mert a doboz vagy a vezérlő újraindult. Amit nem fejezett be, annak a korábbi mentése maradt meg — minden visszaállítási pont annyira friss, amennyire a legrégebbi része. A következő teljes mentés után ez az üzenet eltűnik.</div>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -311,7 +316,7 @@ var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek
function formatSnapshot(s) {
var t = new Date(s.time);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var label = t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate()) +
var label = fhIsoDate(t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate())) +
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
' (' + s.short_id + ')';
var tierLabel = s.tier === 2 ? '2. szint' : '1. szint';
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -362,7 +367,7 @@
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='\/static\/assets\/immich-logo.png';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<div class="app-row-text">
<span class="app-row-name">immich</span>
<span class="app-row-secondary">2 GB · 2026. 09. 14. 03:12 · /mnt/hdd1/copy</span>
<span class="app-row-secondary">2 GB · 2026. 09. 14. 05:12 · /mnt/hdd1/copy</span>
</div>
<div class="app-row-actions">
@@ -540,7 +545,7 @@ var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek
function formatSnapshot(s) {
var t = new Date(s.time);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var label = t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate()) +
var label = fhIsoDate(t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate())) +
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
' (' + s.short_id + ')';
var tierLabel = s.tier === 2 ? '2. szint' : '1. szint';
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -399,7 +404,7 @@ var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek
function formatSnapshot(s) {
var t = new Date(s.time);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var label = t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate()) +
var label = fhIsoDate(t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate())) +
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
' (' + s.short_id + ')';
var tierLabel = s.tier === 2 ? '2. szint' : '1. szint';
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -387,7 +392,7 @@ var huDays = ['vasárnap', 'hétfő', 'kedd', 'szerda', 'csütörtök', 'péntek
function formatSnapshot(s) {
var t = new Date(s.time);
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var label = t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate()) +
var label = fhIsoDate(t.getFullYear() + '-' + pad(t.getMonth()+1) + '-' + pad(t.getDate())) +
' ' + huDays[t.getDay()] + ' ' + pad(t.getHours()) + ':' + pad(t.getMinutes()) +
' (' + s.short_id + ')';
var tierLabel = s.tier === 2 ? '2. szint' : '1. szint';
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -209,7 +214,7 @@
<div class="stat-card stat-stopped">
<div class="stat-value">&#10007;</div>
<div class="stat-label">Utolsó teljes mentés
<br><span class="relative-time">2026-09-14 05:12 (# napja)</span>
<br><span class="relative-time">2026. 09. 14. 05:12 (# napja)</span>
</div>
</div>
<div class="stat-card stat-total">
@@ -225,7 +230,7 @@
<div class="stat-card">
<div class="stat-value">&#10007;</div>
<div class="stat-label">Visszaállítás ellenőrizve
<br><span class="relative-time">2026-09-14 05:12</span>
<br><span class="relative-time">2026. 09. 14. 05:12</span>
</div>
</div>
</div>
@@ -237,7 +242,7 @@
<td style="padding:.5rem 0;vertical-align:top">
<div>&#10003; Utolsó sikeres mentés: 2026-09-14 05:12 (# napja)
<div>&#10003; Utolsó sikeres mentés: 2026. 09. 14. 05:12 (# napja)
<span class="tag tag-warn" style="margin-left:.4rem">Esedékes</span></div>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -193,7 +198,7 @@
<div class="system-info-item meter crit">
<div class="system-info-header">
<span class="system-info-label">Memória</span>
<span class="system-info-value">7.8 GB / 8.0 GB (97%)</span>
<span class="system-info-value">7,8 GB / 8,0 GB (97%)</span>
</div>
<div class="meter-track">
<div class="meter-fill" style="width:97%"></div>
@@ -207,7 +212,7 @@
<div class="meter-track">
<div class="meter-fill" style="width:99%"></div>
</div>
<div class="system-load-avg">Load: 4.00 / 3.00 / 2.00</div>
<div class="system-load-avg">Load: 4,00 / 3,00 / 2,00</div>
</div>
<div class="system-info-item system-info-item-compact">
@@ -225,7 +230,7 @@
<div class="system-info-item meter crit">
<div class="system-info-header">
<span class="system-info-label">Rendszer</span>
<span class="system-info-value">97.0 GB / 100 GB (97%)</span>
<span class="system-info-value">97,0 GB / 100 GB (97%)</span>
</div>
<div class="meter-track">
<div class="meter-fill" style="width:97%"></div>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -193,7 +198,7 @@
<div class="system-info-item meter nominal">
<div class="system-info-header">
<span class="system-info-label">Memória</span>
<span class="system-info-value">4.0 GB / 8.0 GB (50%)</span>
<span class="system-info-value">4,0 GB / 8,0 GB (50%)</span>
</div>
<div class="meter-track">
<div class="meter-fill" style="width:50%"></div>
@@ -207,7 +212,7 @@
<div class="meter-track">
<div class="meter-fill" style="width:12%"></div>
</div>
<div class="system-load-avg">Load: 0.50 / 0.40 / 0.30</div>
<div class="system-load-avg">Load: 0,50 / 0,40 / 0,30</div>
</div>
<div class="system-info-item system-info-item-compact">
@@ -225,7 +230,7 @@
<div class="system-info-item meter warn">
<div class="system-info-header">
<span class="system-info-label">Rendszer</span>
<span class="system-info-value">80.0 GB / 100 GB (80%)</span>
<span class="system-info-value">80,0 GB / 100 GB (80%)</span>
</div>
<div class="meter-track">
<div class="meter-fill" style="width:80%"></div>
@@ -304,7 +309,7 @@
<span class="backup-label">Utolsó mentés:</span>
<span class="backup-value">
<span>2026-09-14 05:12</span>
<span>2026. 09. 14. 05:12</span>
</span>
</div>
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -15,6 +15,11 @@
document.documentElement.className=document.documentElement.className.replace('no-js','js');
function csrfHeaders(){var el=document.querySelector('meta[name="csrf-token"]');return el?{'X-CSRF-Token':el.content}:{};}
var fhEN=document.documentElement.lang==='en';
function fhNum(v,prec){var s=Number(v).toFixed(prec);return fhEN?s:s.replace('.',',');}
function fhIsoDate(iso){return fhEN?iso:iso.replace(/^(\d{4})-(\d{2})-(\d{2})/,'$1. $2. $3.');}
function fhDateTime(d){return fhIsoDate(d.toLocaleString('sv-SE',{timeZone:'Europe/Budapest',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}));}
function fhMonthDay(d){var s=d.toLocaleDateString('sv-SE',{timeZone:'Europe/Budapest',month:'2-digit',day:'2-digit'});return fhEN?s:s.replace(/^(\d{2})-(\d{2})$/,'$1. $2.');}
@@ -193,7 +198,7 @@
<div class="system-info-item meter nominal">
<div class="system-info-header">
<span class="system-info-label">Memória</span>
<span class="system-info-value">1.0 GB / 8.0 GB (12%)</span>
<span class="system-info-value">1,0 GB / 8,0 GB (12%)</span>
</div>
<div class="meter-track">
<div class="meter-fill" style="width:12%"></div>
@@ -207,7 +212,7 @@
<div class="meter-track">
<div class="meter-fill" style="width:1%"></div>
</div>
<div class="system-load-avg">Load: 0.10 / 0.10 / 0.10</div>
<div class="system-load-avg">Load: 0,10 / 0,10 / 0,10</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More