Restore seven-day backup charts

This commit is contained in:
Mikei386
2026-07-13 20:14:04 +02:00
parent a66540d2fb
commit 6cb4c032b9
8 changed files with 46 additions and 39 deletions
-1
View File
@@ -1 +0,0 @@
55ce788fc7ee7fbd005b4a08bb0eb3cd39b6386895e66f5741fe1100d8f173f8 urbm-2026.07.13.r004-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
d8f84b5b02336673ea9816f589546a7f2b7f7bdc805f945561228f84504feac4 urbm-2026.07.13.r005-x86_64-1.txz
+7 -2
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.07.13.r004"> <!ENTITY version "2026.07.13.r005">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!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 packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "55ce788fc7ee7fbd005b4a08bb0eb3cd39b6386895e66f5741fe1100d8f173f8"> <!ENTITY packageSHA256 "d8f84b5b02336673ea9816f589546a7f2b7f7bdc805f945561228f84504feac4">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <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> <CHANGES>
### 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 ### 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. - 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. - Position the overlay against the triggering button and constrain it to the actually available viewport space.
+6 -1
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.07.13.r004"> <!ENTITY version "2026.07.13.r005">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!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 packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE"> <!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"> <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> <CHANGES>
### 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 ### 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. - 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. - Position the overlay against the triggering button and constrain it to the actually available viewport space.
+1 -1
View File
@@ -8,7 +8,7 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
--- ---
<?php <?php
$pluginRoot = '/plugins/urbm'; $pluginRoot = '/plugins/urbm';
$urbmVersion = '2026.07.13.r004'; $urbmVersion = '2026.07.13.r005';
$urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion); $urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
+6
View File
@@ -59,6 +59,10 @@
.bu-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); } .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-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; } .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; } #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; } .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-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 { grid-column: 1 / -1; width: 100%; }
.bu-overview-action .bu-button { 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 { align-items: stretch; }
.bu-toolbar > .bu-actions { width: 100%; } .bu-toolbar > .bu-actions { width: 100%; }
.bu-filter { flex: 1 1 100%; } .bu-filter { flex: 1 1 100%; }
+25 -34
View File
@@ -10,7 +10,7 @@
const dialog = document.getElementById('bu-dialog'); const dialog = document.getElementById('bu-dialog');
const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path})); 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 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 = { const fieldHelp = {
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
@@ -305,29 +305,20 @@
return `<fieldset class="wide bu-fieldset bu-schedule"><legend>Backup-Zeitplan</legend><div class="bu-schedule-grid"><label>Ausführung<select name="scheduleMode">${modes.map(([value,label])=>`<option value="${value}" ${value===schedule.mode?'selected':''}>${label}</option>`).join('')}</select></label><label class="bu-schedule-time" ${schedule.mode==='manual'?'hidden':''}>Uhrzeit<input name="scheduleTime" type="time" required value="${esc(schedule.time)}"></label></div><div class="bu-schedule-manual bu-inline-info" ${schedule.mode==='manual'?'':'hidden'}>Dieser Job wird nicht automatisch gestartet. Er kann jederzeit in der Job-Liste über <strong>Starten</strong> ausgeführt werden.</div><div class="bu-weekdays" ${schedule.mode==='selected'?'':'hidden'}><span>Wochentage</span>${weekDays.map(day=>`<label><input type="checkbox" name="scheduleDays" value="${day.value}" ${schedule.days.includes(day.value)?'checked':''}><span>${day.short}</span></label>`).join('')}</div>${schedule.mode==='custom'?`<label class="bu-custom-cron">Bestehender Zeitplan<input name="customCron" readonly value="${esc(schedule.cron)}"></label>`:''}</fieldset>`; return `<fieldset class="wide bu-fieldset bu-schedule"><legend>Backup-Zeitplan</legend><div class="bu-schedule-grid"><label>Ausführung<select name="scheduleMode">${modes.map(([value,label])=>`<option value="${value}" ${value===schedule.mode?'selected':''}>${label}</option>`).join('')}</select></label><label class="bu-schedule-time" ${schedule.mode==='manual'?'hidden':''}>Uhrzeit<input name="scheduleTime" type="time" required value="${esc(schedule.time)}"></label></div><div class="bu-schedule-manual bu-inline-info" ${schedule.mode==='manual'?'':'hidden'}>Dieser Job wird nicht automatisch gestartet. Er kann jederzeit in der Job-Liste über <strong>Starten</strong> ausgeführt werden.</div><div class="bu-weekdays" ${schedule.mode==='selected'?'':'hidden'}><span>Wochentage</span>${weekDays.map(day=>`<label><input type="checkbox" name="scheduleDays" value="${day.value}" ${schedule.days.includes(day.value)?'checked':''}><span>${day.short}</span></label>`).join('')}</div>${schedule.mode==='custom'?`<label class="bu-custom-cron">Bestehender Zeitplan<input name="customCron" readonly value="${esc(schedule.cron)}"></label>`:''}</fieldset>`;
} }
function lineChart(runs, field, formatter, emptyText) { function lineChart(runs, field, formatter, emptyText, days=7) {
const points = runs.map(run=>({run,value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)})); 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 `<div class="bu-chart-empty">${esc(emptyText)}</div>`; 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 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 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 polyline=coords.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');
const area=`${left},${top+innerH} ${polyline} ${left+innerW},${top+innerH}`; 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 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 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 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 `<text class="bu-chart-date" x="${left+innerW*ratio}" y="${height-8}" text-anchor="${index===0?'start':index===3?'end':'middle'}">${esc(value)}</text>`;}).join('');
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" aria-label="Verlauf der letzten ${days} Tage"><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>${dateLabels}</svg>`;
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) { function setTooltip(element, text) {
@@ -406,21 +397,13 @@
try { try {
const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]); const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]);
state.config = config; state.runs = newestRuns(runs); 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) { } catch (error) {
health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad'; 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>`; 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() { function render() {
const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, logs:renderLogs, settings:renderSettings}; const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, logs:renderLogs, settings:renderSettings};
menuPortal?.replaceChildren(); menuPortal?.replaceChildren();
@@ -469,9 +452,12 @@
const failures = orderedRuns.filter(r => r.status === 'failed').slice(0, 5); const failures = orderedRuns.filter(r => r.status === 'failed').slice(0, 5);
const lastSuccess=orderedRuns.find(r=>r.status==='success'); const lastSuccess=orderedRuns.find(r=>r.status==='success');
const last = orderedRuns.slice(0, 6); const last = orderedRuns.slice(0, 6);
const stats=state.repositoryStats; const backupJobs=state.config.jobs.filter(job=>job.type!=='rsync');
const statsErrors=stats.filter(item=>item.error); if(!state.dashboardJobId) state.dashboardJobId=orderedRuns.find(run=>run.taskType==='backup'&&backupJobs.some(job=>job.id===run.jobId))?.jobId||backupJobs[0]?.id||'all';
const loading=state.repositoryStatsLoading?'<div class="bu-muted">Repository-Werte werden geladen…</div>':statsErrors.length?`<div class="bu-warning">${statsErrors.map(item=>`${esc(item.repositoryName)}: ${esc(displayMessage(item.error))}`).join('<br>')}</div>`:''; 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=`<option value="all" ${state.dashboardJobId==='all'?'selected':''}>Alle Backup-Jobs</option>${backupJobs.map(job=>`<option value="${esc(job.id)}" ${state.dashboardJobId===job.id?'selected':''}>${esc(job.name)}</option>`).join('')}`;
const needsSetup=!state.config.jobs.length||!state.config.repositories.length; const needsSetup=!state.config.jobs.length||!state.config.repositories.length;
const latestFailed=orderedRuns[0]?.status==='failed'; const latestFailed=orderedRuns[0]?.status==='failed';
const heroClass=latestFailed?'bad':active?'active':needsSetup?'neutral':'good'; const heroClass=latestFailed?'bad':active?'active':needsSetup?'neutral':'good';
@@ -484,9 +470,9 @@
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></div><span class="bu-stat-icon" aria-hidden="true"></span></section> <section class="bu-card bu-stat-card"><div><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></div><span class="bu-stat-icon" aria-hidden="true"></span></section>
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></div><span class="bu-stat-icon" aria-hidden="true"></span></section> <section class="bu-card bu-stat-card"><div><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></div><span class="bu-stat-icon" aria-hidden="true"></span></section>
<section class="bu-card bu-stat-card ${failures.length?'has-alert':''}"><div><div class="bu-muted">Jüngste Fehler</div><div class="bu-stat">${failures.length}</div></div><span class="bu-stat-icon" aria-hidden="true">!</span></section> <section class="bu-card bu-stat-card ${failures.length?'has-alert':''}"><div><div class="bu-muted">Jüngste Fehler</div><div class="bu-stat">${failures.length}</div></div><span class="bu-stat-icon" aria-hidden="true">!</span></section>
</div><div class="bu-chart-grid-layout"> </div><div class="bu-chart-toolbar"><div><span class="bu-eyebrow">Letzte 7 Tage</span><h2>Backup-Verlauf</h2><p class="bu-muted">Jeder Punkt entspricht einem abgeschlossenen Backup-Lauf.</p></div><label>Backup-Job<select id="bu-dashboard-job">${chartOptions}</select></label></div><div class="bu-chart-grid-layout">
<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>Backup-Größe</h2><div class="bu-muted">Verarbeitete Quelldaten pro Backup</div>${lineChart(backups,'bytesProcessed',formatBytes,'In den letzten sieben Tagen liegen keine abgeschlossenen Backups vor.')}</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> <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(),'In den letzten sieben Tagen liegen keine abgeschlossenen Backups vor.')}</section>
</div><section class="bu-card bu-section-gap"><div class="bu-toolbar"><div><h2>Letzte Aktivitäten</h2><div class="bu-muted">Die sechs jüngsten Vorgänge auf einen Blick.</div></div>${button('Alle anzeigen','go-view:activity')}</div>${runsTable(last)}</section>`; </div><section class="bu-card bu-section-gap"><div class="bu-toolbar"><div><h2>Letzte Aktivitäten</h2><div class="bu-muted">Die sechs jüngsten Vorgänge auf einen Blick.</div></div>${button('Alle anzeigen','go-view:activity')}</div>${runsTable(last)}</section>`;
} }
@@ -877,7 +863,7 @@
if (name === 'cancel-run') { await api(`/v1/runs/${a}/cancel`,{method:'POST'}); return load(); } 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 === '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 === '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 === '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 === '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'); } 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(); 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) { function handleActionClick(e) {
const target=e.target.closest('[data-action]'); const target=e.target.closest('[data-action]');
if(!target) return; if(!target) return;
@@ -1032,6 +1018,11 @@
},true); },true);
window.addEventListener('resize',()=>document.querySelectorAll('.bu-action-menu[open]').forEach(menu=>menu.removeAttribute('open'))); window.addEventListener('resize',()=>document.querySelectorAll('.bu-action-menu[open]').forEach(menu=>menu.removeAttribute('open')));
content.addEventListener('change', e => { 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')) { if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) {
renderRepositorySpecific(e.target.value); renderRepositorySpecific(e.target.value);
} }