1111 lines
115 KiB
JavaScript
1111 lines
115 KiB
JavaScript
(() => {
|
||
'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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 `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 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 = 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 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 > 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){ state.backupMetrics=await api('/v1/backup-metrics'); if(['dashboard','activity'].includes(state.view))render(); } }catch(_){ } },2000);
|
||
load();
|
||
})();
|