Add live activity progress and logs
This commit is contained in:
@@ -1 +0,0 @@
|
||||
f1cd8773608d0e117ff2a71d5552dcc21b38259061d4399c8d4fa1f168c0373c backupper-2026.06.14.9.4-x86_64-1.txz
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
587fc8645d2a5271c4d40105e7209cda16636afd9be2613f83eb92c9208ad8b5 backupper-2026.06.14.9.5-x86_64-1.txz
|
||||
Vendored
+6
-2
@@ -2,13 +2,17 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "backupper">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.14.9.4">
|
||||
<!ENTITY version "2026.06.14.9.5">
|
||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||
<!ENTITY packageSHA256 "f1cd8773608d0e117ff2a71d5552dcc21b38259061d4399c8d4fa1f168c0373c">
|
||||
<!ENTITY packageSHA256 "587fc8645d2a5271c4d40105e7209cda16636afd9be2613f83eb92c9208ad8b5">
|
||||
]>
|
||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
|
||||
<CHANGES>
|
||||
### 2026.06.14.9.5
|
||||
- Add live backup progress with percentage, file and byte counters, transfer rate, ETA, and current file.
|
||||
- Add a sanitized in-memory live log for backup, restore, check, and prune activities.
|
||||
|
||||
### 2026.06.14.9.4
|
||||
- Make the repository form type-aware for local, SMB, NFS, and SFTP destinations.
|
||||
- Add a folder browser for local repository paths.
|
||||
|
||||
+26
-17
@@ -112,23 +112,32 @@ type NotificationTarget struct {
|
||||
}
|
||||
|
||||
type Run struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
TaskType string `json:"taskType"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
SnapshotID string `json:"snapshotId,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
BytesAdded int64 `json:"bytesAdded,omitempty"`
|
||||
FilesNew int64 `json:"filesNew,omitempty"`
|
||||
FilesChanged int64 `json:"filesChanged,omitempty"`
|
||||
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
|
||||
FilesProcessed int64 `json:"filesProcessed,omitempty"`
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ID string `json:"id"`
|
||||
JobID string `json:"jobId,omitempty"`
|
||||
TaskType string `json:"taskType"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
SnapshotID string `json:"snapshotId,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ErrorCode string `json:"errorCode,omitempty"`
|
||||
BytesAdded int64 `json:"bytesAdded,omitempty"`
|
||||
FilesNew int64 `json:"filesNew,omitempty"`
|
||||
FilesChanged int64 `json:"filesChanged,omitempty"`
|
||||
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
|
||||
FilesProcessed int64 `json:"filesProcessed,omitempty"`
|
||||
ProgressPercent float64 `json:"progressPercent,omitempty"`
|
||||
ProgressBytes int64 `json:"progressBytes,omitempty"`
|
||||
ProgressTotal int64 `json:"progressTotal,omitempty"`
|
||||
ProgressFiles int64 `json:"progressFiles,omitempty"`
|
||||
ProgressFileTotal int64 `json:"progressFileTotal,omitempty"`
|
||||
BytesPerSecond float64 `json:"bytesPerSecond,omitempty"`
|
||||
SecondsRemaining int64 `json:"secondsRemaining,omitempty"`
|
||||
CurrentFile string `json:"currentFile,omitempty"`
|
||||
LiveLog []string `json:"liveLog,omitempty"`
|
||||
}
|
||||
|
||||
type RestoreTask struct {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/backupper-unraid/backupper/internal/model"
|
||||
)
|
||||
|
||||
func TestUpdateActiveIsVisibleWithoutPersistingIntermediateState(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
persistCalls := 0
|
||||
q := New(func(_ context.Context, run model.Run) model.Run {
|
||||
close(started)
|
||||
<-release
|
||||
run.Status = "success"
|
||||
return run
|
||||
}, func([]model.Run) { persistCalls++ })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go q.Run(ctx)
|
||||
if err := q.Enqueue(model.Run{ID: "run", JobID: "job"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-started
|
||||
before := persistCalls
|
||||
if !q.UpdateActive("run", func(run *model.Run) { run.ProgressPercent = 42 }) {
|
||||
t.Fatal("active run was not updated")
|
||||
}
|
||||
if runs := q.Snapshot(); len(runs) != 1 || runs[0].ProgressPercent != 42 {
|
||||
t.Fatalf("runs = %#v", runs)
|
||||
}
|
||||
if persistCalls != before {
|
||||
t.Fatal("intermediate progress was persisted")
|
||||
}
|
||||
close(release)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runs := q.Snapshot(); len(runs) == 1 && runs[0].Status == "success" {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("run did not finish")
|
||||
}
|
||||
@@ -141,6 +141,16 @@ func (q *Queue) Snapshot() []model.Run {
|
||||
return q.snapshotLocked()
|
||||
}
|
||||
|
||||
func (q *Queue) UpdateActive(id string, update func(*model.Run)) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if q.active == nil || q.active.ID != id {
|
||||
return false
|
||||
}
|
||||
update(q.active)
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *Queue) snapshotLocked() []model.Run {
|
||||
result := append([]model.Run{}, q.history...)
|
||||
if q.active != nil {
|
||||
|
||||
@@ -35,11 +35,24 @@ type Summary struct {
|
||||
TotalFilesProcessed int64 `json:"total_files_processed"`
|
||||
}
|
||||
|
||||
type Progress struct {
|
||||
MessageType string `json:"message_type"`
|
||||
PercentDone float64 `json:"percent_done"`
|
||||
TotalFiles int64 `json:"total_files"`
|
||||
FilesDone int64 `json:"files_done"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
BytesDone int64 `json:"bytes_done"`
|
||||
SecondsRemaining int64 `json:"seconds_remaining"`
|
||||
CurrentFiles []string `json:"current_files"`
|
||||
}
|
||||
|
||||
type ProgressCallback func(Progress)
|
||||
|
||||
func (r *Runner) Init(ctx context.Context, repo model.Repository) error {
|
||||
return r.run(ctx, repo, []string{"init"}, nil, nil)
|
||||
}
|
||||
|
||||
func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Job, sources []string) (Summary, error) {
|
||||
func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Job, sources []string, progress ProgressCallback) (Summary, error) {
|
||||
args := []string{"backup", "--json", "--compression", job.Compression}
|
||||
for _, tag := range append([]string{"backupper", "job:" + job.ID}, job.Tags...) {
|
||||
args = append(args, "--tag", tag)
|
||||
@@ -50,6 +63,13 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo
|
||||
args = append(args, sources...)
|
||||
var summary Summary
|
||||
err := r.run(ctx, repo, args, func(line []byte) {
|
||||
var status Progress
|
||||
if json.Unmarshal(line, &status) == nil && status.MessageType == "status" {
|
||||
if progress != nil {
|
||||
progress(status)
|
||||
}
|
||||
return
|
||||
}
|
||||
var candidate Summary
|
||||
if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" {
|
||||
summary = candidate
|
||||
|
||||
@@ -20,20 +20,24 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) {
|
||||
argsPath := filepath.Join(dir, "args")
|
||||
passwordPath := filepath.Join(dir, "password")
|
||||
script := filepath.Join(dir, "restic")
|
||||
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3,\"files_changed\":2,\"total_bytes_processed\":1024,\"total_files_processed\":12}'\n", argsPath, passwordPath)
|
||||
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"status\",\"percent_done\":0.5,\"total_files\":12,\"files_done\":6,\"total_bytes\":1024,\"bytes_done\":512,\"seconds_remaining\":4,\"current_files\":[\"/source path/file.txt\"]}'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3,\"files_changed\":2,\"total_bytes_processed\":1024,\"total_files_processed\":12}'\n", argsPath, passwordPath)
|
||||
if err := os.WriteFile(script, []byte(body), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "top-secret"}}
|
||||
repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo path", PasswordRef: "password"}
|
||||
job := model.Job{ID: "job", Compression: "auto", Tags: []string{"daily"}, Excludes: []string{"*.tmp"}}
|
||||
summary, err := runner.Backup(context.Background(), repo, job, []string{"/source path"})
|
||||
var progress Progress
|
||||
summary, err := runner.Backup(context.Background(), repo, job, []string{"/source path"}, func(value Progress) { progress = value })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.SnapshotID != "abc123" || summary.DataAdded != 42 || summary.FilesChanged != 2 || summary.TotalBytesProcessed != 1024 || summary.TotalFilesProcessed != 12 {
|
||||
t.Fatalf("unexpected summary: %+v", summary)
|
||||
}
|
||||
if progress.PercentDone != 0.5 || progress.FilesDone != 6 || progress.BytesDone != 512 || len(progress.CurrentFiles) != 1 {
|
||||
t.Fatalf("unexpected progress: %+v", progress)
|
||||
}
|
||||
args, _ := os.ReadFile(argsPath)
|
||||
for _, expected := range []string{"/repo path", "backup", "--json", "/source path"} {
|
||||
if !strings.Contains(string(args), expected) {
|
||||
|
||||
@@ -221,20 +221,33 @@ func (s *Service) execute(ctx context.Context, run model.Run) model.Run {
|
||||
if !ok {
|
||||
return failed(run, "validation", "repository no longer exists")
|
||||
}
|
||||
live := run
|
||||
appendLiveLog(&live, strings.ToUpper(run.TaskType)+" wird vorbereitet")
|
||||
s.updateActive(live)
|
||||
mounted, err := s.mounts.Prepare(ctx, repo)
|
||||
if err != nil {
|
||||
return failedError(run, err)
|
||||
failedRun := failedError(run, err)
|
||||
appendLiveLog(&live, "Vorbereitung fehlgeschlagen: "+err.Error())
|
||||
failedRun.LiveLog = live.LiveLog
|
||||
return failedRun
|
||||
}
|
||||
defer s.mounts.Cleanup(context.Background(), mounted)
|
||||
appendLiveLog(&live, "Repository bereit; Restic "+run.TaskType+" läuft")
|
||||
s.updateActive(live)
|
||||
if run.TaskType == "check" {
|
||||
err = s.restic.Check(ctx, mounted.Repository)
|
||||
} else {
|
||||
err = s.restic.Prune(ctx, mounted.Repository)
|
||||
}
|
||||
if err != nil {
|
||||
return failedError(run, err)
|
||||
failedRun := failedError(run, err)
|
||||
appendLiveLog(&live, strings.ToUpper(run.TaskType)+" fehlgeschlagen: "+err.Error())
|
||||
failedRun.LiveLog = live.LiveLog
|
||||
return failedRun
|
||||
}
|
||||
run.Status, run.Message = "success", run.TaskType+" completed"
|
||||
appendLiveLog(&live, strings.ToUpper(run.TaskType)+" abgeschlossen")
|
||||
run.LiveLog = live.LiveLog
|
||||
return run
|
||||
}
|
||||
|
||||
@@ -248,6 +261,9 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
|
||||
if !ok {
|
||||
return failed(run, "validation", "repository no longer exists")
|
||||
}
|
||||
live := run
|
||||
appendLiveLog(&live, "Backup wird vorbereitet")
|
||||
s.updateActive(live)
|
||||
mounted, err := s.mounts.Prepare(ctx, repo)
|
||||
if err != nil {
|
||||
return failedError(run, err)
|
||||
@@ -277,10 +293,36 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
|
||||
return failed(run, "source", "source unavailable: "+path)
|
||||
}
|
||||
}
|
||||
summary, err := s.restic.Backup(ctx, mounted.Repository, job, prepared.Sources)
|
||||
appendLiveLog(&live, fmt.Sprintf("Restic-Backup gestartet: %d Quelle(n)", len(prepared.Sources)))
|
||||
s.updateActive(live)
|
||||
lastLoggedPercent := -5
|
||||
started := time.Now()
|
||||
summary, err := s.restic.Backup(ctx, mounted.Repository, job, prepared.Sources, func(progress restic.Progress) {
|
||||
live.ProgressPercent = progress.PercentDone * 100
|
||||
live.ProgressBytes, live.ProgressTotal = progress.BytesDone, progress.TotalBytes
|
||||
live.ProgressFiles, live.ProgressFileTotal = progress.FilesDone, progress.TotalFiles
|
||||
live.SecondsRemaining = progress.SecondsRemaining
|
||||
if elapsed := time.Since(started).Seconds(); elapsed > 0 {
|
||||
live.BytesPerSecond = float64(progress.BytesDone) / elapsed
|
||||
}
|
||||
if len(progress.CurrentFiles) > 0 {
|
||||
live.CurrentFile = progress.CurrentFiles[len(progress.CurrentFiles)-1]
|
||||
}
|
||||
percent := int(live.ProgressPercent)
|
||||
if percent >= lastLoggedPercent+5 {
|
||||
appendLiveLog(&live, fmt.Sprintf("Fortschritt: %.1f%%, %d/%d Dateien, %s/%s", live.ProgressPercent, live.ProgressFiles, live.ProgressFileTotal, formatBytes(live.ProgressBytes), formatBytes(live.ProgressTotal)))
|
||||
lastLoggedPercent = percent - percent%5
|
||||
}
|
||||
s.updateActive(live)
|
||||
})
|
||||
if err != nil {
|
||||
return failedError(run, err)
|
||||
appendLiveLog(&live, "Backup fehlgeschlagen: "+err.Error())
|
||||
failedRun := failedError(run, err)
|
||||
failedRun.LiveLog = live.LiveLog
|
||||
return failedRun
|
||||
}
|
||||
appendLiveLog(&live, "Backup-Daten übertragen; Aufbewahrung wird angewendet")
|
||||
s.updateActive(live)
|
||||
if err := s.restic.Forget(ctx, mounted.Repository, job); err != nil {
|
||||
run.Status, run.Message, run.ErrorCode = "warning", "backup succeeded but retention failed: "+err.Error(), "repository"
|
||||
} else {
|
||||
@@ -288,10 +330,36 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
|
||||
}
|
||||
run.SnapshotID, run.BytesAdded, run.FilesNew, run.FilesChanged = summary.SnapshotID, summary.DataAdded, summary.FilesNew, summary.FilesChanged
|
||||
run.BytesProcessed, run.FilesProcessed = summary.TotalBytesProcessed, summary.TotalFilesProcessed
|
||||
run.ProgressPercent, run.ProgressBytes, run.ProgressTotal = 100, live.ProgressBytes, live.ProgressTotal
|
||||
run.ProgressFiles, run.ProgressFileTotal = live.ProgressFiles, live.ProgressFileTotal
|
||||
run.BytesPerSecond, run.SecondsRemaining, run.CurrentFile = live.BytesPerSecond, 0, live.CurrentFile
|
||||
appendLiveLog(&live, "Backup abgeschlossen")
|
||||
run.LiveLog = live.LiveLog
|
||||
return run
|
||||
}
|
||||
|
||||
func (s *Service) updateActive(run model.Run) {
|
||||
s.queue.UpdateActive(run.ID, func(active *model.Run) {
|
||||
active.ProgressPercent, active.ProgressBytes, active.ProgressTotal = run.ProgressPercent, run.ProgressBytes, run.ProgressTotal
|
||||
active.ProgressFiles, active.ProgressFileTotal = run.ProgressFiles, run.ProgressFileTotal
|
||||
active.BytesPerSecond, active.SecondsRemaining = run.BytesPerSecond, run.SecondsRemaining
|
||||
active.CurrentFile = run.CurrentFile
|
||||
active.LiveLog = append([]string(nil), run.LiveLog...)
|
||||
})
|
||||
}
|
||||
|
||||
func appendLiveLog(run *model.Run, message string) {
|
||||
line := time.Now().Format("15:04:05") + " " + strings.ReplaceAll(strings.TrimSpace(message), "\n", " ")
|
||||
run.LiveLog = append(run.LiveLog, line)
|
||||
if len(run.LiveLog) > 100 {
|
||||
run.LiveLog = run.LiveLog[len(run.LiveLog)-100:]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run {
|
||||
live := run
|
||||
appendLiveLog(&live, "Restore wird vorbereitet")
|
||||
s.updateActive(live)
|
||||
parts := strings.Split(run.JobID, "\x00")
|
||||
if len(parts) < 4 {
|
||||
return failed(run, "internal", "invalid restore payload")
|
||||
@@ -302,17 +370,30 @@ func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run {
|
||||
}
|
||||
task := model.RestoreTask{SchemaVersion: model.SchemaVersion, ID: run.ID, RepositoryID: parts[0], SnapshotID: parts[1], Target: parts[2], InPlace: parts[3] == "true", Confirmed: true, Includes: parts[4:]}
|
||||
if err := os.MkdirAll(task.Target, 0750); err != nil {
|
||||
return failedError(run, err)
|
||||
failedRun := failedError(run, err)
|
||||
appendLiveLog(&live, "Restore-Ziel konnte nicht vorbereitet werden: "+err.Error())
|
||||
failedRun.LiveLog = live.LiveLog
|
||||
return failedRun
|
||||
}
|
||||
mounted, err := s.mounts.Prepare(ctx, repo)
|
||||
if err != nil {
|
||||
return failedError(run, err)
|
||||
failedRun := failedError(run, err)
|
||||
appendLiveLog(&live, "Repository konnte nicht vorbereitet werden: "+err.Error())
|
||||
failedRun.LiveLog = live.LiveLog
|
||||
return failedRun
|
||||
}
|
||||
defer s.mounts.Cleanup(context.Background(), mounted)
|
||||
appendLiveLog(&live, "Restic stellt den Snapshot wieder her")
|
||||
s.updateActive(live)
|
||||
if err := s.restic.Restore(ctx, mounted.Repository, task); err != nil {
|
||||
return failedError(run, err)
|
||||
failedRun := failedError(run, err)
|
||||
appendLiveLog(&live, "Restore fehlgeschlagen: "+err.Error())
|
||||
failedRun.LiveLog = live.LiveLog
|
||||
return failedRun
|
||||
}
|
||||
run.Status, run.Message, run.JobID = "success", "restore completed", ""
|
||||
appendLiveLog(&live, "Restore abgeschlossen")
|
||||
run.LiveLog = live.LiveLog
|
||||
return run
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "backupper">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.14.9.4">
|
||||
<!ENTITY version "2026.06.14.9.5">
|
||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
||||
]>
|
||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
|
||||
<CHANGES>
|
||||
### 2026.06.14.9.5
|
||||
- Add live backup progress with percentage, file and byte counters, transfer rate, ETA, and current file.
|
||||
- Add a sanitized in-memory live log for backup, restore, check, and prune activities.
|
||||
|
||||
### 2026.06.14.9.4
|
||||
- Make the repository form type-aware for local, SMB, NFS, and SFTP destinations.
|
||||
- Add a folder browser for local repository paths.
|
||||
|
||||
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
||||
<?php
|
||||
$pluginRoot = '/plugins/backupper';
|
||||
?>
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=2026061494">
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=2026061495">
|
||||
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||
<header class="bu-header">
|
||||
<div class="bu-brand">
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=2026061494" alt="URBM logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.06.14.9.4</span></h1><p>Restic Backup Manager for Unraid</p></div>
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=2026061495" alt="URBM logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.06.14.9.5</span></h1><p>Restic Backup Manager for Unraid</p></div>
|
||||
</div>
|
||||
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Connecting...</div>
|
||||
</header>
|
||||
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/backupper';
|
||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||
</div>
|
||||
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.9.4.js"></script>
|
||||
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.9.5.js"></script>
|
||||
|
||||
@@ -97,6 +97,18 @@
|
||||
#backupper-app .bu-weekdays input:checked + span { background: var(--bu-accent) !important; border-color: var(--bu-accent) !important; color: #fff !important; }
|
||||
.bu-custom-cron { margin-top: 14px; }
|
||||
.bu-event-options { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.bu-progress-wrap { min-width: 280px; }
|
||||
.bu-progress-label { align-items: center; display: flex; font-size: 12px; gap: 8px; justify-content: space-between; margin-bottom: 5px; }
|
||||
.bu-progress-label span { color: var(--bu-muted); }
|
||||
.bu-progress { background: var(--bu-panel-alt); border: 1px solid var(--bu-border); border-radius: 999px; height: 12px; overflow: hidden; }
|
||||
.bu-progress span { background: var(--bu-accent); display: block; height: 100%; min-width: 0; transition: width .35s ease; }
|
||||
.bu-progress.indeterminate span { animation: bu-progress-scan 1.2s ease-in-out infinite alternate; }
|
||||
.bu-current-file { color: var(--bu-muted); font-size: 12px; margin-top: 5px; max-width: 440px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bu-log-row td { border-top: 0 !important; padding-top: 0 !important; }
|
||||
.bu-live-log { background: #11161a; border: 1px solid var(--bu-border); border-radius: 6px; color: #e8edf1; margin: 2px 0 8px; padding: 9px 12px; }
|
||||
.bu-live-log summary { cursor: pointer; font-weight: 700; }
|
||||
.bu-live-log pre { color: #e8edf1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; margin: 10px 0 0; max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
|
||||
@keyframes bu-progress-scan { from { transform: translateX(-20%); } to { transform: translateX(250%); } }
|
||||
.bu-form-grid { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); }
|
||||
.bu-managed-mount, .bu-external-mount { margin-top: 14px; }
|
||||
.bu-managed-mount[hidden], .bu-external-mount[hidden] { display: none; }
|
||||
|
||||
@@ -126,6 +126,12 @@
|
||||
while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; }
|
||||
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`;
|
||||
};
|
||||
const formatEta = value => {
|
||||
let seconds=Math.max(0,Math.round(Number(value)||0));
|
||||
const hours=Math.floor(seconds/3600); seconds%=3600;
|
||||
const minutes=Math.floor(seconds/60); seconds%=60;
|
||||
return hours?`${hours}h ${minutes}m`:minutes?`${minutes}m ${seconds}s`:`${seconds}s`;
|
||||
};
|
||||
const weekDays = [{value:'1',short:'Mo',name:'Montag'},{value:'2',short:'Di',name:'Dienstag'},{value:'3',short:'Mi',name:'Mittwoch'},{value:'4',short:'Do',name:'Donnerstag'},{value:'5',short:'Fr',name:'Freitag'},{value:'6',short:'Sa',name:'Samstag'},{value:'0',short:'So',name:'Sonntag'}];
|
||||
function parseSchedule(cron = '0 2 * * *') {
|
||||
const match = String(cron).trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(.+)$/);
|
||||
@@ -451,8 +457,27 @@
|
||||
return `<section class="bu-card"><h2>Restore</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">Include paths, one per line<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Target<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><label>In-place restore<input name="inPlace" type="checkbox"></label><label>Confirm overwrite risk<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Queue restore','submit-restore','primary')}</div></form></section>`;
|
||||
}
|
||||
|
||||
function renderActivity() { return `<div class="bu-toolbar"><h2>Activity</h2>${button('Refresh','refresh','primary')}</div><section class="bu-card">${runsTable([...state.runs].reverse())}</section>`; }
|
||||
function runsTable(runs) { return runs.length ? `<table class="bu-table"><thead><tr><th>Task</th><th>Status</th><th>Created</th><th>Message</th><th></th></tr></thead><tbody>${runs.map(r=>`<tr><td>${esc(r.taskType)} ${esc(r.jobId||'')}</td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${esc(r.message||'')}</td><td>${['queued','running'].includes(r.status)?button('Cancel',`cancel-run:${r.id}`,'danger'):''}</td></tr>`).join('')}</tbody></table>` : '<div class="bu-empty">No activity recorded.</div>'; }
|
||||
function renderActivity() { return `<div class="bu-toolbar"><div><h2>Activity</h2><div class="bu-muted">Running tasks update automatically every two seconds.</div></div>${button('Refresh','refresh','primary')}</div><section class="bu-card">${runsTable([...state.runs].reverse())}</section>`; }
|
||||
function progressView(run) {
|
||||
if(run.status==='queued') return '<span class="bu-muted">Waiting in queue</span>';
|
||||
if(run.taskType!=='backup'&&!run.progressPercent) return '<span class="bu-muted">No percentage available</span>';
|
||||
const percent=Math.max(0,Math.min(100,Number(run.progressPercent)||0));
|
||||
const scanning=run.status==='running'&&percent===0;
|
||||
const details=[];
|
||||
if(run.progressTotal) details.push(`${formatBytes(run.progressBytes)} / ${formatBytes(run.progressTotal)}`);
|
||||
if(run.progressFileTotal) details.push(`${Number(run.progressFiles||0).toLocaleString()} / ${Number(run.progressFileTotal).toLocaleString()} files`);
|
||||
if(run.bytesPerSecond) details.push(`${formatBytes(run.bytesPerSecond)}/s`);
|
||||
if(run.secondsRemaining>0) details.push(`ETA ${formatEta(run.secondsRemaining)}`);
|
||||
return `<div class="bu-progress-wrap"><div class="bu-progress-label"><strong>${scanning?'Scanning…':`${percent.toFixed(1)}%`}</strong><span>${esc(details.join(' · '))}</span></div><div class="bu-progress ${scanning?'indeterminate':''}"><span style="width:${scanning?30:percent}%"></span></div>${run.currentFile?`<div class="bu-current-file" title="${esc(run.currentFile)}">${esc(run.currentFile)}</div>`:''}</div>`;
|
||||
}
|
||||
function liveLogView(run) {
|
||||
if(!(run.liveLog||[]).length) return '';
|
||||
return `<details class="bu-live-log" ${run.status==='running'?'open':''}><summary>${run.status==='running'?'Live log':'Run log'} (${run.liveLog.length})</summary><pre>${esc(run.liveLog.join('\n'))}</pre></details>`;
|
||||
}
|
||||
function runsTable(runs) {
|
||||
if(!runs.length) return '<div class="bu-empty">No activity recorded.</div>';
|
||||
return `<table class="bu-table bu-runs-table"><thead><tr><th>Task</th><th>Status</th><th>Created</th><th>Progress</th><th>Message</th><th></th></tr></thead><tbody>${runs.map(r=>`<tr><td>${esc(r.taskType)} ${esc(r.jobId||'')}</td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${progressView(r)}</td><td>${esc(r.message||'')}</td><td>${['queued','running'].includes(r.status)?button('Cancel',`cancel-run:${r.id}`,'danger'):''}</td></tr>${(r.liveLog||[]).length?`<tr class="bu-log-row"><td colspan="6">${liveLogView(r)}</td></tr>`:''}`).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
const s = state.config.settings;
|
||||
@@ -600,6 +625,6 @@
|
||||
window.addEventListener('scroll', hideTooltip, true);
|
||||
window.addEventListener('resize', hideTooltip);
|
||||
enhanceTooltips(root);
|
||||
setInterval(async()=>{ if(!state.config)return; try { state.runs=await api('/v1/runs'); if(['dashboard','activity'].includes(state.view))render(); }catch(_){ } },10000);
|
||||
setInterval(async()=>{ if(!state.config)return; try { state.runs=await api('/v1/runs'); if(['dashboard','activity'].includes(state.view))render(); }catch(_){ } },2000);
|
||||
load();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user