Unify directory trees and preload snapshots
This commit is contained in:
@@ -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; }
|
||||
|
||||
+124
-73
@@ -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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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;
|
||||
}
|
||||
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();
|
||||
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); }
|
||||
}
|
||||
|
||||
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 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 > 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')) {
|
||||
|
||||
Reference in New Issue
Block a user