Initial Backupper Unraid plugin
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
:root {
|
||||
--bu-bg: #14171b;
|
||||
--bu-panel: #1d2228;
|
||||
--bu-panel-2: #242b32;
|
||||
--bu-border: #343e48;
|
||||
--bu-text: #f3f5f7;
|
||||
--bu-muted: #9ba8b4;
|
||||
--bu-accent: #ff8b21;
|
||||
--bu-good: #2fc98f;
|
||||
--bu-bad: #ff5c68;
|
||||
}
|
||||
|
||||
#backupper-app { color: var(--bu-text); max-width: 1500px; padding: 10px 18px 40px; }
|
||||
.bu-header { align-items: center; display: flex; justify-content: space-between; margin: 6px 0 22px; }
|
||||
.bu-header h1 { font-size: 30px; margin: 0; }
|
||||
.bu-header p { color: var(--bu-muted); margin: 4px 0 0; }
|
||||
.bu-health { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 999px; padding: 8px 14px; }
|
||||
.bu-health.ok { color: var(--bu-good); }
|
||||
.bu-health.bad { color: var(--bu-bad); }
|
||||
.bu-tabs { border-bottom: 1px solid var(--bu-border); display: flex; gap: 4px; margin-bottom: 20px; overflow-x: auto; }
|
||||
.bu-tabs button { background: transparent; border: 0; border-bottom: 3px solid transparent; color: var(--bu-muted); cursor: pointer; padding: 11px 14px; white-space: nowrap; }
|
||||
.bu-tabs button.active { border-color: var(--bu-accent); color: var(--bu-text); }
|
||||
.bu-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
|
||||
.bu-card { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 8px; padding: 18px; }
|
||||
.bu-card h2, .bu-card h3 { margin-top: 0; }
|
||||
.bu-stat { font-size: 30px; font-weight: 700; }
|
||||
.bu-muted { color: var(--bu-muted); }
|
||||
.bu-toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-bottom: 14px; }
|
||||
.bu-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.bu-button { background: var(--bu-panel-2); border: 1px solid var(--bu-border); border-radius: 5px; color: var(--bu-text); cursor: pointer; padding: 8px 12px; }
|
||||
.bu-button.primary { background: var(--bu-accent); border-color: var(--bu-accent); color: #111; font-weight: 700; }
|
||||
.bu-button.danger { color: var(--bu-bad); }
|
||||
.bu-button:disabled { cursor: wait; opacity: .55; }
|
||||
.bu-table { border-collapse: collapse; width: 100%; }
|
||||
.bu-table th, .bu-table td { border-bottom: 1px solid var(--bu-border); padding: 11px 8px; text-align: left; vertical-align: top; }
|
||||
.bu-table th { color: var(--bu-muted); font-size: 12px; text-transform: uppercase; }
|
||||
.bu-status { border-radius: 999px; display: inline-block; font-size: 12px; padding: 3px 8px; text-transform: uppercase; }
|
||||
.bu-status.success { background: rgba(47,201,143,.15); color: var(--bu-good); }
|
||||
.bu-status.failed, .bu-status.cancelled { background: rgba(255,92,104,.14); color: var(--bu-bad); }
|
||||
.bu-status.running, .bu-status.queued, .bu-status.warning { background: rgba(255,139,33,.15); color: var(--bu-accent); }
|
||||
.bu-form { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(220px, 1fr)); }
|
||||
.bu-form label { color: var(--bu-muted); display: grid; gap: 6px; }
|
||||
.bu-form .wide { grid-column: 1 / -1; }
|
||||
.bu-form input, .bu-form select, .bu-form textarea { background: #101317; border: 1px solid var(--bu-border); border-radius: 5px; color: var(--bu-text); padding: 9px; }
|
||||
.bu-form textarea { min-height: 90px; resize: vertical; }
|
||||
.bu-empty { color: var(--bu-muted); padding: 28px; text-align: center; }
|
||||
#bu-toast { background: var(--bu-panel-2); border: 1px solid var(--bu-border); border-radius: 6px; bottom: 24px; display: none; max-width: 460px; padding: 12px 16px; position: fixed; right: 24px; z-index: 20; }
|
||||
#bu-toast.show { display: block; }
|
||||
#bu-toast.error { border-color: var(--bu-bad); }
|
||||
@media (max-width: 700px) { .bu-form { grid-template-columns: 1fr; } .bu-header { align-items: flex-start; gap: 12px; } }
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
(() => {
|
||||
'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) => `<span class="bu-status ${esc(value)}">${esc(value)}</span>`;
|
||||
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" data-action="${esc(action)}">${esc(label)}</button>`;
|
||||
|
||||
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 = `<div class="bu-card"><h2>Connection failed</h2><p>${esc(error.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 `<div class="bu-grid">
|
||||
<section class="bu-card"><div class="bu-muted">Enabled jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></section>
|
||||
<section class="bu-card"><div class="bu-muted">Repositories</div><div class="bu-stat">${state.config.repositories.length}</div></section>
|
||||
<section class="bu-card"><div class="bu-muted">Queued / running</div><div class="bu-stat">${active}</div></section>
|
||||
<section class="bu-card"><div class="bu-muted">Recent failures</div><div class="bu-stat">${failures.length}</div></section>
|
||||
</div><section class="bu-card" style="margin-top:16px"><h2>Recent activity</h2>${runsTable(last)}</section>`;
|
||||
}
|
||||
|
||||
function renderJobs() {
|
||||
const rows = state.config.jobs.map(j => `<tr><td><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td>${esc(j.type)}</td><td>${esc(repoName(j.repositoryId))}</td><td>${esc(j.schedule?.cron || 'manual')}</td><td>${j.enabled ? status('success') : status('cancelled')}</td><td class="bu-actions">${button('Run',`run-job:${j.id}`,'primary')}${button('Edit',`edit-job:${j.id}`)}${button('Duplicate',`duplicate-job:${j.id}`)}${button('Delete',`delete-job:${j.id}`,'danger')}</td></tr>`).join('');
|
||||
return `<div class="bu-toolbar"><div><h2>Backup Jobs</h2><div class="bu-muted">One isolated Restic repository per job.</div></div>${button('New job','new-job','primary')}</div>
|
||||
<section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Job</th><th>Type</th><th>Repository</th><th>Schedule</th><th>Enabled</th><th>Actions</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">No backup jobs configured.</div>'}</section>`;
|
||||
}
|
||||
|
||||
function jobForm(job = {}) {
|
||||
const source = (job.sources || []).map(s => s.path || `@${s.workloadId}`).join('\n');
|
||||
const options = state.config.repositories.map(r => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join('');
|
||||
content.innerHTML = `<section class="bu-card"><h2>${job.id ? 'Edit' : 'New'} backup job</h2><form id="bu-job-form" class="bu-form">
|
||||
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
|
||||
<label>Type<select name="type">${['share','appdata','docker','vm','flash'].map(v=>`<option ${v===job.type?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
<label>Repository<select name="repositoryId" required>${options}</select></label><label>Schedule (cron)<input name="cron" value="${esc(job.schedule?.cron || '0 2 * * *')}"></label>
|
||||
<label>Consistency<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Controlled stop</option></select></label>
|
||||
<label>Compression<select name="compression">${['auto','off','max'].map(v=>`<option ${v===(job.compression||'auto')?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
<label class="wide">Sources, one per line (prefix workload IDs with @)<textarea name="sources">${esc(source)}</textarea></label>
|
||||
<label class="wide">Exclude patterns, one per line<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
|
||||
<label>Shutdown timeout (seconds)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label>
|
||||
<label><span>Enabled</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label>
|
||||
<div class="wide bu-actions">${button('Save','submit-job','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
|
||||
}
|
||||
|
||||
function renderRepositories() {
|
||||
const rows = state.config.repositories.map(r => `<tr><td><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td>${esc(r.type)}</td><td>${esc(r.location)}</td><td>${r.mount?.managed?'Managed':'Direct / external'}</td><td class="bu-actions">${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')}</td></tr>`).join('');
|
||||
return `<div class="bu-toolbar"><div><h2>Repositories</h2><div class="bu-muted">Local, SMB, NFS and SFTP destinations.</div></div>${button('New repository','new-repo','primary')}</div><section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Name</th><th>Type</th><th>Location</th><th>Mount</th><th>Actions</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">No repositories configured.</div>'}</section>`;
|
||||
}
|
||||
|
||||
function repositoryForm(repo = {}) {
|
||||
content.innerHTML = `<section class="bu-card"><h2>${repo.id?'Edit':'New'} repository</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}">
|
||||
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Type<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option ${v===repo.type?'selected':''}>${v}</option>`).join('')}</select></label>
|
||||
<label class="wide">Restic location<input name="location" required value="${esc(repo.location||'')}"></label><label>Password secret ID<input name="passwordRef" required value="${esc(repo.passwordRef||id('repo-password'))}"></label>
|
||||
<label>Repository password<input name="password" type="password" placeholder="Leave blank to keep existing"></label><label>Managed mount<input name="managed" type="checkbox" ${repo.mount?.managed?'checked':''}></label>
|
||||
<label>Remote share<input name="remote" value="${esc(repo.mount?.remote||'')}"></label><label>Mount point<input name="mountPoint" value="${esc(repo.mount?.mountPoint||'')}"></label>
|
||||
<label>Credential secret ID<input name="credentialRef" value="${esc(repo.credentialRef||'')}"></label><label>SMB credentials or SFTP private key<textarea name="credential" placeholder="SMB: username=... / password=... SFTP: private key PEM"></textarea></label>
|
||||
<label class="wide">SFTP known_hosts path<input name="knownHostsPath" value="${esc(repo.options?.knownHostsPath||'/root/.ssh/known_hosts')}"></label>
|
||||
<div class="wide bu-actions">${button('Save','submit-repo','primary')}${button('Cancel','cancel-form')}</div></form></section>`;
|
||||
}
|
||||
|
||||
function renderSnapshots() {
|
||||
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
|
||||
const rows = state.snapshots.map(s=>`<tr><td>${esc(s.short_id||s.id)}</td><td>${esc(new Date(s.time).toLocaleString())}</td><td>${esc(s.hostname)}</td><td>${esc((s.paths||[]).join(', '))}</td><td>${button('Browse',`browse:${s.id}`)}</td></tr>`).join('');
|
||||
return `<div class="bu-toolbar"><h2>Snapshots</h2><div class="bu-actions"><select id="bu-snapshot-repo">${options}</select>${button('Load','load-snapshots','primary')}</div></div><section class="bu-card">${rows?`<table class="bu-table"><thead><tr><th>Snapshot</th><th>Time</th><th>Host</th><th>Paths</th><th></th></tr></thead><tbody>${rows}</tbody></table>`:'<div class="bu-empty">Select a repository and load snapshots.</div>'}</section>${state.selectedSnapshot && state.files.length ? renderFileBrowser() : ''}`;
|
||||
}
|
||||
|
||||
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 `<tr><td><input type="checkbox" class="bu-file-select" value="${esc(path)}"></td><td>${esc(item.type||'')}</td><td>${esc(item.name||path)}</td><td>${Number(item.size||0).toLocaleString()}</td><td>${open}</td></tr>`;
|
||||
}).join('');
|
||||
return `<section class="bu-card" style="margin-top:16px"><div class="bu-toolbar"><div><h3>Snapshot ${esc(state.selectedSnapshot.slice(0,12))}</h3><span class="bu-muted">${esc(state.browserPath)}</span></div><div class="bu-actions">${state.browserPath!=='/'?button('Up','browser-up'):''}${button('Restore selected','restore-selected','primary')}</div></div><table class="bu-table"><thead><tr><th></th><th>Type</th><th>Name</th><th>Size</th><th></th></tr></thead><tbody>${rows}</tbody></table></section>`;
|
||||
}
|
||||
|
||||
function renderRestore() {
|
||||
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
|
||||
return `<section class="bu-card"><h2>Restore</h2><form id="bu-restore-form" class="bu-form"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Include paths, one per line<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Target<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><label>In-place restore<input name="inPlace" type="checkbox"></label><label>Confirm overwrite risk<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Queue restore','submit-restore','primary')}</div></form></section>`;
|
||||
}
|
||||
|
||||
function renderActivity() { return `<div class="bu-toolbar"><h2>Activity</h2>${button('Refresh','refresh','primary')}</div><section class="bu-card">${runsTable([...state.runs].reverse())}</section>`; }
|
||||
function runsTable(runs) { return runs.length ? `<table class="bu-table"><thead><tr><th>Task</th><th>Status</th><th>Created</th><th>Message</th><th></th></tr></thead><tbody>${runs.map(r=>`<tr><td>${esc(r.taskType)} ${esc(r.jobId||'')}</td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${esc(r.message||'')}</td><td>${['queued','running'].includes(r.status)?button('Cancel',`cancel-run:${r.id}`,'danger'):''}</td></tr>`).join('')}</tbody></table>` : '<div class="bu-empty">No activity recorded.</div>'; }
|
||||
|
||||
function renderSettings() {
|
||||
const s = state.config.settings;
|
||||
const ntfy = state.config.notifications.find(n=>n.type==='ntfy') || {};
|
||||
return `<section class="bu-card"><h2>Settings</h2><form id="bu-settings-form" class="bu-form"><label class="wide">Restic binary<input name="resticPath" value="${esc(s.resticPath)}"></label><label class="wide">Restore staging root<input name="restoreRoot" value="${esc(s.restoreRoot)}"></label><label class="wide">Persistent log directory<input name="persistentLogDir" value="${esc(s.persistentLogDir||'')}"></label><label>Catch-up window (hours)<input name="catchUp" type="number" value="${s.catchUpWindowHours||24}"></label><label>Monthly check cron<input name="checkCron" value="${esc(s.checkCron)}"></label><label>Prune cron<input name="pruneCron" value="${esc(s.pruneCron)}"></label><div class="wide bu-actions">${button('Save settings','submit-settings','primary')}</div></form></section>
|
||||
<section class="bu-card" style="margin-top:16px"><h2>ntfy notification</h2><form id="bu-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(ntfy.id||id('ntfy'))}"><label>Server URL<input name="url" value="${esc(ntfy.url||'https://ntfy.sh')}"></label><label>Topic<input name="topic" value="${esc(ntfy.topic||'')}"></label><label>Token secret ID<input name="tokenRef" value="${esc(ntfy.tokenRef||id('ntfy-token'))}"></label><label>Token<input name="token" type="password"></label><label>Enabled<input name="enabled" type="checkbox" ${ntfy.enabled?'checked':''}></label><div class="wide bu-actions">${button('Save notification','submit-notify','primary')}</div></form></section>`;
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
Reference in New Issue
Block a user