diff --git a/dist/urbm-2026.06.15.r021-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r021-x86_64-1.txz.sha256 deleted file mode 100644 index 01f21d7..0000000 --- a/dist/urbm-2026.06.15.r021-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -7cb21e5fd01532e461c7cddf2af78ee6e1f5b26b24988d42c128b64dfe0e6331 urbm-2026.06.15.r021-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r021-x86_64-1.txz b/dist/urbm-2026.06.15.r022-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.06.15.r021-x86_64-1.txz rename to dist/urbm-2026.06.15.r022-x86_64-1.txz index 45f84e6..3f1c0ec 100644 Binary files a/dist/urbm-2026.06.15.r021-x86_64-1.txz and b/dist/urbm-2026.06.15.r022-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r022-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r022-x86_64-1.txz.sha256 new file mode 100644 index 0000000..5889b6e --- /dev/null +++ b/dist/urbm-2026.06.15.r022-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +ad334b4da853736a6583a0a96e11d3d196a4b22275b95dc0eb0e624a66f77bdb urbm-2026.06.15.r022-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index fdcf812..eaab663 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,18 @@ - + - + ]> +### 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. diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 668f372..bf94309 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,18 @@ - + ]> +### 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. diff --git a/webgui/URBM.page b/webgui/URBM.page index 0cefa3a..36749a2 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.15.r021

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r022

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
- + diff --git a/webgui/assets/urbm.css b/webgui/assets/urbm.css index f95225e..a2e49e5 100644 --- a/webgui/assets/urbm.css +++ b/webgui/assets/urbm.css @@ -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; } diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 744a052..1151cd7 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -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 => ``).join(''); content.innerHTML = `

Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}

@@ -408,9 +407,7 @@ document.getElementById('bu-retention-settings').hidden=isRsync; if (type === 'share' || type === 'appdata') { host.innerHTML = `

${type==='appdata'?'Appdata-Ordner':'Freigabeordner'}

Wähle einen oder mehrere Ordner aus. Zusätzliche Dateifilter können darunter eingetragen werden.
`; - 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 = `

${type==='docker'?'Docker-Container':'Virtuelle Maschinen'}

Wähle die zu sichernden Systeme aus. Metadaten und erkannte Bind-Mounts oder virtuelle Datenträger werden automatisch einbezogen.
Wird geladen...
`; loadWorkloads(type); @@ -418,7 +415,7 @@ const o=job.rsync||{}; host.innerHTML = `

Direkte Rsync-Kopie

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.

Zielordner

Rsync-Optionen
`; host.querySelector('.bu-event-options').insertAdjacentHTML('beforeend', ``); - state.sourceBrowserPath='/'; state.sourceDirectories=sourceRoots; state.sourceBrowserError=''; renderSourceBrowser(); renderRsyncTargetBrowser(); + initSourceTree(sourceRoots.map(item=>item.path), false); renderRsyncTargetBrowser(); } else { state.backupSelections = ['/boot']; host.innerHTML = `

Unraid-USB-Flash-Backup

Das vollständige Konfigurationslaufwerk /boot wird immer dateibasiert gesichert.

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.

`; @@ -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?'Teilweise ausgewählt':selection.inherited?'Durch Elternordner ausgewählt':''; + const toggle=``; + let html=`
${toggle}
`; + if(expanded) { + html+='
'; + if(loading) html+='
Unterordner werden geladen…
'; + else if(error) html+=`
${esc(error)} ${button('Erneut versuchen',`retry-source-tree:${encodeURIComponent(path)}`)}
`; + else if(loaded&&!children.length) html+='
Keine Unterordner.
'; + else html+=children.map(child=>sourceTreeNode(child,depth+1)).join(''); + html+='
'; + } + return html+'
'; + } + 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 `Ordner${esc(item.name)}${partial?'Teilweise ausgewählt':''}${esc(item.path)}${button('Öffnen',`open-source-dir:${encodeURIComponent(item.path)}`)}`; - }).join(''); - const chosen = state.backupSelections.length ? state.backupSelections.map(path=>`${esc(path)}`).join('') : 'Keine Ordner ausgewählt.'; - const error = state.sourceBrowserError ? `
${esc(state.sourceBrowserError)}
` : ''; - host.innerHTML = `

Backup-Quellen auswählen

${esc(state.sourceBrowserPath)}
${state.sourceBrowserPath!=='/'?button('Aktuellen Ordner auswählen','select-current-source'):''}${state.sourceBrowserPath!=='/'?button('Nach oben','source-up'):''}Ausgewählt: ${state.backupSelections.length}
${error}
${chosen}
${rows||''}
AuswahlTypNamePfad
Keine Unterordner gefunden.
`; - host.querySelectorAll('.bu-source-select[data-partial="true"]').forEach(box=>{ box.indeterminate=true; box.setAttribute('aria-checked','mixed'); }); + const host=document.getElementById('bu-source-browser'); + if(!host) return; + const chosen=state.backupSelections.length?state.backupSelections.map(path=>`${esc(path)}`).join(''):'Keine Ordner ausgewählt.'; + const tree=state.sourceTreeRoots.map(item=>sourceTreeNode(item,0)).join(''); + host.innerHTML=`

Backup-Quellen auswählen

Ordner mit dem Dreieck aufklappen. Unterordner werden erst bei Bedarf geladen.
Ausgewählt: ${state.backupSelections.length}
${chosen}
${tree||'
Keine Ordner verfügbar.
'}
`; + 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(); - } catch (error) { - state.sourceBrowserError = `Ordner konnte nicht geladen werden: ${error.message}`; - renderSourceBrowser(); + state.sourceTreeChildren[path]=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`); + } catch(error) { + 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')) {