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 - - - { - 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()} - - } - > -
{ - e.preventDefault() - commit() - }} - class="flex items-center gap-1" - > - setDraft(e.currentTarget.value)} - placeholder="http://localhost:4321" - class="text-xs font-mono px-2 py-1 rounded border border-border-muted bg-bg w-60" - autofocus - /> - - -
-
-
-
-
{props.children}
- -
- ) -} 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 -

- -
- setFilter(e.currentTarget.value)} - class="text-xs font-mono px-2 py-1 rounded border border-border-muted bg-bg w-60" - /> -
- - - - - - - - - - - - - - {(e) => ( - - navigate(`/tasks/${encodeURIComponent(`${e.project}#${e.task}`)}`) - } - > - - - - - - - - )} - - -
TaskHashSizeDurationCreatedAccessed
- {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={}> - - - - - - - - - - - - - - {(r) => ( - navigate(`/runs/${r.runId}`)} - > - - - - - - - - )} - - -
RunStartedDurationTasksFailedCache hits
{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 ( -
-
-

- {props.title} -

- - - {props.linkLabel} - - -
- {props.children} -
- ) -} - -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 ( -
-
- - ← back - -

Run {params.id.slice(0, 12)}

-
- -
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 -
- -
- - - - - - - - - - - - {(t) => ( - - - - - - - )} - - -
TaskStatusDurationCache
- {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 ( -
-
- - ← all tasks - -

{params.id}

-
- -
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}) -

-
- - - - - - - - - - - - - - {(r) => ( - - - - - - - - - )} - - -
WhenStatusDurationCPUPeak RSSHash
- {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…
-
- -
- - - - - - - - - - - - - - - - - {(r) => ( - navigate(`/tasks/${encodeURIComponent(r.id)}`)} - > - - - - - - - - - - - )} - - -
- Task - - Runs - - Success - - Hit - - Avg - - p50 - - p99 - - Total time - - Last run -
- - {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 ( + + ) + }} + +
+ )} +
+
+
+ + 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 */} + + + {/* 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?:\/\//, '')} + + } + > +
{ + e.preventDefault() + commit() + }} + class="flex items-center gap-1" + > + setDraft(e.currentTarget.value)} + placeholder="http://localhost:4321" + class="text-[12px] font-mono w-60" + autofocus + /> + + +
+
+
+ +
{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={}> + + + + + + + + + + + + + {(b, i) => { + const max = Math.max(...(bottlenecks() ?? []).map((x) => x.weeklySavingsAt25PctCutMs)) + return ( + navigate(`/tasks/${encodeURIComponent(b.id)}`)} + > + + + + + + + ) + }} + + +
TaskRuns / dayAvgTotal burnWeekly savings
+ {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. 🎉
}> + + + + + + + + + + + {(f) => ( + navigate(`/tasks/${encodeURIComponent(f.id)}`)} + > + + + + + )} + + +
TaskFail %p99/p50
+ {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.
}> + + + + + + + + + + + {(e) => ( + + + + + + )} + + +
TaskSizeLast hit
+ {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

+ +
+ setFilter(e.currentTarget.value)} + class="text-[11px] font-mono w-60" + /> +
+ 0} fallback={}> + + + + + + + + + + + + + + {(e) => ( + navigate(`/tasks/${encodeURIComponent(`${e.project}#${e.task}`)}`)} + > + + + + + + + + )} + + +
TaskHashSizeDurationCreatedAccessed
+ {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) => ( + + )} + +
+
+
+ + all tasks} noPad> + No failures. 🎉
}> +
+ + {(f) => ( + + )} + +
+ + +
+ +
+ 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 ( + + ) + }} + +
+
+
+
+ + + }> + + + + + + + + + + + + + + {(r) => ( + navigate(`/runs/${r.runId}`)} + > + + + + + + + + )} + + +
RunStartedDurationTasksFailedHits
{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 ( +
+
+ ← projects + +

{params.name}

+
+ + }> +
+ + + + 0.5 ? 'good' : 'default'} /> + +
+
+ + + 0} fallback={}> + + + + + + + + + + + + + + + + {(t) => ( + navigate(`/tasks/${encodeURIComponent(t.id)}`)} + > + + + + + + + + + + )} + + +
TaskRunsSuccessHitAvgp99TotalLast
+ {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={}> + + + + + + + + + + + + + + + + {(p) => ( + navigate(`/projects/${encodeURIComponent(p.project)}`)} + > + + + + + + + + + + )} + + +
ProjectRunsFailuresHit %Total timeSavedCacheLast run
+
+ + {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) => ( + navigate(`/tasks/${encodeURIComponent(`${task.project}#${task.task}`)}`)} + > + + + + + + + + )} + + +
TaskStatusDurationCPUPeak RSSCache
+ {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 ( +
+
+ ← tasks +

{params.id}

+
+ + +
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 */} + + + + + + + + + + + + + + + {(r) => ( + + + + + + + + + )} + + +
WhenStatusDurationCPUPeak RSSHash
{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={}> + + + + + + + + + + + + + + + + + {(r) => ( + navigate(`/tasks/${encodeURIComponent(r.id)}`)} + > + + + + + + + + + + + )} + + +
TaskRunsSuccessHitAvgp50p99Total timeLast
+
+ + + {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="[^"]*${chunk.fileName}"[^>]*>`) + 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',