(() => {
'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 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: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', liveLogOpen: {} };
const fieldHelp = {
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
type: 'Art der zu sichernden Daten. Beispiel: appdata für Containerdaten, vm für virtuelle Maschinen oder flash für den Unraid-USB-Stick.',
repositoryId: 'Speicherziel für diesen Vorgang. 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.',
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. Sicheres Beispiel: /mnt/user/urbm-restores/restore-123. Standardmäßig wird nicht direkt über Originaldaten geschrieben.',
inPlace: 'Erlaubt Wiederherstellung direkt an einen produktiven Zielpfad. Nur aktivieren, wenn vorhandene Dateien bewusst ersetzt werden sollen.',
confirmed: 'Zusätzliche Bestätigung für einen In-place-Restore. Ohne diese Bestätigung wird ein direkter Restore blockiert.',
resticPath: 'Pfad zur vom Plugin bereitgestellten Restic-Datei. Standard: /usr/local/libexec/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.',
'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.',
'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.',
'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.',
'browser-up': 'Öffnet im Snapshot-Browser das übergeordnete Verzeichnis.',
'source-up': 'Öffnet im Ordner-Browser das übergeordnete Verzeichnis.',
'select-current-source': 'Wählt den aktuell geöffneten Ordner vollständig als Backup-Quelle aus.',
'repository-up': 'Öffnet im lokalen Repository-Browser das übergeordnete Verzeichnis.',
'select-repository-folder': 'Übernimmt den aktuell geöffneten Ordner als lokalen Repository-Pfad.',
'select-current-restore': 'Wählt den aktuell geöffneten Snapshot-Ordner vollständig für die Wiederherstellung aus.',
};
const esc = (value) => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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);
};
const id = (prefix) => `${prefix}-${crypto.randomUUID()}`;
const repoName = (repoId) => state.config.repositories.find(r => r.id === repoId)?.name || repoId;
const volatileLocation = (repo) => repo.type === 'local' && (/^\/tmp(?:\/|$)/.test(repo.location) || /^\/run(?:\/|$)/.test(repo.location));
const tipAttr = (text) => text ? ` data-tooltip="${esc(text)}"` : '';
const actionTooltip = (action, label) => {
const [name] = action.split(':');
if (actionHelp[name]) return actionHelp[name];
const dynamic = {
'run-job': 'Startet diesen Backup-Job sofort. Beispiel: manuelles Backup vor einem Unraid-Update.',
'edit-job': 'Öffnet die Einstellungen dieses Backup-Jobs zum Bearbeiten.',
'duplicate-job': 'Erstellt eine deaktivierte Kopie als Vorlage. Danach Name, Repository und Quellen anpassen.',
'delete-job': 'Löscht die Job-Konfiguration. Vorhandene Restic-Snapshots im Repository bleiben erhalten.',
'test-repo': 'Prüft Erreichbarkeit, Passwort und Restic-Zugriff, ohne ein Backup zu starten.',
'init-repo': 'Erstellt einmalig ein neues Restic-Repository. Nicht erneut ausführen und nicht für vorhandene Restic-Repositories verwenden.',
'unlock-repo': 'Entfernt verwaiste Restic-Locks, etwa nach einem Neustart oder abgebrochenen Prozess. Aktive Locks bleiben erhalten.',
'repo-snapshots': 'Wechselt zu den Sicherungsständen dieses Repositorys.',
'maint': action.includes(':check:') ? 'Prüft Struktur und Integrität des Restic-Repositorys.' : 'Entfernt nach Retention nicht mehr benötigte Datenblöcke und gibt Speicher frei.',
'edit-repo': 'Öffnet Ziel-, Mount- und Zugangseinstellungen dieses Repositorys.',
'delete-repo': 'Löscht nur die URBM-Konfiguration. Die Backupdaten am Speicherziel werden nicht gelöscht.',
'browse': 'Öffnet den Dateibaum dieses Snapshots, um einzelne Dateien oder Ordner auszuwählen.',
'open-dir': 'Öffnet dieses Verzeichnis innerhalb des schreibgeschützten Snapshots.',
'open-source-dir': 'Öffnet diesen Ordner, um darin weitere Unterordner auszuwählen.',
'open-repository-dir': 'Öffnet diesen lokalen Ordner, um ihn oder einen Unterordner als Repository-Ziel auszuwählen.',
'cancel-run': 'Bricht die laufende oder wartende Aufgabe ab. 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',restore:'Wiederherstellung',check:'Prüfung',prune:'Bereinigung'};
const typeLabels = {share:'Freigaben',appdata:'Appdata',docker:'Docker',vm:'Virtuelle Maschinen',flash:'USB-Flash',local:'Lokal',smb:'SMB',nfs:'NFS',sftp:'SFTP'};
const messageLabels = {'backup completed':'Backup 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) => `${esc(statusLabels[value]||value)}`;
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') 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';
return run.jobId || '';
};
const button = (label, action, kind = '') => ``;
const disabledButton = (label, help) => ``;
const actionGroup = (buttons) => `
${buttons}
`;
const formatBytes = (value) => {
const units = ['B','KiB','MiB','GiB','TiB']; let size = Number(value)||0; let unit = 0;
while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; }
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`;
};
const formatEta = value => {
let seconds=Math.max(0,Math.round(Number(value)||0));
const hours=Math.floor(seconds/3600); seconds%=3600;
const minutes=Math.floor(seconds/60); seconds%=60;
return hours?`${hours}h ${minutes}m`:minutes?`${minutes}m ${seconds}s`:`${seconds}s`;
};
const weekDays = [{value:'1',short:'Mo',name:'Montag'},{value:'2',short:'Di',name:'Dienstag'},{value:'3',short:'Mi',name:'Mittwoch'},{value:'4',short:'Do',name:'Donnerstag'},{value:'5',short:'Fr',name:'Freitag'},{value:'6',short:'Sa',name:'Samstag'},{value:'0',short:'So',name:'Sonntag'}];
function parseSchedule(cron = '0 2 * * *') {
const match = String(cron).trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(.+)$/);
if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return {mode:'custom',time:'02:00',days:[],cron};
const time = `${String(match[2]).padStart(2,'0')}:${String(match[1]).padStart(2,'0')}`;
if (match[3] === '*') return {mode:'daily',time,days:[],cron};
if (match[3] === '1-5') return {mode:'weekdays',time,days:[],cron};
const days = match[3].split(',');
if (days.length && days.every(day=>weekDays.some(item=>item.value===day))) return {mode:'selected',time,days:[...new Set(days)],cron};
return {mode:'custom',time,days:[],cron};
}
function scheduleCron(form) {
const mode = form.get('scheduleMode');
if (mode === 'custom') return form.get('customCron');
const [hour, minute] = String(form.get('scheduleTime') || '02:00').split(':');
const dayField = mode === 'daily' ? '*' : mode === 'weekdays' ? '1-5' : form.getAll('scheduleDays').join(',');
if (mode === 'selected' && !dayField) throw new Error('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 === 'custom') return cron || 'Manuell';
if (schedule.mode === 'daily') return `Täglich um ${schedule.time} Uhr`;
if (schedule.mode === 'weekdays') return `Mo–Fr um ${schedule.time} Uhr`;
const labels = weekDays.filter(day=>schedule.days.includes(day.value)).map(day=>day.short).join(', ');
return `${labels} um ${schedule.time} Uhr`;
}
function scheduleFields(job = {}) {
const schedule = parseSchedule(job.schedule?.cron || '0 2 * * *');
const modes = [['daily','Täglich'],['weekdays','Montag bis Freitag'],['selected','Ausgewählte Wochentage']];
if (schedule.mode === 'custom') modes.push(['custom','Bestehenden Cron-Zeitplan beibehalten']);
return ``;
}
function lineChart(runs, field, formatter, emptyText) {
const points = runs.map(run=>({run,value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)}));
if (!points.length || points.every(point=>point.value===0)) return `
Das vollständige Konfigurationslaufwerk /boot wird immer dateibasiert gesichert.
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.
`;
}
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=>`