diff --git a/dist/urbm-2026.07.13.r011-x86_64-1.txz.sha256 b/dist/urbm-2026.07.13.r011-x86_64-1.txz.sha256 deleted file mode 100644 index 85f1813..0000000 --- a/dist/urbm-2026.07.13.r011-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -eca5aa766ba47c6582d25b64ccbf9ae351aa712bba04bbe2d9b0fc44fa53fb25 urbm-2026.07.13.r011-x86_64-1.txz diff --git a/dist/urbm-2026.07.13.r011-x86_64-1.txz b/dist/urbm-2026.07.13.r012-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.07.13.r011-x86_64-1.txz rename to dist/urbm-2026.07.13.r012-x86_64-1.txz index 1eaeecd..85dc52b 100644 Binary files a/dist/urbm-2026.07.13.r011-x86_64-1.txz and b/dist/urbm-2026.07.13.r012-x86_64-1.txz differ diff --git a/dist/urbm-2026.07.13.r012-x86_64-1.txz.sha256 b/dist/urbm-2026.07.13.r012-x86_64-1.txz.sha256 new file mode 100644 index 0000000..db6a85f --- /dev/null +++ b/dist/urbm-2026.07.13.r012-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +36921b9372b1ee42f50eb3e18cb0a5ac720fc484f5fef992cc454684a6e86282 urbm-2026.07.13.r012-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index e474e66..170c107 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,19 @@ - + - + ]> +### 2026.07.13.r012 +- Draw a separate color-coded line and legend entry for every backup job when the dashboard filter is set to all jobs. +- Base dashboard health on successful backups, failed backup attempts, active work, and timezone-aware overdue schedules instead of unrelated maintenance or restore successes. +- Keep the WebGUI available when metrics, activities, health, or dashboard-status requests fail independently, with localized errors and retry actions. +- Quarantine malformed run and metric state files with a timestamped `.corrupt-*` suffix before creating clean replacements, and log the recovery path. + ### 2026.07.13.r011 - Use durable backup measurements when deciding whether a scheduled job needs a catch-up run, even after activity history was cleared. - Append one compact JSON-lines record per completed backup instead of rewriting the full metric history for every queue state change. diff --git a/internal/api/server.go b/internal/api/server.go index 70ab747..12336f8 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -40,6 +40,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server { 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/dashboard-status", s.dashboardStatus) mux.HandleFunc("GET /v1/logs", s.logs) mux.HandleFunc("GET /v1/filesystem/directories", s.directories) mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads) @@ -133,6 +134,9 @@ func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200 func (s *Server) backupMetrics(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.BackupMetrics()) } +func (s *Server) dashboardStatus(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, s.service.DashboardStatus(time.Now())) +} func (s *Server) clearRuns(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]int{"cleared": s.service.ClearRunHistory()}) } @@ -364,7 +368,7 @@ func shouldLogRequest(r *http.Request, status int, duration time.Duration) bool return true } switch r.URL.Path { - case "/v1/runs", "/v1/backup-metrics", "/v1/health", "/v1/logs": + case "/v1/runs", "/v1/backup-metrics", "/v1/dashboard-status", "/v1/health", "/v1/logs": return false default: return true diff --git a/internal/scheduler/cron_test.go b/internal/scheduler/cron_test.go index 21c6d87..16e5754 100644 --- a/internal/scheduler/cron_test.go +++ b/internal/scheduler/cron_test.go @@ -44,6 +44,18 @@ func TestPreviousWithinCatchUpWindow(t *testing.T) { } } +func TestPreviousScheduledUsesJobTimezone(t *testing.T) { + now := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC) + want := time.Date(2026, 7, 13, 0, 0, 0, 0, time.UTC) + got, err := PreviousScheduled("0 2 * * *", "Europe/Berlin", now, 24*time.Hour) + if err != nil { + t.Fatal(err) + } + if !got.Equal(want) { + t.Fatalf("previous scheduled = %v, want %v", got, want) + } +} + func TestCronUsesOrForRestrictedMonthDayAndWeekday(t *testing.T) { expr, err := Parse("0 2 1 * 1") if err != nil { diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 4a6a0b8..878fc8c 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -14,6 +14,21 @@ type Enqueue func(model.Job) error type EnqueueMaintenance func(string, string) (model.Run, error) type LastRun func(string) time.Time +func PreviousScheduled(spec, timezone string, now time.Time, window time.Duration) (time.Time, error) { + expression, err := Parse(spec) + if err != nil { + return time.Time{}, err + } + loc := time.Local + if timezone != "" { + loc, err = time.LoadLocation(timezone) + if err != nil { + return time.Time{}, err + } + } + return previous(expression, now.In(loc), window).UTC(), nil +} + type Scheduler struct { config ConfigProvider enqueue Enqueue diff --git a/internal/service/safety_test.go b/internal/service/safety_test.go index bb9e45d..77ed7a6 100644 --- a/internal/service/safety_test.go +++ b/internal/service/safety_test.go @@ -79,6 +79,30 @@ func TestLastRunUsesDurableMetricAfterActivityClear(t *testing.T) { } } +func TestDashboardStatusUsesOnlyBackupsAndFindsOverdueJobs(t *testing.T) { + now := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC) + lastSuccess := now.Add(-30 * time.Hour) + failedAt := now.Add(-2 * time.Hour) + q := queue.New(nil, nil) + q.RestoreHistory([]model.Run{ + {ID: "failed-backup", JobID: "job-1", TaskType: "backup", Status: "failed", CreatedAt: failedAt}, + {ID: "successful-check", JobID: "repo-1", TaskType: "check", Status: "success", CreatedAt: now.Add(-time.Hour)}, + }) + config := model.DefaultConfig() + config.Jobs = []model.Job{{ID: "job-1", Name: "Daily", Type: model.JobShare, Enabled: true, Schedule: model.Schedule{Cron: "0 2 * * *", Timezone: "UTC"}}} + s := &Service{queue: q, config: config, backupMetrics: []model.BackupMetric{{RunID: "success", JobID: "job-1", Status: "success", FinishedAt: lastSuccess}}} + status := s.DashboardStatus(now) + if status.LastSuccessfulBackup == nil || status.LastSuccessfulBackup.RunID != "success" { + t.Fatalf("last successful backup = %#v", status.LastSuccessfulBackup) + } + if status.LastFailedBackup == nil || status.LastFailedBackup.ID != "failed-backup" { + t.Fatalf("last failed backup = %#v", status.LastFailedBackup) + } + if len(status.OverdueJobs) != 1 || status.OverdueJobs[0].JobID != "job-1" { + t.Fatalf("overdue jobs = %#v", status.OverdueJobs) + } +} + func TestBackupMetricsOnlyCaptureCompletedSuccessfulBackupsOnce(t *testing.T) { st := store.New(t.TempDir()) s := &Service{store: st} diff --git a/internal/service/service.go b/internal/service/service.go index 0cc9d19..a586b84 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -79,6 +79,19 @@ type SnapshotBrowserPage struct { Generated time.Time `json:"generated"` } +type DashboardStatus struct { + LastSuccessfulBackup *model.BackupMetric `json:"lastSuccessfulBackup,omitempty"` + LastFailedBackup *model.Run `json:"lastFailedBackup,omitempty"` + OverdueJobs []OverdueBackupJob `json:"overdueJobs"` +} + +type OverdueBackupJob struct { + JobID string `json:"jobId"` + Name string `json:"name"` + ScheduledAt time.Time `json:"scheduledAt"` + LastBackupAt *time.Time `json:"lastBackupAt,omitempty"` +} + type snapshotTreeCache struct { generated time.Time total int @@ -127,6 +140,8 @@ func New(st *store.Store, secrets SecretStore, rr *restic.Runner, rs *rsync.Runn s.queue = queue.New(s.execute, s.queuePersistence) if runs, err := st.LoadRuns(); err == nil { s.queue.RestoreHistory(runs) + } else { + log.Error("load run history", "error", err) } s.queuePersistence(s.queue.Snapshot()) return s, nil @@ -418,6 +433,56 @@ func (s *Service) LastRun(jobID string) time.Time { return latest } +func (s *Service) DashboardStatus(now time.Time) DashboardStatus { + status := DashboardStatus{OverdueJobs: []OverdueBackupJob{}} + metrics := s.BackupMetrics() + lastByJob := make(map[string]time.Time) + for index := range metrics { + metric := metrics[index] + if metric.Status == "success" && (status.LastSuccessfulBackup == nil || metric.FinishedAt.After(status.LastSuccessfulBackup.FinishedAt)) { + copy := metric + status.LastSuccessfulBackup = © + } + if metric.FinishedAt.After(lastByJob[metric.JobID]) { + lastByJob[metric.JobID] = metric.FinishedAt + } + } + runs := s.queue.Snapshot() + activeJobs := make(map[string]bool) + for index := range runs { + run := runs[index] + if run.TaskType != "backup" { + continue + } + if run.Status == "queued" || run.Status == "running" || run.Status == "paused" { + activeJobs[run.JobID] = true + } + if run.Status == "failed" && (status.LastFailedBackup == nil || run.CreatedAt.After(status.LastFailedBackup.CreatedAt)) { + copy := run + status.LastFailedBackup = © + } + } + for _, job := range s.Config().Jobs { + if job.Type == model.JobRsync || !job.Enabled || job.Schedule.Cron == "" || activeJobs[job.ID] { + continue + } + scheduledAt, err := scheduler.PreviousScheduled(job.Schedule.Cron, job.Schedule.Timezone, now, 370*24*time.Hour) + if err != nil || scheduledAt.IsZero() || !lastByJob[job.ID].Before(scheduledAt) { + continue + } + overdue := OverdueBackupJob{JobID: job.ID, Name: job.Name, ScheduledAt: scheduledAt} + if last := lastByJob[job.ID]; !last.IsZero() { + lastCopy := last + overdue.LastBackupAt = &lastCopy + } + status.OverdueJobs = append(status.OverdueJobs, overdue) + } + sort.SliceStable(status.OverdueJobs, func(i, j int) bool { + return status.OverdueJobs[i].ScheduledAt.Before(status.OverdueJobs[j].ScheduledAt) + }) + return status +} + func (s *Service) SaveConfig(config model.Config) error { config = model.NormalizeConfig(config) for _, job := range config.Jobs { diff --git a/internal/store/store.go b/internal/store/store.go index 2b46689..fee5785 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sync" + "time" "git.casaderoll.de/michael/urbm/internal/model" ) @@ -51,12 +52,20 @@ func (s *Store) SaveConfig(c model.Config) error { } func (s *Store) LoadRuns() ([]model.Run, error) { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() var runs []model.Run if err := readJSON(s.runsPath(), &runs); errors.Is(err, os.ErrNotExist) { return []model.Run{}, nil } else if err != nil { + var decodeErr *jsonDecodeError + if errors.As(err, &decodeErr) { + quarantined, quarantineErr := quarantineFile(s.runsPath()) + if quarantineErr != nil { + return nil, fmt.Errorf("%w; quarantine failed: %v", err, quarantineErr) + } + return nil, fmt.Errorf("%w; corrupt file moved to %s", err, quarantined) + } return nil, err } return runs, nil @@ -72,8 +81,8 @@ func (s *Store) SaveRuns(runs []model.Run) error { } func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, bool, error) { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() path := s.backupMetricsPath() b, err := os.ReadFile(path) legacySource := false @@ -95,7 +104,7 @@ func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, bool, error) { if b[0] == '[' { var metrics []model.BackupMetric if err := json.Unmarshal(b, &metrics); err != nil { - return nil, true, fmt.Errorf("decode %s: %w", path, err) + return nil, true, quarantineDecodeError(path, err) } return metrics, true, nil } @@ -108,7 +117,7 @@ func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, bool, error) { } var metric model.BackupMetric if err := json.Unmarshal(line, &metric); err != nil { - return nil, legacySource, fmt.Errorf("decode %s line %d: %w", path, index+1, err) + return nil, legacySource, quarantineDecodeError(path, fmt.Errorf("line %d: %w", index+1, err)) } metrics = append(metrics, metric) } @@ -169,11 +178,36 @@ func readJSON(path string, target any) error { return err } if err := json.Unmarshal(b, target); err != nil { - return fmt.Errorf("decode %s: %w", path, err) + return &jsonDecodeError{path: path, err: err} } return nil } +type jsonDecodeError struct { + path string + err error +} + +func (e *jsonDecodeError) Error() string { return fmt.Sprintf("decode %s: %v", e.path, e.err) } +func (e *jsonDecodeError) Unwrap() error { return e.err } + +func quarantineDecodeError(path string, decodeErr error) error { + quarantined, err := quarantineFile(path) + wrapped := &jsonDecodeError{path: path, err: decodeErr} + if err != nil { + return fmt.Errorf("%w; quarantine failed: %v", wrapped, err) + } + return fmt.Errorf("%w; corrupt file moved to %s", wrapped, quarantined) +} + +func quarantineFile(path string) (string, error) { + quarantined := path + ".corrupt-" + time.Now().UTC().Format("20060102-150405.000000000") + if err := os.Rename(path, quarantined); err != nil { + return "", err + } + return quarantined, nil +} + func writeJSONAtomic(path string, value any, mode os.FileMode) error { b, err := json.MarshalIndent(value, "", " ") if err != nil { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ddd08ef..9723e5c 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -2,6 +2,7 @@ package store import ( "os" + "path/filepath" "testing" "time" @@ -30,6 +31,49 @@ func TestStoreInitializesAndPersists(t *testing.T) { } } +func TestCorruptRunHistoryIsQuarantinedBeforeReplacement(t *testing.T) { + dir := t.TempDir() + s := New(dir) + if err := os.WriteFile(s.runsPath(), []byte(`{"broken":`), 0600); err != nil { + t.Fatal(err) + } + if _, err := s.LoadRuns(); err == nil { + t.Fatal("corrupt run history loaded without error") + } + if _, err := os.Stat(s.runsPath()); !os.IsNotExist(err) { + t.Fatalf("corrupt runs file was not moved: %v", err) + } + matches, err := filepath.Glob(s.runsPath() + ".corrupt-*") + if err != nil || len(matches) != 1 { + t.Fatalf("quarantined run files = %#v, %v", matches, err) + } + if err := s.SaveRuns([]model.Run{}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(matches[0]); err != nil { + t.Fatalf("quarantined run file was overwritten: %v", err) + } +} + +func TestCorruptBackupMetricsAreQuarantined(t *testing.T) { + dir := t.TempDir() + s := New(dir) + if err := os.WriteFile(s.backupMetricsPath(), []byte("{not-json}\n"), 0600); err != nil { + t.Fatal(err) + } + if _, _, err := s.LoadBackupMetrics(); err == nil { + t.Fatal("corrupt backup metrics loaded without error") + } + matches, err := filepath.Glob(s.backupMetricsPath() + ".corrupt-*") + if err != nil || len(matches) != 1 { + t.Fatalf("quarantined metric files = %#v, %v", matches, err) + } + contents, err := os.ReadFile(matches[0]) + if err != nil || string(contents) != "{not-json}\n" { + t.Fatalf("quarantined metric contents = %q, %v", contents, err) + } +} + func TestBackupMetricsPersistSeparatelyFromRuns(t *testing.T) { s := New(t.TempDir()) if err := s.Init(); err != nil { diff --git a/plugin/urbm.plg b/plugin/urbm.plg index bb6ee97..cde83f8 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,19 @@ - + ]> +### 2026.07.13.r012 +- Draw a separate color-coded line and legend entry for every backup job when the dashboard filter is set to all jobs. +- Base dashboard health on successful backups, failed backup attempts, active work, and timezone-aware overdue schedules instead of unrelated maintenance or restore successes. +- Keep the WebGUI available when metrics, activities, health, or dashboard-status requests fail independently, with localized errors and retry actions. +- Quarantine malformed run and metric state files with a timestamped `.corrupt-*` suffix before creating clean replacements, and log the recovery path. + ### 2026.07.13.r011 - Use durable backup measurements when deciding whether a scheduled job needs a catch-up run, even after activity history was cleared. - Append one compact JSON-lines record per completed backup instead of rewriting the full metric history for every queue state change. diff --git a/webgui/URBM.page b/webgui/URBM.page index c140e6f..1e89ed7 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.css b/webgui/assets/urbm.css index ee07c87..4299069 100644 --- a/webgui/assets/urbm.css +++ b/webgui/assets/urbm.css @@ -85,13 +85,20 @@ .bu-chart { display: block; height: 240px; margin-top: 12px; overflow: visible; width: 100%; } .bu-chart-grid line { stroke: var(--bu-border); stroke-width: 1; } .bu-chart-grid text, .bu-chart-date { fill: var(--bu-muted); font-size: 11px; } -.bu-chart-area { fill: var(--bu-accent); opacity: .10; } -.bu-chart-line { fill: none; stroke: var(--bu-accent); stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; } -.bu-chart-dots circle { cursor: pointer; fill: var(--bu-panel); stroke: var(--bu-accent); stroke-width: 3; transition: fill .15s ease, stroke-width .15s ease; } -.bu-chart-dots circle:hover, .bu-chart-dots circle:focus { fill: var(--bu-accent); outline: none; stroke-width: 5; } +.bu-chart-area { fill: var(--series-color, var(--bu-accent)); opacity: .10; } +.bu-chart-line { fill: none; stroke: var(--series-color, var(--bu-accent)); stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; } +.bu-chart-dots circle { cursor: pointer; fill: var(--bu-panel); stroke: var(--series-color, var(--bu-accent)); stroke-width: 3; transition: fill .15s ease, stroke-width .15s ease; } +.bu-chart-dots circle:hover, .bu-chart-dots circle:focus { fill: var(--series-color, var(--bu-accent)); outline: none; stroke-width: 5; } +.bu-chart-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 2px 8px 8px 54px; } +.bu-chart-legend span { align-items: center; color: var(--bu-muted); display: inline-flex; font-size: 12px; font-weight: 700; gap: 6px; } +.bu-chart-legend i { background: var(--series-color); border-radius: 50%; display: inline-block; height: 9px; width: 9px; } +.bu-chart-wrap .bu-chart { margin-bottom: 0; } .bu-chart-bar { fill: var(--bu-accent); opacity: .82; } .bu-chart-bar:hover { opacity: 1; } .bu-chart-empty { align-items: center; color: var(--bu-muted); display: flex; height: 240px; justify-content: center; text-align: center; } +.bu-chart-error > div { align-items: center; display: flex; flex-direction: column; gap: 9px; max-width: 440px; } +.bu-chart-error strong { color: var(--bu-text); } +.bu-chart-error span { display: block; } #urbm-app .bu-muted { color: var(--bu-muted) !important; opacity: .72; } .bu-toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-bottom: 14px; } .bu-actions { display: flex; flex-wrap: wrap; gap: 7px; } diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 73c4c77..1c7fe65 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: [], 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 state = { config: null, runs: [], runsError: '', backupMetrics: [], backupMetricsError: '', dashboardStatus: null, dashboardStatusError: '', 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.', @@ -308,6 +308,7 @@ function lineChart(runs, field, formatter, valueLabel, emptyText, days=14) { const end=Date.now(); const start=end-days*24*60*60*1000; + const colors=['#ff7a2f','#2f8cff','#20a779','#a061d1','#d9a019','#e0516d','#27a9b8','#7d8b32']; const previousByJob=new Map(); const allPoints=runs.map(run=>({run,time:new Date(run.finishedAt||run.createdAt).getTime(),value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)})).sort((a,b)=>a.time-b.time).map(point=>{ const previous=previousByJob.get(point.run.jobId); @@ -319,12 +320,16 @@ const width=720, height=220, left=54, right=18, top=18, bottom=38, innerW=width-left-right, innerH=height-top-bottom; const max=Math.max(...points.map(point=>point.value),1); const coords=points.map(point=>({ ...point, x:left+Math.max(0,Math.min(1,(point.time-start)/(end-start)))*innerW, y:top+innerH-(point.value/max)*innerH })); - const polyline=coords.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' '); - const area=`${left},${top+innerH} ${polyline} ${left+innerW},${top+innerH}`; const grid=[0,.25,.5,.75,1].map(ratio=>{const y=top+innerH-ratio*innerH; return `${esc(formatter(max*ratio))}`;}).join(''); - const dots=coords.map(point=>{const job=state.config.jobs.find(item=>item.id===point.run.jobId)?.name||point.run.jobId;const date=new Date(point.run.finishedAt||point.run.createdAt).toLocaleString();const delta=point.delta===null?'kein vorheriger Lauf':point.delta===0?'±0':`${point.delta>0?'+':'−'}${formatter(Math.abs(point.delta))}`;const hint=`Zeitpunkt: ${date} · Job: ${job} · ${valueLabel}: ${formatter(point.value)} · Veränderung: ${delta}`;return `${esc(hint)}`;}).join(''); + const grouped=new Map(); + coords.forEach(point=>{if(!grouped.has(point.run.jobId))grouped.set(point.run.jobId,[]);grouped.get(point.run.jobId).push(point);}); + const series=[...grouped.entries()].map(([jobId,seriesPoints],index)=>({jobId,points:seriesPoints,color:colors[index%colors.length],name:state.config.jobs.find(item=>item.id===jobId)?.name||jobId})); + const areas=series.length===1?series.map(item=>{const polyline=item.points.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');const first=item.points[0],last=item.points[item.points.length-1];return ``;}).join(''):''; + const lines=series.map(item=>``).join(''); + const dots=series.map(item=>item.points.map(point=>{const date=new Date(point.run.finishedAt||point.run.createdAt).toLocaleString();const delta=point.delta===null?'kein vorheriger Lauf':point.delta===0?'±0':`${point.delta>0?'+':'−'}${formatter(Math.abs(point.delta))}`;const hint=`Zeitpunkt: ${date} · Job: ${item.name} · ${valueLabel}: ${formatter(point.value)} · Veränderung: ${delta}`;return `${esc(hint)}`;}).join('')).join(''); const dateLabels=[0,1/3,2/3,1].map((ratio,index)=>{const value=new Date(start+(end-start)*ratio).toLocaleDateString(undefined,{day:'2-digit',month:'2-digit'});return `${esc(value)}`;}).join(''); - return `${grid}${dots}${dateLabels}`; + const legend=series.length>1?`
${series.map(item=>`${esc(item.name)}`).join('')}
`:''; + return `
${grid}${areas}${lines}${dots}${dateLabels}${legend}
`; } function setTooltip(element, text) { @@ -401,15 +406,28 @@ async function load() { try { - 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 = Array.isArray(backupMetrics) ? backupMetrics : []; - health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render(); + const [configResult,runsResult,metricsResult,statusResult,daemonResult] = await Promise.allSettled([api('/v1/config'),api('/v1/runs'),api('/v1/backup-metrics'),api('/v1/dashboard-status'),api('/v1/health')]); + if(configResult.status!=='fulfilled') throw configResult.reason; + state.config=configResult.value; + if(runsResult.status==='fulfilled'){state.runs=newestRuns(Array.isArray(runsResult.value)?runsResult.value:[]);state.runsError='';}else state.runsError=runsResult.reason?.message||'Aktivitäten konnten nicht geladen werden.'; + if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.'; + if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.'; + if(daemonResult.status==='fulfilled'){const daemon=daemonResult.value;health.textContent=`Daemon online (${daemon.version||'unbekannt'})`;health.className='bu-health ok';} + else {health.textContent='Daemon teilweise erreichbar';health.className='bu-health bad';} + render(); } catch (error) { health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad'; content.innerHTML = `

Verbindung fehlgeschlagen

${esc(error.message)}

`; } } + async function reloadDashboardData() { + const [metricsResult,statusResult]=await Promise.allSettled([api('/v1/backup-metrics'),api('/v1/dashboard-status')]); + if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.'; + if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.'; + render(); + } + function render() { const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, logs:renderLogs, settings:renderSettings}; menuPortal?.replaceChildren(); @@ -455,8 +473,7 @@ const orderedRuns = newestRuns(state.runs); const activeRuns = orderedRuns.filter(r => ['queued','running','paused'].includes(r.status)); const active = activeRuns.length; - const failures = orderedRuns.filter(r => r.status === 'failed').slice(0, 5); - const lastSuccess=orderedRuns.find(r=>r.status==='success'); + const failures = orderedRuns.filter(r => r.taskType==='backup'&&r.status === 'failed').slice(0, 5); const last = orderedRuns.slice(0, 6); const backupJobs=state.config.jobs.filter(job=>job.type!=='rsync'); const orderedMetrics=[...state.backupMetrics].sort((a,b)=>new Date(b.finishedAt)-new Date(a.finishedAt)); @@ -465,21 +482,36 @@ 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'; - const heroClass=latestFailed?'bad':active?'active':needsSetup?'neutral':'good'; - const heroTitle=latestFailed?'Ein Vorgang benötigt Aufmerksamkeit':active?`${active} ${active===1?'Vorgang ist':'Vorgänge sind'} aktiv`:needsSetup?'URBM fertig einrichten':'Backups sind im grünen Bereich'; - const heroText=latestFailed?displayMessage(orderedRuns[0].message):active?`${taskLabels[activeRuns[0].taskType]||activeRuns[0].taskType}: ${runTarget(activeRuns[0])}`:needsSetup?'Lege mindestens ein Repository und einen Backup-Job an.':lastSuccess?`Letzter Erfolg ${relativeTime(lastSuccess.finishedAt||lastSuccess.createdAt)} · ${runTarget(lastSuccess)}`:'Noch kein Backup-Lauf vorhanden.'; - const heroAction=latestFailed||active?button('Aktivitäten öffnen','go-view:activity','primary'):needsSetup?button('Einrichtung fortsetzen',`go-view:${state.config.repositories.length?'jobs':'repositories'}`,'primary'):button('Backup-Jobs öffnen','go-view:jobs'); - return `
Systemstatus

${esc(heroTitle)}

${esc(heroText)}

${heroAction}
+ const dashboardStatus=state.dashboardStatus||{}; + const lastSuccess=dashboardStatus.lastSuccessfulBackup||orderedMetrics.find(metric=>metric.status==='success'); + const lastFailure=dashboardStatus.lastFailedBackup||failures[0]; + const successTime=lastSuccess?new Date(lastSuccess.finishedAt).getTime():0; + const failureTime=lastFailure?new Date(lastFailure.finishedAt||lastFailure.createdAt).getTime():0; + const unresolvedFailure=lastFailure&&failureTime>successTime; + const overdueJobs=Array.isArray(dashboardStatus.overdueJobs)?dashboardStatus.overdueJobs:[]; + const successJob=lastSuccess?(state.config.jobs.find(job=>job.id===lastSuccess.jobId)?.name||lastSuccess.jobId):''; + const failureJob=lastFailure?(state.config.jobs.find(job=>job.id===lastFailure.jobId)?.name||lastFailure.jobId):''; + const statusUnavailable=!!state.dashboardStatusError&&!state.dashboardStatus; + const noSuccessfulBackup=!lastSuccess&&!needsSetup; + const heroClass=unresolvedFailure?'bad':active?'active':overdueJobs.length||statusUnavailable?'bad':needsSetup||noSuccessfulBackup?'neutral':'good'; + const heroTitle=unresolvedFailure?'Letzter Backup-Versuch fehlgeschlagen':active?`${active} ${active===1?'Vorgang ist':'Vorgänge sind'} aktiv`:overdueJobs.length?`${overdueJobs.length} ${overdueJobs.length===1?'Backup-Job ist':'Backup-Jobs sind'} überfällig`:statusUnavailable?'Backup-Status unvollständig':needsSetup?'URBM fertig einrichten':noSuccessfulBackup?'Noch kein erfolgreiches Backup':'Backups sind im grünen Bereich'; + const previousFailure=lastFailure&&!unresolvedFailure?` · Letzter Fehler ${relativeTime(lastFailure.finishedAt||lastFailure.createdAt)}`:''; + const heroText=unresolvedFailure?`${failureJob} · ${relativeTime(lastFailure.finishedAt||lastFailure.createdAt)} · ${displayMessage(lastFailure.message)}`:active?`${taskLabels[activeRuns[0].taskType]||activeRuns[0].taskType}: ${runTarget(activeRuns[0])}`:overdueJobs.length?`Fälliger Zeitpunkt überschritten: ${overdueJobs.slice(0,3).map(job=>job.name).join(', ')}${overdueJobs.length>3?' …':''}`:statusUnavailable?state.dashboardStatusError:needsSetup?'Lege mindestens ein Repository und einen Backup-Job an.':lastSuccess?`Letztes erfolgreiches Backup ${relativeTime(lastSuccess.finishedAt)} · ${successJob}${previousFailure}`:'Noch kein erfolgreiches Backup vorhanden.'; + const heroAction=statusUnavailable?button('Status erneut laden','retry-dashboard-data','primary'):unresolvedFailure||active?button('Aktivitäten öffnen','go-view:activity','primary'):overdueJobs.length?button('Backup-Jobs öffnen','go-view:jobs','primary'):needsSetup?button('Einrichtung fortsetzen',`go-view:${state.config.repositories.length?'jobs':'repositories'}`,'primary'):button('Backup-Jobs öffnen','go-view:jobs'); + const chartError=state.backupMetricsError?`
Diagrammdaten konnten nicht geladen werden.${esc(state.backupMetricsError)}${button('Erneut versuchen','retry-dashboard-data','primary')}
`:''; + const activityError=state.runsError?`
Aktivitäten konnten nicht geladen werden: ${esc(state.runsError)}
`:runsTable(last); + const statusNotice=state.dashboardStatusError&&state.dashboardStatus?`
Der Backup-Status konnte nicht aktualisiert werden und zeigt möglicherweise ältere Daten: ${esc(state.dashboardStatusError)} ${button('Erneut versuchen','retry-dashboard-data')}
`:''; + return `
Backup-Status

${esc(heroTitle)}

${esc(heroText)}

${heroAction}
+ ${statusNotice}
Aktive Jobs
${state.config.jobs.filter(j => j.enabled).length}
Repositorys
${state.config.repositories.length}
Wartend / laufend
${active}
-
Jüngste Fehler
${failures.length}
+
Überfällige Jobs
${overdueJobs.length}
Letzte 14 Tage

Backup-Verlauf

Jeder Punkt entspricht einem abgeschlossenen Backup-Lauf.

-

Backup-Größe

Verarbeitete Quelldaten pro Backup
${lineChart(backups,'bytesProcessed',formatBytes,'Größe','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}
-

Gesicherte Dateien

Verarbeitete Dateien pro Backup
${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Dateien','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}
-

Letzte Aktivitäten

Die sechs jüngsten Vorgänge auf einen Blick.
${button('Alle anzeigen','go-view:activity')}
${runsTable(last)}
`; +

Backup-Größe

Verarbeitete Quelldaten pro Backup
${chartError||lineChart(backups,'bytesProcessed',formatBytes,'Größe','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}
+

Gesicherte Dateien

Verarbeitete Dateien pro Backup
${chartError||lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Dateien','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}
+

Letzte Aktivitäten

Die sechs jüngsten Vorgänge auf einen Blick.
${button('Alle anzeigen','go-view:activity')}
${activityError}
`; } function renderJobs() { @@ -865,6 +897,7 @@ if (name === 'change-repo-password') return repositoryPasswordForm(state.config.repositories.find(r=>r.id===a)); if (name === 'cancel-form') return render(); if (name === 'refresh') return load(); + if (name === 'retry-dashboard-data') return reloadDashboardData(); if (name === 'run-job') { await api(`/v1/jobs/${a}/run`,{method:'POST'}); notify('Backup wurde eingereiht'); return load(); } if (name === 'cancel-run') { await api(`/v1/runs/${a}/cancel`,{method:'POST'}); return load(); } if (name === 'pause-run') { await api(`/v1/runs/${a}/pause`,{method:'POST'}); notify('Vorgang pausiert'); return load(); } @@ -1105,6 +1138,31 @@ 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){ const backupMetrics=await api('/v1/backup-metrics'); state.backupMetrics=Array.isArray(backupMetrics) ? backupMetrics : []; if(['dashboard','activity'].includes(state.view))render(); } }catch(_){ } },2000); + let lastDashboardStatusRefresh=0; + setInterval(async()=>{ + if(!state.config)return; + try { + const runs=newestRuns(await api('/v1/runs')); + const runsChanged=JSON.stringify(runs)!==JSON.stringify(state.runs); + const runsRecovered=Boolean(state.runsError); + state.runs=runs;state.runsError=''; + const statusDue=Date.now()-lastDashboardStatusRefresh>=60000; + const dataRetry=Boolean(state.backupMetricsError||state.dashboardStatusError); + if(runsChanged||statusDue||dataRetry){ + const requests=[]; + const loadMetrics=runsChanged||Boolean(state.backupMetricsError); + if(loadMetrics)requests.push(api('/v1/backup-metrics'));else requests.push(Promise.resolve(state.backupMetrics)); + requests.push(api('/v1/dashboard-status')); + const [metricsResult,statusResult]=await Promise.allSettled(requests); + if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.'; + if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.'; + } + if((runsChanged||runsRecovered||statusDue||dataRetry)&&['dashboard','activity'].includes(state.view))render(); + }catch(error){ + const changed=state.runsError!==error.message; + state.runsError=error.message; + if(changed&&['dashboard','activity'].includes(state.view))render(); + } + },2000); load(); })();