(() => {
'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 `
${rows ? `| Job | Type | Repository | Schedule | Enabled | Actions |
${rows}
` : '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
`;
}
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 `${rows ? `| Name | Type | Location | Mount | Actions |
${rows}
` : 'No repositories configured.
'}`;
}
function repositoryForm(repo = {}) {
content.innerHTML = ``;
}
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 `${rows?`| Snapshot | Time | Host | Paths | |
${rows}
`:'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 ``;
}
function renderRestore() {
const options = state.config.repositories.map(r=>``).join('');
return ``;
}
function renderActivity() { return `Activity
${button('Refresh','refresh','primary')}${runsTable([...state.runs].reverse())}`; }
function runsTable(runs) { return runs.length ? `| Task | Status | Created | Message | |
${runs.map(r=>`| ${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'):''} |
`).join('')}
` : 'No activity recorded.
'; }
function renderSettings() {
const s = state.config.settings;
const ntfy = state.config.notifications.find(n=>n.type==='ntfy') || {};
return `
`;
}
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();
})();