Compare commits
22
Commits
c2f0f0b277
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ee08bc9d1 | ||
|
|
73e490ebe1 | ||
|
|
74a69b1580 | ||
|
|
106f9c9387 | ||
|
|
5009dc202b | ||
|
|
8f75a04db6 | ||
|
|
819055cbf0 | ||
|
|
ea97d7dbc9 | ||
|
|
b1fba3ed16 | ||
|
|
2ecdcdf602 | ||
|
|
76c9011df2 | ||
|
|
e364986b55 | ||
|
|
6cb4c032b9 | ||
|
|
a66540d2fb | ||
|
|
4cd2b7da47 | ||
|
|
bdebf1bb2b | ||
|
|
bbf063c157 | ||
|
|
6cbf170d65 | ||
|
|
091c5ff175 | ||
|
|
3f1b7cd781 | ||
|
|
bf06b10519 | ||
|
|
f22c2e462d |
No files matched your search
@@ -44,3 +44,49 @@ The daemon has no TCP listener. Restic passwords are passed through root-only te
|
|||||||
## Current scope
|
## 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.
|
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.
|
||||||
@@ -87,5 +87,8 @@ func main() {
|
|||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
_ = server.Shutdown(shutdownCtx)
|
_ = 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")
|
log.Info("urbm daemon stopped")
|
||||||
}
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
ec72032752aaae7a2bde7587781f65b2b5b4d9ef74a6aad79a3e121491b30958 urbm-2026.07.10.r002-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
36921b9372b1ee42f50eb3e18cb0a5ac720fc484f5fef992cc454684a6e86282 urbm-2026.07.13.r012-x86_64-1.txz
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
55d48398ecd446793f51c0fcc987994ba77e52ac950dbc7f5a32f983449e5b83 urbm-2026.09.21.r001-x86_64-1.txz
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
e66e3d540fba172a1e0b8e60f7b36e33b38ba8357e5a45a07879a32c2d655222 urbm-2026.09.27.r001-x86_64-1.txz
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
12bf23238112ee7ad9fe6c1102528cdd7baeb32a89378b7ac17621642b001b87 urbm-2026.09.27.r002-x86_64-1.txz
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
4f5ea23abab9a154aaebe8af9a0ed286ae645deb7ab47d5e495b6b343b841b71 urbm-2026.09.27.r003-x86_64-1.txz
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
3dcb92984be52415d689195e15bd7436de5b98eac9b60c654b045e5694e80b1c urbm-2026.09.28.r001-x86_64-1.txz
|
||||||
Vendored
+111
-3
@@ -2,13 +2,121 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.07.10.r002">
|
<!ENTITY version "2026.09.28.r001">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
||||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "ec72032752aaae7a2bde7587781f65b2b5b4d9ef74a6aad79a3e121491b30958">
|
<!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">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.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
|
### 2026.07.10.r002
|
||||||
- Include retention results in backup notifications after Restic forget runs, including removed and remaining snapshot counts when available.
|
- 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.
|
- Include repository size before, after, and freed space in prune notifications when Restic statistics are available.
|
||||||
@@ -323,7 +431,7 @@ chmod 0700 /boot/config/plugins/&name;
|
|||||||
</FILE>
|
</FILE>
|
||||||
<FILE Run="/bin/bash" Method="remove">
|
<FILE Run="/bin/bash" Method="remove">
|
||||||
<INLINE>
|
<INLINE>
|
||||||
/etc/rc.d/rc.urbm stop || true
|
/etc/rc.d/rc.urbm stop || exit 1
|
||||||
removepkg &name;
|
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
|
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>
|
</INLINE>
|
||||||
|
|||||||
+49
-4
@@ -26,9 +26,11 @@ type Server struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
var apiErrorLog *slog.Logger
|
||||||
|
|
||||||
func New(socket string, svc *service.Service, log *slog.Logger) *Server {
|
func New(socket string, svc *service.Service, log *slog.Logger) *Server {
|
||||||
s := &Server{service: svc, log: log, socket: socket}
|
s := &Server{service: svc, log: log, socket: socket}
|
||||||
|
apiErrorLog = log
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /v1/health", s.health)
|
mux.HandleFunc("GET /v1/health", s.health)
|
||||||
mux.HandleFunc("GET /v1/config", s.getConfig)
|
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("DELETE /v1/secrets/{id}", s.deleteSecret)
|
||||||
mux.HandleFunc("GET /v1/runs", s.runs)
|
mux.HandleFunc("GET /v1/runs", s.runs)
|
||||||
mux.HandleFunc("DELETE /v1/runs", s.clearRuns)
|
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/filesystem/directories", s.directories)
|
||||||
mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads)
|
mux.HandleFunc("GET /v1/workloads/{kind}", s.workloads)
|
||||||
mux.HandleFunc("POST /v1/jobs/{id}/run", s.runJob)
|
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", s.snapshots)
|
||||||
mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles)
|
mux.HandleFunc("GET /v1/repositories/{id}/snapshots/{snapshot}/files", s.snapshotFiles)
|
||||||
mux.HandleFunc("POST /v1/restores", s.restore)
|
mux.HandleFunc("POST /v1/restores", s.restore)
|
||||||
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,9 +131,16 @@ func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(204)
|
w.WriteHeader(204)
|
||||||
}
|
}
|
||||||
func (s *Server) runs(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, s.service.Runs()) }
|
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) {
|
func (s *Server) clearRuns(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeJSON(w, 200, map[string]int{"cleared": s.service.ClearRunHistory()})
|
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) {
|
func (s *Server) directories(w http.ResponseWriter, r *http.Request) {
|
||||||
items, err := s.service.BrowseDirectories(r.URL.Query().Get("path"))
|
items, err := s.service.BrowseDirectories(r.URL.Query().Get("path"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -268,7 +280,7 @@ func (s *Server) repositoryStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 200, s.service.RepositoryStats(ctx))
|
writeJSON(w, 200, s.service.RepositoryStats(ctx))
|
||||||
}
|
}
|
||||||
func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) snapshotFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
items, err := s.service.SnapshotFiles(ctx, r.PathValue("id"), r.PathValue("snapshot"), r.URL.Query().Get("path"))
|
items, err := s.service.SnapshotFiles(ctx, r.PathValue("id"), r.PathValue("snapshot"), r.URL.Query().Get("path"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -297,6 +309,10 @@ func decode(r *http.Request, target any) error {
|
|||||||
if err := decoder.Decode(target); err != nil {
|
if err := decoder.Decode(target); err != nil {
|
||||||
return fmt.Errorf("validation: invalid JSON: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,13 +335,42 @@ func writeError(w http.ResponseWriter, err error) {
|
|||||||
if strings.Contains(message, "already queued") {
|
if strings.Contains(message, "already queued") {
|
||||||
status = 409
|
status = 409
|
||||||
}
|
}
|
||||||
|
if apiErrorLog != nil {
|
||||||
|
apiErrorLog.Warn("api error", "status", status, "error", message)
|
||||||
|
}
|
||||||
writeJSON(w, status, map[string]string{"error": message})
|
writeJSON(w, status, map[string]string{"error": message})
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestLog(log *slog.Logger, next http.Handler) http.Handler {
|
func requestLog(log *slog.Logger, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
next.ServeHTTP(w, r)
|
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
log.Info("api request", "method", r.Method, "path", r.URL.Path, "durationMs", time.Since(started).Milliseconds())
|
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
-32
@@ -138,38 +138,58 @@ type NotificationTarget struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Run struct {
|
type Run struct {
|
||||||
SchemaVersion int `json:"schemaVersion"`
|
RsyncDryRun bool `json:"rsyncDryRun,omitempty"`
|
||||||
ID string `json:"id"`
|
EntriesDeleted int64 `json:"entriesDeleted,omitempty"`
|
||||||
JobID string `json:"jobId,omitempty"`
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
TaskType string `json:"taskType"`
|
ID string `json:"id"`
|
||||||
Status string `json:"status"`
|
JobID string `json:"jobId,omitempty"`
|
||||||
Priority int `json:"priority"`
|
TaskType string `json:"taskType"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
Status string `json:"status"`
|
||||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
Priority int `json:"priority"`
|
||||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
SnapshotID string `json:"snapshotId,omitempty"`
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||||
ErrorCode string `json:"errorCode,omitempty"`
|
SnapshotID string `json:"snapshotId,omitempty"`
|
||||||
BytesAdded int64 `json:"bytesAdded,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
FilesNew int64 `json:"filesNew,omitempty"`
|
ErrorCode string `json:"errorCode,omitempty"`
|
||||||
FilesChanged int64 `json:"filesChanged,omitempty"`
|
BytesAdded int64 `json:"bytesAdded,omitempty"`
|
||||||
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
|
FilesNew int64 `json:"filesNew,omitempty"`
|
||||||
FilesProcessed int64 `json:"filesProcessed,omitempty"`
|
FilesChanged int64 `json:"filesChanged,omitempty"`
|
||||||
RetentionBefore int `json:"retentionBefore,omitempty"`
|
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
|
||||||
RetentionAfter int `json:"retentionAfter,omitempty"`
|
FilesProcessed int64 `json:"filesProcessed,omitempty"`
|
||||||
RetentionRemoved int `json:"retentionRemoved,omitempty"`
|
RetentionBefore int `json:"retentionBefore,omitempty"`
|
||||||
RepositoryBefore int64 `json:"repositoryBefore,omitempty"`
|
RetentionAfter int `json:"retentionAfter,omitempty"`
|
||||||
RepositoryAfter int64 `json:"repositoryAfter,omitempty"`
|
RetentionRemoved int `json:"retentionRemoved,omitempty"`
|
||||||
RepositoryFreed int64 `json:"repositoryFreed,omitempty"`
|
RepositoryBefore int64 `json:"repositoryBefore,omitempty"`
|
||||||
ProgressPercent float64 `json:"progressPercent,omitempty"`
|
RepositoryAfter int64 `json:"repositoryAfter,omitempty"`
|
||||||
ProgressBytes int64 `json:"progressBytes,omitempty"`
|
RepositoryFreed int64 `json:"repositoryFreed,omitempty"`
|
||||||
ProgressTotal int64 `json:"progressTotal,omitempty"`
|
ProgressPercent float64 `json:"progressPercent,omitempty"`
|
||||||
ProgressFiles int64 `json:"progressFiles,omitempty"`
|
ProgressBytes int64 `json:"progressBytes,omitempty"`
|
||||||
ProgressFileTotal int64 `json:"progressFileTotal,omitempty"`
|
ProgressTotal int64 `json:"progressTotal,omitempty"`
|
||||||
BytesPerSecond float64 `json:"bytesPerSecond,omitempty"`
|
ProgressFiles int64 `json:"progressFiles,omitempty"`
|
||||||
SecondsRemaining int64 `json:"secondsRemaining,omitempty"`
|
ProgressFileTotal int64 `json:"progressFileTotal,omitempty"`
|
||||||
CurrentFile string `json:"currentFile,omitempty"`
|
BytesPerSecond float64 `json:"bytesPerSecond,omitempty"`
|
||||||
LiveLog []string `json:"liveLog,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 {
|
type RestoreTask struct {
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var idPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
|
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 {
|
func ValidateConfig(c Config) error {
|
||||||
if c.SchemaVersion != SchemaVersion {
|
if c.SchemaVersion != SchemaVersion {
|
||||||
return fmt.Errorf("unsupported schemaVersion %d", c.SchemaVersion)
|
return fmt.Errorf("unsupported schemaVersion %d", c.SchemaVersion)
|
||||||
}
|
}
|
||||||
repos := make(map[string]Repository, len(c.Repositories))
|
repos := make(map[string]Repository, len(c.Repositories))
|
||||||
|
mountPoints := make(map[string]string)
|
||||||
for _, repo := range c.Repositories {
|
for _, repo := range c.Repositories {
|
||||||
if err := ValidateRepository(repo); err != nil {
|
if err := ValidateRepository(repo); err != nil {
|
||||||
return fmt.Errorf("repository %q: %w", repo.ID, err)
|
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)
|
return fmt.Errorf("duplicate repository id %q", repo.ID)
|
||||||
}
|
}
|
||||||
repos[repo.ID] = repo
|
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))
|
jobs := make(map[string]struct{}, len(c.Jobs))
|
||||||
for _, job := range c.Jobs {
|
for _, job := range c.Jobs {
|
||||||
@@ -50,8 +60,52 @@ func ValidateConfig(c Config) error {
|
|||||||
}
|
}
|
||||||
notifications[target.ID] = struct{}{}
|
notifications[target.ID] = struct{}{}
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(c.Settings.RestoreRoot) {
|
restoreRoot := filepath.Clean(c.Settings.RestoreRoot)
|
||||||
return errors.New("restoreRoot must be absolute")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -178,6 +232,15 @@ func pathWithin(path, root string) bool {
|
|||||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
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 {
|
func ValidateRepository(r Repository) error {
|
||||||
if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) {
|
if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) {
|
||||||
return errors.New("invalid schemaVersion or 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) {
|
if r.Mount.Remote == "" || !filepath.IsAbs(r.Mount.MountPoint) {
|
||||||
return errors.New("managed mount requires remote and absolute 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 != "" {
|
if r.Type == RepositorySFTP && r.CredentialRef != "" {
|
||||||
knownHosts := r.Options["knownHostsPath"]
|
knownHosts := r.Options["knownHostsPath"]
|
||||||
if !filepath.IsAbs(knownHosts) || strings.ContainsAny(knownHosts, " \t\r\n") {
|
if !safePathPattern.MatchString(knownHosts) || filepath.Clean(knownHosts) != knownHosts {
|
||||||
return errors.New("SFTP key authentication requires an absolute knownHostsPath without whitespace")
|
return errors.New("SFTP key authentication requires a clean absolute knownHostsPath containing only letters, digits, dot, underscore, slash, and hyphen")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -118,3 +118,46 @@ func TestRsyncJobRejectsOverlappingPaths(t *testing.T) {
|
|||||||
t.Fatal("rsync target inside source accepted")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,12 @@ type DirectoryEntry struct {
|
|||||||
var browseRoots = []string{"/mnt/user", "/mnt/disks", "/mnt/remotes", "/boot"}
|
var browseRoots = []string{"/mnt/user", "/mnt/disks", "/mnt/remotes", "/boot"}
|
||||||
|
|
||||||
func BrowseDirectories(path string) ([]DirectoryEntry, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -26,17 +31,29 @@ func BrowseDirectories(path string) ([]DirectoryEntry, error) {
|
|||||||
}
|
}
|
||||||
result := make([]DirectoryEntry, 0, len(entries))
|
result := make([]DirectoryEntry, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
|
child := filepath.Join(clean, entry.Name())
|
||||||
result = append(result, DirectoryEntry{Name: entry.Name(), Path: 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) })
|
sort.Slice(result, func(i, j int) bool { return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) })
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func BrowseRoots() []DirectoryEntry {
|
func BrowseRoots() []DirectoryEntry {
|
||||||
result := make([]DirectoryEntry, 0, len(browseRoots))
|
mounts, _ := mountedPaths()
|
||||||
for _, root := range browseRoots {
|
roots := storageBrowseRoots(mounts)
|
||||||
|
result := make([]DirectoryEntry, 0, len(roots))
|
||||||
|
for _, root := range roots {
|
||||||
if info, err := os.Stat(root); err == nil && info.IsDir() {
|
if info, err := os.Stat(root); err == nil && info.IsDir() {
|
||||||
result = append(result, DirectoryEntry{Name: root, Path: root})
|
result = append(result, DirectoryEntry{Name: root, Path: root})
|
||||||
}
|
}
|
||||||
@@ -45,6 +62,11 @@ func BrowseRoots() []DirectoryEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validateBrowsePath(path string) (string, error) {
|
func validateBrowsePath(path string) (string, error) {
|
||||||
|
mounts, _ := mountedPaths()
|
||||||
|
return validateBrowsePathWithRoots(path, storageBrowseRoots(mounts))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBrowsePathWithRoots(path string, roots []string) (string, error) {
|
||||||
if path == "" || path == "/" {
|
if path == "" || path == "/" {
|
||||||
return "", errors.New("validation: select an allowed browse root")
|
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")
|
return "", errors.New("validation: browse path must be absolute")
|
||||||
}
|
}
|
||||||
allowed := false
|
allowed := false
|
||||||
for _, root := range browseRoots {
|
for _, root := range roots {
|
||||||
if clean == root || strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
if clean == root || strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
||||||
allowed = true
|
allowed = true
|
||||||
break
|
break
|
||||||
@@ -66,9 +88,9 @@ func validateBrowsePath(path string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
for _, root := range browseRoots {
|
for _, root := range roots {
|
||||||
if resolved == root || strings.HasPrefix(resolved, root+string(os.PathSeparator)) {
|
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")
|
return "", errors.New("validation: browse path resolves outside allowed Unraid storage roots")
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package platform
|
package platform
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) {
|
func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) {
|
||||||
for _, path := range []string{"", "/", "/etc", "/mnt/user/../../etc", "relative"} {
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.casaderoll.de/michael/urbm/internal/model"
|
"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()
|
result.CredentialFile = f.Name()
|
||||||
if err := f.Chmod(0600); err != nil {
|
if err := f.Chmod(0600); err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
|
os.Remove(result.CredentialFile)
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
if _, err := f.WriteString(credential); err != nil {
|
if _, err := f.WriteString(credential); err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
|
os.Remove(result.CredentialFile)
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
if err := f.Close(); err != nil {
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(result.CredentialFile)
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
options = append(options, "credentials="+result.CredentialFile)
|
options = append(options, "credentials="+result.CredentialFile)
|
||||||
@@ -97,5 +101,10 @@ func (m *MountManager) Cleanup(ctx context.Context, mounted MountedRepository) e
|
|||||||
if !mounted.Mounted {
|
if !mounted.Mounted {
|
||||||
return nil
|
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()
|
return exec.CommandContext(ctx, "umount", mounted.Repository.Location).Run()
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,55 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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) {
|
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) {
|
if !filepath.IsAbs(target) {
|
||||||
return "", errors.New("validation: restore target must be absolute")
|
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 {
|
if err := validateStagingRestoreTarget(clean, restoreRoot); err != nil {
|
||||||
return "", err
|
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
|
return clean, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateStagingRestoreTarget(target, restoreRoot string) error {
|
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 {
|
for _, root := range allowedRoots {
|
||||||
if root == "." || root == "" {
|
if root == "." || root == "" {
|
||||||
continue
|
continue
|
||||||
@@ -45,6 +84,13 @@ func validateStagingRestoreTarget(target, restoreRoot string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func rejectSymlinks(root, target 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)
|
relative, err := filepath.Rel(root, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.casaderoll.de/michael/urbm/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateRestoreTarget(t *testing.T) {
|
func TestValidateRestoreTarget(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := "/mnt/user/urbm-restores"
|
||||||
target := filepath.Join(root, "task")
|
target := filepath.Join(root, "task")
|
||||||
if got, err := ValidateRestoreTarget(target, root, false, false); err != nil || got != target {
|
if got, err := ValidateRestoreTarget(target, root, false, false); err != nil || got != target {
|
||||||
t.Fatalf("staging target rejected: %q %v", got, err)
|
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 {
|
if _, err := ValidateRestoreTarget("/home/root/restore", root, false, false); err == nil {
|
||||||
t.Fatal("staging target outside allowed restore roots accepted")
|
t.Fatal("staging target outside allowed restore roots accepted")
|
||||||
}
|
}
|
||||||
if _, err := ValidateRestoreTarget("/etc", root, true, true); err == nil {
|
if got, err := ValidateRestoreTarget("/mnt/user/data", root, true, true); err != nil || got != "/" {
|
||||||
t.Fatal("protected target accepted")
|
t.Fatalf("confirmed in-place target = %q, %v", got, err)
|
||||||
}
|
}
|
||||||
if _, err := ValidateRestoreTarget("/mnt/user/data", root, true, false); err == nil {
|
if _, err := ValidateRestoreTarget("/mnt/user/data", root, true, false); err == nil {
|
||||||
t.Fatal("unconfirmed in-place restore accepted")
|
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) {
|
func TestValidateRestoreTargetRejectsSymlink(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
real := filepath.Join(root, "real")
|
real := filepath.Join(root, "real")
|
||||||
@@ -36,7 +54,22 @@ func TestValidateRestoreTargetRejectsSymlink(t *testing.T) {
|
|||||||
if err := os.Symlink(real, link); err != nil {
|
if err := os.Symlink(real, link); err != nil {
|
||||||
t.Fatal(err)
|
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")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"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)
|
discovered, err := w.captureMetadata(ctx, job.Type, source.WorkloadID, metadataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.Cleanup(context.Background(), prepared)
|
return Prepared{}, errors.Join(err, w.Cleanup(context.Background(), prepared))
|
||||||
return Prepared{}, err
|
|
||||||
}
|
}
|
||||||
prepared.Sources = append(prepared.Sources, discovered...)
|
prepared.Sources = append(prepared.Sources, discovered...)
|
||||||
if job.Consistency.Mode == "stop" {
|
if job.Consistency.Mode == "stop" {
|
||||||
if err := w.stop(ctx, job.Type, source.WorkloadID, time.Duration(job.ShutdownSecs)*time.Second); err != nil {
|
running, err := w.running(ctx, job.Type, source.WorkloadID)
|
||||||
w.Cleanup(context.Background(), prepared)
|
if err != nil {
|
||||||
return Prepared{}, err
|
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)
|
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 {
|
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-- {
|
for i := len(prepared.Stopped) - 1; i >= 0; i-- {
|
||||||
source := prepared.Stopped[i]
|
source := prepared.Stopped[i]
|
||||||
if err := w.start(ctx, prepared.Kind, source.WorkloadID); err != nil && first == nil {
|
startCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 120*time.Second)
|
||||||
first = err
|
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 != "" {
|
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) {
|
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
|
var paths []string
|
||||||
if len(inspected) > 0 {
|
if len(inspected) > 0 {
|
||||||
for _, mount := range inspected[0].Mounts {
|
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)
|
paths = append(paths, mount.Source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -25,10 +25,12 @@ type Queue struct {
|
|||||||
stopping bool
|
stopping bool
|
||||||
handler Handler
|
handler Handler
|
||||||
onChange OnChange
|
onChange OnChange
|
||||||
|
done chan struct{}
|
||||||
|
doneOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(handler Handler, onChange OnChange) *Queue {
|
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)
|
q.cond = sync.NewCond(&q.mu)
|
||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,7 @@ func (q *Queue) Enqueue(run model.Run) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queue) Run(ctx context.Context) {
|
func (q *Queue) Run(ctx context.Context) {
|
||||||
|
defer q.doneOnce.Do(func() { close(q.done) })
|
||||||
for {
|
for {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
for len(q.pending) == 0 && !q.stopping {
|
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 {
|
func (q *Queue) Cancel(id string) bool {
|
||||||
q.mu.Lock()
|
q.mu.Lock()
|
||||||
defer q.mu.Unlock()
|
defer q.mu.Unlock()
|
||||||
|
|||||||
@@ -90,3 +90,33 @@ func TestPauseAndResumeActiveRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
close(release)
|
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()
|
||||||
|
}
|
||||||
+77
-36
@@ -30,6 +30,18 @@ type Runner struct {
|
|||||||
processes map[string]*os.Process
|
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{}
|
type runIDKey struct{}
|
||||||
|
|
||||||
func WithRunID(ctx context.Context, runID string) context.Context {
|
func WithRunID(ctx context.Context, runID string) context.Context {
|
||||||
@@ -270,32 +282,32 @@ func decodeStats(output []byte) (Stats, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path string) ([]map[string]any, error) {
|
func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path string) ([]map[string]any, error) {
|
||||||
|
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"}
|
args := []string{"ls", snapshot, "--json"}
|
||||||
if path != "" {
|
if path != "" {
|
||||||
args = append(args, path)
|
args = append(args, path)
|
||||||
}
|
}
|
||||||
var output []byte
|
return r.run(ctx, repo, args, func(line []byte) {
|
||||||
if err := r.run(ctx, repo, args, nil, &output); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
items := []map[string]any{}
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(string(output)))
|
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
|
|
||||||
for scanner.Scan() {
|
|
||||||
var item map[string]any
|
var item map[string]any
|
||||||
if json.Unmarshal(scanner.Bytes(), &item) == nil && item["struct_type"] == "node" {
|
if json.Unmarshal(line, &item) == nil && item["struct_type"] == "node" {
|
||||||
items = append(items, item)
|
onItem(item)
|
||||||
}
|
}
|
||||||
}
|
}, nil)
|
||||||
return items, scanner.Err()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) Diff(ctx context.Context, repo model.Repository, before, after string, limit int) (DiffResult, error) {
|
func (r *Runner) Diff(ctx context.Context, repo model.Repository, before, after string, limit int) (DiffResult, error) {
|
||||||
var output []byte
|
result := DiffResult{}
|
||||||
if err := r.run(ctx, repo, []string{"diff", "--metadata", before, after}, nil, &output); err != nil {
|
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
|
return DiffResult{}, err
|
||||||
}
|
}
|
||||||
return parseDiffOutput(output, limit), nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseDiffOutput(output []byte, limit int) DiffResult {
|
func parseDiffOutput(output []byte, limit int) DiffResult {
|
||||||
@@ -306,29 +318,35 @@ func parseDiffOutput(output []byte, limit int) DiffResult {
|
|||||||
scanner := bufio.NewScanner(bytes.NewReader(output))
|
scanner := bufio.NewScanner(bytes.NewReader(output))
|
||||||
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
|
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
appendDiffLine(&result, scanner.Text(), limit)
|
||||||
if len(line) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kind := diffKind(line[0])
|
|
||||||
if kind == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path := strings.TrimSpace(line[1:])
|
|
||||||
if path == "" || !strings.HasPrefix(path, "/") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.Total++
|
|
||||||
if len(result.Entries) == limit {
|
|
||||||
copy(result.Entries, result.Entries[1:])
|
|
||||||
result.Entries[len(result.Entries)-1] = DiffEntry{Kind: kind, Path: path}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.Entries = append(result.Entries, DiffEntry{Kind: kind, Path: path})
|
|
||||||
}
|
}
|
||||||
return result
|
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 {
|
func diffKind(marker byte) string {
|
||||||
switch marker {
|
switch marker {
|
||||||
case '+':
|
case '+':
|
||||||
@@ -408,7 +426,7 @@ func (r *Runner) runWithInputEnvPassword(ctx context.Context, repo model.Reposit
|
|||||||
globalArgs = append(globalArgs, "-o", "sftp.command="+command)
|
globalArgs = append(globalArgs, "-o", "sftp.command="+command)
|
||||||
}
|
}
|
||||||
fullArgs := append(globalArgs, args...)
|
fullArgs := append(globalArgs, args...)
|
||||||
cmd := exec.CommandContext(ctx, r.Binary, fullArgs...)
|
cmd := exec.CommandContext(ctx, r.binary(), fullArgs...)
|
||||||
cmd.Stdin = input
|
cmd.Stdin = input
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
cmd.Cancel = func() error {
|
cmd.Cancel = func() error {
|
||||||
@@ -455,9 +473,11 @@ func (r *Runner) runWithInputEnvPassword(ctx context.Context, repo model.Reposit
|
|||||||
}
|
}
|
||||||
close(done)
|
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()
|
err = cmd.Wait()
|
||||||
<-done
|
<-done
|
||||||
|
errBytes := <-stderrDone
|
||||||
if capture != nil {
|
if capture != nil {
|
||||||
*capture = captured
|
*capture = captured
|
||||||
}
|
}
|
||||||
@@ -466,11 +486,32 @@ func (r *Runner) runWithInputEnvPassword(ctx context.Context, repo model.Reposit
|
|||||||
if errors.Is(ctx.Err(), context.Canceled) {
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
return fmt.Errorf("cancelled: %w", ctx.Err())
|
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 resticError(message, err)
|
||||||
}
|
}
|
||||||
return nil
|
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) {
|
func (r *Runner) writePasswordFile(pattern, password string) (string, func(), error) {
|
||||||
secretDir := filepath.Join(r.RuntimeDir, "secrets")
|
secretDir := filepath.Join(r.RuntimeDir, "secrets")
|
||||||
if err := os.MkdirAll(secretDir, 0700); err != nil {
|
if err := os.MkdirAll(secretDir, 0700); err != nil {
|
||||||
|
|||||||
@@ -233,6 +233,36 @@ printf '%%s\n' 'Files: 1 new, 1 removed, 2 changed'
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestStatsUsesRequestedRepositoryCountingMode(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
argsPath := filepath.Join(dir, "args")
|
argsPath := filepath.Join(dir, "args")
|
||||||
|
|||||||
+75
-23
@@ -23,27 +23,42 @@ type Runner struct {
|
|||||||
processes map[string]*os.Process
|
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 {
|
type Progress struct {
|
||||||
Percent float64
|
Percent float64
|
||||||
Bytes int64
|
Bytes int64
|
||||||
Files int64
|
Files int64
|
||||||
CurrentFile string
|
CurrentFile string
|
||||||
|
Change string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
Bytes int64
|
Bytes int64
|
||||||
Files int64
|
Files int64
|
||||||
|
Deleted int64
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
progressPattern = regexp.MustCompile(`^\s*([0-9,]+)\s+([0-9]+)%`)
|
progressPattern = regexp.MustCompile(`^\s*([0-9,]+)\s+([0-9]+)%`)
|
||||||
filesPattern = regexp.MustCompile(`^Number of regular files transferred:\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`)
|
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) {
|
func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback func(Progress)) (Summary, error) {
|
||||||
args := Arguments(job)
|
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.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
cmd.Cancel = func() error {
|
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{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
scanOutput(stdout, func(line string) {
|
scanOutput(stdout, func(line string) {
|
||||||
if value, ok := parseCount(filesPattern, line); ok {
|
summary.parseLine(line)
|
||||||
summary.Files = value
|
|
||||||
}
|
|
||||||
if value, ok := parseCount(bytesPattern, line); ok {
|
|
||||||
summary.Bytes = value
|
|
||||||
}
|
|
||||||
if callback != nil {
|
if callback != nil {
|
||||||
if progress, ok := parseProgress(line); ok {
|
if progress, ok := parseProgress(line); ok {
|
||||||
callback(progress)
|
callback(progress)
|
||||||
} else if strings.HasPrefix(line, "FILE|") {
|
} else if change, ok := parseChange(line); ok {
|
||||||
callback(Progress{CurrentFile: strings.TrimPrefix(line, "FILE|")})
|
callback(change)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024))
|
stderrDone := make(chan []byte, 1)
|
||||||
err = cmd.Wait()
|
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
|
||||||
|
// Drain both pipes before Wait closes them, including the final statistics.
|
||||||
<-done
|
<-done
|
||||||
|
errBytes := <-stderrDone
|
||||||
|
err = cmd.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(ctx.Err(), context.Canceled) {
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err())
|
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="
|
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.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
cmd.Cancel = func() error {
|
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{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
scanOutput(stdout, func(line string) {
|
scanOutput(stdout, func(line string) {
|
||||||
if value, ok := parseCount(filesPattern, line); ok {
|
summary.parseLine(line)
|
||||||
summary.Files = value
|
|
||||||
}
|
|
||||||
if value, ok := parseCount(bytesPattern, line); ok {
|
|
||||||
summary.Bytes = value
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
close(done)
|
close(done)
|
||||||
}()
|
}()
|
||||||
errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024))
|
stderrDone := make(chan []byte, 1)
|
||||||
err = cmd.Wait()
|
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
|
||||||
|
// Drain both pipes before Wait closes them, including the final statistics.
|
||||||
<-done
|
<-done
|
||||||
|
errBytes := <-stderrDone
|
||||||
|
err = cmd.Wait()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(ctx.Err(), context.Canceled) {
|
if errors.Is(ctx.Err(), context.Canceled) {
|
||||||
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err())
|
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 {
|
func Arguments(job model.Job) []string {
|
||||||
options := job.Rsync
|
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 {
|
if !options.Overwrite {
|
||||||
args = append(args, "--ignore-existing")
|
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) {
|
func splitLinesAndCarriageReturns(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||||
for i, value := range data {
|
for i, value := range data {
|
||||||
if value == '\n' || value == '\r' {
|
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)
|
value, err := strconv.ParseInt(strings.ReplaceAll(match[1], ",", ""), 10, 64)
|
||||||
return value, err == nil
|
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
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
package rsync
|
package rsync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.casaderoll.de/michael/urbm/internal/model"
|
"git.casaderoll.de/michael/urbm/internal/model"
|
||||||
@@ -23,3 +27,146 @@ func TestParseProgress(t *testing.T) {
|
|||||||
t.Fatalf("progress = %#v, %v", progress, ok)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Expression struct{ fields [5]field }
|
type Expression struct {
|
||||||
|
fields [5]field
|
||||||
|
restricted [5]bool
|
||||||
|
}
|
||||||
type field map[int]bool
|
type field map[int]bool
|
||||||
|
|
||||||
var bounds = [5][2]int{{0, 59}, {0, 23}, {1, 31}, {1, 12}, {0, 6}}
|
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)
|
return Expression{}, fmt.Errorf("cron field %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
expression.fields[i] = parsed
|
expression.fields[i] = parsed
|
||||||
|
expression.restricted[i] = part != "*"
|
||||||
}
|
}
|
||||||
return expression, nil
|
return expression, nil
|
||||||
}
|
}
|
||||||
@@ -76,11 +80,19 @@ func parseField(value string, min, max int) (field, error) {
|
|||||||
func (e Expression) Matches(t time.Time) bool {
|
func (e Expression) Matches(t time.Time) bool {
|
||||||
values := [5]int{t.Minute(), t.Hour(), t.Day(), int(t.Month()), int(t.Weekday())}
|
values := [5]int{t.Minute(), t.Hour(), t.Day(), int(t.Month()), int(t.Weekday())}
|
||||||
for i, value := range values {
|
for i, value := range values {
|
||||||
|
if i == 2 || i == 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !e.fields[i][value] {
|
if !e.fields[i][value] {
|
||||||
return false
|
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 {
|
func (e Expression) Next(after time.Time) time.Time {
|
||||||
|
|||||||
@@ -43,3 +43,25 @@ func TestPreviousWithinCatchUpWindow(t *testing.T) {
|
|||||||
t.Fatalf("unexpected catch-up match %v", got)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,21 @@ type Enqueue func(model.Job) error
|
|||||||
type EnqueueMaintenance func(string, string) (model.Run, error)
|
type EnqueueMaintenance func(string, string) (model.Run, error)
|
||||||
type LastRun func(string) time.Time
|
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 {
|
type Scheduler struct {
|
||||||
config ConfigProvider
|
config ConfigProvider
|
||||||
enqueue Enqueue
|
enqueue Enqueue
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
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"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -42,3 +51,41 @@ func TestNotificationMessageIncludesPruneSummary(t *testing.T) {
|
|||||||
t.Fatalf("message contains redundant prune completion details: %q", message)
|
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: ¬ify.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+788
-56
File diff suppressed because it is too large.
Load diff
+131
-3
@@ -1,12 +1,14 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.casaderoll.de/michael/urbm/internal/model"
|
"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) {
|
func (s *Store) LoadRuns() ([]model.Run, error) {
|
||||||
s.mu.RLock()
|
s.mu.Lock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.Unlock()
|
||||||
var runs []model.Run
|
var runs []model.Run
|
||||||
if err := readJSON(s.runsPath(), &runs); errors.Is(err, os.ErrNotExist) {
|
if err := readJSON(s.runsPath(), &runs); errors.Is(err, os.ErrNotExist) {
|
||||||
return []model.Run{}, nil
|
return []model.Run{}, nil
|
||||||
} else if err != 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 nil, err
|
||||||
}
|
}
|
||||||
return runs, nil
|
return runs, nil
|
||||||
@@ -70,8 +80,97 @@ func (s *Store) SaveRuns(runs []model.Run) error {
|
|||||||
return writeJSONAtomic(s.runsPath(), runs, 0600)
|
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) configPath() string { return filepath.Join(s.dir, "config.json") }
|
||||||
func (s *Store) runsPath() string { return filepath.Join(s.dir, "runs.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 {
|
func readJSON(path string, target any) error {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
@@ -79,17 +178,46 @@ func readJSON(path string, target any) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(b, target); err != nil {
|
if err := json.Unmarshal(b, target); err != nil {
|
||||||
return fmt.Errorf("decode %s: %w", path, err)
|
return &jsonDecodeError{path: path, err: err}
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
func writeJSONAtomic(path string, value any, mode os.FileMode) error {
|
||||||
b, err := json.MarshalIndent(value, "", " ")
|
b, err := json.MarshalIndent(value, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
b = append(b, '\n')
|
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-*")
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".urbm-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.casaderoll.de/michael/urbm/internal/model"
|
"git.casaderoll.de/michael/urbm/internal/model"
|
||||||
)
|
)
|
||||||
@@ -28,3 +30,107 @@ func TestStoreInitializesAndPersists(t *testing.T) {
|
|||||||
t.Fatalf("config mode = %o", info.Mode().Perm())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
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_VERSION=0.19.0
|
||||||
RESTIC_SHA256=13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21
|
RESTIC_SHA256=13176fe6d89d4357947a2cd107218ab2873a5f9d8e1ac2d4cd1c8e07e6839c21
|
||||||
BUILD="$ROOT/build"
|
BUILD="$ROOT/build"
|
||||||
@@ -28,7 +30,7 @@ fi
|
|||||||
bzip2 -dc "$RESTIC_ARCHIVE" > "$STAGE/usr/local/libexec/urbm/restic"
|
bzip2 -dc "$RESTIC_ARCHIVE" > "$STAGE/usr/local/libexec/urbm/restic"
|
||||||
chmod 0755 "$STAGE/usr/local/libexec/urbm/restic" "$STAGE/usr/local/sbin/urbmd"
|
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/api.php" "$STAGE/usr/local/emhttp/plugins/urbm/"
|
||||||
cp "$ROOT/webgui/assets/"* "$STAGE/usr/local/emhttp/plugins/urbm/assets/"
|
cp "$ROOT/webgui/assets/"* "$STAGE/usr/local/emhttp/plugins/urbm/assets/"
|
||||||
cp "$ROOT/webgui/images/"* "$STAGE/usr/local/emhttp/plugins/urbm/images/"
|
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/"
|
cp "$ROOT/plugin/doinst.sh" "$ROOT/plugin/slack-desc" "$STAGE/install/"
|
||||||
chmod 0755 "$STAGE/etc/rc.d/rc.urbm" "$STAGE/install/doinst.sh"
|
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")
|
(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}')
|
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"
|
echo "Built $DIST/urbm-${VERSION}-x86_64-1.txz"
|
||||||
+2
-2
@@ -12,7 +12,7 @@ fi
|
|||||||
|
|
||||||
mkdir -p "$CONFIG" "$ROOT_PREFIX/run/urbm" "$ROOT_PREFIX/var/lib/urbm"
|
mkdir -p "$CONFIG" "$ROOT_PREFIX/run/urbm" "$ROOT_PREFIX/var/lib/urbm"
|
||||||
if [ -d "$OLD_CONFIG" ]; then
|
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
|
if [ -e "$OLD_CONFIG/$item" ] && [ ! -e "$CONFIG/$item" ]; then
|
||||||
cp -a "$OLD_CONFIG/$item" "$CONFIG/$item"
|
cp -a "$OLD_CONFIG/$item" "$CONFIG/$item"
|
||||||
fi
|
fi
|
||||||
@@ -48,5 +48,5 @@ rm -f \
|
|||||||
"$ROOT_PREFIX/var/log/$OLD_NAME.log"
|
"$ROOT_PREFIX/var/log/$OLD_NAME.log"
|
||||||
|
|
||||||
if [ -x "$ROOT_PREFIX/etc/rc.d/rc.urbm" ]; then
|
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
|
fi
|
||||||
+35
-14
@@ -4,43 +4,64 @@ DAEMON=/usr/local/sbin/urbmd
|
|||||||
PIDFILE=/run/urbm/urbm.pid
|
PIDFILE=/run/urbm/urbm.pid
|
||||||
LOGFILE=/var/log/urbm.log
|
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() {
|
start() {
|
||||||
mkdir -p /run/urbm /var/lib/urbm /boot/config/plugins/urbm
|
mkdir -p /run/urbm /var/lib/urbm /boot/config/plugins/urbm
|
||||||
chmod 0750 /run/urbm /var/lib/urbm
|
chmod 0750 /run/urbm /var/lib/urbm
|
||||||
chmod 0700 /boot/config/plugins/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
|
return 0
|
||||||
fi
|
fi
|
||||||
rm -f "$PIDFILE" /run/urbm/urbm.sock
|
rm -f "$PIDFILE" /run/urbm/urbm.sock
|
||||||
nohup "$DAEMON" >>"$LOGFILE" 2>&1 &
|
nohup "$DAEMON" >>"$LOGFILE" 2>&1 &
|
||||||
echo $! >"$PIDFILE"
|
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() {
|
stop() {
|
||||||
STOPPED=0
|
STOPPED=0
|
||||||
if [ -s "$PIDFILE" ]; then
|
if [ -s "$PIDFILE" ]; then
|
||||||
PID=$(cat "$PIDFILE")
|
PID=$(cat "$PIDFILE")
|
||||||
kill "$PID" 2>/dev/null || true
|
if is_daemon_pid "$PID"; then
|
||||||
for _ in $(seq 1 30); do
|
kill "$PID" 2>/dev/null || true
|
||||||
kill -0 "$PID" 2>/dev/null || break
|
for _ in $(seq 1 300); do
|
||||||
sleep 1
|
is_daemon_pid "$PID" || break
|
||||||
done
|
sleep 1
|
||||||
kill -9 "$PID" 2>/dev/null || true
|
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"
|
rm -f "$PIDFILE"
|
||||||
STOPPED=1
|
|
||||||
fi
|
fi
|
||||||
if [ "$STOPPED" -eq 0 ]; then
|
if [ "$STOPPED" -eq 0 ]; then
|
||||||
PIDS=$(pidof urbmd 2>/dev/null || true)
|
PIDS=$(pidof urbmd 2>/dev/null || true)
|
||||||
for PID in $PIDS; do
|
for PID in $PIDS; do
|
||||||
kill "$PID" 2>/dev/null || true
|
kill "$PID" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 300); do
|
||||||
[ -z "$(pidof urbmd 2>/dev/null)" ] && break
|
[ -z "$(pidof urbmd 2>/dev/null)" ] && break
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
for PID in $(pidof urbmd 2>/dev/null); do
|
if [ -n "$(pidof urbmd 2>/dev/null)" ]; then
|
||||||
kill -9 "$PID" 2>/dev/null || true
|
echo "urbmd is still recovering workloads; stop did not complete" >&2
|
||||||
done
|
return 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
rm -f /run/urbm/urbm.sock
|
rm -f /run/urbm/urbm.sock
|
||||||
}
|
}
|
||||||
@@ -48,9 +69,9 @@ stop() {
|
|||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
start) start ;;
|
start) start ;;
|
||||||
stop) stop ;;
|
stop) stop ;;
|
||||||
restart) stop; start ;;
|
restart) stop && start ;;
|
||||||
status)
|
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 ;;
|
*) echo "Usage: $0 {start|stop|restart|status}"; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
+110
-2
@@ -2,13 +2,121 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.07.10.r002">
|
<!ENTITY version "2026.09.28.r001">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
|
||||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.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
|
### 2026.07.10.r002
|
||||||
- Include retention results in backup notifications after Restic forget runs, including removed and remaining snapshot counts when available.
|
- 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.
|
- Include repository size before, after, and freed space in prune notifications when Restic statistics are available.
|
||||||
@@ -323,7 +431,7 @@ chmod 0700 /boot/config/plugins/&name;
|
|||||||
</FILE>
|
</FILE>
|
||||||
<FILE Run="/bin/bash" Method="remove">
|
<FILE Run="/bin/bash" Method="remove">
|
||||||
<INLINE>
|
<INLINE>
|
||||||
/etc/rc.d/rc.urbm stop || true
|
/etc/rc.d/rc.urbm stop || exit 1
|
||||||
removepkg &name;
|
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
|
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>
|
</INLINE>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ NEW="$ROOT/boot/config/plugins/urbm"
|
|||||||
mkdir -p "$OLD/secrets" "$ROOT/var/log/plugins" "$ROOT/tmp/plugins"
|
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' '{"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/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"
|
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"
|
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/config.json"
|
||||||
test -f "$NEW/runs.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 -f "$NEW/secrets/secret.json"
|
||||||
test ! -e "$OLD"
|
test ! -e "$OLD"
|
||||||
test ! -e "$ROOT/boot/config/plugins/$OLD_NAME.plg"
|
test ! -e "$ROOT/boot/config/plugins/$OLD_NAME.plg"
|
||||||
|
|||||||
+26
-4
@@ -8,13 +8,15 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
|||||||
---
|
---
|
||||||
<?php
|
<?php
|
||||||
$pluginRoot = '/plugins/urbm';
|
$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=20260710r002">
|
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
|
||||||
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
|
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||||
<header class="bu-header">
|
<header class="bu-header">
|
||||||
<div class="bu-brand">
|
<div class="bu-brand">
|
||||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260710r002" alt="URBM-Logo">
|
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=<?= $urbmAssetVersion ?>" alt="URBM-Logo">
|
||||||
<div><h1>URBM <span class="bu-version">2026.07.10.r002</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
<div><h1>URBM <span class="bu-version"><?= htmlspecialchars($urbmVersion, ENT_QUOTES) ?></span></h1><p>Restic Backup Manager für Unraid</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
|
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -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="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="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="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>
|
<button data-view="settings" data-tooltip="Globale Einstellungen sowie native Unraid- und Gotify-Benachrichtigungen konfigurieren.">Einstellungen</button>
|
||||||
</nav>
|
</nav>
|
||||||
<main id="bu-content" aria-live="polite"></main>
|
<main id="bu-content" aria-live="polite"></main>
|
||||||
<div id="bu-toast" role="status" aria-live="polite"></div>
|
<div id="bu-toast" role="status" aria-live="polite"></div>
|
||||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></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>
|
</div>
|
||||||
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||||
<script src="<?= $pluginRoot ?>/assets/urbm-2026.07.10.r002.js"></script>
|
<script src="<?= $pluginRoot ?>/assets/urbm-<?= rawurlencode($urbmVersion) ?>.js"></script>
|
||||||
+51
-14
@@ -3,20 +3,37 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
$socket = '/run/urbm/urbm.sock';
|
function urbm_bridge_log(string $level, string $message, array $context = []): void
|
||||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
{
|
||||||
$path = $_GET['path'] ?? '/v1/health';
|
$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, '..')) {
|
function urbm_json_error(int $status, string $message): void
|
||||||
http_response_code(400);
|
{
|
||||||
echo json_encode(['error' => 'invalid API path']);
|
http_response_code($status);
|
||||||
|
echo json_encode(['error' => $message], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
exit;
|
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)) {
|
if (!file_exists($socket)) {
|
||||||
http_response_code(503);
|
urbm_bridge_log('warn', 'daemon socket missing', ['path' => $path, 'socket' => $socket]);
|
||||||
echo json_encode(['error' => 'Der URBM-Daemon läuft nicht']);
|
urbm_json_error(503, 'Der URBM-Daemon läuft nicht');
|
||||||
exit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$curl = curl_init();
|
$curl = curl_init();
|
||||||
@@ -26,7 +43,7 @@ curl_setopt_array($curl, [
|
|||||||
CURLOPT_CUSTOMREQUEST => $method,
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_CONNECTTIMEOUT => 3,
|
CURLOPT_CONNECTTIMEOUT => 3,
|
||||||
CURLOPT_TIMEOUT => 310,
|
CURLOPT_TIMEOUT => $isSnapshotFileRequest ? 1250 : 310,
|
||||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -41,10 +58,30 @@ $error = curl_error($curl);
|
|||||||
curl_close($curl);
|
curl_close($curl);
|
||||||
|
|
||||||
if ($response === false) {
|
if ($response === false) {
|
||||||
http_response_code(502);
|
$message = 'Verbindung zum URBM-Daemon fehlgeschlagen: ' . $error;
|
||||||
echo json_encode(['error' => 'Verbindung zum URBM-Daemon fehlgeschlagen: ' . $error]);
|
urbm_bridge_log('warn', 'daemon request failed', ['path' => $path, 'status' => 502, 'error' => $message]);
|
||||||
exit;
|
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;
|
echo $response;
|
||||||
+111
-9
@@ -11,6 +11,7 @@
|
|||||||
--bu-accent-hover: var(--orange-400, #c94a17);
|
--bu-accent-hover: var(--orange-400, #c94a17);
|
||||||
--bu-good: #138a62;
|
--bu-good: #138a62;
|
||||||
--bu-bad: #c93642;
|
--bu-bad: #c93642;
|
||||||
|
--bu-warn: #d46a16;
|
||||||
--bu-focus: rgba(232, 93, 36, .25);
|
--bu-focus: rgba(232, 93, 36, .25);
|
||||||
background: var(--bu-page);
|
background: var(--bu-page);
|
||||||
color: var(--bu-text) !important;
|
color: var(--bu-text) !important;
|
||||||
@@ -20,13 +21,14 @@
|
|||||||
padding: 10px 18px 40px;
|
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-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; }
|
.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; }
|
#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; }
|
.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; }
|
#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.ok { color: var(--bu-good); }
|
||||||
.bu-health.bad { color: var(--bu-bad); }
|
.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-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-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; }
|
#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-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 { 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 line { stroke: var(--bu-border); stroke-width: 1; }
|
||||||
.bu-chart-grid text, .bu-chart-date { fill: var(--bu-muted); font-size: 11px; }
|
.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-area { fill: var(--series-color, var(--bu-accent)); opacity: .10; }
|
||||||
.bu-chart-line { fill: none; stroke: var(--bu-accent); stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; }
|
.bu-chart-line { fill: none; stroke: var(--series-color, var(--bu-accent)); stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; }
|
||||||
.bu-chart-dots circle { fill: var(--bu-panel); stroke: var(--bu-accent); stroke-width: 3; }
|
.bu-chart-dots circle { 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 { fill: var(--bu-accent); opacity: .82; }
|
||||||
.bu-chart-bar:hover { opacity: 1; }
|
.bu-chart-bar:hover { opacity: 1; }
|
||||||
.bu-chart-empty { align-items: center; color: var(--bu-muted); display: flex; height: 240px; justify-content: center; text-align: center; }
|
.bu-chart-empty { align-items: center; color: var(--bu-muted); display: flex; height: 240px; justify-content: center; text-align: center; }
|
||||||
|
.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; }
|
#urbm-app .bu-muted { color: var(--bu-muted) !important; opacity: .72; }
|
||||||
.bu-toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-bottom: 14px; }
|
.bu-toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.bu-actions { display: flex; flex-wrap: wrap; gap: 7px; }
|
.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 { align-items: flex-start; min-width: 310px; }
|
||||||
.bu-table-actions .bu-button { flex: 0 0 auto; }
|
.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; }
|
.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-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-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-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 { min-width: 0; }
|
||||||
.bu-tree-node + .bu-tree-node { border-top: 1px solid color-mix(in srgb, var(--bu-border) 45%, transparent); }
|
.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; }
|
.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-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-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-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-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-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-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; }
|
.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 { 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 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-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%); } }
|
@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-form-grid { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); }
|
||||||
.bu-managed-mount, .bu-external-mount { margin-top: 14px; }
|
.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.primary:hover { background: var(--bu-accent-hover) !important; }
|
||||||
#urbm-app .bu-button.danger { color: var(--bu-bad) !important; }
|
#urbm-app .bu-button.danger { color: var(--bu-bad) !important; }
|
||||||
#urbm-app .bu-button:disabled { cursor: wait; opacity: .55; }
|
#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 { 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, .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 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-table-scroll { max-width: 100%; overflow-x: auto; }
|
||||||
.bu-snapshot-table { table-layout: fixed; }
|
.bu-snapshot-table { table-layout: fixed; }
|
||||||
.bu-snapshot-id { width: 90px; }
|
.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-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-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 { 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.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); }
|
.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.show { display: block; }
|
||||||
#bu-toast.error { border-color: var(--bu-bad); color: var(--bu-bad); }
|
#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 [data-tooltip] { cursor: help; }
|
||||||
#urbm-app button[data-tooltip],
|
#urbm-app button[data-tooltip],
|
||||||
#urbm-app input[data-tooltip],
|
#urbm-app input[data-tooltip],
|
||||||
@@ -268,6 +342,22 @@
|
|||||||
top: -12px;
|
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) {
|
@media (max-width: 700px) {
|
||||||
#urbm-app { padding-left: 8px; padding-right: 8px; }
|
#urbm-app { padding-left: 8px; padding-right: 8px; }
|
||||||
.bu-form { grid-template-columns: 1fr; }
|
.bu-form { grid-template-columns: 1fr; }
|
||||||
@@ -275,11 +365,23 @@
|
|||||||
.bu-retention-grid { grid-template-columns: 1fr; }
|
.bu-retention-grid { grid-template-columns: 1fr; }
|
||||||
.bu-schedule-grid { grid-template-columns: 1fr; }
|
.bu-schedule-grid { grid-template-columns: 1fr; }
|
||||||
.bu-form-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 { grid-template-columns: 1fr; }
|
||||||
.bu-progress-label span { text-align: left; }
|
.bu-progress-label span { text-align: left; }
|
||||||
.bu-header { align-items: flex-start; flex-direction: column; gap: 12px; }
|
.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 { display: block; overflow-x: auto; }
|
||||||
.bu-table-actions { min-width: 240px; }
|
.bu-table-actions { min-width: 240px; }
|
||||||
#bu-tooltip { font-size: 13px; }
|
#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
@@ -6,9 +6,11 @@
|
|||||||
const health = document.getElementById('bu-health');
|
const health = document.getElementById('bu-health');
|
||||||
const toast = document.getElementById('bu-toast');
|
const toast = document.getElementById('bu-toast');
|
||||||
const tooltip = document.getElementById('bu-tooltip');
|
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 sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path}));
|
||||||
const repositoryRoots = ['/mnt/user','/mnt/disks','/mnt/remotes'].map(path=>({name:path,path}));
|
const repositoryRoots = ['/mnt/user','/mnt/disks','/mnt/remotes'].map(path=>({name:path,path}));
|
||||||
const state = { config: null, runs: [], 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 = {
|
const fieldHelp = {
|
||||||
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
|
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
|
||||||
@@ -72,6 +74,7 @@
|
|||||||
'submit-unraid-notify': 'Speichert native Unraid-Benachrichtigungen. Die Zustellung erfolgt über die in Unraid eingerichteten Notification Agents.',
|
'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.',
|
'submit-gotify-notify': 'Speichert den Gotify-Kanal und das App-Token verschlüsselt.',
|
||||||
'refresh': 'Lädt Laufstatus und Historie erneut vom URBM-Daemon.',
|
'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.',
|
'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.',
|
'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.',
|
'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' : ''}`;
|
toast.textContent = message; toast.className = `show${error ? ' error' : ''}`;
|
||||||
clearTimeout(notify.timer); notify.timer = setTimeout(() => toast.className = '', 4500);
|
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 = () => {
|
const uuid = () => {
|
||||||
if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID();
|
if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID();
|
||||||
const bytes = new Uint8Array(16);
|
const bytes = new Uint8Array(16);
|
||||||
@@ -180,8 +230,12 @@
|
|||||||
const runTarget = run => {
|
const runTarget = run => {
|
||||||
if(run.taskType==='backup'||run.taskType==='rsync') return state.config.jobs.find(job=>job.id===run.jobId)?.name || 'Unbekannter Job';
|
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(['check','prune'].includes(run.taskType)) return state.config.repositories.find(repo=>repo.id===run.jobId)?.name || 'Unbekanntes Repository';
|
||||||
if(run.taskType==='restore') {
|
if(run.taskType==='restore') {
|
||||||
const parts=String(run.jobId||'').split('\u0000');
|
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) {
|
if(parts.length>=4) {
|
||||||
const repo=state.config.repositories.find(candidate=>candidate.id===parts[0])?.name || 'Repository';
|
const repo=state.config.repositories.find(candidate=>candidate.id===parts[0])?.name || 'Repository';
|
||||||
return `${repo} · ${parts[1].slice(0,12)} → ${parts[2]}`;
|
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 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 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 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 formatBytes = (value) => {
|
||||||
const units = ['B','KiB','MiB','GiB','TiB']; let size = Number(value)||0; let unit = 0;
|
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++; }
|
while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; }
|
||||||
@@ -203,6 +259,13 @@
|
|||||||
const minutes=Math.floor(seconds/60); seconds%=60;
|
const minutes=Math.floor(seconds/60); seconds%=60;
|
||||||
return hours?`${hours}h ${minutes}m`:minutes?`${minutes}m ${seconds}s`:`${seconds}s`;
|
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'}];
|
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 * * *') {
|
function parseSchedule(cron = '0 2 * * *') {
|
||||||
const value=String(cron).trim();
|
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>`;
|
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) {
|
function lineChart(runs, field, formatter, valueLabel, emptyText, days=14) {
|
||||||
const points = runs.map(run=>({run,value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)}));
|
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>`;
|
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 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 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 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 polyline=coords.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');
|
|
||||||
const area=`${left},${top+innerH} ${polyline} ${left+innerW},${top+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 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 grouped=new Map();
|
||||||
const first=new Date(points[0].run.finishedAt||points[0].run.createdAt).toLocaleDateString();
|
coords.forEach(point=>{if(!grouped.has(point.run.jobId))grouped.set(point.run.jobId,[]);grouped.get(point.run.jobId).push(point);});
|
||||||
const last=new Date(points.at(-1).run.finishedAt||points.at(-1).run.createdAt).toLocaleDateString();
|
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}));
|
||||||
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>`;
|
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('');
|
||||||
function repositoryChart(items, field, formatter, emptyText) {
|
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 points=items.filter(item=>!item.error).map(item=>({item,value:Number(item[field]||0)}));
|
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>`:'';
|
||||||
if(!points.length||points.every(point=>point.value===0)) return `<div class="bu-chart-empty">${esc(emptyText)}</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>`;
|
||||||
const width=720,height=220,left=58,right=18,top=18,bottom=48,innerW=width-left-right,innerH=height-top-bottom;
|
|
||||||
const max=Math.max(...points.map(point=>point.value),1); const slot=innerW/points.length; const barW=Math.min(70,slot*.62);
|
|
||||||
const grid=[0,.25,.5,.75,1].map(ratio=>{const y=top+innerH-ratio*innerH;return `<line x1="${left}" y1="${y}" x2="${left+innerW}" y2="${y}"/><text x="${left-8}" y="${y+4}" text-anchor="end">${esc(formatter(max*ratio))}</text>`;}).join('');
|
|
||||||
const bars=points.map((point,index)=>{const x=left+slot*index+(slot-barW)/2;const h=(point.value/max)*innerH;const y=top+innerH-h;const label=point.item.repositoryName.length>16?`${point.item.repositoryName.slice(0,15)}…`:point.item.repositoryName;return `<rect class="bu-chart-bar" x="${x}" y="${y}" width="${barW}" height="${Math.max(h,1)}"><title>${esc(`${point.item.repositoryName} · ${formatter(point.value)} · ${point.item.snapshotCount} Snapshot(s)`)}</title></rect><text class="bu-chart-date" x="${x+barW/2}" y="${height-18}" text-anchor="middle">${esc(label)}</text>`;}).join('');
|
|
||||||
return `<svg class="bu-chart" viewBox="0 0 ${width} ${height}" role="img"><g class="bu-chart-grid">${grid}</g>${bars}</svg>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTooltip(element, text) {
|
function setTooltip(element, text) {
|
||||||
@@ -341,25 +406,33 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [config, runs, daemon] = await Promise.all([api('/v1/config'), api('/v1/runs'), api('/v1/health')]);
|
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')]);
|
||||||
state.config = config; state.runs = newestRuns(runs);
|
if(configResult.status!=='fulfilled') throw configResult.reason;
|
||||||
health.textContent = `Daemon online (${daemon.version || 'unbekannt'})`; health.className = 'bu-health ok'; render(); loadRepositoryStats();
|
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) {
|
} catch (error) {
|
||||||
health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad';
|
health.textContent = 'Daemon nicht erreichbar'; health.className = 'bu-health bad';
|
||||||
content.innerHTML = `<div class="bu-card"><h2>Verbindung fehlgeschlagen</h2><p>${esc(error.message)}</p></div>`;
|
content.innerHTML = `<div class="bu-card"><h2>Verbindung fehlgeschlagen</h2><p>${esc(error.message)}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRepositoryStats() {
|
async function reloadDashboardData() {
|
||||||
if(state.repositoryStatsLoading||!state.config) return;
|
const [metricsResult,statusResult]=await Promise.allSettled([api('/v1/backup-metrics'),api('/v1/dashboard-status')]);
|
||||||
state.repositoryStatsLoading=true; if(state.view==='dashboard') render();
|
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.';
|
||||||
try { state.repositoryStats=await api('/v1/repositories/stats'); }
|
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.';
|
||||||
catch(error) { state.repositoryStats=state.config.repositories.map(repo=>({repositoryId:repo.id,repositoryName:repo.name,error:error.message})); }
|
render();
|
||||||
finally { state.repositoryStatsLoading=false; if(state.view==='dashboard') render(); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, settings:renderSettings};
|
const views = {dashboard:renderDashboard, jobs:renderJobs, repositories:renderRepositories, snapshots:renderSnapshots, restore:renderRestore, activity:renderActivity, logs:renderLogs, settings:renderSettings};
|
||||||
|
document.querySelectorAll('.bu-action-menu[open]').forEach(closeActionMenu);
|
||||||
|
menuPortal?.replaceChildren();
|
||||||
content.innerHTML = views[state.view]();
|
content.innerHTML = views[state.view]();
|
||||||
enhanceTooltips(content);
|
enhanceTooltips(content);
|
||||||
content.querySelectorAll('.bu-file-select[data-partial="true"]').forEach(box=>{box.indeterminate=true;box.setAttribute('aria-checked','mixed');});
|
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"]');
|
if(!state.directoryTrees.persistentLogDir) initDirectoryTree('persistentLogDir','Protokollverzeichnis auswählen','#bu-settings-form [name="persistentLogDir"]');
|
||||||
else renderDirectoryTree('persistentLogDir');
|
else renderDirectoryTree('persistentLogDir');
|
||||||
}
|
}
|
||||||
|
if(state.view==='logs'&&!state.logs&&!state.logsLoading&&!state.logsError) loadLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureScrollState(root=document) {
|
function captureScrollState(root=document) {
|
||||||
@@ -398,28 +472,62 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboard() {
|
function renderDashboard() {
|
||||||
const active = state.runs.filter(r => ['queued','running','paused'].includes(r.status)).length;
|
|
||||||
const orderedRuns = newestRuns(state.runs);
|
const orderedRuns = newestRuns(state.runs);
|
||||||
const failures = orderedRuns.filter(r => r.status === 'failed').slice(0, 5);
|
const activeRuns = orderedRuns.filter(r => ['queued','running','paused'].includes(r.status));
|
||||||
const last = orderedRuns.slice(0, 8);
|
const active = activeRuns.length;
|
||||||
const stats=state.repositoryStats;
|
const failures = orderedRuns.filter(r => r.taskType==='backup'&&r.status === 'failed').slice(0, 5);
|
||||||
const statsErrors=stats.filter(item=>item.error);
|
const last = orderedRuns.slice(0, 6);
|
||||||
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>`:'';
|
const backupJobs=state.config.jobs.filter(job=>job.type!=='rsync');
|
||||||
return `<div class="bu-grid">
|
const orderedMetrics=[...state.backupMetrics].sort((a,b)=>new Date(b.finishedAt)-new Date(a.finishedAt));
|
||||||
<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>
|
if(!state.dashboardJobId) state.dashboardJobId=orderedMetrics.find(metric=>backupJobs.some(job=>job.id===metric.jobId))?.jobId||backupJobs[0]?.id||'all';
|
||||||
<section class="bu-card"><div class="bu-muted">Repositorys</div><div class="bu-stat">${state.config.repositories.length}</div></section>
|
if(state.dashboardJobId!=='all'&&!backupJobs.some(job=>job.id===state.dashboardJobId)) state.dashboardJobId='all';
|
||||||
<section class="bu-card"><div class="bu-muted">Wartend / laufend</div><div class="bu-stat">${active}</div></section>
|
const backups=orderedMetrics.filter(metric=>state.dashboardJobId==='all'||metric.jobId===state.dashboardJobId);
|
||||||
<section class="bu-card"><div class="bu-muted">Letzte Fehler</div><div class="bu-stat">${failures.length}</div></section>
|
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('')}`;
|
||||||
</div><div class="bu-chart-grid-layout">
|
const needsSetup=!state.config.jobs.length||!state.config.repositories.length;
|
||||||
<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>
|
const dashboardStatus=state.dashboardStatus||{};
|
||||||
<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>
|
const lastSuccess=dashboardStatus.lastSuccessfulBackup||orderedMetrics.find(metric=>metric.status==='success');
|
||||||
</div><section class="bu-card" style="margin-top:16px"><h2>Letzte Aktivitäten</h2>${runsTable(last)}</section>`;
|
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() {
|
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('');
|
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>${button('Neuer Job','new-job','primary')}</div>
|
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 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>`;
|
<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 = {}) {
|
function jobForm(job = {}) {
|
||||||
@@ -429,16 +537,15 @@
|
|||||||
resetSourceTree(sourceRoots);
|
resetSourceTree(sourceRoots);
|
||||||
delete state.directoryTrees.rsyncTarget;
|
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('');
|
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">
|
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 || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
|
<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>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>
|
<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)}
|
${scheduleFields(job)}
|
||||||
<div id="bu-job-specific" class="wide"></div>
|
<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>
|
<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 bu-form-actions">${button('Abbrechen','cancel-form')}${button('Job speichern','submit-job','primary')}</div></form></section>`;
|
||||||
<div class="wide bu-actions">${button('Speichern','submit-job','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
|
|
||||||
enhanceTooltips(content);
|
enhanceTooltips(content);
|
||||||
renderJobSpecific(job.type || 'share', job);
|
renderJobSpecific(job.type || 'share', job);
|
||||||
}
|
}
|
||||||
@@ -457,7 +564,7 @@
|
|||||||
loadWorkloads(type);
|
loadWorkloads(type);
|
||||||
} else if (type === 'rsync') {
|
} else if (type === 'rsync') {
|
||||||
const o=job.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>`);
|
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"]');
|
initSourceTree(sourceRoots.map(item=>item.path), false); initDirectoryTree('rsyncTarget','Rsync-Ziel auswählen','#bu-job-form [name="rsyncTarget"]');
|
||||||
} else {
|
} else {
|
||||||
@@ -615,11 +722,11 @@
|
|||||||
|
|
||||||
function renderRepositories() {
|
function renderRepositories() {
|
||||||
const rows = state.config.repositories.map(r => {
|
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 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 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');
|
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><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>`;
|
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('');
|
}).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) {
|
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>
|
<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>
|
<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>`}
|
${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);
|
enhanceTooltips(content);
|
||||||
renderRepositorySpecific(repo.type || 'local', repo);
|
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() : ''}`;
|
return `<div class="bu-toolbar"><h2>Snapshots</h2><div class="bu-actions"><select id="bu-snapshot-repo">${options}</select>${button('Laden','load-snapshots','primary')}</div></div><section class="bu-card">${rows?`<div class="bu-table-scroll"><table class="bu-table bu-snapshot-table"><colgroup><col class="bu-snapshot-id"><col class="bu-snapshot-time"><col class="bu-snapshot-host"><col class="bu-snapshot-paths"><col class="bu-snapshot-actions"></colgroup><thead><tr><th>Snapshot</th><th>Zeitpunkt</th><th>Host</th><th>Pfade</th><th></th></tr></thead><tbody>${rows}</tbody></table></div>`:'<div class="bu-empty">Repository auswählen und Snapshots laden.</div>'}</section>${state.selectedSnapshot ? renderFileBrowser() : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotItemPath(item) {
|
|
||||||
return String(item.path || item.name || '').replace(/\/+/g,'/').replace(/\/$/,'') || '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSnapshotTree(items) {
|
|
||||||
const nodes=new Map();
|
|
||||||
const ensure=(path,type='dir',size=0)=>{
|
|
||||||
path=path.replace(/\/+/g,'/').replace(/\/$/,'')||'/';
|
|
||||||
if(nodes.has(path)) return nodes.get(path);
|
|
||||||
const parentPath=path==='/'?'':path.split('/').slice(0,-1).join('/')||'/';
|
|
||||||
const name=path==='/'?'/':path.split('/').pop();
|
|
||||||
const node={path,name,type,size:Number(size||0),children:[]};
|
|
||||||
nodes.set(path,node);
|
|
||||||
if(path!=='/') ensure(parentPath).children.push(node);
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
ensure('/');
|
|
||||||
items.forEach(item=>{
|
|
||||||
const path=snapshotItemPath(item);
|
|
||||||
if(!path||path==='/') return;
|
|
||||||
const type=item.type==='dir'?'dir':'file';
|
|
||||||
const node=ensure(path,type,item.size);
|
|
||||||
node.type=type; node.size=Number(item.size||0);
|
|
||||||
});
|
|
||||||
nodes.forEach(node=>node.children.sort((a,b)=>(a.type===b.type?a.name.localeCompare(b.name):a.type==='dir'?-1:1)));
|
|
||||||
return nodes.get('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotSelectionState(path) {
|
function snapshotSelectionState(path) {
|
||||||
const exact=state.restoreIncludes.includes(path);
|
const exact=state.restoreIncludes.includes(path);
|
||||||
const prefix=path.replace(/\/$/,'')+'/';
|
const prefix=path.replace(/\/$/,'')+'/';
|
||||||
@@ -706,26 +785,46 @@
|
|||||||
const selection=snapshotSelectionState(node.path);
|
const selection=snapshotSelectionState(node.path);
|
||||||
const isDir=node.type==='dir';
|
const isDir=node.type==='dir';
|
||||||
const expanded=state.snapshotTreeExpanded[node.path]===true;
|
const expanded=state.snapshotTreeExpanded[node.path]===true;
|
||||||
const toggle=isDir?`<button class="bu-tree-toggle ${expanded?'expanded':''}" type="button" data-action="toggle-snapshot-tree:${encodeURIComponent(node.path)}" aria-expanded="${expanded}"><span>▶</span></button>`:'<span></span>';
|
const canExpand=isDir&&node.hasChildren;
|
||||||
|
const loading=state.snapshotTreeNodeLoading[node.path]===true;
|
||||||
|
const children=state.snapshotTreeChildren[node.path]||[];
|
||||||
|
const error=state.snapshotTreeErrors[node.path]||'';
|
||||||
|
const toggle=canExpand?`<button class="bu-tree-toggle ${expanded?'expanded':''}" type="button" data-action="toggle-snapshot-tree:${encodeURIComponent(node.path)}" aria-expanded="${expanded}"><span>▶</span></button>`:'<span></span>';
|
||||||
const label=selection.partial?'<span class="bu-partial-label">Teilweise ausgewählt</span>':'';
|
const label=selection.partial?'<span class="bu-partial-label">Teilweise ausgewählt</span>':'';
|
||||||
let html=`<div class="bu-tree-node ${selection.partial?'bu-source-partial':''}" style="--bu-tree-depth:${depth}"><div class="bu-tree-row"><div>${toggle}</div><input type="checkbox" class="bu-file-select" value="${esc(node.path)}" ${selection.exact?'checked':''} data-partial="${selection.partial?'true':'false'}"><button class="bu-tree-name" type="button" ${isDir?`data-action="toggle-snapshot-tree:${encodeURIComponent(node.path)}"`:''}><span>${esc(node.name)}</span>${label}<small>${esc(node.path)}${!isDir&&node.size?` · ${formatBytes(node.size)}`:''}</small></button></div>`;
|
let html=`<div class="bu-tree-node ${selection.partial?'bu-source-partial':''}" style="--bu-tree-depth:${depth}"><div class="bu-tree-row"><div>${toggle}</div><input type="checkbox" class="bu-file-select" value="${esc(node.path)}" ${selection.exact?'checked':''} data-partial="${selection.partial?'true':'false'}"><button class="bu-tree-name" type="button" ${canExpand?`data-action="toggle-snapshot-tree:${encodeURIComponent(node.path)}"`:''}><span>${esc(node.name)}</span>${label}<small>${esc(node.path)}${!isDir&&node.size?` · ${formatBytes(node.size)}`:''}</small></button></div>`;
|
||||||
if(isDir&&expanded) html+=`<div class="bu-tree-children">${node.children.length?node.children.map(child=>snapshotTreeNode(child,depth+1)).join(''):'<div class="bu-tree-state">Keine Einträge.</div>'}</div>`;
|
if(canExpand&&expanded) {
|
||||||
|
let body='';
|
||||||
|
if(loading) body='<div class="bu-tree-state">Snapshot-Ordner wird geladen…</div>';
|
||||||
|
else if(error) body=`<div class="bu-tree-state error">${esc(error)} ${button('Erneut versuchen',`retry-snapshot-tree:${encodeURIComponent(node.path)}`)}</div>`;
|
||||||
|
else body=children.length?children.map(child=>snapshotTreeNode(child,depth+1)).join(''):'<div class="bu-tree-state">Keine Einträge.</div>';
|
||||||
|
html+=`<div class="bu-tree-children">${body}</div>`;
|
||||||
|
}
|
||||||
return html+'</div>';
|
return html+'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFileBrowser() {
|
function renderFileBrowser() {
|
||||||
const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">Nichts ausgewählt.</span>';
|
const chosen = state.restoreIncludes.length ? state.restoreIncludes.map(path=>`<span class="bu-selection">${esc(path)}</span>`).join('') : '<span class="bu-muted">Nichts ausgewählt.</span>';
|
||||||
const loading='<div class="bu-snapshot-loading"><div class="bu-progress-wrap"><div class="bu-progress-label"><strong>Snapshot-Struktur wird vollständig eingelesen…</strong><span>Kein Prozentwert verfügbar, Restic liefert die Dateiliste erst am Ende zurück.</span></div><div class="bu-progress indeterminate"><span style="width:30%"></span></div></div></div>';
|
const loading='<div class="bu-snapshot-loading"><div class="bu-progress-wrap"><div class="bu-progress-label"><strong>Snapshot-Index wird eingelesen…</strong><span>Der Daemon baut daraus intern einen Baum. Danach werden nur sichtbare Ordner übertragen.</span></div><div class="bu-progress indeterminate"><span style="width:30%"></span></div></div></div>';
|
||||||
const body=state.snapshotTreeLoading?loading:state.snapshotTreeError?`<div class="bu-inline-error">${esc(state.snapshotTreeError)}</div>`:state.snapshotTree?snapshotTreeNode(state.snapshotTree,0):'<div class="bu-empty">Keine Dateien im Snapshot gefunden.</div>';
|
const body=state.snapshotTreeLoading?loading:state.snapshotTreeError?`<div class="bu-inline-error">${esc(state.snapshotTreeError)}</div>`:state.snapshotTree?snapshotTreeNode(state.snapshotTree,0):'<div class="bu-empty">Keine Dateien im Snapshot gefunden.</div>';
|
||||||
return `<section class="bu-card" style="margin-top:16px"><div class="bu-toolbar"><div><h3>Snapshot ${esc(state.selectedSnapshot.slice(0,12))}</h3><span class="bu-muted">${state.files.length?`${state.files.length.toLocaleString()} Einträge vorgeladen`:'Struktur noch nicht geladen'}</span></div><div class="bu-actions">${button(`Auswahl wiederherstellen (${state.restoreIncludes.length})`,'restore-selected','primary')}</div></div><div class="bu-selection-list">${chosen}</div><div class="bu-tree" role="tree">${body}</div></section>`;
|
const total=state.snapshotTreeMeta?.total||0;
|
||||||
|
const loaded=Object.values(state.snapshotTreeChildren).reduce((sum,items)=>sum+items.length,0);
|
||||||
|
const hint=total?`${Number(total).toLocaleString()} Einträge im Snapshot · ${Number(loaded).toLocaleString()} sichtbare Einträge geladen`:'Struktur noch nicht geladen';
|
||||||
|
return `<section class="bu-card 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() {
|
function renderRestore() {
|
||||||
const options = state.config.repositories.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
|
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 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) {
|
function progressView(run) {
|
||||||
if(run.status==='queued') return '<span class="bu-muted">Wartet in der Warteschlange</span>';
|
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>';
|
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) {
|
function liveLogView(run) {
|
||||||
if(!(run.liveLog||[]).length) return '';
|
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>`;
|
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) {
|
function runsTable(runs) {
|
||||||
@@ -772,11 +871,25 @@
|
|||||||
notify('Konfiguration gespeichert');
|
notify('Konfiguration gespeichert');
|
||||||
return state.config;
|
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() {
|
function renderPreservingScroll() {
|
||||||
const scrollSnapshot=captureScrollState(content);
|
const scrollSnapshot=captureScrollState(content);
|
||||||
render();
|
render();
|
||||||
restoreScrollState(scrollSnapshot, content);
|
restoreScrollState(scrollSnapshot, content);
|
||||||
}
|
}
|
||||||
|
async function loadSnapshotChildren(path) {
|
||||||
|
const query = path && path !== '/' ? `?path=${encodeURIComponent(path)}` : '';
|
||||||
|
const page = await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${state.selectedSnapshot}/files${query}`);
|
||||||
|
const items = Array.isArray(page) ? page : (page.items || []);
|
||||||
|
state.snapshotTreeChildren[path || '/'] = items;
|
||||||
|
state.snapshotTreeMeta = Array.isArray(page) ? { total: items.length } : page;
|
||||||
|
return items;
|
||||||
|
}
|
||||||
const lines = (value) => value.split('\n').map(v=>v.trim()).filter(Boolean);
|
const lines = (value) => value.split('\n').map(v=>v.trim()).filter(Boolean);
|
||||||
const runTime = run => new Date(run.createdAt || run.startedAt || run.finishedAt || 0).getTime() || 0;
|
const runTime = run => new Date(run.createdAt || run.startedAt || run.finishedAt || 0).getTime() || 0;
|
||||||
const snapshotTime = snapshot => new Date(snapshot.time || 0).getTime() || 0;
|
const snapshotTime = snapshot => new Date(snapshot.time || 0).getTime() || 0;
|
||||||
@@ -794,29 +907,33 @@
|
|||||||
if (name === 'change-repo-password') return repositoryPasswordForm(state.config.repositories.find(r=>r.id===a));
|
if (name === 'change-repo-password') return repositoryPasswordForm(state.config.repositories.find(r=>r.id===a));
|
||||||
if (name === 'cancel-form') return render();
|
if (name === 'cancel-form') return render();
|
||||||
if (name === 'refresh') return load();
|
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 === '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 === '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 === '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 === '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 === '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 === '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(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 === '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(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 === '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 === 'maint') { await api(`/v1/repositories/${b}/${a}`,{method:'POST'}); notify(`${a==='check'?'Prüfung':'Bereinigung'} wurde eingereiht`); return load(); }
|
||||||
if (name === 'repo-snapshots') { state.view='snapshots'; document.querySelector('[data-view="snapshots"]').click(); render(); setTimeout(()=>{document.getElementById('bu-snapshot-repo').value=a; handle('load-snapshots');},0); return; }
|
if (name === 'repo-snapshots') { state.view='snapshots'; document.querySelector('[data-view="snapshots"]').click(); render(); setTimeout(()=>{document.getElementById('bu-snapshot-repo').value=a; handle('load-snapshots');},0); return; }
|
||||||
if (name === 'load-snapshots') { const repo=document.getElementById('bu-snapshot-repo').value; state.snapshots=newestSnapshots(await api(`/v1/repositories/${repo}/snapshots`)); state.snapshotRepo=repo; return render(); }
|
if (name === 'load-snapshots') { const repo=document.getElementById('bu-snapshot-repo').value; state.snapshots=newestSnapshots(await api(`/v1/repositories/${repo}/snapshots`)); state.snapshotRepo=repo; return render(); }
|
||||||
if (name === 'browse') { state.selectedSnapshot=a; state.restoreIncludes=[]; state.files=[]; state.snapshotTree=null; state.snapshotTreeError=''; state.snapshotTreeExpanded={'/':true}; state.snapshotTreeLoading=true; render(); try { state.files=await api(`/v1/repositories/${state.snapshotRepo}/snapshots/${a}/files`); state.snapshotTree=buildSnapshotTree(state.files); } catch(error) { state.snapshotTreeError=`Snapshot-Struktur konnte nicht geladen werden: ${error.message}`; } finally { state.snapshotTreeLoading=false; return render(); } }
|
if (name === 'browse') { state.selectedSnapshot=a; state.restoreIncludes=[]; state.files=[]; state.snapshotTree={path:'/',name:'/',type:'dir',hasChildren:true}; state.snapshotTreeMeta=null; state.snapshotTreeChildren={}; state.snapshotTreeNodeLoading={}; state.snapshotTreeErrors={}; state.snapshotTreeError=''; state.snapshotTreeExpanded={'/':true}; state.snapshotTreeLoading=true; render(); try { state.files=await loadSnapshotChildren('/'); } catch(error) { state.snapshotTreeError=`Snapshot-Struktur konnte nicht geladen werden: ${error.message}. Details stehen im Tab Protokolle.`; } finally { state.snapshotTreeLoading=false; return render(); } }
|
||||||
if (name === 'toggle-source-tree') { return toggleSourceTree(decodeURIComponent(a)); }
|
if (name === 'toggle-source-tree') { return toggleSourceTree(decodeURIComponent(a)); }
|
||||||
if (name === 'retry-source-tree') { const path=decodeURIComponent(a); delete state.sourceTreeChildren[path]; state.sourceTreeErrors[path]=''; return toggleSourceTree(path,true); }
|
if (name === 'retry-source-tree') { const path=decodeURIComponent(a); delete state.sourceTreeChildren[path]; state.sourceTreeErrors[path]=''; return toggleSourceTree(path,true); }
|
||||||
if (name === 'toggle-dir-tree') { return toggleDirectoryTree(a, decodeURIComponent(b)); }
|
if (name === 'toggle-dir-tree') { return toggleDirectoryTree(a, decodeURIComponent(b)); }
|
||||||
if (name === 'retry-dir-tree') { const path=decodeURIComponent(b); const tree=state.directoryTrees[a]; if(tree) { delete tree.children[path]; tree.errors[path]=''; } return toggleDirectoryTree(a,path,true); }
|
if (name === 'retry-dir-tree') { const path=decodeURIComponent(b); const tree=state.directoryTrees[a]; if(tree) { delete tree.children[path]; tree.errors[path]=''; } return toggleDirectoryTree(a,path,true); }
|
||||||
if (name === 'select-dir-tree') { selectDirectoryTreePath(a, decodeURIComponent(b)); return; }
|
if (name === 'select-dir-tree') { selectDirectoryTreePath(a, decodeURIComponent(b)); return; }
|
||||||
if (name === 'toggle-snapshot-tree') { const path=decodeURIComponent(a); state.snapshotTreeExpanded[path]=!state.snapshotTreeExpanded[path]; return renderPreservingScroll(); }
|
if (name === 'toggle-snapshot-tree') { const path=decodeURIComponent(a); if(state.snapshotTreeExpanded[path]) { state.snapshotTreeExpanded[path]=false; return renderPreservingScroll(); } state.snapshotTreeExpanded[path]=true; renderPreservingScroll(); if(Object.prototype.hasOwnProperty.call(state.snapshotTreeChildren,path)&&!state.snapshotTreeErrors[path]) return; state.snapshotTreeNodeLoading[path]=true; state.snapshotTreeErrors[path]=''; renderPreservingScroll(); try { await loadSnapshotChildren(path); } catch(error) { state.snapshotTreeErrors[path]=`Snapshot-Ordner konnte nicht geladen werden: ${error.message}`; } finally { state.snapshotTreeNodeLoading[path]=false; return renderPreservingScroll(); } }
|
||||||
|
if (name === 'retry-snapshot-tree') { const path=decodeURIComponent(a); delete state.snapshotTreeChildren[path]; state.snapshotTreeErrors[path]=''; state.snapshotTreeExpanded[path]=false; return handle(`toggle-snapshot-tree:${encodeURIComponent(path)}`); }
|
||||||
if (name === 'restore-selected') { if(!state.restoreIncludes.length) throw new Error('Wähle mindestens eine Datei oder einen Ordner aus.'); state.view='restore'; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view==='restore')); return render(); }
|
if (name === 'restore-selected') { if(!state.restoreIncludes.length) throw new Error('Wähle mindestens eine Datei oder einen Ordner aus.'); state.view='restore'; document.querySelectorAll('.bu-tabs button').forEach(x=>x.classList.toggle('active',x.dataset.view==='restore')); return render(); }
|
||||||
if (name === 'delete-job') { if(confirm('Diesen Job löschen?')) { state.config.jobs=state.config.jobs.filter(j=>j.id!==a); await saveConfig(); render(); } return; }
|
if (name === 'delete-job') { 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 === '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 === 'adopt-repo-password') { await submitRepositoryPassword(true); return; }
|
||||||
if (name.startsWith('submit-')) { await submit(name); return; }
|
if (name.startsWith('submit-')) { await submit(name); return; }
|
||||||
} catch (error) { notify(error.message, true); }
|
} 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.');
|
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'}:{};
|
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.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.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&&!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.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)};
|
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();
|
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')||'');
|
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(!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(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(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')})});
|
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);
|
state.config.repositories=state.config.repositories.filter(r=>r.id!==repo.id).concat(repo);
|
||||||
@@ -878,16 +995,90 @@
|
|||||||
const password=String(f.get('password')||'');
|
const password=String(f.get('password')||'');
|
||||||
if(password.length<8) throw new Error('Das Repository-Passwort muss mindestens 8 Zeichen lang sein.');
|
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.');
|
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.';
|
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(!confirm(prompt)) return;
|
if(!accepted) return;
|
||||||
const suffix=adopt?'/password/adopt':'/password';
|
const suffix=adopt?'/password/adopt':'/password';
|
||||||
await api(`/v1/repositories/${encodeURIComponent(f.get('id'))}${suffix}`,{method:'POST',body:JSON.stringify({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();
|
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(); });
|
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(); });
|
||||||
content.addEventListener('click', e => { const target=e.target.closest('[data-action]'); if(!target)return; e.preventDefault(); handle(target.dataset.action,target); });
|
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 => {
|
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')) {
|
if (e.target.name === 'type' && e.target.closest('#bu-repo-form')) {
|
||||||
renderRepositorySpecific(e.target.value);
|
renderRepositorySpecific(e.target.value);
|
||||||
}
|
}
|
||||||
@@ -905,10 +1096,19 @@
|
|||||||
const manual=e.target.closest('.bu-schedule')?.querySelector('.bu-schedule-manual');
|
const manual=e.target.closest('.bu-schedule')?.querySelector('.bu-schedule-manual');
|
||||||
if(manual) manual.hidden=e.target.value!=='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=[];
|
state.backupSelections=[]; state.workloadSelections=[];
|
||||||
renderJobSpecific(e.target.value);
|
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')) {
|
if (e.target.classList.contains('bu-source-select')) {
|
||||||
const path=e.target.value; const prefix=path.replace(/\/$/,'')+'/';
|
const path=e.target.value; const prefix=path.replace(/\/$/,'')+'/';
|
||||||
if(e.target.checked) state.backupSelections=normalizeSourceSelections([...state.backupSelections.filter(selected=>!selected.startsWith(prefix)),path]);
|
if(e.target.checked) state.backupSelections=normalizeSourceSelections([...state.backupSelections.filter(selected=>!selected.startsWith(prefix)),path]);
|
||||||
@@ -939,7 +1139,7 @@
|
|||||||
root.addEventListener('mouseover', e => {
|
root.addEventListener('mouseover', e => {
|
||||||
const target = e.target.closest('[data-tooltip]');
|
const target = e.target.closest('[data-tooltip]');
|
||||||
if (!target || target.contains(e.relatedTarget)) return;
|
if (!target || target.contains(e.relatedTarget)) return;
|
||||||
showTooltip(target, 1200);
|
showTooltip(target,Number(target.dataset.tooltipDelay||1200));
|
||||||
});
|
});
|
||||||
root.addEventListener('mouseout', e => {
|
root.addEventListener('mouseout', e => {
|
||||||
const target = e.target.closest('[data-tooltip]');
|
const target = e.target.closest('[data-tooltip]');
|
||||||
@@ -955,6 +1155,31 @@
|
|||||||
window.addEventListener('scroll', hideTooltip, true);
|
window.addEventListener('scroll', hideTooltip, true);
|
||||||
window.addEventListener('resize', hideTooltip);
|
window.addEventListener('resize', hideTooltip);
|
||||||
enhanceTooltips(root);
|
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();
|
load();
|
||||||
})();
|
})();
|
||||||
Reference in new issue
Block a user