Page snapshot browser data

This commit is contained in:
Mikei386
2026-07-10 13:49:29 +02:00
parent 091c5ff175
commit 6cbf170d65
8 changed files with 235 additions and 64 deletions
-1
View File
@@ -1 +0,0 @@
24660d70ea52abe59214ad8a56f31023cef090cb2fafeb557690870ed5790aa9 urbm-2026.07.10.r006-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
3e3f832d98656655d591c05ad4454d8480d5cfcdf60c03a2a498a6498e206beb urbm-2026.07.10.r007-x86_64-1.txz
+6 -2
View File
@@ -2,13 +2,17 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.07.10.r006"> <!ENTITY version "2026.07.10.r007">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!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 packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "24660d70ea52abe59214ad8a56f31023cef090cb2fafeb557690870ed5790aa9"> <!ENTITY packageSHA256 "3e3f832d98656655d591c05ad4454d8480d5cfcdf60c03a2a498a6498e206beb">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <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> <CHANGES>
### 2026.07.10.r007
- Serve snapshot browser folders as compact cached tree pages instead of sending the complete Restic file list to the browser.
- Prevent large snapshots with hundreds of thousands of entries from failing with generic 500 responses during snapshot browsing.
### 2026.07.10.r006 ### 2026.07.10.r006
- Stop logging high-frequency health, log, and activity polling requests unless they are slow or fail. - Stop logging high-frequency health, log, and activity polling requests unless they are slow or fail.
- Log snapshot file-listing requests immediately when they start and show a larger daemon log tail in the Protokolle tab. - Log snapshot file-listing requests immediately when they start and show a larger daemon log tail in the Protokolle tab.
+178 -9
View File
@@ -10,6 +10,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -43,6 +44,38 @@ type Service struct {
queue *queue.Queue queue *queue.Queue
mu sync.RWMutex mu sync.RWMutex
config model.Config config model.Config
snapshotMu sync.Mutex
snapshotTree map[string]snapshotTreeCache
}
type SnapshotBrowserNode struct {
Path string `json:"path"`
Name string `json:"name"`
Type string `json:"type"`
Size int64 `json:"size,omitempty"`
HasChildren bool `json:"hasChildren"`
}
type SnapshotBrowserPage struct {
Path string `json:"path"`
Items []SnapshotBrowserNode `json:"items"`
Total int `json:"total"`
Cached bool `json:"cached"`
Generated time.Time `json:"generated"`
}
type snapshotTreeCache struct {
generated time.Time
total int
children map[string][]SnapshotBrowserNode
}
type snapshotTreeNode struct {
path string
name string
nodeType string
size int64
children map[string]*snapshotTreeNode
} }
func New(st *store.Store, secrets SecretStore, rr *restic.Runner, rs *rsync.Runner, mounts *platform.MountManager, workloads *platform.WorkloadManager, notifier *notify.Sender, log *slog.Logger) (*Service, error) { func New(st *store.Store, secrets SecretStore, rr *restic.Runner, rs *rsync.Runner, mounts *platform.MountManager, workloads *platform.WorkloadManager, notifier *notify.Sender, log *slog.Logger) (*Service, error) {
@@ -384,26 +417,162 @@ func (s *Service) RepositoryStats(ctx context.Context) []model.RepositoryStats {
return result return result
} }
func (s *Service) SnapshotFiles(ctx context.Context, repoID, snapshot, path string) ([]map[string]any, error) { func (s *Service) SnapshotFiles(ctx context.Context, repoID, snapshot, path string) (SnapshotBrowserPage, error) {
started := time.Now() started := time.Now()
s.log.Info("snapshot file listing started", "repoID", repoID, "snapshot", snapshot, "path", path) s.log.Info("snapshot file listing started", "repoID", repoID, "snapshot", snapshot, "path", path)
repo, ok := s.repository(repoID) repo, ok := s.repository(repoID)
if !ok { if !ok {
return nil, errors.New("validation: unknown repository") return SnapshotBrowserPage{}, errors.New("validation: unknown repository")
} }
normalizedPath := normalizeSnapshotPath(path)
key := snapshotCacheKey(repoID, snapshot)
if page, ok := s.snapshotBrowserPage(key, normalizedPath); ok {
s.log.Info("snapshot file listing loaded from cache", "repoID", repoID, "snapshot", snapshot, "path", normalizedPath, "items", len(page.Items), "total", page.Total, "durationMs", time.Since(started).Milliseconds())
return page, nil
}
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil { if err != nil {
s.log.Warn("snapshot file listing failed", "repoID", repoID, "snapshot", snapshot, "path", path, "durationMs", time.Since(started).Milliseconds(), "error", err) s.log.Warn("snapshot file listing failed", "repoID", repoID, "snapshot", snapshot, "path", normalizedPath, "durationMs", time.Since(started).Milliseconds(), "error", err)
return nil, err return SnapshotBrowserPage{}, err
} }
defer s.mounts.Cleanup(context.Background(), mounted) defer s.mounts.Cleanup(context.Background(), mounted)
items, err := s.restic.List(ctx, mounted.Repository, snapshot, path)
items, err := s.restic.List(ctx, mounted.Repository, snapshot, "")
if err != nil { if err != nil {
s.log.Warn("snapshot file listing failed", "repoID", repoID, "snapshot", snapshot, "path", path, "durationMs", time.Since(started).Milliseconds(), "error", err) s.log.Warn("snapshot file listing failed", "repoID", repoID, "snapshot", snapshot, "path", normalizedPath, "durationMs", time.Since(started).Milliseconds(), "error", err)
return nil, err return SnapshotBrowserPage{}, err
} }
s.log.Info("snapshot file listing loaded", "repoID", repoID, "snapshot", snapshot, "path", path, "items", len(items), "durationMs", time.Since(started).Milliseconds()) cache := buildSnapshotTreeCache(items)
return items, nil s.storeSnapshotTree(key, cache)
page, _ := s.snapshotBrowserPage(key, normalizedPath)
s.log.Info("snapshot file listing loaded", "repoID", repoID, "snapshot", snapshot, "path", normalizedPath, "items", len(page.Items), "total", cache.total, "durationMs", time.Since(started).Milliseconds())
return page, nil
}
func snapshotCacheKey(repoID, snapshot string) string {
return repoID + "\x00" + snapshot
}
func (s *Service) snapshotBrowserPage(key, path string) (SnapshotBrowserPage, bool) {
s.snapshotMu.Lock()
defer s.snapshotMu.Unlock()
cache, ok := s.snapshotTree[key]
if !ok || time.Since(cache.generated) > 30*time.Minute {
return SnapshotBrowserPage{}, false
}
items, ok := cache.children[path]
if !ok {
items = []SnapshotBrowserNode{}
}
return SnapshotBrowserPage{Path: path, Items: items, Total: cache.total, Cached: true, Generated: cache.generated}, true
}
func (s *Service) storeSnapshotTree(key string, cache snapshotTreeCache) {
s.snapshotMu.Lock()
defer s.snapshotMu.Unlock()
if s.snapshotTree == nil {
s.snapshotTree = map[string]snapshotTreeCache{}
}
for cacheKey, value := range s.snapshotTree {
if time.Since(value.generated) > 30*time.Minute {
delete(s.snapshotTree, cacheKey)
}
}
s.snapshotTree[key] = cache
}
func buildSnapshotTreeCache(items []map[string]any) snapshotTreeCache {
root := &snapshotTreeNode{path: "/", name: "/", nodeType: "dir", children: map[string]*snapshotTreeNode{}}
nodes := map[string]*snapshotTreeNode{"/": root}
ensure := func(path string, nodeType string, size int64) *snapshotTreeNode {
return root
}
ensure = func(path string, nodeType string, size int64) *snapshotTreeNode {
path = normalizeSnapshotPath(path)
if node, ok := nodes[path]; ok {
if nodeType != "" && nodeType != "dir" {
node.nodeType = nodeType
node.size = size
}
return node
}
parentPath := "/"
if path != "/" {
parentPath = filepath.Dir(path)
if parentPath == "." {
parentPath = "/"
}
}
parent := ensure(parentPath, "dir", 0)
node := &snapshotTreeNode{path: path, name: filepath.Base(path), nodeType: nodeType, size: size, children: map[string]*snapshotTreeNode{}}
if path == "/" {
node.name = "/"
}
nodes[path] = node
parent.children[path] = node
return node
}
for _, item := range items {
path := snapshotItemPath(item)
if path == "/" {
continue
}
nodeType := "file"
if value, _ := item["type"].(string); value == "dir" {
nodeType = "dir"
}
ensure(path, nodeType, snapshotItemSize(item))
}
children := make(map[string][]SnapshotBrowserNode, len(nodes))
for _, node := range nodes {
if len(node.children) == 0 {
continue
}
list := make([]SnapshotBrowserNode, 0, len(node.children))
for _, child := range node.children {
list = append(list, SnapshotBrowserNode{Path: child.path, Name: child.name, Type: child.nodeType, Size: child.size, HasChildren: len(child.children) > 0})
}
sort.Slice(list, func(i, j int) bool {
if list[i].Type != list[j].Type {
return list[i].Type == "dir"
}
return strings.ToLower(list[i].Name) < strings.ToLower(list[j].Name)
})
children[node.path] = list
}
return snapshotTreeCache{generated: time.Now().UTC(), total: len(items), children: children}
}
func snapshotItemPath(item map[string]any) string {
for _, key := range []string{"path", "name"} {
if value, ok := item[key].(string); ok && value != "" {
return normalizeSnapshotPath(value)
}
}
return "/"
}
func snapshotItemSize(item map[string]any) int64 {
switch value := item["size"].(type) {
case float64:
return int64(value)
case int64:
return value
case int:
return int64(value)
default:
return 0
}
}
func normalizeSnapshotPath(path string) string {
path = filepath.Clean("/" + strings.TrimSpace(path))
if path == "." {
return "/"
}
return path
} }
func (s *Service) TestRepository(ctx context.Context, repoID string, initialize bool) error { func (s *Service) TestRepository(ctx context.Context, repoID string, initialize bool) error {
+5 -1
View File
@@ -2,13 +2,17 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.07.10.r006"> <!ENTITY version "2026.07.10.r007">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!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 packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE"> <!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"> <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> <CHANGES>
### 2026.07.10.r007
- Serve snapshot browser folders as compact cached tree pages instead of sending the complete Restic file list to the browser.
- Prevent large snapshots with hundreds of thousands of entries from failing with generic 500 responses during snapshot browsing.
### 2026.07.10.r006 ### 2026.07.10.r006
- Stop logging high-frequency health, log, and activity polling requests unless they are slow or fail. - Stop logging high-frequency health, log, and activity polling requests unless they are slow or fail.
- Log snapshot file-listing requests immediately when they start and show a larger daemon log tail in the Protokolle tab. - Log snapshot file-listing requests immediately when they start and show a larger daemon log tail in the Protokolle tab.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php <?php
$pluginRoot = '/plugins/urbm'; $pluginRoot = '/plugins/urbm';
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260710r006"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260710r007">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php"> <div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header"> <header class="bu-header">
<div class="bu-brand"> <div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260710r006" alt="URBM-Logo"> <img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260710r007" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.07.10.r006</span></h1><p>Restic Backup Manager für Unraid</p></div> <div><h1>URBM <span class="bu-version">2026.07.10.r007</span></h1><p>Restic Backup Manager für Unraid</p></div>
</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> <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> </header>
@@ -33,4 +33,4 @@ $pluginRoot = '/plugins/urbm';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div> <div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div> </div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script> <script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.07.10.r006.js"></script> <script src="<?= $pluginRoot ?>/assets/urbm-2026.07.10.r007.js"></script>
+30 -36
View File
@@ -8,7 +8,7 @@
const tooltip = document.getElementById('bu-tooltip'); const tooltip = document.getElementById('bu-tooltip');
const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path})); 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 repositoryRoots = ['/mnt/user','/mnt/disks','/mnt/remotes'].map(path=>({name:path,path}));
const state = { config: null, runs: [], logs: null, logsLoading: false, logsError: '', 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 state = { config: null, runs: [], logs: null, logsLoading: false, logsError: '', repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], snapshotTree: null, snapshotTreeMeta: null, snapshotTreeChildren: {}, snapshotTreeLoading: false, snapshotTreeNodeLoading: {}, snapshotTreeErrors: {}, snapshotTreeError: '', snapshotTreeExpanded: {}, restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceTreeRoots: sourceRoots, sourceTreeChildren: {}, sourceTreeExpanded: {}, sourceTreeLoading: {}, sourceTreeErrors: {}, directoryTrees: {}, liveLogOpen: {} };
const fieldHelp = { const fieldHelp = {
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
@@ -669,34 +669,6 @@
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?`<div class="bu-table-scroll"><table class="bu-table bu-snapshot-table"><colgroup><col class="bu-snapshot-id"><col class="bu-snapshot-time"><col class="bu-snapshot-host"><col class="bu-snapshot-paths"><col class="bu-snapshot-actions"></colgroup><thead><tr><th>Snapshot</th><th>Zeitpunkt</th><th>Host</th><th>Pfade</th><th></th></tr></thead><tbody>${rows}</tbody></table></div>`:'<div class="bu-empty">Repository auswählen und Snapshots laden.</div>'}</section>${state.selectedSnapshot ? 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?`<div class="bu-table-scroll"><table class="bu-table bu-snapshot-table"><colgroup><col class="bu-snapshot-id"><col class="bu-snapshot-time"><col class="bu-snapshot-host"><col class="bu-snapshot-paths"><col class="bu-snapshot-actions"></colgroup><thead><tr><th>Snapshot</th><th>Zeitpunkt</th><th>Host</th><th>Pfade</th><th></th></tr></thead><tbody>${rows}</tbody></table></div>`:'<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) { function snapshotSelectionState(path) {
const exact=state.restoreIncludes.includes(path); const exact=state.restoreIncludes.includes(path);
const prefix=path.replace(/\/$/,'')+'/'; const prefix=path.replace(/\/$/,'')+'/';
@@ -708,18 +680,31 @@
const selection=snapshotSelectionState(node.path); const selection=snapshotSelectionState(node.path);
const isDir=node.type==='dir'; const isDir=node.type==='dir';
const expanded=state.snapshotTreeExpanded[node.path]===true; 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 canExpand=isDir&&node.hasChildren;
const loading=state.snapshotTreeNodeLoading[node.path]===true;
const children=state.snapshotTreeChildren[node.path]||[];
const error=state.snapshotTreeErrors[node.path]||'';
const toggle=canExpand?`<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>':''; 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>`; 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" ${canExpand?`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>`; if(canExpand&&expanded) {
let body='';
if(loading) body='<div class="bu-tree-state">Snapshot-Ordner wird geladen…</div>';
else if(error) body=`<div class="bu-tree-state error">${esc(error)} ${button('Erneut versuchen',`retry-snapshot-tree:${encodeURIComponent(node.path)}`)}</div>`;
else body=children.length?children.map(child=>snapshotTreeNode(child,depth+1)).join(''):'<div class="bu-tree-state">Keine Einträge.</div>';
html+=`<div class="bu-tree-children">${body}</div>`;
}
return html+'</div>'; return html+'</div>';
} }
function renderFileBrowser() { function renderFileBrowser() {
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>'; 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>';
const loading='<div class="bu-snapshot-loading"><div class="bu-progress-wrap"><div class="bu-progress-label"><strong>Snapshot-Struktur wird vollständig eingelesen…</strong><span>Kein Prozentwert verfügbar, Restic liefert die Dateiliste erst am Ende zurück.</span></div><div class="bu-progress indeterminate"><span style="width:30%"></span></div></div></div>'; const loading='<div class="bu-snapshot-loading"><div class="bu-progress-wrap"><div class="bu-progress-label"><strong>Snapshot-Index wird eingelesen…</strong><span>Der Daemon baut daraus intern einen Baum. Danach werden nur sichtbare Ordner übertragen.</span></div><div class="bu-progress indeterminate"><span style="width:30%"></span></div></div></div>';
const body=state.snapshotTreeLoading?loading: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>'; const body=state.snapshotTreeLoading?loading: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>`; const total=state.snapshotTreeMeta?.total||0;
const loaded=Object.values(state.snapshotTreeChildren).reduce((sum,items)=>sum+items.length,0);
const hint=total?`${Number(total).toLocaleString()} Einträge im Snapshot · ${Number(loaded).toLocaleString()} sichtbare Einträge geladen`:'Struktur noch nicht geladen';
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(hint)}</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() { function renderRestore() {
@@ -792,6 +777,14 @@
render(); render();
restoreScrollState(scrollSnapshot, content); restoreScrollState(scrollSnapshot, content);
} }
async function loadSnapshotChildren(path) {
const query = path && path !== '/' ? `?path=${encodeURIComponent(path)}` : '';
const page = await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files${query}`);
const items = Array.isArray(page) ? page : (page.items || []);
state.snapshotTreeChildren[path || '/'] = items;
state.snapshotTreeMeta = Array.isArray(page) ? { total: items.length } : page;
return items;
}
const lines = (value) => value.split('\n').map(v=>v.trim()).filter(Boolean); const lines = (value) => value.split('\n').map(v=>v.trim()).filter(Boolean);
const runTime = run => new Date(run.createdAt || run.startedAt || run.finishedAt || 0).getTime() || 0; const runTime = run => new Date(run.createdAt || run.startedAt || run.finishedAt || 0).getTime() || 0;
const snapshotTime = snapshot => new Date(snapshot.time || 0).getTime() || 0; const snapshotTime = snapshot => new Date(snapshot.time || 0).getTime() || 0;
@@ -822,13 +815,14 @@
if (name === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a==='check'?'Prüfung':'Bereinigung'} wurde eingereiht`); return load(); } 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 === '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=newestSnapshots(await api(`/v1/repositories/${repo}/snapshots`)); state.snapshotRepo=repo; return render(); } if (name === 'load-snapshots') { const repo=document.getElementById('bu-snapshot-repo').value; state.snapshots=newestSnapshots(await api(`/v1/repositories/${repo}/snapshots`)); state.snapshotRepo=repo; 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}. Details stehen im Tab Protokolle.`; } finally { state.snapshotTreeLoading=false; return render(); } } if (name === 'browse') { state.selectedSnapshot=a; state.restoreIncludes=[]; state.files=[]; state.snapshotTree={path:'/',name:'/',type:'dir',hasChildren:true}; state.snapshotTreeMeta=null; state.snapshotTreeChildren={}; state.snapshotTreeNodeLoading={}; state.snapshotTreeErrors={}; state.snapshotTreeError=''; state.snapshotTreeExpanded={'/':true}; state.snapshotTreeLoading=true; render(); try { state.files=await loadSnapshotChildren('/'); } catch(error) { state.snapshotTreeError=`Snapshot-Struktur konnte nicht geladen werden: ${error.message}. Details stehen im Tab Protokolle.`; } finally { state.snapshotTreeLoading=false; return render(); } }
if (name === 'toggle-source-tree') { return toggleSourceTree(decodeURIComponent(a)); } 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 === 'retry-source-tree') { const path=decodeURIComponent(a); delete state.sourceTreeChildren[path]; state.sourceTreeErrors[path]=''; return toggleSourceTree(path,true); }
if (name === 'toggle-dir-tree') { return toggleDirectoryTree(a, decodeURIComponent(b)); } 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 === '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 === 'select-dir-tree') { selectDirectoryTreePath(a, decodeURIComponent(b)); return; }
if (name === 'toggle-snapshot-tree') { const path=decodeURIComponent(a); state.snapshotTreeExpanded[path]=!state.snapshotTreeExpanded[path]; return renderPreservingScroll(); } if (name === 'toggle-snapshot-tree') { const path=decodeURIComponent(a); if(state.snapshotTreeExpanded[path]) { state.snapshotTreeExpanded[path]=false; return renderPreservingScroll(); } state.snapshotTreeExpanded[path]=true; renderPreservingScroll(); if(Object.prototype.hasOwnProperty.call(state.snapshotTreeChildren,path)&&!state.snapshotTreeErrors[path]) return; state.snapshotTreeNodeLoading[path]=true; state.snapshotTreeErrors[path]=''; renderPreservingScroll(); try { await loadSnapshotChildren(path); } catch(error) { state.snapshotTreeErrors[path]=`Snapshot-Ordner konnte nicht geladen werden: ${error.message}`; } finally { state.snapshotTreeNodeLoading[path]=false; return renderPreservingScroll(); } }
if (name === 'retry-snapshot-tree') { const path=decodeURIComponent(a); delete state.snapshotTreeChildren[path]; state.snapshotTreeErrors[path]=''; state.snapshotTreeExpanded[path]=false; return handle(`toggle-snapshot-tree:${encodeURIComponent(path)}`); }
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 === '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 === '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(); } 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(); }