diff --git a/README.md b/README.md
index 3657314..aae51fc 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ vx mcp # Model Context Protocol server (stdio
vx coordinator build test --workers 4 # start a distributed-CI coordinator
vx run --worker ws://coord:5180 # pull tasks from a coordinator and execute them
-vx serve --ui --open # unified backend + bundled insights SPA + open browser
+vx serve --ui --open # unified backend + embedded dashboard + open browser
# /v1/* JSON, SSE events, WS run protocol, CORS *
```
@@ -86,12 +86,12 @@ vx serve --ui --open # unified backend + bundled insights S
natively; every event flows to Grafana / Honeycomb / Datadog /
Tempo with zero bridge package.
- **Self-host vx serve.** Same backend everywhere — laptop, Docker,
- any container runtime. JSON `/v1/*` insights API + WebSocket run
+ any container runtime. JSON `/v1/*` metrics API + WebSocket run
protocol + SSE event stream + permissive CORS. One stack.
-- **Insights dashboard built in.** `vx serve --ui` bundles a Solid
+- **Dashboard embedded in the binary.** `vx serve --ui` serves a Solid
SPA at `/` — task averages, p50/p99, cache savings, recent runs,
- flamegraphs. Connection picker switches between local and hosted
- backends; same UI for both.
+ flamegraphs — compiled into `vx` itself, nothing to install.
+ Connection picker switches between local and hosted backends.
Each lives behind a one-paragraph design doc under
`docs/design/*-2026-06.md`. Phase-by-phase implementation log:
@@ -309,7 +309,7 @@ Production readiness for the **2026-06 platform layer**:
| `vx coordinator` + `vx run --worker` | **shippable for self-hosted CI** | content-addressed assignment, disconnect recovery |
| Plugin API | **shippable** | crash-isolated, lifecycle hooks fire end-to-end |
| Predictive scheduling | **shippable as opt-in** | gated on `predictive: true` + observed data |
-| `apps/insights/` (Solid SPA → vx serve HTTP) | **scaffold** | connection picker, HTTP /v1/\* reads; pages need real-world iteration |
+| `apps/ui/` (Solid dashboard, embedded in binary) | **scaffold** | connection picker, HTTP /v1/\* reads; pages need real-world iteration |
| OTel native emit (`src/orchestrator/otel-emit.ts`) | **shippable** | env-var auto-attach in `run()`; ships event stream to any OTLP backend |
## Development
diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs
index b837532..2098e08 100644
--- a/apps/docs/astro.config.mjs
+++ b/apps/docs/astro.config.mjs
@@ -70,7 +70,7 @@ export default defineConfig({
{ label: 'Distributed CI execution', link: '/guides/distributed-ci/' },
{ label: 'Writing a vx plugin', link: '/guides/plugins/' },
{ label: 'Predictive scheduling', link: '/guides/predictive-scheduling/' },
- { label: 'Insights dashboard', link: '/guides/insights/' },
+ { label: 'Dashboard', link: '/guides/dashboard/' },
{ label: 'Self-host vx serve', link: '/guides/self-hosting/' },
{ label: 'OpenTelemetry CI/CD spans', link: '/guides/otel-bridge/' },
{ label: 'vx serve wire protocol', link: '/guides/wire-protocol/' },
diff --git a/apps/docs/src/content/docs/guides/insights.md b/apps/docs/src/content/docs/guides/dashboard.md
similarity index 76%
rename from apps/docs/src/content/docs/guides/insights.md
rename to apps/docs/src/content/docs/guides/dashboard.md
index 0d6946c..2b2ebde 100644
--- a/apps/docs/src/content/docs/guides/insights.md
+++ b/apps/docs/src/content/docs/guides/dashboard.md
@@ -1,11 +1,12 @@
---
-title: Insights dashboard
-description: A Solid SPA bundled into vx serve. Run history, per-task averages, cache stats. One flag, no daemon.
+title: Dashboard
+description: A Solid SPA bundled into the vx binary. Run history, per-task averages, cache stats. One flag, no daemon, nothing to install.
---
-The insights dashboard ships inside `vx serve` itself. Pass `--ui`
-and the same backend that drives `vx run` delegation also serves
-the SPA at `/`. One process, one stack.
+The dashboard ships **inside the `vx` binary**. Pass `--ui` to
+`vx serve` and the same backend that drives `vx run` delegation also
+serves the dashboard at `/`. No separate install, no asset directory
+on disk — the SPA is embedded in the executable.
## Quick start
@@ -17,11 +18,11 @@ vx serve --ui --open
That:
1. boots `vx serve` on a kernel-assigned port,
-2. serves the bundled insights SPA at `/`,
+2. serves the embedded dashboard at `/`,
3. opens your default browser at the same origin.
-`Ctrl-C` stops it. Drop `--open` if you'd rather not auto-launch
-a browser; drop `--ui` to get just the JSON API + WS + SSE.
+`Ctrl-C` stops it. Drop `--open` if you'd rather not auto-launch a
+browser; drop `--ui` to get just the JSON API + WS + SSE.
Pin a port with `--port`:
@@ -57,7 +58,7 @@ host the SPA once and let everyone aim it at their own backend.
```
Browser
┌─────────────────────────────┐
- │ apps/insights SPA (Solid) │
+ │ apps/ui SPA (Solid) │
│ • connection picker │
│ • fetch over HTTP │
└────────┬────────────────────┘
@@ -67,7 +68,7 @@ host the SPA once and let everyone aim it at their own backend.
┌─────────────────────────────┐
│ vx serve --ui (Bun.serve) │
│ • /v1/* JSON over cache.db │
- │ • SPA static at / │
+ │ • SPA embedded in binary │
│ • CORS * │
│ • SSE event stream │
│ • WS run protocol │
@@ -80,6 +81,10 @@ host the SPA once and let everyone aim it at their own backend.
└─────────────────────────────┘
```
+The dashboard builds to a single self-contained `index.html` (JS +
+CSS inlined), which the binary embeds via Bun's `with { type: 'file' }`.
+A compiled `vx` carries it with nothing else on disk.
+
## HTTP surface
`vx serve` exposes:
@@ -107,19 +112,19 @@ All routes ship `Access-Control-Allow-Origin: *`.
## Host the SPA once, point it anywhere
-You can also build `apps/insights/dist/` and deploy it to any static
-host. The connection picker means the same hosted bundle works
-against any reachable `vx serve` — browsers allow HTTPS pages to
-call `http://localhost:*` per the Secure Context exception, so a
-hosted `https://insights.example.com` reading from a local
-`http://localhost:4321` Just Works.
+You can also build `apps/ui/dist/` and deploy the single
+`index.html` to any static host. The connection picker means the
+same hosted bundle works against any reachable `vx serve` — browsers
+allow HTTPS pages to call `http://localhost:*` per the Secure
+Context exception, so a hosted `https://dash.example.com` reading
+from a local `http://localhost:4321` Just Works.
## Privacy
When you run `vx serve --ui` locally, nothing leaves your machine.
A hosted SPA pointed at `http://localhost:*` is also entirely
-local — the picker is just configuration; the page reads from
-your machine, not a third party.
+local — the picker is just configuration; the page reads from your
+machine, not a third party.
## Known limits
@@ -127,8 +132,8 @@ your machine, not a third party.
server (`/v1/events`) but the SPA doesn't subscribe yet. Reload
to see new runs.
- **No auth.** `vx serve` binds to localhost by default; trust is
- by network reachability. Add a reverse proxy with auth for
- hosted deployments.
+ by network reachability. Add a reverse proxy with auth for hosted
+ deployments.
See also: [`Self-hosting`](/vx/guides/self-hosting/),
[`Wire protocol`](/vx/guides/wire-protocol/).
diff --git a/apps/docs/src/content/docs/guides/self-hosting.md b/apps/docs/src/content/docs/guides/self-hosting.md
index 4514990..88abc8a 100644
--- a/apps/docs/src/content/docs/guides/self-hosting.md
+++ b/apps/docs/src/content/docs/guides/self-hosting.md
@@ -84,25 +84,28 @@ vx.example.com {
}
```
-## Point the SPA at it
+## Dashboard
-Build `apps/insights/` once and host the resulting `dist/`. Users
-open it, paste the server origin into the connection picker, and
-the SPA reads via `/v1/*`. No build step per user, no per-user
-config — same SPA, any backend.
+`vx serve --ui` serves the dashboard at `/` directly from the binary
+(it's embedded — no asset directory to deploy). For a hosted
+deployment behind a proxy, that's all you need.
+
+If you'd rather host the dashboard separately, build the single-file
+bundle and drop it on any static host. The connection picker means
+the same bundle works against any reachable `vx serve`:
```sh
-cd apps/insights
+cd apps/ui
bun install
bun run build
-# Deploy dist/ to any static host (S3 + CloudFront, Vercel, GitHub
-# Pages, your own nginx — anywhere).
+# Deploy the single dist/index.html anywhere (S3 + CloudFront, Vercel,
+# GitHub Pages, your own nginx).
```
## Browser → localhost gotcha
The Secure Context exception in WHATWG lets HTTPS pages call
-`http://localhost:*`. So a hosted `https://insights.example.com`
+`http://localhost:*`. So a hosted `https://dash.example.com`
can read from `http://localhost:4321` without breaking the mixed-
content rule — that's intentional, and what the connection picker
exploits.
@@ -120,5 +123,5 @@ story. We unified on `vx serve`:
- The hosted SPA sees the same `/v1/*` shape locally or against a
multi-tenant deployment — no shimming.
-See also: [`insights`](/vx/guides/insights/),
+See also: [`dashboard`](/vx/guides/dashboard/),
[`wire protocol`](/vx/guides/wire-protocol/).
diff --git a/apps/docs/src/content/docs/introduction.md b/apps/docs/src/content/docs/introduction.md
index cc674ec..564133e 100644
--- a/apps/docs/src/content/docs/introduction.md
+++ b/apps/docs/src/content/docs/introduction.md
@@ -75,9 +75,10 @@ require additional services:
- **[Self-host vx serve](../guides/self-hosting/)** — drop the binary
in Docker, get a hosted backend with the same JSON `/v1/*` shape
the local one has. One stack, no separate cloud project.
-- **[Insights dashboard](../guides/insights/)** — Solid SPA bundled
- into `vx serve --ui`. Run history, per-task averages, cache stats.
- Connection picker switches between local and hosted servers.
+- **[Dashboard](../guides/dashboard/)** — Solid SPA embedded in the
+ `vx` binary, served by `vx serve --ui`. Run history, per-task
+ averages, cache stats. Connection picker switches between local and
+ hosted servers.
- **[OpenTelemetry CI/CD spans](../guides/otel-bridge/)** — set
`OTEL_EXPORTER_OTLP_ENDPOINT`, install the three OTel peers; every
event lands in Grafana / Honeycomb / Datadog / Tempo natively, no
diff --git a/apps/docs/src/pages/index.astro b/apps/docs/src/pages/index.astro
index 03ba8a6..95f8ab5 100644
--- a/apps/docs/src/pages/index.astro
+++ b/apps/docs/src/pages/index.astro
@@ -103,7 +103,7 @@ const platform = [
icon: 'cloud',
tone: 'var(--plasma)',
title: 'Self-hostable, Docker-ready',
- body: 'vx serve runs locally or in Docker — same backend everywhere. The hosted insights SPA points at any reachable origin via a connection picker. No separate cloud stack, no CF lock-in.',
+ body: 'vx serve runs locally or in Docker — same backend everywhere. The dashboard is embedded in the binary and points at any reachable origin via a connection picker. No separate cloud stack, no CF lock-in.',
},
{
icon: 'brain',
diff --git a/apps/insights/README.md b/apps/insights/README.md
deleted file mode 100644
index ff59d13..0000000
--- a/apps/insights/README.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# @vzn/vx-insights
-
-Local SPA over `cache.db` — read-only analytics for your vx runs.
-
-## What this is
-
-A Solid + UnoCSS + Vite app that loads DuckDB-WASM in the browser, ATTACHes
-the local SQLite `cache.db` via the `sqlite_scanner` extension, and runs
-typed queries against it for the run history and per-task flamegraphs.
-
-No backend. No ETL. Pure client-side analytics over the same database `vx
-run` writes to.
-
-## Run it
-
-The intended entry point is the CLI:
-
-```bash
-vx insights serve
-```
-
-which boots both this SPA's dev server (port 5290 by default) and a tiny
-static file server that exposes the workspace's `cache.db` to the
-browser via the `VITE_CACHE_DB_URL` env var.
-
-Standalone (for development of the SPA itself):
-
-```bash
-bun --cwd apps/insights install
-bun --cwd apps/insights run dev
-```
-
-The SPA will look for `cache.db` at the path in `VITE_CACHE_DB_URL`, or
-fall back to `/cache.db` on the same origin.
-
-## Build
-
-```bash
-bun --cwd apps/insights run build
-# dist/ is the static bundle
-```
-
-## Architecture notes
-
-- **DuckDB-WASM is large (~30MB).** It loads lazily on the first query.
- The Overview page shows a loading state during the bootstrap.
-- **DuckDB reads SQLite directly** via the `sqlite_scanner` extension —
- no ETL, no schema rewrites. The same DB `vx run` writes to is the DB
- the SPA reads.
-- **Read-only.** The SPA never writes to `cache.db`. The SQLite handle
- on the browser side never opens with a write flag.
-- **No build step in the orchestrator's hot path.** `vx insights serve`
- fails loud with a build hint if `apps/insights/dist/` is missing
- rather than silently falling back to something else.
diff --git a/apps/insights/vite.config.ts b/apps/insights/vite.config.ts
deleted file mode 100644
index f881aa7..0000000
--- a/apps/insights/vite.config.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { defineConfig } from 'vite'
-import solid from 'vite-plugin-solid'
-import unocss from 'unocss/vite'
-
-export default defineConfig({
- plugins: [unocss(), solid()],
- server: {
- port: 5290,
- strictPort: false,
- },
- preview: {
- port: 5290,
- },
-})
diff --git a/apps/ui/README.md b/apps/ui/README.md
new file mode 100644
index 0000000..fc836b5
--- /dev/null
+++ b/apps/ui/README.md
@@ -0,0 +1,47 @@
+# @vzn/vx-ui
+
+The vx dashboard — a Solid SPA that reads a `vx serve` over HTTP.
+
+It builds to a **single self-contained `dist/index.html`** (JS + CSS
+inlined; see `vite.config.ts`). `vx serve --ui` embeds that one file
+into the `vx` binary via `with { type: 'file' }`, so the dashboard
+ships inside `vx` with nothing else on disk.
+
+## Run it
+
+From a built `vx`:
+
+```bash
+vx serve --ui --open
+```
+
+## Develop
+
+```bash
+bun run --filter @vzn/vx-ui dev # Vite dev server
+bun run --filter @vzn/vx-ui build # produce the single-file dist/index.html
+# or, via vx (what the binary build uses):
+vx run build.ui
+```
+
+`VITE_DEFAULT_ORIGIN` seeds the dev server's connection target; the
+header's connection picker overrides it at runtime. The SPA is
+platform-agnostic — every read is an HTTP call to a configurable
+origin, so the same UI works against a local or hosted `vx serve`.
+
+## Pages
+
+- **Overview** — time saved, hit rate, top time-burners, recent
+ failures, cache-by-project, recent invocations.
+- **Tasks** — sortable per-(project, task) table (runs, success/hit
+ rate, avg/p50/p99, total time, last run).
+- **Task detail** — full stats, duration sparkline, latest cache
+ entry, recent-run table with CPU + peak RSS.
+- **Cache** — entries table (sortable) + by-project bytes breakdown.
+- **Run detail** — per-task flamegraph.
+
+## Data source
+
+Everything comes from `vx serve`'s `/v1/*` JSON routes over the
+workspace's `cache.db`. No DuckDB, no direct file reads, no build
+step in the runner's hot path.
diff --git a/apps/insights/index.html b/apps/ui/index.html
similarity index 91%
rename from apps/insights/index.html
rename to apps/ui/index.html
index 8f774af..3b68822 100644
--- a/apps/insights/index.html
+++ b/apps/ui/index.html
@@ -4,7 +4,7 @@
-
vx insights
+ vx dashboard
diff --git a/apps/insights/package.json b/apps/ui/package.json
similarity index 93%
rename from apps/insights/package.json
rename to apps/ui/package.json
index 8af922b..e1ad872 100644
--- a/apps/insights/package.json
+++ b/apps/ui/package.json
@@ -1,5 +1,5 @@
{
- "name": "@vzn/vx-insights",
+ "name": "@vzn/vx-ui",
"version": "0.0.0",
"private": true,
"type": "module",
diff --git a/apps/insights/src/api.ts b/apps/ui/src/api.ts
similarity index 96%
rename from apps/insights/src/api.ts
rename to apps/ui/src/api.ts
index a52c4ac..4c132d8 100644
--- a/apps/insights/src/api.ts
+++ b/apps/ui/src/api.ts
@@ -1,4 +1,4 @@
-// HTTP client for the vx serve insights API. Same shape locally or
+// HTTP client for the vx serve metrics API. Same shape locally or
// against a remote/hosted vx serve — the SPA is platform-agnostic.
//
// The base URL is resolved from the connection store; that store
@@ -9,11 +9,11 @@
import { createSignal } from 'solid-js'
-const STORAGE_KEY = 'vx-insights:origin'
+const STORAGE_KEY = 'vx-ui:origin'
function defaultOrigin(): string {
- // `vx insights` injects this at dev time; the hosted build falls back
- // to the user choosing via the connection picker.
+ // The dev server injects this; the hosted build falls back to the user
+ // choosing via the connection picker.
const injected = import.meta.env.VITE_DEFAULT_ORIGIN
if (typeof injected === 'string' && injected.length > 0) return injected
return 'http://localhost:4321'
@@ -52,7 +52,7 @@ async function getJson(pathname: string): Promise {
}
// ---------------------------------------------------------------------------
-// Types — mirror src/orchestrator/insights-queries.ts return shapes.
+// Types — mirror src/orchestrator/metrics.ts return shapes.
// ---------------------------------------------------------------------------
export interface RunSummaryRow {
diff --git a/apps/insights/src/components/Flamegraph.tsx b/apps/ui/src/components/Flamegraph.tsx
similarity index 100%
rename from apps/insights/src/components/Flamegraph.tsx
rename to apps/ui/src/components/Flamegraph.tsx
diff --git a/apps/insights/src/components/Shell.tsx b/apps/ui/src/components/Shell.tsx
similarity index 99%
rename from apps/insights/src/components/Shell.tsx
rename to apps/ui/src/components/Shell.tsx
index 0da6b58..37afd33 100644
--- a/apps/insights/src/components/Shell.tsx
+++ b/apps/ui/src/components/Shell.tsx
@@ -25,7 +25,7 @@ export const Shell: ParentComponent = (props) => {