diff --git a/dist/urbm-2026.06.15.r007-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r007-x86_64-1.txz.sha256 deleted file mode 100644 index cf5e256..0000000 --- a/dist/urbm-2026.06.15.r007-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0 urbm-2026.06.15.r007-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r007-x86_64-1.txz b/dist/urbm-2026.06.15.r008-x86_64-1.txz similarity index 55% rename from dist/urbm-2026.06.15.r007-x86_64-1.txz rename to dist/urbm-2026.06.15.r008-x86_64-1.txz index e4177a0..17aecee 100644 Binary files a/dist/urbm-2026.06.15.r007-x86_64-1.txz and b/dist/urbm-2026.06.15.r008-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r008-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r008-x86_64-1.txz.sha256 new file mode 100644 index 0000000..0988a2a --- /dev/null +++ b/dist/urbm-2026.06.15.r008-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +aa07788cc61822162b8caccd91b5b83afabf6dd770e4800c180dc6ce2b94abe5 urbm-2026.06.15.r008-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 0648798..84fe6e1 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,18 @@ - + - + ]> +### 2026.06.15.r008 +- Replace backup-run history graphs with actual per-repository Restic statistics. +- Show deduplicated stored repository data and the total files referenced by all snapshots. +- Load repository statistics separately from the two-second activity refresh to avoid repeated expensive scans. + ### 2026.06.15.r007 - Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup. - Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file. diff --git a/internal/api/server.go b/internal/api/server.go index afae989..fc411c6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -47,6 +47,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server { mux.HandleFunc("POST /v1/repositories/{id}/init", s.initRepository) mux.HandleFunc("POST /v1/repositories/{id}/unlock", s.unlockRepository) mux.HandleFunc("POST /v1/repositories/{id}/{action}", s.maintenance) + mux.HandleFunc("GET /v1/repositories/stats", s.repositoryStats) mux.HandleFunc("GET /v1/repositories/{id}/snapshots", s.snapshots) mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles) mux.HandleFunc("POST /v1/restores", s.restore) @@ -220,6 +221,12 @@ func (s *Server) snapshots(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, items) } + +func (s *Server) repositoryStats(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + writeJSON(w, 200, s.service.RepositoryStats(ctx)) +} func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) defer cancel() diff --git a/internal/model/model.go b/internal/model/model.go index 961fda5..4e00920 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -162,6 +162,15 @@ type Snapshot struct { Tags []string `json:"tags"` } +type RepositoryStats struct { + RepositoryID string `json:"repositoryId"` + RepositoryName string `json:"repositoryName"` + StoredBytes int64 `json:"storedBytes"` + FileCount int64 `json:"fileCount"` + SnapshotCount int `json:"snapshotCount"` + Error string `json:"error,omitempty"` +} + func DefaultConfig() Config { return Config{ SchemaVersion: SchemaVersion, diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 634f70d..83a9209 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -91,6 +91,12 @@ type Progress struct { type ProgressCallback func(Progress) +type Stats struct { + TotalSize int64 `json:"total_size"` + TotalFileCount int64 `json:"total_file_count"` + TotalBlobCount int64 `json:"total_blob_count"` +} + func (r *Runner) Init(ctx context.Context, repo model.Repository) error { return r.run(ctx, repo, []string{"init"}, nil, nil) } @@ -191,6 +197,22 @@ func (r *Runner) Snapshots(ctx context.Context, repo model.Repository) ([]model. return snapshots, nil } +func (r *Runner) Stats(ctx context.Context, repo model.Repository, mode string) (Stats, error) { + args := []string{"stats", "--json"} + if mode != "" { + args = append(args, "--mode", mode) + } + var output []byte + if err := r.run(ctx, repo, args, nil, &output); err != nil { + return Stats{}, err + } + var stats Stats + if err := json.Unmarshal(output, &stats); err != nil { + return Stats{}, fmt.Errorf("decode repository stats: %w", err) + } + return stats, nil +} + func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path string) ([]map[string]any, error) { args := []string{"ls", snapshot, "--json"} if path != "" { diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index a69d515..b069864 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -82,6 +82,31 @@ func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) { } } +func TestStatsUsesRequestedRepositoryCountingMode(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\nprintf '%%s\\n' '{\"total_size\":4096,\"total_file_count\":12,\"total_blob_count\":8}'\n", argsPath) + if err := os.WriteFile(script, []byte(body), 0700); err != nil { + t.Fatal(err) + } + runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} + repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} + stats, err := runner.Stats(context.Background(), repo, "raw-data") + if err != nil { + t.Fatal(err) + } + if stats.TotalSize != 4096 || stats.TotalFileCount != 12 || stats.TotalBlobCount != 8 { + t.Fatalf("unexpected stats: %+v", stats) + } + args, _ := os.ReadFile(argsPath) + for _, expected := range []string{"stats", "--json", "--mode", "raw-data"} { + if !strings.Contains(string(args), expected) { + t.Fatalf("arguments missing %q: %s", expected, args) + } + } +} + func TestForgetUsesAgeAndCalendarRetention(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") diff --git a/internal/service/service.go b/internal/service/service.go index 636f414..9dba8dc 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -202,6 +202,40 @@ func (s *Service) Snapshots(ctx context.Context, repoID string) ([]model.Snapsho return s.restic.Snapshots(ctx, mounted.Repository) } +func (s *Service) RepositoryStats(ctx context.Context) []model.RepositoryStats { + config := s.Config() + result := make([]model.RepositoryStats, 0, len(config.Repositories)) + for _, repo := range config.Repositories { + item := model.RepositoryStats{RepositoryID: repo.ID, RepositoryName: repo.Name} + mounted, err := s.mounts.Prepare(ctx, repo) + if err == nil { + var stored, files restic.Stats + stored, err = s.restic.Stats(ctx, mounted.Repository, "raw-data") + if err == nil { + files, err = s.restic.Stats(ctx, mounted.Repository, "restore-size") + } + if err == nil { + var snapshots []model.Snapshot + snapshots, err = s.restic.Snapshots(ctx, mounted.Repository) + item.StoredBytes = stored.TotalSize + item.FileCount = files.TotalFileCount + item.SnapshotCount = len(snapshots) + } + if cleanupErr := s.mounts.Cleanup(context.Background(), mounted); err == nil && cleanupErr != nil { + err = cleanupErr + } + } + if err != nil { + item.Error = err.Error() + } + result = append(result, item) + if ctx.Err() != nil { + break + } + } + return result +} + func (s *Service) SnapshotFiles(ctx context.Context, repoID, snapshot, path string) ([]map[string]any, error) { repo, ok := s.repository(repoID) if !ok { diff --git a/plugin/urbm.plg b/plugin/urbm.plg index efd3906..13b0cdb 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,18 @@ - + ]> +### 2026.06.15.r008 +- Replace backup-run history graphs with actual per-repository Restic statistics. +- Show deduplicated stored repository data and the total files referenced by all snapshots. +- Load repository statistics separately from the two-second activity refresh to avoid repeated expensive scans. + ### 2026.06.15.r007 - Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup. - Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file. diff --git a/webgui/URBM.page b/webgui/URBM.page index cc564bd..f784d7f 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.15.r007

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r008

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
- + diff --git a/webgui/assets/urbm.css b/webgui/assets/urbm.css index 77f0ff8..ab9c66d 100644 --- a/webgui/assets/urbm.css +++ b/webgui/assets/urbm.css @@ -66,6 +66,8 @@ .bu-chart-area { fill: var(--bu-accent); opacity: .10; } .bu-chart-line { fill: none; stroke: var(--bu-accent); stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; } .bu-chart-dots circle { fill: var(--bu-panel); stroke: var(--bu-accent); stroke-width: 3; } +.bu-chart-bar { fill: var(--bu-accent); opacity: .82; } +.bu-chart-bar:hover { opacity: 1; } .bu-chart-empty { align-items: center; color: var(--bu-muted); display: flex; height: 240px; justify-content: center; text-align: center; } #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; } diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index a302922..cfd0091 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -8,7 +8,7 @@ const tooltip = document.getElementById('bu-tooltip'); const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path})); const repositoryRoots = ['/mnt/user','/mnt/disks','/mnt/remotes'].map(path=>({name:path,path})); - const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', liveLogOpen: {} }; + const state = { config: null, runs: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', liveLogOpen: {} }; const fieldHelp = { name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', @@ -224,6 +224,16 @@ return `${grid}${dots}${esc(first)}${esc(last)}`; } + function repositoryChart(items, field, formatter, emptyText) { + const points=items.filter(item=>!item.error).map(item=>({item,value:Number(item[field]||0)})); + if(!points.length||points.every(point=>point.value===0)) return `
${esc(emptyText)}
`; + const width=720,height=220,left=58,right=18,top=18,bottom=48,innerW=width-left-right,innerH=height-top-bottom; + const max=Math.max(...points.map(point=>point.value),1); const slot=innerW/points.length; const barW=Math.min(70,slot*.62); + const grid=[0,.25,.5,.75,1].map(ratio=>{const y=top+innerH-ratio*innerH;return `${esc(formatter(max*ratio))}`;}).join(''); + const bars=points.map((point,index)=>{const x=left+slot*index+(slot-barW)/2;const h=(point.value/max)*innerH;const y=top+innerH-h;const label=point.item.repositoryName.length>16?`${point.item.repositoryName.slice(0,15)}…`:point.item.repositoryName;return `${esc(`${point.item.repositoryName} · ${formatter(point.value)} · ${point.item.snapshotCount} Snapshot(s)`)}${esc(label)}`;}).join(''); + return `${grid}${bars}`; + } + function setTooltip(element, text) { if (!element || !text || element.dataset.tooltip) return; element.dataset.tooltip = text; @@ -300,13 +310,21 @@ try { const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]); state.config = config; state.runs = runs; - 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(); loadRepositoryStats(); } catch (error) { health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad'; content.innerHTML = `

Verbindung fehlgeschlagen

${esc(error.message)}

`; } } + async function loadRepositoryStats() { + if(state.repositoryStatsLoading||!state.config) return; + state.repositoryStatsLoading=true; if(state.view==='dashboard') render(); + try { state.repositoryStats=await api('/v1/repositories/stats'); } + catch(error) { state.repositoryStats=state.config.repositories.map(repo=>({repositoryId:repo.id,repositoryName:repo.name,error:error.message})); } + finally { state.repositoryStatsLoading=false; if(state.view==='dashboard') render(); } + } + function render() { const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, settings:renderSettings}; content.innerHTML = views[state.view](); @@ -317,15 +335,17 @@ const active = state.runs.filter(r => ['queued','running','paused'].includes(r.status)).length; const failures = state.runs.filter(r => r.status === 'failed').slice(-5); const last = [...state.runs].reverse().slice(0, 8); - const backups = state.runs.filter(r=>r.taskType==='backup' && ['success','warning'].includes(r.status)).slice(-20); + const stats=state.repositoryStats; + const statsErrors=stats.filter(item=>item.error); + const loading=state.repositoryStatsLoading?'
Repository-Werte werden geladen…
':statsErrors.length?`
${statsErrors.length} Repository(s) konnten nicht gelesen werden.
`:''; return `
Aktive Jobs
${state.config.jobs.filter(j => j.enabled).length}
Repositorys
${state.config.repositories.length}
Wartend / laufend
${active}
Letzte Fehler
${failures.length}
-

Backup-Größe

Verarbeitete Quelldaten pro Backup
${lineChart(backups,'bytesProcessed',formatBytes,'Nach dem ersten Backup wird hier der Größenverlauf angezeigt.')}
-

Gesicherte Dateien

Verarbeitete Dateien pro Backup
${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Nach dem ersten Backup wird hier die Dateianzahl angezeigt.')}
+

Repository-Größe

Tatsächlich gespeicherte, deduplizierte Restic-Daten pro Repository
${loading}${repositoryChart(stats,'storedBytes',formatBytes,'Noch keine Repository-Daten verfügbar.')}
+

Dateien in Snapshots

Gesamte referenzierte Dateianzahl aller Snapshots pro Repository
${loading}${repositoryChart(stats,'fileCount',value=>Math.round(value).toLocaleString(),'Noch keine Snapshot-Dateien verfügbar.')}

Letzte Aktivitäten

${runsTable(last)}
`; } @@ -619,7 +639,7 @@ if (kind === 'submit-gotify-notify') { const f=new FormData(document.getElementById('bu-gotify-notify-form')); const existing=state.config.notifications.find(n=>n.type==='gotify'); const target={schemaVersion:1,id:f.get('id')||'gotify',name:'Gotify',type:'gotify',enabled:f.get('enabled')==='on',url:String(f.get('url')||'').trim(),tokenRef:String(f.get('tokenRef')||'').trim(),events:f.getAll('events')}; if(target.enabled&&!target.url) throw new Error('Die Gotify-Server-URL ist erforderlich.'); if(target.enabled&&!target.events.length) throw new Error('Wähle mindestens ein Ereignis für Gotify-Benachrichtigungen aus.'); if(target.enabled&&!existing&&!f.get('token')) throw new Error('Ein Gotify-Anwendungs-Token ist erforderlich.'); if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'gotify-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='gotify'&&n.type!=='ntfy'); if(target.enabled||target.url) state.config.notifications.push(target); await saveConfig(); render(); } } - document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); }); + document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); if(state.view==='dashboard')loadRepositoryStats(); }); content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); }); content.addEventListener('change', e => { if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) {