Add checkbox folder browsers

This commit is contained in:
Mikei386
2026-06-14 12:08:37 +02:00
parent c35a183a76
commit ebd2a8a47d
12 changed files with 173 additions and 16 deletions
-1
View File
@@ -1 +0,0 @@
85a85800cab7ef561448c8f96d586bd4fd98bd57813241793128a14d2a0d8044 backupper-0.1.10-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
d23bc7b1db4f95e701c1480917590383059eb5c7da6b18a5fc4d4089916f9b24 backupper-0.1.11-x86_64-1.txz
+5 -2
View File
@@ -2,13 +2,16 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "backupper">
<!ENTITY author "Backupper Project">
<!ENTITY version "0.1.10">
<!ENTITY version "0.1.11">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "85a85800cab7ef561448c8f96d586bd4fd98bd57813241793128a14d2a0d8044">
<!ENTITY packageSHA256 "d23bc7b1db4f95e701c1480917590383059eb5c7da6b18a5fc4d4089916f9b24">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
<CHANGES>
### 0.1.11
- Add checkbox folder browsers for backup sources and persistent multi-selection for restores.
### 0.1.10
- Verify repository edits against the daemon and handle form submission without losing changes.
+9
View File
@@ -36,6 +36,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server {
mux.HandleFunc("PUT /v1/secrets/{id}", s.putSecret)
mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret)
mux.HandleFunc("GET /v1/runs", s.runs)
mux.HandleFunc("GET /v1/filesystem/directories", s.directories)
mux.HandleFunc("POST /v1/jobs/{id}/run", s.runJob)
mux.HandleFunc("POST /v1/runs/{id}/cancel", s.cancelRun)
mux.HandleFunc("POST /v1/repositories/{id}/test", s.testRepository)
@@ -116,6 +117,14 @@ func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) }
func (s *Server) directories(w http.ResponseWriter, r *http.Request) {
items, err := s.service.BrowseDirectories(r.URL.Query().Get("path"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, 200, items)
}
func (s *Server) runJob(w http.ResponseWriter, r *http.Request) {
run, err := s.service.EnqueueJob(r.PathValue("id"), 10)
+75
View File
@@ -0,0 +1,75 @@
package platform
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
)
type DirectoryEntry struct {
Name string `json:"name"`
Path string `json:"path"`
}
var browseRoots = []string{"/mnt/user", "/mnt/disks", "/mnt/remotes", "/boot"}
func BrowseDirectories(path string) ([]DirectoryEntry, error) {
clean, err := validateBrowsePath(path)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(clean)
if err != nil {
return nil, err
}
result := make([]DirectoryEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
result = append(result, DirectoryEntry{Name: entry.Name(), Path: filepath.Join(clean, entry.Name())})
}
}
sort.Slice(result, func(i, j int) bool { return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) })
return result, nil
}
func BrowseRoots() []DirectoryEntry {
result := make([]DirectoryEntry, 0, len(browseRoots))
for _, root := range browseRoots {
if info, err := os.Stat(root); err == nil && info.IsDir() {
result = append(result, DirectoryEntry{Name: root, Path: root})
}
}
return result
}
func validateBrowsePath(path string) (string, error) {
if path == "" || path == "/" {
return "", errors.New("validation: select an allowed browse root")
}
clean := filepath.Clean(path)
if !filepath.IsAbs(clean) {
return "", errors.New("validation: browse path must be absolute")
}
allowed := false
for _, root := range browseRoots {
if clean == root || strings.HasPrefix(clean, root+string(os.PathSeparator)) {
allowed = true
break
}
}
if !allowed {
return "", errors.New("validation: browse path is outside allowed Unraid storage roots")
}
resolved, err := filepath.EvalSymlinks(clean)
if err != nil {
return "", err
}
for _, root := range browseRoots {
if resolved == root || strings.HasPrefix(resolved, root+string(os.PathSeparator)) {
return resolved, nil
}
}
return "", errors.New("validation: browse path resolves outside allowed Unraid storage roots")
}
+11
View File
@@ -0,0 +1,11 @@
package platform
import "testing"
func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) {
for _, path := range []string{"", "/", "/etc", "/mnt/user/../../etc", "relative"} {
if _, err := validateBrowsePath(path); err == nil {
t.Fatalf("unsafe browse path accepted: %q", path)
}
}
}
+7
View File
@@ -63,6 +63,13 @@ func (s *Service) Stop() { s.queue.Stop() }
func (s *Service) Config() model.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.config }
func (s *Service) Runs() []model.Run { return s.queue.Snapshot() }
func (s *Service) BrowseDirectories(path string) ([]platform.DirectoryEntry, error) {
if path == "" || path == "/" {
return platform.BrowseRoots(), nil
}
return platform.BrowseDirectories(path)
}
func (s *Service) LastRun(jobID string) time.Time {
var latest time.Time
for _, run := range s.queue.Snapshot() {
+4 -1
View File
@@ -2,13 +2,16 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "backupper">
<!ENTITY author "Backupper Project">
<!ENTITY version "0.1.10">
<!ENTITY version "0.1.11">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
<CHANGES>
### 0.1.11
- Add checkbox folder browsers for backup sources and persistent multi-selection for restores.
### 0.1.10
- Verify repository edits against the daemon and handle form submission without losing changes.
+3 -3
View File
@@ -9,10 +9,10 @@ Tag="backup restic snapshots restore"
<?php
$pluginRoot = '/plugins/backupper';
?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=10">
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=11">
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header">
<div><h1>Backupper <span class="bu-version">0.1.10</span></h1><p>Encrypted Restic backups for Unraid</p></div>
<div><h1>Backupper <span class="bu-version">0.1.11</span></h1><p>Encrypted Restic backups for Unraid</p></div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die Backupper-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Connecting...</div>
</header>
<nav class="bu-tabs" aria-label="Backupper sections">
@@ -29,4 +29,4 @@ $pluginRoot = '/plugins/backupper';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div>
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/backupper-0.1.10.js"></script>
<script src="<?= $pluginRoot ?>/assets/backupper-0.1.11.js"></script>
+5
View File
@@ -63,6 +63,11 @@
.bu-table-actions { align-items: flex-start; min-width: 310px; }
.bu-table-actions .bu-button { flex: 0 0 auto; }
.bu-warning { color: var(--bu-bad) !important; display: block; font-size: 13px; font-weight: 700; margin-top: 5px; }
.bu-browser { border: 1px solid var(--bu-border); border-radius: 6px; margin-top: 10px; max-height: 430px; overflow: auto; padding: 12px; }
.bu-browser-head { align-items: center; display: flex; gap: 10px; justify-content: space-between; margin-bottom: 10px; }
.bu-selection-list { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
.bu-selection { background: var(--bu-panel-alt); border: 1px solid var(--bu-border); border-radius: 999px; color: var(--bu-text); font-size: 12px; padding: 4px 8px; }
.bu-browser input[type="checkbox"], .bu-table input[type="checkbox"] { accent-color: var(--bu-accent); height: 20px; width: 20px; }
#backupper-app .bu-button {
appearance: none;
+53 -9
View File
@@ -6,7 +6,7 @@
const health = document.getElementById('bu-health');
const toast = document.getElementById('bu-toast');
const tooltip = document.getElementById('bu-tooltip');
const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [] };
const state = { config: null, runs: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], sourceBrowserPath: '/', sourceDirectories: [] };
const fieldHelp = {
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
@@ -15,7 +15,7 @@
cron: 'Zeitplan im Cron-Format: Minute Stunde Tag Monat Wochentag. Beispiel: 0 2 * * * startet täglich um 02:00 Uhr; 30 3 * * 0 jeden Sonntag um 03:30 Uhr.',
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: 'Eine Quelle pro Zeile. Pfad-Beispiel: /mnt/user/Media. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.',
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.',
enabled: 'Aktiviert automatische geplante Läufe. Deaktivierte Jobs können weiterhin gespeichert, aber nicht automatisch ausgeführt werden.',
@@ -59,6 +59,8 @@
'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-restore': 'Wählt den aktuell geöffneten Snapshot-Ordner vollständig für die Wiederherstellung aus.',
};
const esc = (value) => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
@@ -97,6 +99,7 @@
'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.`;
@@ -213,7 +216,9 @@
}
function jobForm(job = {}) {
const source = (job.sources || []).map(s => s.path || `@${s.workloadId}`).join('\n');
state.backupSelections = (job.sources || []).filter(s=>s.path).map(s=>s.path);
const source = (job.sources || []).filter(s=>s.workloadId).map(s=>`@${s.workloadId}`).join('\n');
state.sourceBrowserPath = '/'; state.sourceDirectories = [];
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>
@@ -221,12 +226,34 @@
<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>
<div class="wide"><h3>Backup folders</h3><div class="bu-muted">Select one or more folders. Open navigates into a folder without selecting it.</div><div id="bu-source-browser" class="bu-browser"></div></div>
<label class="wide">Additional manual paths or workload IDs<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>`;
enhanceTooltips(content);
loadSourceDirectories('/');
}
function renderSourceBrowser() {
const host = document.getElementById('bu-source-browser');
if (!host) return;
const selected = new Set(state.backupSelections);
const rows = state.sourceDirectories.map(item => `<tr><td><input type="checkbox" class="bu-source-select" value="${esc(item.path)}" ${selected.has(item.path)?'checked':''}></td><td><strong>${esc(item.name)}</strong><br><span class="bu-muted">${esc(item.path)}</span></td><td>${button('Open',`open-source-dir:${encodeURIComponent(item.path)}`)}</td></tr>`).join('');
const chosen = state.backupSelections.length ? state.backupSelections.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">No folders selected.</span>';
host.innerHTML = `<div class="bu-browser-head"><div><strong>Folder: ${esc(state.sourceBrowserPath)}</strong></div><div>${state.sourceBrowserPath!=='/'?button('Up','source-up'):''}</div></div><div class="bu-selection-list">${chosen}</div><table class="bu-table"><thead><tr><th>Select</th><th>Folder</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="3" class="bu-empty">No subfolders found.</td></tr>'}</tbody></table>`;
enhanceTooltips(host);
}
async function loadSourceDirectories(path) {
try {
state.sourceDirectories = await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
state.sourceBrowserPath = path;
renderSourceBrowser();
} catch (error) {
notify(error.message, true);
}
}
function renderRepositories() {
@@ -257,12 +284,14 @@
}
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 `<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>`;
return `<tr><td><input type="checkbox" class="bu-file-select" value="${esc(path)}" ${selected.has(path)?'checked':''}></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>`;
const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">Nothing selected.</span>';
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">${button('Select current folder','select-current-restore')}${state.browserPath!=='/'?button('Up','browser-up'):''}${button(`Restore selected (${state.restoreIncludes.length})`,'restore-selected','primary')}</div></div><div class="bu-selection-list">${chosen}</div><table class="bu-table"><thead><tr><th>Select</th><th>Type</th><th>Name</th><th>Size</th><th></th></tr></thead><tbody>${rows}</tbody></table></section>`;
}
function renderRestore() {
@@ -303,10 +332,13 @@
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 === '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 === '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 === '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; }
@@ -317,7 +349,9 @@
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'))};
const manual=lines(f.get('sources')).map(v=>v.startsWith('@')?{workloadId:v.slice(1)}:{path:v});
const selected=state.backupSelections.map(path=>({path}));
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:[...selected,...manual].filter((item,index,all)=>all.findIndex(other=>(other.path||`@${other.workloadId}`)===(item.path||`@${item.workloadId}`))===index),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') {
@@ -339,6 +373,16 @@
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.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();
}
});
content.addEventListener('submit', e => {
const form = e.target.closest('form');
if (!form) return;