Persist dashboard backup metrics separately
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
b5b8d5e650c016cb5811fc411f844155b4162f6be7eaff02481a0e99eb5c44cb urbm-2026.07.13.r008-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
11efad3fbbe8f71092177ef912353d0c0f733b66974af18aba83a0275c2c22f9 urbm-2026.07.13.r009-x86_64-1.txz
|
||||||
Vendored
+7
-2
@@ -2,13 +2,18 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.07.13.r008">
|
<!ENTITY version "2026.07.13.r009">
|
||||||
<!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 "b5b8d5e650c016cb5811fc411f844155b4162f6be7eaff02481a0e99eb5c44cb">
|
<!ENTITY packageSHA256 "11efad3fbbe8f71092177ef912353d0c0f733b66974af18aba83a0275c2c22f9">
|
||||||
]>
|
]>
|
||||||
<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.r009
|
||||||
|
- Store completed-backup chart measurements independently in `backup-metrics.json` so clearing activities no longer erases the dashboard history.
|
||||||
|
- Migrate existing successful backup runs into the new durable metric history automatically and expose them through a dedicated read-only API.
|
||||||
|
- Keep up to 5,000 compact measurements and clarify in the delete dialog that dashboard chart data is preserved.
|
||||||
|
|
||||||
### 2026.07.13.r008
|
### 2026.07.13.r008
|
||||||
- Remove the nested vertical scrollbar from the snapshot repository browser and use the normal Unraid page scroll.
|
- Remove the nested vertical scrollbar from the snapshot repository browser and use the normal Unraid page scroll.
|
||||||
- Keep lazy folder loading and cap deep tree indentation so long paths remain usable on desktop and mobile.
|
- Keep lazy folder loading and cap deep tree indentation so long paths remain usable on desktop and mobile.
|
||||||
|
|||||||
@@ -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("DELETE /v1/secrets/{id}", s.deleteSecret)
|
||||||
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/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)
|
||||||
@@ -129,6 +130,9 @@ func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(204)
|
w.WriteHeader(204)
|
||||||
}
|
}
|
||||||
func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) }
|
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) {
|
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()})
|
||||||
}
|
}
|
||||||
@@ -360,7 +364,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/health", "/v1/logs":
|
case "/v1/runs", "/v1/backup-metrics", "/v1/health", "/v1/logs":
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -173,6 +173,23 @@ type Run struct {
|
|||||||
Restore *RestoreTask `json:"restore,omitempty"`
|
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 {
|
type RestoreTask struct {
|
||||||
SchemaVersion int `json:"schemaVersion"`
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.casaderoll.de/michael/urbm/internal/model"
|
"git.casaderoll.de/michael/urbm/internal/model"
|
||||||
|
"git.casaderoll.de/michael/urbm/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRepositoryLockHonorsContextCancellation(t *testing.T) {
|
func TestRepositoryLockHonorsContextCancellation(t *testing.T) {
|
||||||
@@ -25,6 +26,40 @@ func TestRepositoryLockHonorsContextCancellation(t *testing.T) {
|
|||||||
unlock()
|
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) {
|
func TestPersistentRunLogsAreWrittenAndPruned(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
config := model.DefaultConfig()
|
config := model.DefaultConfig()
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ type Service struct {
|
|||||||
persistStop chan struct{}
|
persistStop chan struct{}
|
||||||
persistDone chan struct{}
|
persistDone chan struct{}
|
||||||
persistStopOnce sync.Once
|
persistStopOnce sync.Once
|
||||||
|
metricsMu sync.RWMutex
|
||||||
|
backupMetrics []model.BackupMetric
|
||||||
}
|
}
|
||||||
|
|
||||||
type SnapshotBrowserNode struct {
|
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,
|
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{}),
|
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()
|
go s.runPersistence()
|
||||||
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 {
|
||||||
@@ -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) 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) 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 {
|
func (s *Service) Logs() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"daemon": tailFile("/var/log/urbm.log", 1024*1024, 1000),
|
"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) {
|
func (s *Service) queuePersistence(runs []model.Run) {
|
||||||
|
s.captureBackupMetrics(runs)
|
||||||
select {
|
select {
|
||||||
case s.persistRuns <- runs:
|
case s.persistRuns <- runs:
|
||||||
return
|
return
|
||||||
@@ -187,6 +200,39 @@ func (s *Service) saveRunState(runs []model.Run) {
|
|||||||
if err := s.persistRunLogs(runs); err != nil {
|
if err := s.persistRunLogs(runs); err != nil {
|
||||||
s.log.Error("save persistent run logs", "error", err)
|
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 {
|
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)
|
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) configPath() string { return filepath.Join(s.dir, "config.json") }
|
||||||
func (s *Store) runsPath() string { return filepath.Join(s.dir, "runs.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 {
|
func readJSON(path string, target any) error {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.casaderoll.de/michael/urbm/internal/model"
|
"git.casaderoll.de/michael/urbm/internal/model"
|
||||||
)
|
)
|
||||||
@@ -28,3 +29,32 @@ func TestStoreInitializesAndPersists(t *testing.T) {
|
|||||||
t.Fatalf("config mode = %o", info.Mode().Perm())
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ fi
|
|||||||
|
|
||||||
mkdir -p "$CONFIG" "$ROOT_PREFIX/run/urbm" "$ROOT_PREFIX/var/lib/urbm"
|
mkdir -p "$CONFIG" "$ROOT_PREFIX/run/urbm" "$ROOT_PREFIX/var/lib/urbm"
|
||||||
if [ -d "$OLD_CONFIG" ]; then
|
if [ -d "$OLD_CONFIG" ]; then
|
||||||
for item in config.json runs.json secrets; do
|
for item in config.json runs.json backup-metrics.json secrets; do
|
||||||
if [ -e "$OLD_CONFIG/$item" ] && [ ! -e "$CONFIG/$item" ]; then
|
if [ -e "$OLD_CONFIG/$item" ] && [ ! -e "$CONFIG/$item" ]; then
|
||||||
cp -a "$OLD_CONFIG/$item" "$CONFIG/$item"
|
cp -a "$OLD_CONFIG/$item" "$CONFIG/$item"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+6
-1
@@ -2,13 +2,18 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.07.13.r008">
|
<!ENTITY version "2026.07.13.r009">
|
||||||
<!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.r009
|
||||||
|
- Store completed-backup chart measurements independently in `backup-metrics.json` so clearing activities no longer erases the dashboard history.
|
||||||
|
- Migrate existing successful backup runs into the new durable metric history automatically and expose them through a dedicated read-only API.
|
||||||
|
- Keep up to 5,000 compact measurements and clarify in the delete dialog that dashboard chart data is preserved.
|
||||||
|
|
||||||
### 2026.07.13.r008
|
### 2026.07.13.r008
|
||||||
- Remove the nested vertical scrollbar from the snapshot repository browser and use the normal Unraid page scroll.
|
- Remove the nested vertical scrollbar from the snapshot repository browser and use the normal Unraid page scroll.
|
||||||
- Keep lazy folder loading and cap deep tree indentation so long paths remain usable on desktop and mobile.
|
- Keep lazy folder loading and cap deep tree indentation so long paths remain usable on desktop and mobile.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ NEW="$ROOT/boot/config/plugins/urbm"
|
|||||||
mkdir -p "$OLD/secrets" "$ROOT/var/log/plugins" "$ROOT/tmp/plugins"
|
mkdir -p "$OLD/secrets" "$ROOT/var/log/plugins" "$ROOT/tmp/plugins"
|
||||||
printf '%s\n' '{"settings":{"resticPath":"/usr/local/libexec/'"$OLD_NAME"'/restic","restoreRoot":"/mnt/user/'"$OLD_NAME"'-restores","persistentLogDir":"/mnt/user/system/'"$OLD_NAME"'-logs"}}' > "$OLD/config.json"
|
printf '%s\n' '{"settings":{"resticPath":"/usr/local/libexec/'"$OLD_NAME"'/restic","restoreRoot":"/mnt/user/'"$OLD_NAME"'-restores","persistentLogDir":"/mnt/user/system/'"$OLD_NAME"'-logs"}}' > "$OLD/config.json"
|
||||||
printf '%s\n' '[]' > "$OLD/runs.json"
|
printf '%s\n' '[]' > "$OLD/runs.json"
|
||||||
|
printf '%s\n' '[]' > "$OLD/backup-metrics.json"
|
||||||
printf '%s\n' 'encrypted' > "$OLD/secrets/secret.json"
|
printf '%s\n' 'encrypted' > "$OLD/secrets/secret.json"
|
||||||
touch "$ROOT/boot/config/plugins/$OLD_NAME.plg" "$ROOT/var/log/plugins/$OLD_NAME.plg" "$ROOT/tmp/plugins/$OLD_NAME.plg"
|
touch "$ROOT/boot/config/plugins/$OLD_NAME.plg" "$ROOT/var/log/plugins/$OLD_NAME.plg" "$ROOT/tmp/plugins/$OLD_NAME.plg"
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ URBM_ROOT_PREFIX="$ROOT" "$(dirname "$0")/../plugin/doinst.sh"
|
|||||||
|
|
||||||
test -f "$NEW/config.json"
|
test -f "$NEW/config.json"
|
||||||
test -f "$NEW/runs.json"
|
test -f "$NEW/runs.json"
|
||||||
|
test -f "$NEW/backup-metrics.json"
|
||||||
test -f "$NEW/secrets/secret.json"
|
test -f "$NEW/secrets/secret.json"
|
||||||
test ! -e "$OLD"
|
test ! -e "$OLD"
|
||||||
test ! -e "$ROOT/boot/config/plugins/$OLD_NAME.plg"
|
test ! -e "$ROOT/boot/config/plugins/$OLD_NAME.plg"
|
||||||
|
|||||||
+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.r008';
|
$urbmVersion = '2026.07.13.r009';
|
||||||
$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 ?>">
|
||||||
|
|||||||
@@ -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: [], logs: null, logsLoading: false, logsError: '', dashboardJobId: '', view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], snapshotTree: null, snapshotTreeMeta: null, snapshotTreeChildren: {}, snapshotTreeLoading: false, snapshotTreeNodeLoading: {}, snapshotTreeErrors: {}, snapshotTreeError: '', snapshotTreeExpanded: {}, restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, directoryTrees: {}, liveLogOpen: {} };
|
const state = { config: null, runs: [], backupMetrics: [], logs: null, logsLoading: false, logsError: '', dashboardJobId: '', view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], snapshotTree: null, snapshotTreeMeta: null, snapshotTreeChildren: {}, snapshotTreeLoading: false, snapshotTreeNodeLoading: {}, snapshotTreeErrors: {}, snapshotTreeError: '', snapshotTreeExpanded: {}, restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, directoryTrees: {}, liveLogOpen: {} };
|
||||||
|
|
||||||
const fieldHelp = {
|
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.',
|
||||||
@@ -401,8 +401,8 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]);
|
const [config, runs, backupMetrics, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/backup-metrics'), api('/v1/health')]);
|
||||||
state.config = config; state.runs = newestRuns(runs);
|
state.config = config; state.runs = newestRuns(runs); state.backupMetrics = backupMetrics;
|
||||||
health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render();
|
health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; 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';
|
||||||
@@ -459,9 +459,10 @@
|
|||||||
const lastSuccess=orderedRuns.find(r=>r.status==='success');
|
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');
|
||||||
if(!state.dashboardJobId) state.dashboardJobId=orderedRuns.find(run=>run.taskType==='backup'&&backupJobs.some(job=>job.id===run.jobId))?.jobId||backupJobs[0]?.id||'all';
|
const orderedMetrics=[...state.backupMetrics].sort((a,b)=>new Date(b.finishedAt)-new Date(a.finishedAt));
|
||||||
|
if(!state.dashboardJobId) state.dashboardJobId=orderedMetrics.find(metric=>backupJobs.some(job=>job.id===metric.jobId))?.jobId||backupJobs[0]?.id||'all';
|
||||||
if(state.dashboardJobId!=='all'&&!backupJobs.some(job=>job.id===state.dashboardJobId)) state.dashboardJobId='all';
|
if(state.dashboardJobId!=='all'&&!backupJobs.some(job=>job.id===state.dashboardJobId)) state.dashboardJobId='all';
|
||||||
const backups=orderedRuns.filter(run=>run.taskType==='backup'&&['success','warning'].includes(run.status)&&(state.dashboardJobId==='all'||run.jobId===state.dashboardJobId));
|
const backups=orderedMetrics.filter(metric=>state.dashboardJobId==='all'||metric.jobId===state.dashboardJobId);
|
||||||
const chartOptions=`<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 latestFailed=orderedRuns[0]?.status==='failed';
|
||||||
@@ -869,7 +870,7 @@
|
|||||||
if (name === 'pause-run') { await api(`/v1/runs/${a}/pause`,{method:'POST'}); notify('Vorgang pausiert'); return load(); }
|
if (name === 'pause-run') { await api(`/v1/runs/${a}/pause`,{method:'POST'}); notify('Vorgang pausiert'); return load(); }
|
||||||
if (name === 'resume-run') { await api(`/v1/runs/${a}/resume`,{method:'POST'}); notify('Vorgang wird fortgesetzt'); return load(); }
|
if (name === 'resume-run') { await api(`/v1/runs/${a}/resume`,{method:'POST'}); notify('Vorgang wird fortgesetzt'); return load(); }
|
||||||
if (name === 'go-view') { state.view=a; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view===a)); render(); if(a==='logs'&&!state.logs&&!state.logsLoading) loadLogs(); return; }
|
if (name === 'go-view') { state.view=a; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view===a)); render(); if(a==='logs'&&!state.logs&&!state.logsLoading) loadLogs(); return; }
|
||||||
if (name === 'clear-activity') { if(await askConfirm({title:'Aktivitätsverlauf löschen?',message:'Alle abgeschlossenen Aktivitäten und ihre gespeicherten Laufprotokolle werden entfernt. Laufende und wartende Aufgaben bleiben erhalten.',accept:'Verlauf löschen'})) { const result=await api('/v1/runs',{method:'DELETE'}); notify(result.cleared?`${result.cleared} abgeschlossene Aktivitäten gelöscht`:'Keine abgeschlossenen Aktivitäten vorhanden'); return load(); } return; }
|
if (name === 'clear-activity') { if(await askConfirm({title:'Aktivitätsverlauf löschen?',message:'Alle abgeschlossenen Aktivitäten und ihre gespeicherten Laufprotokolle werden entfernt. Laufende und wartende Aufgaben sowie die Messwerte der Dashboard-Diagramme bleiben erhalten.',accept:'Verlauf löschen'})) { const result=await api('/v1/runs',{method:'DELETE'}); notify(result.cleared?`${result.cleared} abgeschlossene Aktivitäten gelöscht`:'Keine abgeschlossenen Aktivitäten vorhanden'); return load(); } return; }
|
||||||
if (name === 'refresh-logs') { state.logs=null; return loadLogs(); }
|
if (name === 'refresh-logs') { state.logs=null; return loadLogs(); }
|
||||||
if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository-Verbindung erfolgreich getestet'); }
|
if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository-Verbindung erfolgreich getestet'); }
|
||||||
if (name === 'init-repo') { if(await askConfirm({title:'Neues Repository initialisieren?',message:'Am konfigurierten Ziel wird ein neues, leeres Restic-Repository angelegt. Verwende diese Aktion nicht für ein bereits vorhandenes Repository.',accept:'Initialisieren',danger:false})) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository erfolgreich initialisiert'); } return; }
|
if (name === 'init-repo') { if(await askConfirm({title:'Neues Repository initialisieren?',message:'Am konfigurierten Ziel wird ein neues, leeres Restic-Repository angelegt. Verwende diese Aktion nicht für ein bereits vorhandenes Repository.',accept:'Initialisieren',danger:false})) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository erfolgreich initialisiert'); } return; }
|
||||||
@@ -1104,6 +1105,6 @@
|
|||||||
window.addEventListener('scroll', hideTooltip, true);
|
window.addEventListener('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&&['dashboard','activity'].includes(state.view))render(); }catch(_){ } },2000);
|
setInterval(async()=>{ if(!state.config)return; try { const runs=newestRuns(await api('/v1/runs')); const changed=JSON.stringify(runs)!==JSON.stringify(state.runs); state.runs=runs; if(changed){ state.backupMetrics=await api('/v1/backup-metrics'); if(['dashboard','activity'].includes(state.view))render(); } }catch(_){ } },2000);
|
||||||
load();
|
load();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user