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/src/components/Shell.tsx b/apps/insights/src/components/Shell.tsx
deleted file mode 100644
index 0da6b58..0000000
--- a/apps/insights/src/components/Shell.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import { createResource, createSignal, Show, type ParentComponent } from 'solid-js'
-import { A } from '@solidjs/router'
-import { getOrigin, getOriginSignal, getVersion, setOriginAndPersist } from '../api.ts'
-
-export const Shell: ParentComponent = (props) => {
- const origin = getOriginSignal()
- const [version] = createResource(origin, async () => {
- try {
- return await getVersion()
- } catch {
- return null
- }
- })
-
- const [editing, setEditing] = createSignal(false)
- const [draft, setDraft] = createSignal(getOrigin())
-
- function commit() {
- setOriginAndPersist(draft())
- setEditing(false)
- }
-
- return (
-
-
-
-
- vx insights
-
-
-
- Overview
-
-
- Tasks
-
-
- Cache
-
-
-
{
- setDraft(getOrigin())
- setEditing(true)
- }}
- class="flex items-center gap-2 text-xs font-mono px-2 py-1 rounded border border-border-muted hover:bg-bg cursor-pointer"
- title="Change connection"
- >
-
- {origin()}
-
- }
- >
-
-
-
-
-
{props.children}
-
-
- Not connected. Run vx serve in your workspace and paste its origin above.
- >
- }
- >
- {(v) => (
- <>
- vx {v().vx} · workspace {v().workspace}
- >
- )}
-
-
-
- )
-}
diff --git a/apps/insights/src/components/Sparkline.tsx b/apps/insights/src/components/Sparkline.tsx
deleted file mode 100644
index 045e6a3..0000000
--- a/apps/insights/src/components/Sparkline.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-// Tiny inline SVG sparkline — no chart library. Renders a line + an
-// optional cache-hit dot marker series, sized to its container.
-
-import { For } from 'solid-js'
-
-export interface SparkPoint {
- value: number
- hit?: boolean
-}
-
-export function Sparkline(props: {
- data: readonly SparkPoint[]
- width?: number
- height?: number
- strokeClass?: string
-}) {
- const w = () => props.width ?? 280
- const h = () => props.height ?? 36
- const data = () => props.data
- const max = () => Math.max(1, ...data().map((p) => p.value))
- const min = () => Math.min(...data().map((p) => p.value), 0)
- const range = () => Math.max(1, max() - min())
-
- const xs = () => {
- const n = data().length
- if (n <= 1) return [w() / 2]
- return data().map((_, i) => (i / (n - 1)) * w())
- }
- const ys = () => data().map((p) => h() - ((p.value - min()) / range()) * h())
-
- const linePath = () => {
- const X = xs()
- const Y = ys()
- if (X.length === 0) return ''
- return X.map((x, i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${Y[i]!.toFixed(1)}`).join(' ')
- }
-
- const areaPath = () => {
- const X = xs()
- const Y = ys()
- if (X.length === 0) return ''
- const line = X.map((x, i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${Y[i]!.toFixed(1)}`).join(' ')
- return `${line} L${X[X.length - 1]!.toFixed(1)},${h()} L${X[0]!.toFixed(1)},${h()} Z`
- }
-
- return (
-
-
-
-
- {(pt, i) => (
-
- )}
-
-
- )
-}
diff --git a/apps/insights/src/format.ts b/apps/insights/src/format.ts
deleted file mode 100644
index 41b7c57..0000000
--- a/apps/insights/src/format.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-export function formatDuration(ms: number): string {
- if (!Number.isFinite(ms) || ms < 0) return '—'
- if (ms < 1) return '<1ms'
- if (ms < 1000) return `${Math.round(ms)}ms`
- if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`
- const m = Math.floor(ms / 60_000)
- const s = Math.floor((ms % 60_000) / 1000)
- return `${m}m ${s}s`
-}
-
-const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
-
-export function formatBytes(b: number): string {
- if (!Number.isFinite(b) || b <= 0) return '0 B'
- const i = Math.min(SIZE_UNITS.length - 1, Math.floor(Math.log(b) / Math.log(1024)))
- return `${(b / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 2)} ${SIZE_UNITS[i]}`
-}
-
-export function formatRelativeTime(date: Date | number): string {
- const ts = typeof date === 'number' ? date : date.getTime()
- const diff = Date.now() - ts
- if (diff < 0) return 'in the future'
- const s = Math.floor(diff / 1000)
- if (s < 60) return `${s}s ago`
- const m = Math.floor(s / 60)
- if (m < 60) return `${m}m ago`
- const h = Math.floor(m / 60)
- if (h < 24) return `${h}h ago`
- const d = Math.floor(h / 24)
- return `${d}d ago`
-}
-
-export function formatPercent(n: number): string {
- if (!Number.isFinite(n)) return '—'
- return `${(n * 100).toFixed(1)}%`
-}
diff --git a/apps/insights/src/pages/CachePage.tsx b/apps/insights/src/pages/CachePage.tsx
deleted file mode 100644
index f2392a3..0000000
--- a/apps/insights/src/pages/CachePage.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
-import { useNavigate } from '@solidjs/router'
-import {
- getCacheBreakdown,
- getCacheStats,
- getOriginSignal,
- listCacheEntries,
-} from '../api.ts'
-import { formatBytes, formatDuration, formatPercent, formatRelativeTime } from '../format.ts'
-
-type Sort = 'created_at' | 'accessed_at' | 'size_bytes' | 'duration_ms'
-
-export function CachePage() {
- const origin = getOriginSignal()
- const [sort, setSort] = createSignal('size_bytes')
- const [filter, setFilter] = createSignal('')
- const navigate = useNavigate()
-
- const [stats] = createResource(origin, () => getCacheStats())
- const [breakdown] = createResource(origin, () => getCacheBreakdown(50))
- const [entries] = createResource(
- () => ({ s: sort(), o: origin() }),
- (args) => listCacheEntries({ limit: 200, orderBy: args.s }),
- )
-
- const filtered = createMemo(() => {
- const rows = entries() ?? []
- const f = filter().toLowerCase()
- if (!f) return rows
- return rows.filter(
- (r) => `${r.project}#${r.task}`.toLowerCase().includes(f) || r.hash.includes(f),
- )
- })
-
- return (
-
-
Cache
-
-
-
-
-
-
-
-
-
-
- {/* Breakdown by project */}
-
0}>
-
-
-
- By project
-
-
-
-
-
- {(p) => {
- const widthPct = () => {
- const max = Math.max(...(breakdown() ?? []).map((x) => x.totalBytes))
- return max > 0 ? (p.totalBytes / max) * 100 : 0
- }
- return (
-
- {p.project}
-
-
-
-
- {p.entries} entries
-
- {formatBytes(p.totalBytes)}
-
- )
- }}
-
-
-
-
-
-
- {/* Entries table */}
-
-
-
-
- Entries
-
- setSort(e.currentTarget.value as Sort)}
- class="text-xs bg-bg border border-border-muted rounded px-2 py-1"
- >
- Largest first
- Newest first
- Recently accessed
- Slowest first
-
-
-
setFilter(e.currentTarget.value)}
- class="text-xs font-mono px-2 py-1 rounded border border-border-muted bg-bg w-60"
- />
-
-
-
-
- Task
- Hash
- Size
- Duration
- Created
- Accessed
-
-
-
-
- {(e) => (
-
- navigate(`/tasks/${encodeURIComponent(`${e.project}#${e.task}`)}`)
- }
- >
-
- {e.project}#{e.task}
-
- {e.hash.slice(0, 12)}…
- {formatBytes(e.sizeBytes)}
- {formatDuration(e.durationMs)}
-
- {formatRelativeTime(e.createdAt)}
-
-
- {formatRelativeTime(e.accessedAt)}
-
-
- )}
-
-
-
-
-
- No matching entries.>}
- >
- No cache entries yet. Run a cacheable task to populate.
-
-
-
-
-
- )
-}
-
-function Stat(props: { label: string; value: string }) {
- return (
-
-
{props.label}
-
{props.value}
-
- )
-}
diff --git a/apps/insights/src/pages/Overview.tsx b/apps/insights/src/pages/Overview.tsx
deleted file mode 100644
index 82cb56e..0000000
--- a/apps/insights/src/pages/Overview.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import { For, Show, createResource } from 'solid-js'
-import { useNavigate, A } from '@solidjs/router'
-import {
- getCacheBreakdown,
- getCacheSavings,
- getCacheStats,
- getFailures,
- getOriginSignal,
- getTopTasks,
- listInvocations,
-} from '../api.ts'
-import { formatBytes, formatDuration, formatPercent, formatRelativeTime } from '../format.ts'
-
-export function Overview() {
- const origin = getOriginSignal()
- const [runs] = createResource(origin, () => listInvocations(25))
- const [stats] = createResource(origin, () => getCacheStats())
- const [savings] = createResource(origin, () => getCacheSavings())
- const [topTasks] = createResource(origin, () => getTopTasks(10))
- const [failures] = createResource(origin, () => getFailures(10))
- const [breakdown] = createResource(origin, () => getCacheBreakdown(5))
- const navigate = useNavigate()
-
- return (
-
- {/* Hero stats: the four numbers a dev cares about */}
-
-
- 0}
- />
-
-
-
-
-
-
- {/* Two-column layout: top time burners + recent failures */}
-
-
- 0}
- fallback={ }
- >
-
-
-
- {(t) => (
- navigate(`/tasks/${encodeURIComponent(t.id)}`)}
- >
- {t.id}
- {t.runs} runs
-
- {formatDuration(t.totalDurationMs)}
-
-
- ~{formatDuration(t.avgDurationMs)} avg
-
-
- )}
-
-
-
-
-
-
-
- 0}
- fallback={No failures recorded.
}
- >
-
-
-
- {(f) => (
-
- navigate(`/tasks/${encodeURIComponent(`${f.project}#${f.task}`)}`)
- }
- >
-
- {f.project}#{f.task}
-
- exit {f.exitCode}
-
- {formatRelativeTime(f.startedAt)}
-
-
- )}
-
-
-
-
-
-
-
- {/* Cache breakdown by project */}
-
- 0}
- fallback={ }
- >
-
-
-
- {(p) => {
- const widthPct = () => {
- const max = Math.max(...(breakdown() ?? []).map((x) => x.totalBytes))
- return max > 0 ? (p.totalBytes / max) * 100 : 0
- }
- return (
-
- {p.project}
-
-
-
- {p.entries} entries
- {formatBytes(p.totalBytes)}
-
- )
- }}
-
-
-
-
-
-
- {/* Recent invocations */}
-
-
- Loading…
-
- 0} fallback={ }>
-
-
-
- Run
- Started
- Duration
- Tasks
- Failed
- Cache hits
-
-
-
-
- {(r) => (
- navigate(`/runs/${r.runId}`)}
- >
- {r.runId.slice(0, 8)}…
-
- {formatRelativeTime(r.startedAt)}
-
- {formatDuration(r.totalDurationMs)}
- {r.taskCount}
- 0 }}
- >
- {r.failedCount}
-
- {r.hitCount}
-
- )}
-
-
-
-
-
-
- )
-}
-
-function Stat(props: { label: string; value: string; sub?: string; highlight?: boolean }) {
- return (
-
-
{props.label}
-
{props.value}
-
- {props.sub}
-
-
- )
-}
-
-function Card(props: {
- title: string
- linkHref: string
- linkLabel: string
- children: ReturnType
-}) {
- return (
-
- )
-}
-
-function EmptyHint() {
- return (
-
- No data yet. Run a task with vx run to populate this view.
-
- )
-}
diff --git a/apps/insights/src/pages/RunDetail.tsx b/apps/insights/src/pages/RunDetail.tsx
deleted file mode 100644
index 0c92391..0000000
--- a/apps/insights/src/pages/RunDetail.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { For, Show, createResource } from 'solid-js'
-import { A, useParams } from '@solidjs/router'
-import { getRun } from '../api.ts'
-import { Flamegraph } from '../components/Flamegraph.tsx'
-import { formatDuration } from '../format.ts'
-
-export function RunDetail() {
- const params = useParams<{ id: string }>()
- const [run] = createResource(() => params.id, getRun)
-
- return (
-
-
-
- Loading…
-
-
- Failed to load: {String(run.error)}
-
-
-
- {run()!.tasks.length} task(s)
- {' · '}
- {formatDuration(
- run()!.tasks.reduce((acc, t) => acc + Number(t.durationMs ?? 0), 0),
- )}{' '}
- total
-
-
-
-
-
-
- Task
- Status
- Duration
- Cache
-
-
-
-
- {(t) => (
-
-
- {t.project}#{t.task}
-
- {t.status}
- {formatDuration(t.durationMs)}
-
- {t.cacheHit === true ? 'hit' : 'miss'}
-
-
- )}
-
-
-
-
-
-
- Run not found.
-
-
- )
-}
diff --git a/apps/insights/src/pages/TaskDetail.tsx b/apps/insights/src/pages/TaskDetail.tsx
deleted file mode 100644
index 116a1f7..0000000
--- a/apps/insights/src/pages/TaskDetail.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-import { For, Show, createMemo, createResource } from 'solid-js'
-import { A, useParams } from '@solidjs/router'
-import { getOriginSignal, getTaskDetail } from '../api.ts'
-import { Sparkline } from '../components/Sparkline.tsx'
-import { formatBytes, formatDuration, formatPercent, formatRelativeTime } from '../format.ts'
-
-export function TaskDetail() {
- const params = useParams<{ id: string }>()
- const origin = getOriginSignal()
- const [detail] = createResource(
- () => ({ id: params.id, o: origin() }),
- (args) => getTaskDetail(args.id),
- )
-
- const sparkPoints = createMemo(() => {
- const recent = detail()?.recent ?? []
- // Reverse so the sparkline reads left-to-right oldest→newest.
- return [...recent]
- .reverse()
- .map((r) => ({ value: r.durationMs, hit: r.cacheHit === true }))
- })
-
- return (
-
-
-
- Loading…
-
-
- Failed to load: {String(detail.error)}
-
-
- No data for this task.
-
-
- {(() => {
- const d = detail() as NonNullable>
- const agg = d.aggregate
- return (
- <>
- {/* Per-task stats grid */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Duration sparkline */}
- 0}>
-
-
- Duration (last {sparkPoints().length} runs · cache hits in cyan)
-
-
-
-
-
- {/* Latest cache entry */}
-
- {(() => {
- const e = d.latestEntry!
- return (
-
-
- Latest cache entry
-
-
- {e.hash.slice(0, 16)}…} />
-
- {e.command}} />
-
-
-
-
-
- )
- })()}
-
-
- {/* Full history table */}
-
-
-
- Recent runs ({d.recent.length})
-
-
-
-
-
- When
- Status
- Duration
- CPU
- Peak RSS
- Hash
-
-
-
-
- {(r) => (
-
-
- {formatRelativeTime(r.startedAt)}
-
-
-
-
- {formatDuration(r.durationMs)}
-
- {r.cpuMs !== null ? formatDuration(r.cpuMs) : '—'}
-
-
- {r.peakRssBytes !== null && r.peakRssBytes > 0
- ? formatBytes(r.peakRssBytes)
- : '—'}
-
-
- {r.hash.slice(0, 10)}…
-
-
- )}
-
-
-
-
- >
- )
- })()}
-
-
- )
-}
-
-function Stat(props: { label: string; value: string; sub?: string }) {
- return (
-
-
{props.label}
-
{props.value}
-
- {props.sub}
-
-
- )
-}
-
-function KV(props: { label: string; value: ReturnType | string }) {
- return (
-
- {props.label}
- {props.value}
-
- )
-}
-
-function StatusBadge(props: { status: string; exitCode: number; cacheHit: boolean | null }) {
- const tone = () =>
- props.status === 'failed'
- ? 'text-failure'
- : props.cacheHit
- ? 'text-cache'
- : props.status === 'success'
- ? 'text-success'
- : 'text-fg-muted'
- return (
-
- {props.status}
- {props.status === 'failed' && <> (exit {props.exitCode})>}
-
- )
-}
diff --git a/apps/insights/src/pages/Tasks.tsx b/apps/insights/src/pages/Tasks.tsx
deleted file mode 100644
index b3bb994..0000000
--- a/apps/insights/src/pages/Tasks.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
-import { useNavigate } from '@solidjs/router'
-import { getHistory, getOriginSignal, type TaskHistoryRow } from '../api.ts'
-import { formatDuration, formatPercent, formatRelativeTime } from '../format.ts'
-
-type SortKey =
- | 'id'
- | 'runs'
- | 'successRate'
- | 'hitRate'
- | 'avgDurationMs'
- | 'p50DurationMs'
- | 'p99DurationMs'
- | 'totalDurationMs'
- | 'lastSeenAt'
-
-export function Tasks() {
- const origin = getOriginSignal()
- const [history] = createResource(origin, () => getHistory(500))
- const [filter, setFilter] = createSignal('')
- const [sortKey, setSortKey] = createSignal('totalDurationMs')
- const [sortDesc, setSortDesc] = createSignal(true)
- const navigate = useNavigate()
-
- const filtered = createMemo(() => {
- const rows = history() ?? []
- const f = filter().toLowerCase()
- const filt = f ? rows.filter((r) => r.id.toLowerCase().includes(f)) : rows
- return [...filt].sort((a, b) => {
- const av = sortValue(a, sortKey())
- const bv = sortValue(b, sortKey())
- const cmp = av === bv ? 0 : av > bv ? 1 : -1
- return sortDesc() ? -cmp : cmp
- })
- })
-
- function onSort(k: SortKey) {
- if (sortKey() === k) setSortDesc(!sortDesc())
- else {
- setSortKey(k)
- setSortDesc(true)
- }
- }
-
- return (
-
-
-
Tasks
- setFilter(e.currentTarget.value)}
- class="text-xs font-mono px-2 py-1 rounded border border-border-muted bg-bg w-72"
- />
-
-
- Failed to load: {String(history.error)}
-
-
- Loading…
-
-
-
-
-
-
-
- Task
-
-
- Runs
-
-
- Success
-
-
- Hit
-
-
- Avg
-
-
- p50
-
-
- p99
-
-
- Total time
-
-
- Last run
-
-
-
-
-
- {(r) => (
- navigate(`/tasks/${encodeURIComponent(r.id)}`)}
- >
-
-
- {r.id}
-
- {r.runs}
- 0 && r.successRate < 0.9 }}
- >
- {formatPercent(r.successRate)}
-
- {formatPercent(r.hitRate)}
- {formatDuration(r.avgDurationMs ?? 0)}
- {formatDuration(r.p50DurationMs ?? 0)}
- {formatDuration(r.p99DurationMs ?? 0)}
- {formatDuration(r.totalDurationMs)}
-
- {r.lastSeenAt !== undefined ? formatRelativeTime(r.lastSeenAt) : '—'}
-
-
- )}
-
-
-
-
-
- No matching tasks.
-
-
-
-
-
- )
-}
-
-function sortValue(r: TaskHistoryRow, k: SortKey): number | string {
- switch (k) {
- case 'id':
- return r.id
- case 'runs':
- return r.runs
- case 'successRate':
- return r.successRate
- case 'hitRate':
- return r.hitRate
- case 'avgDurationMs':
- return r.avgDurationMs ?? 0
- case 'p50DurationMs':
- return r.p50DurationMs ?? 0
- case 'p99DurationMs':
- return r.p99DurationMs ?? 0
- case 'totalDurationMs':
- return r.totalDurationMs
- case 'lastSeenAt':
- return r.lastSeenAt ?? 0
- }
-}
-
-function Th(props: {
- k: SortKey
- curr: SortKey
- desc: boolean
- onSort: (k: SortKey) => void
- align?: 'left' | 'right'
- children: ReturnType
-}) {
- const active = () => props.curr === props.k
- return (
- props.onSort(props.k)}
- >
- {props.children}
-
- {props.desc ? '↓' : '↑'}
-
-
- )
-}
-
-function FailureBadge(props: { mode: TaskHistoryRow['failureMode'] }) {
- const color = () =>
- props.mode === 'stable'
- ? 'bg-success'
- : props.mode === 'flaky-recoverable'
- ? 'bg-skipped'
- : 'bg-failure'
- return (
-
- )
-}
diff --git a/apps/insights/uno.config.ts b/apps/insights/uno.config.ts
deleted file mode 100644
index 5b76da9..0000000
--- a/apps/insights/uno.config.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { defineConfig, presetIcons, presetUno, transformerVariantGroup } from 'unocss'
-
-export default defineConfig({
- presets: [presetUno(), presetIcons({ scale: 1.2 })],
- transformers: [transformerVariantGroup()],
- theme: {
- colors: {
- bg: 'var(--bg)',
- 'bg-elevated': 'var(--bg-elevated)',
- fg: 'var(--fg)',
- 'fg-muted': 'var(--fg-muted)',
- 'border-muted': 'var(--border-muted)',
- accent: 'var(--accent)',
- success: 'var(--success)',
- failure: 'var(--failure)',
- skipped: 'var(--skipped)',
- cache: 'var(--cache)',
- },
- fontFamily: {
- mono: 'ui-monospace, SFMono-Regular, Menlo, monospace',
- },
- },
- preflights: [
- {
- getCSS: () => `
- :root {
- --bg: #0b0d10;
- --bg-elevated: #14181d;
- --fg: #e6e8ec;
- --fg-muted: #8a92a0;
- --border-muted: #232830;
- --accent: #c084fc;
- --success: #4ade80;
- --failure: #f87171;
- --skipped: #facc15;
- --cache: #38bdf8;
- }
- html, body, #root { height: 100%; }
- body {
- margin: 0;
- background: var(--bg);
- color: var(--fg);
- font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
- font-size: 14px;
- }
- `,
- },
- ],
-})
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 87%
rename from apps/insights/package.json
rename to apps/ui/package.json
index 8af922b..7ac1590 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",
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"devDependencies": {
+ "@iconify-json/tabler": "^1.2.35",
"@solidjs/router": "^0.16.1",
"@unocss/preset-icons": "^66.7.2",
"@unocss/preset-uno": "^66.7.2",
diff --git a/apps/insights/src/api.ts b/apps/ui/src/api.ts
similarity index 68%
rename from apps/insights/src/api.ts
rename to apps/ui/src/api.ts
index a52c4ac..94fc5f8 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,13 +9,15 @@
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 page's
+ // own origin (correct when vx serve --ui hosts the SPA), and finally the
+ // canonical local port.
const injected = import.meta.env.VITE_DEFAULT_ORIGIN
if (typeof injected === 'string' && injected.length > 0) return injected
+ if (typeof window !== 'undefined' && window.location?.origin) return window.location.origin
return 'http://localhost:4321'
}
@@ -52,7 +54,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 {
@@ -281,6 +283,140 @@ export async function getTaskDetail(taskId: string): Promise
}
}
+// -- Analytics shapes (mirrored from src/orchestrator/metrics.ts) -----------
+
+export interface ProjectRollup {
+ project: string
+ taskCount: number
+ runs: number
+ failures: number
+ hits: number
+ hitRate: number
+ totalDurationMs: number
+ avgDurationMs: number
+ cacheBytes: number
+ cacheEntries: number
+ lastRunAt: number | undefined
+ estimatedTimeSavedMs: number
+}
+
+export interface TrendPoint {
+ t: number
+ runs: number
+ hits: number
+ failures: number
+ totalDurationMs: number
+}
+
+export interface HeatmapCellApi {
+ dayOfWeek: number
+ hourOfDay: number
+ runs: number
+ totalDurationMs: number
+}
+
+export interface FlakyTask {
+ id: string
+ project: string
+ task: string
+ runs: number
+ failures: number
+ failureRate: number
+ durationTailRatio: number | undefined
+ p50DurationMs: number | undefined
+ p99DurationMs: number | undefined
+}
+
+export interface BottleneckRow {
+ id: string
+ project: string
+ task: string
+ runsRecent: number
+ totalDurationMs: number
+ avgDurationMs: number
+ runsPerDay: number
+ weeklySavingsAt25PctCutMs: number
+}
+
+export interface ParallelismPoint {
+ runId: string
+ startedAt: number
+ cpuSumMs: number
+ wallMs: number
+ factor: number
+ taskCount: number
+}
+
+export interface StoragePoint {
+ t: number
+ bytesAdded: number
+ entriesAdded: number
+}
+
+export interface PrunableEntry {
+ hash: string
+ project: string
+ task: string
+ sizeBytes: number
+ createdAt: number
+ accessedAt: number
+ ageDays: number
+}
+
+export async function listProjects(limit = 100): Promise {
+ const r = await getJson<{ projects: ProjectRollup[] }>(`/v1/projects?limit=${limit}`)
+ return r.projects
+}
+
+export async function getRunTrends(args: {
+ bucket?: 'hour' | 'day'
+ from?: number
+ to?: number
+} = {}): Promise<{ bucket: string; points: TrendPoint[] }> {
+ const params = new URLSearchParams()
+ if (args.bucket) params.set('bucket', args.bucket)
+ if (args.from !== undefined) params.set('from', String(args.from))
+ if (args.to !== undefined) params.set('to', String(args.to))
+ return await getJson(`/v1/trends/runs?${params}`)
+}
+
+export async function getHeatmap(days = 30): Promise {
+ const r = await getJson<{ cells: HeatmapCellApi[] }>(`/v1/trends/heatmap?days=${days}`)
+ return r.cells
+}
+
+export async function getStorageGrowth(days = 30): Promise {
+ const r = await getJson<{ points: StoragePoint[] }>(`/v1/trends/storage?days=${days}`)
+ return r.points
+}
+
+export async function getParallelismHistory(limit = 50): Promise {
+ const r = await getJson<{ points: ParallelismPoint[] }>(`/v1/trends/parallelism?limit=${limit}`)
+ return r.points
+}
+
+export async function getFlakiest(limit = 25): Promise {
+ const r = await getJson<{ tasks: FlakyTask[] }>(`/v1/flakiness?limit=${limit}`)
+ return r.tasks
+}
+
+export async function getBottlenecks(days = 14, limit = 15): Promise {
+ const r = await getJson<{ bottlenecks: BottleneckRow[] }>(
+ `/v1/bottlenecks?days=${days}&limit=${limit}`,
+ )
+ return r.bottlenecks
+}
+
+export async function getPrunable(
+ minAgeDays = 7,
+ limit = 50,
+): Promise {
+ const r = await getJson<{ entries: PrunableEntry[] }>(
+ `/v1/cache/prunable?minAgeDays=${minAgeDays}&limit=${limit}`,
+ )
+ return r.entries
+}
+
/**
* Subscribe to live event stream via SSE. Returns an unsubscribe fn.
* The hosted SPA uses this to overlay running tasks on the Overview.
diff --git a/apps/ui/src/components/CommandPalette.tsx b/apps/ui/src/components/CommandPalette.tsx
new file mode 100644
index 0000000..e660528
--- /dev/null
+++ b/apps/ui/src/components/CommandPalette.tsx
@@ -0,0 +1,170 @@
+import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
+import { getHistory, listProjects } from '../api.ts'
+
+type Item =
+ | { kind: 'nav'; href: string; label: string; group: string }
+ | { kind: 'project'; project: string; href: string; group: string; label: string }
+ | { kind: 'task'; id: string; href: string; group: string; label: string }
+
+const STATIC_NAV: Item[] = [
+ { kind: 'nav', href: '/', label: 'Overview', group: 'Navigation' },
+ { kind: 'nav', href: '/projects', label: 'Projects', group: 'Navigation' },
+ { kind: 'nav', href: '/tasks', label: 'Tasks', group: 'Navigation' },
+ { kind: 'nav', href: '/bottlenecks', label: 'Bottlenecks', group: 'Navigation' },
+ { kind: 'nav', href: '/trends', label: 'Trends', group: 'Navigation' },
+ { kind: 'nav', href: '/cache', label: 'Cache', group: 'Navigation' },
+]
+
+export function CommandPalette(props: {
+ open: boolean
+ onClose: () => void
+ onSelect: (href: string) => void
+}) {
+ const [query, setQuery] = createSignal('')
+ const [active, setActive] = createSignal(0)
+ const [projects] = createResource(
+ () => props.open,
+ async (open) => (open ? await listProjects(100) : []),
+ )
+ const [tasks] = createResource(
+ () => props.open,
+ async (open) => (open ? await getHistory(500) : []),
+ )
+
+ const all = createMemo- (() => {
+ const items: Item[] = [...STATIC_NAV]
+ for (const p of projects() ?? []) {
+ items.push({
+ kind: 'project',
+ project: p.project,
+ href: `/projects/${encodeURIComponent(p.project)}`,
+ group: 'Projects',
+ label: p.project,
+ })
+ }
+ for (const t of tasks() ?? []) {
+ items.push({
+ kind: 'task',
+ id: t.id,
+ href: `/tasks/${encodeURIComponent(t.id)}`,
+ group: 'Tasks',
+ label: t.id,
+ })
+ }
+ return items
+ })
+
+ const filtered = createMemo(() => {
+ const q = query().toLowerCase().trim()
+ if (!q) return all().slice(0, 8)
+ return all()
+ .filter((i) => i.label.toLowerCase().includes(q))
+ .slice(0, 50)
+ })
+
+ const grouped = createMemo(() => {
+ const map = new Map
()
+ for (const item of filtered()) {
+ if (!map.has(item.group)) map.set(item.group, [])
+ map.get(item.group)!.push(item)
+ }
+ return Array.from(map.entries())
+ })
+
+ function onKeyDown(e: KeyboardEvent) {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ setActive((i) => Math.min(filtered().length - 1, i + 1))
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ setActive((i) => Math.max(0, i - 1))
+ } else if (e.key === 'Enter') {
+ e.preventDefault()
+ const sel = filtered()[active()]
+ if (sel) props.onSelect(sel.href)
+ }
+ }
+
+ return (
+
+
+
e.stopPropagation()}
+ >
+
+
+ {
+ setQuery(e.currentTarget.value)
+ setActive(0)
+ }}
+ onKeyDown={onKeyDown}
+ autofocus
+ class="flex-1 bg-transparent border-none p-0 focus:!shadow-none focus:!border-none text-[14px]"
+ />
+ esc
+
+
+
+ No matches.
+
+
+ {([group, items]) => (
+
+
+ {group}
+
+
+ {(item) => {
+ const idx = filtered().indexOf(item)
+ return (
+ props.onSelect(item.href)}
+ onMouseEnter={() => setActive(idx)}
+ class="w-full text-left flex items-center gap-2 px-4 py-1.5 text-[13px] font-mono"
+ classList={{
+ 'bg-surface-hover text-fg': active() === idx,
+ 'text-fg-2': active() !== idx,
+ }}
+ >
+
+ {item.label}
+
+ )
+ }}
+
+
+ )}
+
+
+
+
+ ↑ ↓ navigate
+
+
+ ↵ select
+
+
+ esc close
+
+
+
+
+
+ )
+}
diff --git a/apps/insights/src/components/Flamegraph.tsx b/apps/ui/src/components/Flamegraph.tsx
similarity index 85%
rename from apps/insights/src/components/Flamegraph.tsx
rename to apps/ui/src/components/Flamegraph.tsx
index 052fbe3..9d216f3 100644
--- a/apps/insights/src/components/Flamegraph.tsx
+++ b/apps/ui/src/components/Flamegraph.tsx
@@ -6,9 +6,9 @@ const LANE_HEIGHT = 22
const LANE_PAD = 4
function colorFor(status: string, cacheHit: boolean): string {
- if (status === 'failed') return 'bg-failure/80'
- if (status === 'skipped') return 'bg-skipped/70'
- if (cacheHit) return 'bg-cache/70'
+ if (status === 'failed') return 'bg-danger/80'
+ if (status === 'skipped') return 'bg-warn/70'
+ if (cacheHit) return 'bg-cache-local/70'
return 'bg-success/70'
}
@@ -29,9 +29,8 @@ export function Flamegraph(props: { tasks: readonly RunSummaryRow[] }) {
return (
-
Flamegraph
{(lane, i) => (
{
+ const origin = getOriginSignal()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const [version] = createResource(origin, async () => {
+ try {
+ return await getVersion()
+ } catch {
+ return null
+ }
+ })
+ const [editing, setEditing] = createSignal(false)
+ const [draft, setDraft] = createSignal(getOrigin())
+ const [paletteOpen, setPaletteOpen] = createSignal(false)
+
+ // Global Cmd/Ctrl-K for palette.
+ if (typeof window !== 'undefined') {
+ window.addEventListener('keydown', (e) => {
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
+ e.preventDefault()
+ setPaletteOpen(true)
+ }
+ if (e.key === 'Escape') setPaletteOpen(false)
+ })
+ }
+
+ function commit() {
+ setOriginAndPersist(draft())
+ setEditing(false)
+ }
+
+ return (
+
+ {/* Sidebar */}
+
+
+
+ {NAV.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+
+ setPaletteOpen(true)}
+ class="w-full flex items-center gap-2 px-2.5 py-1.5 rounded text-fg-3 hover:text-fg hover:bg-surface-hover text-[12px] transition-colors"
+ >
+
+ Search
+ ⌘K
+
+
+
+
+ {/* Main */}
+
+ {/* Topbar */}
+
+
+
+ {
+ setDraft(getOrigin())
+ setEditing(true)
+ }}
+ class="flex items-center gap-2 text-[11px] font-mono px-2.5 py-1 rounded border border-border hover:border-border-strong hover:bg-surface-hover"
+ title="Change connection (Cmd/Ctrl-click to edit)"
+ >
+
+ {origin().replace(/^https?:\/\//, '')}
+
+ }
+ >
+
+
+
+
+
{props.children}
+
+
+ Not connected. Start vx serve in your workspace.>}
+ >
+ {(v) => (
+ <>
+ vx {v().vx} · workspace {v().workspace}
+ >
+ )}
+
+
+
+
+
setPaletteOpen(false)} onSelect={(href) => { setPaletteOpen(false); navigate(href) }} />
+
+ )
+}
+
+function Breadcrumb(props: { pathname: string }) {
+ const seg = () => {
+ const parts = props.pathname.split('/').filter(Boolean)
+ if (parts.length === 0) return ['Overview']
+ return parts
+ }
+ return (
+
+ {seg().map((s, i) => (
+ <>
+ 0}>/
+
+ {decodeURIComponent(s).replace(/^./, (c) => c.toUpperCase())}
+
+ >
+ ))}
+
+ )
+}
diff --git a/apps/ui/src/components/charts.tsx b/apps/ui/src/components/charts.tsx
new file mode 100644
index 0000000..0a42048
--- /dev/null
+++ b/apps/ui/src/components/charts.tsx
@@ -0,0 +1,534 @@
+// Pure inline-SVG chart primitives. Tiny, dependency-free, designed to read
+// well in a dense analytics dashboard. Each chart picks a sane default size
+// but is fully containable via width/height props or `class`.
+//
+// Conventions:
+// - Charts assume the parent provides padding; they don't add their own.
+// - All numeric inputs are nullable-safe via the caller (no NaN propagates).
+// - Strokes use semantic / chart-palette CSS variables so theming Just Works.
+
+import { For, Show, createMemo, createSignal } from 'solid-js'
+
+const MARGIN = { top: 8, right: 8, bottom: 18, left: 36 }
+
+interface LineSeries {
+ name: string
+ /** Stroke class, e.g. 'stroke-accent', 'stroke-success'. */
+ strokeClass: string
+ /** Optional fill class for area under the line. */
+ areaClass?: string
+ data: readonly number[]
+}
+
+export interface LineChartProps {
+ width?: number
+ height?: number
+ xs: readonly number[]
+ series: readonly LineSeries[]
+ /** Optional axis-label formatters. */
+ formatX?: (x: number) => string
+ formatY?: (y: number) => string
+ /** Optional Y minimum (default: 0). */
+ yMin?: number
+}
+
+export function LineChart(props: LineChartProps) {
+ const W = () => props.width ?? 600
+ const H = () => props.height ?? 180
+ const innerW = () => W() - MARGIN.left - MARGIN.right
+ const innerH = () => H() - MARGIN.top - MARGIN.bottom
+
+ const allY = createMemo(() => props.series.flatMap((s) => s.data))
+ const yMin = () => props.yMin ?? Math.min(0, ...allY())
+ const yMax = () => {
+ const max = Math.max(1, ...allY())
+ // Round up to a nice tick value (1/2/5 × 10ⁿ).
+ const pow = Math.pow(10, Math.floor(Math.log10(max)))
+ const norm = max / pow
+ return (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * pow
+ }
+
+ const xAt = (i: number, n: number) => {
+ if (n <= 1) return innerW() / 2
+ return (i / (n - 1)) * innerW()
+ }
+ const yAt = (v: number) => {
+ const range = yMax() - yMin()
+ if (range <= 0) return innerH()
+ return innerH() - ((v - yMin()) / range) * innerH()
+ }
+
+ const pathFor = (data: readonly number[]) =>
+ data
+ .map((v, i) => `${i === 0 ? 'M' : 'L'}${xAt(i, data.length).toFixed(1)},${yAt(v).toFixed(1)}`)
+ .join(' ')
+ const areaFor = (data: readonly number[]) => {
+ if (data.length === 0) return ''
+ const line = pathFor(data)
+ return `${line} L${xAt(data.length - 1, data.length).toFixed(1)},${innerH()} L${xAt(0, data.length).toFixed(1)},${innerH()} Z`
+ }
+
+ // Y-axis ticks (3 ticks).
+ const yTicks = createMemo(() => {
+ const min = yMin()
+ const max = yMax()
+ return [min, (min + max) / 2, max]
+ })
+ // X-axis ticks (first, mid, last).
+ const xTicks = createMemo(() => {
+ const xs = props.xs
+ if (xs.length === 0) return []
+ if (xs.length === 1) return [{ i: 0, v: xs[0]! }]
+ const mid = Math.floor(xs.length / 2)
+ return [
+ { i: 0, v: xs[0]! },
+ { i: mid, v: xs[mid]! },
+ { i: xs.length - 1, v: xs[xs.length - 1]! },
+ ]
+ })
+
+ // Hover state — tracks the nearest point on mouse move.
+ const [hoverIdx, setHoverIdx] = createSignal
(null)
+ const onMove = (e: MouseEvent) => {
+ const target = e.currentTarget as SVGSVGElement
+ const rect = target.getBoundingClientRect()
+ const x = e.clientX - rect.left - MARGIN.left
+ const n = props.xs.length
+ if (n === 0) return
+ const idx = Math.max(0, Math.min(n - 1, Math.round((x / innerW()) * (n - 1))))
+ setHoverIdx(idx)
+ }
+ const onLeave = () => setHoverIdx(null)
+
+ return (
+
+ {/* Grid */}
+
+
+ {(y) => (
+
+ )}
+
+
+ {/* Areas (if any) */}
+
+ {(s) => (
+
+
+
+ )}
+
+
+ {/* Lines */}
+
+ {(s) => (
+
+ )}
+
+
+ {/* Hover indicator */}
+
+ {(() => {
+ const i = hoverIdx()!
+ const x = xAt(i, props.xs.length)
+ return (
+
+
+
+ {(s) =>
+ s.data[i] !== undefined ? (
+
+ ) : null
+ }
+
+
+ )
+ })()}
+
+
+
+ {/* Y-axis labels */}
+
+
+ {(y) => (
+
+ {props.formatY ? props.formatY(y) : Math.round(y)}
+
+ )}
+
+
+
+ {/* X-axis labels */}
+
+
+ {(t) => (
+
+ {props.formatX ? props.formatX(t.v) : t.v}
+
+ )}
+
+
+
+ {/* Hover tooltip */}
+
+ {(() => {
+ const i = hoverIdx()!
+ const x = MARGIN.left + xAt(i, props.xs.length)
+ const xv = props.xs[i]
+ return (
+
+
+
{props.formatX && xv !== undefined ? props.formatX(xv) : xv}
+
+ {(s) => (
+
+
+ {s.name}
+
+ {props.formatY && s.data[i] !== undefined ? props.formatY(s.data[i]!) : s.data[i]}
+
+
+ )}
+
+
+
+ )
+ })()}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Treemap — squarified layout for "where did the bytes/time go"
+// ---------------------------------------------------------------------------
+
+interface TreemapNode {
+ label: string
+ value: number
+ colorClass?: string
+}
+
+export function Treemap(props: {
+ data: readonly TreemapNode[]
+ width?: number
+ height?: number
+ format?: (v: number) => string
+}) {
+ const W = () => props.width ?? 600
+ const H = () => props.height ?? 200
+
+ // Squarify algorithm (Bruls/Huijsen/van Wijk) on the normalized data.
+ const tiles = createMemo(() => {
+ const total = props.data.reduce((acc, d) => acc + Math.max(0, d.value), 0)
+ if (total <= 0) return []
+ const items = props.data
+ .filter((d) => d.value > 0)
+ .map((d, i) => ({ ...d, idx: i, value: (d.value / total) * W() * H() }))
+ .sort((a, b) => b.value - a.value)
+ const out: Array<{
+ x: number
+ y: number
+ w: number
+ h: number
+ label: string
+ value: number
+ colorClass: string
+ idx: number
+ }> = []
+ let x = 0
+ let y = 0
+ let w = W()
+ let h = H()
+ let row: typeof items = []
+ let rowSum = 0
+
+ const worst = (row: typeof items, side: number) => {
+ if (row.length === 0) return Infinity
+ const sum = row.reduce((a, r) => a + r.value, 0)
+ const rMax = row.reduce((a, r) => Math.max(a, r.value), 0)
+ const rMin = row.reduce((a, r) => Math.min(a, r.value), Infinity)
+ const s2 = side * side
+ return Math.max((s2 * rMax) / (sum * sum), (sum * sum) / (s2 * rMin))
+ }
+ const layoutRow = (row: typeof items) => {
+ const sum = row.reduce((a, r) => a + r.value, 0)
+ const horizontal = w >= h
+ const side = horizontal ? h : w
+ const strip = sum / side
+ let cursor = horizontal ? y : x
+ for (const r of row) {
+ const len = r.value / strip
+ out.push({
+ x: horizontal ? x : cursor,
+ y: horizontal ? cursor : y,
+ w: horizontal ? strip : len,
+ h: horizontal ? len : strip,
+ label: r.label,
+ value: r.value,
+ colorClass: r.colorClass ?? 'bg-chart-1',
+ idx: r.idx,
+ })
+ cursor += len
+ }
+ if (horizontal) {
+ x += strip
+ w -= strip
+ } else {
+ y += strip
+ h -= strip
+ }
+ }
+
+ for (const item of items) {
+ const side = Math.min(w, h)
+ if (
+ row.length === 0 ||
+ worst([...row, item], side) <= worst(row, side)
+ ) {
+ row.push(item)
+ rowSum += item.value
+ } else {
+ layoutRow(row)
+ row = [item]
+ rowSum = item.value
+ }
+ }
+ if (row.length > 0) layoutRow(row)
+ return out
+ })
+
+ return (
+
+
+ {(t) => {
+ const showLabel = t.w > 60 && t.h > 26
+ // Recover the raw value from props.data for the formatted label.
+ const raw = props.data[t.idx]?.value ?? 0
+ return (
+
+
+
+ {t.label} — {props.format ? props.format(raw) : raw}
+
+
+
+
+ {t.label}
+
+
+ {props.format ? props.format(raw) : raw}
+
+
+
+ )
+ }}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Heatmap — 7×24 grid for run-frequency by day-of-week × hour-of-day
+// ---------------------------------------------------------------------------
+
+export interface HeatmapValue {
+ dayOfWeek: number
+ hourOfDay: number
+ value: number
+}
+
+const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
+
+export function Heatmap(props: {
+ data: readonly HeatmapValue[]
+ cellSize?: number
+ format?: (v: number) => string
+}) {
+ const cell = () => props.cellSize ?? 16
+ const xOffset = 28
+ const yOffset = 14
+ const max = createMemo(() => Math.max(1, ...props.data.map((d) => d.value)))
+
+ return (
+
+ {/* Hour labels (every 4h) */}
+
+
+ {(h) => (
+
+ {String(h).padStart(2, '0')}
+
+ )}
+
+
+ {/* Day labels */}
+
+
+ {(d, i) => (
+
+ {d}
+
+ )}
+
+
+ {/* Cells */}
+
+ {(c) => {
+ const intensity = c.value / max()
+ const opacity = c.value === 0 ? 0.05 : 0.15 + 0.85 * intensity
+ return (
+
+
+ {DAY_LABELS[c.dayOfWeek]} {String(c.hourOfDay).padStart(2, '0')}:00 —{' '}
+ {props.format ? props.format(c.value) : c.value}
+
+
+ )
+ }}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// HBar — single horizontal bar with a fill % (used in rankings)
+// ---------------------------------------------------------------------------
+
+export function HBar(props: { fraction: number; colorClass?: string }) {
+ const pct = () => Math.min(100, Math.max(0, props.fraction * 100))
+ return (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Sparkline — keep the existing API; callers throughout the SPA still use it.
+// ---------------------------------------------------------------------------
+
+export interface SparkPoint {
+ value: number
+ hit?: boolean
+}
+
+export function Sparkline(props: {
+ data: readonly SparkPoint[]
+ width?: number
+ height?: number
+}) {
+ const W = () => props.width ?? 280
+ const H = () => props.height ?? 36
+ const xs = () => {
+ const n = props.data.length
+ if (n <= 1) return [W() / 2]
+ return props.data.map((_, i) => (i / (n - 1)) * W())
+ }
+ const max = () => Math.max(1, ...props.data.map((p) => p.value))
+ const min = () => Math.min(...props.data.map((p) => p.value), 0)
+ const range = () => Math.max(1, max() - min())
+ const ys = () => props.data.map((p) => H() - ((p.value - min()) / range()) * H())
+ const linePath = () => {
+ const X = xs()
+ const Y = ys()
+ if (X.length === 0) return ''
+ return X.map((x, i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${Y[i]!.toFixed(1)}`).join(' ')
+ }
+ const areaPath = () => {
+ const X = xs()
+ const Y = ys()
+ if (X.length === 0) return ''
+ const line = X.map((x, i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${Y[i]!.toFixed(1)}`).join(' ')
+ return `${line} L${X[X.length - 1]!.toFixed(1)},${H()} L${X[0]!.toFixed(1)},${H()} Z`
+ }
+ return (
+
+
+
+
+ {(pt, i) => (
+
+ )}
+
+
+ )
+}
diff --git a/apps/ui/src/components/ui.tsx b/apps/ui/src/components/ui.tsx
new file mode 100644
index 0000000..fa0de1a
--- /dev/null
+++ b/apps/ui/src/components/ui.tsx
@@ -0,0 +1,134 @@
+// Reusable UI primitives — Card, MetricCard, TrendDelta, EmptyState,
+// Skeleton, StatusDot. Tiny and composable.
+
+import { type JSX, Show } from 'solid-js'
+
+export function Card(props: {
+ title?: string
+ action?: JSX.Element
+ children: JSX.Element
+ /** Tighten the body padding (lists already pad each row). */
+ noPad?: boolean
+ class?: string
+}) {
+ return (
+
+
+
+
+ {props.title}
+
+ {props.action}
+
+
+
{props.children}
+
+ )
+}
+
+export function MetricCard(props: {
+ label: string
+ value: string
+ sub?: string
+ delta?: number | undefined
+ /** Optional inline chart (Sparkline/etc) under the value. */
+ chart?: JSX.Element
+ tone?: 'default' | 'good' | 'warn' | 'bad'
+}) {
+ const tone = () => props.tone ?? 'default'
+ const borderTone = () =>
+ tone() === 'good'
+ ? 'border-success/40 bg-success/[0.04]'
+ : tone() === 'warn'
+ ? 'border-warn/40 bg-warn/[0.04]'
+ : tone() === 'bad'
+ ? 'border-danger/40 bg-danger/[0.04]'
+ : 'border-border bg-surface'
+ return (
+
+
+
+ {props.label}
+
+
+
+
+
+
{props.value}
+
+ {props.sub}
+
+
+ {props.chart}
+
+
+ )
+}
+
+export function TrendDelta(props: { value: number; goodIsUp?: boolean }) {
+ const goodIsUp = () => props.goodIsUp ?? true
+ const isUp = () => props.value > 0
+ const isFlat = () => Math.abs(props.value) < 0.005
+ const tone = () => {
+ if (isFlat()) return 'text-fg-3'
+ return (isUp() === goodIsUp()) ? 'text-success' : 'text-danger'
+ }
+ const arrow = () => (isFlat() ? '·' : isUp() ? '▲' : '▼')
+ return (
+
+ {arrow()} {Math.abs(props.value * 100).toFixed(0)}%
+
+ )
+}
+
+export function EmptyState(props: { title: string; hint?: string; cmd?: string }) {
+ return (
+
+
{props.title}
+
+ {props.hint}
+
+
+
+ {props.cmd}
+
+
+
+ )
+}
+
+export function Skeleton(props: { class?: string }) {
+ return (
+
+ )
+}
+
+export function StatusDot(props: { ok: boolean; label?: string }) {
+ return (
+
+
+ {props.label}
+
+ )
+}
+
+export function StatusBadge(props: { status: string; cacheHit?: boolean | null }) {
+ const tone = () => {
+ if (props.status === 'failed') return 'text-danger bg-danger/10 border-danger/30'
+ if (props.cacheHit) return 'text-cache-local bg-cache-local/10 border-cache-local/30'
+ if (props.status === 'success') return 'text-success bg-success/10 border-success/30'
+ if (props.status === 'skipped') return 'text-warn bg-warn/10 border-warn/30'
+ return 'text-fg-2 bg-surface-2 border-border'
+ }
+ return (
+
+ {props.status}
+
+ )
+}
diff --git a/apps/insights/src/flamegraph-layout.ts b/apps/ui/src/flamegraph-layout.ts
similarity index 100%
rename from apps/insights/src/flamegraph-layout.ts
rename to apps/ui/src/flamegraph-layout.ts
diff --git a/apps/ui/src/format.ts b/apps/ui/src/format.ts
new file mode 100644
index 0000000..d451826
--- /dev/null
+++ b/apps/ui/src/format.ts
@@ -0,0 +1,79 @@
+export function formatDuration(ms: number): string {
+ if (!Number.isFinite(ms) || ms < 0) return '—'
+ if (ms < 1) return '<1ms'
+ if (ms < 1000) return `${Math.round(ms)}ms`
+ if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`
+ if (ms < 3600_000) {
+ const m = Math.floor(ms / 60_000)
+ const s = Math.floor((ms % 60_000) / 1000)
+ return `${m}m ${s}s`
+ }
+ const h = Math.floor(ms / 3600_000)
+ const m = Math.floor((ms % 3600_000) / 60_000)
+ return `${h}h ${m}m`
+}
+
+/** Tight form for chart axes and table cells (e.g. `1.2k`, `3.4M`). */
+export function formatCount(n: number): string {
+ if (!Number.isFinite(n)) return '—'
+ const abs = Math.abs(n)
+ if (abs < 1_000) return String(Math.round(n))
+ if (abs < 1_000_000) return `${(n / 1_000).toFixed(abs < 10_000 ? 1 : 0)}k`
+ if (abs < 1_000_000_000) return `${(n / 1_000_000).toFixed(1)}M`
+ return `${(n / 1_000_000_000).toFixed(1)}G`
+}
+
+const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
+
+export function formatBytes(b: number): string {
+ if (!Number.isFinite(b) || b <= 0) return '0 B'
+ // Clamp to [0, SIZE_UNITS.length-1]. Fractional inputs (chart Y mid-ticks)
+ // can produce a negative log and an undefined unit otherwise.
+ const i = Math.max(0, Math.min(SIZE_UNITS.length - 1, Math.floor(Math.log(b) / Math.log(1024))))
+ return `${(b / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${SIZE_UNITS[i]}`
+}
+
+export function formatRelativeTime(date: Date | number): string {
+ const ts = typeof date === 'number' ? date : date.getTime()
+ const diff = Date.now() - ts
+ if (diff < 0) return 'in the future'
+ const s = Math.floor(diff / 1000)
+ if (s < 60) return `${s}s ago`
+ const m = Math.floor(s / 60)
+ if (m < 60) return `${m}m ago`
+ const h = Math.floor(m / 60)
+ if (h < 24) return `${h}h ago`
+ const d = Math.floor(h / 24)
+ if (d < 30) return `${d}d ago`
+ const mo = Math.floor(d / 30)
+ return `${mo}mo ago`
+}
+
+export function formatPercent(n: number, digits = 1): string {
+ if (!Number.isFinite(n)) return '—'
+ return `${(n * 100).toFixed(digits)}%`
+}
+
+export function formatHour(t: number): string {
+ return new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+}
+
+export function formatDate(t: number): string {
+ return new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' })
+}
+
+export function formatDateTime(t: number): string {
+ return new Date(t).toLocaleString([], {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+}
+
+/** Stable hash → chart-palette token for category coloring. */
+export function paletteFor(key: string): string {
+ let h = 5381
+ for (let i = 0; i < key.length; i++) h = ((h * 33) ^ key.charCodeAt(i)) >>> 0
+ return `chart-${(h % 8) + 1}`
+}
diff --git a/apps/insights/src/main.tsx b/apps/ui/src/main.tsx
similarity index 65%
rename from apps/insights/src/main.tsx
rename to apps/ui/src/main.tsx
index 0f3887f..c312201 100644
--- a/apps/insights/src/main.tsx
+++ b/apps/ui/src/main.tsx
@@ -3,9 +3,13 @@ import { HashRouter, Route } from '@solidjs/router'
import 'virtual:uno.css'
import { Shell } from './components/Shell.tsx'
import { Overview } from './pages/Overview.tsx'
+import { Projects } from './pages/Projects.tsx'
+import { ProjectDetail } from './pages/ProjectDetail.tsx'
import { Tasks } from './pages/Tasks.tsx'
import { TaskDetail } from './pages/TaskDetail.tsx'
import { CachePage } from './pages/CachePage.tsx'
+import { Bottlenecks } from './pages/Bottlenecks.tsx'
+import { Trends } from './pages/Trends.tsx'
import { RunDetail } from './pages/RunDetail.tsx'
const root = document.getElementById('root')
@@ -15,8 +19,12 @@ render(
() => (
+
+
+
+
diff --git a/apps/ui/src/pages/Bottlenecks.tsx b/apps/ui/src/pages/Bottlenecks.tsx
new file mode 100644
index 0000000..5512042
--- /dev/null
+++ b/apps/ui/src/pages/Bottlenecks.tsx
@@ -0,0 +1,136 @@
+import { For, Show, createResource } from 'solid-js'
+import { useNavigate } from '@solidjs/router'
+import { getBottlenecks, getFlakiest, getOriginSignal, getPrunable } from '../api.ts'
+import { Card, EmptyState } from '../components/ui.tsx'
+import { HBar } from '../components/charts.tsx'
+import { formatBytes, formatDuration, formatPercent, formatRelativeTime } from '../format.ts'
+
+export function Bottlenecks() {
+ const origin = getOriginSignal()
+ const navigate = useNavigate()
+ const [bottlenecks] = createResource(origin, () => getBottlenecks(14, 25))
+ const [flaky] = createResource(origin, () => getFlakiest(25))
+ const [prunable] = createResource(origin, () => getPrunable(7, 25))
+
+ return (
+
+
+
Bottlenecks
+
High-leverage targets — ranked by where you'd save the most time.
+
+
+
14-day lookback · savings = 25% cut, extrapolated weekly}
+ noPad
+ >
+ 0} fallback={ }>
+
+
+
+ Task
+ Runs / day
+ Avg
+ Total burn
+ Weekly savings
+
+
+
+
+ {(b, i) => {
+ const max = Math.max(...(bottlenecks() ?? []).map((x) => x.weeklySavingsAt25PctCutMs))
+ return (
+ navigate(`/tasks/${encodeURIComponent(b.id)}`)}
+ >
+
+ {i() + 1}.
+ {b.project}# {b.task}
+
+ {b.runsPerDay.toFixed(1)}
+ {formatDuration(b.avgDurationMs)}
+ {formatDuration(b.totalDurationMs)}
+
+
+
{formatDuration(b.weeklySavingsAt25PctCutMs)}
+
+
+
+
+ )
+ }}
+
+
+
+
+
+
+
+
failure rate + tail ratio} noPad>
+ 0} fallback={No flaky tasks. 🎉
}>
+
+
+
+ Task
+ Fail %
+ p99/p50
+
+
+
+
+ {(f) => (
+ navigate(`/tasks/${encodeURIComponent(f.id)}`)}
+ >
+
+ {f.project}# {f.task}
+
+ 0.1 }}>
+ {formatPercent(f.failureRate, 0)}
+
+ 3 }}>
+ {f.durationTailRatio?.toFixed(1) ?? '—'}×
+
+
+ )}
+
+
+
+
+
+
+
unused ≥7d} noPad>
+ 0} fallback={Everything's been accessed recently.
}>
+
+
+
+ Task
+ Size
+ Last hit
+
+
+
+
+ {(e) => (
+
+
+ {e.project}# {e.task}
+
+ {formatBytes(e.sizeBytes)}
+ {formatRelativeTime(e.accessedAt)}
+
+ )}
+
+
+
+
+ Tip: vx cache prune --older-than 7d
+
+
+
+
+
+ )
+}
diff --git a/apps/ui/src/pages/CachePage.tsx b/apps/ui/src/pages/CachePage.tsx
new file mode 100644
index 0000000..6d38d53
--- /dev/null
+++ b/apps/ui/src/pages/CachePage.tsx
@@ -0,0 +1,154 @@
+import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
+import { useNavigate } from '@solidjs/router'
+import {
+ getCacheBreakdown,
+ getCacheSavings,
+ getCacheStats,
+ getOriginSignal,
+ getStorageGrowth,
+ listCacheEntries,
+} from '../api.ts'
+import { Card, EmptyState, MetricCard } from '../components/ui.tsx'
+import { HBar, LineChart, Treemap } from '../components/charts.tsx'
+import { formatBytes, formatDate, formatDuration, formatPercent, formatRelativeTime, paletteFor } from '../format.ts'
+
+type Sort = 'created_at' | 'accessed_at' | 'size_bytes' | 'duration_ms'
+
+export function CachePage() {
+ const origin = getOriginSignal()
+ const [sort, setSort] = createSignal('size_bytes')
+ const [filter, setFilter] = createSignal('')
+ const navigate = useNavigate()
+
+ const [stats] = createResource(origin, () => getCacheStats())
+ const [savings] = createResource(origin, () => getCacheSavings())
+ const [breakdown] = createResource(origin, () => getCacheBreakdown(100))
+ const [storage] = createResource(origin, () => getStorageGrowth(30))
+ const [entries] = createResource(
+ () => ({ s: sort(), o: origin() }),
+ (args) => listCacheEntries({ limit: 200, orderBy: args.s }),
+ )
+
+ const filtered = createMemo(() => {
+ const rows = entries() ?? []
+ const f = filter().toLowerCase()
+ if (!f) return rows
+ return rows.filter((r) => `${r.project}#${r.task}`.toLowerCase().includes(f) || r.hash.includes(f))
+ })
+
+ return (
+
+
Cache
+
+
+
+
+
+
+
+
+
+
+
+
+ p.totalBytes > 0)} fallback={ }>
+ ({ label: p.project, value: p.totalBytes, colorClass: `bg-${paletteFor(p.project)}` }))}
+ format={(v) => formatBytes(v)}
+ height={240}
+ />
+
+
+
+
+ 0} fallback={ }>
+
+ {(p) => {
+ const max = Math.max(...(breakdown() ?? []).map((x) => x.totalBytes))
+ return (
+
+
+
+ {p.project}
+ {p.entries}×
+ {formatBytes(p.totalBytes)}
+
+
+
+ )
+ }}
+
+
+
+
+
+
+ }>
+ p.t) ?? []}
+ series={[
+ { name: 'bytes added', strokeClass: 'stroke-accent-2', areaClass: 'fill-accent-2/10', data: storage()?.map((p) => p.bytesAdded) ?? [] },
+ ]}
+ formatX={(t) => formatDate(t)}
+ formatY={(v) => formatBytes(v)}
+ height={160}
+ />
+
+
+
+
+
+
+
Entries
+ setSort(e.currentTarget.value as Sort)} class="text-[11px]">
+ Largest first
+ Newest first
+ Recently accessed
+ Slowest first
+
+
+
setFilter(e.currentTarget.value)}
+ class="text-[11px] font-mono w-60"
+ />
+
+ 0} fallback={ }>
+
+
+
+ Task
+ Hash
+ Size
+ Duration
+ Created
+ Accessed
+
+
+
+
+ {(e) => (
+ navigate(`/tasks/${encodeURIComponent(`${e.project}#${e.task}`)}`)}
+ >
+
+ {e.project}# {e.task}
+
+ {e.hash.slice(0, 12)}…
+ {formatBytes(e.sizeBytes)}
+ {formatDuration(e.durationMs)}
+ {formatRelativeTime(e.createdAt)}
+ {formatRelativeTime(e.accessedAt)}
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/ui/src/pages/Overview.tsx b/apps/ui/src/pages/Overview.tsx
new file mode 100644
index 0000000..ad1063b
--- /dev/null
+++ b/apps/ui/src/pages/Overview.tsx
@@ -0,0 +1,246 @@
+import { For, Show, createMemo, createResource, createSignal, onCleanup, onMount } from 'solid-js'
+import { A, useNavigate } from '@solidjs/router'
+import {
+ getCacheSavings,
+ getCacheStats,
+ getFailures,
+ getOriginSignal,
+ getRunTrends,
+ getTopTasks,
+ listInvocations,
+ listProjects,
+ subscribeEvents,
+} from '../api.ts'
+import { LineChart, Treemap } from '../components/charts.tsx'
+import { Card, EmptyState, MetricCard } from '../components/ui.tsx'
+import { formatBytes, formatCount, formatDuration, formatHour, formatPercent, formatRelativeTime, paletteFor } from '../format.ts'
+
+export function Overview() {
+ const origin = getOriginSignal()
+ const navigate = useNavigate()
+
+ const [stats] = createResource(origin, () => getCacheStats())
+ const [savings] = createResource(origin, () => getCacheSavings())
+ const [topTasks] = createResource(origin, () => getTopTasks(8))
+ const [failures] = createResource(origin, () => getFailures(8))
+ const [projects] = createResource(origin, () => listProjects(50))
+ const [invocations] = createResource(origin, () => listInvocations(12))
+ const [trend24h] = createResource(origin, () => getRunTrends({ bucket: 'hour' }))
+
+ // Live event ticker — newest first, keep last 12.
+ const [live, setLive] = createSignal>([])
+ let liveSeq = 0
+ onMount(() => {
+ const unsub = subscribeEvents((env: unknown) => {
+ const ev = (env as { params?: { kind?: string; node?: { id?: string }; outcome?: { node?: { id?: string }; status?: string } } }).params
+ if (!ev?.kind) return
+ let label = ''
+ if (ev.kind === 'task:start') label = `▶ ${ev.node?.id ?? ''}`
+ else if (ev.kind === 'task:complete') label = `${ev.outcome?.status === 'failed' ? '✗' : '✓'} ${ev.outcome?.node?.id ?? ''}`
+ else if (ev.kind === 'run:start') label = '· run started'
+ else if (ev.kind === 'run:end') label = '· run finished'
+ else return
+ setLive((prev) => [{ id: ++liveSeq, kind: ev.kind, label, t: Date.now() }, ...prev].slice(0, 12))
+ })
+ onCleanup(unsub)
+ })
+
+ const last24hRuns = createMemo(() => trend24h()?.points.reduce((a, p) => a + p.runs, 0) ?? 0)
+ const last24hHits = createMemo(() => trend24h()?.points.reduce((a, p) => a + p.hits, 0) ?? 0)
+ const last24hFails = createMemo(() => trend24h()?.points.reduce((a, p) => a + p.failures, 0) ?? 0)
+
+ const trendXs = () => trend24h()?.points.map((p) => p.t) ?? []
+ const trendRuns = () => trend24h()?.points.map((p) => p.runs) ?? []
+ const trendDur = () => trend24h()?.points.map((p) => p.totalDurationMs) ?? []
+
+ return (
+
+
+
+ 0 ? 'good' : 'default'}
+ />
+ 0.5 ? 'good' : stats()!.hitRate24h < 0.2 && stats()!.runCountLast24h > 5 ? 'warn' : 'default'}
+ />
+ 0 ? `${formatPercent(last24hFails() / last24hRuns(), 0)} of runs` : 'no runs yet'}
+ tone={last24hFails() > 0 ? 'bad' : 'good'}
+ />
+
+
+
+
+
+
runs · failures}>
+ }>
+ p.failures) ?? [] },
+ ]}
+ formatX={(t) => formatHour(t)}
+ formatY={(v) => formatCount(v)}
+ height={180}
+ />
+
+ {last24hRuns()} runs · {formatDuration(trendDur().reduce((a, b) => a + b, 0))} total · {last24hHits()} hits
+
+
+
+
+
SSE}>
+ 0} fallback={Waiting for events…
}>
+
+
+ {(e) => (
+
+ {formatHour(e.t)}
+ {e.label}
+
+ )}
+
+
+
+
+
+
+
+
all tasks} noPad>
+ }>
+
+
+ {(t, i) => (
+ navigate(`/tasks/${encodeURIComponent(t.id)}`)}
+ class="flex items-center gap-2 px-4 py-2 hover:bg-surface-hover text-left border-t border-border first:border-t-0"
+ >
+ {i() + 1}.
+ {t.id}
+ {t.runs}×
+ {formatDuration(t.totalDurationMs)}
+
+ )}
+
+
+
+
+
+
all tasks} noPad>
+ No failures. 🎉 }>
+
+
+ {(f) => (
+ navigate(`/tasks/${encodeURIComponent(`${f.project}#${f.task}`)}`)}
+ class="flex items-center gap-2 px-4 py-2 hover:bg-surface-hover text-left border-t border-border first:border-t-0"
+ >
+ {f.project}# {f.task}
+ exit {f.exitCode}
+ {formatRelativeTime(f.startedAt)}
+
+ )}
+
+
+
+
+
+
+
+
cache}>
+ p.cacheBytes > 0)} fallback={ }>
+ p.cacheBytes > 0).map((p) => ({
+ label: p.project,
+ value: p.cacheBytes,
+ colorClass: `bg-${paletteFor(p.project)}`,
+ }))}
+ format={(v) => formatBytes(v)}
+ height={240}
+ />
+
+
+
+
all} noPad>
+ }>
+
+
+ {(p) => {
+ const maxTime = Math.max(...(projects() ?? []).map((x) => x.totalDurationMs))
+ const pct = maxTime > 0 ? (p.totalDurationMs / maxTime) * 100 : 0
+ return (
+ navigate(`/projects/${encodeURIComponent(p.project)}`)}
+ class="flex flex-col gap-1 px-4 py-2 hover:bg-surface-hover text-left border-t border-border first:border-t-0"
+ >
+
+ {p.project}
+ {p.runs}×
+ {formatDuration(p.totalDurationMs)}
+
+
+
+ )
+ }}
+
+
+
+
+
+
+
+ }>
+
+
+
+ Run
+ Started
+ Duration
+ Tasks
+ Failed
+ Hits
+
+
+
+
+ {(r) => (
+ navigate(`/runs/${r.runId}`)}
+ >
+ {r.runId.slice(0, 8)}…
+ {formatRelativeTime(r.startedAt)}
+ {formatDuration(r.totalDurationMs)}
+ {r.taskCount}
+ 0 }}>{r.failedCount}
+ {r.hitCount}
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/ui/src/pages/ProjectDetail.tsx b/apps/ui/src/pages/ProjectDetail.tsx
new file mode 100644
index 0000000..13c2014
--- /dev/null
+++ b/apps/ui/src/pages/ProjectDetail.tsx
@@ -0,0 +1,93 @@
+import { For, Show, createMemo, createResource } from 'solid-js'
+import { A, useNavigate, useParams } from '@solidjs/router'
+import { getHistory, getOriginSignal, listProjects, type ProjectRollup, type TaskHistoryRow } from '../api.ts'
+import { Card, EmptyState, MetricCard } from '../components/ui.tsx'
+import { HBar } from '../components/charts.tsx'
+import { formatBytes, formatDuration, formatPercent, formatRelativeTime, paletteFor } from '../format.ts'
+
+export function ProjectDetail() {
+ const params = useParams<{ name: string }>()
+ const origin = getOriginSignal()
+ const navigate = useNavigate()
+
+ const [projects] = createResource(() => ({ name: params.name, o: origin() }), async () => listProjects(500))
+ const [tasks] = createResource(
+ () => ({ name: params.name, o: origin() }),
+ async (args) => {
+ const all = await getHistory(500)
+ return all.filter((t: TaskHistoryRow) => t.project === args.name)
+ },
+ )
+
+ const summary = createMemo
(() =>
+ (projects() ?? []).find((p) => p.project === params.name),
+ )
+ const maxTotal = createMemo(() => Math.max(1, ...(tasks() ?? []).map((t) => t.totalDurationMs)))
+
+ return (
+
+
+
+
}>
+
+
+
+
+ 0.5 ? 'good' : 'default'} />
+
+
+
+
+
+ 0} fallback={ }>
+
+
+
+ Task
+ Runs
+ Success
+ Hit
+ Avg
+ p99
+ Total
+ Last
+
+
+
+
+ {(t) => (
+ navigate(`/tasks/${encodeURIComponent(t.id)}`)}
+ >
+
+ {t.project}# {t.task}
+
+ {t.runs}
+
+ {formatPercent(t.successRate, 0)}
+
+ {formatPercent(t.hitRate, 0)}
+ {formatDuration(t.avgDurationMs ?? 0)}
+ {formatDuration(t.p99DurationMs ?? 0)}
+
+
+
{formatDuration(t.totalDurationMs)}
+
+
+
+ {t.lastSeenAt !== undefined ? formatRelativeTime(t.lastSeenAt) : '—'}
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/ui/src/pages/Projects.tsx b/apps/ui/src/pages/Projects.tsx
new file mode 100644
index 0000000..b972ec4
--- /dev/null
+++ b/apps/ui/src/pages/Projects.tsx
@@ -0,0 +1,140 @@
+import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
+import { useNavigate } from '@solidjs/router'
+import { getOriginSignal, listProjects, type ProjectRollup } from '../api.ts'
+import { Card, EmptyState } from '../components/ui.tsx'
+import { HBar } from '../components/charts.tsx'
+import { formatBytes, formatDuration, formatPercent, formatRelativeTime, paletteFor } from '../format.ts'
+
+type Sort = 'name' | 'runs' | 'totalDurationMs' | 'estimatedTimeSavedMs' | 'hitRate' | 'cacheBytes' | 'lastRunAt' | 'failures'
+
+export function Projects() {
+ const origin = getOriginSignal()
+ const [data] = createResource(origin, () => listProjects(500))
+ const [sort, setSort] = createSignal('totalDurationMs')
+ const [desc, setDesc] = createSignal(true)
+ const [filter, setFilter] = createSignal('')
+ const navigate = useNavigate()
+
+ const rows = createMemo(() => {
+ const items = data() ?? []
+ const f = filter().toLowerCase().trim()
+ const filtered = f ? items.filter((p) => p.project.toLowerCase().includes(f)) : items
+ return [...filtered].sort((a, b) => {
+ const va = pluck(a, sort())
+ const vb = pluck(b, sort())
+ const cmp = va === vb ? 0 : va > vb ? 1 : -1
+ return desc() ? -cmp : cmp
+ })
+ })
+
+ const totals = createMemo(() => {
+ const items = data() ?? []
+ return {
+ totalTime: items.reduce((a, p) => a + p.totalDurationMs, 0),
+ maxTime: Math.max(1, ...items.map((p) => p.totalDurationMs)),
+ maxSaved: Math.max(1, ...items.map((p) => p.estimatedTimeSavedMs)),
+ maxBytes: Math.max(1, ...items.map((p) => p.cacheBytes)),
+ }
+ })
+
+ function onSort(k: Sort) {
+ if (sort() === k) setDesc(!desc())
+ else {
+ setSort(k)
+ setDesc(true)
+ }
+ }
+
+ return (
+
+
+
Projects
+ setFilter(e.currentTarget.value)}
+ class="text-[12px] font-mono w-64"
+ />
+
+
+
+ 0} fallback={ }>
+
+
+
+ Project
+ Runs
+ Failures
+ Hit %
+ Total time
+ Saved
+ Cache
+ Last run
+
+
+
+
+ {(p) => (
+ navigate(`/projects/${encodeURIComponent(p.project)}`)}
+ >
+
+
+
+ {p.project}
+ · {p.taskCount} tasks
+
+
+ {p.runs}
+ 0 }}>{p.failures}
+ {formatPercent(p.hitRate, 0)}
+
+
+
{formatDuration(p.totalDurationMs)}
+
+
+
+
+
+ {formatDuration(p.estimatedTimeSavedMs)}
+ {formatBytes(p.cacheBytes)}
+ {p.lastRunAt ? formatRelativeTime(p.lastRunAt) : '—'}
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+function pluck(p: ProjectRollup, k: Sort): number | string {
+ switch (k) {
+ case 'name': return p.project
+ case 'runs': return p.runs
+ case 'failures': return p.failures
+ case 'hitRate': return p.hitRate
+ case 'totalDurationMs': return p.totalDurationMs
+ case 'estimatedTimeSavedMs': return p.estimatedTimeSavedMs
+ case 'cacheBytes': return p.cacheBytes
+ case 'lastRunAt': return p.lastRunAt ?? 0
+ }
+}
+
+function Th(props: { k: Sort; curr: Sort; desc: boolean; onSort: (k: Sort) => void; align?: 'left' | 'right'; children: any }) {
+ const active = () => props.curr === props.k
+ return (
+ props.onSort(props.k)}
+ >
+ {props.children}
+ {props.desc ? '↓' : '↑'}
+
+ )
+}
diff --git a/apps/ui/src/pages/RunDetail.tsx b/apps/ui/src/pages/RunDetail.tsx
new file mode 100644
index 0000000..a103784
--- /dev/null
+++ b/apps/ui/src/pages/RunDetail.tsx
@@ -0,0 +1,95 @@
+import { For, Show, createMemo, createResource } from 'solid-js'
+import { A, useNavigate, useParams } from '@solidjs/router'
+import { getOriginSignal, getRun } from '../api.ts'
+import { Flamegraph } from '../components/Flamegraph.tsx'
+import { Card, EmptyState, MetricCard, StatusBadge } from '../components/ui.tsx'
+import { formatBytes, formatDuration, formatRelativeTime } from '../format.ts'
+
+export function RunDetail() {
+ const params = useParams<{ id: string }>()
+ const origin = getOriginSignal()
+ const navigate = useNavigate()
+ const [run] = createResource(() => ({ id: params.id, o: origin() }), (args) => getRun(args.id))
+
+ const totals = createMemo(() => {
+ const tasks = run()?.tasks ?? []
+ return {
+ total: tasks.reduce((a, t) => a + (t.durationMs ?? 0), 0),
+ successes: tasks.filter((t) => t.status === 'success').length,
+ failures: tasks.filter((t) => t.status === 'failed').length,
+ hits: tasks.filter((t) => t.cacheHit === true).length,
+ cpu: tasks.reduce((a, t) => a + (t.cpuMs ?? 0), 0),
+ }
+ })
+
+ return (
+
+
+
← runs
+
Run {params.id.slice(0, 12)}
+
+
+
Loading…
+
Failed to load: {String(run.error)}
+
+
+
+ {(() => {
+ const r = run()!
+ const t = totals()
+ const startMs = Math.min(...r.tasks.map((x) => x.startedAt))
+ const wall = Math.max(...r.tasks.map((x) => x.endedAt)) - startMs
+ return (
+ <>
+
+
+
+
+ 0 ? `${(t.cpu / wall).toFixed(2)}× parallelism` : ''} />
+ 0 ? 'failed' : 'success'} tone={t.failures > 0 ? 'bad' : 'good'} />
+
+
+
+
+
+
+
+
+
+
+ Task
+ Status
+ Duration
+ CPU
+ Peak RSS
+ Cache
+
+
+
+
+ {(task) => (
+ navigate(`/tasks/${encodeURIComponent(`${task.project}#${task.task}`)}`)}
+ >
+
+ {task.project}# {task.task}
+
+
+ {formatDuration(task.durationMs)}
+ {task.cpuMs !== null ? formatDuration(task.cpuMs) : '—'}
+ {task.peakRssBytes !== null && task.peakRssBytes > 0 ? formatBytes(task.peakRssBytes) : '—'}
+ {task.cacheHit === true ? 'hit' : 'miss'}
+
+ )}
+
+
+
+
+ >
+ )
+ })()}
+
+
+ )
+}
diff --git a/apps/ui/src/pages/TaskDetail.tsx b/apps/ui/src/pages/TaskDetail.tsx
new file mode 100644
index 0000000..88227c5
--- /dev/null
+++ b/apps/ui/src/pages/TaskDetail.tsx
@@ -0,0 +1,146 @@
+import { For, Show, createMemo, createResource } from 'solid-js'
+import { A, useParams } from '@solidjs/router'
+import { getOriginSignal, getTaskDetail } from '../api.ts'
+import { Card, EmptyState, MetricCard, StatusBadge } from '../components/ui.tsx'
+import { LineChart } from '../components/charts.tsx'
+import { formatBytes, formatCount, formatDuration, formatPercent, formatRelativeTime } from '../format.ts'
+
+export function TaskDetail() {
+ const params = useParams<{ id: string }>()
+ const origin = getOriginSignal()
+ const [detail] = createResource(
+ () => ({ id: params.id, o: origin() }),
+ (args) => getTaskDetail(args.id),
+ )
+
+ // Series for the duration chart — oldest left, newest right.
+ const durSeries = createMemo(() => {
+ const recent = detail()?.recent ?? []
+ return [...recent].reverse()
+ })
+
+ return (
+
+
+
+
+ Loading…
+
+
+ Failed to load: {String(detail.error)}
+
+
+
+
+
+
+ {(() => {
+ const d = detail() as NonNullable>
+ const agg = d.aggregate
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {/* Duration trend */}
+ cache hits in cyan}>
+ 0} fallback={ }>
+ i)}
+ series={[
+ {
+ name: 'duration',
+ strokeClass: 'stroke-accent',
+ areaClass: 'fill-accent/10',
+ data: durSeries().map((r) => r.durationMs),
+ },
+ ]}
+ formatX={(i) => `run ${i + 1}`}
+ formatY={(v) => formatDuration(v)}
+ height={180}
+ />
+
+
+
+ {/* Latest cache entry */}
+
+ {(() => {
+ const e = d.latestEntry!
+ return (
+
+
+ {e.hash.slice(0, 16)}…} />
+
+
+
+
+
+
+ $ {e.command}
+
+ )
+ })()}
+
+
+ {/* Full history */}
+
+
+
+
+ When
+ Status
+ Duration
+ CPU
+ Peak RSS
+ Hash
+
+
+
+
+ {(r) => (
+
+ {formatRelativeTime(r.startedAt)}
+
+
+
+ {formatDuration(r.durationMs)}
+ {r.cpuMs !== null ? formatDuration(r.cpuMs) : '—'}
+ {r.peakRssBytes !== null && r.peakRssBytes > 0 ? formatBytes(r.peakRssBytes) : '—'}
+ {r.hash.slice(0, 10)}…
+
+ )}
+
+
+
+
+ >
+ )
+ })()}
+
+
+ )
+}
+
+function KV(props: { label: string; value: any }) {
+ return (
+
+ {props.label}
+ {props.value}
+
+ )
+}
diff --git a/apps/ui/src/pages/Tasks.tsx b/apps/ui/src/pages/Tasks.tsx
new file mode 100644
index 0000000..72ce37e
--- /dev/null
+++ b/apps/ui/src/pages/Tasks.tsx
@@ -0,0 +1,161 @@
+import { For, Show, createMemo, createResource, createSignal } from 'solid-js'
+import { useNavigate } from '@solidjs/router'
+import { getHistory, getOriginSignal, type TaskHistoryRow } from '../api.ts'
+import { Card, EmptyState } from '../components/ui.tsx'
+import { HBar } from '../components/charts.tsx'
+import { formatDuration, formatPercent, formatRelativeTime, paletteFor } from '../format.ts'
+
+type SortKey =
+ | 'id'
+ | 'runs'
+ | 'successRate'
+ | 'hitRate'
+ | 'avgDurationMs'
+ | 'p50DurationMs'
+ | 'p99DurationMs'
+ | 'totalDurationMs'
+ | 'lastSeenAt'
+
+export function Tasks() {
+ const origin = getOriginSignal()
+ const [history] = createResource(origin, () => getHistory(500))
+ const [filter, setFilter] = createSignal('')
+ const [sortKey, setSortKey] = createSignal('totalDurationMs')
+ const [sortDesc, setSortDesc] = createSignal(true)
+ const navigate = useNavigate()
+
+ const filtered = createMemo(() => {
+ const rows = history() ?? []
+ const f = filter().toLowerCase()
+ const filt = f ? rows.filter((r) => r.id.toLowerCase().includes(f)) : rows
+ return [...filt].sort((a, b) => {
+ const av = sortValue(a, sortKey())
+ const bv = sortValue(b, sortKey())
+ const cmp = av === bv ? 0 : av > bv ? 1 : -1
+ return sortDesc() ? -cmp : cmp
+ })
+ })
+
+ const maxTotal = createMemo(() => Math.max(1, ...(history() ?? []).map((t) => t.totalDurationMs)))
+
+ function onSort(k: SortKey) {
+ if (sortKey() === k) setSortDesc(!sortDesc())
+ else {
+ setSortKey(k)
+ setSortDesc(true)
+ }
+ }
+
+ return (
+
+
+
Tasks
+ setFilter(e.currentTarget.value)}
+ class="text-[12px] font-mono w-72"
+ />
+
+
+ Failed to load: {String(history.error)}
+
+
+ 0} fallback={ }>
+
+
+
+ Task
+ Runs
+ Success
+ Hit
+ Avg
+ p50
+ p99
+ Total time
+ Last
+
+
+
+
+ {(r) => (
+ navigate(`/tasks/${encodeURIComponent(r.id)}`)}
+ >
+
+
+
+
+ {r.id}
+
+
+ {r.runs}
+ 0 && r.successRate < 0.9 }}>
+ {formatPercent(r.successRate, 0)}
+
+ {formatPercent(r.hitRate, 0)}
+ {formatDuration(r.avgDurationMs ?? 0)}
+ {formatDuration(r.p50DurationMs ?? 0)}
+ {formatDuration(r.p99DurationMs ?? 0)}
+
+
+
{formatDuration(r.totalDurationMs)}
+
+
+
+
+
+
+ {r.lastSeenAt !== undefined ? formatRelativeTime(r.lastSeenAt) : '—'}
+
+
+ )}
+
+
+
+
+ No matching tasks.
+
+
+
+
+ )
+}
+
+function sortValue(r: TaskHistoryRow, k: SortKey): number | string {
+ switch (k) {
+ case 'id': return r.id
+ case 'runs': return r.runs
+ case 'successRate': return r.successRate
+ case 'hitRate': return r.hitRate
+ case 'avgDurationMs': return r.avgDurationMs ?? 0
+ case 'p50DurationMs': return r.p50DurationMs ?? 0
+ case 'p99DurationMs': return r.p99DurationMs ?? 0
+ case 'totalDurationMs': return r.totalDurationMs
+ case 'lastSeenAt': return r.lastSeenAt ?? 0
+ }
+}
+
+function Th(props: { k: SortKey; curr: SortKey; desc: boolean; onSort: (k: SortKey) => void; align?: 'left' | 'right'; children: any }) {
+ const active = () => props.curr === props.k
+ return (
+ props.onSort(props.k)}
+ >
+ {props.children}
+ {props.desc ? '↓' : '↑'}
+
+ )
+}
+
+function FailureBadge(props: { mode: TaskHistoryRow['failureMode'] }) {
+ const color = () =>
+ props.mode === 'stable' ? 'bg-success'
+ : props.mode === 'flaky-recoverable' ? 'bg-warn'
+ : 'bg-danger'
+ return
+}
diff --git a/apps/ui/src/pages/Trends.tsx b/apps/ui/src/pages/Trends.tsx
new file mode 100644
index 0000000..3d06786
--- /dev/null
+++ b/apps/ui/src/pages/Trends.tsx
@@ -0,0 +1,120 @@
+import { Show, createResource } from 'solid-js'
+import {
+ getHeatmap,
+ getOriginSignal,
+ getParallelismHistory,
+ getRunTrends,
+ getStorageGrowth,
+} from '../api.ts'
+import { Card, EmptyState, MetricCard } from '../components/ui.tsx'
+import { Heatmap, LineChart } from '../components/charts.tsx'
+import { formatBytes, formatCount, formatDate, formatDuration, formatHour } from '../format.ts'
+
+export function Trends() {
+ const origin = getOriginSignal()
+ const [trend7d] = createResource(origin, () => getRunTrends({ bucket: 'day' }))
+ const [storage] = createResource(origin, () => getStorageGrowth(30))
+ const [heat] = createResource(origin, () => getHeatmap(30))
+ const [parallel] = createResource(origin, () => getParallelismHistory(50))
+
+ return (
+
+
+
Trends
+
Patterns over time — when builds happen, how the cache grows, how parallel you run.
+
+
+
+ }>
+ p.t) ?? []}
+ series={[
+ { name: 'runs', strokeClass: 'stroke-accent', areaClass: 'fill-accent/10', data: trend7d()?.points.map((p) => p.runs) ?? [] },
+ { name: 'hits', strokeClass: 'stroke-cache-local', data: trend7d()?.points.map((p) => p.hits) ?? [] },
+ { name: 'failures', strokeClass: 'stroke-danger', data: trend7d()?.points.map((p) => p.failures) ?? [] },
+ ]}
+ formatX={(t) => formatDate(t)}
+ formatY={(v) => formatCount(v)}
+ height={200}
+ />
+
+
+
+
+
+ }>
+ p.t) ?? []}
+ series={[
+ { name: 'duration', strokeClass: 'stroke-info', areaClass: 'fill-info/10', data: trend7d()?.points.map((p) => p.totalDurationMs) ?? [] },
+ ]}
+ formatX={(t) => formatDate(t)}
+ formatY={(v) => formatDuration(v)}
+ height={200}
+ />
+
+
+
+ last 30d}>
+ c.runs > 0)} fallback={ }>
+ ({ dayOfWeek: c.dayOfWeek, hourOfDay: c.hourOfDay, value: c.runs }))}
+ cellSize={14}
+ format={(v) => `${v} runs`}
+ />
+
+
+
+
+
+ }>
+ p.t) ?? []}
+ series={[
+ { name: 'bytes', strokeClass: 'stroke-accent-2', areaClass: 'fill-accent-2/10', data: storage()?.map((p) => p.bytesAdded) ?? [] },
+ ]}
+ formatX={(t) => formatDate(t)}
+ formatY={(v) => formatBytes(v)}
+ height={180}
+ />
+
+
+
+
cpu sum / wall time — 1.0 = serial}>
+ 0} fallback={ }>
+
+ a + p.factor, 0) / Math.max(1, (parallel() ?? []).length)).toFixed(2) + '×'}
+ sub="across recent runs"
+ />
+ p.factor)).toFixed(2) + '×'}
+ />
+ a + p.cpuSumMs, 0))}
+ />
+
+
+ i))].reverse()}
+ series={[
+ {
+ name: 'parallelism',
+ strokeClass: 'stroke-chart-3',
+ areaClass: 'fill-chart-3/10',
+ data: [...(parallel() ?? []).map((p) => p.factor)].reverse(),
+ },
+ ]}
+ formatX={(x) => `#${x}`}
+ formatY={(v) => v.toFixed(1) + '×'}
+ height={140}
+ />
+
+
+
+
+ )
+}
diff --git a/apps/insights/tsconfig.json b/apps/ui/tsconfig.json
similarity index 100%
rename from apps/insights/tsconfig.json
rename to apps/ui/tsconfig.json
diff --git a/apps/ui/uno.config.ts b/apps/ui/uno.config.ts
new file mode 100644
index 0000000..d91f451
--- /dev/null
+++ b/apps/ui/uno.config.ts
@@ -0,0 +1,124 @@
+import { defineConfig, presetIcons, presetUno, transformerVariantGroup } from 'unocss'
+
+export default defineConfig({
+ presets: [presetUno(), presetIcons({ scale: 1.0 })],
+ transformers: [transformerVariantGroup()],
+ theme: {
+ colors: {
+ // Surfaces
+ bg: 'var(--bg)',
+ surface: 'var(--surface)',
+ 'surface-2': 'var(--surface-2)',
+ 'surface-hover': 'var(--surface-hover)',
+ // Text
+ fg: 'var(--fg)',
+ 'fg-1': 'var(--fg-1)',
+ 'fg-2': 'var(--fg-2)',
+ 'fg-3': 'var(--fg-3)',
+ // Borders
+ border: 'var(--border)',
+ 'border-strong': 'var(--border-strong)',
+ // Brand + semantic
+ accent: 'var(--accent)',
+ 'accent-2': 'var(--accent-2)',
+ success: 'var(--success)',
+ warn: 'var(--warn)',
+ danger: 'var(--danger)',
+ info: 'var(--info)',
+ // Cache provenance
+ 'cache-local': 'var(--cache-local)',
+ 'cache-remote': 'var(--cache-remote)',
+ // Chart palette (8-step categorical, colorblind-friendlier)
+ 'chart-1': 'var(--chart-1)',
+ 'chart-2': 'var(--chart-2)',
+ 'chart-3': 'var(--chart-3)',
+ 'chart-4': 'var(--chart-4)',
+ 'chart-5': 'var(--chart-5)',
+ 'chart-6': 'var(--chart-6)',
+ 'chart-7': 'var(--chart-7)',
+ 'chart-8': 'var(--chart-8)',
+ },
+ fontFamily: {
+ mono: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
+ },
+ },
+ preflights: [
+ {
+ getCSS: () => `
+ :root {
+ color-scheme: dark;
+
+ /* Surfaces — graded for layered depth */
+ --bg: #07080a;
+ --surface: #0e1014;
+ --surface-2: #14171c;
+ --surface-hover: #1a1e24;
+
+ /* Text */
+ --fg: #e7e9ee;
+ --fg-1: #c2c7d0;
+ --fg-2: #8a92a0;
+ --fg-3: #5a6270;
+
+ /* Borders */
+ --border: #1d2128;
+ --border-strong: #2a2f37;
+
+ /* Brand */
+ --accent: #a78bfa;
+ --accent-2: #c084fc;
+
+ /* Semantic */
+ --success: #4ade80;
+ --warn: #facc15;
+ --danger: #f87171;
+ --info: #38bdf8;
+
+ /* Cache provenance */
+ --cache-local: #38bdf8;
+ --cache-remote: #818cf8;
+
+ /* Chart palette */
+ --chart-1: #a78bfa;
+ --chart-2: #38bdf8;
+ --chart-3: #4ade80;
+ --chart-4: #facc15;
+ --chart-5: #f472b6;
+ --chart-6: #fb923c;
+ --chart-7: #818cf8;
+ --chart-8: #2dd4bf;
+ }
+
+ html, body, #root { height: 100%; }
+ body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ font-size: 13px;
+ line-height: 1.5;
+ font-feature-settings: "cv11", "ss01", "ss03";
+ -webkit-font-smoothing: antialiased;
+ }
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
+ ::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 6px;
+ border: 2px solid var(--bg);
+ }
+ ::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }
+ button, input, select { font: inherit; color: inherit; }
+ button { background: none; border: none; cursor: pointer; }
+ input, select { background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; }
+ input:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(167, 139, 250, 0.15); }
+ kbd {
+ display: inline-flex; align-items: center;
+ padding: 2px 6px; border-radius: 4px;
+ background: var(--surface-2); border: 1px solid var(--border);
+ font-family: var(--mono); font-size: 11px; color: var(--fg-2);
+ }
+ code { font-family: var(--mono); }
+ `,
+ },
+ ],
+})
diff --git a/apps/ui/vite.config.ts b/apps/ui/vite.config.ts
new file mode 100644
index 0000000..92c6f4c
--- /dev/null
+++ b/apps/ui/vite.config.ts
@@ -0,0 +1,48 @@
+import { defineConfig, type Plugin } from 'vite'
+import solid from 'vite-plugin-solid'
+import unocss from 'unocss/vite'
+
+/**
+ * Inline every JS chunk and CSS asset into index.html so the build emits a
+ * single self-contained file. `vx serve --ui` embeds that one file into the
+ * binary via `with { type: 'file' }`, so the dashboard ships inside `vx`
+ * with no on-disk asset directory to resolve.
+ */
+function singleFile(): Plugin {
+ return {
+ name: 'vx-single-file',
+ enforce: 'post',
+ generateBundle(_options, bundle) {
+ const html = bundle['index.html']
+ if (!html || html.type !== 'asset') return
+ let src = String(html.source)
+ // CRITICAL: pass the replacement as a FUNCTION so `$&`, `` $` ``, `$1`
+ // etc. in the bundled JS/CSS aren't interpreted as String.replace
+ // substitution patterns. A literal `$&` in a regex body would otherwise
+ // splice the matched HTML tag into the middle of the script.
+ for (const [name, chunk] of Object.entries(bundle)) {
+ if (chunk.type === 'chunk' && chunk.fileName.endsWith('.js')) {
+ const tag = new RegExp(``)
+ src = src.replace(tag, () => ``)
+ delete bundle[name]
+ } else if (chunk.type === 'asset' && chunk.fileName.endsWith('.css')) {
+ const link = new RegExp(` ]*href="[^"]*${chunk.fileName}"[^>]*>`)
+ src = src.replace(link, () => ``)
+ delete bundle[name]
+ }
+ }
+ html.source = src
+ },
+ }
+}
+
+export default defineConfig({
+ plugins: [unocss(), solid(), singleFile()],
+ server: {
+ port: 5290,
+ strictPort: false,
+ },
+ preview: {
+ port: 5290,
+ },
+})
diff --git a/apps/insights/vx.config.ts b/apps/ui/vx.config.ts
similarity index 100%
rename from apps/insights/vx.config.ts
rename to apps/ui/vx.config.ts
diff --git a/bun.lock b/bun.lock
index a9b457f..a0fbfd9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -27,10 +27,11 @@
"unist-util-visit": "^5.1.0",
},
},
- "apps/insights": {
- "name": "@vzn/vx-insights",
+ "apps/ui": {
+ "name": "@vzn/vx-ui",
"version": "0.0.0",
"devDependencies": {
+ "@iconify-json/tabler": "^1.2.35",
"@solidjs/router": "^0.16.1",
"@unocss/preset-icons": "^66.7.2",
"@unocss/preset-uno": "^66.7.2",
@@ -179,6 +180,8 @@
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
+ "@iconify-json/tabler": ["@iconify-json/tabler@1.2.35", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-/sJMqHvh5ZWrEERVfDCT5NjVDeKJdhosFtKjJofAVl+P/3AzLiryOQw7WvrfDF25Xa5N/eoOQ15Y1jnhYXxBoQ=="],
+
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="],
@@ -663,7 +666,7 @@
"@vzn/vx-docs": ["@vzn/vx-docs@workspace:apps/docs"],
- "@vzn/vx-insights": ["@vzn/vx-insights@workspace:apps/insights"],
+ "@vzn/vx-ui": ["@vzn/vx-ui@workspace:apps/ui"],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
diff --git a/docs/cli.md b/docs/cli.md
index 5ea25dc..f827078 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -884,19 +884,23 @@ vx run --worker ws://coord:5180 # connect, register, pull, execute
Workers do NOT yet probe the remote cache before executing — every
assigned task spawns fresh. Cache integration is the next iteration.
-## `vx serve` — unified backend (execution + insights + UI)
+## `vx serve` — unified backend (execution + metrics + UI)
-The same backend powers `vx run` delegation, the insights JSON API,
-the event stream, and (when `--ui` is set) the bundled Solid SPA.
+The same backend powers `vx run` delegation, the metrics JSON API,
+the event stream, and (when `--ui` is set) the embedded dashboard SPA.
One process, one stack, runs locally or in Docker.
```
vx serve # bind a kernel-assigned port
--port # explicit
- --ui # also serve the bundled SPA at /
- --open # open the UI in the default browser (implies --ui)
+ --ui # also serve the embedded dashboard at /
+ --open # open the dashboard in the default browser (implies --ui)
```
+The dashboard is embedded in the binary (a single self-contained
+`apps/ui/dist/index.html` compiled in via `with { type: 'file' }`), so
+`--ui` works from a bare `vx` with nothing else on disk.
+
HTTP routes (all return JSON unless noted):
| Route | Purpose |
diff --git a/docs/progress/implementation-log-2026-06.md b/docs/progress/implementation-log-2026-06.md
index b92c41b..1ab778c 100644
--- a/docs/progress/implementation-log-2026-06.md
+++ b/docs/progress/implementation-log-2026-06.md
@@ -800,3 +800,37 @@ static-server tests.
**Net.** −1646/+896 LOC across 25 files in the first commit; another
−603/+422 across 13 files in the SPA refactor. One stack everywhere;
the SPA is a thin client; `vx serve` is the only backend.
+
+---
+
+## Embed the dashboard in the binary + drop "insights" naming (2026-06-21)
+
+Owner reports: `vx serve --ui` failed with "--ui requires apps/insights/dist"
+from a compiled binary, and "remove all insights naming".
+
+**Embedding.** apps/ui (renamed from apps/insights) now builds to a single
+self-contained `dist/index.html` (JS + CSS inlined via a tiny `generateBundle`
+plugin in vite.config.ts). `src/cli/ui-asset.ts` imports it with
+`with { type: 'file' }`, so `bun build --compile` embeds the bytes inside the
+standalone binary; the import resolves to a bunfs path that `Bun.file()` reads.
+`vx serve --ui` serves that one file for every non-API GET (hash-router SPA).
+Verified: compiled a binary, moved apps/ui/dist away entirely, `vx serve --ui`
+still served the embedded dashboard. The module is dynamically imported only on
+`--ui`, so a source checkout that hasn't built the UI doesn't break `vx run`.
+
+`build.ui` task (root project, `cd apps/ui && bun run build`, boundary-free
+workspaceFiles inputs/outputs) builds the SPA; the four `build.bun.*` compile
+tasks depend on it so a fresh UI is embedded and a UI change cascades into the
+binary cache key. Same-project dep (not a cross-project `@vzn/vx-ui#build` ref)
+so scoped loading always has it in scope and `vx run test`/CI aren't polluted
+with a UI build.
+
+**Rename.** apps/insights → apps/ui; `@vzn/vx-insights` → `@vzn/vx-ui`;
+`src/orchestrator/insights-queries.ts` → `metrics.ts`;
+`tests/insights-queries.test.ts` → `metrics.test.ts`; guide insights.md →
+dashboard.md; brand "vx insights" → "vx dashboard"; localStorage key
+`vx-insights:origin` → `vx-ui:origin`; help/README/cli/landing/introduction
+copy. `VX_INSIGHTS_DIST` removed (embedded, no on-disk dist to point at).
+
+No CACHE_VERSION impact. Module-boundaries test gained a `.html` asset carve-out
+(mirrors the existing `.json` one). Full gate green.
diff --git a/src/cli/help.ts b/src/cli/help.ts
index fc47a7e..f9fd377 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -52,11 +52,11 @@ export function printHelp(): void {
' `vx run` in this workspace DELEGATES to it over a',
' WebSocket (work happens server-side; output streams',
' back and renders identically). Also exposes the',
- ' insights JSON API at /v1/* and SSE / NDJSON event',
+ ' metrics JSON API at /v1/* and SSE / NDJSON event',
' streams. No service running = runs execute in-process',
' as before. Ctrl-C stops it.',
' --port Bind port (default: an open one).',
- ' --ui Also serve the bundled insights SPA at /.',
+ ' --ui Also serve the bundled dashboard SPA at /.',
' --open Open the UI in the default browser (implies --ui).',
' VX_SERVICE_URL= Delegate to an explicit (e.g. hosted) service origin',
' instead of a local one.',
diff --git a/src/cli/serve.ts b/src/cli/serve.ts
index 3a4f6ff..32729da 100644
--- a/src/cli/serve.ts
+++ b/src/cli/serve.ts
@@ -18,17 +18,25 @@ import {
encodeForSSE,
envelopeToClientMessage,
explainCacheKeyQuery,
+ getBottlenecks,
getCacheBreakdown,
getCacheSavings,
getCacheStatsSql,
+ getFlakiestTasks,
getHistory,
+ getParallelismHistory,
+ getPrunableEntries,
getRecentFailures,
getRun,
+ getRunHeatmap,
+ getRunTrends,
+ getStorageGrowth,
getTaskDetail,
getTopTimeBurners,
isEnvelope,
listCacheEntries,
listInvocations,
+ listProjects,
listRuns,
serverMessageToEnvelope,
whyDidThisRerunQuery,
@@ -93,10 +101,10 @@ export interface ServeServer {
stop: () => Promise
}
-// CORS is wide-open: the hosted SPA needs to reach localhost from a foreign
-// origin, and the surface is read-only insights + an authenticated WS run
-// submission. Any tighter policy would break the "host the SPA once, point
-// it at any vx serve" UX.
+// CORS is wide-open: a hosted dashboard needs to reach localhost from a
+// foreign origin, and the surface is read-only metrics + an authenticated WS
+// run submission. Any tighter policy would break the "host the SPA once,
+// point it at any vx serve" UX.
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -113,51 +121,15 @@ function jsonResponse(body: unknown, init?: ResponseInit): Response {
return withCors(Response.json(body, init))
}
-const SPA_MIME: Record = {
- '.html': 'text/html; charset=utf-8',
- '.js': 'text/javascript; charset=utf-8',
- '.mjs': 'text/javascript; charset=utf-8',
- '.css': 'text/css; charset=utf-8',
- '.svg': 'image/svg+xml',
- '.png': 'image/png',
- '.jpg': 'image/jpeg',
- '.json': 'application/json; charset=utf-8',
- '.map': 'application/json; charset=utf-8',
- '.ico': 'image/x-icon',
- '.woff': 'font/woff',
- '.woff2': 'font/woff2',
-}
-
-/**
- * Serve a file from `uiDir` for `pathname`. Returns null when the requested
- * pathname escapes the directory or doesn't exist; the caller falls back to
- * `index.html` for hash-router routes that aren't real files.
- */
-async function serveStatic(uiDir: string, pathname: string): Promise {
- const rel = pathname === '/' ? '/index.html' : pathname
- const abs = path.join(uiDir, rel)
- // Containment check — never escape uiDir even if pathname has ../
- const resolved = path.resolve(abs)
- if (!resolved.startsWith(path.resolve(uiDir))) return null
- const file = Bun.file(resolved)
- if (!(await file.exists())) return null
- const ext = path.extname(resolved).toLowerCase()
- const headers: Record = {
- 'Content-Type': SPA_MIME[ext] ?? 'application/octet-stream',
- }
- // Hashed asset URLs (Vite emits `index-.{js,css}`) can be cached
- // forever; HTML must not be cached or the picker won't pick up a redeploy.
- if (ext === '.html') headers['Cache-Control'] = 'no-store'
- else if (resolved.includes(`${path.sep}assets${path.sep}`))
- headers['Cache-Control'] = 'public, max-age=31536000, immutable'
- return new Response(file, { headers })
-}
-
export async function startServe(opts: {
root: string
port?: number
- /** Absolute path to a pre-built SPA `dist/`. When set, `/` serves it. */
- uiDir?: string
+ /**
+ * Path to the single-file dashboard HTML (the embedded `apps/ui` build).
+ * When set, every non-API GET serves it — the SPA is one self-contained
+ * file with a hash router, so all routes return the same bytes.
+ */
+ uiHtmlPath?: string
onRun?: (request: RunRequest, ok: boolean) => void
}): Promise {
// One registry for the service's whole lifetime — concurrent runs share
@@ -209,9 +181,9 @@ export async function startServe(opts: {
})
}
// -----------------------------------------------------------------
- // Insights HTTP surface — JSON read APIs over cache.db. The hosted
- // SPA in apps/insights/ calls these directly; same shape will be
- // mirrored by a future hosted multi-tenant deployment.
+ // Metrics HTTP surface — JSON read APIs over cache.db. The dashboard
+ // SPA in apps/ui calls these directly; same shape will be mirrored by
+ // a future hosted multi-tenant deployment.
// -----------------------------------------------------------------
if (url.pathname === '/v1/runs') {
const params = url.searchParams
@@ -274,6 +246,53 @@ export async function startServe(opts: {
const limit = Number(url.searchParams.get('limit') ?? '25')
return jsonResponse({ failures: getRecentFailures(cache.dbHandle(), limit) })
}
+ if (url.pathname === '/v1/projects') {
+ const limit = Number(url.searchParams.get('limit') ?? '100')
+ return jsonResponse({ projects: listProjects(cache.dbHandle(), limit) })
+ }
+ if (url.pathname === '/v1/trends/runs') {
+ const params = url.searchParams
+ const bucketRaw = params.get('bucket')
+ const bucket = bucketRaw === 'day' || bucketRaw === 'hour' ? bucketRaw : 'hour'
+ const args: Parameters[1] = { bucket }
+ const fromRaw = params.get('from')
+ if (fromRaw !== null) args.from = Number(fromRaw)
+ const toRaw = params.get('to')
+ if (toRaw !== null) args.to = Number(toRaw)
+ return jsonResponse({ bucket, points: getRunTrends(cache.dbHandle(), args) })
+ }
+ if (url.pathname === '/v1/trends/heatmap') {
+ const days = Number(url.searchParams.get('days') ?? '30')
+ return jsonResponse({ days, cells: getRunHeatmap(cache.dbHandle(), days) })
+ }
+ if (url.pathname === '/v1/trends/storage') {
+ const days = Number(url.searchParams.get('days') ?? '30')
+ return jsonResponse({ days, points: getStorageGrowth(cache.dbHandle(), days) })
+ }
+ if (url.pathname === '/v1/trends/parallelism') {
+ const limit = Number(url.searchParams.get('limit') ?? '50')
+ return jsonResponse({ points: getParallelismHistory(cache.dbHandle(), limit) })
+ }
+ if (url.pathname === '/v1/flakiness') {
+ const limit = Number(url.searchParams.get('limit') ?? '25')
+ return jsonResponse({ tasks: getFlakiestTasks(cache.dbHandle(), limit) })
+ }
+ if (url.pathname === '/v1/bottlenecks') {
+ const lookbackDays = Number(url.searchParams.get('days') ?? '14')
+ const limit = Number(url.searchParams.get('limit') ?? '15')
+ return jsonResponse({
+ lookbackDays,
+ bottlenecks: getBottlenecks(cache.dbHandle(), lookbackDays, limit),
+ })
+ }
+ if (url.pathname === '/v1/cache/prunable') {
+ const minAgeDays = Number(url.searchParams.get('minAgeDays') ?? '7')
+ const limit = Number(url.searchParams.get('limit') ?? '50')
+ return jsonResponse({
+ minAgeDays,
+ entries: getPrunableEntries(cache.dbHandle(), minAgeDays, limit),
+ })
+ }
if (url.pathname === '/v1/history') {
const params = url.searchParams
const args: Parameters[1] = {}
@@ -367,16 +386,15 @@ export async function startServe(opts: {
)
}
if (srv.upgrade(req)) return undefined
- // When --ui is set, serve the bundled SPA from `uiDir`. Hash-router
- // routes that aren't real files fall back to index.html (SPA fallback).
- if (opts.uiDir !== undefined) {
- return (async (): Promise => {
- const res = await serveStatic(opts.uiDir!, url.pathname)
- if (res) return withCors(res)
- const index = await serveStatic(opts.uiDir!, '/index.html')
- if (index) return withCors(index)
- return withCors(new Response('not found', { status: 404 }))
- })()
+ // When --ui is set, serve the single-file dashboard for every non-API
+ // GET. It's one self-contained HTML with a hash router, so every route
+ // (/, /tasks, /cache, …) returns the same bytes.
+ if (opts.uiHtmlPath !== undefined) {
+ return withCors(
+ new Response(Bun.file(opts.uiHtmlPath), {
+ headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
+ }),
+ )
}
return withCors(new Response('vx serve'))
},
@@ -463,15 +481,20 @@ export function parseServeArgs(args: readonly string[]): ServeArgs {
}
/**
- * Resolve the bundled SPA dist. Mirrors apps/docs and apps/insights resolution:
- * the repo lives alongside the running source, even when installed. Allows
- * `VX_INSIGHTS_DIST` for a custom checkout.
+ * Load the embedded single-file dashboard. The asset module embeds
+ * apps/ui/dist/index.html into the binary; in a source checkout the dynamic
+ * import resolves the real file, which only exists after `apps/ui` is built —
+ * so a missing build only affects `--ui`, never a normal `vx run`.
*/
-function resolveUiDist(): string | null {
- const env = process.env.VX_INSIGHTS_DIST
- if (env !== undefined && env.length > 0) return env
- const candidate = path.resolve(import.meta.dir, '..', '..', 'apps', 'insights', 'dist')
- return candidate
+async function loadUiHtmlPath(): Promise {
+ try {
+ const mod = await import('./ui-asset.js')
+ const p = mod.UI_HTML_PATH
+ if (!(await Bun.file(p).exists())) return null
+ return p
+ } catch {
+ return null
+ }
}
function openInBrowser(url: string): void {
@@ -498,28 +521,30 @@ export async function serveCmd(args: readonly string[]): Promise {
}
const root = await findWorkspaceRoot(process.cwd())
- let uiDir: string | undefined
+ let uiHtmlPath: string | undefined
if (parsed.ui) {
- const candidate = resolveUiDist()
- if (candidate === null || !(await Bun.file(path.join(candidate, 'index.html')).exists())) {
+ const p = await loadUiHtmlPath()
+ if (p === null) {
+ // Only reachable in a source checkout that hasn't built the dashboard;
+ // a compiled binary embeds it, so this never fires for end users.
process.stderr.write(
- `vx serve: --ui requires apps/insights/dist (run \`bun --cwd apps/insights run build\` first, or set VX_INSIGHTS_DIST)\n`,
+ `vx serve: dashboard not built — run \`bun run --filter @vzn/vx-ui build\` (only needed when running from source)\n`,
)
return 1
}
- uiDir = candidate
+ uiHtmlPath = p
}
const server = await startServe({
root,
...(parsed.port !== undefined ? { port: parsed.port } : {}),
- ...(uiDir !== undefined ? { uiDir } : {}),
+ ...(uiHtmlPath !== undefined ? { uiHtmlPath } : {}),
onRun: (request, ok) => {
process.stdout.write(` ${ok ? '✓' : '✗'} ${request.tasks.join(', ')}\n`)
},
})
- const uiLine = uiDir !== undefined ? `vx serve: UI ${server.origin}/\n` : ''
+ const uiLine = uiHtmlPath !== undefined ? `vx serve: UI ${server.origin}/\n` : ''
process.stdout.write(
`vx serve: API ${server.origin}\n` +
uiLine +
@@ -527,7 +552,7 @@ export async function serveCmd(args: readonly string[]): Promise {
`(press Ctrl-C to stop)\n\n`,
)
- if (parsed.open && uiDir !== undefined) openInBrowser(server.origin)
+ if (parsed.open && uiHtmlPath !== undefined) openInBrowser(server.origin)
await new Promise((resolve) => {
process.once('SIGINT', () => resolve())
diff --git a/src/cli/ui-asset.ts b/src/cli/ui-asset.ts
new file mode 100644
index 0000000..8039640
--- /dev/null
+++ b/src/cli/ui-asset.ts
@@ -0,0 +1,19 @@
+// The dashboard SPA, embedded into the binary.
+//
+// `apps/ui` builds to a single self-contained `dist/index.html` (JS + CSS
+// inlined — see apps/ui/vite.config.ts). Importing it with `{ type: 'file' }`
+// makes `bun build --compile` embed the bytes inside the standalone binary;
+// the import resolves to a path (a `/$bunfs/...` path in a compiled binary, a
+// real fs path under `bun run`) that `Bun.file()` reads. So `vx serve --ui`
+// works from a bare binary with nothing else on disk.
+//
+// This module is imported dynamically (only when `--ui` is requested) so a
+// source checkout that hasn't run `apps/ui build` doesn't break `vx run`.
+
+// `with { type: 'file' }` makes this resolve to a path string at runtime, but
+// @types/bun types a `.html` import as `HTMLBundle` (its HTML-loader shape) —
+// the file-attribute override isn't modelled. Cast at this one seam.
+import indexHtml from '../../apps/ui/dist/index.html' with { type: 'file' }
+
+/** Absolute (or bunfs) path to the embedded single-file dashboard. */
+export const UI_HTML_PATH = indexHtml as unknown as string
diff --git a/src/orchestrator/index.ts b/src/orchestrator/index.ts
index ce5add5..4986899 100644
--- a/src/orchestrator/index.ts
+++ b/src/orchestrator/index.ts
@@ -75,34 +75,51 @@ export { workerExecute } from './worker-exec.js'
export type { WorkerExecArgs, WorkerExecResult } from './worker-exec.js'
export {
explainCacheKey as explainCacheKeyQuery,
+ getBottlenecks,
getCacheBreakdown,
getCacheSavings,
getCacheStatsSql,
+ getFlakiestTasks,
getHistory,
+ getParallelismHistory,
+ getPrunableEntries,
getRecentFailures,
getRun,
+ getRunHeatmap,
+ getRunTrends,
+ getStorageGrowth,
getTaskDetail,
getTopTimeBurners,
listCacheEntries,
listInvocations,
+ listProjects,
listRuns,
whyDidThisRerun as whyDidThisRerunQuery,
-} from './insights-queries.js'
+} from './metrics.js'
export type {
+ BottleneckRow,
CacheEntryRow,
CacheKeyExplanation,
CacheProjectRow,
CacheSavings,
CacheStatsResult,
FailureRow,
+ FlakyTask,
GetHistoryArgs,
+ HeatmapCell,
InvocationRow,
ListCacheEntriesArgs,
ListRunsArgs,
+ ParallelismPoint,
+ PrunableEntry,
+ ProjectRollup,
RunDetail,
RunSummaryRow,
+ StoragePoint,
TaskDetail,
TaskHistoryRow,
TopTaskRow,
+ TrendBucket,
+ TrendPoint,
WhyDidThisRerun,
-} from './insights-queries.js'
+} from './metrics.js'
diff --git a/src/orchestrator/insights-queries.ts b/src/orchestrator/metrics.ts
similarity index 57%
rename from src/orchestrator/insights-queries.ts
rename to src/orchestrator/metrics.ts
index 7bc1f5a..d242eb2 100644
--- a/src/orchestrator/insights-queries.ts
+++ b/src/orchestrator/metrics.ts
@@ -1,9 +1,8 @@
-// Insights query module — pure functions over a `bun:sqlite` Database.
+// Metrics query module — pure functions over a `bun:sqlite` Database.
//
-// One module, two callers: `vx serve` (over Bun.serve HTTP routes) and
-// the same shape on `apps/cloud` (over Cloudflare D1 — same SQL,
-// different driver). The SPA in `apps/insights` calls /v1/* routes
-// backed by these functions.
+// `vx serve` exposes these as /v1/* HTTP routes; the dashboard SPA in
+// apps/ui and `vx mcp` both read through them. One canonical home for
+// every aggregate over the runs / entries tables.
//
// Pure SQL + JSON-safe return shapes. No Cache lifecycle here; the
// caller opens and closes. bigints are serialized as decimal strings
@@ -596,3 +595,424 @@ function pickPercentile(sorted: number[], q: number): number | undefined {
const idx = Math.min(sorted.length - 1, Math.floor(q * sorted.length))
return sorted[idx]
}
+
+// ---------------------------------------------------------------------------
+// Project-level rollups — where the time and storage actually sit
+// ---------------------------------------------------------------------------
+
+export interface ProjectRollup {
+ project: string
+ taskCount: number
+ runs: number
+ failures: number
+ hits: number
+ hitRate: number
+ totalDurationMs: number
+ avgDurationMs: number
+ cacheBytes: number
+ cacheEntries: number
+ lastRunAt: number | undefined
+ estimatedTimeSavedMs: number
+}
+
+export function listProjects(db: Database, limit = 100): ProjectRollup[] {
+ const rows = db
+ .query(
+ `SELECT project,
+ COUNT(DISTINCT task) AS taskCount,
+ COUNT(*) AS runs,
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
+ SUM(CASE WHEN cache_hit = 1 OR status LIKE 'cache-hit%' THEN 1 ELSE 0 END) AS hits,
+ SUM(duration_ms) AS totalDurationMs,
+ CAST(AVG(duration_ms) AS INTEGER) AS avgDurationMs,
+ MAX(ended_at) AS lastRunAt
+ FROM runs GROUP BY project ORDER BY SUM(duration_ms) DESC LIMIT ?`,
+ )
+ .all(clampInt(limit, 1, 500)) as Array<{
+ project: string
+ taskCount: number
+ runs: number
+ failures: number
+ hits: number
+ totalDurationMs: number | null
+ avgDurationMs: number | null
+ lastRunAt: number | null
+ }>
+ return rows.map((r) => {
+ const ent = db
+ .query(
+ 'SELECT COUNT(*) AS n, COALESCE(SUM(size_bytes), 0) AS b FROM entries WHERE project = ?',
+ )
+ .get(r.project) as { n: number; b: number }
+ const saved = db
+ .query(
+ `SELECT COALESCE(SUM(avg), 0) AS saved FROM (
+ SELECT (SELECT CAST(AVG(duration_ms) AS INTEGER) FROM runs s
+ WHERE s.project = r.project AND s.task = r.task
+ AND (s.cache_hit IS NULL OR s.cache_hit = 0)
+ AND s.status = 'success') AS avg
+ FROM runs r WHERE r.project = ?
+ AND (r.cache_hit = 1 OR r.status LIKE 'cache-hit%')
+ ) WHERE avg IS NOT NULL`,
+ )
+ .get(r.project) as { saved: number }
+ return {
+ project: r.project,
+ taskCount: r.taskCount,
+ runs: r.runs,
+ failures: r.failures,
+ hits: r.hits,
+ hitRate: r.runs > 0 ? r.hits / r.runs : 0,
+ totalDurationMs: r.totalDurationMs ?? 0,
+ avgDurationMs: r.avgDurationMs ?? 0,
+ cacheBytes: ent.b,
+ cacheEntries: ent.n,
+ lastRunAt: r.lastRunAt ?? undefined,
+ estimatedTimeSavedMs: saved.saved,
+ }
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Trends — bucketed time-series for charts
+// ---------------------------------------------------------------------------
+
+export type TrendBucket = 'hour' | 'day'
+
+export interface TrendPoint {
+ /** Epoch ms at the start of the bucket. */
+ t: number
+ runs: number
+ hits: number
+ failures: number
+ /** Sum of duration_ms in the bucket. */
+ totalDurationMs: number
+}
+
+/**
+ * Bucketed run counts + failure/hit/duration over time. Default range = last
+ * 24h for hour buckets, last 30d for day buckets — picked to match the chart
+ * defaults clients use.
+ */
+export function getRunTrends(
+ db: Database,
+ args: { bucket?: TrendBucket; from?: number; to?: number } = {},
+): TrendPoint[] {
+ const bucket: TrendBucket = args.bucket ?? 'hour'
+ const to = args.to ?? Date.now()
+ const defaultRangeMs = bucket === 'hour' ? 24 * 60 * 60 * 1000 : 30 * 24 * 60 * 60 * 1000
+ const from = args.from ?? to - defaultRangeMs
+ const bucketMs = bucket === 'hour' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000
+ // Floor each timestamp to its bucket boundary in SQL so partial buckets line
+ // up cleanly. `started_at` is already epoch-ms.
+ const rows = db
+ .query(
+ `SELECT (started_at / ?) * ? AS t,
+ COUNT(*) AS runs,
+ SUM(CASE WHEN cache_hit = 1 OR status LIKE 'cache-hit%' THEN 1 ELSE 0 END) AS hits,
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
+ SUM(duration_ms) AS totalDurationMs
+ FROM runs
+ WHERE started_at >= ? AND started_at <= ?
+ GROUP BY t ORDER BY t ASC`,
+ )
+ .all(bucketMs, bucketMs, from, to) as TrendPoint[]
+ // Densify — emit zeros for empty buckets so the chart line stays continuous.
+ const start = Math.floor(from / bucketMs) * bucketMs
+ const end = Math.floor(to / bucketMs) * bucketMs
+ const byT = new Map(rows.map((r) => [r.t, r]))
+ const out: TrendPoint[] = []
+ for (let t = start; t <= end; t += bucketMs) {
+ out.push(byT.get(t) ?? { t, runs: 0, hits: 0, failures: 0, totalDurationMs: 0 })
+ }
+ return out
+}
+
+// ---------------------------------------------------------------------------
+// Heatmap — runs per (hour-of-day, day-of-week)
+// ---------------------------------------------------------------------------
+
+export interface HeatmapCell {
+ /** 0 = Sun … 6 = Sat */
+ dayOfWeek: number
+ /** 0 … 23, local time */
+ hourOfDay: number
+ runs: number
+ totalDurationMs: number
+}
+
+/** When do builds happen? Surfaces a 7×24 grid for the last `days` days. */
+export function getRunHeatmap(db: Database, days = 30): HeatmapCell[] {
+ const since = Date.now() - days * 24 * 60 * 60 * 1000
+ // Pull raw rows; bucket in JS (timezone math is ugly in pure SQLite, and
+ // `days * 24 * runs/day` rows is a few thousand at most).
+ const rows = db
+ .query('SELECT started_at, duration_ms FROM runs WHERE started_at >= ?')
+ .all(since) as { started_at: number; duration_ms: number }[]
+ const grid: HeatmapCell[] = []
+ for (let d = 0; d < 7; d++)
+ for (let h = 0; h < 24; h++)
+ grid.push({ dayOfWeek: d, hourOfDay: h, runs: 0, totalDurationMs: 0 })
+ for (const r of rows) {
+ const date = new Date(r.started_at)
+ const cell = grid[date.getDay() * 24 + date.getHours()]!
+ cell.runs++
+ cell.totalDurationMs += r.duration_ms
+ }
+ return grid
+}
+
+// ---------------------------------------------------------------------------
+// Flakiness — tasks that fail unpredictably or whose p99/p50 gap is wide
+// ---------------------------------------------------------------------------
+
+export interface FlakyTask {
+ id: string
+ project: string
+ task: string
+ runs: number
+ failures: number
+ failureRate: number
+ /** p99 / p50 ratio for successful non-hit runs; >3 flags wide tail. */
+ durationTailRatio: number | undefined
+ p50DurationMs: number | undefined
+ p99DurationMs: number | undefined
+}
+
+export function getFlakiestTasks(db: Database, limit = 25): FlakyTask[] {
+ const pairs = db
+ .query(
+ `SELECT project, task, COUNT(*) AS runs,
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures
+ FROM runs GROUP BY project, task HAVING runs >= 3`,
+ )
+ .all() as { project: string; task: string; runs: number; failures: number }[]
+ return pairs
+ .map((p) => {
+ const durs = db
+ .query(
+ `SELECT duration_ms FROM runs
+ WHERE project = ? AND task = ?
+ AND (cache_hit IS NULL OR cache_hit = 0) AND status = 'success'
+ ORDER BY started_at DESC LIMIT 50`,
+ )
+ .all(p.project, p.task) as { duration_ms: number }[]
+ const sorted = durs.map((r) => r.duration_ms).sort((a, b) => a - b)
+ const p50 = pickPercentile(sorted, 0.5)
+ const p99 = pickPercentile(sorted, 0.99)
+ const ratio = p50 && p50 > 0 && p99 !== undefined ? p99 / p50 : undefined
+ return {
+ id: `${p.project}#${p.task}`,
+ project: p.project,
+ task: p.task,
+ runs: p.runs,
+ failures: p.failures,
+ failureRate: p.runs > 0 ? p.failures / p.runs : 0,
+ durationTailRatio: ratio,
+ p50DurationMs: p50,
+ p99DurationMs: p99,
+ } satisfies FlakyTask
+ })
+ .filter(
+ (r) => r.failureRate > 0 || (r.durationTailRatio !== undefined && r.durationTailRatio > 2),
+ )
+ .sort((a, b) => {
+ // Rank by a composite score: failure rate dominates, tail ratio breaks ties.
+ const sa = a.failureRate * 10 + (a.durationTailRatio ?? 1)
+ const sb = b.failureRate * 10 + (b.durationTailRatio ?? 1)
+ return sb - sa
+ })
+ .slice(0, clampInt(limit, 1, 200))
+}
+
+// ---------------------------------------------------------------------------
+// Bottlenecks — "if you sped up X, you'd save Y per week"
+// ---------------------------------------------------------------------------
+
+export interface BottleneckRow {
+ id: string
+ project: string
+ task: string
+ /** Runs in the last `lookbackDays`. */
+ runsRecent: number
+ /** Total non-hit success duration over the lookback. */
+ totalDurationMs: number
+ avgDurationMs: number
+ /** Runs/day extrapolated from the lookback. */
+ runsPerDay: number
+ /** Time you'd save per week if you cut avg duration by 25%. */
+ weeklySavingsAt25PctCutMs: number
+}
+
+/** Highest-leverage targets ranked by extrapolated weekly burn. */
+export function getBottlenecks(db: Database, lookbackDays = 14, limit = 15): BottleneckRow[] {
+ const since = Date.now() - lookbackDays * 24 * 60 * 60 * 1000
+ const rows = db
+ .query(
+ `SELECT project, task,
+ COUNT(*) AS runsRecent,
+ SUM(duration_ms) AS totalDurationMs,
+ CAST(AVG(duration_ms) AS INTEGER) AS avgDurationMs
+ FROM runs
+ WHERE started_at >= ?
+ AND (cache_hit IS NULL OR cache_hit = 0) AND status = 'success'
+ GROUP BY project, task
+ ORDER BY SUM(duration_ms) DESC
+ LIMIT ?`,
+ )
+ .all(since, clampInt(limit, 1, 100)) as Array<{
+ project: string
+ task: string
+ runsRecent: number
+ totalDurationMs: number
+ avgDurationMs: number
+ }>
+ return rows.map((r) => {
+ const runsPerDay = r.runsRecent / Math.max(1, lookbackDays)
+ const weeklySavings = Math.round(runsPerDay * 7 * r.avgDurationMs * 0.25)
+ return {
+ id: `${r.project}#${r.task}`,
+ project: r.project,
+ task: r.task,
+ runsRecent: r.runsRecent,
+ totalDurationMs: r.totalDurationMs,
+ avgDurationMs: r.avgDurationMs,
+ runsPerDay,
+ weeklySavingsAt25PctCutMs: weeklySavings,
+ } satisfies BottleneckRow
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Parallelism factor — how many workers you actually utilized
+// ---------------------------------------------------------------------------
+
+export interface ParallelismPoint {
+ runId: string
+ startedAt: number
+ /** Total task CPU time. */
+ cpuSumMs: number
+ /** Wallclock from first task start to last task end. */
+ wallMs: number
+ /** cpuSumMs / wallMs — effective parallelism (1 = serial). */
+ factor: number
+ taskCount: number
+}
+
+/** Per-invocation parallelism, recent first. */
+export function getParallelismHistory(db: Database, limit = 50): ParallelismPoint[] {
+ const rows = db
+ .query(
+ `SELECT run_id AS runId,
+ MIN(started_at) AS startedAt,
+ MIN(started_at) AS minStart,
+ MAX(ended_at) AS maxEnd,
+ SUM(COALESCE(cpu_ms, duration_ms)) AS cpuSumMs,
+ COUNT(*) AS taskCount
+ FROM runs
+ WHERE run_id IS NOT NULL
+ GROUP BY run_id
+ HAVING taskCount > 0
+ ORDER BY MAX(started_at) DESC
+ LIMIT ?`,
+ )
+ .all(clampInt(limit, 1, 500)) as Array<{
+ runId: string
+ startedAt: number
+ minStart: number
+ maxEnd: number
+ cpuSumMs: number | null
+ taskCount: number
+ }>
+ return rows.map((r) => {
+ const wallMs = Math.max(1, r.maxEnd - r.minStart)
+ const cpuSumMs = r.cpuSumMs ?? 0
+ return {
+ runId: r.runId,
+ startedAt: r.startedAt,
+ cpuSumMs,
+ wallMs,
+ factor: cpuSumMs / wallMs,
+ taskCount: r.taskCount,
+ } satisfies ParallelismPoint
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Cache storage growth — bytes over time, with prunable hint
+// ---------------------------------------------------------------------------
+
+export interface StoragePoint {
+ /** Epoch ms at bucket start. */
+ t: number
+ /** Bytes added in the bucket. */
+ bytesAdded: number
+ entriesAdded: number
+}
+
+/**
+ * Daily storage growth from the entries table. NOTE: this reflects the rows
+ * still in entries (prune evicts; we can't reconstruct pruned bytes). For most
+ * workspaces it's the right "what's the cache doing" view.
+ */
+export function getStorageGrowth(db: Database, days = 30): StoragePoint[] {
+ const since = Date.now() - days * 24 * 60 * 60 * 1000
+ const bucketMs = 24 * 60 * 60 * 1000
+ const rows = db
+ .query(
+ `SELECT (created_at / ?) * ? AS t,
+ COALESCE(SUM(size_bytes), 0) AS bytesAdded,
+ COUNT(*) AS entriesAdded
+ FROM entries WHERE created_at >= ?
+ GROUP BY t ORDER BY t ASC`,
+ )
+ .all(bucketMs, bucketMs, since) as StoragePoint[]
+ const start = Math.floor(since / bucketMs) * bucketMs
+ const end = Math.floor(Date.now() / bucketMs) * bucketMs
+ const byT = new Map(rows.map((r) => [r.t, r]))
+ const out: StoragePoint[] = []
+ for (let t = start; t <= end; t += bucketMs) {
+ out.push(byT.get(t) ?? { t, bytesAdded: 0, entriesAdded: 0 })
+ }
+ return out
+}
+
+// ---------------------------------------------------------------------------
+// Stale / prunable entries — what to evict first
+// ---------------------------------------------------------------------------
+
+export interface PrunableEntry {
+ hash: string
+ project: string
+ task: string
+ sizeBytes: number
+ createdAt: number
+ accessedAt: number
+ /** Days since last access. */
+ ageDays: number
+}
+
+/** Entries unused for ≥ `minAgeDays`, ordered by size — best prune targets. */
+export function getPrunableEntries(db: Database, minAgeDays = 7, limit = 50): PrunableEntry[] {
+ const since = Date.now() - minAgeDays * 24 * 60 * 60 * 1000
+ const rows = db
+ .query(
+ `SELECT hash, project, task, size_bytes AS sizeBytes,
+ created_at AS createdAt, accessed_at AS accessedAt
+ FROM entries WHERE accessed_at <= ?
+ ORDER BY size_bytes DESC LIMIT ?`,
+ )
+ .all(since, clampInt(limit, 1, 500)) as Array<{
+ hash: string
+ project: string
+ task: string
+ sizeBytes: number
+ createdAt: number
+ accessedAt: number
+ }>
+ const now = Date.now()
+ return rows.map((r) => ({
+ ...r,
+ ageDays: Math.max(0, Math.floor((now - r.accessedAt) / (24 * 60 * 60 * 1000))),
+ }))
+}
diff --git a/tests/insights-queries.test.ts b/tests/metrics.test.ts
similarity index 70%
rename from tests/insights-queries.test.ts
rename to tests/metrics.test.ts
index b9bf5cc..48193e7 100644
--- a/tests/insights-queries.test.ts
+++ b/tests/metrics.test.ts
@@ -5,16 +5,24 @@ import { describe, expect, it } from 'bun:test'
import { Cache, type RunRecord } from '../src/cache/index.js'
import {
explainCacheKeyQuery,
+ getBottlenecks,
getCacheBreakdown,
getCacheSavings,
getCacheStatsSql,
+ getFlakiestTasks,
getHistory,
+ getParallelismHistory,
+ getPrunableEntries,
getRecentFailures,
getRun,
+ getRunHeatmap,
+ getRunTrends,
+ getStorageGrowth,
getTaskDetail,
getTopTimeBurners,
listCacheEntries,
listInvocations,
+ listProjects,
listRuns,
whyDidThisRerunQuery,
} from '../src/orchestrator/index.js'
@@ -42,7 +50,7 @@ function mkRun(
}
function withCache(fn: (cache: Cache) => void) {
- const dir = mkdtempSync(path.join(tmpdir(), 'vx-insights-q-'))
+ const dir = mkdtempSync(path.join(tmpdir(), 'vx-metrics-q-'))
const cache = new Cache(dir)
try {
fn(cache)
@@ -389,3 +397,145 @@ describe('getCacheSavings', () => {
})
})
})
+
+describe('listProjects', () => {
+ it('rolls per-project totals + cache entries', () => {
+ withCache((cache) => {
+ cache.recordRuns([
+ mkRun({ hash: 'h1', project: 'a', task: 'build', durationMs: 100 }),
+ mkRun({ hash: 'h2', project: 'a', task: 'test', durationMs: 50 }),
+ mkRun({ hash: 'h3', project: 'b', task: 'build', durationMs: 200 }),
+ ])
+ const rows = listProjects(cache.dbHandle())
+ expect(rows.length).toBe(2)
+ const a = rows.find((r) => r.project === 'a')!
+ expect(a.taskCount).toBe(2)
+ expect(a.totalDurationMs).toBe(150)
+ expect(a.runs).toBe(2)
+ })
+ })
+})
+
+describe('getRunTrends', () => {
+ it('returns a densified time series with hour buckets', () => {
+ withCache((cache) => {
+ const now = Date.now()
+ cache.recordRuns([
+ mkRun({ hash: 'h1', project: 'a', task: 'b', startedAt: now - 60_000 }),
+ mkRun({ hash: 'h2', project: 'a', task: 'b', startedAt: now - 60_000 }),
+ ])
+ const pts = getRunTrends(cache.dbHandle(), { bucket: 'hour' })
+ // 24h of hourly buckets ≈ 25 cells (start + end inclusive).
+ expect(pts.length).toBeGreaterThan(20)
+ const total = pts.reduce((acc, p) => acc + p.runs, 0)
+ expect(total).toBe(2)
+ })
+ })
+})
+
+describe('getRunHeatmap', () => {
+ it('emits a 7×24 grid (168 cells) and counts runs in the right cell', () => {
+ withCache((cache) => {
+ const now = Date.now()
+ cache.recordRun(mkRun({ hash: 'h1', project: 'a', task: 'b', startedAt: now }))
+ const cells = getRunHeatmap(cache.dbHandle())
+ expect(cells.length).toBe(168)
+ const total = cells.reduce((acc, c) => acc + c.runs, 0)
+ expect(total).toBe(1)
+ })
+ })
+})
+
+describe('getFlakiestTasks', () => {
+ it('surfaces tasks with mixed pass/fail or wide p99/p50', () => {
+ withCache((cache) => {
+ const now = Date.now()
+ cache.recordRuns([
+ mkRun({ hash: 'h1', project: 'a', task: 't', status: 'success', startedAt: now - 5000 }),
+ mkRun({
+ hash: 'h2',
+ project: 'a',
+ task: 't',
+ status: 'failed',
+ exitCode: 1,
+ startedAt: now - 4000,
+ }),
+ mkRun({ hash: 'h3', project: 'a', task: 't', status: 'success', startedAt: now - 3000 }),
+ mkRun({ hash: 'h4', project: 'a', task: 't', status: 'success', startedAt: now - 2000 }),
+ ])
+ const flaky = getFlakiestTasks(cache.dbHandle())
+ expect(flaky.length).toBeGreaterThan(0)
+ expect(flaky[0]!.id).toBe('a#t')
+ expect(flaky[0]!.failures).toBe(1)
+ })
+ })
+})
+
+describe('getBottlenecks', () => {
+ it('ranks by extrapolated weekly burn at 25% cut', () => {
+ withCache((cache) => {
+ const now = Date.now()
+ cache.recordRuns([
+ mkRun({ hash: 'h1', project: 'a', task: 'slow', durationMs: 1000, startedAt: now - 1000 }),
+ mkRun({ hash: 'h2', project: 'a', task: 'slow', durationMs: 1000, startedAt: now - 500 }),
+ mkRun({ hash: 'h3', project: 'a', task: 'fast', durationMs: 10, startedAt: now - 100 }),
+ ])
+ const b = getBottlenecks(cache.dbHandle())
+ expect(b[0]!.task).toBe('slow')
+ expect(b[0]!.weeklySavingsAt25PctCutMs).toBeGreaterThan(0)
+ })
+ })
+})
+
+describe('getParallelismHistory', () => {
+ it('computes cpuSum / wall per runId', () => {
+ withCache((cache) => {
+ cache.recordRuns([
+ mkRun({
+ hash: 'h1',
+ project: 'a',
+ task: 't1',
+ runId: 'r1',
+ startedAt: 1000,
+ endedAt: 1100,
+ durationMs: 100,
+ }),
+ mkRun({
+ hash: 'h2',
+ project: 'a',
+ task: 't2',
+ runId: 'r1',
+ startedAt: 1010,
+ endedAt: 1080,
+ durationMs: 70,
+ }),
+ ])
+ const pts = getParallelismHistory(cache.dbHandle())
+ expect(pts.length).toBe(1)
+ expect(pts[0]!.runId).toBe('r1')
+ // cpuSum (cpu_ms fallback to duration_ms via COALESCE in SQL) >= wall
+ expect(pts[0]!.factor).toBeGreaterThan(0)
+ })
+ })
+})
+
+describe('getStorageGrowth', () => {
+ it('returns a densified daily series', () => {
+ withCache((cache) => {
+ cache.recordRun(mkRun({ hash: 'h1', project: 'a', task: 'b' }))
+ const pts = getStorageGrowth(cache.dbHandle(), 7)
+ // 7 days of daily buckets ≈ 7–8 cells.
+ expect(pts.length).toBeGreaterThanOrEqual(7)
+ })
+ })
+})
+
+describe('getPrunableEntries', () => {
+ it('returns empty when nothing is older than the threshold', () => {
+ withCache((cache) => {
+ cache.recordRun(mkRun({ hash: 'h1', project: 'a', task: 'b' }))
+ const entries = getPrunableEntries(cache.dbHandle(), 365)
+ expect(entries).toEqual([])
+ })
+ })
+})
diff --git a/tests/module-boundaries.test.ts b/tests/module-boundaries.test.ts
index 2700ec1..54c2854 100644
--- a/tests/module-boundaries.test.ts
+++ b/tests/module-boundaries.test.ts
@@ -68,6 +68,7 @@ async function collectEdges(): Promise {
const spec = m[1]!
if (!spec.startsWith('.')) continue // bare imports = packages, not modules
if (spec.endsWith('.json')) continue // JSON is data (e.g. version.ts → package.json)
+ if (spec.endsWith('.html')) continue // embedded asset (cli/ui-asset.ts), not a module
const resolved = path
.normalize(path.join(path.dirname(norm), spec))
.split(path.sep)
diff --git a/tests/serve.test.ts b/tests/serve.test.ts
index e638d07..c3f3e28 100644
--- a/tests/serve.test.ts
+++ b/tests/serve.test.ts
@@ -85,7 +85,7 @@ describe('vx serve delegation', () => {
})
})
-describe('vx serve /v1/* insights API', () => {
+describe('vx serve /v1/* metrics API', () => {
it('serves runs / invocations / cache stats / history after a delegated run', async () => {
const root = await makeWorkspace()
const server = await startServe({ root })
@@ -195,52 +195,39 @@ describe('vx serve /v1/* insights API', () => {
})
})
-describe('vx serve --ui (bundled SPA)', () => {
- it('serves uiDir at / with the correct MIME and SPA fallback', async () => {
+describe('vx serve --ui (embedded single-file dashboard)', () => {
+ it('serves the embedded HTML for every non-API route', async () => {
const root = await makeWorkspace()
- const uiDir = await mkdtemp(path.join(tmpdir(), 'vx-ui-'))
- await writeFile(path.join(uiDir, 'index.html'), 'vx ')
- await mkdir(path.join(uiDir, 'assets'), { recursive: true })
- await writeFile(path.join(uiDir, 'assets', 'app.js'), 'console.log(1)')
+ const uiHtmlPath = path.join(await mkdtemp(path.join(tmpdir(), 'vx-ui-')), 'index.html')
+ await writeFile(uiHtmlPath, 'vx dashboard ')
- const server = await startServe({ root, uiDir })
+ const server = await startServe({ root, uiHtmlPath })
try {
- // Root → index.html with no-store cache control
- const root_ = await fetch(`${server.origin}/`)
- expect(root_.status).toBe(200)
- expect(root_.headers.get('content-type')).toContain('text/html')
- expect(root_.headers.get('cache-control')).toBe('no-store')
+ // Root → the embedded HTML, no-store so a binary upgrade isn't cached
+ const home = await fetch(`${server.origin}/`)
+ expect(home.status).toBe(200)
+ expect(home.headers.get('content-type')).toContain('text/html')
+ expect(home.headers.get('cache-control')).toBe('no-store')
+ expect(await home.text()).toContain('vx dashboard')
- // Hashed asset → immutable cache
- const asset = await fetch(`${server.origin}/assets/app.js`)
- expect(asset.status).toBe(200)
- expect(asset.headers.get('content-type')).toContain('javascript')
- expect(asset.headers.get('cache-control')).toContain('immutable')
-
- // SPA hash-router fallback: unknown path serves index.html
+ // SPA hash-router fallback: every unknown route serves the same HTML
const fallback = await fetch(`${server.origin}/tasks/pkg%23build`)
expect(fallback.status).toBe(200)
expect(fallback.headers.get('content-type')).toContain('text/html')
+ expect(await fallback.text()).toContain('vx dashboard')
- // /v1/* still wins over the static handler
+ // /v1/* still wins over the UI catch-all
const api = await fetch(`${server.origin}/v1/cache/stats`)
expect(api.status).toBe(200)
expect(api.headers.get('content-type')).toContain('json')
-
- // Path traversal containment
- const escape = await fetch(`${server.origin}/../../etc/passwd`)
- // The URL parser normalizes ../, but a direct attempt with encoded
- // dots is what we really want to test; the simpler containment check
- // already covers the resolved-path comparison.
- expect([200, 404]).toContain(escape.status)
} finally {
await server.stop()
await rm(root, { recursive: true, force: true })
- await rm(uiDir, { recursive: true, force: true })
+ await rm(path.dirname(uiHtmlPath), { recursive: true, force: true })
}
})
- it('does not serve any UI when uiDir is unset', async () => {
+ it('does not serve any UI when uiHtmlPath is unset', async () => {
const root = await makeWorkspace()
const server = await startServe({ root })
try {
diff --git a/vx.config.ts b/vx.config.ts
index 6678920..bfe6818 100644
--- a/vx.config.ts
+++ b/vx.config.ts
@@ -70,13 +70,41 @@ export default defineProject({
],
},
+ // Build the embedded dashboard (apps/ui → single-file dist/index.html).
+ // The compile step embeds it via `with { type: 'file' }`, so it must
+ // exist first. Uses workspaceFiles (boundary-free) because apps/ui is a
+ // separate project; this keeps the binary tasks' dependency same-project
+ // (no cross-project ref that scoped loading wouldn't pull into scope).
+ 'build.ui': {
+ description: 'build the embedded dashboard SPA (apps/ui)',
+ dependsOn: ['install'],
+ exec: { command: 'cd apps/ui && bun run build' },
+ cache: {
+ inputs: {
+ files: [],
+ workspaceFiles: [
+ 'apps/ui/src/**',
+ 'apps/ui/index.html',
+ 'apps/ui/package.json',
+ 'apps/ui/vite.config.ts',
+ 'apps/ui/uno.config.ts',
+ 'apps/ui/tsconfig.json',
+ ],
+ },
+ outputs: { files: [], workspaceFiles: ['apps/ui/dist/index.html'] },
+ },
+ },
+
// Cross-target standalone binaries. One task per (os, arch) so
// each gets its own cache slot. `dist/` is wiped before each
// build by output cleaning, so the binary on disk always matches
// the cached one.
'build.bun.linux-x64': {
description: 'compile standalone binary (linux x64)',
- dependsOn: ['install'],
+ // The dashboard SPA is embedded via `with { type: 'file' }`; build it
+ // first so apps/ui/dist/index.html exists when the compile resolves
+ // the import (and so a UI change cascades into the binary cache key).
+ dependsOn: ['install', 'build.ui'],
exec: {
command:
'bun build --compile --minify --bytecode --target=bun-linux-x64 src/bin.ts --outfile dist/vx-linux-x64',
@@ -88,7 +116,7 @@ export default defineProject({
},
'build.bun.linux-arm64': {
description: 'compile standalone binary (linux arm64)',
- dependsOn: ['install'],
+ dependsOn: ['install', 'build.ui'],
exec: {
command:
'bun build --compile --minify --bytecode --target=bun-linux-arm64 src/bin.ts --outfile dist/vx-linux-arm64',
@@ -100,7 +128,7 @@ export default defineProject({
},
'build.bun.darwin-x64': {
description: 'compile standalone binary (darwin x64)',
- dependsOn: ['install'],
+ dependsOn: ['install', 'build.ui'],
exec: {
command:
'bun build --compile --minify --bytecode --target=bun-darwin-x64 src/bin.ts --outfile dist/vx-darwin-x64',
@@ -112,7 +140,7 @@ export default defineProject({
},
'build.bun.darwin-arm64': {
description: 'compile standalone binary (darwin arm64)',
- dependsOn: ['install'],
+ dependsOn: ['install', 'build.ui'],
exec: {
command:
'bun build --compile --minify --bytecode --target=bun-darwin-arm64 src/bin.ts --outfile dist/vx-darwin-arm64',