v0.109.0: soft-quota gate + usage bar + offsite report status (SLICE 4)

QuotaGB rides the descriptor into OffboxTarget; RepoSizeBytes persisted
from restic stats. Pre-run gate: >=100% refuses NEW backups (Hungarian
notice + operator alert) but prune STILL runs (red-proofed) and restore is
never gated; >=80% warns. /backups usage bar (quota>0 only). The hub
report gains the non-secret offsite status object for the OffsiteChecker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 23:48:42 +02:00
parent 994e15896f
commit 8917014991
12 changed files with 326 additions and 23 deletions
+2 -2
View File
@@ -243,8 +243,8 @@ func main() {
KeyGen: offsiteapply.ED25519KeyGen{},
Installer: offsiteapply.SSHCopyIDInstaller{},
Prober: offsiteapply.SFTPKeyAuthProber{KeyPath: filepath.Join(cfg.Paths.DataDir, "offbox", "ssh_key")},
Enabler: offsiteapply.EnablerFunc(func(ctx context.Context, host, user string, port int, repoPath, priv, kh string) error {
tgt := &settings.OffboxTarget{Enabled: true, Host: host, User: user, Port: port, RepoPath: repoPath, Schedule: "daily"}
Enabler: offsiteapply.EnablerFunc(func(ctx context.Context, host, user string, port int, repoPath, priv, kh string, quotaGB int) error {
tgt := &settings.OffboxTarget{Enabled: true, Host: host, User: user, Port: port, RepoPath: repoPath, Schedule: "daily", QuotaGB: quotaGB}
stage := func(ctx context.Context, pw string) error {
ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint)
if err != nil {
+112 -8
View File
@@ -346,7 +346,21 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
start := time.Now()
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.LastStatus = "running"; o.LastError = "" })
backedUp, missing, runErr := m.runOffboxInternal(ctx, apps, base, env)
var backedUp int
var missing []string
var runErr error
if usedGB, quota, over := offboxQuotaState(t); over {
// SLICE 4 soft-quota gate (pre-run): NEW backups are refused at ≥100% of the shared-model quota —
// but the retention/prune step STILL RUNS (pruning is the customer's only way back under quota;
// gating it too would deadlock them over-quota) and restore paths are untouched. The CURRENT run's
// gate uses the last-known repo size; a run that crosses 100% mid-flight finishes and the NEXT
// run refuses.
m.offboxPruneOnly(ctx, base, env)
m.offboxRecordStats(ctx, base, env) // the prune may have brought the size back down — refresh
runErr = fmt.Errorf("A NAS-mentés túllépte a tárhelykeretet (%d/%d GB) — törölj régi mentéseket vagy kérj nagyobb keretet.", usedGB, quota)
} else {
backedUp, missing, runErr = m.runOffboxInternal(ctx, apps, base, env)
}
// No-silent-success: apps were toggled but NOTHING was captured (every unit missing) → promote to a
// hard error so the run reports "error" and the operator is alerted, instead of a misleading ok/0.
@@ -371,12 +385,16 @@ func (m *Manager) RunOffboxBackup(ctx context.Context) error {
o.LastStatus = "ok"
o.LastError = ""
o.SnapshotCount = snapshots
if len(missing) == 0 {
o.LastWarning = ""
} else {
o.LastWarning = fmt.Sprintf("Figyelmeztetés: %d alkalmazásnak nincs elérhető mentése, ezek kimaradtak: %s",
len(missing), strings.Join(missing, ", "))
var warns []string
if len(missing) > 0 {
warns = append(warns, fmt.Sprintf("Figyelmeztetés: %d alkalmazásnak nincs elérhető mentése, ezek kimaradtak: %s",
len(missing), strings.Join(missing, ", ")))
}
// SLICE 4: approaching the soft quota (≥80%, <100%) — warn on an otherwise-OK run.
if qw := offboxQuotaWarning(o); qw != "" {
warns = append(warns, qw)
}
o.LastWarning = strings.Join(warns, " ")
}
})
if m.offboxNotify != nil {
@@ -515,6 +533,88 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
return backedUp, missing, nil
}
// offboxGiB is the soft-quota unit: QuotaGB counts binary gigabytes (GiB) of restic restore-size.
const offboxGiB = int64(1) << 30
// OffboxReportStatus is the NON-SECRET offsite summary carried on the hub report (SLICE 4) — the input
// to the hub's OffsiteChecker (fill + staleness alerts). nil when no offbox target is configured.
type OffboxReportStatus struct {
Enabled bool `json:"enabled"`
EscrowState string `json:"escrow_state"`
LastRun string `json:"last_run,omitempty"` // RFC3339
LastStatus string `json:"last_status,omitempty"` // "ok" | "error" | "running"
SnapshotCount int `json:"snapshot_count"`
RepoSizeBytes int64 `json:"repo_size_bytes"`
QuotaGB int `json:"quota_gb"`
}
// OffboxReportStatus returns the offsite summary for the hub report (nil = not configured; the hub's
// checker treats absence as "nothing to watch" — pre-v0.109 reports look the same).
func (m *Manager) OffboxReportStatus() *OffboxReportStatus {
t := m.settings.GetOffboxTarget()
if t == nil || !t.Enabled {
return nil
}
return &OffboxReportStatus{
Enabled: true, EscrowState: t.EscrowState, LastRun: t.LastRun, LastStatus: t.LastStatus,
SnapshotCount: t.SnapshotCount, RepoSizeBytes: t.RepoSizeBytes, QuotaGB: t.QuotaGB,
}
}
// offboxQuotaState evaluates the SLICE 4 soft-quota gate against the LAST-KNOWN repo size (a failed stats
// call keeps the previous value — stale-but-safe). quota<=0 = no soft limit (dedicated/manual targets).
func offboxQuotaState(t *settings.OffboxTarget) (usedGB, quotaGB int, over bool) {
if t == nil || t.QuotaGB <= 0 {
return 0, 0, false
}
usedGB = int(t.RepoSizeBytes / offboxGiB)
return usedGB, t.QuotaGB, t.RepoSizeBytes >= int64(t.QuotaGB)*offboxGiB
}
// offboxQuotaWarning returns the Hungarian ≥80% (<100%) usage notice, or "" (quota unset / usage fine /
// already over — over-quota is the run-refusal error, not a warning).
func offboxQuotaWarning(t *settings.OffboxTarget) string {
if t == nil || t.QuotaGB <= 0 || t.RepoSizeBytes <= 0 {
return ""
}
limit := int64(t.QuotaGB) * offboxGiB
pct := t.RepoSizeBytes * 100 / limit
if pct < 80 || t.RepoSizeBytes >= limit {
return ""
}
return fmt.Sprintf("A NAS-mentés a keret %d%%-át használja (%d/%d GB).", pct, t.RepoSizeBytes/offboxGiB, t.QuotaGB)
}
// OffboxQuotaPercent returns the usage percentage for the /backups usage bar (0 when no quota/size).
func OffboxQuotaPercent(t *settings.OffboxTarget) int {
if t == nil || t.QuotaGB <= 0 || t.RepoSizeBytes <= 0 {
return 0
}
pct := int(t.RepoSizeBytes * 100 / (int64(t.QuotaGB) * offboxGiB))
if pct > 100 {
pct = 100
}
return pct
}
// offboxPruneOnly runs ONLY the retention/prune step (the over-quota path: new backups are refused but
// pruning must stay available — it is the only way back under the quota). Repo-ensure first so a fresh
// target still fails loudly; errors are non-fatal (same as the regular run's prune).
func (m *Manager) offboxPruneOnly(ctx context.Context, base, env []string) {
if rerr := m.ensureOffboxRepo(ctx, base, env); rerr != nil {
m.logger.Printf("[WARN] [offbox] over-quota prune: repo unreachable: %v", rerr)
return
}
fctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout)
defer cancel()
fargs := append(append([]string{}, base...), "forget", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "6", "--prune")
if out, ferr := m.runner()(fctx, env, fargs...); ferr != nil {
m.logger.Printf("[WARN] [offbox] over-quota prune failed: %v: %s", ferr, truncate(out))
} else {
m.logger.Printf("[INFO] [offbox] over-quota: prune executed (new backups refused until under quota)")
}
}
// offboxRecordStats reads the snapshot count (best-effort) for the UI; also fills repo size when stats works.
func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int {
sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout)
@@ -529,14 +629,18 @@ func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int
if json.Unmarshal(out, &snaps) != nil {
return 0
}
// Repo size (best-effort, restore-size).
// Repo size (best-effort, restore-size). Bytes feed the soft-quota gate (SLICE 4); a failed stats
// call keeps the last-known value (stale-but-safe).
if so, serr := m.runner()(sctx, env, append(append([]string{}, base...), "stats", "--json")...); serr == nil {
var st struct {
TotalSize int64 `json:"total_size"`
}
if json.Unmarshal(so, &st) == nil && st.TotalSize > 0 {
human := humanizeBytes(st.TotalSize)
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.RepoSizeHuman = human })
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.RepoSizeHuman = human
o.RepoSizeBytes = st.TotalSize
})
}
}
return len(snaps)
+142
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
@@ -272,6 +273,147 @@ func TestOffbox_RestoreRoundTrip(t *testing.T) {
}
}
// Scenario A (SLICE 4) — over the soft quota: the BACKUP step is refused (Hungarian error + status),
// but the retention/prune step STILL RUNS (pruning is the customer's only way back under quota) and
// restore is untouched. The prune-still-runs assert is the red-proofed core.
func TestOffbox_QuotaRefusesBackupButPrunes(t *testing.T) {
m, sett := newOffboxManager(t)
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.QuotaGB = 50
o.RepoSizeBytes = 51 << 30 // 51 GiB — over the 50 GiB soft quota
})
var backups, forgets, restores int
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
switch {
case contains(args, "cat") && contains(args, "config"):
return []byte(`{}`), nil
case contains(args, "backup"):
backups++
case contains(args, "forget"):
forgets++
case contains(args, "restore"):
restores++
case contains(args, "stats"):
return []byte(`{"total_size":123}`), nil
case contains(args, "snapshots"):
return []byte(`[]`), nil
}
return nil, nil
})
if err := m.RunOffboxBackup(context.Background()); err == nil {
t.Fatal("an over-quota run must report the refusal as an error")
}
if backups != 0 {
t.Fatalf("NEW backups must be refused over quota, got %d backup call(s)", backups)
}
if forgets != 1 {
t.Fatalf("prune MUST still run over quota (the only way back down — gating it deadlocks the customer), got %d", forgets)
}
tgt := sett.GetOffboxTarget()
if tgt.LastStatus != "error" || !strings.Contains(tgt.LastError, "túllépte a tárhelykeretet") ||
!strings.Contains(tgt.LastError, "51/50") {
t.Fatalf("Hungarian over-quota status wrong: status=%q err=%q", tgt.LastStatus, tgt.LastError)
}
// restore is NEVER quota-gated: it must reach the runner even over quota
_ = m.RestoreOffbox(context.Background(), "rallly", t.TempDir())
if restores != 1 {
t.Fatal("restore must NOT be quota-gated")
}
}
// Scenario B (SLICE 4) — approaching the quota (≥80%, <100%): the run proceeds OK and the Hungarian
// usage warning is set (visible on /backups).
func TestOffbox_QuotaWarnsAt80Percent(t *testing.T) {
m, sett := newOffboxManager(t)
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 50 })
var backups int
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
switch {
case contains(args, "cat") && contains(args, "config"):
return []byte(`{}`), nil
case contains(args, "backup"):
backups++
case contains(args, "snapshots"):
return []byte(`[{"id":"s1"}]`), nil
case contains(args, "stats"):
return []byte(fmt.Sprintf(`{"total_size":%d}`, int64(42)<<30)), nil // 42 GiB of 50 = 84%
}
return nil, nil
})
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("an 84%% run must proceed, got %v", err)
}
tgt := sett.GetOffboxTarget()
if tgt.LastStatus != "ok" {
t.Fatalf("status must be ok at 84%%, got %q (%q)", tgt.LastStatus, tgt.LastError)
}
if !strings.Contains(tgt.LastWarning, "84%-át használja") || !strings.Contains(tgt.LastWarning, "42/50") {
t.Fatalf("the 80%%+ usage warning must be set, got %q", tgt.LastWarning)
}
if tgt.RepoSizeBytes != int64(42)<<30 {
t.Fatalf("RepoSizeBytes must persist from stats, got %d", tgt.RepoSizeBytes)
}
}
// Scenario C (SLICE 4) — quota 0 (dedicated/unset): no soft gate regardless of size.
func TestOffbox_QuotaZeroMeansNoGate(t *testing.T) {
m, sett := newOffboxManager(t)
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.QuotaGB = 0
o.RepoSizeBytes = 900 << 30 // enormous — must not matter
})
var backups int
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
switch {
case contains(args, "cat") && contains(args, "config"):
return []byte(`{}`), nil
case contains(args, "backup"):
backups++
case contains(args, "snapshots"):
return []byte(`[]`), nil
case contains(args, "stats"):
return []byte(`{"total_size":123}`), nil
}
return nil, nil
})
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("quota-0 run must proceed, got %v", err)
}
if tgt := sett.GetOffboxTarget(); strings.Contains(tgt.LastWarning, "keret") || strings.Contains(tgt.LastError, "keret") {
t.Fatalf("quota 0 must produce no quota warning/refusal: warn=%q err=%q", tgt.LastWarning, tgt.LastError)
}
}
// SLICE 4 — the report carries the non-secret offsite object when enabled; nil when unconfigured
// (absent on the wire via omitempty → the hub checker skips, no false staleness on old/plain boxes).
func TestOffboxReportStatus(t *testing.T) {
m, sett := newOffboxManager(t)
_ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.QuotaGB = 50
o.RepoSizeBytes = 45 << 30
o.LastStatus = "ok"
o.LastRun = "2026-07-09T20:00:00Z"
o.SnapshotCount = 7
})
st := m.OffboxReportStatus()
if st == nil || !st.Enabled || st.EscrowState != "escrowed" || st.QuotaGB != 50 ||
st.RepoSizeBytes != int64(45)<<30 || st.LastStatus != "ok" || st.SnapshotCount != 7 {
t.Fatalf("offsite report status wrong: %+v", st)
}
// unconfigured manager → nil
logger := log.New(os.Stderr, "", 0)
dataDir := t.TempDir()
sett2, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dataDir
if got := NewManager(cfg, sett2, logger).OffboxReportStatus(); got != nil {
t.Fatalf("unconfigured offbox must report nil (absent on the wire), got %+v", got)
}
}
// TestOffbox_SingleFlight: an off-box run while another backup holds m.running skips (no runner call).
func TestOffbox_SingleFlight(t *testing.T) {
m, _ := newOffboxManager(t)
@@ -38,10 +38,10 @@ type (
KeyInstaller interface {
Install(ctx context.Context, host, user string, port int, password, privPEM, pubAuthorized, knownHosts string) error
}
// OffboxEnabler configures the offbox target (key + known_hosts + target) and goes EscrowState="pending"
// (the fork-4 enable path).
// OffboxEnabler configures the offbox target (key + known_hosts + target + soft quota) and goes
// EscrowState="pending" (the fork-4 enable path). quotaGB=0 = no soft limit (dedicated boxes).
OffboxEnabler interface {
ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error
ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error
}
// KeyAuthProber checks whether an ALREADY-INSTALLED key authenticates to the target (pinned to the
// freshly-scanned knownHosts). ok=true returns that key's PEM so the descriptor change is applied by
@@ -136,7 +136,7 @@ func (b *Bridge) Reconcile(ctx context.Context) error {
// on an already-provisioned guest no longer loops on consume-404.
if b.Prober != nil {
if privPEM, ok := b.Prober.Probe(ctx, o.Host, o.User, port, knownHostsLine); ok {
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine); err != nil {
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine, o.QuotaGB); err != nil {
return fmt.Errorf("offsite-apply: reconfigure (key-auth-first): %w", err)
}
if err := b.writeMarker(h); err != nil {
@@ -171,7 +171,7 @@ func (b *Bridge) Reconcile(ctx context.Context) error {
}
// 5) Configure the offbox target + go EscrowState="pending" (fork-4 enable path).
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine); err != nil {
if err := b.Enabler.ConfigureOffbox(ctx, o.Host, o.User, port, o.RepoPath, privPEM, knownHostsLine, o.QuotaGB); err != nil {
return fmt.Errorf("offsite-apply: configure offbox: %w", err)
}
@@ -81,11 +81,12 @@ type fakeEnabler struct {
gotHost string
gotKnownHost string
gotPriv string
gotQuotaGB int
}
func (f *fakeEnabler) ConfigureOffbox(_ context.Context, host, _ string, _ int, _, privPEM, knownHosts string) error {
func (f *fakeEnabler) ConfigureOffbox(_ context.Context, host, _ string, _ int, _, privPEM, knownHosts string, quotaGB int) error {
f.calls++
f.gotHost, f.gotKnownHost, f.gotPriv = host, knownHosts, privPEM
f.gotHost, f.gotKnownHost, f.gotPriv, f.gotQuotaGB = host, knownHosts, privPEM, quotaGB
return f.err
}
@@ -111,7 +112,7 @@ func newBridge(t *testing.T, o config.OffsiteConfig) (*Bridge, *fakeConsumer, *f
}
func goodOffsite() config.OffsiteConfig {
return config.OffsiteConfig{Enabled: true, Type: "shared", Host: "h", User: "u", Port: 23, RepoPath: "/home/felhom-repo", HostFingerprint: "SHA256:goodfp"}
return config.OffsiteConfig{Enabled: true, Type: "shared", Host: "h", User: "u", Port: 23, RepoPath: "/home/felhom-repo", QuotaGB: 50, HostFingerprint: "SHA256:goodfp"}
}
// Scenario A — full apply: consume → verify-pin → install → configure offbox → marker persisted; pw not logged.
@@ -132,6 +133,9 @@ func TestBridge_AppliesEndToEnd(t *testing.T) {
if en.calls != 1 || en.gotHost != "h" || en.gotKnownHost != "[h]:23 ssh-ed25519 AAAAKEY" || en.gotPriv != "PRIVPEM" {
t.Fatalf("enabler not called with the pinned known_hosts + key: %+v", en)
}
if en.gotQuotaGB != 50 {
t.Fatalf("the bridge must map the descriptor's quota_gb into the target (SLICE 4), got %d", en.gotQuotaGB)
}
if b.readMarker() != descriptorHash(b.Cfg.Offsite) {
t.Fatal("marker not persisted after a successful apply")
}
+3 -3
View File
@@ -27,10 +27,10 @@ type ConsumerFunc func(ctx context.Context) (string, error)
func (f ConsumerFunc) Consume(ctx context.Context) (string, error) { return f(ctx) }
type EnablerFunc func(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error
type EnablerFunc func(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error
func (f EnablerFunc) ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string) error {
return f(ctx, host, user, port, repoPath, privPEM, knownHosts)
func (f EnablerFunc) ConfigureOffbox(ctx context.Context, host, user string, port int, repoPath, privPEM, knownHosts string, quotaGB int) error {
return f(ctx, host, user, port, repoPath, privPEM, knownHosts, quotaGB)
}
// --- HTTPConsumer: POST the hub consume-password endpoint with the per-customer API key ---
+3
View File
@@ -170,6 +170,9 @@ func BuildReport(
if host, user, port, repoPath, ok := backupMgr.OffboxCoord(); ok {
r.DRRecipe.OffsiteRestic = &DRResticCoord{Host: host, User: user, Port: port, RepoPath: repoPath}
}
// SLICE 4: the non-secret offsite status object — the hub's OffsiteChecker input (fill +
// staleness). nil (absent on the wire) when no offbox target is enabled.
r.Offsite = backupMgr.OffboxReportStatus()
}
if debug && logger != nil {
+6
View File
@@ -3,6 +3,7 @@ package report
import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
)
@@ -28,6 +29,11 @@ type Report struct {
// DR recipe — the controller (customer + apps) half of the secret-free reconstruction recipe
// (SPIKE-dr-recipe-2026-06-16). The hub assembles it with the agent's storage/guest/PBS half.
DRRecipe *DRRecipeAppHalf `json:"dr_recipe,omitempty"`
// Offsite (SLICE 4) — the non-secret offbox status for the hub's OffsiteChecker (fill + staleness
// alerts). Absent when no offbox target is enabled (and on pre-v0.109 controllers — the checker is
// nil-safe on both).
Offsite *backup.OffboxReportStatus `json:"offsite,omitempty"`
}
// SystemReport holds host-level system info.
+8 -1
View File
@@ -118,6 +118,10 @@ type OffboxTarget struct {
User string `json:"user"`
RepoPath string `json:"repo_path"` // absolute path on the NAS, e.g. /volume1/felhom-backup/repo
Schedule string `json:"schedule"` // "daily" | "manual"
// QuotaGB is the shared-model SOFT quota (SLICE 4), mapped from the hub descriptor by the
// apply-bridge. 0 = no soft limit (dedicated boxes are Hetzner-enforced; manual targets unset).
// Felhom-enforced: at ≥100% NEW backup runs are refused (prune/restore never are); ≥80% warns.
QuotaGB int `json:"quota_gb,omitempty"`
// Runtime status (written by the off-box runner; never holds a secret).
LastRun string `json:"last_run,omitempty"` // RFC3339
@@ -125,7 +129,10 @@ type OffboxTarget struct {
LastError string `json:"last_error,omitempty"`
LastDuration string `json:"last_duration,omitempty"`
RepoSizeHuman string `json:"repo_size_human,omitempty"`
SnapshotCount int `json:"snapshot_count,omitempty"`
// RepoSizeBytes (SLICE 4) is the machine-readable repo size from `restic stats` — the soft-quota
// gate's input (last-known value; a failed stats call keeps the previous one — stale-but-safe).
RepoSizeBytes int64 `json:"repo_size_bytes,omitempty"`
SnapshotCount int `json:"snapshot_count,omitempty"`
// LastWarning is a customer-visible notice set on an otherwise-OK run when SOME toggled apps had
// no discoverable recovery unit (partial run). Empty on a fully-successful or failed run.
LastWarning string `json:"last_warning,omitempty"`
+4 -1
View File
@@ -638,9 +638,12 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
data["DBDumpTotalBytes"] = dbDumpTotalBytes
// Off-box (NAS) restic-SFTP backup (Part B): the target status + per-app off-box toggles.
data["Offbox"] = s.settings.GetOffboxTarget()
offboxTgt := s.settings.GetOffboxTarget()
data["Offbox"] = offboxTgt
data["OffboxConfigured"] = s.backupMgr.OffboxConfigured()
data["OffboxApps"] = s.buildOffboxApps()
// SLICE 4 soft-quota usage bar (rendered only when a quota is set — shared model).
data["OffboxQuotaPct"] = backup.OffboxQuotaPercent(offboxTgt)
} else {
data["Backup"] = nil
}
@@ -140,6 +140,15 @@
<div class="stat-label">{{if .Offbox.Enabled}}{{.Offbox.RepoPath}}{{else}}A NAS-mentés ki van kapcsolva{{end}}</div>
</div>
</div>
{{if and .Offbox.Enabled (gt .Offbox.QuotaGB 0)}}
<!-- 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árhelykeret: {{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%)</div>
<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>
</div>
{{end}}
{{if .Offbox.LastError}}<p class="form-hint" style="color:var(--crit)">Utolsó hiba: {{.Offbox.LastError}}</p>{{end}}
{{if .Offbox.LastWarning}}<p class="form-hint" style="color:var(--warn)">{{.Offbox.LastWarning}}</p>{{end}}
{{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}}