Release URBM 2026.06.15.r008
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0 urbm-2026.06.15.r007-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
aa07788cc61822162b8caccd91b5b83afabf6dd770e4800c180dc6ce2b94abe5 urbm-2026.06.15.r008-x86_64-1.txz
|
||||||
Vendored
+7
-2
@@ -2,13 +2,18 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.15.r007">
|
<!ENTITY version "2026.06.15.r008">
|
||||||
<!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 "61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0">
|
<!ENTITY packageSHA256 "aa07788cc61822162b8caccd91b5b83afabf6dd770e4800c180dc6ce2b94abe5">
|
||||||
]>
|
]>
|
||||||
<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.06.15.r008
|
||||||
|
- Replace backup-run history graphs with actual per-repository Restic statistics.
|
||||||
|
- Show deduplicated stored repository data and the total files referenced by all snapshots.
|
||||||
|
- Load repository statistics separately from the two-second activity refresh to avoid repeated expensive scans.
|
||||||
|
|
||||||
### 2026.06.15.r007
|
### 2026.06.15.r007
|
||||||
- Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup.
|
- Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup.
|
||||||
- Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file.
|
- Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file.
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server {
|
|||||||
mux.HandleFunc("POST /v1/repositories/{id}/init", s.initRepository)
|
mux.HandleFunc("POST /v1/repositories/{id}/init", s.initRepository)
|
||||||
mux.HandleFunc("POST /v1/repositories/{id}/unlock", s.unlockRepository)
|
mux.HandleFunc("POST /v1/repositories/{id}/unlock", s.unlockRepository)
|
||||||
mux.HandleFunc("POST /v1/repositories/{id}/{action}", s.maintenance)
|
mux.HandleFunc("POST /v1/repositories/{id}/{action}", s.maintenance)
|
||||||
|
mux.HandleFunc("GET /v1/repositories/stats", s.repositoryStats)
|
||||||
mux.HandleFunc("GET /v1/repositories/{id}/snapshots", s.snapshots)
|
mux.HandleFunc("GET /v1/repositories/{id}/snapshots", s.snapshots)
|
||||||
mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles)
|
mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles)
|
||||||
mux.HandleFunc("POST /v1/restores", s.restore)
|
mux.HandleFunc("POST /v1/restores", s.restore)
|
||||||
@@ -220,6 +221,12 @@ func (s *Server) snapshots(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, 200, items)
|
writeJSON(w, 200, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) repositoryStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
writeJSON(w, 200, s.service.RepositoryStats(ctx))
|
||||||
|
}
|
||||||
func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) {
|
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(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -162,6 +162,15 @@ type Snapshot struct {
|
|||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RepositoryStats struct {
|
||||||
|
RepositoryID string `json:"repositoryId"`
|
||||||
|
RepositoryName string `json:"repositoryName"`
|
||||||
|
StoredBytes int64 `json:"storedBytes"`
|
||||||
|
FileCount int64 `json:"fileCount"`
|
||||||
|
SnapshotCount int `json:"snapshotCount"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
SchemaVersion: SchemaVersion,
|
SchemaVersion: SchemaVersion,
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ type Progress struct {
|
|||||||
|
|
||||||
type ProgressCallback func(Progress)
|
type ProgressCallback func(Progress)
|
||||||
|
|
||||||
|
type Stats struct {
|
||||||
|
TotalSize int64 `json:"total_size"`
|
||||||
|
TotalFileCount int64 `json:"total_file_count"`
|
||||||
|
TotalBlobCount int64 `json:"total_blob_count"`
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) Init(ctx context.Context, repo model.Repository) error {
|
func (r *Runner) Init(ctx context.Context, repo model.Repository) error {
|
||||||
return r.run(ctx, repo, []string{"init"}, nil, nil)
|
return r.run(ctx, repo, []string{"init"}, nil, nil)
|
||||||
}
|
}
|
||||||
@@ -191,6 +197,22 @@ func (r *Runner) Snapshots(ctx context.Context, repo model.Repository) ([]model.
|
|||||||
return snapshots, nil
|
return snapshots, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) Stats(ctx context.Context, repo model.Repository, mode string) (Stats, error) {
|
||||||
|
args := []string{"stats", "--json"}
|
||||||
|
if mode != "" {
|
||||||
|
args = append(args, "--mode", mode)
|
||||||
|
}
|
||||||
|
var output []byte
|
||||||
|
if err := r.run(ctx, repo, args, nil, &output); err != nil {
|
||||||
|
return Stats{}, err
|
||||||
|
}
|
||||||
|
var stats Stats
|
||||||
|
if err := json.Unmarshal(output, &stats); err != nil {
|
||||||
|
return Stats{}, fmt.Errorf("decode repository stats: %w", err)
|
||||||
|
}
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path string) ([]map[string]any, error) {
|
func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path string) ([]map[string]any, error) {
|
||||||
args := []string{"ls", snapshot, "--json"}
|
args := []string{"ls", snapshot, "--json"}
|
||||||
if path != "" {
|
if path != "" {
|
||||||
|
|||||||
@@ -82,6 +82,31 @@ func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatsUsesRequestedRepositoryCountingMode(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
argsPath := filepath.Join(dir, "args")
|
||||||
|
script := filepath.Join(dir, "restic")
|
||||||
|
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\nprintf '%%s\\n' '{\"total_size\":4096,\"total_file_count\":12,\"total_blob_count\":8}'\n", argsPath)
|
||||||
|
if err := os.WriteFile(script, []byte(body), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}}
|
||||||
|
repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"}
|
||||||
|
stats, err := runner.Stats(context.Background(), repo, "raw-data")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stats.TotalSize != 4096 || stats.TotalFileCount != 12 || stats.TotalBlobCount != 8 {
|
||||||
|
t.Fatalf("unexpected stats: %+v", stats)
|
||||||
|
}
|
||||||
|
args, _ := os.ReadFile(argsPath)
|
||||||
|
for _, expected := range []string{"stats", "--json", "--mode", "raw-data"} {
|
||||||
|
if !strings.Contains(string(args), expected) {
|
||||||
|
t.Fatalf("arguments missing %q: %s", expected, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestForgetUsesAgeAndCalendarRetention(t *testing.T) {
|
func TestForgetUsesAgeAndCalendarRetention(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
argsPath := filepath.Join(dir, "args")
|
argsPath := filepath.Join(dir, "args")
|
||||||
|
|||||||
@@ -202,6 +202,40 @@ func (s *Service) Snapshots(ctx context.Context, repoID string) ([]model.Snapsho
|
|||||||
return s.restic.Snapshots(ctx, mounted.Repository)
|
return s.restic.Snapshots(ctx, mounted.Repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) RepositoryStats(ctx context.Context) []model.RepositoryStats {
|
||||||
|
config := s.Config()
|
||||||
|
result := make([]model.RepositoryStats, 0, len(config.Repositories))
|
||||||
|
for _, repo := range config.Repositories {
|
||||||
|
item := model.RepositoryStats{RepositoryID: repo.ID, RepositoryName: repo.Name}
|
||||||
|
mounted, err := s.mounts.Prepare(ctx, repo)
|
||||||
|
if err == nil {
|
||||||
|
var stored, files restic.Stats
|
||||||
|
stored, err = s.restic.Stats(ctx, mounted.Repository, "raw-data")
|
||||||
|
if err == nil {
|
||||||
|
files, err = s.restic.Stats(ctx, mounted.Repository, "restore-size")
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
var snapshots []model.Snapshot
|
||||||
|
snapshots, err = s.restic.Snapshots(ctx, mounted.Repository)
|
||||||
|
item.StoredBytes = stored.TotalSize
|
||||||
|
item.FileCount = files.TotalFileCount
|
||||||
|
item.SnapshotCount = len(snapshots)
|
||||||
|
}
|
||||||
|
if cleanupErr := s.mounts.Cleanup(context.Background(), mounted); err == nil && cleanupErr != nil {
|
||||||
|
err = cleanupErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
item.Error = err.Error()
|
||||||
|
}
|
||||||
|
result = append(result, item)
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) ([]map[string]any, error) {
|
||||||
repo, ok := s.repository(repoID)
|
repo, ok := s.repository(repoID)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+6
-1
@@ -2,13 +2,18 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.15.r007">
|
<!ENTITY version "2026.06.15.r008">
|
||||||
<!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.06.15.r008
|
||||||
|
- Replace backup-run history graphs with actual per-repository Restic statistics.
|
||||||
|
- Show deduplicated stored repository data and the total files referenced by all snapshots.
|
||||||
|
- Load repository statistics separately from the two-second activity refresh to avoid repeated expensive scans.
|
||||||
|
|
||||||
### 2026.06.15.r007
|
### 2026.06.15.r007
|
||||||
- Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup.
|
- Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup.
|
||||||
- Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file.
|
- Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file.
|
||||||
|
|||||||
+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=20260615r007">
|
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r008">
|
||||||
<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=20260615r007" alt="URBM-Logo">
|
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r008" alt="URBM-Logo">
|
||||||
<div><h1>URBM <span class="bu-version">2026.06.15.r007</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
<div><h1>URBM <span class="bu-version">2026.06.15.r008</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>
|
||||||
@@ -32,4 +32,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.06.15.r007.js"></script>
|
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r008.js"></script>
|
||||||
|
|||||||
@@ -66,6 +66,8 @@
|
|||||||
.bu-chart-area { fill: var(--bu-accent); opacity: .10; }
|
.bu-chart-area { fill: var(--bu-accent); opacity: .10; }
|
||||||
.bu-chart-line { fill: none; stroke: var(--bu-accent); stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; }
|
.bu-chart-line { fill: none; stroke: var(--bu-accent); stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; }
|
||||||
.bu-chart-dots circle { fill: var(--bu-panel); stroke: var(--bu-accent); stroke-width: 3; }
|
.bu-chart-dots circle { fill: var(--bu-panel); stroke: var(--bu-accent); stroke-width: 3; }
|
||||||
|
.bu-chart-bar { fill: var(--bu-accent); opacity: .82; }
|
||||||
|
.bu-chart-bar:hover { opacity: 1; }
|
||||||
.bu-chart-empty { align-items: center; color: var(--bu-muted); display: flex; height: 240px; justify-content: center; text-align: center; }
|
.bu-chart-empty { align-items: center; color: var(--bu-muted); display: flex; height: 240px; justify-content: center; text-align: center; }
|
||||||
#urbm-app .bu-muted { color: var(--bu-muted) !important; opacity: .72; }
|
#urbm-app .bu-muted { color: var(--bu-muted) !important; opacity: .72; }
|
||||||
.bu-toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-bottom: 14px; }
|
.bu-toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-bottom: 14px; }
|
||||||
|
|||||||
+26
-6
@@ -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: [], view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', liveLogOpen: {} };
|
const state = { config: null, runs: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', 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.',
|
||||||
@@ -224,6 +224,16 @@
|
|||||||
return `<svg class="bu-chart" viewBox="0 0 ${width} ${height}" role="img"><g class="bu-chart-grid">${grid}</g><polygon class="bu-chart-area" points="${area}"/><polyline class="bu-chart-line" points="${polyline}"/><g class="bu-chart-dots">${dots}</g><text class="bu-chart-date" x="${left}" y="${height-8}">${esc(first)}</text><text class="bu-chart-date" x="${left+innerW}" y="${height-8}" text-anchor="end">${esc(last)}</text></svg>`;
|
return `<svg class="bu-chart" viewBox="0 0 ${width} ${height}" role="img"><g class="bu-chart-grid">${grid}</g><polygon class="bu-chart-area" points="${area}"/><polyline class="bu-chart-line" points="${polyline}"/><g class="bu-chart-dots">${dots}</g><text class="bu-chart-date" x="${left}" y="${height-8}">${esc(first)}</text><text class="bu-chart-date" x="${left+innerW}" y="${height-8}" text-anchor="end">${esc(last)}</text></svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function repositoryChart(items, field, formatter, emptyText) {
|
||||||
|
const points=items.filter(item=>!item.error).map(item=>({item,value:Number(item[field]||0)}));
|
||||||
|
if(!points.length||points.every(point=>point.value===0)) return `<div class="bu-chart-empty">${esc(emptyText)}</div>`;
|
||||||
|
const width=720,height=220,left=58,right=18,top=18,bottom=48,innerW=width-left-right,innerH=height-top-bottom;
|
||||||
|
const max=Math.max(...points.map(point=>point.value),1); const slot=innerW/points.length; const barW=Math.min(70,slot*.62);
|
||||||
|
const grid=[0,.25,.5,.75,1].map(ratio=>{const y=top+innerH-ratio*innerH;return `<line x1="${left}" y1="${y}" x2="${left+innerW}" y2="${y}"/><text x="${left-8}" y="${y+4}" text-anchor="end">${esc(formatter(max*ratio))}</text>`;}).join('');
|
||||||
|
const bars=points.map((point,index)=>{const x=left+slot*index+(slot-barW)/2;const h=(point.value/max)*innerH;const y=top+innerH-h;const label=point.item.repositoryName.length>16?`${point.item.repositoryName.slice(0,15)}…`:point.item.repositoryName;return `<rect class="bu-chart-bar" x="${x}" y="${y}" width="${barW}" height="${Math.max(h,1)}"><title>${esc(`${point.item.repositoryName} · ${formatter(point.value)} · ${point.item.snapshotCount} Snapshot(s)`)}</title></rect><text class="bu-chart-date" x="${x+barW/2}" y="${height-18}" text-anchor="middle">${esc(label)}</text>`;}).join('');
|
||||||
|
return `<svg class="bu-chart" viewBox="0 0 ${width} ${height}" role="img"><g class="bu-chart-grid">${grid}</g>${bars}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
function setTooltip(element, text) {
|
function setTooltip(element, text) {
|
||||||
if (!element || !text || element.dataset.tooltip) return;
|
if (!element || !text || element.dataset.tooltip) return;
|
||||||
element.dataset.tooltip = text;
|
element.dataset.tooltip = text;
|
||||||
@@ -300,13 +310,21 @@
|
|||||||
try {
|
try {
|
||||||
const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]);
|
const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]);
|
||||||
state.config = config; state.runs = runs;
|
state.config = config; state.runs = runs;
|
||||||
health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render();
|
health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render(); loadRepositoryStats();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad';
|
health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad';
|
||||||
content.innerHTML = `<div class="bu-card"><h2>Verbindung fehlgeschlagen</h2><p>${esc(error.message)}</p></div>`;
|
content.innerHTML = `<div class="bu-card"><h2>Verbindung fehlgeschlagen</h2><p>${esc(error.message)}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRepositoryStats() {
|
||||||
|
if(state.repositoryStatsLoading||!state.config) return;
|
||||||
|
state.repositoryStatsLoading=true; if(state.view==='dashboard') render();
|
||||||
|
try { state.repositoryStats=await api('/v1/repositories/stats'); }
|
||||||
|
catch(error) { state.repositoryStats=state.config.repositories.map(repo=>({repositoryId:repo.id,repositoryName:repo.name,error:error.message})); }
|
||||||
|
finally { state.repositoryStatsLoading=false; if(state.view==='dashboard') render(); }
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, settings:renderSettings};
|
const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, settings:renderSettings};
|
||||||
content.innerHTML = views[state.view]();
|
content.innerHTML = views[state.view]();
|
||||||
@@ -317,15 +335,17 @@
|
|||||||
const active = state.runs.filter(r => ['queued','running','paused'].includes(r.status)).length;
|
const active = state.runs.filter(r => ['queued','running','paused'].includes(r.status)).length;
|
||||||
const failures = state.runs.filter(r => r.status === 'failed').slice(-5);
|
const failures = state.runs.filter(r => r.status === 'failed').slice(-5);
|
||||||
const last = [...state.runs].reverse().slice(0, 8);
|
const last = [...state.runs].reverse().slice(0, 8);
|
||||||
const backups = state.runs.filter(r=>r.taskType==='backup' && ['success','warning'].includes(r.status)).slice(-20);
|
const stats=state.repositoryStats;
|
||||||
|
const statsErrors=stats.filter(item=>item.error);
|
||||||
|
const loading=state.repositoryStatsLoading?'<div class="bu-muted">Repository-Werte werden geladen…</div>':statsErrors.length?`<div class="bu-warning">${statsErrors.length} Repository(s) konnten nicht gelesen werden.</div>`:'';
|
||||||
return `<div class="bu-grid">
|
return `<div class="bu-grid">
|
||||||
<section class="bu-card"><div class="bu-muted">Aktive Jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></section>
|
<section class="bu-card"><div class="bu-muted">Aktive Jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></section>
|
||||||
<section class="bu-card"><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></section>
|
<section class="bu-card"><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></section>
|
||||||
<section class="bu-card"><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></section>
|
<section class="bu-card"><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></section>
|
||||||
<section class="bu-card"><div class="bu-muted">Letzte Fehler</div><div class="bu-stat">${failures.length}</div></section>
|
<section class="bu-card"><div class="bu-muted">Letzte Fehler</div><div class="bu-stat">${failures.length}</div></section>
|
||||||
</div><div class="bu-chart-grid-layout">
|
</div><div class="bu-chart-grid-layout">
|
||||||
<section class="bu-card"><h2>Backup-Größe</h2><div class="bu-muted">Verarbeitete Quelldaten pro Backup</div>${lineChart(backups,'bytesProcessed',formatBytes,'Nach dem ersten Backup wird hier der Größenverlauf angezeigt.')}</section>
|
<section class="bu-card"><h2>Repository-Größe</h2><div class="bu-muted">Tatsächlich gespeicherte, deduplizierte Restic-Daten pro Repository</div>${loading}${repositoryChart(stats,'storedBytes',formatBytes,'Noch keine Repository-Daten verfügbar.')}</section>
|
||||||
<section class="bu-card"><h2>Gesicherte Dateien</h2><div class="bu-muted">Verarbeitete Dateien pro Backup</div>${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Nach dem ersten Backup wird hier die Dateianzahl angezeigt.')}</section>
|
<section class="bu-card"><h2>Dateien in Snapshots</h2><div class="bu-muted">Gesamte referenzierte Dateianzahl aller Snapshots pro Repository</div>${loading}${repositoryChart(stats,'fileCount',value=>Math.round(value).toLocaleString(),'Noch keine Snapshot-Dateien verfügbar.')}</section>
|
||||||
</div><section class="bu-card" style="margin-top:16px"><h2>Letzte Aktivitäten</h2>${runsTable(last)}</section>`;
|
</div><section class="bu-card" style="margin-top:16px"><h2>Letzte Aktivitäten</h2>${runsTable(last)}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,7 +639,7 @@
|
|||||||
if (kind === 'submit-gotify-notify') { const f=new FormData(document.getElementById('bu-gotify-notify-form')); const existing=state.config.notifications.find(n=>n.type==='gotify'); const target={schemaVersion:1,id:f.get('id')||'gotify',name:'Gotify',type:'gotify',enabled:f.get('enabled')==='on',url:String(f.get('url')||'').trim(),tokenRef:String(f.get('tokenRef')||'').trim(),events:f.getAll('events')}; if(target.enabled&&!target.url) throw new Error('Die Gotify-Server-URL ist erforderlich.'); if(target.enabled&&!target.events.length) throw new Error('Wähle mindestens ein Ereignis für Gotify-Benachrichtigungen aus.'); if(target.enabled&&!existing&&!f.get('token')) throw new Error('Ein Gotify-Anwendungs-Token ist erforderlich.'); if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'gotify-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='gotify'&&n.type!=='ntfy'); if(target.enabled||target.url) state.config.notifications.push(target); await saveConfig(); render(); }
|
if (kind === 'submit-gotify-notify') { const f=new FormData(document.getElementById('bu-gotify-notify-form')); const existing=state.config.notifications.find(n=>n.type==='gotify'); const target={schemaVersion:1,id:f.get('id')||'gotify',name:'Gotify',type:'gotify',enabled:f.get('enabled')==='on',url:String(f.get('url')||'').trim(),tokenRef:String(f.get('tokenRef')||'').trim(),events:f.getAll('events')}; if(target.enabled&&!target.url) throw new Error('Die Gotify-Server-URL ist erforderlich.'); if(target.enabled&&!target.events.length) throw new Error('Wähle mindestens ein Ereignis für Gotify-Benachrichtigungen aus.'); if(target.enabled&&!existing&&!f.get('token')) throw new Error('Ein Gotify-Anwendungs-Token ist erforderlich.'); if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'gotify-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='gotify'&&n.type!=='ntfy'); if(target.enabled||target.url) state.config.notifications.push(target); await saveConfig(); render(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); });
|
document.querySelector('.bu-tabs').addEventListener('click', e => { const button=e.target.closest('[data-view]'); if(!button)return; state.view=button.dataset.view; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x===button)); render(); if(state.view==='dashboard')loadRepositoryStats(); });
|
||||||
content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); });
|
content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); });
|
||||||
content.addEventListener('change', e => {
|
content.addEventListener('change', e => {
|
||||||
if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) {
|
if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user