Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 15 additions & 0 deletions apps/ui/dist/index.html

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion apps/ui/src/components/charts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <rect>; callers must pass `fill-…` classes.
colorClass: r.colorClass ?? 'fill-chart-1',
idx: r.idx,
})
cursor += len
Expand Down
2 changes: 1 addition & 1 deletion apps/ui/src/pages/CachePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function CachePage() {
<Card title="Storage by project">
<Show when={breakdown()?.some((p) => p.totalBytes > 0)} fallback={<EmptyState title="No cached output yet" />}>
<Treemap
data={(breakdown() ?? []).map((p) => ({ 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}
/>
Expand Down
59 changes: 33 additions & 26 deletions apps/ui/src/pages/Overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 &gt;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<Array<{ id: number; kind: string; label: string; t: number }>>([])
Expand All @@ -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 (
<div class="flex flex-col gap-5">
<Show when={stats() && savings()}>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<MetricCard
label="Time saved (24h)"
value={formatDuration(savings()!.estimatedTimeSavedMs)}
sub={`${savings()!.hitsLast24h} cache hits`}
tone={savings()!.estimatedTimeSavedMs > 0 ? 'good' : 'default'}
label="Time saved"
value={formatDuration(savings()!.estimatedTimeSavedTotalMs)}
sub={`${totalHits()} cache hits`}
tone={savings()!.estimatedTimeSavedTotalMs > 0 ? 'good' : 'default'}
/>
<MetricCard
label="Hit rate (24h)"
value={formatPercent(stats()!.hitRate24h, 0)}
sub={`${stats()!.hitCountLast24h} / ${stats()!.runCountLast24h} runs`}
tone={stats()!.hitRate24h > 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'}
/>
<MetricCard
label="Failures (24h)"
value={String(last24hFails())}
sub={last24hRuns() > 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'}
/>
<MetricCard
label="Cache footprint"
Expand All @@ -84,20 +91,20 @@ export function Overview() {
</Show>

<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4">
<Card title="Activity — last 24h" action={<span class="text-[10px] text-fg-3 font-mono">runs · failures</span>}>
<Show when={trend24h()?.points.length} fallback={<EmptyState title="No runs in the last 24h" cmd="vx run <task>" />}>
<Card title="Activity — last 30 days" action={<span class="text-[10px] text-fg-3 font-mono">runs · failures</span>}>
<Show when={last30dRuns() > 0} fallback={<EmptyState title="No runs in the last 30 days" cmd="vx run <task>" />}>
<LineChart
xs={trendXs()}
series={[
{ name: 'runs', strokeClass: 'stroke-accent', areaClass: 'fill-accent/10', data: trendRuns() },
{ name: 'failures', strokeClass: 'stroke-danger', data: trend24h()?.points.map((p) => 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}
/>
<div class="text-[11px] text-fg-3 mt-2 font-mono">
{last24hRuns()} runs · {formatDuration(trendDur().reduce((a, b) => a + b, 0))} total · {last24hHits()} hits
{last30dRuns()} runs · {formatDuration(last30dDur())} total · {last30dHits()} hits
</div>
</Show>
</Card>
Expand Down Expand Up @@ -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}
Expand Down
12 changes: 7 additions & 5 deletions apps/ui/src/pages/ProjectDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,31 @@ 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)
},
)

const summary = createMemo<ProjectRollup | undefined>(() =>
(projects() ?? []).find((p) => p.project === params.name),
(projects() ?? []).find((p) => p.project === projectName()),
)
const maxTotal = createMemo(() => Math.max(1, ...(tasks() ?? []).map((t) => t.totalDurationMs)))

return (
<div class="flex flex-col gap-5">
<div class="flex items-center gap-3">
<A href="/projects" class="text-fg-3 hover:text-fg no-underline text-[11px] font-mono">← projects</A>
<span class={`inline-block w-2 h-2 rounded-full bg-${paletteFor(params.name)}`} />
<h1 class="text-base font-semibold m-0 font-mono">{params.name}</h1>
<span class={`inline-block w-2 h-2 rounded-full bg-${paletteFor(projectName())}`} />
<h1 class="text-base font-semibold m-0 font-mono">{projectName()}</h1>
</div>

<Show when={summary()} fallback={<EmptyState title="No data for this project" />}>
Expand Down
8 changes: 5 additions & 3 deletions apps/ui/src/pages/TaskDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

Expand All @@ -23,7 +25,7 @@ export function TaskDetail() {
<div class="flex flex-col gap-5">
<div class="flex items-center gap-3">
<A href="/tasks" class="text-fg-3 hover:text-fg no-underline text-[11px] font-mono">← tasks</A>
<h1 class="text-base font-semibold m-0 font-mono">{params.id}</h1>
<h1 class="text-base font-semibold m-0 font-mono">{taskId()}</h1>
</div>

<Show when={detail.loading}>
Expand All @@ -33,7 +35,7 @@ export function TaskDetail() {
<div class="text-danger font-mono text-sm">Failed to load: {String(detail.error)}</div>
</Show>
<Show when={detail() === null}>
<EmptyState title="No data for this task" cmd={`vx run ${params.id}`} />
<EmptyState title="No data for this task" cmd={`vx run ${taskId()}`} />
</Show>

<Show when={detail() !== undefined && detail() !== null}>
Expand Down
6 changes: 3 additions & 3 deletions apps/ui/src/pages/Trends.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,16 @@ export function Trends() {
</div>
<div class="mt-4">
<LineChart
xs={[...((parallel() ?? []).map((p, i) => 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}
/>
Expand Down
13 changes: 13 additions & 0 deletions apps/ui/uno.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/orchestrator/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &lt; 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,
Expand All @@ -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 ?`,
)
Expand Down
Loading