Files
URBM/webgui/assets/urbm.js
T

1111 lines
115 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('urbm-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 menuPortal = document.getElementById('bu-menu-portal');
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 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. Mehrere Backup-Jobs können dasselbe Restic-Repository verwenden; Aufbewahrungsregeln bleiben über Job-Tags getrennt.',
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.',
cpuCores: 'Begrenzt diesen Backup-Lauf über GOMAXPROCS auf die angegebene Anzahl CPU-Kerne. 0 verwendet wie bisher alle verfügbaren Kerne.',
readConcurrency: 'Anzahl der Dateien, die Restic gleichzeitig liest. Höhere Werte können SSD- und NVMe-Quellen mit vielen kleinen Dateien beschleunigen, bei HDDs aber langsamer sein. 0 verwendet den Restic-Standard.',
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.',
passwordConfirm: 'Wiederhole das Repository-Passwort exakt. URBM speichert das Passwort nur, wenn beide Eingaben übereinstimmen.',
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 Durchsuchen 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. Beispiele: /mnt/user/Transfer oder /mnt/user/urbm-restores/restore-123. Ohne Originalort-Option sind sichere Ziele unter /mnt/user, /mnt/disks, /mnt/remotes oder dem Restore-Staging-Verzeichnis erlaubt.',
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/urbm/restic. Normalerweise nicht ändern.',
restoreRoot: 'Basisverzeichnis für sichere Staging-Restores. Beispiel: /mnt/user/urbm-restores.',
persistentLogDir: 'Optionales Verzeichnis für dauerhafte Detailprotokolle. Beispiel: /mnt/user/system/urbm-logs. Leer lassen, um Flash-Schreibzugriffe zu vermeiden.',
showLiveLog: 'Zeigt bereinigte Restic-Laufmeldungen unter den Aufgaben im Aktivitäten-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.',
'existing-repo': 'Bindet ein bereits initialisiertes Restic-Repository mit seinem ursprünglichen Passwort ein. URBM initialisiert oder überschreibt es nicht.',
'submit-repo': 'Speichert Ziel und Zugangsdaten. Das Repository wird dadurch noch nicht automatisch initialisiert.',
'submit-repo-password': 'Erstellt und prüft einen neuen Restic-Zugangs-Key, entfernt danach den bisherigen Key und aktualisiert das geschützte URBM-Secret.',
'adopt-repo-password': 'Prüft ein bereits gültiges Repository-Passwort und speichert es nur auf diesem URBM-Server, ohne Restic-Keys zu verändern.',
'init-repo': 'Initialisiert am Ziel einmalig ein neues, leeres Restic-Repository. Bei einem vorhandenen Repository nur das ursprüngliche Passwort eintragen und Test verwenden.',
'unlock-repo': 'Entfernt ausschließlich von Restic als veraltet erkannte Repository-Sperren. Aktive Sperren werden nicht gelöscht.',
'force-unlock-repo': 'Notfallaktion: entfernt alle Restic-Sperren dieses Repositorys. Nur verwenden, wenn sicher kein Backup, Restore, Check oder Prune auf dieses Repository zugreift.',
'load-snapshots': 'Lädt alle Sicherungsstände des ausgewählten Repositorys.',
'restore-selected': 'Übernimmt die markierten Dateien oder Ordner in den Wiederherstellungsassistenten.',
'submit-restore': 'Stellt die Wiederherstellung in die globale Warteschlange; Backups und Wiederherstellungen 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.',
'refresh-logs': 'Lädt Daemon-Protokoll und gespeicherte Laufprotokolle erneut.',
'clear-activity': 'Löscht alle abgeschlossenen Aktivitätseinträge und deren gespeicherte Laufprotokolle. Laufende und wartende Aufgaben bleiben erhalten.',
'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.',
'toggle-source-tree': 'Klappt die direkten Unterordner dieses Ordners auf oder zu. Inhalte werden erst beim ersten Öffnen geladen.',
'retry-source-tree': 'Versucht erneut, die Unterordner dieses Ordners zu laden.',
'toggle-dir-tree': 'Klappt Unterordner in dieser Verzeichnisauswahl auf oder zu.',
'select-dir-tree': 'Übernimmt diesen Ordner in das zugehörige Pfadfeld.',
'toggle-snapshot-tree': 'Klappt diesen Ordner innerhalb des vorgeladenen Snapshots auf oder zu.',
};
const esc = (value) => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
function translateUserMessage(value) {
const text=String(value||'');
const exact={
'run not found':'Vorgang nicht gefunden',
'queue is stopping':'Die Warteschlange wird beendet',
'task already queued or running':'Die Aufgabe wartet bereits oder wird ausgeführt',
'restic process is not active yet':'Der Restic-Prozess ist noch nicht aktiv',
'run is not currently running':'Der Vorgang wird derzeit nicht ausgeführt',
'run is not currently paused':'Der Vorgang ist derzeit nicht pausiert'
};
if(exact[text]) return exact[text];
return text
.replace(/^validation:\s*/,'Ungültige Eingabe: ')
.replace(/^authentication:\s*/,'Authentifizierung: ')
.replace(/^connectivity:\s*/,'Verbindung: ')
.replace(/^environment:\s*/,'Systemumgebung: ')
.replace(/^repository:\s*/,'Repository: ')
.replace(/^source:\s*/,'Quelle: ')
.replace(/^cancelled:\s*/,'Abgebrochen: ')
.replace('unknown repository','unbekanntes Repository')
.replace('unknown job','unbekannter Backup-Job')
.replace('repository no longer exists','Repository ist nicht mehr vorhanden')
.replace('job no longer exists','Backup-Job ist nicht mehr vorhanden');
}
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.URBM_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(translateUserMessage(body?.error || `Anfrage fehlgeschlagen (${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);
};
function askConfirm({title='Aktion bestätigen', message='', accept='Bestätigen', danger=true, phrase=''}) {
if (!dialog) return Promise.resolve(window.confirm(`${title}\n\n${message}`));
const titleNode=dialog.querySelector('#bu-dialog-title');
const messageNode=dialog.querySelector('#bu-dialog-message');
const acceptButton=dialog.querySelector('#bu-dialog-accept');
const dialogIcon=dialog.querySelector('.bu-dialog-icon');
const cancelButton=dialog.querySelector('#bu-dialog-cancel');
const confirmWrap=dialog.querySelector('#bu-dialog-confirm-wrap');
const confirmLabel=dialog.querySelector('#bu-dialog-confirm-label');
const confirmInput=dialog.querySelector('#bu-dialog-confirm-input');
titleNode.textContent=title;
messageNode.textContent=message;
acceptButton.textContent=accept;
acceptButton.classList.toggle('danger',danger);
acceptButton.classList.toggle('primary',!danger);
dialogIcon.textContent=danger?'!':'✓';
dialogIcon.classList.toggle('safe',!danger);
confirmWrap.hidden=!phrase;
confirmLabel.textContent=phrase?`Zur Bestätigung „${phrase}“ eingeben.`:'';
confirmInput.value='';
acceptButton.disabled=Boolean(phrase);
dialog.hidden=false;
document.body.classList.add('bu-dialog-open');
requestAnimationFrame(()=>(phrase?confirmInput:cancelButton).focus());
return new Promise(resolve=>{
const finish=value=>{
dialog.hidden=true;
document.body.classList.remove('bu-dialog-open');
dialog.removeEventListener('click',onClick);
dialog.removeEventListener('keydown',onKey);
confirmInput.removeEventListener('input',onInput);
resolve(value);
};
const onInput=()=>{ acceptButton.disabled=Boolean(phrase)&&confirmInput.value.trim()!==phrase; };
const onClick=event=>{
if(event.target.closest('#bu-dialog-accept')) finish(true);
else if(event.target.closest('#bu-dialog-cancel')||event.target.matches('[data-dialog-cancel]')) finish(false);
};
const onKey=event=>{
if(event.key==='Escape') finish(false);
if(event.key==='Enter'&&!acceptButton.disabled&&event.target!==cancelButton) finish(true);
};
dialog.addEventListener('click',onClick);
dialog.addEventListener('keydown',onKey);
confirmInput.addEventListener('input',onInput);
});
}
const uuid = () => {
if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID();
const bytes = new Uint8Array(16);
if (window.crypto && typeof window.crypto.getRandomValues === 'function') {
window.crypto.getRandomValues(bytes);
} else {
for (let i=0; i<bytes.length; i++) bytes[i]=Math.floor(Math.random()*256);
const time=Date.now();
for (let i=0; i<6; i++) bytes[i]^=(time/(2**(i*8)))&255;
}
bytes[6]=(bytes[6]&15)|64; bytes[8]=(bytes[8]&63)|128;
const hex=Array.from(bytes, value=>value.toString(16).padStart(2,'0')).join('');
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
};
const id = (prefix) => `${prefix}-${uuid()}`;
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.',
'unlock-repo': 'Entfernt verwaiste Restic-Locks, etwa nach einem Neustart oder abgebrochenen Prozess. Aktive Locks bleiben erhalten.',
'force-unlock-repo': 'Notfallaktion gegen hängende Locks. Entfernt alle Restic-Locks und darf nur genutzt werden, wenn wirklich kein Vorgang mehr läuft.',
'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.',
'change-repo-password': 'Ändert den Restic-Key im Repository und aktualisiert danach das geschützte URBM-Secret. Vorhandene Snapshots bleiben unverändert.',
'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.',
'toggle-dir-tree': 'Klappt diesen Ordner auf oder zu. Unterordner werden bei Bedarf geladen.',
'select-dir-tree': 'Übernimmt diesen Ordner in das passende Eingabefeld.',
'toggle-snapshot-tree': 'Klappt diesen Ordner im lokal vorgeladenen Snapshot auf oder zu.',
'cancel-run': 'Bricht die laufende oder wartende Aufgabe ab. Bereinigung und Neustart gestoppter Systeme werden trotzdem versucht.',
'pause-run': 'Hält den aktiven Restic-Prozess und seine Unterprozesse an. Mounts sowie gestoppte Container oder VMs bleiben bis zum Fortsetzen oder Abbrechen bestehen.',
'resume-run': 'Setzt einen zuvor pausierten Restic-Vorgang an derselben Stelle fort.',
};
return dynamic[name] || `${label} ausführen.`;
};
const statusLabels = {success:'Erfolgreich',warning:'Warnung',failed:'Fehlgeschlagen',running:'Läuft',paused:'Pausiert',queued:'Wartet',cancelled:'Abgebrochen'};
const taskLabels = {backup:'Backup',rsync:'Rsync-Kopie',restore:'Wiederherstellung',check:'Prüfung',prune:'Bereinigung'};
const typeLabels = {share:'Freigaben',appdata:'Appdata',docker:'Docker',vm:'Virtuelle Maschinen',flash:'USB-Flash',rsync:'Rsync-Kopie',local:'Lokal',smb:'SMB',nfs:'NFS',sftp:'SFTP'};
const messageLabels = {'backup completed':'Backup abgeschlossen','rsync completed':'Rsync-Kopie abgeschlossen','restore completed':'Wiederherstellung abgeschlossen','check completed':'Prüfung abgeschlossen','prune completed':'Bereinigung abgeschlossen','daemon restarted while task was active':'Daemon wurde während eines aktiven Vorgangs neu gestartet'};
const status = (value) => `<span class="bu-status ${esc(value)}" tabindex="0"${tipAttr('Aktueller oder endgültiger Status dieses Vorgangs.')}>${esc(statusLabels[value]||value)}</span>`;
const displayMessage = value => {
const text=String(value||'');
if(text.includes('backup succeeded but retention failed')&&text.includes('repository is already locked')) return 'Backup erfolgreich. Die Aufbewahrung konnte wegen einer Repository-Sperre nicht ausgeführt werden. Unter Repositorys „Entsperren“ und danach „Bereinigen“ wählen.';
if(text.includes('repository is already locked')) return 'Repository ist gesperrt. Falls kein anderer Restic-Vorgang läuft, unter Repositorys „Entsperren“ wählen.';
return messageLabels[text] || translateUserMessage(text);
};
const runTarget = run => {
if(run.taskType==='backup'||run.taskType==='rsync') return state.config.jobs.find(job=>job.id===run.jobId)?.name || 'Unbekannter Job';
if(['check','prune'].includes(run.taskType)) return state.config.repositories.find(repo=>repo.id===run.jobId)?.name || 'Unbekanntes Repository';
if(run.taskType==='restore') {
if(run.restore) {
const repo=state.config.repositories.find(candidate=>candidate.id===run.restore.repositoryId)?.name || 'Repository';
return `${repo} · ${String(run.restore.snapshotId||'').slice(0,12)}${run.restore.target||''}`;
}
const parts=String(run.jobId||'').split('\u0000');
if(parts.length>=4) {
const repo=state.config.repositories.find(candidate=>candidate.id===parts[0])?.name || 'Repository';
return `${repo} · ${parts[1].slice(0,12)}${parts[2]}`;
}
}
return run.jobId || '';
};
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 actionMenu = (label, buttons) => `<details class="bu-action-menu"><summary class="bu-button" aria-label="${esc(label)}">Mehr <span aria-hidden="true">⋮</span></summary><div class="bu-action-menu-popover">${buttons}</div></details>`;
const filterBox = (placeholder, target) => `<label class="bu-filter"><span aria-hidden="true">⌕</span><input type="search" placeholder="${esc(placeholder)}" aria-label="${esc(placeholder)}" data-filter-table="${esc(target)}"></label>`;
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 relativeTime = value => {
const time=new Date(value||0).getTime();
if(!time) return 'noch nie';
const seconds=Math.round((Date.now()-time)/1000); const future=seconds<0; const absolute=Math.abs(seconds);
const [amount,unit]=absolute<60?[absolute,'Sek.']:absolute<3600?[Math.round(absolute/60),'Min.']:absolute<86400?[Math.round(absolute/3600),'Std.']:[Math.round(absolute/86400),'Tagen'];
return future?`in ${amount} ${unit}`:`vor ${amount} ${unit}`;
};
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 value=String(cron).trim();
if(!value) return {mode:'manual',time:'02:00',days:[],cron:''};
const match = value.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 === 'manual') return '';
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('Wähle mindestens einen Wochentag für den Backup-Zeitplan aus.');
return `${Number(minute)} ${Number(hour)} * * ${dayField}`;
}
function scheduleLabel(cron) {
const schedule = parseSchedule(cron);
if (schedule.mode === 'manual') return 'Nur manuell';
if (schedule.mode === 'custom') return cron || 'Manuell';
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 cron=job.schedule&&Object.prototype.hasOwnProperty.call(job.schedule,'cron')?job.schedule.cron:'0 2 * * *';
const schedule = parseSchedule(cron);
const modes = [['manual','Nur manuell starten'],['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-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, valueLabel, emptyText, days=14) {
const end=Date.now();
const start=end-days*24*60*60*1000;
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);
previousByJob.set(point.run.jobId,point);
return {...point,delta:previous?point.value-previous.value:null};
});
const points=allPoints.filter(point=>point.time>=start&&point.time<=end);
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=>({ ...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 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>`;
}
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 = {
'Aktive Jobs': 'Anzahl aktivierter Backup-Jobs, die nach Zeitplan laufen dürfen. Beispiel: 3 bedeutet drei aktive Sicherungspläne.',
'Repositorys': 'Anzahl konfigurierter Restic-Speicherziele. Mehrere Jobs dürfen dasselbe Repository verwenden.',
'Wartend / laufend': 'Aufgaben in der Warteschlange oder in Ausführung. URBM führt immer nur eine Aufgabe gleichzeitig aus.',
'Letzte Fehler': 'Fehlgeschlagene Läufe in der jüngsten Historie. Details stehen unter Aktivitäten.',
};
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.', Typ: 'Art der gesicherten Daten.', Repository: 'Verwendetes Restic-Speicherziel.', Zeitplan: 'Wochentage und Uhrzeit des automatischen Backups.', Aktiv: 'Zeigt, ob der Job automatisch laufen darf.', Aktionen: 'Verfügbare Aktionen für diesen Eintrag.', Name: 'Anzeigename und interne ID.', Speicherort: 'Pfad oder Adresse des Restic-Ziels.', Einbindung: 'Zeigt, ob URBM das Netzwerkziel selbst einbindet.', Snapshot: 'Eindeutiger Sicherungsstand.', Zeitpunkt: 'Erstellungszeitpunkt des Snapshots.', Host: 'Hostname des Systems, das den Snapshot erstellt hat.', Pfade: 'Im Snapshot enthaltene Quellpfade.', Aufgabe: 'Art und Ziel der Aufgabe.', Status: 'Aktueller oder endgültiger Laufstatus.', Erstellt: 'Zeitpunkt, an dem die Aufgabe eingereiht wurde.', Meldung: 'Ergebnis oder Fehlerbeschreibung.', Größe: '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 die Wiederherstellung auswählen. Beispielpfad: ${box.value}`));
const sectionHelp = {
'Backup-Jobs': 'Sicherungspläne verbinden Quellen, Zeitplan und genau ein Restic-Repository.',
'Repositorys': 'Speicherziele und verschlüsselte Zugangsdaten für Restic.',
'Snapshots': 'Unveränderliche Sicherungsstände, die durchsucht und wiederhergestellt werden können.',
'Wiederherstellen': 'Wiederherstellung ausgewählter Dateien oder Ordner. Staging ist die sichere Standardmethode.',
'Aktivitäten': 'Globale Warteschlange und Historie aller URBM-Aufgaben.',
'Einstellungen': 'Globale Pfade sowie Zeitpläne für Prüfung, Bereinigung und Nachholläufe.',
'Unraid-Benachrichtigungen': 'Native Meldungen im Unraid-Webinterface und über alle unter Einstellungen > Benachrichtigungen aktivierten Agenten.',
'Gotify-Benachrichtigung': 'Optionaler direkter Push-Kanal zu deinem eigenen Gotify-Server.',
'Letzte Aktivitäten': 'Die zuletzt gestarteten Backup-, Wiederherstellungs- 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, 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();
} 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>`;
}
}
function render() {
const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, logs:renderLogs, settings:renderSettings};
menuPortal?.replaceChildren();
content.innerHTML = views[state.view]();
enhanceTooltips(content);
content.querySelectorAll('.bu-file-select[data-partial="true"]').forEach(box=>{box.indeterminate=true;box.setAttribute('aria-checked','mixed');});
if(state.view==='restore') {
if(!state.directoryTrees.restoreTarget) initDirectoryTree('restoreTarget','Restore-Ziel auswählen','#bu-restore-form [name="target"]');
else renderDirectoryTree('restoreTarget');
}
if(state.view==='settings') {
if(!state.directoryTrees.restoreRoot) initDirectoryTree('restoreRoot','Staging-Verzeichnis auswählen','#bu-settings-form [name="restoreRoot"]');
else renderDirectoryTree('restoreRoot');
if(!state.directoryTrees.persistentLogDir) initDirectoryTree('persistentLogDir','Protokollverzeichnis auswählen','#bu-settings-form [name="persistentLogDir"]');
else renderDirectoryTree('persistentLogDir');
}
if(state.view==='logs'&&!state.logs&&!state.logsLoading&&!state.logsError) loadLogs();
}
function captureScrollState(root=document) {
const trees=[...root.querySelectorAll('.bu-tree')].map((tree,index)=>({index,top:tree.scrollTop,left:tree.scrollLeft}));
return {windowX:window.scrollX,windowY:window.scrollY,trees};
}
function restoreScrollState(snapshot, root=document) {
if(!snapshot) return;
const apply=()=>{
const trees=[...root.querySelectorAll('.bu-tree')];
snapshot.trees.forEach(item=>{
const tree=trees[item.index];
if(tree) {
tree.scrollTop=item.top;
tree.scrollLeft=item.left;
}
});
window.scrollTo(snapshot.windowX, snapshot.windowY);
};
apply();
requestAnimationFrame(()=>{ apply(); setTimeout(apply, 0); });
}
function renderDashboard() {
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 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));
if(!state.dashboardJobId) state.dashboardJobId=orderedMetrics.find(metric=>backupJobs.some(job=>job.id===metric.jobId))?.jobId||backupJobs[0]?.id||'all';
if(state.dashboardJobId!=='all'&&!backupJobs.some(job=>job.id===state.dashboardJobId)) state.dashboardJobId='all';
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>
<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>
</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>`;
}
function renderJobs() {
const rows = state.config.jobs.map(j => { const destination=j.type==='rsync'?(j.rsync?.target||'Kein Ziel'):repoName(j.repositoryId); const secondary=button('Bearbeiten',`edit-job:${j.id}`)+button('Duplizieren',`duplicate-job:${j.id}`)+button('Löschen',`delete-job:${j.id}`,'danger'); return `<tr data-search="${esc(`${j.name} ${j.id} ${typeLabels[j.type]||j.type} ${destination}`.toLowerCase())}"><td data-label="Job"><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td data-label="Typ">${esc(typeLabels[j.type]||j.type)}</td><td data-label="Repository / Ziel">${esc(destination)}</td><td data-label="Zeitplan">${esc(scheduleLabel(j.schedule?.cron))}</td><td data-label="Aktiv">${j.enabled ? status('success') : status('cancelled')}</td><td data-label="Aktionen"><div class="bu-row-actions">${button('Starten',`run-job:${j.id}`,'primary')}${actionMenu(`Weitere Aktionen für ${j.name}`,secondary)}</div></td></tr>`; }).join('');
return `<div class="bu-toolbar"><div><h2>Backup-Jobs</h2><div class="bu-muted">Restic-Backups und direkte Rsync-Kopien werden gemeinsam geplant und überwacht.</div></div><div class="bu-actions">${filterBox('Jobs durchsuchen','jobs')}${button('Neuer Job','new-job','primary')}</div></div>
<section class="bu-card">${rows ? `<table id="bu-table-jobs" class="bu-table bu-responsive-table"><thead><tr><th>Job</th><th>Typ</th><th>Repository / Ziel</th><th>Zeitplan</th><th>Aktiv</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Backup-Jobs konfiguriert.</div>'}</section>`;
}
function jobForm(job = {}) {
state.backupSelections = normalizeSourceSelections((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 = '';
resetSourceTree(sourceRoots);
delete state.directoryTrees.rsyncTarget;
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 bu-form-card"><div class="bu-form-heading"><span class="bu-eyebrow">Backup-Plan</span><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><p class="bu-muted">Quelle, Ziel und Zeitplan in einem Sicherungsplan zusammenfassen.</p></div><form id="bu-job-form" class="bu-form">
<input type="hidden" name="id" value="${esc(job.id || '')}"><fieldset class="wide bu-fieldset"><legend>Allgemein</legend><div class="bu-form-grid"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
<label>Typ<select name="type">${['share','appdata','docker','vm','flash','rsync'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label><label class="wide bu-inline-check"><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}><span>Automatische Ausführung aktiviert</span></label></div></fieldset>
<fieldset id="bu-restic-job-settings" class="wide bu-fieldset"><legend>Restic-Einstellungen</legend><div class="bu-form-grid"><label>Repository<select name="repositoryId">${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label><label>CPU-Kerne für dieses Backup<input name="cpuCores" type="number" min="0" max="256" value="${Number(job.cpuCores||0)}"><span class="bu-muted">0 = alle verfügbaren Kerne</span></label><label>Gleichzeitig gelesene Dateien<input name="readConcurrency" type="number" min="0" max="64" value="${Number(job.readConcurrency||0)}"><span class="bu-muted">0 = Restic-Standard</span></label></div></fieldset>
${scheduleFields(job)}
<div id="bu-job-specific" class="wide"></div>
<details class="wide bu-form-section"><summary>Erweiterte Dateifilter</summary><label>Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label><p class="bu-muted">Optional. Beispielsweise Cache-Verzeichnisse, temporäre Dateien oder große, reproduzierbare Inhalte ausschließen.</p></details>
<fieldset id="bu-retention-settings" class="wide bu-fieldset"><legend>Backup-Aufbewahrung</legend><p class="bu-muted">Snapshots bleiben erhalten, wenn mindestens eine aktivierte Regel zutrifft. Täglich, wöchentlich und monatlich auf 0 setzen, um ausschließlich nach Alter aufzubewahren.</p><div class="bu-retention-grid"><label>Alle Backups behalten für Tage<input name="keepWithinDays" type="number" min="0" value="${job.retention?.keepWithinDays ?? 30}"></label><label>Zusätzliche tägliche Backups<input name="keepDaily" type="number" min="0" value="${job.retention?.daily ?? 0}"></label><label>Zusätzliche wöchentliche Backups<input name="keepWeekly" type="number" min="0" value="${job.retention?.weekly ?? 0}"></label><label>Zusätzliche monatliche Backups<input name="keepMonthly" type="number" min="0" value="${job.retention?.monthly ?? 0}"></label></div></fieldset>
<div class="wide bu-actions bu-form-actions">${button('Abbrechen','cancel-form')}${button('Job speichern','submit-job','primary')}</div></form></section>`;
enhanceTooltips(content);
renderJobSpecific(job.type || 'share', job);
}
function renderJobSpecific(type, job = {}) {
const host = document.getElementById('bu-job-specific');
if (!host) return;
const isRsync=type==='rsync';
document.getElementById('bu-restic-job-settings').hidden=isRsync;
document.getElementById('bu-retention-settings').hidden=isRsync;
if (type === 'share' || type === 'appdata') {
host.innerHTML = `<h3>${type==='appdata'?'Appdata-Ordner':'Freigabeordner'}</h3><div class="bu-muted">Wähle einen oder mehrere Ordner aus. Zusätzliche Dateifilter können darunter eingetragen werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Pfade<textarea name="sources"></textarea></label>`;
initSourceTree([type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user'], true);
} else if (type === 'docker' || type === 'vm') {
host.innerHTML = `<h3>${type==='docker'?'Docker-Container':'Virtuelle Maschinen'}</h3><div class="bu-muted">Wähle die zu sichernden Systeme aus. Metadaten und erkannte Bind-Mounts oder virtuelle Datenträger werden automatisch einbezogen.</div><div id="bu-workload-browser" class="bu-browser-card"><div class="bu-empty">Wird geladen...</div></div><div class="bu-workload-options"><label>Konsistenz<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Kontrolliert stoppen</option></select></label><label>Stopp-Zeitlimit (Sekunden)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label></div>`;
loadWorkloads(type);
} else if (type === 'rsync') {
const o=job.rsync||{};
host.innerHTML = `<h3>Direkte Rsync-Kopie</h3><div class="bu-inline-info">Rsync erstellt keine verschlüsselten Snapshots. Die ausgewählten Ordner werden direkt in das Ziel kopiert. Die Option „Am Ziel löschen“ spiegelt Löschungen und sollte bewusst aktiviert werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Quellpfade<textarea name="sources"></textarea></label><h3>Zielordner</h3><label class="wide">Rsync-Ziel<input name="rsyncTarget" required value="${esc(o.target||'')}" placeholder="/mnt/remotes/backup"></label><div id="bu-dir-tree-rsyncTarget"></div><fieldset class="wide bu-fieldset"><legend>Rsync-Optionen</legend><div class="bu-event-options"><label><input name="rsyncOverwrite" type="checkbox" ${o.overwrite!==false?'checked':''}><span>Vorhandene Dateien aktualisieren / überschreiben</span></label><label><input name="rsyncDelete" type="checkbox" ${o.delete===true?'checked':''}><span>Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind</span></label><label><input name="rsyncPermissions" type="checkbox" ${o.preservePermissions!==false?'checked':''}><span>Dateiberechtigungen übernehmen</span></label><label><input name="rsyncOwner" type="checkbox" ${o.preserveOwner===true?'checked':''}><span>Besitzer übernehmen</span></label><label><input name="rsyncGroup" type="checkbox" ${o.preserveGroup===true?'checked':''}><span>Gruppen übernehmen</span></label><label><input name="rsyncTimes" type="checkbox" ${o.preserveTimes!==false?'checked':''}><span>Zeitstempel übernehmen</span></label><label><input name="rsyncLinks" type="checkbox" ${o.preserveLinks!==false?'checked':''}><span>Symbolische Links als Links übernehmen</span></label><label><input name="rsyncACLs" type="checkbox" ${o.preserveAcls===true?'checked':''}><span>ACLs übernehmen</span></label><label><input name="rsyncXattrs" type="checkbox" ${o.preserveXattrs===true?'checked':''}><span>Erweiterte Attribute übernehmen</span></label><label><input name="rsyncChecksum" type="checkbox" ${o.checksum===true?'checked':''}><span>Dateien per Prüfsumme vergleichen (langsamer)</span></label><label><input name="rsyncDryRun" type="checkbox" ${o.dryRun===true?'checked':''}><span>Testlauf ohne Änderungen</span></label></div></fieldset>`;
host.querySelector('.bu-event-options').insertAdjacentHTML('beforeend', `<label><input name="rsyncSkipSpaceCheck" type="checkbox" ${o.skipSpaceCheck===true?'checked':''}><span>Kapazitätsprüfung überspringen (riskant; nur wenn vorhandene Zieldateien ausreichend Platz freigeben)</span></label>`);
initSourceTree(sourceRoots.map(item=>item.path), false); initDirectoryTree('rsyncTarget','Rsync-Ziel auswählen','#bu-job-form [name="rsyncTarget"]');
} else {
state.backupSelections = ['/boot'];
host.innerHTML = `<div class="bu-info-card"><h3>Unraid-USB-Flash-Backup</h3><p>Das vollständige Konfigurationslaufwerk <strong>/boot</strong> wird immer dateibasiert gesichert.</p><label class="bu-inline-check"><input name="flashImage" type="checkbox" ${job.flashImage===true?'checked':''}><span>Zusätzlich vollständiges USB-Rohabbild <strong>urbm-usb-flash.img</strong> sichern</span></label><p class="bu-muted">Das Abbild wird ohne Zwischenkopie direkt zu Restic übertragen und kann nach einer Wiederherstellung mit einem Image-Programm auf einen mindestens gleich großen Stick geschrieben werden. Die an die USB-GUID gebundene Unraid-Lizenz muss bei einem Stickwechsel separat übertragen werden.</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||'unbekannt')}</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'?'Verfügbare Container':'Verfügbare virtuelle Maschinen'}</h3><span class="bu-count">Ausgewählt: ${state.workloadSelections.length}</span></div>${notice}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Auswahl</th><th>Name</th><th>Status</th></tr></thead><tbody>${rows||(!state.workloadError?'<tr><td colspan="3" class="bu-empty">Keine Systeme gefunden.</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 = `Systeme konnten nicht geladen werden: ${error.message}`;
}
renderWorkloadBrowser(type);
}
function resetSourceTree(roots=sourceRoots) {
state.sourceTreeRoots=roots.map(item=>typeof item==='string'?{name:item,path:item}:item);
state.sourceTreeChildren={}; state.sourceTreeExpanded={}; state.sourceTreeLoading={}; state.sourceTreeErrors={};
}
function normalizeSourceSelections(paths) {
const unique=[...new Set(paths)].sort((left,right)=>left.length-right.length);
return unique.filter((path,index)=>!unique.slice(0,index).some(parent=>path.startsWith(parent.replace(/\/$/,'')+'/')));
}
function initSourceTree(paths, expand) {
resetSourceTree(paths.map(path=>({name:path,path})));
renderSourceBrowser();
if(expand) paths.forEach(path=>toggleSourceTree(path, true));
}
function selectedAncestor(path) {
return state.backupSelections.find(selected=>path!==selected&&path.startsWith(selected.replace(/\/$/,'')+'/')) || '';
}
function sourceSelectionState(path) {
const exact=state.backupSelections.includes(path);
const inherited=selectedAncestor(path);
const prefix=path.replace(/\/$/,'')+'/';
const partial=!exact&&!inherited&&state.backupSelections.some(selected=>selected.startsWith(prefix));
return {exact,inherited,partial};
}
function sourceTreeNode(item, depth) {
const path=item.path;
const selection=sourceSelectionState(path);
const expanded=state.sourceTreeExpanded[path]===true;
const loading=state.sourceTreeLoading[path]===true;
const loaded=Object.prototype.hasOwnProperty.call(state.sourceTreeChildren,path);
const children=state.sourceTreeChildren[path]||[];
const error=state.sourceTreeErrors[path]||'';
const hint=selection.inherited?`Durch den ausgewählten Ordner ${selection.inherited} vollständig enthalten.`:selection.partial?'Ein oder mehrere Unterordner sind ausgewählt. Klicken, um den gesamten Ordner auszuwählen.':'Diesen Ordner als vollständige Backup-Quelle auswählen.';
const label=selection.partial?'<span class="bu-partial-label">Teilweise ausgewählt</span>':selection.inherited?'<span class="bu-inherited-label">Durch Elternordner ausgewählt</span>':'';
const toggle=`<button class="bu-tree-toggle ${expanded?'expanded':''}" type="button" data-action="toggle-source-tree:${encodeURIComponent(path)}" aria-expanded="${expanded}" data-tooltip="${expanded?'Unterordner einklappen':'Unterordner aufklappen'}"><span>▶</span></button>`;
let html=`<div class="bu-tree-node ${selection.partial?'bu-source-partial':''}" style="--bu-tree-depth:${depth}"><div class="bu-tree-row">${toggle}<input type="checkbox" class="bu-source-select" value="${esc(path)}" ${(selection.exact||selection.inherited)?'checked':''} ${selection.inherited?'disabled':''} data-partial="${selection.partial?'true':'false'}" data-tooltip="${esc(hint)}"><button class="bu-tree-name" type="button" data-action="toggle-source-tree:${encodeURIComponent(path)}"><span>${esc(item.name)}</span>${label}<small>${esc(path)}</small></button></div>`;
if(expanded) {
html+='<div class="bu-tree-children">';
if(loading) html+='<div class="bu-tree-state">Unterordner werden geladen…</div>';
else if(error) html+=`<div class="bu-tree-state error">${esc(error)} ${button('Erneut versuchen',`retry-source-tree:${encodeURIComponent(path)}`)}</div>`;
else if(loaded&&!children.length) html+='<div class="bu-tree-state">Keine Unterordner.</div>';
else html+=children.map(child=>sourceTreeNode(child,depth+1)).join('');
html+='</div>';
}
return html+'</div>';
}
function renderSourceBrowser() {
const host=document.getElementById('bu-source-browser');
if(!host) return;
const scrollSnapshot=captureScrollState(host);
const chosen=state.backupSelections.length?state.backupSelections.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join(''):'<span class="bu-muted">Keine Ordner ausgewählt.</span>';
const tree=state.sourceTreeRoots.map(item=>sourceTreeNode(item,0)).join('');
host.innerHTML=`<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Backup-Quellen auswählen</h3><span class="bu-muted">Ordner mit dem Dreieck aufklappen. Unterordner werden erst bei Bedarf geladen.</span></div><span class="bu-count">Ausgewählt: ${state.backupSelections.length}</span></div><div class="bu-selection-list">${chosen}</div><div class="bu-tree" role="tree">${tree||'<div class="bu-empty">Keine Ordner verfügbar.</div>'}</div></section>`;
host.querySelectorAll('.bu-source-select[data-partial="true"]').forEach(box=>{box.indeterminate=true;box.setAttribute('aria-checked','mixed');});
enhanceTooltips(host);
restoreScrollState(scrollSnapshot, host);
}
async function toggleSourceTree(path, forceOpen=false) {
if(state.sourceTreeExpanded[path]&&!forceOpen) {
state.sourceTreeExpanded[path]=false; renderSourceBrowser(); return;
}
state.sourceTreeExpanded[path]=true; renderSourceBrowser();
if(Object.prototype.hasOwnProperty.call(state.sourceTreeChildren,path)&&!state.sourceTreeErrors[path]) return;
state.sourceTreeLoading[path]=true; state.sourceTreeErrors[path]=''; renderSourceBrowser();
try {
state.sourceTreeChildren[path]=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
} catch(error) {
state.sourceTreeErrors[path]=`Ordner konnte nicht geladen werden: ${error.message}`;
} finally {
state.sourceTreeLoading[path]=false; renderSourceBrowser();
}
}
function initDirectoryTree(key, title, targetSelector, roots=repositoryRoots) {
state.directoryTrees[key]={title,targetSelector,roots:roots.map(item=>typeof item==='string'?{name:item,path:item}:item),children:{},expanded:{},loading:{},errors:{}};
renderDirectoryTree(key);
}
function directoryTreeNode(key, item, depth) {
const tree=state.directoryTrees[key]; const path=item.path;
const expanded=tree.expanded[path]===true; const loading=tree.loading[path]===true;
const loaded=Object.prototype.hasOwnProperty.call(tree.children,path); const children=tree.children[path]||[]; const error=tree.errors[path]||'';
const toggle=`<button class="bu-tree-toggle ${expanded?'expanded':''}" type="button" data-action="toggle-dir-tree:${key}:${encodeURIComponent(path)}" aria-expanded="${expanded}" data-tooltip="${expanded?'Unterordner einklappen':'Unterordner aufklappen'}"><span>▶</span></button>`;
let html=`<div class="bu-tree-node" style="--bu-tree-depth:${depth}"><div class="bu-tree-row bu-dir-tree-row">${toggle}<button class="bu-tree-name" type="button" data-action="toggle-dir-tree:${key}:${encodeURIComponent(path)}"><span>${esc(item.name)}</span><small>${esc(path)}</small></button><button class="bu-button" type="button" data-action="select-dir-tree:${key}:${encodeURIComponent(path)}">Auswählen</button></div>`;
if(expanded) {
html+='<div class="bu-tree-children">';
if(loading) html+='<div class="bu-tree-state">Unterordner werden geladen…</div>';
else if(error) html+=`<div class="bu-tree-state error">${esc(error)} ${button('Erneut versuchen',`retry-dir-tree:${key}:${encodeURIComponent(path)}`)}</div>`;
else if(loaded&&!children.length) html+='<div class="bu-tree-state">Keine Unterordner.</div>';
else html+=children.map(child=>directoryTreeNode(key,child,depth+1)).join('');
html+='</div>';
}
return html+'</div>';
}
function renderDirectoryTree(key) {
const tree=state.directoryTrees[key]; const host=document.getElementById(`bu-dir-tree-${key}`);
if(!tree||!host) return;
const scrollSnapshot=captureScrollState(host);
const html=tree.roots.map(item=>directoryTreeNode(key,item,0)).join('');
host.innerHTML=`<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>${esc(tree.title)}</h3><span class="bu-muted">Ordner mit dem Dreieck aufklappen und den gewünschten Zielordner auswählen.</span></div></div><div class="bu-tree" role="tree">${html||'<div class="bu-empty">Keine Ordner verfügbar.</div>'}</div></section>`;
enhanceTooltips(host);
restoreScrollState(scrollSnapshot, host);
}
async function toggleDirectoryTree(key, path, forceOpen=false) {
const tree=state.directoryTrees[key]; if(!tree) return;
if(tree.expanded[path]&&!forceOpen) { tree.expanded[path]=false; renderDirectoryTree(key); return; }
tree.expanded[path]=true; renderDirectoryTree(key);
if(Object.prototype.hasOwnProperty.call(tree.children,path)&&!tree.errors[path]) return;
tree.loading[path]=true; tree.errors[path]=''; renderDirectoryTree(key);
try { tree.children[path]=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`); }
catch(error) { tree.errors[path]=`Ordner konnte nicht geladen werden: ${error.message}`; }
finally { tree.loading[path]=false; renderDirectoryTree(key); }
}
function selectDirectoryTreePath(key, path) {
const tree=state.directoryTrees[key]; if(!tree) return;
const input=document.querySelector(tree.targetSelector);
if(input) input.value=path;
}
function renderRepositories() {
const rows = state.config.repositories.map(r => {
const initialize = volatileLocation(r) ? disabledButton('Initialisieren','Dieses Ziel liegt im flüchtigen RAM. Zuerst über Bearbeiten einen dauerhaften Pfad wie /mnt/user/Backups/HomeServer eintragen.') : button('Initialisieren',`init-repo:${r.id}`);
const secondary = button('Snapshots',`repo-snapshots:${r.id}`)+button('Bearbeiten',`edit-repo:${r.id}`)+button('Passwort ändern',`change-repo-password:${r.id}`)+initialize+button('Prüfen',`maint:check:${r.id}`)+button('Bereinigen',`maint:prune:${r.id}`)+button('Entsperren',`unlock-repo:${r.id}`)+button('Notfall entsperren',`force-unlock-repo:${r.id}`,'danger')+button('Löschen',`delete-repo:${r.id}`,'danger');
return `<tr data-search="${esc(`${r.name} ${r.id} ${typeLabels[r.type]||r.type} ${r.location}`.toLowerCase())}"><td data-label="Name"><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td data-label="Typ">${esc(typeLabels[r.type]||r.type)}</td><td data-label="Speicherort" class="bu-path-cell">${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...</span>':''}</td><td data-label="Einbindung">${r.mount?.managed?'Durch URBM':'Direkt / extern'}</td><td data-label="Aktionen"><div class="bu-row-actions">${button('Testen',`test-repo:${r.id}`,'primary')}${actionMenu(`Weitere Aktionen für ${r.name}`,secondary)}</div></td></tr>`;
}).join('');
return `<div class="bu-toolbar"><div><h2>Repositorys</h2><div class="bu-muted">Lokale, SMB-, NFS- und SFTP-Ziele.</div></div><div class="bu-actions">${filterBox('Repositorys durchsuchen','repositories')}${button('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}</div></div><section class="bu-card">${rows ? `<table id="bu-table-repositories" class="bu-table bu-responsive-table"><thead><tr><th>Name</th><th>Typ</th><th>Speicherort</th><th>Einbindung</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Repositorys konfiguriert.</div>'}</section>`;
}
function repositoryForm(repo = {}, existingRepository = false) {
delete state.directoryTrees.repository;
const passwordRef=repo.passwordRef||id('repo-password');
const credentialRef=repo.credentialRef||id('repo-credential');
content.innerHTML = `<section class="bu-card"><h2>${repo.id?'Repository bearbeiten':existingRepository?'Vorhandenes Repository einbinden':'Neues Repository erstellen'}</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}"><input type="hidden" name="existingRepository" value="${existingRepository?'true':'false'}"><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'}">
${existingRepository?'<div class="wide bu-inline-info">Wähle den exakten Ordner des vorhandenen Restic-Repositorys. Darin müssen direkt die Einträge <strong>config</strong>, <strong>data</strong>, <strong>index</strong>, <strong>keys</strong> und <strong>snapshots</strong> liegen. Verwende das ursprüngliche Repository-Passwort. Es wird nichts initialisiert.</div>':''}
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Typ<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option value="${v}" ${v===repo.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
<div id="bu-repository-specific" class="wide"></div>
${repo.id?`<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><p>Das Repository-Passwort wird getrennt von den Ziel- und Mount-Einstellungen geändert.</p><div class="bu-actions">${button('Repository-Passwort ändern',`change-repo-password:${repo.id}`)}</div></fieldset>`:`<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><div class="bu-form-grid"><label>Repository-Passwort<input name="password" type="password" autocomplete="new-password" required placeholder="Ein starkes Repository-Passwort eingeben"></label><label>Repository-Passwort wiederholen<input name="passwordConfirm" type="password" autocomplete="new-password" required placeholder="Repository-Passwort erneut eingeben"></label></div><p class="bu-muted">Das Passwort wird von URBM geschützt gespeichert. Bewahre dieses Passwort zusätzlich sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.</p></fieldset>`}
<div class="wide bu-actions bu-form-actions">${button('Abbrechen','cancel-form')}${button(existingRepository?'Einbinden und testen':'Repository speichern','submit-repo','primary')}</div></form></section>`;
enhanceTooltips(content);
renderRepositorySpecific(repo.type || 'local', repo);
}
function repositoryPasswordForm(repo) {
content.innerHTML=`<section class="bu-card"><h2>Repository-Passwort verwalten</h2><div class="bu-inline-info">Repository: <strong>${esc(repo.name)}</strong><br><strong>Passwort sicher ändern</strong> erstellt einen neuen Restic-Key und entfernt den bisherigen. Snapshots und Backupdaten werden nicht neu verschlüsselt oder kopiert.<br><strong>Vorhandenes Passwort übernehmen</strong> ist für weitere URBM-Server gedacht, nachdem das Passwort bereits auf einem anderen Server geändert wurde.</div><form id="bu-repo-password-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id)}"><label>Neues oder bereits geändertes Passwort<input name="password" type="password" minlength="8" required autocomplete="new-password"></label><label>Passwort wiederholen<input name="passwordConfirm" type="password" minlength="8" required autocomplete="new-password"></label><div class="wide bu-warning">Der Vorgang ist nur möglich, wenn keine URBM-Aufgabe wartet, läuft oder pausiert ist. Nach einer echten Passwortänderung müssen weitere Server mit „Vorhandenes Passwort übernehmen“ auf das neue Passwort umgestellt werden.</div><div class="wide bu-actions">${button('Passwort sicher ändern','submit-repo-password','primary')}${button('Vorhandenes Passwort übernehmen','adopt-repo-password')}${button('Abbrechen','cancel-form')}</div></form></section>`;
enhanceTooltips(content);
}
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('Lokaler Repository-Ordner',repo.location,'/mnt/user/backups/urbm')}<div id="bu-dir-tree-repository"></div>`;
initDirectoryTree('repository','Lokalen Repository-Ordner auswählen','#bu-repo-form [name="location"]');
} else if (type === 'smb' || type === 'nfs') {
const managed = repo.mount?.managed === true;
host.innerHTML = `<fieldset class="bu-fieldset"><legend>${type.toUpperCase()}-Ziel</legend><label class="bu-inline-check"><input name="managed" type="checkbox" ${managed?'checked':''}><span>URBM soll diese Freigabe einbinden</span></label><div class="bu-external-mount" ${managed?'hidden':''}>${repositoryLocationField('Bereits eingebundener Ordner',repo.location,'/mnt/remotes/backup-nas')}</div><div class="bu-managed-mount" ${managed?'':'hidden'}><div class="bu-form-grid"><label>Netzwerkfreigabe<input name="remote" placeholder="${type==='smb'?'//192.168.1.20/backup':'192.168.1.20:/volume1/backup'}" value="${esc(repo.mount?.remote||'')}"></label><label>Einhängepunkt<input name="mountPoint" placeholder="/mnt/remotes/urbm-backup" value="${esc(repo.mount?.mountPoint||repo.location||'')}"></label>${type==='smb'?`<label class="wide">SMB-Zugangsdaten<textarea name="credential" placeholder="${repo.credentialRef?'Leer lassen, um vorhandene Zugangsdaten zu behalten':'username=backup&#10;password=geheim'}"></textarea></label>`:''}</div></div></fieldset>`;
} else {
host.innerHTML = `<fieldset class="bu-fieldset"><legend>SFTP-Ziel</legend><div class="bu-form-grid">${repositoryLocationField('SFTP-Repository-Adresse',repo.location,'backup@192.168.1.20:/srv/backups/urbm')}<label>Pfad zu SFTP known_hosts<input name="knownHostsPath" value="${esc(repo.options?.knownHostsPath||'/root/.ssh/known_hosts')}"></label><label class="wide">Privater SSH-Schlüssel (optional)<textarea name="credential" placeholder="${repo.credentialRef?'Leer lassen, um den vorhandenen Schlüssel zu behalten':'-----BEGIN OPENSSH PRIVATE KEY-----'}"></textarea></label></div></fieldset>`;
}
enhanceTooltips(host);
}
function renderSnapshots() {
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
const pathCount = paths => `<span class="bu-path-count" title="${esc((paths||[]).join('\n'))}">${Number((paths||[]).length).toLocaleString()} Pfad${(paths||[]).length===1?'':'e'}</span>`;
const rows = newestSnapshots(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>${pathCount(s.paths)}</td><td>${button('Durchsuchen',`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('Laden','load-snapshots','primary')}</div></div><section class="bu-card">${rows?`<div class="bu-table-scroll"><table class="bu-table bu-snapshot-table"><colgroup><col class="bu-snapshot-id"><col class="bu-snapshot-time"><col class="bu-snapshot-host"><col class="bu-snapshot-paths"><col class="bu-snapshot-actions"></colgroup><thead><tr><th>Snapshot</th><th>Zeitpunkt</th><th>Host</th><th>Pfade</th><th></th></tr></thead><tbody>${rows}</tbody></table></div>`:'<div class="bu-empty">Repository auswählen und Snapshots laden.</div>'}</section>${state.selectedSnapshot ? renderFileBrowser() : ''}`;
}
function snapshotSelectionState(path) {
const exact=state.restoreIncludes.includes(path);
const prefix=path.replace(/\/$/,'')+'/';
const partial=!exact&&state.restoreIncludes.some(selected=>selected.startsWith(prefix));
return {exact,partial};
}
function snapshotTreeNode(node, depth) {
const selection=snapshotSelectionState(node.path);
const isDir=node.type==='dir';
const expanded=state.snapshotTreeExpanded[node.path]===true;
const canExpand=isDir&&node.hasChildren;
const loading=state.snapshotTreeNodeLoading[node.path]===true;
const children=state.snapshotTreeChildren[node.path]||[];
const error=state.snapshotTreeErrors[node.path]||'';
const toggle=canExpand?`<button class="bu-tree-toggle ${expanded?'expanded':''}" type="button" data-action="toggle-snapshot-tree:${encodeURIComponent(node.path)}" aria-expanded="${expanded}"><span>▶</span></button>`:'<span></span>';
const label=selection.partial?'<span class="bu-partial-label">Teilweise ausgewählt</span>':'';
let html=`<div class="bu-tree-node ${selection.partial?'bu-source-partial':''}" style="--bu-tree-depth:${depth}"><div class="bu-tree-row"><div>${toggle}</div><input type="checkbox" class="bu-file-select" value="${esc(node.path)}" ${selection.exact?'checked':''} data-partial="${selection.partial?'true':'false'}"><button class="bu-tree-name" type="button" ${canExpand?`data-action="toggle-snapshot-tree:${encodeURIComponent(node.path)}"`:''}><span>${esc(node.name)}</span>${label}<small>${esc(node.path)}${!isDir&&node.size?` · ${formatBytes(node.size)}`:''}</small></button></div>`;
if(canExpand&&expanded) {
let body='';
if(loading) body='<div class="bu-tree-state">Snapshot-Ordner wird geladen…</div>';
else if(error) body=`<div class="bu-tree-state error">${esc(error)} ${button('Erneut versuchen',`retry-snapshot-tree:${encodeURIComponent(node.path)}`)}</div>`;
else body=children.length?children.map(child=>snapshotTreeNode(child,depth+1)).join(''):'<div class="bu-tree-state">Keine Einträge.</div>';
html+=`<div class="bu-tree-children">${body}</div>`;
}
return html+'</div>';
}
function renderFileBrowser() {
const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">Nichts ausgewählt.</span>';
const loading='<div class="bu-snapshot-loading"><div class="bu-progress-wrap"><div class="bu-progress-label"><strong>Snapshot-Index wird eingelesen…</strong><span>Der Daemon baut daraus intern einen Baum. Danach werden nur sichtbare Ordner übertragen.</span></div><div class="bu-progress indeterminate"><span style="width:30%"></span></div></div></div>';
const body=state.snapshotTreeLoading?loading:state.snapshotTreeError?`<div class="bu-inline-error">${esc(state.snapshotTreeError)}</div>`:state.snapshotTree?snapshotTreeNode(state.snapshotTree,0):'<div class="bu-empty">Keine Dateien im Snapshot gefunden.</div>';
const total=state.snapshotTreeMeta?.total||0;
const loaded=Object.values(state.snapshotTreeChildren).reduce((sum,items)=>sum+items.length,0);
const hint=total?`${Number(total).toLocaleString()} Einträge im Snapshot · ${Number(loaded).toLocaleString()} sichtbare Einträge geladen`:'Struktur noch nicht geladen';
return `<section class="bu-card bu-section-gap bu-snapshot-browser"><div class="bu-toolbar"><div><h3>Snapshot ${esc(state.selectedSnapshot.slice(0,12))}</h3><span class="bu-muted">${esc(hint)} · Der Dateibaum verwendet den Seiten-Scroll.</span></div><div class="bu-actions">${button(`Auswahl wiederherstellen (${state.restoreIncludes.length})`,'restore-selected','primary')}</div></div><div class="bu-selection-list">${chosen}</div><div class="bu-tree bu-snapshot-tree" role="tree">${body}</div></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 bu-form-card"><div class="bu-form-heading"><span class="bu-eyebrow">Sicher wiederherstellen</span><h2>Wiederherstellen</h2><p class="bu-muted">Standardmäßig landen Dateien in einem getrennten Staging-Verzeichnis.</p></div><form id="bu-restore-form" class="bu-form"><fieldset class="wide bu-fieldset"><legend>Quelle</legend><div class="bu-form-grid"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot-ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Wiederherzustellende Pfade, einer pro Zeile<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label></div></fieldset><fieldset class="wide bu-fieldset"><legend>Ziel</legend><label class="wide">Zielverzeichnis<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><div id="bu-dir-tree-restoreTarget" class="wide"></div></fieldset><fieldset class="wide bu-fieldset bu-danger-zone"><legend>Originalpfade überschreiben</legend><p class="bu-muted">Nur für eine bewusst geplante In-place-Wiederherstellung aktivieren.</p><div class="bu-form-grid"><label class="bu-inline-check"><input name="inPlace" type="checkbox"><span>Am Originalort wiederherstellen</span></label><label class="bu-inline-check"><input name="confirmed" type="checkbox"><span>Überschreibungsrisiko bestätigen</span></label></div></fieldset><div class="wide bu-actions bu-form-actions">${button('Wiederherstellung einreihen','submit-restore','primary')}</div></form></section>`;
}
function renderActivity() { return `<div class="bu-toolbar"><div><h2>Aktivitäten</h2><div class="bu-muted">Laufende Aufgaben werden automatisch alle zwei Sekunden aktualisiert.</div></div><div class="bu-actions">${button('Aktualisieren','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}</div></div><section class="bu-card">${runsTable(newestRuns(state.runs))}</section>`; }
function renderLogs() {
const daemon=state.logs?.daemon||{};
const daemonLines=daemon.lines||[];
const daemonHint=daemon.error?`<div class="bu-inline-error">${esc(daemon.error)}</div>`:`<div class="bu-muted">${esc(daemon.path||'/var/log/urbm.log')}${daemon.truncatedLines?` · ${daemon.truncatedLines} ältere Zeilen ausgeblendet`:''}${daemon.truncatedBytes?` · Anfang wegen Größe gekürzt`:''}</div>`;
const runLogs=(state.logs?.runs||[]).map(run=>`<details class="bu-live-log" open><summary>${esc(taskLabels[run.taskType]||run.taskType)} · ${esc(runTarget(run))} · ${esc(statusLabels[run.status]||run.status)} · ${esc(new Date(run.createdAt).toLocaleString())}</summary><pre>${esc((run.liveLog||[]).join('\n'))}</pre></details>`).join('');
return `<div class="bu-toolbar"><div><h2>Protokolle</h2><div class="bu-muted">Nur lesbare Diagnoseansicht. Secrets und Passwörter werden von URBM nicht geloggt.</div></div><div class="bu-actions">${button('Aktualisieren','refresh-logs','primary')}</div></div>${state.logsLoading?'<section class="bu-card"><div class="bu-empty">Protokolle werden geladen…</div></section>':state.logsError?`<section class="bu-card"><div class="bu-inline-error">${esc(state.logsError)}</div></section>`:`<section class="bu-card"><h3>Daemon-Protokoll</h3>${daemonHint}<pre class="bu-log-pre">${esc(daemonLines.join('\n'))}</pre></section><section class="bu-card" style="margin-top:16px"><h3>Laufprotokolle</h3>${runLogs||'<div class="bu-empty">Keine gespeicherten Laufprotokolle vorhanden.</div>'}</section>`}`;
}
function progressView(run) {
if(run.status==='queued') return '<span class="bu-muted">Wartet in der Warteschlange</span>';
if(!['backup','rsync','restore'].includes(run.taskType)&&!run.progressPercent) return '<span class="bu-muted">Kein Prozentwert verfügbar</span>';
const usbImage=run.status==='running'&&run.currentFile==='urbm-usb-flash.img';
if(usbImage) {
const details=[];
if(run.progressBytes) details.push(`${formatBytes(run.progressBytes)} gelesen`);
if(run.bytesPerSecond) details.push(`${formatBytes(run.bytesPerSecond)}/s`);
return `<div class="bu-progress-wrap"><div class="bu-progress-label"><strong>USB-Abbild wird übertragen…</strong><span>${esc(details.join(' · '))}</span></div><div class="bu-progress indeterminate"><span style="width:30%"></span></div><div class="bu-current-file">urbm-usb-flash.img · Gesamtgröße bis zum Abschluss unbekannt</div></div>`;
}
const percent=Math.max(0,Math.min(100,Number(run.progressPercent)||0));
const scanning=run.status==='running'&&percent===0;
const waitingLabel=run.taskType==='restore'?'Wiederherstellung läuft…':'Wird eingelesen…';
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()} Dateien`);
if(run.bytesPerSecond) details.push(`${formatBytes(run.bytesPerSecond)}/s`);
if(run.secondsRemaining>0) details.push(`Restzeit ${formatEta(run.secondsRemaining)}`);
return `<div class="bu-progress-wrap"><div class="bu-progress-label"><strong>${run.status==='paused'?'Pausiert':scanning?waitingLabel:`${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-Protokoll':'Laufprotokoll'} (${run.liveLog.length})</summary><pre>${esc(run.liveLog.join('\n'))}</pre></details>`;
}
function runsTable(runs) {
if(!runs.length) return '<div class="bu-empty">Keine Aktivitäten vorhanden.</div>';
const showLiveLog=state.config?.settings?.showLiveLog===true;
return `<div class="bu-table-scroll"><table class="bu-table bu-runs-table"><colgroup><col class="bu-run-task"><col class="bu-run-status"><col class="bu-run-created"><col class="bu-run-progress"><col class="bu-run-message"></colgroup><thead><tr><th>Aufgabe</th><th>Status</th><th>Erstellt</th><th>Fortschritt</th><th>Meldung / Aktionen</th></tr></thead><tbody>${runs.map(r=>{const controls=r.status==='running'?button('Pause',`pause-run:${r.id}`)+button('Abbrechen',`cancel-run:${r.id}`,'danger'):r.status==='paused'?button('Fortsetzen',`resume-run:${r.id}`,'primary')+button('Abbrechen',`cancel-run:${r.id}`,'danger'):r.status==='queued'?button('Abbrechen',`cancel-run:${r.id}`,'danger'):'';const target=runTarget(r);return `<tr><td class="bu-run-task-cell" title="${esc(target)}"><strong>${esc(taskLabels[r.taskType]||r.taskType)}</strong><span>${esc(target)}</span></td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${progressView(r)}</td><td class="bu-run-message-cell">${controls?actionGroup(controls):esc(displayMessage(r.message))}</td></tr>${showLiveLog&&(r.liveLog||[]).length?`<tr class="bu-log-row"><td colspan="5">${liveLogView(r)}</td></tr>`:''}`;}).join('')}</tbody></table></div>`;
}
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','Erfolg'],['warning','Warnung'],['failed','Fehler']].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>Einstellungen</h2><form id="bu-settings-form" class="bu-form"><label class="wide">Restic-Binärdatei<input name="resticPath" value="${esc(s.resticPath)}"></label><label class="wide">Staging-Verzeichnis für Wiederherstellungen<input name="restoreRoot" value="${esc(s.restoreRoot)}"></label><div id="bu-dir-tree-restoreRoot" class="wide"></div><label class="wide">Verzeichnis für dauerhafte Protokolle<input name="persistentLogDir" value="${esc(s.persistentLogDir||'')}"></label><div id="bu-dir-tree-persistentLogDir" class="wide"></div><label class="wide bu-inline-check"><input name="showLiveLog" type="checkbox" ${s.showLiveLog===true?'checked':''}><span>Live-Protokoll im Aktivitäten-Bereich anzeigen</span></label><label>Nachholzeitraum (Stunden)<input name="catchUp" type="number" value="${s.catchUpWindowHours||24}"></label><label>Zeitplan für monatliche Prüfung<input name="checkCron" value="${esc(s.checkCron)}"></label><label>Zeitplan für Bereinigung<input name="pruneCron" value="${esc(s.pruneCron)}"></label><div class="wide bu-actions">${button('Einstellungen speichern','submit-settings','primary')}</div></form></section>
<section class="bu-card" style="margin-top:16px"><h2>Unraid-Benachrichtigungen</h2><p class="bu-muted">Verwendet das Unraid-Benachrichtigungssystem und alle unter Einstellungen &gt; Benachrichtigungseinstellungen konfigurierten Agenten.</p><form id="bu-unraid-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(unraid.id)}"><label><span>Aktiviert</span><input name="enabled" type="checkbox" ${unraid.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Benachrichtigen bei</legend><div class="bu-event-options">${eventBoxes(unraid)}</div></fieldset><div class="wide bu-actions">${button('Unraid-Benachrichtigungen speichern','submit-unraid-notify','primary')}</div></form></section>
<section class="bu-card" style="margin-top:16px"><h2>Gotify-Benachrichtigung</h2><p class="bu-muted">Erstelle in Gotify eine Anwendung und trage hier deren Anwendungs-Token ein.</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>Anwendungs-Token<input name="token" type="password" autocomplete="new-password" placeholder="${gotify.tokenRef?'Leer lassen, um das vorhandene Token zu behalten':'Gotify-Anwendungs-Token eingeben'}"></label><label><span>Aktiviert</span><input name="enabled" type="checkbox" ${gotify.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Benachrichtigen bei</legend><div class="bu-event-options">${eventBoxes(gotify)}</div></fieldset><div class="wide bu-actions">${button('Gotify-Benachrichtigung speichern','submit-gotify-notify','primary')}</div></form></section>`;
}
async function saveConfig() {
state.config = await api('/v1/config',{method:'PUT',body:JSON.stringify(state.config)});
notify('Konfiguration gespeichert');
return state.config;
}
async function loadLogs() {
state.logsLoading=true; state.logsError=''; render();
try { state.logs=await api('/v1/logs'); }
catch(error) { state.logsError=error.message; }
finally { state.logsLoading=false; render(); }
}
function renderPreservingScroll() {
const scrollSnapshot=captureScrollState(content);
render();
restoreScrollState(scrollSnapshot, content);
}
async function loadSnapshotChildren(path) {
const query = path && path !== '/' ? `?path=${encodeURIComponent(path)}` : '';
const page = await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files${query}`);
const items = Array.isArray(page) ? page : (page.items || []);
state.snapshotTreeChildren[path || '/'] = items;
state.snapshotTreeMeta = Array.isArray(page) ? { total: items.length } : page;
return items;
}
const lines = (value) => value.split('\n').map(v=>v.trim()).filter(Boolean);
const runTime = run => new Date(run.createdAt || run.startedAt || run.finishedAt || 0).getTime() || 0;
const snapshotTime = snapshot => new Date(snapshot.time || 0).getTime() || 0;
const newestRuns = runs => [...runs].sort((a,b)=>runTime(b)-runTime(a));
const newestSnapshots = snapshots => [...snapshots].sort((a,b)=>snapshotTime(b)-snapshotTime(a));
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 === 'existing-repo') return repositoryForm({}, true);
if (name === 'edit-repo') return repositoryForm(state.config.repositories.find(r=>r.id===a));
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 === '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(); }
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==='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 sowie die Messwerte der Dashboard-Diagramme bleiben erhalten.',accept:'Verlauf löschen'})) { const result=await api('/v1/runs',{method:'DELETE'}); notify(result.cleared?`${result.cleared} abgeschlossene Aktivitäten gelöscht`:'Keine abgeschlossenen Aktivitäten vorhanden'); return load(); } return; }
if (name === 'refresh-logs') { state.logs=null; return loadLogs(); }
if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository-Verbindung erfolgreich getestet'); }
if (name === 'init-repo') { if(await askConfirm({title:'Neues Repository initialisieren?',message:'Am konfigurierten Ziel wird ein neues, leeres Restic-Repository angelegt. Verwende diese Aktion nicht für ein bereits vorhandenes Repository.',accept:'Initialisieren',danger:false})) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository erfolgreich initialisiert'); } return; }
if (name === 'unlock-repo') { if(await askConfirm({title:'Verwaiste Sperren entfernen?',message:'Restic entfernt nur als veraltet erkannte Sperren. Diese Aktion ist für abgebrochene Prozesse oder einen Neustart gedacht.',accept:'Entsperren',danger:false})) { await api(`/v1/repositories/${a}/unlock`,{method:'POST'}); notify('Verwaiste Repository-Sperren wurden entfernt'); } return; }
if (name === 'force-unlock-repo') { if(await askConfirm({title:'Notfall-Entsperrung',message:'Alle Restic-Sperren werden entfernt. Fahre nur fort, wenn auf keinem Server ein Backup, Restore, Check oder Prune auf dieses Repository zugreift. Ein aktiver Vorgang kann sonst fehlschlagen oder Daten beschädigen.',accept:'Alle Sperren entfernen',phrase:'ENTSPERREN'})) { await api(`/v1/repositories/${a}/force-unlock`,{method:'POST'}); notify('Alle Repository-Sperren wurden entfernt'); } return; }
if (name === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a==='check'?'Prüfung':'Bereinigung'} wurde eingereiht`); 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=newestSnapshots(await api(`/v1/repositories/${repo}/snapshots`)); state.snapshotRepo=repo; return render(); }
if (name === 'browse') { state.selectedSnapshot=a; state.restoreIncludes=[]; state.files=[]; state.snapshotTree={path:'/',name:'/',type:'dir',hasChildren:true}; state.snapshotTreeMeta=null; state.snapshotTreeChildren={}; state.snapshotTreeNodeLoading={}; state.snapshotTreeErrors={}; state.snapshotTreeError=''; state.snapshotTreeExpanded={'/':true}; state.snapshotTreeLoading=true; render(); try { state.files=await loadSnapshotChildren('/'); } catch(error) { state.snapshotTreeError=`Snapshot-Struktur konnte nicht geladen werden: ${error.message}. Details stehen im Tab Protokolle.`; } finally { state.snapshotTreeLoading=false; return render(); } }
if (name === 'toggle-source-tree') { return toggleSourceTree(decodeURIComponent(a)); }
if (name === 'retry-source-tree') { const path=decodeURIComponent(a); delete state.sourceTreeChildren[path]; state.sourceTreeErrors[path]=''; return toggleSourceTree(path,true); }
if (name === 'toggle-dir-tree') { return toggleDirectoryTree(a, decodeURIComponent(b)); }
if (name === 'retry-dir-tree') { const path=decodeURIComponent(b); const tree=state.directoryTrees[a]; if(tree) { delete tree.children[path]; tree.errors[path]=''; } return toggleDirectoryTree(a,path,true); }
if (name === 'select-dir-tree') { selectDirectoryTreePath(a, decodeURIComponent(b)); return; }
if (name === 'toggle-snapshot-tree') { const path=decodeURIComponent(a); if(state.snapshotTreeExpanded[path]) { state.snapshotTreeExpanded[path]=false; return renderPreservingScroll(); } state.snapshotTreeExpanded[path]=true; renderPreservingScroll(); if(Object.prototype.hasOwnProperty.call(state.snapshotTreeChildren,path)&&!state.snapshotTreeErrors[path]) return; state.snapshotTreeNodeLoading[path]=true; state.snapshotTreeErrors[path]=''; renderPreservingScroll(); try { await loadSnapshotChildren(path); } catch(error) { state.snapshotTreeErrors[path]=`Snapshot-Ordner konnte nicht geladen werden: ${error.message}`; } finally { state.snapshotTreeNodeLoading[path]=false; return renderPreservingScroll(); } }
if (name === 'retry-snapshot-tree') { const path=decodeURIComponent(a); delete state.snapshotTreeChildren[path]; state.snapshotTreeErrors[path]=''; state.snapshotTreeExpanded[path]=false; return handle(`toggle-snapshot-tree:${encodeURIComponent(path)}`); }
if (name === 'restore-selected') { if(!state.restoreIncludes.length) throw new Error('Wähle mindestens eine Datei oder einen Ordner aus.'); state.view='restore'; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view==='restore')); return render(); }
if (name === 'delete-job') { const job=state.config.jobs.find(j=>j.id===a); if(await askConfirm({title:'Backup-Job löschen?',message:`„${job?.name||a}“ wird aus der Konfiguration entfernt. Bereits vorhandene Snapshots bleiben erhalten.`,accept:'Job löschen'})) { 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 += ' Kopie'; 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('Das Repository wird noch von einem Job verwendet.'); const repo=state.config.repositories.find(r=>r.id===a); if(await askConfirm({title:'Repository entfernen?',message:`„${repo?.name||a}“ wird nur aus URBM entfernt. Die Backup-Daten am Speicherziel werden nicht gelöscht.`,accept:'Repository entfernen'})) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; }
if (name === 'adopt-repo-password') { await submitRepositoryPassword(true); return; }
if (name.startsWith('submit-')) { await submit(name); return; }
} 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(type!=='rsync'&&!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.');
const rsync=type==='rsync'?{target:String(f.get('rsyncTarget')||'').trim(),overwrite:f.get('rsyncOverwrite')==='on',delete:f.get('rsyncDelete')==='on',preservePermissions:f.get('rsyncPermissions')==='on',preserveOwner:f.get('rsyncOwner')==='on',preserveGroup:f.get('rsyncGroup')==='on',preserveTimes:f.get('rsyncTimes')==='on',preserveLinks:f.get('rsyncLinks')==='on',preserveAcls:f.get('rsyncACLs')==='on',preserveXattrs:f.get('rsyncXattrs')==='on',checksum:f.get('rsyncChecksum')==='on',dryRun:f.get('rsyncDryRun')==='on',skipSpaceCheck:f.get('rsyncSkipSpaceCheck')==='on'}:{};
if(type==='rsync'&&!rsync.target) throw new Error('Wähle einen Zielordner für die Rsync-Kopie.');
if(type==='rsync'&&rsync.delete&&!await askConfirm({title:'Rsync-Löschungen aktivieren?',message:'Dateien am Ziel, die in der Quelle nicht mehr vorhanden sind, dürfen beim Lauf gelöscht werden.',accept:'Löschungen erlauben'})) return;
if(type==='rsync'&&rsync.skipSpaceCheck&&!await askConfirm({title:'Kapazitätsprüfung abschalten?',message:'Der Rsync-Lauf kann das Ziel vollständig füllen und anschließend mit einem Schreibfehler abbrechen.',accept:'Prüfung abschalten'})) return;
const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:type==='rsync'?'':f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:type==='rsync'?'':f.get('compression'),cpuCores:type==='rsync'?0:Number(f.get('cpuCores')||0),readConcurrency:type==='rsync'?0:Number(f.get('readConcurrency')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention:type==='rsync'?{}:retention,rsync,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';
const existingRepository=value('existingRepository')==='true';
let location=managed?value('mountPoint'):value('location');
if(!location) throw new Error(managed?'Ein Einhängepunkt ist erforderlich.':'Ein Repository-Speicherort ist erforderlich.');
if(managed&&!value('remote')) throw new Error('Für eine verwaltete Einbindung ist eine Netzwerkfreigabe erforderlich.');
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')}: {}};
const password=String(f.get('password')||''); const passwordConfirm=String(f.get('passwordConfirm')||'');
if(!value('id')&&!password) throw new Error(existingRepository?'Zum Einbinden ist das ursprüngliche Repository-Passwort erforderlich.':'Für ein neues Repository ist ein Passwort erforderlich.');
if(password!==passwordConfirm) throw new Error('Die beiden Repository-Passwörter stimmen nicht überein.');
if (volatileLocation(repo) && !await askConfirm({title:'Flüchtiges Speicherziel',message:'Dieses Ziel liegt unter /tmp oder /run. Sämtliche dort gespeicherten Daten gehen bei einem Unraid-Neustart verloren.',accept:'Trotzdem speichern',phrase:'FLÜCHTIG'})) return;
if(password) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value: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-Speicherort wurde nicht gespeichert. Erwartet: ${repo.location}`);
if(existingRepository) {
await api(`/v1/repositories/${repo.id}/test`,{method:'POST'});
render(); notify(`Vorhandenes Repository erfolgreich eingebunden: ${saved.location}`);
} else {
render(); notify(`Repository gespeichert: ${saved.location}`);
}
}
if (kind === 'submit-repo-password') {
await submitRepositoryPassword(false);
}
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('Wiederherstellung wurde eingereiht'); state.view='activity'; render(); }
if (kind === 'submit-settings') { const f=new FormData(document.getElementById('bu-settings-form')); state.config.settings={resticPath:f.get('resticPath'),rsyncPath:state.config.settings.rsyncPath||'/usr/bin/rsync',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('Wähle mindestens ein Ereignis für Unraid-Benachrichtigungen aus.'); 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('Die Gotify-Server-URL ist erforderlich.'); if(target.enabled&&!target.events.length) throw new Error('Wähle mindestens ein Ereignis für Gotify-Benachrichtigungen aus.'); if(target.enabled&&!existing&&!f.get('token')) throw new Error('Ein Gotify-Anwendungs-Token ist erforderlich.'); if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'gotify-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='gotify'&&n.type!=='ntfy'); if(target.enabled||target.url) state.config.notifications.push(target); await saveConfig(); render(); }
}
async function submitRepositoryPassword(adopt) {
const f=new FormData(document.getElementById('bu-repo-password-form'));
const password=String(f.get('password')||'');
if(password.length<8) throw new Error('Das Repository-Passwort muss mindestens 8 Zeichen lang sein.');
if(password!==String(f.get('passwordConfirm')||'')) throw new Error('Die beiden Repository-Passwörter stimmen nicht überein.');
const accepted=await askConfirm(adopt?{title:'Passwort übernehmen?',message:'Das bereits gültige Passwort wird geprüft und nur auf diesem URBM-Server gespeichert.',accept:'Prüfen und übernehmen',danger:false}:{title:'Repository-Passwort ändern?',message:'Der bisherige Restic-Key wird entfernt. Weitere Server müssen anschließend auf das neue Passwort umgestellt werden.',accept:'Passwort ändern'});
if(!accepted) return;
const suffix=adopt?'/password/adopt':'/password';
await api(`/v1/repositories/${encodeURIComponent(f.get('id'))}${suffix}`,{method:'POST',body:JSON.stringify({password})});
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==='logs'&&!state.logs&&!state.logsLoading)loadLogs(); });
function handleActionClick(e) {
const target=e.target.closest('[data-action]');
if(!target) return;
e.preventDefault();
const popover=target.closest('.bu-action-menu-popover');
const menu=target.closest('.bu-action-menu')||popover?._ownerMenu;
menu?.removeAttribute('open');
handle(target.dataset.action,target);
}
content.addEventListener('click', handleActionClick);
menuPortal?.addEventListener('click', handleActionClick);
content.addEventListener('input', e => {
const target=e.target.closest('[data-filter-table]');
if(!target) return;
const query=target.value.trim().toLowerCase();
const table=document.getElementById(`bu-table-${target.dataset.filterTable}`);
table?.querySelectorAll('tbody tr[data-search]').forEach(row=>{ row.hidden=Boolean(query)&&!row.dataset.search.includes(query); });
});
document.addEventListener('click', e=>{
document.querySelectorAll('.bu-action-menu[open]').forEach(menu=>{
const popover=menu._popover;
if(!menu.contains(e.target)&&!popover?.contains(e.target)) menu.removeAttribute('open');
});
});
content.addEventListener('toggle', e=>{
const menu=e.target.closest?.('.bu-action-menu');
if(!menu) return;
const row=menu.closest('tr');
row?.classList.toggle('bu-menu-open',menu.open);
if(!menu.open) {
const popover=menu._popover;
if(popover) {
popover.removeAttribute('style');
popover.classList.remove('portaled');
delete popover._ownerMenu;
menu.appendChild(popover);
delete menu._popover;
}
return;
}
content.querySelectorAll('.bu-action-menu[open]').forEach(other=>{ if(other!==menu) other.removeAttribute('open'); });
requestAnimationFrame(()=>{
const summary=menu.querySelector('summary');
const popover=menu.querySelector('.bu-action-menu-popover');
if(!summary||!popover||!menuPortal) return;
const rect=summary.getBoundingClientRect();
menu._popover=popover;
popover._ownerMenu=menu;
popover.classList.add('portaled');
menuPortal.appendChild(popover);
const width=Math.min(Math.max(popover.offsetWidth,220),window.innerWidth-16);
const needed=Math.min(popover.scrollHeight,430);
const below=window.innerHeight-rect.bottom-12;
const above=rect.top-12;
const openUp=below<needed&&above>below;
const available=Math.max(120,(openUp?above:below)-6);
const height=Math.min(needed,available);
const left=Math.max(8,Math.min(rect.right-width,window.innerWidth-width-8));
const top=openUp?Math.max(8,rect.top-height-6):rect.bottom+6;
Object.assign(popover.style,{left:`${left}px`,right:'auto',top:`${top}px`,bottom:'auto',width:`${width}px`,maxHeight:`${height}px`});
});
},true);
window.addEventListener('resize',()=>document.querySelectorAll('.bu-action-menu[open]').forEach(menu=>menu.removeAttribute('open')));
content.addEventListener('change', e => {
if(e.target.id==='bu-dashboard-job') {
state.dashboardJobId=e.target.value;
render();
return;
}
if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) {
renderRepositorySpecific(e.target.value);
}
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';
const time=e.target.closest('.bu-schedule')?.querySelector('.bu-schedule-time');
if(time) time.hidden=e.target.value==='manual';
const manual=e.target.closest('.bu-schedule')?.querySelector('.bu-schedule-manual');
if(manual) manual.hidden=e.target.value!=='manual';
}
if (e.target.name === 'type' && e.target.closest('#bu-job-form')) {
state.backupSelections=[]; state.workloadSelections=[];
renderJobSpecific(e.target.value);
}
if (e.target.name === 'inPlace' && e.target.closest('#bu-restore-form')) {
const target=e.target.closest('form').querySelector('[name="target"]');
const tree=e.target.closest('form').querySelector('#bu-dir-tree-restoreTarget');
if(target) {
if(e.target.checked) { target.dataset.stagingTarget=target.value; target.value='/'; target.readOnly=true; }
else { target.value=target.dataset.stagingTarget||state.config.settings.restoreRoot; target.readOnly=false; }
}
if(tree) tree.hidden=e.target.checked;
}
if (e.target.classList.contains('bu-source-select')) {
const path=e.target.value; const prefix=path.replace(/\/$/,'')+'/';
if(e.target.checked) state.backupSelections=normalizeSourceSelections([...state.backupSelections.filter(selected=>!selected.startsWith(prefix)),path]);
else state.backupSelections=state.backupSelections.filter(selected=>selected!==path);
renderSourceBrowser();
}
if (e.target.classList.contains('bu-file-select')) {
const path=e.target.value; const prefix=path.replace(/\/$/,'')+'/';
state.restoreIncludes = e.target.checked ? normalizeSourceSelections([...state.restoreIncludes.filter(selected=>!selected.startsWith(prefix)),path]) : state.restoreIncludes.filter(selected=>selected!==path);
renderPreservingScroll();
}
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-repo-password-form' ? 'submit-repo-password' : 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,Number(target.dataset.tooltipDelay||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 { 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);
load();
})();