Compare commits

..
25 Commits
Author SHA1 Message Date
Mikei386 4ee08bc9d1 Release 2026.09.28.r001: distinguish job automation from run status 2026-09-28 09:08:42 +02:00
Mikei386 73e490ebe1 Release 2026.09.27.r003: close action menus before opening edit forms 2026-09-27 16:41:02 +02:00
Mikei386 74a69b1580 Release 2026.09.27.r002: clarify rsync dry runs and expand run diagnostics 2026-09-27 16:23:01 +02:00
Mikei386 106f9c9387 Release 2026.09.27.r001: support array disk rsync backups to mounted USB disks 2026-09-27 16:07:00 +02:00
Mikei386 5009dc202b Fix backup check notifications and workload recovery; release 2026.09.21.r001 2026-09-21 20:36:31 +02:00
Mikei386 8f75a04db6 Improve dashboard health and state recovery 2026-07-13 21:06:09 +02:00
Mikei386 819055cbf0 Reduce metric writes and preserve scheduler history 2026-07-13 20:53:16 +02:00
Mikei386 ea97d7dbc9 Handle empty dashboard metrics safely 2026-07-13 20:44:46 +02:00
Mikei386 b1fba3ed16 Persist dashboard backup metrics separately 2026-07-13 20:39:31 +02:00
Mikei386 2ecdcdf602 Use page scrolling for snapshot browser 2026-07-13 20:30:39 +02:00
Mikei386 76c9011df2 Extend backup charts to fourteen days 2026-07-13 20:24:47 +02:00
Mikei386 e364986b55 Add backup chart deltas 2026-07-13 20:21:14 +02:00
Mikei386 6cb4c032b9 Restore seven-day backup charts 2026-07-13 20:14:04 +02:00
Mikei386 a66540d2fb Portal action menus above Unraid layout 2026-07-13 20:08:39 +02:00
Mikei386 4cd2b7da47 Fix action menu positioning 2026-07-13 20:00:43 +02:00
Mikei386 bdebf1bb2b Refresh the Unraid WebGUI 2026-07-13 19:55:59 +02:00
Mikei386 bbf063c157 Harden backup and restore operations 2026-07-13 19:47:43 +02:00
Mikei386 6cbf170d65 Page snapshot browser data 2026-07-10 13:49:29 +02:00
Mikei386 091c5ff175 Reduce API log noise 2026-07-10 13:09:56 +02:00
Mikei386 3f1b7cd781 Log snapshot listing failures 2026-07-10 13:01:16 +02:00
Mikei386 bf06b10519 Add log viewer 2026-07-10 12:51:35 +02:00
Mikei386 f22c2e462d Stream snapshot file listings 2026-07-10 12:41:48 +02:00
Mikei386 c2f0f0b277 Include retention and prune details in notifications 2026-07-10 11:42:06 +02:00
Mikei386 5aa28cf43e Log changed files after backup 2026-07-10 11:32:48 +02:00
Mikei386 99e2753c10 Notify when prune completes 2026-06-22 21:19:41 +02:00
53 changed files with 3637 additions and 336 deletions

No files matched your search

+46
View File
@@ -44,3 +44,49 @@ The daemon has no TCP listener. Restic passwords are passed through root-only te
## Current scope
The code implements the MVP control plane and adapters. Docker and VM operations require the standard Unraid `docker` and `virsh` commands. Managed SMB/NFS mounts require the corresponding Unraid mount helpers. Hardware and end-to-end compatibility must be validated on supported Unraid 7 releases before a stable publication.
### Backup safety (2026.09.21.r001)
Repository checks report success/failure through configured notification targets. The
standard check verifies repository structure; it is not a full data-read or restore test.
Docker jobs discover bind mounts and named volumes. Live backups do not guarantee
application/database consistency. Use stop mode for coordinated offline backups.
Stop mode restarts only workloads that were running before preparation; ambiguous
paused/restarting states fail safely. Each restart has a 120-second deadline and
recovery continues after individual failures. Shutdown waits for cleanup; the rc
script refuses a forced kill or overlapping restart if cleanup exceeds five minutes.
Hard crashes or power loss still require checking workload state and backup results.
### Mirror an array disk to a mounted USB disk (2026.09.27.r001)
Create an Rsync job and select the mounted array disk, for example `/mnt/disk1`,
as the source. Select the USB disk under `/mnt/disks`, for example
`/mnt/disks/USB-Backup`, as the target. URBM copies the selected directory, so this
creates `/mnt/disks/USB-Backup/disk1` and copies its contents there. Select the USB
root, not its existing `disk1` subdirectory, to avoid nesting another `disk1`.
Enable overwrite to update existing files. Enable destination deletion for a
mirror that also removes files deleted from the source; deletion is off by default.
Use the dry-run option to preview a job without changing files. The USB disk must
already be mounted, for example through Unassigned Devices. URBM checks array and
USB mounts before starting the job and refuses leftover directories after an
unmount. Keep the USB disk connected throughout the run. Reload the page after
mounting a new array disk to refresh the available sources.
### Rsync diagnostics (2026.09.27.r002)
Run logs start collapsed and retain your open/closed selection during refresh.
New Rsync runs log the source paths, target, dry-run mode, overwrite/delete and
comparison options, exclusions, execution phases, up to 20 itemized change
examples, and final file/byte/deletion totals. Deletion counts include directories
and symlinks. Byte totals describe the contents of transferred files, not protocol
traffic. A dry run reports planned changes and records zero bytes actually copied;
its notification explicitly states that no changes were made. Historical logs
cannot be reconstructed with these additional details.
For an integration test against Rsync 3.x, run
`URBM_TEST_RSYNC=/path/to/rsync go test ./internal/rsync -run TestRealRsync -v`.
The test uses temporary directories only and checks that a dry run changes nothing,
that a subsequent copy matches the planned counts, and that another comparison
then finds no changes.
+3
View File
@@ -87,5 +87,8 @@ func main() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
if err := svc.Wait(context.Background()); err != nil {
log.Warn("queue cleanup did not finish before shutdown timeout", "error", err)
}
log.Info("urbm daemon stopped")
}
-1
View File
@@ -1 +0,0 @@
1fda0986918fa5838cbd8f56ce013e1c5a2add51c41fb5cee14fe77c0e33c0b4 urbm-2026.06.22.r002-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
36921b9372b1ee42f50eb3e18cb0a5ac720fc484f5fef992cc454684a6e86282 urbm-2026.07.13.r012-x86_64-1.txz
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
55d48398ecd446793f51c0fcc987994ba77e52ac950dbc7f5a32f983449e5b83 urbm-2026.09.21.r001-x86_64-1.txz
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
e66e3d540fba172a1e0b8e60f7b36e33b38ba8357e5a45a07879a32c2d655222 urbm-2026.09.27.r001-x86_64-1.txz
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
12bf23238112ee7ad9fe6c1102528cdd7baeb32a89378b7ac17621642b001b87 urbm-2026.09.27.r002-x86_64-1.txz
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
4f5ea23abab9a154aaebe8af9a0ed286ae645deb7ab47d5e495b6b343b841b71 urbm-2026.09.27.r003-x86_64-1.txz
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
3dcb92984be52415d689195e15bd7436de5b98eac9b60c654b045e5694e80b1c urbm-2026.09.28.r001-x86_64-1.txz
+123 -3
View File
@@ -2,13 +2,133 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.22.r002">
<!ENTITY version "2026.09.28.r001">
<!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 packageSHA256 "1fda0986918fa5838cbd8f56ce013e1c5a2add51c41fb5cee14fe77c0e33c0b4">
<!ENTITY packageSHA256 "3dcb92984be52415d689195e15bd7436de5b98eac9b60c654b045e5694e80b1c">
]>
<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>
### 2026.09.28.r001
- Label job automation separately from run results: manual, enabled or disabled instead of misleading success/cancelled badges.
- Display manual and disabled jobs neutrally and explain scheduling state in tooltips.
### 2026.09.27.r003
- Close action menus synchronously before opening edit forms or running menu actions.
- Prevent delayed menu positioning from reopening closed or detached menus.
### 2026.09.27.r002
- Collapse run logs by default and retain manually opened logs during refresh.
- Log Rsync sources, destination, comparison and deletion options, execution phases, sample changes, errors and final totals.
- Clearly distinguish dry-run plans from actual copies and include deleted entries in logs and notifications.
- Parse exact byte statistics for progress, summaries and free-space checks; drain process output before closing pipes.
- Test dry runs, copies and deletions with real Rsync fixtures as well as regression tests.
### 2026.09.27.r001
- Discover mounted array disks as selectable Rsync sources, including complete disks.
- Show directory symlinks only when they resolve within allowed storage roots.
- Refuse Rsync disk jobs when the array source or USB destination is not mounted.
- Document array-to-USB mirroring and cover disk discovery, mount checks and safe browsing with regression tests.
### 2026.09.21.r001
- Notify on successful and failed repository checks; complete notification attempts before finishing a task.
- Preserve stopped Docker/VM workloads and refuse ambiguous paused or restarting states.
- Recover each previously running workload with its own timeout, including cancellation and partial preparation failures.
- Include named Docker volumes and reject persistent mounts without a usable source path.
- Avoid force-killing the daemon during workload recovery and prevent overlapping daemon restarts.
- Add regression coverage for check notifications, volume discovery, state preservation and recovery failures.
### 2026.07.13.r012
- Draw a separate color-coded line and legend entry for every backup job when the dashboard filter is set to all jobs.
- Base dashboard health on successful backups, failed backup attempts, active work, and timezone-aware overdue schedules instead of unrelated maintenance or restore successes.
- Keep the WebGUI available when metrics, activities, health, or dashboard-status requests fail independently, with localized errors and retry actions.
- Quarantine malformed run and metric state files with a timestamped `.corrupt-*` suffix before creating clean replacements, and log the recovery path.
### 2026.07.13.r011
- Use durable backup measurements when deciding whether a scheduled job needs a catch-up run, even after activity history was cleared.
- Append one compact JSON-lines record per completed backup instead of rewriting the full metric history for every queue state change.
- Migrate the existing `backup-metrics.json` automatically to `backup-metrics.jsonl` and compact in batches after the file exceeds 5,500 records while retaining the newest 5,000.
### 2026.07.13.r010
- Return an empty JSON array instead of `null` when no dashboard backup measurements exist yet.
- Make the WebGUI tolerate empty or invalid metric responses so the rest of the interface remains available.
### 2026.07.13.r009
- Store completed-backup chart measurements independently in `backup-metrics.json` so clearing activities no longer erases the dashboard history.
- Migrate existing successful backup runs into the new durable metric history automatically and expose them through a dedicated read-only API.
- Keep up to 5,000 compact measurements and clarify in the delete dialog that dashboard chart data is preserved.
### 2026.07.13.r008
- Remove the nested vertical scrollbar from the snapshot repository browser and use the normal Unraid page scroll.
- Keep lazy folder loading and cap deep tree indentation so long paths remain usable on desktop and mobile.
### 2026.07.13.r007
- Extend both dashboard backup timelines from seven to fourteen days.
- Keep axis labels, empty states, hover deltas, and predecessor comparisons consistent with the longer range.
### 2026.07.13.r006
- Show timestamp, job, absolute value, and signed change from the previous run when hovering or focusing chart points.
- Compare points only with the previous run of the same backup job, including a predecessor just outside the visible seven-day range.
- Avoid rebuilding an idle dashboard every two seconds so chart tooltips remain stable.
### 2026.07.13.r005
- Restore the dashboard backup-size and file-count timelines using completed runs from the last seven days.
- Plot runs at their actual time and add a backup-job selector that defaults to the most recently used job.
- Stop loading current repository totals for charts that are intended to show historical backup runs.
### 2026.07.13.r004
- Render open job and repository action menus in a fixed top-level overlay so Unraid table and card layers cannot clip them.
- Position the overlay against the triggering button and constrain it to the actually available viewport space.
### 2026.07.13.r003
- Keep repository and job action menus above following table rows and cards.
- Automatically open long action menus upward when the available viewport space below is insufficient.
### 2026.07.13.r002
- Redesign the dashboard around actionable health, active work, recent failures, and the latest successful backup.
- Add searchable responsive job and repository lists with compact secondary-action menus.
- Structure backup and restore forms more clearly and keep their primary actions visible while scrolling.
- Replace browser confirmations with accessible in-app dialogs and typed confirmation for emergency operations.
### 2026.07.13.r001
- Harden restore task validation and make confirmed in-place restores target original paths safely.
- Serialize repository and managed-mount access, block unsafe force-unlock operations, and wait for task cleanup during daemon shutdown.
- Bound snapshot browser memory, stream Restic diffs, continuously drain command output, and reject symlink-overlapping Rsync paths.
- Activate persistent run logs and align package, WebGUI, and manifest versions during builds.
### 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
- 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.
### 2026.07.10.r005
- Log snapshot file-listing failures from the daemon and PHP bridge to `/var/log/urbm.log` so they appear in the Protokolle tab.
- Increase the daemon HTTP write timeout for large snapshot trees and return concrete backend errors instead of generic 500 responses where possible.
### 2026.07.10.r004
- Add a dedicated Protokolle tab showing the daemon log tail and recent saved run logs.
- Add a read-only `/v1/logs` API endpoint limited to `/var/log/urbm.log` and stored URBM run logs.
### 2026.07.10.r003
- Stream Restic snapshot file listings instead of buffering the full `restic ls --json` output in memory.
- Extend snapshot file-listing timeouts across the daemon and PHP bridge to reduce generic 500 errors on large snapshots.
- Return a clearer timeout error when Restic snapshot listing exceeds the daemon limit.
### 2026.07.10.r002
- Include retention results in backup notifications after Restic forget runs, including removed and remaining snapshot counts when available.
- Include repository size before, after, and freed space in prune notifications when Restic statistics are available.
### 2026.07.10.r001
- Add a bounded backup change list to the run log by diffing the previous job snapshot against the newly created snapshot.
- Show up to the last 50 new, modified, or removed paths after successful Restic backups without failing the backup if diff collection is unavailable.
### 2026.06.22.r003
- Send Unraid and Gotify notifications after scheduled or manual repository prune tasks finish.
- Use task-specific notification summary headings for backup, rsync, restore, prune, and check messages.
### 2026.06.22.r002
- Preserve page and tree-container scroll positions after expanding deeply nested folders in source, target, settings, repository, and snapshot trees.
- Restore scroll positions after the browser has processed focus/layout changes to prevent jumps back to the root.
@@ -311,7 +431,7 @@ chmod 0700 /boot/config/plugins/&name;
</FILE>
<FILE Run="/bin/bash" Method="remove">
<INLINE>
/etc/rc.d/rc.urbm stop || true
/etc/rc.d/rc.urbm stop || exit 1
removepkg &name;
rm -rf /usr/local/emhttp/plugins/&name; /usr/local/libexec/&name; /usr/local/sbin/urbmd /etc/rc.d/rc.urbm /run/urbm /var/lib/urbm
</INLINE>
+49 -4
View File
@@ -26,9 +26,11 @@ type Server struct {
}
var Version = "dev"
var apiErrorLog *slog.Logger
func New(socket string, svc *service.Service, log *slog.Logger) *Server {
s := &Server{service: svc, log: log, socket: socket}
apiErrorLog = log
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", s.health)
mux.HandleFunc("GET /v1/config", s.getConfig)
@@ -37,6 +39,9 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server {
mux.HandleFunc("DELETE /v1/secrets/{id}", s.deleteSecret)
mux.HandleFunc("GET /v1/runs", s.runs)
mux.HandleFunc("DELETE /v1/runs", s.clearRuns)
mux.HandleFunc("GET /v1/backup-metrics", s.backupMetrics)
mux.HandleFunc("GET /v1/dashboard-status", s.dashboardStatus)
mux.HandleFunc("GET /v1/logs", s.logs)
mux.HandleFunc("GET /v1/filesystem/directories", s.directories)
mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads)
mux.HandleFunc("POST /v1/jobs/{id}/run", s.runJob)
@@ -54,7 +59,7 @@ func New(socket string, svc *service.Service, log *slog.Logger) *Server {
mux.HandleFunc("GET /v1/repositories/{id}/snapshots", s.snapshots)
mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles)
mux.HandleFunc("POST /v1/restores", s.restore)
s.http = &http.Server{Handler: requestLog(log, mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, IdleTimeout: 30 * time.Second}
s.http = &http.Server{Handler: requestLog(log, mux), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 25 * time.Minute, IdleTimeout: 30 * time.Second}
return s
}
@@ -126,9 +131,16 @@ func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) }
func (s *Server) backupMetrics(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, s.service.BackupMetrics())
}
func (s *Server) dashboardStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, s.service.DashboardStatus(time.Now()))
}
func (s *Server) clearRuns(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, 200, map[string]int{"cleared": s.service.ClearRunHistory()})
}
func (s *Server) logs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Logs()) }
func (s *Server) directories(w http.ResponseWriter, r *http.Request) {
items, err := s.service.BrowseDirectories(r.URL.Query().Get("path"))
if err != nil {
@@ -268,7 +280,7 @@ func (s *Server) repositoryStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, s.service.RepositoryStats(ctx))
}
func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Minute)
defer cancel()
items, err := s.service.SnapshotFiles(ctx, r.PathValue("id"), r.PathValue("snapshot"), r.URL.Query().Get("path"))
if err != nil {
@@ -297,6 +309,10 @@ func decode(r *http.Request, target any) error {
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("validation: invalid JSON: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return errors.New("validation: JSON body must contain exactly one value and be at most 2 MiB")
}
return nil
}
@@ -319,13 +335,42 @@ func writeError(w http.ResponseWriter, err error) {
if strings.Contains(message, "already queued") {
status = 409
}
if apiErrorLog != nil {
apiErrorLog.Warn("api error", "status", status, "error", message)
}
writeJSON(w, status, map[string]string{"error": message})
}
func requestLog(log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
log.Info("api request", "method", r.Method, "path", r.URL.Path, "durationMs", time.Since(started).Milliseconds())
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
duration := time.Since(started)
if shouldLogRequest(r, recorder.status, duration) {
log.Info("api request", "method", r.Method, "path", r.URL.Path, "status", recorder.status, "durationMs", duration.Milliseconds())
}
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func shouldLogRequest(r *http.Request, status int, duration time.Duration) bool {
if status >= 400 || duration >= time.Second {
return true
}
switch r.URL.Path {
case "/v1/runs", "/v1/backup-metrics", "/v1/dashboard-status", "/v1/health", "/v1/logs":
return false
default:
return true
}
}
+52 -26
View File
@@ -138,32 +138,58 @@ type NotificationTarget struct {
}
type Run struct {
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
JobID string `json:"jobId,omitempty"`
TaskType string `json:"taskType"`
Status string `json:"status"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"createdAt"`
StartedAt *time.Time `json:"startedAt,omitempty"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
SnapshotID string `json:"snapshotId,omitempty"`
Message string `json:"message,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
BytesAdded int64 `json:"bytesAdded,omitempty"`
FilesNew int64 `json:"filesNew,omitempty"`
FilesChanged int64 `json:"filesChanged,omitempty"`
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
FilesProcessed int64 `json:"filesProcessed,omitempty"`
ProgressPercent float64 `json:"progressPercent,omitempty"`
ProgressBytes int64 `json:"progressBytes,omitempty"`
ProgressTotal int64 `json:"progressTotal,omitempty"`
ProgressFiles int64 `json:"progressFiles,omitempty"`
ProgressFileTotal int64 `json:"progressFileTotal,omitempty"`
BytesPerSecond float64 `json:"bytesPerSecond,omitempty"`
SecondsRemaining int64 `json:"secondsRemaining,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
LiveLog []string `json:"liveLog,omitempty"`
RsyncDryRun bool `json:"rsyncDryRun,omitempty"`
EntriesDeleted int64 `json:"entriesDeleted,omitempty"`
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
JobID string `json:"jobId,omitempty"`
TaskType string `json:"taskType"`
Status string `json:"status"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"createdAt"`
StartedAt *time.Time `json:"startedAt,omitempty"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
SnapshotID string `json:"snapshotId,omitempty"`
Message string `json:"message,omitempty"`
ErrorCode string `json:"errorCode,omitempty"`
BytesAdded int64 `json:"bytesAdded,omitempty"`
FilesNew int64 `json:"filesNew,omitempty"`
FilesChanged int64 `json:"filesChanged,omitempty"`
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
FilesProcessed int64 `json:"filesProcessed,omitempty"`
RetentionBefore int `json:"retentionBefore,omitempty"`
RetentionAfter int `json:"retentionAfter,omitempty"`
RetentionRemoved int `json:"retentionRemoved,omitempty"`
RepositoryBefore int64 `json:"repositoryBefore,omitempty"`
RepositoryAfter int64 `json:"repositoryAfter,omitempty"`
RepositoryFreed int64 `json:"repositoryFreed,omitempty"`
ProgressPercent float64 `json:"progressPercent,omitempty"`
ProgressBytes int64 `json:"progressBytes,omitempty"`
ProgressTotal int64 `json:"progressTotal,omitempty"`
ProgressFiles int64 `json:"progressFiles,omitempty"`
ProgressFileTotal int64 `json:"progressFileTotal,omitempty"`
BytesPerSecond float64 `json:"bytesPerSecond,omitempty"`
SecondsRemaining int64 `json:"secondsRemaining,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
LiveLog []string `json:"liveLog,omitempty"`
Restore *RestoreTask `json:"restore,omitempty"`
}
// BackupMetric is the durable, compact source for dashboard history. It is
// intentionally stored separately from Run so clearing activity history does
// not erase the backup charts.
type BackupMetric struct {
SchemaVersion int `json:"schemaVersion"`
RunID string `json:"runId"`
JobID string `json:"jobId"`
SnapshotID string `json:"snapshotId,omitempty"`
FinishedAt time.Time `json:"finishedAt"`
Status string `json:"status"`
BytesProcessed int64 `json:"bytesProcessed"`
FilesProcessed int64 `json:"filesProcessed"`
BytesAdded int64 `json:"bytesAdded,omitempty"`
FilesNew int64 `json:"filesNew,omitempty"`
FilesChanged int64 `json:"filesChanged,omitempty"`
}
type RestoreTask struct {
+70 -4
View File
@@ -10,12 +10,15 @@ import (
)
var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
var snapshotPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$`)
var safePathPattern = regexp.MustCompile(`^/[a-zA-Z0-9_./-]+$`)
func ValidateConfig(c Config) error {
if c.SchemaVersion != SchemaVersion {
return fmt.Errorf("unsupported schemaVersion %d", c.SchemaVersion)
}
repos := make(map[string]Repository, len(c.Repositories))
mountPoints := make(map[string]string)
for _, repo := range c.Repositories {
if err := ValidateRepository(repo); err != nil {
return fmt.Errorf("repository %q: %w", repo.ID, err)
@@ -24,6 +27,13 @@ func ValidateConfig(c Config) error {
return fmt.Errorf("duplicate repository id %q", repo.ID)
}
repos[repo.ID] = repo
if repo.Mount != nil && repo.Mount.Managed {
point := filepath.Clean(repo.Mount.MountPoint)
if previous, exists := mountPoints[point]; exists {
return fmt.Errorf("repositories %q and %q use the same managed mountPoint %q", previous, repo.ID, point)
}
mountPoints[point] = repo.ID
}
}
jobs := make(map[string]struct{}, len(c.Jobs))
for _, job := range c.Jobs {
@@ -50,8 +60,52 @@ func ValidateConfig(c Config) error {
}
notifications[target.ID] = struct{}{}
}
if !filepath.IsAbs(c.Settings.RestoreRoot) {
return errors.New("restoreRoot must be absolute")
restoreRoot := filepath.Clean(c.Settings.RestoreRoot)
if !filepath.IsAbs(restoreRoot) || !belowStorageRoot(restoreRoot) {
return errors.New("restoreRoot must be below /mnt/user, /mnt/disks, or /mnt/remotes")
}
if strings.TrimSpace(c.Settings.ResticPath) == "" || !filepath.IsAbs(c.Settings.ResticPath) {
return errors.New("resticPath must be an absolute path")
}
if strings.TrimSpace(c.Settings.RsyncPath) == "" || !filepath.IsAbs(c.Settings.RsyncPath) {
return errors.New("rsyncPath must be an absolute path")
}
if c.Settings.CatchUpWindowHrs < 0 || c.Settings.CatchUpWindowHrs > 24*31 {
return errors.New("catchUpWindowHours must be between 0 and 744")
}
if c.Settings.PersistentLogDir != "" {
logDir := filepath.Clean(c.Settings.PersistentLogDir)
if !filepath.IsAbs(logDir) || (!pathWithin(logDir, "/mnt/user") && !pathWithin(logDir, "/mnt/disks") && !pathWithin(logDir, "/mnt/remotes")) {
return errors.New("persistentLogDir must be below /mnt/user, /mnt/disks, or /mnt/remotes")
}
}
return nil
}
func ValidateRestoreTask(task RestoreTask) error {
if !idPattern.MatchString(task.RepositoryID) {
return errors.New("restore repositoryId is invalid")
}
if err := ValidateSnapshotID(task.SnapshotID); err != nil {
return err
}
if strings.ContainsRune(task.Target, '\x00') {
return errors.New("restore target contains a forbidden control character")
}
if task.InPlace && len(task.Includes) == 0 {
return errors.New("in-place restore requires at least one included path")
}
for _, include := range task.Includes {
if strings.ContainsAny(include, "\x00\r\n") || !filepath.IsAbs(include) || filepath.Clean(include) == "/" {
return fmt.Errorf("invalid restore include %q", include)
}
}
return nil
}
func ValidateSnapshotID(value string) error {
if !snapshotPattern.MatchString(value) {
return errors.New("restore snapshotId is invalid")
}
return nil
}
@@ -178,6 +232,15 @@ func pathWithin(path, root string) bool {
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
func belowStorageRoot(path string) bool {
for _, root := range []string{"/mnt/user", "/mnt/disks", "/mnt/remotes"} {
if filepath.Clean(path) != root && pathWithin(path, root) {
return true
}
}
return false
}
func ValidateRepository(r Repository) error {
if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) {
return errors.New("invalid schemaVersion or id")
@@ -200,11 +263,14 @@ func ValidateRepository(r Repository) error {
if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) {
return errors.New("managed mount requires remote and absolute mountPoint")
}
if strings.HasPrefix(r.Mount.Remote, "-") || strings.ContainsAny(r.Mount.Remote, "\x00\r\n") {
return errors.New("managed mount remote contains forbidden characters")
}
}
if r.Type == RepositorySFTP && r.CredentialRef != "" {
knownHosts := r.Options["knownHostsPath"]
if !filepath.IsAbs(knownHosts) || strings.ContainsAny(knownHosts, " \t\r\n") {
return errors.New("SFTP key authentication requires an absolute knownHostsPath without whitespace")
if !safePathPattern.MatchString(knownHosts) || filepath.Clean(knownHosts) != knownHosts {
return errors.New("SFTP key authentication requires a clean absolute knownHostsPath containing only letters, digits, dot, underscore, slash, and hyphen")
}
}
return nil
+43
View File
@@ -118,3 +118,46 @@ func TestRsyncJobRejectsOverlappingPaths(t *testing.T) {
t.Fatal("rsync target inside source accepted")
}
}
func TestValidateRestoreTaskRejectsDelimiterInjection(t *testing.T) {
task := RestoreTask{RepositoryID: "repo", SnapshotID: "abc123\x00/", Target: "/mnt/user/restore", Includes: []string{"/mnt/user/data"}}
if err := ValidateRestoreTask(task); err == nil {
t.Fatal("NUL-delimited snapshot injection accepted")
}
task.SnapshotID = "abc123"
task.InPlace = true
task.Includes = nil
if err := ValidateRestoreTask(task); err == nil {
t.Fatal("in-place restore without an include accepted")
}
}
func TestValidateConfigRejectsDuplicateManagedMountPoint(t *testing.T) {
c := DefaultConfig()
c.Repositories = []Repository{
{SchemaVersion: 1, ID: "one", Name: "One", Type: RepositorySMB, Location: "/mnt/remotes/shared", PasswordRef: "one-password", Mount: &MountConfig{Managed: true, Remote: "//one/share", MountPoint: "/mnt/remotes/shared"}},
{SchemaVersion: 1, ID: "two", Name: "Two", Type: RepositoryNFS, Location: "/mnt/remotes/shared", PasswordRef: "two-password", Mount: &MountConfig{Managed: true, Remote: "two:/share", MountPoint: "/mnt/remotes/shared"}},
}
if err := ValidateConfig(c); err == nil {
t.Fatal("duplicate managed mount point accepted")
}
}
func TestValidateConfigRejectsUnsafeRestoreRoot(t *testing.T) {
c := DefaultConfig()
c.Settings.RestoreRoot = "/etc/urbm-restores"
if err := ValidateConfig(c); err == nil {
t.Fatal("restoreRoot outside Unraid storage roots accepted")
}
c.Settings.RestoreRoot = "/mnt/user/urbm-restores"
if err := ValidateConfig(c); err != nil {
t.Fatalf("safe restoreRoot rejected: %v", err)
}
}
func TestRsyncArrayDiskToUSB(t *testing.T) {
job := Job{SchemaVersion: 1, ID: "disk-mirror", Name: "Array to USB", Type: JobRsync, Sources: []Source{{Path: "/mnt/disk1"}}, Rsync: RsyncOptions{Target: "/mnt/disks/USB Backup", Overwrite: true, Delete: true}}
if err := ValidateJob(job); err != nil {
t.Fatal(err)
}
}
+30 -8
View File
@@ -16,7 +16,12 @@ type DirectoryEntry struct {
var browseRoots = []string{"/mnt/user", "/mnt/disks", "/mnt/remotes", "/boot"}
func BrowseDirectories(path string) ([]DirectoryEntry, error) {
clean, err := validateBrowsePath(path)
mounts, _ := mountedPaths()
return browseDirectories(path, storageBrowseRoots(mounts))
}
func browseDirectories(path string, roots []string) ([]DirectoryEntry, error) {
clean, err := validateBrowsePathWithRoots(path, roots)
if err != nil {
return nil, err
}
@@ -26,17 +31,29 @@ func BrowseDirectories(path string) ([]DirectoryEntry, error) {
}
result := make([]DirectoryEntry, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
result = append(result, DirectoryEntry{Name: entry.Name(), Path: filepath.Join(clean, entry.Name())})
child := filepath.Join(clean, entry.Name())
if entry.Type()&os.ModeSymlink != 0 {
if _, err := validateBrowsePathWithRoots(child, roots); err != nil {
continue
}
info, err := os.Stat(child)
if err != nil || !info.IsDir() {
continue
}
} else if !entry.IsDir() {
continue
}
result = append(result, DirectoryEntry{Name: entry.Name(), Path: child})
}
sort.Slice(result, func(i, j int) bool { return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) })
return result, nil
}
func BrowseRoots() []DirectoryEntry {
result := make([]DirectoryEntry, 0, len(browseRoots))
for _, root := range browseRoots {
mounts, _ := mountedPaths()
roots := storageBrowseRoots(mounts)
result := make([]DirectoryEntry, 0, len(roots))
for _, root := range roots {
if info, err := os.Stat(root); err == nil && info.IsDir() {
result = append(result, DirectoryEntry{Name: root, Path: root})
}
@@ -45,6 +62,11 @@ func BrowseRoots() []DirectoryEntry {
}
func validateBrowsePath(path string) (string, error) {
mounts, _ := mountedPaths()
return validateBrowsePathWithRoots(path, storageBrowseRoots(mounts))
}
func validateBrowsePathWithRoots(path string, roots []string) (string, error) {
if path == "" || path == "/" {
return "", errors.New("validation: select an allowed browse root")
}
@@ -53,7 +75,7 @@ func validateBrowsePath(path string) (string, error) {
return "", errors.New("validation: browse path must be absolute")
}
allowed := false
for _, root := range browseRoots {
for _, root := range roots {
if clean == root || strings.HasPrefix(clean, root+string(os.PathSeparator)) {
allowed = true
break
@@ -66,9 +88,9 @@ func validateBrowsePath(path string) (string, error) {
if err != nil {
return "", err
}
for _, root := range browseRoots {
for _, root := range roots {
if resolved == root || strings.HasPrefix(resolved, root+string(os.PathSeparator)) {
return resolved, nil
return clean, nil
}
}
return "", errors.New("validation: browse path resolves outside allowed Unraid storage roots")
+72 -1
View File
@@ -1,6 +1,11 @@
package platform
import "testing"
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) {
for _, path := range []string{"", "/", "/etc", "/mnt/user/../../etc", "relative"} {
@@ -9,3 +14,69 @@ func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) {
}
}
}
func TestBrowseDirectoriesIncludesSafeLinks(t *testing.T) {
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
outside := t.TempDir()
for _, name := range []string{"disk1", "USB"} {
if err := os.Mkdir(filepath.Join(root, name), 0755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(root, "file"), nil, 0600); err != nil {
t.Fatal(err)
}
for name, target := range map[string]string{"alias": "USB", "escape": outside, "broken": "missing", "file-link": "file"} {
if err := os.Symlink(target, filepath.Join(root, name)); err != nil {
t.Fatal(err)
}
}
items, err := browseDirectories(root, []string{root})
if err != nil {
t.Fatal(err)
}
var names []string
for _, item := range items {
names = append(names, item.Name)
}
if !reflect.DeepEqual(names, []string{"alias", "disk1", "USB"}) {
t.Fatalf("entries = %v", names)
}
if _, err := browseDirectories(filepath.Join(root, "alias"), []string{root}); err != nil {
t.Fatal(err)
}
if _, err := browseDirectories(filepath.Join(root, "escape"), []string{root}); err == nil {
t.Fatal("escaped storage root")
}
}
func TestStorageBrowseRootsOnlyMountedArrayDisks(t *testing.T) {
mounts := []string{"/mnt/disk1", "/mnt/disk12", "/mnt/disk1", "/mnt/disk0", "/mnt/disk01", "/mnt/disk1-extra", "/mnt/disk2/subdir", "/etc", "/mnt/disks/USB"}
want := append(append([]string{}, browseRoots...), "/mnt/disk1", "/mnt/disk12")
if got := storageBrowseRoots(mounts); !reflect.DeepEqual(got, want) {
t.Fatalf("roots = %v", got)
}
}
func TestMountPathsAndUnmountedDisks(t *testing.T) {
mounts := parseMountPaths("36 25 8:1 / /mnt/disk1 rw - xfs /dev/md1 rw\n37 25 8:2 / /mnt/disks/USB\\040Backup rw - xfs /dev/sdb1 rw\n")
if !reflect.DeepEqual(mounts, []string{"/mnt/disk1", "/mnt/disks/USB Backup"}) {
t.Fatalf("mounts = %v", mounts)
}
for _, path := range []string{"/mnt/disk1", "/mnt/disk1/data", "/mnt/disks/USB Backup", "/mnt/disks/USB Backup/disk1"} {
if !diskPathMounted(path, mounts) {
t.Fatalf("mounted path rejected: %s", path)
}
}
for _, path := range []string{"/mnt/disk2", "/mnt/disk10", "/mnt/disks", "/mnt/disks/USB Backup-old", "/mnt/disks/unmounted"} {
if diskPathMounted(path, mounts) {
t.Fatalf("unmounted path accepted: %s", path)
}
}
if diskPathMounted("/mnt/disks/USB", []string{"/", "/mnt", "/mnt/disks"}) {
t.Fatal("parent mount accepted as USB mount")
}
}
+9
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"time"
"git.casaderoll.de/michael/urbm/internal/model"
)
@@ -66,13 +67,16 @@ func (m *MountManager) Prepare(ctx context.Context, repo model.Repository) (Moun
result.CredentialFile = f.Name()
if err := f.Chmod(0600); err != nil {
f.Close()
os.Remove(result.CredentialFile)
return result, err
}
if _, err := f.WriteString(credential); err != nil {
f.Close()
os.Remove(result.CredentialFile)
return result, err
}
if err := f.Close(); err != nil {
os.Remove(result.CredentialFile)
return result, err
}
options = append(options, "credentials="+result.CredentialFile)
@@ -97,5 +101,10 @@ func (m *MountManager) Cleanup(ctx context.Context, mounted MountedRepository) e
if !mounted.Mounted {
return nil
}
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
defer cancel()
}
return exec.CommandContext(ctx, "umount", mounted.Repository.Location).Run()
}
+55 -9
View File
@@ -5,9 +5,55 @@ import (
"os"
"path/filepath"
"strings"
"git.casaderoll.de/michael/urbm/internal/model"
)
func ValidateRsyncPaths(job model.Job) error {
needsMountCheck := pathUnder(filepath.Clean(job.Rsync.Target), "/mnt/disks")
for _, source := range job.Sources {
needsMountCheck = needsMountCheck || isArrayDiskPath(source.Path) || pathUnder(filepath.Clean(source.Path), "/mnt/disks")
}
if needsMountCheck {
mounts, err := mountedPaths()
if err != nil {
return errors.New("environment: cannot inspect rsync disk mounts: " + err.Error())
}
if err := validateRsyncMounts(job, mounts); err != nil {
return err
}
}
target, err := filepath.EvalSymlinks(job.Rsync.Target)
if err != nil {
return errors.New("validation: resolve rsync target: " + err.Error())
}
for _, source := range job.Sources {
resolved, err := filepath.EvalSymlinks(source.Path)
if err != nil {
return errors.New("validation: resolve rsync source: " + err.Error())
}
if pathsContainEachOther(resolved, target) {
return errors.New("validation: resolved rsync source and target must not contain each other")
}
}
return nil
}
func pathsContainEachOther(first, second string) bool {
within := func(path, root string) bool {
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
return within(first, second) || within(second, first)
}
func ValidateRestoreTarget(target, restoreRoot string, inPlace, confirmed bool) (string, error) {
if inPlace {
if !confirmed {
return "", errors.New("validation: in-place restore requires explicit confirmation")
}
return string(os.PathSeparator), nil
}
if !filepath.IsAbs(target) {
return "", errors.New("validation: restore target must be absolute")
}
@@ -19,20 +65,13 @@ func ValidateRestoreTarget(target, restoreRoot string, inPlace, confirmed bool)
if err := validateStagingRestoreTarget(clean, restoreRoot); err != nil {
return "", err
}
} else if !confirmed {
return "", errors.New("validation: in-place restore requires explicit confirmation")
}
if inPlace {
anchor := string(os.PathSeparator) + strings.Split(strings.TrimPrefix(clean, string(os.PathSeparator)), string(os.PathSeparator))[0]
if err := rejectSymlinks(anchor, clean); err != nil {
return "", err
}
}
return clean, nil
}
func validateStagingRestoreTarget(target, restoreRoot string) error {
allowedRoots := []string{filepath.Clean(restoreRoot), "/mnt/user", "/mnt/disks", "/mnt/remotes"}
_ = restoreRoot // Configuration validation keeps this below one of the storage roots.
allowedRoots := []string{"/mnt/user", "/mnt/disks", "/mnt/remotes"}
for _, root := range allowedRoots {
if root == "." || root == "" {
continue
@@ -45,6 +84,13 @@ func validateStagingRestoreTarget(target, restoreRoot string) error {
}
func rejectSymlinks(root, target string) error {
if info, err := os.Lstat(root); err == nil {
if info.Mode()&os.ModeSymlink != 0 {
return errors.New("validation: restore target traverses a symlink")
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
relative, err := filepath.Rel(root, target)
if err != nil {
return err
+37 -4
View File
@@ -4,10 +4,12 @@ import (
"os"
"path/filepath"
"testing"
"git.casaderoll.de/michael/urbm/internal/model"
)
func TestValidateRestoreTarget(t *testing.T) {
root := t.TempDir()
root := "/mnt/user/urbm-restores"
target := filepath.Join(root, "task")
if got, err := ValidateRestoreTarget(target, root, false, false); err != nil || got != target {
t.Fatalf("staging target rejected: %q %v", got, err)
@@ -18,14 +20,30 @@ func TestValidateRestoreTarget(t *testing.T) {
if _, err := ValidateRestoreTarget("/home/root/restore", root, false, false); err == nil {
t.Fatal("staging target outside allowed restore roots accepted")
}
if _, err := ValidateRestoreTarget("/etc", root, true, true); err == nil {
t.Fatal("protected target accepted")
if got, err := ValidateRestoreTarget("/mnt/user/data", root, true, true); err != nil || got != "/" {
t.Fatalf("confirmed in-place target = %q, %v", got, err)
}
if _, err := ValidateRestoreTarget("/mnt/user/data", root, true, false); err == nil {
t.Fatal("unconfirmed in-place restore accepted")
}
}
func TestValidateRsyncPathsRejectsSymlinkOverlap(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "source")
if err := os.Mkdir(source, 0755); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "target-link")
if err := os.Symlink(source, target); err != nil {
t.Fatal(err)
}
job := model.Job{Sources: []model.Source{{Path: source}}, Rsync: model.RsyncOptions{Target: target}}
if err := ValidateRsyncPaths(job); err == nil {
t.Fatal("symlinked rsync target overlapping the source was accepted")
}
}
func TestValidateRestoreTargetRejectsSymlink(t *testing.T) {
root := t.TempDir()
real := filepath.Join(root, "real")
@@ -36,7 +54,22 @@ func TestValidateRestoreTargetRejectsSymlink(t *testing.T) {
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}
if _, err := ValidateRestoreTarget(filepath.Join(link, "task"), root, false, false); err == nil {
if err := rejectSymlinks(root, filepath.Join(link, "task")); err == nil {
t.Fatal("symlink traversal accepted")
}
}
func TestRejectSymlinksChecksRootItself(t *testing.T) {
base := t.TempDir()
real := filepath.Join(base, "real")
link := filepath.Join(base, "root-link")
if err := os.Mkdir(real, 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}
if err := rejectSymlinks(link, filepath.Join(link, "task")); err == nil {
t.Fatal("symlinked restore root accepted")
}
}
+120
View File
@@ -0,0 +1,120 @@
package platform
import (
"context"
"git.casaderoll.de/michael/urbm/internal/model"
"os"
"path/filepath"
"strings"
"testing"
)
func fakeDocker(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "docker")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
return dir
}
func TestPreparePreservesStoppedContainersAndIncludesVolumes(t *testing.T) {
dir := fakeDocker(t, `case "$1" in
inspect)
if [ "$2" = --format ]; then
if [ "$4" = off ]; then echo '{"Running":false}'; else echo '{"Running":true}'; fi
else echo '[{"Mounts":[{"Type":"bind","Source":"/appdata"},{"Type":"volume","Source":"/docker/volumes/db/_data"},{"Type":"tmpfs","Source":""}]}]'; fi;;
stop|start) echo "$1 $2" >> "$ACTIONS";;
esac
`)
actions := filepath.Join(dir, "actions")
t.Setenv("ACTIONS", actions)
w := &WorkloadManager{RuntimeDir: dir}
job := model.Job{ID: "test", Type: model.JobDocker, Sources: []model.Source{{WorkloadID: "off"}, {WorkloadID: "on"}}}
job.Consistency.Mode = "stop"
p, err := w.Prepare(context.Background(), job)
if err != nil {
t.Fatal(err)
}
if len(p.Stopped) != 1 || p.Stopped[0].WorkloadID != "on" {
t.Fatalf("stopped: %+v", p.Stopped)
}
if !strings.Contains(strings.Join(p.Sources, "\n"), "/docker/volumes/db/_data") {
t.Fatal("named volume omitted")
}
if err = w.Cleanup(context.Background(), p); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(actions)
if strings.Contains(string(data), "start off") || !strings.Contains(string(data), "start on") {
t.Fatalf("actions: %s", data)
}
}
func TestRecoveryContinuesAfterFailureAndCancellation(t *testing.T) {
dir := fakeDocker(t, `echo "$1 $2" >> "$ACTIONS"
if [ "$2" = broken ]; then exit 1; fi
`)
actions := filepath.Join(dir, "actions")
t.Setenv("ACTIONS", actions)
ctx, cancel := context.WithCancel(context.Background())
cancel()
w := &WorkloadManager{}
err := w.Cleanup(ctx, Prepared{Kind: model.JobDocker, Stopped: []model.Source{{WorkloadID: "healthy"}, {WorkloadID: "broken"}}})
if err == nil || !strings.Contains(err.Error(), "broken") {
t.Fatalf("error: %v", err)
}
data, _ := os.ReadFile(actions)
if !strings.Contains(string(data), "start healthy") {
t.Fatalf("recovery aborted: %s", data)
}
}
func TestPrepareRollsBackPreviouslyStoppedContainers(t *testing.T) {
dir := fakeDocker(t, `case "$1" in
inspect)
if [ "$2" = bad ]; then exit 1; fi
if [ "$2" = --format ]; then echo '{"Running":true}'; else echo '[{"Mounts":[]}]'; fi;;
stop|start) echo "$1 $2" >> "$ACTIONS";;
esac
`)
actions := filepath.Join(dir, "actions")
t.Setenv("ACTIONS", actions)
w := &WorkloadManager{RuntimeDir: dir}
job := model.Job{ID: "test", Type: model.JobDocker, Sources: []model.Source{{WorkloadID: "first"}, {WorkloadID: "bad"}}}
job.Consistency.Mode = "stop"
if _, err := w.Prepare(context.Background(), job); err == nil {
t.Fatal("expected failure")
}
data, _ := os.ReadFile(actions)
if !strings.Contains(string(data), "start first") {
t.Fatalf("missing rollback: %s", data)
}
}
func TestSlowRecoveryDoesNotExhaustOtherStarts(t *testing.T) {
dir := fakeDocker(t, `if [ "$2" = slow ]; then sleep 6; fi
echo "$2" >> "$ACTIONS"
`)
actions := filepath.Join(dir, "actions")
t.Setenv("ACTIONS", actions)
err := (&WorkloadManager{}).Cleanup(context.Background(), Prepared{Kind: model.JobDocker, Stopped: []model.Source{{WorkloadID: "next"}, {WorkloadID: "slow"}}})
if err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(actions)
if string(data) != "slow\nnext\n" {
t.Fatalf("incomplete recovery: %s", data)
}
}
func TestPersistentMountWithoutPathFails(t *testing.T) {
dir := fakeDocker(t, `echo '[{"Mounts":[{"Type":"volume","Source":""}]}]'
`)
w := &WorkloadManager{}
if _, err := w.captureMetadata(context.Background(), model.JobDocker, "db", dir); err == nil {
t.Fatal("missing volume path silently omitted")
}
}
+86
View File
@@ -0,0 +1,86 @@
package platform
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.casaderoll.de/michael/urbm/internal/model"
)
var arrayDiskName = regexp.MustCompile(`^disk[1-9][0-9]*$`)
func isArrayDiskPath(path string) bool {
parts := strings.Split(strings.TrimPrefix(filepath.Clean(path), "/"), "/")
return len(parts) >= 2 && parts[0] == "mnt" && arrayDiskName.MatchString(parts[1])
}
func mountedPaths() ([]string, error) {
data, err := os.ReadFile("/proc/self/mountinfo")
if err != nil {
return nil, err
}
return parseMountPaths(string(data)), nil
}
func parseMountPaths(data string) []string {
var paths []string
unescape := strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`)
for _, line := range strings.Split(data, "\n") {
fields := strings.Fields(line)
if len(fields) >= 10 {
paths = append(paths, unescape.Replace(fields[4]))
}
}
return paths
}
func storageBrowseRoots(mounts []string) []string {
roots := append([]string{}, browseRoots...)
seen := make(map[string]bool)
for _, mount := range mounts {
if filepath.Dir(mount) == "/mnt" && isArrayDiskPath(mount) && !seen[mount] {
roots = append(roots, mount)
seen[mount] = true
}
}
return roots
}
func pathUnder(path, root string) bool {
return path == root || strings.HasPrefix(path, root+string(os.PathSeparator))
}
// Require an actual mount for removable disks and array sources. An empty
// directory left behind by an unmount must never become a backup destination.
func validateRsyncMounts(job model.Job, mounts []string) error {
paths := []string{job.Rsync.Target}
for _, source := range job.Sources {
paths = append(paths, source.Path)
}
for _, path := range paths {
clean := filepath.Clean(path)
if !isArrayDiskPath(clean) && !pathUnder(clean, "/mnt/disks") {
continue
}
resolved, err := filepath.EvalSymlinks(clean)
if err != nil {
return fmt.Errorf("environment: rsync disk unavailable: %s: %w", clean, err)
}
if !diskPathMounted(resolved, mounts) {
return fmt.Errorf("environment: rsync disk is not mounted: %s", clean)
}
}
return nil
}
func diskPathMounted(resolved string, mounts []string) bool {
for _, mount := range mounts {
if ((isArrayDiskPath(mount) && filepath.Dir(mount) == "/mnt") || strings.HasPrefix(mount, "/mnt/disks/")) && pathUnder(resolved, mount) {
return true
}
}
return false
}
+60 -12
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"os"
"os/exec"
@@ -138,16 +139,21 @@ func (w *WorkloadManager) Prepare(ctx context.Context, job model.Job) (Prepared,
}
discovered, err := w.captureMetadata(ctx, job.Type, source.WorkloadID, metadataDir)
if err != nil {
w.Cleanup(context.Background(), prepared)
return Prepared{}, err
return Prepared{}, errors.Join(err, w.Cleanup(context.Background(), prepared))
}
prepared.Sources = append(prepared.Sources, discovered...)
if job.Consistency.Mode == "stop" {
if err := w.stop(ctx, job.Type, source.WorkloadID, time.Duration(job.ShutdownSecs)*time.Second); err != nil {
w.Cleanup(context.Background(), prepared)
return Prepared{}, err
running, err := w.running(ctx, job.Type, source.WorkloadID)
if err != nil {
return Prepared{}, errors.Join(err, w.Cleanup(context.Background(), prepared))
}
if running {
// Record before stopping: cancellation can happen after the daemon stopped it.
prepared.Stopped = append(prepared.Stopped, source)
if err := w.stop(ctx, job.Type, source.WorkloadID, time.Duration(job.ShutdownSecs)*time.Second); err != nil {
return Prepared{}, errors.Join(err, w.Cleanup(context.Background(), prepared))
}
}
prepared.Stopped = append(prepared.Stopped, source)
}
}
prepared.Sources = uniqueStrings(prepared.Sources)
@@ -203,17 +209,56 @@ func (w *WorkloadManager) FlashDevice(ctx context.Context) (string, error) {
}
func (w *WorkloadManager) Cleanup(ctx context.Context, prepared Prepared) error {
var first error
var failures []error
// Recovery must continue even when the backup was cancelled. Each workload
// receives its own deadline so one slow start cannot starve the others.
for i := len(prepared.Stopped) - 1; i >= 0; i-- {
source := prepared.Stopped[i]
if err := w.start(ctx, prepared.Kind, source.WorkloadID); err != nil && first == nil {
first = err
startCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 120*time.Second)
if err := w.start(startCtx, prepared.Kind, source.WorkloadID); err != nil {
failures = append(failures, fmt.Errorf("restart %s: %w", source.WorkloadID, err))
}
cancel()
}
if prepared.MetadataDir != "" {
_ = os.RemoveAll(prepared.MetadataDir)
if err := os.RemoveAll(prepared.MetadataDir); err != nil {
failures = append(failures, err)
}
}
return errors.Join(failures...)
}
func (w *WorkloadManager) running(ctx context.Context, kind model.JobType, id string) (bool, error) {
if kind == model.JobDocker {
output, err := exec.CommandContext(ctx, "docker", "inspect", "--format", "{{json .State}}", id).Output()
if err != nil {
return false, fmt.Errorf("source: inspect state of %s: %w", id, err)
}
var state struct {
Running bool
Paused bool
Restarting bool
}
if err := json.Unmarshal(output, &state); err != nil {
return false, err
}
if state.Paused || state.Restarting {
return false, fmt.Errorf("source: %s is paused or restarting; refusing to change its state", id)
}
return state.Running, nil
}
output, err := exec.CommandContext(ctx, "virsh", "domstate", id).Output()
if err != nil {
return false, fmt.Errorf("source: inspect VM state %s: %w", id, err)
}
switch strings.TrimSpace(string(output)) {
case "running":
return true, nil
case "shut off":
return false, nil
default:
return false, fmt.Errorf("source: VM %s is in an unsupported state", id)
}
return first
}
func (w *WorkloadManager) captureMetadata(ctx context.Context, kind model.JobType, id, dir string) ([]string, error) {
@@ -244,7 +289,10 @@ func (w *WorkloadManager) captureMetadata(ctx context.Context, kind model.JobTyp
var paths []string
if len(inspected) > 0 {
for _, mount := range inspected[0].Mounts {
if mount.Type == "bind" && filepath.IsAbs(mount.Source) {
if mount.Type == "bind" || mount.Type == "volume" {
if !filepath.IsAbs(mount.Source) {
return nil, fmt.Errorf("source: container %s has a %s mount without an absolute backup path", id, mount.Type)
}
paths = append(paths, mount.Source)
}
}
+13 -1
View File
@@ -25,10 +25,12 @@ type Queue struct {
stopping bool
handler Handler
onChange OnChange
done chan struct{}
doneOnce sync.Once
}
func New(handler Handler, onChange OnChange) *Queue {
q := &Queue{handler: handler, onChange: onChange}
q := &Queue{handler: handler, onChange: onChange, done: make(chan struct{})}
q.cond = sync.NewCond(&q.mu)
return q
}
@@ -71,6 +73,7 @@ func (q *Queue) Enqueue(run model.Run) error {
}
func (q *Queue) Run(ctx context.Context) {
defer q.doneOnce.Do(func() { close(q.done) })
for {
q.mu.Lock()
for len(q.pending) == 0 && !q.stopping {
@@ -104,6 +107,15 @@ func (q *Queue) Run(ctx context.Context) {
}
}
func (q *Queue) Wait(ctx context.Context) error {
select {
case <-q.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (q *Queue) Cancel(id string) bool {
q.mu.Lock()
defer q.mu.Unlock()
+30
View File
@@ -90,3 +90,33 @@ func TestPauseAndResumeActiveRun(t *testing.T) {
}
close(release)
}
func TestWaitDoesNotReturnBeforeCancelledHandlerCleanup(t *testing.T) {
started := make(chan struct{})
cleanup := make(chan struct{})
q := New(func(ctx context.Context, run model.Run) model.Run {
close(started)
<-ctx.Done()
close(cleanup)
run.Status = "cancelled"
return run
}, nil)
ctx, cancel := context.WithCancel(context.Background())
go q.Run(ctx)
if err := q.Enqueue(model.Run{ID: "run", JobID: "job"}); err != nil {
t.Fatal(err)
}
<-started
q.Stop()
waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second)
defer waitCancel()
if err := q.Wait(waitCtx); err != nil {
t.Fatal(err)
}
select {
case <-cleanup:
default:
t.Fatal("queue wait returned before handler cleanup")
}
cancel()
}
+115 -12
View File
@@ -30,6 +30,18 @@ type Runner struct {
processes map[string]*os.Process
}
func (r *Runner) SetBinary(path string) {
r.mu.Lock()
r.Binary = path
r.mu.Unlock()
}
func (r *Runner) binary() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.Binary
}
type runIDKey struct{}
func WithRunID(ctx context.Context, runID string) context.Context {
@@ -98,6 +110,16 @@ type Stats struct {
TotalBlobCount int64 `json:"total_blob_count"`
}
type DiffEntry struct {
Kind string
Path string
}
type DiffResult struct {
Entries []DiffEntry
Total int
}
func (r *Runner) Init(ctx context.Context, repo model.Repository) error {
return r.run(ctx, repo, []string{"init"}, nil, nil)
}
@@ -260,24 +282,82 @@ func decodeStats(output []byte) (Stats, error) {
}
func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path string) ([]map[string]any, error) {
items := []map[string]any{}
err := r.WalkList(ctx, repo, snapshot, path, func(item map[string]any) { items = append(items, item) })
return items, err
}
func (r *Runner) WalkList(ctx context.Context, repo model.Repository, snapshot, path string, onItem func(map[string]any)) error {
args := []string{"ls", snapshot, "--json"}
if path != "" {
args = append(args, path)
}
var output []byte
if err := r.run(ctx, repo, args, nil, &output); err != nil {
return nil, err
return r.run(ctx, repo, args, func(line []byte) {
var item map[string]any
if json.Unmarshal(line, &item) == nil && item["struct_type"] == "node" {
onItem(item)
}
}, nil)
}
func (r *Runner) Diff(ctx context.Context, repo model.Repository, before, after string, limit int) (DiffResult, error) {
result := DiffResult{}
if err := r.run(ctx, repo, []string{"diff", "--metadata", before, after}, func(line []byte) {
appendDiffLine(&result, string(line), limit)
}, nil); err != nil {
return DiffResult{}, err
}
items := []map[string]any{}
scanner := bufio.NewScanner(strings.NewReader(string(output)))
return result, nil
}
func parseDiffOutput(output []byte, limit int) DiffResult {
if limit <= 0 {
limit = 50
}
result := DiffResult{}
scanner := bufio.NewScanner(bytes.NewReader(output))
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
for scanner.Scan() {
var item map[string]any
if json.Unmarshal(scanner.Bytes(), &item) == nil && item["struct_type"] == "node" {
items = append(items, item)
}
appendDiffLine(&result, scanner.Text(), limit)
}
return result
}
func appendDiffLine(result *DiffResult, line string, limit int) {
if limit <= 0 {
limit = 50
}
if len(line) < 2 {
return
}
kind := diffKind(line[0])
if kind == "" {
return
}
path := strings.TrimSpace(line[1:])
if path == "" || !strings.HasPrefix(path, "/") {
return
}
result.Total++
if len(result.Entries) == limit {
copy(result.Entries, result.Entries[1:])
result.Entries[len(result.Entries)-1] = DiffEntry{Kind: kind, Path: path}
return
}
result.Entries = append(result.Entries, DiffEntry{Kind: kind, Path: path})
}
func diffKind(marker byte) string {
switch marker {
case '+':
return "new"
case '-':
return "removed"
case 'M':
return "modified"
default:
return ""
}
return items, scanner.Err()
}
func (r *Runner) Restore(ctx context.Context, repo model.Repository, task model.RestoreTask, progress ProgressCallback) error {
@@ -346,7 +426,7 @@ func (r *Runner) runWithInputEnvPassword(ctx context.Context, repo model.Reposit
globalArgs = append(globalArgs, "-o", "sftp.command="+command)
}
fullArgs := append(globalArgs, args...)
cmd := exec.CommandContext(ctx, r.Binary, fullArgs...)
cmd := exec.CommandContext(ctx, r.binary(), fullArgs...)
cmd.Stdin = input
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
@@ -393,9 +473,11 @@ func (r *Runner) runWithInputEnvPassword(ctx context.Context, repo model.Reposit
}
close(done)
}()
errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024))
stderrDone := make(chan []byte, 1)
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
err = cmd.Wait()
<-done
errBytes := <-stderrDone
if capture != nil {
*capture = captured
}
@@ -404,11 +486,32 @@ func (r *Runner) runWithInputEnvPassword(ctx context.Context, repo model.Reposit
if errors.Is(ctx.Err(), context.Canceled) {
return fmt.Errorf("cancelled: %w", ctx.Err())
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("environment: Restic-Aufruf hat das Zeitlimit überschritten: %w", ctx.Err())
}
return resticError(message, err)
}
return nil
}
func drainLimited(reader io.Reader, limit int) []byte {
result := make([]byte, 0, limit)
buffer := make([]byte, 32*1024)
for {
count, err := reader.Read(buffer)
if count > 0 && len(result) < limit {
keep := count
if keep > limit-len(result) {
keep = limit - len(result)
}
result = append(result, buffer[:keep]...)
}
if err != nil {
return result
}
}
}
func (r *Runner) writePasswordFile(pattern, password string) (string, func(), error) {
secretDir := filepath.Join(r.RuntimeDir, "secrets")
if err := os.MkdirAll(secretDir, 0700); err != nil {
+73
View File
@@ -190,6 +190,79 @@ func TestRestoreReportsProgress(t *testing.T) {
}
}
func TestDiffReportsLastChangedPaths(t *testing.T) {
dir := t.TempDir()
argsPath := filepath.Join(dir, "args")
script := filepath.Join(dir, "restic")
body := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$@" > '%s'
printf '%%s\n' 'comparing snapshot before to after:'
printf '%%s\n' '+ /mnt/user/new.txt'
printf '%%s\n' 'M /mnt/user/modified-a.txt'
printf '%%s\n' '- /mnt/user/removed.txt'
printf '%%s\n' 'M /mnt/user/modified-b.txt'
printf '%%s\n' 'Files: 1 new, 1 removed, 2 changed'
`, 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"}
diff, err := runner.Diff(context.Background(), repo, "before", "after", 3)
if err != nil {
t.Fatal(err)
}
if diff.Total != 4 || len(diff.Entries) != 3 {
t.Fatalf("unexpected diff result: %+v", diff)
}
expected := []DiffEntry{
{Kind: "modified", Path: "/mnt/user/modified-a.txt"},
{Kind: "removed", Path: "/mnt/user/removed.txt"},
{Kind: "modified", Path: "/mnt/user/modified-b.txt"},
}
for index, entry := range expected {
if diff.Entries[index] != entry {
t.Fatalf("entry %d = %+v, want %+v", index, diff.Entries[index], entry)
}
}
args, _ := os.ReadFile(argsPath)
for _, expected := range []string{"diff", "--metadata", "before", "after"} {
if !strings.Contains(string(args), expected) {
t.Fatalf("arguments missing %q: %s", expected, args)
}
}
}
func TestListStreamsSnapshotNodes(t *testing.T) {
dir := t.TempDir()
argsPath := filepath.Join(dir, "args")
script := filepath.Join(dir, "restic")
body := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$@" > '%s'
printf '%%s\n' '{"struct_type":"snapshot","id":"snap"}'
printf '%%s\n' '{"struct_type":"node","path":"/mnt/user","type":"dir"}'
printf '%%s\n' '{"struct_type":"node","path":"/mnt/user/file.txt","type":"file","size":12}'
`, 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"}
items, err := runner.List(context.Background(), repo, "snap", "/mnt/user")
if err != nil {
t.Fatal(err)
}
if len(items) != 2 || items[0]["path"] != "/mnt/user" || items[1]["path"] != "/mnt/user/file.txt" {
t.Fatalf("unexpected items: %+v", items)
}
args, _ := os.ReadFile(argsPath)
for _, expected := range []string{"ls", "snap", "--json", "/mnt/user"} {
if !strings.Contains(string(args), expected) {
t.Fatalf("arguments missing %q: %s", expected, args)
}
}
}
func TestStatsUsesRequestedRepositoryCountingMode(t *testing.T) {
dir := t.TempDir()
argsPath := filepath.Join(dir, "args")
+75 -23
View File
@@ -23,27 +23,42 @@ type Runner struct {
processes map[string]*os.Process
}
func (r *Runner) SetBinary(path string) {
r.mu.Lock()
r.Binary = path
r.mu.Unlock()
}
func (r *Runner) binary() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.Binary
}
type Progress struct {
Percent float64
Bytes int64
Files int64
CurrentFile string
Change string
}
type Summary struct {
Bytes int64
Files int64
Bytes int64
Files int64
Deleted int64
}
var (
progressPattern = regexp.MustCompile(`^\s*([0-9,]+)\s+([0-9]+)%`)
filesPattern = regexp.MustCompile(`^Number of regular files transferred:\s*([0-9,]+)`)
deletedPattern = regexp.MustCompile(`^Number of deleted files:\s*([0-9,]+)`)
bytesPattern = regexp.MustCompile(`^Total transferred file size:\s*([0-9,]+) bytes`)
)
func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback func(Progress)) (Summary, error) {
args := Arguments(job)
cmd := exec.CommandContext(ctx, r.Binary, args...)
cmd := exec.CommandContext(ctx, r.binary(), args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
@@ -76,25 +91,23 @@ func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback
done := make(chan struct{})
go func() {
scanOutput(stdout, func(line string) {
if value, ok := parseCount(filesPattern, line); ok {
summary.Files = value
}
if value, ok := parseCount(bytesPattern, line); ok {
summary.Bytes = value
}
summary.parseLine(line)
if callback != nil {
if progress, ok := parseProgress(line); ok {
callback(progress)
} else if strings.HasPrefix(line, "FILE|") {
callback(Progress{CurrentFile: strings.TrimPrefix(line, "FILE|")})
} else if change, ok := parseChange(line); ok {
callback(change)
}
}
})
close(done)
}()
errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024))
err = cmd.Wait()
stderrDone := make(chan []byte, 1)
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
// Drain both pipes before Wait closes them, including the final statistics.
<-done
errBytes := <-stderrDone
err = cmd.Wait()
if err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err())
@@ -117,7 +130,7 @@ func (r *Runner) Estimate(ctx context.Context, runID string, job model.Job) (Sum
args[index] = "--out-format="
}
}
cmd := exec.CommandContext(ctx, r.Binary, args...)
cmd := exec.CommandContext(ctx, r.binary(), args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
@@ -149,18 +162,16 @@ func (r *Runner) Estimate(ctx context.Context, runID string, job model.Job) (Sum
done := make(chan struct{})
go func() {
scanOutput(stdout, func(line string) {
if value, ok := parseCount(filesPattern, line); ok {
summary.Files = value
}
if value, ok := parseCount(bytesPattern, line); ok {
summary.Bytes = value
}
summary.parseLine(line)
})
close(done)
}()
errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024))
err = cmd.Wait()
stderrDone := make(chan []byte, 1)
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
// Drain both pipes before Wait closes them, including the final statistics.
<-done
errBytes := <-stderrDone
err = cmd.Wait()
if err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err())
@@ -172,7 +183,7 @@ func (r *Runner) Estimate(ctx context.Context, runID string, job model.Job) (Sum
func Arguments(job model.Job) []string {
options := job.Rsync
args := []string{"--recursive", "--human-readable", "--info=progress2", "--stats", "--partial", "--out-format=FILE|%n"}
args := []string{"--recursive", "--info=progress2", "--stats", "--partial", "--outbuf=L", "--out-format=CHANGE|%i|%n%L"}
if !options.Overwrite {
args = append(args, "--ignore-existing")
}
@@ -258,6 +269,24 @@ func scanOutput(reader io.Reader, callback func(string)) {
}
}
func drainLimited(reader io.Reader, limit int) []byte {
result := make([]byte, 0, limit)
buffer := make([]byte, 32*1024)
for {
count, err := reader.Read(buffer)
if count > 0 && len(result) < limit {
keep := count
if keep > limit-len(result) {
keep = limit - len(result)
}
result = append(result, buffer[:keep]...)
}
if err != nil {
return result
}
}
}
func splitLinesAndCarriageReturns(data []byte, atEOF bool) (advance int, token []byte, err error) {
for i, value := range data {
if value == '\n' || value == '\r' {
@@ -288,3 +317,26 @@ func parseCount(pattern *regexp.Regexp, line string) (int64, bool) {
value, err := strconv.ParseInt(strings.ReplaceAll(match[1], ",", ""), 10, 64)
return value, err == nil
}
func (s *Summary) parseLine(line string) {
if value, ok := parseCount(filesPattern, line); ok {
s.Files = value
}
if value, ok := parseCount(bytesPattern, line); ok {
s.Bytes = value
}
if value, ok := parseCount(deletedPattern, line); ok {
s.Deleted = value
}
}
func parseChange(line string) (Progress, bool) {
if !strings.HasPrefix(line, "CHANGE|") {
return Progress{}, false
}
fields := strings.SplitN(strings.TrimPrefix(line, "CHANGE|"), "|", 2)
if len(fields) != 2 || len(fields[0]) != 11 {
return Progress{}, false
}
return Progress{Change: fields[0], CurrentFile: fields[1]}, true
}
+147
View File
@@ -1,7 +1,11 @@
package rsync
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.casaderoll.de/michael/urbm/internal/model"
@@ -23,3 +27,146 @@ func TestParseProgress(t *testing.T) {
t.Fatalf("progress = %#v, %v", progress, ok)
}
}
func TestArrayDiskMirrorKeepsDiskDirectory(t *testing.T) {
job := model.Job{Sources: []model.Source{{Path: "/mnt/disk1"}}, Rsync: model.RsyncOptions{Target: "/mnt/disks/USB Backup", Overwrite: true, Delete: true}}
args := Arguments(job)
if !slices.Equal(args[len(args)-3:], []string{"--", "/mnt/disk1", "/mnt/disks/USB Backup/"}) || !slices.Contains(args, "--delete-delay") {
t.Fatalf("mirror arguments = %v", args)
}
}
func TestMachineReadableArgumentsAndSummary(t *testing.T) {
args := Arguments(model.Job{})
if slices.Contains(args, "--human-readable") {
t.Fatal("human readable sizes break byte parsing")
}
if !slices.Contains(args, "--out-format=CHANGE|%i|%n%L") {
t.Fatal(args)
}
var summary Summary
for _, line := range []string{"Number of regular files transferred: 1,376", "Number of deleted files: 1,214 (reg: 1,200, dir: 14)", "Total transferred file size: 1,234,567,890 bytes"} {
summary.parseLine(line)
}
if summary.Files != 1376 || summary.Deleted != 1214 || summary.Bytes != 1234567890 {
t.Fatalf("summary = %+v", summary)
}
}
func TestParseChangePreservesNames(t *testing.T) {
for _, code := range []string{">f+++++++++", ">f.st......", ".d..t......", "*deleting "} {
got, ok := parseChange("CHANGE|" + code + "|disk1/a | b.txt")
if !ok || got.Change != code || got.CurrentFile != "disk1/a | b.txt" {
t.Fatalf("change = %+v, %v", got, ok)
}
}
}
func TestRunnerReadsFinalStats(t *testing.T) {
binary := filepath.Join(t.TempDir(), "rsync")
script := "#!/bin/sh\nprintf '%s\\n' 'CHANGE|>f+++++++++|disk1/new.txt' 'Number of regular files transferred: 1,376' 'Number of deleted files: 1,214 (reg: 1214)' 'Total transferred file size: 1,234,567,890 bytes'\n"
if err := os.WriteFile(binary, []byte(script), 0700); err != nil {
t.Fatal(err)
}
runner := Runner{Binary: binary}
for _, estimate := range []bool{false, true} {
var summary Summary
var err error
var changes []string
if estimate {
summary, err = runner.Estimate(context.Background(), "test", model.Job{})
} else {
summary, err = runner.Run(context.Background(), "test", model.Job{}, func(p Progress) {
if p.CurrentFile != "" {
changes = append(changes, p.CurrentFile)
}
})
}
if err != nil || summary.Files != 1376 || summary.Deleted != 1214 || summary.Bytes != 1234567890 {
t.Fatalf("estimate=%v summary=%+v err=%v", estimate, summary, err)
}
if !estimate && strings.Join(changes, ",") != "disk1/new.txt" {
t.Fatal(changes)
}
}
}
// Opt in with URBM_TEST_RSYNC pointing to rsync 3.x (Unraid's supported version).
func TestRealRsyncDryRunAndCopy(t *testing.T) {
binary := os.Getenv("URBM_TEST_RSYNC")
if binary == "" {
t.Skip("set URBM_TEST_RSYNC to test with rsync 3.x")
}
dir := t.TempDir()
source, target := filepath.Join(dir, "disk1"), filepath.Join(dir, "USB")
destination := filepath.Join(target, "disk1")
for _, path := range []string{source, filepath.Join(destination, "old-dir")} {
if err := os.MkdirAll(path, 0700); err != nil {
t.Fatal(err)
}
}
for path, data := range map[string]string{
filepath.Join(source, "new"): "new", filepath.Join(source, "changed"): "updated",
filepath.Join(source, "same"): "same", filepath.Join(destination, "same"): "same",
filepath.Join(destination, "changed"): "old", filepath.Join(destination, "obsolete"): "delete",
filepath.Join(destination, "old-dir", "file"): "delete",
} {
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink("obsolete", filepath.Join(destination, "old-link")); err != nil {
t.Fatal(err)
}
runner := Runner{Binary: binary}
job := model.Job{Sources: []model.Source{{Path: source}}, Rsync: model.RsyncOptions{Target: target, DryRun: true, Overwrite: true, Delete: true, Checksum: true, PreserveLinks: true}}
var changes []Progress
summary, err := runner.Run(context.Background(), "test", job, func(p Progress) {
if p.Change != "" {
changes = append(changes, p)
}
})
if err != nil || summary.Files != 2 || summary.Bytes != 10 || summary.Deleted != 4 {
t.Fatalf("dry run: %+v %v", summary, err)
}
if len(changes) < 6 {
t.Fatalf("missing change details: %+v", changes)
}
if _, err := os.Stat(filepath.Join(destination, "new")); !os.IsNotExist(err) {
t.Fatal("dry run wrote a file")
}
if _, err := os.Stat(filepath.Join(destination, "obsolete")); err != nil {
t.Fatal("dry run deleted a file")
}
estimate, err := runner.Estimate(context.Background(), "test", job)
if err != nil || estimate != summary {
t.Fatalf("estimate differs: %+v %v", estimate, err)
}
job.Rsync.DryRun = false
copied, err := runner.Run(context.Background(), "test", job, nil)
if err != nil || copied != summary {
t.Fatalf("copy differs: %+v %v", copied, err)
}
for _, name := range []string{"new", "changed", "same"} {
got, err := os.ReadFile(filepath.Join(destination, name))
if err != nil {
t.Fatal(err)
}
want, err := os.ReadFile(filepath.Join(source, name))
if err != nil {
t.Fatal(err)
}
if string(got) != string(want) {
t.Fatalf("different contents: %s", name)
}
}
for _, name := range []string{"obsolete", "old-dir", "old-link"} {
if _, err := os.Lstat(filepath.Join(destination, name)); !os.IsNotExist(err) {
t.Fatalf("not deleted: %s", name)
}
}
identical, err := runner.Estimate(context.Background(), "test", job)
if err != nil || identical.Files != 0 || identical.Bytes != 0 || identical.Deleted != 0 {
t.Fatalf("identical: %+v %v", identical, err)
}
}
+14 -2
View File
@@ -7,7 +7,10 @@ import (
"time"
)
type Expression struct{ fields [5]field }
type Expression struct {
fields [5]field
restricted [5]bool
}
type field map[int]bool
var bounds = [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 6}}
@@ -24,6 +27,7 @@ func Parse(spec string) (Expression, error) {
return Expression{}, fmt.Errorf("cron field %d: %w", i+1, err)
}
expression.fields[i] = parsed
expression.restricted[i] = part != "*"
}
return expression, nil
}
@@ -76,11 +80,19 @@ func parseField(value string, min, max int) (field, error) {
func (e Expression) Matches(t time.Time) bool {
values := [5]int{t.Minute(), t.Hour(), t.Day(), int(t.Month()), int(t.Weekday())}
for i, value := range values {
if i == 2 || i == 4 {
continue
}
if !e.fields[i][value] {
return false
}
}
return true
dayOfMonth := e.fields[2][values[2]]
dayOfWeek := e.fields[4][values[4]]
if e.restricted[2] && e.restricted[4] {
return dayOfMonth || dayOfWeek
}
return dayOfMonth && dayOfWeek
}
func (e Expression) Next(after time.Time) time.Time {
+22
View File
@@ -43,3 +43,25 @@ func TestPreviousWithinCatchUpWindow(t *testing.T) {
t.Fatalf("unexpected catch-up match %v", got)
}
}
func TestPreviousScheduledUsesJobTimezone(t *testing.T) {
now := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
want := time.Date(2026, 7, 13, 0, 0, 0, 0, time.UTC)
got, err := PreviousScheduled("0 2 * * *", "Europe/Berlin", now, 24*time.Hour)
if err != nil {
t.Fatal(err)
}
if !got.Equal(want) {
t.Fatalf("previous scheduled = %v, want %v", got, want)
}
}
func TestCronUsesOrForRestrictedMonthDayAndWeekday(t *testing.T) {
expr, err := Parse("0 2 1 * 1")
if err != nil {
t.Fatal(err)
}
if !expr.Matches(time.Date(2026, 6, 8, 2, 0, 0, 0, time.UTC)) {
t.Fatal("restricted day-of-month and weekday were treated as AND")
}
}
+15
View File
@@ -14,6 +14,21 @@ type Enqueue func(model.Job) error
type EnqueueMaintenance func(string, string) (model.Run, error)
type LastRun func(string) time.Time
func PreviousScheduled(spec, timezone string, now time.Time, window time.Duration) (time.Time, error) {
expression, err := Parse(spec)
if err != nil {
return time.Time{}, err
}
loc := time.Local
if timezone != "" {
loc, err = time.LoadLocation(timezone)
if err != nil {
return time.Time{}, err
}
}
return previous(expression, now.In(loc), window).UTC(), nil
}
type Scheduler struct {
config ConfigProvider
enqueue Enqueue
+64 -2
View File
@@ -1,6 +1,15 @@
package service
import (
"context"
"fmt"
"git.casaderoll.de/michael/urbm/internal/notify"
"git.casaderoll.de/michael/urbm/internal/platform"
"git.casaderoll.de/michael/urbm/internal/queue"
"git.casaderoll.de/michael/urbm/internal/restic"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -11,9 +20,9 @@ import (
func TestNotificationMessageIncludesBackupStatistics(t *testing.T) {
started := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
finished := started.Add(2*time.Minute + 8*time.Second)
run := model.Run{TaskType: "backup", Status: "success", StartedAt: &started, FinishedAt: &finished, SnapshotID: "abc123456789abcdef", BytesAdded: 1536, FilesNew: 7, FilesChanged: 2, BytesProcessed: 5 * 1024 * 1024, FilesProcessed: 120}
run := model.Run{TaskType: "backup", Status: "success", StartedAt: &started, FinishedAt: &finished, SnapshotID: "abc123456789abcdef", BytesAdded: 1536, FilesNew: 7, FilesChanged: 2, BytesProcessed: 5 * 1024 * 1024, FilesProcessed: 120, RetentionRemoved: 3, RetentionAfter: 14}
message := notificationMessage("Freigaben", "LV-426", run, finished)
for _, expected := range []string{"📊 Backup-Zusammenfassung", "📦 Repository: LV-426", "🗂️ Job: Freigaben", "📸 Snapshot: abc123456789", "⏱️ Dauer: 2 Min. 8 Sek.", "📂 Dateien: 120 verarbeitet", "💾 Verarbeitet: 5.0 MiB", "➕ Neu: 7 Dateien", "✏️ Geändert: 2 Dateien", "📤 Neu gespeichert: 1.5 KiB", "🔐 Ziel: Restic Repository", "✅ Status: Erfolgreich"} {
for _, expected := range []string{"📊 Backup-Zusammenfassung", "📦 Repository: LV-426", "🗂️ Job: Freigaben", "📸 Snapshot: abc123456789", "⏱️ Dauer: 2 Min. 8 Sek.", "📂 Dateien: 120 verarbeitet", "💾 Verarbeitet: 5.0 MiB", "➕ Neu: 7 Dateien", "✏️ Geändert: 2 Dateien", "📤 Neu gespeichert: 1.5 KiB", "🧹 Aufbewahrung: 3 Snapshot(s) entfernt", "📸 Verbleibende Snapshots: 14", "🔐 Ziel: Restic Repository", "✅ Status: Erfolgreich"} {
if !strings.Contains(message, expected) {
t.Errorf("message %q does not contain %q", message, expected)
}
@@ -27,3 +36,56 @@ func TestNotificationTitleIncludesRepositoryAndStatus(t *testing.T) {
t.Fatalf("title = %q", title)
}
}
func TestNotificationMessageIncludesPruneSummary(t *testing.T) {
started := time.Date(2026, 6, 22, 3, 0, 0, 0, time.UTC)
finished := started.Add(42 * time.Second)
run := model.Run{TaskType: "prune", Status: "success", StartedAt: &started, FinishedAt: &finished, Message: "prune completed", RepositoryBefore: 100 * 1024 * 1024, RepositoryAfter: 75 * 1024 * 1024, RepositoryFreed: 25 * 1024 * 1024}
message := notificationMessage("Repository-Bereinigung", "LV-426", run, finished)
for _, expected := range []string{"🧹 Prune-Zusammenfassung", "📦 Repository: LV-426", "🗂️ Job: Repository-Bereinigung", "⏱️ Dauer: 42 Sekunden", "📦 Vorher: 100.0 MiB", "📦 Nachher: 75.0 MiB", "🧹 Freigegeben: 25.0 MiB", "🔐 Ziel: Restic Repository", "✅ Status: Erfolgreich"} {
if !strings.Contains(message, expected) {
t.Errorf("message %q does not contain %q", message, expected)
}
}
if strings.Contains(message, "⚠️ Details: prune completed") {
t.Fatalf("message contains redundant prune completion details: %q", message)
}
}
type checkSecrets struct{}
func (checkSecrets) Get(string) (string, error) { return "test", nil }
func TestCheckSendsSuccessAndFailureNotifications(t *testing.T) {
for _, code := range []int{0, 1} {
t.Run(fmt.Sprint(code), func(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "restic")
if err := os.WriteFile(binary, []byte(fmt.Sprintf("#!/bin/sh\necho check-result >&2\nexit %d\n", code)), 0700); err != nil {
t.Fatal(err)
}
var sent []string
s := &Service{
config: model.Config{Repositories: []model.Repository{{ID: "repo", Name: "test", Type: model.RepositoryLocal, Location: dir}}, Notifications: []model.NotificationTarget{{Type: "unraid", Enabled: true, Events: []string{"success", "failed"}}}},
repositoryLocks: map[string]chan struct{}{},
restic: &restic.Runner{Binary: binary, RuntimeDir: dir, Secrets: checkSecrets{}},
mounts: &platform.MountManager{}, queue: queue.New(nil, nil), log: slog.Default(),
notifier: &notify.Sender{RunCommand: func(_ context.Context, _ string, args ...string) error {
sent = append(sent, strings.Join(args, " "))
return nil
}},
}
result := s.execute(context.Background(), model.Run{ID: "run", JobID: "repo", TaskType: "check"})
want := "success"
if code != 0 {
want = "failed"
}
if result.Status != want || len(sent) != 1 {
t.Fatalf("status=%s messages=%v", result.Status, sent)
}
if !strings.Contains(sent[0], "URBM") {
t.Fatal(sent)
}
})
}
}
+97
View File
@@ -0,0 +1,97 @@
package service
import (
"fmt"
"strings"
"git.casaderoll.de/michael/urbm/internal/model"
"git.casaderoll.de/michael/urbm/internal/rsync"
)
func logRsyncSetup(run *model.Run, job model.Job) {
mode := "Kopierlauf – Änderungen am Ziel werden ausgeführt"
if job.Rsync.DryRun {
mode = "Testlauf – es werden keine Dateien geschrieben oder gelöscht"
}
appendLiveLog(run, "Rsync: "+mode)
for _, source := range job.Sources {
appendLiveLog(run, "Quelle: "+source.Path)
}
appendLiveLog(run, "Ziel: "+job.Rsync.Target)
overwrite := "vorhandene Dateien überspringen"
if job.Rsync.Overwrite {
overwrite = "abweichende vorhandene Dateien aktualisieren"
}
deletion := "aus"
if job.Rsync.Delete {
deletion = "an (zusätzliche Einträge im Ziel entfernen)"
}
comparison := "Dateigröße und Änderungszeit"
if job.Rsync.Checksum {
comparison = "Dateiinhalt per Prüfsumme (liest Dateien vollständig)"
}
appendLiveLog(run, "Vorhandene Dateien: "+overwrite+"; Löschen: "+deletion)
appendLiveLog(run, "Vergleich: "+comparison)
var preserve []string
for _, option := range []struct {
enabled bool
label string
}{
{job.Rsync.PreserveTimes, "Zeitstempel"}, {job.Rsync.PreservePermissions, "Berechtigungen"},
{job.Rsync.PreserveOwner, "Besitzer"}, {job.Rsync.PreserveGroup, "Gruppe"},
{job.Rsync.PreserveLinks, "Symlinks"}, {job.Rsync.PreserveACLs, "ACLs"}, {job.Rsync.PreserveXattrs, "erweiterte Attribute"},
} {
if option.enabled {
preserve = append(preserve, option.label)
}
}
if len(preserve) > 0 {
appendLiveLog(run, "Übernehmen: "+strings.Join(preserve, ", "))
}
appendLiveLog(run, fmt.Sprintf("Ausschlussmuster: %d", len(job.Excludes)))
for index, pattern := range job.Excludes {
if index == 10 {
appendLiveLog(run, "Weitere Ausschlussmuster siehe Job-Konfiguration")
break
}
appendLiveLog(run, "Ausschluss: "+pattern)
}
}
func rsyncChangeLabel(code string) string {
if strings.HasPrefix(code, "*deleting") {
return "Löschen"
}
if len(code) != 11 {
return "Änderung"
}
if code[2:] == "+++++++++" {
return "Neu"
}
if code[0] == '.' {
return "Metadaten ändern"
}
return "Aktualisieren"
}
func applyRsyncSummary(run *model.Run, summary rsync.Summary) {
run.Status, run.Message = "success", "rsync completed"
run.BytesProcessed, run.FilesProcessed = summary.Bytes, summary.Files
run.EntriesDeleted = summary.Deleted
run.BytesAdded = summary.Bytes
if run.RsyncDryRun {
run.BytesAdded = 0
run.Message = "Rsync-Testlauf erfolgreich – keine Änderungen vorgenommen"
}
// Rsync's transferred-file count combines new and updated regular files.
run.FilesNew, run.FilesChanged = 0, 0
}
func logRsyncSummary(run *model.Run, summary rsync.Summary) {
if run.RsyncDryRun {
appendLiveLog(run, fmt.Sprintf("Testlauf: %d Dateien würden kopiert/aktualisiert (%s Dateiinhalt); %d Einträge würden gelöscht (inkl. Ordner/Symlinks)", summary.Files, formatBytes(summary.Bytes), summary.Deleted))
appendLiveLog(run, "Testlauf erfolgreich – keine Änderungen vorgenommen")
} else {
appendLiveLog(run, fmt.Sprintf("Rsync abgeschlossen: %d Dateien kopiert/aktualisiert (%s Dateiinhalt); %d Einträge gelöscht (inkl. Ordner/Symlinks)", summary.Files, formatBytes(summary.Bytes), summary.Deleted))
}
}
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.casaderoll.de/michael/urbm/internal/model"
"git.casaderoll.de/michael/urbm/internal/queue"
"git.casaderoll.de/michael/urbm/internal/rsync"
)
func TestRsyncSummaryDistinguishesDryRun(t *testing.T) {
for _, dry := range []bool{true, false} {
run := model.Run{TaskType: "rsync", RsyncDryRun: dry}
summary := rsync.Summary{Files: 1376, Deleted: 1214, Bytes: 1234567890}
applyRsyncSummary(&run, summary)
logRsyncSummary(&run, summary)
message := notificationMessage("Mirror", "", run, time.Now())
for _, expected := range []string{"1376", "1214", "1.1 GiB"} {
if !strings.Contains(message, expected) || !strings.Contains(strings.Join(run.LiveLog, "\n"), expected) {
t.Fatalf("missing %s: %s %v", expected, message, run.LiveLog)
}
}
if dry {
if run.BytesAdded != 0 || !strings.Contains(message, "würden") || !strings.Contains(message, "keine Änderungen vorgenommen") || strings.Contains(message, "⚠️") {
t.Fatal(message)
}
} else if run.BytesAdded != summary.Bytes || strings.Contains(message, "würden") {
t.Fatal(message)
}
}
}
func TestExecuteRsyncLogsSetupSamplesAndErrors(t *testing.T) {
for _, fail := range []bool{false, true} {
dir := t.TempDir()
source, target := filepath.Join(dir, "source"), filepath.Join(dir, "target")
for _, path := range []string{source, target} {
if err := os.Mkdir(path, 0700); err != nil {
t.Fatal(err)
}
}
binary := filepath.Join(dir, "rsync")
script := "#!/bin/sh\ncase \" $* \" in *' --dry-run '*) ;; *) exit 99;; esac\n"
if fail {
script += "echo 'test failure' >&2\nexit 23\n"
} else {
script += "i=0; while [ $i -lt 25 ]; do printf 'CHANGE|>f+++++++++|source/file%s\\n' \"$i\"; i=$((i+1)); done\nprintf '%s\\n' 'Number of regular files transferred: 25' 'Number of deleted files: 3 (reg: 3)' 'Total transferred file size: 123456 bytes'\n"
}
if err := os.WriteFile(binary, []byte(script), 0700); err != nil {
t.Fatal(err)
}
job := model.Job{ID: "mirror", Sources: []model.Source{{Path: source}}, Rsync: model.RsyncOptions{Target: target, DryRun: true, Overwrite: true, Delete: true, Checksum: true}}
s := &Service{config: model.Config{Jobs: []model.Job{job}}, rsync: &rsync.Runner{Binary: binary}, queue: queue.New(nil, nil)}
result := s.executeRsync(context.Background(), model.Run{ID: "run", JobID: job.ID, TaskType: "rsync"})
log := strings.Join(result.LiveLog, "\n")
for _, expected := range []string{"Testlauf", "Quelle: " + source, "Ziel: " + target, "Löschen: an", "Prüfsumme"} {
if !strings.Contains(log, expected) {
t.Fatalf("missing %s: %s", expected, log)
}
}
if fail {
if result.Status != "failed" || !strings.Contains(log, "test failure") {
t.Fatal(result)
}
} else {
if result.Status != "success" || result.BytesAdded != 0 || result.BytesProcessed != 123456 || result.EntriesDeleted != 3 {
t.Fatal(result)
}
if strings.Count(log, "Geplant: Neu") != 20 || !strings.Contains(log, "Weitere Einzeländerungen ausgeblendet") || !strings.Contains(log, "25 Dateien würden") {
t.Fatal(log)
}
}
}
}
+163
View File
@@ -0,0 +1,163 @@
package service
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"git.casaderoll.de/michael/urbm/internal/model"
"git.casaderoll.de/michael/urbm/internal/queue"
"git.casaderoll.de/michael/urbm/internal/store"
)
func TestEmptyBackupMetricsEncodeAsJSONArray(t *testing.T) {
s := &Service{}
encoded, err := json.Marshal(s.BackupMetrics())
if err != nil {
t.Fatal(err)
}
if string(encoded) != "[]" {
t.Fatalf("empty backup metrics JSON = %s", encoded)
}
}
func TestRepositoryLockHonorsContextCancellation(t *testing.T) {
s := &Service{repositoryLocks: map[string]chan struct{}{}}
repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo"}
unlock, err := s.lockRepository(context.Background(), repo)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := s.lockRepository(ctx, repo); err == nil {
t.Fatal("concurrent repository access ignored the request deadline")
}
unlock()
}
func TestBackupMetricsSurviveClearedRunSnapshot(t *testing.T) {
dir := t.TempDir()
st := store.New(dir)
if err := st.Init(); err != nil {
t.Fatal(err)
}
s := &Service{store: st, persistRuns: make(chan []model.Run, 1)}
finished := time.Date(2026, 7, 13, 1, 30, 0, 0, time.UTC)
run := model.Run{ID: "run-1", JobID: "job-1", TaskType: "backup", Status: "success", FinishedAt: &finished, BytesProcessed: 1024, FilesProcessed: 12}
s.queuePersistence([]model.Run{run})
before, err := os.ReadFile(filepath.Join(dir, "backup-metrics.jsonl"))
if err != nil {
t.Fatal(err)
}
s.queuePersistence(nil)
after, err := os.ReadFile(filepath.Join(dir, "backup-metrics.jsonl"))
if err != nil {
t.Fatal(err)
}
if string(after) != string(before) {
t.Fatal("clearing activities rewrote the backup metric file")
}
metrics := s.BackupMetrics()
if len(metrics) != 1 || metrics[0].RunID != run.ID || metrics[0].FilesProcessed != 12 {
t.Fatalf("backup metrics after clear = %#v", metrics)
}
}
func TestLastRunUsesDurableMetricAfterActivityClear(t *testing.T) {
created := time.Date(2026, 7, 13, 1, 0, 0, 0, time.UTC)
finished := created.Add(3 * time.Hour)
q := queue.New(nil, nil)
q.RestoreHistory([]model.Run{{ID: "run-1", JobID: "job-1", Status: "success", CreatedAt: created}})
s := &Service{queue: q, backupMetrics: []model.BackupMetric{{RunID: "run-1", JobID: "job-1", Status: "success", FinishedAt: finished}}}
q.ClearHistory()
if got := s.LastRun("job-1"); !got.Equal(finished) {
t.Fatalf("last run after activity clear = %v, want %v", got, finished)
}
}
func TestDashboardStatusUsesOnlyBackupsAndFindsOverdueJobs(t *testing.T) {
now := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)
lastSuccess := now.Add(-30 * time.Hour)
failedAt := now.Add(-2 * time.Hour)
q := queue.New(nil, nil)
q.RestoreHistory([]model.Run{
{ID: "failed-backup", JobID: "job-1", TaskType: "backup", Status: "failed", CreatedAt: failedAt},
{ID: "successful-check", JobID: "repo-1", TaskType: "check", Status: "success", CreatedAt: now.Add(-time.Hour)},
})
config := model.DefaultConfig()
config.Jobs = []model.Job{{ID: "job-1", Name: "Daily", Type: model.JobShare, Enabled: true, Schedule: model.Schedule{Cron: "0 2 * * *", Timezone: "UTC"}}}
s := &Service{queue: q, config: config, backupMetrics: []model.BackupMetric{{RunID: "success", JobID: "job-1", Status: "success", FinishedAt: lastSuccess}}}
status := s.DashboardStatus(now)
if status.LastSuccessfulBackup == nil || status.LastSuccessfulBackup.RunID != "success" {
t.Fatalf("last successful backup = %#v", status.LastSuccessfulBackup)
}
if status.LastFailedBackup == nil || status.LastFailedBackup.ID != "failed-backup" {
t.Fatalf("last failed backup = %#v", status.LastFailedBackup)
}
if len(status.OverdueJobs) != 1 || status.OverdueJobs[0].JobID != "job-1" {
t.Fatalf("overdue jobs = %#v", status.OverdueJobs)
}
}
func TestBackupMetricsOnlyCaptureCompletedSuccessfulBackupsOnce(t *testing.T) {
st := store.New(t.TempDir())
s := &Service{store: st}
finished := time.Now().UTC()
valid := model.Run{ID: "valid", JobID: "job-1", TaskType: "backup", Status: "warning", FinishedAt: &finished}
s.captureBackupMetrics([]model.Run{
valid,
valid,
{ID: "failed", TaskType: "backup", Status: "failed", FinishedAt: &finished},
{ID: "active", TaskType: "backup", Status: "running"},
{ID: "rsync", TaskType: "rsync", Status: "success", FinishedAt: &finished},
})
metrics := s.BackupMetrics()
if len(metrics) != 1 || metrics[0].RunID != valid.ID {
t.Fatalf("captured backup metrics = %#v", metrics)
}
}
func TestPersistentRunLogsAreWrittenAndPruned(t *testing.T) {
dir := t.TempDir()
config := model.DefaultConfig()
config.Settings.PersistentLogDir = dir
s := &Service{config: config}
finished := time.Now()
run := model.Run{ID: "run-1", FinishedAt: &finished, LiveLog: []string{"first", "second"}}
if err := s.persistRunLogs([]model.Run{run}); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "urbm-run-1.log")
if contents, err := os.ReadFile(path); err != nil || string(contents) != "first\nsecond\n" {
t.Fatalf("persistent log = %q, %v", contents, err)
}
if err := s.persistRunLogs(nil); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("cleared run log still exists: %v", err)
}
}
func TestQueuePersistenceDoesNotBlockWhenWriterIsBusy(t *testing.T) {
s := &Service{persistRuns: make(chan []model.Run, 1)}
s.persistRuns <- []model.Run{{ID: "old"}}
done := make(chan struct{})
go func() {
s.queuePersistence([]model.Run{{ID: "latest"}})
close(done)
}()
select {
case <-done:
case <-time.After(100 * time.Millisecond):
t.Fatal("queue persistence blocked the queue callback")
}
runs := <-s.persistRuns
if len(runs) != 1 || runs[0].ID != "latest" {
t.Fatalf("queued persistence state = %#v", runs)
}
}
File diff suppressed because it is too large. Load diff
+131 -3
View File
@@ -1,12 +1,14 @@
package store
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"git.casaderoll.de/michael/urbm/internal/model"
)
@@ -50,12 +52,20 @@ func (s *Store) SaveConfig(c model.Config) error {
}
func (s *Store) LoadRuns() ([]model.Run, error) {
s.mu.RLock()
defer s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()
var runs []model.Run
if err := readJSON(s.runsPath(), &runs); errors.Is(err, os.ErrNotExist) {
return []model.Run{}, nil
} else if err != nil {
var decodeErr *jsonDecodeError
if errors.As(err, &decodeErr) {
quarantined, quarantineErr := quarantineFile(s.runsPath())
if quarantineErr != nil {
return nil, fmt.Errorf("%w; quarantine failed: %v", err, quarantineErr)
}
return nil, fmt.Errorf("%w; corrupt file moved to %s", err, quarantined)
}
return nil, err
}
return runs, nil
@@ -70,8 +80,97 @@ func (s *Store) SaveRuns(runs []model.Run) error {
return writeJSONAtomic(s.runsPath(), runs, 0600)
}
func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
path := s.backupMetricsPath()
b, err := os.ReadFile(path)
legacySource := false
if errors.Is(err, os.ErrNotExist) {
path = s.legacyBackupMetricsPath()
b, err = os.ReadFile(path)
legacySource = true
if errors.Is(err, os.ErrNotExist) {
return []model.BackupMetric{}, false, nil
}
}
if err != nil {
return nil, false, err
}
b = bytes.TrimSpace(b)
if len(b) == 0 {
return []model.BackupMetric{}, false, nil
}
if b[0] == '[' {
var metrics []model.BackupMetric
if err := json.Unmarshal(b, &metrics); err != nil {
return nil, true, quarantineDecodeError(path, err)
}
return metrics, true, nil
}
lines := bytes.Split(b, []byte{'\n'})
metrics := make([]model.BackupMetric, 0, len(lines))
for index, line := range lines {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var metric model.BackupMetric
if err := json.Unmarshal(line, &metric); err != nil {
return nil, legacySource, quarantineDecodeError(path, fmt.Errorf("line %d: %w", index+1, err))
}
metrics = append(metrics, metric)
}
return metrics, legacySource, nil
}
func (s *Store) SaveBackupMetrics(metrics []model.BackupMetric) error {
s.mu.Lock()
defer s.mu.Unlock()
if len(metrics) > 5000 {
metrics = metrics[len(metrics)-5000:]
}
var data bytes.Buffer
encoder := json.NewEncoder(&data)
for _, metric := range metrics {
if err := encoder.Encode(metric); err != nil {
return err
}
}
return writeBytesAtomic(s.backupMetricsPath(), data.Bytes(), 0600)
}
func (s *Store) AppendBackupMetric(metric model.BackupMetric) error {
s.mu.Lock()
defer s.mu.Unlock()
b, err := json.Marshal(metric)
if err != nil {
return err
}
b = append(b, '\n')
file, err := os.OpenFile(s.backupMetricsPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return err
}
if _, err := file.Write(b); err != nil {
file.Close()
return err
}
if err := file.Sync(); err != nil {
file.Close()
return err
}
return file.Close()
}
func (s *Store) configPath() string { return filepath.Join(s.dir, "config.json") }
func (s *Store) runsPath() string { return filepath.Join(s.dir, "runs.json") }
func (s *Store) backupMetricsPath() string {
return filepath.Join(s.dir, "backup-metrics.jsonl")
}
func (s *Store) legacyBackupMetricsPath() string {
return filepath.Join(s.dir, "backup-metrics.json")
}
func readJSON(path string, target any) error {
b, err := os.ReadFile(path)
@@ -79,17 +178,46 @@ func readJSON(path string, target any) error {
return err
}
if err := json.Unmarshal(b, target); err != nil {
return fmt.Errorf("decode %s: %w", path, err)
return &jsonDecodeError{path: path, err: err}
}
return nil
}
type jsonDecodeError struct {
path string
err error
}
func (e *jsonDecodeError) Error() string { return fmt.Sprintf("decode %s: %v", e.path, e.err) }
func (e *jsonDecodeError) Unwrap() error { return e.err }
func quarantineDecodeError(path string, decodeErr error) error {
quarantined, err := quarantineFile(path)
wrapped := &jsonDecodeError{path: path, err: decodeErr}
if err != nil {
return fmt.Errorf("%w; quarantine failed: %v", wrapped, err)
}
return fmt.Errorf("%w; corrupt file moved to %s", wrapped, quarantined)
}
func quarantineFile(path string) (string, error) {
quarantined := path + ".corrupt-" + time.Now().UTC().Format("20060102-150405.000000000")
if err := os.Rename(path, quarantined); err != nil {
return "", err
}
return quarantined, nil
}
func writeJSONAtomic(path string, value any, mode os.FileMode) error {
b, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
return writeBytesAtomic(path, b, mode)
}
func writeBytesAtomic(path string, b []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".urbm-*")
if err != nil {
return err
+106
View File
@@ -2,7 +2,9 @@ package store
import (
"os"
"path/filepath"
"testing"
"time"
"git.casaderoll.de/michael/urbm/internal/model"
)
@@ -28,3 +30,107 @@ func TestStoreInitializesAndPersists(t *testing.T) {
t.Fatalf("config mode = %o", info.Mode().Perm())
}
}
func TestCorruptRunHistoryIsQuarantinedBeforeReplacement(t *testing.T) {
dir := t.TempDir()
s := New(dir)
if err := os.WriteFile(s.runsPath(), []byte(`{"broken":`), 0600); err != nil {
t.Fatal(err)
}
if _, err := s.LoadRuns(); err == nil {
t.Fatal("corrupt run history loaded without error")
}
if _, err := os.Stat(s.runsPath()); !os.IsNotExist(err) {
t.Fatalf("corrupt runs file was not moved: %v", err)
}
matches, err := filepath.Glob(s.runsPath() + ".corrupt-*")
if err != nil || len(matches) != 1 {
t.Fatalf("quarantined run files = %#v, %v", matches, err)
}
if err := s.SaveRuns([]model.Run{}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(matches[0]); err != nil {
t.Fatalf("quarantined run file was overwritten: %v", err)
}
}
func TestCorruptBackupMetricsAreQuarantined(t *testing.T) {
dir := t.TempDir()
s := New(dir)
if err := os.WriteFile(s.backupMetricsPath(), []byte("{not-json}\n"), 0600); err != nil {
t.Fatal(err)
}
if _, _, err := s.LoadBackupMetrics(); err == nil {
t.Fatal("corrupt backup metrics loaded without error")
}
matches, err := filepath.Glob(s.backupMetricsPath() + ".corrupt-*")
if err != nil || len(matches) != 1 {
t.Fatalf("quarantined metric files = %#v, %v", matches, err)
}
contents, err := os.ReadFile(matches[0])
if err != nil || string(contents) != "{not-json}\n" {
t.Fatalf("quarantined metric contents = %q, %v", contents, err)
}
}
func TestBackupMetricsPersistSeparatelyFromRuns(t *testing.T) {
s := New(t.TempDir())
if err := s.Init(); err != nil {
t.Fatal(err)
}
finished := time.Date(2026, 7, 13, 1, 30, 0, 0, time.UTC)
metrics := []model.BackupMetric{{SchemaVersion: model.SchemaVersion, RunID: "run-1", JobID: "job-1", FinishedAt: finished, Status: "success", BytesProcessed: 42, FilesProcessed: 7}}
if err := s.SaveBackupMetrics(metrics); err != nil {
t.Fatal(err)
}
if err := s.SaveRuns(nil); err != nil {
t.Fatal(err)
}
loaded, legacy, err := s.LoadBackupMetrics()
if err != nil {
t.Fatal(err)
}
if legacy {
t.Fatal("compacted metric file was detected as legacy JSON array")
}
if len(loaded) != 1 || loaded[0].RunID != "run-1" || loaded[0].BytesProcessed != 42 {
t.Fatalf("backup metrics = %#v", loaded)
}
info, err := os.Stat(s.backupMetricsPath())
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("backup metrics mode = %o", info.Mode().Perm())
}
}
func TestBackupMetricsMigrateFromJSONArrayAndAppend(t *testing.T) {
s := New(t.TempDir())
finished := time.Date(2026, 7, 13, 1, 30, 0, 0, time.UTC)
legacyJSON := `[{"schemaVersion":1,"runId":"old","jobId":"job-1","finishedAt":"2026-07-13T01:30:00Z","status":"success","bytesProcessed":42,"filesProcessed":7}]`
if err := os.WriteFile(s.legacyBackupMetricsPath(), []byte(legacyJSON), 0600); err != nil {
t.Fatal(err)
}
metrics, legacy, err := s.LoadBackupMetrics()
if err != nil {
t.Fatal(err)
}
if !legacy || len(metrics) != 1 || metrics[0].RunID != "old" {
t.Fatalf("legacy metrics = %#v, legacy = %v", metrics, legacy)
}
if err := s.SaveBackupMetrics(metrics); err != nil {
t.Fatal(err)
}
if err := s.AppendBackupMetric(model.BackupMetric{SchemaVersion: model.SchemaVersion, RunID: "new", JobID: "job-1", FinishedAt: finished.Add(time.Hour), Status: "success"}); err != nil {
t.Fatal(err)
}
metrics, legacy, err = s.LoadBackupMetrics()
if err != nil {
t.Fatal(err)
}
if legacy || len(metrics) != 2 || metrics[1].RunID != "new" {
t.Fatalf("appended metrics = %#v, legacy = %v", metrics, legacy)
}
}
+11 -4
View File
@@ -2,7 +2,9 @@
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd)
VERSION=${VERSION:-0.1.0}
PLUGIN_VERSION=$(sed -n 's/^<!ENTITY version "\([^"]*\)">/\1/p' "$ROOT/plugin/urbm.plg")
VERSION=${VERSION:-$PLUGIN_VERSION}
[ -n "$VERSION" ] || { echo "Could not determine plugin version" >&2; exit 1; }
RESTIC_VERSION=0.19.0
RESTIC_SHA256=13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21
BUILD="$ROOT/build"
@@ -28,7 +30,7 @@ fi
bzip2 -dc "$RESTIC_ARCHIVE" > "$STAGE/usr/local/libexec/urbm/restic"
chmod 0755 "$STAGE/usr/local/libexec/urbm/restic" "$STAGE/usr/local/sbin/urbmd"
cp "$ROOT/webgui/URBM.page" "$STAGE/usr/local/emhttp/plugins/urbm/"
sed "s/$PLUGIN_VERSION/$VERSION/g" "$ROOT/webgui/URBM.page" > "$STAGE/usr/local/emhttp/plugins/urbm/URBM.page"
cp "$ROOT/webgui/api.php" "$STAGE/usr/local/emhttp/plugins/urbm/"
cp "$ROOT/webgui/assets/"* "$STAGE/usr/local/emhttp/plugins/urbm/assets/"
cp "$ROOT/webgui/images/"* "$STAGE/usr/local/emhttp/plugins/urbm/images/"
@@ -37,8 +39,13 @@ cp "$ROOT/plugin/rc.urbm" "$STAGE/etc/rc.d/rc.urbm"
cp "$ROOT/plugin/doinst.sh" "$ROOT/plugin/slack-desc" "$STAGE/install/"
chmod 0755 "$STAGE/etc/rc.d/rc.urbm" "$STAGE/install/doinst.sh"
(cd "$STAGE" && tar --owner=0 --group=0 -cJf "$DIST/urbm-${VERSION}-x86_64-1.txz" .)
# Suppress macOS metadata and normalize ownership with GNU or BSD tar.
if tar --version | grep -q 'GNU tar'; then
(cd "$STAGE" && COPYFILE_DISABLE=1 tar --owner=0 --group=0 -cJf "$DIST/urbm-${VERSION}-x86_64-1.txz" .)
else
(cd "$STAGE" && COPYFILE_DISABLE=1 tar --uid 0 --gid 0 --uname root --gname root -cJf "$DIST/urbm-${VERSION}-x86_64-1.txz" .)
fi
(cd "$DIST" && sha256sum "urbm-${VERSION}-x86_64-1.txz" > "urbm-${VERSION}-x86_64-1.txz.sha256")
PACKAGE_SHA256=$(sha256sum "$DIST/urbm-${VERSION}-x86_64-1.txz" | awk '{print $1}')
sed "s/REPLACE_DURING_RELEASE/$PACKAGE_SHA256/" "$ROOT/plugin/urbm.plg" > "$DIST/urbm.plg"
sed -e "s/<!ENTITY version \"$PLUGIN_VERSION\">/<!ENTITY version \"$VERSION\">/" -e "s/REPLACE_DURING_RELEASE/$PACKAGE_SHA256/" "$ROOT/plugin/urbm.plg" > "$DIST/urbm.plg"
echo "Built $DIST/urbm-${VERSION}-x86_64-1.txz"
+2 -2
View File
@@ -12,7 +12,7 @@ fi
mkdir -p "$CONFIG" "$ROOT_PREFIX/run/urbm" "$ROOT_PREFIX/var/lib/urbm"
if [ -d "$OLD_CONFIG" ]; then
for item in config.json runs.json secrets; do
for item in config.json runs.json backup-metrics.json backup-metrics.jsonl secrets; do
if [ -e "$OLD_CONFIG/$item" ] && [ ! -e "$CONFIG/$item" ]; then
cp -a "$OLD_CONFIG/$item" "$CONFIG/$item"
fi
@@ -48,5 +48,5 @@ rm -f \
"$ROOT_PREFIX/var/log/$OLD_NAME.log"
if [ -x "$ROOT_PREFIX/etc/rc.d/rc.urbm" ]; then
"$ROOT_PREFIX/etc/rc.d/rc.urbm" restart || true
"$ROOT_PREFIX/etc/rc.d/rc.urbm" restart
fi
+35 -14
View File
@@ -4,43 +4,64 @@ DAEMON=/usr/local/sbin/urbmd
PIDFILE=/run/urbm/urbm.pid
LOGFILE=/var/log/urbm.log
is_daemon_pid() {
PID=${1:-}
[ -n "$PID" ] || return 1
EXE=$(readlink "/proc/$PID/exe" 2>/dev/null)
[ "$EXE" = "$DAEMON" ] || [ "$EXE" = "$DAEMON (deleted)" ]
}
start() {
mkdir -p /run/urbm /var/lib/urbm /boot/config/plugins/urbm
chmod 0750 /run/urbm /var/lib/urbm
chmod 0700 /boot/config/plugins/urbm
if [ -s "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
if [ -s "$PIDFILE" ] && is_daemon_pid "$(cat "$PIDFILE")"; then
return 0
fi
rm -f "$PIDFILE" /run/urbm/urbm.sock
nohup "$DAEMON" >>"$LOGFILE" 2>&1 &
echo $! >"$PIDFILE"
for _ in $(seq 1 50); do
[ -S /run/urbm/urbm.sock ] && return 0
is_daemon_pid "$(cat "$PIDFILE" 2>/dev/null)" || break
sleep 0.1
done
rm -f "$PIDFILE"
echo "urbmd failed to create its API socket" >&2
return 1
}
stop() {
STOPPED=0
if [ -s "$PIDFILE" ]; then
PID=$(cat "$PIDFILE")
kill "$PID" 2>/dev/null || true
for _ in $(seq 1 30); do
kill -0 "$PID" 2>/dev/null || break
sleep 1
done
kill -9 "$PID" 2>/dev/null || true
if is_daemon_pid "$PID"; then
kill "$PID" 2>/dev/null || true
for _ in $(seq 1 300); do
is_daemon_pid "$PID" || break
sleep 1
done
if is_daemon_pid "$PID"; then
echo "urbmd is still recovering workloads; refusing to force-kill it" >&2
return 1
fi
STOPPED=1
fi
rm -f "$PIDFILE"
STOPPED=1
fi
if [ "$STOPPED" -eq 0 ]; then
PIDS=$(pidof urbmd 2>/dev/null || true)
for PID in $PIDS; do
kill "$PID" 2>/dev/null || true
done
for _ in $(seq 1 30); do
for _ in $(seq 1 300); do
[ -z "$(pidof urbmd 2>/dev/null)" ] && break
sleep 1
done
for PID in $(pidof urbmd 2>/dev/null); do
kill -9 "$PID" 2>/dev/null || true
done
if [ -n "$(pidof urbmd 2>/dev/null)" ]; then
echo "urbmd is still recovering workloads; stop did not complete" >&2
return 1
fi
fi
rm -f /run/urbm/urbm.sock
}
@@ -48,9 +69,9 @@ stop() {
case "${1:-}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
restart) stop && start ;;
status)
if [ -s "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then echo "urbmd is running"; else echo "urbmd is stopped"; exit 1; fi
if [ -s "$PIDFILE" ] && is_daemon_pid "$(cat "$PIDFILE")"; then echo "urbmd is running"; else echo "urbmd is stopped"; exit 1; fi
;;
*) echo "Usage: $0 {start|stop|restart|status}"; exit 2 ;;
esac
+122 -2
View File
@@ -2,13 +2,133 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "urbm">
<!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.22.r002">
<!ENTITY version "2026.09.28.r001">
<!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 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">
<CHANGES>
### 2026.09.28.r001
- Label job automation separately from run results: manual, enabled or disabled instead of misleading success/cancelled badges.
- Display manual and disabled jobs neutrally and explain scheduling state in tooltips.
### 2026.09.27.r003
- Close action menus synchronously before opening edit forms or running menu actions.
- Prevent delayed menu positioning from reopening closed or detached menus.
### 2026.09.27.r002
- Collapse run logs by default and retain manually opened logs during refresh.
- Log Rsync sources, destination, comparison and deletion options, execution phases, sample changes, errors and final totals.
- Clearly distinguish dry-run plans from actual copies and include deleted entries in logs and notifications.
- Parse exact byte statistics for progress, summaries and free-space checks; drain process output before closing pipes.
- Test dry runs, copies and deletions with real Rsync fixtures as well as regression tests.
### 2026.09.27.r001
- Discover mounted array disks as selectable Rsync sources, including complete disks.
- Show directory symlinks only when they resolve within allowed storage roots.
- Refuse Rsync disk jobs when the array source or USB destination is not mounted.
- Document array-to-USB mirroring and cover disk discovery, mount checks and safe browsing with regression tests.
### 2026.09.21.r001
- Notify on successful and failed repository checks; complete notification attempts before finishing a task.
- Preserve stopped Docker/VM workloads and refuse ambiguous paused or restarting states.
- Recover each previously running workload with its own timeout, including cancellation and partial preparation failures.
- Include named Docker volumes and reject persistent mounts without a usable source path.
- Avoid force-killing the daemon during workload recovery and prevent overlapping daemon restarts.
- Add regression coverage for check notifications, volume discovery, state preservation and recovery failures.
### 2026.07.13.r012
- Draw a separate color-coded line and legend entry for every backup job when the dashboard filter is set to all jobs.
- Base dashboard health on successful backups, failed backup attempts, active work, and timezone-aware overdue schedules instead of unrelated maintenance or restore successes.
- Keep the WebGUI available when metrics, activities, health, or dashboard-status requests fail independently, with localized errors and retry actions.
- Quarantine malformed run and metric state files with a timestamped `.corrupt-*` suffix before creating clean replacements, and log the recovery path.
### 2026.07.13.r011
- Use durable backup measurements when deciding whether a scheduled job needs a catch-up run, even after activity history was cleared.
- Append one compact JSON-lines record per completed backup instead of rewriting the full metric history for every queue state change.
- Migrate the existing `backup-metrics.json` automatically to `backup-metrics.jsonl` and compact in batches after the file exceeds 5,500 records while retaining the newest 5,000.
### 2026.07.13.r010
- Return an empty JSON array instead of `null` when no dashboard backup measurements exist yet.
- Make the WebGUI tolerate empty or invalid metric responses so the rest of the interface remains available.
### 2026.07.13.r009
- Store completed-backup chart measurements independently in `backup-metrics.json` so clearing activities no longer erases the dashboard history.
- Migrate existing successful backup runs into the new durable metric history automatically and expose them through a dedicated read-only API.
- Keep up to 5,000 compact measurements and clarify in the delete dialog that dashboard chart data is preserved.
### 2026.07.13.r008
- Remove the nested vertical scrollbar from the snapshot repository browser and use the normal Unraid page scroll.
- Keep lazy folder loading and cap deep tree indentation so long paths remain usable on desktop and mobile.
### 2026.07.13.r007
- Extend both dashboard backup timelines from seven to fourteen days.
- Keep axis labels, empty states, hover deltas, and predecessor comparisons consistent with the longer range.
### 2026.07.13.r006
- Show timestamp, job, absolute value, and signed change from the previous run when hovering or focusing chart points.
- Compare points only with the previous run of the same backup job, including a predecessor just outside the visible seven-day range.
- Avoid rebuilding an idle dashboard every two seconds so chart tooltips remain stable.
### 2026.07.13.r005
- Restore the dashboard backup-size and file-count timelines using completed runs from the last seven days.
- Plot runs at their actual time and add a backup-job selector that defaults to the most recently used job.
- Stop loading current repository totals for charts that are intended to show historical backup runs.
### 2026.07.13.r004
- Render open job and repository action menus in a fixed top-level overlay so Unraid table and card layers cannot clip them.
- Position the overlay against the triggering button and constrain it to the actually available viewport space.
### 2026.07.13.r003
- Keep repository and job action menus above following table rows and cards.
- Automatically open long action menus upward when the available viewport space below is insufficient.
### 2026.07.13.r002
- Redesign the dashboard around actionable health, active work, recent failures, and the latest successful backup.
- Add searchable responsive job and repository lists with compact secondary-action menus.
- Structure backup and restore forms more clearly and keep their primary actions visible while scrolling.
- Replace browser confirmations with accessible in-app dialogs and typed confirmation for emergency operations.
### 2026.07.13.r001
- Harden restore task validation and make confirmed in-place restores target original paths safely.
- Serialize repository and managed-mount access, block unsafe force-unlock operations, and wait for task cleanup during daemon shutdown.
- Bound snapshot browser memory, stream Restic diffs, continuously drain command output, and reject symlink-overlapping Rsync paths.
- Activate persistent run logs and align package, WebGUI, and manifest versions during builds.
### 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
- 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.
### 2026.07.10.r005
- Log snapshot file-listing failures from the daemon and PHP bridge to `/var/log/urbm.log` so they appear in the Protokolle tab.
- Increase the daemon HTTP write timeout for large snapshot trees and return concrete backend errors instead of generic 500 responses where possible.
### 2026.07.10.r004
- Add a dedicated Protokolle tab showing the daemon log tail and recent saved run logs.
- Add a read-only `/v1/logs` API endpoint limited to `/var/log/urbm.log` and stored URBM run logs.
### 2026.07.10.r003
- Stream Restic snapshot file listings instead of buffering the full `restic ls --json` output in memory.
- Extend snapshot file-listing timeouts across the daemon and PHP bridge to reduce generic 500 errors on large snapshots.
- Return a clearer timeout error when Restic snapshot listing exceeds the daemon limit.
### 2026.07.10.r002
- Include retention results in backup notifications after Restic forget runs, including removed and remaining snapshot counts when available.
- Include repository size before, after, and freed space in prune notifications when Restic statistics are available.
### 2026.07.10.r001
- Add a bounded backup change list to the run log by diffing the previous job snapshot against the newly created snapshot.
- Show up to the last 50 new, modified, or removed paths after successful Restic backups without failing the backup if diff collection is unavailable.
### 2026.06.22.r003
- Send Unraid and Gotify notifications after scheduled or manual repository prune tasks finish.
- Use task-specific notification summary headings for backup, rsync, restore, prune, and check messages.
### 2026.06.22.r002
- Preserve page and tree-container scroll positions after expanding deeply nested folders in source, target, settings, repository, and snapshot trees.
- Restore scroll positions after the browser has processed focus/layout changes to prevent jumps back to the root.
@@ -311,7 +431,7 @@ chmod 0700 /boot/config/plugins/&name;
</FILE>
<FILE Run="/bin/bash" Method="remove">
<INLINE>
/etc/rc.d/rc.urbm stop || true
/etc/rc.d/rc.urbm stop || exit 1
removepkg &name;
rm -rf /usr/local/emhttp/plugins/&name; /usr/local/libexec/&name; /usr/local/sbin/urbmd /etc/rc.d/rc.urbm /run/urbm /var/lib/urbm
</INLINE>
+4
View File
@@ -10,6 +10,8 @@ NEW="$ROOT/boot/config/plugins/urbm"
mkdir -p "$OLD/secrets" "$ROOT/var/log/plugins" "$ROOT/tmp/plugins"
printf '%s\n' '{"settings":{"resticPath":"/usr/local/libexec/'"$OLD_NAME"'/restic","restoreRoot":"/mnt/user/'"$OLD_NAME"'-restores","persistentLogDir":"/mnt/user/system/'"$OLD_NAME"'-logs"}}' > "$OLD/config.json"
printf '%s\n' '[]' > "$OLD/runs.json"
printf '%s\n' '[]' > "$OLD/backup-metrics.json"
printf '%s\n' '{"runId":"new-format"}' > "$OLD/backup-metrics.jsonl"
printf '%s\n' 'encrypted' > "$OLD/secrets/secret.json"
touch "$ROOT/boot/config/plugins/$OLD_NAME.plg" "$ROOT/var/log/plugins/$OLD_NAME.plg" "$ROOT/tmp/plugins/$OLD_NAME.plg"
@@ -17,6 +19,8 @@ URBM_ROOT_PREFIX="$ROOT" "$(dirname "$0")/../plugin/doinst.sh"
test -f "$NEW/config.json"
test -f "$NEW/runs.json"
test -f "$NEW/backup-metrics.json"
test -f "$NEW/backup-metrics.jsonl"
test -f "$NEW/secrets/secret.json"
test ! -e "$OLD"
test ! -e "$ROOT/boot/config/plugins/$OLD_NAME.plg"
+26 -4
View File
@@ -8,13 +8,15 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
---
<?php
$pluginRoot = '/plugins/urbm';
$urbmVersion = '2026.09.28.r001';
$urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260622r002">
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header">
<div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260622r002" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.22.r002</span></h1><p>Restic Backup Manager für Unraid</p></div>
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=<?= $urbmAssetVersion ?>" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version"><?= htmlspecialchars($urbmVersion, ENT_QUOTES) ?></span></h1><p>Restic Backup Manager für Unraid</p></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>
</header>
@@ -25,11 +27,31 @@ $pluginRoot = '/plugins/urbm';
<button data-view="snapshots" data-tooltip="Zeigt vorhandene Sicherungsstände und deren Dateien. Beispiel: Snapshot vom gestrigen Backup öffnen.">Snapshots</button>
<button data-view="restore" data-tooltip="Stellt Dateien oder Ordner aus einem Snapshot wieder her. Standardmäßig in ein separates Staging-Verzeichnis.">Wiederherstellen</button>
<button data-view="activity" data-tooltip="Zeigt wartende, laufende und abgeschlossene Backup-, Restore- und Wartungsaufgaben.">Aktivitäten</button>
<button data-view="logs" data-tooltip="Zeigt das URBM-Daemon-Protokoll und gespeicherte Laufprotokolle zur Fehlersuche.">Protokolle</button>
<button data-view="settings" data-tooltip="Globale Einstellungen sowie native Unraid- und Gotify-Benachrichtigungen konfigurieren.">Einstellungen</button>
</nav>
<main id="bu-content" aria-live="polite"></main>
<div id="bu-toast" role="status" aria-live="polite"></div>
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
<div id="bu-menu-portal" class="bu-menu-portal" aria-live="polite"></div>
<div id="bu-dialog" class="bu-dialog" role="dialog" aria-modal="true" aria-labelledby="bu-dialog-title" hidden>
<div class="bu-dialog-backdrop" data-dialog-cancel></div>
<section class="bu-dialog-panel" tabindex="-1">
<div class="bu-dialog-icon" aria-hidden="true">!</div>
<div>
<h2 id="bu-dialog-title"></h2>
<div id="bu-dialog-message" class="bu-dialog-message"></div>
<label id="bu-dialog-confirm-wrap" class="bu-dialog-confirm" hidden>
<span id="bu-dialog-confirm-label"></span>
<input id="bu-dialog-confirm-input" autocomplete="off" spellcheck="false">
</label>
<div class="bu-dialog-actions">
<button id="bu-dialog-cancel" class="bu-button" type="button">Abbrechen</button>
<button id="bu-dialog-accept" class="bu-button danger" type="button">Bestätigen</button>
</div>
</div>
</section>
</div>
</div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.22.r002.js"></script>
<script src="<?= $pluginRoot ?>/assets/urbm-<?= rawurlencode($urbmVersion) ?>.js"></script>
+51 -14
View File
@@ -3,20 +3,37 @@ declare(strict_types=1);
header('Content-Type: application/json');
$socket = '/run/urbm/urbm.sock';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = $_GET['path'] ?? '/v1/health';
function urbm_bridge_log(string $level, string $message, array $context = []): void
{
$entry = array_merge([
'time' => gmdate('c'),
'level' => $level,
'component' => 'webgui',
'message' => $message,
], $context);
@file_put_contents('/var/log/urbm.log', json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
if (!preg_match('#^/v1/[A-Za-z0-9_./?=&%:-]*$#', $path) || str_contains($path, '..')) {
http_response_code(400);
echo json_encode(['error' => 'invalid API path']);
function urbm_json_error(int $status, string $message): void
{
http_response_code($status);
echo json_encode(['error' => $message], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
$socket = '/run/urbm/urbm.sock';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = $_GET['path'] ?? '/v1/health';
$isSnapshotFileRequest = preg_match('#/snapshots/[^/]+/files(?:\?|$)#', $path) === 1;
if (!preg_match('#^/v1/[A-Za-z0-9_./?=&%:-]*$#', $path) || str_contains($path, '..')) {
urbm_bridge_log('warn', 'invalid API path', ['path' => $path]);
urbm_json_error(400, 'invalid API path');
}
if (!file_exists($socket)) {
http_response_code(503);
echo json_encode(['error' => 'Der URBM-Daemon läuft nicht']);
exit;
urbm_bridge_log('warn', 'daemon socket missing', ['path' => $path, 'socket' => $socket]);
urbm_json_error(503, 'Der URBM-Daemon läuft nicht');
}
$curl = curl_init();
@@ -26,7 +43,7 @@ curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 310,
CURLOPT_TIMEOUT => $isSnapshotFileRequest ? 1250 : 310,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
@@ -41,10 +58,30 @@ $error = curl_error($curl);
curl_close($curl);
if ($response === false) {
http_response_code(502);
echo json_encode(['error' => 'Verbindung zum URBM-Daemon fehlgeschlagen: ' . $error]);
exit;
$message = 'Verbindung zum URBM-Daemon fehlgeschlagen: ' . $error;
urbm_bridge_log('warn', 'daemon request failed', ['path' => $path, 'status' => 502, 'error' => $message]);
urbm_json_error(502, $message);
}
http_response_code($status > 0 ? $status : 502);
if ($status <= 0) {
$message = 'URBM-Daemon lieferte keine HTTP-Antwort';
urbm_bridge_log('warn', 'daemon response missing', ['path' => $path, 'status' => 502, 'error' => $message]);
urbm_json_error(502, $message);
}
if ($status >= 400) {
$decoded = json_decode((string)$response, true);
if (is_array($decoded) && isset($decoded['error']) && is_string($decoded['error'])) {
$message = $decoded['error'];
} else {
$message = trim((string)$response);
}
if ($message === '') {
$message = 'URBM-Daemon lieferte HTTP ' . $status . ' ohne Fehlertext';
}
urbm_bridge_log('warn', 'api request failed', ['path' => $path, 'status' => $status, 'error' => $message]);
urbm_json_error($status, $message);
}
http_response_code($status);
echo $response;
+111 -9
View File
@@ -11,6 +11,7 @@
--bu-accent-hover: var(--orange-400, #c94a17);
--bu-good: #138a62;
--bu-bad: #c93642;
--bu-warn: #d46a16;
--bu-focus: rgba(232, 93, 36, .25);
background: var(--bu-page);
color: var(--bu-text) !important;
@@ -20,13 +21,14 @@
padding: 10px 18px 40px;
}
.bu-header { align-items: center; display: flex; justify-content: space-between; margin: 6px 0 22px; }
.bu-header { align-items: center; display: flex; justify-content: space-between; margin: 6px 0 22px; padding: 8px 2px; }
.bu-brand { align-items: center; display: flex; gap: 14px; min-width: 0; }
.bu-logo { display: block; flex: 0 0 auto; height: 58px; object-fit: contain; width: 58px; }
#urbm-app .bu-header h1 { color: var(--bu-text) !important; font-size: 30px; font-weight: 700; line-height: 1.2; margin: 0; }
.bu-version { border: 1px solid var(--bu-border); border-radius: 999px; display: inline-block; font-size: 12px; font-weight: 600; padding: 2px 7px; vertical-align: middle; }
#urbm-app .bu-header p { color: var(--bu-muted) !important; font-size: 16px; margin: 5px 0 0; opacity: .78; }
.bu-health { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 999px; color: var(--bu-text); font-weight: 600; padding: 9px 15px; }
.bu-health { align-items: center; background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 999px; color: var(--bu-text); display: inline-flex; font-weight: 600; gap: 8px; padding: 9px 15px; }
.bu-health::before { background: currentColor; border-radius: 50%; box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 12%, transparent); content: ''; height: 8px; width: 8px; }
.bu-health.ok { color: var(--bu-good); }
.bu-health.bad { color: var(--bu-bad); }
@@ -57,21 +59,50 @@
.bu-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
.bu-chart-grid-layout { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 16px; }
.bu-card { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.12); color: var(--bu-text); padding: 18px; }
.bu-chart-toolbar { align-items: end; display: flex; gap: 18px; justify-content: space-between; margin-top: 26px; }
.bu-chart-toolbar h2 { margin: 0; }
.bu-chart-toolbar p { margin: 4px 0 0; }
.bu-chart-toolbar label { display: grid; font-size: 13px; font-weight: 700; gap: 6px; min-width: min(340px, 100%); }
.bu-card { background: var(--bu-panel); border: 1px solid color-mix(in srgb, var(--bu-border) 78%, transparent); border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,.08); color: var(--bu-text); padding: 20px; }
#urbm-app .bu-card h2, #urbm-app .bu-card h3 { color: var(--bu-text) !important; font-weight: 700; margin-top: 0; }
.bu-stat { color: var(--bu-text); font-size: 32px; font-weight: 700; }
.bu-section-gap { margin-top: 16px; }
.bu-eyebrow { color: var(--bu-accent); display: block; font-size: 12px; font-weight: 800; letter-spacing: .11em; margin-bottom: 4px; text-transform: uppercase; }
.bu-overview-hero { align-items: center; background: linear-gradient(115deg, color-mix(in srgb, var(--bu-good) 11%, var(--bu-panel)), var(--bu-panel)); border: 1px solid color-mix(in srgb, var(--bu-good) 55%, var(--bu-border)); border-radius: 12px; display: grid; gap: 16px; grid-template-columns: auto minmax(0, 1fr) auto; margin-bottom: 16px; overflow: hidden; padding: 22px 24px; position: relative; }
.bu-overview-hero.active { background: linear-gradient(115deg, color-mix(in srgb, var(--bu-accent) 12%, var(--bu-panel)), var(--bu-panel)); border-color: color-mix(in srgb, var(--bu-accent) 60%, var(--bu-border)); }
.bu-overview-hero.bad { background: linear-gradient(115deg, color-mix(in srgb, var(--bu-bad) 11%, var(--bu-panel)), var(--bu-panel)); border-color: color-mix(in srgb, var(--bu-bad) 58%, var(--bu-border)); }
.bu-overview-hero.neutral { background: linear-gradient(115deg, color-mix(in srgb, var(--bu-border) 12%, var(--bu-panel)), var(--bu-panel)); }
.bu-overview-icon { align-items: center; background: var(--bu-good); border-radius: 50%; color: #fff; display: flex; font-size: 22px; font-weight: 800; height: 48px; justify-content: center; width: 48px; }
.bu-overview-hero.active .bu-overview-icon { background: var(--bu-accent); }
.bu-overview-hero.bad .bu-overview-icon { background: var(--bu-bad); }
.bu-overview-hero.neutral .bu-overview-icon { background: var(--bu-border-strong); }
#urbm-app .bu-overview-copy h2 { color: var(--bu-text) !important; font-size: 22px; margin: 0; }
.bu-overview-copy p { color: var(--bu-muted); margin: 4px 0 0; }
.bu-stat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.bu-stat-card { align-items: center; display: flex; justify-content: space-between; min-height: 88px; }
.bu-stat-icon { align-items: center; background: var(--bu-panel-alt); border-radius: 9px; color: var(--bu-muted); display: flex; font-size: 20px; height: 42px; justify-content: center; width: 42px; }
.bu-stat-card.has-alert .bu-stat, .bu-stat-card.has-alert .bu-stat-icon { color: var(--bu-bad); }
.bu-chart { display: block; height: 240px; margin-top: 12px; overflow: visible; width: 100%; }
.bu-chart-grid line { stroke: var(--bu-border); stroke-width: 1; }
.bu-chart-grid text, .bu-chart-date { fill: var(--bu-muted); font-size: 11px; }
.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-dots circle { fill: var(--bu-panel); stroke: var(--bu-accent); stroke-width: 3; }
.bu-chart-area { fill: var(--series-color, var(--bu-accent)); opacity: .10; }
.bu-chart-line { fill: none; stroke: var(--series-color, var(--bu-accent)); stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; }
.bu-chart-dots circle { cursor: pointer; fill: var(--bu-panel); stroke: var(--series-color, var(--bu-accent)); stroke-width: 3; transition: fill .15s ease, stroke-width .15s ease; }
.bu-chart-dots circle:hover, .bu-chart-dots circle:focus { fill: var(--series-color, var(--bu-accent)); outline: none; stroke-width: 5; }
.bu-chart-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 2px 8px 8px 54px; }
.bu-chart-legend span { align-items: center; color: var(--bu-muted); display: inline-flex; font-size: 12px; font-weight: 700; gap: 6px; }
.bu-chart-legend i { background: var(--series-color); border-radius: 50%; display: inline-block; height: 9px; width: 9px; }
.bu-chart-wrap .bu-chart { margin-bottom: 0; }
.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-error > div { align-items: center; display: flex; flex-direction: column; gap: 9px; max-width: 440px; }
.bu-chart-error strong { color: var(--bu-text); }
.bu-chart-error span { display: block; }
#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-actions { display: flex; flex-wrap: wrap; gap: 7px; }
.bu-row-actions { align-items: center; display: flex; gap: 7px; justify-content: flex-end; }
.bu-table-actions { align-items: flex-start; min-width: 310px; }
.bu-table-actions .bu-button { flex: 0 0 auto; }
.bu-warning { color: var(--bu-bad) !important; display: block; font-size: 13px; font-weight: 700; margin-top: 5px; }
@@ -85,6 +116,8 @@
.bu-partial-label { border: 1px solid var(--bu-accent); border-radius: 999px; color: var(--bu-text); display: inline-block; font-size: 11px; font-weight: 700; margin-left: 8px; padding: 2px 7px; vertical-align: middle; }
.bu-inherited-label { border: 1px solid var(--bu-good); border-radius: 999px; color: var(--bu-good); display: inline-block; font-size: 11px; font-weight: 700; margin-left: 8px; padding: 2px 7px; vertical-align: middle; }
.bu-tree { background: var(--bu-input); border: 1px solid var(--bu-border); border-radius: 6px; max-height: 520px; overflow: auto; padding: 6px; }
.bu-snapshot-tree { max-height: none; overflow: visible; }
.bu-snapshot-tree .bu-tree-row { margin-left: min(calc(var(--bu-tree-depth) * 24px), 320px); }
.bu-tree-node { min-width: 0; }
.bu-tree-node + .bu-tree-node { border-top: 1px solid color-mix(in srgb, var(--bu-border) 45%, transparent); }
.bu-tree-row { align-items: center; border-radius: 6px; display: grid; gap: 14px; grid-template-columns: 34px 28px minmax(0, 1fr); margin-left: calc(var(--bu-tree-depth) * 24px); min-height: 48px; padding: 5px 8px; }
@@ -112,8 +145,17 @@
.bu-workload-options { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); margin-top: 15px; }
.bu-manual-source { display: grid; gap: 7px; margin-top: 14px; }
.bu-info-card { background: var(--bu-panel-alt); border: 1px solid var(--bu-border); border-radius: 6px; padding: 16px; }
.bu-fieldset { border: 1px solid var(--bu-border); border-radius: 6px; padding: 14px; }
.bu-fieldset { border: 1px solid color-mix(in srgb, var(--bu-border) 78%, transparent); border-radius: 8px; padding: 16px; }
.bu-fieldset legend { color: var(--bu-text); font-size: 17px; font-weight: 700; padding: 0 7px; }
.bu-danger-zone { background: color-mix(in srgb, var(--bu-bad) 5%, var(--bu-panel)); border-color: color-mix(in srgb, var(--bu-bad) 55%, var(--bu-border)); }
.bu-form-heading { border-bottom: 1px solid var(--bu-border); margin: -2px 0 20px; padding-bottom: 15px; }
.bu-form-heading h2 { margin-bottom: 4px; }
.bu-form-heading p { margin: 0; }
.bu-form-section { border: 1px solid var(--bu-border); border-radius: 8px; padding: 0; }
.bu-form-section summary { cursor: pointer; font-size: 16px; font-weight: 700; padding: 14px 16px; }
.bu-form-section[open] summary { border-bottom: 1px solid var(--bu-border); }
.bu-form-section > :not(summary) { margin-left: 16px; margin-right: 16px; }
.bu-form-actions { align-items: center; background: color-mix(in srgb, var(--bu-panel) 92%, transparent); border-top: 1px solid var(--bu-border); bottom: 0; justify-content: flex-end; margin: 4px -20px -20px; padding: 14px 20px; position: sticky; z-index: 5; }
.bu-retention-grid { display: grid; gap: 15px; grid-template-columns: repeat(4, minmax(150px, 1fr)); }
.bu-schedule-grid { display: grid; gap: 15px; grid-template-columns: minmax(240px, 2fr) minmax(150px, 1fr); }
.bu-weekdays { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
@@ -138,6 +180,7 @@
.bu-live-log { background: #11161a; border: 1px solid var(--bu-border); border-radius: 6px; color: #e8edf1; margin: 2px 0 8px; padding: 9px 12px; }
.bu-live-log summary { cursor: pointer; font-weight: 700; }
.bu-live-log pre { color: #e8edf1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; margin: 10px 0 0; max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
.bu-log-pre { background: #11161a; border: 1px solid var(--bu-border); border-radius: 6px; color: #e8edf1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; margin: 10px 0 0; max-height: 520px; overflow: auto; padding: 12px; white-space: pre-wrap; word-break: break-word; }
@keyframes bu-progress-scan { from { transform: translateX(-20%); } to { transform: translateX(250%); } }
.bu-form-grid { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); }
.bu-managed-mount, .bu-external-mount { margin-top: 14px; }
@@ -167,10 +210,26 @@
#urbm-app .bu-button.primary:hover { background: var(--bu-accent-hover) !important; }
#urbm-app .bu-button.danger { color: var(--bu-bad) !important; }
#urbm-app .bu-button:disabled { cursor: wait; opacity: .55; }
.bu-action-menu { position: relative; }
.bu-action-menu[open] { z-index: 80; }
.bu-table tr.bu-menu-open, .bu-table tr.bu-menu-open > td { overflow: visible; position: relative; z-index: 70; }
#urbm-app .bu-action-menu > summary { align-items: center; display: inline-flex; gap: 8px; list-style: none; }
.bu-action-menu > summary::-webkit-details-marker { display: none; }
.bu-action-menu-popover { background: var(--bu-panel); border: 1px solid var(--bu-border-strong); border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,.22); display: grid; max-height: min(430px, calc(100vh - 24px)); min-width: 220px; overflow-y: auto; padding: 6px; position: absolute; right: 0; top: calc(100% + 6px); z-index: 90; }
.bu-menu-portal { inset: 0; pointer-events: none; position: fixed; z-index: 100000; }
.bu-menu-portal .bu-action-menu-popover.portaled { pointer-events: auto; position: fixed; z-index: 100001; }
#urbm-app .bu-action-menu-popover .bu-button { background: transparent !important; border-color: transparent !important; justify-content: flex-start; text-align: left; width: 100%; }
#urbm-app .bu-action-menu-popover .bu-button:hover { background: var(--bu-panel-alt) !important; border-color: transparent !important; }
#urbm-app .bu-action-menu-popover .bu-button.danger { border-top: 1px solid var(--bu-border) !important; border-radius: 0 !important; margin-top: 4px !important; }
.bu-filter { align-items: center; background: var(--bu-input); border: 1px solid var(--bu-border-strong); border-radius: 6px; display: flex; gap: 7px; min-width: 230px; padding: 0 10px; }
#urbm-app .bu-filter input { background: transparent !important; border: 0 !important; box-shadow: none !important; color: var(--bu-text) !important; min-height: 38px; min-width: 0; outline: 0; padding: 7px 0 !important; width: 100%; }
.bu-table { border-collapse: collapse; color: var(--bu-text); width: 100%; }
.bu-table th, .bu-table td { border-bottom: 1px solid var(--bu-border); color: var(--bu-text); padding: 12px 8px; text-align: left; vertical-align: top; }
.bu-table th { color: var(--bu-text); font-size: 13px; font-weight: 700; letter-spacing: .03em; opacity: .76; text-transform: uppercase; }
.bu-table tbody tr { transition: background-color .15s ease; }
.bu-table tbody tr:not(.bu-log-row):hover { background: color-mix(in srgb, var(--bu-accent) 4%, transparent); }
.bu-path-cell { max-width: 420px; overflow-wrap: anywhere; }
.bu-table-scroll { max-width: 100%; overflow-x: auto; }
.bu-snapshot-table { table-layout: fixed; }
.bu-snapshot-id { width: 90px; }
@@ -194,7 +253,8 @@
.bu-runs-table .bu-run-task-cell span { color: var(--bu-muted); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bu-runs-table .bu-status { white-space: nowrap; }
.bu-status { border: 1px solid currentColor; border-radius: 999px; display: inline-block; font-size: 12px; font-weight: 700; padding: 3px 8px; text-transform: uppercase; }
.bu-status.success { background: rgba(8,122,85,.10); color: var(--bu-good); }
.bu-status.automation-disabled { background: transparent; color: var(--bu-muted); }
.bu-status.automation-enabled, .bu-status.success { background: rgba(8,122,85,.10); color: var(--bu-good); }
.bu-status.failed, .bu-status.cancelled { background: rgba(180,35,47,.10); color: var(--bu-bad); }
.bu-status.running, .bu-status.queued, .bu-status.paused, .bu-status.warning { background: rgba(232,93,36,.10); color: var(--bu-accent); }
@@ -229,6 +289,20 @@
#bu-toast.show { display: block; }
#bu-toast.error { border-color: var(--bu-bad); color: var(--bu-bad); }
.bu-dialog[hidden] { display: none; }
.bu-dialog { align-items: center; display: flex; inset: 0; justify-content: center; padding: 20px; position: fixed; z-index: 100001; }
.bu-dialog-backdrop { background: rgba(10,14,18,.62); inset: 0; position: absolute; }
.bu-dialog-panel { background: var(--bu-panel); border: 1px solid var(--bu-border-strong); border-radius: 12px; box-shadow: 0 24px 70px rgba(0,0,0,.38); display: grid; gap: 16px; grid-template-columns: auto minmax(0,1fr); max-width: 560px; padding: 24px; position: relative; width: 100%; }
.bu-dialog-icon { align-items: center; background: color-mix(in srgb, var(--bu-bad) 13%, transparent); border-radius: 50%; color: var(--bu-bad); display: flex; font-size: 22px; font-weight: 800; height: 46px; justify-content: center; width: 46px; }
.bu-dialog-icon.safe { background: color-mix(in srgb, var(--bu-good) 13%, transparent); color: var(--bu-good); }
#urbm-app .bu-dialog-panel h2 { color: var(--bu-text) !important; font-size: 21px; margin: 0 0 8px; }
.bu-dialog-message { color: var(--bu-muted); line-height: 1.55; white-space: pre-line; }
.bu-dialog-confirm { display: grid; font-weight: 700; gap: 7px; margin-top: 16px; }
.bu-dialog-confirm[hidden] { display: none; }
#urbm-app .bu-dialog-confirm input { background: var(--bu-input) !important; border: 1px solid var(--bu-border-strong) !important; border-radius: 5px !important; color: var(--bu-text) !important; font: inherit; min-height: 40px; padding: 9px 10px !important; }
.bu-dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
body.bu-dialog-open { overflow: hidden; }
#urbm-app [data-tooltip] { cursor: help; }
#urbm-app button[data-tooltip],
#urbm-app input[data-tooltip],
@@ -268,6 +342,22 @@
top: -12px;
}
@media (max-width: 1000px) {
.bu-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.bu-chart-grid-layout { grid-template-columns: 1fr; }
}
@media (max-width: 820px) {
.bu-responsive-table thead { display: none; }
.bu-responsive-table, .bu-responsive-table tbody, .bu-responsive-table tr, .bu-responsive-table td { display: block; width: 100%; }
.bu-responsive-table tr { border: 1px solid var(--bu-border); border-radius: 8px; margin-bottom: 12px; padding: 6px 12px; }
.bu-responsive-table td { align-items: start; border-bottom: 1px solid color-mix(in srgb, var(--bu-border) 55%, transparent); display: grid; gap: 12px; grid-template-columns: minmax(105px, 32%) minmax(0, 1fr); padding: 10px 0; }
.bu-responsive-table td::before { color: var(--bu-muted); content: attr(data-label); font-size: 12px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
.bu-responsive-table td:last-child { border-bottom: 0; }
.bu-row-actions { justify-content: flex-start; }
.bu-action-menu-popover { left: 0; right: auto; }
}
@media (max-width: 700px) {
#urbm-app { padding-left: 8px; padding-right: 8px; }
.bu-form { grid-template-columns: 1fr; }
@@ -275,11 +365,23 @@
.bu-retention-grid { grid-template-columns: 1fr; }
.bu-schedule-grid { grid-template-columns: 1fr; }
.bu-form-grid { grid-template-columns: 1fr; }
.bu-chart-grid-layout { grid-template-columns: 1fr; }
.bu-stat-grid { grid-template-columns: 1fr 1fr; }
.bu-progress-label { grid-template-columns: 1fr; }
.bu-progress-label span { text-align: left; }
.bu-header { align-items: flex-start; flex-direction: column; gap: 12px; }
.bu-overview-hero { align-items: start; grid-template-columns: auto minmax(0,1fr); padding: 18px; }
.bu-overview-action { grid-column: 1 / -1; width: 100%; }
.bu-overview-action .bu-button { width: 100%; }
.bu-chart-toolbar { align-items: stretch; flex-direction: column; }
.bu-chart-toolbar label { min-width: 0; width: 100%; }
.bu-toolbar { align-items: stretch; }
.bu-toolbar > .bu-actions { width: 100%; }
.bu-filter { flex: 1 1 100%; }
.bu-table { display: block; overflow-x: auto; }
.bu-table-actions { min-width: 240px; }
#bu-tooltip { font-size: 13px; }
.bu-dialog-panel { grid-template-columns: 1fr; padding: 20px; }
.bu-dialog-actions { flex-direction: column-reverse; }
.bu-dialog-actions .bu-button { width: 100%; }
.bu-snapshot-tree .bu-tree-row { margin-left: min(calc(var(--bu-tree-depth) * 18px), 110px); }
}
+341 -116
View File
@@ -6,9 +6,11 @@
const health = document.getElementById('bu-health');
const toast = document.getElementById('bu-toast');
const tooltip = document.getElementById('bu-tooltip');
const menuPortal = document.getElementById('bu-menu-portal');
const dialog = document.getElementById('bu-dialog');
const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path}));
const repositoryRoots = ['/mnt/user','/mnt/disks','/mnt/remotes'].map(path=>({name:path,path}));
const state = { config: null, runs: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], 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: [], runsError: '', backupMetrics: [], backupMetricsError: '', dashboardStatus: null, dashboardStatusError: '', logs: null, logsLoading: false, logsError: '', dashboardJobId: '', 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 = {
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
@@ -72,6 +74,7 @@
'submit-unraid-notify': 'Speichert native Unraid-Benachrichtigungen. Die Zustellung erfolgt über die in Unraid eingerichteten Notification Agents.',
'submit-gotify-notify': 'Speichert den Gotify-Kanal und das App-Token verschlüsselt.',
'refresh': 'Lädt Laufstatus und Historie erneut vom URBM-Daemon.',
'refresh-logs': 'Lädt Daemon-Protokoll und gespeicherte Laufprotokolle erneut.',
'clear-activity': 'Löscht alle abgeschlossenen Aktivitätseinträge und deren gespeicherte Laufprotokolle. Laufende und wartende Aufgaben bleiben erhalten.',
'cancel-form': 'Verwirft ungespeicherte Eingaben und kehrt zur Übersicht zurück.',
'toggle-source-tree': 'Klappt die direkten Unterordner dieses Ordners auf oder zu. Inhalte werden erst beim ersten Öffnen geladen.',
@@ -121,6 +124,53 @@
toast.textContent = message; toast.className = `show${error ? ' error' : ''}`;
clearTimeout(notify.timer); notify.timer = setTimeout(() => toast.className = '', 4500);
};
function askConfirm({title='Aktion bestätigen', message='', accept='Bestätigen', danger=true, phrase=''}) {
if (!dialog) return Promise.resolve(window.confirm(`${title}\n\n${message}`));
const titleNode=dialog.querySelector('#bu-dialog-title');
const messageNode=dialog.querySelector('#bu-dialog-message');
const acceptButton=dialog.querySelector('#bu-dialog-accept');
const dialogIcon=dialog.querySelector('.bu-dialog-icon');
const cancelButton=dialog.querySelector('#bu-dialog-cancel');
const confirmWrap=dialog.querySelector('#bu-dialog-confirm-wrap');
const confirmLabel=dialog.querySelector('#bu-dialog-confirm-label');
const confirmInput=dialog.querySelector('#bu-dialog-confirm-input');
titleNode.textContent=title;
messageNode.textContent=message;
acceptButton.textContent=accept;
acceptButton.classList.toggle('danger',danger);
acceptButton.classList.toggle('primary',!danger);
dialogIcon.textContent=danger?'!':'✓';
dialogIcon.classList.toggle('safe',!danger);
confirmWrap.hidden=!phrase;
confirmLabel.textContent=phrase?`Zur Bestätigung „${phrase}“ eingeben.`:'';
confirmInput.value='';
acceptButton.disabled=Boolean(phrase);
dialog.hidden=false;
document.body.classList.add('bu-dialog-open');
requestAnimationFrame(()=>(phrase?confirmInput:cancelButton).focus());
return new Promise(resolve=>{
const finish=value=>{
dialog.hidden=true;
document.body.classList.remove('bu-dialog-open');
dialog.removeEventListener('click',onClick);
dialog.removeEventListener('keydown',onKey);
confirmInput.removeEventListener('input',onInput);
resolve(value);
};
const onInput=()=>{ acceptButton.disabled=Boolean(phrase)&&confirmInput.value.trim()!==phrase; };
const onClick=event=>{
if(event.target.closest('#bu-dialog-accept')) finish(true);
else if(event.target.closest('#bu-dialog-cancel')||event.target.matches('[data-dialog-cancel]')) finish(false);
};
const onKey=event=>{
if(event.key==='Escape') finish(false);
if(event.key==='Enter'&&!acceptButton.disabled&&event.target!==cancelButton) finish(true);
};
dialog.addEventListener('click',onClick);
dialog.addEventListener('keydown',onKey);
confirmInput.addEventListener('input',onInput);
});
}
const uuid = () => {
if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID();
const bytes = new Uint8Array(16);
@@ -180,8 +230,12 @@
const runTarget = run => {
if(run.taskType==='backup'||run.taskType==='rsync') return state.config.jobs.find(job=>job.id===run.jobId)?.name || 'Unbekannter Job';
if(['check','prune'].includes(run.taskType)) return state.config.repositories.find(repo=>repo.id===run.jobId)?.name || 'Unbekanntes Repository';
if(run.taskType==='restore') {
const parts=String(run.jobId||'').split('\u0000');
if(run.taskType==='restore') {
if(run.restore) {
const repo=state.config.repositories.find(candidate=>candidate.id===run.restore.repositoryId)?.name || 'Repository';
return `${repo} · ${String(run.restore.snapshotId||'').slice(0,12)} → ${run.restore.target||''}`;
}
const parts=String(run.jobId||'').split('\u0000');
if(parts.length>=4) {
const repo=state.config.repositories.find(candidate=>candidate.id===parts[0])?.name || 'Repository';
return `${repo} · ${parts[1].slice(0,12)} → ${parts[2]}`;
@@ -192,6 +246,8 @@
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" type="button" data-action="${esc(action)}"${tipAttr(actionTooltip(action, label))}>${esc(label)}</button>`;
const disabledButton = (label, help) => `<button class="bu-button" type="button" disabled${tipAttr(help)}>${esc(label)}</button>`;
const actionGroup = (buttons) => `<div class="bu-actions bu-table-actions">${buttons}</div>`;
const actionMenu = (label, buttons) => `<details class="bu-action-menu"><summary class="bu-button" aria-label="${esc(label)}">Mehr <span aria-hidden="true">⋮</span></summary><div class="bu-action-menu-popover">${buttons}</div></details>`;
const filterBox = (placeholder, target) => `<label class="bu-filter"><span aria-hidden="true">⌕</span><input type="search" placeholder="${esc(placeholder)}" aria-label="${esc(placeholder)}" data-filter-table="${esc(target)}"></label>`;
const formatBytes = (value) => {
const units = ['B','KiB','MiB','GiB','TiB']; let size = Number(value)||0; let unit = 0;
while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; }
@@ -203,6 +259,13 @@
const minutes=Math.floor(seconds/60); seconds%=60;
return hours?`${hours}h ${minutes}m`:minutes?`${minutes}m ${seconds}s`:`${seconds}s`;
};
const relativeTime = value => {
const time=new Date(value||0).getTime();
if(!time) return 'noch nie';
const seconds=Math.round((Date.now()-time)/1000); const future=seconds<0; const absolute=Math.abs(seconds);
const [amount,unit]=absolute<60?[absolute,'Sek.']:absolute<3600?[Math.round(absolute/60),'Min.']:absolute<86400?[Math.round(absolute/3600),'Std.']:[Math.round(absolute/86400),'Tagen'];
return future?`in ${amount} ${unit}`:`vor ${amount} ${unit}`;
};
const weekDays = [{value:'1',short:'Mo',name:'Montag'},{value:'2',short:'Di',name:'Dienstag'},{value:'3',short:'Mi',name:'Mittwoch'},{value:'4',short:'Do',name:'Donnerstag'},{value:'5',short:'Fr',name:'Freitag'},{value:'6',short:'Sa',name:'Samstag'},{value:'0',short:'So',name:'Sonntag'}];
function parseSchedule(cron = '0 2 * * *') {
const value=String(cron).trim();
@@ -242,29 +305,31 @@
return `<fieldset class="wide bu-fieldset bu-schedule"><legend>Backup-Zeitplan</legend><div class="bu-schedule-grid"><label>Ausführung<select name="scheduleMode">${modes.map(([value,label])=>`<option value="${value}" ${value===schedule.mode?'selected':''}>${label}</option>`).join('')}</select></label><label class="bu-schedule-time" ${schedule.mode==='manual'?'hidden':''}>Uhrzeit<input name="scheduleTime" type="time" required value="${esc(schedule.time)}"></label></div><div class="bu-schedule-manual bu-inline-info" ${schedule.mode==='manual'?'':'hidden'}>Dieser Job wird nicht automatisch gestartet. Er kann jederzeit in der Job-Liste über <strong>Starten</strong> ausgeführt werden.</div><div class="bu-weekdays" ${schedule.mode==='selected'?'':'hidden'}><span>Wochentage</span>${weekDays.map(day=>`<label><input type="checkbox" name="scheduleDays" value="${day.value}" ${schedule.days.includes(day.value)?'checked':''}><span>${day.short}</span></label>`).join('')}</div>${schedule.mode==='custom'?`<label class="bu-custom-cron">Bestehender Zeitplan<input name="customCron" readonly value="${esc(schedule.cron)}"></label>`:''}</fieldset>`;
}
function lineChart(runs, field, formatter, emptyText) {
const points = runs.map(run=>({run,value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)}));
function lineChart(runs, field, formatter, valueLabel, emptyText, days=14) {
const end=Date.now();
const start=end-days*24*60*60*1000;
const colors=['#ff7a2f','#2f8cff','#20a779','#a061d1','#d9a019','#e0516d','#27a9b8','#7d8b32'];
const previousByJob=new Map();
const allPoints=runs.map(run=>({run,time:new Date(run.finishedAt||run.createdAt).getTime(),value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)})).sort((a,b)=>a.time-b.time).map(point=>{
const previous=previousByJob.get(point.run.jobId);
previousByJob.set(point.run.jobId,point);
return {...point,delta:previous?point.value-previous.value:null};
});
const points=allPoints.filter(point=>point.time>=start&&point.time<=end);
if (!points.length || points.every(point=>point.value===0)) return `<div class="bu-chart-empty">${esc(emptyText)}</div>`;
const width=720, height=220, left=54, right=18, top=18, bottom=38, innerW=width-left-right, innerH=height-top-bottom;
const max=Math.max(...points.map(point=>point.value),1);
const coords=points.map((point,index)=>({ ...point, x:left+(points.length===1?innerW/2:index*innerW/(points.length-1)), y:top+innerH-(point.value/max)*innerH }));
const polyline=coords.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');
const area=`${left},${top+innerH} ${polyline} ${left+innerW},${top+innerH}`;
const coords=points.map(point=>({ ...point, x:left+Math.max(0,Math.min(1,(point.time-start)/(end-start)))*innerW, y:top+innerH-(point.value/max)*innerH }));
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 dots=coords.map(point=>{const job=state.config.jobs.find(item=>item.id===point.run.jobId)?.name||point.run.jobId; const date=new Date(point.run.finishedAt||point.run.createdAt).toLocaleString(); return `<circle cx="${point.x}" cy="${point.y}" r="4"><title>${esc(`${date} · ${job} · ${formatter(point.value)}`)}</title></circle>`;}).join('');
const first=new Date(points[0].run.finishedAt||points[0].run.createdAt).toLocaleDateString();
const last=new Date(points.at(-1).run.finishedAt||points.at(-1).run.createdAt).toLocaleDateString();
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>`;
const grouped=new Map();
coords.forEach(point=>{if(!grouped.has(point.run.jobId))grouped.set(point.run.jobId,[]);grouped.get(point.run.jobId).push(point);});
const series=[...grouped.entries()].map(([jobId,seriesPoints],index)=>({jobId,points:seriesPoints,color:colors[index%colors.length],name:state.config.jobs.find(item=>item.id===jobId)?.name||jobId}));
const areas=series.length===1?series.map(item=>{const polyline=item.points.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');const first=item.points[0],last=item.points[item.points.length-1];return `<polygon class="bu-chart-area" style="--series-color:${item.color}" points="${first.x},${top+innerH} ${polyline} ${last.x},${top+innerH}"/>`;}).join(''):'';
const lines=series.map(item=>`<polyline class="bu-chart-line" style="--series-color:${item.color}" points="${item.points.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ')}"/>`).join('');
const dots=series.map(item=>item.points.map(point=>{const date=new Date(point.run.finishedAt||point.run.createdAt).toLocaleString();const delta=point.delta===null?'kein vorheriger Lauf':point.delta===0?'±0':`${point.delta>0?'+':'−'}${formatter(Math.abs(point.delta))}`;const hint=`Zeitpunkt: ${date} · Job: ${item.name} · ${valueLabel}: ${formatter(point.value)} · Veränderung: ${delta}`;return `<circle style="--series-color:${item.color}" cx="${point.x}" cy="${point.y}" r="5" tabindex="0" role="img" aria-label="${esc(hint)}" data-tooltip="${esc(hint)}" data-tooltip-delay="100"><title>${esc(hint)}</title></circle>`;}).join('')).join('');
const dateLabels=[0,1/3,2/3,1].map((ratio,index)=>{const value=new Date(start+(end-start)*ratio).toLocaleDateString(undefined,{day:'2-digit',month:'2-digit'});return `<text class="bu-chart-date" x="${left+innerW*ratio}" y="${height-8}" text-anchor="${index===0?'start':index===3?'end':'middle'}">${esc(value)}</text>`;}).join('');
const legend=series.length>1?`<div class="bu-chart-legend">${series.map(item=>`<span><i style="--series-color:${item.color}"></i>${esc(item.name)}</span>`).join('')}</div>`:'';
return `<div class="bu-chart-wrap"><svg class="bu-chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="Verlauf der letzten ${days} Tage"><g class="bu-chart-grid">${grid}</g>${areas}${lines}<g class="bu-chart-dots">${dots}</g>${dateLabels}</svg>${legend}</div>`;
}
function setTooltip(element, text) {
@@ -341,25 +406,33 @@
async function load() {
try {
const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]);
state.config = config; state.runs = newestRuns(runs);
health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render(); loadRepositoryStats();
const [configResult,runsResult,metricsResult,statusResult,daemonResult,rootsResult] = await Promise.allSettled([api('/v1/config'),api('/v1/runs'),api('/v1/backup-metrics'),api('/v1/dashboard-status'),api('/v1/health'),api('/v1/filesystem/directories')]);
if(configResult.status!=='fulfilled') throw configResult.reason;
state.config=configResult.value;
if(rootsResult.status==='fulfilled' && Array.isArray(rootsResult.value)) sourceRoots.splice(0,sourceRoots.length,...rootsResult.value);
if(runsResult.status==='fulfilled'){state.runs=newestRuns(Array.isArray(runsResult.value)?runsResult.value:[]);state.runsError='';}else state.runsError=runsResult.reason?.message||'Aktivitäten konnten nicht geladen werden.';
if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.';
if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.';
if(daemonResult.status==='fulfilled'){const daemon=daemonResult.value;health.textContent=`Daemon online (${daemon.version||'unbekannt'})`;health.className='bu-health ok';}
else {health.textContent='Daemon teilweise erreichbar';health.className='bu-health bad';}
render();
} catch (error) {
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>`;
}
}
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(); }
async function reloadDashboardData() {
const [metricsResult,statusResult]=await Promise.allSettled([api('/v1/backup-metrics'),api('/v1/dashboard-status')]);
if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.';
if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.';
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, logs:renderLogs, settings:renderSettings};
document.querySelectorAll('.bu-action-menu[open]').forEach(closeActionMenu);
menuPortal?.replaceChildren();
content.innerHTML = views[state.view]();
enhanceTooltips(content);
content.querySelectorAll('.bu-file-select[data-partial="true"]').forEach(box=>{box.indeterminate=true;box.setAttribute('aria-checked','mixed');});
@@ -373,6 +446,7 @@
if(!state.directoryTrees.persistentLogDir) initDirectoryTree('persistentLogDir','Protokollverzeichnis auswählen','#bu-settings-form [name="persistentLogDir"]');
else renderDirectoryTree('persistentLogDir');
}
if(state.view==='logs'&&!state.logs&&!state.logsLoading&&!state.logsError) loadLogs();
}
function captureScrollState(root=document) {
@@ -398,28 +472,62 @@
}
function renderDashboard() {
const active = state.runs.filter(r => ['queued','running','paused'].includes(r.status)).length;
const orderedRuns = newestRuns(state.runs);
const failures = orderedRuns.filter(r => r.status === 'failed').slice(0, 5);
const last = orderedRuns.slice(0, 8);
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.map(item=>`${esc(item.repositoryName)}: ${esc(displayMessage(item.error))}`).join('<br>')}</div>`:'';
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">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">Letzte Fehler</div><div class="bu-stat">${failures.length}</div></section>
</div><div class="bu-chart-grid-layout">
<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>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>`;
const activeRuns = orderedRuns.filter(r => ['queued','running','paused'].includes(r.status));
const active = activeRuns.length;
const failures = orderedRuns.filter(r => r.taskType==='backup'&&r.status === 'failed').slice(0, 5);
const last = orderedRuns.slice(0, 6);
const backupJobs=state.config.jobs.filter(job=>job.type!=='rsync');
const orderedMetrics=[...state.backupMetrics].sort((a,b)=>new Date(b.finishedAt)-new Date(a.finishedAt));
if(!state.dashboardJobId) state.dashboardJobId=orderedMetrics.find(metric=>backupJobs.some(job=>job.id===metric.jobId))?.jobId||backupJobs[0]?.id||'all';
if(state.dashboardJobId!=='all'&&!backupJobs.some(job=>job.id===state.dashboardJobId)) state.dashboardJobId='all';
const backups=orderedMetrics.filter(metric=>state.dashboardJobId==='all'||metric.jobId===state.dashboardJobId);
const chartOptions=`<option value="all" ${state.dashboardJobId==='all'?'selected':''}>Alle Backup-Jobs</option>${backupJobs.map(job=>`<option value="${esc(job.id)}" ${state.dashboardJobId===job.id?'selected':''}>${esc(job.name)}</option>`).join('')}`;
const needsSetup=!state.config.jobs.length||!state.config.repositories.length;
const dashboardStatus=state.dashboardStatus||{};
const lastSuccess=dashboardStatus.lastSuccessfulBackup||orderedMetrics.find(metric=>metric.status==='success');
const lastFailure=dashboardStatus.lastFailedBackup||failures[0];
const successTime=lastSuccess?new Date(lastSuccess.finishedAt).getTime():0;
const failureTime=lastFailure?new Date(lastFailure.finishedAt||lastFailure.createdAt).getTime():0;
const unresolvedFailure=lastFailure&&failureTime>successTime;
const overdueJobs=Array.isArray(dashboardStatus.overdueJobs)?dashboardStatus.overdueJobs:[];
const successJob=lastSuccess?(state.config.jobs.find(job=>job.id===lastSuccess.jobId)?.name||lastSuccess.jobId):'';
const failureJob=lastFailure?(state.config.jobs.find(job=>job.id===lastFailure.jobId)?.name||lastFailure.jobId):'';
const statusUnavailable=!!state.dashboardStatusError&&!state.dashboardStatus;
const noSuccessfulBackup=!lastSuccess&&!needsSetup;
const heroClass=unresolvedFailure?'bad':active?'active':overdueJobs.length||statusUnavailable?'bad':needsSetup||noSuccessfulBackup?'neutral':'good';
const heroTitle=unresolvedFailure?'Letzter Backup-Versuch fehlgeschlagen':active?`${active} ${active===1?'Vorgang ist':'Vorgänge sind'} aktiv`:overdueJobs.length?`${overdueJobs.length} ${overdueJobs.length===1?'Backup-Job ist':'Backup-Jobs sind'} überfällig`:statusUnavailable?'Backup-Status unvollständig':needsSetup?'URBM fertig einrichten':noSuccessfulBackup?'Noch kein erfolgreiches Backup':'Backups sind im grünen Bereich';
const previousFailure=lastFailure&&!unresolvedFailure?` · Letzter Fehler ${relativeTime(lastFailure.finishedAt||lastFailure.createdAt)}`:'';
const heroText=unresolvedFailure?`${failureJob} · ${relativeTime(lastFailure.finishedAt||lastFailure.createdAt)} · ${displayMessage(lastFailure.message)}`:active?`${taskLabels[activeRuns[0].taskType]||activeRuns[0].taskType}: ${runTarget(activeRuns[0])}`:overdueJobs.length?`Fälliger Zeitpunkt überschritten: ${overdueJobs.slice(0,3).map(job=>job.name).join(', ')}${overdueJobs.length>3?' …':''}`:statusUnavailable?state.dashboardStatusError:needsSetup?'Lege mindestens ein Repository und einen Backup-Job an.':lastSuccess?`Letztes erfolgreiches Backup ${relativeTime(lastSuccess.finishedAt)} · ${successJob}${previousFailure}`:'Noch kein erfolgreiches Backup vorhanden.';
const heroAction=statusUnavailable?button('Status erneut laden','retry-dashboard-data','primary'):unresolvedFailure||active?button('Aktivitäten öffnen','go-view:activity','primary'):overdueJobs.length?button('Backup-Jobs öffnen','go-view:jobs','primary'):needsSetup?button('Einrichtung fortsetzen',`go-view:${state.config.repositories.length?'jobs':'repositories'}`,'primary'):button('Backup-Jobs öffnen','go-view:jobs');
const chartError=state.backupMetricsError?`<div class="bu-chart-empty bu-chart-error"><div><strong>Diagrammdaten konnten nicht geladen werden.</strong><span>${esc(state.backupMetricsError)}</span>${button('Erneut versuchen','retry-dashboard-data','primary')}</div></div>`:'';
const activityError=state.runsError?`<div class="bu-inline-error">Aktivitäten konnten nicht geladen werden: ${esc(state.runsError)}</div>`:runsTable(last);
const statusNotice=state.dashboardStatusError&&state.dashboardStatus?`<div class="bu-inline-error bu-section-gap">Der Backup-Status konnte nicht aktualisiert werden und zeigt möglicherweise ältere Daten: ${esc(state.dashboardStatusError)} ${button('Erneut versuchen','retry-dashboard-data')}</div>`:'';
return `<section class="bu-overview-hero ${heroClass}"><div class="bu-overview-icon" aria-hidden="true">${unresolvedFailure||overdueJobs.length||statusUnavailable?'!':active?'↻':needsSetup||noSuccessfulBackup?'→':'✓'}</div><div class="bu-overview-copy"><span class="bu-eyebrow">Backup-Status</span><h2>${esc(heroTitle)}</h2><p>${esc(heroText)}</p></div><div class="bu-overview-action">${heroAction}</div></section>
${statusNotice}
<div class="bu-grid bu-stat-grid">
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Aktive Jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></div><span class="bu-stat-icon" aria-hidden="true">◷</span></section>
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></div><span class="bu-stat-icon" aria-hidden="true">▣</span></section>
<section class="bu-card bu-stat-card"><div><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></div><span class="bu-stat-icon" aria-hidden="true">↻</span></section>
<section class="bu-card bu-stat-card ${overdueJobs.length?'has-alert':''}"><div><div class="bu-muted">Überfällige Jobs</div><div class="bu-stat">${overdueJobs.length}</div></div><span class="bu-stat-icon" aria-hidden="true">!</span></section>
</div><div class="bu-chart-toolbar"><div><span class="bu-eyebrow">Letzte 14 Tage</span><h2>Backup-Verlauf</h2><p class="bu-muted">Jeder Punkt entspricht einem abgeschlossenen Backup-Lauf.</p></div><label>Backup-Job<select id="bu-dashboard-job">${chartOptions}</select></label></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>${chartError||lineChart(backups,'bytesProcessed',formatBytes,'Größe','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}</section>
<section class="bu-card"><h2>Gesicherte Dateien</h2><div class="bu-muted">Verarbeitete Dateien pro Backup</div>${chartError||lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Dateien','In den letzten 14 Tagen liegen keine abgeschlossenen Backups vor.')}</section>
</div><section class="bu-card bu-section-gap"><div class="bu-toolbar"><div><h2>Letzte Aktivitäten</h2><div class="bu-muted">Die sechs jüngsten Vorgänge auf einen Blick.</div></div>${button('Alle anzeigen','go-view:activity')}</div>${activityError}</section>`;
}
function jobAutomationBadge(job) {
const scheduled=Boolean(job.schedule?.cron?.trim());
const active=scheduled&&job.enabled;
const label=!scheduled?'Nur manuell':active?'Aktiviert':'Deaktiviert';
const hint=!scheduled?'Kein Zeitplan hinterlegt. Der Job kann manuell gestartet werden.':active?'Automatische Ausführung nach Zeitplan ist aktiviert.':'Automatische Ausführung ist deaktiviert. Der Job kann weiterhin manuell gestartet werden.';
return `<span class="bu-status ${active?'automation-enabled':'automation-disabled'}" tabindex="0"${tipAttr(hint)}>${label}</span>`;
}
function renderJobs() {
const rows = state.config.jobs.map(j => `<tr><td><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td>${esc(typeLabels[j.type]||j.type)}</td><td>${esc(j.type==='rsync'?(j.rsync?.target||'Kein Ziel'):repoName(j.repositoryId))}</td><td>${esc(scheduleLabel(j.schedule?.cron))}</td><td>${j.enabled ? status('success') : status('cancelled')}</td><td>${actionGroup(button('Starten',`run-job:${j.id}`,'primary')+button('Bearbeiten',`edit-job:${j.id}`)+button('Duplizieren',`duplicate-job:${j.id}`)+button('Löschen',`delete-job:${j.id}`,'danger'))}</td></tr>`).join('');
return `<div class="bu-toolbar"><div><h2>Backup-Jobs</h2><div class="bu-muted">Restic-Backups und direkte Rsync-Kopien werden gemeinsam geplant und überwacht.</div></div>${button('Neuer Job','new-job','primary')}</div>
<section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Job</th><th>Typ</th><th>Repository / Ziel</th><th>Zeitplan</th><th>Aktiv</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Backup-Jobs konfiguriert.</div>'}</section>`;
const rows = state.config.jobs.map(j => { const destination=j.type==='rsync'?(j.rsync?.target||'Kein Ziel'):repoName(j.repositoryId); const secondary=button('Bearbeiten',`edit-job:${j.id}`)+button('Duplizieren',`duplicate-job:${j.id}`)+button('Löschen',`delete-job:${j.id}`,'danger'); return `<tr data-search="${esc(`${j.name} ${j.id} ${typeLabels[j.type]||j.type} ${destination}`.toLowerCase())}"><td data-label="Job"><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td data-label="Typ">${esc(typeLabels[j.type]||j.type)}</td><td data-label="Repository / Ziel">${esc(destination)}</td><td data-label="Zeitplan">${esc(scheduleLabel(j.schedule?.cron))}</td><td data-label="Automatik">${jobAutomationBadge(j)}</td><td data-label="Aktionen"><div class="bu-row-actions">${button('Starten',`run-job:${j.id}`,'primary')}${actionMenu(`Weitere Aktionen für ${j.name}`,secondary)}</div></td></tr>`; }).join('');
return `<div class="bu-toolbar"><div><h2>Backup-Jobs</h2><div class="bu-muted">Restic-Backups und direkte Rsync-Kopien werden gemeinsam geplant und überwacht.</div></div><div class="bu-actions">${filterBox('Jobs durchsuchen','jobs')}${button('Neuer Job','new-job','primary')}</div></div>
<section class="bu-card">${rows ? `<table id="bu-table-jobs" class="bu-table bu-responsive-table"><thead><tr><th>Job</th><th>Typ</th><th>Repository / Ziel</th><th>Zeitplan</th><th>Automatik</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Backup-Jobs konfiguriert.</div>'}</section>`;
}
function jobForm(job = {}) {
@@ -429,16 +537,15 @@
resetSourceTree(sourceRoots);
delete state.directoryTrees.rsyncTarget;
const options = state.config.repositories.map(r => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join('');
content.innerHTML = `<section class="bu-card"><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><form id="bu-job-form" class="bu-form">
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
<label>Typ<select name="type">${['share','appdata','docker','vm','flash','rsync'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
content.innerHTML = `<section class="bu-card bu-form-card"><div class="bu-form-heading"><span class="bu-eyebrow">Backup-Plan</span><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><p class="bu-muted">Quelle, Ziel und Zeitplan in einem Sicherungsplan zusammenfassen.</p></div><form id="bu-job-form" class="bu-form">
<input type="hidden" name="id" value="${esc(job.id || '')}"><fieldset class="wide bu-fieldset"><legend>Allgemein</legend><div class="bu-form-grid"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
<label>Typ<select name="type">${['share','appdata','docker','vm','flash','rsync'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label><label class="wide bu-inline-check"><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}><span>Automatische Ausführung aktiviert</span></label></div></fieldset>
<fieldset id="bu-restic-job-settings" class="wide bu-fieldset"><legend>Restic-Einstellungen</legend><div class="bu-form-grid"><label>Repository<select name="repositoryId">${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label><label>CPU-Kerne für dieses Backup<input name="cpuCores" type="number" min="0" max="256" value="${Number(job.cpuCores||0)}"><span class="bu-muted">0 = alle verfügbaren Kerne</span></label><label>Gleichzeitig gelesene Dateien<input name="readConcurrency" type="number" min="0" max="64" value="${Number(job.readConcurrency||0)}"><span class="bu-muted">0 = Restic-Standard</span></label></div></fieldset>
${scheduleFields(job)}
<div id="bu-job-specific" class="wide"></div>
<label class="wide">Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
<details class="wide bu-form-section"><summary>Erweiterte Dateifilter</summary><label>Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label><p class="bu-muted">Optional. Beispielsweise Cache-Verzeichnisse, temporäre Dateien oder große, reproduzierbare Inhalte ausschließen.</p></details>
<fieldset id="bu-retention-settings" class="wide bu-fieldset"><legend>Backup-Aufbewahrung</legend><p class="bu-muted">Snapshots bleiben erhalten, wenn mindestens eine aktivierte Regel zutrifft. Täglich, wöchentlich und monatlich auf 0 setzen, um ausschließlich nach Alter aufzubewahren.</p><div class="bu-retention-grid"><label>Alle Backups behalten für Tage<input name="keepWithinDays" type="number" min="0" value="${job.retention?.keepWithinDays ?? 30}"></label><label>Zusätzliche tägliche Backups<input name="keepDaily" type="number" min="0" value="${job.retention?.daily ?? 0}"></label><label>Zusätzliche wöchentliche Backups<input name="keepWeekly" type="number" min="0" value="${job.retention?.weekly ?? 0}"></label><label>Zusätzliche monatliche Backups<input name="keepMonthly" type="number" min="0" value="${job.retention?.monthly ?? 0}"></label></div></fieldset>
<label><span>Aktiviert</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label>
<div class="wide bu-actions">${button('Speichern','submit-job','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
<div class="wide bu-actions bu-form-actions">${button('Abbrechen','cancel-form')}${button('Job speichern','submit-job','primary')}</div></form></section>`;
enhanceTooltips(content);
renderJobSpecific(job.type || 'share', job);
}
@@ -457,7 +564,7 @@
loadWorkloads(type);
} else if (type === 'rsync') {
const o=job.rsync||{};
host.innerHTML = `<h3>Direkte Rsync-Kopie</h3><div class="bu-inline-info">Rsync erstellt keine verschlüsselten Snapshots. Die ausgewählten Ordner werden direkt in das Ziel kopiert. Die Option „Am Ziel löschen“ spiegelt Löschungen und sollte bewusst aktiviert werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Quellpfade<textarea name="sources"></textarea></label><h3>Zielordner</h3><label class="wide">Rsync-Ziel<input name="rsyncTarget" required value="${esc(o.target||'')}" placeholder="/mnt/remotes/backup"></label><div id="bu-dir-tree-rsyncTarget"></div><fieldset class="wide bu-fieldset"><legend>Rsync-Optionen</legend><div class="bu-event-options"><label><input name="rsyncOverwrite" type="checkbox" ${o.overwrite!==false?'checked':''}><span>Vorhandene Dateien aktualisieren / überschreiben</span></label><label><input name="rsyncDelete" type="checkbox" ${o.delete===true?'checked':''}><span>Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind</span></label><label><input name="rsyncPermissions" type="checkbox" ${o.preservePermissions!==false?'checked':''}><span>Dateiberechtigungen übernehmen</span></label><label><input name="rsyncOwner" type="checkbox" ${o.preserveOwner===true?'checked':''}><span>Besitzer übernehmen</span></label><label><input name="rsyncGroup" type="checkbox" ${o.preserveGroup===true?'checked':''}><span>Gruppen übernehmen</span></label><label><input name="rsyncTimes" type="checkbox" ${o.preserveTimes!==false?'checked':''}><span>Zeitstempel übernehmen</span></label><label><input name="rsyncLinks" type="checkbox" ${o.preserveLinks!==false?'checked':''}><span>Symbolische Links als Links übernehmen</span></label><label><input name="rsyncACLs" type="checkbox" ${o.preserveAcls===true?'checked':''}><span>ACLs übernehmen</span></label><label><input name="rsyncXattrs" type="checkbox" ${o.preserveXattrs===true?'checked':''}><span>Erweiterte Attribute übernehmen</span></label><label><input name="rsyncChecksum" type="checkbox" ${o.checksum===true?'checked':''}><span>Dateien per Prüfsumme vergleichen (langsamer)</span></label><label><input name="rsyncDryRun" type="checkbox" ${o.dryRun===true?'checked':''}><span>Testlauf ohne Änderungen</span></label></div></fieldset>`;
host.innerHTML = `<h3>Direkte Rsync-Kopie</h3><div class="bu-inline-info">Rsync erstellt keine verschlüsselten Snapshots. Die ausgewählten Ordner werden direkt in das Ziel kopiert (z. B. /mnt/disk1 nach /mnt/disks/USB-Backup/disk1). Array-Disks erscheinen als eigene Quellen, gemountete USB-Disks unter /mnt/disks. Die Option „Am Ziel löschen“ spiegelt Löschungen und sollte bewusst aktiviert werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Quellpfade<textarea name="sources"></textarea></label><h3>Zielordner</h3><label class="wide">Rsync-Ziel<input name="rsyncTarget" required value="${esc(o.target||'')}" placeholder="/mnt/remotes/backup"></label><div id="bu-dir-tree-rsyncTarget"></div><fieldset class="wide bu-fieldset"><legend>Rsync-Optionen</legend><div class="bu-event-options"><label><input name="rsyncOverwrite" type="checkbox" ${o.overwrite!==false?'checked':''}><span>Vorhandene Dateien aktualisieren / überschreiben</span></label><label><input name="rsyncDelete" type="checkbox" ${o.delete===true?'checked':''}><span>Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind</span></label><label><input name="rsyncPermissions" type="checkbox" ${o.preservePermissions!==false?'checked':''}><span>Dateiberechtigungen übernehmen</span></label><label><input name="rsyncOwner" type="checkbox" ${o.preserveOwner===true?'checked':''}><span>Besitzer übernehmen</span></label><label><input name="rsyncGroup" type="checkbox" ${o.preserveGroup===true?'checked':''}><span>Gruppen übernehmen</span></label><label><input name="rsyncTimes" type="checkbox" ${o.preserveTimes!==false?'checked':''}><span>Zeitstempel übernehmen</span></label><label><input name="rsyncLinks" type="checkbox" ${o.preserveLinks!==false?'checked':''}><span>Symbolische Links als Links übernehmen</span></label><label><input name="rsyncACLs" type="checkbox" ${o.preserveAcls===true?'checked':''}><span>ACLs übernehmen</span></label><label><input name="rsyncXattrs" type="checkbox" ${o.preserveXattrs===true?'checked':''}><span>Erweiterte Attribute übernehmen</span></label><label><input name="rsyncChecksum" type="checkbox" ${o.checksum===true?'checked':''}><span>Dateien per Prüfsumme vergleichen (langsamer)</span></label><label><input name="rsyncDryRun" type="checkbox" ${o.dryRun===true?'checked':''}><span>Testlauf ohne Änderungen</span></label></div></fieldset>`;
host.querySelector('.bu-event-options').insertAdjacentHTML('beforeend', `<label><input name="rsyncSkipSpaceCheck" type="checkbox" ${o.skipSpaceCheck===true?'checked':''}><span>Kapazitätsprüfung überspringen (riskant; nur wenn vorhandene Zieldateien ausreichend Platz freigeben)</span></label>`);
initSourceTree(sourceRoots.map(item=>item.path), false); initDirectoryTree('rsyncTarget','Rsync-Ziel auswählen','#bu-job-form [name="rsyncTarget"]');
} else {
@@ -615,11 +722,11 @@
function renderRepositories() {
const rows = state.config.repositories.map(r => {
const initialize = volatileLocation(r) ? disabledButton('Initialisieren','Dieses Ziel liegt im flüchtigen RAM. Zuerst über Bearbeiten einen dauerhaften Pfad wie /mnt/user/Backups/HomeServer eintragen.') : button('Initialisieren',`init-repo:${r.id}`,'primary');
const actions = initialize+button('Testen',`test-repo:${r.id}`)+button('Passwort ändern',`change-repo-password:${r.id}`)+button('Entsperren',`unlock-repo:${r.id}`)+button('Notfall entsperren',`force-unlock-repo:${r.id}`,'danger')+button('Snapshots',`repo-snapshots:${r.id}`)+button('Prüfen',`maint:check:${r.id}`)+button('Bereinigen',`maint:prune:${r.id}`)+button('Bearbeiten',`edit-repo:${r.id}`)+button('Löschen',`delete-repo:${r.id}`,'danger');
return `<tr><td><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td>${esc(typeLabels[r.type]||r.type)}</td><td>${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...</span>':''}</td><td>${r.mount?.managed?'Durch URBM':'Direkt / extern'}</td><td>${actionGroup(actions)}</td></tr>`;
const initialize = volatileLocation(r) ? disabledButton('Initialisieren','Dieses Ziel liegt im flüchtigen RAM. Zuerst über Bearbeiten einen dauerhaften Pfad wie /mnt/user/Backups/HomeServer eintragen.') : button('Initialisieren',`init-repo:${r.id}`);
const secondary = button('Snapshots',`repo-snapshots:${r.id}`)+button('Bearbeiten',`edit-repo:${r.id}`)+button('Passwort ändern',`change-repo-password:${r.id}`)+initialize+button('Prüfen',`maint:check:${r.id}`)+button('Bereinigen',`maint:prune:${r.id}`)+button('Entsperren',`unlock-repo:${r.id}`)+button('Notfall entsperren',`force-unlock-repo:${r.id}`,'danger')+button('Löschen',`delete-repo:${r.id}`,'danger');
return `<tr data-search="${esc(`${r.name} ${r.id} ${typeLabels[r.type]||r.type} ${r.location}`.toLowerCase())}"><td data-label="Name"><strong>${esc(r.name)}</strong><br><span class="bu-muted">${esc(r.id)}</span></td><td data-label="Typ">${esc(typeLabels[r.type]||r.type)}</td><td data-label="Speicherort" class="bu-path-cell">${esc(r.location)}${volatileLocation(r)?'<span class="bu-warning">Flüchtiger Pfad: Die Daten gehen beim Neustart verloren. Verwende /mnt/user/...</span>':''}</td><td data-label="Einbindung">${r.mount?.managed?'Durch URBM':'Direkt / extern'}</td><td data-label="Aktionen"><div class="bu-row-actions">${button('Testen',`test-repo:${r.id}`,'primary')}${actionMenu(`Weitere Aktionen für ${r.name}`,secondary)}</div></td></tr>`;
}).join('');
return `<div class="bu-toolbar"><div><h2>Repositorys</h2><div class="bu-muted">Lokale, SMB-, NFS- und SFTP-Ziele.</div></div><div class="bu-actions">${button('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}</div></div><section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Name</th><th>Typ</th><th>Speicherort</th><th>Einbindung</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Repositorys konfiguriert.</div>'}</section>`;
return `<div class="bu-toolbar"><div><h2>Repositorys</h2><div class="bu-muted">Lokale, SMB-, NFS- und SFTP-Ziele.</div></div><div class="bu-actions">${filterBox('Repositorys durchsuchen','repositories')}${button('Vorhandenes einbinden','existing-repo')}${button('Neues Repository','new-repo','primary')}</div></div><section class="bu-card">${rows ? `<table id="bu-table-repositories" class="bu-table bu-responsive-table"><thead><tr><th>Name</th><th>Typ</th><th>Speicherort</th><th>Einbindung</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Repositorys konfiguriert.</div>'}</section>`;
}
function repositoryForm(repo = {}, existingRepository = false) {
@@ -631,7 +738,7 @@
<label>Name<input name="name" required value="${esc(repo.name||'')}"></label><label>Typ<select name="type">${['local','smb','nfs','sftp'].map(v=>`<option value="${v}" ${v===repo.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
<div id="bu-repository-specific" class="wide"></div>
${repo.id?`<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><p>Das Repository-Passwort wird getrennt von den Ziel- und Mount-Einstellungen geändert.</p><div class="bu-actions">${button('Repository-Passwort ändern',`change-repo-password:${repo.id}`)}</div></fieldset>`:`<fieldset class="wide bu-fieldset"><legend>Restic-Verschlüsselung</legend><div class="bu-form-grid"><label>Repository-Passwort<input name="password" type="password" autocomplete="new-password" required placeholder="Ein starkes Repository-Passwort eingeben"></label><label>Repository-Passwort wiederholen<input name="passwordConfirm" type="password" autocomplete="new-password" required placeholder="Repository-Passwort erneut eingeben"></label></div><p class="bu-muted">Das Passwort wird von URBM geschützt gespeichert. Bewahre dieses Passwort zusätzlich sicher auf; ohne das Passwort können Backups nicht wiederhergestellt werden.</p></fieldset>`}
<div class="wide bu-actions">${button(existingRepository?'Einbinden und testen':'Speichern','submit-repo','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
<div class="wide bu-actions bu-form-actions">${button('Abbrechen','cancel-form')}${button(existingRepository?'Einbinden und testen':'Repository speichern','submit-repo','primary')}</div></form></section>`;
enhanceTooltips(content);
renderRepositorySpecific(repo.type || 'local', repo);
}
@@ -667,34 +774,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() : ''}`;
}
function snapshotItemPath(item) {
return String(item.path || item.name || '').replace(/\/+/g,'/').replace(/\/$/,'') || '/';
}
function buildSnapshotTree(items) {
const nodes=new Map();
const ensure=(path,type='dir',size=0)=>{
path=path.replace(/\/+/g,'/').replace(/\/$/,'')||'/';
if(nodes.has(path)) return nodes.get(path);
const parentPath=path==='/'?'':path.split('/').slice(0,-1).join('/')||'/';
const name=path==='/'?'/':path.split('/').pop();
const node={path,name,type,size:Number(size||0),children:[]};
nodes.set(path,node);
if(path!=='/') ensure(parentPath).children.push(node);
return node;
};
ensure('/');
items.forEach(item=>{
const path=snapshotItemPath(item);
if(!path||path==='/') return;
const type=item.type==='dir'?'dir':'file';
const node=ensure(path,type,item.size);
node.type=type; node.size=Number(item.size||0);
});
nodes.forEach(node=>node.children.sort((a,b)=>(a.type===b.type?a.name.localeCompare(b.name):a.type==='dir'?-1:1)));
return nodes.get('/');
}
function snapshotSelectionState(path) {
const exact=state.restoreIncludes.includes(path);
const prefix=path.replace(/\/$/,'')+'/';
@@ -706,26 +785,46 @@
const selection=snapshotSelectionState(node.path);
const isDir=node.type==='dir';
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>':'';
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>`;
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>`;
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(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>';
}
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 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>';
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 bu-section-gap bu-snapshot-browser"><div class="bu-toolbar"><div><h3>Snapshot ${esc(state.selectedSnapshot.slice(0,12))}</h3><span class="bu-muted">${esc(hint)} · Der Dateibaum verwendet den Seiten-Scroll.</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 bu-snapshot-tree" role="tree">${body}</div></section>`;
}
function renderRestore() {
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
return `<section class="bu-card"><h2>Wiederherstellen</h2><form id="bu-restore-form" class="bu-form"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot-ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Wiederherzustellende Pfade, einer pro Zeile<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Ziel<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><div id="bu-dir-tree-restoreTarget" class="wide"></div><label>Am Originalort wiederherstellen<input name="inPlace" type="checkbox"></label><label>Überschreibungsrisiko bestätigen<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Wiederherstellung einreihen','submit-restore','primary')}</div></form></section>`;
return `<section class="bu-card bu-form-card"><div class="bu-form-heading"><span class="bu-eyebrow">Sicher wiederherstellen</span><h2>Wiederherstellen</h2><p class="bu-muted">Standardmäßig landen Dateien in einem getrennten Staging-Verzeichnis.</p></div><form id="bu-restore-form" class="bu-form"><fieldset class="wide bu-fieldset"><legend>Quelle</legend><div class="bu-form-grid"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot-ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Wiederherzustellende Pfade, einer pro Zeile<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label></div></fieldset><fieldset class="wide bu-fieldset"><legend>Ziel</legend><label class="wide">Zielverzeichnis<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><div id="bu-dir-tree-restoreTarget" class="wide"></div></fieldset><fieldset class="wide bu-fieldset bu-danger-zone"><legend>Originalpfade überschreiben</legend><p class="bu-muted">Nur für eine bewusst geplante In-place-Wiederherstellung aktivieren.</p><div class="bu-form-grid"><label class="bu-inline-check"><input name="inPlace" type="checkbox"><span>Am Originalort wiederherstellen</span></label><label class="bu-inline-check"><input name="confirmed" type="checkbox"><span>Überschreibungsrisiko bestätigen</span></label></div></fieldset><div class="wide bu-actions bu-form-actions">${button('Wiederherstellung einreihen','submit-restore','primary')}</div></form></section>`;
}
function renderActivity() { return `<div class="bu-toolbar"><div><h2>Aktivitäten</h2><div class="bu-muted">Laufende Aufgaben werden automatisch alle zwei Sekunden aktualisiert.</div></div><div class="bu-actions">${button('Aktualisieren','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}</div></div><section class="bu-card">${runsTable(newestRuns(state.runs))}</section>`; }
function renderLogs() {
const daemon=state.logs?.daemon||{};
const daemonLines=daemon.lines||[];
const daemonHint=daemon.error?`<div class="bu-inline-error">${esc(daemon.error)}</div>`:`<div class="bu-muted">${esc(daemon.path||'/var/log/urbm.log')}${daemon.truncatedLines?` · ${daemon.truncatedLines} ältere Zeilen ausgeblendet`:''}${daemon.truncatedBytes?` · Anfang wegen Größe gekürzt`:''}</div>`;
const runLogs=(state.logs?.runs||[]).map(run=>`<details class="bu-live-log" data-run-id="${esc(run.id)}" ${state.liveLogOpen[run.id]===true?'open':''}><summary>${esc(run.rsyncDryRun?'Rsync-Testlauf':taskLabels[run.taskType]||run.taskType)} · ${esc(runTarget(run))} · ${esc(statusLabels[run.status]||run.status)} · ${esc(new Date(run.createdAt).toLocaleString())}</summary><pre>${esc((run.liveLog||[]).join('\n'))}</pre></details>`).join('');
return `<div class="bu-toolbar"><div><h2>Protokolle</h2><div class="bu-muted">Nur lesbare Diagnoseansicht. Secrets und Passwörter werden von URBM nicht geloggt.</div></div><div class="bu-actions">${button('Aktualisieren','refresh-logs','primary')}</div></div>${state.logsLoading?'<section class="bu-card"><div class="bu-empty">Protokolle werden geladen…</div></section>':state.logsError?`<section class="bu-card"><div class="bu-inline-error">${esc(state.logsError)}</div></section>`:`<section class="bu-card"><h3>Daemon-Protokoll</h3>${daemonHint}<pre class="bu-log-pre">${esc(daemonLines.join('\n'))}</pre></section><section class="bu-card" style="margin-top:16px"><h3>Laufprotokolle</h3>${runLogs||'<div class="bu-empty">Keine gespeicherten Laufprotokolle vorhanden.</div>'}</section>`}`;
}
function progressView(run) {
if(run.status==='queued') return '<span class="bu-muted">Wartet in der Warteschlange</span>';
if(!['backup','rsync','restore'].includes(run.taskType)&&!run.progressPercent) return '<span class="bu-muted">Kein Prozentwert verfügbar</span>';
@@ -748,7 +847,7 @@
}
function liveLogView(run) {
if(!(run.liveLog||[]).length) return '';
const open=Object.prototype.hasOwnProperty.call(state.liveLogOpen,run.id)?state.liveLogOpen[run.id]:run.status==='running';
const open=Object.prototype.hasOwnProperty.call(state.liveLogOpen,run.id)?state.liveLogOpen[run.id]:false;
return `<details class="bu-live-log" data-run-id="${esc(run.id)}" ${open?'open':''}><summary>${run.status==='running'?'Live-Protokoll':'Laufprotokoll'} (${run.liveLog.length})</summary><pre>${esc(run.liveLog.join('\n'))}</pre></details>`;
}
function runsTable(runs) {
@@ -772,11 +871,25 @@
notify('Konfiguration gespeichert');
return state.config;
}
async function loadLogs() {
state.logsLoading=true; state.logsError=''; render();
try { state.logs=await api('/v1/logs'); }
catch(error) { state.logsError=error.message; }
finally { state.logsLoading=false; render(); }
}
function renderPreservingScroll() {
const scrollSnapshot=captureScrollState(content);
render();
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 runTime = run => new Date(run.createdAt || run.startedAt || run.finishedAt || 0).getTime() || 0;
const snapshotTime = snapshot => new Date(snapshot.time || 0).getTime() || 0;
@@ -794,29 +907,33 @@
if (name === 'change-repo-password') return repositoryPasswordForm(state.config.repositories.find(r=>r.id===a));
if (name === 'cancel-form') return render();
if (name === 'refresh') return load();
if (name === 'retry-dashboard-data') return reloadDashboardData();
if (name === 'run-job') { await api(`/v1/jobs/${a}/run`,{method:'POST'}); notify('Backup wurde eingereiht'); return load(); }
if (name === 'cancel-run') { await api(`/v1/runs/${a}/cancel`,{method:'POST'}); return load(); }
if (name === 'pause-run') { await api(`/v1/runs/${a}/pause`,{method:'POST'}); notify('Vorgang pausiert'); return load(); }
if (name === 'resume-run') { await api(`/v1/runs/${a}/resume`,{method:'POST'}); notify('Vorgang wird fortgesetzt'); return load(); }
if (name === 'clear-activity') { if(confirm('Alle abgeschlossenen Aktivitäten und deren gespeicherte Laufprotokolle löschen?\n\nLaufende und wartende Aufgaben bleiben erhalten.')) { const result=await api('/v1/runs',{method:'DELETE'}); notify(result.cleared?`${result.cleared} abgeschlossene Aktivitäten gelöscht`:'Keine abgeschlossenen Aktivitäten vorhanden'); return load(); } return; }
if (name === 'go-view') { state.view=a; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view===a)); render(); if(a==='logs'&&!state.logs&&!state.logsLoading) loadLogs(); return; }
if (name === 'clear-activity') { if(await askConfirm({title:'Aktivitätsverlauf löschen?',message:'Alle abgeschlossenen Aktivitäten und ihre gespeicherten Laufprotokolle werden entfernt. Laufende und wartende Aufgaben sowie die Messwerte der Dashboard-Diagramme bleiben erhalten.',accept:'Verlauf löschen'})) { const result=await api('/v1/runs',{method:'DELETE'}); notify(result.cleared?`${result.cleared} abgeschlossene Aktivitäten gelöscht`:'Keine abgeschlossenen Aktivitäten vorhanden'); return load(); } return; }
if (name === 'refresh-logs') { state.logs=null; return loadLogs(); }
if (name === 'test-repo') { await api(`/v1/repositories/${a}/test`,{method:'POST'}); return notify('Repository-Verbindung erfolgreich getestet'); }
if (name === 'init-repo') { if(confirm('Hier ein NEUES Restic-Repository erstellen?\n\nNur fortfahren, wenn dieses Ziel noch kein Restic-Repository enthält. Bei einem vorhandenen Repository das ursprüngliche Passwort unter Bearbeiten eintragen und Testen verwenden.')) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository erfolgreich initialisiert'); } return; }
if (name === 'unlock-repo') { if(confirm('Verwaiste Restic-Sperren dieses Repositorys entfernen?\n\nRestic entfernt dabei nur als veraltet erkannte Locks. Verwende diese Aktion nach einem Neustart oder abgebrochenen Restic-Prozess.')) { await api(`/v1/repositories/${a}/unlock`,{method:'POST'}); notify('Verwaiste Repository-Sperren wurden entfernt'); } return; }
if (name === 'force-unlock-repo') { if(confirm('NOTFALL-ENTSPERRUNG\n\nDiese Aktion entfernt ALLE Restic-Sperren dieses Repositorys.\n\nNur fortfahren, wenn wirklich kein Backup, Restore, Check oder Prune mehr läuft - auch nicht auf einem anderen Server, der dieses Repository nutzt. Bei einem laufenden Vorgang kann das Repository beschädigt oder der laufende Vorgang fehlschlagen.\n\nAlle Sperren jetzt entfernen?')) { await api(`/v1/repositories/${a}/force-unlock`,{method:'POST'}); notify('Alle Repository-Sperren wurden entfernt'); } return; }
if (name === 'init-repo') { if(await askConfirm({title:'Neues Repository initialisieren?',message:'Am konfigurierten Ziel wird ein neues, leeres Restic-Repository angelegt. Verwende diese Aktion nicht für ein bereits vorhandenes Repository.',accept:'Initialisieren',danger:false})) { await api(`/v1/repositories/${a}/init`,{method:'POST'}); notify('Repository erfolgreich initialisiert'); } return; }
if (name === 'unlock-repo') { if(await askConfirm({title:'Verwaiste Sperren entfernen?',message:'Restic entfernt nur als veraltet erkannte Sperren. Diese Aktion ist für abgebrochene Prozesse oder einen Neustart gedacht.',accept:'Entsperren',danger:false})) { await api(`/v1/repositories/${a}/unlock`,{method:'POST'}); notify('Verwaiste Repository-Sperren wurden entfernt'); } return; }
if (name === 'force-unlock-repo') { if(await askConfirm({title:'Notfall-Entsperrung',message:'Alle Restic-Sperren werden entfernt. Fahre nur fort, wenn auf keinem Server ein Backup, Restore, Check oder Prune auf dieses Repository zugreift. Ein aktiver Vorgang kann sonst fehlschlagen oder Daten beschädigen.',accept:'Alle Sperren entfernen',phrase:'ENTSPERREN'})) { await api(`/v1/repositories/${a}/force-unlock`,{method:'POST'}); notify('Alle Repository-Sperren wurden entfernt'); } return; }
if (name === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a==='check'?'Prüfung':'Bereinigung'} wurde eingereiht`); return load(); }
if (name === 'repo-snapshots') { state.view='snapshots'; document.querySelector('[data-view="snapshots"]').click(); render(); setTimeout(()=>{document.getElementById('bu-snapshot-repo').value=a; handle('load-snapshots');},0); return; }
if (name === 'load-snapshots') { const repo=document.getElementById('bu-snapshot-repo').value; state.snapshots=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}`; } 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 === '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 === 'retry-dir-tree') { const path=decodeURIComponent(b); const tree=state.directoryTrees[a]; if(tree) { delete tree.children[path]; tree.errors[path]=''; } return toggleDirectoryTree(a,path,true); }
if (name === 'select-dir-tree') { selectDirectoryTreePath(a, decodeURIComponent(b)); return; }
if (name === 'toggle-snapshot-tree') { const path=decodeURIComponent(a); state.snapshotTreeExpanded[path]=!state.snapshotTreeExpanded[path]; return 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 === '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') { const job=state.config.jobs.find(j=>j.id===a); if(await askConfirm({title:'Backup-Job löschen?',message:`„${job?.name||a}“ wird aus der Konfiguration entfernt. Bereits vorhandene Snapshots bleiben erhalten.`,accept:'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 === 'delete-repo') { if(state.config.jobs.some(j=>j.repositoryId===a)) throw new Error('Das Repository wird noch von einem Job verwendet.'); if(confirm('Dieses Repository aus der URBM-Konfiguration löschen? Die Backup-Daten am Ziel bleiben erhalten.')) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; }
if (name === 'delete-repo') { if(state.config.jobs.some(j=>j.repositoryId===a)) throw new Error('Das Repository wird noch von einem Job verwendet.'); const repo=state.config.repositories.find(r=>r.id===a); if(await askConfirm({title:'Repository entfernen?',message:`„${repo?.name||a}“ wird nur aus URBM entfernt. Die Backup-Daten am Speicherziel werden nicht gelöscht.`,accept:'Repository entfernen'})) { state.config.repositories=state.config.repositories.filter(r=>r.id!==a); await saveConfig(); render(); } return; }
if (name === 'adopt-repo-password') { await submitRepositoryPassword(true); return; }
if (name.startsWith('submit-')) { await submit(name); return; }
} catch (error) { notify(error.message, true); }
@@ -833,8 +950,8 @@
if(type!=='rsync'&&!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.');
const rsync=type==='rsync'?{target:String(f.get('rsyncTarget')||'').trim(),overwrite:f.get('rsyncOverwrite')==='on',delete:f.get('rsyncDelete')==='on',preservePermissions:f.get('rsyncPermissions')==='on',preserveOwner:f.get('rsyncOwner')==='on',preserveGroup:f.get('rsyncGroup')==='on',preserveTimes:f.get('rsyncTimes')==='on',preserveLinks:f.get('rsyncLinks')==='on',preserveAcls:f.get('rsyncACLs')==='on',preserveXattrs:f.get('rsyncXattrs')==='on',checksum:f.get('rsyncChecksum')==='on',dryRun:f.get('rsyncDryRun')==='on',skipSpaceCheck:f.get('rsyncSkipSpaceCheck')==='on'}:{};
if(type==='rsync'&&!rsync.target) throw new Error('Wähle einen Zielordner für die Rsync-Kopie.');
if(type==='rsync'&&rsync.delete&&!confirm('Rsync darf Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind. Diese Option kann Daten am Ziel entfernen. Wirklich speichern?')) return;
if(type==='rsync'&&rsync.skipSpaceCheck&&!confirm('Die Kapazitätsprüfung wirklich abschalten? Der Rsync-Lauf kann dann das Ziel vollständig füllen und mit einem Schreibfehler abbrechen.')) return;
if(type==='rsync'&&rsync.delete&&!await askConfirm({title:'Rsync-Löschungen aktivieren?',message:'Dateien am Ziel, die in der Quelle nicht mehr vorhanden sind, dürfen beim Lauf gelöscht werden.',accept:'Löschungen erlauben'})) return;
if(type==='rsync'&&rsync.skipSpaceCheck&&!await askConfirm({title:'Kapazitätsprüfung abschalten?',message:'Der Rsync-Lauf kann das Ziel vollständig füllen und anschließend mit einem Schreibfehler abbrechen.',accept:'Prüfung abschalten'})) return;
const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:type==='rsync'?'':f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:type==='rsync'?'':f.get('compression'),cpuCores:type==='rsync'?0:Number(f.get('cpuCores')||0),readConcurrency:type==='rsync'?0:Number(f.get('readConcurrency')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention:type==='rsync'?{}:retention,rsync,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)};
state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
}
@@ -849,7 +966,7 @@
const password=String(f.get('password')||''); const passwordConfirm=String(f.get('passwordConfirm')||'');
if(!value('id')&&!password) throw new Error(existingRepository?'Zum Einbinden ist das ursprüngliche Repository-Passwort erforderlich.':'Für ein neues Repository ist ein Passwort erforderlich.');
if(password!==passwordConfirm) throw new Error('Die beiden Repository-Passwörter stimmen nicht überein.');
if (volatileLocation(repo) && !confirm('WARNUNG: Dieses Ziel liegt unter /tmp oder /run und geht nach einem Unraid-Neustart verloren. Trotzdem speichern?')) return;
if (volatileLocation(repo) && !await askConfirm({title:'Flüchtiges Speicherziel',message:'Dieses Ziel liegt unter /tmp oder /run. Sämtliche dort gespeicherten Daten gehen bei einem Unraid-Neustart verloren.',accept:'Trotzdem speichern',phrase:'FLÜCHTIG'})) return;
if(password) await api(`/v1/secrets/${encodeURIComponent(repo.passwordRef)}`,{method:'PUT',body:JSON.stringify({type:'restic-password',value:password})});
if(f.get('credential')) await api(`/v1/secrets/${encodeURIComponent(repo.credentialRef)}`,{method:'PUT',body:JSON.stringify({type:type==='sftp'?'sftp-private-key':'smb-credential',value:f.get('credential')})});
state.config.repositories=state.config.repositories.filter(r=>r.id!==repo.id).concat(repo);
@@ -878,16 +995,90 @@
const password=String(f.get('password')||'');
if(password.length<8) throw new Error('Das Repository-Passwort muss mindestens 8 Zeichen lang sein.');
if(password!==String(f.get('passwordConfirm')||'')) throw new Error('Die beiden Repository-Passwörter stimmen nicht überein.');
const prompt=adopt?'Dieses bereits gültige Passwort prüfen und nur auf diesem URBM-Server übernehmen?':'Repository-Passwort jetzt wirklich ändern?\n\nDas bisherige Passwort funktioniert danach für den bisherigen Restic-Key nicht mehr. Weitere Server müssen anschließend auf das neue Passwort umgestellt werden.';
if(!confirm(prompt)) return;
const accepted=await askConfirm(adopt?{title:'Passwort übernehmen?',message:'Das bereits gültige Passwort wird geprüft und nur auf diesem URBM-Server gespeichert.',accept:'Prüfen und übernehmen',danger:false}:{title:'Repository-Passwort ändern?',message:'Der bisherige Restic-Key wird entfernt. Weitere Server müssen anschließend auf das neue Passwort umgestellt werden.',accept:'Passwort ändern'});
if(!accepted) return;
const suffix=adopt?'/password/adopt':'/password';
await api(`/v1/repositories/${encodeURIComponent(f.get('id'))}${suffix}`,{method:'POST',body:JSON.stringify({password})});
notify(adopt?'Repository-Passwort wurde geprüft und auf diesem Server übernommen':'Repository-Passwort wurde sicher geändert'); 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); });
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==='logs'&&!state.logs&&!state.logsLoading)loadLogs(); });
function closeActionMenu(menu) {
if(!menu) return;
menu.removeAttribute('open');
menu.closest('tr')?.classList.remove('bu-menu-open');
const popover=menu._popover;
if(popover) {
popover.removeAttribute('style');
popover.classList.remove('portaled');
delete popover._ownerMenu;
menu.appendChild(popover);
delete menu._popover;
}
}
function handleActionClick(e) {
const target=e.target.closest('[data-action]');
if(!target) return;
e.preventDefault();
const popover=target.closest('.bu-action-menu-popover');
const menu=target.closest('.bu-action-menu')||popover?._ownerMenu;
closeActionMenu(menu);
handle(target.dataset.action,target);
}
content.addEventListener('click', handleActionClick);
menuPortal?.addEventListener('click', handleActionClick);
content.addEventListener('input', e => {
const target=e.target.closest('[data-filter-table]');
if(!target) return;
const query=target.value.trim().toLowerCase();
const table=document.getElementById(`bu-table-${target.dataset.filterTable}`);
table?.querySelectorAll('tbody tr[data-search]').forEach(row=>{ row.hidden=Boolean(query)&&!row.dataset.search.includes(query); });
});
document.addEventListener('click', e=>{
document.querySelectorAll('.bu-action-menu[open]').forEach(menu=>{
const popover=menu._popover;
if(!menu.contains(e.target)&&!popover?.contains(e.target)) closeActionMenu(menu);
});
});
content.addEventListener('toggle', e=>{
const menu=e.target.closest?.('.bu-action-menu');
if(!menu) return;
const row=menu.closest('tr');
row?.classList.toggle('bu-menu-open',menu.open);
if(!menu.open) {
closeActionMenu(menu);
return;
}
content.querySelectorAll('.bu-action-menu[open]').forEach(other=>{ if(other!==menu) closeActionMenu(other); });
requestAnimationFrame(()=>{
if(!menu.open||!menu.isConnected) return;
const summary=menu.querySelector('summary');
const popover=menu.querySelector('.bu-action-menu-popover');
if(!summary||!popover||!menuPortal) return;
const rect=summary.getBoundingClientRect();
menu._popover=popover;
popover._ownerMenu=menu;
popover.classList.add('portaled');
menuPortal.appendChild(popover);
const width=Math.min(Math.max(popover.offsetWidth,220),window.innerWidth-16);
const needed=Math.min(popover.scrollHeight,430);
const below=window.innerHeight-rect.bottom-12;
const above=rect.top-12;
const openUp=below<needed&&above>below;
const available=Math.max(120,(openUp?above:below)-6);
const height=Math.min(needed,available);
const left=Math.max(8,Math.min(rect.right-width,window.innerWidth-width-8));
const top=openUp?Math.max(8,rect.top-height-6):rect.bottom+6;
Object.assign(popover.style,{left:`${left}px`,right:'auto',top:`${top}px`,bottom:'auto',width:`${width}px`,maxHeight:`${height}px`});
});
},true);
window.addEventListener('resize',()=>document.querySelectorAll('.bu-action-menu[open]').forEach(closeActionMenu));
content.addEventListener('change', e => {
if(e.target.id==='bu-dashboard-job') {
state.dashboardJobId=e.target.value;
render();
return;
}
if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) {
renderRepositorySpecific(e.target.value);
}
@@ -905,10 +1096,19 @@
const manual=e.target.closest('.bu-schedule')?.querySelector('.bu-schedule-manual');
if(manual) manual.hidden=e.target.value!=='manual';
}
if (e.target.name === 'type' && e.target.closest('#bu-job-form')) {
if (e.target.name === 'type' && e.target.closest('#bu-job-form')) {
state.backupSelections=[]; state.workloadSelections=[];
renderJobSpecific(e.target.value);
}
}
if (e.target.name === 'inPlace' && e.target.closest('#bu-restore-form')) {
const target=e.target.closest('form').querySelector('[name="target"]');
const tree=e.target.closest('form').querySelector('#bu-dir-tree-restoreTarget');
if(target) {
if(e.target.checked) { target.dataset.stagingTarget=target.value; target.value='/'; target.readOnly=true; }
else { target.value=target.dataset.stagingTarget||state.config.settings.restoreRoot; target.readOnly=false; }
}
if(tree) tree.hidden=e.target.checked;
}
if (e.target.classList.contains('bu-source-select')) {
const path=e.target.value; const prefix=path.replace(/\/$/,'')+'/';
if(e.target.checked) state.backupSelections=normalizeSourceSelections([...state.backupSelections.filter(selected=>!selected.startsWith(prefix)),path]);
@@ -939,7 +1139,7 @@
root.addEventListener('mouseover', e => {
const target = e.target.closest('[data-tooltip]');
if (!target || target.contains(e.relatedTarget)) return;
showTooltip(target, 1200);
showTooltip(target,Number(target.dataset.tooltipDelay||1200));
});
root.addEventListener('mouseout', e => {
const target = e.target.closest('[data-tooltip]');
@@ -955,6 +1155,31 @@
window.addEventListener('scroll', hideTooltip, true);
window.addEventListener('resize', hideTooltip);
enhanceTooltips(root);
setInterval(async()=>{ if(!state.config)return; try { state.runs=newestRuns(await api('/v1/runs')); if(['dashboard','activity'].includes(state.view))render(); }catch(_){ } },2000);
let lastDashboardStatusRefresh=0;
setInterval(async()=>{
if(!state.config)return;
try {
const runs=newestRuns(await api('/v1/runs'));
const runsChanged=JSON.stringify(runs)!==JSON.stringify(state.runs);
const runsRecovered=Boolean(state.runsError);
state.runs=runs;state.runsError='';
const statusDue=Date.now()-lastDashboardStatusRefresh>=60000;
const dataRetry=Boolean(state.backupMetricsError||state.dashboardStatusError);
if(runsChanged||statusDue||dataRetry){
const requests=[];
const loadMetrics=runsChanged||Boolean(state.backupMetricsError);
if(loadMetrics)requests.push(api('/v1/backup-metrics'));else requests.push(Promise.resolve(state.backupMetrics));
requests.push(api('/v1/dashboard-status'));
const [metricsResult,statusResult]=await Promise.allSettled(requests);
if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.';
if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.';
}
if((runsChanged||runsRecovered||statusDue||dataRetry)&&['dashboard','activity'].includes(state.view))render();
}catch(error){
const changed=state.runsError!==error.message;
state.runsError=error.message;
if(changed&&['dashboard','activity'].includes(state.view))render();
}
},2000);
load();
})();