Persist dashboard backup metrics separately
This commit is contained in:
@@ -39,6 +39,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server {
|
||||
mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret)
|
||||
mux.HandleFunc("GET /v1/runs", s.runs)
|
||||
mux.HandleFunc("DELETE /v1/runs", s.clearRuns)
|
||||
mux.HandleFunc("GET /v1/backup-metrics", s.backupMetrics)
|
||||
mux.HandleFunc("GET /v1/logs", s.logs)
|
||||
mux.HandleFunc("GET /v1/filesystem/directories", s.directories)
|
||||
mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads)
|
||||
@@ -129,6 +130,9 @@ func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) }
|
||||
func (s *Server) backupMetrics(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, 200, s.service.BackupMetrics())
|
||||
}
|
||||
func (s *Server) clearRuns(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, 200, map[string]int{"cleared": s.service.ClearRunHistory()})
|
||||
}
|
||||
@@ -360,7 +364,7 @@ func shouldLogRequest(r *http.Request, status int, duration time.Duration) bool
|
||||
return true
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/v1/runs", "/v1/health", "/v1/logs":
|
||||
case "/v1/runs", "/v1/backup-metrics", "/v1/health", "/v1/logs":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
|
||||
@@ -173,6 +173,23 @@ type Run struct {
|
||||
Restore *RestoreTask `json:"restore,omitempty"`
|
||||
}
|
||||
|
||||
// BackupMetric is the durable, compact source for dashboard history. It is
|
||||
// intentionally stored separately from Run so clearing activity history does
|
||||
// not erase the backup charts.
|
||||
type BackupMetric struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
RunID string `json:"runId"`
|
||||
JobID string `json:"jobId"`
|
||||
SnapshotID string `json:"snapshotId,omitempty"`
|
||||
FinishedAt time.Time `json:"finishedAt"`
|
||||
Status string `json:"status"`
|
||||
BytesProcessed int64 `json:"bytesProcessed"`
|
||||
FilesProcessed int64 `json:"filesProcessed"`
|
||||
BytesAdded int64 `json:"bytesAdded,omitempty"`
|
||||
FilesNew int64 `json:"filesNew,omitempty"`
|
||||
FilesChanged int64 `json:"filesChanged,omitempty"`
|
||||
}
|
||||
|
||||
type RestoreTask struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.casaderoll.de/michael/urbm/internal/model"
|
||||
"git.casaderoll.de/michael/urbm/internal/store"
|
||||
)
|
||||
|
||||
func TestRepositoryLockHonorsContextCancellation(t *testing.T) {
|
||||
@@ -25,6 +26,40 @@ func TestRepositoryLockHonorsContextCancellation(t *testing.T) {
|
||||
unlock()
|
||||
}
|
||||
|
||||
func TestBackupMetricsSurviveClearedRunSnapshot(t *testing.T) {
|
||||
st := store.New(t.TempDir())
|
||||
if err := st.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Service{store: st, persistRuns: make(chan []model.Run, 1)}
|
||||
finished := time.Date(2026, 7, 13, 1, 30, 0, 0, time.UTC)
|
||||
run := model.Run{ID: "run-1", JobID: "job-1", TaskType: "backup", Status: "success", FinishedAt: &finished, BytesProcessed: 1024, FilesProcessed: 12}
|
||||
s.queuePersistence([]model.Run{run})
|
||||
s.queuePersistence(nil)
|
||||
metrics := s.BackupMetrics()
|
||||
if len(metrics) != 1 || metrics[0].RunID != run.ID || metrics[0].FilesProcessed != 12 {
|
||||
t.Fatalf("backup metrics after clear = %#v", metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupMetricsOnlyCaptureCompletedSuccessfulBackupsOnce(t *testing.T) {
|
||||
st := store.New(t.TempDir())
|
||||
s := &Service{store: st}
|
||||
finished := time.Now().UTC()
|
||||
valid := model.Run{ID: "valid", JobID: "job-1", TaskType: "backup", Status: "warning", FinishedAt: &finished}
|
||||
s.captureBackupMetrics([]model.Run{
|
||||
valid,
|
||||
valid,
|
||||
{ID: "failed", TaskType: "backup", Status: "failed", FinishedAt: &finished},
|
||||
{ID: "active", TaskType: "backup", Status: "running"},
|
||||
{ID: "rsync", TaskType: "rsync", Status: "success", FinishedAt: &finished},
|
||||
})
|
||||
metrics := s.BackupMetrics()
|
||||
if len(metrics) != 1 || metrics[0].RunID != valid.ID {
|
||||
t.Fatalf("captured backup metrics = %#v", metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentRunLogsAreWrittenAndPruned(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
config := model.DefaultConfig()
|
||||
|
||||
@@ -52,6 +52,8 @@ type Service struct {
|
||||
persistStop chan struct{}
|
||||
persistDone chan struct{}
|
||||
persistStopOnce sync.Once
|
||||
metricsMu sync.RWMutex
|
||||
backupMetrics []model.BackupMetric
|
||||
}
|
||||
|
||||
type SnapshotBrowserNode struct {
|
||||
@@ -99,6 +101,11 @@ func New(st *store.Store, secrets SecretStore, rr *restic.Runner, rs *rsync.Runn
|
||||
store: st, secrets: secrets, restic: rr, rsync: rs, mounts: mounts, workloads: workloads, notifier: notifier, log: log, config: config,
|
||||
repositoryLocks: map[string]chan struct{}{}, persistRuns: make(chan []model.Run, 1), persistStop: make(chan struct{}), persistDone: make(chan struct{}),
|
||||
}
|
||||
if metrics, err := st.LoadBackupMetrics(); err != nil {
|
||||
log.Error("load backup metrics", "error", err)
|
||||
} else {
|
||||
s.backupMetrics = metrics
|
||||
}
|
||||
go s.runPersistence()
|
||||
s.queue = queue.New(s.execute, s.queuePersistence)
|
||||
if runs, err := st.LoadRuns(); err == nil {
|
||||
@@ -124,6 +131,11 @@ func (s *Service) Wait(ctx context.Context) error {
|
||||
}
|
||||
func (s *Service) Config() model.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.config }
|
||||
func (s *Service) Runs() []model.Run { return s.queue.Snapshot() }
|
||||
func (s *Service) BackupMetrics() []model.BackupMetric {
|
||||
s.metricsMu.RLock()
|
||||
defer s.metricsMu.RUnlock()
|
||||
return append([]model.BackupMetric(nil), s.backupMetrics...)
|
||||
}
|
||||
func (s *Service) Logs() map[string]any {
|
||||
return map[string]any{
|
||||
"daemon": tailFile("/var/log/urbm.log", 1024*1024, 1000),
|
||||
@@ -143,6 +155,7 @@ func runsWithLogs(runs []model.Run, limit int) []model.Run {
|
||||
}
|
||||
|
||||
func (s *Service) queuePersistence(runs []model.Run) {
|
||||
s.captureBackupMetrics(runs)
|
||||
select {
|
||||
case s.persistRuns <- runs:
|
||||
return
|
||||
@@ -187,6 +200,39 @@ func (s *Service) saveRunState(runs []model.Run) {
|
||||
if err := s.persistRunLogs(runs); err != nil {
|
||||
s.log.Error("save persistent run logs", "error", err)
|
||||
}
|
||||
if err := s.store.SaveBackupMetrics(s.BackupMetrics()); err != nil {
|
||||
s.log.Error("save backup metrics", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) captureBackupMetrics(runs []model.Run) {
|
||||
if s.store == nil {
|
||||
return
|
||||
}
|
||||
s.metricsMu.Lock()
|
||||
defer s.metricsMu.Unlock()
|
||||
known := make(map[string]struct{}, len(s.backupMetrics))
|
||||
for _, metric := range s.backupMetrics {
|
||||
known[metric.RunID] = struct{}{}
|
||||
}
|
||||
for _, run := range runs {
|
||||
if run.TaskType != "backup" || run.FinishedAt == nil || (run.Status != "success" && run.Status != "warning") {
|
||||
continue
|
||||
}
|
||||
if _, exists := known[run.ID]; exists {
|
||||
continue
|
||||
}
|
||||
s.backupMetrics = append(s.backupMetrics, model.BackupMetric{
|
||||
SchemaVersion: model.SchemaVersion, RunID: run.ID, JobID: run.JobID, SnapshotID: run.SnapshotID,
|
||||
FinishedAt: *run.FinishedAt, Status: run.Status, BytesProcessed: run.BytesProcessed,
|
||||
FilesProcessed: run.FilesProcessed, BytesAdded: run.BytesAdded, FilesNew: run.FilesNew, FilesChanged: run.FilesChanged,
|
||||
})
|
||||
known[run.ID] = struct{}{}
|
||||
}
|
||||
sort.SliceStable(s.backupMetrics, func(i, j int) bool { return s.backupMetrics[i].FinishedAt.Before(s.backupMetrics[j].FinishedAt) })
|
||||
if len(s.backupMetrics) > 5000 {
|
||||
s.backupMetrics = append([]model.BackupMetric(nil), s.backupMetrics[len(s.backupMetrics)-5000:]...)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) persistRunLogs(runs []model.Run) error {
|
||||
|
||||
@@ -70,8 +70,32 @@ func (s *Store) SaveRuns(runs []model.Run) error {
|
||||
return writeJSONAtomic(s.runsPath(), runs, 0600)
|
||||
}
|
||||
|
||||
func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var metrics []model.BackupMetric
|
||||
if err := readJSON(s.backupMetricsPath(), &metrics); errors.Is(err, os.ErrNotExist) {
|
||||
return []model.BackupMetric{}, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveBackupMetrics(metrics []model.BackupMetric) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(metrics) > 5000 {
|
||||
metrics = metrics[len(metrics)-5000:]
|
||||
}
|
||||
return writeJSONAtomic(s.backupMetricsPath(), metrics, 0600)
|
||||
}
|
||||
|
||||
func (s *Store) configPath() string { return filepath.Join(s.dir, "config.json") }
|
||||
func (s *Store) runsPath() string { return filepath.Join(s.dir, "runs.json") }
|
||||
func (s *Store) backupMetricsPath() string {
|
||||
return filepath.Join(s.dir, "backup-metrics.json")
|
||||
}
|
||||
|
||||
func readJSON(path string, target any) error {
|
||||
b, err := os.ReadFile(path)
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.casaderoll.de/michael/urbm/internal/model"
|
||||
)
|
||||
@@ -28,3 +29,32 @@ func TestStoreInitializesAndPersists(t *testing.T) {
|
||||
t.Fatalf("config mode = %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupMetricsPersistSeparatelyFromRuns(t *testing.T) {
|
||||
s := New(t.TempDir())
|
||||
if err := s.Init(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
finished := time.Date(2026, 7, 13, 1, 30, 0, 0, time.UTC)
|
||||
metrics := []model.BackupMetric{{SchemaVersion: model.SchemaVersion, RunID: "run-1", JobID: "job-1", FinishedAt: finished, Status: "success", BytesProcessed: 42, FilesProcessed: 7}}
|
||||
if err := s.SaveBackupMetrics(metrics); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SaveRuns(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := s.LoadBackupMetrics()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(loaded) != 1 || loaded[0].RunID != "run-1" || loaded[0].BytesProcessed != 42 {
|
||||
t.Fatalf("backup metrics = %#v", loaded)
|
||||
}
|
||||
info, err := os.Stat(s.backupMetricsPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("backup metrics mode = %o", info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user