diff --git a/dist/backupper-2026.06.14.9.12-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.9.12-x86_64-1.txz.sha256 deleted file mode 100644 index e285c03..0000000 --- a/dist/backupper-2026.06.14.9.12-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -33d2cf963aeee0b1b53ee785a506562f3f8d317d9ffd2004b0f4fa44ec0484f5 backupper-2026.06.14.9.12-x86_64-1.txz diff --git a/dist/backupper-2026.06.14.9.12-x86_64-1.txz b/dist/backupper-2026.06.14.9.13-x86_64-1.txz similarity index 55% rename from dist/backupper-2026.06.14.9.12-x86_64-1.txz rename to dist/backupper-2026.06.14.9.13-x86_64-1.txz index 55b438d..067a014 100644 Binary files a/dist/backupper-2026.06.14.9.12-x86_64-1.txz and b/dist/backupper-2026.06.14.9.13-x86_64-1.txz differ diff --git a/dist/backupper-2026.06.14.9.13-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.9.13-x86_64-1.txz.sha256 new file mode 100644 index 0000000..2f3f2eb --- /dev/null +++ b/dist/backupper-2026.06.14.9.13-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +e440c00eec4f43ed4bcfbe6a2e3fcda72f4da0a15fb58fdf6b72346b1c7b0ec9 backupper-2026.06.14.9.13-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index dbc97bd..7b3064b 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,16 @@ - + - + ]> +### 2026.06.14.9.13 +- Add a complete German translation for navigation, forms, activities, settings, actions, and user-facing messages. + ### 2026.06.14.9.12 - Add Pause, Resume, and Abort controls for active Restic operations using Linux process-group signals. diff --git a/plugin/backupper.plg b/plugin/backupper.plg index 125d230..f1302d1 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,16 @@ - + ]> +### 2026.06.14.9.13 +- Add a complete German translation for navigation, forms, activities, settings, actions, and user-facing messages. + ### 2026.06.14.9.12 - Add Pause, Resume, and Abort controls for active Restic operations using Linux process-group signals. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index be33408..57e7150 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,27 +9,27 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.14.9.9

Restic Backup Manager for Unraid

+ +

URBM 2026.06.14.9.13

Restic Backup Manager für Unraid

-
Connecting...
+
Verbindung wird hergestellt...
-
- + diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js index 24f4799..7e7076f 100644 --- a/webgui/assets/backupper.js +++ b/webgui/assets/backupper.js @@ -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) => `${esc(value)}`; + const statusLabels = {success:'Erfolgreich',warning:'Warnung',failed:'Fehlgeschlagen',running:'Läuft',paused:'Pausiert',queued:'Wartet',cancelled:'Abgebrochen'}; + const taskLabels = {backup:'Backup',restore:'Wiederherstellung',check:'Prüfung',prune:'Bereinigung'}; + const typeLabels = {share:'Freigaben',appdata:'Appdata',docker:'Docker',vm:'Virtuelle Maschinen',flash:'USB-Flash',local:'Lokal',smb:'SMB',nfs:'NFS',sftp:'SFTP'}; + const messageLabels = {'backup completed':'Backup abgeschlossen','restore completed':'Wiederherstellung abgeschlossen','check completed':'Prüfung abgeschlossen','prune completed':'Bereinigung abgeschlossen','daemon restarted while task was active':'Daemon wurde während eines aktiven Vorgangs neu gestartet'}; + const status = (value) => `${esc(statusLabels[value]||value)}`; + const displayMessage = value => messageLabels[value] || translateUserMessage(value); const button = (label, action, kind = '') => ``; const disabledButton = (label, help) => ``; const actionGroup = (buttons) => `
${buttons}
`; @@ -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 `
Backup schedule
Weekdays${weekDays.map(day=>``).join('')}
${schedule.mode==='custom'?``:''}
`; + return `
Backup-Zeitplan
Wochentage${weekDays.map(day=>``).join('')}
${schedule.mode==='custom'?``:''}
`; } 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 = `

Connection failed

${esc(error.message)}

`; + health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad'; + content.innerHTML = `

Verbindung fehlgeschlagen

${esc(error.message)}

`; } } @@ -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 `
-
Enabled jobs
${state.config.jobs.filter(j => j.enabled).length}
-
Repositories
${state.config.repositories.length}
-
Queued / running
${active}
-
Recent failures
${failures.length}
+
Aktive Jobs
${state.config.jobs.filter(j => j.enabled).length}
+
Repositorys
${state.config.repositories.length}
+
Wartend / laufend
${active}
+
Letzte Fehler
${failures.length}
-

Backup size

Total source data processed per backup
${lineChart(backups,'bytesProcessed',formatBytes,'Run a backup to collect size history.')}
-

Files backed up

Total files processed per backup
${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Run a backup to collect file history.')}
-

Recent activity

${runsTable(last)}
`; +

Backup-Größe

Verarbeitete Quelldaten pro Backup
${lineChart(backups,'bytesProcessed',formatBytes,'Nach dem ersten Backup wird hier der Größenverlauf angezeigt.')}
+

Gesicherte Dateien

Verarbeitete Dateien pro Backup
${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Nach dem ersten Backup wird hier die Dateianzahl angezeigt.')}
+

Letzte Aktivitäten

${runsTable(last)}
`; } function renderJobs() { - const rows = state.config.jobs.map(j => `${esc(j.name)}
${esc(j.id)}${esc(j.type)}${esc(repoName(j.repositoryId))}${esc(scheduleLabel(j.schedule?.cron))}${j.enabled ? status('success') : status('cancelled')}${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'))}`).join(''); - return `

Backup Jobs

One isolated Restic repository per job.
${button('New job','new-job','primary')}
-
${rows ? `${rows}
JobTypeRepositoryScheduleEnabledActions
` : '
No backup jobs configured.
'}
`; + const rows = state.config.jobs.map(j => `${esc(j.name)}
${esc(j.id)}${esc(typeLabels[j.type]||j.type)}${esc(repoName(j.repositoryId))}${esc(scheduleLabel(j.schedule?.cron))}${j.enabled ? status('success') : status('cancelled')}${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'))}`).join(''); + return `

Backup-Jobs

Jeder Job verwendet ein eigenes Restic-Repository.
${button('Neuer Job','new-job','primary')}
+
${rows ? `${rows}
JobTypRepositoryZeitplanAktivAktionen
` : '
Keine Backup-Jobs konfiguriert.
'}
`; } 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 => ``).join(''); - content.innerHTML = `

${job.id ? 'Edit' : 'New'} backup job

+ content.innerHTML = `

Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}

- - + + ${scheduleFields(job)}
- -
Backup retention

Snapshots matching any enabled rule are kept. Set daily, weekly and monthly to 0 for a strict age limit.

- -
${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}
`; + +
Backup-Aufbewahrung

Snapshots bleiben erhalten, wenn mindestens eine aktivierte Regel zutrifft. Täglich, wöchentlich und monatlich auf 0 setzen, um ausschließlich nach Alter aufzubewahren.

+ +
${button('Speichern','submit-job','primary')}${button('Abbrechen','cancel-form')}
`; 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 = `

${type==='appdata'?'Appdata folders':'Share folders'}

Select one or more folders. Existing file exclude patterns remain available below.
`; + host.innerHTML = `

${type==='appdata'?'Appdata-Ordner':'Freigabeordner'}

Wähle einen oder mehrere Ordner aus. Zusätzliche Dateifilter können darunter eingetragen werden.
`; state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user'; state.sourceDirectories = []; state.sourceBrowserError = ''; loadSourceDirectories(state.sourceBrowserPath); } else if (type === 'docker' || type === 'vm') { - host.innerHTML = `

${type==='docker'?'Docker containers':'Virtual machines'}

Select the workloads to back up. Metadata and discovered bind mounts or virtual disks are included automatically.
Loading...
`; + host.innerHTML = `

${type==='docker'?'Docker-Container':'Virtuelle Maschinen'}

Wähle die zu sichernden Systeme aus. Metadaten und erkannte Bind-Mounts oder virtuelle Datenträger werden automatisch einbezogen.
Wird geladen...
`; loadWorkloads(type); } else { state.backupSelections = ['/boot']; - host.innerHTML = `

Unraid boot backup

The complete /boot configuration drive is backed up. No additional source selection is required.

`; + host.innerHTML = `

Unraid-USB-Flash-Backup

Das vollständige Konfigurationslaufwerk /boot wird gesichert. Es ist keine zusätzliche Quellenauswahl erforderlich.

`; } 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=>`${esc(item.name)}
${esc(item.id)}${esc(item.status||'unknown')}`).join(''); + const rows = state.workloads.map(item=>`${esc(item.name)}
${esc(item.id)}${esc(item.status||'unbekannt')}`).join(''); const notice = state.workloadError ? `
${esc(state.workloadError)}
` : ''; - host.innerHTML = `

${type==='docker'?'Available containers':'Available virtual machines'}

Selected: ${state.workloadSelections.length}
${notice}
${rows||(!state.workloadError?'':'')}
SelectNameStatus
No workloads found.
`; + host.innerHTML = `

${type==='docker'?'Verfügbare Container':'Verfügbare virtuelle Maschinen'}

Ausgewählt: ${state.workloadSelections.length}
${notice}
${rows||(!state.workloadError?'':'')}
AuswahlNameStatus
Keine Systeme gefunden.
`; } 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 => `dir${esc(item.name)}${esc(item.path)}${button('Open',`open-source-dir:${encodeURIComponent(item.path)}`)}`).join(''); - const chosen = state.backupSelections.length ? state.backupSelections.map(path=>`${esc(path)}`).join('') : 'No folders selected.'; + const rows = state.sourceDirectories.map(item => `Ordner${esc(item.name)}${esc(item.path)}${button('Öffnen',`open-source-dir:${encodeURIComponent(item.path)}`)}`).join(''); + const chosen = state.backupSelections.length ? state.backupSelections.map(path=>`${esc(path)}`).join('') : 'Keine Ordner ausgewählt.'; const error = state.sourceBrowserError ? `
${esc(state.sourceBrowserError)}
` : ''; - host.innerHTML = `

Backup source browser

${esc(state.sourceBrowserPath)}
${state.sourceBrowserPath!=='/'?button('Select current folder','select-current-source'):''}${state.sourceBrowserPath!=='/'?button('Up','source-up'):''}Selected: ${state.backupSelections.length}
${error}
${chosen}
${rows||''}
SelectTypeNamePath
No subfolders found.
`; + host.innerHTML = `

Backup-Quellen auswählen

${esc(state.sourceBrowserPath)}
${state.sourceBrowserPath!=='/'?button('Aktuellen Ordner auswählen','select-current-source'):''}${state.sourceBrowserPath!=='/'?button('Nach oben','source-up'):''}Ausgewählt: ${state.backupSelections.length}
${error}
${chosen}
${rows||''}
AuswahlTypNamePfad
Keine Unterordner gefunden.
`; 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 `${esc(r.name)}
${esc(r.id)}${esc(r.type)}${esc(r.location)}${volatileLocation(r)?'Volatile path: data is lost after reboot. Use /mnt/user/...':''}${r.mount?.managed?'Managed':'Direct / external'}${actionGroup(actions)}`; + 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 `${esc(r.name)}
${esc(r.id)}${esc(typeLabels[r.type]||r.type)}${esc(r.location)}${volatileLocation(r)?'Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...':''}${r.mount?.managed?'Durch URBM':'Direkt / extern'}${actionGroup(actions)}`; }).join(''); - return `

Repositories

Local, SMB, NFS and SFTP destinations.
${button('New repository','new-repo','primary')}
${rows ? `${rows}
NameTypeLocationMountActions
` : '
No repositories configured.
'}
`; + return `

Repositorys

Lokale, SMB-, NFS- und SFTP-Ziele.
${button('Neues Repository','new-repo','primary')}
${rows ? `${rows}
NameTypSpeicherortEinbindungAktionen
` : '
Keine Repositorys konfiguriert.
'}
`; } 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 = `

${repo.id?'Edit':'New'} repository

- + content.innerHTML = `

Repository ${repo.id?'bearbeiten':'erstellen'}

+
-
Restic encryption

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.'}

-
${button('Save','submit-repo','primary')}${button('Cancel','cancel-form')}
`; +
Restic-Verschlüsselung

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.'}

+
${button('Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}
`; 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')}
`; + host.innerHTML = `${repositoryLocationField('Lokaler Repository-Ordner',repo.location,'/mnt/user/backups/urbm')}
`; renderRepositoryBrowser(); } else if (type === 'smb' || type === 'nfs') { const managed = repo.mount?.managed === true; - host.innerHTML = `
${type.toUpperCase()} destination
${repositoryLocationField('Existing mounted folder',repo.location,'/mnt/remotes/backup-nas')}
${type==='smb'?``:''}
`; + host.innerHTML = `
${type.toUpperCase()}-Ziel
${repositoryLocationField('Bereits eingebundener Ordner',repo.location,'/mnt/remotes/backup-nas')}
${type==='smb'?``:''}
`; } else { - host.innerHTML = `
SFTP destination
${repositoryLocationField('SFTP repository location',repo.location,'backup@192.168.1.20:/srv/backups/urbm')}
`; + host.innerHTML = `
SFTP-Ziel
${repositoryLocationField('SFTP-Repository-Adresse',repo.location,'backup@192.168.1.20:/srv/backups/urbm')}
`; } enhanceTooltips(host); } @@ -419,9 +448,9 @@ function renderRepositoryBrowser() { const host = document.getElementById('bu-repository-browser'); if (!host) return; - const rows = state.repositoryDirectories.map(item=>`${esc(item.name)}${esc(item.path)}${button('Open',`open-repository-dir:${encodeURIComponent(item.path)}`)}`).join(''); + const rows = state.repositoryDirectories.map(item=>`${esc(item.name)}${esc(item.path)}${button('Öffnen',`open-repository-dir:${encodeURIComponent(item.path)}`)}`).join(''); const error = state.repositoryBrowserError ? `
${esc(state.repositoryBrowserError)}
` : ''; - host.innerHTML = `

Local folder browser

${esc(state.repositoryBrowserPath)}
${state.repositoryBrowserPath!=='/'?button('Use this folder','select-repository-folder','primary'):''}${state.repositoryBrowserPath!=='/'?button('Up','repository-up'):''}
${error}
${rows||''}
NamePath
No subfolders found.
`; + host.innerHTML = `

Lokalen Ordner auswählen

${esc(state.repositoryBrowserPath)}
${state.repositoryBrowserPath!=='/'?button('Diesen Ordner verwenden','select-repository-folder','primary'):''}${state.repositoryBrowserPath!=='/'?button('Nach oben','repository-up'):''}
${error}
${rows||''}
NamePfad
Keine Unterordner gefunden.
`; 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=>``).join(''); - const rows = state.snapshots.map(s=>`${esc(s.short_id||s.id)}${esc(new Date(s.time).toLocaleString())}${esc(s.hostname)}${esc((s.paths||[]).join(', '))}${button('Browse',`browse:${s.id}`)}`).join(''); - return `

Snapshots

${button('Load','load-snapshots','primary')}
${rows?`${rows}
SnapshotTimeHostPaths
`:'
Select a repository and load snapshots.
'}
${state.selectedSnapshot && state.files.length ? renderFileBrowser() : ''}`; + const rows = state.snapshots.map(s=>`${esc(s.short_id||s.id)}${esc(new Date(s.time).toLocaleString())}${esc(s.hostname)}${esc((s.paths||[]).join(', '))}${button('Durchsuchen',`browse:${s.id}`)}`).join(''); + return `

Snapshots

${button('Laden','load-snapshots','primary')}
${rows?`${rows}
SnapshotZeitpunktHostPfade
`:'
Repository auswählen und Snapshots laden.
'}
${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 `${esc(item.type||'')}${esc(item.name||path)}${Number(item.size||0).toLocaleString()}${open}`; + const open = item.type === 'dir' ? button('Öffnen',`open-dir:${encodeURIComponent(path)}`) : ''; + return `${esc(item.type==='dir'?'Ordner':item.type==='file'?'Datei':item.type||'')}${esc(item.name||path)}${Number(item.size||0).toLocaleString()}${open}`; }).join(''); - const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`${esc(path)}`).join('') : 'Nothing selected.'; - return `

Snapshot ${esc(state.selectedSnapshot.slice(0,12))}

${esc(state.browserPath)}
${button('Select current folder','select-current-restore')}${state.browserPath!=='/'?button('Up','browser-up'):''}${button(`Restore selected (${state.restoreIncludes.length})`,'restore-selected','primary')}
${chosen}
${rows}
SelectTypeNameSize
`; + const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`${esc(path)}`).join('') : 'Nichts ausgewählt.'; + return `

Snapshot ${esc(state.selectedSnapshot.slice(0,12))}

${esc(state.browserPath)}
${button('Aktuellen Ordner auswählen','select-current-restore')}${state.browserPath!=='/'?button('Nach oben','browser-up'):''}${button(`Auswahl wiederherstellen (${state.restoreIncludes.length})`,'restore-selected','primary')}
${chosen}
${rows}
AuswahlTypNameGröße
`; } function renderRestore() { const options = state.config.repositories.map(r=>``).join(''); - return `

Restore

${button('Queue restore','submit-restore','primary')}
`; + return `

Wiederherstellen

${button('Wiederherstellung einreihen','submit-restore','primary')}
`; } - function renderActivity() { return `

Activity

Running tasks update automatically every two seconds.
${button('Refresh','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}
${runsTable([...state.runs].reverse())}
`; } + function renderActivity() { return `

Aktivitäten

Laufende Aufgaben werden automatisch alle zwei Sekunden aktualisiert.
${button('Aktualisieren','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}
${runsTable([...state.runs].reverse())}
`; } function progressView(run) { - if(run.status==='queued') return 'Waiting in queue'; - if(run.taskType!=='backup'&&!run.progressPercent) return 'No percentage available'; + if(run.status==='queued') return 'Wartet in der Warteschlange'; + if(run.taskType!=='backup'&&!run.progressPercent) return 'Kein Prozentwert verfügbar'; 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 `
${run.status==='paused'?'Pausiert':scanning?'Scanning…':`${percent.toFixed(1)}%`}${esc(details.join(' · '))}
${run.currentFile?`
${esc(run.currentFile)}
`:''}
`; + if(run.secondsRemaining>0) details.push(`Restzeit ${formatEta(run.secondsRemaining)}`); + return `
${run.status==='paused'?'Pausiert':scanning?'Wird eingelesen…':`${percent.toFixed(1)}%`}${esc(details.join(' · '))}
${run.currentFile?`
${esc(run.currentFile)}
`:''}
`; } 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 `
${run.status==='running'?'Live log':'Run log'} (${run.liveLog.length})
${esc(run.liveLog.join('\n'))}
`; + return `
${run.status==='running'?'Live-Protokoll':'Laufprotokoll'} (${run.liveLog.length})
${esc(run.liveLog.join('\n'))}
`; } function runsTable(runs) { - if(!runs.length) return '
No activity recorded.
'; + if(!runs.length) return '
Keine Aktivitäten vorhanden.
'; const showLiveLog=state.config?.settings?.showLiveLog===true; - return `${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 `${showLiveLog&&(r.liveLog||[]).length?``:''}`;}).join('')}
TaskStatusCreatedProgressMessage
${esc(r.taskType)} ${esc(r.jobId||'')}${status(r.status)}${esc(new Date(r.createdAt).toLocaleString())}${progressView(r)}${esc(r.message||'')}${actionGroup(controls)}
${liveLogView(r)}
`; + return `${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 `${showLiveLog&&(r.liveLog||[]).length?``:''}`;}).join('')}
AufgabeStatusErstelltFortschrittMeldung
${esc(taskLabels[r.taskType]||r.taskType)} ${esc(r.jobId||'')}${status(r.status)}${esc(new Date(r.createdAt).toLocaleString())}${progressView(r)}${esc(displayMessage(r.message))}${actionGroup(controls)}
${liveLogView(r)}
`; } 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])=>``).join(''); - return `

Settings

${button('Save settings','submit-settings','primary')}
-

Unraid notifications

Uses Unraid's notification system and all agents configured under Settings > Notification Settings.

Notify on
${eventBoxes(unraid)}
${button('Save Unraid notifications','submit-unraid-notify','primary')}
-

Gotify notification

Create an application in Gotify and enter its application token here.

Notify on
${eventBoxes(gotify)}
${button('Save Gotify notification','submit-gotify-notify','primary')}
`; + const eventBoxes = target => [['success','Erfolg'],['warning','Warnung'],['failed','Fehler']].map(([value,label])=>``).join(''); + return `

Einstellungen

${button('Einstellungen speichern','submit-settings','primary')}
+

Unraid-Benachrichtigungen

Verwendet das Unraid-Benachrichtigungssystem und alle unter Einstellungen > Benachrichtigungseinstellungen konfigurierten Agenten.

Benachrichtigen bei
${eventBoxes(unraid)}
${button('Unraid-Benachrichtigungen speichern','submit-unraid-notify','primary')}
+

Gotify-Benachrichtigung

Erstelle in Gotify eine Anwendung und trage hier deren Anwendungs-Token ein.

Benachrichtigen bei
${eventBoxes(gotify)}
${button('Gotify-Benachrichtigung speichern','submit-gotify-notify','primary')}
`; } 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(); });