diff --git a/dist/backupper-2026.06.14.3-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.3-x86_64-1.txz.sha256 deleted file mode 100644 index 7e74505..0000000 --- a/dist/backupper-2026.06.14.3-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -36178615cdb1a303639d03ce5c211a3fb02ab889d5b47808b707cfe5e0c87db8 backupper-2026.06.14.3-x86_64-1.txz diff --git a/dist/backupper-2026.06.14.3-x86_64-1.txz b/dist/backupper-2026.06.14.4-x86_64-1.txz similarity index 55% rename from dist/backupper-2026.06.14.3-x86_64-1.txz rename to dist/backupper-2026.06.14.4-x86_64-1.txz index c0f8afa..317a045 100644 Binary files a/dist/backupper-2026.06.14.3-x86_64-1.txz and b/dist/backupper-2026.06.14.4-x86_64-1.txz differ diff --git a/dist/backupper-2026.06.14.4-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.4-x86_64-1.txz.sha256 new file mode 100644 index 0000000..8f53df7 --- /dev/null +++ b/dist/backupper-2026.06.14.4-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +ecf48010be750ee450981401f71398aa73a004090ecba122c83bbd4643febb76 backupper-2026.06.14.4-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index d30e709..d18db92 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,16 @@ - + - + ]> +### 2026.06.14.4 +- Add dashboard history charts for processed backup size and backed-up file count. + ### 2026.06.14.3 - Force a daemon restart after every plugin upgrade so new API endpoints become active immediately. diff --git a/internal/model/model.go b/internal/model/model.go index f4056bf..a985e43 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -111,20 +111,22 @@ type NotificationTarget struct { } type Run struct { - SchemaVersion int `json:"schemaVersion"` - ID string `json:"id"` - JobID string `json:"jobId,omitempty"` - TaskType string `json:"taskType"` - Status string `json:"status"` - Priority int `json:"priority"` - CreatedAt time.Time `json:"createdAt"` - StartedAt *time.Time `json:"startedAt,omitempty"` - FinishedAt *time.Time `json:"finishedAt,omitempty"` - SnapshotID string `json:"snapshotId,omitempty"` - Message string `json:"message,omitempty"` - ErrorCode string `json:"errorCode,omitempty"` - BytesAdded int64 `json:"bytesAdded,omitempty"` - FilesNew int64 `json:"filesNew,omitempty"` + SchemaVersion int `json:"schemaVersion"` + ID string `json:"id"` + JobID string `json:"jobId,omitempty"` + TaskType string `json:"taskType"` + Status string `json:"status"` + Priority int `json:"priority"` + CreatedAt time.Time `json:"createdAt"` + StartedAt *time.Time `json:"startedAt,omitempty"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + SnapshotID string `json:"snapshotId,omitempty"` + Message string `json:"message,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + BytesAdded int64 `json:"bytesAdded,omitempty"` + FilesNew int64 `json:"filesNew,omitempty"` + BytesProcessed int64 `json:"bytesProcessed,omitempty"` + FilesProcessed int64 `json:"filesProcessed,omitempty"` } type RestoreTask struct { diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 679f938..52cf513 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -26,10 +26,12 @@ type Runner struct { } type Summary struct { - MessageType string `json:"message_type"` - SnapshotID string `json:"snapshot_id"` - DataAdded int64 `json:"data_added"` - FilesNew int64 `json:"files_new"` + MessageType string `json:"message_type"` + SnapshotID string `json:"snapshot_id"` + DataAdded int64 `json:"data_added"` + FilesNew int64 `json:"files_new"` + TotalBytesProcessed int64 `json:"total_bytes_processed"` + TotalFilesProcessed int64 `json:"total_files_processed"` } func (r *Runner) Init(ctx context.Context, repo model.Repository) error { diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index 64103b5..038eeb0 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -20,7 +20,7 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) { argsPath := filepath.Join(dir, "args") passwordPath := filepath.Join(dir, "password") script := filepath.Join(dir, "restic") - body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3}'\n", argsPath, passwordPath) + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3,\"total_bytes_processed\":1024,\"total_files_processed\":12}'\n", argsPath, passwordPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } @@ -31,7 +31,7 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) { if err != nil { t.Fatal(err) } - if summary.SnapshotID != "abc123" || summary.DataAdded != 42 { + if summary.SnapshotID != "abc123" || summary.DataAdded != 42 || summary.TotalBytesProcessed != 1024 || summary.TotalFilesProcessed != 12 { t.Fatalf("unexpected summary: %+v", summary) } args, _ := os.ReadFile(argsPath) diff --git a/internal/service/service.go b/internal/service/service.go index b99a042..9985e00 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -282,6 +282,7 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode run.Status, run.Message = "success", "backup completed" } run.SnapshotID, run.BytesAdded, run.FilesNew = summary.SnapshotID, summary.DataAdded, summary.FilesNew + run.BytesProcessed, run.FilesProcessed = summary.TotalBytesProcessed, summary.TotalFilesProcessed return run } diff --git a/plugin/backupper.plg b/plugin/backupper.plg index 4068ad0..bf22ce8 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,16 @@ - + ]> +### 2026.06.14.4 +- Add dashboard history charts for processed backup size and backed-up file count. + ### 2026.06.14.3 - Force a daemon restart after every plugin upgrade so new API endpoints become active immediately. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index 0a44da6..5e7deb6 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,10 +9,10 @@ Tag="backup restic snapshots restore" - +
-

Backupper 2026.06.14.3

Encrypted Restic backups for Unraid

+

Backupper 2026.06.14.4

Encrypted Restic backups for Unraid

Connecting...
- + diff --git a/webgui/assets/backupper.css b/webgui/assets/backupper.css index d3c2e56..204d2bc 100644 --- a/webgui/assets/backupper.css +++ b/webgui/assets/backupper.css @@ -54,9 +54,17 @@ #backupper-app .bu-tabs button.active { border-bottom-color: var(--bu-accent) !important; color: var(--bu-text) !important; opacity: 1; } .bu-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); } +.bu-chart-grid-layout { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 16px; } .bu-card { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.12); color: var(--bu-text); padding: 18px; } #backupper-app .bu-card h2, #backupper-app .bu-card h3 { color: var(--bu-text) !important; font-weight: 700; margin-top: 0; } .bu-stat { color: var(--bu-text); font-size: 32px; font-weight: 700; } +.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 text, .bu-chart-date { fill: var(--bu-muted); font-size: 11px; } +.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-empty { align-items: center; color: var(--bu-muted); display: flex; height: 240px; justify-content: center; text-align: center; } #backupper-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-actions { display: flex; flex-wrap: wrap; gap: 7px; } @@ -173,6 +181,7 @@ @media (max-width: 700px) { #backupper-app { padding-left: 8px; padding-right: 8px; } .bu-form { grid-template-columns: 1fr; } + .bu-chart-grid-layout { grid-template-columns: 1fr; } .bu-header { align-items: flex-start; flex-direction: column; gap: 12px; } .bu-table { display: block; overflow-x: auto; } .bu-table-actions { min-width: 240px; } diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js index d77f98b..4fe049f 100644 --- a/webgui/assets/backupper.js +++ b/webgui/assets/backupper.js @@ -110,6 +110,26 @@ const button = (label, action, kind = '') => ``; const disabledButton = (label, help) => ``; const actionGroup = (buttons) => `
${buttons}
`; + const formatBytes = (value) => { + const units = ['B','KiB','MiB','GiB','TiB']; let size = Number(value)||0; let unit = 0; + while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; } + return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`; + }; + + function lineChart(runs, field, formatter, emptyText) { + const points = runs.map(run=>({run,value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)})); + if (!points.length || points.every(point=>point.value===0)) return `
${esc(emptyText)}
`; + 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 coords=points.map((point,index)=>({ ...point, x:left+(points.length===1?innerW/2:index*innerW/(points.length-1)), 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 `${esc(formatter(max*ratio))}`;}).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(); return `${esc(`${date} · ${job} · ${formatter(point.value)}`)}`;}).join(''); + const first=new Date(points[0].run.finishedAt||points[0].run.createdAt).toLocaleDateString(); + const last=new Date(points.at(-1).run.finishedAt||points.at(-1).run.createdAt).toLocaleDateString(); + return `${grid}${dots}${esc(first)}${esc(last)}`; + } function setTooltip(element, text) { if (!element || !text || element.dataset.tooltip) return; @@ -203,11 +223,15 @@ const active = state.runs.filter(r => ['queued','running'].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); return `
Enabled jobs
${state.config.jobs.filter(j => j.enabled).length}
Repositories
${state.config.repositories.length}
Queued / running
${active}
Recent failures
${failures.length}
+
+

Backup size

Total source data processed per backup
${lineChart(backups,'bytesProcessed',formatBytes,'Run a backup to collect size history.')}
+

Files backed up

Total files processed per backup
${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Run a backup to collect file history.')}

Recent activity

${runsTable(last)}
`; }