Improve dashboard health and state recovery

This commit is contained in:
Mikei386
2026-07-13 21:06:09 +02:00
parent 819055cbf0
commit 8f75a04db6
15 changed files with 313 additions and 38 deletions
-1
View File
@@ -1 +0,0 @@
eca5aa766ba47c6582d25b64ccbf9ae351aa712bba04bbe2d9b0fc44fa53fb25 urbm-2026.07.13.r011-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
36921b9372b1ee42f50eb3e18cb0a5ac720fc484f5fef992cc454684a6e86282 urbm-2026.07.13.r012-x86_64-1.txz
+8 -2
View File
@@ -2,13 +2,19 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.07.13.r011"> <!ENTITY version "2026.07.13.r012">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "eca5aa766ba47c6582d25b64ccbf9ae351aa712bba04bbe2d9b0fc44fa53fb25"> <!ENTITY packageSHA256 "36921b9372b1ee42f50eb3e18cb0a5ac720fc484f5fef992cc454684a6e86282">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 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 ### 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. - 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. - Append one compact JSON-lines record per completed backup instead of rewriting the full metric history for every queue state change.
+5 -1
View File
@@ -40,6 +40,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server {
mux.HandleFunc("GET /v1/runs", s.runs) mux.HandleFunc("GET /v1/runs", s.runs)
mux.HandleFunc("DELETE /v1/runs", s.clearRuns) mux.HandleFunc("DELETE /v1/runs", s.clearRuns)
mux.HandleFunc("GET /v1/backup-metrics", s.backupMetrics) 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/logs", s.logs)
mux.HandleFunc("GET /v1/filesystem/directories", s.directories) mux.HandleFunc("GET /v1/filesystem/directories", s.directories)
mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads) 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) { func (s *Server) backupMetrics(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, s.service.BackupMetrics()) 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) { func (s *Server) clearRuns(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, map[string]int{"cleared": s.service.ClearRunHistory()}) 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 return true
} }
switch r.URL.Path { 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 return false
default: default:
return true return true
+12
View File
@@ -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) { func TestCronUsesOrForRestrictedMonthDayAndWeekday(t *testing.T) {
expr, err := Parse("0 2 1 * 1") expr, err := Parse("0 2 1 * 1")
if err != nil { if err != nil {
+15
View File
@@ -14,6 +14,21 @@ type Enqueue func(model.Job) error
type EnqueueMaintenance func(string, string) (model.Run, error) type EnqueueMaintenance func(string, string) (model.Run, error)
type LastRun func(string) time.Time 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 { type Scheduler struct {
config ConfigProvider config ConfigProvider
enqueue Enqueue enqueue Enqueue
+24
View File
@@ -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) { func TestBackupMetricsOnlyCaptureCompletedSuccessfulBackupsOnce(t *testing.T) {
st := store.New(t.TempDir()) st := store.New(t.TempDir())
s := &Service{store: st} s := &Service{store: st}
+65
View File
@@ -79,6 +79,19 @@ type SnapshotBrowserPage struct {
Generated time.Time `json:"generated"` 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 { type snapshotTreeCache struct {
generated time.Time generated time.Time
total int 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) s.queue = queue.New(s.execute, s.queuePersistence)
if runs, err := st.LoadRuns(); err == nil { if runs, err := st.LoadRuns(); err == nil {
s.queue.RestoreHistory(runs) s.queue.RestoreHistory(runs)
} else {
log.Error("load run history", "error", err)
} }
s.queuePersistence(s.queue.Snapshot()) s.queuePersistence(s.queue.Snapshot())
return s, nil return s, nil
@@ -418,6 +433,56 @@ func (s *Service) LastRun(jobID string) time.Time {
return latest 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 = &copy
}
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 = &copy
}
}
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 { func (s *Service) SaveConfig(config model.Config) error {
config = model.NormalizeConfig(config) config = model.NormalizeConfig(config)
for _, job := range config.Jobs { for _, job := range config.Jobs {
+41 -7
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"time"
"git.casaderoll.de/michael/urbm/internal/model" "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) { func (s *Store) LoadRuns() ([]model.Run, error) {
s.mu.RLock() s.mu.Lock()
defer s.mu.RUnlock() defer s.mu.Unlock()
var runs []model.Run var runs []model.Run
if err := readJSON(s.runsPath(), &runs); errors.Is(err, os.ErrNotExist) { if err := readJSON(s.runsPath(), &runs); errors.Is(err, os.ErrNotExist) {
return []model.Run{}, nil return []model.Run{}, nil
} else if err != 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 nil, err
} }
return runs, nil return runs, nil
@@ -72,8 +81,8 @@ func (s *Store) SaveRuns(runs []model.Run) error {
} }
func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, bool, error) { func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, bool, error) {
s.mu.RLock() s.mu.Lock()
defer s.mu.RUnlock() defer s.mu.Unlock()
path := s.backupMetricsPath() path := s.backupMetricsPath()
b, err := os.ReadFile(path) b, err := os.ReadFile(path)
legacySource := false legacySource := false
@@ -95,7 +104,7 @@ func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, bool, error) {
if b[0] == '[' { if b[0] == '[' {
var metrics []model.BackupMetric var metrics []model.BackupMetric
if err := json.Unmarshal(b, &metrics); err != nil { 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 return metrics, true, nil
} }
@@ -108,7 +117,7 @@ func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, bool, error) {
} }
var metric model.BackupMetric var metric model.BackupMetric
if err := json.Unmarshal(line, &metric); err != nil { 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) metrics = append(metrics, metric)
} }
@@ -169,11 +178,36 @@ func readJSON(path string, target any) error {
return err return err
} }
if err := json.Unmarshal(b, target); err != nil { if err := json.Unmarshal(b, target); err != nil {
return fmt.Errorf("decode %s: %w", path, err) return &jsonDecodeError{path: path, err: err}
} }
return nil 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 { func writeJSONAtomic(path string, value any, mode os.FileMode) error {
b, err := json.MarshalIndent(value, "", " ") b, err := json.MarshalIndent(value, "", " ")
if err != nil { if err != nil {
+44
View File
@@ -2,6 +2,7 @@ package store
import ( import (
"os" "os"
"path/filepath"
"testing" "testing"
"time" "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) { func TestBackupMetricsPersistSeparatelyFromRuns(t *testing.T) {
s := New(t.TempDir()) s := New(t.TempDir())
if err := s.Init(); err != nil { if err := s.Init(); err != nil {
+7 -1
View File
@@ -2,13 +2,19 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.07.13.r011"> <!ENTITY version "2026.07.13.r012">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE"> <!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 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 ### 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. - 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. - Append one compact JSON-lines record per completed backup instead of rewriting the full metric history for every queue state change.
+1 -1
View File
@@ -8,7 +8,7 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
--- ---
<?php <?php
$pluginRoot = '/plugins/urbm'; $pluginRoot = '/plugins/urbm';
$urbmVersion = '2026.07.13.r011'; $urbmVersion = '2026.07.13.r012';
$urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion); $urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
+11 -4
View File
@@ -85,13 +85,20 @@
.bu-chart { display: block; height: 240px; margin-top: 12px; overflow: visible; width: 100%; } .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 line { stroke: var(--bu-border); stroke-width: 1; }
.bu-chart-grid text, .bu-chart-date { fill: var(--bu-muted); font-size: 11px; } .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-area { fill: var(--series-color, 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-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(--bu-accent); stroke-width: 3; transition: fill .15s ease, stroke-width .15s ease; } .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(--bu-accent); outline: none; stroke-width: 5; } .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 { fill: var(--bu-accent); opacity: .82; }
.bu-chart-bar:hover { opacity: 1; } .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-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; } #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-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; } .bu-actions { display: flex; flex-wrap: wrap; gap: 7px; }
+79 -21
View File
@@ -10,7 +10,7 @@
const dialog = document.getElementById('bu-dialog'); const dialog = document.getElementById('bu-dialog');
const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path})); 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 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 = { const fieldHelp = {
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', 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) { function lineChart(runs, field, formatter, valueLabel, emptyText, days=14) {
const end=Date.now(); const end=Date.now();
const start=end-days*24*60*60*1000; const start=end-days*24*60*60*1000;
const colors=['#ff7a2f','#2f8cff','#20a779','#a061d1','#d9a019','#e0516d','#27a9b8','#7d8b32'];
const previousByJob=new Map(); 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 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); 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 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 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 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 `<line x1="${left}" y1="${y}" x2="${left+innerW}" y2="${y}"/><text x="${left-8}" y="${y+4}" text-anchor="end">${esc(formatter(max*ratio))}</text>`;}).join(''); const grid=[0,.25,.5,.75,1].map(ratio=>{const y=top+innerH-ratio*innerH; return `<line x1="${left}" y1="${y}" x2="${left+innerW}" y2="${y}"/><text x="${left-8}" y="${y+4}" text-anchor="end">${esc(formatter(max*ratio))}</text>`;}).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 `<circle cx="${point.x}" cy="${point.y}" r="5" tabindex="0" role="img" aria-label="${esc(hint)}" data-tooltip="${esc(hint)}" data-tooltip-delay="100"><title>${esc(hint)}</title></circle>`;}).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 `<polygon class="bu-chart-area" style="--series-color:${item.color}" points="${first.x},${top+innerH} ${polyline} ${last.x},${top+innerH}"/>`;}).join(''):'';
const lines=series.map(item=>`<polyline class="bu-chart-line" style="--series-color:${item.color}" points="${item.points.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')}"/>`).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 `<circle style="--series-color:${item.color}" cx="${point.x}" cy="${point.y}" r="5" tabindex="0" role="img" aria-label="${esc(hint)}" data-tooltip="${esc(hint)}" data-tooltip-delay="100"><title>${esc(hint)}</title></circle>`;}).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 `<text class="bu-chart-date" x="${left+innerW*ratio}" y="${height-8}" text-anchor="${index===0?'start':index===3?'end':'middle'}">${esc(value)}</text>`;}).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 `<text class="bu-chart-date" x="${left+innerW*ratio}" y="${height-8}" text-anchor="${index===0?'start':index===3?'end':'middle'}">${esc(value)}</text>`;}).join('');
return `<svg class="bu-chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="Verlauf der letzten ${days} Tage"><g class="bu-chart-grid">${grid}</g><polygon class="bu-chart-area" points="${area}"/><polyline class="bu-chart-line" points="${polyline}"/><g class="bu-chart-dots">${dots}</g>${dateLabels}</svg>`; const legend=series.length>1?`<div class="bu-chart-legend">${series.map(item=>`<span><i style="--series-color:${item.color}"></i>${esc(item.name)}</span>`).join('')}</div>`:'';
return `<div class="bu-chart-wrap"><svg class="bu-chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="Verlauf der letzten ${days} Tage"><g class="bu-chart-grid">${grid}</g>${areas}${lines}<g class="bu-chart-dots">${dots}</g>${dateLabels}</svg>${legend}</div>`;
} }
function setTooltip(element, text) { function setTooltip(element, text) {
@@ -401,15 +406,28 @@
async function load() { async function load() {
try { try {
const [config, runs, backupMetrics, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/backup-metrics'), api('/v1/health')]); 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')]);
state.config = config; state.runs = newestRuns(runs); state.backupMetrics = Array.isArray(backupMetrics) ? backupMetrics : []; if(configResult.status!=='fulfilled') throw configResult.reason;
health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render(); 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) { } catch (error) {
health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad'; health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad';
content.innerHTML = `<div class="bu-card"><h2>Verbindung fehlgeschlagen</h2><p>${esc(error.message)}</p></div>`; content.innerHTML = `<div class="bu-card"><h2>Verbindung fehlgeschlagen</h2><p>${esc(error.message)}</p></div>`;
} }
} }
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() { function render() {
const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, logs:renderLogs, settings:renderSettings}; const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, logs:renderLogs, settings:renderSettings};
menuPortal?.replaceChildren(); menuPortal?.replaceChildren();
@@ -455,8 +473,7 @@
const orderedRuns = newestRuns(state.runs); const orderedRuns = newestRuns(state.runs);
const activeRuns = orderedRuns.filter(r => ['queued','running','paused'].includes(r.status)); const activeRuns = orderedRuns.filter(r => ['queued','running','paused'].includes(r.status));
const active = activeRuns.length; const active = activeRuns.length;
const failures = orderedRuns.filter(r => r.status === 'failed').slice(0, 5); const failures = orderedRuns.filter(r => r.taskType==='backup'&&r.status === 'failed').slice(0, 5);
const lastSuccess=orderedRuns.find(r=>r.status==='success');
const last = orderedRuns.slice(0, 6); const last = orderedRuns.slice(0, 6);
const backupJobs=state.config.jobs.filter(job=>job.type!=='rsync'); 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)); 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 backups=orderedMetrics.filter(metric=>state.dashboardJobId==='all'||metric.jobId===state.dashboardJobId);
const chartOptions=`<option value="all" ${state.dashboardJobId==='all'?'selected':''}>Alle Backup-Jobs</option>${backupJobs.map(job=>`<option value="${esc(job.id)}" ${state.dashboardJobId===job.id?'selected':''}>${esc(job.name)}</option>`).join('')}`; const chartOptions=`<option value="all" ${state.dashboardJobId==='all'?'selected':''}>Alle Backup-Jobs</option>${backupJobs.map(job=>`<option value="${esc(job.id)}" ${state.dashboardJobId===job.id?'selected':''}>${esc(job.name)}</option>`).join('')}`;
const needsSetup=!state.config.jobs.length||!state.config.repositories.length; const needsSetup=!state.config.jobs.length||!state.config.repositories.length;
const latestFailed=orderedRuns[0]?.status==='failed'; const dashboardStatus=state.dashboardStatus||{};
const heroClass=latestFailed?'bad':active?'active':needsSetup?'neutral':'good'; const lastSuccess=dashboardStatus.lastSuccessfulBackup||orderedMetrics.find(metric=>metric.status==='success');
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 lastFailure=dashboardStatus.lastFailedBackup||failures[0];
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 successTime=lastSuccess?new Date(lastSuccess.finishedAt).getTime():0;
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'); const failureTime=lastFailure?new Date(lastFailure.finishedAt||lastFailure.createdAt).getTime():0;
return `<section class="bu-overview-hero ${heroClass}"><div class="bu-overview-icon" aria-hidden="true">${latestFailed?'!':active?'↻':needsSetup?'→':'✓'}</div><div class="bu-overview-copy"><span class="bu-eyebrow">Systemstatus</span><h2>${esc(heroTitle)}</h2><p>${esc(heroText)}</p></div><div class="bu-overview-action">${heroAction}</div></section> 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?`<div class="bu-chart-empty bu-chart-error"><div><strong>Diagrammdaten konnten nicht geladen werden.</strong><span>${esc(state.backupMetricsError)}</span>${button('Erneut versuchen','retry-dashboard-data','primary')}</div></div>`:'';
const activityError=state.runsError?`<div class="bu-inline-error">Aktivitäten konnten nicht geladen werden: ${esc(state.runsError)}</div>`:runsTable(last);
const statusNotice=state.dashboardStatusError&&state.dashboardStatus?`<div class="bu-inline-error bu-section-gap">Der Backup-Status konnte nicht aktualisiert werden und zeigt möglicherweise ältere Daten: ${esc(state.dashboardStatusError)} ${button('Erneut versuchen','retry-dashboard-data')}</div>`:'';
return `<section class="bu-overview-hero ${heroClass}"><div class="bu-overview-icon" aria-hidden="true">${unresolvedFailure||overdueJobs.length||statusUnavailable?'!':active?'↻':needsSetup||noSuccessfulBackup?'→':'✓'}</div><div class="bu-overview-copy"><span class="bu-eyebrow">Backup-Status</span><h2>${esc(heroTitle)}</h2><p>${esc(heroText)}</p></div><div class="bu-overview-action">${heroAction}</div></section>
${statusNotice}
<div class="bu-grid bu-stat-grid"> <div class="bu-grid bu-stat-grid">
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Aktive Jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></div><span class="bu-stat-icon" aria-hidden="true"></span></section> <section class="bu-card bu-stat-card"><div><div class="bu-muted">Aktive Jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></div><span class="bu-stat-icon" aria-hidden="true"></span></section>
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></div><span class="bu-stat-icon" aria-hidden="true"></span></section> <section class="bu-card bu-stat-card"><div><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></div><span class="bu-stat-icon" aria-hidden="true"></span></section>
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></div><span class="bu-stat-icon" aria-hidden="true"></span></section> <section class="bu-card bu-stat-card"><div><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></div><span class="bu-stat-icon" aria-hidden="true"></span></section>
<section class="bu-card bu-stat-card ${failures.length?'has-alert':''}"><div><div class="bu-muted">Jüngste Fehler</div><div class="bu-stat">${failures.length}</div></div><span class="bu-stat-icon" aria-hidden="true">!</span></section> <section class="bu-card bu-stat-card ${overdueJobs.length?'has-alert':''}"><div><div class="bu-muted">Überfällige Jobs</div><div class="bu-stat">${overdueJobs.length}</div></div><span class="bu-stat-icon" aria-hidden="true">!</span></section>
</div><div class="bu-chart-toolbar"><div><span class="bu-eyebrow">Letzte 14 Tage</span><h2>Backup-Verlauf</h2><p class="bu-muted">Jeder Punkt entspricht einem abgeschlossenen Backup-Lauf.</p></div><label>Backup-Job<select id="bu-dashboard-job">${chartOptions}</select></label></div><div class="bu-chart-grid-layout"> </div><div class="bu-chart-toolbar"><div><span class="bu-eyebrow">Letzte 14 Tage</span><h2>Backup-Verlauf</h2><p class="bu-muted">Jeder Punkt entspricht einem abgeschlossenen Backup-Lauf.</p></div><label>Backup-Job<select id="bu-dashboard-job">${chartOptions}</select></label></div><div class="bu-chart-grid-layout">
<section class="bu-card"><h2>Backup-Größe</h2><div class="bu-muted">Verarbeitete Quelldaten pro Backup</div>${lineChart(backups,'bytesProcessed',formatBytes,'Größe','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}</section> <section class="bu-card"><h2>Backup-Größe</h2><div class="bu-muted">Verarbeitete Quelldaten pro Backup</div>${chartError||lineChart(backups,'bytesProcessed',formatBytes,'Größe','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}</section>
<section class="bu-card"><h2>Gesicherte Dateien</h2><div class="bu-muted">Verarbeitete Dateien pro Backup</div>${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Dateien','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}</section> <section class="bu-card"><h2>Gesicherte Dateien</h2><div class="bu-muted">Verarbeitete Dateien pro Backup</div>${chartError||lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Dateien','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}</section>
</div><section class="bu-card bu-section-gap"><div class="bu-toolbar"><div><h2>Letzte Aktivitäten</h2><div class="bu-muted">Die sechs jüngsten Vorgänge auf einen Blick.</div></div>${button('Alle anzeigen','go-view:activity')}</div>${runsTable(last)}</section>`; </div><section class="bu-card bu-section-gap"><div class="bu-toolbar"><div><h2>Letzte Aktivitäten</h2><div class="bu-muted">Die sechs jüngsten Vorgänge auf einen Blick.</div></div>${button('Alle anzeigen','go-view:activity')}</div>${activityError}</section>`;
} }
function renderJobs() { function renderJobs() {
@@ -865,6 +897,7 @@
if (name === 'change-repo-password') return repositoryPasswordForm(state.config.repositories.find(r=>r.id===a)); if (name === 'change-repo-password') return repositoryPasswordForm(state.config.repositories.find(r=>r.id===a));
if (name === 'cancel-form') return render(); if (name === 'cancel-form') return render();
if (name === 'refresh') return load(); 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 === '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 === '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(); } 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('scroll', hideTooltip, true);
window.addEventListener('resize', hideTooltip); window.addEventListener('resize', hideTooltip);
enhanceTooltips(root); 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(); load();
})(); })();