diff --git a/dist/urbm-2026.07.13.r004-x86_64-1.txz.sha256 b/dist/urbm-2026.07.13.r004-x86_64-1.txz.sha256 deleted file mode 100644 index 3ebbdca..0000000 --- a/dist/urbm-2026.07.13.r004-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -55ce788fc7ee7fbd005b4a08bb0eb3cd39b6386895e66f5741fe1100d8f173f8 urbm-2026.07.13.r004-x86_64-1.txz diff --git a/dist/urbm-2026.07.13.r004-x86_64-1.txz b/dist/urbm-2026.07.13.r005-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.07.13.r004-x86_64-1.txz rename to dist/urbm-2026.07.13.r005-x86_64-1.txz index fe9a0c8..937bdf9 100644 Binary files a/dist/urbm-2026.07.13.r004-x86_64-1.txz and b/dist/urbm-2026.07.13.r005-x86_64-1.txz differ diff --git a/dist/urbm-2026.07.13.r005-x86_64-1.txz.sha256 b/dist/urbm-2026.07.13.r005-x86_64-1.txz.sha256 new file mode 100644 index 0000000..005462c --- /dev/null +++ b/dist/urbm-2026.07.13.r005-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +d8f84b5b02336673ea9816f589546a7f2b7f7bdc805f945561228f84504feac4 urbm-2026.07.13.r005-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 0204532..bcacf0a 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,18 @@ - + - + ]> +### 2026.07.13.r005 +- Restore the dashboard backup-size and file-count timelines using completed runs from the last seven days. +- Plot runs at their actual time and add a backup-job selector that defaults to the most recently used job. +- Stop loading current repository totals for charts that are intended to show historical backup runs. + ### 2026.07.13.r004 - Render open job and repository action menus in a fixed top-level overlay so Unraid table and card layers cannot clip them. - Position the overlay against the triggering button and constrain it to the actually available viewport space. diff --git a/plugin/urbm.plg b/plugin/urbm.plg index ac7c97f..54524cf 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,18 @@ - + ]> +### 2026.07.13.r005 +- Restore the dashboard backup-size and file-count timelines using completed runs from the last seven days. +- Plot runs at their actual time and add a backup-job selector that defaults to the most recently used job. +- Stop loading current repository totals for charts that are intended to show historical backup runs. + ### 2026.07.13.r004 - Render open job and repository action menus in a fixed top-level overlay so Unraid table and card layers cannot clip them. - Position the overlay against the triggering button and constrain it to the actually available viewport space. diff --git a/webgui/URBM.page b/webgui/URBM.page index 47b362c..4c9b21a 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -8,7 +8,7 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" --- diff --git a/webgui/assets/urbm.css b/webgui/assets/urbm.css index 239a91b..1141fcf 100644 --- a/webgui/assets/urbm.css +++ b/webgui/assets/urbm.css @@ -59,6 +59,10 @@ .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-chart-toolbar { align-items: end; display: flex; gap: 18px; justify-content: space-between; margin-top: 26px; } +.bu-chart-toolbar h2 { margin: 0; } +.bu-chart-toolbar p { margin: 4px 0 0; } +.bu-chart-toolbar label { display: grid; font-size: 13px; font-weight: 700; gap: 6px; min-width: min(340px, 100%); } .bu-card { background: var(--bu-panel); border: 1px solid color-mix(in srgb, var(--bu-border) 78%, transparent); border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,.08); color: var(--bu-text); padding: 20px; } #urbm-app .bu-card h2, #urbm-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; } @@ -357,6 +361,8 @@ body.bu-dialog-open { overflow: hidden; } .bu-overview-hero { align-items: start; grid-template-columns: auto minmax(0,1fr); padding: 18px; } .bu-overview-action { grid-column: 1 / -1; width: 100%; } .bu-overview-action .bu-button { width: 100%; } + .bu-chart-toolbar { align-items: stretch; flex-direction: column; } + .bu-chart-toolbar label { min-width: 0; width: 100%; } .bu-toolbar { align-items: stretch; } .bu-toolbar > .bu-actions { width: 100%; } .bu-filter { flex: 1 1 100%; } diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 0bf5cb7..e0ed71c 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -10,7 +10,7 @@ const dialog = document.getElementById('bu-dialog'); 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: [], logs: null, logsLoading: false, logsError: '', repositoryStats: [], repositoryStatsLoading: false, 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: [], 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 = { name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', @@ -305,29 +305,20 @@ return `
Backup-Zeitplan
Dieser Job wird nicht automatisch gestartet. Er kann jederzeit in der Job-Liste über Starten ausgeführt werden.
Wochentage${weekDays.map(day=>``).join('')}
${schedule.mode==='custom'?``:''}
`; } - function lineChart(runs, field, formatter, emptyText) { - const points = runs.map(run=>({run,value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)})); + function lineChart(runs, field, formatter, emptyText, days=7) { + const end=Date.now(); + const start=end-days*24*60*60*1000; + const points = runs.map(run=>({run,time:new Date(run.finishedAt||run.createdAt).getTime(),value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)})).filter(point=>point.time>=start&&point.time<=end).sort((a,b)=>a.time-b.time); 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 coords=points.map(point=>({ ...point, x:left+Math.max(0,Math.min(1,(point.time-start)/(end-start)))*innerW, 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 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}`; + const dateLabels=[0,1/3,2/3,1].map((ratio,index)=>{const value=new Date(start+(end-start)*ratio).toLocaleDateString(undefined,{day:'2-digit',month:'2-digit'});return `${esc(value)}`;}).join(''); + return `${grid}${dots}${dateLabels}`; } function setTooltip(element, text) { @@ -406,21 +397,13 @@ try { const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]); state.config = config; state.runs = newestRuns(runs); - health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render(); loadRepositoryStats(); + health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render(); } 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, logs:renderLogs, settings:renderSettings}; menuPortal?.replaceChildren(); @@ -469,9 +452,12 @@ const failures = orderedRuns.filter(r => r.status === 'failed').slice(0, 5); const lastSuccess=orderedRuns.find(r=>r.status==='success'); const last = orderedRuns.slice(0, 6); - const stats=state.repositoryStats; - const statsErrors=stats.filter(item=>item.error); - const loading=state.repositoryStatsLoading?'
Repository-Werte werden geladen…
':statsErrors.length?`
${statsErrors.map(item=>`${esc(item.repositoryName)}: ${esc(displayMessage(item.error))}`).join('
')}
`:''; + 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'; + if(state.dashboardJobId!=='all'&&!backupJobs.some(job=>job.id===state.dashboardJobId)) state.dashboardJobId='all'; + const sevenDaysAgo=Date.now()-7*24*60*60*1000; + const backups=orderedRuns.filter(run=>run.taskType==='backup'&&['success','warning'].includes(run.status)&&runTime(run)>=sevenDaysAgo&&(state.dashboardJobId==='all'||run.jobId===state.dashboardJobId)); + const chartOptions=`${backupJobs.map(job=>``).join('')}`; const needsSetup=!state.config.jobs.length||!state.config.repositories.length; const latestFailed=orderedRuns[0]?.status==='failed'; const heroClass=latestFailed?'bad':active?'active':needsSetup?'neutral':'good'; @@ -484,9 +470,9 @@
Repositorys
${state.config.repositories.length}
Wartend / laufend
${active}
Jüngste Fehler
${failures.length}
-
-

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 7 Tage

Backup-Verlauf

Jeder Punkt entspricht einem abgeschlossenen Backup-Lauf.

+

Backup-Größe

Verarbeitete Quelldaten pro Backup
${lineChart(backups,'bytesProcessed',formatBytes,'In den letzten sieben Tagen liegen keine abgeschlossenen Backups vor.')}
+

Gesicherte Dateien

Verarbeitete Dateien pro Backup
${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'In den letzten sieben Tagen liegen keine abgeschlossenen Backups vor.')}

Letzte Aktivitäten

Die sechs jüngsten Vorgänge auf einen Blick.
${button('Alle anzeigen','go-view:activity')}
${runsTable(last)}
`; } @@ -877,7 +863,7 @@ if (name === 'cancel-run') { await api(`/v1/runs/${a}/cancel`,{method:'POST'}); 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 === 'go-view') { state.view=a; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view===a)); render(); if(a==='dashboard') loadRepositoryStats(); 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 === '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'); } @@ -967,7 +953,7 @@ notify(adopt?'Repository-Passwort wurde geprüft und auf diesem Server übernommen':'Repository-Passwort wurde sicher geändert'); 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(); if(state.view==='logs'&&!state.logs&&!state.logsLoading)loadLogs(); }); + 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==='logs'&&!state.logs&&!state.logsLoading)loadLogs(); }); function handleActionClick(e) { const target=e.target.closest('[data-action]'); if(!target) return; @@ -1032,6 +1018,11 @@ },true); window.addEventListener('resize',()=>document.querySelectorAll('.bu-action-menu[open]').forEach(menu=>menu.removeAttribute('open'))); content.addEventListener('change', e => { + if(e.target.id==='bu-dashboard-job') { + state.dashboardJobId=e.target.value; + render(); + return; + } if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) { renderRepositorySpecific(e.target.value); }