diff --git a/dist/urbm-2026.07.13.r008-x86_64-1.txz.sha256 b/dist/urbm-2026.07.13.r008-x86_64-1.txz.sha256 deleted file mode 100644 index e2c34ac..0000000 --- a/dist/urbm-2026.07.13.r008-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -b5b8d5e650c016cb5811fc411f844155b4162f6be7eaff02481a0e99eb5c44cb urbm-2026.07.13.r008-x86_64-1.txz diff --git a/dist/urbm-2026.07.13.r008-x86_64-1.txz b/dist/urbm-2026.07.13.r009-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.07.13.r008-x86_64-1.txz rename to dist/urbm-2026.07.13.r009-x86_64-1.txz index fd752fc..22118ce 100644 Binary files a/dist/urbm-2026.07.13.r008-x86_64-1.txz and b/dist/urbm-2026.07.13.r009-x86_64-1.txz differ diff --git a/dist/urbm-2026.07.13.r009-x86_64-1.txz.sha256 b/dist/urbm-2026.07.13.r009-x86_64-1.txz.sha256 new file mode 100644 index 0000000..540c94c --- /dev/null +++ b/dist/urbm-2026.07.13.r009-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +11efad3fbbe8f71092177ef912353d0c0f733b66974af18aba83a0275c2c22f9 urbm-2026.07.13.r009-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index ebf0d6e..57b6ba4 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,18 @@ - + - + ]> +### 2026.07.13.r009 +- Store completed-backup chart measurements independently in `backup-metrics.json` so clearing activities no longer erases the dashboard history. +- Migrate existing successful backup runs into the new durable metric history automatically and expose them through a dedicated read-only API. +- Keep up to 5,000 compact measurements and clarify in the delete dialog that dashboard chart data is preserved. + ### 2026.07.13.r008 - Remove the nested vertical scrollbar from the snapshot repository browser and use the normal Unraid page scroll. - Keep lazy folder loading and cap deep tree indentation so long paths remain usable on desktop and mobile. diff --git a/internal/api/server.go b/internal/api/server.go index f749735..70ab747 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -39,6 +39,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server { mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret) mux.HandleFunc("GET /v1/runs", s.runs) mux.HandleFunc("DELETE /v1/runs", s.clearRuns) + mux.HandleFunc("GET /v1/backup-metrics", s.backupMetrics) mux.HandleFunc("GET /v1/logs", s.logs) mux.HandleFunc("GET /v1/filesystem/directories", s.directories) mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads) @@ -129,6 +130,9 @@ func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) } +func (s *Server) backupMetrics(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, s.service.BackupMetrics()) +} func (s *Server) clearRuns(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]int{"cleared": s.service.ClearRunHistory()}) } @@ -360,7 +364,7 @@ func shouldLogRequest(r *http.Request, status int, duration time.Duration) bool return true } switch r.URL.Path { - case "/v1/runs", "/v1/health", "/v1/logs": + case "/v1/runs", "/v1/backup-metrics", "/v1/health", "/v1/logs": return false default: return true diff --git a/internal/model/model.go b/internal/model/model.go index d05d645..16e7030 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -173,6 +173,23 @@ type Run struct { Restore *RestoreTask `json:"restore,omitempty"` } +// BackupMetric is the durable, compact source for dashboard history. It is +// intentionally stored separately from Run so clearing activity history does +// not erase the backup charts. +type BackupMetric struct { + SchemaVersion int `json:"schemaVersion"` + RunID string `json:"runId"` + JobID string `json:"jobId"` + SnapshotID string `json:"snapshotId,omitempty"` + FinishedAt time.Time `json:"finishedAt"` + Status string `json:"status"` + BytesProcessed int64 `json:"bytesProcessed"` + FilesProcessed int64 `json:"filesProcessed"` + BytesAdded int64 `json:"bytesAdded,omitempty"` + FilesNew int64 `json:"filesNew,omitempty"` + FilesChanged int64 `json:"filesChanged,omitempty"` +} + type RestoreTask struct { SchemaVersion int `json:"schemaVersion"` ID string `json:"id"` diff --git a/internal/service/safety_test.go b/internal/service/safety_test.go index dbae671..d776c7d 100644 --- a/internal/service/safety_test.go +++ b/internal/service/safety_test.go @@ -8,6 +8,7 @@ import ( "time" "git.casaderoll.de/michael/urbm/internal/model" + "git.casaderoll.de/michael/urbm/internal/store" ) func TestRepositoryLockHonorsContextCancellation(t *testing.T) { @@ -25,6 +26,40 @@ func TestRepositoryLockHonorsContextCancellation(t *testing.T) { unlock() } +func TestBackupMetricsSurviveClearedRunSnapshot(t *testing.T) { + st := store.New(t.TempDir()) + if err := st.Init(); err != nil { + t.Fatal(err) + } + s := &Service{store: st, persistRuns: make(chan []model.Run, 1)} + finished := time.Date(2026, 7, 13, 1, 30, 0, 0, time.UTC) + run := model.Run{ID: "run-1", JobID: "job-1", TaskType: "backup", Status: "success", FinishedAt: &finished, BytesProcessed: 1024, FilesProcessed: 12} + s.queuePersistence([]model.Run{run}) + s.queuePersistence(nil) + metrics := s.BackupMetrics() + if len(metrics) != 1 || metrics[0].RunID != run.ID || metrics[0].FilesProcessed != 12 { + t.Fatalf("backup metrics after clear = %#v", metrics) + } +} + +func TestBackupMetricsOnlyCaptureCompletedSuccessfulBackupsOnce(t *testing.T) { + st := store.New(t.TempDir()) + s := &Service{store: st} + finished := time.Now().UTC() + valid := model.Run{ID: "valid", JobID: "job-1", TaskType: "backup", Status: "warning", FinishedAt: &finished} + s.captureBackupMetrics([]model.Run{ + valid, + valid, + {ID: "failed", TaskType: "backup", Status: "failed", FinishedAt: &finished}, + {ID: "active", TaskType: "backup", Status: "running"}, + {ID: "rsync", TaskType: "rsync", Status: "success", FinishedAt: &finished}, + }) + metrics := s.BackupMetrics() + if len(metrics) != 1 || metrics[0].RunID != valid.ID { + t.Fatalf("captured backup metrics = %#v", metrics) + } +} + func TestPersistentRunLogsAreWrittenAndPruned(t *testing.T) { dir := t.TempDir() config := model.DefaultConfig() diff --git a/internal/service/service.go b/internal/service/service.go index dc24d7b..d174b4f 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -52,6 +52,8 @@ type Service struct { persistStop chan struct{} persistDone chan struct{} persistStopOnce sync.Once + metricsMu sync.RWMutex + backupMetrics []model.BackupMetric } type SnapshotBrowserNode struct { @@ -99,6 +101,11 @@ func New(st *store.Store, secrets SecretStore, rr *restic.Runner, rs *rsync.Runn store: st, secrets: secrets, restic: rr, rsync: rs, mounts: mounts, workloads: workloads, notifier: notifier, log: log, config: config, repositoryLocks: map[string]chan struct{}{}, persistRuns: make(chan []model.Run, 1), persistStop: make(chan struct{}), persistDone: make(chan struct{}), } + if metrics, err := st.LoadBackupMetrics(); err != nil { + log.Error("load backup metrics", "error", err) + } else { + s.backupMetrics = metrics + } go s.runPersistence() s.queue = queue.New(s.execute, s.queuePersistence) if runs, err := st.LoadRuns(); err == nil { @@ -124,6 +131,11 @@ func (s *Service) Wait(ctx context.Context) error { } func (s *Service) Config() model.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.config } func (s *Service) Runs() []model.Run { return s.queue.Snapshot() } +func (s *Service) BackupMetrics() []model.BackupMetric { + s.metricsMu.RLock() + defer s.metricsMu.RUnlock() + return append([]model.BackupMetric(nil), s.backupMetrics...) +} func (s *Service) Logs() map[string]any { return map[string]any{ "daemon": tailFile("/var/log/urbm.log", 1024*1024, 1000), @@ -143,6 +155,7 @@ func runsWithLogs(runs []model.Run, limit int) []model.Run { } func (s *Service) queuePersistence(runs []model.Run) { + s.captureBackupMetrics(runs) select { case s.persistRuns <- runs: return @@ -187,6 +200,39 @@ func (s *Service) saveRunState(runs []model.Run) { if err := s.persistRunLogs(runs); err != nil { s.log.Error("save persistent run logs", "error", err) } + if err := s.store.SaveBackupMetrics(s.BackupMetrics()); err != nil { + s.log.Error("save backup metrics", "error", err) + } +} + +func (s *Service) captureBackupMetrics(runs []model.Run) { + if s.store == nil { + return + } + s.metricsMu.Lock() + defer s.metricsMu.Unlock() + known := make(map[string]struct{}, len(s.backupMetrics)) + for _, metric := range s.backupMetrics { + known[metric.RunID] = struct{}{} + } + for _, run := range runs { + if run.TaskType != "backup" || run.FinishedAt == nil || (run.Status != "success" && run.Status != "warning") { + continue + } + if _, exists := known[run.ID]; exists { + continue + } + s.backupMetrics = append(s.backupMetrics, model.BackupMetric{ + SchemaVersion: model.SchemaVersion, RunID: run.ID, JobID: run.JobID, SnapshotID: run.SnapshotID, + FinishedAt: *run.FinishedAt, Status: run.Status, BytesProcessed: run.BytesProcessed, + FilesProcessed: run.FilesProcessed, BytesAdded: run.BytesAdded, FilesNew: run.FilesNew, FilesChanged: run.FilesChanged, + }) + known[run.ID] = struct{}{} + } + sort.SliceStable(s.backupMetrics, func(i, j int) bool { return s.backupMetrics[i].FinishedAt.Before(s.backupMetrics[j].FinishedAt) }) + if len(s.backupMetrics) > 5000 { + s.backupMetrics = append([]model.BackupMetric(nil), s.backupMetrics[len(s.backupMetrics)-5000:]...) + } } func (s *Service) persistRunLogs(runs []model.Run) error { diff --git a/internal/store/store.go b/internal/store/store.go index 09b2e72..f153d19 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -70,8 +70,32 @@ func (s *Store) SaveRuns(runs []model.Run) error { return writeJSONAtomic(s.runsPath(), runs, 0600) } +func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, error) { + s.mu.RLock() + defer s.mu.RUnlock() + var metrics []model.BackupMetric + if err := readJSON(s.backupMetricsPath(), &metrics); errors.Is(err, os.ErrNotExist) { + return []model.BackupMetric{}, nil + } else if err != nil { + return nil, err + } + return metrics, nil +} + +func (s *Store) SaveBackupMetrics(metrics []model.BackupMetric) error { + s.mu.Lock() + defer s.mu.Unlock() + if len(metrics) > 5000 { + metrics = metrics[len(metrics)-5000:] + } + return writeJSONAtomic(s.backupMetricsPath(), metrics, 0600) +} + func (s *Store) configPath() string { return filepath.Join(s.dir, "config.json") } func (s *Store) runsPath() string { return filepath.Join(s.dir, "runs.json") } +func (s *Store) backupMetricsPath() string { + return filepath.Join(s.dir, "backup-metrics.json") +} func readJSON(path string, target any) error { b, err := os.ReadFile(path) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a355c09..9810bd0 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -3,6 +3,7 @@ package store import ( "os" "testing" + "time" "git.casaderoll.de/michael/urbm/internal/model" ) @@ -28,3 +29,32 @@ func TestStoreInitializesAndPersists(t *testing.T) { t.Fatalf("config mode = %o", info.Mode().Perm()) } } + +func TestBackupMetricsPersistSeparatelyFromRuns(t *testing.T) { + s := New(t.TempDir()) + if err := s.Init(); err != nil { + t.Fatal(err) + } + finished := time.Date(2026, 7, 13, 1, 30, 0, 0, time.UTC) + metrics := []model.BackupMetric{{SchemaVersion: model.SchemaVersion, RunID: "run-1", JobID: "job-1", FinishedAt: finished, Status: "success", BytesProcessed: 42, FilesProcessed: 7}} + if err := s.SaveBackupMetrics(metrics); err != nil { + t.Fatal(err) + } + if err := s.SaveRuns(nil); err != nil { + t.Fatal(err) + } + loaded, err := s.LoadBackupMetrics() + if err != nil { + t.Fatal(err) + } + if len(loaded) != 1 || loaded[0].RunID != "run-1" || loaded[0].BytesProcessed != 42 { + t.Fatalf("backup metrics = %#v", loaded) + } + info, err := os.Stat(s.backupMetricsPath()) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("backup metrics mode = %o", info.Mode().Perm()) + } +} diff --git a/plugin/doinst.sh b/plugin/doinst.sh index 54e4be8..73614cd 100755 --- a/plugin/doinst.sh +++ b/plugin/doinst.sh @@ -12,7 +12,7 @@ fi mkdir -p "$CONFIG" "$ROOT_PREFIX/run/urbm" "$ROOT_PREFIX/var/lib/urbm" if [ -d "$OLD_CONFIG" ]; then - for item in config.json runs.json secrets; do + for item in config.json runs.json backup-metrics.json secrets; do if [ -e "$OLD_CONFIG/$item" ] && [ ! -e "$CONFIG/$item" ]; then cp -a "$OLD_CONFIG/$item" "$CONFIG/$item" fi diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 5d082ba..4eff816 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,18 @@ - + ]> +### 2026.07.13.r009 +- Store completed-backup chart measurements independently in `backup-metrics.json` so clearing activities no longer erases the dashboard history. +- Migrate existing successful backup runs into the new durable metric history automatically and expose them through a dedicated read-only API. +- Keep up to 5,000 compact measurements and clarify in the delete dialog that dashboard chart data is preserved. + ### 2026.07.13.r008 - Remove the nested vertical scrollbar from the snapshot repository browser and use the normal Unraid page scroll. - Keep lazy folder loading and cap deep tree indentation so long paths remain usable on desktop and mobile. diff --git a/tests/migration_test.sh b/tests/migration_test.sh index f8a08ad..f518d0d 100755 --- a/tests/migration_test.sh +++ b/tests/migration_test.sh @@ -10,6 +10,7 @@ NEW="$ROOT/boot/config/plugins/urbm" mkdir -p "$OLD/secrets" "$ROOT/var/log/plugins" "$ROOT/tmp/plugins" printf '%s\n' '{"settings":{"resticPath":"/usr/local/libexec/'"$OLD_NAME"'/restic","restoreRoot":"/mnt/user/'"$OLD_NAME"'-restores","persistentLogDir":"/mnt/user/system/'"$OLD_NAME"'-logs"}}' > "$OLD/config.json" printf '%s\n' '[]' > "$OLD/runs.json" +printf '%s\n' '[]' > "$OLD/backup-metrics.json" printf '%s\n' 'encrypted' > "$OLD/secrets/secret.json" touch "$ROOT/boot/config/plugins/$OLD_NAME.plg" "$ROOT/var/log/plugins/$OLD_NAME.plg" "$ROOT/tmp/plugins/$OLD_NAME.plg" @@ -17,6 +18,7 @@ URBM_ROOT_PREFIX="$ROOT" "$(dirname "$0")/../plugin/doinst.sh" test -f "$NEW/config.json" test -f "$NEW/runs.json" +test -f "$NEW/backup-metrics.json" test -f "$NEW/secrets/secret.json" test ! -e "$OLD" test ! -e "$ROOT/boot/config/plugins/$OLD_NAME.plg" diff --git a/webgui/URBM.page b/webgui/URBM.page index 0d34e51..3f03172 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -8,7 +8,7 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" --- diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 5cb19c2..3331fea 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -10,7 +10,7 @@ const dialog = document.getElementById('bu-dialog'); const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path})); const repositoryRoots = ['/mnt/user','/mnt/disks','/mnt/remotes'].map(path=>({name:path,path})); - const state = { config: null, runs: [], logs: null, logsLoading: false, logsError: '', dashboardJobId: '', view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], snapshotTree: null, snapshotTreeMeta: null, snapshotTreeChildren: {}, snapshotTreeLoading: false, snapshotTreeNodeLoading: {}, snapshotTreeErrors: {}, snapshotTreeError: '', snapshotTreeExpanded: {}, restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, directoryTrees: {}, liveLogOpen: {} }; + const state = { config: null, runs: [], backupMetrics: [], logs: null, logsLoading: false, logsError: '', dashboardJobId: '', view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], snapshotTree: null, snapshotTreeMeta: null, snapshotTreeChildren: {}, snapshotTreeLoading: false, snapshotTreeNodeLoading: {}, snapshotTreeErrors: {}, snapshotTreeError: '', snapshotTreeExpanded: {}, restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, directoryTrees: {}, liveLogOpen: {} }; const fieldHelp = { name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', @@ -401,8 +401,8 @@ async function load() { try { - const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]); - state.config = config; state.runs = newestRuns(runs); + const [config, runs, backupMetrics, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/backup-metrics'), api('/v1/health')]); + state.config = config; state.runs = newestRuns(runs); state.backupMetrics = backupMetrics; health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render(); } catch (error) { health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad'; @@ -459,9 +459,10 @@ const lastSuccess=orderedRuns.find(r=>r.status==='success'); const last = orderedRuns.slice(0, 6); const backupJobs=state.config.jobs.filter(job=>job.type!=='rsync'); - if(!state.dashboardJobId) state.dashboardJobId=orderedRuns.find(run=>run.taskType==='backup'&&backupJobs.some(job=>job.id===run.jobId))?.jobId||backupJobs[0]?.id||'all'; + const orderedMetrics=[...state.backupMetrics].sort((a,b)=>new Date(b.finishedAt)-new Date(a.finishedAt)); + if(!state.dashboardJobId) state.dashboardJobId=orderedMetrics.find(metric=>backupJobs.some(job=>job.id===metric.jobId))?.jobId||backupJobs[0]?.id||'all'; if(state.dashboardJobId!=='all'&&!backupJobs.some(job=>job.id===state.dashboardJobId)) state.dashboardJobId='all'; - const backups=orderedRuns.filter(run=>run.taskType==='backup'&&['success','warning'].includes(run.status)&&(state.dashboardJobId==='all'||run.jobId===state.dashboardJobId)); + const backups=orderedMetrics.filter(metric=>state.dashboardJobId==='all'||metric.jobId===state.dashboardJobId); const chartOptions=`${backupJobs.map(job=>``).join('')}`; const needsSetup=!state.config.jobs.length||!state.config.repositories.length; const latestFailed=orderedRuns[0]?.status==='failed'; @@ -869,7 +870,7 @@ if (name === 'pause-run') { await api(`/v1/runs/${a}/pause`,{method:'POST'}); notify('Vorgang pausiert'); return load(); } if (name === 'resume-run') { await api(`/v1/runs/${a}/resume`,{method:'POST'}); notify('Vorgang wird fortgesetzt'); return load(); } if (name === 'go-view') { state.view=a; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view===a)); render(); if(a==='logs'&&!state.logs&&!state.logsLoading) loadLogs(); return; } - if (name === 'clear-activity') { if(await askConfirm({title:'Aktivitätsverlauf löschen?',message:'Alle abgeschlossenen Aktivitäten und ihre gespeicherten Laufprotokolle werden entfernt. Laufende und wartende Aufgaben bleiben erhalten.',accept:'Verlauf löschen'})) { const result=await api('/v1/runs',{method:'DELETE'}); notify(result.cleared?`${result.cleared} abgeschlossene Aktivitäten gelöscht`:'Keine abgeschlossenen Aktivitäten vorhanden'); return load(); } return; } + if (name === 'clear-activity') { if(await askConfirm({title:'Aktivitätsverlauf löschen?',message:'Alle abgeschlossenen Aktivitäten und ihre gespeicherten Laufprotokolle werden entfernt. Laufende und wartende Aufgaben sowie die Messwerte der Dashboard-Diagramme bleiben erhalten.',accept:'Verlauf löschen'})) { const result=await api('/v1/runs',{method:'DELETE'}); notify(result.cleared?`${result.cleared} abgeschlossene Aktivitäten gelöscht`:'Keine abgeschlossenen Aktivitäten vorhanden'); return load(); } return; } if (name === 'refresh-logs') { state.logs=null; return loadLogs(); } if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository-Verbindung erfolgreich getestet'); } if (name === 'init-repo') { if(await askConfirm({title:'Neues Repository initialisieren?',message:'Am konfigurierten Ziel wird ein neues, leeres Restic-Repository angelegt. Verwende diese Aktion nicht für ein bereits vorhandenes Repository.',accept:'Initialisieren',danger:false})) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository erfolgreich initialisiert'); } return; } @@ -1104,6 +1105,6 @@ window.addEventListener('scroll', hideTooltip, true); window.addEventListener('resize', hideTooltip); enhanceTooltips(root); - setInterval(async()=>{ if(!state.config)return; try { const runs=newestRuns(await api('/v1/runs')); const changed=JSON.stringify(runs)!==JSON.stringify(state.runs); state.runs=runs; if(changed&&['dashboard','activity'].includes(state.view))render(); }catch(_){ } },2000); + setInterval(async()=>{ if(!state.config)return; try { const runs=newestRuns(await api('/v1/runs')); const changed=JSON.stringify(runs)!==JSON.stringify(state.runs); state.runs=runs; if(changed){ state.backupMetrics=await api('/v1/backup-metrics'); if(['dashboard','activity'].includes(state.view))render(); } }catch(_){ } },2000); load(); })();