Release URBM 2026.06.15.r008

This commit is contained in:
Mikei386
2026-06-15 07:17:37 +02:00
parent b0fa11a40a
commit 5e68a50373
13 changed files with 143 additions and 14 deletions
-1
View File
@@ -1 +0,0 @@
61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0 urbm-2026.06.15.r007-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
aa07788cc61822162b8caccd91b5b83afabf6dd770e4800c180dc6ce2b94abe5 urbm-2026.06.15.r008-x86_64-1.txz
+7 -2
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r007">
<!ENTITY version "2026.06.15.r008">
<!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 packageSHA256 "61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0">
<!ENTITY packageSHA256 "aa07788cc61822162b8caccd91b5b83afabf6dd770e4800c180dc6ce2b94abe5">
]>
<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>
### 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.
+7
View File
@@ -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()
+9
View File
@@ -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,
+22
View File
@@ -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 != "" {
+25
View File
@@ -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")
+34
View File
@@ -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 {
+6 -1
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r007">
<!ENTITY version "2026.06.15.r008">
<!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 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">
<CHANGES>
### 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.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php
$pluginRoot = '/plugins/urbm';
?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r007">
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r008">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header">
<div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r007" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r007</span></h1><p>Restic Backup Manager für Unraid</p></div>
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r008" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r008</span></h1><p>Restic Backup Manager für Unraid</p></div>
</div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
</header>
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r007.js"></script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r008.js"></script>
+2
View File
@@ -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; }
+26 -6
View File
@@ -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 `<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 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 `<div class="bu-chart-empty">${esc(emptyText)}</div>`;
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 `<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 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 `<rect class="bu-chart-bar" x="${x}" y="${y}" width="${barW}" height="${Math.max(h,1)}"><title>${esc(`${point.item.repositoryName} · ${formatter(point.value)} · ${point.item.snapshotCount} Snapshot(s)`)}</title></rect><text class="bu-chart-date" x="${x+barW/2}" y="${height-18}" text-anchor="middle">${esc(label)}</text>`;}).join('');
return `<svg class="bu-chart" viewBox="0 0 ${width} ${height}" role="img"><g class="bu-chart-grid">${grid}</g>${bars}</svg>`;
}
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 = `<div class="bu-card"><h2>Verbindung fehlgeschlagen</h2><p>${esc(error.message)}</p></div>`;
}
}
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?'<div class="bu-muted">Repository-Werte werden geladen…</div>':statsErrors.length?`<div class="bu-warning">${statsErrors.length} Repository(s) konnten nicht gelesen werden.</div>`:'';
return `<div class="bu-grid">
<section class="bu-card"><div class="bu-muted">Aktive Jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></section>
<section class="bu-card"><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></section>
<section class="bu-card"><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></section>
<section class="bu-card"><div class="bu-muted">Letzte Fehler</div><div class="bu-stat">${failures.length}</div></section>
</div><div class="bu-chart-grid-layout">
<section class="bu-card"><h2>Backup-Größe</h2><div class="bu-muted">Verarbeitete Quelldaten pro Backup</div>${lineChart(backups,'bytesProcessed',formatBytes,'Nach dem ersten Backup wird hier der Größenverlauf angezeigt.')}</section>
<section class="bu-card"><h2>Gesicherte Dateien</h2><div class="bu-muted">Verarbeitete Dateien pro Backup</div>${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Nach dem ersten Backup wird hier die Dateianzahl angezeigt.')}</section>
<section class="bu-card"><h2>Repository-Größe</h2><div class="bu-muted">Tatsächlich gespeicherte, deduplizierte Restic-Daten pro Repository</div>${loading}${repositoryChart(stats,'storedBytes',formatBytes,'Noch keine Repository-Daten verfügbar.')}</section>
<section class="bu-card"><h2>Dateien in Snapshots</h2><div class="bu-muted">Gesamte referenzierte Dateianzahl aller Snapshots pro Repository</div>${loading}${repositoryChart(stats,'fileCount',value=>Math.round(value).toLocaleString(),'Noch keine Snapshot-Dateien verfügbar.')}</section>
</div><section class="bu-card" style="margin-top:16px"><h2>Letzte Aktivitäten</h2>${runsTable(last)}</section>`;
}
@@ -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')) {