Page snapshot browser data
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
24660d70ea52abe59214ad8a56f31023cef090cb2fafeb557690870ed5790aa9 urbm-2026.07.10.r006-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
3e3f832d98656655d591c05ad4454d8480d5cfcdf60c03a2a498a6498e206beb urbm-2026.07.10.r007-x86_64-1.txz
|
||||||
Vendored
+6
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(); }
|
||||||
|
|||||||
Reference in New Issue
Block a user