Skip to content
Merged
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
126 changes: 116 additions & 10 deletions .github/workflows/web-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,22 @@
# The Vercel project's Root Directory must be set to `web/` (Phase-4.2 console step);
# the CLI runs from the repo root and uses the pulled project settings to build web/.
#
# Trigger: production deploy on push to `develop` (this repo's main branch after the
# decouple merge); `workflow_dispatch` for manual prod/preview runs during setup. No
# pull_request/feature-branch trigger — the project + secrets are provisioned in 4.2
# and a feature-branch preview flow is out of scope here. End-to-end deploy is
# validated in Phase 4.2 once creds exist; 4.1 only authors + actionlint-validates.
# Triggers: production deploy on push to `develop`; a PREVIEW deploy on every
# pull_request that touches `web/` (or this workflow), which posts the preview URL
# back to the PR as a sticky comment; and `workflow_dispatch` for manual prod/preview
# runs. Fork PRs are skipped (they have no access to VERCEL_TOKEN). The CLI builds on
# the runner and deploys --prebuilt, so this repo never races the monorepo's project.

name: Web Deploy

on:
push:
branches: [develop]
pull_request:
branches: [develop]
paths:
- 'web/**'
- '.github/workflows/web-deploy.yml'
workflow_dispatch:
inputs:
environment:
Expand All @@ -38,6 +43,11 @@ concurrency:

permissions:
contents: read
pull-requests: write
# Create the GitHub Deployment + statuses that render the native "View
# deployment" button and the in-progress spinner on the PR (Vercel's git
# integration is off here, so the workflow posts these itself).
deployments: write

env:
VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
Expand All @@ -51,10 +61,48 @@ jobs:
name: Build & deploy to Vercel
runs-on: ubuntu-latest
timeout-minutes: 20
# Fork PRs have no access to VERCEL_TOKEN, and we do not auto-deploy untrusted
# code; same-repo PRs, pushes, and manual dispatches run normally.
if: >-
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository
steps:
- name: Checkout
uses: actions/checkout@v4

# Open a GitHub Deployment against the PR head commit BEFORE the build, so
# the PR shows an in-progress (spinner) status immediately and the later
# success/failure step flips it to the "View deployment" button. PR-only;
# `transient_environment` lets GitHub retire superseded previews. A failure
# to open the deployment must not fail the build, so the `id` is optional
# and every downstream step guards on it being non-empty.
- name: Start preview deployment
id: deployment
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -Eeuo pipefail
id="$(gh api "repos/${GITHUB_REPOSITORY}/deployments" --method POST --jq '.id' --input - <<EOF || true
{
"ref": "${HEAD_SHA}",
"environment": "preview",
"required_contexts": [],
"auto_merge": false,
"transient_environment": true,
"production_environment": false,
"description": "Vercel preview"
}
EOF
)"
echo "id=${id}" >> "$GITHUB_OUTPUT"
if [ -n "${id}" ]; then
gh api "repos/${GITHUB_REPOSITORY}/deployments/${id}/statuses" --method POST --input - >/dev/null <<EOF || true
{ "state": "in_progress", "description": "Building preview…" }
EOF
fi

# `vercel build` below builds ON THE RUNNER (not Vercel's infra), so it shells out
# to pnpm to install web/'s deps and run `next build`. Without pnpm + Node on PATH it
# fails with `spawn pnpm ENOENT`. Mirror web-ci.yml's setup.
Expand All @@ -78,16 +126,18 @@ jobs:

- name: Resolve target environment
id: target
# push to develop => production; manual dispatch uses the input.
# Expression context is passed through env: rather than interpolated into the
# shell body, so a value can never be parsed as shell (injection-safe).
# push to develop => production; pull_request => preview; manual dispatch =>
# the input. Expression context is passed through env: rather than interpolated
# into the shell body, so a value can never be parsed as shell (injection-safe).
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_ENV: ${{ inputs.environment }}
run: |
set -Eeuo pipefail
if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
echo "env=${INPUT_ENV}" >> "$GITHUB_OUTPUT"
elif [ "${EVENT_NAME}" = "pull_request" ]; then
echo "env=preview" >> "$GITHUB_OUTPUT"
else
echo "env=production" >> "$GITHUB_OUTPUT"
fi
Expand All @@ -105,10 +155,66 @@ jobs:
fi

- name: Deploy (prebuilt)
id: deploy
run: |
set -Eeuo pipefail
if [ "${{ steps.target.outputs.env }}" = "production" ]; then
vercel deploy --prebuilt --prod --token="${{ secrets.VERCEL_TOKEN }}"
url="$(vercel deploy --prebuilt --prod --token="${{ secrets.VERCEL_TOKEN }}" | tail -n1)"
else
vercel deploy --prebuilt --token="${{ secrets.VERCEL_TOKEN }}"
url="$(vercel deploy --prebuilt --token="${{ secrets.VERCEL_TOKEN }}" | tail -n1)"
fi
echo "url=${url}" >> "$GITHUB_OUTPUT"
printf 'Deployed: %s\n' "$url"

# On a pull_request, post the preview URL back to the PR as a single sticky
# comment (edited in place on each push) so the preview is one click from the PR.
- name: Comment preview URL on the PR
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
COMMIT_SHA: ${{ github.sha }}
run: |
set -Eeuo pipefail
gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" \
--body "Vercel preview for \`${COMMIT_SHA}\`: ${DEPLOY_URL}" \
--edit-last --create-if-none

# Flip the deployment to success and attach the preview URL, which is what
# turns the in-progress status into the clickable "View deployment" button.
- name: Mark preview deployment ready
if: ${{ success() && github.event_name == 'pull_request' && steps.deployment.outputs.id != '' }}
env:
GH_TOKEN: ${{ github.token }}
DEPLOY_ID: ${{ steps.deployment.outputs.id }}
DEPLOY_URL: ${{ steps.deploy.outputs.url }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -Eeuo pipefail
gh api "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOY_ID}/statuses" --method POST --input - >/dev/null <<EOF
{
"state": "success",
"environment_url": "${DEPLOY_URL}",
"log_url": "${RUN_URL}",
"description": "Preview ready"
}
EOF

# If the build or deploy failed, mark the deployment failed so the PR shows
# a red status instead of a spinner that never resolves.
- name: Mark preview deployment failed
if: ${{ failure() && github.event_name == 'pull_request' && steps.deployment.outputs.id != '' }}
env:
GH_TOKEN: ${{ github.token }}
DEPLOY_ID: ${{ steps.deployment.outputs.id }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -Eeuo pipefail
gh api "repos/${GITHUB_REPOSITORY}/deployments/${DEPLOY_ID}/statuses" --method POST --input - >/dev/null <<EOF
{
"state": "failure",
"log_url": "${RUN_URL}",
"description": "Preview deploy failed"
}
EOF
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist
.scratch/
/target
.vercel
.env*
78 changes: 40 additions & 38 deletions web/components/Chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,10 @@ import type {
} from 'chart.js';

import {
assignStableColors,
CHART_FETCH_N,
clampRangeWindow,
collectAllValues,
colorFor,
commitDateLabel,
decimateSeries,
DEFAULT_VISIBLE,
escapeHtml,
Expand All @@ -31,7 +30,6 @@ import {
HOVER_PREFETCH_PRIORITY,
IDENTITY_UNIT,
INTERACTION_FULL_PRIORITY,
labelForCommit,
LAZY_HYDRATION_ROOT_MARGIN,
MAX_VISIBLE_POINTS,
normalizeChartPayload,
Expand All @@ -40,8 +38,11 @@ import {
pickDisplayUnit,
predecessorValue,
rangeTouchesUnloadedHistory,
displaySeriesLabel,
seriesOrder,
seriesPassesFilter,
seriesPassesGroupFilter,
seriesStyle,
shortDate,
shortSha,
throttle,
Expand Down Expand Up @@ -117,10 +118,6 @@ interface CardState {
/** Series labels the user has explicitly legend-toggled on this card. Once
* set, the global/group filters no longer drive that label here. */
overrides: Record<string, true>;
/** Stable per-series colors by label. Series are colored by first-seen order
* (see [`assignStableColors`]) so the `?n=all` upgrade surfacing older series
* never reshuffles the colors of series already on the chart. */
seriesColors: Map<string, string>;
displayUnit: DisplayUnit;
/** v3 also tracked `__bench_inline_trimmed`; it was write-only there and is
* dropped here. `payload.history.complete` carries the same information. */
Expand Down Expand Up @@ -175,6 +172,11 @@ interface CardCallbacks {
setConstructed: (on: boolean) => void;
}

/** Subtle, theme-neutral grid line color (slate-gray at low opacity), matching
* the v2 frontend's faint gridlines a reviewer preferred over the heavier
* Chart.js default. */
const GRID_COLOR = 'rgba(148, 163, 184, 0.14)';

// ---------------------------------------------------------------------------
// Crosshair plugin: draws a vertical line at the chart's active hover index.
// An inline plugin is cheaper than chartjs-plugin-crosshair, which is overkill
Expand Down Expand Up @@ -220,22 +222,19 @@ function benchDatasets(chart: ChartJs): BenchDataset[] {
* visible range. `rawData` holds a reference to the original payload so the
* tooltip can show raw values regardless of LTTB.
*/
function buildDatasets(
payload: NormalizedChartPayload,
colors: ReadonlyMap<string, string>,
): BenchDataset[] {
function buildDatasets(payload: NormalizedChartPayload): BenchDataset[] {
const raw = payload.series ?? {};
const meta = payload.series_meta ?? {};
const n = payload.commits.length;
const global = getGlobalFilterSnapshot();
return Object.keys(raw)
.sort()
.map((name, i) => {
.map((name) => {
const seriesMeta = meta[name] ?? {};
const rawValues = Array.isArray(raw[name]) ? raw[name] : [];
// Stable color by label (see `assignStableColors`); `colorFor(i)` is only a
// fallback for a label the caller did not pre-register in `colors`.
const color = colors.get(name) ?? colorFor(i);
// Tier-based style: the named Vortex/Parquet series get vivid, thicker
// lines; everything else is muted and thin (see `seriesStyle`).
const style = seriesStyle(name, seriesMeta);
// `data` starts null-padded; `rebuildVisibleAndUpdate` fills the current
// visible window with raw or LTTB-kept values. With `spanGaps: true` the
// line connects across nulls, so a series with partial coverage still
Expand All @@ -245,9 +244,12 @@ function buildDatasets(
label: name,
data,
rawData: rawValues,
borderColor: color,
backgroundColor: `${color}20`,
borderWidth: 1.5,
borderColor: style.color,
backgroundColor: `${style.color}20`,
borderWidth: style.width,
// Layer the hero series on top: Vortex in front ... arrow at the back,
// datafusion ahead of duckdb. Lower `order` renders on top in Chart.js.
order: seriesOrder(name, seriesMeta),
spanGaps: true,
tension: 0,
pointRadius: 2,
Expand Down Expand Up @@ -334,7 +336,7 @@ function externalTooltipHandler(state: CardState, host: HTMLDivElement | null) {
(r) =>
`<div class="tt-row">` +
`<span class="tt-swatch" style="background:${r.color}"></span>` +
`<span class="tt-label">${escapeHtml(r.label)}</span>` +
`<span class="tt-label">${escapeHtml(displaySeriesLabel(r.label))}</span>` +
`<span class="tt-value">${escapeHtml(formatDisplayValue(r.raw, displayUnit))}</span>` +
r.deltaHtml +
`</div>`,
Expand Down Expand Up @@ -415,7 +417,6 @@ class ChartController {
payload: null,
ui: { y: 'linear', scope: DEFAULT_VISIBLE },
overrides: {},
seriesColors: new Map(),
displayUnit: IDENTITY_UNIT,
fullLoaded: false,
initialFetchEntry: null,
Expand Down Expand Up @@ -789,20 +790,6 @@ class ChartController {
return state.fullFetchPending;
}

/**
* Fold this payload's series labels into the chart's stable color map
* (first-seen order) and return the map for [`buildDatasets`]. Run on every
* (re)build so the `?n=all` upgrade keeps every already-seen series on its
* original color and only assigns fresh palette slots to newly surfaced series.
*/
private colorsFor(payload: NormalizedChartPayload): ReadonlyMap<string, string> {
this.state.seriesColors = assignStableColors(
this.state.seriesColors,
Object.keys(payload.series ?? {}),
);
return this.state.seriesColors;
}

/**
* Construct the Chart.js instance when the payload is present and the
* enclosing group (if any) is open. Loads Chart.js lazily on first need.
Expand Down Expand Up @@ -855,8 +842,8 @@ class ChartController {
// recomputed only when `replaceChartPayload` swaps in the wider window.
state.displayUnit = pickDisplayUnit(payload.unit_kind, collectAllValues(payload));

const labels = payload.commits.map(labelForCommit);
const datasets = buildDatasets(payload, this.colorsFor(payload));
const labels = payload.commits.map(commitDateLabel);
const datasets = buildDatasets(payload);
const range = visibleRange(labels.length, state.ui.scope);
const legendPosition = window.matchMedia?.('(max-width: 768px)').matches ? 'top' : 'bottom';

Expand Down Expand Up @@ -918,6 +905,7 @@ class ChartController {
y: {
type: state.ui.y === 'log' ? 'logarithmic' : 'linear',
beginAtZero: state.ui.y !== 'log',
grid: { color: GRID_COLOR },
// The axis title reflects the locked display unit; empty for
// dimensionless kinds so a "1.2x speedup" chart does not get an
// arbitrary label.
Expand All @@ -929,6 +917,7 @@ class ChartController {
x: {
min: range.min,
max: range.max,
grid: { color: GRID_COLOR },
title: { display: false },
// One tick per commit is unreadable on a 5000-commit history;
// Chart.js picks a sensible subset.
Expand All @@ -938,6 +927,19 @@ class ChartController {
plugins: {
legend: {
position: legendPosition,
// Rewrite only the displayed legend text (vortex-file-compressed ->
// vortex); `dataset.label` stays the real key the toggle/filter use.
labels: {
generateLabels: (ci) => {
const items = Chart.defaults.plugins.legend.labels.generateLabels(ci);
for (const item of items) {
if (typeof item.text === 'string') {
item.text = displaySeriesLabel(item.text);
}
}
return items;
},
},
// Wrap the default toggle to record the per-card override and
// keep `dataset.hidden` in sync with the legend's visibility
// flag; the filter passes write to `dataset.hidden`, so they
Expand Down Expand Up @@ -1122,8 +1124,8 @@ class ChartController {
yAxis.title.display = state.displayUnit.axisLabel !== '';
yAxis.title.text = state.displayUnit.axisLabel;
}
const newLabels = payload.commits.map(labelForCommit);
const newDatasets = buildDatasets(payload, this.colorsFor(payload));
const newLabels = payload.commits.map(commitDateLabel);
const newDatasets = buildDatasets(payload);
// Honour any explicit legend toggles the user had made already.
for (const ds of newDatasets) {
if (ds.label && state.overrides[ds.label]) {
Expand Down
4 changes: 2 additions & 2 deletions web/components/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { useEffect, useRef, useState, useSyncExternalStore } from 'react';

import { seedActiveFromAllowlist, type FilterUniverse } from '@/lib/chart-format';
import { displayFormat, seedActiveFromAllowlist, type FilterUniverse } from '@/lib/chart-format';
import {
getGlobalFilterSnapshot,
initGlobalFilter,
Expand Down Expand Up @@ -189,7 +189,7 @@ function FilterRow({
aria-pressed={isActive}
onClick={() => onChipClick(dim, value)}
>
{value}
{displayFormat(value)}
</button>
);
})}
Expand Down
Loading
Loading