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
11 changes: 10 additions & 1 deletion app/src/hooks/useBenchmark.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { rpc } from '../rpc/bootstrap';

export const useBenchmarkSnapshots = (workspaceId: string) =>
useQuery({ queryKey: ['bench', workspaceId], queryFn: () => rpc.bench.listSnapshots(workspaceId) });

export function useBenchmarkCapture(workspaceId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => rpc.bench.capture(workspaceId, name),
onSuccess: () => qc.invalidateQueries({ queryKey: ['bench', workspaceId] })
});
}

export const useBenchmarkCompare = (workspaceId: string) =>
useMutation({ mutationFn: ({ runId, baselineId }: { runId: string; baselineId: string }) => rpc.bench.compare(workspaceId, runId, baselineId) });
203 changes: 138 additions & 65 deletions app/src/panels/A16-benchmark/BenchmarkPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react';
import { ArrowDown, ArrowUp, GitCompareArrows, Minus } from 'lucide-react';
import { ArrowDown, ArrowUp, Camera, GitCompareArrows, Minus } from 'lucide-react';
import { Panel } from '../../components/Panel';
import { useBenchmarkCompare, useBenchmarkSnapshots } from '../../hooks/useBenchmark';
import { useBenchmarkCapture, useBenchmarkCompare, useBenchmarkSnapshots } from '../../hooks/useBenchmark';
import { usePontoonStore } from '../../state/store';
import {
Badge,
Button,
EmptyState,
Input,
SectionLabel,
StatCard,
Table,
Expand All @@ -18,7 +19,7 @@ import {
} from '../../components/ui';
import { cn } from '../../lib/cn';

type Snapshot = { id: string; path: string; createdAt: number };
type Snapshot = { id: string; name: string; path: string; createdAt: number };
type Metric = {
name: string;
current: string;
Expand All @@ -40,6 +41,7 @@ export function BenchmarkPanel() {
const workspaceId = usePontoonStore((s) => s.activeWorkspaceId);
const query = useBenchmarkSnapshots(workspaceId);
const compare = useBenchmarkCompare(workspaceId);
const capture = useBenchmarkCapture(workspaceId);

return (
<Panel query={query}>
Expand All @@ -50,6 +52,9 @@ export function BenchmarkPanel() {
comparing={compare.isPending}
metrics={compare.data?.metrics}
error={compare.error}
onCapture={(name) => capture.mutate(name)}
capturing={capture.isPending}
captureError={capture.error}
/>
)}
</Panel>
Expand All @@ -62,9 +67,21 @@ interface BenchmarkBodyProps {
comparing: boolean;
metrics?: Metric[];
error: unknown;
onCapture: (name: string) => void;
capturing: boolean;
captureError: unknown;
}

function BenchmarkBody({ snapshots, onCompare, comparing, metrics, error }: BenchmarkBodyProps) {
function BenchmarkBody({
snapshots,
onCompare,
comparing,
metrics,
error,
onCapture,
capturing,
captureError
}: BenchmarkBodyProps) {
const [runId, setRunId] = useState('');
const [baselineId, setBaselineId] = useState('');

Expand All @@ -78,73 +95,129 @@ function BenchmarkBody({ snapshots, onCompare, comparing, metrics, error }: Benc
const regressions = metrics?.filter((m) => m.regression).length ?? 0;
const canCompare = Boolean(runId && baselineId && runId !== baselineId);

if (snapshots.length === 0) {
return (
<EmptyState
icon={GitCompareArrows}
title="No benchmark snapshots."
description="Attach a Blueprint project with recorded benchmark runs, then compare any run against a baseline here."
return (
<div className="grid gap-4">
<CaptureBar
onCapture={onCapture}
capturing={capturing}
error={captureError}
count={snapshots.length}
/>
);
}

{snapshots.length === 0 ? (
<EmptyState
icon={GitCompareArrows}
title="No benchmark runs yet."
description="Run a scenario and capture it as a baseline, then run another scenario and capture again — each capture covers only the transactions executed since the one before."
/>
) : (
<>
<div className="grid gap-2 rounded-lg border border-border bg-card/50 p-3 sm:grid-cols-[1fr_auto_1fr_auto]">
<Picker label="Run" snapshots={snapshots} value={runId} onChange={setRunId} />
<div className="hidden items-end pb-2 text-muted-foreground sm:flex">
<GitCompareArrows className="size-4" />
</div>
<Picker label="Baseline" snapshots={snapshots} value={baselineId} onChange={setBaselineId} />
<div className="flex items-end">
<Button
onClick={() => onCompare(runId, baselineId)}
disabled={!canCompare || comparing}
className="w-full gap-1.5 sm:w-auto"
>
<GitCompareArrows />
{comparing ? 'Comparing…' : 'Compare'}
</Button>
</div>
</div>

{error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-2.5 text-sm text-destructive" role="alert">
{error instanceof Error ? error.message : String(error)}
</div>
) : metrics ? (
metrics.length === 0 ? (
<EmptyBox label="No metrics returned for this comparison." />
) : (
<div className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatCard label="Metrics" value={metrics.length} hint="Compared data points." />
<StatCard
label="Regressions"
value={
<span className="flex items-center gap-2">
{regressions}
{regressions > 0 ? (
<Badge variant="destructive">attention</Badge>
) : (
<Badge variant="success">clean</Badge>
)}
</span>
}
hint="Metrics flagged worse than baseline."
/>
<StatCard
label="Improvements"
value={metrics.filter((m) => m.deltaPct < 0 && !m.regression).length}
hint="Metrics moved in a good direction."
/>
</div>
<MetricsTable metrics={metrics} />
</div>
)
) : (
<EmptyBox label="Pick a run and baseline, then compare to see deltas." />
)}
</>
)}
</div>
);
}

function CaptureBar({
onCapture,
capturing,
error,
count
}: {
onCapture: (name: string) => void;
capturing: boolean;
error: unknown;
count: number;
}) {
const [name, setName] = useState('');
const submit = () => {
onCapture(name.trim() || `Run ${count + 1}`);
setName('');
};

return (
<div className="grid gap-4">
<div className="grid gap-2 rounded-lg border border-border bg-card/50 p-3 sm:grid-cols-[1fr_auto_1fr_auto]">
<Picker label="Run" snapshots={snapshots} value={runId} onChange={setRunId} />
<div className="hidden items-end pb-2 text-muted-foreground sm:flex">
<GitCompareArrows className="size-4" />
</div>
<Picker label="Baseline" snapshots={snapshots} value={baselineId} onChange={setBaselineId} />
<div className="flex items-end">
<Button
onClick={() => onCompare(runId, baselineId)}
disabled={!canCompare || comparing}
className="w-full gap-1.5 sm:w-auto"
>
<GitCompareArrows />
{comparing ? 'Comparing…' : 'Compare'}
</Button>
</div>
<div className="grid gap-2 rounded-lg border border-border bg-card/50 p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<label className="grid flex-1 gap-1 text-xs text-muted-foreground">
<SectionLabel className="text-inherit">Capture run</SectionLabel>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !capturing) submit();
}}
placeholder={`Run ${count + 1}`}
aria-label="Benchmark run name"
/>
</label>
<Button onClick={submit} disabled={capturing} className="gap-1.5 sm:w-auto">
<Camera />
{capturing ? 'Capturing…' : 'Capture run'}
</Button>
</div>

<p className="text-xs text-muted-foreground">
Aggregates total fees, compute gas, and message-tree metrics from the transactions executed since the previous capture.
</p>
{error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-2.5 text-sm text-destructive" role="alert">
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive" role="alert">
{error instanceof Error ? error.message : String(error)}
</div>
) : metrics ? (
metrics.length === 0 ? (
<EmptyBox label="No metrics returned for this comparison." />
) : (
<div className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatCard label="Metrics" value={metrics.length} hint="Compared data points." />
<StatCard
label="Regressions"
value={
<span className="flex items-center gap-2">
{regressions}
{regressions > 0 ? (
<Badge variant="destructive">attention</Badge>
) : (
<Badge variant="success">clean</Badge>
)}
</span>
}
hint="Metrics flagged worse than baseline."
/>
<StatCard
label="Improvements"
value={metrics.filter((m) => m.deltaPct < 0 && !m.regression).length}
hint="Metrics moved in a good direction."
/>
</div>
<MetricsTable metrics={metrics} />
</div>
)
) : (
<EmptyBox label="Pick a run and baseline, then compare to see deltas." />
)}
) : null}
</div>
);
}
Expand All @@ -171,7 +244,7 @@ function Picker({
>
{snapshots.map((s) => (
<option key={s.id} value={s.id}>
{s.path}
{s.name === s.id ? s.path : `${s.name} — ${s.path}`}
</option>
))}
</select>
Expand Down
1 change: 1 addition & 0 deletions app/src/rpc/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export class RpcClient {
merge: (workspaceId: string, runs: string[]) => this.call(METHODS.coverage.merge, { workspaceId, runs })
};
bench = {
capture: (workspaceId: string, name: string) => this.call(METHODS.bench.capture, { workspaceId, name }),
listSnapshots: (workspaceId: string, dir?: string) => this.call(METHODS.bench.listSnapshots, { workspaceId, dir }),
compare: (workspaceId: string, runId: string, baselineId: string) => this.call(METHODS.bench.compare, { workspaceId, runId, baselineId })
};
Expand Down
4 changes: 3 additions & 1 deletion app/src/rpc/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const METHODS = {
merge: 'coverage.merge'
},
bench: {
capture: 'bench.capture',
listSnapshots: 'bench.listSnapshots',
compare: 'bench.compare'
},
Expand Down Expand Up @@ -321,7 +322,8 @@ export type MethodMap = {
'coverage.enable': { req: { workspaceId: string }; res: { ok: boolean } };
'coverage.get': { req: { workspaceId: string; contract: string }; res: { source: 'func' | 'tact'; sourcePath: string; lines: { line: number; hits: number; opcodes?: number }[] } };
'coverage.merge': { req: { workspaceId: string; runs: string[] }; res: { source: 'func' | 'tact'; sourcePath: string; lines: { line: number; hits: number; opcodes?: number }[] } };
'bench.listSnapshots': { req: { workspaceId: string; dir?: string }; res: { snapshots: { id: string; path: string; createdAt: number }[] } };
'bench.capture': { req: { workspaceId: string; name: string }; res: { id: string; path: string; createdAt: number } };
'bench.listSnapshots': { req: { workspaceId: string; dir?: string }; res: { snapshots: { id: string; name: string; path: string; createdAt: number }[] } };
'bench.compare': { req: { workspaceId: string; runId: string; baselineId: string }; res: { metrics: { name: string; current: string; baseline: string; deltaPct: number; regression: boolean }[] } };
'step.begin': { req: { workspaceId: string; message: SerializedMessage }; res: { iterId: string; done: boolean } };
'step.next': { req: { iterId: string }; res: { node: TxNode | null; done: boolean } };
Expand Down
Loading