diff --git a/dist/urbm-2026.06.21.r001-x86_64-1.txz.sha256 b/dist/urbm-2026.06.21.r001-x86_64-1.txz.sha256 deleted file mode 100644 index 73be9a7..0000000 --- a/dist/urbm-2026.06.21.r001-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -28f348211cf8437f8227303eb0c697e4846048c8cad5b51bf9c26891b271684e urbm-2026.06.21.r001-x86_64-1.txz diff --git a/dist/urbm-2026.06.21.r001-x86_64-1.txz b/dist/urbm-2026.06.21.r002-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.06.21.r001-x86_64-1.txz rename to dist/urbm-2026.06.21.r002-x86_64-1.txz index fcf7157..8c5ca55 100644 Binary files a/dist/urbm-2026.06.21.r001-x86_64-1.txz and b/dist/urbm-2026.06.21.r002-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.21.r002-x86_64-1.txz.sha256 b/dist/urbm-2026.06.21.r002-x86_64-1.txz.sha256 new file mode 100644 index 0000000..ebd13e3 --- /dev/null +++ b/dist/urbm-2026.06.21.r002-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +f51b27d4b6bd430c0092014e3b7651f5537965a9daf198895da84fd11e20ed3b urbm-2026.06.21.r002-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 4c16599..28369d7 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,18 @@ - + - + ]> +### 2026.06.21.r002 +- Reuse the expandable folder tree for local repository folders, Rsync targets, restore targets, and directory settings. +- Preload complete snapshot file listings once and browse them locally in an expandable restore tree. +- Remove the previous snapshot browser truncation and extend the snapshot listing timeout for large backups. + ### 2026.06.21.r001 - Add a separate emergency repository unlock action that invokes Restic unlock with --remove-all after a strong confirmation. - Keep the normal unlock action safe by removing only stale locks that Restic considers safe to clear. diff --git a/internal/api/server.go b/internal/api/server.go index ebf77e3..36a62ec 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -268,7 +268,7 @@ func (s *Server) repositoryStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.service.RepositoryStats(ctx)) } func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute) defer cancel() items, err := s.service.SnapshotFiles(ctx, r.PathValue("id"), r.PathValue("snapshot"), r.URL.Query().Get("path")) if err != nil { diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 57c2d1d..b7bd31b 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -270,14 +270,12 @@ func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path } items := []map[string]any{} scanner := bufio.NewScanner(strings.NewReader(string(output))) + scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) for scanner.Scan() { var item map[string]any if json.Unmarshal(scanner.Bytes(), &item) == nil && item["struct_type"] == "node" { items = append(items, item) } - if len(items) >= 5000 { - break - } } return items, scanner.Err() } diff --git a/plugin/urbm.plg b/plugin/urbm.plg index fd6b728..f979250 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,18 @@ - + ]> +### 2026.06.21.r002 +- Reuse the expandable folder tree for local repository folders, Rsync targets, restore targets, and directory settings. +- Preload complete snapshot file listings once and browse them locally in an expandable restore tree. +- Remove the previous snapshot browser truncation and extend the snapshot listing timeout for large backups. + ### 2026.06.21.r001 - Add a separate emergency repository unlock action that invokes Restic unlock with --remove-all after a strong confirmation. - Keep the normal unlock action safe by removing only stale locks that Restic considers safe to clear. diff --git a/webgui/URBM.page b/webgui/URBM.page index 1eb1196..609d822 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.21.r001

Restic Backup Manager für Unraid

+ +

URBM 2026.06.21.r002

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 a2e49e5..699e917 100644 --- a/webgui/assets/urbm.css +++ b/webgui/assets/urbm.css @@ -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; } diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 7a1f811..7e6a44e 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: '', 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 => ``).join(''); content.innerHTML = `

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

@@ -416,9 +427,9 @@ loadWorkloads(type); } else if (type === 'rsync') { 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.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', ``); - 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 = `

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.

`; @@ -521,26 +532,51 @@ } } - function renderRsyncTargetBrowser() { - const host=document.getElementById('bu-rsync-target-browser'); - if(!host) return; - const rows=state.rsyncTargetDirectories.map(item=>`${esc(item.name)}${esc(item.path)}${button('Öffnen',`open-rsync-target:${encodeURIComponent(item.path)}`)}`).join(''); - const error=state.rsyncTargetBrowserError?`
${esc(state.rsyncTargetBrowserError)}
`:''; - host.innerHTML=`

Rsync-Ziel auswählen

${esc(state.rsyncTargetBrowserPath)}
${state.rsyncTargetBrowserPath!=='/'?button('Diesen Ordner verwenden','select-rsync-target','primary'):''}${state.rsyncTargetBrowserPath!=='/'?button('Nach oben','rsync-target-up'):''}
${error}
${rows||''}
NamePfad
Keine Unterordner gefunden.
`; + 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=``; + let html=`
${toggle}
`; + if(expanded) { + html+='
'; + if(loading) html+='
Unterordner werden geladen…
'; + else if(error) html+=`
${esc(error)} ${button('Erneut versuchen',`retry-dir-tree:${key}:${encodeURIComponent(path)}`)}
`; + else if(loaded&&!children.length) html+='
Keine Unterordner.
'; + else html+=children.map(child=>directoryTreeNode(key,child,depth+1)).join(''); + html+='
'; + } + return html+'
'; + } + + 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=`

${esc(tree.title)}

Ordner mit dem Dreieck aufklappen und den gewünschten Zielordner auswählen.
${html||'
Keine Ordner verfügbar.
'}
`; 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 = `

${repo.id?'Repository bearbeiten':existingRepository?'Vorhandenes Repository einbinden':'Neues Repository erstellen'}

@@ -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')}
`; - renderRepositoryBrowser(); + host.innerHTML = `${repositoryLocationField('Lokaler Repository-Ordner',repo.location,'/mnt/user/backups/urbm')}
`; + 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 = `
${type.toUpperCase()}-Ziel
${repositoryLocationField('Bereits eingebundener Ordner',repo.location,'/mnt/remotes/backup-nas')}
${type==='smb'?``:''}
`; @@ -590,48 +626,67 @@ enhanceTooltips(host); } - function renderRepositoryBrowser() { - const host = document.getElementById('bu-repository-browser'); - if (!host) return; - const rows = state.repositoryDirectories.map(item=>`${esc(item.name)}${esc(item.path)}${button('Öffnen',`open-repository-dir:${encodeURIComponent(item.path)}`)}`).join(''); - const error = state.repositoryBrowserError ? `
${esc(state.repositoryBrowserError)}
` : ''; - host.innerHTML = `

Lokalen Ordner auswählen

${esc(state.repositoryBrowserPath)}
${state.repositoryBrowserPath!=='/'?button('Diesen Ordner verwenden','select-repository-folder','primary'):''}${state.repositoryBrowserPath!=='/'?button('Nach oben','repository-up'):''}
${error}
${rows||''}
NamePfad
Keine Unterordner gefunden.
`; - 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=>``).join(''); const rows = state.snapshots.map(s=>`${esc(s.short_id||s.id)}${esc(new Date(s.time).toLocaleString())}${esc(s.hostname)}${esc((s.paths||[]).join(', '))}${button('Durchsuchen',`browse:${s.id}`)}`).join(''); - return `

Snapshots

${button('Laden','load-snapshots','primary')}
${rows?`${rows}
SnapshotZeitpunktHostPfade
`:'
Repository auswählen und Snapshots laden.
'}
${state.selectedSnapshot && state.files.length ? renderFileBrowser() : ''}`; + return `

Snapshots

${button('Laden','load-snapshots','primary')}
${rows?`${rows}
SnapshotZeitpunktHostPfade
`:'
Repository auswählen und Snapshots laden.
'}
${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?``:''; + const label=selection.partial?'Teilweise ausgewählt':''; + let html=`
${toggle}
`; + if(isDir&&expanded) html+=`
${node.children.length?node.children.map(child=>snapshotTreeNode(child,depth+1)).join(''):'
Keine Einträge.
'}
`; + return html+'
'; } 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 `${esc(item.type==='dir'?'Ordner':item.type==='file'?'Datei':item.type||'')}${esc(item.name||path)}${Number(item.size||0).toLocaleString()}${open}`; - }).join(''); const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`${esc(path)}`).join('') : 'Nichts ausgewählt.'; - return `

Snapshot ${esc(state.selectedSnapshot.slice(0,12))}

${esc(state.browserPath)}
${button('Aktuellen Ordner auswählen','select-current-restore')}${state.browserPath!=='/'?button('Nach oben','browser-up'):''}${button(`Auswahl wiederherstellen (${state.restoreIncludes.length})`,'restore-selected','primary')}
${chosen}
${rows}
AuswahlTypNameGröße
`; + const body=state.snapshotTreeLoading?'
Snapshot-Struktur wird vollständig eingelesen…
':state.snapshotTreeError?`
${esc(state.snapshotTreeError)}
`:state.snapshotTree?snapshotTreeNode(state.snapshotTree,0):'
Keine Dateien im Snapshot gefunden.
'; + return `

Snapshot ${esc(state.selectedSnapshot.slice(0,12))}

${state.files.length?`${state.files.length.toLocaleString()} Einträge vorgeladen`:'Struktur noch nicht geladen'}
${button(`Auswahl wiederherstellen (${state.restoreIncludes.length})`,'restore-selected','primary')}
${chosen}
${body}
`; } function renderRestore() { const options = state.config.repositories.map(r=>``).join(''); - return `

Wiederherstellen

${button('Wiederherstellung einreihen','submit-restore','primary')}
`; + return `

Wiederherstellen

${button('Wiederherstellung einreihen','submit-restore','primary')}
`; } function renderActivity() { return `

Aktivitäten

Laufende Aufgaben werden automatisch alle zwei Sekunden aktualisiert.
${button('Aktualisieren','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}
${runsTable([...state.runs].reverse())}
`; } @@ -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])=>``).join(''); - return `

Einstellungen

${button('Einstellungen speichern','submit-settings','primary')}
+ return `

Einstellungen

${button('Einstellungen speichern','submit-settings','primary')}

Unraid-Benachrichtigungen

Verwendet das Unraid-Benachrichtigungssystem und alle unter Einstellungen > Benachrichtigungseinstellungen konfigurierten Agenten.

Benachrichtigen bei
${eventBoxes(unraid)}
${button('Unraid-Benachrichtigungen speichern','submit-unraid-notify','primary')}

Gotify-Benachrichtigung

Erstelle in Gotify eine Anwendung und trage hier deren Anwendungs-Token ein.

Benachrichtigen bei
${eventBoxes(gotify)}
${button('Gotify-Benachrichtigung speichern','submit-gotify-notify','primary')}
`; } @@ -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')) {