Unify directory trees and preload snapshots

This commit is contained in:
Mikei386
2026-06-21 14:09:34 +02:00
parent 92ba5ce05f
commit f3f846bdfa
10 changed files with 145 additions and 85 deletions
-1
View File
@@ -1 +0,0 @@
28f348211cf8437f8227303eb0c697e4846048c8cad5b51bf9c26891b271684e urbm-2026.06.21.r001-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
f51b27d4b6bd430c0092014e3b7651f5537965a9daf198895da84fd11e20ed3b urbm-2026.06.21.r002-x86_64-1.txz
+7 -2
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.21.r001">
<!ENTITY version "2026.06.21.r002">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "28f348211cf8437f8227303eb0c697e4846048c8cad5b51bf9c26891b271684e">
<!ENTITY packageSHA256 "f51b27d4b6bd430c0092014e3b7651f5537965a9daf198895da84fd11e20ed3b">
]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES>
### 2026.06.21.r002
- Reuse the expandable folder tree for local repository folders, Rsync targets, restore targets, and directory settings.
- Preload complete snapshot file listings once and browse them locally in an expandable restore tree.
- Remove the previous snapshot browser truncation and extend the snapshot listing timeout for large backups.
### 2026.06.21.r001
- Add a separate emergency repository unlock action that invokes Restic unlock with --remove-all after a strong confirmation.
- Keep the normal unlock action safe by removing only stale locks that Restic considers safe to clear.
+1 -1
View File
@@ -268,7 +268,7 @@ func (s *Server) repositoryStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, s.service.RepositoryStats(ctx))
}
func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
items, err := s.service.SnapshotFiles(ctx, r.PathValue("id"), r.PathValue("snapshot"), r.URL.Query().Get("path"))
if err != nil {
+1 -3
View File
@@ -270,14 +270,12 @@ func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path
}
items := []map[string]any{}
scanner := bufio.NewScanner(strings.NewReader(string(output)))
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
for scanner.Scan() {
var item map[string]any
if json.Unmarshal(scanner.Bytes(), &item) == nil && item["struct_type"] == "node" {
items = append(items, item)
}
if len(items) >= 5000 {
break
}
}
return items, scanner.Err()
}
+6 -1
View File
@@ -2,13 +2,18 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.21.r001">
<!ENTITY version "2026.06.21.r002">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&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/URBM/issues" icon="urbm.png">
<CHANGES>
### 2026.06.21.r002
- Reuse the expandable folder tree for local repository folders, Rsync targets, restore targets, and directory settings.
- Preload complete snapshot file listings once and browse them locally in an expandable restore tree.
- Remove the previous snapshot browser truncation and extend the snapshot listing timeout for large backups.
### 2026.06.21.r001
- Add a separate emergency repository unlock action that invokes Restic unlock with --remove-all after a strong confirmation.
- Keep the normal unlock action safe by removing only stale locks that Restic considers safe to clear.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php
$pluginRoot = '/plugins/urbm';
?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260621r001">
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260621r002">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header">
<div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260621r001" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.21.r001</span></h1><p>Restic Backup Manager für Unraid</p></div>
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260621r002" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.21.r002</span></h1><p>Restic Backup Manager für Unraid</p></div>
</div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
</header>
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.21.r001.js"></script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.21.r002.js"></script>
+1
View File
@@ -87,6 +87,7 @@
.bu-tree { border: 1px solid var(--bu-border); border-radius: 6px; max-height: 520px; overflow: auto; padding: 5px; }
.bu-tree-node { min-width: 0; }
.bu-tree-row { align-items: center; border-radius: 5px; display: grid; gap: 6px; grid-template-columns: 28px 24px minmax(0, 1fr); margin-left: calc(var(--bu-tree-depth) * 22px); min-height: 42px; padding: 3px 7px; }
.bu-dir-tree-row { grid-template-columns: 28px minmax(0, 1fr) auto; }
.bu-tree-row:hover { background: var(--bu-panel-alt); }
.bu-source-partial > .bu-tree-row { background: rgba(255, 140, 47, .09); background: color-mix(in srgb, var(--bu-accent) 9%, transparent); }
#urbm-app .bu-tree-toggle, #urbm-app .bu-tree-name { appearance: none; background: transparent !important; border: 0 !important; box-shadow: none !important; color: var(--bu-text) !important; margin: 0 !important; min-height: 0; padding: 0 !important; text-align: left; text-shadow: none !important; }
+123 -72
View File
@@ -8,7 +8,7 @@
const tooltip = document.getElementById('bu-tooltip');
const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path}));
const repositoryRoots = ['/mnt/user','/mnt/disks','/mnt/remotes'].map(path=>({name:path,path}));
const state = { config: null, runs: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, rsyncTargetBrowserPath: '/', rsyncTargetDirectories: repositoryRoots, rsyncTargetBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', liveLogOpen: {} };
const state = { config: null, runs: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], snapshotTree: null, snapshotTreeLoading: false, snapshotTreeError: '', snapshotTreeExpanded: {}, restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, directoryTrees: {}, liveLogOpen: {} };
const fieldHelp = {
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
@@ -74,12 +74,11 @@
'refresh': 'Lädt Laufstatus und Historie erneut vom URBM-Daemon.',
'clear-activity': 'Löscht alle abgeschlossenen Aktivitätseinträge und deren gespeicherte Laufprotokolle. Laufende und wartende Aufgaben bleiben erhalten.',
'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.',
'browser-up': 'Öffnet im Snapshot-Browser das übergeordnete Verzeichnis.',
'toggle-source-tree': 'Klappt die direkten Unterordner dieses Ordners auf oder zu. Inhalte werden erst beim ersten Öffnen geladen.',
'retry-source-tree': 'Versucht erneut, die Unterordner dieses Ordners zu laden.',
'repository-up': 'Öffnet im lokalen Repository-Browser das übergeordnete Verzeichnis.',
'select-repository-folder': 'Übernimmt den aktuell geöffneten Ordner als lokalen Repository-Pfad.',
'select-current-restore': 'Wählt den aktuell geöffneten Snapshot-Ordner vollständig für die Wiederherstellung aus.',
'toggle-dir-tree': 'Klappt Unterordner in dieser Verzeichnisauswahl auf oder zu.',
'select-dir-tree': 'Übernimmt diesen Ordner in das zugehörige Pfadfeld.',
'toggle-snapshot-tree': 'Klappt diesen Ordner innerhalb des vorgeladenen Snapshots auf oder zu.',
};
const esc = (value) => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
@@ -158,8 +157,9 @@
'change-repo-password': 'Ändert den Restic-Key im Repository und aktualisiert danach das geschützte URBM-Secret. Vorhandene Snapshots bleiben unverändert.',
'delete-repo': 'Löscht nur die URBM-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-repository-dir': 'Öffnet diesen lokalen Ordner, um ihn oder einen Unterordner als Repository-Ziel auszuwählen.',
'toggle-dir-tree': 'Klappt diesen Ordner auf oder zu. Unterordner werden bei Bedarf geladen.',
'select-dir-tree': 'Übernimmt diesen Ordner in das passende Eingabefeld.',
'toggle-snapshot-tree': 'Klappt diesen Ordner im lokal vorgeladenen Snapshot auf oder zu.',
'cancel-run': 'Bricht die laufende oder wartende Aufgabe ab. Bereinigung und Neustart gestoppter Systeme werden trotzdem versucht.',
'pause-run': 'Hält den aktiven Restic-Prozess und seine Unterprozesse an. Mounts sowie gestoppte Container oder VMs bleiben bis zum Fortsetzen oder Abbrechen bestehen.',
'resume-run': 'Setzt einen zuvor pausierten Restic-Vorgang an derselben Stelle fort.',
@@ -355,6 +355,17 @@
const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, settings:renderSettings};
content.innerHTML = views[state.view]();
enhanceTooltips(content);
content.querySelectorAll('.bu-file-select[data-partial="true"]').forEach(box=>{box.indeterminate=true;box.setAttribute('aria-checked','mixed');});
if(state.view==='restore') {
if(!state.directoryTrees.restoreTarget) initDirectoryTree('restoreTarget','Restore-Ziel auswählen','#bu-restore-form [name="target"]');
else renderDirectoryTree('restoreTarget');
}
if(state.view==='settings') {
if(!state.directoryTrees.restoreRoot) initDirectoryTree('restoreRoot','Staging-Verzeichnis auswählen','#bu-settings-form [name="restoreRoot"]');
else renderDirectoryTree('restoreRoot');
if(!state.directoryTrees.persistentLogDir) initDirectoryTree('persistentLogDir','Protokollverzeichnis auswählen','#bu-settings-form [name="persistentLogDir"]');
else renderDirectoryTree('persistentLogDir');
}
}
function renderDashboard() {
@@ -386,7 +397,7 @@
state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId);
state.workloads = []; state.workloadAvailable = true; state.workloadError = '';
resetSourceTree(sourceRoots);
state.rsyncTargetBrowserPath = '/'; state.rsyncTargetDirectories = repositoryRoots; state.rsyncTargetBrowserError = '';
delete state.directoryTrees.rsyncTarget;
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>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</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>
@@ -416,9 +427,9 @@
loadWorkloads(type);
} else if (type === 'rsync') {
const o=job.rsync||{};
host.innerHTML = `<h3>Direkte Rsync-Kopie</h3><div class="bu-inline-info">Rsync erstellt keine verschlüsselten Snapshots. Die ausgewählten Ordner werden direkt in das Ziel kopiert. Die Option „Am Ziel löschen“ spiegelt Löschungen und sollte bewusst aktiviert werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Quellpfade<textarea name="sources"></textarea></label><h3>Zielordner</h3><label class="wide">Rsync-Ziel<input name="rsyncTarget" required value="${esc(o.target||'')}" placeholder="/mnt/remotes/backup"></label><div id="bu-rsync-target-browser"></div><fieldset class="wide bu-fieldset"><legend>Rsync-Optionen</legend><div class="bu-event-options"><label><input name="rsyncOverwrite" type="checkbox" ${o.overwrite!==false?'checked':''}><span>Vorhandene Dateien aktualisieren / überschreiben</span></label><label><input name="rsyncDelete" type="checkbox" ${o.delete===true?'checked':''}><span>Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind</span></label><label><input name="rsyncPermissions" type="checkbox" ${o.preservePermissions!==false?'checked':''}><span>Dateiberechtigungen übernehmen</span></label><label><input name="rsyncOwner" type="checkbox" ${o.preserveOwner===true?'checked':''}><span>Besitzer übernehmen</span></label><label><input name="rsyncGroup" type="checkbox" ${o.preserveGroup===true?'checked':''}><span>Gruppen übernehmen</span></label><label><input name="rsyncTimes" type="checkbox" ${o.preserveTimes!==false?'checked':''}><span>Zeitstempel übernehmen</span></label><label><input name="rsyncLinks" type="checkbox" ${o.preserveLinks!==false?'checked':''}><span>Symbolische Links als Links übernehmen</span></label><label><input name="rsyncACLs" type="checkbox" ${o.preserveAcls===true?'checked':''}><span>ACLs übernehmen</span></label><label><input name="rsyncXattrs" type="checkbox" ${o.preserveXattrs===true?'checked':''}><span>Erweiterte Attribute übernehmen</span></label><label><input name="rsyncChecksum" type="checkbox" ${o.checksum===true?'checked':''}><span>Dateien per Prüfsumme vergleichen (langsamer)</span></label><label><input name="rsyncDryRun" type="checkbox" ${o.dryRun===true?'checked':''}><span>Testlauf ohne Änderungen</span></label></div></fieldset>`;
host.innerHTML = `<h3>Direkte Rsync-Kopie</h3><div class="bu-inline-info">Rsync erstellt keine verschlüsselten Snapshots. Die ausgewählten Ordner werden direkt in das Ziel kopiert. Die Option „Am Ziel löschen“ spiegelt Löschungen und sollte bewusst aktiviert werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Quellpfade<textarea name="sources"></textarea></label><h3>Zielordner</h3><label class="wide">Rsync-Ziel<input name="rsyncTarget" required value="${esc(o.target||'')}" placeholder="/mnt/remotes/backup"></label><div id="bu-dir-tree-rsyncTarget"></div><fieldset class="wide bu-fieldset"><legend>Rsync-Optionen</legend><div class="bu-event-options"><label><input name="rsyncOverwrite" type="checkbox" ${o.overwrite!==false?'checked':''}><span>Vorhandene Dateien aktualisieren / überschreiben</span></label><label><input name="rsyncDelete" type="checkbox" ${o.delete===true?'checked':''}><span>Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind</span></label><label><input name="rsyncPermissions" type="checkbox" ${o.preservePermissions!==false?'checked':''}><span>Dateiberechtigungen übernehmen</span></label><label><input name="rsyncOwner" type="checkbox" ${o.preserveOwner===true?'checked':''}><span>Besitzer übernehmen</span></label><label><input name="rsyncGroup" type="checkbox" ${o.preserveGroup===true?'checked':''}><span>Gruppen übernehmen</span></label><label><input name="rsyncTimes" type="checkbox" ${o.preserveTimes!==false?'checked':''}><span>Zeitstempel übernehmen</span></label><label><input name="rsyncLinks" type="checkbox" ${o.preserveLinks!==false?'checked':''}><span>Symbolische Links als Links übernehmen</span></label><label><input name="rsyncACLs" type="checkbox" ${o.preserveAcls===true?'checked':''}><span>ACLs übernehmen</span></label><label><input name="rsyncXattrs" type="checkbox" ${o.preserveXattrs===true?'checked':''}><span>Erweiterte Attribute übernehmen</span></label><label><input name="rsyncChecksum" type="checkbox" ${o.checksum===true?'checked':''}><span>Dateien per Prüfsumme vergleichen (langsamer)</span></label><label><input name="rsyncDryRun" type="checkbox" ${o.dryRun===true?'checked':''}><span>Testlauf ohne Änderungen</span></label></div></fieldset>`;
host.querySelector('.bu-event-options').insertAdjacentHTML('beforeend', `<label><input name="rsyncSkipSpaceCheck" type="checkbox" ${o.skipSpaceCheck===true?'checked':''}><span>Kapazitätsprüfung überspringen (riskant; nur wenn vorhandene Zieldateien ausreichend Platz freigeben)</span></label>`);
initSourceTree(sourceRoots.map(item=>item.path), false); renderRsyncTargetBrowser();
initSourceTree(sourceRoots.map(item=>item.path), false); initDirectoryTree('rsyncTarget','Rsync-Ziel auswählen','#bu-job-form [name="rsyncTarget"]');
} else {
state.backupSelections = ['/boot'];
host.innerHTML = `<div class="bu-info-card"><h3>Unraid-USB-Flash-Backup</h3><p>Das vollständige Konfigurationslaufwerk <strong>/boot</strong> wird immer dateibasiert gesichert.</p><label class="bu-inline-check"><input name="flashImage" type="checkbox" ${job.flashImage===true?'checked':''}><span>Zusätzlich vollständiges USB-Rohabbild <strong>urbm-usb-flash.img</strong> sichern</span></label><p class="bu-muted">Das Abbild wird ohne Zwischenkopie direkt zu Restic übertragen und kann nach einer Wiederherstellung mit einem Image-Programm auf einen mindestens gleich großen Stick geschrieben werden. Die an die USB-GUID gebundene Unraid-Lizenz muss bei einem Stickwechsel separat übertragen werden.</p></div>`;
@@ -521,26 +532,51 @@
}
}
function renderRsyncTargetBrowser() {
const host=document.getElementById('bu-rsync-target-browser');
if(!host) return;
const rows=state.rsyncTargetDirectories.map(item=>`<tr><td>${esc(item.name)}</td><td>${esc(item.path)}</td><td>${button('Öffnen',`open-rsync-target:${encodeURIComponent(item.path)}`)}</td></tr>`).join('');
const error=state.rsyncTargetBrowserError?`<div class="bu-inline-error">${esc(state.rsyncTargetBrowserError)}</div>`:'';
host.innerHTML=`<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Rsync-Ziel auswählen</h3><span class="bu-muted">${esc(state.rsyncTargetBrowserPath)}</span></div><div class="bu-actions">${state.rsyncTargetBrowserPath!=='/'?button('Diesen Ordner verwenden','select-rsync-target','primary'):''}${state.rsyncTargetBrowserPath!=='/'?button('Nach oben','rsync-target-up'):''}</div></div>${error}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Name</th><th>Pfad</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="3" class="bu-empty">Keine Unterordner gefunden.</td></tr>'}</tbody></table></div></section>`;
function initDirectoryTree(key, title, targetSelector, roots=repositoryRoots) {
state.directoryTrees[key]={title,targetSelector,roots:roots.map(item=>typeof item==='string'?{name:item,path:item}:item),children:{},expanded:{},loading:{},errors:{}};
renderDirectoryTree(key);
}
function directoryTreeNode(key, item, depth) {
const tree=state.directoryTrees[key]; const path=item.path;
const expanded=tree.expanded[path]===true; const loading=tree.loading[path]===true;
const loaded=Object.prototype.hasOwnProperty.call(tree.children,path); const children=tree.children[path]||[]; const error=tree.errors[path]||'';
const toggle=`<button class="bu-tree-toggle ${expanded?'expanded':''}" type="button" data-action="toggle-dir-tree:${key}:${encodeURIComponent(path)}" aria-expanded="${expanded}" data-tooltip="${expanded?'Unterordner einklappen':'Unterordner aufklappen'}"><span>▶</span></button>`;
let html=`<div class="bu-tree-node" style="--bu-tree-depth:${depth}"><div class="bu-tree-row bu-dir-tree-row">${toggle}<button class="bu-tree-name" type="button" data-action="toggle-dir-tree:${key}:${encodeURIComponent(path)}"><span>${esc(item.name)}</span><small>${esc(path)}</small></button><button class="bu-button" type="button" data-action="select-dir-tree:${key}:${encodeURIComponent(path)}">Auswählen</button></div>`;
if(expanded) {
html+='<div class="bu-tree-children">';
if(loading) html+='<div class="bu-tree-state">Unterordner werden geladen…</div>';
else if(error) html+=`<div class="bu-tree-state error">${esc(error)} ${button('Erneut versuchen',`retry-dir-tree:${key}:${encodeURIComponent(path)}`)}</div>`;
else if(loaded&&!children.length) html+='<div class="bu-tree-state">Keine Unterordner.</div>';
else html+=children.map(child=>directoryTreeNode(key,child,depth+1)).join('');
html+='</div>';
}
return html+'</div>';
}
function renderDirectoryTree(key) {
const tree=state.directoryTrees[key]; const host=document.getElementById(`bu-dir-tree-${key}`);
if(!tree||!host) return;
const html=tree.roots.map(item=>directoryTreeNode(key,item,0)).join('');
host.innerHTML=`<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>${esc(tree.title)}</h3><span class="bu-muted">Ordner mit dem Dreieck aufklappen und den gewünschten Zielordner auswählen.</span></div></div><div class="bu-tree" role="tree">${html||'<div class="bu-empty">Keine Ordner verfügbar.</div>'}</div></section>`;
enhanceTooltips(host);
}
async function loadRsyncTargetDirectories(path) {
if(path==='/') {
state.rsyncTargetBrowserPath='/'; state.rsyncTargetDirectories=repositoryRoots; state.rsyncTargetBrowserError=''; renderRsyncTargetBrowser(); return;
async function toggleDirectoryTree(key, path, forceOpen=false) {
const tree=state.directoryTrees[key]; if(!tree) return;
if(tree.expanded[path]&&!forceOpen) { tree.expanded[path]=false; renderDirectoryTree(key); return; }
tree.expanded[path]=true; renderDirectoryTree(key);
if(Object.prototype.hasOwnProperty.call(tree.children,path)&&!tree.errors[path]) return;
tree.loading[path]=true; tree.errors[path]=''; renderDirectoryTree(key);
try { tree.children[path]=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`); }
catch(error) { tree.errors[path]=`Ordner konnte nicht geladen werden: ${error.message}`; }
finally { tree.loading[path]=false; renderDirectoryTree(key); }
}
try {
state.rsyncTargetDirectories=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
state.rsyncTargetBrowserPath=path; state.rsyncTargetBrowserError='';
} catch(error) {
state.rsyncTargetBrowserError=`Zielordner konnte nicht geladen werden: ${error.message}`;
}
renderRsyncTargetBrowser();
function selectDirectoryTreePath(key, path) {
const tree=state.directoryTrees[key]; if(!tree) return;
const input=document.querySelector(tree.targetSelector);
if(input) input.value=path;
}
function renderRepositories() {
@@ -553,7 +589,7 @@
}
function repositoryForm(repo = {}, existingRepository = false) {
state.repositoryBrowserPath = '/'; state.repositoryDirectories = repositoryRoots; state.repositoryBrowserError = '';
delete state.directoryTrees.repository;
const passwordRef=repo.passwordRef||id('repo-password');
const credentialRef=repo.credentialRef||id('repo-credential');
content.innerHTML = `<section class="bu-card"><h2>${repo.id?'Repository bearbeiten':existingRepository?'Vorhandenes Repository einbinden':'Neues Repository erstellen'}</h2><form id="bu-repo-form" class="bu-form"><input type="hidden" name="id" value="${esc(repo.id||'')}"><input type="hidden" name="existingRepository" value="${existingRepository?'true':'false'}"><input type="hidden" name="passwordRef" value="${esc(passwordRef)}"><input type="hidden" name="credentialRef" value="${esc(credentialRef)}"><input type="hidden" name="hasCredential" value="${repo.credentialRef?'true':'false'}">
@@ -579,8 +615,8 @@
const host = document.getElementById('bu-repository-specific');
if (!host) return;
if (type === 'local') {
host.innerHTML = `${repositoryLocationField('Lokaler Repository-Ordner',repo.location,'/mnt/user/backups/urbm')}<div id="bu-repository-browser"></div>`;
renderRepositoryBrowser();
host.innerHTML = `${repositoryLocationField('Lokaler Repository-Ordner',repo.location,'/mnt/user/backups/urbm')}<div id="bu-dir-tree-repository"></div>`;
initDirectoryTree('repository','Lokalen Repository-Ordner auswählen','#bu-repo-form [name="location"]');
} else if (type === 'smb' || type === 'nfs') {
const managed = repo.mount?.managed === true;
host.innerHTML = `<fieldset class="bu-fieldset"><legend>${type.toUpperCase()}-Ziel</legend><label class="bu-inline-check"><input name="managed" type="checkbox" ${managed?'checked':''}><span>URBM soll diese Freigabe einbinden</span></label><div class="bu-external-mount" ${managed?'hidden':''}>${repositoryLocationField('Bereits eingebundener Ordner',repo.location,'/mnt/remotes/backup-nas')}</div><div class="bu-managed-mount" ${managed?'':'hidden'}><div class="bu-form-grid"><label>Netzwerkfreigabe<input name="remote" placeholder="${type==='smb'?'//192.168.1.20/backup':'192.168.1.20:/volume1/backup'}" value="${esc(repo.mount?.remote||'')}"></label><label>Einhängepunkt<input name="mountPoint" placeholder="/mnt/remotes/urbm-backup" value="${esc(repo.mount?.mountPoint||repo.location||'')}"></label>${type==='smb'?`<label class="wide">SMB-Zugangsdaten<textarea name="credential" placeholder="${repo.credentialRef?'Leer lassen, um vorhandene Zugangsdaten zu behalten':'username=backup&#10;password=geheim'}"></textarea></label>`:''}</div></div></fieldset>`;
@@ -590,48 +626,67 @@
enhanceTooltips(host);
}
function renderRepositoryBrowser() {
const host = document.getElementById('bu-repository-browser');
if (!host) return;
const rows = state.repositoryDirectories.map(item=>`<tr><td>${esc(item.name)}</td><td>${esc(item.path)}</td><td>${button('Öffnen',`open-repository-dir:${encodeURIComponent(item.path)}`)}</td></tr>`).join('');
const error = state.repositoryBrowserError ? `<div class="bu-inline-error">${esc(state.repositoryBrowserError)}</div>` : '';
host.innerHTML = `<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Lokalen Ordner auswählen</h3><span class="bu-muted">${esc(state.repositoryBrowserPath)}</span></div><div class="bu-actions">${state.repositoryBrowserPath!=='/'?button('Diesen Ordner verwenden','select-repository-folder','primary'):''}${state.repositoryBrowserPath!=='/'?button('Nach oben','repository-up'):''}</div></div>${error}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Name</th><th>Pfad</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="3" class="bu-empty">Keine Unterordner gefunden.</td></tr>'}</tbody></table></div></section>`;
enhanceTooltips(host);
}
async function loadRepositoryDirectories(path) {
if (path === '/') {
state.repositoryBrowserPath='/'; state.repositoryDirectories=repositoryRoots; state.repositoryBrowserError=''; renderRepositoryBrowser(); return;
}
try {
state.repositoryDirectories=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
state.repositoryBrowserPath=path; state.repositoryBrowserError='';
} catch (error) {
state.repositoryBrowserError=`Ordner konnte nicht geladen werden: ${error.message}`;
}
renderRepositoryBrowser();
}
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('Durchsuchen',`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('Laden','load-snapshots','primary')}</div></div><section class="bu-card">${rows?`<table class="bu-table"><thead><tr><th>Snapshot</th><th>Zeitpunkt</th><th>Host</th><th>Pfade</th><th></th></tr></thead><tbody>${rows}</tbody></table>`:'<div class="bu-empty">Repository auswählen und Snapshots laden.</div>'}</section>${state.selectedSnapshot && state.files.length ? renderFileBrowser() : ''}`;
return `<div class="bu-toolbar"><h2>Snapshots</h2><div class="bu-actions"><select id="bu-snapshot-repo">${options}</select>${button('Laden','load-snapshots','primary')}</div></div><section class="bu-card">${rows?`<table class="bu-table"><thead><tr><th>Snapshot</th><th>Zeitpunkt</th><th>Host</th><th>Pfade</th><th></th></tr></thead><tbody>${rows}</tbody></table>`:'<div class="bu-empty">Repository auswählen und Snapshots laden.</div>'}</section>${state.selectedSnapshot ? renderFileBrowser() : ''}`;
}
function snapshotItemPath(item) {
return String(item.path || item.name || '').replace(/\/+/g,'/').replace(/\/$/,'') || '/';
}
function buildSnapshotTree(items) {
const nodes=new Map();
const ensure=(path,type='dir',size=0)=>{
path=path.replace(/\/+/g,'/').replace(/\/$/,'')||'/';
if(nodes.has(path)) return nodes.get(path);
const parentPath=path==='/'?'':path.split('/').slice(0,-1).join('/')||'/';
const name=path==='/'?'/':path.split('/').pop();
const node={path,name,type,size:Number(size||0),children:[]};
nodes.set(path,node);
if(path!=='/') ensure(parentPath).children.push(node);
return node;
};
ensure('/');
items.forEach(item=>{
const path=snapshotItemPath(item);
if(!path||path==='/') return;
const type=item.type==='dir'?'dir':'file';
const node=ensure(path,type,item.size);
node.type=type; node.size=Number(item.size||0);
});
nodes.forEach(node=>node.children.sort((a,b)=>(a.type===b.type?a.name.localeCompare(b.name):a.type==='dir'?-1:1)));
return nodes.get('/');
}
function snapshotSelectionState(path) {
const exact=state.restoreIncludes.includes(path);
const prefix=path.replace(/\/$/,'')+'/';
const partial=!exact&&state.restoreIncludes.some(selected=>selected.startsWith(prefix));
return {exact,partial};
}
function snapshotTreeNode(node, depth) {
const selection=snapshotSelectionState(node.path);
const isDir=node.type==='dir';
const expanded=state.snapshotTreeExpanded[node.path]===true;
const toggle=isDir?`<button class="bu-tree-toggle ${expanded?'expanded':''}" type="button" data-action="toggle-snapshot-tree:${encodeURIComponent(node.path)}" aria-expanded="${expanded}"><span>▶</span></button>`:'<span></span>';
const label=selection.partial?'<span class="bu-partial-label">Teilweise ausgewählt</span>':'';
let html=`<div class="bu-tree-node ${selection.partial?'bu-source-partial':''}" style="--bu-tree-depth:${depth}"><div class="bu-tree-row"><div>${toggle}</div><input type="checkbox" class="bu-file-select" value="${esc(node.path)}" ${selection.exact?'checked':''} data-partial="${selection.partial?'true':'false'}"><button class="bu-tree-name" type="button" ${isDir?`data-action="toggle-snapshot-tree:${encodeURIComponent(node.path)}"`:''}><span>${esc(node.name)}</span>${label}<small>${esc(node.path)}${!isDir&&node.size?` · ${formatBytes(node.size)}`:''}</small></button></div>`;
if(isDir&&expanded) html+=`<div class="bu-tree-children">${node.children.length?node.children.map(child=>snapshotTreeNode(child,depth+1)).join(''):'<div class="bu-tree-state">Keine Einträge.</div>'}</div>`;
return html+'</div>';
}
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('Öffnen',`open-dir:${encodeURIComponent(path)}`) : '';
return `<tr><td><input type="checkbox" class="bu-file-select" value="${esc(path)}" ${selected.has(path)?'checked':''}></td><td>${esc(item.type==='dir'?'Ordner':item.type==='file'?'Datei':item.type||'')}</td><td>${esc(item.name||path)}</td><td>${Number(item.size||0).toLocaleString()}</td><td>${open}</td></tr>`;
}).join('');
const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">Nichts ausgewählt.</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('Aktuellen Ordner auswählen','select-current-restore')}${state.browserPath!=='/'?button('Nach oben','browser-up'):''}${button(`Auswahl wiederherstellen (${state.restoreIncludes.length})`,'restore-selected','primary')}</div></div><div class="bu-selection-list">${chosen}</div><table class="bu-table"><thead><tr><th>Auswahl</th><th>Typ</th><th>Name</th><th>Größe</th><th></th></tr></thead><tbody>${rows}</tbody></table></section>`;
const body=state.snapshotTreeLoading?'<div class="bu-tree-state">Snapshot-Struktur wird vollständig eingelesen…</div>':state.snapshotTreeError?`<div class="bu-inline-error">${esc(state.snapshotTreeError)}</div>`:state.snapshotTree?snapshotTreeNode(state.snapshotTree,0):'<div class="bu-empty">Keine Dateien im Snapshot gefunden.</div>';
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">${state.files.length?`${state.files.length.toLocaleString()} Einträge vorgeladen`:'Struktur noch nicht geladen'}</span></div><div class="bu-actions">${button(`Auswahl wiederherstellen (${state.restoreIncludes.length})`,'restore-selected','primary')}</div></div><div class="bu-selection-list">${chosen}</div><div class="bu-tree" role="tree">${body}</div></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>Wiederherstellen</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">Wiederherzustellende Pfade, einer pro Zeile<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Ziel<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><label>Am Originalort wiederherstellen<input name="inPlace" type="checkbox"></label><label>Überschreibungsrisiko bestätigen<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Wiederherstellung einreihen','submit-restore','primary')}</div></form></section>`;
return `<section class="bu-card"><h2>Wiederherstellen</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">Wiederherzustellende Pfade, einer pro Zeile<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Ziel<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><div id="bu-dir-tree-restoreTarget" class="wide"></div><label>Am Originalort wiederherstellen<input name="inPlace" type="checkbox"></label><label>Überschreibungsrisiko bestätigen<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Wiederherstellung einreihen','submit-restore','primary')}</div></form></section>`;
}
function renderActivity() { return `<div class="bu-toolbar"><div><h2>Aktivitäten</h2><div class="bu-muted">Laufende Aufgaben werden automatisch alle zwei Sekunden aktualisiert.</div></div><div class="bu-actions">${button('Aktualisieren','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}</div></div><section class="bu-card">${runsTable([...state.runs].reverse())}</section>`; }
@@ -670,7 +725,7 @@
const unraid = state.config.notifications.find(n=>n.type==='unraid') || {id:'unraid',enabled:true,events:['success','warning','failed']};
const gotify = state.config.notifications.find(n=>n.type==='gotify') || {id:'gotify',enabled:false,events:['success','warning','failed']};
const eventBoxes = target => [['success','Erfolg'],['warning','Warnung'],['failed','Fehler']].map(([value,label])=>`<label><input name="events" type="checkbox" value="${value}" ${(target.events||[]).includes(value)?'checked':''}><span>${label}</span></label>`).join('');
return `<section class="bu-card"><h2>Einstellungen</h2><form id="bu-settings-form" class="bu-form"><label class="wide">Restic-Binärdatei<input name="resticPath" value="${esc(s.resticPath)}"></label><label class="wide">Staging-Verzeichnis für Wiederherstellungen<input name="restoreRoot" value="${esc(s.restoreRoot)}"></label><label class="wide">Verzeichnis für dauerhafte Protokolle<input name="persistentLogDir" value="${esc(s.persistentLogDir||'')}"></label><label class="wide bu-inline-check"><input name="showLiveLog" type="checkbox" ${s.showLiveLog===true?'checked':''}><span>Live-Protokoll im Aktivitäten-Bereich anzeigen</span></label><label>Nachholzeitraum (Stunden)<input name="catchUp" type="number" value="${s.catchUpWindowHours||24}"></label><label>Zeitplan für monatliche Prüfung<input name="checkCron" value="${esc(s.checkCron)}"></label><label>Zeitplan für Bereinigung<input name="pruneCron" value="${esc(s.pruneCron)}"></label><div class="wide bu-actions">${button('Einstellungen speichern','submit-settings','primary')}</div></form></section>
return `<section class="bu-card"><h2>Einstellungen</h2><form id="bu-settings-form" class="bu-form"><label class="wide">Restic-Binärdatei<input name="resticPath" value="${esc(s.resticPath)}"></label><label class="wide">Staging-Verzeichnis für Wiederherstellungen<input name="restoreRoot" value="${esc(s.restoreRoot)}"></label><div id="bu-dir-tree-restoreRoot" class="wide"></div><label class="wide">Verzeichnis für dauerhafte Protokolle<input name="persistentLogDir" value="${esc(s.persistentLogDir||'')}"></label><div id="bu-dir-tree-persistentLogDir" class="wide"></div><label class="wide bu-inline-check"><input name="showLiveLog" type="checkbox" ${s.showLiveLog===true?'checked':''}><span>Live-Protokoll im Aktivitäten-Bereich anzeigen</span></label><label>Nachholzeitraum (Stunden)<input name="catchUp" type="number" value="${s.catchUpWindowHours||24}"></label><label>Zeitplan für monatliche Prüfung<input name="checkCron" value="${esc(s.checkCron)}"></label><label>Zeitplan für Bereinigung<input name="pruneCron" value="${esc(s.pruneCron)}"></label><div class="wide bu-actions">${button('Einstellungen speichern','submit-settings','primary')}</div></form></section>
<section class="bu-card" style="margin-top:16px"><h2>Unraid-Benachrichtigungen</h2><p class="bu-muted">Verwendet das Unraid-Benachrichtigungssystem und alle unter Einstellungen &gt; Benachrichtigungseinstellungen konfigurierten Agenten.</p><form id="bu-unraid-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(unraid.id)}"><label><span>Aktiviert</span><input name="enabled" type="checkbox" ${unraid.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Benachrichtigen bei</legend><div class="bu-event-options">${eventBoxes(unraid)}</div></fieldset><div class="wide bu-actions">${button('Unraid-Benachrichtigungen speichern','submit-unraid-notify','primary')}</div></form></section>
<section class="bu-card" style="margin-top:16px"><h2>Gotify-Benachrichtigung</h2><p class="bu-muted">Erstelle in Gotify eine Anwendung und trage hier deren Anwendungs-Token ein.</p><form id="bu-gotify-notify-form" class="bu-form"><input type="hidden" name="id" value="${esc(gotify.id||'gotify')}"><input type="hidden" name="tokenRef" value="${esc(gotify.tokenRef||id('gotify-token'))}"><label class="wide">Server-URL<input name="url" type="url" placeholder="https://gotify.example.de" value="${esc(gotify.url||'')}"></label><label>Anwendungs-Token<input name="token" type="password" autocomplete="new-password" placeholder="${gotify.tokenRef?'Leer lassen, um das vorhandene Token zu behalten':'Gotify-Anwendungs-Token eingeben'}"></label><label><span>Aktiviert</span><input name="enabled" type="checkbox" ${gotify.enabled?'checked':''}></label><fieldset class="wide bu-fieldset"><legend>Benachrichtigen bei</legend><div class="bu-event-options">${eventBoxes(gotify)}</div></fieldset><div class="wide bu-actions">${button('Gotify-Benachrichtigung speichern','submit-gotify-notify','primary')}</div></form></section>`;
}
@@ -705,18 +760,13 @@
if (name === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a==='check'?'Prüfung':'Bereinigung'} wurde eingereiht`); 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 === 'browse') { state.selectedSnapshot=a; state.restoreIncludes=[]; state.files=[]; state.snapshotTree=null; state.snapshotTreeError=''; state.snapshotTreeExpanded={'/':true}; state.snapshotTreeLoading=true; render(); try { state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${a}/files`); state.snapshotTree=buildSnapshotTree(state.files); } catch(error) { state.snapshotTreeError=`Snapshot-Struktur konnte nicht geladen werden: ${error.message}`; } finally { state.snapshotTreeLoading=false; return render(); } }
if (name === 'toggle-source-tree') { return toggleSourceTree(decodeURIComponent(a)); }
if (name === 'retry-source-tree') { const path=decodeURIComponent(a); delete state.sourceTreeChildren[path]; state.sourceTreeErrors[path]=''; return toggleSourceTree(path,true); }
if (name === 'open-rsync-target') { return loadRsyncTargetDirectories(decodeURIComponent(a)); }
if (name === 'rsync-target-up') { const parent=state.rsyncTargetBrowserPath==='/'?'/':state.rsyncTargetBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadRsyncTargetDirectories(parent==='/mnt'?'/':parent); }
if (name === 'select-rsync-target') { const target=document.querySelector('#bu-job-form [name="rsyncTarget"]'); if(target) target.value=state.rsyncTargetBrowserPath; return; }
if (name === 'open-repository-dir') { return loadRepositoryDirectories(decodeURIComponent(a)); }
if (name === 'repository-up') { const parent=state.repositoryBrowserPath==='/'?'/':state.repositoryBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadRepositoryDirectories(parent==='/mnt'?'/':parent); }
if (name === 'select-repository-folder') { const location=document.querySelector('#bu-repo-form [name="location"]'); if(location) location.value=state.repositoryBrowserPath; return; }
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 === 'toggle-dir-tree') { return toggleDirectoryTree(a, decodeURIComponent(b)); }
if (name === 'retry-dir-tree') { const path=decodeURIComponent(b); const tree=state.directoryTrees[a]; if(tree) { delete tree.children[path]; tree.errors[path]=''; } return toggleDirectoryTree(a,path,true); }
if (name === 'select-dir-tree') { selectDirectoryTreePath(a, decodeURIComponent(b)); return; }
if (name === 'toggle-snapshot-tree') { const path=decodeURIComponent(a); state.snapshotTreeExpanded[path]=!state.snapshotTreeExpanded[path]; return render(); }
if (name === 'restore-selected') { if(!state.restoreIncludes.length) throw new Error('Wähle mindestens eine Datei oder einen Ordner aus.'); 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('Diesen Job löschen?')) { 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 += ' Kopie'; copy.enabled=false; state.config.jobs.push(copy); await saveConfig(); return render(); }
@@ -820,7 +870,8 @@
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);
const path=e.target.value; const prefix=path.replace(/\/$/,'')+'/';
state.restoreIncludes = e.target.checked ? normalizeSourceSelections([...state.restoreIncludes.filter(selected=>!selected.startsWith(prefix)),path]) : state.restoreIncludes.filter(selected=>selected!==path);
render();
}
if (e.target.classList.contains('bu-workload-select')) {