(() => { '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 state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [] }; 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 || '', ...(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 status = (value) => `${esc(value)}`; const button = (label, action, kind = '') => ``; async function load() { try { const [config, runs] = await Promise.all([api('/v1/config'), api('/v1/runs')]); state.config = config; state.runs = runs; health.textContent = 'Daemon online'; 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](); } 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); return `
Enabled jobs
${state.config.jobs.filter(j => j.enabled).length}
Repositories
${state.config.repositories.length}
Queued / running
${active}
Recent failures
${failures.length}

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(j.schedule?.cron || 'manual')}${j.enabled ? status('success') : status('cancelled')}${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 = {}) { const source = (job.sources || []).map(s => s.path || `@${s.workloadId}`).join('\n'); const options = state.config.repositories.map(r => ``).join(''); content.innerHTML = `

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

${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}
`; } function renderRepositories() { const rows = state.config.repositories.map(r => `${esc(r.name)}
${esc(r.id)}${esc(r.type)}${esc(r.location)}${r.mount?.managed?'Managed':'Direct / external'}${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')}`).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')}
`; } 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 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(''); return `

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

${esc(state.browserPath)}
${state.browserPath!=='/'?button('Up','browser-up'):''}${button('Restore selected','restore-selected','primary')}
${rows}
TypeNameSize
`; } 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() { await api('/v1/config',{method:'PUT',body:JSON.stringify(state.config)}); notify('Configuration saved'); } 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 === '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.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${a}/files?path=${encodeURIComponent('/')}`); return render(); } 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 === 'restore-selected') { state.restoreIncludes=[...document.querySelectorAll('.bu-file-select:checked')].map(x=>x.value); 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 job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type:f.get('type'),enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources:lines(f.get('sources')).map(v=>v.startsWith('@')?{workloadId:v.slice(1)}:{path:v}),schedule:{cron:f.get('cron'),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')},compression:f.get('compression'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention:existing?.retention||{daily:7,weekly:4,monthly:12},notifyOn:existing?.notifyOn||['failed','warning'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs'))}; 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'),type:f.get('type'),location:f.get('location'),passwordRef:f.get('passwordRef'),credentialRef:f.get('credentialRef')||'',mount:f.get('managed')==='on'?{managed:true,remote:f.get('remote'),mountPoint:f.get('mountPoint')}:null,options:f.get('type')==='sftp'?{knownHostsPath:f.get('knownHostsPath')}:{}}; 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(); 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('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); }); setInterval(async()=>{ if(!state.config)return; try { state.runs=await api('/v1/runs'); if(['dashboard','activity'].includes(state.view))render(); }catch(_){ } },10000); load(); })();