Files
URBM/webgui/assets/backupper.js
T

639 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'use strict';
const root = document.getElementById('backupper-app');
if (!root) return;
const content = document.getElementById('bu-content');
const health = document.getElementById('bu-health');
const toast = document.getElementById('bu-toast');
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 fieldHelp = {
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
type: 'Art der zu sichernden Daten. Beispiel: appdata für Containerdaten, vm für virtuelle Maschinen oder flash für den Unraid-USB-Stick.',
repositoryId: 'Speicherziel für diesen Vorgang. Jeder Backup-Job verwendet ein eigenes Restic-Repository.',
scheduleMode: 'Legt fest, an welchen Tagen das Backup startet. Beispiel: täglich, Montag bis Freitag oder ausgewählte Wochentage.',
scheduleTime: 'Uhrzeit für den automatischen Start. Beispiel: 02:30 startet das Backup nachts um halb drei.',
scheduleDays: 'Wähle mindestens einen Wochentag. Beispiel: Montag, Mittwoch und Sonntag jeweils zur eingestellten Uhrzeit.',
consistency: 'Live sichert ohne Unterbrechung, kann aber nur crash-konsistent sein. Controlled stop beendet Container oder VM vor dem Backup und startet sie danach wieder.',
compression: 'Restic-Kompression. auto ist empfohlen; off spart CPU; max spart eher Speicher, benötigt aber mehr CPU-Zeit.',
sources: 'Optionale manuelle Quellen, eine pro Zeile. Ordner wählst du einfacher über die Checkbox-Liste. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.',
excludes: 'Muster für Dateien, die nicht gesichert werden. Ein Muster pro Zeile. Beispiele: *.tmp, cache/** oder /mnt/user/appdata/plex/Cache/**.',
shutdownSecs: 'Maximale Wartezeit für einen kontrollierten Stopp. Beispiel: 120 wartet bis zu zwei Minuten; danach wird das Backup ohne Force-Stop abgebrochen.',
keepWithinDays: 'Behält alle Snapshots, die innerhalb dieser Anzahl Tage erstellt wurden. Beispiel: 30 entfernt ältere Snapshots beim nächsten erfolgreichen Backup.',
keepDaily: 'Zusätzliche tägliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.',
keepWeekly: 'Zusätzliche wöchentliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.',
keepMonthly: 'Zusätzliche monatliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.',
enabled: 'Aktiviert automatische geplante Läufe. Deaktivierte Jobs können weiterhin gespeichert, aber nicht automatisch ausgeführt werden.',
location: 'Restic-Ziel. Lokal: /mnt/user/backups/job1. SFTP: user@server:/backup/job1. Bei SMB/NFS mit Mount wird hier der Mount-Pfad verwendet.',
password: 'Passwort zur Restic-Verschlüsselung. Beispiel: eine lange zufällige Passphrase. Leer lassen, wenn ein vorhandenes Passwort unverändert bleiben soll.',
managed: 'Wenn aktiv, mountet URBM SMB/NFS vor dem Job selbst. Wenn aus, muss der angegebene Pfad bereits durch Unraid oder Unassigned Devices gemountet sein.',
remote: 'Adresse der Netzwerkfreigabe. SMB-Beispiel: //192.168.1.20/backup. NFS-Beispiel: 192.168.1.20:/volume1/backup.',
mountPoint: 'Lokaler Einhängepunkt auf Unraid. Beispiel: /mnt/remotes/backup-nas. Erlaubt sind Pfade unter /mnt/remotes oder /mnt/disks.',
credential: 'SMB-Beispiel: username=backup und password=geheim auf getrennten Zeilen. Bei SFTP hier den kompletten privaten SSH-Schlüssel einfügen.',
knownHostsPath: 'Datei mit dem geprüften SSH-Hostschlüssel. Beispiel: /root/.ssh/known_hosts. StrictHostKeyChecking ist immer aktiv.',
snapshotId: 'Eindeutige ID des Sicherungsstands. Beispiel: 7f3a91cd. Sie kann im Bereich Snapshots über Browse ausgewählt werden.',
includes: 'Nur diese Pfade werden wiederhergestellt, einer pro Zeile. Beispiel: /mnt/user/Documents/Rechnung.pdf oder /mnt/user/Documents.',
target: 'Zielverzeichnis der Wiederherstellung. Sicheres Beispiel: /mnt/user/backupper-restores/restore-123. Standardmäßig wird nicht direkt über Originaldaten geschrieben.',
inPlace: 'Erlaubt Wiederherstellung direkt an einen produktiven Zielpfad. Nur aktivieren, wenn vorhandene Dateien bewusst ersetzt werden sollen.',
confirmed: 'Zusätzliche Bestätigung für einen In-place-Restore. Ohne diese Bestätigung wird ein direkter Restore blockiert.',
resticPath: 'Pfad zur vom Plugin bereitgestellten Restic-Datei. Standard: /usr/local/libexec/backupper/restic. Normalerweise nicht ändern.',
restoreRoot: 'Basisverzeichnis für sichere Staging-Restores. Beispiel: /mnt/user/backupper-restores.',
persistentLogDir: 'Optionales Verzeichnis für dauerhafte Detailprotokolle. Beispiel: /mnt/user/system/backupper-logs. Leer lassen, um Flash-Schreibzugriffe zu vermeiden.',
showLiveLog: 'Zeigt bereinigte Restic-Laufmeldungen unter den Aufgaben im Activity-Bereich. Standardmäßig deaktiviert; Fortschritt und Ergebnis bleiben trotzdem sichtbar.',
catchUp: 'Wie viele Stunden nach einem verpassten Termin ein Job beim Boot nachgeholt werden darf. Beispiel: 24 holt einen innerhalb des letzten Tages verpassten Lauf nach.',
checkCron: 'Zeitplan für vollständige Repository-Prüfungen. Beispiel: 0 3 1 * * prüft am ersten Tag jedes Monats um 03:00 Uhr.',
pruneCron: 'Zeitplan zum Freigeben nicht mehr benötigter Restic-Daten. Beispiel: 0 4 * * 0 startet sonntags um 04:00 Uhr.',
url: 'Basisadresse deines Gotify-Servers. Beispiel: https://gotify.example.de oder http://192.168.1.20:8080.',
token: 'App-Token aus Gotify unter Apps. Das Token wird verschlüsselt gespeichert und nicht erneut angezeigt.',
events: 'Legt fest, bei welchen Ergebnissen dieser Kanal informiert. Mindestens ein Ereignis muss aktiv sein.',
};
const actionHelp = {
'new-job': 'Öffnet den Assistenten für einen neuen Backup-Job. Beispiel: tägliches Appdata-Backup.',
'submit-job': 'Prüft und speichert diesen Backup-Job.',
'new-repo': 'Legt ein neues, isoliertes Restic-Speicherziel an.',
'submit-repo': 'Speichert Ziel und Zugangsdaten. Das Repository wird dadurch noch nicht automatisch initialisiert.',
'init-repo': 'Initialisiert am Ziel einmalig ein neues, leeres Restic-Repository. Bei einem vorhandenen Repository nur das ursprüngliche Passwort eintragen und Test verwenden.',
'load-snapshots': 'Lädt alle Sicherungsstände des ausgewählten Repositorys.',
'restore-selected': 'Übernimmt die markierten Dateien oder Ordner in den Restore-Assistenten.',
'submit-restore': 'Stellt den Restore in die globale Warteschlange; Backups und Restores laufen nacheinander.',
'submit-settings': 'Speichert die globalen URBM-Einstellungen.',
'submit-unraid-notify': 'Speichert native Unraid-Benachrichtigungen. Die Zustellung erfolgt über die in Unraid eingerichteten Notification Agents.',
'submit-gotify-notify': 'Speichert den Gotify-Kanal und das App-Token verschlüsselt.',
'refresh': 'Lädt Laufstatus und Historie erneut vom URBM-Daemon.',
'clear-activity': 'Löscht alle abgeschlossenen Activity-Einträge und deren gespeicherte Laufprotokolle. Laufende und wartende Aufgaben bleiben erhalten.',
'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.',
'browser-up': 'Öffnet im Snapshot-Browser das übergeordnete Verzeichnis.',
'source-up': 'Öffnet im Ordner-Browser das übergeordnete Verzeichnis.',
'select-current-source': 'Wählt den aktuell geöffneten Ordner vollständig als Backup-Quelle aus.',
'repository-up': 'Öffnet im lokalen Repository-Browser das übergeordnete Verzeichnis.',
'select-repository-folder': 'Übernimmt den aktuell geöffneten Ordner als lokalen Repository-Pfad.',
'select-current-restore': 'Wählt den aktuell geöffneten Snapshot-Ordner vollständig für die Wiederherstellung aus.',
};
const esc = (value) => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const api = async (path, options = {}) => {
const response = await fetch(`${root.dataset.api}?path=${encodeURIComponent(path)}`, {
...options,
headers: {'Content-Type':'application/json', 'X-CSRF-Token':window.BACKUPPER_CSRF || window.csrf_token || '', ...(options.headers || {})}
});
const text = await response.text();
let body = null;
try { body = text ? JSON.parse(text) : null; } catch (_) { body = {error:text}; }
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
return body;
};
const notify = (message, error = false) => {
toast.textContent = message; toast.className = `show${error ? ' error' : ''}`;
clearTimeout(notify.timer); notify.timer = setTimeout(() => toast.className = '', 4500);
};
const id = (prefix) => `${prefix}-${crypto.randomUUID()}`;
const repoName = (repoId) => state.config.repositories.find(r => r.id === repoId)?.name || repoId;
const volatileLocation = (repo) => repo.type === 'local' && (/^\/tmp(?:\/|$)/.test(repo.location) || /^\/run(?:\/|$)/.test(repo.location));
const tipAttr = (text) => text ? ` data-tooltip="${esc(text)}"` : '';
const actionTooltip = (action, label) => {
const [name] = action.split(':');
if (actionHelp[name]) return actionHelp[name];
const dynamic = {
'run-job': 'Startet diesen Backup-Job sofort. Beispiel: manuelles Backup vor einem Unraid-Update.',
'edit-job': 'Öffnet die Einstellungen dieses Backup-Jobs zum Bearbeiten.',
'duplicate-job': 'Erstellt eine deaktivierte Kopie als Vorlage. Danach Name, Repository und Quellen anpassen.',
'delete-job': 'Löscht die Job-Konfiguration. Vorhandene Restic-Snapshots im Repository bleiben erhalten.',
'test-repo': 'Prüft Erreichbarkeit, Passwort und Restic-Zugriff, ohne ein Backup zu starten.',
'init-repo': 'Erstellt einmalig ein neues Restic-Repository. Nicht erneut ausführen und nicht für vorhandene Restic-Repositories verwenden.',
'repo-snapshots': 'Wechselt zu den Sicherungsständen dieses Repositorys.',
'maint': action.includes(':check:') ? 'Prüft Struktur und Integrität des Restic-Repositorys.' : 'Entfernt nach Retention nicht mehr benötigte Datenblöcke und gibt Speicher frei.',
'edit-repo': 'Öffnet Ziel-, Mount- und Zugangseinstellungen dieses Repositorys.',
'delete-repo': 'Löscht nur die URBM-Konfiguration. Die Backupdaten am Speicherziel werden nicht gelöscht.',
'browse': 'Öffnet den Dateibaum dieses Snapshots, um einzelne Dateien oder Ordner auszuwählen.',
'open-dir': 'Öffnet dieses Verzeichnis innerhalb des schreibgeschützten Snapshots.',
'open-source-dir': 'Öffnet diesen Ordner, um darin weitere Unterordner auszuwählen.',
'open-repository-dir': 'Öffnet diesen lokalen Ordner, um ihn oder einen Unterordner als Repository-Ziel auszuwählen.',
'cancel-run': 'Bricht die laufende oder wartende Aufgabe ab. Cleanup und Neustart gestoppter Workloads werden trotzdem versucht.',
};
return dynamic[name] || `${label} ausführen.`;
};
const status = (value) => `<span class="bu-status ${esc(value)}" tabindex="0"${tipAttr(`Status dieses Vorgangs. Beispiel: success = erfolgreich, warning = mit Warnung, failed = fehlgeschlagen, running = läuft.`)}>${esc(value)}</span>`;
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]}`;
};
const formatEta = value => {
let seconds=Math.max(0,Math.round(Number(value)||0));
const hours=Math.floor(seconds/3600); seconds%=3600;
const minutes=Math.floor(seconds/60); seconds%=60;
return hours?`${hours}h ${minutes}m`:minutes?`${minutes}m ${seconds}s`:`${seconds}s`;
};
const weekDays = [{value:'1',short:'Mo',name:'Montag'},{value:'2',short:'Di',name:'Dienstag'},{value:'3',short:'Mi',name:'Mittwoch'},{value:'4',short:'Do',name:'Donnerstag'},{value:'5',short:'Fr',name:'Freitag'},{value:'6',short:'Sa',name:'Samstag'},{value:'0',short:'So',name:'Sonntag'}];
function parseSchedule(cron = '0 2 * * *') {
const match = String(cron).trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(.+)$/);
if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return {mode:'custom',time:'02:00',days:[],cron};
const time = `${String(match[2]).padStart(2,'0')}:${String(match[1]).padStart(2,'0')}`;
if (match[3] === '*') return {mode:'daily',time,days:[],cron};
if (match[3] === '1-5') return {mode:'weekdays',time,days:[],cron};
const days = match[3].split(',');
if (days.length && days.every(day=>weekDays.some(item=>item.value===day))) return {mode:'selected',time,days:[...new Set(days)],cron};
return {mode:'custom',time,days:[],cron};
}
function scheduleCron(form) {
const mode = form.get('scheduleMode');
if (mode === 'custom') return form.get('customCron');
const [hour, minute] = String(form.get('scheduleTime') || '02:00').split(':');
const dayField = mode === 'daily' ? '*' : mode === 'weekdays' ? '1-5' : form.getAll('scheduleDays').join(',');
if (mode === 'selected' && !dayField) throw new Error('Select at least one weekday for the backup schedule');
return `${Number(minute)} ${Number(hour)} * * ${dayField}`;
}
function scheduleLabel(cron) {
const schedule = parseSchedule(cron);
if (schedule.mode === 'custom') return cron || 'Manual';
if (schedule.mode === 'daily') return `Täglich um ${schedule.time} Uhr`;
if (schedule.mode === 'weekdays') return `MoFr um ${schedule.time} Uhr`;
const labels = weekDays.filter(day=>schedule.days.includes(day.value)).map(day=>day.short).join(', ');
return `${labels} um ${schedule.time} Uhr`;
}
function scheduleFields(job = {}) {
const schedule = parseSchedule(job.schedule?.cron || '0 2 * * *');
const modes = [['daily','Täglich'],['weekdays','Montag bis Freitag'],['selected','Ausgewählte Wochentage']];
if (schedule.mode === 'custom') modes.push(['custom','Bestehenden Cron-Zeitplan beibehalten']);
return `<fieldset class="wide bu-fieldset bu-schedule"><legend>Backup schedule</legend><div class="bu-schedule-grid"><label>Days<select name="scheduleMode">${modes.map(([value,label])=>`<option value="${value}" ${value===schedule.mode?'selected':''}>${label}</option>`).join('')}</select></label><label>Time<input name="scheduleTime" type="time" required value="${esc(schedule.time)}"></label></div><div class="bu-weekdays" ${schedule.mode==='selected'?'':'hidden'}><span>Weekdays</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">Existing schedule<input name="customCron" readonly value="${esc(schedule.cron)}"></label>`:''}</fieldset>`;
}
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;
element.dataset.tooltip = text;
if (!['INPUT','SELECT','TEXTAREA','BUTTON'].includes(element.tagName) && !element.hasAttribute('tabindex')) element.tabIndex = 0;
}
function enhanceTooltips(container = root) {
container.querySelectorAll('.bu-form [name]').forEach(control => {
const help = fieldHelp[control.name];
if (help) {
setTooltip(control, help);
setTooltip(control.closest('label'), help);
}
});
const dashboardHelp = {
'Enabled jobs': 'Anzahl aktivierter Backup-Jobs, die nach Zeitplan laufen dürfen. Beispiel: 3 bedeutet drei aktive Sicherungspläne.',
'Repositories': 'Anzahl konfigurierter Restic-Speicherziele. Im MVP gehört jedes Repository genau zu einem Job.',
'Queued / running': 'Aufgaben in der Warteschlange oder in Ausführung. URBM führt immer nur eine Aufgabe gleichzeitig aus.',
'Recent failures': 'Fehlgeschlagene Läufe in der jüngsten Historie. Details stehen unter Activity.',
};
container.querySelectorAll('.bu-card .bu-muted').forEach(element => setTooltip(element.closest('.bu-card') || element, dashboardHelp[element.textContent.trim()]));
const headerHelp = {
Job: 'Name und interne ID des Backup-Jobs.', Type: 'Art der gesicherten Daten.', Repository: 'Verwendetes Restic-Speicherziel.', Schedule: 'Wochentage und Uhrzeit des automatischen Backups.', Enabled: 'Zeigt, ob der Job automatisch laufen darf.', Actions: 'Verfügbare Aktionen für diesen Eintrag.', Name: 'Anzeigename und interne ID.', Location: 'Pfad oder Adresse des Restic-Ziels.', Mount: 'Zeigt, ob URBM das Netzwerkziel selbst mountet.', Snapshot: 'Eindeutiger Sicherungsstand.', Time: 'Erstellungszeitpunkt des Snapshots.', Host: 'Hostname des Systems, das den Snapshot erstellt hat.', Paths: 'Im Snapshot enthaltene Quellpfade.', Task: 'Art und Ziel der Aufgabe.', Status: 'Aktueller oder endgültiger Laufstatus.', Created: 'Zeitpunkt, an dem die Aufgabe eingereiht wurde.', Message: 'Ergebnis oder Fehlerbeschreibung.', Size: 'Gespeicherte Dateigröße im Snapshot.'
};
container.querySelectorAll('.bu-table th').forEach(th => setTooltip(th, headerHelp[th.textContent.trim()] || 'Informationen zu dieser Tabellenspalte.'));
const snapshotSelect = container.querySelector('#bu-snapshot-repo');
setTooltip(snapshotSelect, 'Repository auswählen, dessen Snapshots geladen werden. Beispiel: Appdata Backup NAS.');
container.querySelectorAll('.bu-file-select').forEach(box => setTooltip(box, `Für den Restore auswählen. Beispielpfad: ${box.value}`));
const sectionHelp = {
'Backup Jobs': 'Sicherungspläne verbinden Quellen, Zeitplan und genau ein Restic-Repository.',
'Repositories': 'Speicherziele und verschlüsselte Zugangsdaten für Restic.',
'Snapshots': 'Unveränderliche Sicherungsstände, die durchsucht und wiederhergestellt werden können.',
'Restore': 'Wiederherstellung ausgewählter Dateien oder Ordner. Staging ist die sichere Standardmethode.',
'Activity': 'Globale Warteschlange und Historie aller URBM-Aufgaben.',
'Settings': 'Globale Pfade sowie Zeitpläne für Check, Prune und Nachholläufe.',
'Unraid notifications': 'Native Meldungen im Unraid-Webinterface und über alle unter Einstellungen > Benachrichtigungen aktivierten Agenten.',
'Gotify notification': 'Optionaler direkter Push-Kanal zu deinem eigenen Gotify-Server.',
'Recent activity': 'Die zuletzt gestarteten Backup-, Restore- und Wartungsvorgänge.',
};
container.querySelectorAll('h2, h3').forEach(heading => setTooltip(heading, sectionHelp[heading.textContent.trim()]));
}
let tooltipTimer = null;
let tooltipTarget = null;
function hideTooltip() {
clearTimeout(tooltipTimer); tooltipTimer = null; tooltipTarget = null;
tooltip.classList.remove('visible', 'below'); tooltip.setAttribute('aria-hidden', 'true');
}
function placeTooltip(target) {
const rect = target.getBoundingClientRect();
tooltip.classList.add('visible');
tooltip.style.left = '0px'; tooltip.style.top = '0px';
const box = tooltip.getBoundingClientRect();
const gap = 10;
let left = Math.max(10, Math.min(rect.left + rect.width / 2 - box.width / 2, window.innerWidth - box.width - 10));
let top = rect.top - box.height - gap;
const below = top < 8;
if (below) top = Math.min(window.innerHeight - box.height - 8, rect.bottom + gap);
tooltip.classList.toggle('below', below);
tooltip.style.setProperty('--bu-tooltip-arrow', `${Math.max(14, Math.min(box.width - 20, rect.left + rect.width / 2 - left - 6))}px`);
tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`;
}
function showTooltip(target, delay = 1200) {
const text = target?.dataset?.tooltip;
if (!text) return;
clearTimeout(tooltipTimer); tooltipTarget = target;
tooltipTimer = setTimeout(() => {
if (tooltipTarget !== target || !document.contains(target)) return;
tooltip.textContent = text; tooltip.setAttribute('aria-hidden', 'false'); placeTooltip(target);
}, delay);
}
async function load() {
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 || 'unknown'})`; health.className = 'bu-health ok'; render();
} catch (error) {
health.textContent = 'Daemon unavailable'; health.className = 'bu-health bad';
content.innerHTML = `<div class="bu-card"><h2>Connection failed</h2><p>${esc(error.message)}</p></div>`;
}
}
function render() {
const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, settings:renderSettings};
content.innerHTML = views[state.view]();
enhanceTooltips(content);
}
function renderDashboard() {
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>`;
}
function renderJobs() {
const rows = state.config.jobs.map(j => `<tr><td><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td>${esc(j.type)}</td><td>${esc(repoName(j.repositoryId))}</td><td>${esc(scheduleLabel(j.schedule?.cron))}</td><td>${j.enabled ? status('success') : status('cancelled')}</td><td>${actionGroup(button('Run',`run-job:${j.id}`,'primary')+button('Edit',`edit-job:${j.id}`)+button('Duplicate',`duplicate-job:${j.id}`)+button('Delete',`delete-job:${j.id}`,'danger'))}</td></tr>`).join('');
return `<div class="bu-toolbar"><div><h2>Backup Jobs</h2><div class="bu-muted">One isolated Restic repository per job.</div></div>${button('New job','new-job','primary')}</div>
<section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Job</th><th>Type</th><th>Repository</th><th>Schedule</th><th>Enabled</th><th>Actions</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">No backup jobs configured.</div>'}</section>`;
}
function jobForm(job = {}) {
state.backupSelections = (job.sources || []).filter(s=>s.path).map(s=>s.path);
state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId);
state.workloads = []; state.workloadAvailable = true; state.workloadError = '';
state.sourceBrowserPath = '/'; state.sourceDirectories = sourceRoots; state.sourceBrowserError = '';
const options = state.config.repositories.map(r => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join('');
content.innerHTML = `<section class="bu-card"><h2>${job.id ? 'Edit' : 'New'} backup job</h2><form id="bu-job-form" class="bu-form">
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
<label>Type<select name="type">${['share','appdata','docker','vm','flash'].map(v=>`<option ${v===job.type?'selected':''}>${v}</option>`).join('')}</select></label>
<label>Repository<select name="repositoryId" required>${options}</select></label><label>Compression<select name="compression">${['auto','off','max'].map(v=>`<option ${v===(job.compression||'auto')?'selected':''}>${v}</option>`).join('')}</select></label>
${scheduleFields(job)}
<div id="bu-job-specific" class="wide"></div>
<label class="wide">Exclude patterns, one per line<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
<fieldset class="wide bu-fieldset"><legend>Backup retention</legend><p class="bu-muted">Snapshots matching any enabled rule are kept. Set daily, weekly and monthly to 0 for a strict age limit.</p><div class="bu-retention-grid"><label>Keep all backups for days<input name="keepWithinDays" type="number" min="0" value="${job.retention?.keepWithinDays ?? 30}"></label><label>Additional daily backups<input name="keepDaily" type="number" min="0" value="${job.retention?.daily ?? 0}"></label><label>Additional weekly backups<input name="keepWeekly" type="number" min="0" value="${job.retention?.weekly ?? 0}"></label><label>Additional monthly backups<input name="keepMonthly" type="number" min="0" value="${job.retention?.monthly ?? 0}"></label></div></fieldset>
<label><span>Enabled</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label>
<div class="wide bu-actions">${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
enhanceTooltips(content);
renderJobSpecific(job.type || 'share', job);
}
function renderJobSpecific(type, job = {}) {
const host = document.getElementById('bu-job-specific');
if (!host) return;
if (type === 'share' || type === 'appdata') {
host.innerHTML = `<h3>${type==='appdata'?'Appdata folders':'Share folders'}</h3><div class="bu-muted">Select one or more folders. Existing file exclude patterns remain available below.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Additional manual paths<textarea name="sources"></textarea></label>`;
state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user';
state.sourceDirectories = []; state.sourceBrowserError = '';
loadSourceDirectories(state.sourceBrowserPath);
} else if (type === 'docker' || type === 'vm') {
host.innerHTML = `<h3>${type==='docker'?'Docker containers':'Virtual machines'}</h3><div class="bu-muted">Select the workloads to back up. Metadata and discovered bind mounts or virtual disks are included automatically.</div><div id="bu-workload-browser" class="bu-browser-card"><div class="bu-empty">Loading...</div></div><div class="bu-workload-options"><label>Consistency<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Controlled stop</option></select></label><label>Shutdown timeout (seconds)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label></div>`;
loadWorkloads(type);
} else {
state.backupSelections = ['/boot'];
host.innerHTML = `<div class="bu-info-card"><h3>Unraid boot backup</h3><p>The complete <strong>/boot</strong> configuration drive is backed up. No additional source selection is required.</p></div>`;
}
enhanceTooltips(host);
}
function renderWorkloadBrowser(type) {
const host = document.getElementById('bu-workload-browser');
if (!host) return;
const selected = new Set(state.workloadSelections);
const rows = state.workloads.map(item=>`<tr><td><input type="checkbox" class="bu-workload-select" value="${esc(item.id)}" ${selected.has(item.id)?'checked':''}></td><td><strong>${esc(item.name)}</strong><br><span class="bu-muted">${esc(item.id)}</span></td><td>${esc(item.status||'unknown')}</td></tr>`).join('');
const notice = state.workloadError ? `<div class="${state.workloadAvailable===false?'bu-inline-info':'bu-inline-error'}">${esc(state.workloadError)}</div>` : '';
host.innerHTML = `<div class="bu-toolbar"><h3>${type==='docker'?'Available containers':'Available virtual machines'}</h3><span class="bu-count">Selected: ${state.workloadSelections.length}</span></div>${notice}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Select</th><th>Name</th><th>Status</th></tr></thead><tbody>${rows||(!state.workloadError?'<tr><td colspan="3" class="bu-empty">No workloads found.</td></tr>':'')}</tbody></table></div>`;
}
async function loadWorkloads(type) {
try {
const result = await api(`/v1/workloads/${type}`); state.workloads = result.items || []; state.workloadAvailable = result.available; state.workloadError = result.message || '';
} catch (error) {
state.workloads = []; state.workloadAvailable = true; state.workloadError = `Workloads could not be loaded: ${error.message}`;
}
renderWorkloadBrowser(type);
}
function renderSourceBrowser() {
const host = document.getElementById('bu-source-browser');
if (!host) return;
const selected = new Set(state.backupSelections);
const rows = state.sourceDirectories.map(item => `<tr><td><input type="checkbox" class="bu-source-select" value="${esc(item.path)}" ${selected.has(item.path)?'checked':''}></td><td>dir</td><td>${esc(item.name)}</td><td>${esc(item.path)}</td><td>${button('Open',`open-source-dir:${encodeURIComponent(item.path)}`)}</td></tr>`).join('');
const chosen = state.backupSelections.length ? state.backupSelections.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">No folders selected.</span>';
const error = state.sourceBrowserError ? `<div class="bu-inline-error">${esc(state.sourceBrowserError)}</div>` : '';
host.innerHTML = `<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Backup source browser</h3><span class="bu-muted">${esc(state.sourceBrowserPath)}</span></div><div class="bu-actions">${state.sourceBrowserPath!=='/'?button('Select current folder','select-current-source'):''}${state.sourceBrowserPath!=='/'?button('Up','source-up'):''}<span class="bu-count">Selected: ${state.backupSelections.length}</span></div></div>${error}<div class="bu-selection-list">${chosen}</div><div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Select</th><th>Type</th><th>Name</th><th>Path</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="5" class="bu-empty">No subfolders found.</td></tr>'}</tbody></table></div></section>`;
enhanceTooltips(host);
}
async function loadSourceDirectories(path) {
if (path === '/') {
state.sourceDirectories = sourceRoots; state.sourceBrowserPath = '/'; state.sourceBrowserError = ''; renderSourceBrowser(); return;
}
try {
state.sourceDirectories = await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
state.sourceBrowserPath = path;
state.sourceBrowserError = '';
renderSourceBrowser();
} catch (error) {
state.sourceBrowserError = `Folder could not be loaded: ${error.message}`;
renderSourceBrowser();
}
}
function renderRepositories() {
const rows = state.config.repositories.map(r => {
const initialize = volatileLocation(r) ? disabledButton('Initialize','Dieses Ziel liegt im flüchtigen RAM. Zuerst über Edit einen dauerhaften Pfad wie /mnt/user/Backups/HomeServer eintragen.') : button('Initialize',`init-repo:${r.id}`,'primary');
const actions = initialize+button('Test',`test-repo:${r.id}`)+button('Snapshots',`repo-snapshots:${r.id}`)+button('Check',`maint:check:${r.id}`)+button('Prune',`maint:prune:${r.id}`)+button('Edit',`edit-repo:${r.id}`)+button('Delete',`delete-repo:${r.id}`,'danger');
return `<tr><td><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td>${esc(r.type)}</td><td>${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Volatile path: data is lost after reboot. Use /mnt/user/...</span>':''}</td><td>${r.mount?.managed?'Managed':'Direct / external'}</td><td>${actionGroup(actions)}</td></tr>`;
}).join('');
return `<div class="bu-toolbar"><div><h2>Repositories</h2><div class="bu-muted">Local, SMB, NFS and SFTP destinations.</div></div>${button('New repository','new-repo','primary')}</div><section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Name</th><th>Type</th><th>Location</th><th>Mount</th><th>Actions</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">No repositories configured.</div>'}</section>`;
}
function repositoryForm(repo = {}) {
state.repositoryBrowserPath = '/'; state.repositoryDirectories = repositoryRoots; state.repositoryBrowserError = '';
const passwordRef=repo.passwordRef||id('repo-password');
const credentialRef=repo.credentialRef||id('repo-credential');
content.innerHTML = `<section class="bu-card"><h2>${repo.id?'Edit':'New'} repository</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}"><input type="hidden" name="passwordRef" value="${esc(passwordRef)}"><input type="hidden" name="credentialRef" value="${esc(credentialRef)}"><input type="hidden" name="hasCredential" value="${repo.credentialRef?'true':'false'}">
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Type<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option ${v===repo.type?'selected':''}>${v}</option>`).join('')}</select></label>
<div id="bu-repository-specific" class="wide"></div>
<fieldset class="wide bu-fieldset"><legend>Restic encryption</legend><label>Repository password<input name="password" type="password" autocomplete="new-password" ${repo.id?'':'required'} placeholder="${repo.id?'Leave blank to keep existing':'Enter a strong repository password'}"></label><p class="bu-muted">Encrypted by Restic and stored securely by URBM. ${repo.id?'Leave blank to keep the existing password.':'Keep this password safe; backups cannot be restored without it.'}</p></fieldset>
<div class="wide bu-actions">${button('Save','submit-repo','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
enhanceTooltips(content);
renderRepositorySpecific(repo.type || 'local', repo);
}
function repositoryLocationField(label, value, placeholder) {
return `<label class="wide">${label}<input name="location" required placeholder="${esc(placeholder)}" value="${esc(value||'')}"></label>`;
}
function renderRepositorySpecific(type, repo = {}) {
const host = document.getElementById('bu-repository-specific');
if (!host) return;
if (type === 'local') {
host.innerHTML = `${repositoryLocationField('Local repository folder',repo.location,'/mnt/user/backups/urbm')}<div id="bu-repository-browser"></div>`;
renderRepositoryBrowser();
} else if (type === 'smb' || type === 'nfs') {
const managed = repo.mount?.managed === true;
host.innerHTML = `<fieldset class="bu-fieldset"><legend>${type.toUpperCase()} destination</legend><label class="bu-inline-check"><input name="managed" type="checkbox" ${managed?'checked':''}><span>URBM should mount this share</span></label><div class="bu-external-mount" ${managed?'hidden':''}>${repositoryLocationField('Existing mounted folder',repo.location,'/mnt/remotes/backup-nas')}</div><div class="bu-managed-mount" ${managed?'':'hidden'}><div class="bu-form-grid"><label>Remote share<input name="remote" placeholder="${type==='smb'?'//192.168.1.20/backup':'192.168.1.20:/volume1/backup'}" value="${esc(repo.mount?.remote||'')}"></label><label>Mount point<input name="mountPoint" placeholder="/mnt/remotes/urbm-backup" value="${esc(repo.mount?.mountPoint||repo.location||'')}"></label>${type==='smb'?`<label class="wide">SMB credentials<textarea name="credential" placeholder="${repo.credentialRef?'Leave blank to keep existing credentials':'username=backup&#10;password=secret'}"></textarea></label>`:''}</div></div></fieldset>`;
} else {
host.innerHTML = `<fieldset class="bu-fieldset"><legend>SFTP destination</legend><div class="bu-form-grid">${repositoryLocationField('SFTP repository location',repo.location,'backup@192.168.1.20:/srv/backups/urbm')}<label>SFTP known_hosts path<input name="knownHostsPath" value="${esc(repo.options?.knownHostsPath||'/root/.ssh/known_hosts')}"></label><label class="wide">Private SSH key (optional)<textarea name="credential" placeholder="${repo.credentialRef?'Leave blank to keep existing private key':'-----BEGIN OPENSSH PRIVATE KEY-----'}"></textarea></label></div></fieldset>`;
}
enhanceTooltips(host);
}
function renderRepositoryBrowser() {
const host = document.getElementById('bu-repository-browser');
if (!host) return;
const rows = state.repositoryDirectories.map(item=>`<tr><td>${esc(item.name)}</td><td>${esc(item.path)}</td><td>${button('Open',`open-repository-dir:${encodeURIComponent(item.path)}`)}</td></tr>`).join('');
const error = state.repositoryBrowserError ? `<div class="bu-inline-error">${esc(state.repositoryBrowserError)}</div>` : '';
host.innerHTML = `<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Local folder browser</h3><span class="bu-muted">${esc(state.repositoryBrowserPath)}</span></div><div class="bu-actions">${state.repositoryBrowserPath!=='/'?button('Use this folder','select-repository-folder','primary'):''}${state.repositoryBrowserPath!=='/'?button('Up','repository-up'):''}</div></div>${error}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Name</th><th>Path</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="3" class="bu-empty">No subfolders found.</td></tr>'}</tbody></table></div></section>`;
enhanceTooltips(host);
}
async function loadRepositoryDirectories(path) {
if (path === '/') {
state.repositoryBrowserPath='/'; state.repositoryDirectories=repositoryRoots; state.repositoryBrowserError=''; renderRepositoryBrowser(); return;
}
try {
state.repositoryDirectories=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
state.repositoryBrowserPath=path; state.repositoryBrowserError='';
} catch (error) {
state.repositoryBrowserError=`Folder could not be loaded: ${error.message}`;
}
renderRepositoryBrowser();
}
function renderSnapshots() {
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
const rows = state.snapshots.map(s=>`<tr><td>${esc(s.short_id||s.id)}</td><td>${esc(new Date(s.time).toLocaleString())}</td><td>${esc(s.hostname)}</td><td>${esc((s.paths||[]).join(', '))}</td><td>${button('Browse',`browse:${s.id}`)}</td></tr>`).join('');
return `<div class="bu-toolbar"><h2>Snapshots</h2><div class="bu-actions"><select id="bu-snapshot-repo">${options}</select>${button('Load','load-snapshots','primary')}</div></div><section class="bu-card">${rows?`<table class="bu-table"><thead><tr><th>Snapshot</th><th>Time</th><th>Host</th><th>Paths</th><th></th></tr></thead><tbody>${rows}</tbody></table>`:'<div class="bu-empty">Select a repository and load snapshots.</div>'}</section>${state.selectedSnapshot && state.files.length ? renderFileBrowser() : ''}`;
}
function renderFileBrowser() {
const selected = new Set(state.restoreIncludes);
const rows = state.files.map(item => {
const path = item.path || `${state.browserPath.replace(/\/$/,'')}/${item.name||''}`;
const open = item.type === 'dir' ? button('Open',`open-dir:${encodeURIComponent(path)}`) : '';
return `<tr><td><input type="checkbox" class="bu-file-select" value="${esc(path)}" ${selected.has(path)?'checked':''}></td><td>${esc(item.type||'')}</td><td>${esc(item.name||path)}</td><td>${Number(item.size||0).toLocaleString()}</td><td>${open}</td></tr>`;
}).join('');
const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">Nothing selected.</span>';
return `<section class="bu-card" style="margin-top:16px"><div class="bu-toolbar"><div><h3>Snapshot ${esc(state.selectedSnapshot.slice(0,12))}</h3><span class="bu-muted">${esc(state.browserPath)}</span></div><div class="bu-actions">${button('Select current folder','select-current-restore')}${state.browserPath!=='/'?button('Up','browser-up'):''}${button(`Restore selected (${state.restoreIncludes.length})`,'restore-selected','primary')}</div></div><div class="bu-selection-list">${chosen}</div><table class="bu-table"><thead><tr><th>Select</th><th>Type</th><th>Name</th><th>Size</th><th></th></tr></thead><tbody>${rows}</tbody></table></section>`;
}
function renderRestore() {
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
return `<section class="bu-card"><h2>Restore</h2><form id="bu-restore-form" class="bu-form"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Include paths, one per line<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Target<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><label>In-place restore<input name="inPlace" type="checkbox"></label><label>Confirm overwrite risk<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Queue restore','submit-restore','primary')}</div></form></section>`;
}
function renderActivity() { return `<div class="bu-toolbar"><div><h2>Activity</h2><div class="bu-muted">Running tasks update automatically every two seconds.</div></div><div class="bu-actions">${button('Refresh','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}</div></div><section class="bu-card">${runsTable([...state.runs].reverse())}</section>`; }
function progressView(run) {
if(run.status==='queued') return '<span class="bu-muted">Waiting in queue</span>';
if(run.taskType!=='backup'&&!run.progressPercent) return '<span class="bu-muted">No percentage available</span>';
const percent=Math.max(0,Math.min(100,Number(run.progressPercent)||0));
const scanning=run.status==='running'&&percent===0;
const details=[];
if(run.progressTotal) details.push(`${formatBytes(run.progressBytes)} / ${formatBytes(run.progressTotal)}`);
if(run.progressFileTotal) details.push(`${Number(run.progressFiles||0).toLocaleString()} / ${Number(run.progressFileTotal).toLocaleString()} files`);
if(run.bytesPerSecond) details.push(`${formatBytes(run.bytesPerSecond)}/s`);
if(run.secondsRemaining>0) details.push(`ETA ${formatEta(run.secondsRemaining)}`);
return `<div class="bu-progress-wrap"><div class="bu-progress-label"><strong>${scanning?'Scanning…':`${percent.toFixed(1)}%`}</strong><span>${esc(details.join(' · '))}</span></div><div class="bu-progress ${scanning?'indeterminate':''}"><span style="width:${scanning?30:percent}%"></span></div>${run.currentFile?`<div class="bu-current-file" title="${esc(run.currentFile)}">${esc(run.currentFile)}</div>`:''}</div>`;
}
function liveLogView(run) {
if(!(run.liveLog||[]).length) return '';
const open=Object.prototype.hasOwnProperty.call(state.liveLogOpen,run.id)?state.liveLogOpen[run.id]:run.status==='running';
return `<details class="bu-live-log" data-run-id="${esc(run.id)}" ${open?'open':''}><summary>${run.status==='running'?'Live log':'Run log'} (${run.liveLog.length})</summary><pre>${esc(run.liveLog.join('\n'))}</pre></details>`;
}
function runsTable(runs) {
if(!runs.length) return '<div class="bu-empty">No activity recorded.</div>';
const showLiveLog=state.config?.settings?.showLiveLog===true;
return `<table class="bu-table bu-runs-table"><thead><tr><th>Task</th><th>Status</th><th>Created</th><th>Progress</th><th>Message</th><th></th></tr></thead><tbody>${runs.map(r=>`<tr><td>${esc(r.taskType)} ${esc(r.jobId||'')}</td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${progressView(r)}</td><td>${esc(r.message||'')}</td><td>${['queued','running'].includes(r.status)?button('Cancel',`cancel-run:${r.id}`,'danger'):''}</td></tr>${showLiveLog&&(r.liveLog||[]).length?`<tr class="bu-log-row"><td colspan="6">${liveLogView(r)}</td></tr>`:''}`).join('')}</tbody></table>`;
}
function renderSettings() {
const s = state.config.settings;
const unraid = state.config.notifications.find(n=>n.type==='unraid') || {id:'unraid',enabled:true,events:['success','warning','failed']};
const gotify = state.config.notifications.find(n=>n.type==='gotify') || {id:'gotify',enabled:false,events:['success','warning','failed']};
const eventBoxes = target => [['success','Success'],['warning','Warning'],['failed','Failed']].map(([value,label])=>`<label><input name="events" type="checkbox" value="${value}" ${(target.events||[]).includes(value)?'checked':''}><span>${label}</span></label>`).join('');
return `<section class="bu-card"><h2>Settings</h2><form id="bu-settings-form" class="bu-form"><label class="wide">Restic binary<input name="resticPath" value="${esc(s.resticPath)}"></label><label class="wide">Restore staging root<input name="restoreRoot" value="${esc(s.restoreRoot)}"></label><label class="wide">Persistent log directory<input name="persistentLogDir" value="${esc(s.persistentLogDir||'')}"></label><label class="wide bu-inline-check"><input name="showLiveLog" type="checkbox" ${s.showLiveLog===true?'checked':''}><span>Live-Log im Activity-Bereich anzeigen</span></label><label>Catch-up window (hours)<input name="catchUp" type="number" value="${s.catchUpWindowHours||24}"></label><label>Monthly check cron<input name="checkCron" value="${esc(s.checkCron)}"></label><label>Prune cron<input name="pruneCron" value="${esc(s.pruneCron)}"></label><div class="wide bu-actions">${button('Save settings','submit-settings','primary')}</div></form></section>
<section class="bu-card" style="margin-top:16px"><h2>Unraid notifications</h2><p class="bu-muted">Uses Unraid's notification system and all agents configured under Settings &gt; Notification Settings.</p><form id="bu-unraid-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(unraid.id)}"><label><span>Enabled</span><input name="enabled" type="checkbox" ${unraid.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Notify on</legend><div class="bu-event-options">${eventBoxes(unraid)}</div></fieldset><div class="wide bu-actions">${button('Save Unraid notifications','submit-unraid-notify','primary')}</div></form></section>
<section class="bu-card" style="margin-top:16px"><h2>Gotify notification</h2><p class="bu-muted">Create an application in Gotify and enter its application token here.</p><form id="bu-gotify-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(gotify.id||'gotify')}"><input type="hidden" name="tokenRef" value="${esc(gotify.tokenRef||id('gotify-token'))}"><label class="wide">Server URL<input name="url" type="url" placeholder="https://gotify.example.de" value="${esc(gotify.url||'')}"></label><label>Application token<input name="token" type="password" autocomplete="new-password" placeholder="${gotify.tokenRef?'Leave blank to keep existing token':'Enter the Gotify application token'}"></label><label><span>Enabled</span><input name="enabled" type="checkbox" ${gotify.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Notify on</legend><div class="bu-event-options">${eventBoxes(gotify)}</div></fieldset><div class="wide bu-actions">${button('Save Gotify notification','submit-gotify-notify','primary')}</div></form></section>`;
}
async function saveConfig() {
state.config = await api('/v1/config',{method:'PUT',body:JSON.stringify(state.config)});
notify('Configuration saved');
return state.config;
}
const lines = (value) => value.split('\n').map(v=>v.trim()).filter(Boolean);
async function handle(action, element) {
const [name, a, b] = action.split(':');
try {
if (name === 'new-job') return jobForm();
if (name === 'edit-job') return jobForm(state.config.jobs.find(j=>j.id===a));
if (name === 'new-repo') return repositoryForm();
if (name === 'edit-repo') return repositoryForm(state.config.repositories.find(r=>r.id===a));
if (name === 'cancel-form') return render();
if (name === 'refresh') return load();
if (name === 'run-job') { await api(`/v1/jobs/${a}/run`,{method:'POST'}); notify('Backup queued'); return load(); }
if (name === 'cancel-run') { await api(`/v1/runs/${a}/cancel`,{method:'POST'}); return load(); }
if (name === 'clear-activity') { if(confirm('Alle abgeschlossenen Aktivitäten und deren gespeicherte Laufprotokolle löschen?\n\nLaufende und wartende Aufgaben bleiben erhalten.')) { 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 === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository connection succeeded'); }
if (name === 'init-repo') { if(confirm('Create a NEW Restic repository here?\n\nOnly continue if this destination does not already contain a Restic repository. For an existing repository, enter its original password under Edit and use Test.')) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository initialized successfully'); } return; }
if (name === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a} queued`); return load(); }
if (name === 'repo-snapshots') { state.view='snapshots'; document.querySelector('[data-view="snapshots"]').click(); render(); setTimeout(()=>{document.getElementById('bu-snapshot-repo').value=a; handle('load-snapshots');},0); return; }
if (name === 'load-snapshots') { const repo=document.getElementById('bu-snapshot-repo').value; state.snapshots=await api(`/v1/repositories/${repo}/snapshots`); state.snapshotRepo=repo; return render(); }
if (name === 'browse') { state.selectedSnapshot=a; state.browserPath='/'; state.restoreIncludes=[]; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${a}/files?path=${encodeURIComponent('/')}`); return render(); }
if (name === 'open-source-dir') { return loadSourceDirectories(decodeURIComponent(a)); }
if (name === 'source-up') { const parent=state.sourceBrowserPath==='/'?'/':state.sourceBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadSourceDirectories(parent==='/mnt'?'/':parent); }
if (name === 'select-current-source') { if(!state.backupSelections.includes(state.sourceBrowserPath)) state.backupSelections.push(state.sourceBrowserPath); return renderSourceBrowser(); }
if (name === 'open-repository-dir') { return loadRepositoryDirectories(decodeURIComponent(a)); }
if (name === 'repository-up') { const parent=state.repositoryBrowserPath==='/'?'/':state.repositoryBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadRepositoryDirectories(parent==='/mnt'?'/':parent); }
if (name === 'select-repository-folder') { const location=document.querySelector('#bu-repo-form [name="location"]'); if(location) location.value=state.repositoryBrowserPath; return; }
if (name === 'open-dir') { state.browserPath=decodeURIComponent(a); state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files?path=${encodeURIComponent(state.browserPath)}`); return render(); }
if (name === 'browser-up') { state.browserPath=state.browserPath.replace(/\/$/,'').split('/').slice(0,-1).join('/')||'/'; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files?path=${encodeURIComponent(state.browserPath)}`); return render(); }
if (name === 'select-current-restore') { if(!state.restoreIncludes.includes(state.browserPath)) state.restoreIncludes.push(state.browserPath); return render(); }
if (name === 'restore-selected') { if(!state.restoreIncludes.length) throw new Error('Select at least one file or directory'); state.view='restore'; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view==='restore')); return render(); }
if (name === 'delete-job') { if(confirm('Delete this job?')) { state.config.jobs=state.config.jobs.filter(j=>j.id!==a); await saveConfig(); render(); } return; }
if (name === 'duplicate-job') { const source=state.config.jobs.find(j=>j.id===a); const copy=structuredClone(source); copy.id=id('job'); copy.name += ' copy'; copy.enabled=false; state.config.jobs.push(copy); await saveConfig(); return render(); }
if (name === 'delete-repo') { if(state.config.jobs.some(j=>j.repositoryId===a)) throw new Error('Repository is still referenced by a job'); if(confirm('Delete this repository?')) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; }
if (name.startsWith('submit-')) return submit(name);
} catch (error) { notify(error.message, true); }
}
async function submit(kind) {
if (kind === 'submit-job') {
const f=new FormData(document.getElementById('bu-job-form')); const existing=state.config.jobs.find(j=>j.id===f.get('id'));
const type=f.get('type'); let sources=[];
if(type==='docker'||type==='vm') sources=state.workloadSelections.map(workloadId=>({workloadId}));
else if(type==='flash') sources=[];
else sources=[...state.backupSelections.map(path=>({path})),...lines(f.get('sources')||'').map(path=>({path}))].filter((item,index,all)=>all.findIndex(other=>other.path===item.path)===index);
const retention={keepWithinDays:Number(f.get('keepWithinDays')),daily:Number(f.get('keepDaily')),weekly:Number(f.get('keepWeekly')),monthly:Number(f.get('keepMonthly'))};
if(!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Retention must keep at least one age or calendar policy');
const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)};
state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
}
if (kind === 'submit-repo') {
const f=new FormData(document.getElementById('bu-repo-form')); const value=name=>String(f.get(name)||'').trim(); const type=value('type'); const managed=(type==='smb'||type==='nfs')&&f.get('managed')==='on';
let location=managed?value('mountPoint'):value('location');
if(!location) throw new Error(managed?'Mount point is required':'Repository location is required');
if(managed&&!value('remote')) throw new Error('Remote share is required for a managed mount');
const usesCredential=type==='sftp'||(type==='smb'&&managed); const credentialRef=usesCredential&&(value('hasCredential')==='true'||Boolean(f.get('credential')))?value('credentialRef'):'';
const repo={schemaVersion:1,id:value('id')||id('repo'),name:value('name'),type,location,passwordRef:value('passwordRef'),credentialRef,mount:managed?{managed:true,remote:value('remote'),mountPoint:value('mountPoint')}:null,options:type==='sftp'?{knownHostsPath:value('knownHostsPath')}: {}};
if(!value('id')&&!f.get('password')) throw new Error('A repository password is required for a new repository');
if (volatileLocation(repo) && !confirm('WARNING: This destination is under /tmp or /run and will be lost after an Unraid reboot. Save it anyway?')) return;
if(f.get('password')) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:f.get('password')})});
if(f.get('credential')) await api(`/v1/secrets/${encodeURIComponent(repo.credentialRef)}`,{method:'PUT',body:JSON.stringify({type:type==='sftp'?'sftp-private-key':'smb-credential',value:f.get('credential')})});
state.config.repositories=state.config.repositories.filter(r=>r.id!==repo.id).concat(repo);
await saveConfig();
state.config = await api('/v1/config');
const saved = state.config.repositories.find(r=>r.id===repo.id);
if (!saved || saved.location !== repo.location) throw new Error(`Repository location was not saved. Expected: ${repo.location}`);
render(); notify(`Repository saved: ${saved.location}`);
}
if (kind === 'submit-restore') { const f=new FormData(document.getElementById('bu-restore-form')); await api('/v1/restores',{method:'POST',body:JSON.stringify({schemaVersion:1,id:'',repositoryId:f.get('repositoryId'),snapshotId:f.get('snapshotId'),includes:lines(f.get('includes')),target:f.get('target'),inPlace:f.get('inPlace')==='on',confirmed:f.get('confirmed')==='on'})}); notify('Restore queued'); state.view='activity'; render(); }
if (kind === 'submit-settings') { const f=new FormData(document.getElementById('bu-settings-form')); state.config.settings={resticPath:f.get('resticPath'),restoreRoot:f.get('restoreRoot'),persistentLogDir:f.get('persistentLogDir'),showLiveLog:f.get('showLiveLog')==='on',catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); render(); }
if (kind === 'submit-unraid-notify') { const f=new FormData(document.getElementById('bu-unraid-notify-form')); const target={schemaVersion:1,id:f.get('id')||'unraid',name:'Unraid',type:'unraid',enabled:f.get('enabled')==='on',events:f.getAll('events')}; if(target.enabled&&!target.events.length) throw new Error('Select at least one event for Unraid notifications'); state.config.notifications=state.config.notifications.filter(n=>n.type!=='unraid'&&n.type!=='ntfy').concat(target); await saveConfig(); render(); }
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('Gotify server URL is required'); if(target.enabled&&!target.events.length) throw new Error('Select at least one event for Gotify notifications'); if(target.enabled&&!existing&&!f.get('token')) throw new Error('A Gotify application token is required'); 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(); });
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')) {
renderRepositorySpecific(e.target.value);
}
if (e.target.name === 'managed' && e.target.closest('#bu-repo-form')) {
const fieldset=e.target.closest('.bu-fieldset');
const managed=fieldset?.querySelector('.bu-managed-mount'); const external=fieldset?.querySelector('.bu-external-mount');
if(managed) managed.hidden=!e.target.checked;
if(external) external.hidden=e.target.checked;
}
if (e.target.name === 'scheduleMode') {
const weekdays=e.target.closest('.bu-schedule')?.querySelector('.bu-weekdays');
if (weekdays) weekdays.hidden=e.target.value!=='selected';
}
if (e.target.name === 'type' && e.target.closest('#bu-job-form')) {
state.backupSelections=[]; state.workloadSelections=[];
renderJobSpecific(e.target.value);
}
if (e.target.classList.contains('bu-source-select')) {
state.backupSelections = e.target.checked ? [...new Set([...state.backupSelections,e.target.value])] : state.backupSelections.filter(path=>path!==e.target.value);
renderSourceBrowser();
}
if (e.target.classList.contains('bu-file-select')) {
state.restoreIncludes = e.target.checked ? [...new Set([...state.restoreIncludes,e.target.value])] : state.restoreIncludes.filter(path=>path!==e.target.value);
render();
}
if (e.target.classList.contains('bu-workload-select')) {
state.workloadSelections = e.target.checked ? [...new Set([...state.workloadSelections,e.target.value])] : state.workloadSelections.filter(id=>id!==e.target.value);
renderWorkloadBrowser(document.querySelector('#bu-job-form [name="type"]')?.value||'docker');
}
});
content.addEventListener('submit', e => {
const form = e.target.closest('form');
if (!form) return;
e.preventDefault();
const action = form.id === 'bu-repo-form' ? 'submit-repo' : form.id === 'bu-job-form' ? 'submit-job' : form.id === 'bu-restore-form' ? 'submit-restore' : form.id === 'bu-settings-form' ? 'submit-settings' : form.id === 'bu-unraid-notify-form' ? 'submit-unraid-notify' : form.id === 'bu-gotify-notify-form' ? 'submit-gotify-notify' : '';
if (action) handle(action);
});
content.addEventListener('toggle', e => {
const details=e.target.closest?.('.bu-live-log[data-run-id]');
if(details) state.liveLogOpen[details.dataset.runId]=details.open;
}, true);
root.addEventListener('mouseover', e => {
const target = e.target.closest('[data-tooltip]');
if (!target || target.contains(e.relatedTarget)) return;
showTooltip(target, 1200);
});
root.addEventListener('mouseout', e => {
const target = e.target.closest('[data-tooltip]');
if (!target || target.contains(e.relatedTarget)) return;
hideTooltip();
});
root.addEventListener('focusin', e => {
const target = e.target.closest('[data-tooltip]');
if (target) showTooltip(target, 0);
});
root.addEventListener('focusout', hideTooltip);
root.addEventListener('keydown', e => { if (e.key === 'Escape') hideTooltip(); });
window.addEventListener('scroll', hideTooltip, true);
window.addEventListener('resize', hideTooltip);
enhanceTooltips(root);
setInterval(async()=>{ if(!state.config)return; try { state.runs=await api('/v1/runs'); if(['dashboard','activity'].includes(state.view))render(); }catch(_){ } },2000);
load();
})();