Skip to content

feat: downloads UX, failed-download cleanup, live progress and uploads view - #104

Open
khaosdoctor wants to merge 17 commits into
roziscoding:mainfrom
khaosdoctor:feat/downloads-uploads
Open

khaosdoctor wants to merge 17 commits into
roziscoding:mainfrom
khaosdoctor:feat/downloads-uploads

Conversation

@khaosdoctor

@khaosdoctor khaosdoctor commented Nov 8, 2026 •

Copy link
Copy Markdown

What does this PR do?

Replaces #100, #101, #102 and #103 with one PR, and fixes the review feedback left on #102. One commit per change, so they can still be read separately. The dashboard redesign is a separate PR.

  • Wrap long file names in the Downloads table so the action buttons stay in view.
  • Bulk actions on Downloads: a checkbox column (and a checkbox per card on mobile) with Retry, Cancel and Delete for the selection. Each button counts the rows it applies to and skips the rest. Uses the existing per-download endpoints. Bulk actions only reach rows still in view: changing a filter clears the selection, and a row that a live update moves out of the filter is no longer a target.
  • Retry, then remove failed downloads (downloads.removeFailedDownloads, off by default, applies live). A failed download is retried up to failedDownloadRetries times (minimum and default 3, since one network error is often enough to fail an attempt), with the delay doubling from failedRetryBaseDelayMs and capped at retryMaxDelayMs. If it keeps failing, the row and its files are deleted the way Delete does it.
    • User cancels are never retried or removed.
    • Each row is re-read right before the reaper acts on it, and the delete checks again inside the per-row delete lock, so a row the user retried or cancelled meanwhile is left alone.
    • With the management API, a missing key reads as off, so switching it off in the UI stops the reaper right away.
    • Retry counts are in memory, so a restart starts them over.
  • Version in the sidebar and a repository link in Settings -> About.
  • Correct version in the UI. The UI read apps/ui/package.json, which stays at 0.1.0. CI now passes the image's org.opencontainers.image.version label value as APP_VERSION. Release images show v1.2.3, branch and PR images show main / pr-<n>. Local builds fall back to the latest git tag, then dev.
  • Download speed and ETA. Built on the existing progress writes (every 10 s / 64 MiB): each one stores the speed since the previous one in the same attempt as downloads.speed_bps, and the status API derives etaSeconds from it. A new attempt or a resume reset clears it.
  • Uploads view. Files peers pull from this instance are recorded in a new uploads table, one row per transfer. Migration 0014 is the table as this PR first shipped it, unchanged; 0015 converts it to transfers (existing rows kept, aborted ones become unconfirmed) and adds downloads.speed_bps, so a database that ran an earlier build of this PR still starts. Peer protocol goes to 0.3.0:
    • POST /peer/items/:itemId/downloads with a clientTransferId creates the transfer and returns an opaque downloadId, the file metadata and its URL. It's idempotent per API key, so a create whose response got lost returns the same transfer. The downloading jack uses a client id derived from its download row, so every retry and restart of one download reuses one transfer.
    • GET /peer/downloads/:downloadId/file streams it with Range support, as often as needed, without new rows. A dropped request leaves the transfer open to resume.
    • POST /peer/downloads/:downloadId/receipt completes it once the file is in place. Bun gives no signal when a zero-copy response finishes, so the receipt is how jack knows. The bytes (total size minus the lowest range start served) and the duration (creation to receipt) are computed on the serving side, so a peer can't skew them.
    • Only the API key that created a transfer can stream or complete it.
    • No receipt within 48 h leaves the row unconfirmed; a late one still completes it.
    • GET /peer/items/:itemId/file stays for older peers and still serves the file, but records nothing. A newer jack falls back to it for older peers, or when creating the transfer fails (a full disk on the other side, say).
    • Files still go out zero-copy: counting bytes in JS cost about 7x the CPU per GiB in a local benchmark (0.30 s vs 0.04 s), so jack doesn't touch the body.
    • New Uploads page plus an uploads card on the Dashboard. There is no live speed for uploads.

Related issue

Supersedes #100, #101, #102 and #103.

Checklist

  • Commits follow Conventional Commits
  • Commits are signed (GitHub enforces this — unsigned commits are rejected)
  • mise run lint:fix was run and its output is committed
  • mise run test passes (and mise run test:e2e if transfer/request paths changed)
  • Docs updated if behavior or configuration changed

Copilot AI balanced review requested due to automatic review settings November 8, 2026 23:28
@greptile-apps

greptile-apps Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[High impact] The migration split appears safe to merge; the earlier stale-speed issue remains non-blocking.

Summary

Adds bulk download actions, failed-download cleanup, download speed and ETA, upload history, and build versions in the UI.

  • Peers can resume one tracked transfer and confirm when the file arrives.
  • Failed downloads can be retried and removed when cleanup is enabled.
  • The Downloads page offers bulk actions and keeps long names from hiding buttons.
  • Download rows show their current speed and estimated time left.
  • Operators can see upload history and current transfers in the management UI.
  • The Dashboard gets download totals without loading the full history.
  • The sidebar shows the built Jack version and Settings links to its repository.

Reviews (11) · Last reviewed commit: "fix: keep the original uploads migration..." · Reviewed by Greptile

Comment thread apps/backend/src/index.ts Resolved

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Uncaught SQLite write failures can disrupt transfers or terminate the server, the reaper can continue retrying or deleting after being disabled, and dashboard updates have throttling gaps and costly full-history queries.

5 open issues
What changed in this PR

This PR combines the earlier downloads improvements with upload tracking,
extending jack's management UI and backend transfer lifecycle.

Changes:

  • Adds bulk download actions, filename wrapping, live progress and optional
    retry-then-delete cleanup for failed downloads.
  • Records peer uploads without replacing zero-copy file serving, exposes upload
    history in the UI, and displays the build version.
File Description
website/​reference/​configuration.md Documents failed-download cleanup settings.
website/​guide/​management-ui.md Documents uploads and live settings.
apps/​ui/​nuxt.config.ts Resolves the build version from environment or git.
apps/​ui/​Dockerfile Accepts the version build argument.
apps/​ui/​app/​utils/​format.ts Adds transfer-rate and version formatting.
apps/​ui/​app/​utils/​format.test.ts Tests release and branch version labels.
apps/​ui/​app/​types/​management.ts Adds upload, progress and cleanup types.
apps/​ui/​app/​pages/​uploads.vue Adds a filterable upload-history page.
apps/​ui/​app/​pages/​settings.vue Updates version display and adds repository link.
apps/​ui/​app/​pages/​index.vue Shows download rates and upload activity.
apps/​ui/​app/​pages/​downloads.vue Adds bulk actions, wrapping and transfer rates.
apps/​ui/​app/​layouts/​default.vue Adds uploads navigation and sidebar version.
apps/​ui/​app/​components/​DownloadsSection.vue Exposes failed-download cleanup controls.
apps/​backend/​src/​modules/​uploads/​uploads.service.ts Coordinates upload tracking and receipts.
apps/​backend/​src/​modules/​uploads/​uploads.repository.ts Persists upload history and state transitions.
apps/​backend/​src/​modules/​status/​status.router.ts Adds upload list and stream endpoints.
apps/​backend/​src/​modules/​status/​status.controller.ts Exposes upload summaries and live download metrics.
apps/​backend/​src/​modules/​peer/​peer.router.ts Tracks file requests and accepts receipts.
apps/​backend/​src/​modules/​peer/​peer.controller.ts Explains zero-copy tracking in comments.
apps/​backend/​src/​modules/​downloads/​live-progress.ts Tracks in-memory download speed and bytes.
apps/​backend/​src/​modules/​downloads/​failed-download-reaper.ts Retries and removes failed downloads.
apps/​backend/​src/​modules/​downloads/​downloads.service.ts Publishes live progress and guards automated deletion.
apps/​backend/​src/​modules/​config/​config.service.ts Clarifies which settings apply live.
apps/​backend/​src/​modules/​config/​config.router.ts Updates configuration API documentation.
apps/​backend/​src/​middleware/​require-auth.ts Exposes key IDs for receipt ownership checks.
apps/​backend/​src/​management-app.ts Connects upload tracking to management endpoints.
apps/​backend/​src/​lib/​servers/​peer.ts Reports live bytes and sends upload receipts.
apps/​backend/​src/​lib/​openapi.ts Adds the uploads documentation tag.
apps/​backend/​src/​lib/​config.ts Defines cleanup settings and validation.
apps/​backend/​src/​index.ts Starts upload maintenance and failed-download cleanup.
apps/​backend/​src/​database/​schema.ts Defines upload records and indexes.
apps/​backend/​src/​app.ts Injects upload tracking into peer routes.
apps/​backend/​src/​__tests__/​uploads-repository.test.ts Tests upload persistence and ownership.
apps/​backend/​src/​__tests__/​upload-tracking.test.ts Tests tracking failures and receipt routes.
apps/​backend/​src/​__tests__/​peer-download.test.ts Tests outgoing upload receipts.
apps/​backend/​src/​__tests__/​live-progress.test.ts Tests speed calculation and status enrichment.
apps/​backend/​src/​__tests__/​failed-download-reaper.test.ts Tests retry backoff and cleanup eligibility.
apps/​backend/​src/​__tests__/​downloads-service.test.ts Extends configuration fixtures.
apps/​backend/​drizzle/​meta/​0014_snapshot.json Records the uploads schema snapshot.
apps/​backend/​drizzle/​meta/​_journal.json Registers migration 0014.
apps/​backend/​drizzle/​0014_uploads.sql Creates the uploads table and indexes.
.github/​workflows/​publish.yml Passes the image version into UI builds.

Review effort: Balanced

Comment thread apps/backend/src/index.ts Resolved
@khaosdoctor

Copy link
Copy Markdown
Author

@greptileai review

@khaosdoctor

Copy link
Copy Markdown
Author

@greptileai review

1 similar comment
@khaosdoctor

Copy link
Copy Markdown
Author

@greptileai review

@khaosdoctor

Copy link
Copy Markdown
Author

@greptileai review

@roziscoding roziscoding left a comment •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superseded after re-reviewing the follow-up fixes; the stale inline comments were removed.


// Record the upload; the body still goes out zero-copy. A HEAD request sends
// no body, so it is not an upload. The master key sets no name.
const uploadId = uploads && c.req.method !== 'HEAD'

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 785a9ff, the transfer is its own resource now:

  • POST /peer/items/:itemId/downloads takes { clientTransferId } and returns downloadId (a UUID), filename, totalBytes and url. It's idempotent per API key: the same id returns the existing transfer with a 200 instead of a 201, and reusing it for another item is a 409.
  • GET /peer/downloads/:downloadId/file streams with Range and only records the lowest byte served, no new row. A dropped request leaves the transfer open.
  • POST /peer/downloads/:downloadId/receipt completes the same row. Bytes and duration are computed on the serving side (total minus the lowest range start, creation to receipt), so the receipt has no body.

The downloading jack uses download-<rowId>-<startedAt> as the client id, so every retry of one download hits the same transfer, restarts included. Protocol goes to 0.3.0. GET /items/:itemId/file stays for older peers, but it no longer records anything, and a newer jack only falls back to it for older peers or when creating the transfer fails.

* (each write also fsyncs the .part), so this is what lets the UI move the bar
* and show a speed in between. Nothing here is persisted.
*/
export class LiveProgressTracker {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, removed the tracker and the bytes event in 785a9ff. Speed now comes from the previous progress sample in the same attempt and is stored as downloads.speed_bps, so it goes out through the repository and the existing SSE; etaSeconds is derived from it in enrichDownload.

The trade-off is the cadence: progress, speed and ETA now update every 10 s / 64 MiB instead of every second, so the bar moves in steps and the speed lags up to 10 s behind. I kept that interval because each progress write also fsyncs the .part, and a write per second would mean an fsync per second per transfer. If one-second updates matter to you, I can bump the cadence of the existing progress writes, maybe with the fsync kept on the 10 s / 64 MiB rhythm, rather than bring back a second source.


// Record the upload; the body still goes out zero-copy. A HEAD request sends
// no body, so it is not an upload. The master key sets no name.
const uploadId = uploads && c.req.method !== 'HEAD'

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 785a9ff, the transfer is its own resource now:

  • POST /peer/items/:itemId/downloads takes { clientTransferId } and returns downloadId (a UUID), filename, totalBytes and url. It's idempotent per API key: the same id returns the existing transfer with a 200 instead of a 201, and reusing it for another item is a 409.
  • GET /peer/downloads/:downloadId/file streams with Range and only records the lowest byte served, no new row. A dropped request leaves the transfer open.
  • POST /peer/downloads/:downloadId/receipt completes the same row. Bytes and duration are computed on the serving side (total minus the lowest range start, creation to receipt), so the receipt has no body.

The downloading jack uses download-<rowId>-<startedAt> as the client id, so every retry of one download hits the same transfer, restarts included. Protocol goes to 0.3.0. GET /items/:itemId/file stays for older peers, but it no longer records anything, and a newer jack only falls back to it for older peers or when creating the transfer fails.

* (each write also fsyncs the .part), so this is what lets the UI move the bar
* and show a speed in between. Nothing here is persisted.
*/
export class LiveProgressTracker {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, removed the tracker and the bytes event in 785a9ff. Speed now comes from the previous progress sample in the same attempt and is stored as downloads.speed_bps, so it goes out through the repository and the existing SSE; etaSeconds is derived from it in enrichDownload.

The trade-off is the cadence: progress, speed and ETA now update every 10 s / 64 MiB instead of every second, so the bar moves in steps and the speed lags up to 10 s behind. I kept that interval because each progress write also fsyncs the .part, and a write per second would mean an fsync per second per transfer. If one-second updates matter to you, I can bump the cadence of the existing progress writes, maybe with the fsync kept on the 10 s / 64 MiB rhythm, rather than bring back a second source.

Comment on lines +38 to +41
// Measured between progress writes (every 10 s / 64 MiB); only while running.
const speedBps = d.status === 'downloading'
? d.speedBps
: null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stopped downloads show old speed

enrichDownload() now returns the saved speedBps without checking when bytes last arrived. After a peer stops sending, the UI keeps showing the old speed and ETA through the idle timeout and retry wait. Removing the live tracker also makes progress updates wait for the 10-second / 64-MiB writes.

Keep a separate live byte count and clear its speed when samples stop.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ffca03e without bringing back a second byte count: a failed attempt clears speed_bps (and notifies) before its retry wait, and enrichDownload drops a speed whose row hasn't been written for 30 s, so a stall shows no speed past that. The 10 s / 64 MiB cadence is discussed in the maintainer's thread above.

@roziscoding roziscoding left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the current head after the follow-up fixes. The transfer lifecycle now uses an explicit idempotent resource, and speed/ETA build on the existing progress path. Backend tests, UI typecheck, targeted lint, and diff checks pass.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants