Skip to content

feat: management UI + backend status endpoints#26

Merged
roziscoding merged 25 commits into
mainfrom
feat/ui
Jun 24, 2026
Merged

feat: management UI + backend status endpoints#26
roziscoding merged 25 commits into
mainfrom
feat/ui

Conversation

@roziscoding

@roziscoding roziscoding commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a management UI for jack plus the backend endpoints it consumes.

Backend (feat(backend))

  • New status module exposing:
    • GET /overview — peers, servers, and download counts (downloads enriched with computed progress)
    • GET /downloads — full download list, enriched
    • GET /ping — cheap key-guarded probe so the UI's BFF can verify the key and that the management API is up
  • Wires the downloads repository into the management app.

UI (feat(ui))

  • New 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 ui task to run the dev server.
  • Enables Vue linting at the repo root and ignores Nuxt build artifacts.

Test plan

  • bun test apps/backend/src/__tests__/management-status.test.ts

Greptile 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 new ImportWatcher periodically reconciles import_queued downloads against each *arr server's history to flip rows to the new imported terminal state.

  • Backend: New status module with overview/downloads endpoints, renames markCompletedmarkImportQueued and adds markImported, adds ImportWatcher with a clean start/stop lifecycle, and adds rollback-aware ConnectorInitializationError handling to addServerConnector/addPeerConnector for interactive adds from the UI.
  • UI BFF: Nuxt 4 client-rendered SPA whose server routes inject the management key (inject mode) or seal it in an HttpOnly/SameSite=Strict session cookie (cookie mode), so the key never reaches the browser. Download pages include an auto-refresh ticker with pause/resume controls.
  • Migration: Schema migration 0005 removes the completed status, adds imported, and maps existing completed rows to imported for 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 → imported rather than import_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 old completed state.

apps/backend/drizzle/0005_young_sabretooth.sql — the status mapping for completed rows is worth confirming before deploying to instances with existing data.

Important Files Changed

Filename Overview
apps/backend/drizzle/0005_young_sabretooth.sql Adds imported status and removes completed; migration maps completed → imported but semantically completed was a pre-handoff intermediate state, so import_queued may be more accurate for edge-case rows.
apps/backend/src/modules/downloads/import-watcher.ts New ImportWatcher polls *arr for import history and flips import_queued → imported; correctly uses unref() so it doesn't keep the process alive, and has clean start/stop lifecycle hooked into SIGINT/SIGTERM.
apps/backend/src/lib/servers/index.ts Adds rollback-aware rethrowInitError option to addServerConnector/addPeerConnector; snapshot-and-restore pattern correctly covers serverMap, destinationIds, and sourceIds.
apps/backend/src/modules/config/config.service.ts Adds transactional rollback for interactive add/update operations: persists config changes, attempts connector init, and rolls back both the file and in-memory state if init fails.
apps/ui/server/utils/management.ts BFF utilities: session management, key resolution (env vs cookie mode), upstream URL construction, CSRF same-origin check, and upstream probe — all correctly implemented with defense-in-depth.
apps/ui/server/api/management/[...path].ts BFF reverse proxy: correctly resolves key, enforces same-origin on mutations, mirrors upstream status and response body, and never exposes the management key to the browser.
apps/ui/nuxt.config.ts Nuxt 4 config with correct SSR-off, BFF runtime config, and JACK_ env prefix setup; comment describing envPrefix as a "swap" is slightly misleading (it adds a secondary prefix, not replaces NUXT_).
apps/backend/src/modules/status/status.controller.ts New controller exposing overview and download-list endpoints; enriches downloads with computed progress; handles optional repository correctly with ?.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)
Loading
%%{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)
Loading

Reviews (6): Last reviewed commit: "perf(downloads): query import_queued row..." | Re-trigger Greptile

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.
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

🐳 Docker images published

This PR has been built and pushed to GHCR:

ghcr.io/roziscoding/jack:pr-26        # backend
ghcr.io/roziscoding/jack-ui:pr-26     # management UI

Pull them locally:

docker pull ghcr.io/roziscoding/jack:pr-26
docker pull ghcr.io/roziscoding/jack-ui:pr-26

Run the backend standalone:

docker run --rm ghcr.io/roziscoding/jack:pr-26

The UI needs the backend + a management key, so run the two together with examples/docker-compose.yml, overriding the image tags to pr-26.

Last built from commit 9cd218d. Heads up: these images are automatically deleted when the PR is closed.

…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.
@roziscoding roziscoding marked this pull request as ready for review June 23, 2026 22:06
Comment thread apps/ui/app/pages/peers.vue
Comment thread apps/ui/server/api/ping.get.ts
Comment thread examples/docker-compose.yml Outdated
…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.)
@roziscoding roziscoding changed the title feat: management UI (Nuxt BFF) + backend status endpoints feat: management UI + backend status endpoints Jun 24, 2026
@roziscoding roziscoding merged commit f9af149 into main Jun 24, 2026
6 checks passed
@roziscoding roziscoding deleted the feat/ui branch June 24, 2026 00:01
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.

1 participant