feat: management UI + backend status endpoints#26
Merged
Merged
Conversation
Adds a status module exposing /overview and /downloads (downloads enriched with computed progress), a cheap key-guarded /ping probe for the UI's BFF, and wires the downloads repository into the management app.
Adds apps/ui, a Nuxt 4 client-rendered management UI that proxies the jack management API through a server-side BFF (inject- or cookie-key modes). Includes a 'mise run ui' task, enables Vue linting at the repo root, and ignores Nuxt build artifacts.
The root package.json globs apps/*, so bun resolves the full workspace graph against bun.lock. apps/ui (Nuxt) was added to the workspace but its manifest was never copied into the deps/generate stages, so bun install --frozen-lockfile failed with 'lockfile had changes'. Copy apps/ui/package.json to satisfy the lockfile and scope the install with --filter to keep Nuxt out of the backend image.
🐳 Docker images publishedThis PR has been built and pushed to GHCR: Pull them locally: docker pull ghcr.io/roziscoding/jack:pr-26
docker pull ghcr.io/roziscoding/jack-ui:pr-26Run the backend standalone: docker run --rm ghcr.io/roziscoding/jack:pr-26The UI needs the backend + a management key, so run the two together with
|
…lves it xmlbuilder2 is imported only by apps/backend (src/helpers/xml.ts) but was declared at the workspace root. The previous unscoped install pulled it into the root node_modules, masking the misplacement. Once the Docker install was scoped with --filter to the backend/schemas workspaces, the root package's deps were no longer installed and the runtime container failed with 'Cannot find package xmlbuilder2'. Move it to where it is actually used.
GET /config now returns refs-intact apiKey + headers (from the persisted config, never resolved) and the effective autoregister, so the management UI can prefill every editable connector field. SecretInput prefills on edit and reveals plaintext secrets via an opt-in checkbox; HeadersEditor is now secret-ref aware.
Adding or editing a peer/server via the management UI ran the connectivity check but swallowed any failure, so a dead target was persisted and the API returned 201/200. Make these mutations atomic: on init failure they restore the live connector map (and a server's source/destination lists) to its prior state, roll the persisted config back, and throw a ConnectorInitializationError (502) carrying the cause — so the UI keeps the modal open and shows the error. Boot (initAll) keeps the resident-and-retry behavior via an opt-in rethrow flag.
mise auto-discovers tasks under .mise/tasks just like mise-tasks/, so task names and CI (mise run test:e2e) are unaffected.
Add apps/ui/Dockerfile (multi-stage Bun build of the Nuxt/Nitro server) and relocate the backend Dockerfile/.dockerignore into apps/backend so each app owns its image. Both build with the repo root as context; the .dockerignore moves alongside as Dockerfile.dockerignore (BuildKit picks it up). publish.yml now builds and pushes the UI image as ghcr.io/<repo>-ui with its own cache scope, and the PR comment/cleanup cover both images. Wire the jack-ui service into both example compose files: it talks to the backend management API over the compose network, gated by a shared JACK_MANAGEMENT_KEY. README quick-start and layout updated to match.
A single newline renders as a hard break in GitHub PR comments.
The Nitro server already listens on $PORT and the healthcheck already uses it; make EXPOSE reference it too so the documented port matches an overridden PORT.
localhost resolves to ::1 (IPv6) first in the alpine image, but the Nitro server binds IPv4 (0.0.0.0) only and BusyBox wget won't fall back — so the probe was refused and the container never became healthy (Coolify/Traefik then reported 'no available server').
The task derives E2E_DIR relative to $0; .mise/tasks/test/ is one level deeper than the old mise-tasks/test/, so ../../ landed in .mise/ instead of the repo root. Use ../../../ to reach e2e/.
The Dockerfile moved from the repo root to apps/backend/, but e2e's compose still built jack-alpha/jack-beta with dockerfile: Dockerfile (context ..), breaking the e2e job at the 'Starting Jack instances' step.
…plate - README: add a Management UI section (TOC + after Quick start) - README: document MANAGEMENT_KEY / MANAGEMENT_PORT env vars - ui README: NUXT_MANAGEMENT_KEY -> JACK_MANAGEMENT_KEY (env prefix is JACK_) - backend CLAUDE: note Hono server, drop irrelevant frontend section - schemas CLAUDE: note types-only package, drop irrelevant frontend section
… session key - peers/servers: catch DELETE failures and show them in the remove modal instead of silently swallowing the rejected promise - ping: comment said three-state but the probe returns four states - compose: don't default JACK_SESSION_KEY to an empty string (would override nuxt.config and break cookie sealing); leave it unset for inject mode and document enabling it for cookie mode
Holding Shift while clicking Save adds/updates the peer even if its handshake fails, instead of aborting and rolling back. The peer is persisted and stays resident so the backend retries it lazily. - backend: `?force=true` on POST/PATCH /config/peers flips off rethrowInitError - ui: PeerForm captures the shift modifier on the submit click and emits it; peers.vue forwards it as the force query param - collapse the submit label to a single "Save" for add and edit
…ected On a 401 in env-inject mode the key came from the UI's own env, so prompting for one in the browser is useless — surface the error screen instead. Cookie mode still falls back to the login gate. (Greptile review follow-up.)
An interval picker (2s–1m), a live countdown to the next refresh, and a pause/resume toggle (resuming refreshes immediately). Replaces the fixed 5s poll.
Downloads handed to *arr over the qB API had no terminal state: jack never learned when *arr finished importing, so rows sat at import_queued forever and the dashboard's completed count was always 0. - replace the vestigial transient 'completed' status with an 'imported' terminal status (migration remaps any legacy rows); lifecycle is now downloading -> import_queued -> imported - ImportWatcher polls each destination's /api/v3/history on an interval (downloads.importPollIntervalMs, default 30s) and flips matching import_queued rows to imported by infohash; rows are kept so the list is a history - ArrServerConnector.recentlyImportedDownloadIds reads downloadFolderImported events (case-insensitive hash match) - overview 'Completed' card now counts imported; UI status badge gains Imported - add the version to the 'Server listening' log (same version peers see)
Same fix as docker-compose.yml: an empty-string default would override nuxt.config and break cookie-mode session sealing. Comment it out for the inject-mode default. (Greptile review follow-up.)
…tcher Use a status-filtered query (backed by downloads_status_idx) instead of scanning the full table each tick. (Greptile review follow-up.)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a management UI for jack plus the backend endpoints it consumes.
Backend (
feat(backend))statusmodule exposing:GET /overview— peers, servers, and download counts (downloads enriched with computedprogress)GET /downloads— full download list, enrichedGET /ping— cheap key-guarded probe so the UI's BFF can verify the key and that the management API is upUI (
feat(ui))apps/ui— a Nuxt 4 client-rendered management UI that proxies the management API through a server-side BFF (never exposing the key to the browser), supporting both inject-key and cookie-key modes.mise run uitask to run the dev server.Test plan
bun test apps/backend/src/__tests__/management-status.test.tsGreptile Summary
This PR adds a Nuxt 4 management UI (
apps/ui) with a server-side BFF that proxies jack's management API, plus the backend endpoints (GET /overview,GET /downloads,GET /ping) and import lifecycle tracking the UI depends on. A newImportWatcherperiodically reconcilesimport_queueddownloads against each *arr server's history to flip rows to the newimportedterminal state.statusmodule with overview/downloads endpoints, renamesmarkCompleted→markImportQueuedand addsmarkImported, addsImportWatcherwith a clean start/stop lifecycle, and adds rollback-awareConnectorInitializationErrorhandling toaddServerConnector/addPeerConnectorfor interactive adds from the UI.completedstatus, addsimported, and maps existingcompletedrows toimportedfor backwards compatibility.Confidence Score: 5/5
Safe to merge; the BFF security model is correctly implemented and all identified concerns are minor migration semantics and a doc comment inaccuracy.
The BFF proxy, session sealing, CSRF guard, and key resolution logic are all correctly implemented. The rollback-aware connector init is a clean addition. The only substantive finding is the migration mapping
completed → importedrather thanimport_queued— this affects only edge-case rows from processes that crashed mid-transfer on old versions, and the practical impact is minimal since most deployments will have no rows stuck in the oldcompletedstate.apps/backend/drizzle/0005_young_sabretooth.sql — the status mapping for
completedrows is worth confirming before deploying to instances with existing data.Important Files Changed
importedstatus and removescompleted; migration mapscompleted → importedbut semanticallycompletedwas a pre-handoff intermediate state, soimport_queuedmay be more accurate for edge-case rows.import_queued → imported; correctly uses unref() so it doesn't keep the process alive, and has clean start/stop lifecycle hooked into SIGINT/SIGTERM.rethrowInitErroroption toaddServerConnector/addPeerConnector; snapshot-and-restore pattern correctly covers serverMap, destinationIds, and sourceIds.?.list() ?? [].Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Browser participant NuxtBFF as Nuxt BFF (server/) participant Session as Cookie/Env Key participant MgmtAPI as jack Management API Browser->>NuxtBFF: GET /api/ping NuxtBFF->>Session: resolveKey() Session-->>NuxtBFF: "{ key, mode }" NuxtBFF->>MgmtAPI: GET /ping (X-Management-Key: key) MgmtAPI-->>NuxtBFF: 200 / 401 / connection error NuxtBFF-->>Browser: "{ status: ok|needs-key|disabled|error, mode }" Browser->>NuxtBFF: "POST /api/login { key }" NuxtBFF->>NuxtBFF: assertSameOrigin() NuxtBFF->>MgmtAPI: GET /ping (X-Management-Key: key) MgmtAPI-->>NuxtBFF: 200 NuxtBFF->>Session: "session.update({ key })" NuxtBFF-->>Browser: "{ status: ok }" Browser->>NuxtBFF: GET /api/management/overview NuxtBFF->>Session: resolveKey() Session-->>NuxtBFF: "{ key }" NuxtBFF->>MgmtAPI: GET /overview (X-Management-Key: key) MgmtAPI-->>NuxtBFF: "{ peers, servers, downloads }" NuxtBFF-->>Browser: mirrors status + body (key never sent to browser)%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Browser participant NuxtBFF as Nuxt BFF (server/) participant Session as Cookie/Env Key participant MgmtAPI as jack Management API Browser->>NuxtBFF: GET /api/ping NuxtBFF->>Session: resolveKey() Session-->>NuxtBFF: "{ key, mode }" NuxtBFF->>MgmtAPI: GET /ping (X-Management-Key: key) MgmtAPI-->>NuxtBFF: 200 / 401 / connection error NuxtBFF-->>Browser: "{ status: ok|needs-key|disabled|error, mode }" Browser->>NuxtBFF: "POST /api/login { key }" NuxtBFF->>NuxtBFF: assertSameOrigin() NuxtBFF->>MgmtAPI: GET /ping (X-Management-Key: key) MgmtAPI-->>NuxtBFF: 200 NuxtBFF->>Session: "session.update({ key })" NuxtBFF-->>Browser: "{ status: ok }" Browser->>NuxtBFF: GET /api/management/overview NuxtBFF->>Session: resolveKey() Session-->>NuxtBFF: "{ key }" NuxtBFF->>MgmtAPI: GET /overview (X-Management-Key: key) MgmtAPI-->>NuxtBFF: "{ peers, servers, downloads }" NuxtBFF-->>Browser: mirrors status + body (key never sent to browser)Reviews (6): Last reviewed commit: "perf(downloads): query import_queued row..." | Re-trigger Greptile