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) │ /control (→ :6800)
▼ ▼
┌─────────────────┐ ┌───────────────────────┐
│ bunqueue server │ │ control agent (Bun) │
│ HTTP :6790 │◄──spawn────│ ProcessManager │
│ SSE /events │ /health │ binds 127.0.0.1:6800 │
└─────────────────┘ └───────────────────────┘Components
- 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 the fetcher immediately and everyconnectionStore.refreshMsms, keeping the last good value while refreshing (no flicker) and never callingsetStateafter unmount. It has no per-request sequence guard, see known-issues.md for the resulting (self-healing, non-corrupting) race on rapid filter changes. - 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. See known-issues.md for two verified gaps in itsconnectedstate and reconnect behaviour. - 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.*(POST/PUT/DELETE) and thenrefetch(). Destructive actions (Cancel, Drain, Obliterate, Purge, Restart, Stop, remove-webhook) are gated behindwindow.confirm, there is no bespoke confirmation dialog component, by consistent convention across the whole app. - Job action gating. Anywhere a job's Promote/Retry/Discard/Cancel/Requeue action is offered (
JobInspector,JobsPro), the button set is computed by the single sharedlib/jobActions.ts::actionGates(state)from the job's current state, mirroring exactly what the server's location-based handlers will accept (e.g. Cancel only for a queue-resident job). This avoids offering an action that would silently fail or throw. See api-mapping.md for the full table.
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. Throws only on non-2xx HTTP status; does not inspect the response body for a logicalok:false(several bunqueue endpoints return HTTP 200 even on failure, see api-mapping.md). A few of its response types don't match the server's real shape (storage(),DlqEntry,DlqStats), see known-issues.md.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. Do not "fix"api.tsin place, add a corrected page usingbqinstead (the additive rule).
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.
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.