diff --git a/.github/workflows/sync-dev-from-main.yml b/.github/workflows/sync-dev-from-main.yml deleted file mode 100644 index afce609b..00000000 --- a/.github/workflows/sync-dev-from-main.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Sync dev from main - -# Purpose -# ------- -# The workflow enforces the AGENTS.md rule "immediately fast-forward dev -# from main after a hotfix". Every hotfix that lands on main used to -# require a manual `git checkout dev && git merge --ff-only main && git -# push` step; that step was skipped often enough for dev to drift 100+ -# commits behind main, silently rotting the staging environment. -# -# On every push to main this job: -# 1. Tries a fast-forward of dev to main. If it succeeds, dev is now -# identical to main and the staging deploy fires as usual (its -# workflow triggers on push to dev). -# 2. If a fast-forward isn't possible (dev has commits main doesn't), -# the job attempts a real merge commit. Clean auto-merge = pushed -# straight to dev. -# 3. Only when git itself can't resolve the merge (real conflicts) do -# we fall back to opening a "sync/main-to-dev-" branch + a PR -# against dev. A human resolves those conflicts by merging the PR. -# -# The job is intentionally lenient: pushing to dev directly is normally -# forbidden (AGENTS.md), but this bot is the one exception because it -# only carries commits that main has already accepted. - -on: - push: - branches: - - main - workflow_dispatch: - -concurrency: - group: sync-dev-from-main - cancel-in-progress: false - -jobs: - sync: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout full history - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: dev - - - name: Configure bot identity - run: | - git config user.name "ocb-sync-bot" - git config user.email "ocb-sync-bot@users.noreply.github.com" - - - name: Try fast-forward dev → main - id: ff - run: | - set -e - git fetch origin main:refs/remotes/origin/main - if git merge-base --is-ancestor origin/main HEAD; then - echo "already-in-sync=true" >> "$GITHUB_OUTPUT" - echo "dev already contains origin/main; nothing to do." - exit 0 - fi - if git merge --ff-only origin/main; then - git push origin dev - echo "ff-succeeded=true" >> "$GITHUB_OUTPUT" - echo "dev fast-forwarded to $(git rev-parse --short HEAD)" - exit 0 - fi - echo "ff-failed=true" >> "$GITHUB_OUTPUT" - - - name: Attempt clean merge commit - if: steps.ff.outputs.ff-failed == 'true' - id: merge - run: | - set -e - if git merge --no-edit --no-ff origin/main -m "sync: main → dev (auto-merge $(git rev-parse --short origin/main))"; then - git push origin dev - echo "merge-pushed=true" >> "$GITHUB_OUTPUT" - echo "dev auto-merged main at $(git rev-parse --short HEAD)" - exit 0 - fi - git merge --abort - echo "merge-failed=true" >> "$GITHUB_OUTPUT" - - - name: Open sync PR on conflict - if: steps.merge.outputs.merge-failed == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -e - SHORT=$(git rev-parse --short origin/main) - BRANCH="sync/main-to-dev-$SHORT" - git checkout -B "$BRANCH" - # Force a merge commit that stops on conflicts. We DO NOT - # push conflict markers to dev; instead we push a branch that - # a human can pull, resolve locally, and merge into dev. - git reset --hard dev - git merge --no-edit --no-ff origin/main || true - git add -A - git commit -m "sync: main → dev (CONFLICTS — resolve before merge)" || true - git push -f origin "$BRANCH" - gh pr create \ - --repo "$GITHUB_REPOSITORY" \ - --base dev \ - --head "$BRANCH" \ - --title "sync: main → dev (auto, needs conflict resolution)" \ - --body "Automated by \`.github/workflows/sync-dev-from-main.yml\`. Fast-forward + auto-merge both failed; a human needs to resolve conflicts and merge this PR into \`dev\`. Base main SHA: $SHORT." \ - || echo "PR already exists for $BRANCH; leaving it alone." diff --git a/src/components/export-video-section.tsx b/src/components/export-video-section.tsx index a4018288..5d1d3a10 100644 --- a/src/components/export-video-section.tsx +++ b/src/components/export-video-section.tsx @@ -44,6 +44,8 @@ type Props = { slug: string; title: string; benchmark: Benchmark; + /** When provided, only these ranges appear in the selector (skips the probe). */ + availableRanges?: Set; }; /** @@ -58,16 +60,16 @@ type Props = { * Gated by NEXT_PUBLIC_EXPORT_VIDEO at the render entry point; if the * flag isn't on, this component renders nothing (no DOM, no bundle weight). */ -export function ExportVideoSection({ slug, title, benchmark }: Props) { +export function ExportVideoSection({ slug, title, benchmark, availableRanges }: Props) { // Hide on environments without the flag set. The flag is read at build // time via NEXT_PUBLIC_; flipping it requires a redeploy, which is the // exact rollback we want for a feature still in soak. if (!EXPORT_VIDEO_ENABLED) return null; - return ; + return ; } -function ExportVideoModal({ slug, title, benchmark }: Props) { +function ExportVideoModal({ slug, title, benchmark, availableRanges }: Props) { const [open, setOpen] = useState(false); // Lock body scroll + Esc to close. Same pattern share-section.tsx uses. @@ -103,6 +105,7 @@ function ExportVideoModal({ slug, title, benchmark }: Props) { slug={slug} title={title} benchmark={benchmark} + availableRanges={availableRanges} onClose={() => setOpen(false)} /> )} @@ -113,9 +116,14 @@ function ExportVideoModal({ slug, title, benchmark }: Props) { function ModalBody({ slug, benchmark, + availableRanges, onClose, }: Props & { onClose: () => void }) { const [range, setRange] = useState("30d"); + // null = still probing, Set = probed result. + const [probedAvail, setProbedAvail] = useState | null>( + availableRanges ? new Set(RANGE_IDS.filter((r) => availableRanges.has(r))) : null, + ); const [view, setView] = useState("BarChartRace"); // 12s default — long enough to land the trajectory, short enough to keep // the first-render wall-clock under 30-40s on the standard VPS. User can @@ -133,6 +141,45 @@ function ModalBody({ const [state, setState] = useState({ status: "idle" }); const [copied, setCopied] = useState(false); + // Probe each range when the modal opens without an availableRanges prop. + useEffect(() => { + if (availableRanges) return; // parent already told us which ranges exist + let cancelled = false; + Promise.allSettled( + RANGE_IDS.map((r) => + fetch(`/api/series/${encodeURIComponent(slug)}?range=${r}&raw=1`) + .then((res) => (res.ok ? res.json() : Promise.reject(res.status))) + .then((data: { providers: { slug: string; values: (number | null)[] }[] }) => + data.providers.length > 0 ? r : null, + ) + .catch(() => null), + ), + ).then((results) => { + if (cancelled) return; + const avail = new Set(); + for (const r of results) { + if (r.status === "fulfilled" && r.value) avail.add(r.value as RangeId); + } + setProbedAvail(avail); + // If current range has no data, switch to the longest that does. + setRange((prev) => { + if (avail.has(prev)) return prev; + const best = [...RANGE_IDS].reverse().find((r) => avail.has(r)); + return (best as RangeId | undefined) ?? "24h"; + }); + }); + return () => { cancelled = true; }; + // Run once on mount; slug is stable for the modal's lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Effective ranges: from prop, from probe, or all while probing. + const effectiveRanges: RangeId[] = availableRanges + ? RANGE_IDS.filter((r) => availableRanges.has(r)) + : probedAvail + ? RANGE_IDS.filter((r) => probedAvail.has(r)) + : RANGE_IDS; + // Metric-panel picker. Benches with companion metrics (e.g. hyperliquid-frontends // exposes Revenue / Volume / Users / Effective fee / Outage tabs) can race any // of those via the panel picker. `tab: false` panels are data-only (no series) @@ -444,18 +491,31 @@ function ModalBody({ {/* Range */}
- - {RANGE_IDS.map((id) => ( - setRange(id)} - disabled={isBusy} - > - {RANGE_LABEL[id]} - - ))} - + {!availableRanges && probedAvail === null ? ( +
+ {RANGE_IDS.map((id) => ( +
+ {RANGE_LABEL[id]} +
+ ))} +
+ ) : ( + + {effectiveRanges.map((id) => ( + setRange(id)} + disabled={isBusy} + > + {RANGE_LABEL[id]} + + ))} + + )}
{/* View */} diff --git a/src/components/time-series-chart/index.tsx b/src/components/time-series-chart/index.tsx index c53d59ca..ad3f9888 100644 --- a/src/components/time-series-chart/index.tsx +++ b/src/components/time-series-chart/index.tsx @@ -90,6 +90,9 @@ type Props = { /** Tooltip surfaced on disabled long-range pills (e.g. "Archive * temporarily unavailable"). */ longRangeDisabledTitle?: string; + /** Called whenever the set of ranges with confirmed data changes. + * Useful for syncing the video exporter's range picker. */ + onAvailableRangesChange?: (ranges: Set) => void; }; export function TimeSeriesChart({ @@ -114,6 +117,7 @@ export function TimeSeriesChart({ longRangeSeries, longRangeDisabled, longRangeDisabledTitle, + onAvailableRangesChange, }: Props) { const [rangeLocal, setRangeLocal] = useState("24h"); const range = rangeProp ?? rangeLocal; @@ -155,6 +159,11 @@ export function TimeSeriesChart({ // most one fan-out per (bench, range) per minute. const [lazySeries7d, setLazySeries7d] = useState | null>(null); const [lazySeries30d, setLazySeries30d] = useState | null>(null); + // Probed on demand after 30d confirms data. null = not yet probed, {} = probed+empty. + const [lazySeries90d, setLazySeries90d] = useState | null>(null); + const [lazySeries1y, setLazySeries1y] = useState | null>(null); + // Declared early so effects below can guard on it without TDZ issues. + const panelActive = !!seriesOverride; // Per-panel lazy series, keyed by panel id. Cached across a panel // switch so flipping back to a previously-viewed panel is instant. // 7d/30d: fetched on panel activation. 90d/1y: fetched on-demand when @@ -222,6 +231,61 @@ export function TimeSeriesChart({ }; }, [regionProp, chainProp, benchmark.slug]); + // Probe 90d after 30d confirms data. Uses a slug ref so a benchmark change + // resets the probe even if lazySeries30d stays non-null briefly. + const probed90dSlugRef = useRef(null); + useEffect(() => { + if (panelActive) return; + if (lazySeries30d === null || Object.keys(lazySeries30d).length === 0) return; + if (lazySeries90d !== null) return; + if (probed90dSlugRef.current === benchmark.slug) return; + probed90dSlugRef.current = benchmark.slug; + let cancelled = false; + const qs = new URLSearchParams({ range: "90d", raw: "1" }); + if (regionProp && regionProp !== "all") qs.set("region", regionProp); + if (chainProp && chainProp !== "all") qs.set("chain", chainProp); + fetch(`/api/series/${benchmark.slug}?${qs.toString()}`) + .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) + .then((data: { providers: { slug: string; values: (number | null)[] }[] }) => { + if (cancelled) return; + const map: Record = {}; + for (const p of data.providers) map[p.slug] = p.values; + setLazySeries90d(map); + }) + .catch(() => { + if (cancelled) return; + setLazySeries90d({}); + }); + return () => { cancelled = true; }; + }, [panelActive, lazySeries30d, lazySeries90d, benchmark.slug, regionProp, chainProp]); + + // Probe 1y after 90d confirms data. + const probed1ySlugRef = useRef(null); + useEffect(() => { + if (panelActive) return; + if (lazySeries90d === null || Object.keys(lazySeries90d).length === 0) return; + if (lazySeries1y !== null) return; + if (probed1ySlugRef.current === benchmark.slug) return; + probed1ySlugRef.current = benchmark.slug; + let cancelled = false; + const qs = new URLSearchParams({ range: "1y", raw: "1" }); + if (regionProp && regionProp !== "all") qs.set("region", regionProp); + if (chainProp && chainProp !== "all") qs.set("chain", chainProp); + fetch(`/api/series/${benchmark.slug}?${qs.toString()}`) + .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) + .then((data: { providers: { slug: string; values: (number | null)[] }[] }) => { + if (cancelled) return; + const map: Record = {}; + for (const p of data.providers) map[p.slug] = p.values; + setLazySeries1y(map); + }) + .catch(() => { + if (cancelled) return; + setLazySeries1y({}); + }); + return () => { cancelled = true; }; + }, [panelActive, lazySeries90d, lazySeries1y, benchmark.slug, regionProp, chainProp]); + // Panel-scoped lazy fetch. Runs when the active panel id changes and the // parent did NOT ship the panel's own 7d/30d series inline (the common // case since slimBenchmarkForCache drops them). Cache-keyed per panel so @@ -324,19 +388,46 @@ export function TimeSeriesChart({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [activePanelId, regionProp, chainProp, benchmark.slug]); - const panelActive = !!seriesOverride; const panelLazy7d = activePanelId ? lazyPanel7d[activePanelId] : undefined; const panelLazy30d = activePanelId ? lazyPanel30d[activePanelId] : undefined; const panelLazy90d = activePanelId ? lazyPanel90d[activePanelId] : undefined; const panelLazy1y = activePanelId ? lazyPanel1y[activePanelId] : undefined; - const has7d = - !panelActive || - !!seriesOverride7d || - (!!activePanelId && panelLazy7d !== undefined); - const has30d = - !panelActive || - !!seriesOverride30d || - (!!activePanelId && panelLazy30d !== undefined); + + // null = still loading → show optimistically; {} = confirmed empty → hide + const seriesHasData = (m: Record | null) => + m === null || Object.keys(m).length > 0; + + const has7d = panelActive + ? (!!seriesOverride7d || (!!activePanelId && panelLazy7d !== undefined)) + : seriesHasData(lazySeries7d); + const has30d = panelActive + ? (!!seriesOverride30d || (!!activePanelId && panelLazy30d !== undefined)) + : seriesHasData(lazySeries30d); + const has90d = !panelActive && lazySeries90d !== null && Object.keys(lazySeries90d).length > 0; + const has1y = !panelActive && lazySeries1y !== null && Object.keys(lazySeries1y).length > 0; + + // When confirmed-empty data comes back, fall back to the nearest longer available range. + useEffect(() => { + if (panelActive) return; + if (range === "7d" && lazySeries7d !== null && !seriesHasData(lazySeries7d)) setRange("24h"); + if (range === "30d" && lazySeries30d !== null && !seriesHasData(lazySeries30d)) setRange("24h"); + if (range === "90d" && lazySeries90d !== null && Object.keys(lazySeries90d).length === 0) + setRange(has30d ? "30d" : "24h"); + if (range === "1y" && lazySeries1y !== null && Object.keys(lazySeries1y).length === 0) + setRange(has90d ? "90d" : "24h"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [range, panelActive, lazySeries7d, lazySeries30d, lazySeries90d, lazySeries1y]); + + // Notify parent whenever the set of available ranges changes. + useEffect(() => { + if (!onAvailableRangesChange || panelActive) return; + const available = new Set(["1h", "6h", "24h"]); + if (has7d) available.add("7d"); + if (has30d) available.add("30d"); + if (has90d) available.add("90d"); + if (has1y) available.add("1y"); + onAvailableRangesChange(available); + }, [onAvailableRangesChange, panelActive, has7d, has30d, has90d, has1y]); const availableRegions = useMemo(() => { const set = new Set(); @@ -402,6 +493,11 @@ export function TimeSeriesChart({ // Pick from the lazy map when in those ranges, fall back to // pickSeries (which uses benchmark.extras.series24h) otherwise. const pickBenchValues = (slug: string): (number | null)[] => { + // Probed ranges take priority over the archive-backed longRangeSeries path. + if (range === "90d" && lazySeries90d && Object.keys(lazySeries90d).length > 0) + return lazySeries90d[slug] ?? []; + if (range === "1y" && lazySeries1y && Object.keys(lazySeries1y).length > 0) + return lazySeries1y[slug] ?? []; if (isLongRange) { return longRangeSeries?.[range]?.[slug] ?? []; } @@ -433,7 +529,7 @@ export function TimeSeriesChart({ return higherIsBetter ? bv - av : av - bv; }); return built; - }, [benchmark, range, region, colors, excluded, seriesOverride, seriesOverride7d, seriesOverride30d, higherIsBetterOverride, lazySeries7d, lazySeries30d, isLongRange, longRangeSeries, panelLazy90d, panelLazy1y]); + }, [benchmark, range, region, colors, excluded, seriesOverride, seriesOverride7d, seriesOverride30d, higherIsBetterOverride, lazySeries7d, lazySeries30d, lazySeries90d, lazySeries1y, isLongRange, longRangeSeries, panelLazy90d, panelLazy1y]); // Top-N selector — sized off the post-filter line count via the // shared `useTopN` hook so the option set agrees across every @@ -511,6 +607,9 @@ export function TimeSeriesChart({
{RANGES.map((r) => { + // Hide entirely once we have confirmed there's no data for this range. + if (r === "7d" && lazySeries7d !== null && !has7d) return null; + if (r === "30d" && lazySeries30d !== null && !has30d) return null; const active = r === range; const disabled = (r === "7d" && !has7d) || (r === "30d" && !has30d); @@ -537,6 +636,41 @@ export function TimeSeriesChart({ ); })} + {(has90d || has1y) && ( + <> + + {has90d && ( + + )} + {has1y && ( + + )} + + )} {longRangeSeries && ( <> {/* Hairline separator between live (Prom) and archive