Improve dashboard health and state recovery

This commit is contained in:
Mikei386
2026-07-13 21:06:09 +02:00
parent 819055cbf0
commit 8f75a04db6
15 changed files with 313 additions and 38 deletions
+11 -4
View File
@@ -85,13 +85,20 @@
.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 { cursor: pointer; fill: var(--bu-panel); stroke: var(--bu-accent); stroke-width: 3; transition: fill .15s ease, stroke-width .15s ease; }
.bu-chart-dots circle:hover, .bu-chart-dots circle:focus { fill: var(--bu-accent); outline: none; stroke-width: 5; }
.bu-chart-area { fill: var(--series-color, var(--bu-accent)); opacity: .10; }
.bu-chart-line { fill: none; stroke: var(--series-color, var(--bu-accent)); stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; }
.bu-chart-dots circle { cursor: pointer; fill: var(--bu-panel); stroke: var(--series-color, var(--bu-accent)); stroke-width: 3; transition: fill .15s ease, stroke-width .15s ease; }
.bu-chart-dots circle:hover, .bu-chart-dots circle:focus { fill: var(--series-color, var(--bu-accent)); outline: none; stroke-width: 5; }
.bu-chart-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 2px 8px 8px 54px; }
.bu-chart-legend span { align-items: center; color: var(--bu-muted); display: inline-flex; font-size: 12px; font-weight: 700; gap: 6px; }
.bu-chart-legend i { background: var(--series-color); border-radius: 50%; display: inline-block; height: 9px; width: 9px; }
.bu-chart-wrap .bu-chart { margin-bottom: 0; }
.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; }
.bu-chart-error > div { align-items: center; display: flex; flex-direction: column; gap: 9px; max-width: 440px; }
.bu-chart-error strong { color: var(--bu-text); }
.bu-chart-error span { display: block; }
#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; }
.bu-actions { display: flex; flex-wrap: wrap; gap: 7px; }
+79 -21
View File
@@ -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: [], backupMetrics: [], 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 state = { config: null, runs: [], runsError: '', backupMetrics: [], backupMetricsError: '', dashboardStatus: null, dashboardStatusError: '', 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.',
@@ -308,6 +308,7 @@
function lineChart(runs, field, formatter, valueLabel, emptyText, days=14) {
const end=Date.now();
const start=end-days*24*60*60*1000;
const colors=['#ff7a2f','#2f8cff','#20a779','#a061d1','#d9a019','#e0516d','#27a9b8','#7d8b32'];
const previousByJob=new Map();
const allPoints=runs.map(run=>({run,time:new Date(run.finishedAt||run.createdAt).getTime(),value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)})).sort((a,b)=>a.time-b.time).map(point=>{
const previous=previousByJob.get(point.run.jobId);
@@ -319,12 +320,16 @@
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=>({ ...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 `<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();const delta=point.delta===null?'kein vorheriger Lauf':point.delta===0?'±0':`${point.delta>0?'+':''}${formatter(Math.abs(point.delta))}`;const hint=`Zeitpunkt: ${date} · Job: ${job} · ${valueLabel}: ${formatter(point.value)} · Veränderung: ${delta}`;return `<circle cx="${point.x}" cy="${point.y}" r="5" tabindex="0" role="img" aria-label="${esc(hint)}" data-tooltip="${esc(hint)}" data-tooltip-delay="100"><title>${esc(hint)}</title></circle>`;}).join('');
const grouped=new Map();
coords.forEach(point=>{if(!grouped.has(point.run.jobId))grouped.set(point.run.jobId,[]);grouped.get(point.run.jobId).push(point);});
const series=[...grouped.entries()].map(([jobId,seriesPoints],index)=>({jobId,points:seriesPoints,color:colors[index%colors.length],name:state.config.jobs.find(item=>item.id===jobId)?.name||jobId}));
const areas=series.length===1?series.map(item=>{const polyline=item.points.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');const first=item.points[0],last=item.points[item.points.length-1];return `<polygon class="bu-chart-area" style="--series-color:${item.color}" points="${first.x},${top+innerH} ${polyline} ${last.x},${top+innerH}"/>`;}).join(''):'';
const lines=series.map(item=>`<polyline class="bu-chart-line" style="--series-color:${item.color}" points="${item.points.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')}"/>`).join('');
const dots=series.map(item=>item.points.map(point=>{const date=new Date(point.run.finishedAt||point.run.createdAt).toLocaleString();const delta=point.delta===null?'kein vorheriger Lauf':point.delta===0?'±0':`${point.delta>0?'+':''}${formatter(Math.abs(point.delta))}`;const hint=`Zeitpunkt: ${date} · Job: ${item.name} · ${valueLabel}: ${formatter(point.value)} · Veränderung: ${delta}`;return `<circle style="--series-color:${item.color}" cx="${point.x}" cy="${point.y}" r="5" tabindex="0" role="img" aria-label="${esc(hint)}" data-tooltip="${esc(hint)}" data-tooltip-delay="100"><title>${esc(hint)}</title></circle>`;}).join('')).join('');
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('');
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>`;
const legend=series.length>1?`<div class="bu-chart-legend">${series.map(item=>`<span><i style="--series-color:${item.color}"></i>${esc(item.name)}</span>`).join('')}</div>`:'';
return `<div class="bu-chart-wrap"><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>${areas}${lines}<g class="bu-chart-dots">${dots}</g>${dateLabels}</svg>${legend}</div>`;
}
function setTooltip(element, text) {
@@ -401,15 +406,28 @@
async function load() {
try {
const [config, runs, backupMetrics, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/backup-metrics'), api('/v1/health')]);
state.config = config; state.runs = newestRuns(runs); state.backupMetrics = Array.isArray(backupMetrics) ? backupMetrics : [];
health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render();
const [configResult,runsResult,metricsResult,statusResult,daemonResult] = await Promise.allSettled([api('/v1/config'),api('/v1/runs'),api('/v1/backup-metrics'),api('/v1/dashboard-status'),api('/v1/health')]);
if(configResult.status!=='fulfilled') throw configResult.reason;
state.config=configResult.value;
if(runsResult.status==='fulfilled'){state.runs=newestRuns(Array.isArray(runsResult.value)?runsResult.value:[]);state.runsError='';}else state.runsError=runsResult.reason?.message||'Aktivitäten konnten nicht geladen werden.';
if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.';
if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.';
if(daemonResult.status==='fulfilled'){const daemon=daemonResult.value;health.textContent=`Daemon online (${daemon.version||'unbekannt'})`;health.className='bu-health ok';}
else {health.textContent='Daemon teilweise erreichbar';health.className='bu-health bad';}
render();
} 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 reloadDashboardData() {
const [metricsResult,statusResult]=await Promise.allSettled([api('/v1/backup-metrics'),api('/v1/dashboard-status')]);
if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.';
if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.';
render();
}
function render() {
const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, logs:renderLogs, settings:renderSettings};
menuPortal?.replaceChildren();
@@ -455,8 +473,7 @@
const orderedRuns = newestRuns(state.runs);
const activeRuns = orderedRuns.filter(r => ['queued','running','paused'].includes(r.status));
const active = activeRuns.length;
const failures = orderedRuns.filter(r => r.status === 'failed').slice(0, 5);
const lastSuccess=orderedRuns.find(r=>r.status==='success');
const failures = orderedRuns.filter(r => r.taskType==='backup'&&r.status === 'failed').slice(0, 5);
const last = orderedRuns.slice(0, 6);
const backupJobs=state.config.jobs.filter(job=>job.type!=='rsync');
const orderedMetrics=[...state.backupMetrics].sort((a,b)=>new Date(b.finishedAt)-new Date(a.finishedAt));
@@ -465,21 +482,36 @@
const backups=orderedMetrics.filter(metric=>state.dashboardJobId==='all'||metric.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 latestFailed=orderedRuns[0]?.status==='failed';
const heroClass=latestFailed?'bad':active?'active':needsSetup?'neutral':'good';
const heroTitle=latestFailed?'Ein Vorgang benötigt Aufmerksamkeit':active?`${active} ${active===1?'Vorgang ist':'Vorgänge sind'} aktiv`:needsSetup?'URBM fertig einrichten':'Backups sind im grünen Bereich';
const heroText=latestFailed?displayMessage(orderedRuns[0].message):active?`${taskLabels[activeRuns[0].taskType]||activeRuns[0].taskType}: ${runTarget(activeRuns[0])}`:needsSetup?'Lege mindestens ein Repository und einen Backup-Job an.':lastSuccess?`Letzter Erfolg ${relativeTime(lastSuccess.finishedAt||lastSuccess.createdAt)} · ${runTarget(lastSuccess)}`:'Noch kein Backup-Lauf vorhanden.';
const heroAction=latestFailed||active?button('Aktivitäten öffnen','go-view:activity','primary'):needsSetup?button('Einrichtung fortsetzen',`go-view:${state.config.repositories.length?'jobs':'repositories'}`,'primary'):button('Backup-Jobs öffnen','go-view:jobs');
return `<section class="bu-overview-hero ${heroClass}"><div class="bu-overview-icon" aria-hidden="true">${latestFailed?'!':active?'↻':needsSetup?'→':'✓'}</div><div class="bu-overview-copy"><span class="bu-eyebrow">Systemstatus</span><h2>${esc(heroTitle)}</h2><p>${esc(heroText)}</p></div><div class="bu-overview-action">${heroAction}</div></section>
const dashboardStatus=state.dashboardStatus||{};
const lastSuccess=dashboardStatus.lastSuccessfulBackup||orderedMetrics.find(metric=>metric.status==='success');
const lastFailure=dashboardStatus.lastFailedBackup||failures[0];
const successTime=lastSuccess?new Date(lastSuccess.finishedAt).getTime():0;
const failureTime=lastFailure?new Date(lastFailure.finishedAt||lastFailure.createdAt).getTime():0;
const unresolvedFailure=lastFailure&&failureTime>successTime;
const overdueJobs=Array.isArray(dashboardStatus.overdueJobs)?dashboardStatus.overdueJobs:[];
const successJob=lastSuccess?(state.config.jobs.find(job=>job.id===lastSuccess.jobId)?.name||lastSuccess.jobId):'';
const failureJob=lastFailure?(state.config.jobs.find(job=>job.id===lastFailure.jobId)?.name||lastFailure.jobId):'';
const statusUnavailable=!!state.dashboardStatusError&&!state.dashboardStatus;
const noSuccessfulBackup=!lastSuccess&&!needsSetup;
const heroClass=unresolvedFailure?'bad':active?'active':overdueJobs.length||statusUnavailable?'bad':needsSetup||noSuccessfulBackup?'neutral':'good';
const heroTitle=unresolvedFailure?'Letzter Backup-Versuch fehlgeschlagen':active?`${active} ${active===1?'Vorgang ist':'Vorgänge sind'} aktiv`:overdueJobs.length?`${overdueJobs.length} ${overdueJobs.length===1?'Backup-Job ist':'Backup-Jobs sind'} überfällig`:statusUnavailable?'Backup-Status unvollständig':needsSetup?'URBM fertig einrichten':noSuccessfulBackup?'Noch kein erfolgreiches Backup':'Backups sind im grünen Bereich';
const previousFailure=lastFailure&&!unresolvedFailure?` · Letzter Fehler ${relativeTime(lastFailure.finishedAt||lastFailure.createdAt)}`:'';
const heroText=unresolvedFailure?`${failureJob} · ${relativeTime(lastFailure.finishedAt||lastFailure.createdAt)} · ${displayMessage(lastFailure.message)}`:active?`${taskLabels[activeRuns[0].taskType]||activeRuns[0].taskType}: ${runTarget(activeRuns[0])}`:overdueJobs.length?`Fälliger Zeitpunkt überschritten: ${overdueJobs.slice(0,3).map(job=>job.name).join(', ')}${overdueJobs.length>3?' …':''}`:statusUnavailable?state.dashboardStatusError:needsSetup?'Lege mindestens ein Repository und einen Backup-Job an.':lastSuccess?`Letztes erfolgreiches Backup ${relativeTime(lastSuccess.finishedAt)} · ${successJob}${previousFailure}`:'Noch kein erfolgreiches Backup vorhanden.';
const heroAction=statusUnavailable?button('Status erneut laden','retry-dashboard-data','primary'):unresolvedFailure||active?button('Aktivitäten öffnen','go-view:activity','primary'):overdueJobs.length?button('Backup-Jobs öffnen','go-view:jobs','primary'):needsSetup?button('Einrichtung fortsetzen',`go-view:${state.config.repositories.length?'jobs':'repositories'}`,'primary'):button('Backup-Jobs öffnen','go-view:jobs');
const chartError=state.backupMetricsError?`<div class="bu-chart-empty bu-chart-error"><div><strong>Diagrammdaten konnten nicht geladen werden.</strong><span>${esc(state.backupMetricsError)}</span>${button('Erneut versuchen','retry-dashboard-data','primary')}</div></div>`:'';
const activityError=state.runsError?`<div class="bu-inline-error">Aktivitäten konnten nicht geladen werden: ${esc(state.runsError)}</div>`:runsTable(last);
const statusNotice=state.dashboardStatusError&&state.dashboardStatus?`<div class="bu-inline-error bu-section-gap">Der Backup-Status konnte nicht aktualisiert werden und zeigt möglicherweise ältere Daten: ${esc(state.dashboardStatusError)} ${button('Erneut versuchen','retry-dashboard-data')}</div>`:'';
return `<section class="bu-overview-hero ${heroClass}"><div class="bu-overview-icon" aria-hidden="true">${unresolvedFailure||overdueJobs.length||statusUnavailable?'!':active?'↻':needsSetup||noSuccessfulBackup?'→':'✓'}</div><div class="bu-overview-copy"><span class="bu-eyebrow">Backup-Status</span><h2>${esc(heroTitle)}</h2><p>${esc(heroText)}</p></div><div class="bu-overview-action">${heroAction}</div></section>
${statusNotice}
<div class="bu-grid bu-stat-grid">
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Aktive Jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).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 ${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 ${overdueJobs.length?'has-alert':''}"><div><div class="bu-muted">Überfällige Jobs</div><div class="bu-stat">${overdueJobs.length}</div></div><span class="bu-stat-icon" aria-hidden="true">!</span></section>
</div><div class="bu-chart-toolbar"><div><span class="bu-eyebrow">Letzte 14 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>Backup-Größe</h2><div class="bu-muted">Verarbeitete Quelldaten pro Backup</div>${lineChart(backups,'bytesProcessed',formatBytes,'Größe','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}</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(),'Dateien','In den letzten 14 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>`;
<section class="bu-card"><h2>Backup-Größe</h2><div class="bu-muted">Verarbeitete Quelldaten pro Backup</div>${chartError||lineChart(backups,'bytesProcessed',formatBytes,'Größe','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}</section>
<section class="bu-card"><h2>Gesicherte Dateien</h2><div class="bu-muted">Verarbeitete Dateien pro Backup</div>${chartError||lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Dateien','In den letzten 14 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>${activityError}</section>`;
}
function renderJobs() {
@@ -865,6 +897,7 @@
if (name === 'change-repo-password') return repositoryPasswordForm(state.config.repositories.find(r=>r.id===a));
if (name === 'cancel-form') return render();
if (name === 'refresh') return load();
if (name === 'retry-dashboard-data') return reloadDashboardData();
if (name === 'run-job') { await api(`/v1/jobs/${a}/run`,{method:'POST'}); notify('Backup wurde eingereiht'); 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(); }
@@ -1105,6 +1138,31 @@
window.addEventListener('scroll', hideTooltip, true);
window.addEventListener('resize', hideTooltip);
enhanceTooltips(root);
setInterval(async()=>{ if(!state.config)return; try { const runs=newestRuns(await api('/v1/runs')); const changed=JSON.stringify(runs)!==JSON.stringify(state.runs); state.runs=runs; if(changed){ const backupMetrics=await api('/v1/backup-metrics'); state.backupMetrics=Array.isArray(backupMetrics) ? backupMetrics : []; if(['dashboard','activity'].includes(state.view))render(); } }catch(_){ } },2000);
let lastDashboardStatusRefresh=0;
setInterval(async()=>{
if(!state.config)return;
try {
const runs=newestRuns(await api('/v1/runs'));
const runsChanged=JSON.stringify(runs)!==JSON.stringify(state.runs);
const runsRecovered=Boolean(state.runsError);
state.runs=runs;state.runsError='';
const statusDue=Date.now()-lastDashboardStatusRefresh>=60000;
const dataRetry=Boolean(state.backupMetricsError||state.dashboardStatusError);
if(runsChanged||statusDue||dataRetry){
const requests=[];
const loadMetrics=runsChanged||Boolean(state.backupMetricsError);
if(loadMetrics)requests.push(api('/v1/backup-metrics'));else requests.push(Promise.resolve(state.backupMetrics));
requests.push(api('/v1/dashboard-status'));
const [metricsResult,statusResult]=await Promise.allSettled(requests);
if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.';
if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.';
}
if((runsChanged||runsRecovered||statusDue||dataRetry)&&['dashboard','activity'].includes(state.view))render();
}catch(error){
const changed=state.runsError!==error.message;
state.runsError=error.message;
if(changed&&['dashboard','activity'].includes(state.view))render();
}
},2000);
load();
})();