Add backup history charts

This commit is contained in:
Mikei386
2026-06-14 12:37:51 +02:00
parent acad073f09
commit 92564a1620
12 changed files with 71 additions and 27 deletions
-1
View File
@@ -1 +0,0 @@
36178615cdb1a303639d03ce5c211a3fb02ab889d5b47808b707cfe5e0c87db8 backupper-2026.06.14.3-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
ecf48010be750ee450981401f71398aa73a004090ecba122c83bbd4643febb76 backupper-2026.06.14.4-x86_64-1.txz
+5 -2
View File
@@ -2,13 +2,16 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "backupper">
<!ENTITY author "Backupper Project">
<!ENTITY version "2026.06.14.3">
<!ENTITY version "2026.06.14.4">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "36178615cdb1a303639d03ce5c211a3fb02ab889d5b47808b707cfe5e0c87db8">
<!ENTITY packageSHA256 "ecf48010be750ee450981401f71398aa73a004090ecba122c83bbd4643febb76">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
<CHANGES>
### 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.
+16 -14
View File
@@ -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 {
+6 -4
View File
@@ -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 {
+2 -2
View File
@@ -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)
+1
View File
@@ -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
}
+4 -1
View File
@@ -2,13 +2,16 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "backupper">
<!ENTITY author "Backupper Project">
<!ENTITY version "2026.06.14.3">
<!ENTITY version "2026.06.14.4">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
<CHANGES>
### 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.
+3 -3
View File
@@ -9,10 +9,10 @@ Tag="backup restic snapshots restore"
<?php
$pluginRoot = '/plugins/backupper';
?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606143">
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606144">
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header">
<div><h1>Backupper <span class="bu-version">2026.06.14.3</span></h1><p>Encrypted Restic backups for Unraid</p></div>
<div><h1>Backupper <span class="bu-version">2026.06.14.4</span></h1><p>Encrypted Restic backups for Unraid</p></div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die Backupper-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Connecting...</div>
</header>
<nav class="bu-tabs" aria-label="Backupper sections">
@@ -29,4 +29,4 @@ $pluginRoot = '/plugins/backupper';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div>
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.3.js"></script>
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.4.js"></script>
+9
View File
@@ -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; }
+24
View File
@@ -110,6 +110,26 @@
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" type="button" data-action="${esc(action)}"${tipAttr(actionTooltip(action, label))}>${esc(label)}</button>`;
const disabledButton = (label, help) => `<button class="bu-button" type="button" disabled${tipAttr(help)}>${esc(label)}</button>`;
const actionGroup = (buttons) => `<div class="bu-actions bu-table-actions">${buttons}</div>`;
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 `<div class="bu-chart-empty">${esc(emptyText)}</div>`;
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 `<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(); return `<circle cx="${point.x}" cy="${point.y}" r="4"><title>${esc(`${date} · ${job} · ${formatter(point.value)}`)}</title></circle>`;}).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 `<svg class="bu-chart" viewBox="0 0 ${width} ${height}" role="img"><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><text class="bu-chart-date" x="${left}" y="${height-8}">${esc(first)}</text><text class="bu-chart-date" x="${left+innerW}" y="${height-8}" text-anchor="end">${esc(last)}</text></svg>`;
}
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 `<div class="bu-grid">
<section class="bu-card"><div class="bu-muted">Enabled jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></section>
<section class="bu-card"><div class="bu-muted">Repositories</div><div class="bu-stat">${state.config.repositories.length}</div></section>
<section class="bu-card"><div class="bu-muted">Queued / running</div><div class="bu-stat">${active}</div></section>
<section class="bu-card"><div class="bu-muted">Recent failures</div><div class="bu-stat">${failures.length}</div></section>
</div><div class="bu-chart-grid-layout">
<section class="bu-card"><h2>Backup size</h2><div class="bu-muted">Total source data processed per backup</div>${lineChart(backups,'bytesProcessed',formatBytes,'Run a backup to collect size history.')}</section>
<section class="bu-card"><h2>Files backed up</h2><div class="bu-muted">Total files processed per backup</div>${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Run a backup to collect file history.')}</section>
</div><section class="bu-card" style="margin-top:16px"><h2>Recent activity</h2>${runsTable(last)}</section>`;
}