Add expandable source folder tree
This commit is contained in:
@@ -1 +0,0 @@
|
||||
7cb21e5fd01532e461c7cddf2af78ee6e1f5b26b24988d42c128b64dfe0e6331 urbm-2026.06.15.r021-x86_64-1.txz
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
ad334b4da853736a6583a0a96e11d3d196a4b22275b95dc0eb0e624a66f77bdb urbm-2026.06.15.r022-x86_64-1.txz
|
||||
Vendored
+7
-2
@@ -2,13 +2,18 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "urbm">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.15.r021">
|
||||
<!ENTITY version "2026.06.15.r022">
|
||||
<!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 "7cb21e5fd01532e461c7cddf2af78ee6e1f5b26b24988d42c128b64dfe0e6331">
|
||||
<!ENTITY packageSHA256 "ad334b4da853736a6583a0a96e11d3d196a4b22275b95dc0eb0e624a66f77bdb">
|
||||
]>
|
||||
<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.15.r022
|
||||
- Replace the source folder table with a lazy-loaded expandable tree for Restic and Rsync jobs.
|
||||
- Add rotating disclosure triangles, nested indentation, and per-folder loading, empty, retry, and error states.
|
||||
- Preserve full, inherited, and partial checkbox states across all expanded levels and normalize redundant parent/child selections.
|
||||
|
||||
### 2026.06.15.r021
|
||||
- Show an orange mixed checkbox on parent folders when only some descendant folders are selected.
|
||||
- Keep partial-selection indicators visible while navigating between folder levels for Restic and Rsync source selection.
|
||||
|
||||
+6
-1
@@ -2,13 +2,18 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "urbm">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.15.r021">
|
||||
<!ENTITY version "2026.06.15.r022">
|
||||
<!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.15.r022
|
||||
- Replace the source folder table with a lazy-loaded expandable tree for Restic and Rsync jobs.
|
||||
- Add rotating disclosure triangles, nested indentation, and per-folder loading, empty, retry, and error states.
|
||||
- Preserve full, inherited, and partial checkbox states across all expanded levels and normalize redundant parent/child selections.
|
||||
|
||||
### 2026.06.15.r021
|
||||
- Show an orange mixed checkbox on parent folders when only some descendant folders are selected.
|
||||
- Keep partial-selection indicators visible while navigating between folder levels for Restic and Rsync source selection.
|
||||
|
||||
+4
-4
@@ -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=20260615r021">
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r022">
|
||||
<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=20260615r021" alt="URBM-Logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.06.15.r021</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r022" alt="URBM-Logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.06.15.r022</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.15.r021.js"></script>
|
||||
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r022.js"></script>
|
||||
|
||||
@@ -83,6 +83,23 @@
|
||||
.bu-source-partial { background: rgba(255, 140, 47, .09); background: color-mix(in srgb, var(--bu-accent) 9%, transparent); }
|
||||
.bu-source-partial .bu-source-select { accent-color: var(--bu-accent); }
|
||||
.bu-partial-label { border: 1px solid var(--bu-accent); border-radius: 999px; color: var(--bu-text); display: inline-block; font-size: 11px; font-weight: 700; margin-left: 8px; padding: 2px 7px; vertical-align: middle; }
|
||||
.bu-inherited-label { border: 1px solid var(--bu-good); border-radius: 999px; color: var(--bu-good); display: inline-block; font-size: 11px; font-weight: 700; margin-left: 8px; padding: 2px 7px; vertical-align: middle; }
|
||||
.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-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; }
|
||||
#urbm-app .bu-tree-toggle { align-items: center; cursor: pointer; display: flex; height: 28px; justify-content: center; width: 28px; }
|
||||
.bu-tree-toggle span { display: inline-block; font-size: 14px; transition: transform .16s ease; }
|
||||
.bu-tree-toggle.expanded span { transform: rotate(90deg); }
|
||||
#urbm-app .bu-tree-name { cursor: pointer; display: grid; gap: 1px; grid-template-columns: auto minmax(0, 1fr); min-width: 0; }
|
||||
.bu-tree-name > span:first-child { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bu-tree-name small { color: var(--bu-muted); grid-column: 1 / -1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bu-tree-children { position: relative; }
|
||||
.bu-tree-children::before { background: var(--bu-border); bottom: 3px; content: ''; left: calc((var(--bu-tree-depth, 0) + 1) * 22px + 18px); opacity: .45; position: absolute; top: 0; width: 1px; }
|
||||
.bu-tree-state { color: var(--bu-muted); margin-left: 66px; padding: 8px 12px; }
|
||||
.bu-tree-state.error { color: var(--bu-bad); }
|
||||
.bu-count { align-items: center; border: 1px solid var(--bu-border); border-radius: 999px; display: inline-flex; font-size: 13px; font-weight: 700; padding: 7px 11px; }
|
||||
.bu-inline-error { background: rgba(180,35,47,.08); border: 1px solid var(--bu-bad); border-radius: 5px; color: var(--bu-bad); font-weight: 600; margin: 10px 0; padding: 10px 12px; }
|
||||
.bu-inline-info { background: rgba(19,138,98,.08); border: 1px solid var(--bu-good); border-radius: 5px; color: var(--bu-text); font-weight: 600; margin: 10px 0; padding: 10px 12px; }
|
||||
|
||||
+75
-33
@@ -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: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '', 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: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, rsyncTargetBrowserPath: '/', rsyncTargetDirectories: repositoryRoots, rsyncTargetBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', liveLogOpen: {} };
|
||||
|
||||
const fieldHelp = {
|
||||
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
|
||||
@@ -73,8 +73,8 @@
|
||||
'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.',
|
||||
'source-up': 'Öffnet im Ordner-Browser das übergeordnete Verzeichnis.',
|
||||
'select-current-source': 'Wählt den aktuell geöffneten Ordner vollständig als Backup-Quelle aus.',
|
||||
'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.',
|
||||
@@ -156,7 +156,6 @@
|
||||
'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-source-dir': 'Öffnet diesen Ordner, um darin weitere Unterordner auszuwählen.',
|
||||
'open-repository-dir': 'Öffnet diesen lokalen Ordner, um ihn oder einen Unterordner als Repository-Ziel auszuwählen.',
|
||||
'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.',
|
||||
@@ -380,10 +379,10 @@
|
||||
}
|
||||
|
||||
function jobForm(job = {}) {
|
||||
state.backupSelections = (job.sources || []).filter(s=>s.path).map(s=>s.path);
|
||||
state.backupSelections = normalizeSourceSelections((job.sources || []).filter(s=>s.path).map(s=>s.path));
|
||||
state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId);
|
||||
state.workloads = []; state.workloadAvailable = true; state.workloadError = '';
|
||||
state.sourceBrowserPath = '/'; state.sourceDirectories = sourceRoots; state.sourceBrowserError = '';
|
||||
resetSourceTree(sourceRoots);
|
||||
state.rsyncTargetBrowserPath = '/'; state.rsyncTargetDirectories = repositoryRoots; state.rsyncTargetBrowserError = '';
|
||||
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">
|
||||
@@ -408,9 +407,7 @@
|
||||
document.getElementById('bu-retention-settings').hidden=isRsync;
|
||||
if (type === 'share' || type === 'appdata') {
|
||||
host.innerHTML = `<h3>${type==='appdata'?'Appdata-Ordner':'Freigabeordner'}</h3><div class="bu-muted">Wähle einen oder mehrere Ordner aus. Zusätzliche Dateifilter können darunter eingetragen werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Pfade<textarea name="sources"></textarea></label>`;
|
||||
state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user';
|
||||
state.sourceDirectories = []; state.sourceBrowserError = '';
|
||||
loadSourceDirectories(state.sourceBrowserPath);
|
||||
initSourceTree([type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user'], true);
|
||||
} else if (type === 'docker' || type === 'vm') {
|
||||
host.innerHTML = `<h3>${type==='docker'?'Docker-Container':'Virtuelle Maschinen'}</h3><div class="bu-muted">Wähle die zu sichernden Systeme aus. Metadaten und erkannte Bind-Mounts oder virtuelle Datenträger werden automatisch einbezogen.</div><div id="bu-workload-browser" class="bu-browser-card"><div class="bu-empty">Wird geladen...</div></div><div class="bu-workload-options"><label>Konsistenz<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Kontrolliert stoppen</option></select></label><label>Stopp-Zeitlimit (Sekunden)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label></div>`;
|
||||
loadWorkloads(type);
|
||||
@@ -418,7 +415,7 @@
|
||||
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.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>`);
|
||||
state.sourceBrowserPath='/'; state.sourceDirectories=sourceRoots; state.sourceBrowserError=''; renderSourceBrowser(); renderRsyncTargetBrowser();
|
||||
initSourceTree(sourceRoots.map(item=>item.path), false); renderRsyncTargetBrowser();
|
||||
} 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>`;
|
||||
@@ -444,36 +441,80 @@
|
||||
renderWorkloadBrowser(type);
|
||||
}
|
||||
|
||||
function resetSourceTree(roots=sourceRoots) {
|
||||
state.sourceTreeRoots=roots.map(item=>typeof item==='string'?{name:item,path:item}:item);
|
||||
state.sourceTreeChildren={}; state.sourceTreeExpanded={}; state.sourceTreeLoading={}; state.sourceTreeErrors={};
|
||||
}
|
||||
|
||||
function normalizeSourceSelections(paths) {
|
||||
const unique=[...new Set(paths)].sort((left,right)=>left.length-right.length);
|
||||
return unique.filter((path,index)=>!unique.slice(0,index).some(parent=>path.startsWith(parent.replace(/\/$/,'')+'/')));
|
||||
}
|
||||
|
||||
function initSourceTree(paths, expand) {
|
||||
resetSourceTree(paths.map(path=>({name:path,path})));
|
||||
renderSourceBrowser();
|
||||
if(expand) paths.forEach(path=>toggleSourceTree(path, true));
|
||||
}
|
||||
|
||||
function selectedAncestor(path) {
|
||||
return state.backupSelections.find(selected=>path!==selected&&path.startsWith(selected.replace(/\/$/,'')+'/')) || '';
|
||||
}
|
||||
|
||||
function sourceSelectionState(path) {
|
||||
const exact=state.backupSelections.includes(path);
|
||||
const inherited=selectedAncestor(path);
|
||||
const prefix=path.replace(/\/$/,'')+'/';
|
||||
const partial=!exact&&!inherited&&state.backupSelections.some(selected=>selected.startsWith(prefix));
|
||||
return {exact,inherited,partial};
|
||||
}
|
||||
|
||||
function sourceTreeNode(item, depth) {
|
||||
const path=item.path;
|
||||
const selection=sourceSelectionState(path);
|
||||
const expanded=state.sourceTreeExpanded[path]===true;
|
||||
const loading=state.sourceTreeLoading[path]===true;
|
||||
const loaded=Object.prototype.hasOwnProperty.call(state.sourceTreeChildren,path);
|
||||
const children=state.sourceTreeChildren[path]||[];
|
||||
const error=state.sourceTreeErrors[path]||'';
|
||||
const hint=selection.inherited?`Durch den ausgewählten Ordner ${selection.inherited} vollständig enthalten.`:selection.partial?'Ein oder mehrere Unterordner sind ausgewählt. Klicken, um den gesamten Ordner auszuwählen.':'Diesen Ordner als vollständige Backup-Quelle auswählen.';
|
||||
const label=selection.partial?'<span class="bu-partial-label">Teilweise ausgewählt</span>':selection.inherited?'<span class="bu-inherited-label">Durch Elternordner ausgewählt</span>':'';
|
||||
const toggle=`<button class="bu-tree-toggle ${expanded?'expanded':''}" type="button" data-action="toggle-source-tree:${encodeURIComponent(path)}" aria-expanded="${expanded}" data-tooltip="${expanded?'Unterordner einklappen':'Unterordner aufklappen'}"><span>▶</span></button>`;
|
||||
let html=`<div class="bu-tree-node ${selection.partial?'bu-source-partial':''}" style="--bu-tree-depth:${depth}"><div class="bu-tree-row">${toggle}<input type="checkbox" class="bu-source-select" value="${esc(path)}" ${(selection.exact||selection.inherited)?'checked':''} ${selection.inherited?'disabled':''} data-partial="${selection.partial?'true':'false'}" data-tooltip="${esc(hint)}"><button class="bu-tree-name" type="button" data-action="toggle-source-tree:${encodeURIComponent(path)}"><span>${esc(item.name)}</span>${label}<small>${esc(path)}</small></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-source-tree:${encodeURIComponent(path)}`)}</div>`;
|
||||
else if(loaded&&!children.length) html+='<div class="bu-tree-state">Keine Unterordner.</div>';
|
||||
else html+=children.map(child=>sourceTreeNode(child,depth+1)).join('');
|
||||
html+='</div>';
|
||||
}
|
||||
return html+'</div>';
|
||||
}
|
||||
|
||||
function renderSourceBrowser() {
|
||||
const host=document.getElementById('bu-source-browser');
|
||||
if(!host) return;
|
||||
const selected = new Set(state.backupSelections);
|
||||
const rows = state.sourceDirectories.map(item => {
|
||||
const exact=selected.has(item.path);
|
||||
const prefix=item.path.replace(/\/$/,'')+'/';
|
||||
const partial=!exact&&state.backupSelections.some(path=>path.startsWith(prefix));
|
||||
const hint=partial?'Ein oder mehrere Unterordner sind ausgewählt. Klicken, um den gesamten Ordner auszuwählen.':'Diesen Ordner als vollständige Backup-Quelle auswählen.';
|
||||
return `<tr class="${partial?'bu-source-partial':''}"><td><input type="checkbox" class="bu-source-select" value="${esc(item.path)}" ${exact?'checked':''} data-partial="${partial?'true':'false'}" data-tooltip="${esc(hint)}"></td><td>Ordner</td><td>${esc(item.name)}${partial?'<span class="bu-partial-label">Teilweise ausgewählt</span>':''}</td><td>${esc(item.path)}</td><td>${button('Öffnen',`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">Keine Ordner ausgewählt.</span>';
|
||||
const error = state.sourceBrowserError ? `<div class="bu-inline-error">${esc(state.sourceBrowserError)}</div>` : '';
|
||||
host.innerHTML = `<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Backup-Quellen auswählen</h3><span class="bu-muted">${esc(state.sourceBrowserPath)}</span></div><div class="bu-actions">${state.sourceBrowserPath!=='/'?button('Aktuellen Ordner auswählen','select-current-source'):''}${state.sourceBrowserPath!=='/'?button('Nach oben','source-up'):''}<span class="bu-count">Ausgewählt: ${state.backupSelections.length}</span></div></div>${error}<div class="bu-selection-list">${chosen}</div><div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Auswahl</th><th>Typ</th><th>Name</th><th>Pfad</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="5" class="bu-empty">Keine Unterordner gefunden.</td></tr>'}</tbody></table></div></section>`;
|
||||
const tree=state.sourceTreeRoots.map(item=>sourceTreeNode(item,0)).join('');
|
||||
host.innerHTML=`<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Backup-Quellen auswählen</h3><span class="bu-muted">Ordner mit dem Dreieck aufklappen. Unterordner werden erst bei Bedarf geladen.</span></div><span class="bu-count">Ausgewählt: ${state.backupSelections.length}</span></div><div class="bu-selection-list">${chosen}</div><div class="bu-tree" role="tree">${tree||'<div class="bu-empty">Keine Ordner verfügbar.</div>'}</div></section>`;
|
||||
host.querySelectorAll('.bu-source-select[data-partial="true"]').forEach(box=>{box.indeterminate=true;box.setAttribute('aria-checked','mixed');});
|
||||
enhanceTooltips(host);
|
||||
}
|
||||
|
||||
async function loadSourceDirectories(path) {
|
||||
if (path === '/') {
|
||||
state.sourceDirectories = sourceRoots; state.sourceBrowserPath = '/'; state.sourceBrowserError = ''; renderSourceBrowser(); return;
|
||||
async function toggleSourceTree(path, forceOpen=false) {
|
||||
if(state.sourceTreeExpanded[path]&&!forceOpen) {
|
||||
state.sourceTreeExpanded[path]=false; renderSourceBrowser(); return;
|
||||
}
|
||||
state.sourceTreeExpanded[path]=true; renderSourceBrowser();
|
||||
if(Object.prototype.hasOwnProperty.call(state.sourceTreeChildren,path)&&!state.sourceTreeErrors[path]) return;
|
||||
state.sourceTreeLoading[path]=true; state.sourceTreeErrors[path]=''; renderSourceBrowser();
|
||||
try {
|
||||
state.sourceDirectories = await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
|
||||
state.sourceBrowserPath = path;
|
||||
state.sourceBrowserError = '';
|
||||
renderSourceBrowser();
|
||||
state.sourceTreeChildren[path]=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`);
|
||||
} catch(error) {
|
||||
state.sourceBrowserError = `Ordner konnte nicht geladen werden: ${error.message}`;
|
||||
renderSourceBrowser();
|
||||
state.sourceTreeErrors[path]=`Ordner konnte nicht geladen werden: ${error.message}`;
|
||||
} finally {
|
||||
state.sourceTreeLoading[path]=false; renderSourceBrowser();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,9 +702,8 @@
|
||||
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 === '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 === 'select-current-source') { if(!state.backupSelections.includes(state.sourceBrowserPath)) state.backupSelections.push(state.sourceBrowserPath); return renderSourceBrowser(); }
|
||||
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; }
|
||||
@@ -770,7 +810,9 @@
|
||||
renderJobSpecific(e.target.value);
|
||||
}
|
||||
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);
|
||||
const path=e.target.value; const prefix=path.replace(/\/$/,'')+'/';
|
||||
if(e.target.checked) state.backupSelections=normalizeSourceSelections([...state.backupSelections.filter(selected=>!selected.startsWith(prefix)),path]);
|
||||
else state.backupSelections=state.backupSelections.filter(selected=>selected!==path);
|
||||
renderSourceBrowser();
|
||||
}
|
||||
if (e.target.classList.contains('bu-file-select')) {
|
||||
|
||||
Reference in New Issue
Block a user