From 50f8ff764f0331b70b1845490c181097549af8dc Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 22 Jun 2026 21:49:14 +0100 Subject: [PATCH 1/9] feat(webapp): improve and unify task landing page activity charts Improve the activity charts on the agent, standard, and scheduled task landing pages: - Bucket density adapts to the selected range so short ranges render many fine-grained bars instead of collapsing to a single 1h bar. - X-axis labels are chosen from the available width: evenly spaced across the plot, first + last always shown, kept horizontal, deduped, and they reflow on resize. - Synced vertical hover indicator across the agent page's charts via a reusable ChartSyncProvider. - 'Maximize' button + fullscreen dialog on each chart card (shared ChartCard), matching the dashboard widgets. - Abbreviated y-axis values (8000 -> 8K) by default on the compound charts. Also de-duplicates the previously copy-pasted status colors, time-axis formatters, and bucket/zero-fill logic into shared helpers. --- .../task-landing-activity-charts.md | 14 ++ .../components/primitives/charts/ChartBar.tsx | 74 +++++++- .../primitives/charts/ChartCard.tsx | 95 ++++++++++ .../primitives/charts/ChartLine.tsx | 6 +- .../primitives/charts/ChartSyncContext.tsx | 38 ++++ .../primitives/charts/activityTimeAxis.ts | 74 ++++++++ .../primitives/charts/statusColors.ts | 27 +++ .../primitives/charts/useXAxisTicks.ts | 103 +++++++++++ .../primitives/charts/useYAxisWidth.ts | 9 + .../v3/AgentDetailPresenter.server.ts | 147 +++------------ .../v3/TaskDetailPresenter.server.ts | 86 ++------- .../presenters/v3/activitySeries.server.ts | 172 ++++++++++++++++++ .../route.tsx | 106 ++--------- .../route.tsx | 95 ++-------- .../route.tsx | 101 ++-------- .../webapp/test/activitySeries.server.test.ts | 132 ++++++++++++++ .../webapp/test/chartActivityTimeAxis.test.ts | 58 ++++++ apps/webapp/test/chartXAxisTicks.test.ts | 95 ++++++++++ 18 files changed, 984 insertions(+), 448 deletions(-) create mode 100644 .server-changes/task-landing-activity-charts.md create mode 100644 apps/webapp/app/components/primitives/charts/ChartCard.tsx create mode 100644 apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx create mode 100644 apps/webapp/app/components/primitives/charts/activityTimeAxis.ts create mode 100644 apps/webapp/app/components/primitives/charts/statusColors.ts create mode 100644 apps/webapp/app/components/primitives/charts/useXAxisTicks.ts create mode 100644 apps/webapp/app/presenters/v3/activitySeries.server.ts create mode 100644 apps/webapp/test/activitySeries.server.test.ts create mode 100644 apps/webapp/test/chartActivityTimeAxis.test.ts create mode 100644 apps/webapp/test/chartXAxisTicks.test.ts diff --git a/.server-changes/task-landing-activity-charts.md b/.server-changes/task-landing-activity-charts.md new file mode 100644 index 00000000000..016ac2730c7 --- /dev/null +++ b/.server-changes/task-landing-activity-charts.md @@ -0,0 +1,14 @@ +--- +area: webapp +type: improvement +--- + +Improve the activity charts on the agent, standard, and scheduled task landing pages: + +- Bucket density now adapts to the selected time range, so short ranges (e.g. "5 min") render many fine-grained bars instead of a single 1-hour bar. +- X-axis labels are chosen dynamically based on the available width — evenly spaced, first + last always shown, kept horizontal, and reflowed on resize so they no longer overlap. +- Charts on the agent page share a synced vertical hover indicator (reusable via `ChartSyncProvider`). +- Each chart card gains a "Maximize" button (fullscreen dialog + `v` shortcut), matching the dashboard widgets. +- Y-axis values are abbreviated (8000 → "8K") by default across the compound charts. + +Also de-duplicates the previously copy-pasted status colors, time-axis formatters, and bucket/zero-fill logic into shared helpers. diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index 78a72fc0dc2..d1c7bd5dddc 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -14,9 +14,19 @@ import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/chart import { useChartContext } from "./ChartContext"; import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading"; import { useHasNoData } from "./ChartRoot"; -import { useYAxisWidth } from "./useYAxisWidth"; +import { defaultYAxisTickFormatter, useYAxisWidth } from "./useYAxisWidth"; +import { useXAxisTicks } from "./useXAxisTicks"; +import { useChartSync } from "./ChartSyncContext"; import { ZoomTooltip, useZoomHandlers } from "./ChartZoom"; +// charcoal-600 — a subtle vertical line used to mirror the hovered x position +// across charts in the same ChartSyncProvider group. +const SYNC_LINE_COLOR = "#3B3E45"; + +// Chart margins. The right margin keeps the centered last x-axis label (e.g. +// "Jun 22") from being clipped at the SVG's right edge. +const CHART_MARGIN = { top: 5, right: 20, bottom: 5, left: 5 } as const; + //TODO: fix the first and last bars in a stack not having rounded corners type ReferenceLineProps = { @@ -75,8 +85,30 @@ export function ChartBarRenderer({ const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, setActivePayload, zoom, showLegend } = useChartContext(); const hasNoData = useHasNoData(); const zoomHandlers = useZoomHandlers(); + const sync = useChartSync(); const enableZoom = zoom !== null; - const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisPropsProp?.tickFormatter); + const yAxisTickFormatter = yAxisPropsProp?.tickFormatter ?? defaultYAxisTickFormatter; + const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisTickFormatter); + + // Width-aware horizontal x-axis labels. Engaged only when the caller isn't + // controlling tick placement (no ticks/interval/angle), so callers like the + // query widget keep their custom (e.g. angled) axes. + const callerControlsXTicks = + xAxisPropsProp?.ticks !== undefined || + xAxisPropsProp?.interval !== undefined || + xAxisPropsProp?.angle !== undefined; + // Plot width = full width minus the y-axis and horizontal margins, so the + // "how many labels fit" estimate matches the area labels are drawn in. + const xAxisPlotWidth = + width != null + ? Math.max(0, width - computedYAxisWidth - CHART_MARGIN.left - CHART_MARGIN.right) + : undefined; + const autoXTicks = useXAxisTicks( + data, + dataKey, + xAxisPlotWidth, + xAxisPropsProp?.tickFormatter as ((value: any, index: number) => string) | undefined + ); const handleBarClick = useCallback( (barData: any, e: React.MouseEvent) => { @@ -94,7 +126,8 @@ export function ChartBarRenderer({ const handleMouseLeave = useCallback(() => { zoomHandlers.onMouseLeave?.(); highlight.reset(); - }, [zoomHandlers, highlight]); + sync?.setActiveX(null); + }, [zoomHandlers, highlight, sync]); // Render loading/error states if (state === "loading") { @@ -105,27 +138,39 @@ export function ChartBarRenderer({ return ; } - // Get the x-axis ticks based on tooltip state - // Only hide middle ticks when zoom is enabled (to make room for zoom instructions) - const xAxisTicks = + // Get the x-axis ticks based on tooltip state. + // Only hide middle ticks when zoom is enabled (to make room for zoom instructions). + const zoomXAxisTicks = enableZoom && highlight.tooltipActive && data.length > 2 ? [data[0]?.[dataKey], data[data.length - 1]?.[dataKey]] : undefined; + // Zoom ticks win; otherwise use width-aware auto ticks unless the caller + // controls tick placement. + const useAutoXTicks = !callerControlsXTicks && !zoomXAxisTicks && autoXTicks != null; + const baseXTicks = zoomXAxisTicks ?? (useAutoXTicks ? autoXTicks : undefined); + const baseXInterval = useAutoXTicks ? 0 : ("preserveStartEnd" as const); + + // Synced hover indicator (mirrored across charts in the same ChartSyncProvider). + const syncActiveX = sync?.activeX ?? null; + return ( { zoomHandlers.onMouseMove?.(e); if (e?.activePayload?.length) { setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); + sync?.setActiveX(e.activeLabel ?? null); } else { highlight.setTooltipActive(false); + sync?.setActiveX(null); } }} onMouseUp={zoomHandlers.onMouseUp} @@ -138,8 +183,8 @@ export function ChartBarRenderer({ tickLine={false} tickMargin={10} axisLine={false} - ticks={xAxisTicks} - interval="preserveStartEnd" + ticks={baseXTicks} + interval={baseXInterval} tick={{ fill: "#878C99", fontSize: 11, @@ -157,6 +202,7 @@ export function ChartBarRenderer({ fontSize: 11, style: { fontVariantNumeric: "tabular-nums" }, }} + tickFormatter={yAxisTickFormatter} domain={["auto", (dataMax: number) => dataMax * 1.15]} {...yAxisPropsProp} /> @@ -229,6 +275,18 @@ export function ChartBarRenderer({ ); })} + {/* Synced hover indicator — mirrors the hovered x across charts in the same ChartSyncProvider group. + pointer-events-none so it never steals hover from the bar underneath it. */} + {syncActiveX != null && ( + + )} + {/* Horizontal reference line */} {referenceLine && ( (null); + + // "v" toggles fullscreen for the hovered card. + useShortcutKeys({ + shortcut: { key: "v" }, + action: useCallback(() => { + const isHovered = containerRef.current?.matches(":hover"); + if (!isFullscreen && !isHovered) return; + setIsFullscreen((prev) => !prev); + }, [isFullscreen]), + disabled: !maximizable, + }); + + return ( +
+ + +
{title}
+ {maximizable && ( + + +
+ ); +} diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index fca87330162..c14891a18db 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -19,7 +19,7 @@ import { import { ChartLineLoading, ChartLineNoData, ChartLineInvalid } from "./ChartLoading"; import { useChartContext } from "./ChartContext"; import { ChartRoot, useHasNoData } from "./ChartRoot"; -import { useYAxisWidth } from "./useYAxisWidth"; +import { defaultYAxisTickFormatter, useYAxisWidth } from "./useYAxisWidth"; // Legend is now rendered by ChartRoot outside the chart container import type { ZoomRange } from "./hooks/useZoomSelection"; @@ -84,7 +84,8 @@ export function ChartLineRenderer({ }: ChartLineRendererProps) { const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, setActivePayload, showLegend } = useChartContext(); const hasNoData = useHasNoData(); - const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisPropsProp?.tickFormatter); + const yAxisTickFormatter = yAxisPropsProp?.tickFormatter ?? defaultYAxisTickFormatter; + const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisTickFormatter); // Render loading/error states if (state === "loading") { @@ -126,6 +127,7 @@ export function ChartLineRenderer({ fontSize: 11, style: { fontVariantNumeric: "tabular-nums" }, }, + tickFormatter: yAxisTickFormatter, ...yAxisPropsProp, }; diff --git a/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx b/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx new file mode 100644 index 00000000000..49be0e02ac3 --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx @@ -0,0 +1,38 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; + +/** + * Cross-chart hover synchronization. + * + * Wrap a group of charts that share an x-axis domain in a single + * . When the user hovers one chart, every chart in the + * group renders a vertical indicator line at the same x value, so the same + * point in time is highlighted across all of them. + * + * Chart.Bar reads this context automatically (via useChartSync) — it is a + * no-op when no provider is present, so other pages are unaffected. + * + * Modeled on DateRangeContext (the existing cross-chart sync mechanism). + */ + +type ChartSyncXValue = number | string | null; + +type ChartSyncContextValue = { + /** The x-axis value currently hovered in any chart in the group. */ + activeX: ChartSyncXValue; + setActiveX: (x: ChartSyncXValue) => void; +}; + +const ChartSyncContext = createContext(null); + +export function ChartSyncProvider({ children }: { children: React.ReactNode }) { + const [activeX, setActiveXState] = useState(null); + const setActiveX = useCallback((x: ChartSyncXValue) => setActiveXState(x), []); + const value = useMemo(() => ({ activeX, setActiveX }), [activeX, setActiveX]); + + return {children}; +} + +/** Returns the sync context, or null when not inside a ChartSyncProvider. */ +export function useChartSync(): ChartSyncContextValue | null { + return useContext(ChartSyncContext); +} diff --git a/apps/webapp/app/components/primitives/charts/activityTimeAxis.ts b/apps/webapp/app/components/primitives/charts/activityTimeAxis.ts new file mode 100644 index 00000000000..ead968bbce2 --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/activityTimeAxis.ts @@ -0,0 +1,74 @@ +/** + * Builds the x-axis tick + tooltip label formatters for the task/agent + * activity charts. Previously duplicated ~verbatim across the three task + * landing pages. + * + * ClickHouse buckets are aligned to UTC, so labels are formatted in UTC — + * using local time causes off-by-one day labels. + * + * Note: this only produces the label *formatters*. Which ticks are rendered + * (and how many, based on available width) is handled centrally by + * `useXAxisTicks` inside Chart.Bar. + */ + +const ONE_MINUTE = 60 * 1000; +const ONE_DAY = 24 * 60 * 60 * 1000; + +type ActivityPoint = { bucket: number }; + +export function buildActivityTimeAxis(data: ActivityPoint[]) { + const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0; + const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0; + + // ≤ 1 day range → show clock time, otherwise show the date. + const showTime = range <= ONE_DAY; + // Sub-minute buckets need seconds in the label or many ticks collapse to the + // same "HH:MM". + const showSeconds = bucketMs > 0 && bucketMs < ONE_MINUTE; + const isSubDayBucket = bucketMs > 0 && bucketMs < ONE_DAY; + + const tickFormatter = (value: number) => { + const date = new Date(value); + if (showTime) { + return date.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + ...(showSeconds ? { second: "2-digit" } : {}), + hour12: false, + timeZone: "UTC", + }); + } + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", + }); + }; + + const tooltipLabelFormatter = ( + _label: string, + payload: { payload?: { bucket?: number } }[] + ) => { + const ts = payload?.[0]?.payload?.bucket; + if (typeof ts !== "number" || !Number.isFinite(ts)) return _label; + const date = new Date(ts); + return isSubDayBucket + ? date.toLocaleString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + ...(showSeconds ? { second: "2-digit" } : {}), + hour12: false, + timeZone: "UTC", + }) + : date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); + }; + + return { tickFormatter, tooltipLabelFormatter }; +} diff --git a/apps/webapp/app/components/primitives/charts/statusColors.ts b/apps/webapp/app/components/primitives/charts/statusColors.ts new file mode 100644 index 00000000000..e33164db696 --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/statusColors.ts @@ -0,0 +1,27 @@ +/** + * Shared colors for the task/agent activity charts. Previously each task + * landing page (agent / standard / scheduled) defined its own identical + * `STATUS_COLOR` table. + * + * Keys are the grouped chart series — run-status groups (see + * `RUN_STATUS_GROUPS` in activitySeries.server.ts) plus the agent session + * statuses. + */ +export const STATUS_COLOR: Record = { + // Run-status groups + COMPLETED: "#28BF5C", + RUNNING: "#3B82F6", + FAILED: "#E11D48", + CANCELED: "#878C99", + // Agent session statuses + ACTIVE: "#3B82F6", + CLOSED: "#28BF5C", + EXPIRED: "#878C99", +}; + +/** Fallback for any status not in the table. */ +export const STATUS_COLOR_FALLBACK = "#9CA3AF"; + +export function statusColor(status: string): string { + return STATUS_COLOR[status] ?? STATUS_COLOR_FALLBACK; +} diff --git a/apps/webapp/app/components/primitives/charts/useXAxisTicks.ts b/apps/webapp/app/components/primitives/charts/useXAxisTicks.ts new file mode 100644 index 00000000000..e03da98cb3d --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/useXAxisTicks.ts @@ -0,0 +1,103 @@ +import { useMemo } from "react"; + +// Mirrors useYAxisWidth: at 11px tabular-nums, 1 char ≈ 6.5px. Labels use +// tabular-nums so character count is a faithful width proxy. +const PX_PER_CH = 6.5; +// Minimum horizontal breathing room between two adjacent labels. +const LABEL_GAP_PX = 16; +// Floor so very short labels still get some space. +const MIN_LABEL_PX = 24; + +/** + * Pick `count` indices evenly spaced across [0, n), always including the first + * and last. "Evenly spaced" here means even *screen* spacing on a band axis + * (each bucket occupies an equal slice of the plot width). + */ +export function selectEvenlySpacedIndices(n: number, count: number): number[] { + if (n <= 0) return []; + if (count <= 1) return [0]; + if (count >= n) return Array.from({ length: n }, (_, i) => i); + if (count === 2) return [0, n - 1]; + + const step = (n - 1) / (count - 1); + const out: number[] = []; + const seen = new Set(); + for (let i = 0; i < count; i++) { + const idx = Math.round(i * step); + if (!seen.has(idx)) { + seen.add(idx); + out.push(idx); + } + } + // Rounding can drop the final index — guarantee the last is present. + if (!seen.has(n - 1)) out.push(n - 1); + return out; +} + +/** + * Pick `maxLabels` values evenly spaced across `values`, always including the + * first and last. + */ +export function selectEvenlySpacedTicks(values: T[], maxLabels: number): T[] { + return selectEvenlySpacedIndices(values.length, maxLabels).map((i) => values[i]); +} + +/** + * How many labels of `maxLabelChars` width fit in `width` pixels. + */ +export function estimateMaxLabels(width: number, maxLabelChars: number): number { + if (!width || width <= 0) return 0; + const labelPx = Math.max(MIN_LABEL_PX, maxLabelChars * PX_PER_CH) + LABEL_GAP_PX; + return Math.max(1, Math.floor(width / labelPx)); +} + +/** + * Compute the explicit x-axis tick values to render labels at, so they: + * - are evenly spaced across the plot (no crowding, even when the first/last + * bucket is a partial period), + * - never overlap (count is bounded by how many fit in `plotWidth`), + * - never repeat the same text (count is also bounded by the number of + * distinct labels), + * - stay horizontal and include the first + last bucket. + * + * `plotWidth` is the width of the plotting area (full width minus the y-axis and + * horizontal margins), so the "how many fit" estimate matches the area labels + * are actually drawn in. Returns `undefined` until a width is known (first paint). + */ +export function useXAxisTicks( + data: Array>, + dataKey: string, + plotWidth: number | undefined, + tickFormatter?: (value: any, index: number) => string +): any[] | undefined { + return useMemo(() => { + if (!data?.length || !plotWidth || plotWidth <= 0) return undefined; + + const n = data.length; + const fmt = tickFormatter ?? ((v: any) => String(v)); + const labels = data.map((d, i) => fmt(d[dataKey], i) ?? ""); + + let maxChars = 0; + for (const label of labels) { + if (label.length > maxChars) maxChars = label.length; + } + + // How many labels fit, capped at the number of distinct labels — there's no + // point reserving slots for more labels than there are unique values. The + // distinct cap is what keeps spacing even: we lay out N evenly-spaced labels + // rather than one-per-period (which crowds when the first period is partial). + const fit = estimateMaxLabels(plotWidth, maxChars); + const distinct = new Set(labels).size; + const target = Math.min(fit, distinct, n); + + // Evenly spaced on screen, then drop any that repeat the previous label. + const ticks: any[] = []; + let lastLabel: string | null = null; + for (const idx of selectEvenlySpacedIndices(n, target)) { + if (labels[idx] === lastLabel) continue; + lastLabel = labels[idx]; + ticks.push(data[idx][dataKey]); + } + return ticks; + }, [data, dataKey, plotWidth, tickFormatter]); +} diff --git a/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts b/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts index 930fab80404..236f77ce894 100644 --- a/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts +++ b/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts @@ -1,4 +1,13 @@ import { useMemo } from "react"; +import { formatNumberCompact } from "~/utils/numberFormatter"; + +/** + * Default y-axis tick formatter for compound charts: compact notation so large + * values are abbreviated (8000 → "8K", 1_200_000 → "1.2M"). Applied by + * Chart.Bar / Chart.Line unless the caller supplies its own tickFormatter. + */ +export const defaultYAxisTickFormatter = (value: any): string => + typeof value === "number" ? formatNumberCompact(value) : String(value); // 1ch at 11px tabular-nums system-ui ≈ 6.5px. Recharts' YAxis.width prop is a // raw number (pixels), so we can't use the CSS `ch` unit directly — but tabular-nums diff --git a/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts index 4b97db3cddb..3d3f74bb86c 100644 --- a/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts @@ -5,6 +5,13 @@ import { } from "@trigger.dev/database"; import { z } from "zod"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; +import { + chooseBucketSeconds, + groupRunStatus, + RUN_STATUS_GROUPS, + zeroFillGroupedSeries, + zeroFillScalarSeries, +} from "./activitySeries.server"; export type AgentDetail = { slug: string; @@ -23,39 +30,6 @@ export type AgentActivity = { statuses: string[]; }; -const TERMINAL_GROUPS = { - COMPLETED: ["COMPLETED_SUCCESSFULLY"], - FAILED: [ - "COMPLETED_WITH_ERRORS", - "SYSTEM_FAILURE", - "CRASHED", - "INTERRUPTED", - "TIMED_OUT", - ], - CANCELED: ["CANCELED", "EXPIRED"], - RUNNING: [ - "EXECUTING", - "DEQUEUED", - "PENDING_EXECUTING", - "WAITING_TO_RESUME", - "QUEUED_EXECUTING", - "PENDING", - "PENDING_VERSION", - "DELAYED", - "WAITING_FOR_DEPLOY", - ], -} as const; - -const GROUP_LABEL = ["COMPLETED", "FAILED", "CANCELED", "RUNNING"] as const; -type GroupLabel = (typeof GROUP_LABEL)[number]; - -function groupForStatus(status: string): GroupLabel | undefined { - for (const label of GROUP_LABEL) { - if ((TERMINAL_GROUPS[label] as readonly string[]).includes(status)) return label; - } - return undefined; -} - // Stable legend order for the sessions activity chart. Derived statuses: // ACTIVE = closed_at IS NULL AND (expires_at IS NULL OR expires_at > now) // CLOSED = closed_at IS NOT NULL @@ -126,17 +100,7 @@ export class AgentDetailPresenter { to: Date; }): Promise { const rangeMs = Math.max(1, to.getTime() - from.getTime()); - const oneHour = 60 * 60 * 1000; - const sixHours = 6 * oneHour; - const oneDay = 24 * oneHour; - - // Pick a sensible bucket interval based on the range - const bucketSeconds = - rangeMs <= oneDay - ? 60 * 60 // 1h buckets - : rangeMs <= 7 * oneDay - ? 6 * 60 * 60 // 6h buckets - : 24 * 60 * 60; // 1d buckets + const bucketSeconds = chooseBucketSeconds(rangeMs); // NOTE: We intentionally don't filter by `task_kind = 'AGENT'` here: // ClickHouse stores `task_kind = ""` for pre-migration rows and rows @@ -195,33 +159,19 @@ export class AgentDetailPresenter { return { data: [], statuses: [] }; } - const bucketMap = new Map>(); - for (const row of rows) { - const group = groupForStatus(row.status) ?? "RUNNING"; - const ts = row.bucket * 1000; - const existing = bucketMap.get(ts) ?? {}; - existing[group] = (existing[group] ?? 0) + row.val; - bucketMap.set(ts, existing); - } - - // Build zero-filled time series. We always emit every status group so - // the chart legend is stable across time ranges (even when a group has - // no runs in the current window). - const bucketMs = bucketSeconds * 1000; - const start = Math.floor(from.getTime() / bucketMs) * bucketMs; - const end = Math.ceil(to.getTime() / bucketMs) * bucketMs; - const points: AgentActivityPoint[] = []; - const orderedStatuses = [...GROUP_LABEL]; - for (let ts = start; ts < end; ts += bucketMs) { - const existing = bucketMap.get(ts) ?? {}; - const point: AgentActivityPoint = { bucket: ts }; - for (const g of orderedStatuses) { - point[g] = existing[g] ?? 0; - } - points.push(point); - } + // Always emit every status group so the chart legend is stable across time + // ranges (even when a group has no runs in the current window). + const points = zeroFillGroupedSeries({ + rows, + from, + to, + bucketSeconds, + orderedKeys: RUN_STATUS_GROUPS, + groupFn: groupRunStatus, + fallbackKey: "RUNNING", + }); - return { data: points, statuses: orderedStatuses }; + return { data: points, statuses: [...RUN_STATUS_GROUPS] }; } async getSessionActivity({ @@ -240,15 +190,7 @@ export class AgentDetailPresenter { to: Date; }): Promise { const rangeMs = Math.max(1, to.getTime() - from.getTime()); - const oneHour = 60 * 60 * 1000; - const oneDay = 24 * oneHour; - - const bucketSeconds = - rangeMs <= oneDay - ? 60 * 60 - : rangeMs <= 7 * oneDay - ? 6 * 60 * 60 - : 24 * 60 * 60; + const bucketSeconds = chooseBucketSeconds(rangeMs); // FINAL collapses ReplacingMergeTree versions so we see each session's // latest state — important since closed_at / expires_at are mutated @@ -304,27 +246,14 @@ export class AgentDetailPresenter { return { data: [], statuses: [] }; } - const bucketMap = new Map>(); - for (const row of rows) { - const ts = row.bucket * 1000; - const existing = bucketMap.get(ts) ?? {}; - existing[row.status] = (existing[row.status] ?? 0) + row.val; - bucketMap.set(ts, existing); - } - - const bucketMs = bucketSeconds * 1000; - const start = Math.floor(from.getTime() / bucketMs) * bucketMs; - const end = Math.ceil(to.getTime() / bucketMs) * bucketMs; - const points: AgentActivityPoint[] = []; const orderedStatuses: SessionStatusLabel[] = [...SESSION_STATUSES]; - for (let ts = start; ts < end; ts += bucketMs) { - const existing = bucketMap.get(ts) ?? {}; - const point: AgentActivityPoint = { bucket: ts }; - for (const s of orderedStatuses) { - point[s] = existing[s] ?? 0; - } - points.push(point); - } + const points = zeroFillGroupedSeries({ + rows, + from, + to, + bucketSeconds, + orderedKeys: orderedStatuses, + }); return { data: points, statuses: orderedStatuses }; } @@ -369,11 +298,7 @@ export class AgentDetailPresenter { metric: "cost" | "tokens"; }): Promise { const rangeMs = Math.max(1, to.getTime() - from.getTime()); - const oneHour = 60 * 60 * 1000; - const oneDay = 24 * oneHour; - - const bucketSeconds = - rangeMs <= oneDay ? 60 * 60 : rangeMs <= 7 * oneDay ? 6 * 60 * 60 : 24 * 60 * 60; + const bucketSeconds = chooseBucketSeconds(rangeMs); const seriesKey = metric === "cost" ? "cost" : "tokens"; // total_cost is Decimal64(12); cast to Float64 so the JSON wire format is @@ -430,19 +355,7 @@ export class AgentDetailPresenter { return { data: [], statuses: [] }; } - const bucketMap = new Map(); - for (const row of rows) { - const ts = row.bucket * 1000; - bucketMap.set(ts, (bucketMap.get(ts) ?? 0) + row.val); - } - - const bucketMs = bucketSeconds * 1000; - const start = Math.floor(from.getTime() / bucketMs) * bucketMs; - const end = Math.ceil(to.getTime() / bucketMs) * bucketMs; - const points: AgentActivityPoint[] = []; - for (let ts = start; ts < end; ts += bucketMs) { - points.push({ bucket: ts, [seriesKey]: bucketMap.get(ts) ?? 0 }); - } + const points = zeroFillScalarSeries({ rows, from, to, bucketSeconds, seriesKey }); return { data: points, statuses: [seriesKey] }; } diff --git a/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts index aa7f0132a55..d4bd38cf643 100644 --- a/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts @@ -8,6 +8,12 @@ import { import { z } from "zod"; import { machinePresetFromConfig } from "~/v3/machinePresets.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; +import { + chooseBucketSeconds, + groupRunStatus, + RUN_STATUS_GROUPS, + zeroFillGroupedSeries, +} from "./activitySeries.server"; export type TaskDetailQueue = { friendlyId: string; @@ -50,39 +56,6 @@ export type TaskActivity = { statuses: string[]; }; -const TERMINAL_GROUPS = { - COMPLETED: ["COMPLETED_SUCCESSFULLY"], - FAILED: [ - "COMPLETED_WITH_ERRORS", - "SYSTEM_FAILURE", - "CRASHED", - "INTERRUPTED", - "TIMED_OUT", - ], - CANCELED: ["CANCELED", "EXPIRED"], - RUNNING: [ - "EXECUTING", - "DEQUEUED", - "PENDING_EXECUTING", - "WAITING_TO_RESUME", - "QUEUED_EXECUTING", - "PENDING", - "PENDING_VERSION", - "DELAYED", - "WAITING_FOR_DEPLOY", - ], -} as const; - -const GROUP_LABEL = ["COMPLETED", "FAILED", "CANCELED", "RUNNING"] as const; -type GroupLabel = (typeof GROUP_LABEL)[number]; - -function groupForStatus(status: string): GroupLabel | undefined { - for (const label of GROUP_LABEL) { - if ((TERMINAL_GROUPS[label] as readonly string[]).includes(status)) return label; - } - return undefined; -} - export class TaskDetailPresenter { constructor( private readonly replica: PrismaClientOrTransaction, @@ -184,15 +157,7 @@ export class TaskDetailPresenter { to: Date; }): Promise { const rangeMs = Math.max(1, to.getTime() - from.getTime()); - const oneHour = 60 * 60 * 1000; - const oneDay = 24 * oneHour; - - const bucketSeconds = - rangeMs <= oneDay - ? 60 * 60 - : rangeMs <= 7 * oneDay - ? 6 * 60 * 60 - : 24 * 60 * 60; + const bucketSeconds = chooseBucketSeconds(rangeMs); // FINAL + _is_deleted = 0 because task_runs_v2 is a ReplacingMergeTree; // org/project filters engage the sort-key prefix for partition pruning. @@ -245,31 +210,18 @@ export class TaskDetailPresenter { return { data: [], statuses: [] }; } - const bucketMap = new Map>(); - for (const row of rows) { - const group = groupForStatus(row.status) ?? "RUNNING"; - const ts = row.bucket * 1000; - const existing = bucketMap.get(ts) ?? {}; - existing[group] = (existing[group] ?? 0) + row.val; - bucketMap.set(ts, existing); - } - - // Always emit every status group so the chart legend is stable across - // time ranges (even when a group has no runs in the current window). - const bucketMs = bucketSeconds * 1000; - const start = Math.floor(from.getTime() / bucketMs) * bucketMs; - const end = Math.ceil(to.getTime() / bucketMs) * bucketMs; - const points: TaskActivityPoint[] = []; - const orderedStatuses = [...GROUP_LABEL]; - for (let ts = start; ts < end; ts += bucketMs) { - const existing = bucketMap.get(ts) ?? {}; - const point: TaskActivityPoint = { bucket: ts }; - for (const g of orderedStatuses) { - point[g] = existing[g] ?? 0; - } - points.push(point); - } + // Always emit every status group so the chart legend is stable across time + // ranges (even when a group has no runs in the current window). + const points = zeroFillGroupedSeries({ + rows, + from, + to, + bucketSeconds, + orderedKeys: RUN_STATUS_GROUPS, + groupFn: groupRunStatus, + fallbackKey: "RUNNING", + }); - return { data: points, statuses: orderedStatuses }; + return { data: points, statuses: [...RUN_STATUS_GROUPS] }; } } diff --git a/apps/webapp/app/presenters/v3/activitySeries.server.ts b/apps/webapp/app/presenters/v3/activitySeries.server.ts new file mode 100644 index 00000000000..adc087e76eb --- /dev/null +++ b/apps/webapp/app/presenters/v3/activitySeries.server.ts @@ -0,0 +1,172 @@ +/** + * Shared helpers for the task/agent "activity" bar charts. + * + * These were previously duplicated across AgentDetailPresenter and + * TaskDetailPresenter (bucket-size ladder, run-status grouping, and the + * zero-fill loop). Centralising them fixes the "sub-hour range renders one + * 1h bar" problem in one place and keeps the three task landing pages + * consistent. + */ + +// Nice, human-friendly bucket intervals (seconds). toStartOfInterval accepts +// any integer, but snapping to these keeps tick boundaries readable. +const NICE_BUCKET_SECONDS = [ + 1, 5, 10, 15, 30, // sub-minute + 60, 120, 300, 600, 900, 1800, // 1m, 2m, 5m, 10m, 15m, 30m + 3600, 7200, 10800, 21600, 43200, // 1h, 2h, 3h, 6h, 12h + 86400, 172800, 604800, // 1d, 2d, 7d +] as const; + +export type ChooseBucketOptions = { + /** Bucket count we aim for — produces a chart that looks "full". */ + targetBuckets?: number; + /** Hard ceiling so we never emit sub-pixel bars / huge result sets. */ + maxBuckets?: number; +}; + +/** + * Choose a bucket interval (in seconds) for a time range so the chart renders + * a sensible number of bars regardless of how short or long the range is. + * + * Picks the nice interval whose resulting bucket count is closest to + * `targetBuckets` without exceeding `maxBuckets`. A 5-minute range becomes + * ~5s buckets (≈60 bars) instead of a single 1-hour bar. + */ +export function chooseBucketSeconds( + rangeMs: number, + { targetBuckets = 72, maxBuckets = 120 }: ChooseBucketOptions = {} +): number { + const rangeSeconds = Math.max(1, Math.ceil(rangeMs / 1000)); + + let best: number | null = null; + let bestScore = Infinity; + for (const secs of NICE_BUCKET_SECONDS) { + const count = rangeSeconds / secs; + if (count > maxBuckets) continue; // too many bars + const score = Math.abs(count - targetBuckets); + if (score < bestScore) { + bestScore = score; + best = secs; + } + } + + // No nice interval keeps us under maxBuckets (range larger than the ladder) — + // compute one that respects the ceiling. + if (best === null) { + return Math.ceil(rangeSeconds / maxBuckets); + } + + return best; +} + +export const RUN_STATUS_GROUPS = ["COMPLETED", "FAILED", "CANCELED", "RUNNING"] as const; +export type RunStatusGroup = (typeof RUN_STATUS_GROUPS)[number]; + +const TERMINAL_GROUPS: Record = { + COMPLETED: ["COMPLETED_SUCCESSFULLY"], + FAILED: ["COMPLETED_WITH_ERRORS", "SYSTEM_FAILURE", "CRASHED", "INTERRUPTED", "TIMED_OUT"], + CANCELED: ["CANCELED", "EXPIRED"], + RUNNING: [ + "EXECUTING", + "DEQUEUED", + "PENDING_EXECUTING", + "WAITING_TO_RESUME", + "QUEUED_EXECUTING", + "PENDING", + "PENDING_VERSION", + "DELAYED", + "WAITING_FOR_DEPLOY", + ], +}; + +/** Map a raw TaskRun status to one of the four chart groups. */ +export function groupRunStatus(status: string): RunStatusGroup | undefined { + for (const label of RUN_STATUS_GROUPS) { + if (TERMINAL_GROUPS[label].includes(status)) return label; + } + return undefined; +} + +export type ActivitySeriesPoint = { bucket: number } & Record; + +function bucketBounds(from: Date, to: Date, bucketSeconds: number) { + const bucketMs = bucketSeconds * 1000; + return { + bucketMs, + start: Math.floor(from.getTime() / bucketMs) * bucketMs, + end: Math.ceil(to.getTime() / bucketMs) * bucketMs, + }; +} + +/** + * Build a zero-filled, grouped time series. Every bucket across [from, to) is + * emitted (even empty ones) and every key in `orderedKeys` is present on every + * point, so the chart renders contiguous bars and a stable legend. + */ +export function zeroFillGroupedSeries({ + rows, + from, + to, + bucketSeconds, + orderedKeys, + groupFn, + fallbackKey, +}: { + rows: Array<{ bucket: number; status: string; val: number }>; + from: Date; + to: Date; + bucketSeconds: number; + orderedKeys: readonly K[]; + /** Maps a raw status to a key. Defaults to identity (status === key). */ + groupFn?: (status: string) => K | undefined; + /** Key to use when groupFn returns undefined (e.g. unknown statuses). */ + fallbackKey?: K; +}): ActivitySeriesPoint[] { + const bucketMap = new Map>(); + for (const row of rows) { + const key = (groupFn ? groupFn(row.status) : (row.status as K)) ?? fallbackKey; + if (!key) continue; + const ts = row.bucket * 1000; + const existing = bucketMap.get(ts) ?? {}; + existing[key] = (existing[key] ?? 0) + row.val; + bucketMap.set(ts, existing); + } + + const { bucketMs, start, end } = bucketBounds(from, to, bucketSeconds); + const points: ActivitySeriesPoint[] = []; + for (let ts = start; ts < end; ts += bucketMs) { + const existing = bucketMap.get(ts) ?? {}; + const point: ActivitySeriesPoint = { bucket: ts }; + for (const k of orderedKeys) point[k] = existing[k] ?? 0; + points.push(point); + } + return points; +} + +/** Build a zero-filled single-series (scalar) time series. */ +export function zeroFillScalarSeries({ + rows, + from, + to, + bucketSeconds, + seriesKey, +}: { + rows: Array<{ bucket: number; val: number }>; + from: Date; + to: Date; + bucketSeconds: number; + seriesKey: string; +}): ActivitySeriesPoint[] { + const bucketMap = new Map(); + for (const row of rows) { + const ts = row.bucket * 1000; + bucketMap.set(ts, (bucketMap.get(ts) ?? 0) + row.val); + } + + const { bucketMs, start, end } = bucketBounds(from, to, bucketSeconds); + const points: ActivitySeriesPoint[] = []; + for (let ts = start; ts < end; ts += bucketMs) { + points.push({ bucket: ts, [seriesKey]: bucketMap.get(ts) ?? 0 }); + } + return points; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx index 6d1dc87ee98..fd5fd44ea98 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx @@ -1,7 +1,7 @@ import { BookOpenIcon } from "@heroicons/react/24/solid"; import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { type ReactNode, Suspense, useMemo, useState } from "react"; +import { Suspense, useMemo, useState } from "react"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; @@ -9,8 +9,11 @@ import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon"; import { PageBody } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { LinkButton } from "~/components/primitives/Buttons"; -import { Card } from "~/components/primitives/charts/Card"; +import { ChartCard } from "~/components/primitives/charts/ChartCard"; +import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; +import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; +import { statusColor } from "~/components/primitives/charts/statusColors"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime"; @@ -270,7 +273,8 @@ export default function Page() { {/* Activity / LLM cost / Token charts */}
-
+ +
{tab === "sessions" ? ( }> @@ -330,7 +334,8 @@ export default function Page() { -
+
+
@@ -492,32 +497,20 @@ function AgentDetailSidebar({ ); } -const STATUS_COLOR: Record = { - // Run statuses - COMPLETED: "#28BF5C", - RUNNING: "#3B82F6", - FAILED: "#E11D48", - CANCELED: "#878C99", - // Session statuses - ACTIVE: "#3B82F6", - CLOSED: "#28BF5C", - EXPIRED: "#878C99", -}; - function ActivityChart({ activity }: { activity: AgentActivity }) { const chartConfig: ChartConfig = useMemo(() => { const cfg: ChartConfig = {}; for (const status of activity.statuses) { cfg[status] = { label: status.charAt(0) + status.slice(1).toLowerCase(), - color: STATUS_COLOR[status] ?? "#9CA3AF", + color: statusColor(status), }; } return cfg; }, [activity.statuses]); - const { xAxisFormatter, xAxisTicks, tooltipLabelFormatter } = useMemo( - () => buildTimeAxis(activity.data), + const { tickFormatter, tooltipLabelFormatter } = useMemo( + () => buildActivityTimeAxis(activity.data), [activity.data] ); @@ -532,10 +525,7 @@ function ActivityChart({ activity }: { activity: AgentActivity }) { @@ -560,15 +550,6 @@ function TableLoading() { ); } -function ChartCard({ title, children }: { title: string; children: ReactNode }) { - return ( - - {title} -
{children}
-
- ); -} - function ScalarActivityChart({ activity, seriesKey, @@ -587,8 +568,8 @@ function ScalarActivityChart({ [seriesKey, label, color] ); - const { xAxisFormatter, xAxisTicks, tooltipLabelFormatter } = useMemo( - () => buildTimeAxis(activity.data), + const { tickFormatter, tooltipLabelFormatter } = useMemo( + () => buildActivityTimeAxis(activity.data), [activity.data] ); @@ -596,10 +577,7 @@ function ScalarActivityChart({ @@ -607,58 +585,6 @@ function ScalarActivityChart({ ); } -function buildTimeAxis(data: AgentActivity["data"]) { - const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0; - const oneDay = 24 * 60 * 60 * 1000; - const showTime = range <= oneDay; - - const xAxisFormatter = (value: number) => { - const date = new Date(value); - return showTime - ? date.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - timeZone: "UTC", - }); - }; - - const xAxisTicks = showTime - ? undefined - : data.filter((d) => new Date(d.bucket).getUTCHours() === 0).map((d) => d.bucket); - - const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0; - const isSubDayBucket = bucketMs > 0 && bucketMs < oneDay; - - const tooltipLabelFormatter = (_label: string, payload: { payload?: { bucket?: number } }[]) => { - const ts = payload?.[0]?.payload?.bucket; - if (typeof ts !== "number" || !Number.isFinite(ts)) return _label; - const date = new Date(ts); - return isSubDayBucket - ? date.toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - timeZone: "UTC", - }); - }; - - return { xAxisFormatter, xAxisTicks, tooltipLabelFormatter }; -} - function formatCurrency(value: number): string { if (value === 0) return "$0"; if (value < 0.01) return `$${value.toFixed(4)}`; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index d82ebea7a5d..35761cfbfe3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -11,8 +11,10 @@ import { RunsIcon } from "~/assets/icons/RunsIcon"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { Card } from "~/components/primitives/charts/Card"; +import { ChartCard } from "~/components/primitives/charts/ChartCard"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; +import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; +import { statusColor } from "~/components/primitives/charts/statusColors"; import { Dialog, DialogContent, @@ -296,16 +298,13 @@ export default function Page() { {/* Activity chart */}
- - Runs by status -
- }> - }> - {(result) => } - - -
-
+ + }> + }> + {(result) => } + + +
@@ -980,81 +979,22 @@ function SchedulesMiniTable({ ); } -const STATUS_COLOR: Record = { - COMPLETED: "#28BF5C", - RUNNING: "#3B82F6", - FAILED: "#E11D48", - CANCELED: "#878C99", -}; - function ActivityChart({ activity }: { activity: TaskActivity }) { const chartConfig: ChartConfig = useMemo(() => { const cfg: ChartConfig = {}; for (const status of activity.statuses) { cfg[status] = { label: status.charAt(0) + status.slice(1).toLowerCase(), - color: STATUS_COLOR[status] ?? "#9CA3AF", + color: statusColor(status), }; } return cfg; }, [activity.statuses]); - const { xAxisFormatter, xAxisTicks } = useMemo(() => { - const data = activity.data; - const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0; - const oneDay = 24 * 60 * 60 * 1000; - const showTime = range <= oneDay; - - const formatter = (value: number) => { - const date = new Date(value); - return showTime - ? date.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - timeZone: "UTC", - }); - }; - - const ticks = showTime - ? undefined - : data.filter((d) => new Date(d.bucket).getUTCHours() === 0).map((d) => d.bucket); - - return { xAxisFormatter: formatter, xAxisTicks: ticks }; - }, [activity.data]); - - const tooltipLabelFormatter = useMemo(() => { - const data = activity.data; - const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0; - const oneDay = 24 * 60 * 60 * 1000; - const isSubDayBucket = bucketMs > 0 && bucketMs < oneDay; - - return (_label: string, payload: { payload?: { bucket?: number } }[]) => { - const ts = payload?.[0]?.payload?.bucket; - if (typeof ts !== "number" || !Number.isFinite(ts)) return _label; - const date = new Date(ts); - return isSubDayBucket - ? date.toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - timeZone: "UTC", - }); - }; - }, [activity.data]); + const { tickFormatter, tooltipLabelFormatter } = useMemo( + () => buildActivityTimeAxis(activity.data), + [activity.data] + ); return ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx index a6608488e41..caa0c0bda5a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx @@ -10,8 +10,10 @@ import { MachineLabelCombo } from "~/components/MachineLabelCombo"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { LinkButton } from "~/components/primitives/Buttons"; -import { Card } from "~/components/primitives/charts/Card"; +import { ChartCard } from "~/components/primitives/charts/ChartCard"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; +import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; +import { statusColor } from "~/components/primitives/charts/statusColors"; import { CopyableText } from "~/components/primitives/CopyableText"; import { DateTime } from "~/components/primitives/DateTime"; import { Header2 } from "~/components/primitives/Headers"; @@ -174,16 +176,13 @@ export default function Page() { {/* Activity chart */}
- - Runs by status -
- }> - }> - {(result) => } - - -
-
+ + }> + }> + {(result) => } + + +
@@ -374,87 +373,22 @@ function formatRetrySummary(retry: TaskDetail["retry"]): string { return `${retry.maxAttempts} attempts`; } -const STATUS_COLOR: Record = { - COMPLETED: "#28BF5C", - RUNNING: "#3B82F6", - FAILED: "#E11D48", - CANCELED: "#878C99", -}; - function ActivityChart({ activity }: { activity: TaskActivity }) { const chartConfig: ChartConfig = useMemo(() => { const cfg: ChartConfig = {}; for (const status of activity.statuses) { cfg[status] = { label: status.charAt(0) + status.slice(1).toLowerCase(), - color: STATUS_COLOR[status] ?? "#9CA3AF", + color: statusColor(status), }; } return cfg; }, [activity.statuses]); - const { xAxisFormatter, xAxisTicks } = useMemo(() => { - const data = activity.data; - const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0; - const oneDay = 24 * 60 * 60 * 1000; - const showTime = range <= oneDay; - - // ClickHouse buckets are aligned to UTC, so we format and pick ticks in - // UTC. Using local time here causes off-by-one day labels and a tick - // filter that matches zero buckets in any timezone other than UTC. - const formatter = (value: number) => { - const date = new Date(value); - return showTime - ? date.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - timeZone: "UTC", - }); - }; - - // For multi-day ranges with sub-day buckets, only label the midnight - // bucket on each day so we don't get repeated date labels across the - // multiple sub-day buckets within a single day. - const ticks = showTime - ? undefined - : data.filter((d) => new Date(d.bucket).getUTCHours() === 0).map((d) => d.bucket); - - return { xAxisFormatter: formatter, xAxisTicks: ticks }; - }, [activity.data]); - - const tooltipLabelFormatter = useMemo(() => { - const data = activity.data; - const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0; - const oneDay = 24 * 60 * 60 * 1000; - const isSubDayBucket = bucketMs > 0 && bucketMs < oneDay; - - return (_label: string, payload: { payload?: { bucket?: number } }[]) => { - const ts = payload?.[0]?.payload?.bucket; - if (typeof ts !== "number" || !Number.isFinite(ts)) return _label; - const date = new Date(ts); - return isSubDayBucket - ? date.toLocaleString("en-US", { - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "UTC", - }) - : date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - timeZone: "UTC", - }); - }; - }, [activity.data]); + const { tickFormatter, tooltipLabelFormatter } = useMemo( + () => buildActivityTimeAxis(activity.data), + [activity.data] + ); return ( diff --git a/apps/webapp/test/activitySeries.server.test.ts b/apps/webapp/test/activitySeries.server.test.ts new file mode 100644 index 00000000000..d3e032f50be --- /dev/null +++ b/apps/webapp/test/activitySeries.server.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + chooseBucketSeconds, + groupRunStatus, + RUN_STATUS_GROUPS, + zeroFillGroupedSeries, + zeroFillScalarSeries, +} from "~/presenters/v3/activitySeries.server"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +describe("chooseBucketSeconds", () => { + it("uses fine buckets for sub-hour ranges (the 5-minute bug)", () => { + // 5 minutes should NOT collapse to a single 1h bar. + expect(chooseBucketSeconds(5 * MINUTE)).toBe(5); // 60 buckets + expect(chooseBucketSeconds(1 * MINUTE)).toBe(1); // 60 buckets + expect(chooseBucketSeconds(30 * MINUTE)).toBe(30); // 60 buckets + }); + + it("scales the interval up for longer ranges", () => { + expect(chooseBucketSeconds(1 * HOUR)).toBe(60); + expect(chooseBucketSeconds(6 * HOUR)).toBe(300); + expect(chooseBucketSeconds(7 * DAY)).toBe(7200); + expect(chooseBucketSeconds(30 * DAY)).toBe(43200); + }); + + it("never exceeds the bucket ceiling", () => { + const ranges = [1 * MINUTE, 5 * MINUTE, 1 * HOUR, 24 * HOUR, 7 * DAY, 30 * DAY]; + for (const range of ranges) { + const secs = chooseBucketSeconds(range); + const count = range / 1000 / secs; + expect(count).toBeLessThanOrEqual(120); + expect(count).toBeGreaterThan(0); + } + }); + + it("falls back to a computed interval for ranges beyond the ladder", () => { + const huge = 2000 * DAY; + const secs = chooseBucketSeconds(huge); + const count = huge / 1000 / secs; + expect(count).toBeLessThanOrEqual(120); + }); + + it("honours a custom target", () => { + // Smaller target => wider buckets => fewer bars. + const wide = chooseBucketSeconds(1 * HOUR, { targetBuckets: 12 }); + const dense = chooseBucketSeconds(1 * HOUR, { targetBuckets: 72 }); + expect(wide).toBeGreaterThan(dense); + }); +}); + +describe("groupRunStatus", () => { + it("maps raw statuses to chart groups", () => { + expect(groupRunStatus("COMPLETED_SUCCESSFULLY")).toBe("COMPLETED"); + expect(groupRunStatus("CRASHED")).toBe("FAILED"); + expect(groupRunStatus("EXPIRED")).toBe("CANCELED"); + expect(groupRunStatus("EXECUTING")).toBe("RUNNING"); + expect(groupRunStatus("SOMETHING_UNKNOWN")).toBeUndefined(); + }); +}); + +describe("zeroFillGroupedSeries", () => { + it("emits a contiguous, fully zero-filled series", () => { + const from = new Date("2026-06-22T00:00:00.000Z"); + const to = new Date("2026-06-22T00:00:05.000Z"); // 5 seconds + const bucketSeconds = 1; + const at2s = Math.floor(new Date("2026-06-22T00:00:02.000Z").getTime() / 1000); + + const points = zeroFillGroupedSeries({ + rows: [{ bucket: at2s, status: "COMPLETED_SUCCESSFULLY", val: 3 }], + from, + to, + bucketSeconds, + orderedKeys: RUN_STATUS_GROUPS, + groupFn: groupRunStatus, + fallbackKey: "RUNNING", + }); + + expect(points).toHaveLength(5); + // Every point has every key (stable legend). + for (const p of points) { + for (const key of RUN_STATUS_GROUPS) { + expect(typeof p[key]).toBe("number"); + } + } + // The matching bucket carries the value; the rest are zero. + const filled = points.find((p) => p.bucket === at2s * 1000); + expect(filled?.COMPLETED).toBe(3); + expect(points.filter((p) => p.COMPLETED > 0)).toHaveLength(1); + }); + + it("uses identity grouping when no groupFn is provided", () => { + const from = new Date("2026-06-22T00:00:00.000Z"); + const to = new Date("2026-06-22T00:00:02.000Z"); + const at0 = Math.floor(from.getTime() / 1000); + + const points = zeroFillGroupedSeries({ + rows: [{ bucket: at0, status: "ACTIVE", val: 7 }], + from, + to, + bucketSeconds: 1, + orderedKeys: ["ACTIVE", "CLOSED", "EXPIRED"] as const, + }); + + expect(points).toHaveLength(2); + expect(points[0].ACTIVE).toBe(7); + expect(points[0].CLOSED).toBe(0); + }); +}); + +describe("zeroFillScalarSeries", () => { + it("zero-fills a single series", () => { + const from = new Date("2026-06-22T00:00:00.000Z"); + const to = new Date("2026-06-22T00:00:03.000Z"); + const at1 = Math.floor(new Date("2026-06-22T00:00:01.000Z").getTime() / 1000); + + const points = zeroFillScalarSeries({ + rows: [{ bucket: at1, val: 42 }], + from, + to, + bucketSeconds: 1, + seriesKey: "cost", + }); + + expect(points).toHaveLength(3); + expect(points.find((p) => p.bucket === at1 * 1000)?.cost).toBe(42); + expect(points.filter((p) => p.cost > 0)).toHaveLength(1); + }); +}); diff --git a/apps/webapp/test/chartActivityTimeAxis.test.ts b/apps/webapp/test/chartActivityTimeAxis.test.ts new file mode 100644 index 00000000000..413a8526518 --- /dev/null +++ b/apps/webapp/test/chartActivityTimeAxis.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +function series(startISO: string, count: number, stepMs: number) { + const start = new Date(startISO).getTime(); + return Array.from({ length: count }, (_, i) => ({ bucket: start + i * stepMs })); +} + +describe("buildActivityTimeAxis", () => { + it("shows seconds in labels for sub-minute buckets", () => { + const data = series("2026-06-22T00:00:00.000Z", 6, 5 * SECOND); // 5s buckets, 25s span + const { tickFormatter } = buildActivityTimeAxis(data); + const label = tickFormatter(data[0].bucket); + // HH:MM:SS -> two colons + expect(label.split(":").length).toBe(3); + }); + + it("shows HH:MM (no seconds) for minute+ buckets within a day", () => { + const data = series("2026-06-22T00:00:00.000Z", 12, 30 * MINUTE); // 30m buckets, 6h span + const { tickFormatter } = buildActivityTimeAxis(data); + const label = tickFormatter(data[0].bucket); + expect(label.split(":").length).toBe(2); + }); + + it("shows the date for multi-day ranges", () => { + const data = series("2026-06-20T00:00:00.000Z", 4, DAY); // 4 days + const { tickFormatter } = buildActivityTimeAxis(data); + const label = tickFormatter(data[0].bucket); + // e.g. "Jun 20" — no time component + expect(label).toMatch(/[A-Za-z]{3}\s+\d{1,2}/); + expect(label).not.toContain(":"); + }); + + it("formats labels in UTC", () => { + const data = series("2026-06-22T13:30:00.000Z", 4, 15 * MINUTE); + const { tickFormatter } = buildActivityTimeAxis(data); + expect(tickFormatter(data[0].bucket)).toBe("13:30"); + }); + + it("tooltip formatter reads the bucket from payload and includes date + time for sub-day buckets", () => { + const data = series("2026-06-22T00:00:00.000Z", 12, 30 * MINUTE); + const { tooltipLabelFormatter } = buildActivityTimeAxis(data); + const label = tooltipLabelFormatter("", [{ payload: { bucket: data[0].bucket } }]); + expect(label).toContain("Jun"); + expect(label).toContain(":"); + }); + + it("tooltip formatter falls back to the raw label when no bucket is present", () => { + const data = series("2026-06-22T00:00:00.000Z", 2, 30 * MINUTE); + const { tooltipLabelFormatter } = buildActivityTimeAxis(data); + expect(tooltipLabelFormatter("fallback", [{}])).toBe("fallback"); + }); +}); diff --git a/apps/webapp/test/chartXAxisTicks.test.ts b/apps/webapp/test/chartXAxisTicks.test.ts new file mode 100644 index 00000000000..6475e72fab6 --- /dev/null +++ b/apps/webapp/test/chartXAxisTicks.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + estimateMaxLabels, + selectEvenlySpacedIndices, + selectEvenlySpacedTicks, +} from "~/components/primitives/charts/useXAxisTicks"; + +describe("selectEvenlySpacedTicks", () => { + const values = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + + it("always includes first and last", () => { + const ticks = selectEvenlySpacedTicks(values, 4); + expect(ticks[0]).toBe(0); + expect(ticks[ticks.length - 1]).toBe(9); + }); + + it("spaces ticks evenly", () => { + expect(selectEvenlySpacedTicks(values, 4)).toEqual([0, 3, 6, 9]); + }); + + it("returns all values when more labels than points are allowed", () => { + expect(selectEvenlySpacedTicks(values, 50)).toEqual(values); + }); + + it("returns just the endpoints for 2 labels", () => { + expect(selectEvenlySpacedTicks(values, 2)).toEqual([0, 9]); + }); + + it("returns a single value for 1 label", () => { + expect(selectEvenlySpacedTicks(values, 1)).toEqual([0]); + }); + + it("handles empty input", () => { + expect(selectEvenlySpacedTicks([], 5)).toEqual([]); + }); + + it("never produces duplicates", () => { + const ticks = selectEvenlySpacedTicks([0, 1, 2], 3); + expect(new Set(ticks).size).toBe(ticks.length); + }); +}); + +describe("selectEvenlySpacedIndices", () => { + it("includes first and last, evenly spaced", () => { + expect(selectEvenlySpacedIndices(10, 4)).toEqual([0, 3, 6, 9]); + }); + + it("returns all indices when count >= n", () => { + expect(selectEvenlySpacedIndices(3, 10)).toEqual([0, 1, 2]); + }); + + it("returns endpoints for count 2", () => { + expect(selectEvenlySpacedIndices(50, 2)).toEqual([0, 49]); + }); + + it("returns [0] for count <= 1", () => { + expect(selectEvenlySpacedIndices(50, 1)).toEqual([0]); + }); + + it("handles empty range", () => { + expect(selectEvenlySpacedIndices(0, 5)).toEqual([]); + }); + + it("always ends at the last index (partial-period start stays even)", () => { + // 74 buckets, room for ~8 labels -> evenly spaced, last index present + const idx = selectEvenlySpacedIndices(74, 8); + expect(idx[0]).toBe(0); + expect(idx[idx.length - 1]).toBe(73); + // gaps are roughly uniform (no tiny first gap) + const gaps = idx.slice(1).map((v, i) => v - idx[i]); + expect(Math.min(...gaps)).toBeGreaterThanOrEqual(9); + }); +}); + +describe("estimateMaxLabels", () => { + it("returns 0 when width is unknown", () => { + expect(estimateMaxLabels(0, 5)).toBe(0); + }); + + it("fits more labels in a wider chart", () => { + const narrow = estimateMaxLabels(200, 5); + const wide = estimateMaxLabels(800, 5); + expect(wide).toBeGreaterThan(narrow); + }); + + it("fits fewer labels when labels are wider", () => { + const shortLabels = estimateMaxLabels(400, 5); + const longLabels = estimateMaxLabels(400, 12); + expect(longLabels).toBeLessThanOrEqual(shortLabels); + }); + + it("always allows at least one label for a positive width", () => { + expect(estimateMaxLabels(10, 100)).toBeGreaterThanOrEqual(1); + }); +}); From d8f249f04a56484eb7a413c63207756a94d33200 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 24 Jun 2026 15:13:37 +0100 Subject: [PATCH 2/9] feat(webapp): add drag-to-zoom to task landing page charts Click-drag across an activity chart to zoom into that time range: - The drag selection mirrors across every chart in the same ChartSyncProvider group, and on release sets the Time/Date filter (from/to) to the dragged window, matching how the TimeFilter applies a custom range. - While dragging, a From/To range tooltip replaces the hovered-value tooltip and updates live. Built on the existing ChartSyncProvider (shared selection state) plus a small useZoomToTimeFilter hook; the per-chart enableZoom path is untouched. --- .../task-landing-activity-charts.md | 1 + .../components/primitives/charts/ChartBar.tsx | 83 ++++++++++++- .../primitives/charts/ChartSyncContext.tsx | 112 ++++++++++++++++-- apps/webapp/app/hooks/useZoomToTimeFilter.ts | 26 ++++ .../route.tsx | 4 +- .../route.tsx | 19 +-- .../route.tsx | 19 +-- apps/webapp/test/chartZoomRange.test.ts | 26 ++++ 8 files changed, 261 insertions(+), 29 deletions(-) create mode 100644 apps/webapp/app/hooks/useZoomToTimeFilter.ts create mode 100644 apps/webapp/test/chartZoomRange.test.ts diff --git a/.server-changes/task-landing-activity-charts.md b/.server-changes/task-landing-activity-charts.md index 016ac2730c7..15f200d2467 100644 --- a/.server-changes/task-landing-activity-charts.md +++ b/.server-changes/task-landing-activity-charts.md @@ -8,6 +8,7 @@ Improve the activity charts on the agent, standard, and scheduled task landing p - Bucket density now adapts to the selected time range, so short ranges (e.g. "5 min") render many fine-grained bars instead of a single 1-hour bar. - X-axis labels are chosen dynamically based on the available width — evenly spaced, first + last always shown, kept horizontal, and reflowed on resize so they no longer overlap. - Charts on the agent page share a synced vertical hover indicator (reusable via `ChartSyncProvider`). +- Click-drag across a chart to zoom into that time range — the selection mirrors across synced charts and sets the Time/Date filter to the dragged window. - Each chart card gains a "Maximize" button (fullscreen dialog + `v` shortcut), matching the dashboard widgets. - Y-axis values are abbreviated (8000 → "8K") by default across the compound charts. diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index d1c7bd5dddc..9e198fb2316 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -11,6 +11,7 @@ import { type YAxisProps, } from "recharts"; import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart"; +import TooltipPortal from "~/components/primitives/TooltipPortal"; import { useChartContext } from "./ChartContext"; import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading"; import { useHasNoData } from "./ChartRoot"; @@ -27,6 +28,33 @@ const SYNC_LINE_COLOR = "#3B3E45"; // "Jun 22") from being clipped at the SVG's right edge. const CHART_MARGIN = { top: 5, right: 20, bottom: 5, left: 5 } as const; +/** + * Tooltip shown while drag-to-zooming: the selected From/To range instead of the + * normal hovered-value tooltip. Uses the same cursor-following portal as the + * standard tooltip. + */ +function ZoomRangeTooltip({ + active, + from, + to, +}: { + active?: boolean; + from: string; + to: string; +}) { + if (!active) return null; + return ( + +
+ From: + {from} + To: + {to} +
+
+ ); +} + //TODO: fix the first and last bars in a stack not having rounded corners type ReferenceLineProps = { @@ -122,11 +150,12 @@ export function ChartBarRenderer({ [enableZoom, zoom, dataKey] ); - // Handle mouse leave to also reset highlight + // Handle mouse leave to also reset highlight + cancel any in-progress zoom drag const handleMouseLeave = useCallback(() => { zoomHandlers.onMouseLeave?.(); highlight.reset(); sync?.setActiveX(null); + sync?.cancelZoom(); }, [zoomHandlers, highlight, sync]); // Render loading/error states @@ -153,6 +182,26 @@ export function ChartBarRenderer({ // Synced hover indicator (mirrored across charts in the same ChartSyncProvider). const syncActiveX = sync?.activeX ?? null; + // Synced drag-to-zoom selection (mirrored across charts). + const syncZoomSelection = sync?.zoomSelection ?? null; + // Bucket width so the committed zoom range includes the last selected bucket. + const bucketWidthMs = + data.length >= 2 ? Number(data[1][dataKey]) - Number(data[0][dataKey]) : 0; + + // While dragging, show a From/To range tooltip instead of the hovered values. + // Reuse the chart's tooltip label formatter (it reads `bucket` off the payload). + const formatZoomEdge = (v: number): string => + tooltipLabelFormatter ? tooltipLabelFormatter("", [{ payload: { bucket: v } }]) : String(v); + let zoomFrom: string | null = null; + let zoomTo: string | null = null; + if (syncZoomSelection) { + const a = Number(syncZoomSelection.start); + const b = Number(syncZoomSelection.current); + if (Number.isFinite(a) && Number.isFinite(b)) { + zoomFrom = formatZoomEdge(Math.min(a, b)); + zoomTo = formatZoomEdge(Math.max(a, b)); + } + } return ( { + zoomHandlers.onMouseDown?.(e); + if (sync?.zoomEnabled && e?.activeLabel != null) sync.startZoom(e.activeLabel); + }} onMouseMove={(e: any) => { zoomHandlers.onMouseMove?.(e); + if (sync?.zoomEnabled && sync.zoomSelection && e?.activeLabel != null) { + sync.updateZoom(e.activeLabel); + } if (e?.activePayload?.length) { setActivePayload(e.activePayload, e.activeTooltipIndex); highlight.setTooltipActive(true); @@ -173,7 +229,10 @@ export function ChartBarRenderer({ sync?.setActiveX(null); } }} - onMouseUp={zoomHandlers.onMouseUp} + onMouseUp={() => { + zoomHandlers.onMouseUp?.(); + if (sync?.zoomEnabled) sync.endZoom(bucketWidthMs); + }} onClick={zoomHandlers.onClick} onMouseLeave={handleMouseLeave} > @@ -211,7 +270,9 @@ export function ChartBarRenderer({ + ) : showLegend ? ( () => null ) : tooltipLabelFormatter ? ( @@ -275,6 +336,20 @@ export function ChartBarRenderer({ ); })} + {/* Synced drag-to-zoom selection — mirrored across charts in the same group. */} + {syncZoomSelection && ( + + )} + {/* Synced hover indicator — mirrors the hovered x across charts in the same ChartSyncProvider group. pointer-events-none so it never steals hover from the bar underneath it. */} {syncActiveX != null && ( diff --git a/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx b/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx index 49be0e02ac3..5d7d5d8818f 100644 --- a/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx @@ -1,33 +1,125 @@ -import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; +import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; /** - * Cross-chart hover synchronization. + * Cross-chart synchronization for a group of charts that share an x-axis domain. * - * Wrap a group of charts that share an x-axis domain in a single - * . When the user hovers one chart, every chart in the - * group renders a vertical indicator line at the same x value, so the same - * point in time is highlighted across all of them. + * Wrap them in a single . It provides two synced behaviours, + * both mirrored across every chart in the group: * - * Chart.Bar reads this context automatically (via useChartSync) — it is a - * no-op when no provider is present, so other pages are unaffected. + * 1. Hover indicator — hovering one chart draws a vertical line at the same x + * value on all of them. + * 2. Drag-to-zoom — click-dragging on any chart draws a selection rectangle on + * all of them; releasing commits the selected range via `onZoom` (used to set + * the Time/Date filter). + * + * Chart.Bar reads this context automatically (via useChartSync) — it is a no-op + * when no provider is present, so other pages are unaffected. Drag-to-zoom is + * only active when an `onZoom` callback is provided. * * Modeled on DateRangeContext (the existing cross-chart sync mechanism). */ type ChartSyncXValue = number | string | null; +/** Committed zoom range (epoch ms). */ +export type ChartZoomRange = { start: number; end: number }; + +/** In-progress drag selection (raw x values; not yet ordered). */ +type ZoomSelection = { start: number | string; current: number | string }; + type ChartSyncContextValue = { /** The x-axis value currently hovered in any chart in the group. */ activeX: ChartSyncXValue; setActiveX: (x: ChartSyncXValue) => void; + + /** Whether drag-to-zoom is active (an onZoom handler was provided). */ + zoomEnabled: boolean; + /** Current drag selection, mirrored across charts; null when not dragging. */ + zoomSelection: ZoomSelection | null; + startZoom: (x: number | string) => void; + updateZoom: (x: number | string) => void; + /** Finish the drag and commit the range (adds `bucketWidthMs` so the last bucket is included). */ + endZoom: (bucketWidthMs?: number) => void; + cancelZoom: () => void; }; const ChartSyncContext = createContext(null); -export function ChartSyncProvider({ children }: { children: React.ReactNode }) { +/** + * Turn a raw drag selection into an ordered, inclusive range. Returns null for + * a non-drag (start === current) or non-numeric selection. + */ +export function computeZoomRange( + start: number | string, + current: number | string, + bucketWidthMs = 0 +): ChartZoomRange | null { + const a = Number(start); + const b = Number(current); + if (!Number.isFinite(a) || !Number.isFinite(b) || a === b) return null; + const width = Number.isFinite(bucketWidthMs) ? bucketWidthMs : 0; + return { start: Math.min(a, b), end: Math.max(a, b) + width }; +} + +export function ChartSyncProvider({ + children, + onZoom, +}: { + children: React.ReactNode; + /** Called with the selected range when a drag-to-zoom completes. */ + onZoom?: (range: ChartZoomRange) => void; +}) { const [activeX, setActiveXState] = useState(null); + const [zoomSelection, setZoomSelection] = useState(null); + // Track selection synchronously so endZoom (fired on mouseup) reads the latest. + const selectionRef = useRef(null); + const setActiveX = useCallback((x: ChartSyncXValue) => setActiveXState(x), []); - const value = useMemo(() => ({ activeX, setActiveX }), [activeX, setActiveX]); + + const startZoom = useCallback((x: number | string) => { + const next = { start: x, current: x }; + selectionRef.current = next; + setZoomSelection(next); + }, []); + + const updateZoom = useCallback((x: number | string) => { + const prev = selectionRef.current; + if (!prev) return; + const next = { start: prev.start, current: x }; + selectionRef.current = next; + setZoomSelection(next); + }, []); + + const cancelZoom = useCallback(() => { + selectionRef.current = null; + setZoomSelection(null); + }, []); + + const endZoom = useCallback( + (bucketWidthMs = 0) => { + const sel = selectionRef.current; + selectionRef.current = null; + setZoomSelection(null); + if (!sel) return; + const range = computeZoomRange(sel.start, sel.current, bucketWidthMs); + if (range) onZoom?.(range); + }, + [onZoom] + ); + + const value = useMemo( + () => ({ + activeX, + setActiveX, + zoomEnabled: onZoom != null, + zoomSelection, + startZoom, + updateZoom, + endZoom, + cancelZoom, + }), + [activeX, setActiveX, onZoom, zoomSelection, startZoom, updateZoom, endZoom, cancelZoom] + ); return {children}; } diff --git a/apps/webapp/app/hooks/useZoomToTimeFilter.ts b/apps/webapp/app/hooks/useZoomToTimeFilter.ts new file mode 100644 index 00000000000..ffeea83a45d --- /dev/null +++ b/apps/webapp/app/hooks/useZoomToTimeFilter.ts @@ -0,0 +1,26 @@ +import { useCallback } from "react"; +import { type ChartZoomRange } from "~/components/primitives/charts/ChartSyncContext"; +import { useSearchParams } from "~/hooks/useSearchParam"; + +/** + * Returns an `onZoom` handler for chart drag-to-zoom that sets the Time/Date + * filter to the selected range. Mirrors how `TimeFilter` applies a custom range: + * epoch-ms `from`/`to`, clearing `period` (and pagination) so the page reloads + * scoped to the dragged window. + */ +export function useZoomToTimeFilter() { + const { replace } = useSearchParams(); + + return useCallback( + (range: ChartZoomRange) => { + replace({ + period: undefined, + cursor: undefined, + direction: undefined, + from: Math.round(range.start).toString(), + to: Math.round(range.end).toString(), + }); + }, + [replace] + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx index fd5fd44ea98..6a7ce253ce3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx @@ -11,6 +11,7 @@ import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { LinkButton } from "~/components/primitives/Buttons"; import { ChartCard } from "~/components/primitives/charts/ChartCard"; import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; +import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; import { statusColor } from "~/components/primitives/charts/statusColors"; @@ -204,6 +205,7 @@ export default function Page() { const tasksPath = v3EnvironmentPath(organization, project, environment); const [tab, setTab] = useState("sessions"); + const zoomToTimeFilter = useZoomToTimeFilter(); const tabLabel = tab === "sessions" ? "Sessions" : "Runs"; return ( @@ -273,7 +275,7 @@ export default function Page() { {/* Activity / LLM cost / Token charts */}
- +
{tab === "sessions" ? ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 35761cfbfe3..1f620e26408 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -12,6 +12,8 @@ import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ChartCard } from "~/components/primitives/charts/ChartCard"; +import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; +import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; import { statusColor } from "~/components/primitives/charts/statusColors"; @@ -193,6 +195,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export default function Page() { const { task, activity, scheduleList, runList } = useTypedLoaderData(); + const zoomToTimeFilter = useZoomToTimeFilter(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -298,13 +301,15 @@ export default function Page() { {/* Activity chart */}
- - }> - }> - {(result) => } - - - + + + }> + }> + {(result) => } + + + +
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx index caa0c0bda5a..d04c81fac7e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx @@ -11,6 +11,8 @@ import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { LinkButton } from "~/components/primitives/Buttons"; import { ChartCard } from "~/components/primitives/charts/ChartCard"; +import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; +import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; import { statusColor } from "~/components/primitives/charts/statusColors"; @@ -131,6 +133,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { export default function Page() { const { task, activity, runList } = useTypedLoaderData(); + const zoomToTimeFilter = useZoomToTimeFilter(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -176,13 +179,15 @@ export default function Page() { {/* Activity chart */}
- - }> - }> - {(result) => } - - - + + + }> + }> + {(result) => } + + + +
diff --git a/apps/webapp/test/chartZoomRange.test.ts b/apps/webapp/test/chartZoomRange.test.ts new file mode 100644 index 00000000000..81fef105008 --- /dev/null +++ b/apps/webapp/test/chartZoomRange.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { computeZoomRange } from "~/components/primitives/charts/ChartSyncContext"; + +describe("computeZoomRange", () => { + it("orders the selection ascending regardless of drag direction", () => { + expect(computeZoomRange(300, 100)).toEqual({ start: 100, end: 300 }); + expect(computeZoomRange(100, 300)).toEqual({ start: 100, end: 300 }); + }); + + it("adds the bucket width so the last selected bucket is included", () => { + expect(computeZoomRange(100, 300, 60)).toEqual({ start: 100, end: 360 }); + }); + + it("returns null for a non-drag (start === current)", () => { + expect(computeZoomRange(100, 100)).toBeNull(); + expect(computeZoomRange(100, 100, 60)).toBeNull(); + }); + + it("returns null for non-numeric selections", () => { + expect(computeZoomRange("a", "b")).toBeNull(); + }); + + it("handles numeric-string category values from recharts", () => { + expect(computeZoomRange("100", "300", 60)).toEqual({ start: 100, end: 360 }); + }); +}); From 71418c9cb3f7a1a29c36a51895a13a7da62dbd20 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 24 Jun 2026 17:32:25 +0100 Subject: [PATCH 3/9] fix(webapp): make dashboard line chart x-axis label density width-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date-based line charts (Run metrics, AI metrics, custom dashboards, Models pages — everything rendered by QueryResultsChart) capped the x-axis at a fixed ~8 time labels regardless of chart width, so labels were too sparse on wide charts. Derive the tick count from the measured chart width (one label per TIME_AXIS_LABEL_SPACING_PX), feeding it into the existing nice-interval generateTimeTicks. Wide charts now show more clean-interval labels, narrow widgets fewer. The continuous time scale is kept, so data gaps still render. --- .../dashboard-line-chart-x-axis-density.md | 6 +++ .../app/components/code/QueryResultsChart.tsx | 33 +++++++++++++++- .../webapp/test/queryResultsTimeTicks.test.ts | 39 +++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 .server-changes/dashboard-line-chart-x-axis-density.md create mode 100644 apps/webapp/test/queryResultsTimeTicks.test.ts diff --git a/.server-changes/dashboard-line-chart-x-axis-density.md b/.server-changes/dashboard-line-chart-x-axis-density.md new file mode 100644 index 00000000000..706fe5a1a72 --- /dev/null +++ b/.server-changes/dashboard-line-chart-x-axis-density.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Dashboard line charts (Run metrics, AI metrics, custom dashboards, and the Models pages — anything rendered by `QueryResultsChart`) now choose how many x-axis time labels to show based on the chart's rendered width, instead of a fixed cap of ~8. Wide charts show more labels; narrow widgets show fewer. The continuous time scale is unchanged, so gaps in the data still render as gaps. diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index 2c3f1c9f2bf..d5dab0fb66c 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -2,6 +2,7 @@ import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhous import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; import { BarChart3, LineChart } from "lucide-react"; import { memo, useMemo } from "react"; +import { useMeasure } from "react-use"; import { createValueFormatter } from "~/utils/columnFormat"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import type { ChartConfig } from "~/components/primitives/charts/Chart"; @@ -18,6 +19,14 @@ const MAX_SVG_ELEMENT_BUDGET = 6_000; const MIN_DATA_POINTS = 100; const MAX_DATA_POINTS = 500; +// Width-aware x-axis label density for date-based line charts. We reserve room +// for the y-axis + margins, then fit one label per TIME_AXIS_LABEL_SPACING_PX of +// the remaining width. *** Lower TIME_AXIS_LABEL_SPACING_PX = denser labels. *** +// Rotated (-45°) date labels stay readable down to ~32px. Tunable. +const TIME_AXIS_Y_ALLOWANCE_PX = 56; +const TIME_AXIS_LABEL_SPACING_PX = 40; +const MIN_TIME_AXIS_TICKS = 3; + interface QueryResultsChartProps { rows: Record[]; columns: OutputColumnMetadata[]; @@ -307,7 +316,7 @@ const NICE_TIME_INTERVALS = [ * Generate evenly-spaced tick values for a time axis using "nice" intervals * that align to natural time boundaries (midnight, noon, hour marks, etc.) */ -function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] { +export function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] { const range = maxTime - minTime; if (range <= 0) { @@ -793,6 +802,24 @@ export const QueryResultsChart = memo(function QueryResultsChart({ timeTicks, } = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]); + // Measure the rendered chart width so the x-axis label density adapts to it — + // same principle as the bar charts, applied to the line chart's nice-interval + // time ticks. (Continuous time scale is kept, so data gaps still show.) + const [chartMeasureRef, { width: chartWidth }] = useMeasure(); + + // Width-aware time-axis ticks: reuse the bar charts' "how many labels fit" + // estimate to choose how many clean intervals to render. Falls back to the + // default ticks until the width is known (first paint / SSR). + const widthAwareTimeTicks = useMemo(() => { + if (!isDateBased || !timeDomain || !chartWidth) return timeTicks; + const plotWidth = Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX); + const maxTicks = Math.max( + MIN_TIME_AXIS_TICKS, + Math.floor(plotWidth / TIME_AXIS_LABEL_SPACING_PX) + ); + return generateTimeTicks(timeDomain[0], timeDomain[1], maxTicks); + }, [isDateBased, timeDomain, timeTicks, chartWidth]); + // Apply sorting (for date-based, sort by timestamp to ensure correct order) const data = useMemo(() => { if (isDateBased) { @@ -1073,7 +1100,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ domain: timeDomain ?? (["auto", "auto"] as [string, string]), scale: "time" as const, // Explicitly specify tick positions so labels appear across the entire range - ticks: timeTicks ?? undefined, + ticks: widthAwareTimeTicks ?? undefined, ...baseXAxisProps, } : baseXAxisProps; @@ -1123,6 +1150,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ // Line or stacked area chart return ( +
+
); }); diff --git a/apps/webapp/test/queryResultsTimeTicks.test.ts b/apps/webapp/test/queryResultsTimeTicks.test.ts new file mode 100644 index 00000000000..299478b20e0 --- /dev/null +++ b/apps/webapp/test/queryResultsTimeTicks.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { generateTimeTicks } from "~/components/code/QueryResultsChart"; + +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const min = new Date("2026-06-15T00:00:00.000Z").getTime(); +const max = new Date("2026-06-22T00:00:00.000Z").getTime(); // 7 days + +describe("generateTimeTicks (width-aware tick budget)", () => { + it("produces more ticks when a wider chart allows more to fit", () => { + const few = generateTimeTicks(min, max, 4); + const many = generateTimeTicks(min, max, 16); + expect(many.length).toBeGreaterThan(few.length); + }); + + it("stays near the budget for a narrow chart", () => { + // The loop targets <= maxTicks; edge ticks can add 1-2. + expect(generateTimeTicks(min, max, 4).length).toBeLessThanOrEqual(6); + }); + + it("keeps every tick within the range (plus a small edge margin)", () => { + for (const t of generateTimeTicks(min, max, 12)) { + expect(t).toBeGreaterThanOrEqual(min - DAY); + expect(t).toBeLessThanOrEqual(max + DAY); + } + }); + + it("ticks are sorted ascending", () => { + const ticks = generateTimeTicks(min, max, 12); + for (let i = 1; i < ticks.length; i++) { + expect(ticks[i]).toBeGreaterThan(ticks[i - 1]); + } + }); + + it("returns a single tick for a zero range", () => { + expect(generateTimeTicks(min, min, 8)).toEqual([min]); + }); +}); From bac90537cafb5b02eb50a6cda46bc91d0276cb36 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 24 Jun 2026 18:23:59 +0100 Subject: [PATCH 4/9] feat(webapp): keep custom chart x-axis labels neat for any value type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-date x-axes on custom (Query mode) and dashboard charts — run IDs, task names, arbitrary strings — were rendered horizontally with every label shown, so long values overlapped into an unreadable smear. Now, with no configuration: - Labels are thinned to as many as fit the measured width (evenly spaced); all data still renders and the full value shows on hover. - Long values are middle-truncated (run_abc…f9c2) so ids sharing a prefix stay distinguishable. - The axis auto-rotates to -45° only when labels are long; short labels stay horizontal. Also share one CHART_MARGIN between ChartBar and ChartLine so the two stay aligned when toggling chart type and angled labels aren't clipped on line charts. --- .../custom-chart-categorical-x-axis.md | 12 ++++ .../app/components/code/QueryResultsChart.tsx | 72 ++++++++++++++++++- .../components/primitives/charts/ChartBar.tsx | 7 +- .../primitives/charts/ChartLine.tsx | 12 +--- .../webapp/test/queryResultsTimeTicks.test.ts | 24 ++++++- 5 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 .server-changes/custom-chart-categorical-x-axis.md diff --git a/.server-changes/custom-chart-categorical-x-axis.md b/.server-changes/custom-chart-categorical-x-axis.md new file mode 100644 index 00000000000..d5746d8bfac --- /dev/null +++ b/.server-changes/custom-chart-categorical-x-axis.md @@ -0,0 +1,12 @@ +--- +area: webapp +type: improvement +--- + +Custom chart (Query mode) and dashboard charts now keep their x-axis readable for non-date values like run IDs and task names, with no configuration: + +- Labels are thinned to only as many as fit the chart's width (evenly spaced); all data still renders, full value shows on hover. +- Long values are middle-truncated (`run_abc…f9c2`) so IDs that share a prefix stay distinguishable. +- The axis auto-rotates to -45° only when labels are long; short labels stay horizontal. + +Also made bar and line charts share the same margins so they stay aligned when toggling chart type, and so angled labels are no longer clipped on line charts. diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index d5dab0fb66c..68e2495bc8d 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -7,6 +7,7 @@ import { createValueFormatter } from "~/utils/columnFormat"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import type { ChartConfig } from "~/components/primitives/charts/Chart"; import { Chart } from "~/components/primitives/charts/ChartCompound"; +import { selectEvenlySpacedIndices } from "~/components/primitives/charts/useXAxisTicks"; import { ChartBlankState } from "../primitives/charts/ChartBlankState"; import { Callout } from "../primitives/Callout"; import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget"; @@ -27,6 +28,35 @@ const TIME_AXIS_Y_ALLOWANCE_PX = 56; const TIME_AXIS_LABEL_SPACING_PX = 40; const MIN_TIME_AXIS_TICKS = 3; +// Categorical (non-date) x-axis label behavior. Labels are thinned to fit the +// width; long values (run IDs, task names, etc.) are middle-truncated and the +// axis auto-rotates so the chart stays neat without any customer configuration. +const X_LABEL_PX_PER_CHAR = 6.5; +const X_LABEL_GAP_PX = 16; +const MIN_CATEGORICAL_LABEL_PX = 36; +// Labels longer than this rotate to -45° (auto-rotate "only when needed"). +const CATEGORICAL_HORIZONTAL_MAX_CHARS = 10; +// Middle-ellipsis cap applied to rotated labels (keeps the axis height bounded). +const CATEGORICAL_ROTATED_MAX_CHARS = 14; +// Rotated labels can sit closer together without overlapping than horizontal ones. +const CATEGORICAL_ROTATED_LABEL_PX = 32; +const CATEGORICAL_ROTATED_HEIGHT_PX = 80; +const MIN_CATEGORICAL_TICKS = 2; + +/** + * Shorten a string to `maxChars`, keeping the start and end with an ellipsis in + * the middle (e.g. "run_abc…f9c2"). Middle truncation is important for IDs that + * share a common prefix — the distinguishing tail is preserved. + */ +export function truncateMiddle(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + if (maxChars <= 1) return value.slice(0, Math.max(0, maxChars)); + const keep = maxChars - 1; // room for the ellipsis + const head = Math.ceil(keep / 2); + const tail = Math.floor(keep / 2); + return `${value.slice(0, head)}…${value.slice(value.length - tail)}`; +} + interface QueryResultsChartProps { rows: Record[]; columns: OutputColumnMetadata[]; @@ -1052,6 +1082,44 @@ export const QueryResultsChart = memo(function QueryResultsChart({ }; }, [isDateBased, xAxisTickFormatter, xAxisAngle]); + // Categorical (non-date) x-axis presentation: thin labels to fit the width, + // middle-truncate long values (run IDs, etc.), and auto-rotate only when the + // labels are long. Applies to both bar and line charts; zero customer config. + const categoricalXAxisProps = useMemo(() => { + if (isDateBased) return null; + + const labels = data.map((d) => String(d[xDataKey] ?? "")); + const maxLabelChars = labels.reduce((max, l) => Math.max(max, l.length), 0); + const angled = maxLabelChars > CATEGORICAL_HORIZONTAL_MAX_CHARS; + + const tickFormatter = angled + ? (value: unknown) => truncateMiddle(String(value ?? ""), CATEGORICAL_ROTATED_MAX_CHARS) + : (value: unknown) => String(value ?? ""); + + // Thin to as many labels as fit. Rotated labels pack tighter; horizontal + // labels need roughly their own width. + const perLabelPx = angled + ? CATEGORICAL_ROTATED_LABEL_PX + : Math.max(MIN_CATEGORICAL_LABEL_PX, maxLabelChars * X_LABEL_PX_PER_CHAR + X_LABEL_GAP_PX); + const plotWidth = chartWidth > 0 ? Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX) : 0; + const fitCount = + plotWidth > 0 + ? Math.max(MIN_CATEGORICAL_TICKS, Math.floor(plotWidth / perLabelPx)) + : data.length; + const ticks = + fitCount < data.length + ? selectEvenlySpacedIndices(data.length, fitCount).map((i) => data[i][xDataKey] as string) + : undefined; + + return { + tickFormatter, + angle: angled ? -45 : 0, + textAnchor: angled ? ("end" as const) : ("middle" as const), + height: angled ? CATEGORICAL_ROTATED_HEIGHT_PX : undefined, + ...(ticks ? { ticks, interval: 0 as const } : {}), + }; + }, [isDateBased, data, xDataKey, chartWidth]); + // Validation — all hooks must be above this point const chartIcon = chartType === "bar" ? BarChart3 : LineChart; @@ -1103,12 +1171,12 @@ export const QueryResultsChart = memo(function QueryResultsChart({ ticks: widthAwareTimeTicks ?? undefined, ...baseXAxisProps, } - : baseXAxisProps; + : categoricalXAxisProps ?? baseXAxisProps; // Bar charts always use categorical axis positioning // This ensures bars are evenly distributed regardless of data point count // (prevents massive bars when there are only a few data points) - const xAxisPropsForBar = baseXAxisProps; + const xAxisPropsForBar = isDateBased ? baseXAxisProps : categoricalXAxisProps ?? baseXAxisProps; const yAxisProps = { tickFormatter: yAxisFormatter, diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index 9e198fb2316..519c733a5b7 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -24,9 +24,10 @@ import { ZoomTooltip, useZoomHandlers } from "./ChartZoom"; // across charts in the same ChartSyncProvider group. const SYNC_LINE_COLOR = "#3B3E45"; -// Chart margins. The right margin keeps the centered last x-axis label (e.g. -// "Jun 22") from being clipped at the SVG's right edge. -const CHART_MARGIN = { top: 5, right: 20, bottom: 5, left: 5 } as const; +// Chart margins, shared with ChartLine so bar/line align when toggling between +// them. The right margin keeps the centered last x-axis label (e.g. "Jun 22") +// from being clipped; the bottom margin gives angled x-axis labels room. +export const CHART_MARGIN = { top: 5, right: 20, bottom: 5, left: 5 } as const; /** * Tooltip shown while drag-to-zooming: the selected From/To range instead of the diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index c14891a18db..a3d2f3d47ce 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -20,6 +20,7 @@ import { ChartLineLoading, ChartLineNoData, ChartLineInvalid } from "./ChartLoad import { useChartContext } from "./ChartContext"; import { ChartRoot, useHasNoData } from "./ChartRoot"; import { defaultYAxisTickFormatter, useYAxisWidth } from "./useYAxisWidth"; +import { CHART_MARGIN } from "./ChartBar"; // Legend is now rendered by ChartRoot outside the chart container import type { ZoomRange } from "./hooks/useZoomSelection"; @@ -145,10 +146,7 @@ export function ChartLineRenderer({ width={width} height={height} stackOffset="none" - margin={{ - left: 12, - right: 12, - }} + margin={CHART_MARGIN} onMouseMove={(e: any) => { if (e?.activePayload?.length) { setActivePayload(e.activePayload, e.activeTooltipIndex); @@ -198,11 +196,7 @@ export function ChartLineRenderer({ data={data} width={width} height={height} - margin={{ - top: 5, - left: 12, - right: 12, - }} + margin={CHART_MARGIN} onMouseMove={(e: any) => { if (e?.activePayload?.length) { setActivePayload(e.activePayload, e.activeTooltipIndex); diff --git a/apps/webapp/test/queryResultsTimeTicks.test.ts b/apps/webapp/test/queryResultsTimeTicks.test.ts index 299478b20e0..c55228691c4 100644 --- a/apps/webapp/test/queryResultsTimeTicks.test.ts +++ b/apps/webapp/test/queryResultsTimeTicks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { generateTimeTicks } from "~/components/code/QueryResultsChart"; +import { generateTimeTicks, truncateMiddle } from "~/components/code/QueryResultsChart"; const HOUR = 60 * 60 * 1000; const DAY = 24 * HOUR; @@ -37,3 +37,25 @@ describe("generateTimeTicks (width-aware tick budget)", () => { expect(generateTimeTicks(min, min, 8)).toEqual([min]); }); }); + +describe("truncateMiddle", () => { + it("leaves short strings unchanged", () => { + expect(truncateMiddle("run_abc", 14)).toBe("run_abc"); + }); + + it("keeps the head and tail with a middle ellipsis", () => { + expect(truncateMiddle("abcdefghijklmnop", 9)).toBe("abcd…mnop"); + }); + + it("never exceeds maxChars", () => { + const out = truncateMiddle("run_0123456789abcdef", 12); + expect(out.length).toBeLessThanOrEqual(12); + expect(out).toContain("…"); + }); + + it("preserves the distinguishing tail for ids sharing a prefix", () => { + const a = truncateMiddle("run_commonprefix_AAAA", 12); + const b = truncateMiddle("run_commonprefix_BBBB", 12); + expect(a).not.toBe(b); + }); +}); From d1cdcd1717ba84a6d7da14799d3637813d51f835 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 26 Jun 2026 09:16:07 +0100 Subject: [PATCH 5/9] fix(webapp): brighten and dash the synced chart hover line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine the cross-chart hover indicator on the task landing pages: brighter (charcoal-500), dashed, and shown only on the charts you're not hovering — the hovered chart already has its own tooltip cursor. --- .../app/components/primitives/charts/ChartBar.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index 519c733a5b7..45d8491cc1a 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -20,9 +20,9 @@ import { useXAxisTicks } from "./useXAxisTicks"; import { useChartSync } from "./ChartSyncContext"; import { ZoomTooltip, useZoomHandlers } from "./ChartZoom"; -// charcoal-600 — a subtle vertical line used to mirror the hovered x position -// across charts in the same ChartSyncProvider group. -const SYNC_LINE_COLOR = "#3B3E45"; +// charcoal-500 — a subtle, dashed vertical line used to mirror the hovered x +// position across charts in the same ChartSyncProvider group. +const SYNC_LINE_COLOR = "#5F6570"; // Chart margins, shared with ChartLine so bar/line align when toggling between // them. The right margin keeps the centered last x-axis label (e.g. "Jun 22") @@ -351,13 +351,15 @@ export function ChartBarRenderer({ /> )} - {/* Synced hover indicator — mirrors the hovered x across charts in the same ChartSyncProvider group. + {/* Synced hover indicator: mirrored onto the *other* charts in the group, but + not the chart being hovered (it already shows its own tooltip cursor). pointer-events-none so it never steals hover from the bar underneath it. */} - {syncActiveX != null && ( + {syncActiveX != null && !highlight.tooltipActive && ( From 491a78936dc5d5796f18557087dc5bb6499604c4 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Fri, 26 Jun 2026 10:56:46 +0100 Subject: [PATCH 6/9] fix(webapp): apply width-aware x-axis thinning to categorical bar charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The useMeasure ref was attached only to the line/area return path, so bar charts measured a width of 0 and never produced evenly-spaced x-axis ticks (and, because the categorical axis sets an angle, Chart.Bar's own useXAxisTicks was disabled too) — falling back to Recharts' default label spacing. Wrap the bar return in the same measuring div so categorical bar charts get the intended even-spaced label thinning. --- .../app/components/code/QueryResultsChart.tsx | 74 ++++++++++--------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index 68e2495bc8d..903a14d0fc6 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -1187,6 +1187,40 @@ export const QueryResultsChart = memo(function QueryResultsChart({ if (chartType === "bar") { return ( +
+ + + +
+ ); + } + + // Line or stacked area chart + return ( +
- 1} tooltipLabelFormatter={tooltipLabelFormatter} tooltipValueFormatter={tooltipValueFormatter} + lineType="linear" /> - ); - } - - // Line or stacked area chart - return ( -
- - 1} - tooltipLabelFormatter={tooltipLabelFormatter} - tooltipValueFormatter={tooltipValueFormatter} - lineType="linear" - /> -
); }); From e325c6f533ba6c78acdba885a9ad4f1bc684f49b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 28 Jun 2026 15:30:35 +0100 Subject: [PATCH 7/9] chore(webapp): tidy chart comments and formatting Trim the comments added in the chart work down to the load-bearing "why", drop redundant what-narration, and run the formatter over the changed files. --- .../app/components/code/QueryResultsChart.tsx | 43 ++++----- .../components/primitives/charts/ChartBar.tsx | 51 +++------- .../primitives/charts/ChartCard.tsx | 5 +- .../primitives/charts/ChartSyncContext.tsx | 21 ++--- .../primitives/charts/activityTimeAxis.ts | 23 ++--- .../primitives/charts/statusColors.ts | 11 +-- .../primitives/charts/useXAxisTicks.ts | 43 +++------ .../primitives/charts/useYAxisWidth.ts | 5 +- apps/webapp/app/hooks/useZoomToTimeFilter.ts | 7 +- .../v3/AgentDetailPresenter.server.ts | 5 +- .../presenters/v3/activitySeries.server.ts | 49 ++++------ .../route.tsx | 94 +++++++++---------- 12 files changed, 129 insertions(+), 228 deletions(-) diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index e94fa768585..fd7b3c32f4c 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -20,33 +20,29 @@ const MAX_SVG_ELEMENT_BUDGET = 6_000; const MIN_DATA_POINTS = 100; const MAX_DATA_POINTS = 500; -// Width-aware x-axis label density for date-based line charts. We reserve room -// for the y-axis + margins, then fit one label per TIME_AXIS_LABEL_SPACING_PX of -// the remaining width. *** Lower TIME_AXIS_LABEL_SPACING_PX = denser labels. *** -// Rotated (-45°) date labels stay readable down to ~32px. Tunable. +// Width-aware x-axis label density for date-based line charts: reserve room for +// the y-axis + margins, then fit one label per TIME_AXIS_LABEL_SPACING_PX (smaller = denser). const TIME_AXIS_Y_ALLOWANCE_PX = 56; const TIME_AXIS_LABEL_SPACING_PX = 40; const MIN_TIME_AXIS_TICKS = 3; -// Categorical (non-date) x-axis label behavior. Labels are thinned to fit the -// width; long values (run IDs, task names, etc.) are middle-truncated and the -// axis auto-rotates so the chart stays neat without any customer configuration. +// Categorical (non-date) x-axis: thin labels to fit, middle-truncate long values +// (run IDs, task names), and auto-rotate when labels are long. const X_LABEL_PX_PER_CHAR = 6.5; const X_LABEL_GAP_PX = 16; const MIN_CATEGORICAL_LABEL_PX = 36; -// Labels longer than this rotate to -45° (auto-rotate "only when needed"). +// Labels longer than this rotate to -45°. const CATEGORICAL_HORIZONTAL_MAX_CHARS = 10; -// Middle-ellipsis cap applied to rotated labels (keeps the axis height bounded). +// Middle-ellipsis cap for rotated labels (bounds axis height). const CATEGORICAL_ROTATED_MAX_CHARS = 14; -// Rotated labels can sit closer together without overlapping than horizontal ones. +// Rotated labels pack tighter than horizontal ones. const CATEGORICAL_ROTATED_LABEL_PX = 32; const CATEGORICAL_ROTATED_HEIGHT_PX = 80; const MIN_CATEGORICAL_TICKS = 2; /** - * Shorten a string to `maxChars`, keeping the start and end with an ellipsis in - * the middle (e.g. "run_abc…f9c2"). Middle truncation is important for IDs that - * share a common prefix — the distinguishing tail is preserved. + * Shorten to `maxChars` with a middle ellipsis (e.g. "run_abc…f9c2"), preserving + * the distinguishing tail for IDs that share a prefix. */ export function truncateMiddle(value: string, maxChars: number): string { if (value.length <= maxChars) return value; @@ -832,14 +828,12 @@ export const QueryResultsChart = memo(function QueryResultsChart({ timeTicks, } = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]); - // Measure the rendered chart width so the x-axis label density adapts to it — - // same principle as the bar charts, applied to the line chart's nice-interval - // time ticks. (Continuous time scale is kept, so data gaps still show.) + // Measure the chart width so x-axis label density adapts to it (continuous time + // scale is kept, so data gaps still show). const [chartMeasureRef, { width: chartWidth }] = useMeasure(); - // Width-aware time-axis ticks: reuse the bar charts' "how many labels fit" - // estimate to choose how many clean intervals to render. Falls back to the - // default ticks until the width is known (first paint / SSR). + // Width-aware time-axis ticks: choose how many clean intervals to render based + // on width. Falls back to the default ticks until width is known (first paint). const widthAwareTimeTicks = useMemo(() => { if (!isDateBased || !timeDomain || !chartWidth) return timeTicks; const plotWidth = Math.max(0, chartWidth - TIME_AXIS_Y_ALLOWANCE_PX); @@ -1079,9 +1073,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ }; }, [isDateBased, xAxisTickFormatter, xAxisAngle]); - // Categorical (non-date) x-axis presentation: thin labels to fit the width, - // middle-truncate long values (run IDs, etc.), and auto-rotate only when the - // labels are long. Applies to both bar and line charts; zero customer config. + // Categorical x-axis: thin to fit width, middle-truncate long values, rotate when long. const categoricalXAxisProps = useMemo(() => { if (isDateBased) return null; @@ -1093,8 +1085,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ ? (value: unknown) => truncateMiddle(String(value ?? ""), CATEGORICAL_ROTATED_MAX_CHARS) : (value: unknown) => String(value ?? ""); - // Thin to as many labels as fit. Rotated labels pack tighter; horizontal - // labels need roughly their own width. + // Rotated labels pack tighter; horizontal ones need roughly their own width. const perLabelPx = angled ? CATEGORICAL_ROTATED_LABEL_PX : Math.max(MIN_CATEGORICAL_LABEL_PX, maxLabelChars * X_LABEL_PX_PER_CHAR + X_LABEL_GAP_PX); @@ -1168,12 +1159,12 @@ export const QueryResultsChart = memo(function QueryResultsChart({ ticks: widthAwareTimeTicks ?? undefined, ...baseXAxisProps, } - : categoricalXAxisProps ?? baseXAxisProps; + : (categoricalXAxisProps ?? baseXAxisProps); // Bar charts always use categorical axis positioning // This ensures bars are evenly distributed regardless of data point count // (prevents massive bars when there are only a few data points) - const xAxisPropsForBar = isDateBased ? baseXAxisProps : categoricalXAxisProps ?? baseXAxisProps; + const xAxisPropsForBar = isDateBased ? baseXAxisProps : (categoricalXAxisProps ?? baseXAxisProps); const yAxisProps = { tickFormatter: yAxisFormatter, diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index 3dc8514359f..53c20fbfde9 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -20,29 +20,15 @@ import { useXAxisTicks } from "./useXAxisTicks"; import { useChartSync } from "./ChartSyncContext"; import { ZoomTooltip, useZoomHandlers } from "./ChartZoom"; -// charcoal-500 — a subtle, dashed vertical line used to mirror the hovered x -// position across charts in the same ChartSyncProvider group. +// charcoal-500: dashed line mirroring the hovered x across synced charts. const SYNC_LINE_COLOR = "#5F6570"; -// Chart margins, shared with ChartLine so bar/line align when toggling between -// them. The right margin keeps the centered last x-axis label (e.g. "Jun 22") -// from being clipped; the bottom margin gives angled x-axis labels room. +// Shared with ChartLine so bar/line align when toggling. Right margin keeps the +// centered last x-axis label from clipping; bottom gives angled labels room. export const CHART_MARGIN = { top: 5, right: 20, bottom: 5, left: 5 } as const; -/** - * Tooltip shown while drag-to-zooming: the selected From/To range instead of the - * normal hovered-value tooltip. Uses the same cursor-following portal as the - * standard tooltip. - */ -function ZoomRangeTooltip({ - active, - from, - to, -}: { - active?: boolean; - from: string; - to: string; -}) { +/** While drag-to-zooming, show the selected From/To range instead of hovered values. */ +function ZoomRangeTooltip({ active, from, to }: { active?: boolean; from: string; to: string }) { if (!active) return null; return ( @@ -130,15 +116,13 @@ export function ChartBarRenderer({ const yAxisTickFormatter = yAxisPropsProp?.tickFormatter ?? defaultYAxisTickFormatter; const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisTickFormatter); - // Width-aware horizontal x-axis labels. Engaged only when the caller isn't - // controlling tick placement (no ticks/interval/angle), so callers like the - // query widget keep their custom (e.g. angled) axes. + // Width-aware horizontal labels, but only when the caller isn't already + // controlling ticks/interval/angle (e.g. the query widget's angled axes). const callerControlsXTicks = xAxisPropsProp?.ticks !== undefined || xAxisPropsProp?.interval !== undefined || xAxisPropsProp?.angle !== undefined; - // Plot width = full width minus the y-axis and horizontal margins, so the - // "how many labels fit" estimate matches the area labels are drawn in. + // Plot width = full width minus the y-axis and horizontal margins. const xAxisPlotWidth = width != null ? Math.max(0, width - computedYAxisWidth - CHART_MARGIN.left - CHART_MARGIN.right) @@ -162,7 +146,7 @@ export function ChartBarRenderer({ [enableZoom, zoom, dataKey] ); - // Handle mouse leave to also reset highlight + cancel any in-progress zoom drag + // Reset highlight and cancel any in-progress zoom drag on leave. const handleMouseLeave = useCallback(() => { zoomHandlers.onMouseLeave?.(); highlight.reset(); @@ -179,8 +163,8 @@ export function ChartBarRenderer({ return ; } - // Get the x-axis ticks based on tooltip state. - // Only hide middle ticks when zoom is enabled (to make room for zoom instructions). + // When zoom is enabled, collapse to first/last ticks during hover to make room + // for the zoom instructions. const zoomXAxisTicks = enableZoom && highlight.tooltipActive && data.length > 2 ? [data[0]?.[dataKey], data[data.length - 1]?.[dataKey]] @@ -192,16 +176,12 @@ export function ChartBarRenderer({ const baseXTicks = zoomXAxisTicks ?? (useAutoXTicks ? autoXTicks : undefined); const baseXInterval = useAutoXTicks ? 0 : ("preserveStartEnd" as const); - // Synced hover indicator (mirrored across charts in the same ChartSyncProvider). const syncActiveX = sync?.activeX ?? null; - // Synced drag-to-zoom selection (mirrored across charts). const syncZoomSelection = sync?.zoomSelection ?? null; // Bucket width so the committed zoom range includes the last selected bucket. - const bucketWidthMs = - data.length >= 2 ? Number(data[1][dataKey]) - Number(data[0][dataKey]) : 0; + const bucketWidthMs = data.length >= 2 ? Number(data[1][dataKey]) - Number(data[0][dataKey]) : 0; - // While dragging, show a From/To range tooltip instead of the hovered values. - // Reuse the chart's tooltip label formatter (it reads `bucket` off the payload). + // Reuse the tooltip label formatter for the From/To edges (it reads `bucket` off the payload). const formatZoomEdge = (v: number): string => tooltipLabelFormatter ? tooltipLabelFormatter("", [{ payload: { bucket: v } }]) : String(v); let zoomFrom: string | null = null; @@ -360,9 +340,8 @@ export function ChartBarRenderer({ /> )} - {/* Synced hover indicator: mirrored onto the *other* charts in the group, but - not the chart being hovered (it already shows its own tooltip cursor). - pointer-events-none so it never steals hover from the bar underneath it. */} + {/* Synced hover indicator: drawn on the *other* charts only (the hovered one + shows its own cursor); pointer-events-none so it never steals hover. */} {syncActiveX != null && !highlight.tooltipActive && ( ; both behaviours mirror across every chart: a hover indicator + * (vertical line at the hovered x), and drag-to-zoom (a selection rectangle that + * commits the range via `onZoom`, e.g. to set the Time/Date filter). * - * Wrap them in a single . It provides two synced behaviours, - * both mirrored across every chart in the group: - * - * 1. Hover indicator — hovering one chart draws a vertical line at the same x - * value on all of them. - * 2. Drag-to-zoom — click-dragging on any chart draws a selection rectangle on - * all of them; releasing commits the selected range via `onZoom` (used to set - * the Time/Date filter). - * - * Chart.Bar reads this context automatically (via useChartSync) — it is a no-op - * when no provider is present, so other pages are unaffected. Drag-to-zoom is - * only active when an `onZoom` callback is provided. - * - * Modeled on DateRangeContext (the existing cross-chart sync mechanism). + * Chart.Bar reads this via useChartSync, a no-op when no provider is present. + * Drag-to-zoom is active only when `onZoom` is provided. */ type ChartSyncXValue = number | string | null; diff --git a/apps/webapp/app/components/primitives/charts/activityTimeAxis.ts b/apps/webapp/app/components/primitives/charts/activityTimeAxis.ts index ead968bbce2..452988f44d7 100644 --- a/apps/webapp/app/components/primitives/charts/activityTimeAxis.ts +++ b/apps/webapp/app/components/primitives/charts/activityTimeAxis.ts @@ -1,14 +1,7 @@ /** - * Builds the x-axis tick + tooltip label formatters for the task/agent - * activity charts. Previously duplicated ~verbatim across the three task - * landing pages. - * - * ClickHouse buckets are aligned to UTC, so labels are formatted in UTC — - * using local time causes off-by-one day labels. - * - * Note: this only produces the label *formatters*. Which ticks are rendered - * (and how many, based on available width) is handled centrally by - * `useXAxisTicks` inside Chart.Bar. + * X-axis tick + tooltip label formatters for the task/agent activity charts. + * ClickHouse buckets are UTC-aligned, so labels are formatted in UTC (local time + * causes off-by-one day labels). Tick selection itself lives in useXAxisTicks. */ const ONE_MINUTE = 60 * 1000; @@ -20,10 +13,9 @@ export function buildActivityTimeAxis(data: ActivityPoint[]) { const range = data.length >= 2 ? data[data.length - 1].bucket - data[0].bucket : 0; const bucketMs = data.length >= 2 ? data[1].bucket - data[0].bucket : 0; - // ≤ 1 day range → show clock time, otherwise show the date. + // ≤ 1 day range → clock time, otherwise date. const showTime = range <= ONE_DAY; - // Sub-minute buckets need seconds in the label or many ticks collapse to the - // same "HH:MM". + // Sub-minute buckets need seconds, or adjacent ticks collapse to the same "HH:MM". const showSeconds = bucketMs > 0 && bucketMs < ONE_MINUTE; const isSubDayBucket = bucketMs > 0 && bucketMs < ONE_DAY; @@ -45,10 +37,7 @@ export function buildActivityTimeAxis(data: ActivityPoint[]) { }); }; - const tooltipLabelFormatter = ( - _label: string, - payload: { payload?: { bucket?: number } }[] - ) => { + const tooltipLabelFormatter = (_label: string, payload: { payload?: { bucket?: number } }[]) => { const ts = payload?.[0]?.payload?.bucket; if (typeof ts !== "number" || !Number.isFinite(ts)) return _label; const date = new Date(ts); diff --git a/apps/webapp/app/components/primitives/charts/statusColors.ts b/apps/webapp/app/components/primitives/charts/statusColors.ts index e33164db696..2ae7470a2d4 100644 --- a/apps/webapp/app/components/primitives/charts/statusColors.ts +++ b/apps/webapp/app/components/primitives/charts/statusColors.ts @@ -1,12 +1,4 @@ -/** - * Shared colors for the task/agent activity charts. Previously each task - * landing page (agent / standard / scheduled) defined its own identical - * `STATUS_COLOR` table. - * - * Keys are the grouped chart series — run-status groups (see - * `RUN_STATUS_GROUPS` in activitySeries.server.ts) plus the agent session - * statuses. - */ +/** Shared status → color map for the task/agent activity charts. */ export const STATUS_COLOR: Record = { // Run-status groups COMPLETED: "#28BF5C", @@ -19,7 +11,6 @@ export const STATUS_COLOR: Record = { EXPIRED: "#878C99", }; -/** Fallback for any status not in the table. */ export const STATUS_COLOR_FALLBACK = "#9CA3AF"; export function statusColor(status: string): string { diff --git a/apps/webapp/app/components/primitives/charts/useXAxisTicks.ts b/apps/webapp/app/components/primitives/charts/useXAxisTicks.ts index e03da98cb3d..e7a9aad10f5 100644 --- a/apps/webapp/app/components/primitives/charts/useXAxisTicks.ts +++ b/apps/webapp/app/components/primitives/charts/useXAxisTicks.ts @@ -1,18 +1,13 @@ import { useMemo } from "react"; -// Mirrors useYAxisWidth: at 11px tabular-nums, 1 char ≈ 6.5px. Labels use -// tabular-nums so character count is a faithful width proxy. +// At 11px tabular-nums, 1 char ≈ 6.5px, so character count is a width proxy (see useYAxisWidth). const PX_PER_CH = 6.5; -// Minimum horizontal breathing room between two adjacent labels. +// Minimum gap between adjacent labels. const LABEL_GAP_PX = 16; // Floor so very short labels still get some space. const MIN_LABEL_PX = 24; -/** - * Pick `count` indices evenly spaced across [0, n), always including the first - * and last. "Evenly spaced" here means even *screen* spacing on a band axis - * (each bucket occupies an equal slice of the plot width). - */ +/** Pick `count` indices evenly spaced across [0, n), always including the first and last. */ export function selectEvenlySpacedIndices(n: number, count: number): number[] { if (n <= 0) return []; if (count <= 1) return [0]; @@ -29,22 +24,17 @@ export function selectEvenlySpacedIndices(n: number, count: number): number[] { out.push(idx); } } - // Rounding can drop the final index — guarantee the last is present. + // Rounding can drop the final index; force it in. if (!seen.has(n - 1)) out.push(n - 1); return out; } -/** - * Pick `maxLabels` values evenly spaced across `values`, always including the - * first and last. - */ +/** Pick `maxLabels` values evenly spaced across `values`, always including the first and last. */ export function selectEvenlySpacedTicks(values: T[], maxLabels: number): T[] { return selectEvenlySpacedIndices(values.length, maxLabels).map((i) => values[i]); } -/** - * How many labels of `maxLabelChars` width fit in `width` pixels. - */ +/** How many labels of `maxLabelChars` width fit in `width` pixels. */ export function estimateMaxLabels(width: number, maxLabelChars: number): number { if (!width || width <= 0) return 0; const labelPx = Math.max(MIN_LABEL_PX, maxLabelChars * PX_PER_CH) + LABEL_GAP_PX; @@ -52,17 +42,10 @@ export function estimateMaxLabels(width: number, maxLabelChars: number): number } /** - * Compute the explicit x-axis tick values to render labels at, so they: - * - are evenly spaced across the plot (no crowding, even when the first/last - * bucket is a partial period), - * - never overlap (count is bounded by how many fit in `plotWidth`), - * - never repeat the same text (count is also bounded by the number of - * distinct labels), - * - stay horizontal and include the first + last bucket. - * - * `plotWidth` is the width of the plotting area (full width minus the y-axis and - * horizontal margins), so the "how many fit" estimate matches the area labels - * are actually drawn in. Returns `undefined` until a width is known (first paint). + * Explicit x-axis tick values: evenly spaced across the plot, including first + + * last, bounded by how many fit in `plotWidth` and by the count of distinct + * labels (so nothing overlaps or repeats). `plotWidth` excludes the y-axis and + * margins. Returns `undefined` until a width is known (first paint). */ export function useXAxisTicks( data: Array>, @@ -82,10 +65,8 @@ export function useXAxisTicks( if (label.length > maxChars) maxChars = label.length; } - // How many labels fit, capped at the number of distinct labels — there's no - // point reserving slots for more labels than there are unique values. The - // distinct cap is what keeps spacing even: we lay out N evenly-spaced labels - // rather than one-per-period (which crowds when the first period is partial). + // Cap at distinct labels: laying out N evenly-spaced labels (vs one-per-period) + // keeps spacing even when the first/last period is partial. const fit = estimateMaxLabels(plotWidth, maxChars); const distinct = new Set(labels).size; const target = Math.min(fit, distinct, n); diff --git a/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts b/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts index ab52783a256..fb7d1abcf95 100644 --- a/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts +++ b/apps/webapp/app/components/primitives/charts/useYAxisWidth.ts @@ -2,9 +2,8 @@ import { useMemo } from "react"; import { formatNumberCompact } from "~/utils/numberFormatter"; /** - * Default y-axis tick formatter for compound charts: compact notation so large - * values are abbreviated (8000 → "8K", 1_200_000 → "1.2M"). Applied by - * Chart.Bar / Chart.Line unless the caller supplies its own tickFormatter. + * Default y-axis tick formatter: compact notation (8000 → "8K", 1_200_000 → "1.2M"). + * Chart.Bar / Chart.Line use it unless the caller supplies its own tickFormatter. */ export const defaultYAxisTickFormatter = (value: any): string => typeof value === "number" ? formatNumberCompact(value) : String(value); diff --git a/apps/webapp/app/hooks/useZoomToTimeFilter.ts b/apps/webapp/app/hooks/useZoomToTimeFilter.ts index ffeea83a45d..b80318f6362 100644 --- a/apps/webapp/app/hooks/useZoomToTimeFilter.ts +++ b/apps/webapp/app/hooks/useZoomToTimeFilter.ts @@ -3,10 +3,9 @@ import { type ChartZoomRange } from "~/components/primitives/charts/ChartSyncCon import { useSearchParams } from "~/hooks/useSearchParam"; /** - * Returns an `onZoom` handler for chart drag-to-zoom that sets the Time/Date - * filter to the selected range. Mirrors how `TimeFilter` applies a custom range: - * epoch-ms `from`/`to`, clearing `period` (and pagination) so the page reloads - * scoped to the dragged window. + * `onZoom` handler that sets the Time/Date filter to the dragged range, the same + * way TimeFilter applies a custom range: epoch-ms `from`/`to`, clearing `period` + * and pagination so the page reloads scoped to that window. */ export function useZoomToTimeFilter() { const { replace } = useSearchParams(); diff --git a/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts index 3d3f74bb86c..5d4fa6914f8 100644 --- a/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts @@ -1,8 +1,5 @@ import { type ClickHouse } from "@internal/clickhouse"; -import { - type PrismaClientOrTransaction, - type RuntimeEnvironmentType, -} from "@trigger.dev/database"; +import { type PrismaClientOrTransaction, type RuntimeEnvironmentType } from "@trigger.dev/database"; import { z } from "zod"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { diff --git a/apps/webapp/app/presenters/v3/activitySeries.server.ts b/apps/webapp/app/presenters/v3/activitySeries.server.ts index adc087e76eb..ba2cbe322e5 100644 --- a/apps/webapp/app/presenters/v3/activitySeries.server.ts +++ b/apps/webapp/app/presenters/v3/activitySeries.server.ts @@ -1,36 +1,22 @@ -/** - * Shared helpers for the task/agent "activity" bar charts. - * - * These were previously duplicated across AgentDetailPresenter and - * TaskDetailPresenter (bucket-size ladder, run-status grouping, and the - * zero-fill loop). Centralising them fixes the "sub-hour range renders one - * 1h bar" problem in one place and keeps the three task landing pages - * consistent. - */ +/** Shared bucketing + zero-fill helpers for the task/agent activity bar charts. */ -// Nice, human-friendly bucket intervals (seconds). toStartOfInterval accepts -// any integer, but snapping to these keeps tick boundaries readable. +// Snap bucket intervals to human-friendly values (1s…7d) so tick boundaries stay readable. const NICE_BUCKET_SECONDS = [ - 1, 5, 10, 15, 30, // sub-minute - 60, 120, 300, 600, 900, 1800, // 1m, 2m, 5m, 10m, 15m, 30m - 3600, 7200, 10800, 21600, 43200, // 1h, 2h, 3h, 6h, 12h - 86400, 172800, 604800, // 1d, 2d, 7d + 1, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800, + 604800, ] as const; export type ChooseBucketOptions = { - /** Bucket count we aim for — produces a chart that looks "full". */ + /** Bucket count to aim for (a "full"-looking chart). */ targetBuckets?: number; - /** Hard ceiling so we never emit sub-pixel bars / huge result sets. */ + /** Hard ceiling so we never emit sub-pixel bars or huge result sets. */ maxBuckets?: number; }; /** - * Choose a bucket interval (in seconds) for a time range so the chart renders - * a sensible number of bars regardless of how short or long the range is. - * - * Picks the nice interval whose resulting bucket count is closest to - * `targetBuckets` without exceeding `maxBuckets`. A 5-minute range becomes - * ~5s buckets (≈60 bars) instead of a single 1-hour bar. + * Pick a bucket interval (seconds): the nice value whose bucket count is closest + * to `targetBuckets` without exceeding `maxBuckets`. Keeps a 5-minute range from + * collapsing to a single 1-hour bar. */ export function chooseBucketSeconds( rangeMs: number, @@ -42,7 +28,7 @@ export function chooseBucketSeconds( let bestScore = Infinity; for (const secs of NICE_BUCKET_SECONDS) { const count = rangeSeconds / secs; - if (count > maxBuckets) continue; // too many bars + if (count > maxBuckets) continue; const score = Math.abs(count - targetBuckets); if (score < bestScore) { bestScore = score; @@ -50,8 +36,7 @@ export function chooseBucketSeconds( } } - // No nice interval keeps us under maxBuckets (range larger than the ladder) — - // compute one that respects the ceiling. + // Range larger than the ladder: derive an interval that respects the ceiling. if (best === null) { return Math.ceil(rangeSeconds / maxBuckets); } @@ -99,9 +84,9 @@ function bucketBounds(from: Date, to: Date, bucketSeconds: number) { } /** - * Build a zero-filled, grouped time series. Every bucket across [from, to) is - * emitted (even empty ones) and every key in `orderedKeys` is present on every - * point, so the chart renders contiguous bars and a stable legend. + * Zero-filled grouped series: every bucket in [from, to) is emitted and every + * `orderedKeys` entry is present on each point, for contiguous bars and a stable + * legend. */ export function zeroFillGroupedSeries({ rows, @@ -117,9 +102,9 @@ export function zeroFillGroupedSeries({ to: Date; bucketSeconds: number; orderedKeys: readonly K[]; - /** Maps a raw status to a key. Defaults to identity (status === key). */ + /** Maps a raw status to a key; defaults to identity. */ groupFn?: (status: string) => K | undefined; - /** Key to use when groupFn returns undefined (e.g. unknown statuses). */ + /** Key for statuses groupFn doesn't map (e.g. unknown statuses). */ fallbackKey?: K; }): ActivitySeriesPoint[] { const bucketMap = new Map>(); @@ -143,7 +128,7 @@ export function zeroFillGroupedSeries({ return points; } -/** Build a zero-filled single-series (scalar) time series. */ +/** Zero-filled single-series (scalar) time series. */ export function zeroFillScalarSeries({ rows, from, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx index d41061bc400..75e2de21a96 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx @@ -277,65 +277,65 @@ export default function Page() {
- - {tab === "sessions" ? ( + + {tab === "sessions" ? ( + }> + } + > + {(result) => } + + + ) : ( + }> + } + > + {(result) => } + + + )} + + + }> } > - {(result) => } + {(result) => ( + + )} - ) : ( + + + }> } > - {(result) => } + {(result) => ( + + )} - )} - - - - }> - } - > - {(result) => ( - - )} - - - - - - }> - } - > - {(result) => ( - - )} - - - +
From 7c028fb14e1cca9bd61881d2499088fdfed434b6 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 28 Jun 2026 16:32:01 +0100 Subject: [PATCH 8/9] fix(webapp): address chart review feedback (a11y maximize button, last x-axis tick) Give the chart maximize button an aria-label and reveal it on focus as well as hover, so keyboard and screen-reader users get a reachable, named control. Preserve the final x-axis tick when pruning duplicate labels (now extracted as dedupeTicksByLabel) so the end-of-range label is never dropped. --- .../primitives/charts/ChartCard.tsx | 3 +- .../primitives/charts/useXAxisTicks.ts | 31 ++++++++++++++----- apps/webapp/test/chartXAxisTicks.test.ts | 29 +++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/components/primitives/charts/ChartCard.tsx b/apps/webapp/app/components/primitives/charts/ChartCard.tsx index d31cf6b9888..507092e40b5 100644 --- a/apps/webapp/app/components/primitives/charts/ChartCard.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartCard.tsx @@ -55,10 +55,11 @@ export function ChartCard({ +