Skip to content

Commit 9fe5af2

Browse files
authored
feat(benchmark): port base/benchmark report as an internal-only section (#59)
1 parent c424c53 commit 9fe5af2

64 files changed

Lines changed: 7548 additions & 18 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ NEXT_PUBLIC_VIBENET_RPC_URL=https://rpc.vibes.base.org
1515
#
1616
# Normally you don't set this by hand — the scripts do it:
1717
# npm run dev / npm run build -> external (default)
18-
# npm run dev:internal / npm run build:internal -> internal (TIPS visible)
18+
# npm run dev:internal / npm run build:internal -> internal (TIPS +
19+
# Benchmark visible)
1920
#
2021
# Set it here to change your local default (either value works), e.g. pin
2122
# `internal` so a plain `npm run dev` includes TIPS. The *:internal scripts still
@@ -39,3 +40,10 @@ NEXT_PUBLIC_VIBENET_RPC_URL=https://rpc.vibes.base.org
3940
# Per-chain block explorer for TIPS links (client-visible). Defaults:
4041
# mainnet=https://base.blockscout.com, sepolia=https://base-sepolia.blockscout.com.
4142
# NEXT_PUBLIC_TIPS_ZERONET_EXPLORER_URL=
43+
44+
# Benchmark (internal-only): base URL of the benchmark report API. The browser
45+
# calls it directly — there is no server-side proxy in this app — so it must be
46+
# reachable from wherever the internal deployment is viewed. Required for
47+
# /benchmark to load any data; the section throws a configuration error without
48+
# it. No credentials belong here: this value is inlined into the client bundle.
49+
# NEXT_PUBLIC_BENCHMARK_API_BASE_URL=

.github/workflows/ci.yml

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@ jobs:
125125
fail=0
126126
127127
# Internal-only routes must 404 on the public build.
128-
for route in /tips /tips/block/0x1 /tips/bundles/0x1 /api/tips/blocks; do
128+
for route in /tips /tips/block/0x1 /tips/bundles/0x1 /api/tips/blocks \
129+
/benchmark /benchmark/run/latest /benchmark/run-comparison/1 \
130+
/benchmark/load-tests/sepolia; do
129131
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:3000${route}")
130132
if [ "${code}" != "404" ]; then
131133
echo "FAIL: ${route} returned ${code}, expected 404"
@@ -143,14 +145,16 @@ jobs:
143145
done
144146
145147
# No nav link to, or sitemap entry for, an internal-only section.
146-
if curl -s http://localhost:3000/ | grep -q 'href="/tips"'; then
147-
echo "FAIL: public homepage links to /tips"
148-
fail=1
149-
fi
150-
if curl -s http://localhost:3000/sitemap.xml | grep -q '/tips'; then
151-
echo "FAIL: public sitemap lists /tips"
152-
fail=1
153-
fi
148+
for section in /tips /benchmark; do
149+
if curl -s http://localhost:3000/ | grep -q "href=\"${section}\""; then
150+
echo "FAIL: public homepage links to ${section}"
151+
fail=1
152+
fi
153+
if curl -s http://localhost:3000/sitemap.xml | grep -q "${section}"; then
154+
echo "FAIL: public sitemap lists ${section}"
155+
fail=1
156+
fi
157+
done
154158
155159
if [ "${fail}" -ne 0 ]; then exit 1; fi
156160
echo "OK: internal-only surfaces are absent from the public build"

AGENTS.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,10 @@ absent from the map ships everywhere — the map is an exception list, so **an
107107
internal-only page with no entry will fail open and publish**. A disabled surface
108108
is *unreachable* in that build (routes + API 404, dropped from nav/sitemap/llms);
109109
its client chunks may still be emitted, so treat this as a reachability
110-
guarantee, not secrecy. **TIPS** is internal-only today, and the
111-
`public-build-excludes-internal` CI job enforces its absence from the public
112-
build — extend that job's path list when you add another internal-only surface.
110+
guarantee, not secrecy. **TIPS** and **Benchmark** are internal-only today, and
111+
the `public-build-excludes-internal` CI job enforces their absence from the
112+
public build — extend that job's path list when you add another internal-only
113+
surface.
113114

114115
When you add or change an environment-specific section:
115116

@@ -119,6 +120,9 @@ When you add or change an environment-specific section:
119120
2. Gate the per-section surfaces that aren't automatic: the nav entry
120121
(`app/tips/flag.ts``app/navigation.ts`), a layout `notFound()` backstop,
121122
and the API routes (`app/api/tips/guard.ts` pattern). Use `surfaceEnabled(...)`.
123+
A section with no API routes of its own needs no guard and no `apiPrefixes`
124+
entry — Benchmark is the example: its browser code calls the report API
125+
directly via `NEXT_PUBLIC_BENCHMARK_API_BASE_URL`.
122126
3. Select the target only via the build/dev script, never a hand-set env var. The
123127
internal image sets it in its Dockerfile (`npm run build:internal`).
124128
4. Regenerate the agent index with the **external** (default) target so the

app/benchmark/benchmark.css

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Chart chrome ported from base/benchmark's report/src/index.css.
3+
*
4+
* Everything is scoped under `.benchmark-root` on purpose: the upstream file was
5+
* a standalone SPA's global stylesheet and claims very generic names (.grid,
6+
* .bar, .dot, .tooltip, .axis) that d3 sets via .attr("class", …). Unscoped they
7+
* would collide with the rest of omni-ui.
8+
*/
9+
10+
.benchmark-root {
11+
/* Upstream painted `body` slate-50 so the white cards read as cards. omni-ui's
12+
* content area is white, so bleed a slate-50 panel out to the edges of
13+
* AppShell's content padding. These offsets must stay in sync with
14+
* `styles.contentInner` in app/components/AppShell.tsx (padding 24px 28px 80px). */
15+
margin: -24px -28px -80px;
16+
padding: 24px 28px 80px;
17+
background: #f8fafc;
18+
color: #333;
19+
}
20+
21+
.benchmark-root .charts-container {
22+
display: grid;
23+
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
24+
gap: 20px;
25+
}
26+
27+
.benchmark-root .chart-container {
28+
border: 1px solid #ccc;
29+
border-radius: 8px;
30+
padding: 4px;
31+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
32+
background: white;
33+
}
34+
35+
.benchmark-root .chart-wrapper {
36+
width: 100%;
37+
height: 0;
38+
padding-bottom: 50%; /* Maintain aspect ratio */
39+
position: relative;
40+
}
41+
42+
.benchmark-root .chart-wrapper svg {
43+
position: absolute;
44+
top: 0;
45+
left: 0;
46+
width: 100%;
47+
height: 100%;
48+
}
49+
50+
.benchmark-root .tooltip {
51+
position: absolute;
52+
background-color: rgba(0, 0, 0, 0.7);
53+
color: #fff;
54+
padding: 5px 10px;
55+
border-radius: 4px;
56+
font-size: 12px;
57+
pointer-events: none;
58+
opacity: 0;
59+
transition: opacity 0.2s;
60+
}
61+
62+
.benchmark-root .axis text {
63+
font-size: 12px;
64+
fill: #666;
65+
}
66+
67+
.benchmark-root .axis path,
68+
.benchmark-root .axis line {
69+
stroke: #ddd;
70+
}
71+
72+
.benchmark-root .grid line {
73+
stroke: #eee;
74+
}
75+
76+
.benchmark-root .dot {
77+
transition: all 0.2s ease-in-out;
78+
}
79+
80+
.benchmark-root .dot:hover {
81+
cursor: pointer;
82+
}
83+
84+
.benchmark-root .bar {
85+
transition: all 0.2s ease-in-out;
86+
}
87+
88+
.benchmark-root .bar:hover {
89+
cursor: pointer;
90+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import React, { useRef, useEffect, useState } from "react";
2+
import * as d3 from "d3";
3+
import { MetricData } from "../types";
4+
5+
interface BaseChartProps {
6+
data: MetricData[];
7+
metricKey: string;
8+
title?: string;
9+
description?: string;
10+
children: (
11+
svg: d3.Selection<SVGGElement, unknown, null, undefined>,
12+
dimensions: {
13+
width: number;
14+
height: number;
15+
margin: { top: number; right: number; bottom: number; left: number };
16+
},
17+
) => void;
18+
}
19+
20+
const TOP_MARGIN = 20;
21+
const ASPECT_RATIO = 0.5;
22+
const LEGEND_SPACE = 20;
23+
const X_AXIS_SPACE = 60;
24+
const Y_AXIS_SPACE = 40;
25+
const TITLE_SPACE = 50;
26+
27+
const DEFAULT_MARGIN = {
28+
top: TOP_MARGIN + TITLE_SPACE,
29+
right: 40,
30+
bottom: Y_AXIS_SPACE + LEGEND_SPACE,
31+
left: X_AXIS_SPACE,
32+
};
33+
34+
const BaseChart: React.FC<BaseChartProps> = ({
35+
data,
36+
metricKey,
37+
title,
38+
description,
39+
children,
40+
}: BaseChartProps) => {
41+
const svgRef = useRef<SVGSVGElement>(null);
42+
const wrapperRef = useRef<HTMLDivElement>(null);
43+
const [dimensions, setDimensions] = useState<{
44+
width: number;
45+
height: number;
46+
margin: typeof DEFAULT_MARGIN;
47+
} | null>(null);
48+
49+
const initialDimensions = useRef(dimensions);
50+
initialDimensions.current = dimensions;
51+
52+
useEffect(() => {
53+
const updateDimensions = () => {
54+
if (wrapperRef.current) {
55+
const width = wrapperRef.current.offsetWidth;
56+
const height = width * ASPECT_RATIO;
57+
// Ensure minimum dimensions after margin calculation
58+
const calculatedWidth = Math.max(
59+
100,
60+
width - DEFAULT_MARGIN.left - DEFAULT_MARGIN.right,
61+
);
62+
const calculatedHeight = Math.max(
63+
50,
64+
height - DEFAULT_MARGIN.top - DEFAULT_MARGIN.bottom,
65+
);
66+
setDimensions({
67+
width: calculatedWidth,
68+
height: calculatedHeight,
69+
margin: DEFAULT_MARGIN,
70+
});
71+
}
72+
};
73+
74+
// Ensure initial dimensions are set even if ResizeObserver fires quickly
75+
if (!initialDimensions.current && wrapperRef.current?.offsetWidth) {
76+
updateDimensions();
77+
}
78+
79+
const resizeObserver = new ResizeObserver(updateDimensions);
80+
if (wrapperRef.current) {
81+
resizeObserver.observe(wrapperRef.current);
82+
}
83+
84+
return () => {
85+
resizeObserver.disconnect();
86+
};
87+
// Only depends on the existence of the wrapper ref
88+
}, []);
89+
90+
useEffect(() => {
91+
if (!svgRef.current || !dimensions) return;
92+
93+
const svgRoot = d3.select(svgRef.current);
94+
svgRoot.selectAll("*").remove(); // Clear previous render
95+
96+
const svg = svgRoot
97+
.attr(
98+
"width",
99+
dimensions.width + dimensions.margin.left + dimensions.margin.right,
100+
)
101+
.attr(
102+
"height",
103+
dimensions.height + dimensions.margin.top + dimensions.margin.bottom,
104+
)
105+
.append("g")
106+
.attr(
107+
"transform",
108+
`translate(${dimensions.margin.left},${dimensions.margin.top})`,
109+
);
110+
111+
if (title) {
112+
svg
113+
.append("text")
114+
.attr("x", dimensions.width / 2)
115+
.attr("y", -TOP_MARGIN - 20)
116+
.attr("text-anchor", "middle")
117+
.style("font-size", "16px")
118+
.style("font-weight", "bold")
119+
.text(title);
120+
}
121+
122+
if (description) {
123+
svg
124+
.append("text")
125+
.attr("x", dimensions.width / 2)
126+
.attr("y", -TOP_MARGIN)
127+
.attr("text-anchor", "middle")
128+
.style("font-size", "12px")
129+
.style("fill", "#666")
130+
.text(description);
131+
}
132+
133+
children(svg, dimensions);
134+
135+
// Re-render when data or dimensions change
136+
}, [data, metricKey, title, description, children, dimensions]);
137+
138+
return (
139+
<div className="chart-wrapper" ref={wrapperRef}>
140+
{/* Render SVG only when dimensions are known */}
141+
{dimensions && <svg ref={svgRef}></svg>}
142+
</div>
143+
);
144+
};
145+
146+
export default BaseChart;

0 commit comments

Comments
 (0)