Architecture
┌────────────────────────────────────────────────────────────────────┐
│ Browser (SPA, :5273) │
│ │
│ main.tsx → <App/> (React Router) │
│ └─ AppLayout ─ Sidebar + Topbar + <Outlet/> │
│ └─ pages/* and pages/control/* │
│ │
│ Data layer │
│ usePolledData(fetcher) ── interval ──► lib/bq.ts / lib/api.ts │
│ useActivityStream() ── SSE ───────► lib/sse.ts │
│ useThroughputSeries() ── 1s tick ───► bq.overview() │
│ stores/: theme · connection · alerts · s3 (Zustand + persist) │
└───────────┬──────────────────────────────┬────────────────────────┘
│ HTTP /api (proxy → :6790) │ /agent (→ :6800)
▼ ▼
┌─────────────────┐ ┌───────────────────────┐
│ bunqueue server │ │ control agent (Bun) │
│ HTTP :6790 │◄──spawn────│ ProcessManager │
│ SSE /events │ /health │ public client + CLI │
└─────────────────┘ └───────────────────────┘Components
Feature-slice architecture
New operational surfaces use a small hexagonal (ports-and-adapters) feature slice instead of importing transport code inside React components:
src/features/<capability>/
├── domain/ # pure state, traversal, validation and selection rules
├── application/ # repository ports and use-case orchestration
├── infrastructure/ # Bunqueue HTTP/agent adapters and response validation
└── ui/ # views and interaction stateWorkflow, Job Flow, Queue SDK, and S3 operations follow this boundary. Tests inject repository ports into the UI and fake runtime ports into agent routes; real E2E scripts exercise the same adapters against a disposable Bunqueue 2.8.59 process. Non-idempotent commands use synchronous leases, while reads carry a target/request generation so a late response cannot cross a server, queue, workflow, or form retarget.
The agent also owns one shared lifecycle gate across its local and bridged handlers. Workflow commands parse bounded input first, then atomically recheck the managed process state and generation before touching the Engine. Stop and restart close that Engine inside the same gate, so a slow request cannot revive runtime resources after the managed server has transitioned.
- Router & layout,
App.tsxdeclares every route (see pages.md for the full, verified table, several routes' page-family assignment is not what the path name would suggest) under oneAppLayout(Sidebar+Topbar+<Outlet/>). See components.md for the layout shell in detail. - Pages, two families, distinguished by which API client they use, not by any visual marker:
src/pages/*, first-generation classic view pages. Uselib/api.ts.src/pages/control/*, the Pro, full-control pages. Uselib/bq.ts.pages/control/job/andpages/control/queue/hold page-specific subcomponents too small to be their own page (e.g.JobTimeline,JobBackoff,QueueActions,ConfigForms).- The two families are not cleanly partitioned by route path, some Pro pages render at the "plain" path with the classic page pushed to
-classic(/jobs,/dlq,/metrics,/s3); others have no Pro equivalent at all (/queues,/workers,/usage,/settings). See pages.md for the authoritative table, don't infer family from the URL. - One page mixes clients:
LogsProcallsbq.queues()for the queue filter dropdown butuseActivityStream(shared with the classicLogspage) builds its SSE URL viaapi.eventsUrl(). Not a bug, the SSE endpoint is identical either way, but worth knowing if you're grepping for "does this page usebqorapi".
- UI kit & stores, see components.md for the full reference (
Card,StatCard,StatusBadge,Button,form.tsx,feedback.tsx,PageHeader,AreaChart,CopyButton, inline SVGicons, and the four Zustand stores).
Data flow
- Polling.
usePolledData(fetcher, deps)runs immediately, then schedules the next tick only after the current request settles. Dependency/server/token generations hide the previous view synchronously, abort obsolete work, and discard late results. The last good snapshot remains visible on a same-scope refresh error, while identical serialized snapshots avoid a React re-render. - Live activity.
useActivityStream(queue?)streams SSE from/events(or/events/queues/:q) via a fetch-based reader (lib/sse.ts) that supports a bearer token, unlikeEventSource. It keeps a bounded ring buffer of recent events (MAX_EVENTS = 250), cumulative counters, and a rolling 5s throughput. PowersOverviewPro's Recent Activity and bothLogsPro/Logs; any delivered frame proves liveness, and a clean stream end reconnects with bounded backoff. - Throughput sampling.
useThroughputSeries(windowSize=60)is independent of both of the above, it ticks on its own 1-secondsetInterval, callingbq.overview()each time and appendingthroughput.{pushPerSec,completePerSec,failPerSec}into a rolling window forMetricsPro'sAreaChart. This means the chart's cadence is fixed at 1s regardless ofconnectionStore.refreshMs. - Writes. Page actions call
bq.*/api.*and thenrefetch(), behind synchronous server/credential/owner leases and strict{ok:true}checks. Confirmations remain for authorized high-impact writes such as process lifecycle and webhook deletion. Cancel, queue Discard, Drain/Clean/Obliterate, every DLQ Retry/Purge and completed-job requeue are disabled because a confirmation cannot compensate for missing atomic generation/state/topology guarantees. DLQmaxAge/maxEntriesare rendered read-only and omitted from saves; auto-retry can only be disabled. - Job action gating. Anywhere job lifecycle actions are rendered (
JobInspector,JobsPro), the button set is computed by the single sharedlib/jobActions.ts::actionGates(state). Promote is available only for delayed jobs. DLQ retry remains false because a fresh exact-ID GET cannot atomically constrain the later POST, which may hit a recreated job; completed-job requeue remains false becauseretryCompleteddoes not rebuild dependency registration or flow order. Cancel and Discard are always false. See api-mapping.md for the full table. - Copilot mutations. The assistant can read queue, job, DLQ, worker, cron and health data, but exposes only Promote, Pause and Resume as confirmed mutations. It has no DLQ retry or completed-job requeue tool.
The API layer
Two clients, on purpose (see the additive rule in the project CLAUDE.md):
lib/api.ts, the original client, used only by classic pages. It throws on non-2xx responses and logical HTTP-200{ ok:false }failures, and its storage, job-timestamp and DLQ types mirror the server.lib/bq.ts, the complete, shape-verified client behind everypages/control/*page and the control agent. Itscall()helper throws on non-2xx and on a parsed{ ok: false }body, with one deliberate carve-out:health()passesstrict:falsebecauseGET /health'sokfield means "server healthy" (can legitimately befalseon disk-full with HTTP 200), not "request succeeded", see api-mapping.md for why that distinction matters and which other endpoints are strict.- Types for
bqlive inlib/bqTypes.ts(verified against a live server, see api-mapping.md); types forapilive inlib/types.ts. - New work always uses
bq, which exposes the complete control surface.
The control agent
A tiny local Bun process (agent/) that supervises a bunqueue server child process, because a browser can't start/stop an OS process and bunqueue's HTTP API has no process-lifecycle endpoint. See agent.md for the full reference (endpoints, ServerConfig/runningConfig split, dbStats()). Because it can spawn processes it binds 127.0.0.1 only and is guarded by a locked-CORS Origin allowlist (never *) plus an optional AGENT_TOKEN bearer gate, see agent.md and SECURITY.md. Keep its port on loopback (or an equally trusted network) regardless. The all-in-one server independently gates every remote/proxied administrative /api/* request with BUNQUEUE_TOKEN; this is not the agent credential.
Theming
Tailwind CSS v4 with CSS-variable tokens (--bg, --surface, --line, --fg, --muted, --accent, …) mapped into Tailwind via @theme inline, so utilities like bg-surface / text-muted / border-line flip instantly when data-theme changes. Dark is the default; light: is a custom variant. Inter + JetBrains Mono (variable) via Fontsource; numbers use tabular figures (.tnum). themeStore.initTheme() applies the persisted theme before the first render (no flash-of-wrong-theme); see the Stores section in components.md.