diff --git a/.gitignore b/.gitignore index 2fd7e69..3f296c0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ node_modules dist +# apps/ui/dist is the prebuilt single-file dashboard the binary embeds via +# `with { type: 'file' }`. Committing it means `bun build --compile` works +# straight out of a fresh checkout — no SPA build step required to produce +# the binary. +!apps/ui/dist/ *.tsbuildinfo coverage .DS_Store diff --git a/apps/ui/dist/index.html b/apps/ui/dist/index.html new file mode 100644 index 0000000..18b3738 --- /dev/null +++ b/apps/ui/dist/index.html @@ -0,0 +1,15 @@ + + + + + + + vx dashboard + + + + +
+ + diff --git a/apps/ui/src/components/charts.tsx b/apps/ui/src/components/charts.tsx index 0a42048..4b6da5a 100644 --- a/apps/ui/src/components/charts.tsx +++ b/apps/ui/src/components/charts.tsx @@ -308,7 +308,8 @@ export function Treemap(props: { h: horizontal ? len : strip, label: r.label, value: r.value, - colorClass: r.colorClass ?? 'bg-chart-1', + // Treemap renders SVG ; callers must pass `fill-…` classes. + colorClass: r.colorClass ?? 'fill-chart-1', idx: r.idx, }) cursor += len diff --git a/apps/ui/src/pages/CachePage.tsx b/apps/ui/src/pages/CachePage.tsx index 6d38d53..330f96f 100644 --- a/apps/ui/src/pages/CachePage.tsx +++ b/apps/ui/src/pages/CachePage.tsx @@ -53,7 +53,7 @@ export function CachePage() { p.totalBytes > 0)} fallback={}> ({ label: p.project, value: p.totalBytes, colorClass: `bg-${paletteFor(p.project)}` }))} + data={(breakdown() ?? []).map((p) => ({ label: p.project, value: p.totalBytes, colorClass: `fill-${paletteFor(p.project)}` }))} format={(v) => formatBytes(v)} height={240} /> diff --git a/apps/ui/src/pages/Overview.tsx b/apps/ui/src/pages/Overview.tsx index ad1063b..656792b 100644 --- a/apps/ui/src/pages/Overview.tsx +++ b/apps/ui/src/pages/Overview.tsx @@ -13,7 +13,7 @@ import { } 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' +import { formatBytes, formatCount, formatDate, formatDuration, formatHour, formatPercent, formatRelativeTime, paletteFor } from '../format.ts' export function Overview() { const origin = getOriginSignal() @@ -25,7 +25,9 @@ export function Overview() { 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' })) + // 30-day day-bucketed series → real signal even on workspaces whose last + // run was >24h ago. A 24h hour-bucket chart goes blank too easily. + const [trend30d] = createResource(origin, () => getRunTrends({ bucket: 'day' })) // Live event ticker — newest first, keep last 12. const [live, setLive] = createSignal>([]) @@ -45,35 +47,40 @@ export function Overview() { 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) + // Lifetime totals — always have signal, unlike 24h windows. + const totalRuns = createMemo(() => (projects() ?? []).reduce((a, p) => a + p.runs, 0)) + const totalHits = createMemo(() => (projects() ?? []).reduce((a, p) => a + p.hits, 0)) + const totalFails = createMemo(() => (projects() ?? []).reduce((a, p) => a + p.failures, 0)) + const lifetimeHitRate = createMemo(() => (totalRuns() > 0 ? totalHits() / totalRuns() : 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) ?? [] + const last30dRuns = createMemo(() => trend30d()?.points.reduce((a, p) => a + p.runs, 0) ?? 0) + const last30dDur = createMemo(() => trend30d()?.points.reduce((a, p) => a + p.totalDurationMs, 0) ?? 0) + const last30dHits = createMemo(() => trend30d()?.points.reduce((a, p) => a + p.hits, 0) ?? 0) + + const trendXs = () => trend30d()?.points.map((p) => p.t) ?? [] + const trendRuns = () => trend30d()?.points.map((p) => p.runs) ?? [] return (
0 ? 'good' : 'default'} + label="Time saved" + value={formatDuration(savings()!.estimatedTimeSavedTotalMs)} + sub={`${totalHits()} cache hits`} + tone={savings()!.estimatedTimeSavedTotalMs > 0 ? 'good' : 'default'} /> 0.5 ? 'good' : stats()!.hitRate24h < 0.2 && stats()!.runCountLast24h > 5 ? 'warn' : 'default'} + label="Hit rate" + value={formatPercent(lifetimeHitRate(), 0)} + sub={`${totalHits()} / ${totalRuns()} runs`} + tone={lifetimeHitRate() > 0.5 ? 'good' : lifetimeHitRate() < 0.2 && totalRuns() > 5 ? 'warn' : 'default'} /> 0 ? `${formatPercent(last24hFails() / last24hRuns(), 0)} of runs` : 'no runs yet'} - tone={last24hFails() > 0 ? 'bad' : 'good'} + label="Total runs" + value={String(totalRuns())} + sub={totalFails() > 0 ? `${totalFails()} failed (${formatPercent(totalFails() / Math.max(1, totalRuns()), 0)})` : 'no failures'} + tone={totalFails() > 0 ? 'bad' : 'good'} />
- runs · failures}> - }> + runs · failures}> + 0} fallback={}> p.failures) ?? [] }, + { name: 'failures', strokeClass: 'stroke-danger', data: trend30d()?.points.map((p) => p.failures) ?? [] }, ]} - formatX={(t) => formatHour(t)} + formatX={(t) => formatDate(t)} formatY={(v) => formatCount(v)} height={180} />
- {last24hRuns()} runs · {formatDuration(trendDur().reduce((a, b) => a + b, 0))} total · {last24hHits()} hits + {last30dRuns()} runs · {formatDuration(last30dDur())} total · {last30dHits()} hits
@@ -171,7 +178,7 @@ export function Overview() { data={(projects() ?? []).filter((p) => p.cacheBytes > 0).map((p) => ({ label: p.project, value: p.cacheBytes, - colorClass: `bg-${paletteFor(p.project)}`, + colorClass: `fill-${paletteFor(p.project)}`, }))} format={(v) => formatBytes(v)} height={240} diff --git a/apps/ui/src/pages/ProjectDetail.tsx b/apps/ui/src/pages/ProjectDetail.tsx index 13c2014..26f7fb4 100644 --- a/apps/ui/src/pages/ProjectDetail.tsx +++ b/apps/ui/src/pages/ProjectDetail.tsx @@ -7,12 +7,14 @@ import { formatBytes, formatDuration, formatPercent, formatRelativeTime, palette export function ProjectDetail() { const params = useParams<{ name: string }>() + // @solidjs/router gives the raw URL segment; decode for display + API use. + const projectName = () => decodeURIComponent(params.name) const origin = getOriginSignal() const navigate = useNavigate() - const [projects] = createResource(() => ({ name: params.name, o: origin() }), async () => listProjects(500)) + const [projects] = createResource(() => ({ name: projectName(), o: origin() }), async () => listProjects(500)) const [tasks] = createResource( - () => ({ name: params.name, o: origin() }), + () => ({ name: projectName(), o: origin() }), async (args) => { const all = await getHistory(500) return all.filter((t: TaskHistoryRow) => t.project === args.name) @@ -20,7 +22,7 @@ export function ProjectDetail() { ) const summary = createMemo(() => - (projects() ?? []).find((p) => p.project === params.name), + (projects() ?? []).find((p) => p.project === projectName()), ) const maxTotal = createMemo(() => Math.max(1, ...(tasks() ?? []).map((t) => t.totalDurationMs))) @@ -28,8 +30,8 @@ export function ProjectDetail() {
← projects - -

{params.name}

+ +

{projectName()}

}> diff --git a/apps/ui/src/pages/TaskDetail.tsx b/apps/ui/src/pages/TaskDetail.tsx index 88227c5..9a9378d 100644 --- a/apps/ui/src/pages/TaskDetail.tsx +++ b/apps/ui/src/pages/TaskDetail.tsx @@ -7,9 +7,11 @@ import { formatBytes, formatCount, formatDuration, formatPercent, formatRelative export function TaskDetail() { const params = useParams<{ id: string }>() + // @solidjs/router gives the raw URL segment; decode for display + API use. + const taskId = () => decodeURIComponent(params.id) const origin = getOriginSignal() const [detail] = createResource( - () => ({ id: params.id, o: origin() }), + () => ({ id: taskId(), o: origin() }), (args) => getTaskDetail(args.id), ) @@ -23,7 +25,7 @@ export function TaskDetail() {
← tasks -

{params.id}

+

{taskId()}

@@ -33,7 +35,7 @@ export function TaskDetail() {
Failed to load: {String(detail.error)}
- + diff --git a/apps/ui/src/pages/Trends.tsx b/apps/ui/src/pages/Trends.tsx index 3d06786..3cdc58e 100644 --- a/apps/ui/src/pages/Trends.tsx +++ b/apps/ui/src/pages/Trends.tsx @@ -99,16 +99,16 @@ export function Trends() {
i))].reverse()} + xs={[...(parallel() ?? [])].reverse().map((p) => p.startedAt)} series={[ { name: 'parallelism', strokeClass: 'stroke-chart-3', areaClass: 'fill-chart-3/10', - data: [...(parallel() ?? []).map((p) => p.factor)].reverse(), + data: [...(parallel() ?? [])].reverse().map((p) => p.factor), }, ]} - formatX={(x) => `#${x}`} + formatX={(t) => formatDate(t)} formatY={(v) => v.toFixed(1) + '×'} height={140} /> diff --git a/apps/ui/uno.config.ts b/apps/ui/uno.config.ts index d91f451..96e87b8 100644 --- a/apps/ui/uno.config.ts +++ b/apps/ui/uno.config.ts @@ -3,6 +3,19 @@ import { defineConfig, presetIcons, presetUno, transformerVariantGroup } from 'u export default defineConfig({ presets: [presetUno(), presetIcons({ scale: 1.0 })], transformers: [transformerVariantGroup()], + // Chart palette classes are computed from project names at runtime via + // `paletteFor()` — UnoCSS's static analyzer can't see them, so we list + // them explicitly. + safelist: [ + ...['1', '2', '3', '4', '5', '6', '7', '8'].flatMap((n) => [ + `bg-chart-${n}`, + `text-chart-${n}`, + `stroke-chart-${n}`, + `fill-chart-${n}`, + `fill-chart-${n}/10`, + `border-chart-${n}`, + ]), + ], theme: { colors: { // Surfaces diff --git a/src/orchestrator/metrics.ts b/src/orchestrator/metrics.ts index d242eb2..313d0f1 100644 --- a/src/orchestrator/metrics.ts +++ b/src/orchestrator/metrics.ts @@ -901,6 +901,9 @@ export interface ParallelismPoint { /** Per-invocation parallelism, recent first. */ export function getParallelismHistory(db: Database, limit = 50): ParallelismPoint[] { + // Filter out trivially-short invocations (wall < 50 ms): the cpu/wall + // ratio is dominated by measurement noise there and produces 0.5×/2× + // junk that pollutes the chart's average. const rows = db .query( `SELECT run_id AS runId, @@ -912,7 +915,7 @@ export function getParallelismHistory(db: Database, limit = 50): ParallelismPoin FROM runs WHERE run_id IS NOT NULL GROUP BY run_id - HAVING taskCount > 0 + HAVING taskCount > 1 AND (MAX(ended_at) - MIN(started_at)) >= 50 ORDER BY MAX(started_at) DESC LIMIT ?`, )