(() => { 'use strict'; const root = document.getElementById('backupper-app'); if (!root) return; const content = document.getElementById('bu-content'); const health = document.getElementById('bu-health'); const toast = document.getElementById('bu-toast'); const tooltip = document.getElementById('bu-tooltip'); const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path})); const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '' }; const fieldHelp = { name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', type: 'Art der zu sichernden Daten. Beispiel: appdata für Containerdaten, vm für virtuelle Maschinen oder flash für den Unraid-USB-Stick.', repositoryId: 'Speicherziel für diesen Vorgang. Jeder Backup-Job verwendet ein eigenes Restic-Repository.', scheduleMode: 'Legt fest, an welchen Tagen das Backup startet. Beispiel: täglich, Montag bis Freitag oder ausgewählte Wochentage.', scheduleTime: 'Uhrzeit für den automatischen Start. Beispiel: 02:30 startet das Backup nachts um halb drei.', scheduleDays: 'Wähle mindestens einen Wochentag. Beispiel: Montag, Mittwoch und Sonntag jeweils zur eingestellten Uhrzeit.', consistency: 'Live sichert ohne Unterbrechung, kann aber nur crash-konsistent sein. Controlled stop beendet Container oder VM vor dem Backup und startet sie danach wieder.', compression: 'Restic-Kompression. auto ist empfohlen; off spart CPU; max spart eher Speicher, benötigt aber mehr CPU-Zeit.', sources: 'Optionale manuelle Quellen, eine pro Zeile. Ordner wählst du einfacher über die Checkbox-Liste. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.', excludes: 'Muster für Dateien, die nicht gesichert werden. Ein Muster pro Zeile. Beispiele: *.tmp, cache/** oder /mnt/user/appdata/plex/Cache/**.', shutdownSecs: 'Maximale Wartezeit für einen kontrollierten Stopp. Beispiel: 120 wartet bis zu zwei Minuten; danach wird das Backup ohne Force-Stop abgebrochen.', keepWithinDays: 'Behält alle Snapshots, die innerhalb dieser Anzahl Tage erstellt wurden. Beispiel: 30 entfernt ältere Snapshots beim nächsten erfolgreichen Backup.', keepDaily: 'Zusätzliche tägliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.', keepWeekly: 'Zusätzliche wöchentliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.', keepMonthly: 'Zusätzliche monatliche Stände außerhalb der Altersfrist. 0 deaktiviert diese Zusatzregel.', enabled: 'Aktiviert automatische geplante Läufe. Deaktivierte Jobs können weiterhin gespeichert, aber nicht automatisch ausgeführt werden.', location: 'Restic-Ziel. Lokal: /mnt/user/backups/job1. SFTP: user@server:/backup/job1. Bei SMB/NFS mit Mount wird hier der Mount-Pfad verwendet.', passwordRef: 'Interne ID des verschlüsselt gespeicherten Restic-Passworts. Beispiel: repo-password-appdata. Keine Leerzeichen verwenden.', password: 'Passwort zur Restic-Verschlüsselung. Beispiel: eine lange zufällige Passphrase. Leer lassen, wenn ein vorhandenes Passwort unverändert bleiben soll.', managed: 'Wenn aktiv, mountet Backupper SMB/NFS vor dem Job selbst. Wenn aus, muss der angegebene Pfad bereits durch Unraid oder Unassigned Devices gemountet sein.', remote: 'Adresse der Netzwerkfreigabe. SMB-Beispiel: //192.168.1.20/backup. NFS-Beispiel: 192.168.1.20:/volume1/backup.', mountPoint: 'Lokaler Einhängepunkt auf Unraid. Beispiel: /mnt/remotes/backup-nas. Erlaubt sind Pfade unter /mnt/remotes oder /mnt/disks.', credentialRef: 'Interne ID für SMB-Zugangsdaten oder einen SFTP-Schlüssel. Beispiel: nas-login oder sftp-backup-key.', 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.', 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.', confirmed: 'Zusätzliche Bestätigung für einen In-place-Restore. Ohne diese Bestätigung wird ein direkter Restore blockiert.', resticPath: 'Pfad zur vom Plugin bereitgestellten Restic-Datei. Standard: /usr/local/libexec/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.', catchUp: 'Wie viele Stunden nach einem verpassten Termin ein Job beim Boot nachgeholt werden darf. Beispiel: 24 holt einen innerhalb des letzten Tages verpassten Lauf nach.', checkCron: 'Zeitplan für vollständige Repository-Prüfungen. Beispiel: 0 3 1 * * prüft am ersten Tag jedes Monats um 03:00 Uhr.', pruneCron: 'Zeitplan zum Freigeben nicht mehr benötigter Restic-Daten. Beispiel: 0 4 * * 0 startet sonntags um 04:00 Uhr.', url: 'Adresse des ntfy-Servers. Öffentliches Beispiel: https://ntfy.sh. Für einen eigenen Server z. B. https://ntfy.example.de.', topic: 'ntfy-Thema, an das Meldungen gesendet werden. Beispiel: unraid-backup-7f42. Verwende bei ntfy.sh einen schwer erratbaren Namen.', tokenRef: 'Interne ID für das verschlüsselt gespeicherte ntfy-Token. Beispiel: ntfy-token-main.', token: 'Optionales Bearer-Token deines ntfy-Servers. Leer lassen, wenn das Topic keine Anmeldung benötigt.', }; const actionHelp = { 'new-job': 'Öffnet den Assistenten für einen neuen Backup-Job. Beispiel: tägliches Appdata-Backup.', 'submit-job': 'Prüft und speichert diesen Backup-Job.', 'new-repo': 'Legt ein neues, isoliertes Restic-Speicherziel an.', 'submit-repo': 'Speichert Ziel und Zugangsdaten. Das Repository wird dadurch noch nicht automatisch initialisiert.', 'init-repo': 'Initialisiert am konfigurierten Ziel ein neues, leeres Restic-Repository. Beispiel: nach dem erstmaligen Speichern eines neuen Backup-Ziels. Nicht für ein bereits vorhandenes Repository 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.', 'submit-settings': 'Speichert die globalen Backupper-Einstellungen.', 'submit-notify': 'Speichert den ntfy-Kanal und das Token verschlüsselt.', 'refresh': 'Lädt Laufstatus und Historie erneut vom Backupper-Daemon.', 'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.', 'browser-up': 'Öffnet im Snapshot-Browser das übergeordnete Verzeichnis.', 'source-up': 'Öffnet im Ordner-Browser das übergeordnete Verzeichnis.', 'select-current-source': 'Wählt den aktuell geöffneten Ordner vollständig als Backup-Quelle aus.', 'select-current-restore': 'Wählt den aktuell geöffneten Snapshot-Ordner vollständig für die Wiederherstellung aus.', }; const esc = (value) => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); const api = async (path, options = {}) => { const response = await fetch(`${root.dataset.api}?path=${encodeURIComponent(path)}`, { ...options, headers: {'Content-Type':'application/json', 'X-CSRF-Token':window.BACKUPPER_CSRF || window.csrf_token || '', ...(options.headers || {})} }); const text = await response.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch (_) { body = {error:text}; } if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`); return body; }; const notify = (message, error = false) => { toast.textContent = message; toast.className = `show${error ? ' error' : ''}`; clearTimeout(notify.timer); notify.timer = setTimeout(() => toast.className = '', 4500); }; const id = (prefix) => `${prefix}-${crypto.randomUUID()}`; const repoName = (repoId) => state.config.repositories.find(r => r.id === repoId)?.name || repoId; const volatileLocation = (repo) => repo.type === 'local' && (/^\/tmp(?:\/|$)/.test(repo.location) || /^\/run(?:\/|$)/.test(repo.location)); const tipAttr = (text) => text ? ` data-tooltip="${esc(text)}"` : ''; const actionTooltip = (action, label) => { const [name] = action.split(':'); if (actionHelp[name]) return actionHelp[name]; const dynamic = { 'run-job': 'Startet diesen Backup-Job sofort. Beispiel: manuelles Backup vor einem Unraid-Update.', 'edit-job': 'Öffnet die Einstellungen dieses Backup-Jobs zum Bearbeiten.', 'duplicate-job': 'Erstellt eine deaktivierte Kopie als Vorlage. Danach Name, Repository und Quellen anpassen.', 'delete-job': 'Löscht die Job-Konfiguration. Vorhandene Restic-Snapshots im Repository bleiben erhalten.', 'test-repo': 'Prüft Erreichbarkeit, Passwort und Restic-Zugriff, ohne ein Backup zu starten.', 'init-repo': 'Erstellt die Restic-Struktur am Speicherziel. Diese Aktion ist einmalig für ein neues, leeres Ziel erforderlich.', 'repo-snapshots': 'Wechselt zu den Sicherungsständen dieses Repositorys.', 'maint': action.includes(':check:') ? 'Prüft Struktur und Integrität des Restic-Repositorys.' : 'Entfernt nach Retention nicht mehr benötigte Datenblöcke und gibt Speicher frei.', 'edit-repo': 'Öffnet Ziel-, Mount- und Zugangseinstellungen dieses Repositorys.', 'delete-repo': 'Löscht nur die Backupper-Konfiguration. Die Backupdaten am Speicherziel werden nicht gelöscht.', 'browse': 'Öffnet den Dateibaum dieses Snapshots, um einzelne Dateien oder Ordner auszuwählen.', 'open-dir': 'Öffnet dieses Verzeichnis innerhalb des schreibgeschützten Snapshots.', 'open-source-dir': 'Öffnet diesen Ordner, um darin weitere Unterordner auszuwählen.', 'cancel-run': 'Bricht die laufende oder wartende Aufgabe ab. Cleanup und Neustart gestoppter Workloads werden trotzdem versucht.', }; return dynamic[name] || `${label} ausführen.`; }; const status = (value) => `${esc(value)}`; const button = (label, action, kind = '') => ``; const disabledButton = (label, help) => ``; const actionGroup = (buttons) => `
${buttons}
`; const formatBytes = (value) => { const units = ['B','KiB','MiB','GiB','TiB']; let size = Number(value)||0; let unit = 0; while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; } return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`; }; const weekDays = [{value:'1',short:'Mo',name:'Montag'},{value:'2',short:'Di',name:'Dienstag'},{value:'3',short:'Mi',name:'Mittwoch'},{value:'4',short:'Do',name:'Donnerstag'},{value:'5',short:'Fr',name:'Freitag'},{value:'6',short:'Sa',name:'Samstag'},{value:'0',short:'So',name:'Sonntag'}]; function parseSchedule(cron = '0 2 * * *') { const match = String(cron).trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(.+)$/); if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return {mode:'custom',time:'02:00',days:[],cron}; const time = `${String(match[2]).padStart(2,'0')}:${String(match[1]).padStart(2,'0')}`; if (match[3] === '*') return {mode:'daily',time,days:[],cron}; if (match[3] === '1-5') return {mode:'weekdays',time,days:[],cron}; const days = match[3].split(','); if (days.length && days.every(day=>weekDays.some(item=>item.value===day))) return {mode:'selected',time,days:[...new Set(days)],cron}; return {mode:'custom',time,days:[],cron}; } function scheduleCron(form) { const mode = form.get('scheduleMode'); if (mode === 'custom') return form.get('customCron'); const [hour, minute] = String(form.get('scheduleTime') || '02:00').split(':'); const dayField = mode === 'daily' ? '*' : mode === 'weekdays' ? '1-5' : form.getAll('scheduleDays').join(','); if (mode === 'selected' && !dayField) throw new Error('Select at least one weekday for the backup schedule'); return `${Number(minute)} ${Number(hour)} * * ${dayField}`; } function scheduleLabel(cron) { const schedule = parseSchedule(cron); if (schedule.mode === 'custom') return cron || 'Manual'; if (schedule.mode === 'daily') return `Täglich um ${schedule.time} Uhr`; if (schedule.mode === 'weekdays') return `Mo–Fr um ${schedule.time} Uhr`; const labels = weekDays.filter(day=>schedule.days.includes(day.value)).map(day=>day.short).join(', '); return `${labels} um ${schedule.time} Uhr`; } function scheduleFields(job = {}) { const schedule = parseSchedule(job.schedule?.cron || '0 2 * * *'); const modes = [['daily','Täglich'],['weekdays','Montag bis Freitag'],['selected','Ausgewählte Wochentage']]; if (schedule.mode === 'custom') modes.push(['custom','Bestehenden Cron-Zeitplan beibehalten']); return `
Backup schedule
Weekdays${weekDays.map(day=>``).join('')}
${schedule.mode==='custom'?``:''}
`; } function lineChart(runs, field, formatter, emptyText) { const points = runs.map(run=>({run,value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)})); if (!points.length || points.every(point=>point.value===0)) return `
${esc(emptyText)}
`; const width=720, height=220, left=54, right=18, top=18, bottom=38, innerW=width-left-right, innerH=height-top-bottom; const max=Math.max(...points.map(point=>point.value),1); const coords=points.map((point,index)=>({ ...point, x:left+(points.length===1?innerW/2:index*innerW/(points.length-1)), y:top+innerH-(point.value/max)*innerH })); const polyline=coords.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' '); const area=`${left},${top+innerH} ${polyline} ${left+innerW},${top+innerH}`; const grid=[0,.25,.5,.75,1].map(ratio=>{const y=top+innerH-ratio*innerH; return `${esc(formatter(max*ratio))}`;}).join(''); const dots=coords.map(point=>{const job=state.config.jobs.find(item=>item.id===point.run.jobId)?.name||point.run.jobId; const date=new Date(point.run.finishedAt||point.run.createdAt).toLocaleString(); return `${esc(`${date} · ${job} · ${formatter(point.value)}`)}`;}).join(''); const first=new Date(points[0].run.finishedAt||points[0].run.createdAt).toLocaleDateString(); const last=new Date(points.at(-1).run.finishedAt||points.at(-1).run.createdAt).toLocaleDateString(); return `${grid}${dots}${esc(first)}${esc(last)}`; } function setTooltip(element, text) { if (!element || !text || element.dataset.tooltip) return; element.dataset.tooltip = text; if (!['INPUT','SELECT','TEXTAREA','BUTTON'].includes(element.tagName) && !element.hasAttribute('tabindex')) element.tabIndex = 0; } function enhanceTooltips(container = root) { container.querySelectorAll('.bu-form [name]').forEach(control => { const help = fieldHelp[control.name]; if (help) { setTooltip(control, help); setTooltip(control.closest('label'), help); } }); const dashboardHelp = { '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. Backupper führt immer nur eine Aufgabe gleichzeitig aus.', 'Recent failures': 'Fehlgeschlagene Läufe in der jüngsten Historie. Details stehen unter Activity.', }; 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 Backupper 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.' }; 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}`)); const sectionHelp = { 'Backup Jobs': 'Sicherungspläne verbinden Quellen, Zeitplan und genau ein Restic-Repository.', 'Repositories': '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 Backupper-Aufgaben.', 'Settings': 'Globale Pfade sowie Zeitpläne für Check, Prune und Nachholläufe.', 'ntfy notification': 'Optionaler Push-Kanal für Erfolg, Warnung und Fehler.', 'Recent activity': 'Die zuletzt gestarteten Backup-, Restore- und Wartungsvorgänge.', }; container.querySelectorAll('h2, h3').forEach(heading => setTooltip(heading, sectionHelp[heading.textContent.trim()])); } let tooltipTimer = null; let tooltipTarget = null; function hideTooltip() { clearTimeout(tooltipTimer); tooltipTimer = null; tooltipTarget = null; tooltip.classList.remove('visible', 'below'); tooltip.setAttribute('aria-hidden', 'true'); } function placeTooltip(target) { const rect = target.getBoundingClientRect(); tooltip.classList.add('visible'); tooltip.style.left = '0px'; tooltip.style.top = '0px'; const box = tooltip.getBoundingClientRect(); const gap = 10; let left = Math.max(10, Math.min(rect.left + rect.width / 2 - box.width / 2, window.innerWidth - box.width - 10)); let top = rect.top - box.height - gap; const below = top < 8; if (below) top = Math.min(window.innerHeight - box.height - 8, rect.bottom + gap); tooltip.classList.toggle('below', below); tooltip.style.setProperty('--bu-tooltip-arrow', `${Math.max(14, Math.min(box.width - 20, rect.left + rect.width / 2 - left - 6))}px`); tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; } function showTooltip(target, delay = 1200) { const text = target?.dataset?.tooltip; if (!text) return; clearTimeout(tooltipTimer); tooltipTarget = target; tooltipTimer = setTimeout(() => { if (tooltipTarget !== target || !document.contains(target)) return; tooltip.textContent = text; tooltip.setAttribute('aria-hidden', 'false'); placeTooltip(target); }, delay); } async function load() { try { const [config, runs, 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(); } catch (error) { health.textContent = 'Daemon unavailable'; health.className = 'bu-health bad'; content.innerHTML = `

Connection failed

${esc(error.message)}

`; } } function render() { const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, settings:renderSettings}; content.innerHTML = views[state.view](); enhanceTooltips(content); } function renderDashboard() { const active = state.runs.filter(r => ['queued','running'].includes(r.status)).length; const failures = state.runs.filter(r => r.status === 'failed').slice(-5); 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}

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)}
`; } 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.
'}
`; } function jobForm(job = {}) { state.backupSelections = (job.sources || []).filter(s=>s.path).map(s=>s.path); state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId); state.workloads = []; state.workloadAvailable = true; state.workloadError = ''; state.sourceBrowserPath = '/'; state.sourceDirectories = sourceRoots; state.sourceBrowserError = ''; const options = state.config.repositories.map(r => ``).join(''); content.innerHTML = `

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

${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')}
`; enhanceTooltips(content); renderJobSpecific(job.type || 'share', job); } function renderJobSpecific(type, job = {}) { 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.
`; 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...
`; loadWorkloads(type); } else { state.backupSelections = ['/boot']; host.innerHTML = `

Unraid USB flash backup

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

`; } enhanceTooltips(host); } function renderWorkloadBrowser(type) { const host = document.getElementById('bu-workload-browser'); if (!host) return; const selected = new Set(state.workloadSelections); const rows = state.workloads.map(item=>`${esc(item.name)}
${esc(item.id)}${esc(item.status||'unknown')}`).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.
`; } 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}`; } renderWorkloadBrowser(type); } function renderSourceBrowser() { 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 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.
`; enhanceTooltips(host); } async function loadSourceDirectories(path) { if (path === '/') { state.sourceDirectories = sourceRoots; state.sourceBrowserPath = '/'; state.sourceBrowserError = ''; renderSourceBrowser(); return; } try { state.sourceDirectories = await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`); state.sourceBrowserPath = path; state.sourceBrowserError = ''; renderSourceBrowser(); } catch (error) { state.sourceBrowserError = `Folder could not be loaded: ${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)}`; }).join(''); return `

Repositories

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

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

${button('Save','submit-repo','primary')}${button('Cancel','cancel-form')}
`; enhanceTooltips(content); } 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() : ''}`; } 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}`; }).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
`; } function renderRestore() { const options = state.config.repositories.map(r=>``).join(''); return `

Restore

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

Activity

${button('Refresh','refresh','primary')}
${runsTable([...state.runs].reverse())}
`; } function runsTable(runs) { return runs.length ? `${runs.map(r=>``).join('')}
TaskStatusCreatedMessage
${esc(r.taskType)} ${esc(r.jobId||'')}${status(r.status)}${esc(new Date(r.createdAt).toLocaleString())}${esc(r.message||'')}${['queued','running'].includes(r.status)?button('Cancel',`cancel-run:${r.id}`,'danger'):''}
` : '
No activity recorded.
'; } function renderSettings() { const s = state.config.settings; const ntfy = state.config.notifications.find(n=>n.type==='ntfy') || {}; return `

Settings

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

ntfy notification

${button('Save notification','submit-notify','primary')}
`; } async function saveConfig() { state.config = await api('/v1/config',{method:'PUT',body:JSON.stringify(state.config)}); notify('Configuration saved'); return state.config; } const lines = (value) => value.split('\n').map(v=>v.trim()).filter(Boolean); async function handle(action, element) { const [name, a, b] = action.split(':'); try { if (name === 'new-job') return jobForm(); if (name === 'edit-job') return jobForm(state.config.jobs.find(j=>j.id===a)); if (name === 'new-repo') return repositoryForm(); if (name === '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 === 'cancel-run') { await api(`/v1/runs/${a}/cancel`,{method:'POST'}); return load(); } if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository connection succeeded'); } if (name === 'init-repo') { if(confirm('Initialize a new Restic repository at this destination? Use this only for a new, empty backup destination.')) { 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 === '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(); } if (name === 'open-source-dir') { return loadSourceDirectories(decodeURIComponent(a)); } if (name === 'source-up') { const parent=state.sourceBrowserPath==='/'?'/':state.sourceBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadSourceDirectories(parent==='/mnt'?'/':parent); } if (name === 'select-current-source') { if(!state.backupSelections.includes(state.sourceBrowserPath)) state.backupSelections.push(state.sourceBrowserPath); return renderSourceBrowser(); } 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.startsWith('submit-')) return submit(name); } catch (error) { notify(error.message, true); } } async function submit(kind) { if (kind === 'submit-job') { const f=new FormData(document.getElementById('bu-job-form')); const existing=state.config.jobs.find(j=>j.id===f.get('id')); const type=f.get('type'); let sources=[]; if(type==='docker'||type==='vm') sources=state.workloadSelections.map(workloadId=>({workloadId})); else if(type==='flash') sources=[]; else sources=[...state.backupSelections.map(path=>({path})),...lines(f.get('sources')||'').map(path=>({path}))].filter((item,index,all)=>all.findIndex(other=>other.path===item.path)===index); const retention={keepWithinDays:Number(f.get('keepWithinDays')),daily:Number(f.get('keepDaily')),weekly:Number(f.get('keepWeekly')),monthly:Number(f.get('keepMonthly'))}; if(!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Retention must keep at least one age or calendar policy'); 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||['failed','warning'],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 repo={schemaVersion:1,id:f.get('id')||id('repo'),name:f.get('name').trim(),type:f.get('type'),location:f.get('location').trim(),passwordRef:f.get('passwordRef').trim(),credentialRef:f.get('credentialRef').trim(),mount:f.get('managed')==='on'?{managed:true,remote:f.get('remote').trim(),mountPoint:f.get('mountPoint').trim()}:null,options:f.get('type')==='sftp'?{knownHostsPath:f.get('knownHostsPath').trim()}:{}}; 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(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')) { if(!repo.credentialRef) repo.credentialRef=id('mount-credential'); await api(`/v1/secrets/${encodeURIComponent(repo.credentialRef)}`,{method:'PUT',body:JSON.stringify({type:'mount-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 (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-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'),catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); } if (kind === 'submit-notify') { const f=new FormData(document.getElementById('bu-notify-form')); const target={schemaVersion:1,id:f.get('id'),name:'ntfy',type:'ntfy',enabled:f.get('enabled')==='on',url:f.get('url'),topic:f.get('topic'),tokenRef:f.get('tokenRef'),events:['failed','warning','success']}; if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'notification-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='ntfy').concat(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(); }); content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); }); content.addEventListener('change', e => { if (e.target.name === 'scheduleMode') { const weekdays=e.target.closest('.bu-schedule')?.querySelector('.bu-weekdays'); if (weekdays) weekdays.hidden=e.target.value!=='selected'; } if (e.target.name === 'type' && e.target.closest('#bu-job-form')) { state.backupSelections=[]; state.workloadSelections=[]; renderJobSpecific(e.target.value); } if (e.target.classList.contains('bu-source-select')) { state.backupSelections = e.target.checked ? [...new Set([...state.backupSelections,e.target.value])] : state.backupSelections.filter(path=>path!==e.target.value); renderSourceBrowser(); } if (e.target.classList.contains('bu-file-select')) { state.restoreIncludes = e.target.checked ? [...new Set([...state.restoreIncludes,e.target.value])] : state.restoreIncludes.filter(path=>path!==e.target.value); render(); } if (e.target.classList.contains('bu-workload-select')) { state.workloadSelections = e.target.checked ? [...new Set([...state.workloadSelections,e.target.value])] : state.workloadSelections.filter(id=>id!==e.target.value); renderWorkloadBrowser(document.querySelector('#bu-job-form [name="type"]')?.value||'docker'); } }); content.addEventListener('submit', e => { const form = e.target.closest('form'); if (!form) return; e.preventDefault(); const action = form.id === 'bu-repo-form' ? 'submit-repo' : form.id === 'bu-job-form' ? 'submit-job' : form.id === 'bu-restore-form' ? 'submit-restore' : form.id === 'bu-settings-form' ? 'submit-settings' : form.id === 'bu-notify-form' ? 'submit-notify' : ''; if (action) handle(action); }); root.addEventListener('mouseover', e => { const target = e.target.closest('[data-tooltip]'); if (!target || target.contains(e.relatedTarget)) return; showTooltip(target, 1200); }); root.addEventListener('mouseout', e => { const target = e.target.closest('[data-tooltip]'); if (!target || target.contains(e.relatedTarget)) return; hideTooltip(); }); root.addEventListener('focusin', e => { const target = e.target.closest('[data-tooltip]'); if (target) showTooltip(target, 0); }); root.addEventListener('focusout', hideTooltip); root.addEventListener('keydown', e => { if (e.key === 'Escape') hideTooltip(); }); window.addEventListener('scroll', hideTooltip, true); window.addEventListener('resize', hideTooltip); enhanceTooltips(root); setInterval(async()=>{ if(!state.config)return; try { state.runs=await api('/v1/runs'); if(['dashboard','activity'].includes(state.view))render(); }catch(_){ } },10000); load(); })();