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:
@@ -1,3 +1,47 @@
|
||||
## v0.308.0 — the controller batch of 2026-10-12: clear-address warning, a secret sweep over every page, NVMe heat, the share globe, Hungarian dates and numbers (2026-10-12)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged — no agent call changed). **R-266 needs the hub of the same day** to act on the new
|
||||
`read_failed` field (an older hub ignores an unknown JSON field and bands the zeros, today's behaviour). Operator rulings
|
||||
2026-10-12, `09` §3 decisions 196, 204–207, 223, 227.
|
||||
|
||||
- **R-922 (S1) — clearing the notification address is warned first.** An empty address over a stored one saves nothing
|
||||
and sends nothing on the first press; the page says „Ha törlöd az értesítési e-mail-címet, nem kapsz több értesítő
|
||||
levelet." / "If you remove the notification e-mail address, you will get no more notification e-mails." with its own
|
||||
confirm button. Only the confirmed save clears it and sends `email_cleared` (v0.305.0). Server-side (no script), so it
|
||||
holds for any client. What is sent to the hub is unchanged. `TestR922_ClearWarnsFirst` (RED without the warning block:
|
||||
„the address was cleared before the warning"); a new parity case `settings_notifications_clear_confirm`.
|
||||
- **R-255 (S9) — one test renders EVERY page with planted secrets.** `TestR255_NoPlantedSecretReachesAnyPage` renders
|
||||
all 142 parity cases of all 35 page templates, in both languages, with a unique value in every secret-named field of
|
||||
their data (any depth) plus the keys handlers really set (`RetrievalPassword`, `InitialCreds.Password`, …), and fails
|
||||
on any that reaches the HTML. Pages are DERIVED from the templates, so a new page without a case fails it. **It found
|
||||
one leak, fixed here:** an app's optional-config `secret_input` (a key the household typed in, e.g. a metadata
|
||||
provider's API key) was rendered into `value="…"` of its settings page on every open. It is now fetched on demand
|
||||
through the existing reveal endpoint (authorised for `secret_input` fields only), and a blank field never revealed is
|
||||
not sent, so saving other fields keeps the key. RED: the old `deploy.html` → „a planted secret reached the HTML — from
|
||||
deploy_new.CurrentValues.API_KEY"; a planted `{{.RetrievalPassword}}` in settings_security → caught.
|
||||
`TestR255_OptionalSecretIsRevealedOnDemand` (RED without the new reveal branch: 403).
|
||||
- **R-266 (S10, controller half) — a failed root statfs says so on the wire.** `report.StorageReport` gains
|
||||
`read_failed` (omitempty); the `/` entry carries `true` when `SystemInfo.DiskKnown` is false, the numbers stay zeros.
|
||||
`TestRootStorageEntry_ReadFailed` (RED without the assignment).
|
||||
- **R-333 (S11) — no heat band for NVMe.** An NVMe drive's temperature alone never raises Figyelmeztetés or Hiba; its own
|
||||
`critical_warning` (incl. the temperature bit) still makes it Hiba; spinning/SATA bands unchanged. The alert text no
|
||||
longer names heat for an NVMe. `TestDiskVerdict_NVMeHasNoHeatBand` (RED with the guard removed: a 72 °C NVMe was Hiba).
|
||||
- **R-577 (S12) — the language globe on the guest share pages**, the visitor's own (`/lang`, their cookie; the household's
|
||||
setting is the default as on the sign-in page). The share's „Hibás jelszó" line now follows the visitor's language
|
||||
(`flash.share.wrong_password`). `TestGuestSharePagesHaveGlobe` replaces `TestGuestSharePagesHaveNoGlobe` (RED without
|
||||
the globe line); `TestGuestSharePage_HonoursVisitorCookie` drives `/s/<token>` through the full mux.
|
||||
- **R-562 (S28) — Hungarian dates and numbers in Hungarian, ISO-like in English.** One set of layouts in
|
||||
`internal/i18n/format.go`; every template date/size func is per-language (`formatFuncs`), the Go-side page data in
|
||||
`internal/web` follows, page scripts use `fhNum`/`fhDateTime` (layout.html), precomputed sizes go through `numText`.
|
||||
Raw RFC3339 stamps that reached pages (geo sync, drive retirement, share-backup runs, sealed-at) now render as local
|
||||
dates. **The Hungarian parity fixtures were re-captured for this release only**: 107 changed by date/number format
|
||||
only (machine-classified), the rest by the edited page scripts, the share globe and the deploy fix — named in
|
||||
REPORT.md. `TestR562_BackupsPageFormatsPerLanguage`, `TestR562_FormatFuncsPerLanguage`, `TestFormat_*` (RED with the
|
||||
formatters ignoring the language).
|
||||
- **R-521 (S32, controller half) — the lost-drive option.** The household's „Meghajtó leválasztva" option already exists,
|
||||
saves and is sent in `enabled_events`; `TestR521_StorageDisconnectedOptionRoundTrips` now pins it both ways (RED with
|
||||
the option dropped from the handler). It has been in `DefaultEnabledEvents` since v0.17.0 — left as it is (see REPORT).
|
||||
|
||||
## v0.307.0 — after a restart the Databases table lists each database once, not every undo copy (2026-10-10)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged — no agent call changed). Carries v0.306.0 (R-570). Released 2026-10-10 alone (no hub
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
| `i18n.Bundle` (`Shared` / `Msg` / `Plural` / `Expand`) | controller/internal/i18n/i18n.go | `Msg(lang, key) string` | ALL customer copy that has been converted (v0.247.0) | hu.json is authoritative; a key missing in en shows Hungarian (counted), never blank; `Expand` runs BEFORE template parse — never add a runtime `T` func (escaping would change Hungarian bytes). A template edit to converted copy goes in hu.json + en.json, then `TestI18nParity` must still pass |
|
||||
| `huTemplateSource` (test) | controller/internal/web/i18n_wiring_test.go | `(name) ([]byte, error)` | A test that asserts a template's Hungarian WORDING | Reads the expansion, not the raw file — since slice 1 the wording lives in hu.json. Never use `templateFS.ReadFile` for a wording assertion |
|
||||
| `Server.langFor` / `templatesFor` / `addLanguageData` | controller/internal/web/server.go + i18n_web.go | request → lang → template set | Rendering a page in the household's language | Only `executeTemplate` is language-aware; `s.tmpl` is the Hungarian set; Go-side titles via `data["TitleKey"]` |
|
||||
| `i18n.FormatDateTime` / `FormatDate` / `FormatMonthDayTime` / `FormatDecimal` / `LocalizeDecimals` | controller/internal/i18n/format.go | `(lang, …) string` | EVERY date or decimal number shown on the dashboard (R-562, v0.308.0): hu „2026. 10. 10. 15:04" + decimal comma, en „2026-10-10 15:04" + point | Pass the time already in Budapest; `LocalizeDecimals` is only for a size another package already formatted („1.5 GB") — new code calls `FormatDecimal`. Templates use the bound names in `formatFuncs(lang)` (funcmap.go: `fmtTime`, `fmtTimeStr`, `fmtGB`, `fmtBytes`, `numText`, …); Go page data uses `fmtLocalTime(lang, t)` / `fmtRFC3339Local(lang, s)` / `fmtSizeBytes(lang, n)`; page scripts use `fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` (layout.html). Never a `"2006-01-02 15:04"` literal for display. CSS widths keep `printf "%.1f"` |
|
||||
| `timeAgoStr` | controller/internal/web/funcmap.go | `(s RFC3339 string) string` | Ago-format for STRING timestamps | Exists because `timeAgo(time.Time)` 500'd on strings (v0.93 bug) |
|
||||
| `Server.baseData` / `executeTemplate` | controller/internal/web/handlers.go + server.go | page-data plumbing | New pages | baseData injects nav/alerts/version; templates must pass `controller/scripts/template_id_gate.py` + `controller/scripts/emoji_gate.py` |
|
||||
| `Server.RequireAuth` / `CsrfProtect` / `csrfField` | controller/internal/web/auth.go + csrf.go | middleware | Any new authed route/form | csrfField emits the hidden input; setup wizard has its OWN csrf (§6) |
|
||||
|
||||
+29
-5
@@ -298,10 +298,15 @@ self-reported failure gets:
|
||||
|
||||
- **Rendben** — clean.
|
||||
- **Figyelmeztetés** — a first sighting of unreadable sectors below the bar, reallocated sectors, NVMe
|
||||
media errors, `percentage_used ≥ 90`, or `temperature_c ≥ 55`.
|
||||
media errors, `percentage_used ≥ 90`, or `temperature_c ≥ 55` (spinning/SATA drives only).
|
||||
- **Hiba** — the drive reports FAILING, **or** (v0.215.0) unreadable sectors are **sustained** across
|
||||
two consecutive checks, unreadable sectors sit alongside reallocation, the count reaches **64**,
|
||||
`temperature_c ≥ 60`, NVMe's `critical_warning` is set, or `percentage_used ≥ 100`.
|
||||
`temperature_c ≥ 60` (spinning/SATA drives only), NVMe's `critical_warning` is set, or `percentage_used ≥ 100`.
|
||||
- **No heat band for NVMe (v0.308.0, R-333, `09` §3 decision 206).** The 55/60 °C bands are spinning-disk bands;
|
||||
a healthy NVMe idles at 53 °C and passes 60 °C under load. An NVMe drive (`agentapi.IsNVMe`: the summary
|
||||
carries `critical_warning`, `percentage_used` or `media_errors`) never reaches Figyelmeztetés/Hiba from its
|
||||
temperature; its own `critical_warning` (bit 1 is its temperature alarm) still makes it Hiba. Pinned by
|
||||
`TestDiskVerdict_NVMeHasNoHeatBand`.
|
||||
- **Nincs adat** — nil/UNKNOWN/old agent. Never alarms, and never erases a disk's stored history.
|
||||
|
||||
`DiskVerdictFor(smart, prior)` takes an `agentapi.DiskPrior`; a zero prior is the fail-safe, so a first
|
||||
@@ -2721,7 +2726,7 @@ The controller pushes structured events to the Hub's `/api/v1/event` endpoint. T
|
||||
| `health_recovered` | info | Health status recovers (fail/warn→ok) |
|
||||
| `disk_warning` | warning | Disk usage crosses 90% |
|
||||
| `disk_critical` | **critical** | Disk usage crosses 95% (this row read `error` until v0.223.0; the emitter is `fillwatch.Band.Severity()` and it has always sent `critical`) |
|
||||
| `storage_disconnected` | error | Storage drive physically removed |
|
||||
| `storage_disconnected` | error | Storage drive physically removed. The household's „Meghajtó leválasztva" option (R-521, `09` §3 decision 227) carries it in `enabled_events`; the hub mails a household a lost drive only when that option is on. Pinned by `TestR521_StorageDisconnectedOptionRoundTrips` |
|
||||
| `storage_reconnected` | info | Storage drive reconnected |
|
||||
| `controller_started` | info | Controller process starts |
|
||||
| `controller_updated` | info/error | Self-update success or failure |
|
||||
@@ -2921,6 +2926,12 @@ provisioning-seeded alert delivery). The form re-renders with a Hungarian error
|
||||
ticked events preserved; no save, no sync. Clearing the e-mail with **zero** events enabled is
|
||||
allowed (an intentional turn-everything-off).
|
||||
|
||||
**Clearing the address is warned first (v0.308.0, R-922, `09` §3 decision 196):** an empty box over a
|
||||
STORED address saves nothing and sends nothing on the first press; the page says „you will get no more
|
||||
notification mails" (both languages) with its own confirm button (`confirm_clear=1`). Only the confirmed
|
||||
save clears the address and sends `email_cleared` (v0.305.0). Server-side, so it holds without JavaScript.
|
||||
The hub's registered address is not the box's and is not touched. Pinned by `TestR922_ClearWarnsFirst`.
|
||||
|
||||
---
|
||||
|
||||
### 7. Update Management
|
||||
@@ -3265,6 +3276,10 @@ starts a countdown or shortens one (`TestOpActions_ClosedList`). The box's log r
|
||||
|
||||
Periodic JSON push (default every 15 min) to the central felhom-hub service:
|
||||
- System: hostname, OS, CPU, memory, disk usage, uptime
|
||||
- Storage: one entry per mount (`/` first). **`read_failed: true` (v0.308.0, R-266)** marks the root entry when
|
||||
its statfs failed (`SystemInfo.DiskKnown` false); the numbers stay the zeros the failed read left and the hub
|
||||
skips banding that entry instead of reading it as an empty disk. Absent on a measured entry. Pinned by
|
||||
`TestRootStorageEntry_ReadFailed`.
|
||||
- Containers: running/stopped counts, per-container CPU/memory
|
||||
- Backup: last DB-dump run, success (disk-tier backup is the host agent's; restic password is no longer reported — removed v0.69.0)
|
||||
- Health: current status, issues, warnings
|
||||
@@ -4097,8 +4112,17 @@ the apps' own UIs (not ours).
|
||||
never reads the cookie. `/lang` is CSRF-exempt because its only achievable effect is the language of
|
||||
the page the victim's own browser shows them — **if it gains a second effect it needs CSRF that day**.
|
||||
`safeBackPath` refuses a protocol-relative `//host` as well as an absolute URL. A successful CLAIM
|
||||
carries the cookie into the household setting (§16). **No globe on the guest share pages or the
|
||||
not-found page** — R-577.
|
||||
carries the cookie into the household setting (§16). **The guest share pages carry the globe too (v0.308.0,
|
||||
R-577, `09` §3 decision 207)** — the visitor's own: it posts to `/lang`, their cookie decides, the household's
|
||||
setting is the default as on the sign-in page, and the share's wrong-password line follows it. The not-found
|
||||
page has none. Pinned by `TestGuestSharePagesHaveGlobe`, `TestGuestSharePage_HonoursVisitorCookie`.
|
||||
- **Dates and numbers per language (v0.308.0, R-562, `09` §3 decision 223):** Hungarian „2026. 10. 10. 15:04" and
|
||||
a decimal comma, English „2026-10-10 15:04" and a decimal point — in every template func that prints a date
|
||||
or a size (`formatFuncs(lang)` in `funcmap.go`, layouts in `internal/i18n/format.go`), in the Go-side page
|
||||
data built in `internal/web`, and in page scripts (`fhNum` / `fhDateTime` / `fhIsoDate` / `fhMonthDay` in
|
||||
`layout.html`). A size another package already formatted („1.5 GB") goes through `numText` /
|
||||
`i18n.LocalizeDecimals`. CSS widths keep their point. The Hungarian parity fixtures were re-captured ONCE for
|
||||
this release (the change is classified date/number-only, see REPORT.md).
|
||||
- **Report:** the hub report carries `"language"` (always present). No hub release reads it yet.
|
||||
- **Tools and gates:** `scripts/i18n_extract.py` converts a template (moves each Hungarian run into
|
||||
hu.json, leaves a marker); `scripts/i18n_missing_gate.py` (in `controller_gates.py`) checks keys
|
||||
|
||||
@@ -59,7 +59,7 @@ type DiskPrior struct {
|
||||
//
|
||||
// 1. nil / "" / UNKNOWN -> Nincs adat
|
||||
// 2. Health == FAILING -> Hiba (drive self-reports)
|
||||
// 3. temperature_c >= 60 -> Hiba
|
||||
// 3. temperature_c >= 60 (NOT for NVMe — R-333) -> Hiba
|
||||
// 4. critical_warning > 0 (NVMe's own flag: a declaration) -> Hiba
|
||||
// 5. percentage_used >= 100 -> Hiba
|
||||
// 6. unreadable > 0 AND prior.SawUncorrectable -> Hiba (SUSTAINED)
|
||||
@@ -69,7 +69,7 @@ type DiskPrior struct {
|
||||
// 10. reallocated > 0 -> Figyelmeztetés
|
||||
// 11. media_errors > 0 -> Figyelmeztetés
|
||||
// 12. percentage_used >= 90 -> Figyelmeztetés
|
||||
// 13. temperature_c >= 55 -> Figyelmeztetés
|
||||
// 13. temperature_c >= 55 (NOT for NVMe — R-333) -> Figyelmeztetés
|
||||
// 14. otherwise -> Rendben
|
||||
//
|
||||
// WHY rows 2-8 exist at all: smart_status.passed CANNOT fail on unreadable sectors. Attributes 187,
|
||||
@@ -82,6 +82,12 @@ type DiskPrior struct {
|
||||
// drive sustain fires a full day earlier (12 Aug) than the count threshold (13 Aug). Row 8 exists for
|
||||
// a box that was powered off or restarted across the sustain window and so has no prior.
|
||||
//
|
||||
// WHY rows 3 and 13 skip an NVMe drive (R-333, `09` §3 decision 206, v0.308.0): the 55/60 °C bands are
|
||||
// SPINNING-DISK bands. A healthy NVMe idles at 53 °C (measured on demo-hp) and routinely passes 60 °C
|
||||
// under load with no fault, so the bands would tell a household "Hiba" about a working drive. An NVMe
|
||||
// drive judges its own heat: critical_warning bit 1 is the device's temperature alarm, and row 4 still
|
||||
// turns any critical_warning bit into Hiba. Pinned by TestDiskVerdict_NVMeHasNoHeatBand.
|
||||
//
|
||||
// Pure: no clock, no I/O, no logging. Everything it needs arrives as an argument.
|
||||
func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
|
||||
// 1 — no data. Never alarms.
|
||||
@@ -94,8 +100,9 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
|
||||
}
|
||||
// Health == PASSED (or any non-empty non-FAILING value we treat as passing): inspect the counters,
|
||||
// because the overall verdict is structurally unable to report this class of fault.
|
||||
nvme := IsNVMe(s)
|
||||
switch {
|
||||
case atLeast(s.TemperatureC, TemperatureFailC): // 3
|
||||
case !nvme && atLeast(s.TemperatureC, TemperatureFailC): // 3
|
||||
return DiskVerdictFail
|
||||
case positive(s.CriticalWarning): // 4
|
||||
return DiskVerdictFail
|
||||
@@ -118,7 +125,7 @@ func DiskVerdictFor(s *SmartSummary, prior DiskPrior) DiskVerdict {
|
||||
return DiskVerdictWarn
|
||||
case atLeast(s.PercentageUsed, percentageUsedWarn): // 12
|
||||
return DiskVerdictWarn
|
||||
case atLeast(s.TemperatureC, temperatureWarnC): // 13
|
||||
case !nvme && atLeast(s.TemperatureC, temperatureWarnC): // 13
|
||||
return DiskVerdictWarn
|
||||
}
|
||||
return DiskVerdictOK // 14
|
||||
@@ -190,12 +197,21 @@ func DegradedAttributes(s *SmartSummary) []string {
|
||||
if atLeast(s.PercentageUsed, percentageUsedWarn) {
|
||||
out = append(out, "elhasználódás")
|
||||
}
|
||||
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable.
|
||||
if atLeast(s.TemperatureC, temperatureWarnC) {
|
||||
// Newly able to trigger a verdict on its own (rows 3 and 13), so it must be nameable. Not for an
|
||||
// NVMe drive (R-333): its heat never triggers a verdict, so naming it would give a wrong reason.
|
||||
if !IsNVMe(s) && atLeast(s.TemperatureC, temperatureWarnC) {
|
||||
out = append(out, "hőmérséklet")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsNVMe reports whether the summary came from an NVMe drive. The agent fills critical_warning,
|
||||
// percentage_used and media_errors ONLY from smartctl's nvme_smart_health_information_log
|
||||
// (felhom-agent internal/storage/smart.go), so any of them present means NVMe; a SATA or USB drive
|
||||
// carries none of them.
|
||||
func IsNVMe(s *SmartSummary) bool {
|
||||
return s != nil && (s.CriticalWarning != nil || s.PercentageUsed != nil || s.MediaErrors != nil)
|
||||
}
|
||||
|
||||
func positive(p *int) bool { return p != nil && *p > 0 }
|
||||
func atLeast(p *int, n int) bool { return p != nil && *p >= n }
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package agentapi
|
||||
|
||||
import "testing"
|
||||
|
||||
// R-333 (`09` §3 decision 206): no heat band for an NVMe drive. Both directions, because "fixed" means
|
||||
// one kind of drive stopped alarming on heat and the other did not, and the drive's own alarm stays.
|
||||
func TestDiskVerdict_NVMeHasNoHeatBand(t *testing.T) {
|
||||
noPrior := DiskPrior{}
|
||||
cases := []struct {
|
||||
name string
|
||||
in *SmartSummary
|
||||
want DiskVerdict
|
||||
}{
|
||||
// A healthy NVMe under load: 72 °C, the device raises no flag. No heat alarm.
|
||||
{"hot NVMe, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(0), PercentageUsed: ip(3), MediaErrors: ip(0)}, DiskVerdictOK},
|
||||
{"warm NVMe 56 °C, no critical warning → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), CriticalWarning: ip(0)}, DiskVerdictOK},
|
||||
// The same drive with its own temperature bit set (critical_warning bit 1 = 0x02): Hiba.
|
||||
{"hot NVMe with critical_warning temperature bit → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}, DiskVerdictFail},
|
||||
{"cool NVMe with critical_warning → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(40), CriticalWarning: ip(1)}, DiskVerdictFail},
|
||||
// Spinning/SATA drives keep their 55/60 °C bands, unchanged.
|
||||
{"SATA 56 °C → warn", &SmartSummary{Health: SmartPassed, TemperatureC: ip(56), ReallocatedSectors: ip(0)}, DiskVerdictWarn},
|
||||
{"SATA 60 °C → fail", &SmartSummary{Health: SmartPassed, TemperatureC: ip(60), ReallocatedSectors: ip(0)}, DiskVerdictFail},
|
||||
{"SATA 54 °C → ok", &SmartSummary{Health: SmartPassed, TemperatureC: ip(54)}, DiskVerdictOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := DiskVerdictFor(c.in, noPrior); got != c.want {
|
||||
t.Errorf("%s: DiskVerdictFor = %s, want %s", c.name, got.Label(), c.want.Label())
|
||||
}
|
||||
}
|
||||
// The alert body must not name heat as the reason for an NVMe drive.
|
||||
for _, a := range DegradedAttributes(&SmartSummary{Health: SmartPassed, TemperatureC: ip(72), CriticalWarning: ip(2)}) {
|
||||
if a == "hőmérséklet" {
|
||||
t.Error("an NVMe alert names temperature as an attribute; the verdict fired on the drive's own warning")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Dates and numbers per language (R-562, v0.308.0, `09` §3 decision 223) ────────────────────────
|
||||
//
|
||||
// The ONE place the dashboard decides how a date, a time or a decimal number looks:
|
||||
//
|
||||
// Hungarian 2026. 10. 10. 15:04 1,5 GB (the Hungarian date order with its dots; decimal COMMA)
|
||||
// English 2026-10-10 15:04 1.5 GB (ISO-like; decimal point)
|
||||
//
|
||||
// Every function takes the time ALREADY in the zone it is shown in (the dashboard shows Budapest
|
||||
// local time; the callers convert). An unknown language formats as Hungarian, like every other
|
||||
// fallback in this package. Pinned by TestFormat_* in format_test.go.
|
||||
|
||||
// FormatDateTime renders a date with hour and minute.
|
||||
func FormatDateTime(lang string, t time.Time) string {
|
||||
if Normalize(lang) == "en" {
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
return t.Format("2006. 01. 02. 15:04")
|
||||
}
|
||||
|
||||
// FormatDate renders a date alone.
|
||||
func FormatDate(lang string, t time.Time) string {
|
||||
if Normalize(lang) == "en" {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
return t.Format("2006. 01. 02.")
|
||||
}
|
||||
|
||||
// FormatMonthDayTime renders a date in the current year: month, day, hour and minute.
|
||||
func FormatMonthDayTime(lang string, t time.Time) string {
|
||||
if Normalize(lang) == "en" {
|
||||
return t.Format("01-02 15:04")
|
||||
}
|
||||
return t.Format("01. 02. 15:04")
|
||||
}
|
||||
|
||||
// FormatDecimal renders v with prec digits after the decimal separator of lang.
|
||||
func FormatDecimal(lang string, v float64, prec int) string {
|
||||
s := strconv.FormatFloat(v, 'f', prec, 64)
|
||||
if Normalize(lang) == "en" {
|
||||
return s
|
||||
}
|
||||
return strings.Replace(s, ".", ",", 1)
|
||||
}
|
||||
|
||||
// LocalizeDecimals rewrites the decimal POINT of every number in an already formatted short string
|
||||
// ("1.5 GB", "Immich (95.0 GB)") into lang's separator. Only a point with a digit on BOTH sides is
|
||||
// touched, so a sentence's full stop, a version ("0.307.0" — see below) or an address is not a target
|
||||
// by accident: a dot preceded by a digit and followed by a digit is rewritten only when the number it
|
||||
// belongs to has exactly ONE dot. English returns s unchanged.
|
||||
//
|
||||
// For strings another package formatted with %.1f (appbackup.HumanizeBytes and its siblings) and
|
||||
// handed to a page as data. A new formatter should call FormatDecimal instead.
|
||||
func LocalizeDecimals(lang, s string) string {
|
||||
if Normalize(lang) == "en" || !strings.Contains(s, ".") {
|
||||
return s
|
||||
}
|
||||
b := []byte(s)
|
||||
isDigit := func(c byte) bool { return c >= '0' && c <= '9' }
|
||||
for i := 1; i < len(b)-1; i++ {
|
||||
if b[i] != '.' || !isDigit(b[i-1]) || !isDigit(b[i+1]) {
|
||||
continue
|
||||
}
|
||||
// The whole digit-and-dot run around this dot: more than one dot is a version or an address.
|
||||
start, end := i, i
|
||||
for start > 0 && (isDigit(b[start-1]) || b[start-1] == '.') {
|
||||
start--
|
||||
}
|
||||
for end < len(b)-1 && (isDigit(b[end+1]) || b[end+1] == '.') {
|
||||
end++
|
||||
}
|
||||
if strings.Count(string(b[start:end+1]), ".") != 1 {
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
b[i] = ','
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-562 (`09` §3 decision 223): Hungarian dates and numbers in Hungarian, ISO-like in English.
|
||||
func TestFormat_DatesPerLanguage(t *testing.T) {
|
||||
at := time.Date(2026, 10, 10, 15, 4, 0, 0, time.UTC)
|
||||
cases := []struct{ name, got, want string }{
|
||||
{"hu datetime", FormatDateTime("hu", at), "2026. 10. 10. 15:04"},
|
||||
{"en datetime", FormatDateTime("en", at), "2026-10-10 15:04"},
|
||||
{"hu date", FormatDate("hu", at), "2026. 10. 10."},
|
||||
{"en date", FormatDate("en", at), "2026-10-10"},
|
||||
{"hu month-day", FormatMonthDayTime("hu", at), "10. 10. 15:04"},
|
||||
{"en month-day", FormatMonthDayTime("en", at), "10-10 15:04"},
|
||||
{"unknown language falls back to Hungarian", FormatDateTime("xx", at), "2026. 10. 10. 15:04"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s: got %q, want %q", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_NumbersPerLanguage(t *testing.T) {
|
||||
cases := []struct{ name, got, want string }{
|
||||
{"hu decimal comma", FormatDecimal("hu", 1.53, 1), "1,5"},
|
||||
{"en decimal point", FormatDecimal("en", 1.53, 1), "1.5"},
|
||||
{"hu integer has no separator", FormatDecimal("hu", 12, 0), "12"},
|
||||
{"hu size string", LocalizeDecimals("hu", "1.5 GB"), "1,5 GB"},
|
||||
{"hu list of sizes", LocalizeDecimals("hu", "Immich (95.0 GB), Nextcloud (34.0 GB)"), "Immich (95,0 GB), Nextcloud (34,0 GB)"},
|
||||
{"en untouched", LocalizeDecimals("en", "1.5 GB"), "1.5 GB"},
|
||||
{"hu sentence full stop untouched", LocalizeDecimals("hu", "Kész. 3 elem."), "Kész. 3 elem."},
|
||||
{"hu version untouched", LocalizeDecimals("hu", "v0.307.0 és 1.5 GB"), "v0.307.0 és 1,5 GB"},
|
||||
{"hu address untouched", LocalizeDecimals("hu", "192.168.0.180"), "192.168.0.180"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s: got %q, want %q", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1508,6 +1508,7 @@
|
||||
"flash.share.disabled": "Sharing is off.",
|
||||
"flash.share.enable_failed": "Sharing could not be turned on.",
|
||||
"flash.share.enabled": "Sharing is on.",
|
||||
"flash.share.wrong_password": "Wrong password",
|
||||
"flash.share.not_on": "Sharing is not on.",
|
||||
"flash.share.pw_clear_failed": "The password could not be removed.",
|
||||
"flash.share.pw_cleared": "The share password is removed.",
|
||||
@@ -1885,7 +1886,7 @@
|
||||
"recovery.adatok": "Retrieve",
|
||||
"recovery.adatok_visszaszerzese_felhom": "Retrieve data — Felhom",
|
||||
"recovery.alkalmazas": "App",
|
||||
"recovery.amelyet_zartunk_le": ", which we\n sealed on <strong>{{.}}</strong>",
|
||||
"recovery.amelyet_zartunk_le": ", which we\n sealed on <strong>{{fmtTimeStr .}}</strong>",
|
||||
"recovery.biztosan_nem_kered_vissza_a": "Are you sure you do not want the earlier backups back?",
|
||||
"recovery.elerhetok": "are available",
|
||||
"recovery.ez_a_lehetoseg_akkor_valik": "this option becomes available once the machine has reconnected to the off-site\n storage. Until then your backups stay untouched.",
|
||||
@@ -1943,6 +1944,8 @@
|
||||
"settings_notifications.beallitasok_ertesitesek": "Settings — Notifications",
|
||||
"settings_notifications.bekapcsolas_utan_az_egyes_alkalmazasokna": "After turning it on, allow sending e-mail for each app too (on the app’s page).",
|
||||
"settings_notifications.biztonsagi_mentes_sikertelen": "Backup failed",
|
||||
"settings_notifications.clear_confirm": "Yes, remove the address",
|
||||
"settings_notifications.clear_warning": "If you remove the notification e-mail address, you will get no more notification e-mails. Nothing is saved until you confirm.",
|
||||
"settings_notifications.e_mail_cim": "E-mail address",
|
||||
"settings_notifications.elvart_adatbazismentes_elmaradt": "Expected database backup missed",
|
||||
"settings_notifications.elvart_fajlmentes_elmaradt": "Expected file backup missed",
|
||||
@@ -2010,7 +2013,7 @@
|
||||
"settings_security.uj_jelszo": "New password",
|
||||
"settings_security.uj_jelszo_megerositese": "Confirm new password",
|
||||
"settings_security.utolso": "Last:",
|
||||
"settings_security.utolso_szinkronizalas": "Last sync: {{.GeoLastSync}}",
|
||||
"settings_security.utolso_szinkronizalas": "Last sync: {{fmtTimeStr .GeoLastSync}}",
|
||||
"settings_security.veszhelyzeti_informaciok": "Emergency information",
|
||||
"settings_security.visszaallitasi_jelszo": "Retrieval password",
|
||||
"settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "The host agent is not reachable right now — try again later.",
|
||||
@@ -2251,7 +2254,7 @@
|
||||
"storage.hiba": "Error:",
|
||||
"storage.host_tarhely_rendszer_sablonok_iso": "Host storage — system templates, ISOs, host-level backups. It holds no app data.",
|
||||
"storage.hozzaadas": "Add",
|
||||
"storage.idopont": "Time: {{.DecommissionedAt}}",
|
||||
"storage.idopont": "Time: {{fmtTimeStr .DecommissionedAt}}",
|
||||
"storage.inaktiv": "Inactive",
|
||||
"storage.ismeretlen": "unknown",
|
||||
"storage.kesz": "Done",
|
||||
|
||||
@@ -1499,6 +1499,7 @@
|
||||
"flash.share.disabled": "A megosztás kikapcsolva.",
|
||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||
"flash.share.enabled": "A megosztás bekapcsolva.",
|
||||
"flash.share.wrong_password": "Hibás jelszó",
|
||||
"flash.share.not_on": "A megosztás nincs bekapcsolva.",
|
||||
"flash.share.pw_clear_failed": "A jelszó törlése nem sikerült.",
|
||||
"flash.share.pw_cleared": "A megosztási jelszó törölve.",
|
||||
@@ -1873,7 +1874,7 @@
|
||||
"recovery.adatok": "Adatok",
|
||||
"recovery.adatok_visszaszerzese_felhom": "Adatok visszaszerzése — Felhom",
|
||||
"recovery.alkalmazas": "Alkalmazás",
|
||||
"recovery.amelyet_zartunk_le": ", amelyet\n <strong>{{.}}</strong> zártunk le",
|
||||
"recovery.amelyet_zartunk_le": ", amelyet\n <strong>{{fmtTimeStr .}}</strong> zártunk le",
|
||||
"recovery.biztosan_nem_kered_vissza_a": "Biztosan nem kéred vissza a korábbi mentéseket?",
|
||||
"recovery.elerhetok": "elérhetők",
|
||||
"recovery.ez_a_lehetoseg_akkor_valik": "ez a lehetőség akkor válik elérhetővé, ha a gép már újra kapcsolódott a házon kívüli\n tárhelyhez. Addig a mentéseid érintetlenül megmaradnak.",
|
||||
@@ -1931,6 +1932,8 @@
|
||||
"settings_notifications.beallitasok_ertesitesek": "Beállítások — Értesítések",
|
||||
"settings_notifications.bekapcsolas_utan_az_egyes_alkalmazasokna": "Bekapcsolás után az egyes alkalmazásoknál is engedélyezni kell az email-küldést (az alkalmazás oldalán).",
|
||||
"settings_notifications.biztonsagi_mentes_sikertelen": "Biztonsági mentés sikertelen",
|
||||
"settings_notifications.clear_confirm": "Igen, törlöm a címet",
|
||||
"settings_notifications.clear_warning": "Ha törlöd az értesítési e-mail-címet, nem kapsz több értesítő levelet. A mentés csak akkor történik meg, ha megerősíted.",
|
||||
"settings_notifications.e_mail_cim": "E-mail cím",
|
||||
"settings_notifications.elvart_adatbazismentes_elmaradt": "Elvárt adatbázismentés elmaradt",
|
||||
"settings_notifications.elvart_fajlmentes_elmaradt": "Elvárt fájlmentés elmaradt",
|
||||
@@ -1998,7 +2001,7 @@
|
||||
"settings_security.uj_jelszo": "Új jelszó",
|
||||
"settings_security.uj_jelszo_megerositese": "Új jelszó megerősítése",
|
||||
"settings_security.utolso": "Utolsó:",
|
||||
"settings_security.utolso_szinkronizalas": "Utolsó szinkronizálás: {{.GeoLastSync}}",
|
||||
"settings_security.utolso_szinkronizalas": "Utolsó szinkronizálás: {{fmtTimeStr .GeoLastSync}}",
|
||||
"settings_security.veszhelyzeti_informaciok": "Vészhelyzeti információk",
|
||||
"settings_security.visszaallitasi_jelszo": "Visszaállítási jelszó",
|
||||
"settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "A kiszolgáló ügynök jelenleg nem elérhető — próbáld meg később.",
|
||||
@@ -2239,7 +2242,7 @@
|
||||
"storage.hiba": "Hiba:",
|
||||
"storage.host_tarhely_rendszer_sablonok_iso": "Host tárhely — rendszer-sablonok, ISO-k, host szintű mentések. Nem tárol alkalmazásadatot.",
|
||||
"storage.hozzaadas": "Hozzáadás",
|
||||
"storage.idopont": "Időpont: {{.DecommissionedAt}}",
|
||||
"storage.idopont": "Időpont: {{fmtTimeStr .DecommissionedAt}}",
|
||||
"storage.inaktiv": "Inaktív",
|
||||
"storage.ismeretlen": "ismeretlen",
|
||||
"storage.kesz": "Kész",
|
||||
|
||||
@@ -91,7 +91,7 @@ func BuildReport(
|
||||
|
||||
// Storage — root filesystem + all registered storage paths
|
||||
r.Storage = []StorageReport{
|
||||
{Mount: "/", Label: "SSD", TotalGB: sysInfo.DiskTotalGB, UsedGB: sysInfo.DiskUsedGB, Percent: sysInfo.DiskPercent},
|
||||
rootStorageEntry(sysInfo),
|
||||
}
|
||||
for _, sp := range storagePaths {
|
||||
if sp.Decommissioned {
|
||||
@@ -328,3 +328,14 @@ func buildStacksReport(stackMgr *stacks.Manager) StacksReport {
|
||||
|
||||
return sr
|
||||
}
|
||||
|
||||
// rootStorageEntry is the report's "/" entry. When the root statfs failed (DiskKnown false) the
|
||||
// zeros it carries are not a measurement, so the entry says so (R-266): the hub skips banding it
|
||||
// instead of reading a failed read as an empty disk.
|
||||
func rootStorageEntry(sysInfo system.SystemInfo) StorageReport {
|
||||
return StorageReport{
|
||||
Mount: "/", Label: "SSD",
|
||||
TotalGB: sysInfo.DiskTotalGB, UsedGB: sysInfo.DiskUsedGB, Percent: sysInfo.DiskPercent,
|
||||
ReadFailed: !sysInfo.DiskKnown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
)
|
||||
|
||||
// R-266: a failed root statfs reaches the hub as read_failed, not as a 0-of-0 disk the hub would band
|
||||
// as "0 % used". A measured disk carries no read_failed at all (omitempty), so an old hub sees no change.
|
||||
func TestRootStorageEntry_ReadFailed(t *testing.T) {
|
||||
failed := rootStorageEntry(system.SystemInfo{DiskKnown: false})
|
||||
if !failed.ReadFailed {
|
||||
t.Fatal("root statfs failed (DiskKnown false) but the storage entry does not say read_failed")
|
||||
}
|
||||
if failed.TotalGB != 0 || failed.UsedGB != 0 || failed.Percent != 0 || failed.Mount != "/" {
|
||||
t.Errorf("failed entry changed its numbers or mount: %+v", failed)
|
||||
}
|
||||
b, _ := json.Marshal(failed)
|
||||
if !strings.Contains(string(b), `"read_failed":true`) {
|
||||
t.Errorf("wire form lacks read_failed: %s", b)
|
||||
}
|
||||
|
||||
ok := rootStorageEntry(system.SystemInfo{DiskKnown: true, DiskTotalGB: 100, DiskUsedGB: 40, DiskPercent: 40})
|
||||
if ok.ReadFailed {
|
||||
t.Error("a measured root disk is marked read_failed")
|
||||
}
|
||||
b, _ = json.Marshal(ok)
|
||||
if strings.Contains(string(b), "read_failed") {
|
||||
t.Errorf("a measured disk must not carry read_failed on the wire: %s", b)
|
||||
}
|
||||
if ok.TotalGB != 100 || ok.Percent != 40 {
|
||||
t.Errorf("measured numbers lost: %+v", ok)
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,11 @@ type StorageReport struct {
|
||||
Disconnected bool `json:"disconnected,omitempty"`
|
||||
Decommissioned bool `json:"decommissioned,omitempty"`
|
||||
MigratedTo string `json:"migrated_to,omitempty"`
|
||||
// ReadFailed (R-266, v0.308.0) — the measurement behind this entry FAILED (today: the root "/"
|
||||
// statfs, SystemInfo.DiskKnown false). The numbers stay the zeros the failed read left; the hub
|
||||
// must not band them as "0 % used". Absent (false) means the numbers were measured. Pinned by
|
||||
// TestRootStorageEntry_ReadFailed.
|
||||
ReadFailed bool `json:"read_failed,omitempty"`
|
||||
}
|
||||
|
||||
// ContainerReport holds aggregate and per-container status.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}}🔒{{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"}}';
|
||||
|
||||
@@ -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 {'&':'&','<':'<','>':'>','"':'"'}[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>🔒</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.');}
|
||||
|
||||
|
||||
|
||||
|
||||
+5
@@ -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.');}
|
||||
|
||||
|
||||
|
||||
|
||||
+5
@@ -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.');}
|
||||
|
||||
|
||||
|
||||
|
||||
+5
@@ -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>
|
||||
|
||||
|
||||
|
||||
+18
-13
@@ -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>
|
||||
|
||||
|
||||
|
||||
+17
-12
@@ -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>
|
||||
|
||||
|
||||
|
||||
+17
-12
@@ -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">✓</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">✓</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>✓ Utolsó sikeres mentés: 2026-09-14 05:12 (# napja) · 3.0 GB
|
||||
<div>✓ 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">✗ 2026-09-14 05:12 — sikertelen</div>
|
||||
<div style="color:var(--red);margin-top:.25rem">✗ 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.');}
|
||||
|
||||
|
||||
|
||||
|
||||
+5
@@ -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.');}
|
||||
|
||||
|
||||
|
||||
|
||||
+5
@@ -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.');}
|
||||
|
||||
|
||||
|
||||
|
||||
+5
@@ -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.');}
|
||||
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user