diff --git a/dist/backupper-2026.06.14.9.4-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.9.4-x86_64-1.txz.sha256 deleted file mode 100644 index fa9ed71..0000000 --- a/dist/backupper-2026.06.14.9.4-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -f1cd8773608d0e117ff2a71d5552dcc21b38259061d4399c8d4fa1f168c0373c backupper-2026.06.14.9.4-x86_64-1.txz diff --git a/dist/backupper-2026.06.14.9.4-x86_64-1.txz b/dist/backupper-2026.06.14.9.5-x86_64-1.txz similarity index 55% rename from dist/backupper-2026.06.14.9.4-x86_64-1.txz rename to dist/backupper-2026.06.14.9.5-x86_64-1.txz index 6a2514a..fe8bdd8 100644 Binary files a/dist/backupper-2026.06.14.9.4-x86_64-1.txz and b/dist/backupper-2026.06.14.9.5-x86_64-1.txz differ diff --git a/dist/backupper-2026.06.14.9.5-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.9.5-x86_64-1.txz.sha256 new file mode 100644 index 0000000..e3d6a54 --- /dev/null +++ b/dist/backupper-2026.06.14.9.5-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +587fc8645d2a5271c4d40105e7209cda16636afd9be2613f83eb92c9208ad8b5 backupper-2026.06.14.9.5-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index 00e3dc1..c7b50af 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,17 @@ - + - + ]> +### 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. diff --git a/internal/model/model.go b/internal/model/model.go index 95ac8ca..e50a73c 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -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 { diff --git a/internal/queue/progress_test.go b/internal/queue/progress_test.go new file mode 100644 index 0000000..7d5e29b --- /dev/null +++ b/internal/queue/progress_test.go @@ -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") +} diff --git a/internal/queue/queue.go b/internal/queue/queue.go index f8279dc..f8be99d 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -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 { diff --git a/internal/restic/restic.go b/internal/restic/restic.go index d62781c..806746c 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -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 diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index 3672700..20637da 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -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) { diff --git a/internal/service/service.go b/internal/service/service.go index b35cadc..a0f57c7 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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 } diff --git a/plugin/backupper.plg b/plugin/backupper.plg index da81f08..38af214 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,17 @@ - + ]> +### 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. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index 6941417..fcd4eae 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.14.9.4

Restic Backup Manager for Unraid

+ +

URBM 2026.06.14.9.5

Restic Backup Manager for Unraid

Connecting...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/backupper';
- + diff --git a/webgui/assets/backupper.css b/webgui/assets/backupper.css index ea971d6..28b02e2 100644 --- a/webgui/assets/backupper.css +++ b/webgui/assets/backupper.css @@ -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; } diff --git a/webgui/assets/backupper.js b/webgui/assets/backupper.js index 3bdafe1..1a12b91 100644 --- a/webgui/assets/backupper.js +++ b/webgui/assets/backupper.js @@ -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 `

Restore

${button('Queue restore','submit-restore','primary')}
`; } - function renderActivity() { return `

Activity

${button('Refresh','refresh','primary')}
${runsTable([...state.runs].reverse())}
`; } - function runsTable(runs) { return runs.length ? `${runs.map(r=>``).join('')}
TaskStatusCreatedMessage
${esc(r.taskType)} ${esc(r.jobId||'')}${status(r.status)}${esc(new Date(r.createdAt).toLocaleString())}${esc(r.message||'')}${['queued','running'].includes(r.status)?button('Cancel',`cancel-run:${r.id}`,'danger'):''}
` : '
No activity recorded.
'; } + function renderActivity() { return `

Activity

Running tasks update automatically every two seconds.
${button('Refresh','refresh','primary')}
${runsTable([...state.runs].reverse())}
`; } + function progressView(run) { + if(run.status==='queued') return 'Waiting in queue'; + if(run.taskType!=='backup'&&!run.progressPercent) return 'No percentage available'; + 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 `
${scanning?'Scanning…':`${percent.toFixed(1)}%`}${esc(details.join(' · '))}
${run.currentFile?`
${esc(run.currentFile)}
`:''}
`; + } + function liveLogView(run) { + if(!(run.liveLog||[]).length) return ''; + return `
${run.status==='running'?'Live log':'Run log'} (${run.liveLog.length})
${esc(run.liveLog.join('\n'))}
`; + } + function runsTable(runs) { + if(!runs.length) return '
No activity recorded.
'; + return `${runs.map(r=>`${(r.liveLog||[]).length?``:''}`).join('')}
TaskStatusCreatedProgressMessage
${esc(r.taskType)} ${esc(r.jobId||'')}${status(r.status)}${esc(new Date(r.createdAt).toLocaleString())}${progressView(r)}${esc(r.message||'')}${['queued','running'].includes(r.status)?button('Cancel',`cancel-run:${r.id}`,'danger'):''}
${liveLogView(r)}
`; + } 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(); })();