Add German web interface
This commit is contained in:
+137
-108
@@ -34,7 +34,7 @@
|
||||
mountPoint: 'Lokaler Einhängepunkt auf Unraid. Beispiel: /mnt/remotes/backup-nas. Erlaubt sind Pfade unter /mnt/remotes oder /mnt/disks.',
|
||||
credential: 'SMB-Beispiel: username=backup und password=geheim auf getrennten Zeilen. Bei SFTP hier den kompletten privaten SSH-Schlüssel einfügen.',
|
||||
knownHostsPath: 'Datei mit dem geprüften SSH-Hostschlüssel. Beispiel: /root/.ssh/known_hosts. StrictHostKeyChecking ist immer aktiv.',
|
||||
snapshotId: 'Eindeutige ID des Sicherungsstands. Beispiel: 7f3a91cd. Sie kann im Bereich Snapshots über Browse ausgewählt werden.',
|
||||
snapshotId: 'Eindeutige ID des Sicherungsstands. Beispiel: 7f3a91cd. Sie kann im Bereich Snapshots über Durchsuchen ausgewählt werden.',
|
||||
includes: 'Nur diese Pfade werden wiederhergestellt, einer pro Zeile. Beispiel: /mnt/user/Documents/Rechnung.pdf oder /mnt/user/Documents.',
|
||||
target: 'Zielverzeichnis der Wiederherstellung. Sicheres Beispiel: /mnt/user/backupper-restores/restore-123. Standardmäßig wird nicht direkt über Originaldaten geschrieben.',
|
||||
inPlace: 'Erlaubt Wiederherstellung direkt an einen produktiven Zielpfad. Nur aktivieren, wenn vorhandene Dateien bewusst ersetzt werden sollen.',
|
||||
@@ -42,7 +42,7 @@
|
||||
resticPath: 'Pfad zur vom Plugin bereitgestellten Restic-Datei. Standard: /usr/local/libexec/backupper/restic. Normalerweise nicht ändern.',
|
||||
restoreRoot: 'Basisverzeichnis für sichere Staging-Restores. Beispiel: /mnt/user/backupper-restores.',
|
||||
persistentLogDir: 'Optionales Verzeichnis für dauerhafte Detailprotokolle. Beispiel: /mnt/user/system/backupper-logs. Leer lassen, um Flash-Schreibzugriffe zu vermeiden.',
|
||||
showLiveLog: 'Zeigt bereinigte Restic-Laufmeldungen unter den Aufgaben im Activity-Bereich. Standardmäßig deaktiviert; Fortschritt und Ergebnis bleiben trotzdem sichtbar.',
|
||||
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.',
|
||||
@@ -58,13 +58,13 @@
|
||||
'submit-repo': 'Speichert Ziel und Zugangsdaten. Das Repository wird dadurch noch nicht automatisch initialisiert.',
|
||||
'init-repo': 'Initialisiert am Ziel einmalig ein neues, leeres Restic-Repository. Bei einem vorhandenen Repository nur das ursprüngliche Passwort eintragen und Test verwenden.',
|
||||
'load-snapshots': 'Lädt alle Sicherungsstände des ausgewählten Repositorys.',
|
||||
'restore-selected': 'Übernimmt die markierten Dateien oder Ordner in den Restore-Assistenten.',
|
||||
'submit-restore': 'Stellt den Restore in die globale Warteschlange; Backups und Restores laufen nacheinander.',
|
||||
'restore-selected': 'Übernimmt die markierten Dateien oder Ordner in den Wiederherstellungsassistenten.',
|
||||
'submit-restore': 'Stellt die Wiederherstellung in die globale Warteschlange; Backups und Wiederherstellungen laufen nacheinander.',
|
||||
'submit-settings': 'Speichert die globalen URBM-Einstellungen.',
|
||||
'submit-unraid-notify': 'Speichert native Unraid-Benachrichtigungen. Die Zustellung erfolgt über die in Unraid eingerichteten Notification Agents.',
|
||||
'submit-gotify-notify': 'Speichert den Gotify-Kanal und das App-Token verschlüsselt.',
|
||||
'refresh': 'Lädt Laufstatus und Historie erneut vom URBM-Daemon.',
|
||||
'clear-activity': 'Löscht alle abgeschlossenen Activity-Einträge und deren gespeicherte Laufprotokolle. Laufende und wartende Aufgaben bleiben erhalten.',
|
||||
'clear-activity': 'Löscht alle abgeschlossenen Aktivitätseinträge und deren gespeicherte Laufprotokolle. Laufende und wartende Aufgaben bleiben erhalten.',
|
||||
'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.',
|
||||
'browser-up': 'Öffnet im Snapshot-Browser das übergeordnete Verzeichnis.',
|
||||
'source-up': 'Öffnet im Ordner-Browser das übergeordnete Verzeichnis.',
|
||||
@@ -75,6 +75,30 @@
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -83,7 +107,7 @@
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch (_) { body = {error:text}; }
|
||||
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
|
||||
if (!response.ok) throw new Error(translateUserMessage(body?.error || `Anfrage fehlgeschlagen (${response.status})`));
|
||||
return body;
|
||||
};
|
||||
const notify = (message, error = false) => {
|
||||
@@ -112,13 +136,18 @@
|
||||
'open-dir': 'Öffnet dieses Verzeichnis innerhalb des schreibgeschützten Snapshots.',
|
||||
'open-source-dir': 'Öffnet diesen Ordner, um darin weitere Unterordner auszuwählen.',
|
||||
'open-repository-dir': 'Öffnet diesen lokalen Ordner, um ihn oder einen Unterordner als Repository-Ziel auszuwählen.',
|
||||
'cancel-run': 'Bricht die laufende oder wartende Aufgabe ab. Cleanup und Neustart gestoppter Workloads werden trotzdem versucht.',
|
||||
'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 status = (value) => `<span class="bu-status ${esc(value)}" tabindex="0"${tipAttr(`Status dieses Vorgangs. Beispiel: success = erfolgreich, warning = mit Warnung, failed = fehlgeschlagen, running = läuft, paused = pausiert.`)}>${esc(value)}</span>`;
|
||||
const statusLabels = {success:'Erfolgreich',warning:'Warnung',failed:'Fehlgeschlagen',running:'Läuft',paused:'Pausiert',queued:'Wartet',cancelled:'Abgebrochen'};
|
||||
const taskLabels = {backup:'Backup',restore:'Wiederherstellung',check:'Prüfung',prune:'Bereinigung'};
|
||||
const typeLabels = {share:'Freigaben',appdata:'Appdata',docker:'Docker',vm:'Virtuelle Maschinen',flash:'USB-Flash',local:'Lokal',smb:'SMB',nfs:'NFS',sftp:'SFTP'};
|
||||
const messageLabels = {'backup completed':'Backup abgeschlossen','restore completed':'Wiederherstellung abgeschlossen','check completed':'Prüfung abgeschlossen','prune completed':'Bereinigung abgeschlossen','daemon restarted while task was active':'Daemon wurde während eines aktiven Vorgangs neu gestartet'};
|
||||
const status = (value) => `<span class="bu-status ${esc(value)}" tabindex="0"${tipAttr('Aktueller oder endgültiger Status dieses Vorgangs.')}>${esc(statusLabels[value]||value)}</span>`;
|
||||
const displayMessage = value => messageLabels[value] || translateUserMessage(value);
|
||||
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>`;
|
||||
@@ -149,12 +178,12 @@
|
||||
if (mode === 'custom') return form.get('customCron');
|
||||
const [hour, minute] = String(form.get('scheduleTime') || '02:00').split(':');
|
||||
const dayField = mode === 'daily' ? '*' : mode === 'weekdays' ? '1-5' : form.getAll('scheduleDays').join(',');
|
||||
if (mode === 'selected' && !dayField) throw new Error('Select at least one weekday for the backup schedule');
|
||||
if (mode === 'selected' && !dayField) throw new Error('Wähle mindestens einen Wochentag für den Backup-Zeitplan aus.');
|
||||
return `${Number(minute)} ${Number(hour)} * * ${dayField}`;
|
||||
}
|
||||
function scheduleLabel(cron) {
|
||||
const schedule = parseSchedule(cron);
|
||||
if (schedule.mode === 'custom') return cron || 'Manual';
|
||||
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(', ');
|
||||
@@ -164,7 +193,7 @@
|
||||
const schedule = parseSchedule(job.schedule?.cron || '0 2 * * *');
|
||||
const modes = [['daily','Täglich'],['weekdays','Montag bis Freitag'],['selected','Ausgewählte Wochentage']];
|
||||
if (schedule.mode === 'custom') modes.push(['custom','Bestehenden Cron-Zeitplan beibehalten']);
|
||||
return `<fieldset class="wide bu-fieldset bu-schedule"><legend>Backup schedule</legend><div class="bu-schedule-grid"><label>Days<select name="scheduleMode">${modes.map(([value,label])=>`<option value="${value}" ${value===schedule.mode?'selected':''}>${label}</option>`).join('')}</select></label><label>Time<input name="scheduleTime" type="time" required value="${esc(schedule.time)}"></label></div><div class="bu-weekdays" ${schedule.mode==='selected'?'':'hidden'}><span>Weekdays</span>${weekDays.map(day=>`<label><input type="checkbox" name="scheduleDays" value="${day.value}" ${schedule.days.includes(day.value)?'checked':''}><span>${day.short}</span></label>`).join('')}</div>${schedule.mode==='custom'?`<label class="bu-custom-cron">Existing schedule<input name="customCron" readonly value="${esc(schedule.cron)}"></label>`:''}</fieldset>`;
|
||||
return `<fieldset class="wide bu-fieldset bu-schedule"><legend>Backup-Zeitplan</legend><div class="bu-schedule-grid"><label>Tage<select name="scheduleMode">${modes.map(([value,label])=>`<option value="${value}" ${value===schedule.mode?'selected':''}>${label}</option>`).join('')}</select></label><label>Uhrzeit<input name="scheduleTime" type="time" required value="${esc(schedule.time)}"></label></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, emptyText) {
|
||||
@@ -197,29 +226,29 @@
|
||||
}
|
||||
});
|
||||
const dashboardHelp = {
|
||||
'Enabled jobs': 'Anzahl aktivierter Backup-Jobs, die nach Zeitplan laufen dürfen. Beispiel: 3 bedeutet drei aktive Sicherungspläne.',
|
||||
'Repositories': 'Anzahl konfigurierter Restic-Speicherziele. Im MVP gehört jedes Repository genau zu einem Job.',
|
||||
'Queued / running': 'Aufgaben in der Warteschlange oder in Ausführung. URBM führt immer nur eine Aufgabe gleichzeitig aus.',
|
||||
'Recent failures': 'Fehlgeschlagene Läufe in der jüngsten Historie. Details stehen unter Activity.',
|
||||
'Aktive Jobs': 'Anzahl aktivierter Backup-Jobs, die nach Zeitplan laufen dürfen. Beispiel: 3 bedeutet drei aktive Sicherungspläne.',
|
||||
'Repositorys': 'Anzahl konfigurierter Restic-Speicherziele. Im MVP gehört jedes Repository genau zu einem Job.',
|
||||
'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.', Type: 'Art der gesicherten Daten.', Repository: 'Verwendetes Restic-Speicherziel.', Schedule: 'Wochentage und Uhrzeit des automatischen Backups.', Enabled: 'Zeigt, ob der Job automatisch laufen darf.', Actions: 'Verfügbare Aktionen für diesen Eintrag.', Name: 'Anzeigename und interne ID.', Location: 'Pfad oder Adresse des Restic-Ziels.', Mount: 'Zeigt, ob URBM das Netzwerkziel selbst mountet.', Snapshot: 'Eindeutiger Sicherungsstand.', Time: 'Erstellungszeitpunkt des Snapshots.', Host: 'Hostname des Systems, das den Snapshot erstellt hat.', Paths: 'Im Snapshot enthaltene Quellpfade.', Task: 'Art und Ziel der Aufgabe.', Status: 'Aktueller oder endgültiger Laufstatus.', Created: 'Zeitpunkt, an dem die Aufgabe eingereiht wurde.', Message: 'Ergebnis oder Fehlerbeschreibung.', Size: 'Gespeicherte Dateigröße im Snapshot.'
|
||||
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 den Restore auswählen. Beispielpfad: ${box.value}`));
|
||||
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.',
|
||||
'Repositories': 'Speicherziele und verschlüsselte Zugangsdaten für Restic.',
|
||||
'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.',
|
||||
'Restore': 'Wiederherstellung ausgewählter Dateien oder Ordner. Staging ist die sichere Standardmethode.',
|
||||
'Activity': 'Globale Warteschlange und Historie aller URBM-Aufgaben.',
|
||||
'Settings': 'Globale Pfade sowie Zeitpläne für Check, Prune und Nachholläufe.',
|
||||
'Unraid notifications': 'Native Meldungen im Unraid-Webinterface und über alle unter Einstellungen > Benachrichtigungen aktivierten Agenten.',
|
||||
'Gotify notification': 'Optionaler direkter Push-Kanal zu deinem eigenen Gotify-Server.',
|
||||
'Recent activity': 'Die zuletzt gestarteten Backup-, Restore- und Wartungsvorgänge.',
|
||||
'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()]));
|
||||
}
|
||||
@@ -258,10 +287,10 @@
|
||||
try {
|
||||
const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]);
|
||||
state.config = config; state.runs = runs;
|
||||
health.textContent = `Daemon online (${daemon.version || 'unknown'})`; health.className = 'bu-health ok'; render();
|
||||
health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render();
|
||||
} catch (error) {
|
||||
health.textContent = 'Daemon unavailable'; health.className = 'bu-health bad';
|
||||
content.innerHTML = `<div class="bu-card"><h2>Connection failed</h2><p>${esc(error.message)}</p></div>`;
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,20 +306,20 @@
|
||||
const last = [...state.runs].reverse().slice(0, 8);
|
||||
const backups = state.runs.filter(r=>r.taskType==='backup' && ['success','warning'].includes(r.status)).slice(-20);
|
||||
return `<div class="bu-grid">
|
||||
<section class="bu-card"><div class="bu-muted">Enabled jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></section>
|
||||
<section class="bu-card"><div class="bu-muted">Repositories</div><div class="bu-stat">${state.config.repositories.length}</div></section>
|
||||
<section class="bu-card"><div class="bu-muted">Queued / running</div><div class="bu-stat">${active}</div></section>
|
||||
<section class="bu-card"><div class="bu-muted">Recent failures</div><div class="bu-stat">${failures.length}</div></section>
|
||||
<section class="bu-card"><div class="bu-muted">Aktive Jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></section>
|
||||
<section class="bu-card"><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></section>
|
||||
<section class="bu-card"><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></section>
|
||||
<section class="bu-card"><div class="bu-muted">Letzte Fehler</div><div class="bu-stat">${failures.length}</div></section>
|
||||
</div><div class="bu-chart-grid-layout">
|
||||
<section class="bu-card"><h2>Backup size</h2><div class="bu-muted">Total source data processed per backup</div>${lineChart(backups,'bytesProcessed',formatBytes,'Run a backup to collect size history.')}</section>
|
||||
<section class="bu-card"><h2>Files backed up</h2><div class="bu-muted">Total files processed per backup</div>${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Run a backup to collect file history.')}</section>
|
||||
</div><section class="bu-card" style="margin-top:16px"><h2>Recent activity</h2>${runsTable(last)}</section>`;
|
||||
<section class="bu-card"><h2>Backup-Größe</h2><div class="bu-muted">Verarbeitete Quelldaten pro Backup</div>${lineChart(backups,'bytesProcessed',formatBytes,'Nach dem ersten Backup wird hier der Größenverlauf angezeigt.')}</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(),'Nach dem ersten Backup wird hier die Dateianzahl angezeigt.')}</section>
|
||||
</div><section class="bu-card" style="margin-top:16px"><h2>Letzte Aktivitäten</h2>${runsTable(last)}</section>`;
|
||||
}
|
||||
|
||||
function renderJobs() {
|
||||
const rows = state.config.jobs.map(j => `<tr><td><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td>${esc(j.type)}</td><td>${esc(repoName(j.repositoryId))}</td><td>${esc(scheduleLabel(j.schedule?.cron))}</td><td>${j.enabled ? status('success') : status('cancelled')}</td><td>${actionGroup(button('Run',`run-job:${j.id}`,'primary')+button('Edit',`edit-job:${j.id}`)+button('Duplicate',`duplicate-job:${j.id}`)+button('Delete',`delete-job:${j.id}`,'danger'))}</td></tr>`).join('');
|
||||
return `<div class="bu-toolbar"><div><h2>Backup Jobs</h2><div class="bu-muted">One isolated Restic repository per job.</div></div>${button('New job','new-job','primary')}</div>
|
||||
<section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Job</th><th>Type</th><th>Repository</th><th>Schedule</th><th>Enabled</th><th>Actions</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">No backup jobs configured.</div>'}</section>`;
|
||||
const rows = state.config.jobs.map(j => `<tr><td><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td>${esc(typeLabels[j.type]||j.type)}</td><td>${esc(repoName(j.repositoryId))}</td><td>${esc(scheduleLabel(j.schedule?.cron))}</td><td>${j.enabled ? status('success') : status('cancelled')}</td><td>${actionGroup(button('Starten',`run-job:${j.id}`,'primary')+button('Bearbeiten',`edit-job:${j.id}`)+button('Duplizieren',`duplicate-job:${j.id}`)+button('Löschen',`delete-job:${j.id}`,'danger'))}</td></tr>`).join('');
|
||||
return `<div class="bu-toolbar"><div><h2>Backup-Jobs</h2><div class="bu-muted">Jeder Job verwendet ein eigenes Restic-Repository.</div></div>${button('Neuer Job','new-job','primary')}</div>
|
||||
<section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Job</th><th>Typ</th><th>Repository</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 = {}) {
|
||||
@@ -299,16 +328,16 @@
|
||||
state.workloads = []; state.workloadAvailable = true; state.workloadError = '';
|
||||
state.sourceBrowserPath = '/'; state.sourceDirectories = sourceRoots; state.sourceBrowserError = '';
|
||||
const options = state.config.repositories.map(r => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join('');
|
||||
content.innerHTML = `<section class="bu-card"><h2>${job.id ? 'Edit' : 'New'} backup job</h2><form id="bu-job-form" class="bu-form">
|
||||
content.innerHTML = `<section class="bu-card"><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><form id="bu-job-form" class="bu-form">
|
||||
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
|
||||
<label>Type<select name="type">${['share','appdata','docker','vm','flash'].map(v=>`<option ${v===job.type?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
<label>Repository<select name="repositoryId" required>${options}</select></label><label>Compression<select name="compression">${['auto','off','max'].map(v=>`<option ${v===(job.compression||'auto')?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
<label>Typ<select name="type">${['share','appdata','docker','vm','flash'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
|
||||
<label>Repository<select name="repositoryId" required>${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>
|
||||
${scheduleFields(job)}
|
||||
<div id="bu-job-specific" class="wide"></div>
|
||||
<label class="wide">Exclude patterns, one per line<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
|
||||
<fieldset class="wide bu-fieldset"><legend>Backup retention</legend><p class="bu-muted">Snapshots matching any enabled rule are kept. Set daily, weekly and monthly to 0 for a strict age limit.</p><div class="bu-retention-grid"><label>Keep all backups for days<input name="keepWithinDays" type="number" min="0" value="${job.retention?.keepWithinDays ?? 30}"></label><label>Additional daily backups<input name="keepDaily" type="number" min="0" value="${job.retention?.daily ?? 0}"></label><label>Additional weekly backups<input name="keepWeekly" type="number" min="0" value="${job.retention?.weekly ?? 0}"></label><label>Additional monthly backups<input name="keepMonthly" type="number" min="0" value="${job.retention?.monthly ?? 0}"></label></div></fieldset>
|
||||
<label><span>Enabled</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label>
|
||||
<div class="wide bu-actions">${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
|
||||
<label class="wide">Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
|
||||
<fieldset 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>
|
||||
<label><span>Aktiviert</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label>
|
||||
<div class="wide bu-actions">${button('Speichern','submit-job','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
|
||||
enhanceTooltips(content);
|
||||
renderJobSpecific(job.type || 'share', job);
|
||||
}
|
||||
@@ -317,16 +346,16 @@
|
||||
const host = document.getElementById('bu-job-specific');
|
||||
if (!host) return;
|
||||
if (type === 'share' || type === 'appdata') {
|
||||
host.innerHTML = `<h3>${type==='appdata'?'Appdata folders':'Share folders'}</h3><div class="bu-muted">Select one or more folders. Existing file exclude patterns remain available below.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Additional manual paths<textarea name="sources"></textarea></label>`;
|
||||
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>`;
|
||||
state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user';
|
||||
state.sourceDirectories = []; state.sourceBrowserError = '';
|
||||
loadSourceDirectories(state.sourceBrowserPath);
|
||||
} else if (type === 'docker' || type === 'vm') {
|
||||
host.innerHTML = `<h3>${type==='docker'?'Docker containers':'Virtual machines'}</h3><div class="bu-muted">Select the workloads to back up. Metadata and discovered bind mounts or virtual disks are included automatically.</div><div id="bu-workload-browser" class="bu-browser-card"><div class="bu-empty">Loading...</div></div><div class="bu-workload-options"><label>Consistency<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Controlled stop</option></select></label><label>Shutdown timeout (seconds)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label></div>`;
|
||||
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 {
|
||||
state.backupSelections = ['/boot'];
|
||||
host.innerHTML = `<div class="bu-info-card"><h3>Unraid boot backup</h3><p>The complete <strong>/boot</strong> configuration drive is backed up. No additional source selection is required.</p></div>`;
|
||||
host.innerHTML = `<div class="bu-info-card"><h3>Unraid-USB-Flash-Backup</h3><p>Das vollständige Konfigurationslaufwerk <strong>/boot</strong> wird gesichert. Es ist keine zusätzliche Quellenauswahl erforderlich.</p></div>`;
|
||||
}
|
||||
enhanceTooltips(host);
|
||||
}
|
||||
@@ -335,16 +364,16 @@
|
||||
const host = document.getElementById('bu-workload-browser');
|
||||
if (!host) return;
|
||||
const selected = new Set(state.workloadSelections);
|
||||
const rows = state.workloads.map(item=>`<tr><td><input type="checkbox" class="bu-workload-select" value="${esc(item.id)}" ${selected.has(item.id)?'checked':''}></td><td><strong>${esc(item.name)}</strong><br><span class="bu-muted">${esc(item.id)}</span></td><td>${esc(item.status||'unknown')}</td></tr>`).join('');
|
||||
const 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'?'Available containers':'Available virtual machines'}</h3><span class="bu-count">Selected: ${state.workloadSelections.length}</span></div>${notice}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Select</th><th>Name</th><th>Status</th></tr></thead><tbody>${rows||(!state.workloadError?'<tr><td colspan="3" class="bu-empty">No workloads found.</td></tr>':'')}</tbody></table></div>`;
|
||||
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 = `Workloads could not be loaded: ${error.message}`;
|
||||
state.workloads = []; state.workloadAvailable = true; state.workloadError = `Systeme konnten nicht geladen werden: ${error.message}`;
|
||||
}
|
||||
renderWorkloadBrowser(type);
|
||||
}
|
||||
@@ -353,10 +382,10 @@
|
||||
const host = document.getElementById('bu-source-browser');
|
||||
if (!host) return;
|
||||
const selected = new Set(state.backupSelections);
|
||||
const rows = state.sourceDirectories.map(item => `<tr><td><input type="checkbox" class="bu-source-select" value="${esc(item.path)}" ${selected.has(item.path)?'checked':''}></td><td>dir</td><td>${esc(item.name)}</td><td>${esc(item.path)}</td><td>${button('Open',`open-source-dir:${encodeURIComponent(item.path)}`)}</td></tr>`).join('');
|
||||
const chosen = state.backupSelections.length ? state.backupSelections.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">No folders selected.</span>';
|
||||
const rows = state.sourceDirectories.map(item => `<tr><td><input type="checkbox" class="bu-source-select" value="${esc(item.path)}" ${selected.has(item.path)?'checked':''}></td><td>Ordner</td><td>${esc(item.name)}</td><td>${esc(item.path)}</td><td>${button('Öffnen',`open-source-dir:${encodeURIComponent(item.path)}`)}</td></tr>`).join('');
|
||||
const chosen = state.backupSelections.length ? state.backupSelections.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">Keine Ordner ausgewählt.</span>';
|
||||
const error = state.sourceBrowserError ? `<div class="bu-inline-error">${esc(state.sourceBrowserError)}</div>` : '';
|
||||
host.innerHTML = `<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Backup source browser</h3><span class="bu-muted">${esc(state.sourceBrowserPath)}</span></div><div class="bu-actions">${state.sourceBrowserPath!=='/'?button('Select current folder','select-current-source'):''}${state.sourceBrowserPath!=='/'?button('Up','source-up'):''}<span class="bu-count">Selected: ${state.backupSelections.length}</span></div></div>${error}<div class="bu-selection-list">${chosen}</div><div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Select</th><th>Type</th><th>Name</th><th>Path</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="5" class="bu-empty">No subfolders found.</td></tr>'}</tbody></table></div></section>`;
|
||||
host.innerHTML = `<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Backup-Quellen auswählen</h3><span class="bu-muted">${esc(state.sourceBrowserPath)}</span></div><div class="bu-actions">${state.sourceBrowserPath!=='/'?button('Aktuellen Ordner auswählen','select-current-source'):''}${state.sourceBrowserPath!=='/'?button('Nach oben','source-up'):''}<span class="bu-count">Ausgewählt: ${state.backupSelections.length}</span></div></div>${error}<div class="bu-selection-list">${chosen}</div><div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Auswahl</th><th>Typ</th><th>Name</th><th>Pfad</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="5" class="bu-empty">Keine Unterordner gefunden.</td></tr>'}</tbody></table></div></section>`;
|
||||
enhanceTooltips(host);
|
||||
}
|
||||
|
||||
@@ -370,29 +399,29 @@
|
||||
state.sourceBrowserError = '';
|
||||
renderSourceBrowser();
|
||||
} catch (error) {
|
||||
state.sourceBrowserError = `Folder could not be loaded: ${error.message}`;
|
||||
state.sourceBrowserError = `Ordner konnte nicht geladen werden: ${error.message}`;
|
||||
renderSourceBrowser();
|
||||
}
|
||||
}
|
||||
|
||||
function renderRepositories() {
|
||||
const rows = state.config.repositories.map(r => {
|
||||
const initialize = volatileLocation(r) ? disabledButton('Initialize','Dieses Ziel liegt im flüchtigen RAM. Zuerst über Edit einen dauerhaften Pfad wie /mnt/user/Backups/HomeServer eintragen.') : button('Initialize',`init-repo:${r.id}`,'primary');
|
||||
const actions = initialize+button('Test',`test-repo:${r.id}`)+button('Snapshots',`repo-snapshots:${r.id}`)+button('Check',`maint:check:${r.id}`)+button('Prune',`maint:prune:${r.id}`)+button('Edit',`edit-repo:${r.id}`)+button('Delete',`delete-repo:${r.id}`,'danger');
|
||||
return `<tr><td><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td>${esc(r.type)}</td><td>${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Volatile path: data is lost after reboot. Use /mnt/user/...</span>':''}</td><td>${r.mount?.managed?'Managed':'Direct / external'}</td><td>${actionGroup(actions)}</td></tr>`;
|
||||
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}`,'primary');
|
||||
const actions = initialize+button('Testen',`test-repo:${r.id}`)+button('Snapshots',`repo-snapshots:${r.id}`)+button('Prüfen',`maint:check:${r.id}`)+button('Bereinigen',`maint:prune:${r.id}`)+button('Bearbeiten',`edit-repo:${r.id}`)+button('Löschen',`delete-repo:${r.id}`,'danger');
|
||||
return `<tr><td><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td>${esc(typeLabels[r.type]||r.type)}</td><td>${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...</span>':''}</td><td>${r.mount?.managed?'Durch URBM':'Direkt / extern'}</td><td>${actionGroup(actions)}</td></tr>`;
|
||||
}).join('');
|
||||
return `<div class="bu-toolbar"><div><h2>Repositories</h2><div class="bu-muted">Local, SMB, NFS and SFTP destinations.</div></div>${button('New repository','new-repo','primary')}</div><section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Name</th><th>Type</th><th>Location</th><th>Mount</th><th>Actions</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">No repositories configured.</div>'}</section>`;
|
||||
return `<div class="bu-toolbar"><div><h2>Repositorys</h2><div class="bu-muted">Lokale, SMB-, NFS- und SFTP-Ziele.</div></div>${button('Neues Repository','new-repo','primary')}</div><section class="bu-card">${rows ? `<table class="bu-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 = {}) {
|
||||
state.repositoryBrowserPath = '/'; state.repositoryDirectories = repositoryRoots; state.repositoryBrowserError = '';
|
||||
const passwordRef=repo.passwordRef||id('repo-password');
|
||||
const credentialRef=repo.credentialRef||id('repo-credential');
|
||||
content.innerHTML = `<section class="bu-card"><h2>${repo.id?'Edit':'New'} repository</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}"><input type="hidden" name="passwordRef" value="${esc(passwordRef)}"><input type="hidden" name="credentialRef" value="${esc(credentialRef)}"><input type="hidden" name="hasCredential" value="${repo.credentialRef?'true':'false'}">
|
||||
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Type<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option ${v===repo.type?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
content.innerHTML = `<section class="bu-card"><h2>Repository ${repo.id?'bearbeiten':'erstellen'}</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}"><input type="hidden" name="passwordRef" value="${esc(passwordRef)}"><input type="hidden" name="credentialRef" value="${esc(credentialRef)}"><input type="hidden" name="hasCredential" value="${repo.credentialRef?'true':'false'}">
|
||||
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>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>
|
||||
<fieldset class="wide bu-fieldset"><legend>Restic encryption</legend><label>Repository password<input name="password" type="password" autocomplete="new-password" ${repo.id?'':'required'} placeholder="${repo.id?'Leave blank to keep existing':'Enter a strong repository password'}"></label><p class="bu-muted">Encrypted by Restic and stored securely by URBM. ${repo.id?'Leave blank to keep the existing password.':'Keep this password safe; backups cannot be restored without it.'}</p></fieldset>
|
||||
<div class="wide bu-actions">${button('Save','submit-repo','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
|
||||
<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><label>Repository-Passwort<input name="password" type="password" autocomplete="new-password" ${repo.id?'':'required'} placeholder="${repo.id?'Leer lassen, um das vorhandene Passwort zu behalten':'Ein starkes Repository-Passwort eingeben'}"></label><p class="bu-muted">Das Passwort wird von URBM geschützt gespeichert. ${repo.id?'Leer lassen, um das vorhandene Passwort zu behalten.':'Bewahre dieses Passwort sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.'}</p></fieldset>
|
||||
<div class="wide bu-actions">${button('Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
|
||||
enhanceTooltips(content);
|
||||
renderRepositorySpecific(repo.type || 'local', repo);
|
||||
}
|
||||
@@ -405,13 +434,13 @@
|
||||
const host = document.getElementById('bu-repository-specific');
|
||||
if (!host) return;
|
||||
if (type === 'local') {
|
||||
host.innerHTML = `${repositoryLocationField('Local repository folder',repo.location,'/mnt/user/backups/urbm')}<div id="bu-repository-browser"></div>`;
|
||||
host.innerHTML = `${repositoryLocationField('Lokaler Repository-Ordner',repo.location,'/mnt/user/backups/urbm')}<div id="bu-repository-browser"></div>`;
|
||||
renderRepositoryBrowser();
|
||||
} else if (type === 'smb' || type === 'nfs') {
|
||||
const managed = repo.mount?.managed === true;
|
||||
host.innerHTML = `<fieldset class="bu-fieldset"><legend>${type.toUpperCase()} destination</legend><label class="bu-inline-check"><input name="managed" type="checkbox" ${managed?'checked':''}><span>URBM should mount this share</span></label><div class="bu-external-mount" ${managed?'hidden':''}>${repositoryLocationField('Existing mounted folder',repo.location,'/mnt/remotes/backup-nas')}</div><div class="bu-managed-mount" ${managed?'':'hidden'}><div class="bu-form-grid"><label>Remote share<input name="remote" placeholder="${type==='smb'?'//192.168.1.20/backup':'192.168.1.20:/volume1/backup'}" value="${esc(repo.mount?.remote||'')}"></label><label>Mount point<input name="mountPoint" placeholder="/mnt/remotes/urbm-backup" value="${esc(repo.mount?.mountPoint||repo.location||'')}"></label>${type==='smb'?`<label class="wide">SMB credentials<textarea name="credential" placeholder="${repo.credentialRef?'Leave blank to keep existing credentials':'username=backup password=secret'}"></textarea></label>`:''}</div></div></fieldset>`;
|
||||
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 destination</legend><div class="bu-form-grid">${repositoryLocationField('SFTP repository location',repo.location,'backup@192.168.1.20:/srv/backups/urbm')}<label>SFTP known_hosts path<input name="knownHostsPath" value="${esc(repo.options?.knownHostsPath||'/root/.ssh/known_hosts')}"></label><label class="wide">Private SSH key (optional)<textarea name="credential" placeholder="${repo.credentialRef?'Leave blank to keep existing private key':'-----BEGIN OPENSSH PRIVATE KEY-----'}"></textarea></label></div></fieldset>`;
|
||||
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);
|
||||
}
|
||||
@@ -419,9 +448,9 @@
|
||||
function renderRepositoryBrowser() {
|
||||
const host = document.getElementById('bu-repository-browser');
|
||||
if (!host) return;
|
||||
const rows = state.repositoryDirectories.map(item=>`<tr><td>${esc(item.name)}</td><td>${esc(item.path)}</td><td>${button('Open',`open-repository-dir:${encodeURIComponent(item.path)}`)}</td></tr>`).join('');
|
||||
const rows = state.repositoryDirectories.map(item=>`<tr><td>${esc(item.name)}</td><td>${esc(item.path)}</td><td>${button('Öffnen',`open-repository-dir:${encodeURIComponent(item.path)}`)}</td></tr>`).join('');
|
||||
const error = state.repositoryBrowserError ? `<div class="bu-inline-error">${esc(state.repositoryBrowserError)}</div>` : '';
|
||||
host.innerHTML = `<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Local folder browser</h3><span class="bu-muted">${esc(state.repositoryBrowserPath)}</span></div><div class="bu-actions">${state.repositoryBrowserPath!=='/'?button('Use this folder','select-repository-folder','primary'):''}${state.repositoryBrowserPath!=='/'?button('Up','repository-up'):''}</div></div>${error}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Name</th><th>Path</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="3" class="bu-empty">No subfolders found.</td></tr>'}</tbody></table></div></section>`;
|
||||
host.innerHTML = `<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Lokalen Ordner auswählen</h3><span class="bu-muted">${esc(state.repositoryBrowserPath)}</span></div><div class="bu-actions">${state.repositoryBrowserPath!=='/'?button('Diesen Ordner verwenden','select-repository-folder','primary'):''}${state.repositoryBrowserPath!=='/'?button('Nach oben','repository-up'):''}</div></div>${error}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Name</th><th>Pfad</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="3" class="bu-empty">Keine Unterordner gefunden.</td></tr>'}</tbody></table></div></section>`;
|
||||
enhanceTooltips(host);
|
||||
}
|
||||
|
||||
@@ -433,70 +462,70 @@
|
||||
state.repositoryDirectories=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
|
||||
state.repositoryBrowserPath=path; state.repositoryBrowserError='';
|
||||
} catch (error) {
|
||||
state.repositoryBrowserError=`Folder could not be loaded: ${error.message}`;
|
||||
state.repositoryBrowserError=`Ordner konnte nicht geladen werden: ${error.message}`;
|
||||
}
|
||||
renderRepositoryBrowser();
|
||||
}
|
||||
|
||||
function renderSnapshots() {
|
||||
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
|
||||
const rows = state.snapshots.map(s=>`<tr><td>${esc(s.short_id||s.id)}</td><td>${esc(new Date(s.time).toLocaleString())}</td><td>${esc(s.hostname)}</td><td>${esc((s.paths||[]).join(', '))}</td><td>${button('Browse',`browse:${s.id}`)}</td></tr>`).join('');
|
||||
return `<div class="bu-toolbar"><h2>Snapshots</h2><div class="bu-actions"><select id="bu-snapshot-repo">${options}</select>${button('Load','load-snapshots','primary')}</div></div><section class="bu-card">${rows?`<table class="bu-table"><thead><tr><th>Snapshot</th><th>Time</th><th>Host</th><th>Paths</th><th></th></tr></thead><tbody>${rows}</tbody></table>`:'<div class="bu-empty">Select a repository and load snapshots.</div>'}</section>${state.selectedSnapshot && state.files.length ? renderFileBrowser() : ''}`;
|
||||
const rows = state.snapshots.map(s=>`<tr><td>${esc(s.short_id||s.id)}</td><td>${esc(new Date(s.time).toLocaleString())}</td><td>${esc(s.hostname)}</td><td>${esc((s.paths||[]).join(', '))}</td><td>${button('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?`<table class="bu-table"><thead><tr><th>Snapshot</th><th>Zeitpunkt</th><th>Host</th><th>Pfade</th><th></th></tr></thead><tbody>${rows}</tbody></table>`:'<div class="bu-empty">Repository auswählen und Snapshots laden.</div>'}</section>${state.selectedSnapshot && state.files.length ? renderFileBrowser() : ''}`;
|
||||
}
|
||||
|
||||
function renderFileBrowser() {
|
||||
const selected = new Set(state.restoreIncludes);
|
||||
const rows = state.files.map(item => {
|
||||
const path = item.path || `${state.browserPath.replace(/\/$/,'')}/${item.name||''}`;
|
||||
const open = item.type === 'dir' ? button('Open',`open-dir:${encodeURIComponent(path)}`) : '';
|
||||
return `<tr><td><input type="checkbox" class="bu-file-select" value="${esc(path)}" ${selected.has(path)?'checked':''}></td><td>${esc(item.type||'')}</td><td>${esc(item.name||path)}</td><td>${Number(item.size||0).toLocaleString()}</td><td>${open}</td></tr>`;
|
||||
const open = item.type === 'dir' ? button('Öffnen',`open-dir:${encodeURIComponent(path)}`) : '';
|
||||
return `<tr><td><input type="checkbox" class="bu-file-select" value="${esc(path)}" ${selected.has(path)?'checked':''}></td><td>${esc(item.type==='dir'?'Ordner':item.type==='file'?'Datei':item.type||'')}</td><td>${esc(item.name||path)}</td><td>${Number(item.size||0).toLocaleString()}</td><td>${open}</td></tr>`;
|
||||
}).join('');
|
||||
const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">Nothing selected.</span>';
|
||||
return `<section class="bu-card" style="margin-top:16px"><div class="bu-toolbar"><div><h3>Snapshot ${esc(state.selectedSnapshot.slice(0,12))}</h3><span class="bu-muted">${esc(state.browserPath)}</span></div><div class="bu-actions">${button('Select current folder','select-current-restore')}${state.browserPath!=='/'?button('Up','browser-up'):''}${button(`Restore selected (${state.restoreIncludes.length})`,'restore-selected','primary')}</div></div><div class="bu-selection-list">${chosen}</div><table class="bu-table"><thead><tr><th>Select</th><th>Type</th><th>Name</th><th>Size</th><th></th></tr></thead><tbody>${rows}</tbody></table></section>`;
|
||||
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>';
|
||||
return `<section class="bu-card" style="margin-top:16px"><div class="bu-toolbar"><div><h3>Snapshot ${esc(state.selectedSnapshot.slice(0,12))}</h3><span class="bu-muted">${esc(state.browserPath)}</span></div><div class="bu-actions">${button('Aktuellen Ordner auswählen','select-current-restore')}${state.browserPath!=='/'?button('Nach oben','browser-up'):''}${button(`Auswahl wiederherstellen (${state.restoreIncludes.length})`,'restore-selected','primary')}</div></div><div class="bu-selection-list">${chosen}</div><table class="bu-table"><thead><tr><th>Auswahl</th><th>Typ</th><th>Name</th><th>Größe</th><th></th></tr></thead><tbody>${rows}</tbody></table></section>`;
|
||||
}
|
||||
|
||||
function renderRestore() {
|
||||
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
|
||||
return `<section class="bu-card"><h2>Restore</h2><form id="bu-restore-form" class="bu-form"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Include paths, one per line<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Target<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><label>In-place restore<input name="inPlace" type="checkbox"></label><label>Confirm overwrite risk<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Queue restore','submit-restore','primary')}</div></form></section>`;
|
||||
return `<section class="bu-card"><h2>Wiederherstellen</h2><form id="bu-restore-form" class="bu-form"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot-ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Wiederherzustellende Pfade, einer pro Zeile<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Ziel<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><label>Am Originalort wiederherstellen<input name="inPlace" type="checkbox"></label><label>Überschreibungsrisiko bestätigen<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Wiederherstellung einreihen','submit-restore','primary')}</div></form></section>`;
|
||||
}
|
||||
|
||||
function renderActivity() { return `<div class="bu-toolbar"><div><h2>Activity</h2><div class="bu-muted">Running tasks update automatically every two seconds.</div></div><div class="bu-actions">${button('Refresh','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}</div></div><section class="bu-card">${runsTable([...state.runs].reverse())}</section>`; }
|
||||
function 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([...state.runs].reverse())}</section>`; }
|
||||
function progressView(run) {
|
||||
if(run.status==='queued') return '<span class="bu-muted">Waiting in queue</span>';
|
||||
if(run.taskType!=='backup'&&!run.progressPercent) return '<span class="bu-muted">No percentage available</span>';
|
||||
if(run.status==='queued') return '<span class="bu-muted">Wartet in der Warteschlange</span>';
|
||||
if(run.taskType!=='backup'&&!run.progressPercent) return '<span class="bu-muted">Kein Prozentwert verfügbar</span>';
|
||||
const percent=Math.max(0,Math.min(100,Number(run.progressPercent)||0));
|
||||
const scanning=run.status==='running'&&percent===0;
|
||||
const details=[];
|
||||
if(run.progressTotal) details.push(`${formatBytes(run.progressBytes)} / ${formatBytes(run.progressTotal)}`);
|
||||
if(run.progressFileTotal) details.push(`${Number(run.progressFiles||0).toLocaleString()} / ${Number(run.progressFileTotal).toLocaleString()} files`);
|
||||
if(run.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(`ETA ${formatEta(run.secondsRemaining)}`);
|
||||
return `<div class="bu-progress-wrap"><div class="bu-progress-label"><strong>${run.status==='paused'?'Pausiert':scanning?'Scanning…':`${percent.toFixed(1)}%`}</strong><span>${esc(details.join(' · '))}</span></div><div class="bu-progress ${scanning?'indeterminate':''}"><span style="width:${scanning?30:percent}%"></span></div>${run.currentFile?`<div class="bu-current-file" title="${esc(run.currentFile)}">${esc(run.currentFile)}</div>`:''}</div>`;
|
||||
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?'Wird eingelesen…':`${percent.toFixed(1)}%`}</strong><span>${esc(details.join(' · '))}</span></div><div class="bu-progress ${scanning?'indeterminate':''}"><span style="width:${scanning?30:percent}%"></span></div>${run.currentFile?`<div class="bu-current-file" title="${esc(run.currentFile)}">${esc(run.currentFile)}</div>`:''}</div>`;
|
||||
}
|
||||
function liveLogView(run) {
|
||||
if(!(run.liveLog||[]).length) return '';
|
||||
const open=Object.prototype.hasOwnProperty.call(state.liveLogOpen,run.id)?state.liveLogOpen[run.id]:run.status==='running';
|
||||
return `<details class="bu-live-log" data-run-id="${esc(run.id)}" ${open?'open':''}><summary>${run.status==='running'?'Live log':'Run log'} (${run.liveLog.length})</summary><pre>${esc(run.liveLog.join('\n'))}</pre></details>`;
|
||||
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">No activity recorded.</div>';
|
||||
if(!runs.length) return '<div class="bu-empty">Keine Aktivitäten vorhanden.</div>';
|
||||
const showLiveLog=state.config?.settings?.showLiveLog===true;
|
||||
return `<table class="bu-table bu-runs-table"><thead><tr><th>Task</th><th>Status</th><th>Created</th><th>Progress</th><th>Message</th><th></th></tr></thead><tbody>${runs.map(r=>{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'):'';return `<tr><td>${esc(r.taskType)} ${esc(r.jobId||'')}</td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${progressView(r)}</td><td>${esc(r.message||'')}</td><td>${actionGroup(controls)}</td></tr>${showLiveLog&&(r.liveLog||[]).length?`<tr class="bu-log-row"><td colspan="6">${liveLogView(r)}</td></tr>`:''}`;}).join('')}</tbody></table>`;
|
||||
return `<table class="bu-table bu-runs-table"><thead><tr><th>Aufgabe</th><th>Status</th><th>Erstellt</th><th>Fortschritt</th><th>Meldung</th><th></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'):'';return `<tr><td>${esc(taskLabels[r.taskType]||r.taskType)} ${esc(r.jobId||'')}</td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${progressView(r)}</td><td>${esc(displayMessage(r.message))}</td><td>${actionGroup(controls)}</td></tr>${showLiveLog&&(r.liveLog||[]).length?`<tr class="bu-log-row"><td colspan="6">${liveLogView(r)}</td></tr>`:''}`;}).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
const s = state.config.settings;
|
||||
const unraid = state.config.notifications.find(n=>n.type==='unraid') || {id:'unraid',enabled:true,events:['success','warning','failed']};
|
||||
const gotify = state.config.notifications.find(n=>n.type==='gotify') || {id:'gotify',enabled:false,events:['success','warning','failed']};
|
||||
const eventBoxes = target => [['success','Success'],['warning','Warning'],['failed','Failed']].map(([value,label])=>`<label><input name="events" type="checkbox" value="${value}" ${(target.events||[]).includes(value)?'checked':''}><span>${label}</span></label>`).join('');
|
||||
return `<section class="bu-card"><h2>Settings</h2><form id="bu-settings-form" class="bu-form"><label class="wide">Restic binary<input name="resticPath" value="${esc(s.resticPath)}"></label><label class="wide">Restore staging root<input name="restoreRoot" value="${esc(s.restoreRoot)}"></label><label class="wide">Persistent log directory<input name="persistentLogDir" value="${esc(s.persistentLogDir||'')}"></label><label class="wide bu-inline-check"><input name="showLiveLog" type="checkbox" ${s.showLiveLog===true?'checked':''}><span>Live-Log im Activity-Bereich anzeigen</span></label><label>Catch-up window (hours)<input name="catchUp" type="number" value="${s.catchUpWindowHours||24}"></label><label>Monthly check cron<input name="checkCron" value="${esc(s.checkCron)}"></label><label>Prune cron<input name="pruneCron" value="${esc(s.pruneCron)}"></label><div class="wide bu-actions">${button('Save settings','submit-settings','primary')}</div></form></section>
|
||||
<section class="bu-card" style="margin-top:16px"><h2>Unraid notifications</h2><p class="bu-muted">Uses Unraid's notification system and all agents configured under Settings > Notification Settings.</p><form id="bu-unraid-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(unraid.id)}"><label><span>Enabled</span><input name="enabled" type="checkbox" ${unraid.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Notify on</legend><div class="bu-event-options">${eventBoxes(unraid)}</div></fieldset><div class="wide bu-actions">${button('Save Unraid notifications','submit-unraid-notify','primary')}</div></form></section>
|
||||
<section class="bu-card" style="margin-top:16px"><h2>Gotify notification</h2><p class="bu-muted">Create an application in Gotify and enter its application token here.</p><form id="bu-gotify-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(gotify.id||'gotify')}"><input type="hidden" name="tokenRef" value="${esc(gotify.tokenRef||id('gotify-token'))}"><label class="wide">Server URL<input name="url" type="url" placeholder="https://gotify.example.de" value="${esc(gotify.url||'')}"></label><label>Application token<input name="token" type="password" autocomplete="new-password" placeholder="${gotify.tokenRef?'Leave blank to keep existing token':'Enter the Gotify application token'}"></label><label><span>Enabled</span><input name="enabled" type="checkbox" ${gotify.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Notify on</legend><div class="bu-event-options">${eventBoxes(gotify)}</div></fieldset><div class="wide bu-actions">${button('Save Gotify notification','submit-gotify-notify','primary')}</div></form></section>`;
|
||||
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><label class="wide">Verzeichnis für dauerhafte Protokolle<input name="persistentLogDir" value="${esc(s.persistentLogDir||'')}"></label><label class="wide bu-inline-check"><input name="showLiveLog" type="checkbox" ${s.showLiveLog===true?'checked':''}><span>Live-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('Configuration saved');
|
||||
notify('Konfiguration gespeichert');
|
||||
return state.config;
|
||||
}
|
||||
const lines = (value) => value.split('\n').map(v=>v.trim()).filter(Boolean);
|
||||
@@ -510,14 +539,14 @@
|
||||
if (name === 'edit-repo') return repositoryForm(state.config.repositories.find(r=>r.id===a));
|
||||
if (name === 'cancel-form') return render();
|
||||
if (name === 'refresh') return load();
|
||||
if (name === 'run-job') { await api(`/v1/jobs/${a}/run`,{method:'POST'}); notify('Backup queued'); return load(); }
|
||||
if (name === '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 === 'clear-activity') { if(confirm('Alle abgeschlossenen Aktivitäten und deren gespeicherte Laufprotokolle löschen?\n\nLaufende und wartende Aufgaben bleiben erhalten.')) { const result=await api('/v1/runs',{method:'DELETE'}); notify(result.cleared?`${result.cleared} abgeschlossene Aktivitäten gelöscht`:'Keine abgeschlossenen Aktivitäten vorhanden'); return load(); } return; }
|
||||
if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository connection succeeded'); }
|
||||
if (name === 'init-repo') { if(confirm('Create a NEW Restic repository here?\n\nOnly continue if this destination does not already contain a Restic repository. For an existing repository, enter its original password under Edit and use Test.')) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository initialized successfully'); } return; }
|
||||
if (name === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a} queued`); return load(); }
|
||||
if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository-Verbindung erfolgreich getestet'); }
|
||||
if (name === 'init-repo') { if(confirm('Hier ein NEUES Restic-Repository erstellen?\n\nNur fortfahren, wenn dieses Ziel noch kein Restic-Repository enthält. Bei einem vorhandenen Repository das ursprüngliche Passwort unter Bearbeiten eintragen und Testen verwenden.')) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository erfolgreich initialisiert'); } 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=await api(`/v1/repositories/${repo}/snapshots`); state.snapshotRepo=repo; return render(); }
|
||||
if (name === 'browse') { state.selectedSnapshot=a; state.browserPath='/'; state.restoreIncludes=[]; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${a}/files?path=${encodeURIComponent('/')}`); return render(); }
|
||||
@@ -530,10 +559,10 @@
|
||||
if (name === 'open-dir') { state.browserPath=decodeURIComponent(a); state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files?path=${encodeURIComponent(state.browserPath)}`); return render(); }
|
||||
if (name === 'browser-up') { state.browserPath=state.browserPath.replace(/\/$/,'').split('/').slice(0,-1).join('/')||'/'; state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files?path=${encodeURIComponent(state.browserPath)}`); return render(); }
|
||||
if (name === 'select-current-restore') { if(!state.restoreIncludes.includes(state.browserPath)) state.restoreIncludes.push(state.browserPath); return render(); }
|
||||
if (name === 'restore-selected') { if(!state.restoreIncludes.length) throw new Error('Select at least one file or directory'); state.view='restore'; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view==='restore')); return render(); }
|
||||
if (name === 'delete-job') { if(confirm('Delete this job?')) { state.config.jobs=state.config.jobs.filter(j=>j.id!==a); await saveConfig(); render(); } return; }
|
||||
if (name === 'duplicate-job') { const source=state.config.jobs.find(j=>j.id===a); const copy=structuredClone(source); copy.id=id('job'); copy.name += ' copy'; copy.enabled=false; state.config.jobs.push(copy); await saveConfig(); return render(); }
|
||||
if (name === 'delete-repo') { if(state.config.jobs.some(j=>j.repositoryId===a)) throw new Error('Repository is still referenced by a job'); if(confirm('Delete this repository?')) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; }
|
||||
if (name === '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') { if(confirm('Diesen 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.'); if(confirm('Dieses Repository aus der URBM-Konfiguration löschen? Die Backup-Daten am Ziel bleiben erhalten.')) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; }
|
||||
if (name.startsWith('submit-')) return submit(name);
|
||||
} catch (error) { notify(error.message, true); }
|
||||
}
|
||||
@@ -546,32 +575,32 @@
|
||||
else if(type==='flash') sources=[];
|
||||
else sources=[...state.backupSelections.map(path=>({path})),...lines(f.get('sources')||'').map(path=>({path}))].filter((item,index,all)=>all.findIndex(other=>other.path===item.path)===index);
|
||||
const retention={keepWithinDays:Number(f.get('keepWithinDays')),daily:Number(f.get('keepDaily')),weekly:Number(f.get('keepWeekly')),monthly:Number(f.get('keepMonthly'))};
|
||||
if(!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Retention must keep at least one age or calendar policy');
|
||||
if(!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.');
|
||||
const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)};
|
||||
state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
|
||||
}
|
||||
if (kind === 'submit-repo') {
|
||||
const f=new FormData(document.getElementById('bu-repo-form')); const value=name=>String(f.get(name)||'').trim(); const type=value('type'); const managed=(type==='smb'||type==='nfs')&&f.get('managed')==='on';
|
||||
let location=managed?value('mountPoint'):value('location');
|
||||
if(!location) throw new Error(managed?'Mount point is required':'Repository location is required');
|
||||
if(managed&&!value('remote')) throw new Error('Remote share is required for a managed mount');
|
||||
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')}: {}};
|
||||
if(!value('id')&&!f.get('password')) throw new Error('A repository password is required for a new repository');
|
||||
if (volatileLocation(repo) && !confirm('WARNING: This destination is under /tmp or /run and will be lost after an Unraid reboot. Save it anyway?')) return;
|
||||
if(!value('id')&&!f.get('password')) throw new Error('Für ein neues Repository ist ein Passwort erforderlich.');
|
||||
if (volatileLocation(repo) && !confirm('WARNUNG: Dieses Ziel liegt unter /tmp oder /run und geht nach einem Unraid-Neustart verloren. Trotzdem speichern?')) return;
|
||||
if(f.get('password')) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:f.get('password')})});
|
||||
if(f.get('credential')) await api(`/v1/secrets/${encodeURIComponent(repo.credentialRef)}`,{method:'PUT',body:JSON.stringify({type:type==='sftp'?'sftp-private-key':'smb-credential',value:f.get('credential')})});
|
||||
state.config.repositories=state.config.repositories.filter(r=>r.id!==repo.id).concat(repo);
|
||||
await saveConfig();
|
||||
state.config = await api('/v1/config');
|
||||
const saved = state.config.repositories.find(r=>r.id===repo.id);
|
||||
if (!saved || saved.location !== repo.location) throw new Error(`Repository location was not saved. Expected: ${repo.location}`);
|
||||
render(); notify(`Repository saved: ${saved.location}`);
|
||||
if (!saved || saved.location !== repo.location) throw new Error(`Repository-Speicherort wurde nicht gespeichert. Erwartet: ${repo.location}`);
|
||||
render(); notify(`Repository gespeichert: ${saved.location}`);
|
||||
}
|
||||
if (kind === 'submit-restore') { const f=new FormData(document.getElementById('bu-restore-form')); await api('/v1/restores',{method:'POST',body:JSON.stringify({schemaVersion:1,id:'',repositoryId:f.get('repositoryId'),snapshotId:f.get('snapshotId'),includes:lines(f.get('includes')),target:f.get('target'),inPlace:f.get('inPlace')==='on',confirmed:f.get('confirmed')==='on'})}); notify('Restore queued'); state.view='activity'; render(); }
|
||||
if (kind === 'submit-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'),restoreRoot:f.get('restoreRoot'),persistentLogDir:f.get('persistentLogDir'),showLiveLog:f.get('showLiveLog')==='on',catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); render(); }
|
||||
if (kind === 'submit-unraid-notify') { const f=new FormData(document.getElementById('bu-unraid-notify-form')); const target={schemaVersion:1,id:f.get('id')||'unraid',name:'Unraid',type:'unraid',enabled:f.get('enabled')==='on',events:f.getAll('events')}; if(target.enabled&&!target.events.length) throw new Error('Select at least one event for Unraid notifications'); state.config.notifications=state.config.notifications.filter(n=>n.type!=='unraid'&&n.type!=='ntfy').concat(target); await saveConfig(); render(); }
|
||||
if (kind === 'submit-gotify-notify') { const f=new FormData(document.getElementById('bu-gotify-notify-form')); const existing=state.config.notifications.find(n=>n.type==='gotify'); const target={schemaVersion:1,id:f.get('id')||'gotify',name:'Gotify',type:'gotify',enabled:f.get('enabled')==='on',url:String(f.get('url')||'').trim(),tokenRef:String(f.get('tokenRef')||'').trim(),events:f.getAll('events')}; if(target.enabled&&!target.url) throw new Error('Gotify server URL is required'); if(target.enabled&&!target.events.length) throw new Error('Select at least one event for Gotify notifications'); if(target.enabled&&!existing&&!f.get('token')) throw new Error('A Gotify application token is required'); if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'gotify-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='gotify'&&n.type!=='ntfy'); if(target.enabled||target.url) state.config.notifications.push(target); await saveConfig(); render(); }
|
||||
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(); }
|
||||
}
|
||||
|
||||
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(); });
|
||||
|
||||
Reference in New Issue
Block a user