RFC — Filebrowser Quantum Transfer Progress + Tasks Page
Created: 2026-05-14 Status: Implementing — v7 in QA validation Decision: See ADR-021 for the fork-vs-upstream rationale.
Goal
Add real-time progress feedback to all cross-filesystem copy/move operations in Filebrowser Quantum, plus a /tasks route that lists in-flight and recently-finished transfers so reloading the page or opening a second device doesn't destroy visibility.
Why this is needed
- All four storage roots in our setup (
/srv/unohana,/srv/urahara,/srv/filedump,/srv/dlbox) sit on different filesystems. Every cross-folder move triggers a byte copy that takes minutes to hours — the existing indeterminate spinner is unusable. - The byte copy continues server-side even if the client disconnects, but there's no way to query it. Closing the tab equals losing visibility.
- Cloud Commander has the same problem, with its progress bar broken at 1% throughout a multi-hour copy.
Non-goals
- Resumable transfers. If filebrowser crashes mid-copy, the partial file at the destination is left orphaned. Adding checkpoint/resume is a significant rewrite and not worth it for the rare-restart case. Interrupted transfers are surfaced as
state=interruptedand the user re-initiates. - Server-side queue / scheduling. Transfers run on the request goroutine as before. No "queue 10 jobs and process serially" behavior. The HTTP request is the unit of work.
- Other operation types (uploads, archive creation, search indexing) in v1. The job registry is generic enough to support them later, but only copy/move/rename are instrumented now.
Design
Data flow
Browser action (drag, Cmd+V, dialog confirm)
↓ PATCH /api/resources
HTTP handler ──→ jobs.Create(jobID, owner, ...) ── persist.go writes data/jobs.json
↓ for each item: │
│ fileutils.TotalSize(item) ── pre-walk for % │
│ patchAction(ctx, ...) │
│ ↓ │
│ fileutils.CopyFileWithProgress(ctx, ..., cb) │
│ ↓ │
│ io.Copy(&progressWriter{ctx, cb}, src) │
│ ↓ on each chunk: │
│ cb fires (throttled 5/sec) ──┐ │
│ ├─ events.SendToUsers("copyProgress", ...)
│ └─ jobs.UpdateProgress(jobID, copied, ...)
│
↓ jobs.Finish(jobID, state=completed|failed|cancelled)
↓ persist.go writes data/jobs.json
↓ HTTP response
SSE clients (existing /api/events stream):
copyProgress events ──→ Vue dispatches window.copyProgressEvent
├──→ MoveCopy.vue prompt (in-dialog progress)
├──→ CopyPasteConfirm.vue (in-dialog progress)
└──→ Tasks.vue (table view live update)
Tasks page on mount:
GET /api/jobs → initial table from registry → subscribe to SSE for updates
Backend components
| Component | File | Responsibility |
|---|---|---|
| Job registry | backend/jobs/registry.go |
sync.Map[id]*Job; Create / UpdateProgress / Finish / List / Get / Cancel |
| Persistence | backend/jobs/persist.go |
Atomic-write data/jobs.json on state transitions; LoadFromDisk on startup; PersistNow on shutdown |
| Cleanup | backend/jobs/cleanup.go |
Drops finished jobs older than 30 min |
| Counting writer | backend/adapters/fs/fileutils/progress.go |
progressWriter wraps io.Writer; honors ctx.Done() and throttles cb invocations to ~5/sec |
| Copy primitives | backend/adapters/fs/fileutils/file.go |
CopyFileWithProgress(ctx, ...) and MoveFileWithProgress(ctx, ...); existing CopyFile/MoveFile become thin wrappers passing context.Background() so other callers don't break |
| Resource handlers | backend/adapters/fs/files/files.go |
CopyResourceWithProgress, MoveResourceWithProgress — same pattern |
| HTTP layer | backend/http/resource.go |
resourcePatchHandler generates jobID, creates a cancellable ctx, registers the job, builds the per-item progress cb, calls patchAction, calls jobs.Finish at the end with the final state |
| HTTP layer | backend/http/jobs.go |
New endpoints: list / get / cancel |
Frontend components
| Component | File | Responsibility |
|---|---|---|
| API client | frontend/src/api/jobs.js |
list / get / cancel against /api/jobs |
| Route | frontend/src/router/index.ts |
/tasks → Tasks.vue (requires auth) |
| Tasks view | frontend/src/views/Tasks.vue |
Table of jobs from /api/jobs; live-updates from copyProgressEvent window events; per-job rate (5-sec moving avg), ETA, elapsed; Cancel button |
| SSE router | frontend/src/notify/events.js |
New case 'copyProgress': dispatches window.CustomEvent('copyProgressEvent', { detail }) |
| Existing dialog | frontend/src/components/prompts/MoveCopy.vue |
"Move to..." dialog — replaces indeterminate spinner with bytes/percent/rate/ETA |
| Existing dialog | frontend/src/components/prompts/CopyPasteConfirm.vue |
Cmd+C/Cmd+V dialog — same treatment |
| Sidebar | frontend/src/components/sidebar/General.vue |
"Tasks" button → /tasks |
SSE message format
// On job start
{
"eventType": "copyProgress",
"message": {
"jobID": "f2c8a3b14d2e",
"phase": "start",
"action": "copy",
"itemCount": 1
}
}
// During copy (throttled to 5/sec)
{
"eventType": "copyProgress",
"message": {
"jobID": "f2c8a3b14d2e",
"phase": "progress",
"itemIndex": 0,
"itemCount": 1,
"itemName": "/music",
"copied": 4422052013,
"total": 4428835203,
"currentFile": "/srv/unohana/music/twenty one pilots/Blurryface/03 - Ride.opus"
}
}
// On completion
{
"eventType": "copyProgress",
"message": {
"jobID": "f2c8a3b14d2e",
"phase": "done",
"state": "completed",
"succeeded": 1,
"failed": 0
}
}
Persistence format
data/jobs.json is a flat array of Job objects. Written atomically (tempfile + rename) on every state transition (Create, Finish, Cancel) — not on every progress update (high-frequency). On startup any state=running entry is rewritten to state=interrupted with error "server restarted while job was running".
Cancellation semantics
progressWriter.Write checks ctx.Err() on every chunk (default 32 KB). When DELETE /api/jobs/{id} calls cancel(), the next Write returns context.Canceled, io.Copy aborts, and the calling stack unwinds back to resourcePatchHandler which marks the job as state=cancelled. Partial files at the destination are left in place — the user can clean them up manually. (See "Non-goals".)
Validation plan
- v1 → v6 (already done): incremental progress bar + rate/ETA. Validated via real 4.4 GB music copy in QA. All seven progress events flowed through SSE; UI rendered correctly in Safari private window.
- v7 (this RFC): jobs registry + tasks page + cancel. Validation steps:
- Trigger a copy, navigate to
/tasks, see the row with bytes climbing. - Reload the browser mid-copy; row remains, progress continues.
- Click Cancel; row transitions to
state=cancelledwithin ~1 second. - Restart the filebrowser-qa container mid-copy; reload
/tasks; row should showstate=interrupted. - Open
/tasksfrom a second device (NetBird VPN); same job visible. - v8 (anticipated): cleanup pass — remove debug-only paths, finalize sidebar styling, write CHANGELOG entries, prepare for either a permanent fork commit or an upstream draft PR.
Open questions
- Multiple concurrent jobs from one user. The progress dialog (MoveCopy / CopyPasteConfirm) uses
progressJobIDto filter SSE events. But two simultaneous moves would only show one in the dialog. The tasks page handles this fine. Acceptable for v7; revisit if it becomes a friction point. - File-count progress. Currently we only report bytes. Pre-walking already gives us file count for free — exposing
filesDone / filesTotalis ~20 lines on backend + a span on frontend. Punted to v8. - Job retention. 30 minutes after finish. Long enough to debug, short enough to keep
data/jobs.jsonsmall. Tune later if needed. - Admin-vs-owner visibility. Currently admins see everyone's jobs. Probably fine for our single-user use case but worth a setting if we ever onboard others.
Upstream alignment
If the design holds up after a few weeks of use:
1. Open an issue on gtsteffaniak/filebrowser describing the registry approach.
2. Decompose the patch into smaller PRs: (a) CopyFileWithProgress API, (b) SSE event type, (c) frontend dialog progress, (d) jobs package + /tasks page.
3. Each PR is digestible independently. Maintainer can accept any subset.
Related
- ADR-021: Filebrowser Quantum local fork
- Filebrowser service docs
- Upstream issue #1019