Improve dashboard health and state recovery
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
eca5aa766ba47c6582d25b64ccbf9ae351aa712bba04bbe2d9b0fc44fa53fb25 urbm-2026.07.13.r011-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
36921b9372b1ee42f50eb3e18cb0a5ac720fc484f5fef992cc454684a6e86282 urbm-2026.07.13.r012-x86_64-1.txz
|
||||||
Vendored
+8
-2
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 = ©
|
||||||
|
}
|
||||||
|
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 {
|
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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user