From 2a22896c71197be3f9bf31edc94c8b0e5691e3a6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 19 Jul 2026 22:42:24 +0000 Subject: [PATCH 01/37] =?UTF-8?q?feat(webapp):=20queue=20pages=20design=20?= =?UTF-8?q?overhaul=20=E2=80=94=20live=20blocks,=20charts,=20per-tenant=20?= =?UTF-8?q?drilldown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queues list: header charts (env saturation, backlog, delay p95, throttled) with synced hover and drag-to-zoom into the time filter, live Queued/Running blocks polled from ClickHouse, sortable columns, per-queue pause/override controls, and clearer readouts (duration/percent axes, share-of-window throttled, hover explanations). Queue detail: live Concurrency/Queued/Oldest-wait blocks (CH-polled, with window peak/worst captions), pause/override/view-runs actions on the blocks, four square trend charts, a Concurrency keys tab with per-key charts, a sortable keys table, key search, per-key drilldown, and the live most-starved key named on the worst-wait card. Shared: small-icon button size, chart sync isolation for maximized charts, tooltip flicker/positioning fixes, queues-purple chart palette, hidden-tab polling pause, 30-day period cap, and a synthetic metrics seeder for the design environment. --- .../queue-detail-pause-override.md | 6 + .../queue-pages-live-and-clarity.md | 6 + .../components/metrics/ActivityBarChart.tsx | 82 + .../app/components/metrics/BigNumber.tsx | 8 + .../app/components/metrics/MiniLineChart.tsx | 194 +++ .../app/components/primitives/Buttons.tsx | 10 + .../app/components/primitives/Table.tsx | 85 +- .../components/primitives/TooltipPortal.tsx | 24 +- .../primitives/charts/ChartCard.tsx | 13 +- .../primitives/charts/ChartLine.tsx | 448 ++++- .../primitives/charts/ChartSyncContext.tsx | 3 + .../app/components/primitives/useTableSort.ts | 130 ++ .../app/components/queues/QueueControls.tsx | 361 ++++ .../components/queues/QueueMetricCards.tsx | 38 +- .../app/hooks/useMetricResourceQuery.ts | 8 +- .../webapp/app/models/queueMutation.server.ts | 153 ++ .../v3/QueueAllocationPresenter.server.ts | 106 +- .../v3/QueueListPresenter.server.ts | 23 +- .../v3/QueueMetricsPresenter.server.ts | 29 +- .../route.tsx | 74 +- .../AllocationView.tsx | 490 ------ .../route.tsx | 1463 ++++++++--------- .../route.tsx | 728 ++++++-- ...dmin.api.v1.environments.$environmentId.ts | 4 + ...api.v1.orgs.$organizationId.concurrency.ts | 7 + ...queues.$queueParam.concurrency.override.ts | 25 +- .../route.tsx | 2 +- .../app/routes/storybook.charts/route.tsx | 152 ++ .../v3/services/allocateConcurrency.server.ts | 4 + .../v3/services/concurrencySystem.server.ts | 126 +- apps/webapp/seed-queue-metrics.mts | 35 + .../concurrencySystemPercentOverride.test.ts | 307 ++++ apps/webapp/test/useTableSort.test.ts | 55 + .../clickhouse/src/queueMetrics.ts | 14 +- .../migration.sql | 5 + .../database/prisma/schema.prisma | 14 +- 36 files changed, 3564 insertions(+), 1668 deletions(-) create mode 100644 .server-changes/queue-detail-pause-override.md create mode 100644 .server-changes/queue-pages-live-and-clarity.md create mode 100644 apps/webapp/app/components/metrics/ActivityBarChart.tsx create mode 100644 apps/webapp/app/components/metrics/MiniLineChart.tsx create mode 100644 apps/webapp/app/components/primitives/useTableSort.ts create mode 100644 apps/webapp/app/components/queues/QueueControls.tsx create mode 100644 apps/webapp/app/models/queueMutation.server.ts delete mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/AllocationView.tsx create mode 100644 apps/webapp/test/concurrencySystemPercentOverride.test.ts create mode 100644 apps/webapp/test/useTableSort.test.ts create mode 100644 internal-packages/database/prisma/migrations/20260716120000_add_task_queue_concurrency_limit_override_percent/migration.sql diff --git a/.server-changes/queue-detail-pause-override.md b/.server-changes/queue-detail-pause-override.md new file mode 100644 index 00000000000..4a9b9b6ee18 --- /dev/null +++ b/.server-changes/queue-detail-pause-override.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +You can now pause or resume a queue and override its concurrency limit directly from the queue's detail page, and jump straight to its queued runs. diff --git a/.server-changes/queue-pages-live-and-clarity.md b/.server-changes/queue-pages-live-and-clarity.md new file mode 100644 index 00000000000..3462a008678 --- /dev/null +++ b/.server-changes/queue-pages-live-and-clarity.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +The queue pages now keep their live numbers fresh automatically (updates pause while the tab is in the background), and the charts are easier to read: clearer axis labels, hover explanations for the headline stats, and the busiest concurrency key is named right on the chart. diff --git a/apps/webapp/app/components/metrics/ActivityBarChart.tsx b/apps/webapp/app/components/metrics/ActivityBarChart.tsx new file mode 100644 index 00000000000..4e361b224b8 --- /dev/null +++ b/apps/webapp/app/components/metrics/ActivityBarChart.tsx @@ -0,0 +1,82 @@ +import { type ReactElement, type ReactNode } from "react"; +import { BarChart, ReferenceLine, Tooltip, YAxis } from "recharts"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; + +// Fixed px dims skip ResponsiveContainer's ResizeObserver — otherwise every panel resize +// re-renders all the charts in a list at once. +export const ACTIVITY_CHART_WIDTH = 112; +export const ACTIVITY_CHART_HEIGHT = 24; +export const ACTIVITY_CHART_PEAK_CLASS = + "-mt-1 inline-block min-w-7 text-xxs tabular-nums text-text-dimmed"; + +type ActivityBarChartProps = { + /** Recharts row data; each row is a bucket. The bar `children` read their `dataKey`s off it. */ + data: ReadonlyArray>; + /** Y-axis domain top and the height of the dashed peak line. */ + max: number; + /** The `` element(s) — one stacked series per bar, or a single bar with ``s. */ + children: ReactNode; + /** Recharts `` element for the per-bucket hover card. */ + tooltip: ReactElement; + /** Trailing peak label shown to the right of the chart. */ + peak: ReactNode; + /** Optional tooltip wrapping the peak label. */ + peakTooltip?: ReactNode; + /** Chart width in px. Defaults to the shared ACTIVITY_CHART_WIDTH. */ + width?: number; +}; + +/** + * Shared visual frame for the inline activity/backlog mini bar charts (tasks page + queues list). + * Owns the fixed dimensions, y-axis, hover tooltip, baseline, dashed peak line, and the trailing + * peak label — the single source of truth for how these charts look. Callers supply the bars and + * the tooltip content, which is where the two usages differ (stacked per-status vs. single series). + */ +export function ActivityBarChart({ + data, + max, + children, + tooltip, + peak, + peakTooltip, + width = ACTIVITY_CHART_WIDTH, +}: ActivityBarChartProps) { + return ( +
+
+ []} + width={width} + height={ACTIVITY_CHART_HEIGHT} + margin={{ top: 0, right: 0, left: 0, bottom: 0 }} + > + + + {children} + + {max > 0 && ( + + )} + +
+ {peak} +
+ ); +} + +function ActivityPeakLabel({ tooltip, children }: { tooltip?: ReactNode; children: ReactNode }) { + const label = {children}; + if (!tooltip) return label; + return ; +} diff --git a/apps/webapp/app/components/metrics/BigNumber.tsx b/apps/webapp/app/components/metrics/BigNumber.tsx index db98668e22f..f9e0ecdb5c5 100644 --- a/apps/webapp/app/components/metrics/BigNumber.tsx +++ b/apps/webapp/app/components/metrics/BigNumber.tsx @@ -11,6 +11,8 @@ interface BigNumberProps { animate?: boolean; loading?: boolean; value?: number; + /** Pre-formatted display value; overrides the numeric `value` rendering when set. */ + formattedValue?: ReactNode; valueClassName?: string; defaultValue?: number; accessory?: ReactNode; @@ -22,6 +24,7 @@ interface BigNumberProps { export function BigNumber({ title, value, + formattedValue, defaultValue, valueClassName, suffix, @@ -50,6 +53,11 @@ export function BigNumber({ > {loading ? ( + ) : formattedValue !== undefined ? ( +
+ {formattedValue} + {suffix &&
{suffix}
} +
) : v !== undefined ? (
{shouldCompact ? ( diff --git a/apps/webapp/app/components/metrics/MiniLineChart.tsx b/apps/webapp/app/components/metrics/MiniLineChart.tsx new file mode 100644 index 00000000000..9d561a92182 --- /dev/null +++ b/apps/webapp/app/components/metrics/MiniLineChart.tsx @@ -0,0 +1,194 @@ +import { type ReactNode } from "react"; +import { Line, LineChart, ReferenceLine, Tooltip, type TooltipProps, YAxis } from "recharts"; +import { formatDateTime } from "~/components/primitives/DateTime"; +import { Header3 } from "~/components/primitives/Headers"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import TooltipPortal from "~/components/primitives/TooltipPortal"; +import { + ACTIVITY_CHART_HEIGHT, + ACTIVITY_CHART_PEAK_CLASS, + ACTIVITY_CHART_WIDTH, +} from "./ActivityBarChart"; + +type UnitLabel = { singular: string; plural: string }; + +/** Extra px above the plot so the hover activeDot at the peak value isn't clipped by the SVG edge. */ +const DOT_HEADROOM = 3; + +type MiniLineChartDatum = { + date: Date; + count: number; + /** Raw per-bucket throttled count (tooltip). */ + throttledCount: number; + /** The queued value again, present only around throttled buckets, so the warning overlay + * retraces the same line and reads as one line changing colour. */ + throttledOverlay: number | null; +}; + +export type MiniLineChartProps = { + /** Equal-width time buckets, oldest first. */ + data?: number[]; + /** + * Per-bucket throttled counts aligned 1:1 with `data`. Where throttling occurred, the queued + * line itself is retraced in the warning colour — one line that changes colour, with the + * throttled magnitude carried by the tooltip. + */ + throttled?: number[]; + /** Epoch ms of the first bucket's start. When omitted, the last bucket is anchored to now. */ + bucketStartMs?: number; + /** Width of each bucket in ms. Defaults to one hour. */ + bucketIntervalMs?: number; + /** Line colour for the queued series. */ + color?: string; + /** Trailing peak scalar shown after the chart. Defaults to the max of the buckets. */ + peak?: number; + /** Format the trailing peak label. Defaults to `toLocaleString`. */ + formatPeak?: (peak: number) => string; + /** Tooltip content shown on hover of the trailing peak label. */ + peakTooltip?: ReactNode; + /** Unit shown in the per-bucket tooltip (e.g. queued, runs). */ + unitLabel?: UnitLabel; + /** Chart width in px. Defaults to the shared ACTIVITY_CHART_WIDTH. */ + width?: number; + /** Show the trailing peak label to the right of the chart. Defaults to true. */ + showPeak?: boolean; +}; + +/** + * Inline fixed-size mini line sparkline for list rows, plus a trailing peak label. Presentational — + * the caller supplies zero/carry-forward-filled buckets. Renders an em-dash when there's no data. + * The queued series is a thin monotone line (no dots) matching the big Backlog chart; stretches + * where the queue was throttled retrace the same line in the warning colour. Shares its fixed + * dimensions and trailing peak label with {@link ActivityBarChart}, but plots lines instead of bars. + */ +export function MiniLineChart({ + data, + throttled, + bucketStartMs, + bucketIntervalMs, + color = "var(--color-tasks)", + peak: peakOverride, + formatPeak, + peakTooltip, + unitLabel = { singular: "value", plural: "values" }, + width = ACTIVITY_CHART_WIDTH, + showPeak = true, +}: MiniLineChartProps) { + const hasPeakOverride = peakOverride !== undefined; + if (!data || data.length === 0 || (data.every((v) => v === 0) && !hasPeakOverride)) { + return ; + } + + // The overlay only draws where throttling happened. Mapping other buckets to null leaves gaps so + // a wholly-zero throttled series never paints over the queued line. + const hasThrottled = throttled?.some((v) => v > 0) ?? false; + + const max = Math.max(...data); + const peak = peakOverride ?? max; + + // Map each bucket to a dated point so the tooltip can show the window it represents. Buckets are + // `intervalMs` wide; if the caller didn't pass the first bucket's start, anchor the last bucket to + // now (hourly default). + const intervalMs = bucketIntervalMs ?? 3600_000; + const startMs = bucketStartMs ?? Date.now() - (data.length - 1) * intervalMs; + const chartData: MiniLineChartDatum[] = data.map((count, i) => { + const t = throttled?.[i] ?? 0; + // Extend the mask one bucket forward (a segment needs both endpoints non-null), so even a + // single throttled bucket draws a visible warning stretch. + const inOverlay = t > 0 || (throttled?.[i - 1] ?? 0) > 0; + return { + date: new Date(startMs + i * intervalMs), + count, + throttledCount: t, + throttledOverlay: inOverlay ? count : null, + }; + }); + + return ( +
+ {/* +DOT_HEADROOM of extra height, spent as top margin, so the hover activeDot at the peak + isn't clipped by the SVG edge while the plotted area stays ACTIVITY_CHART_HEIGHT tall. */} +
+ {/* Fixed px dims skip ResponsiveContainer's ResizeObserver — see ActivityBarChart. */} + + + } + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 1000 }} + animationDuration={0} + /> + + + {hasThrottled && ( + + )} + +
+ {showPeak && ( + + {formatPeak ? formatPeak(peak) : peak.toLocaleString()} + + )} +
+ ); +} + +function MiniLinePeakLabel({ tooltip, children }: { tooltip?: ReactNode; children: ReactNode }) { + const label = {children}; + if (!tooltip) return label; + return ; +} + +function MiniLineChartTooltip({ + active, + payload, + unitLabel, +}: TooltipProps & { unitLabel: UnitLabel }) { + if (!active || !payload || payload.length === 0) return null; + const entry = payload[0].payload as MiniLineChartDatum; + const date = entry.date instanceof Date ? entry.date : new Date(entry.date); + const formattedDate = formatDateTime(date, "UTC", [], false, true); + const throttled = entry.throttledCount; + return ( + +
+ {formattedDate} +
+ {entry.count.toLocaleString()}{" "} + + {entry.count === 1 ? unitLabel.singular : unitLabel.plural} + +
+ {throttled > 0 && ( +
+ {throttled.toLocaleString()} throttled +
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 323f3743826..5686fe4d333 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -22,6 +22,15 @@ const sizes = { shortcutVariant: "small" as const, shortcut: "-ml-0.5 -mr-1.5 justify-self-center", }, + // Icon-only small button: fixed width so a row of icon buttons (with different icon + // aspect ratios) lines up, e.g. the queue block accessories. + "small-icon": { + button: "h-6 min-w-[34px] px-2 text-xs", + icon: "h-3.5 -mx-1", + iconSpacing: "gap-x-2.5", + shortcutVariant: "small" as const, + shortcut: "-ml-0.5 -mr-1.5 justify-self-center", + }, medium: { button: "h-8 px-3 text-sm", icon: "h-4 -mx-1", @@ -118,6 +127,7 @@ const variant = { "primary/large": createVariant("large", "primary"), "primary/extra-large": createVariant("extra-large", "primary"), "secondary/small": createVariant("small", "secondary"), + "secondary/small-icon": createVariant("small-icon", "secondary"), "secondary/medium": createVariant("medium", "secondary"), "secondary/large": createVariant("large", "secondary"), "secondary/extra-large": createVariant("extra-large", "secondary"), diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index 2b740fac0b8..9ef4976d424 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -1,3 +1,4 @@ +import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid"; import { ChevronRightIcon } from "@heroicons/react/24/solid"; import { Link } from "@remix-run/react"; import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react"; @@ -181,6 +182,15 @@ type TableHeaderCellProps = TableCellBasicProps & { hiddenLabel?: boolean; tooltip?: ReactNode; disableTooltipHoverableContent?: boolean; + /** + * When set (together with `onSort`), the header renders a sort indicator and becomes clickable. + * `"asc"`/`"desc"` show the active direction; `null` shows the neutral (unsorted) affordance. + * This cell is presentational and fully controlled — the parent owns the sort state (see + * `useTableSort`). + */ + sortDirection?: "asc" | "desc" | null; + /** Invoked when the header is clicked or activated via keyboard. Enables sorting when provided. */ + onSort?: () => void; }; export const TableHeaderCell = forwardRef( @@ -193,6 +203,8 @@ export const TableHeaderCell = forwardRef { @@ -207,12 +219,48 @@ export const TableHeaderCell = forwardRef{children} : children; + + const tooltipNode = tooltip ? ( + + ) : null; + + const sortIndicator = sortable ? ( + + {sortDirection === "asc" ? ( + + ) : sortDirection === "desc" ? ( + + ) : ( + + )} + + ) : null; + + const rowClassName = cn("flex items-center gap-1", { + "justify-center": alignment === "center", + "justify-end": alignment === "right", + }); return ( setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > - {hiddenLabel ? ( - {children} - ) : tooltip ? ( -
- {children} - + {label} + {tooltipNode} + {sortIndicator} + + ) : tooltip ? ( +
+ {label} + {tooltipNode}
) : ( - children + label )} ); diff --git a/apps/webapp/app/components/primitives/TooltipPortal.tsx b/apps/webapp/app/components/primitives/TooltipPortal.tsx index e389a9e5e72..011c5aae7ef 100644 --- a/apps/webapp/app/components/primitives/TooltipPortal.tsx +++ b/apps/webapp/app/components/primitives/TooltipPortal.tsx @@ -9,6 +9,22 @@ import useLazyRef from "~/hooks/useLazyRef"; // Recharts 3.x will have portal support, but until then we're using this: //https://github.com/recharts/recharts/issues/2458#issuecomment-1063463873 +// A portal only mounts once its tooltip is active, so its own mousemove listener attaches too late +// to know where the cursor already is — the tooltip would sit at {0,0} (top-left of the page) until +// the next mouse movement. Track the pointer globally so a newly-activated tooltip can seed its +// position immediately. +const lastPointer = { x: 0, y: 0 }; +if (typeof window !== "undefined") { + window.addEventListener( + "mousemove", + (e) => { + lastPointer.x = e.clientX; + lastPointer.y = e.clientY; + }, + { passive: true } + ); +} + export interface PopperPortalProps { active?: boolean; children: ReactNode; @@ -40,8 +56,11 @@ export default function TooltipPortal({ active = true, children }: PopperPortalP useEffect(() => { if (!active) return; + // Seed from the last known pointer so the tooltip appears at the cursor immediately, even if the + // mouse is held still after hovering onto a point (otherwise it flashes in the top-left corner). + virtualElementRef.current?.update(lastPointer.x, lastPointer.y); update?.(); - }, [active, update]); + }, [active, update, virtualElementRef]); if (!portalElement) return null; @@ -53,6 +72,9 @@ export default function TooltipPortal({ active = true, children }: PopperPortalP ...styles.popper, zIndex: 1000, display: active ? "block" : "none", + // The tooltip sits just under the cursor; without this, moving along the line drags the + // cursor onto the tooltip, which fires the chart's mouseleave and flickers it off/on. + pointerEvents: "none", }} > {children} diff --git a/apps/webapp/app/components/primitives/charts/ChartCard.tsx b/apps/webapp/app/components/primitives/charts/ChartCard.tsx index 367bb703ea4..c008db80692 100644 --- a/apps/webapp/app/components/primitives/charts/ChartCard.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartCard.tsx @@ -7,6 +7,7 @@ import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { cn } from "~/utils/cn"; import { Dialog, DialogContent, DialogHeader } from "../Dialog"; import { Card } from "./Card"; +import { ChartSyncProvider, useChartSync } from "./ChartSyncContext"; type ChartCardProps = { /** Title shown in the card header (and the fullscreen dialog header). */ @@ -34,6 +35,10 @@ export function ChartCard({ }: ChartCardProps) { const [isFullscreen, setIsFullscreen] = useState(false); const containerRef = useRef(null); + // A maximized chart is its own sync group: hover + drag-select shouldn't mirror onto the + // (hidden) sibling charts behind the dialog. Give it a fresh provider with isolated state, + // but inherit the page group's onZoom so drag-to-zoom still sets the time filter. + const parentSync = useChartSync(); // "v" toggles fullscreen for the hovered card. useShortcutKeys({ @@ -85,7 +90,13 @@ export function ChartCard({ {title}
- {fullscreenChildren ?? children} + {parentSync ? ( + + {fullscreenChildren ?? children} + + ) : ( + (fullscreenChildren ?? children) + )}
diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index f19750a7e75..e32122f3262 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -1,9 +1,11 @@ +import { useId } from "react"; import { Area, AreaChart, CartesianGrid, Line, LineChart, + ReferenceArea, ReferenceLine, XAxis, YAxis, @@ -11,13 +13,23 @@ import { type YAxisProps, } from "recharts"; import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart"; +import TooltipPortal from "~/components/primitives/TooltipPortal"; import { CHART_MARGIN } from "./ChartBar"; import { useChartContext } from "./ChartContext"; import { ChartLineInvalid, ChartLineLoading, ChartLineNoData } from "./ChartLoading"; import { useHasNoData } from "./ChartRoot"; +import { useChartSync } from "./ChartSyncContext"; import { defaultYAxisTickFormatter, useYAxisWidth } from "./useYAxisWidth"; // Legend is now rendered by ChartRoot outside the chart container +// Dashed line mirroring the hovered x across synced charts. +const SYNC_LINE_COLOR = "var(--color-text-faint)"; + +// Data key the warning overlay line is plotted under. Injected into the render data only; never +// added to config/series, so it stays out of the legend, no-data check and series totals. Deduped +// out of the tooltip so a hovered bucket shows one value, not the base + overlay retrace. +const WARNING_OVERLAY_KEY = "__warningOverlay"; + type CurveType = | "basis" | "basisClosed" @@ -32,6 +44,34 @@ type CurveType = | "stepBefore" | "stepAfter"; +/** 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 ( + +
+ From: + {from} + To: + {to} +
+
+ ); +} + +// Stable module-level tooltip for warning-overlay charts: drops the overlay's retraced entry so a +// hovered bucket shows one value (base), not base + overlay. Defined at module scope (not inline in +// the renderer) so recharts reconciles it in place across hover re-renders instead of remounting +// the portaled tooltip — the latter caused a flicker while moving along the line. +function OverlayFilteredTooltip(props: any) { + return ( + p.dataKey !== WARNING_OVERLAY_KEY)} + /> + ); +} + // ============================================================================ // COMPOUND COMPONENT API // ============================================================================ @@ -51,40 +91,112 @@ export type ChartLineRendererProps = { tooltipValueFormatter?: (value: number) => string; /** Draw a dot at each data point. Defaults to true; turn off for dense/compact charts. */ showDots?: boolean; - /** Horizontal reference lines (e.g. limits); the y-domain extends to include them. */ - referenceLines?: Array<{ y: number; label?: string; color?: string }>; + /** + * Horizontal reference lines (e.g. limits); the y-domain extends to include them. + * + * `labelPlacement` controls where the label sits relative to the plot: + * - `"inside"` (default): right-aligned just below the line, inside the plot area. + * - `"outside"`: in the right gutter at the line's y. The chart's right margin is widened + * automatically so outside labels are not clipped by the SVG viewport. + */ + referenceLines?: Array<{ + y: number; + label?: string; + color?: string; + labelPlacement?: "inside" | "outside"; + }>; + /** + * Recolor the stroke above a threshold value (e.g. an over-limit warning). The y-domain is + * pinned so the gradient split lines up exactly with the plotted values and reference lines. + * Single-series (non-stacked) line charts only. + * + * Prefer {@link warningOverlay} when the threshold sits far below the data maximum: a gradient + * split whose boundary collapses onto the baseline paints zero/near-zero buckets the warning + * colour. The overlay retraces per-bucket instead, so under-threshold buckets always stay blue. + */ + thresholdStroke?: { value: number; aboveColor: string }; + /** + * Per-bucket warning recolour: a series is retraced in the warning colour only across buckets + * where it crosses a limit — either strictly above a constant `threshold` (single-series case, + * applied to the first series), or where one series drops below another (`series` below `below`, + * e.g. started < enqueued = "not keeping up"). The mask extends one bucket forward so a lone + * crossing still yields a visible segment. Unlike {@link thresholdStroke}'s gradient split, + * non-crossing buckets always stay the base colour. The overlay is excluded from the legend and + * deduped out of the tooltip. Non-stacked line charts only. + */ + warningOverlay?: + | { threshold: number; color?: string } + | { series: string; below: string; color?: string }; /** Width injected by ResponsiveContainer */ width?: number; /** Height injected by ResponsiveContainer */ height?: number; }; -/** Reference-line label: right-aligned just below the line (recharts injects viewBox). */ +/** Font size used for reference-line labels; also used to size the outside-label gutter. */ +const REFERENCE_LABEL_FONT_SIZE = 10; + +/** + * Reference-line label (recharts injects viewBox). + * - `"inside"`: right-aligned just below the line, inside the plot. + * - `"outside"`: left-aligned in the right gutter, vertically centered on the line. + */ function ReferenceLineLabel({ viewBox, value, + placement = "inside", }: { viewBox?: { x: number; y: number; width: number }; value: string; + placement?: "inside" | "outside"; }) { if (!viewBox) return null; + if (placement === "outside") { + return ( + + {value} + + ); + } return ( {value} ); } +/** + * Extra right margin (px) needed so outside reference-line labels aren't clipped by the SVG + * viewport. Estimates label width from character count; returns 0 when no label is outside-placed. + */ +function outsideLabelGutter(referenceLines: ChartLineRendererProps["referenceLines"]): number { + const outside = (referenceLines ?? []).filter((l) => l.labelPlacement === "outside" && l.label); + if (outside.length === 0) return 0; + const maxChars = Math.max(...outside.map((l) => l.label!.length)); + // ~0.62em per char at this font size, plus padding on both sides of the label. + return Math.ceil(maxChars * REFERENCE_LABEL_FONT_SIZE * 0.62) + 12; +} + /** * Line chart renderer for the compound component system. * Must be used within a Chart.Root. * + * When wrapped in a , participates in the group's shared hover + * indicator and drag-to-zoom (mirrors Chart.Bar; a no-op when no provider is present). + * * @example * ```tsx * @@ -102,6 +214,8 @@ export function ChartLineRenderer({ tooltipValueFormatter, showDots = true, referenceLines, + thresholdStroke, + warningOverlay, width, height, }: ChartLineRendererProps) { @@ -117,6 +231,9 @@ export function ChartLineRenderer({ showLegend, } = useChartContext(); const hasNoData = useHasNoData(); + const sync = useChartSync(); + // Strip the colons React injects (":r1:") so the id is safe inside an SVG url(#…) reference. + const gradientId = `line-threshold-${useId().replace(/:/g, "")}`; const yAxisTickFormatter = yAxisPropsProp?.tickFormatter ?? defaultYAxisTickFormatter; const computedYAxisWidth = useYAxisWidth(data, visibleSeries, yAxisTickFormatter); @@ -150,6 +267,58 @@ export function ChartLineRenderer({ ...xAxisPropsProp, }; + // A threshold stroke needs an exact, fixed y-domain so the gradient split aligns with the + // plotted values and the reference lines. Compute it from the data + reference/threshold ys. + let thresholdDomain: [number, number] | undefined; + let thresholdOffset = 0; + if (thresholdStroke && !stacked) { + let dataMax = thresholdStroke.value; + for (const row of data) { + for (const key of visibleSeries) { + const v = Number(row[key]); + if (Number.isFinite(v) && v > dataMax) dataMax = v; + } + } + for (const line of referenceLines ?? []) { + if (Number.isFinite(line.y) && line.y > dataMax) dataMax = line.y; + } + const domainMax = dataMax > 0 ? dataMax * 1.1 : 1; + thresholdDomain = [0, domainMax]; + // Gradient runs top (offset 0 = domainMax) to bottom (offset 1 = 0); the split sits where + // the threshold value falls within the domain. + thresholdOffset = Math.min(1, Math.max(0, (domainMax - thresholdStroke.value) / domainMax)); + } + + // Per-bucket warning overlay: single-series line charts only. Retrace the primary series in the + // warning colour, non-null only across over-threshold stretches. Include the immediate neighbours + // of an over-threshold bucket (both endpoints of a segment must be non-null), so the crossing + // segment on BOTH sides is drawn — the colour change tracks the axis crossing symmetrically, and + // a lone over-threshold bucket still yields a visible segment. + const overlayKey = + warningOverlay && !stacked + ? "series" in warningOverlay + ? warningOverlay.series + : visibleSeries[0] + : undefined; + const overlayActive = overlayKey != null; + const chartData = overlayActive + ? data.map((row, i) => { + const isOver = (r: (typeof data)[number] | undefined) => { + if (!r) return false; + const v = Number(r[overlayKey]); + if (!Number.isFinite(v)) return false; + if ("below" in warningOverlay!) { + // "Not keeping up": the series dips below its companion (e.g. started < enqueued). + const b = Number(r[warningOverlay.below]); + return Number.isFinite(b) && v < b; + } + return v > warningOverlay!.threshold; + }; + const inOverlay = isOver(data[i - 1]) || isOver(row) || isOver(data[i + 1]); + return { ...row, [WARNING_OVERLAY_KEY]: inOverlay ? row[overlayKey] : null }; + }) + : data; + const yAxisConfig = { axisLine: false, tickLine: false, @@ -161,15 +330,129 @@ export function ChartLineRenderer({ style: { fontVariantNumeric: "tabular-nums" }, }, tickFormatter: yAxisTickFormatter, + ...(thresholdDomain ? { domain: thresholdDomain } : {}), ...yAxisPropsProp, }; - // Handle mouse leave to also reset highlight + // Widen the right margin only when a reference line is outside-labeled, so charts without + // outside labels keep their existing geometry. + const rightGutter = outsideLabelGutter(referenceLines); + const chartMargin = + rightGutter > 0 + ? { ...CHART_MARGIN, right: Math.max(CHART_MARGIN.right, rightGutter) } + : CHART_MARGIN; + + // Handle mouse leave to also reset highlight and any synced hover/zoom drag. const handleMouseLeave = () => { highlight.setTooltipActive(false); highlight.reset(); + sync?.setActiveX(null); + sync?.cancelZoom(); + }; + + // Synced hover + drag-to-zoom state (mirrors Chart.Bar; all no-ops without a provider). + const syncActiveX = sync?.activeX ?? null; + const syncZoomSelection = sync?.zoomSelection ?? null; + const bucketWidthMs = data.length >= 2 ? Number(data[1][dataKey]) - Number(data[0][dataKey]) : 0; + const formatZoomEdge = (v: number): string => + tooltipLabelFormatter ? tooltipLabelFormatter("", [{ payload: { [dataKey]: 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)); + } + } + + const sharedMouseHandlers = { + className: sync?.zoomEnabled ? "cursor-crosshair select-none" : undefined, + onMouseDown: (e: any) => { + if (sync?.zoomEnabled && e?.activeLabel != null) sync.startZoom(e.activeLabel); + }, + onMouseMove: (e: any) => { + if (sync?.zoomEnabled && sync.zoomSelection && e?.activeLabel != null) { + sync.updateZoom(e.activeLabel); + } + 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: () => { + if (sync?.zoomEnabled) sync.endZoom(bucketWidthMs); + }, + onMouseLeave: handleMouseLeave, }; + // Pass the tooltip as a stable ELEMENT (not an inline function). recharts remounts a function + // `content` on the re-renders that fire while hovering along the line (sync/highlight state + // updates), which unmounts the portaled tooltip every bucket = flicker. An element of a + // module-level component type reconciles in place, so the tooltip stays mounted while moving. + const tooltipContent = + syncZoomSelection && zoomFrom != null && zoomTo != null ? ( + + ) : showLegend ? ( + () => null + ) : overlayActive ? ( + + ) : ( + + ); + + const referenceOverlays = ( + <> + {/* Synced drag-to-zoom selection — mirrored across charts in the same group. */} + {syncZoomSelection && ( + + )} + {/* 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 && ( + + )} + {referenceLines?.map((line) => ( + + ) : undefined + } + /> + ))} + + ); + // Render stacked area chart if stacked prop is true if (stacked && visibleSeries.length > 1) { return ( @@ -178,16 +461,8 @@ export function ChartLineRenderer({ width={width} height={height} stackOffset="none" - margin={CHART_MARGIN} - onMouseMove={(e: any) => { - if (e?.activePayload?.length) { - setActivePayload(e.activePayload, e.activeTooltipIndex); - highlight.setTooltipActive(true); - } else { - highlight.setTooltipActive(false); - } - }} - onMouseLeave={handleMouseLeave} + margin={chartMargin} + {...sharedMouseHandlers} > @@ -195,27 +470,14 @@ export function ChartLineRenderer({ {/* When legend is shown below, render tooltip with cursor only (no content popup) */} null - ) : ( - - ) - } + content={tooltipContent} labelFormatter={tooltipLabelFormatter} + isAnimationActive={false} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 1000 }} /> {/* Note: Legend is now rendered by ChartRoot outside the chart container */} - {referenceLines?.map((line) => ( - : undefined} - /> - ))} + {referenceOverlays} {visibleSeries.map((key) => ( { - if (e?.activePayload?.length) { - setActivePayload(e.activePayload, e.activeTooltipIndex); - highlight.setTooltipActive(true); - } else { - highlight.setTooltipActive(false); - } - }} - onMouseLeave={handleMouseLeave} + margin={chartMargin} + {...sharedMouseHandlers} > + {thresholdStroke && thresholdDomain ? ( + + + + + + + ) : null} {/* When legend is shown below, render tooltip with cursor only (no content popup) */} null : - } + content={tooltipContent} labelFormatter={tooltipLabelFormatter} + isAnimationActive={false} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 1000 }} /> {/* Note: Legend is now rendered by ChartRoot outside the chart container */} - {referenceLines?.map((line) => ( - : undefined} - /> - ))} + {referenceOverlays} {visibleSeries.map((key) => ( ( + + ) + : { r: 4, fill: config[key]?.color, strokeWidth: 0 } + } isAnimationActive={false} /> ))} + {overlayActive && ( + // Drawn after the base line so the warning colour sits on top. connectNulls={false} keeps + // the mask to over-threshold stretches; excluded from the legend and (above) the tooltip. + // Its active dot inherits the warning colour so the hover dot is yellow over yellow. + // Slightly wider than the base line so it fully covers it — otherwise the base colour peeks + // out along the edges and the warning stretch reads as an outlined line. + + )} ); } + +type ActiveDotProps = { + cx?: number; + cy?: number; + value?: number | Array; + payload?: Record; +}; + +/** Hover dot for a gradient (threshold) line: filled with the colour of the line under it — + * the warning colour at/above the threshold, the base colour below. Reads the bucket value from + * `payload[dataKey]` (robust across recharts versions) and falls back to the `value` prop. */ +function ThresholdActiveDot({ + cx, + cy, + value, + payload, + dataKey, + threshold, + aboveColor, + baseColor, +}: ActiveDotProps & { + dataKey: string; + threshold: number; + aboveColor: string; + baseColor: string; +}) { + if (cx === undefined || cy === undefined) return null; + const fromPayload = payload?.[dataKey]; + const raw = + typeof fromPayload === "number" + ? fromPayload + : Array.isArray(value) + ? value[value.length - 1] + : value; + const color = typeof raw === "number" && raw > threshold ? aboveColor : baseColor; + return ; +} diff --git a/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx b/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx index ef01e1ded8b..6ef7f073dd0 100644 --- a/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartSyncContext.tsx @@ -25,6 +25,8 @@ type ChartSyncContextValue = { /** Whether drag-to-zoom is active (an onZoom handler was provided). */ zoomEnabled: boolean; + /** The commit handler, re-exposed so a nested group (e.g. a fullscreen chart) can inherit it. */ + onZoom?: (range: ChartZoomRange) => void; /** Current drag selection, mirrored across charts; null when not dragging. */ zoomSelection: ZoomSelection | null; startZoom: (x: number | string) => void; @@ -103,6 +105,7 @@ export function ChartSyncProvider({ activeX, setActiveX, zoomEnabled: onZoom != null, + onZoom, zoomSelection, startZoom, updateZoom, diff --git a/apps/webapp/app/components/primitives/useTableSort.ts b/apps/webapp/app/components/primitives/useTableSort.ts new file mode 100644 index 00000000000..5bb5f600431 --- /dev/null +++ b/apps/webapp/app/components/primitives/useTableSort.ts @@ -0,0 +1,130 @@ +import { useCallback, useMemo, useState } from "react"; + +export type SortDirection = "asc" | "desc"; + +export type SortState = { + key: K; + direction: SortDirection; +}; + +/** + * A sortable column definition for {@link useTableSort}. + * + * - `"number"`: sorts numerically. `null`/`undefined`/`NaN` values always sort last, + * regardless of the sort direction. + * - `"alpha"`: sorts with `localeCompare`, case-insensitively. Empty/nullish values sort last. + * - `"custom"`: sorts with the provided comparator `(a, b) => number` (asc order); the direction + * flip is applied on top for you. + * + * In every case the sort is stable: rows that compare equal keep their original relative order, + * and with no active sort the rows are returned untouched. + */ +export type SortColumn = + | { key: K; type: "number"; value: (row: T) => number | null | undefined } + | { key: K; type: "alpha"; value: (row: T) => string | null | undefined } + | { key: K; type: "custom"; compare: (a: T, b: T) => number }; + +/** Presentational props to spread onto a `` for a given column. */ +export type TableSortHeaderProps = { + sortDirection: SortDirection | null; + onSort: () => void; +}; + +export function compareColumn( + column: SortColumn, + a: T, + b: T, + direction: SortDirection +): number { + const sign = direction === "asc" ? 1 : -1; + + if (column.type === "custom") { + return sign * column.compare(a, b); + } + + if (column.type === "number") { + const av = column.value(a); + const bv = column.value(b); + const aNull = av === null || av === undefined || Number.isNaN(av); + const bNull = bv === null || bv === undefined || Number.isNaN(bv); + // Nulls always sort last, independent of direction. + if (aNull && bNull) return 0; + if (aNull) return 1; + if (bNull) return -1; + return sign * (av - bv); + } + + // alpha + const av = column.value(a); + const bv = column.value(b); + const aEmpty = av === null || av === undefined || av === ""; + const bEmpty = bv === null || bv === undefined || bv === ""; + if (aEmpty && bEmpty) return 0; + if (aEmpty) return 1; + if (bEmpty) return -1; + return sign * av.localeCompare(bv, undefined, { sensitivity: "base" }); +} + +/** + * Stable sort of `rows` by a single `column`/`direction`. Rows that compare equal keep their + * original relative order. Pure and side-effect free — exported so the sort behavior can be + * unit-tested without rendering a component. + */ +export function sortRows( + rows: ReadonlyArray, + column: SortColumn, + direction: SortDirection +): T[] { + return rows + .map((row, index) => ({ row, index })) + .sort((a, b) => { + const result = compareColumn(column, a.row, b.row, direction); + return result !== 0 ? result : a.index - b.index; + }) + .map((entry) => entry.row); +} + +/** + * Client-side, header-click column sorting for tables of any row shape. + * + * Clicking a column cycles asc -> desc -> cleared (back to the original row order), so the + * incoming order (e.g. a server default) is always reachable without a reload. Returns the + * sorted rows plus a `getSortProps(key)` helper whose result spreads straight onto + * ``. + */ +export function useTableSort( + rows: T[], + columns: ReadonlyArray> +) { + const [sort, setSort] = useState | null>(null); + + const columnsByKey = useMemo(() => { + const map = new Map>(); + for (const column of columns) { + map.set(column.key, column); + } + return map; + }, [columns]); + + const sortedRows = useMemo(() => { + if (!sort) return rows; + const column = columnsByKey.get(sort.key); + if (!column) return rows; + return sortRows(rows, column, sort.direction); + }, [rows, sort, columnsByKey]); + + const getSortProps = useCallback( + (key: K): TableSortHeaderProps => ({ + sortDirection: sort?.key === key ? sort.direction : null, + onSort: () => + setSort((current) => { + if (!current || current.key !== key) return { key, direction: "asc" }; + if (current.direction === "asc") return { key, direction: "desc" }; + return null; + }), + }), + [sort] + ); + + return { sortedRows, getSortProps, sort }; +} diff --git a/apps/webapp/app/components/queues/QueueControls.tsx b/apps/webapp/app/components/queues/QueueControls.tsx new file mode 100644 index 00000000000..9c4302d06e7 --- /dev/null +++ b/apps/webapp/app/components/queues/QueueControls.tsx @@ -0,0 +1,361 @@ +import { AdjustmentsHorizontalIcon, PauseIcon, PlayIcon } from "@heroicons/react/20/solid"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useNavigation } from "@remix-run/react"; +import type { QueueItem } from "@trigger.dev/core/v3/schemas"; +import { useEffect, useState } from "react"; +import { Button, type ButtonVariant } from "~/components/primitives/Buttons"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { PopoverMenuItem } from "~/components/primitives/Popover"; +import SegmentedControl from "~/components/primitives/SegmentedControl"; +import { Spinner } from "~/components/primitives/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/primitives/Tooltip"; + +// Per-queue action controls. Extracted from the Queues list route so the queue detail page can +// reuse them. Both submit a `
` to the current route, so whichever route renders +// them must handle the `queue-pause` / `queue-resume` / `queue-override` / `queue-remove-override` +// actions (see `handleQueueMutationAction` in `~/models/queueMutation.server`). + +export function QueuePauseResumeButton({ + queue, + variant = "tertiary/small", + fullWidth = false, + showTooltip = true, + iconOnly = false, +}: { + /** The "id" here is a friendlyId */ + queue: { id: string; name: string; paused: boolean }; + variant?: ButtonVariant; + fullWidth?: boolean; + showTooltip?: boolean; + /** Icon-only trigger (label moves to the tooltip). For compact placements like the detail-page + * live blocks. */ + iconOnly?: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + + const label = queue.paused + ? `Resume processing runs in queue "${queue.name}"` + : `Pause processing runs in queue "${queue.name}"`; + + const trigger = showTooltip ? ( +
+ + + +
+ + + +
+
+ + {label} + +
+
+
+ ) : ( + + + + ); + + return ( + + {trigger} + + {queue.paused ? "Resume queue?" : "Pause queue?"} +
+ + {queue.paused + ? `This will allow runs to be dequeued in the "${queue.name}" queue again.` + : `This will pause all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`} + + setIsOpen(false)}> + + + + {queue.paused ? "Resume queue" : "Pause queue"} + + } + cancelButton={ + + + + } + /> + +
+
+
+ ); +} + +export function QueueOverrideConcurrencyButton({ + queue, + environmentConcurrencyLimit, + trigger, +}: { + queue: QueueItem & { concurrencyLimitOverridePercent: number | null }; + environmentConcurrencyLimit: number; + /** How to render the dialog trigger. "menu-item" (default) is a PopoverMenuItem for row menus; + * "button" is a standalone labeled button; "icon" is an icon-only button with the label in a + * hover tooltip, for compact placements like the detail-page live blocks. */ + trigger?: "menu-item" | "button" | "icon"; +}) { + const navigation = useNavigation(); + const [isOpen, setIsOpen] = useState(false); + const [mode, setMode] = useState<"absolute" | "percent">( + queue.concurrencyLimitOverridePercent !== null ? "percent" : "absolute" + ); + const [concurrencyLimit, setConcurrencyLimit] = useState( + queue.concurrencyLimit?.toString() ?? environmentConcurrencyLimit.toString() + ); + const [percent, setPercent] = useState( + queue.concurrencyLimitOverridePercent?.toString() ?? "100" + ); + + const isOverridden = !!queue.concurrency?.overriddenAt; + const currentLimit = queue.concurrencyLimit ?? environmentConcurrencyLimit; + + useEffect(() => { + if (navigation.state === "loading" || navigation.state === "idle") { + setIsOpen(false); + } + }, [navigation.state]); + + const isLoading = Boolean( + navigation.formData?.get("action") === "queue-override" || + navigation.formData?.get("action") === "queue-remove-override" + ); + + // Client-side mirror of the backend cap + materialization, so the user sees the resolved value + // and can't submit an above-limit override. + const percentNumber = Number(percent); + const percentValid = Number.isFinite(percentNumber) && percentNumber > 0 && percentNumber <= 100; + const materializedFromPercent = percentValid + ? Math.min( + Math.max(Math.floor((environmentConcurrencyLimit * percentNumber) / 100), 1), + environmentConcurrencyLimit + ) + : null; + + const limitNumber = Number(concurrencyLimit); + const limitOverCap = Number.isFinite(limitNumber) && limitNumber > environmentConcurrencyLimit; + + const submitDisabled = + isLoading || (mode === "percent" ? !percentValid : !concurrencyLimit || limitOverCap); + + const iconLabel = isOverridden ? "Edit override" : "Override limit"; + + return ( + + {trigger === "icon" ? ( + + + +
+ +
+
+ + {iconLabel} + +
+
+ ) : ( + + {trigger === "button" ? ( + + ) : ( + + )} + + )} + + + {isOverridden ? "Edit concurrency override" : "Override concurrency limit"} + +
+ {isOverridden ? ( + + This queue's concurrency limit is currently overridden to {currentLimit}. + {typeof queue.concurrency?.base === "number" && + ` The original limit set in code was ${queue.concurrency.base}.`}{" "} + You can update the override or remove it to restore the{" "} + {typeof queue.concurrency?.base === "number" + ? "limit set in code" + : "environment concurrency limit"} + . + + ) : ( + + Override this queue's concurrency limit. The current limit is {currentLimit}, which is + set {queue.concurrencyLimit !== null ? "in code" : "by the environment"}. + + )} +
setIsOpen(false)} className="space-y-3"> + + + +
+ + setMode(value === "percent" ? "percent" : "absolute")} + /> +
+ {mode === "percent" ? ( + <> + setPercent(e.target.value)} + placeholder="100" + autoFocus + /> + + {materializedFromPercent !== null + ? `${percentNumber}% = ${materializedFromPercent} concurrent ${ + materializedFromPercent === 1 ? "run" : "runs" + } of the environment's ${environmentConcurrencyLimit}. Recalculates automatically when the environment limit changes.` + : "Enter a percentage between 1 and 100."} + + + ) : ( + <> + setConcurrencyLimit(e.target.value)} + placeholder={currentLimit.toString()} + autoFocus + /> + + {limitOverCap + ? `Can't exceed the environment limit of ${environmentConcurrencyLimit}.` + : `Up to the environment limit of ${environmentConcurrencyLimit}.`} + + + )} +
+ + } + shortcut={{ modifiers: ["mod"], key: "enter" }} + > + {isOverridden ? "Update override" : "Override limit"} + + } + cancelButton={ +
+ {isOverridden && ( + + )} + + + +
+ } + /> + +
+
+
+ ); +} diff --git a/apps/webapp/app/components/queues/QueueMetricCards.tsx b/apps/webapp/app/components/queues/QueueMetricCards.tsx index f8b35780d95..9dc38390131 100644 --- a/apps/webapp/app/components/queues/QueueMetricCards.tsx +++ b/apps/webapp/app/components/queues/QueueMetricCards.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, type ReactNode } from "react"; import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; import { Chart, @@ -12,6 +12,7 @@ import { type MetricResourceTimeRange, } from "~/hooks/useMetricResourceQuery"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { useSearchParams } from "~/hooks/useSearchParam"; import { cn } from "~/utils/cn"; @@ -20,9 +21,9 @@ import { cn } from "~/utils/cn"; // so pages render instantly; loaders only supply live counts and identifiers. export const QUEUE_METRIC_COLORS = { - running: "#6366F1", + running: "var(--color-queues)", limit: "#4D525B", - queued: "#A78BFA", + queued: "var(--color-queues)", p50: "#22D3EE", p95: "#F59E0B", p99: "#EF4444", @@ -50,6 +51,8 @@ export function useQueueMetric( fillGaps?: boolean; /** Match the host page's TimeFilter default (e.g. "7d" on task detail). */ defaultPeriod?: string; + /** Poll ClickHouse on this cadence (ms). Omit to use the query's default interval. */ + refreshIntervalMs?: number; } ) { return useMetricResourceQuery(query, { @@ -58,6 +61,7 @@ export function useQueueMetric( defaultPeriod: opts.defaultPeriod ?? QUEUE_METRICS_DEFAULT_PERIOD, queues: [opts.queueName], fillGaps: opts.fillGaps, + refreshIntervalMs: opts.refreshIntervalMs, }); } @@ -89,6 +93,8 @@ type QueueMetricChartProps = { valueFormat?: (value: number) => string; fillGaps?: boolean; defaultPeriod?: string; + /** Recolor a series warning where it drops below another (e.g. started below enqueued). */ + warningOverlay?: { series: string; below: string }; }; // Bare chart (no card chrome) so it can live inside a shared card, e.g. a tabbed panel. @@ -101,6 +107,7 @@ export function QueueMetricChart({ valueFormat, fillGaps, defaultPeriod, + warningOverlay, }: QueueMetricChartProps) { const { rows, showLoading, failed } = useQueueMetric(query, { ids, @@ -150,6 +157,7 @@ export function QueueMetricChart({ yAxisProps={valueFormat ? { tickFormatter: (v: number) => valueFormat(v) } : undefined} tooltipLabelFormatter={tooltipLabelFormatter} tooltipValueFormatter={valueFormat} + warningOverlay={warningOverlay} />
); @@ -157,12 +165,32 @@ export function QueueMetricChart({ export function QueueMetricChartCard({ title, + info, + titleAccessory, className, ...chart -}: QueueMetricChartProps & { title: string; className?: string }) { +}: QueueMetricChartProps & { + title: string; + info?: ReactNode; + /** Extra content rendered after the info icon inside the title row (e.g. a live readout). */ + titleAccessory?: ReactNode; + className?: string; +}) { return (
- + + {title} + {info ? : null} + {titleAccessory} + + ) : ( + title + ) + } + >
diff --git a/apps/webapp/app/hooks/useMetricResourceQuery.ts b/apps/webapp/app/hooks/useMetricResourceQuery.ts index 8cb8faec507..2a2ee413af7 100644 --- a/apps/webapp/app/hooks/useMetricResourceQuery.ts +++ b/apps/webapp/app/hooks/useMetricResourceQuery.ts @@ -103,7 +103,13 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO return () => abortRef.current?.abort(); }, [load]); - useInterval({ interval: refreshIntervalMs, onLoad: false, onFocus: true, callback: load }); + useInterval({ + interval: refreshIntervalMs, + onLoad: false, + onFocus: true, + pauseWhenHidden: true, + callback: load, + }); return { rows: rows ?? [], isLoading, showLoading: isLoading && !rows, failed }; } diff --git a/apps/webapp/app/models/queueMutation.server.ts b/apps/webapp/app/models/queueMutation.server.ts new file mode 100644 index 00000000000..f6e63027d84 --- /dev/null +++ b/apps/webapp/app/models/queueMutation.server.ts @@ -0,0 +1,153 @@ +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { getUserById } from "~/models/user.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server"; +import { PauseQueueService } from "~/v3/services/pauseQueue.server"; + +/** + * Handles the per-queue mutating form actions (pause/resume/override/remove-override) shared by the + * Queues list route and the queue detail route. Returns a redirect Response for one of those four + * actions, or `null` if `formData`'s `action` isn't one of them (so the caller can fall through to + * its own action handling). `redirectPath` is where to send the user afterwards — the caller passes + * its own page so a mutation from the detail page stays on the detail page. + */ +export async function handleQueueMutationAction({ + request, + environment, + userId, + formData, + redirectPath, +}: { + request: Request; + environment: AuthenticatedEnvironment; + userId: string; + formData: FormData; + redirectPath: string; +}): Promise { + const action = formData.get("action"); + + switch (action) { + case "queue-pause": + case "queue-resume": { + const friendlyId = formData.get("friendlyId"); + if (!friendlyId) { + return redirectWithErrorMessage(redirectPath, request, "Queue ID is required"); + } + + const queueService = new PauseQueueService(); + const result = await queueService.call( + environment, + friendlyId.toString(), + action === "queue-pause" ? "paused" : "resumed" + ); + + if (!result.success) { + return redirectWithErrorMessage( + redirectPath, + request, + result.error ?? `Failed to ${action === "queue-pause" ? "pause" : "resume"} queue` + ); + } + + return redirectWithSuccessMessage( + redirectPath, + request, + `Queue ${action === "queue-pause" ? "paused" : "resumed"}` + ); + } + case "queue-override": { + const friendlyId = formData.get("friendlyId"); + const mode = formData.get("mode") === "percent" ? "percent" : "absolute"; + + if (!friendlyId) { + return redirectWithErrorMessage(redirectPath, request, "Queue ID is required"); + } + + // The dialog submits either a `percent` of the environment limit or an absolute `limit`, + // depending on the unit toggle. Build the matching override shape for the service. + let override: number | { limit: number } | { percent: number }; + if (mode === "percent") { + const percentValue = formData.get("percent"); + if (!percentValue) { + return redirectWithErrorMessage(redirectPath, request, "Percentage is required"); + } + const percentNumber = Number(percentValue.toString()); + if (!Number.isFinite(percentNumber) || percentNumber <= 0 || percentNumber > 100) { + return redirectWithErrorMessage( + redirectPath, + request, + "Percentage must be greater than 0 and less than or equal to 100" + ); + } + override = { percent: percentNumber }; + } else { + const concurrencyLimit = formData.get("concurrencyLimit"); + if (!concurrencyLimit) { + return redirectWithErrorMessage(redirectPath, request, "Concurrency limit is required"); + } + const limitNumber = parseInt(concurrencyLimit.toString(), 10); + if (isNaN(limitNumber) || limitNumber < 0) { + return redirectWithErrorMessage( + redirectPath, + request, + "Concurrency limit must be a valid number" + ); + } + override = { limit: limitNumber }; + } + + const user = await getUserById(userId); + if (!user) { + return redirectWithErrorMessage(redirectPath, request, "User not found"); + } + + const result = await concurrencySystem.queues.overrideQueueConcurrencyLimit( + environment, + friendlyId.toString(), + override, + user + ); + + if (!result.isOk()) { + // Surface the service's specific message (e.g. the above-cap rejection) instead of a + // generic failure so the user learns why the override was refused. + const error = result.error; + const message = + "message" in error && typeof error.message === "string" + ? error.message + : "Failed to override queue concurrency limit"; + return redirectWithErrorMessage(redirectPath, request, message); + } + + return redirectWithSuccessMessage( + redirectPath, + request, + "Queue concurrency limit overridden" + ); + } + case "queue-remove-override": { + const friendlyId = formData.get("friendlyId"); + + if (!friendlyId) { + return redirectWithErrorMessage(redirectPath, request, "Queue ID is required"); + } + + const result = await concurrencySystem.queues.resetConcurrencyLimit( + environment, + friendlyId.toString() + ); + + if (!result.isOk()) { + return redirectWithErrorMessage( + redirectPath, + request, + "Failed to reset queue concurrency limit" + ); + } + + return redirectWithSuccessMessage(redirectPath, request, "Queue concurrency limit reset"); + } + default: + return null; + } +} diff --git a/apps/webapp/app/presenters/v3/QueueAllocationPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueAllocationPresenter.server.ts index c7a8166b6a3..3cb98128de7 100644 --- a/apps/webapp/app/presenters/v3/QueueAllocationPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueAllocationPresenter.server.ts @@ -1,94 +1,56 @@ -import { TaskQueueType, type Prisma } from "@trigger.dev/database"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; -import { engine } from "~/v3/runEngine.server"; +import { sqlDatabaseSchema } from "~/db.server"; import { BasePresenter } from "./basePresenter.server"; -const MAX_ALLOCATION_QUEUES = 500; - -export type QueueAllocationItem = { - id: string; - name: string; - type: "task" | "custom"; - running: number; - queued: number; - paused: boolean; - /** Explicit per-queue limit; null means the queue floats up to the env limit. */ - limit: number | null; - overridden: boolean; -}; - export type QueueAllocation = { - queues: QueueAllocationItem[]; + /** Number of V2 queues in the environment. */ totalQueues: number; - truncated: boolean; - /** Sum of explicit limits, each clamped to the env limit. */ + /** Sum of explicit per-queue limits, each clamped to the env limit. */ allocated: number; + /** Queues with no explicit limit (they float up to the env limit). */ unlimitedCount: number; }; -/** Every queue in the environment (capped) with live counts, for the allocation view. */ +/** + * Environment-wide allocation totals for the queues page summary tiles. + * + * The page only needs the aggregate `allocated` value (sum of each queue's + * explicit limit clamped to the env limit), so this computes it in a single + * Postgres aggregate over ALL V2 queues in the environment — no row cap and no + * Redis lookups. + */ export class QueueAllocationPresenter extends BasePresenter { public async call({ environment, }: { environment: AuthenticatedEnvironment; }): Promise { - const where: Prisma.TaskQueueWhereInput = { - runtimeEnvironmentId: environment.id, - version: "V2", - }; - - const [totalQueues, queues] = await Promise.all([ - this._replica.taskQueue.count({ where }), - this._replica.taskQueue.findMany({ - where, - select: { - friendlyId: true, - name: true, - type: true, - paused: true, - concurrencyLimit: true, - concurrencyLimitOverriddenAt: true, - }, - orderBy: { orderableName: "asc" }, - take: MAX_ALLOCATION_QUEUES, - }), - ]); - - const names = queues.map((q) => q.name); - const [queuedByQueue, runningByQueue] = await Promise.all([ - engine.lengthOfQueues(environment, names), - engine.currentConcurrencyOfQueues(environment, names), - ]); - const envLimit = environment.maximumConcurrencyLimit; - let allocated = 0; - let unlimitedCount = 0; - const items: QueueAllocationItem[] = queues.map((queue) => { - if (queue.concurrencyLimit === null) { - unlimitedCount++; - } else { - allocated += Math.min(queue.concurrencyLimit, envLimit); - } - return { - id: queue.friendlyId, - name: queue.name.replace(/^task\//, ""), - type: queue.type === TaskQueueType.VIRTUAL ? ("task" as const) : ("custom" as const), - running: runningByQueue[queue.name] ?? 0, - queued: queuedByQueue[queue.name] ?? 0, - paused: queue.paused, - limit: queue.concurrencyLimit, - overridden: queue.concurrencyLimitOverriddenAt !== null, - }; - }); + const [row] = await this._replica.$queryRaw< + { + totalQueues: number; + allocated: number; + unlimitedCount: number; + }[] + >` + SELECT + COUNT(*)::int AS "totalQueues", + COUNT(*) FILTER (WHERE "concurrencyLimit" IS NULL)::int AS "unlimitedCount", + COALESCE( + SUM(LEAST("concurrencyLimit", ${envLimit})) + FILTER (WHERE "concurrencyLimit" IS NOT NULL), + 0 + )::int AS "allocated" + FROM ${sqlDatabaseSchema}."TaskQueue" + WHERE "runtimeEnvironmentId" = ${environment.id} + AND "version" = 'V2' + `; return { - queues: items, - totalQueues, - truncated: totalQueues > queues.length, - allocated, - unlimitedCount, + totalQueues: row?.totalQueues ?? 0, + allocated: row?.allocated ?? 0, + unlimitedCount: row?.unlimitedCount ?? 0, }; } } diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 751d4b0a602..0fc9939eec4 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -34,13 +34,18 @@ const queueListSelect = { concurrencyLimitBase: true, concurrencyLimitOverriddenAt: true, concurrencyLimitOverriddenBy: true, + concurrencyLimitOverridePercent: true, type: true, paused: true, } satisfies Prisma.TaskQueueSelect; type QueueListRow = Prisma.TaskQueueGetPayload<{ select: typeof queueListSelect }>; -type QueueListItem = ReturnType; +// The percent source-of-truth for percent-based overrides isn't part of the shared `QueueItem` +// schema (that's a public contract), so we surface it as an extra field on the list item. +type QueueListItem = ReturnType & { + concurrencyLimitOverridePercent: number | null; +}; type QueueListPagination = | { mode: "filtered"; currentPage: number; hasMore: boolean } @@ -375,10 +380,11 @@ export class QueueListPresenter extends BasePresenter { concurrencyLimitBase: number | null; concurrencyLimitOverriddenAt: Date | null; concurrencyLimitOverriddenBy: string | null; + concurrencyLimitOverridePercent: Prisma.Decimal | null; type: TaskQueueType; paused: boolean; }[] - ) { + ): Promise { const [queuedByQueue, runningByQueue] = await Promise.all([ this.engineClient.lengthOfQueues( environment, @@ -401,8 +407,8 @@ export class QueueListPresenter extends BasePresenter { const overriddenByMap = new Map(overriddenByUsers.map((u) => [u.id, u])); - return queues.map((queue) => - toQueueItem({ + return queues.map((queue) => ({ + ...toQueueItem({ friendlyId: queue.friendlyId, name: queue.name, type: queue.type, @@ -415,7 +421,12 @@ export class QueueListPresenter extends BasePresenter { ? (overriddenByMap.get(queue.concurrencyLimitOverriddenBy) ?? null) : null, paused: queue.paused, - }) - ); + }), + // Prisma returns Decimal; the client only needs a plain number (null for absolute overrides). + concurrencyLimitOverridePercent: + queue.concurrencyLimitOverridePercent !== null + ? Number(queue.concurrencyLimitOverridePercent) + : null, + })); } } diff --git a/apps/webapp/app/presenters/v3/QueueMetricsPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueMetricsPresenter.server.ts index a36c402dda7..2db8cbdb710 100644 --- a/apps/webapp/app/presenters/v3/QueueMetricsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueMetricsPresenter.server.ts @@ -6,8 +6,16 @@ export type QueueListMetric = { p50WaitMs: number | null; p95WaitMs: number | null; peakQueued: number; + /** Times this queue was throttled (running at limit with a backlog) over the window. */ + throttledTotal: number; /** Equal-width buckets, oldest first, carry-forward filled across idle gaps. */ depthSparkline: number[]; + /** + * Throttled count per bucket, aligned 1:1 with `depthSparkline`. Not carry-forward filled — + * a bucket is non-zero only when throttling actually happened in it, so callers can tint + * exactly those bars. + */ + throttledSparkline: number[]; }; export type QueueListMetrics = { @@ -95,36 +103,41 @@ export class QueueMetricsPresenter { return empty; } - // Bucket -> depth per queue, mapped onto the aligned grid and forward-filled. - const depthsByQueue = new Map>(); + // Bucket -> depth + throttled per queue, mapped onto the aligned grid. Depth is + // forward-filled below; throttled is not (only real per-bucket counts tint bars). + const bucketsByQueue = new Map>(); for (const row of sparklineRows ?? []) { const bucketMs = Date.parse(row.bucket.replace(" ", "T") + "Z"); if (Number.isNaN(bucketMs)) continue; const index = Math.round((bucketMs - bucketStartMs) / bucketIntervalMs); if (index < 0 || index >= numBuckets) continue; - let byIndex = depthsByQueue.get(row.queue_name); + let byIndex = bucketsByQueue.get(row.queue_name); if (!byIndex) { byIndex = new Map(); - depthsByQueue.set(row.queue_name, byIndex); + bucketsByQueue.set(row.queue_name, byIndex); } - byIndex.set(index, row.depth); + byIndex.set(index, { depth: row.depth, throttled: row.throttled }); } const byQueue = new Map(); for (const row of summaryRows ?? []) { - const byIndex = depthsByQueue.get(row.queue_name); + const byIndex = bucketsByQueue.get(row.queue_name); const sparkline: number[] = new Array(numBuckets); + const throttledSparkline: number[] = new Array(numBuckets); let last = 0; for (let i = 0; i < numBuckets; i++) { - const value = byIndex?.get(i); - if (value !== undefined) last = value; + const bucket = byIndex?.get(i); + if (bucket !== undefined) last = bucket.depth; sparkline[i] = last; + throttledSparkline[i] = bucket?.throttled ?? 0; } byQueue.set(row.queue_name, { p50WaitMs: finiteOrNull(row.p50_wait_ms), p95WaitMs: finiteOrNull(row.p95_wait_ms), peakQueued: row.peak_queued, + throttledTotal: row.throttled_count, depthSparkline: sparkline, + throttledSparkline, }); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index c16e96b8f51..425275b0beb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -6,7 +6,7 @@ import type { TaskRunStatus } from "@trigger.dev/database"; import type { PanelHandle } from "@window-splitter/react"; import { Fragment, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ClientOnly } from "remix-utils/client-only"; -import { Bar, BarChart, ReferenceLine, Tooltip, type TooltipProps, YAxis } from "recharts"; +import { Bar, type TooltipProps } from "recharts"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; import { ClockIcon } from "~/assets/icons/ClockIcon"; @@ -51,6 +51,11 @@ import { } from "~/components/primitives/Table"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import TooltipPortal from "~/components/primitives/TooltipPortal"; +import { + ActivityBarChart, + ACTIVITY_CHART_HEIGHT, + ACTIVITY_CHART_WIDTH, +} from "~/components/metrics/ActivityBarChart"; import { TaskFileName } from "~/components/runs/v3/TaskPath"; import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; import { @@ -624,9 +629,6 @@ const STATUS_BARS: { status: TaskRunStatus; fill: string }[] = [ { status: "TIMED_OUT", fill: "var(--color-run-timed-out)" }, ]; -// Fixed px dims skip ResponsiveContainer's ResizeObserver — otherwise every panel resize re-renders all 25 charts. -const ACTIVITY_CHART_WIDTH = 112; -const ACTIVITY_CHART_HEIGHT = 24; // chart (112) + gap-1.5 (6) + count min-w (28). Reserved so the column stays put while the chart unmounts. const ACTIVITY_CELL_WIDTH = 146; const ACTIVITY_CHART_COUNT_CLASS = @@ -636,52 +638,24 @@ function TaskActivityGraph({ activity }: { activity: HourlyTaskActivity[string] const maxTotal = Math.max(...activity.map((d) => d.total)); return ( -
-
- - - } - allowEscapeViewBox={{ x: true, y: true }} - wrapperStyle={{ zIndex: 1000 }} - animationDuration={0} - /> - {STATUS_BARS.map(({ status, fill }) => ( - - ))} - - {maxTotal > 0 && ( - - )} - -
- {formatNumberCompact(maxTotal)}} - content="Peak runs in a single hour" - /> -
+ } + peak={formatNumberCompact(maxTotal)} + peakTooltip="Peak runs in a single hour" + > + {STATUS_BARS.map(({ status, fill }) => ( + + ))} + ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/AllocationView.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/AllocationView.tsx deleted file mode 100644 index 8b13afbcb87..00000000000 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/AllocationView.tsx +++ /dev/null @@ -1,490 +0,0 @@ -import { Form, useNavigation } from "@remix-run/react"; -import { type ReactNode, useEffect, useMemo, useState } from "react"; -import { BigNumber } from "~/components/metrics/BigNumber"; -import { Badge } from "~/components/primitives/Badge"; -import { Button } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; -import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; -import { Input } from "~/components/primitives/Input"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import { - Table, - TableBody, - TableCell, - TableHeader, - TableHeaderCell, - TableRow, -} from "~/components/primitives/Table"; -import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { getSeriesColor } from "~/components/code/chartColors"; -import { QueueName } from "~/components/runs/v3/QueueName"; -import { type Environment } from "~/presenters/v3/EnvironmentQueuePresenter.server"; -import { - type QueueAllocation, - type QueueAllocationItem, -} from "~/presenters/v3/QueueAllocationPresenter.server"; -import { cn } from "~/utils/cn"; - -type Drafts = Record; - -export function AllocationView({ - allocation, - environment, -}: { - allocation: QueueAllocation; - environment: Environment; -}) { - const [drafts, setDrafts] = useState({}); - const [reviewOpen, setReviewOpen] = useState(false); - const navigation = useNavigation(); - const isSubmitting = navigation.state !== "idle"; - - const envLimit = environment.concurrencyLimit; - const burstLimit = Math.round(envLimit * environment.burstFactor); - - useEffect(() => { - if (navigation.state === "loading" || navigation.state === "idle") { - setReviewOpen(false); - } - }, [navigation.state]); - - // After an apply revalidates the loader, drop drafts that now match the saved limits. - useEffect(() => { - setDrafts((prev) => { - const next = { ...prev }; - for (const queue of allocation.queues) { - if (next[queue.id] !== undefined && next[queue.id] === queue.limit) { - delete next[queue.id]; - } - } - return next; - }); - }, [allocation]); - - const draftLimit = (queue: QueueAllocationItem): number | null => drafts[queue.id] ?? queue.limit; - - const draftAllocated = allocation.queues.reduce((sum, queue) => { - const limit = draftLimit(queue); - return limit === null ? sum : sum + Math.min(limit, envLimit); - }, 0); - - const changes = allocation.queues.filter( - (queue) => drafts[queue.id] !== undefined && drafts[queue.id] !== queue.limit - ); - - const unlimitedCount = allocation.queues.filter((queue) => draftLimit(queue) === null).length; - const allocationPct = envLimit > 0 ? Math.round((draftAllocated / envLimit) * 100) : 0; - const overAllocated = draftAllocated > envLimit; - - const setDraft = (queue: QueueAllocationItem, value: string) => { - setDrafts((prev) => { - const next = { ...prev }; - if (value.trim() === "") { - delete next[queue.id]; - return next; - } - const parsed = parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed < 0) return prev; - if (parsed === queue.limit) { - delete next[queue.id]; - } else { - next[queue.id] = parsed; - } - return next; - }); - }; - - const changesPayload = useMemo( - () => - JSON.stringify(changes.map((queue) => ({ friendlyId: queue.id, limit: drafts[queue.id] }))), - [changes, drafts] - ); - - const colorByQueue = useMemo(() => { - const map = new Map(); - allocation.queues.forEach((queue, i) => map.set(queue.id, getSeriesColor(i))); - return map; - }, [allocation.queues]); - const colorFor = (id: string) => colorByQueue.get(id) ?? "#878C99"; - - // Busiest first: the queues you'd rebalance are the ones under load. Colors stay - // keyed to the loader order so they don't shift as counts change. - const tableQueues = useMemo( - () => [...allocation.queues].sort((a, b) => b.running + b.queued - (a.running + a.queued)), - [allocation.queues] - ); - - return ( -
-
- 1 ? `bursts up to ${burstLimit}` : undefined} - suffixClassName="text-text-dimmed" - /> - - 0 - ? `${unlimitedCount} without a limit (can use up to ${envLimit})` - : "all have limits" - } - suffixClassName="text-text-dimmed" - /> -
- - - - {overAllocated && ( - - The queue limits add up to more than the environment limit, so queues will compete for - concurrency when the environment saturates. Reduce limits to guarantee each queue its - allocation. - - )} - - {allocation.truncated && ( - - Showing the first {allocation.queues.length} of {allocation.totalQueues} queues. - Allocation totals only include the queues shown. - - )} - -
- -
- - - - - - Apply queue limits -
- - - - Queue - Current - New - - - - {changes.map((queue) => ( - - - - - {queue.limit ?? "–"} - {drafts[queue.id]} - - ))} - -
-
- - Limits apply immediately and are set as overrides, so they survive deploys until - removed. - -
- - - -
-
-
-
- - - - - Name - Running - Queued - - Limit - - - - - {tableQueues.map((queue) => { - const changed = drafts[queue.id] !== undefined && drafts[queue.id] !== queue.limit; - return ( - - - - - - {queue.paused && ( - - Paused - - )} - {queue.overridden && ( - - Override - - )} - - - {queue.running} - {queue.queued} - - - {changed && ( - - {queue.limit ?? "–"} → {drafts[queue.id]} - - )} - setDraft(queue, e.target.value)} - disabled={isSubmitting} - className="w-24" - variant="small" - /> - - - - ); - })} - -
-
- ); -} - -const MAX_BAR_SEGMENTS = 24; - -function AllocationBar({ - queues, - draftLimit, - envLimit, - burstLimit, - draftAllocated, - colorFor, -}: { - queues: QueueAllocationItem[]; - draftLimit: (queue: QueueAllocationItem) => number | null; - envLimit: number; - burstLimit: number; - draftAllocated: number; - colorFor: (id: string) => string; -}) { - const limited = queues - .map((queue) => ({ queue, limit: draftLimit(queue) })) - .filter( - (entry): entry is { queue: QueueAllocationItem; limit: number } => - typeof entry.limit === "number" && entry.limit > 0 - ) - .sort((a, b) => b.limit - a.limit); - - const top = limited.slice(0, MAX_BAR_SEGMENTS); - const rest = limited.slice(MAX_BAR_SEGMENTS); - const restTotal = rest.reduce((sum, entry) => sum + entry.limit, 0); - const restRunning = rest.reduce( - (sum, entry) => sum + Math.min(entry.queue.running, entry.limit), - 0 - ); - - const hasBurst = burstLimit > envLimit; - // The axis runs to the burst ceiling: allocations are guaranteed up to the env - // limit, and everything between the limit and burst is shared overflow headroom. - const scale = Math.max(draftAllocated, envLimit, burstLimit); - if (scale === 0) return null; - - const free = Math.max(0, envLimit - draftAllocated); - const limitMarkerPct = (envLimit / scale) * 100; - const burstZoneWidthPct = ((Math.min(burstLimit, scale) - envLimit) / scale) * 100; - - return ( -
-
-
- {hasBurst && ( - - } - content={`Shared burst headroom: beyond the environment limit, queues can burst up to ${burstLimit} combined`} - disableHoverableContent - /> - )} -
- {top.map((entry) => ( - - } - /> - ))} - {restTotal > 0 && ( - - )} -
-
-
-
-
- - {draftAllocated} allocated - {free > 0 ? ` · ${free} unallocated` : ""} - - {hasBurst ? ( - <> - - Environment limit {envLimit} - - Burst {burstLimit} - - ) : ( - Environment limit {envLimit} - )} -
-
- ); -} - -function QueueSegmentTooltip({ - queue, - limit, - envLimit, - color, -}: { - queue: QueueAllocationItem; - limit: number; - envLimit: number; - color: string; -}) { - const utilizationPct = limit > 0 ? Math.round((queue.running / limit) * 100) : 0; - const sharePct = envLimit > 0 ? Math.round((limit / envLimit) * 100) : 0; - return ( -
- - - - {queue.paused && ( - - Paused - - )} - -
- Running - - {queue.running} of {limit} ({utilizationPct}%) - - Queued - {queue.queued} - Allocation - - {sharePct}% of the environment limit - -
-
- ); -} - -/** One queue's slice of the capacity bar: dim fill = allocation, solid fill = current usage. */ -function BarSegment({ - color, - widthPct, - usagePct, - tooltip, -}: { - color: string; - widthPct: number; - usagePct: number; - tooltip: ReactNode; -}) { - return ( - - {usagePct > 0 && ( -
- )} -
- } - content={tooltip} - disableHoverableContent - /> - ); -} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 4e4e9c6613c..92247805ec7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -1,7 +1,7 @@ import { - AdjustmentsHorizontalIcon, ArrowUpCircleIcon, BookOpenIcon, + ExclamationTriangleIcon, PauseIcon, PlayIcon, RectangleStackIcon, @@ -11,7 +11,7 @@ import { Form, Link, useNavigation, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { QueueItem } from "@trigger.dev/core/v3/schemas"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; -import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; @@ -22,7 +22,7 @@ import { QueuesHasNoTasks } from "~/components/BlankStatePanels"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Badge } from "~/components/primitives/Badge"; -import { Button, LinkButton, type ButtonVariant } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; @@ -43,6 +43,7 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { useTableSort, type SortColumn } from "~/components/primitives/useTableSort"; import { InfoIconTooltip, SimpleTooltip, @@ -54,29 +55,27 @@ import { import { QueueName } from "~/components/runs/v3/QueueName"; import { env } from "~/env.server"; import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; -import { LoadingBarDivider } from "~/components/primitives/LoadingBarDivider"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { getUserById } from "~/models/user.server"; import { EnvironmentQueuePresenter } from "~/presenters/v3/EnvironmentQueuePresenter.server"; import { QueueListPresenter } from "~/presenters/v3/QueueListPresenter.server"; import { QueueMetricsPresenter, type QueueListMetric, } from "~/presenters/v3/QueueMetricsPresenter.server"; -import * as Ariakit from "@ariakit/react"; -import { AppliedFilter } from "~/components/primitives/AppliedFilter"; -import { SelectItem, SelectPopover, SelectProvider } from "~/components/primitives/Select"; import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { useSearchParams } from "~/hooks/useSearchParam"; import { parseFiniteInt } from "~/utils/searchParams"; -import { UsageSparkline } from "~/components/primitives/UsageSparkline"; +import { MiniLineChart } from "~/components/metrics/MiniLineChart"; import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; +import { ChartCard } from "~/components/primitives/charts/ChartCard"; +import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; +import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { useMetricResourceQuery, type MetricResourceTimeRange, @@ -93,15 +92,16 @@ import { v3QueuePath, v3RunsPath, } from "~/utils/pathBuilder"; -import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server"; import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server"; -import { PauseQueueService } from "~/v3/services/pauseQueue.server"; +import { handleQueueMutationAction } from "~/models/queueMutation.server"; +import { + QueueOverrideConcurrencyButton, + QueuePauseResumeButton, +} from "~/components/queues/QueueControls"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { BigNumber } from "~/components/metrics/BigNumber"; import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server"; import { QueueAllocationPresenter } from "~/presenters/v3/QueueAllocationPresenter.server"; -import { TabButton, TabContainer } from "~/components/primitives/Tabs"; -import { AllocationView } from "./AllocationView"; const SearchParamsSchema = z.object({ query: z.string().optional(), @@ -109,17 +109,19 @@ const SearchParamsSchema = z.object({ period: z.string().optional(), from: z.string().optional(), to: z.string().optional(), - view: z.string().optional(), sort: z.enum(["busiest", "queued", "name"]).optional(), }); -const AllocationChangesSchema = z - .array(z.object({ friendlyId: z.string(), limit: z.number().int().min(0) })) - .min(1) - .max(200); - const QUEUE_METRICS_DEFAULT_PERIOD = "1d"; +// The live "Queued" / "Running" header blocks poll ClickHouse on a short cadence so they stay +// current after first paint. They read the env-wide gauges from env_metrics (the env-level rollup +// of queue_metrics, cheapest for a dimension-free query), always over a fixed 15m window regardless +// of the chart/table period, and are NOT scoped to the visible queue set (the blocks are env-wide). +const QUEUE_LIVE_BLOCKS_PERIOD = "15m"; +const QUEUE_LIVE_BLOCKS_QUERY = + "SELECT timeBucket() AS t, max(max_env_queued) AS env_queued, max(max_env_running) AS env_running FROM env_metrics GROUP BY t ORDER BY t"; + export const meta: MetaFunction = () => { return [ { @@ -133,7 +135,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); const url = new URL(request.url); - const { page, query, period, from, to, view, sort } = SearchParamsSchema.parse( + const { page, query, period, from, to, sort } = SearchParamsSchema.parse( Object.fromEntries(url.searchParams) ); @@ -172,7 +174,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const autoReloadPollIntervalMs = env.QUEUES_AUTORELOAD_POLL_INTERVAL_MS; // Per-queue list metrics (Delay p95 + backlog sparkline columns) are SSR'd with the table. - // The environment header tiles are fetched client-side per card (see QueueEnvMetricTile) so a + // The environment header tiles are fetched client-side per card (see QueueEnvMetricChart) so a // slow ClickHouse query never blocks the queues list from rendering. let metrics: { bucketStartMs: number; @@ -180,9 +182,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { byQueue: Record; } | null = null; - const allocationView = queueMetricsUiEnabled && view === "allocation"; - - if (queueMetricsUiEnabled && queues.success && !allocationView) { + if (queueMetricsUiEnabled && queues.success) { // Metrics are additive observability; a ClickHouse hiccup must not take down queue // management. Fail open to metrics: null instead of bubbling to the page-level 400. try { @@ -217,10 +217,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } } - const allocation = - allocationView && queues.success - ? await new QueueAllocationPresenter().call({ environment }) - : null; + // Allocation summary (Environment limit + Allocated tiles) is additive; a presenter + // failure must not 400 the page, so fail open to null like the metrics block above. + let allocation: Awaited> | null = null; + if (queueMetricsUiEnabled && queues.success) { + try { + allocation = await new QueueAllocationPresenter().call({ environment }); + } catch (error) { + logger.warn("Queue allocation summary unavailable, rendering without it", { error }); + } + } return typedjson({ ...queues, @@ -277,6 +283,19 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return redirectWithErrorMessage(redirectPath, request, "This branch is archived"); } + // Per-queue actions (pause/resume/override/remove-override) are shared with the queue detail + // route, so they live in a helper that both routes call. + const queueMutation = await handleQueueMutationAction({ + request, + environment, + userId, + formData, + redirectPath, + }); + if (queueMutation) { + return queueMutation; + } + switch (action) { case "environment-pause": { const pauseService = new PauseEnvironmentService(); @@ -294,150 +313,32 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } return redirectWithSuccessMessage(redirectPath, request, "Environment resumed"); } - case "queue-pause": - case "queue-resume": { - const friendlyId = formData.get("friendlyId"); - if (!friendlyId) { - return redirectWithErrorMessage(redirectPath, request, "Queue ID is required"); - } - - const queueService = new PauseQueueService(); - const result = await queueService.call( - environment, - friendlyId.toString(), - action === "queue-pause" ? "paused" : "resumed" - ); - - if (!result.success) { - return redirectWithErrorMessage( - redirectPath, - request, - result.error ?? `Failed to ${action === "queue-pause" ? "pause" : "resume"} queue` - ); - } - - return redirectWithSuccessMessage( - redirectPath, - request, - `Queue ${action === "queue-pause" ? "paused" : "resumed"}` - ); - } - case "queue-override": { - const friendlyId = formData.get("friendlyId"); - const concurrencyLimit = formData.get("concurrencyLimit"); - - if (!friendlyId) { - return redirectWithErrorMessage(redirectPath, request, "Queue ID is required"); - } - - if (!concurrencyLimit) { - return redirectWithErrorMessage(redirectPath, request, "Concurrency limit is required"); - } - - const limitNumber = parseInt(concurrencyLimit.toString(), 10); - if (isNaN(limitNumber) || limitNumber < 0) { - return redirectWithErrorMessage( - redirectPath, - request, - "Concurrency limit must be a valid number" - ); - } - - const user = await getUserById(userId); - if (!user) { - return redirectWithErrorMessage(redirectPath, request, "User not found"); - } - - const result = await concurrencySystem.queues.overrideQueueConcurrencyLimit( - environment, - friendlyId.toString(), - limitNumber, - user - ); - - if (!result.isOk()) { - return redirectWithErrorMessage( - redirectPath, - request, - "Failed to override queue concurrency limit" - ); - } - - return redirectWithSuccessMessage( - redirectPath, - request, - "Queue concurrency limit overridden" - ); - } - case "queue-remove-override": { - const friendlyId = formData.get("friendlyId"); - - if (!friendlyId) { - return redirectWithErrorMessage(redirectPath, request, "Queue ID is required"); - } - - const result = await concurrencySystem.queues.resetConcurrencyLimit( - environment, - friendlyId.toString() - ); - - if (!result.isOk()) { - return redirectWithErrorMessage( - redirectPath, - request, - "Failed to reset queue concurrency limit" - ); - } - - return redirectWithSuccessMessage(redirectPath, request, "Queue concurrency limit reset"); - } - case "allocation-apply": { - if (!(await canAccessQueueMetricsUi({ userId, organizationSlug }))) { - return redirectWithErrorMessage(redirectPath, request, "Not available"); - } - - let changes; - try { - changes = AllocationChangesSchema.parse(JSON.parse(String(formData.get("changes")))); - } catch { - return redirectWithErrorMessage(redirectPath, request, "Invalid changes"); - } - - const user = await getUserById(userId); - if (!user) { - return redirectWithErrorMessage(redirectPath, request, "User not found"); - } - - let failed = 0; - for (const change of changes) { - const result = await concurrencySystem.queues.overrideQueueConcurrencyLimit( - environment, - change.friendlyId, - change.limit, - user - ); - if (!result.isOk()) failed++; - } - - if (failed > 0) { - return redirectWithErrorMessage( - redirectPath, - request, - `Failed to update ${failed} of ${changes.length} queue limits` - ); - } - - return redirectWithSuccessMessage( - redirectPath, - request, - `Updated ${changes.length} queue limit${changes.length === 1 ? "" : "s"}` - ); - } default: return redirectWithErrorMessage(redirectPath, request, "Something went wrong"); } }; +// Derives the environment concurrency status ("limit" | "burst" | "within") and the matching +// text color from the current running count vs. the env limit and burst factor. Shared by both +// the classic and metrics views so the "Running" tile styling stays in sync. +function getEnvConcurrencyLimitStatus(environment: { + running: number; + concurrencyLimit: number; + burstFactor: number; +}) { + const limitStatus = + environment.running === environment.concurrencyLimit * environment.burstFactor + ? "limit" + : environment.running > environment.concurrencyLimit + ? "burst" + : "within"; + + const limitClassName = + limitStatus === "burst" ? "text-warning" : limitStatus === "limit" ? "text-error" : undefined; + + return { limitStatus, limitClassName }; +} + export default function Page() { // Per-org flag decides which whole page renders. Off => the classic Queues page, // byte-for-byte the pre-metrics UI. Each branch is its own component (own hooks). @@ -461,53 +362,121 @@ function QueuesWithMetricsView() { const metricsByQueue = metrics?.byQueue ?? {}; + // The four header charts mirror exactly the queue set the table is showing (post-search, + // post-pagination). These are the `task/`-prefixed queue_name values the queue_metrics table + // stores, so the client-side tile queries scope to the same rows the loader listed. + const chartQueueNames = success + ? queues.map((q) => (q.type === "task" ? `task/${q.name}` : q.name)) + : []; + const organization = useOrganization(); const project = useProject(); const env = useEnvironment(); const plan = useCurrentPlan(); - const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; + // Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for + // plans whose query-period limit was raised above it — a longer window would render empty. + const planPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; + const maxPeriodDays = Math.min(planPeriodDays ?? 30, 30); // The header tiles fetch client-side with the same period/from/to the TimeFilter writes. - const { value, replace } = useSearchParams(); + const { value } = useSearchParams(); const timeRange = { period: value("period") ?? null, from: value("from") ?? null, to: value("to") ?? null, }; - const view = value("view") === "allocation" ? ("allocation" as const) : ("queues" as const); useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true }); + // Drag-to-zoom on either chart narrows the page's from/to search params, which the + // TimeFilter and the client-side metric queries both read (same wiring as the Agent page). + const zoomToTimeFilter = useZoomToTimeFilter(); + + // Live env-wide Queued/Running blocks. First paint uses the loader's Redis-exact values; from the + // first poll on we prefer ClickHouse so the blocks stay current without a full page revalidate. + // Empty rows (quiet env, or the very first fetch still in flight) fall back to the loader values, + // so we never flash a stale 0. Fixed 15m window, env-wide (no queue filter), CH-only recurring + // load; pauses while the tab is hidden (handled inside the hook). + const { rows: liveBlockRows } = useMetricResourceQuery(QUEUE_LIVE_BLOCKS_QUERY, { + organizationId: organization.id, + projectId: project.id, + environmentId: env.id, + timeRange: { period: QUEUE_LIVE_BLOCKS_PERIOD, from: null, to: null }, + defaultPeriod: QUEUE_LIVE_BLOCKS_PERIOD, + fillGaps: false, + refreshIntervalMs: 15_000, + }); + const lastLiveBlockRow = + liveBlockRows.length > 0 ? liveBlockRows[liveBlockRows.length - 1] : null; + const envQueuedLive = lastLiveBlockRow + ? tileNumber(lastLiveBlockRow.env_queued) + : environment.queued; + const envRunningLive = lastLiveBlockRow + ? tileNumber(lastLiveBlockRow.env_running) + : environment.running; + + // Allocation summary tiles. The presenter computes the env-wide allocated total (sum of + // each queue's explicit limit clamped to the env limit) in a single aggregate query. + const envLimit = environment.concurrencyLimit; + const burstLimit = Math.round(envLimit * environment.burstFactor); + const allocated = allocation?.allocated ?? 0; + const allocationPct = envLimit > 0 ? Math.round((allocated / envLimit) * 100) : 0; + const overAllocated = allocated > envLimit; + + // Running-block tinting (burst/limit) tracks the live running value, not the loader snapshot. + const { limitStatus, limitClassName } = getEnvConcurrencyLimitStatus({ + running: envRunningLive, + concurrencyLimit: environment.concurrencyLimit, + burstFactor: environment.burstFactor, + }); + + // Client-side, header-click sorting over the current page's rows. Server pagination and the + // default busiest order are unchanged; clearing a sort returns to that server order. + const queueRows = queues ?? []; + type QueueRow = (typeof queueRows)[number]; + const sortColumns = useMemo[]>( + () => [ + { key: "name", type: "alpha", value: (q) => q.name }, + { key: "queued", type: "number", value: (q) => q.queued }, + { key: "running", type: "number", value: (q) => q.running }, + { + key: "limit", + type: "number", + value: (q) => q.concurrencyLimit ?? environment.concurrencyLimit, + }, + { key: "limitedBy", type: "alpha", value: (q) => queueLimitedByLabel(q) }, + { + key: "health", + type: "alpha", + value: (q) => + queueHealthLabel({ + paused: q.paused, + running: q.running, + queued: q.queued, + limit: q.concurrencyLimit ?? environment.concurrencyLimit, + }), + }, + { + key: "delayP95", + type: "number", + value: (q) => metricsByQueue[queueMetricsKey(q)]?.p95WaitMs ?? null, + }, + { + key: "backlog", + type: "number", + value: (q) => metricsByQueue[queueMetricsKey(q)]?.peakQueued ?? null, + }, + ], + [environment.concurrencyLimit, metricsByQueue] + ); + const { sortedRows: sortedQueues, getSortProps } = useTableSort(queueRows, sortColumns); + return ( - {plan ? ( - plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? ( - - Increase limit - - ) : ( - - Increase limit - - ) - ) : null} - {environment.runsEnabled && env.pauseSource !== ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT ? ( - - ) : null} -
-
- {QUEUE_HEADER_TILES.map((tile) => ( - 1 - ? [ - { - y: Math.round(environment.burstFactor * 100), - label: `Burst ${Math.round( - environment.concurrencyLimit * environment.burstFactor - )}`, - }, - ] - : []), - ] - : undefined - } + {/* The whole page scrolls as one: filters, charts, summary tiles and the table share a + single vertical scroll context, so the charts scroll out of view with everything else + (not an inner table-only scroll). */} +
+ {/* Filters — top row, directly under the NavBar */} + {success ? ( +
+
+ + +
+ - ))} -
+
+ ) : ( +
+ )} - {success ? ( - - replace({ view: undefined })} - > - Queues - - replace({ view: "allocation", page: undefined })} - > - Allocation - - + {/* Queued + Running + Allocated + Environment limit summary */} + {success && allocation ? ( +
+ paused : undefined} + animate + accessory={ +
+ {environment.runsEnabled && + env.pauseSource !== ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT ? ( + + ) : null} + +
+ } + valueClassName={env.paused ? "text-warning tabular-nums" : "tabular-nums"} + compactThreshold={1000000} + /> + + Including {envRunningLive - environment.concurrencyLimit} burst runs{" "} + + + ) : limitStatus === "limit" ? ( + "At concurrency limit" + ) : undefined + } + accessory={ + + } + compactThreshold={1000000} + /> + + Allocated + {overAllocated ? ( + + ) : null} + + } + value={allocated} + valueClassName={cn(overAllocated && "text-warning")} + suffix={`${allocationPct}% of the environment limit`} + suffixClassName="text-text-bright" + /> + 1 ? `bursts up to ${burstLimit}` : undefined} + suffixClassName="text-text-dimmed" + accessory={ + plan ? ( + plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? ( + + ) : ( + + ) + ) : undefined + } + /> +
) : (
)} - {success && view === "allocation" ? ( - allocation ? ( - - ) : ( -
- -
- ) - ) : success ? ( -
-
-
- - - + +
+ {QUEUE_HEADER_TILES.map((tile) => ( + 1 + ? [ + { + y: Math.round(environment.burstFactor * 100), + label: `Burst ${Math.round( + environment.concurrencyLimit * environment.burstFactor + )}`, + labelPlacement: "outside" as const, + }, + ] + : []), + ] + : undefined + } + // Saturation and p95 "step over the line": a per-bucket overlay retraces only + // the over-threshold stretches in warning colour, so under-threshold values stay + // blue. (A gradient split can't do this reliably — an SVG objectBoundingBox + // gradient tracks the line's own bbox, not the y-axis, so a low/flat line reads + // as entirely warning-coloured.) + // All thresholded lines colour warning where they step over the threshold: the + // per-bucket overlay retraces only the over-threshold stretches, so the colour + // change tracks the axis crossing. + warningOverlay={ + tile.id === "saturation" + ? { threshold: 100 } + : tile.id === "p95" + ? { threshold: 60_000 } + : tile.id === "throttled" + ? // Integer counts: threshold 0 warns once a bucket has ≥1 throttle. + { threshold: 0 } + : undefined + } /> -
- + ))}
+ +
+ + {success ? ( +
+ {/* Default overflow-x-auto container so wide tables still scroll horizontally on + narrow viewports; the page (not this region) owns vertical scrolling. */} - Name - Queued - Running - Limit + Name + + Queued + + + Running + + + Limit + -
- Environment - - This queue is limited by your environment's concurrency limit of{" "} - {environment.concurrencyLimit}. - -
-
- User - - This queue is limited by a concurrency limit set in your code. - -
-
- Override - - This queue's concurrency limit has been manually overridden from the - dashboard or API. - -
+
+

+ Environment — the + environment's limit of {environment.concurrencyLimit}. +

+

+ User — a limit set in your + code. +

+

+ Override — set manually from + the dashboard or API. +

} > Limited by
- Health + + Health + Delay p95 - Backlog + + Backlog + Pause/resume
- {queues.length > 0 ? ( - queues.map((queue) => { + {sortedQueues.length > 0 ? ( + sortedQueues.map((queue) => { const limit = queue.concurrencyLimit ?? environment.concurrencyLimit; const isAtConcurrencyLimit = queue.running >= limit; const isAtQueueLimit = environment.queueSizeLimit !== null && queue.queued >= environment.queueSizeLimit; - const queueFilterableName = `${queue.type === "task" ? "task/" : ""}${ - queue.name - }`; + const queueFilterableName = queueMetricsKey(queue); const queueMetric = metricsByQueue[queueFilterableName]; return ( @@ -683,15 +765,9 @@ function QueuesWithMetricsView() { {queue.concurrency?.overriddenAt ? ( - - Concurrency limit overridden - - } + ) : null} {queue.paused ? ( @@ -705,9 +781,14 @@ function QueuesWithMetricsView() { ) : null} {isAtConcurrencyLimit ? ( - - At concurrency limit - + + } + content="At concurrency limit: this queue is running as many runs as its limit allows; new runs wait in the backlog." + className="max-w-xs" + disableHoverableContent + /> ) : null} @@ -726,8 +807,7 @@ function QueuesWithMetricsView() { className={cn( "w-[1%] pl-16 tabular-nums", queue.paused ? "opacity-50" : undefined, - queue.running > 0 && "text-text-bright", - isAtConcurrencyLimit && "text-warning" + queue.running > 0 && "text-text-bright" )} > {queue.running} @@ -740,26 +820,48 @@ function QueuesWithMetricsView() { queue.concurrency?.overriddenAt && "font-medium text-text-bright" )} > - {limit} + {queue.concurrencyLimitOverridePercent !== null ? ( + <> + {limit}{" "} + + ({formatOverridePercent(queue.concurrencyLimitOverridePercent)}%) + + + ) : ( + limit + )} {queue.concurrency?.overriddenAt ? ( - Override + Override} + content={ + queue.concurrencyLimitOverridePercent !== null + ? `Overridden at ${formatOverridePercent( + queue.concurrencyLimitOverridePercent + )}% of the environment limit.` + : `This queue's concurrency limit has been manually overridden to ${limit}.` + } + className="max-w-xs" + disableHoverableContent + /> ) : queue.concurrencyLimit ? ( "User" ) : ( "Environment" )} - + - + {queueMetric && queueMetric.p95WaitMs !== null ? ( - = 60_000 - ? "text-warning" - : "text-text-bright" - )} - > + {formatWaitMs(queueMetric.p95WaitMs)} ) : ( @@ -783,15 +879,24 @@ function QueuesWithMetricsView() { )} - v.toLocaleString()} + showPeak={false} + formatPeak={(v) => v.toLocaleString()} + peakTooltip={ + queueMetric && queueMetric.throttledTotal > 0 + ? `Peak queued; this queue was throttled ${queueMetric.throttledTotal.toLocaleString()} ${ + queueMetric.throttledTotal === 1 ? "time" : "times" + } in this period` + : "Peak queued in this period" + } /> -
+
+ aria-label={ + env.paused + ? `Resume processing runs in ${environmentFullTitle(env)}` + : `Pause processing runs in ${environmentFullTitle(env)}` + } + />
@@ -987,225 +1095,6 @@ function EnvironmentPauseResumeButton({ ); } -function QueuePauseResumeButton({ - queue, - variant = "tertiary/small", - fullWidth = false, - showTooltip = true, -}: { - /** The "id" here is a friendlyId */ - queue: { id: string; name: string; paused: boolean }; - variant?: ButtonVariant; - fullWidth?: boolean; - showTooltip?: boolean; -}) { - const [isOpen, setIsOpen] = useState(false); - - const trigger = showTooltip ? ( -
- - - -
- - - -
-
- - {queue.paused - ? `Resume processing runs in queue "${queue.name}"` - : `Pause processing runs in queue "${queue.name}"`} - -
-
-
- ) : ( - - - - ); - - return ( - - {trigger} - - {queue.paused ? "Resume queue?" : "Pause queue?"} -
- - {queue.paused - ? `This will allow runs to be dequeued in the "${queue.name}" queue again.` - : `This will pause all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`} - -
setIsOpen(false)}> - - - - {queue.paused ? "Resume queue" : "Pause queue"} - - } - cancelButton={ - - - - } - /> - -
-
-
- ); -} - -function QueueOverrideConcurrencyButton({ - queue, - environmentConcurrencyLimit, -}: { - queue: QueueItem; - environmentConcurrencyLimit: number; -}) { - const navigation = useNavigation(); - const [isOpen, setIsOpen] = useState(false); - const [concurrencyLimit, setConcurrencyLimit] = useState( - queue.concurrencyLimit?.toString() ?? environmentConcurrencyLimit.toString() - ); - - const isOverridden = !!queue.concurrency?.overriddenAt; - const currentLimit = queue.concurrencyLimit ?? environmentConcurrencyLimit; - - useEffect(() => { - if (navigation.state === "loading" || navigation.state === "idle") { - setIsOpen(false); - } - }, [navigation.state]); - - const isLoading = Boolean( - navigation.formData?.get("action") === "queue-override" || - navigation.formData?.get("action") === "queue-remove-override" - ); - - return ( - - - - - - - {isOverridden ? "Edit concurrency override" : "Override concurrency limit"} - -
- {isOverridden ? ( - - This queue's concurrency limit is currently overridden to {currentLimit}. - {typeof queue.concurrency?.base === "number" && - ` The original limit set in code was ${queue.concurrency.base}.`}{" "} - You can update the override or remove it to restore the{" "} - {typeof queue.concurrency?.base === "number" - ? "limit set in code" - : "environment concurrency limit"} - . - - ) : ( - - Override this queue's concurrency limit. The current limit is {currentLimit}, which is - set {queue.concurrencyLimit !== null ? "in code" : "by the environment"}. - - )} -
setIsOpen(false)} className="space-y-3"> - -
- - setConcurrencyLimit(e.target.value)} - placeholder={currentLimit.toString()} - autoFocus - /> -
- - } - shortcut={{ modifiers: ["mod"], key: "enter" }} - > - {isOverridden ? "Update override" : "Override limit"} - - } - cancelButton={ -
- {isOverridden && ( - - )} - - - -
- } - /> - -
-
-
- ); -} - function EngineVersionUpgradeCallout() { return (
@@ -1253,57 +1142,32 @@ export function QueueFilters() { return ; } -const QUEUE_SORT_OPTIONS = [ - { value: "busiest", label: "Busiest" }, - { value: "queued", label: "Backlog" }, - { value: "name", label: "Name" }, -] as const; - -type QueueSortValue = (typeof QUEUE_SORT_OPTIONS)[number]["value"]; - -function QueueSortFilter() { - const { value, replace } = useSearchParams(); - const sort: QueueSortValue = (value("sort") as QueueSortValue) ?? "busiest"; - const label = QUEUE_SORT_OPTIONS.find((option) => option.value === sort)?.label ?? "Busiest"; - - return ( - - replace({ sort: next === "busiest" ? undefined : (next as string), page: undefined }) - } - > - }> - - - - {QUEUE_SORT_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - ); -} - type MetricTileRow = Record; +/** One charted point per time bucket, already aggregated across the visible queue set. */ +type TilePoint = { bucket: number; value: number }; + type QueueHeaderTile = { id: string; label: string; + /** Info-icon copy explaining what the chart shows, rendered next to the card title. */ + description: string; color: string; query: string; /** Formats a single bucket's value in the chart tooltip. */ formatValue?: (value: number) => string; + /** Formats the y-axis tick labels. Without it the axis shows raw numbers (bad for durations + * in ms or percent scales). Passed through to Chart.Line's yAxisProps.tickFormatter. */ + formatAxis?: (value: number) => string; + /** Hover tooltip explaining the headline readout next to the title (e.g. what "9% of window" + * means). Without it the readout has no tooltip. */ + totalTooltip?: string; + // Rows can be one-per-bucket (p95, throttled: aggregated across the set in ClickHouse) or + // one-per-(bucket, queue) (saturation, backlog: summed across the set here, since summing a + // gauge across queues can't be a flat aggregate without double-counting sub-buckets). Either + // way derive returns the per-bucket points the chart draws. derive: (rows: MetricTileRow[]) => { - sparkline: number[]; + points: TilePoint[]; total: number; formatTotal?: (total: number) => string; totalClassName?: string; @@ -1320,46 +1184,84 @@ function tileTimeToMs(value: number | string | null): number { return Date.parse(s.endsWith("Z") ? s : `${s}Z`); } -// Header tiles fetch their own TRQL query client-side (resources.metric) with fillGaps, mirroring the -// metrics dashboard widgets: the gauges (saturation inputs, backlog) carry, counters/p95 zero-fill. +// Sums a per-(bucket, queue) row set into one value per bucket. `read` pulls the queue's +// contribution; `envColumn`, when set, carries an env-wide column (identical across the set's +// rows in a bucket) through as the max, so saturation can divide by the env limit. +function sumByBucket( + rows: MetricTileRow[], + read: (row: MetricTileRow) => number, + envColumn?: string +): Array<{ bucket: number; sum: number; env: number }> { + const byBucket = new Map(); + for (const row of rows) { + const bucket = tileTimeToMs(row.t); + if (!Number.isFinite(bucket)) continue; + const entry = byBucket.get(bucket) ?? { sum: 0, env: 0 }; + entry.sum += read(row); + if (envColumn) entry.env = Math.max(entry.env, tileNumber(row[envColumn])); + byBucket.set(bucket, entry); + } + return [...byBucket.entries()] + .map(([bucket, { sum, env }]) => ({ bucket, sum, env })) + .sort((a, b) => a.bucket - b.bucket); +} + +// Header tiles fetch their own TRQL query client-side (resources.metric) with fillGaps, scoped to +// the visible queue set (queue_metrics WHERE queue IN ). Saturation and backlog GROUP BY the +// queue too and sum here; p95 merges quantile states and throttled sums counters in ClickHouse. const QUEUE_HEADER_TILES: QueueHeaderTile[] = [ { id: "saturation", label: "Env saturation", - color: "#6366F1", - query: `SELECT timeBucket() AS t,\n max(max_env_running) AS used,\n max(max_env_limit) AS env_limit\nFROM env_metrics\nGROUP BY t\nORDER BY t`, - formatValue: (v) => `${v}%`, + description: "Running as a share of the environment limit. Yellow over 100% (burst headroom).", + color: "var(--color-queues)", + // Numerator: running summed across the visible set. Denominator: the env-wide limit (same for + // every queue in a bucket), so the line reads as the set's share of the environment capacity. + query: `SELECT timeBucket() AS t,\n queue,\n max(max_running) AS running,\n max(max_env_limit) AS env_limit\nFROM queue_metrics\nGROUP BY t, queue\nORDER BY t`, + formatValue: (v) => (v > 100 ? `${v}% — over the environment limit` : `${v}%`), + formatAxis: (v) => `${v}%`, derive: (rows) => { - const sparkline = rows.map((r) => { - const limit = tileNumber(r.env_limit); - return limit > 0 ? Math.round((tileNumber(r.used) / limit) * 100) : 0; - }); - const peak = sparkline.reduce((max, v) => Math.max(max, v), 0); - return { sparkline, total: peak, formatTotal: (v) => `${v}% peak` }; + const points = sumByBucket(rows, (r) => tileNumber(r.running), "env_limit").map( + ({ bucket, sum, env }) => ({ + bucket, + value: env > 0 ? Math.round((sum / env) * 100) : 0, + }) + ); + const peak = points.reduce((max, p) => Math.max(max, p.value), 0); + return { points, total: peak, formatTotal: (v) => `${v}% peak` }; }, }, { id: "backlog", label: "Backlog", - color: "#A78BFA", - query: `SELECT timeBucket() AS t,\n max(max_env_queued) AS queued\nFROM env_metrics\nGROUP BY t\nORDER BY t`, + description: "Runs waiting across these queues over time.", + color: "var(--color-queues)", + query: `SELECT timeBucket() AS t,\n queue,\n max(max_queued) AS queued\nFROM queue_metrics\nGROUP BY t, queue\nORDER BY t`, derive: (rows) => { - const sparkline = rows.map((r) => tileNumber(r.queued)); - const peak = sparkline.reduce((max, v) => Math.max(max, v), 0); - return { sparkline, total: peak, formatTotal: (v) => `${v.toLocaleString()} peak` }; + const points = sumByBucket(rows, (r) => tileNumber(r.queued)).map(({ bucket, sum }) => ({ + bucket, + value: sum, + })); + const peak = points.reduce((max, p) => Math.max(max, p.value), 0); + return { points, total: peak, formatTotal: (v) => `${v.toLocaleString()} peak` }; }, }, { id: "p95", label: "Scheduling delay p95", - color: "#F59E0B", - query: `SELECT timeBucket() AS t,\n round(quantilesTDigestMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS p95\nFROM env_metrics\nGROUP BY t\nORDER BY t`, + description: "p95 wait from eligible to dequeued. Yellow over 1 min.", + totalTooltip: "The worst p95 in the selected window.", + color: "var(--color-queues)", + // quantilesMerge over the set's rows in a bucket is the true p95 across the union of samples + // (merging quantile states is valid; averaging per-queue percentiles would not be). + query: `SELECT timeBucket() AS t,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS p95\nFROM queue_metrics\nGROUP BY t\nORDER BY t`, formatValue: formatWaitMs, + formatAxis: formatWaitMs, derive: (rows) => { - const sparkline = rows.map((r) => tileNumber(r.p95)); - const worst = sparkline.reduce((max, v) => Math.max(max, v), 0); + const points = rows.map((r) => ({ bucket: tileTimeToMs(r.t), value: tileNumber(r.p95) })); + const worst = points.reduce((max, p) => Math.max(max, p.value), 0); return { - sparkline, + points, total: worst, formatTotal: (v) => (v > 0 ? formatWaitMs(v) : "–"), totalClassName: worst >= 60_000 ? "text-warning" : undefined, @@ -1369,179 +1271,249 @@ const QUEUE_HEADER_TILES: QueueHeaderTile[] = [ { id: "throttled", label: "Throttled", - color: "#F59E0B", - query: `SELECT timeBucket() AS t,\n sum(throttled_count) AS throttled\nFROM env_metrics\nGROUP BY t\nORDER BY t`, + description: "Times dequeuing was blocked by a limit.", + totalTooltip: "The share of the selected window with at least one blocked dequeue.", + color: "var(--color-queues)", + query: `SELECT timeBucket() AS t,\n sum(throttled_count) AS throttled\nFROM queue_metrics\nGROUP BY t\nORDER BY t`, derive: (rows) => { - const sparkline = rows.map((r) => tileNumber(r.throttled)); - const total = sparkline.reduce((sum, v) => sum + v, 0); + const points = rows.map((r) => ({ + bucket: tileTimeToMs(r.t), + value: tileNumber(r.throttled), + })); + // Share of the window that saw any throttling. A raw event sum isn't interpretable (it + // scales with poll rate and window length); the fraction of buckets with a throttle is. + // The data path fills gaps (zero-fill for this counter), so every bucket in the window is + // present and `points.length` is the honest denominator. + const nonzero = points.filter((p) => p.value > 0).length; + const pct = points.length > 0 ? Math.round((nonzero / points.length) * 100) : 0; return { - sparkline, - total, - totalClassName: total > 0 ? "text-warning" : undefined, + points, + total: pct, + formatTotal: (v) => `${v}% of window`, + totalClassName: pct > 0 ? "text-warning" : undefined, }; }, }, ]; +// When a search matches no queues the set is empty. We still fetch (hooks can't be conditional), +// but with a queue name that can't exist so the IN filter returns nothing and the tile falls +// through to its "No activity" empty state instead of silently widening to the whole environment. +const NO_QUEUES_SENTINEL = "__no_queues__"; + type TileTimeRange = MetricResourceTimeRange; -function QueueEnvMetricTile({ +// Full-size env metric chart rendered inside a ChartCard. Same data path as before +// (client-side TRQL via useMetricResourceQuery with fillGaps), drawn as a line that +// participates in the shared hover + drag-to-zoom of the enclosing ChartSyncProvider. +function QueueEnvMetricChart({ tile, timeRange, + queueNames, referenceLines, + thresholdStroke, + warningOverlay, + solidWarning = false, }: { tile: QueueHeaderTile; timeRange: TileTimeRange; - referenceLines?: Array<{ y: number; label?: string }>; + /** The visible queue set (post-search, post-pagination) the chart scopes to. */ + queueNames: string[]; + referenceLines?: Array<{ + y: number; + label?: string; + labelPlacement?: "inside" | "outside"; + }>; + thresholdStroke?: { value: number; aboveColor: string }; + warningOverlay?: { threshold: number }; + /** When set, the ENTIRE line turns warning-coloured if the series is ever non-zero (used for + * throttling: any throttle in the window colours the whole line). Mutually exclusive with the + * per-bucket warningOverlay. */ + solidWarning?: boolean; }) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const { rows, isLoading, showLoading, failed } = useMetricResourceQuery(tile.query, { + // Scope to exactly the queues the table is showing. Empty set => sentinel that matches nothing, + // so the tile shows "No activity" rather than the whole environment. The hook re-fetches when + // this list changes (it keys on the joined names), so search/pagination reflow the charts. + const { rows, showLoading, failed } = useMetricResourceQuery(tile.query, { organizationId: organization.id, projectId: project.id, environmentId: environment.id, timeRange, defaultPeriod: QUEUE_METRICS_DEFAULT_PERIOD, fillGaps: true, + queues: queueNames.length > 0 ? queueNames : [NO_QUEUES_SENTINEL], }); - const { sparkline, total, formatTotal, totalClassName } = tile.derive(rows); + const { points, total, formatTotal, totalClassName } = tile.derive(rows); - // Same point shape the full-size charts use so the shared axis/tooltip helpers apply. - const data = rows - .map((r, i) => ({ bucket: tileTimeToMs(r.t), [tile.id]: sparkline[i] ?? 0 })) + // Same point shape the shared axis/tooltip helpers expect. + const data = points + .map((p) => ({ bucket: p.bucket, [tile.id]: p.value })) .filter((p) => Number.isFinite(p.bucket)); + // Whole-line warning colour when the series was ever non-zero (throttling: one throttle in the + // window colours the entire line). Otherwise the tile's normal colour. + const wholeLineWarning = solidWarning && total > 0; + const lineColor = wholeLineWarning ? "var(--color-warning)" : tile.color; + const chartConfig = useMemo( - () => ({ [tile.id]: { label: tile.label, color: tile.color } }), - [tile.id, tile.label, tile.color] + () => ({ [tile.id]: { label: tile.label, color: lineColor } }), + [tile.id, tile.label, lineColor] ); - const { tooltipLabelFormatter } = useMemo(() => buildActivityTimeAxis(data), [data]); - const hasData = data.length > 0 && sparkline.some((v) => v > 0); + const { tickFormatter, tooltipLabelFormatter } = useMemo( + () => buildActivityTimeAxis(data), + [data] + ); + const hasData = data.length > 0 && data.some((p) => (p[tile.id] as number) > 0); + + // Peak readout lives in the card title (ChartCard has no dedicated value slot). A zero/empty + // total renders no readout at all (skipping "0% peak", "0 peak", "0" and the p95 "–" placeholder) + // so the card title stands alone until there's a non-zero value to show. + const peak = showLoading ? ( + + ) : failed || total === 0 ? null : formatTotal ? ( + formatTotal(total) + ) : ( + total.toLocaleString() + ); return ( - - ) : failed ? undefined : formatTotal ? ( - formatTotal(total) - ) : ( - total.toLocaleString() - ) + + + {tile.label} + + + {peak != null ? ( + tile.totalTooltip && !showLoading ? ( + + {peak} + + } + content={tile.totalTooltip} + className="max-w-xs" + disableHoverableContent + /> + ) : ( + + {peak} + + ) + ) : null} + } - valueClassName={totalClassName} > - {showLoading ? ( -
+ ) : failed ? ( -
+
Unable to load metrics
) : hasData ? ( -
- - - -
+ + + ) : ( -
No activity
+
+ No activity +
)} - + ); } -function HeaderTile({ - label, - value, - valueClassName, - children, -}: { - label: ReactNode; - value?: ReactNode; - valueClassName?: string; - children: ReactNode; -}) { +function QueueMetricChartSkeleton() { return ( -
-
- {label} - {value !== undefined ? ( - - {value} - - ) : null} -
- {children} +
+ {Array.from({ length: 42 }).map((_, i) => ( +
+ ))}
); } -function QueueHealthBadge({ - paused, - running, - queued, - limit, -}: { +/** Health as a stock Badge: color carries the state, w-fit keeps it content-width. */ +type QueueHealth = { paused: boolean; running: number; queued: number; limit: number; -}) { - if (paused) { - return ( - - Paused - - ); - } - if (running >= limit && queued > 0) { - return ( - - At capacity - - ); - } - if (queued > 0) { - return ( - - Backlogged - - ); - } - if (running > 0) { - return ( - - Active - - ); - } +}; + +type QueueHealthLabel = "Paused" | "At capacity" | "Backlogged" | "Active" | "Idle"; + +// Single source of truth for the queue health decision, shared by the badge and the table's +// health-column sort so the sorted order always matches the labels shown. +function queueHealthLabel({ paused, running, queued, limit }: QueueHealth): QueueHealthLabel { + if (paused) return "Paused"; + if (running >= limit && queued > 0) return "At capacity"; + if (queued > 0) return "Backlogged"; + if (running > 0) return "Active"; + return "Idle"; +} + +const QUEUE_HEALTH_STYLES: Record = { + Paused: "text-warning", + "At capacity": "text-warning", + Backlogged: "text-blue-500", + Active: "text-success", + Idle: "text-text-dimmed", +}; + +function QueueHealthBadge(health: QueueHealth) { + const label = queueHealthLabel(health); return ( - - Idle + + {label} ); } +// The label rendered in the "Limited by" cell, also used to sort that column. +function queueLimitedByLabel(queue: { + concurrency?: { overriddenAt?: Date | string | null } | null; + concurrencyLimit?: number | null; +}): "Override" | "User" | "Environment" { + if (queue.concurrency?.overriddenAt) return "Override"; + if (queue.concurrencyLimit) return "User"; + return "Environment"; +} + +// The `queue_metrics`-prefixed key a queue is stored under (task queues are prefixed `task/`). +function queueMetricsKey(queue: { type: string; name: string }): string { + return `${queue.type === "task" ? "task/" : ""}${queue.name}`; +} + function formatWaitMs(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; @@ -1549,6 +1521,11 @@ function formatWaitMs(ms: number): string { return `${(ms / 3_600_000).toFixed(1)}h`; } +// Drop a trailing ".00" from whole percentages so "50.00" reads as "50" but "12.50" is preserved. +function formatOverridePercent(percent: number): string { + return Number.isInteger(percent) ? percent.toString() : percent.toFixed(2).replace(/\.?0+$/, ""); +} + // Classic Queues page, restored verbatim from before the Queue Metrics feature. Rendered // when queueMetricsUiEnabled is off so a gated org sees exactly the pre-metrics UI. function ClassicQueuesView() { @@ -1570,15 +1547,7 @@ function ClassicQueuesView() { useAutoRevalidate({ interval: autoReloadPollIntervalMs, onFocus: true }); - const limitStatus = - environment.running === environment.concurrencyLimit * environment.burstFactor - ? "limit" - : environment.running > environment.concurrencyLimit - ? "burst" - : "within"; - - const limitClassName = - limitStatus === "burst" ? "text-warning" : limitStatus === "limit" ? "text-error" : undefined; + const { limitStatus, limitClassName } = getEnvConcurrencyLimitStatus(environment); return ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 560a1760a5d..89ffc0d1a59 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -1,10 +1,14 @@ import { type MetaFunction } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { useMemo } from "react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { useMemo, type ReactNode } from "react"; +import type { QueueItem } from "@trigger.dev/core/v3/schemas"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { BigNumber } from "~/components/metrics/BigNumber"; +import { Header3 } from "~/components/primitives/Headers"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Spinner } from "~/components/primitives/Spinner"; import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; import { Chart, @@ -12,11 +16,12 @@ import { type ChartState, } from "~/components/primitives/charts/ChartCompound"; import { ChartCard } from "~/components/primitives/charts/ChartCard"; +import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; +import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { QUEUE_METRIC_COLORS as COLORS, QUEUE_METRICS_DEFAULT_PERIOD, QueueMetricChartCard as QueueDetailChartCard, - QueueMetricStat as Stat, type QueueMetricIds as Ids, type QueueMetricTimeRange as TimeRangeParams, clickhouseTimeToMs, @@ -29,6 +34,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { QueueRetrievePresenter } from "~/presenters/v3/QueueRetrievePresenter.server"; import { Table, + TableBlankRow, TableBody, TableCell, TableHeader, @@ -36,13 +42,26 @@ import { TableRow, } from "~/components/primitives/Table"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; +import { InfoIconTooltip } from "~/components/primitives/Tooltip"; +import { SearchInput } from "~/components/primitives/SearchInput"; import { engine } from "~/v3/runEngine.server"; import { TimeFilter } from "~/components/runs/v3/SharedFilters"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { useTableSort, type SortColumn } from "~/components/primitives/useTableSort"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema, v3RunsPath } from "~/utils/pathBuilder"; +import { formatNumberCompact } from "~/utils/numberFormatter"; +import { cn } from "~/utils/cn"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { handleQueueMutationAction } from "~/models/queueMutation.server"; +import { + QueueOverrideConcurrencyButton, + QueuePauseResumeButton, +} from "~/components/queues/QueueControls"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; export const meta: MetaFunction = () => [{ title: `Queue metrics | Trigger.dev` }]; @@ -75,17 +94,32 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const queue = retrieve.queue; const fullName = queue.type === "task" ? `task/${queue.name}` : queue.name; - const ckBreakdown = await engine.concurrencyKeyBreakdown(environment, fullName, { - limit: CK_LIVE_LIMIT, - }); + const [ckBreakdown, oldestQueuedAt] = await Promise.all([ + engine.concurrencyKeyBreakdown(environment, fullName, { limit: CK_LIVE_LIMIT }), + // Enqueue time of the oldest run still waiting in the queue right now (any queue, keyed or + // not); undefined when the queue is empty. Drives the live "Oldest wait" block. + engine.oldestMessageInQueue(environment, fullName), + ]); // Charts + CH-derived stats are fetched client-side per card (see QueueDetailChartCard / // useQueueMetric) so the drill-down renders instantly. The loader only returns the live // "now" counts + identifiers the client fetches need. + // Link the Queued block to this queue's pending runs (same filter the list uses). + const queuedRunsPath = v3RunsPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { queues: [fullName], statuses: ["PENDING"], period: "30d", rootOnly: false } + ); + return typedjson({ queue, fullName, + queuedRunsPath, + // The override dialog caps at the environment's concurrency limit. + environmentConcurrencyLimit: environment.maximumConcurrencyLimit, ckBreakdown, + oldestQueuedAt: oldestQueuedAt ?? null, loadedAt: Date.now(), backPath: url.pathname.replace(/\/[^/]+$/, ""), ids: { @@ -96,13 +130,73 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); }; +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam, queueParam } = ParamsSchema.parse(params); + + const url = new URL(request.url); + const redirectPath = `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues/${queueParam}${url.search}`; + + if (request.method.toLowerCase() !== "post") { + return redirectWithErrorMessage(redirectPath, request, "Wrong method"); + } + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) throw new Response(undefined, { status: 404, statusText: "Project not found" }); + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) + throw new Response(undefined, { status: 404, statusText: "Environment not found" }); + + const formData = await request.formData(); + + // Pause/resume/override actions are shared with the Queues list route; here we redirect back to + // the detail page so the user stays put. + const result = await handleQueueMutationAction({ + request, + environment, + userId, + formData, + redirectPath, + }); + if (result) return result; + + return redirectWithErrorMessage(redirectPath, request, "Something went wrong"); +}; + const CK_LIVE_LIMIT = 50; +// Whole-queue oldest wait right now: for keyed queues the per-key breakdown carries the oldest +// enqueue time per key, so the queue's oldest is the max wait across keys; otherwise fall back to +// the queue's oldest message directly. Returns null when nothing is waiting. +function wholeQueueOldestWaitMs( + breakdown: CkBreakdown, + oldestQueuedAt: number | null, + now: number +): number | null { + if (breakdown.keys.length > 0) { + return breakdown.keys.reduce((max, k) => Math.max(max, now - k.oldestEnqueuedAt), 0); + } + return oldestQueuedAt !== null ? Math.max(0, now - oldestQueuedAt) : null; +} + export default function Page() { - const { queue, fullName, ckBreakdown, loadedAt, backPath, ids } = - useTypedLoaderData(); + const { + queue, + fullName, + queuedRunsPath, + environmentConcurrencyLimit, + ckBreakdown, + oldestQueuedAt, + loadedAt, + backPath, + ids, + } = useTypedLoaderData(); const plan = useCurrentPlan(); - const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; + // Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for + // plans whose query-period limit was raised above it — a longer window would render empty. + const planPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number; + const maxPeriodDays = Math.min(planPeriodDays ?? 30, 30); const { value, replace } = useSearchParams(); const timeRange: TimeRangeParams = { @@ -131,13 +225,12 @@ export default function Page() {
-
- + {/* Filters — search (concurrency keys) + time filter, above everything, like the + Queues list. The time filter scopes the tab charts; search filters the keys table. */} +
+ {showKeysTab ? ( + + ) : null}
- {showKeysTab && ( + {/* Live "right now" state of the whole queue — independent of the time filter above. */} + + + {showKeysTab ? ( - )} + ) : null} {view === "keys" ? ( - - - - - + +
+ + + + + +
+
); } @@ -266,44 +399,85 @@ function ConcurrencyKeysView({ timeRange: TimeRangeParams; queueName: string; }) { + const zoomToTimeFilter = useZoomToTimeFilter(); + const { replace } = useSearchParams(); + + // The live most-starved key: among keys with a live backlog, the one whose oldest waiting run + // has been waiting longest right now. Names the culprit on the "Worst key wait" card so the chart + // (which shows how bad, not who) points at a tenant you can click through to. + const worstKeyNow = useMemo(() => { + let worst: { key: string; waitMs: number } | null = null; + for (const k of breakdown.keys) { + if (k.queued <= 0) continue; + const waitMs = Math.max(0, loadedAt - k.oldestEnqueuedAt); + if (!worst || waitMs > worst.waitMs) worst = { key: k.concurrencyKey, waitMs }; + } + return worst; + }, [breakdown, loadedAt]); + return ( - <> - - - - +
+ {/* Per-key breakdown: which keys hold the backlog / do the work. */} + +
+ + + {/* Whole-queue health across keys (single series, tasks-blue). */} + + replace({ key: worstKeyNow.key })} + className="cursor-pointer text-xs font-normal tabular-nums text-text-dimmed transition-colors hover:text-text-bright" + > + {worstKeyNow.key} · {formatWaitMs(worstKeyNow.waitMs)} now + + ) : null + } + className="aspect-[2/1]" + query={`SELECT timeBucket() AS t, max(max_ck_wait_ms) AS wait\nFROM queue_metrics\nGROUP BY t\nORDER BY t`} + fillGaps + ids={ids} + timeRange={timeRange} + queueName={queueName} + valueFormat={formatWaitMs} + series={[{ key: "wait", label: "Max wait", color: COLORS.running }]} + /> +
+
- +
); } @@ -331,8 +505,21 @@ const KEY_SERIES_COLORS = [ "#84CC16", ]; +// A card title with an info "i" tooltip, matching the Queues header charts. Used by the grouped +// per-key charts (the plain single-series cards get the same treatment via QueueMetricChartCard). +function chartTitleWithInfo(title: string, info: string) { + return ( + + {title} + + + ); +} + type GroupedKeyChartProps = { title: string; + info: string; + className?: string; /** Aggregate expression ranking keys over the whole range (top 8 charted). */ rankExpr: string; /** Aggregate expression charted per (bucket, key). */ @@ -363,6 +550,8 @@ function GroupedKeyChartCard(props: GroupedKeyChartProps) { function GroupedKeySeries({ keys, title, + info, + className, seriesExpr, fillGaps, valueFormat, @@ -406,8 +595,8 @@ function GroupedKeySeries({ const state: ChartState = showLoading ? "loading" : failed ? "invalid" : undefined; return ( -
- +
+ (query ? merged.filter((r) => r.key.toLowerCase().includes(query)) : merged), + [merged, query] + ); + + const sortColumns = useMemo[]>( + () => [ + { key: "key", type: "alpha", value: (r) => r.key }, + { key: "queued", type: "number", value: (r) => r.queued }, + { key: "running", type: "number", value: (r) => r.running }, + { key: "oldestWait", type: "number", value: (r) => r.oldestWaitMs }, + { key: "started", type: "number", value: (r) => r.range?.started }, + { key: "peakBacklog", type: "number", value: (r) => r.range?.peakBacklog }, + { key: "meanWait", type: "number", value: (r) => r.range?.meanWaitMs }, + ], + [] + ); + const { sortedRows, getSortProps } = useTableSort(filtered, sortColumns); + if (merged.length === 0) return null; return ( <>
-
-
Concurrency keys
-
- {breakdown.totalBackloggedKeys > 0 - ? `${breakdown.totalBackloggedKeys.toLocaleString()} ${ - breakdown.totalBackloggedKeys === 1 ? "key" : "keys" - } with queued runs now` - : "No keys with queued runs right now"} -
-
- Key - Queued now - Running now - Oldest wait - Started - Peak backlog - Mean delay + Key + + Queued now + + + Running now + + + Oldest wait + + + Started + + + Peak backlog + + + Mean delay + - {merged.map((row) => ( + {sortedRows.length === 0 ? ( + + No keys match “{query}” + + ) : null} + {sortedRows.map((row) => ( - - - 0, round(sum(wait_ms_sum) / sum(wait_ms_count)), 0) AS wait\nFROM queue_metrics_by_key\nWHERE ${pin}\nGROUP BY t\nORDER BY t`} - ids={ids} - timeRange={timeRange} - queueName={queueName} - valueFormat={formatWaitMs} - series={[{ key: "wait", label: "Mean delay", color: COLORS.p95 }]} - /> - + +
+ + + 0, round(sum(wait_ms_sum) / sum(wait_ms_count)), 0) AS wait\nFROM queue_metrics_by_key\nWHERE ${pin}\nGROUP BY t\nORDER BY t`} + ids={ids} + timeRange={timeRange} + queueName={queueName} + valueFormat={formatWaitMs} + series={[{ key: "wait", label: "Mean delay", color: COLORS.running }]} + /> +
+
); } +// Live "right now" snapshot of the whole queue: a hybrid feed. The loader (Redis/PG) supplies the +// first paint so these blocks render instantly; after that a tiny 15s ClickHouse poll keeps them +// fresh, always reading the newest gauge row and falling back to the loader values until the first +// poll lands (so we never flash 0). These blocks never change with the filter. Period trends +// (backlog, throughput, delay over time) live in the charts below. +// Oldest-wait threshold for the warning tint: the head of the queue sitting unstarted this long +// signals the queue is stuck, not just busy. +const OLDEST_WAIT_WARNING_MS = 5 * 60_000; + function QueueStats({ queue, + environmentConcurrencyLimit, + queuedRunsPath, + oldestWaitMs, ids, timeRange, queueName, }: { - queue: { running: number; queued: number }; + queue: QueueItem; + environmentConcurrencyLimit: number; + queuedRunsPath: string; + oldestWaitMs: number | null; ids: Ids; timeRange: TimeRangeParams; queueName: string; }) { - // One scalar query feeds the CH-derived stats; the "now" counts come from the loader (live). - const { rows, showLoading } = useQueueMetric( - `SELECT max(max_limit) AS lim, max(max_queued) AS peak_queued, deltaSumTimestampMerge(started_delta) AS started,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS worst_p95\nFROM queue_metrics`, + // Live "now" is empty for a queue that just drained, so add the window peak/worst as a dimmed + // caption (styled like "bursts up to 50" on the Queues list) — a quiet queue still shows how + // backed up it got recently. "worst_wait" is the window's worst head-of-line wait (max of the + // oldest still-waiting run's age), the same metric as the live "Oldest wait" headline — not the + // scheduling-delay percentiles of runs that already started. Only concurrency-keyed queues record + // this (max_ck_wait_ms), so it's 0/absent for non-keyed queues. + const { rows } = useQueueMetric( + `SELECT max(max_queued) AS peak_queued,\n max(max_ck_wait_ms) AS worst_wait\nFROM queue_metrics`, { ids, timeRange, queueName } ); - const row = rows[0]; - const worstP95 = row ? toNumber(row.worst_p95) : 0; + const peakQueued = rows[0] ? toNumber(rows[0].peak_queued) : 0; + const worstWaitMs = rows[0] ? toNumber(rows[0].worst_wait) : 0; + + // Latest gauges from ClickHouse, polled every 15s so the live blocks keep ticking after first + // paint. Read the newest bucket (largest t); until the first poll lands liveRows is empty and the + // *Live values stay null, so the blocks show the loader values instead of flashing 0. + const { rows: liveRows } = useQueueMetric( + `SELECT timeBucket() AS t, max(max_running) AS running, max(max_queued) AS queued, max(max_limit) AS q_limit, max(max_ck_wait_ms) AS ck_wait FROM queue_metrics GROUP BY t ORDER BY t`, + { + ids, + timeRange: { period: "15m", from: null, to: null }, + defaultPeriod: "15m", + queueName, + refreshIntervalMs: 15_000, + } + ); + const latest = liveRows.length > 0 ? liveRows[liveRows.length - 1] : undefined; + const runningLive = latest ? toNumber(latest.running) : null; + const queuedLive = latest ? toNumber(latest.queued) : null; + const limitLive = latest ? toNumber(latest.q_limit) : null; + const ckWaitLive = latest ? toNumber(latest.ck_wait) : null; + + // Prefer CH once it has landed; loader values before that. + const runningDisplay = runningLive ?? queue.running; + const queuedDisplay = queuedLive ?? queue.queued; + // Limit is queue config, not a live signal: keep the loader's value. Only if the loader had none + // do we fall back to the CH gauge for display. + const limitDisplay = queue.concurrencyLimit ?? (limitLive || null); + // Keyed queues report head-of-line wait via CH (max_ck_wait_ms); use it as the live headline when + // present. Non-keyed queues have no CH signal, so they stay on the loader value. + const oldestWaitDisplayMs = ckWaitLive !== null && ckWaitLive > 0 ? ckWaitLive : oldestWaitMs; return ( -
- - - - + + + +
+ } /> - 0 ? `peak ${formatNumberCompact(peakQueued)}` : undefined} + suffixClassName="text-text-dimmed" + accessory={ + + } /> - 0 ? formatWaitMs(worstP95) : "–"} - loading={showLoading} - className={worstP95 >= 60_000 ? "text-warning" : undefined} + 0 + ? formatWaitMs(oldestWaitDisplayMs) + : "0" + } + valueClassName={cn( + "tabular-nums", + oldestWaitDisplayMs !== null && + oldestWaitDisplayMs >= OLDEST_WAIT_WARNING_MS && + "text-warning" + )} + suffix={worstWaitMs > 0 ? `worst ${formatWaitMs(worstWaitMs)}` : undefined} + suffixClassName="text-text-dimmed" /> ); } + +/** Live concurrency as a single block: running vs limit with a utilization bar, so "how close to + * the ceiling" reads at a glance instead of two separate numbers. Warning-tinted at/over the limit. */ +function ConcurrencyBlock({ + running, + limit, + paused = false, + loading, + accessory, +}: { + running: number; + limit: number | null; + paused?: boolean; + loading?: boolean; + accessory?: ReactNode; +}) { + const atLimit = limit !== null && limit > 0 && running >= limit; + const pct = limit && limit > 0 ? Math.min(100, Math.round((running / limit) * 100)) : 0; + return ( +
+
+
+ Concurrency + {paused ? paused : null} +
+ {accessory ?
{accessory}
: null} +
+ {loading ? ( + + ) : ( +
+
+ + {running.toLocaleString()} + + + / {limit !== null ? limit.toLocaleString() : "∞"} + + {limit !== null && limit > 0 && ( + + {pct}% of limit + + )} +
+ {limit !== null && limit > 0 && ( +
+
+
+ )} +
+ )} +
+ ); +} diff --git a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts index 908e8f449a0..9717aa2b942 100644 --- a/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts +++ b/apps/webapp/app/routes/admin.api.v1.environments.$environmentId.ts @@ -6,6 +6,7 @@ import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; import { engine } from "~/v3/runEngine.server"; import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; +import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server"; const ParamsSchema = z.object({ environmentId: z.string(), @@ -46,6 +47,9 @@ export async function action({ request, params }: ActionFunctionArgs) { await updateEnvConcurrencyLimits(environment); + // Percent-based queue overrides follow the environment limit automatically. + await concurrencySystem.queues.recalculatePercentLimits(environment); + // Org max-concurrency changed too, which is embedded in every env of the org; invalidating // the org drops the env/authEnv rows for all of them (including this env). controlPlaneResolver.invalidateOrganization(environment.organizationId); diff --git a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts index c99637a0d10..403df19fe37 100644 --- a/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts +++ b/apps/webapp/app/routes/admin.api.v1.orgs.$organizationId.concurrency.ts @@ -5,6 +5,7 @@ import { prisma } from "~/db.server"; import { requireAdminApiRequest } from "~/services/personalAccessToken.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; import { updateEnvConcurrencyLimits } from "~/v3/runQueue.server"; +import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server"; const ParamsSchema = z.object({ organizationId: z.string(), @@ -82,6 +83,12 @@ export async function action({ request, params }: ActionFunctionArgs) { }); await updateEnvConcurrencyLimits({ ...modifiedEnvironment, organization }); + + // Percent-based queue overrides follow the environment limit automatically. + await concurrencySystem.queues.recalculatePercentLimits({ + ...modifiedEnvironment, + organization, + }); } // Org + every affected env's concurrency changed; one org invalidation covers them all. diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts index 3223a2a6062..099cb9fa204 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts @@ -5,10 +5,18 @@ import { toQueueItem } from "~/presenters/v3/QueueRetrievePresenter.server"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server"; -const BodySchema = z.object({ - type: RetrieveQueueType.default("id"), - concurrencyLimit: z.number().int().min(0).max(100000), -}); +const BodySchema = z + .object({ + type: RetrieveQueueType.default("id"), + // Absolute concurrency limit. Backwards compatible with existing callers. + concurrencyLimit: z.number().int().min(0).max(100000).optional(), + // Percentage of the environment's maximum concurrency limit (0 < percent <= 100). + // Stored as the source of truth; the absolute limit is materialized from it. + percent: z.number().gt(0).max(100).optional(), + }) + .refine((body) => (body.concurrencyLimit === undefined) !== (body.percent === undefined), { + message: "Provide exactly one of `concurrencyLimit` or `percent`", + }); export const { action } = createActionApiRoute( { @@ -26,8 +34,11 @@ export const { action } = createActionApiRoute( name: decodeURIComponent(params.queueParam).replace(/%2F/g, "/"), }; + const override = + body.percent !== undefined ? { percent: body.percent } : { limit: body.concurrencyLimit! }; + return concurrencySystem.queues - .overrideQueueConcurrencyLimit(authentication.environment, input, body.concurrencyLimit) + .overrideQueueConcurrencyLimit(authentication.environment, input, override) .match( (queue) => { return json( @@ -51,6 +62,10 @@ export const { action } = createActionApiRoute( case "queue_not_found": { return json({ error: "Queue not found" }, { status: 404 }); } + case "invalid_override": + case "concurrency_limit_exceeds_maximum": { + return json({ error: error.message }, { status: 400 }); + } case "queue_update_failed": { return json({ error: "Failed to update queue concurrency limit" }, { status: 500 }); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index da36e2871d4..047a6ab4342 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -1218,7 +1218,7 @@ function WaitingInQueueBlock({ title="Backlog" headline={`${waiting.queued.toLocaleString()} queued`} query={`SELECT timeBucket() AS t, max(max_queued) AS v\nFROM queue_metrics\nGROUP BY t\nORDER BY t`} - color="#A78BFA" + color="var(--color-tasks)" ids={waiting.ids} queueName={queueName} /> diff --git a/apps/webapp/app/routes/storybook.charts/route.tsx b/apps/webapp/app/routes/storybook.charts/route.tsx index 8c7580cb613..040211437ad 100644 --- a/apps/webapp/app/routes/storybook.charts/route.tsx +++ b/apps/webapp/app/routes/storybook.charts/route.tsx @@ -13,6 +13,7 @@ import { useDateRange, } from "~/components/primitives/charts/DateRangeContext"; import type { ZoomRange } from "~/components/primitives/charts/hooks/useZoomSelection"; +import { MiniLineChart } from "~/components/metrics/MiniLineChart"; import { Paragraph } from "~/components/primitives/Paragraph"; import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; import SegmentedControl from "~/components/primitives/SegmentedControl"; @@ -262,6 +263,126 @@ function ChartsDashboard() { /> + + {/* Line with per-bucket warning overlay (queues redesign) */} + + +
+ + Warning overlay{" "} + (per-bucket recolour) +
+
+ + {/* The base line stays the series colour; buckets strictly above `threshold` retrace + in the warning colour, so the same line reads blue -> yellow -> blue as it crosses. */} + + + + +
+ + {/* Line with gradient threshold split (queues redesign) */} + + +
+ + Threshold stroke{" "} + (gradient split) +
+
+ + {/* Gradient split above the threshold. Best when the threshold sits mid-domain — if it + sits far below the data max the split collapses onto the baseline; prefer + `warningOverlay` in that case (see the Warning overlay example). */} + + + + +
+ + {/* Line with outside-placed reference line label (queues redesign) */} + + +
+ + Reference line{" "} + (label outside, in the gutter) +
+
+ + {/* labelPlacement: "outside" renders the label in the right gutter at the line's y; + the chart's right margin is widened automatically so it isn't clipped. */} + + + + +
+ + {/* MiniLineChart backlog sparklines (queues redesign) */} + + +
+ + Mini line chart{" "} + (inline backlog sparkline) +
+
+ + {/* Fixed-size inline sparkline sized for a table cell, with a trailing peak label. The + second row passes aligned `throttled` buckets so throttled stretches retrace the same + line in the warning colour. */} +
+ + + + + + + + + + + + + + + + +
QueueBacklog
emails + +
image-processing + +
+ +
); @@ -681,6 +802,30 @@ const API_DATA = { "analyze-document": 5678, }, ], + // Single-series queue depth that crosses the 5,000 threshold twice (blue -> yellow -> blue), + // used by the warning overlay, threshold stroke and outside reference-line examples. + queueDepthData: [ + { day: "2023-11-01", queued: 1200 }, + { day: "2023-11-02", queued: 2400 }, + { day: "2023-11-03", queued: 3800 }, + { day: "2023-11-04", queued: 4600 }, + { day: "2023-11-05", queued: 6200 }, + { day: "2023-11-06", queued: 7400 }, + { day: "2023-11-07", queued: 6800 }, + { day: "2023-11-08", queued: 5200 }, + { day: "2023-11-09", queued: 3600 }, + { day: "2023-11-10", queued: 2800 }, + { day: "2023-11-11", queued: 3400 }, + { day: "2023-11-12", queued: 5600 }, + { day: "2023-11-13", queued: 7800 }, + { day: "2023-11-14", queued: 6400 }, + { day: "2023-11-15", queued: 4200 }, + ], + // Inline sparkline buckets (oldest first). No throttling on this one. + miniLineData: [3, 5, 8, 12, 9, 14, 20, 18, 11, 7, 4, 6], + // A backlog that was throttled across a stretch in the middle; `throttled` aligns 1:1 with `data`. + miniLineThrottledData: [2, 4, 9, 16, 24, 30, 27, 19, 12, 8, 5, 3], + miniLineThrottledBuckets: [0, 0, 0, 6, 14, 18, 11, 0, 0, 0, 0, 0], }; const lineChartConfig = { @@ -694,6 +839,13 @@ const lineChartConfig = { }, } satisfies ChartConfig; +const queueDepthConfig = { + queued: { + label: "Queue depth", + color: "var(--color-tasks)", + }, +} satisfies ChartConfig; + const barChartBigDatasetConfig = { "sync-data": { label: ( diff --git a/apps/webapp/app/v3/services/allocateConcurrency.server.ts b/apps/webapp/app/v3/services/allocateConcurrency.server.ts index b9a28daba60..3ed7bbf2b59 100644 --- a/apps/webapp/app/v3/services/allocateConcurrency.server.ts +++ b/apps/webapp/app/v3/services/allocateConcurrency.server.ts @@ -3,6 +3,7 @@ import { ManageConcurrencyPresenter } from "~/presenters/v3/ManageConcurrencyPre import { BaseService } from "./baseService.server"; import { updateEnvConcurrencyLimits } from "../runQueue.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; +import { concurrencySystem } from "./concurrencySystemInstance.server"; type Input = { userId: string; @@ -90,6 +91,9 @@ export class AllocateConcurrencyService extends BaseService { await updateEnvConcurrencyLimits(updatedEnvironment); } + // Percent-based queue overrides follow the environment limit automatically. + await concurrencySystem.queues.recalculatePercentLimits(updatedEnvironment); + // maximumConcurrencyLimit changed in the control-plane; drop any cached copy. controlPlaneResolver.invalidateEnvironment(environment.id); } diff --git a/apps/webapp/app/v3/services/concurrencySystem.server.ts b/apps/webapp/app/v3/services/concurrencySystem.server.ts index 488f38ce279..47317c23763 100644 --- a/apps/webapp/app/v3/services/concurrencySystem.server.ts +++ b/apps/webapp/app/v3/services/concurrencySystem.server.ts @@ -2,6 +2,7 @@ import type { TaskQueue, User } from "@trigger.dev/database"; import { errAsync, fromPromise, okAsync } from "neverthrow"; import type { PrismaClientOrTransaction } from "~/db.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; import { removeQueueConcurrencyLimits, updateQueueConcurrencyLimits } from "../runQueue.server"; import { engine } from "../runEngine.server"; @@ -12,6 +13,25 @@ export type ConcurrencySystemOptions = { export type QueueInput = string | { type: "task" | "custom"; name: string }; +/** + * The concurrency-limit override to apply to a queue. Either an absolute `limit` or a `percent` + * of the environment's maximum concurrency limit. A bare `number` is accepted for backwards + * compatibility and is treated as an absolute limit. + */ +export type ConcurrencyLimitOverride = number | { limit: number } | { percent: number }; + +/** + * Materializes an absolute concurrency limit from a percentage of the environment limit. + * Reused by the recalculation task that runs when an environment limit changes. + * + * Clamped to `>= 1` so a percent-based override never produces a `0` (pause-like) limit, and + * to `<= envLimit` so it can never exceed the environment maximum. + */ +export function materializePercentLimit(envLimit: number, percent: number): number { + const materialized = Math.floor((envLimit * percent) / 100); + return Math.min(Math.max(materialized, 1), envLimit); +} + export class ConcurrencySystem { constructor(private readonly options: ConcurrencySystemOptions) {} @@ -24,18 +44,12 @@ export class ConcurrencySystem { overrideQueueConcurrencyLimit: ( environment: AuthenticatedEnvironment, queue: QueueInput, - concurrencyLimit: number, + override: ConcurrencyLimitOverride, overriddenBy?: User ) => { return findQueueFromInput(this.db, environment, queue) .andThen((queue) => - overrideQueueConcurrencyLimit( - this.db, - environment, - queue, - concurrencyLimit, - overriddenBy - ) + overrideQueueConcurrencyLimit(this.db, environment, queue, override, overriddenBy) ) .andThen((queue) => syncQueueConcurrencyToEngine(environment, queue)) .andThen((queue) => getQueueStats(environment, queue)); @@ -46,6 +60,54 @@ export class ConcurrencySystem { .andThen((queue) => syncQueueConcurrencyToEngine(environment, queue)) .andThen((queue) => getQueueStats(environment, queue)); }, + /** + * Recalculates the materialized limit of every percent-based override in the environment + * against its CURRENT maximumConcurrencyLimit and syncs changed queues to the run engine. + * Call AFTER the environment-limit DB update has committed (engine syncs must not run + * inside an open transaction). Idempotent: unchanged queues are skipped. One failing queue + * is logged and skipped so the rest still converge. + */ + recalculatePercentLimits: async (environment: AuthenticatedEnvironment) => { + const queues = await this.db.taskQueue.findMany({ + where: { + runtimeEnvironmentId: environment.id, + concurrencyLimitOverridePercent: { not: null }, + }, + }); + + let updated = 0; + for (const queue of queues) { + try { + const percent = queue.concurrencyLimitOverridePercent; + if (percent === null) continue; + const newLimit = materializePercentLimit( + environment.maximumConcurrencyLimit, + percent.toNumber() + ); + if (newLimit === queue.concurrencyLimit) continue; + + await this.db.taskQueue.update({ + where: { id: queue.id }, + data: { concurrencyLimit: newLimit }, + }); + // Paused queues keep their engine limit at 0 (the pause/resume flow re-syncs from the + // stored value on resume); only push the recomputed limit for active queues so a + // percent recalc never effectively un-pauses a queue. + if (!queue.paused) { + await updateQueueConcurrencyLimits(environment, queue.name, newLimit); + } + updated++; + } catch (error) { + logger.error("Failed to recalculate percent queue limit", { + queueId: queue.id, + environmentId: environment.id, + error, + }); + } + } + + return { total: queues.length, updated }; + }, }; } } @@ -117,13 +179,49 @@ function overrideQueueConcurrencyLimit( db: PrismaClientOrTransaction, environment: AuthenticatedEnvironment, queue: TaskQueue, - concurrencyLimit: number, + override: ConcurrencyLimitOverride, overriddenBy?: User ) { - const newConcurrencyLimit = Math.max( - Math.min(concurrencyLimit, environment.maximumConcurrencyLimit), - 0 - ); + const maximum = environment.maximumConcurrencyLimit; + + // Normalize the input into the absolute limit to persist and the percent source-of-truth + // (null for absolute overrides). + let newConcurrencyLimit: number; + let overridePercent: number | null; + + if (typeof override === "object" && "percent" in override) { + const percent = override.percent; + + if (!Number.isFinite(percent) || percent <= 0 || percent > 100) { + return errAsync({ + type: "invalid_override" as const, + message: "Percent must be greater than 0 and less than or equal to 100", + }); + } + + newConcurrencyLimit = materializePercentLimit(maximum, percent); + overridePercent = percent; + } else { + const limit = typeof override === "number" ? override : override.limit; + + if (!Number.isFinite(limit) || limit < 0) { + return errAsync({ + type: "invalid_override" as const, + message: "Concurrency limit must be a non-negative number", + }); + } + + // Cap: an absolute override may not exceed the environment limit. Reject rather than clamp. + if (limit > maximum) { + return errAsync({ + type: "concurrency_limit_exceeds_maximum" as const, + message: `Concurrency limit (${limit}) cannot exceed the environment limit (${maximum})`, + }); + } + + newConcurrencyLimit = limit; + overridePercent = null; + } const concurrencyLimitBase = queue.concurrencyLimitOverriddenAt ? queue.concurrencyLimitBase @@ -137,6 +235,7 @@ function overrideQueueConcurrencyLimit( data: { concurrencyLimit: newConcurrencyLimit, concurrencyLimitBase: concurrencyLimitBase ?? null, + concurrencyLimitOverridePercent: overridePercent, concurrencyLimitOverriddenAt: new Date(), concurrencyLimitOverriddenBy: overriddenBy?.id ?? null, }, @@ -162,6 +261,7 @@ function resetQueueConcurrencyLimit(db: PrismaClientOrTransaction, queue: TaskQu concurrencyLimitOverriddenAt: null, concurrencyLimit: newConcurrencyLimit, concurrencyLimitBase: null, + concurrencyLimitOverridePercent: null, concurrencyLimitOverriddenBy: null, }, }), diff --git a/apps/webapp/seed-queue-metrics.mts b/apps/webapp/seed-queue-metrics.mts index 709ba8f25ed..df56b1c55d1 100644 --- a/apps/webapp/seed-queue-metrics.mts +++ b/apps/webapp/seed-queue-metrics.mts @@ -615,6 +615,20 @@ async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear const prefix = "engine:runqueue:"; const logicalBase = `{org:${ids.organization_id}}:proj:${ids.project_id}:env:${ids.environment_id}:queue:`; const base = `${prefix}${logicalBase}`; + + // Env-level structures the Queues list "Queued"/"Running" blocks read: + // lengthOfEnvQueue -> ZCARD(envQueueKey) (ZSET, no proj section) + // concurrencyOfEnvQueue -> SCARD(envCurrentDequeuedKey) (SET) + // We accumulate the per-queue staged counts below and stage these so the blocks + // equal the table's per-queue sums instead of showing 0/0. + const envQueueKey = `${prefix}{org:${ids.organization_id}}:env:${ids.environment_id}`; + const envCurrentDequeuedKey = `${prefix}{org:${ids.organization_id}}:proj:${ids.project_id}:env:${ids.environment_id}:currentDequeued`; + await redis.del(envQueueKey, envCurrentDequeuedKey); + // Table Queued = ZCARD(base) + lengthCounter (base zset unstaged -> only CK queues + // contribute their lengthCounter). Table Running = SCARD(currentDequeued) per queue. + let envQueuedTotal = 0; + let envRunningTotal = 0; + for (const [q, profile] of scenario.queues.entries()) { const key = `${base}${profile.name}:currentDequeued`; await redis.del(key); @@ -642,6 +656,7 @@ async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear if (count > 0) { await redis.sadd(key, ...Array.from({ length: count }, (_v, i) => `sim_run_${i}`)); } + envRunningTotal += count; if (profile.ck) { const now = Date.now(); @@ -670,8 +685,28 @@ async function stageRedisUsage(scenario: Scenario, ids: Ids, seed: number, clear } // The aggregate "Queued now" reads ZCARD(base) + this counter; keep them coherent. await redis.set(lengthCounterKey, totalCkQueued, "EX", 24 * 3600); + envQueuedTotal += totalCkQueued; + } + } + + // Stage the env-level structures so the list-page blocks match the table sums. + // Members are unique across queues (each per-queue set uses its own key, but the + // env set/zset needs distinct members to reach the summed cardinality). + if (!clear) { + if (envRunningTotal > 0) { + await redis.sadd( + envCurrentDequeuedKey, + ...Array.from({ length: envRunningTotal }, (_v, i) => `sim_env_run_${i}`) + ); + } + if (envQueuedTotal > 0) { + const now = Date.now(); + const zargs: Array = []; + for (let i = 0; i < envQueuedTotal; i++) zargs.push(now + i, `sim_env_queued_${i}`); + await redis.zadd(envQueueKey, ...zargs); } } + await redis.quit(); console.log( clear diff --git a/apps/webapp/test/concurrencySystemPercentOverride.test.ts b/apps/webapp/test/concurrencySystemPercentOverride.test.ts new file mode 100644 index 00000000000..533a530ca7e --- /dev/null +++ b/apps/webapp/test/concurrencySystemPercentOverride.test.ts @@ -0,0 +1,307 @@ +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { describe, expect, it, vi } from "vitest"; +import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { ConcurrencySystem, materializePercentLimit } from "~/v3/services/concurrencySystem.server"; + +// The real run engine opens eager Redis connections and needs the whole engine +// wired up. These tests exercise the DB-write + recalculation logic against a +// real Postgres (postgresTest), so we replace the engine singleton with a thin +// stub that satisfies the sync + stats calls the service makes. The engine sync +// itself (RunQueue/Redis) is therefore NOT exercised here — see the note in the +// verification report. Everything the service persists to Postgres IS real. +vi.mock("~/v3/runEngine.server", () => ({ + engine: { + lengthOfQueues: async () => ({}), + currentConcurrencyOfQueues: async () => ({}), + runQueue: { + updateQueueConcurrencyLimits: async () => undefined, + removeQueueConcurrencyLimits: async () => undefined, + updateEnvConcurrencyLimits: async () => undefined, + }, + }, +})); + +vi.setConfig({ testTimeout: 30_000 }); + +describe("materializePercentLimit", () => { + it("floors the materialized value", () => { + // 10 * 55 / 100 = 5.5 -> floor -> 5 + expect(materializePercentLimit(10, 55)).toBe(5); + // 7 * 50 / 100 = 3.5 -> floor -> 3 + expect(materializePercentLimit(7, 50)).toBe(3); + }); + + it("clamps to at least 1 so a percent override never produces a 0 (pause-like) limit", () => { + // 10 * 1 / 100 = 0.1 -> floor -> 0 -> clamp up to 1 + expect(materializePercentLimit(10, 1)).toBe(1); + // tiny env, small percent still floors to 0 then clamps to 1 + expect(materializePercentLimit(1, 1)).toBe(1); + expect(materializePercentLimit(3, 10)).toBe(1); + }); + + it("clamps to at most the environment limit at 100%", () => { + expect(materializePercentLimit(10, 100)).toBe(10); + expect(materializePercentLimit(1, 100)).toBe(1); + expect(materializePercentLimit(250, 100)).toBe(250); + }); + + it("handles a tiny environment limit at the 100% boundary", () => { + expect(materializePercentLimit(1, 50)).toBe(1); // 0.5 -> 0 -> clamp to 1 + expect(materializePercentLimit(2, 50)).toBe(1); // 1.0 -> 1 + expect(materializePercentLimit(2, 100)).toBe(2); + }); + + it("supports fractional percentages", () => { + // 100 * 12.5 / 100 = 12.5 -> floor -> 12 + expect(materializePercentLimit(100, 12.5)).toBe(12); + }); +}); + +async function seedEnvAndQueue( + prisma: PrismaClient, + opts: { maximumConcurrencyLimit: number; queueConcurrencyLimit?: number | null } +) { + const slug = `s${Math.random().toString(36).slice(2, 10)}`; + + const organization = await prisma.organization.create({ + data: { title: slug, slug }, + }); + + const project = await prisma.project.create({ + data: { name: slug, slug, organizationId: organization.id, externalRef: slug }, + }); + + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug, + type: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + apiKey: slug, + pkApiKey: slug, + shortcode: slug, + maximumConcurrencyLimit: opts.maximumConcurrencyLimit, + }, + }); + + const queue = await prisma.taskQueue.create({ + data: { + friendlyId: `queue_${slug}`, + name: `task/${slug}`, + projectId: project.id, + runtimeEnvironmentId: environment.id, + concurrencyLimit: opts.queueConcurrencyLimit ?? null, + }, + }); + + const authEnv = { + id: environment.id, + maximumConcurrencyLimit: environment.maximumConcurrencyLimit, + } as unknown as AuthenticatedEnvironment; + + return { organization, project, environment, queue, authEnv }; +} + +describe("ConcurrencySystem percent overrides", () => { + postgresTest( + "materializes a percent override and stores the percent as the source of truth", + async ({ prisma }) => { + const { queue, authEnv } = await seedEnvAndQueue(prisma, { + maximumConcurrencyLimit: 10, + queueConcurrencyLimit: 8, + }); + + const system = new ConcurrencySystem({ db: prisma, reader: prisma }); + + const result = await system.queues.overrideQueueConcurrencyLimit(authEnv, queue.friendlyId, { + percent: 50, + }); + + expect(result.isOk()).toBe(true); + + const row = await prisma.taskQueue.findUniqueOrThrow({ where: { id: queue.id } }); + // floor(10 * 50 / 100) = 5 + expect(row.concurrencyLimit).toBe(5); + expect(row.concurrencyLimitOverridePercent?.toNumber()).toBe(50); + // base captures the pre-override absolute limit + expect(row.concurrencyLimitBase).toBe(8); + expect(row.concurrencyLimitOverriddenAt).not.toBeNull(); + } + ); + + postgresTest( + "rejects an absolute override that exceeds the environment limit and persists nothing", + async ({ prisma }) => { + const { queue, authEnv } = await seedEnvAndQueue(prisma, { + maximumConcurrencyLimit: 10, + queueConcurrencyLimit: 8, + }); + + const system = new ConcurrencySystem({ db: prisma, reader: prisma }); + + const result = await system.queues.overrideQueueConcurrencyLimit(authEnv, queue.friendlyId, { + limit: 9999, + }); + + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.type).toBe("concurrency_limit_exceeds_maximum"); + } + + // Nothing should have been written. + const row = await prisma.taskQueue.findUniqueOrThrow({ where: { id: queue.id } }); + expect(row.concurrencyLimit).toBe(8); + expect(row.concurrencyLimitOverridePercent).toBeNull(); + expect(row.concurrencyLimitOverriddenAt).toBeNull(); + expect(row.concurrencyLimitBase).toBeNull(); + } + ); + + postgresTest("rejects an out-of-range percent as invalid_override", async ({ prisma }) => { + const { queue, authEnv } = await seedEnvAndQueue(prisma, { + maximumConcurrencyLimit: 10, + }); + + const system = new ConcurrencySystem({ db: prisma, reader: prisma }); + + const tooHigh = await system.queues.overrideQueueConcurrencyLimit(authEnv, queue.friendlyId, { + percent: 101, + }); + expect(tooHigh.isErr()).toBe(true); + if (tooHigh.isErr()) expect(tooHigh.error.type).toBe("invalid_override"); + + const tooLow = await system.queues.overrideQueueConcurrencyLimit(authEnv, queue.friendlyId, { + percent: 0, + }); + expect(tooLow.isErr()).toBe(true); + if (tooLow.isErr()) expect(tooLow.error.type).toBe("invalid_override"); + + const row = await prisma.taskQueue.findUniqueOrThrow({ where: { id: queue.id } }); + expect(row.concurrencyLimitOverriddenAt).toBeNull(); + }); + + postgresTest("reset clears both the absolute limit and the percent", async ({ prisma }) => { + const { queue, authEnv } = await seedEnvAndQueue(prisma, { + maximumConcurrencyLimit: 10, + queueConcurrencyLimit: 8, + }); + + const system = new ConcurrencySystem({ db: prisma, reader: prisma }); + + const overridden = await system.queues.overrideQueueConcurrencyLimit( + authEnv, + queue.friendlyId, + { percent: 50 } + ); + expect(overridden.isOk()).toBe(true); + + const reset = await system.queues.resetConcurrencyLimit(authEnv, queue.friendlyId); + expect(reset.isOk()).toBe(true); + + const row = await prisma.taskQueue.findUniqueOrThrow({ where: { id: queue.id } }); + // restored to the base captured at override time + expect(row.concurrencyLimit).toBe(8); + expect(row.concurrencyLimitOverridePercent).toBeNull(); + expect(row.concurrencyLimitBase).toBeNull(); + expect(row.concurrencyLimitOverriddenAt).toBeNull(); + expect(row.concurrencyLimitOverriddenBy).toBeNull(); + }); + + postgresTest( + "recalculatePercentLimits recomputes percent queues and leaves absolute overrides untouched", + async ({ prisma }) => { + const { project, environment, authEnv } = await seedEnvAndQueue(prisma, { + maximumConcurrencyLimit: 10, + queueConcurrencyLimit: 8, + }); + + const system = new ConcurrencySystem({ db: prisma, reader: prisma }); + + // A percent-based queue: 50% of env(10) -> 5 + const percentQueue = await prisma.taskQueue.create({ + data: { + friendlyId: `queue_pct_${Math.random().toString(36).slice(2, 8)}`, + name: `task/pct-${Math.random().toString(36).slice(2, 8)}`, + projectId: project.id, + runtimeEnvironmentId: environment.id, + concurrencyLimit: 6, + }, + }); + const pctResult = await system.queues.overrideQueueConcurrencyLimit( + authEnv, + percentQueue.friendlyId, + { percent: 50 } + ); + expect(pctResult.isOk()).toBe(true); + { + const row = await prisma.taskQueue.findUniqueOrThrow({ where: { id: percentQueue.id } }); + expect(row.concurrencyLimit).toBe(5); // floor(10 * 0.5) + } + + // An absolute-override queue: limit 4, no percent + const absQueue = await prisma.taskQueue.create({ + data: { + friendlyId: `queue_abs_${Math.random().toString(36).slice(2, 8)}`, + name: `task/abs-${Math.random().toString(36).slice(2, 8)}`, + projectId: project.id, + runtimeEnvironmentId: environment.id, + concurrencyLimit: 6, + }, + }); + const absResult = await system.queues.overrideQueueConcurrencyLimit( + authEnv, + absQueue.friendlyId, + { limit: 4 } + ); + expect(absResult.isOk()).toBe(true); + + // Simulate the environment limit changing from 10 -> 20 and recalculate. + const bumpedEnv = { + id: environment.id, + maximumConcurrencyLimit: 20, + } as unknown as AuthenticatedEnvironment; + + const outcome = await system.queues.recalculatePercentLimits(bumpedEnv); + expect(outcome.total).toBe(1); // only the percent queue is considered + expect(outcome.updated).toBe(1); + + const pctRow = await prisma.taskQueue.findUniqueOrThrow({ where: { id: percentQueue.id } }); + // floor(20 * 50 / 100) = 10 + expect(pctRow.concurrencyLimit).toBe(10); + expect(pctRow.concurrencyLimitOverridePercent?.toNumber()).toBe(50); + + const absRow = await prisma.taskQueue.findUniqueOrThrow({ where: { id: absQueue.id } }); + // absolute override must be untouched + expect(absRow.concurrencyLimit).toBe(4); + expect(absRow.concurrencyLimitOverridePercent).toBeNull(); + } + ); + + postgresTest( + "recalculatePercentLimits is idempotent when the limit is unchanged", + async ({ prisma }) => { + const { queue, authEnv } = await seedEnvAndQueue(prisma, { + maximumConcurrencyLimit: 10, + queueConcurrencyLimit: 8, + }); + + const system = new ConcurrencySystem({ db: prisma, reader: prisma }); + + const overridden = await system.queues.overrideQueueConcurrencyLimit( + authEnv, + queue.friendlyId, + { percent: 50 } + ); + expect(overridden.isOk()).toBe(true); + + // Recalculate against the SAME environment limit -> nothing to update. + const outcome = await system.queues.recalculatePercentLimits(authEnv); + expect(outcome.total).toBe(1); + expect(outcome.updated).toBe(0); + + const row = await prisma.taskQueue.findUniqueOrThrow({ where: { id: queue.id } }); + expect(row.concurrencyLimit).toBe(5); + } + ); +}); diff --git a/apps/webapp/test/useTableSort.test.ts b/apps/webapp/test/useTableSort.test.ts new file mode 100644 index 00000000000..4b6c83666d9 --- /dev/null +++ b/apps/webapp/test/useTableSort.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { compareColumn, sortRows, type SortColumn } from "~/components/primitives/useTableSort"; + +type Row = { id: number; name: string | null; queued: number | null }; + +const rows: Row[] = [ + { id: 0, name: "banana", queued: 3 }, + { id: 1, name: "Apple", queued: null }, + { id: 2, name: "cherry", queued: 3 }, + { id: 3, name: null, queued: 1 }, +]; + +describe("sortRows", () => { + it("sorts numbers ascending with nulls last", () => { + const column: SortColumn = { key: "queued", type: "number", value: (r) => r.queued }; + expect(sortRows(rows, column, "asc").map((r) => r.id)).toEqual([3, 0, 2, 1]); + }); + + it("keeps nulls last even when descending", () => { + const column: SortColumn = { key: "queued", type: "number", value: (r) => r.queued }; + // 3 and 0 both have queued=3; stable order (0 before 2) is preserved, null (id 1) stays last. + expect(sortRows(rows, column, "desc").map((r) => r.id)).toEqual([0, 2, 3, 1]); + }); + + it("sorts alphabetically case-insensitively with empty/null last", () => { + const column: SortColumn = { key: "name", type: "alpha", value: (r) => r.name }; + expect(sortRows(rows, column, "asc").map((r) => r.id)).toEqual([1, 0, 2, 3]); + expect(sortRows(rows, column, "desc").map((r) => r.id)).toEqual([2, 0, 1, 3]); + }); + + it("is stable for equal values (preserves original order)", () => { + const column: SortColumn = { key: "queued", type: "number", value: () => 5 }; + expect(sortRows(rows, column, "asc").map((r) => r.id)).toEqual([0, 1, 2, 3]); + }); + + it("supports a custom comparator", () => { + // Sort by name length. + const column: SortColumn = { + key: "name", + type: "custom", + compare: (a, b) => (a.name?.length ?? 0) - (b.name?.length ?? 0), + }; + expect(sortRows(rows, column, "asc").map((r) => r.id)).toEqual([3, 1, 0, 2]); + }); +}); + +describe("compareColumn", () => { + it("treats NaN as null (sorts last)", () => { + const column: SortColumn = { key: "queued", type: "number", value: (r) => r.queued }; + const withNaN: Row = { id: 9, name: "x", queued: NaN }; + const normal: Row = { id: 10, name: "y", queued: 1 }; + expect(compareColumn(column, withNaN, normal, "asc")).toBe(1); + expect(compareColumn(column, withNaN, normal, "desc")).toBe(1); + }); +}); diff --git a/internal-packages/clickhouse/src/queueMetrics.ts b/internal-packages/clickhouse/src/queueMetrics.ts index dce9323ef26..d86645e5153 100644 --- a/internal-packages/clickhouse/src/queueMetrics.ts +++ b/internal-packages/clickhouse/src/queueMetrics.ts @@ -51,6 +51,7 @@ const QueueMetricsSummaryRow = z.object({ p95_wait_ms: z.coerce.number(), peak_queued: z.coerce.number(), started_count: z.coerce.number(), + throttled_count: z.coerce.number(), }); // Callers align window bounds to the bucket grid so repeated loads share cache entries. @@ -68,7 +69,8 @@ export function getQueueListMetricsSummary(reader: ClickhouseReader) { round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[1]) AS p50_wait_ms, round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS p95_wait_ms, max(max_queued) AS peak_queued, - deltaSumTimestampMerge(started_delta) AS started_count + deltaSumTimestampMerge(started_delta) AS started_count, + sum(throttled_count) AS throttled_count FROM trigger_dev.queue_metrics_v1 WHERE organization_id = {organizationId: String} AND project_id = {projectId: String} @@ -91,16 +93,22 @@ const QueueDepthSparklineRow = z.object({ queue_name: z.string(), bucket: z.string(), depth: z.coerce.number(), + throttled: z.coerce.number(), }); -/** Per-queue, per-bucket peak depth for inline sparklines (carry-forward filled by the caller). */ +/** + * Per-queue, per-bucket peak depth (carry-forward filled by the caller) plus the throttled + * count in each bucket, so the sparkline can tint the exact buckets where throttling occurred. + * The extra aggregate rides the same scan as the depth series — no additional round trip. + */ export function getQueueDepthSparklines(reader: ClickhouseReader) { return reader.query({ name: "getQueueDepthSparklines", query: `SELECT queue_name, toStartOfInterval(bucket_start, toIntervalSecond({bucketSeconds: UInt32})) AS bucket, - max(max_queued) AS depth + max(max_queued) AS depth, + sum(throttled_count) AS throttled FROM trigger_dev.queue_metrics_v1 WHERE organization_id = {organizationId: String} AND project_id = {projectId: String} diff --git a/internal-packages/database/prisma/migrations/20260716120000_add_task_queue_concurrency_limit_override_percent/migration.sql b/internal-packages/database/prisma/migrations/20260716120000_add_task_queue_concurrency_limit_override_percent/migration.sql new file mode 100644 index 00000000000..cf1aa0ed84e --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260716120000_add_task_queue_concurrency_limit_override_percent/migration.sql @@ -0,0 +1,5 @@ +-- Adds a nullable column to store a queue concurrency-limit override expressed as a percentage +-- of the environment limit. This percentage is the source of truth; the absolute +-- "concurrencyLimit" is materialized from it at save time. Additive and nullable, so existing +-- absolute overrides are unaffected. Decimal(5,2) supports fractional percentages (0.01–100.00). +ALTER TABLE "TaskQueue" ADD COLUMN IF NOT EXISTS "concurrencyLimitOverridePercent" DECIMAL(5,2); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 1b8642e866f..b861b80d83a 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1829,14 +1829,18 @@ model TaskQueue { runtimeEnvironmentId String /// Represents the current concurrency limit for the queue - concurrencyLimit Int? + concurrencyLimit Int? /// When the concurrency limit was overridden - concurrencyLimitOverriddenAt DateTime? + concurrencyLimitOverriddenAt DateTime? /// Who overrode the concurrency limit (will be null if overridden via the API) - concurrencyLimitOverriddenBy String? + concurrencyLimitOverriddenBy String? /// If concurrencyLimit is overridden, this is the overridden value - concurrencyLimitBase Int? - rateLimit Json? + concurrencyLimitBase Int? + /// If the override was expressed as a percentage of the environment limit, this stores that + /// percentage (the source of truth). The absolute concurrencyLimit is materialized from it. + /// Decimal(5,2) allows fractional percentages like 12.50% (0.01–100.00). + concurrencyLimitOverridePercent Decimal? @db.Decimal(5, 2) + rateLimit Json? paused Boolean @default(false) From 24e8f7a9fdf27b26373ca1b584a58d44865360a7 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 19 Jul 2026 22:59:05 +0000 Subject: [PATCH 02/37] fix(webapp): reset queue override fields when re-seeding the metrics sim Re-seeding overwrote the materialized concurrencyLimit while leaving a manually-set override percent behind, so the table showed e.g. "10 (77%)" against an env limit of 25 while the override dialog correctly computed 19. --- apps/webapp/seed-queue-metrics.mts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/webapp/seed-queue-metrics.mts b/apps/webapp/seed-queue-metrics.mts index df56b1c55d1..911ce51d9c6 100644 --- a/apps/webapp/seed-queue-metrics.mts +++ b/apps/webapp/seed-queue-metrics.mts @@ -768,7 +768,16 @@ async function ensureTaskQueues( projectId, type: "NAMED", }, - update: { concurrencyLimit }, + // Reset any dashboard override left from manual testing: re-seeding overwrites the + // materialized concurrencyLimit, so a surviving override percent/base would contradict it + // (e.g. "10 (77%)" with an env limit of 25). + update: { + concurrencyLimit, + concurrencyLimitBase: null, + concurrencyLimitOverridePercent: null, + concurrencyLimitOverriddenAt: null, + concurrencyLimitOverriddenBy: null, + }, }); } From 49330219d2be559bcad455999de179b1f9b75875 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 19 Jul 2026 23:03:15 +0000 Subject: [PATCH 03/37] chore(webapp): drop unused imports left by the controls extraction --- .../route.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 92247805ec7..224bf09cde7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -9,7 +9,6 @@ import { import { DialogClose } from "@radix-ui/react-dialog"; import { Form, Link, useNavigation, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import type { QueueItem } from "@trigger.dev/core/v3/schemas"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { useEffect, useMemo, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -27,7 +26,6 @@ import { Callout } from "~/components/primitives/Callout"; import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; import { Header3 } from "~/components/primitives/Headers"; -import { Input } from "~/components/primitives/Input"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; From b169e13673c26b9b88e3b8c7c73467fb609825f8 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 19 Jul 2026 23:37:36 +0000 Subject: [PATCH 04/37] feat(webapp): add MetricsLayout component and storybook page Add a presentational compound layout (MetricsLayout.Root/.Filters/.Grid/ .Content) for metric/dashboard pages: one page-level scroll, named slots, and a count-adaptive tile grid. Showcase it in a route-based storybook page demonstrating the auto-adapting grids and a tabs-vs-table content slot. --- .../app/components/layout/MetricsLayout.tsx | 204 ++++++++++++++++++ .../app/routes/storybook.layout/route.tsx | 202 +++++++++++++++++ apps/webapp/app/routes/storybook/route.tsx | 4 + 3 files changed, 410 insertions(+) create mode 100644 apps/webapp/app/components/layout/MetricsLayout.tsx create mode 100644 apps/webapp/app/routes/storybook.layout/route.tsx diff --git a/apps/webapp/app/components/layout/MetricsLayout.tsx b/apps/webapp/app/components/layout/MetricsLayout.tsx new file mode 100644 index 00000000000..edf03936cc8 --- /dev/null +++ b/apps/webapp/app/components/layout/MetricsLayout.tsx @@ -0,0 +1,204 @@ +/** + * MetricsLayout — a compound layout for metric / dashboard pages. + * + * Every metrics page reads top-to-bottom as the same three slots: + * + * 1. `MetricsLayout.Filters` — the row under the NavBar (search, TimeFilter, pagination…). + * 2. `MetricsLayout.Grid` — one or more grids of tiles (stat BigNumbers, chart cards). + * The grid adapts its column count to the number of tiles unless + * you pass an explicit `columns` spec. + * 3. `MetricsLayout.Content` — the tabs / table / list below the tiles. + * + * `MetricsLayout.Root` owns the single, page-level scroll: the WHOLE page (filters, tiles and + * content) scrolls as one, rather than an inner table-only scroll. + * + * The family is purely presentational — slots, grids and scroll only, no data logic. It is meant + * to live inside a `PageContainer` right after the `NavBar`, mirroring how the `Chart.Root` / + * `Chart.Line` compound composes. + * + * @example List page (self-padded sections, count-derived grids) + * ```tsx + * + * + * + * + *
…search + TimeFilter…
+ * + *
+ * …4 stat tiles… + *
+ * + * …4 chart tiles… + * + *
+ * …table… + *
+ *
+ * ``` + * + * @example Detail page (a single padded, gap-4 column) + * ```tsx + * + * …search + TimeFilter… + * …3 stat tiles… + * + * + * …active view (charts / keys)… + * + * + * ``` + */ +import { Children, type ReactNode } from "react"; +import { PageBody } from "~/components/layout/AppLayout"; +import { cn } from "~/utils/cn"; + +type ColumnCount = 1 | 2 | 3 | 4 | 5 | 6; + +/** + * A responsive column spec. Each key is a breakpoint (mobile-first `base`, then `sm`/`md`/`lg`); + * the value is the number of grid columns from that breakpoint up. Pass this to `Grid` when the + * tile count shouldn't drive the layout (e.g. a chart grid that is always two-up). + */ +export type GridColumns = { + base?: ColumnCount; + sm?: ColumnCount; + md?: ColumnCount; + lg?: ColumnCount; +}; + +// Static class maps so Tailwind's scanner sees every column class as a literal string. +const BASE_COLS: Record = { + 1: "grid-cols-1", + 2: "grid-cols-2", + 3: "grid-cols-3", + 4: "grid-cols-4", + 5: "grid-cols-5", + 6: "grid-cols-6", +}; +const SM_COLS: Record = { + 1: "sm:grid-cols-1", + 2: "sm:grid-cols-2", + 3: "sm:grid-cols-3", + 4: "sm:grid-cols-4", + 5: "sm:grid-cols-5", + 6: "sm:grid-cols-6", +}; +const MD_COLS: Record = { + 1: "md:grid-cols-1", + 2: "md:grid-cols-2", + 3: "md:grid-cols-3", + 4: "md:grid-cols-4", + 5: "md:grid-cols-5", + 6: "md:grid-cols-6", +}; +const LG_COLS: Record = { + 1: "lg:grid-cols-1", + 2: "lg:grid-cols-2", + 3: "lg:grid-cols-3", + 4: "lg:grid-cols-4", + 5: "lg:grid-cols-5", + 6: "lg:grid-cols-6", +}; + +// When `columns` is omitted the grid figures itself out from the tile count. The breakpoints are +// chosen so the common metric layouts fall out for free: a trio of stat blocks goes one-up then +// three-up, a quartet of stat/chart tiles goes two-up then four-up, and anything larger settles +// into a comfortable two-up. +function columnsForCount(count: number): GridColumns { + switch (count) { + case 1: + return { base: 1 }; + case 2: + return { base: 1, sm: 2 }; + case 3: + return { base: 1, sm: 3 }; + case 4: + return { base: 2, lg: 4 }; + default: + return { base: 1, sm: 2 }; + } +} + +function MetricsLayoutRoot({ + children, + className, + pageBodyClassName, +}: { + children: ReactNode; + /** Applied to the inner scroll container (e.g. `flex flex-col gap-4 p-6` for a single column). */ + className?: string; + /** Applied to the outer, non-scrolling PageBody wrapper. */ + pageBodyClassName?: string; +}) { + return ( + + {/* The whole page scrolls as one: filters, tiles and content share a single vertical scroll + context, so the tiles scroll out of view with everything else (not an inner content-only + scroll). */} +
+ {children} +
+
+ ); +} + +function MetricsLayoutFilters({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return
{children}
; +} + +function MetricsLayoutGrid({ + children, + columns, + className, +}: { + children: ReactNode; + /** Explicit responsive columns. Omit to derive the layout from the number of tiles. */ + columns?: GridColumns; + className?: string; +}) { + const resolved = columns ?? columnsForCount(Children.toArray(children).length); + return ( +
+ {children} +
+ ); +} + +function MetricsLayoutContent({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return
{children}
; +} + +export const MetricsLayout = { + Root: MetricsLayoutRoot, + Filters: MetricsLayoutFilters, + Grid: MetricsLayoutGrid, + Content: MetricsLayoutContent, +}; + +export { MetricsLayoutRoot, MetricsLayoutFilters, MetricsLayoutGrid, MetricsLayoutContent }; diff --git a/apps/webapp/app/routes/storybook.layout/route.tsx b/apps/webapp/app/routes/storybook.layout/route.tsx new file mode 100644 index 00000000000..b1b6b08c69f --- /dev/null +++ b/apps/webapp/app/routes/storybook.layout/route.tsx @@ -0,0 +1,202 @@ +import { useState } from "react"; +import { PageContainer } from "~/components/layout/AppLayout"; +import { MetricsLayout } from "~/components/layout/MetricsLayout"; +import { Badge } from "~/components/primitives/Badge"; +import { Header3 } from "~/components/primitives/Headers"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Table, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { TabButton, TabContainer } from "~/components/primitives/Tabs"; +import { cn } from "~/utils/cn"; + +// A placeholder for a search input / TimeFilter / pagination — the real pages drop live controls +// into the Filters slot; here we only show the row shape. +function FilterChip({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// A stat BigNumber-style tile. +function StatTile({ label, value }: { label: string; value: string }) { + return ( +
+ {label} +
+ {value} +
+
+ ); +} + +// A chart-card-style tile with a fake bar sparkline so the grid's height + column behaviour is +// visible. +function ChartTile({ label, className }: { label: string; className?: string }) { + return ( +
+ {label} +
+ {Array.from({ length: 40 }).map((_, i) => ( +
+ ))} +
+
+ ); +} + +function PlaceholderTable() { + return ( + + + + Name + Queued + Running + Limit + + + + {[ + { name: "background", queued: 0, running: 4, limit: 19 }, + { name: "per-tenant", queued: 83, running: 11, limit: 10 }, + { name: "emails", queued: 2, running: 1, limit: 50 }, + ].map((row) => ( + + {row.name} + {row.queued} + {row.running} + {row.limit} + + ))} + +
+ ); +} + +/** + * Demonstrates the MetricsLayout compound: a Filters row, count-adaptive Grids (a 4-tile stat + * grid + a 4-tile chart grid + a 3-tile grid, all with no hand-written grid-cols), and a Content + * slot that toggles between tabs and a table. The whole page scrolls as one via MetricsLayout.Root. + */ +export default function Story() { + const [tab, setTab] = useState<"tabs" | "table">("tabs"); + + return ( + + + + + + {/* Filters slot — the row directly under the NavBar. */} + +
+ Search… + Period: 7d + Filters slot +
+ {"< 1 / 4 >"} +
+ + {/* Grid slot — 4 stat tiles. Columns are derived from the tile count: two-up, four-up + from lg. */} +
+ Grid — 4 tiles (auto: 2-up, 4-up from lg) +
+ + + + + + + + {/* Grid slot — 4 chart tiles at a fixed row height, same auto columns as the stats. */} +
+ Grid — 4 chart tiles (fixed 280px row) +
+
+ + + + + + +
+ + {/* Grid slot — 3 tiles. The same component now lays out one-up, three-up from sm, proving + the grid adapts to the child count. */} +
+ Grid — 3 tiles (auto: 1-up, 3-up from sm) +
+ + + + + + + {/* Grid slot — explicit columns for a chart grid that should always be two-up regardless + of tile count. */} +
+ Grid — explicit columns={{ base: 1, sm: 2 }} (5 tiles) +
+
+ + + + + + + +
+ + {/* Content slot — tabs vs. table. */} + + + setTab("tabs")} + > + Tabs content + + setTab("table")} + > + Table content + + + {tab === "table" ? ( + + ) : ( +
+ + The Content slot hosts whatever sits below the tiles — a TabContainer with panels, + or a full-width table. The whole page (filters, tiles and content) shares one + vertical scroll. + +
+ )} +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index 012d47827de..8309fee5b2b 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -71,6 +71,10 @@ const stories: Story[] = [ name: "Inline code", slug: "inline-code", }, + { + name: "Layout", + slug: "layout", + }, { name: "Loading bar divider", slug: "loading-bar-divider", From 120e0877f066cfe11db338faa854826185805398 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Sun, 19 Jul 2026 23:37:43 +0000 Subject: [PATCH 05/37] refactor(webapp): adopt MetricsLayout on the queues list and detail pages Rebuild both queues pages on the shared MetricsLayout skeleton (Filters / Grid / Content slots + single page scroll). No visual change: same spacing, grids and breakpoints; the stat grids now derive their columns from the tile count, chart grids pass explicit columns. --- .../route.tsx | 961 +++++++++--------- .../route.tsx | 78 +- 2 files changed, 515 insertions(+), 524 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 224bf09cde7..d7cf6253f8c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -20,6 +20,7 @@ import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { QueuesHasNoTasks } from "~/components/BlankStatePanels"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { MetricsLayout } from "~/components/layout/MetricsLayout"; import { Badge } from "~/components/primitives/Badge"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; @@ -484,518 +485,506 @@ function QueuesWithMetricsView() { - - {/* The whole page scrolls as one: filters, charts, summary tiles and the table share a - single vertical scroll context, so the charts scroll out of view with everything else - (not an inner table-only scroll). */} -
- {/* Filters — top row, directly under the NavBar */} - {success ? ( -
-
- - -
- + {/* Filters — top row, directly under the NavBar */} + {success ? ( + +
+ +
- ) : ( -
- )} - - {/* Queued + Running + Allocated + Environment limit summary */} - {success && allocation ? ( -
- paused : undefined} - animate - accessory={ -
- {environment.runsEnabled && - env.pauseSource !== ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT ? ( - - ) : null} - -
- } - valueClassName={env.paused ? "text-warning tabular-nums" : "tabular-nums"} - compactThreshold={1000000} - /> - - Including {envRunningLive - environment.concurrencyLimit} burst runs{" "} - - - ) : limitStatus === "limit" ? ( - "At concurrency limit" - ) : undefined - } - accessory={ + + + ) : ( +
+ )} + + {/* Queued + Running + Allocated + Environment limit summary. Four stat tiles: the grid + derives its columns from the tile count (two-up, four-up from lg). */} + {success && allocation ? ( + + paused : undefined} + animate + accessory={ +
+ {environment.runsEnabled && + env.pauseSource !== ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT ? ( + + ) : null} - } - compactThreshold={1000000} - /> - - Allocated - {overAllocated ? ( - - ) : null} +
+ } + valueClassName={env.paused ? "text-warning tabular-nums" : "tabular-nums"} + compactThreshold={1000000} + /> + + Including {envRunningLive - environment.concurrencyLimit} burst runs{" "} + - } - value={allocated} - valueClassName={cn(overAllocated && "text-warning")} - suffix={`${allocationPct}% of the environment limit`} - suffixClassName="text-text-bright" - /> - 1 ? `bursts up to ${burstLimit}` : undefined} - suffixClassName="text-text-dimmed" - accessory={ - plan ? ( - plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? ( - - ) : ( - - ) - ) : undefined - } - /> -
- ) : ( -
- )} + ) : limitStatus === "limit" ? ( + "At concurrency limit" + ) : undefined + } + accessory={ + + } + compactThreshold={1000000} + /> + + Allocated + {overAllocated ? ( + + ) : null} + + } + value={allocated} + valueClassName={cn(overAllocated && "text-warning")} + suffix={`${allocationPct}% of the environment limit`} + suffixClassName="text-text-bright" + /> + 1 ? `bursts up to ${burstLimit}` : undefined} + suffixClassName="text-text-dimmed" + accessory={ + plan ? ( + plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? ( + + ) : ( + + ) + ) : undefined + } + /> + + ) : ( +
+ )} + + {/* Env saturation, Backlog, Scheduling delay p95, Throttled viz — full-size, synced, + drag-to-zoom line charts (Agent page pattern). Four chart tiles: 2x2 below lg, 4-up + from lg, derived from the tile count. */} +
+ + + {QUEUE_HEADER_TILES.map((tile) => ( + 1 + ? [ + { + y: Math.round(environment.burstFactor * 100), + label: `Burst ${Math.round( + environment.concurrencyLimit * environment.burstFactor + )}`, + labelPlacement: "outside" as const, + }, + ] + : []), + ] + : undefined + } + // Saturation and p95 "step over the line": a per-bucket overlay retraces only + // the over-threshold stretches in warning colour, so under-threshold values stay + // blue. (A gradient split can't do this reliably — an SVG objectBoundingBox + // gradient tracks the line's own bbox, not the y-axis, so a low/flat line reads + // as entirely warning-coloured.) + // All thresholded lines colour warning where they step over the threshold: the + // per-bucket overlay retraces only the over-threshold stretches, so the colour + // change tracks the axis crossing. + warningOverlay={ + tile.id === "saturation" + ? { threshold: 100 } + : tile.id === "p95" + ? { threshold: 60_000 } + : tile.id === "throttled" + ? // Integer counts: threshold 0 warns once a bucket has ≥1 throttle. + { threshold: 0 } + : undefined + } + /> + ))} + + +
- {/* Env saturation, Backlog, Scheduling delay p95, Throttled viz — full-size, synced, - drag-to-zoom line charts (Agent page pattern). 2x2 below lg, 4-up from lg. */} -
- -
- {QUEUE_HEADER_TILES.map((tile) => ( - 1 - ? [ - { - y: Math.round(environment.burstFactor * 100), - label: `Burst ${Math.round( - environment.concurrencyLimit * environment.burstFactor - )}`, - labelPlacement: "outside" as const, - }, - ] - : []), - ] - : undefined - } - // Saturation and p95 "step over the line": a per-bucket overlay retraces only - // the over-threshold stretches in warning colour, so under-threshold values stay - // blue. (A gradient split can't do this reliably — an SVG objectBoundingBox - // gradient tracks the line's own bbox, not the y-axis, so a low/flat line reads - // as entirely warning-coloured.) - // All thresholded lines colour warning where they step over the threshold: the - // per-bucket overlay retraces only the over-threshold stretches, so the colour - // change tracks the axis crossing. - warningOverlay={ - tile.id === "saturation" - ? { threshold: 100 } - : tile.id === "p95" - ? { threshold: 60_000 } - : tile.id === "throttled" - ? // Integer counts: threshold 0 warns once a bucket has ≥1 throttle. - { threshold: 0 } - : undefined + {success ? ( + + {/* Default overflow-x-auto container so wide tables still scroll horizontally on + narrow viewports; the page (not this region) owns vertical scrolling. */} + + + + Name + + Queued + + + Running + + + Limit + + +

+ Environment — the environment's + limit of {environment.concurrencyLimit}. +

+

+ User — a limit set in your code. +

+

+ Override — set manually from the + dashboard or API. +

+ } - /> - ))} - - - - - {success ? ( -
- {/* Default overflow-x-auto container so wide tables still scroll horizontally on - narrow viewports; the page (not this region) owns vertical scrolling. */} -
- - - Name - - Queued - - - Running - - - Limit - - -

- Environment — the - environment's limit of {environment.concurrencyLimit}. -

-

- User — a limit set in your - code. -

-

- Override — set manually from - the dashboard or API. -

- - } - > - Limited by -
- - Health - - - Delay p95 - - - Backlog - - - Pause/resume - -
-
- - {sortedQueues.length > 0 ? ( - sortedQueues.map((queue) => { - const limit = queue.concurrencyLimit ?? environment.concurrencyLimit; - const isAtConcurrencyLimit = queue.running >= limit; - const isAtQueueLimit = - environment.queueSizeLimit !== null && - queue.queued >= environment.queueSizeLimit; - const queueFilterableName = queueMetricsKey(queue); - const queueMetric = metricsByQueue[queueFilterableName]; - return ( - - - - - - - {queue.concurrency?.overriddenAt ? ( - - ) : null} - {queue.paused ? ( - - Paused - - ) : null} - {isAtQueueLimit ? ( - - At queue limit - - ) : null} - {isAtConcurrencyLimit ? ( - - } - content="At concurrency limit: this queue is running as many runs as its limit allows; new runs wait in the backlog." - className="max-w-xs" - disableHoverableContent - /> - ) : null} - - - - {queue.queued} - - 0 && "text-text-bright" - )} - > - {queue.running} - - - {queue.concurrencyLimitOverridePercent !== null ? ( - <> - {limit}{" "} - - ({formatOverridePercent(queue.concurrencyLimitOverridePercent)}%) - - - ) : ( - limit - )} - - + > + Limited by + + + Health + + + Delay p95 + + + Backlog + + + Pause/resume + + + + + {sortedQueues.length > 0 ? ( + sortedQueues.map((queue) => { + const limit = queue.concurrencyLimit ?? environment.concurrencyLimit; + const isAtConcurrencyLimit = queue.running >= limit; + const isAtQueueLimit = + environment.queueSizeLimit !== null && + queue.queued >= environment.queueSizeLimit; + const queueFilterableName = queueMetricsKey(queue); + const queueMetric = metricsByQueue[queueFilterableName]; + return ( + + + + + + {queue.concurrency?.overriddenAt ? ( + + ) : null} + {queue.paused ? ( + + Paused + + ) : null} + {isAtQueueLimit ? ( + + At queue limit + + ) : null} + {isAtConcurrencyLimit ? ( Override} - content={ - queue.concurrencyLimitOverridePercent !== null - ? `Overridden at ${formatOverridePercent( - queue.concurrencyLimitOverridePercent - )}% of the environment limit.` - : `This queue's concurrency limit has been manually overridden to ${limit}.` - } + button={} + content="At concurrency limit: this queue is running as many runs as its limit allows; new runs wait in the backlog." className="max-w-xs" disableHoverableContent /> - ) : queue.concurrencyLimit ? ( - "User" - ) : ( - "Environment" - )} - - - - - - {queueMetric && queueMetric.p95WaitMs !== null ? ( - - {formatWaitMs(queueMetric.p95WaitMs)} + ) : null} + + + + {queue.queued} + + 0 && "text-text-bright" + )} + > + {queue.running} + + + {queue.concurrencyLimitOverridePercent !== null ? ( + <> + {limit}{" "} + + ({formatOverridePercent(queue.concurrencyLimitOverridePercent)}%) - ) : ( - - )} - - - v.toLocaleString()} - peakTooltip={ - queueMetric && queueMetric.throttledTotal > 0 - ? `Peak queued; this queue was throttled ${queueMetric.throttledTotal.toLocaleString()} ${ - queueMetric.throttledTotal === 1 ? "time" : "times" - } in this period` - : "Peak queued in this period" + + ) : ( + limit + )} + + + {queue.concurrency?.overriddenAt ? ( + Override} + content={ + queue.concurrencyLimitOverridePercent !== null + ? `Overridden at ${formatOverridePercent( + queue.concurrencyLimitOverridePercent + )}% of the environment limit.` + : `This queue's concurrency limit has been manually overridden to ${limit}.` } + className="max-w-xs" + disableHoverableContent /> - - - } - hiddenButtons={ - !queue.paused && + ) : queue.concurrencyLimit ? ( + "User" + ) : ( + "Environment" + )} + + + + + + {queueMetric && queueMetric.p95WaitMs !== null ? ( + + {formatWaitMs(queueMetric.p95WaitMs)} + + ) : ( + + )} + + + v.toLocaleString()} + peakTooltip={ + queueMetric && queueMetric.throttledTotal > 0 + ? `Peak queued; this queue was throttled ${queueMetric.throttledTotal.toLocaleString()} ${ + queueMetric.throttledTotal === 1 ? "time" : "times" + } in this period` + : "Peak queued in this period" } - popoverContent={ - <> - {queue.paused ? ( - - ) : ( - - )} - - - - + + } + hiddenButtons={!queue.paused && } + popoverContent={ + <> + {queue.paused ? ( + - - - } - /> - - ); - }) - ) : ( - - -
- - {hasFilters - ? "No queues found matching your filters" - : "No queues found"} - -
-
-
- )} -
-
-
- ) : ( -
- {totalQueues === 0 ? ( -
- -
- ) : code === "engine-version" ? ( - - ) : ( - Something went wrong - )} -
- )} -
- + )} + + + + + + + } + /> + + ); + }) + ) : ( + + +
+ + {hasFilters ? "No queues found matching your filters" : "No queues found"} + +
+
+
+ )} + + + + ) : ( +
+ {totalQueues === 0 ? ( +
+ +
+ ) : code === "engine-version" ? ( + + ) : ( + Something went wrong + )} +
+ )} + ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 89ffc0d1a59..71401ebf550 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -4,7 +4,8 @@ import { useMemo, type ReactNode } from "react"; import type { QueueItem } from "@trigger.dev/core/v3/schemas"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { PageContainer } from "~/components/layout/AppLayout"; +import { MetricsLayout } from "~/components/layout/MetricsLayout"; import { BigNumber } from "~/components/metrics/BigNumber"; import { Header3 } from "~/components/primitives/Headers"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; @@ -223,35 +224,36 @@ export default function Page() { - -
- {/* Filters — search (concurrency keys) + time filter, above everything, like the - Queues list. The time filter scopes the tab charts; search filters the keys table. */} -
- {showKeysTab ? ( - - ) : null} - -
- - {/* Live "right now" state of the whole queue — independent of the time filter above. */} - + {/* Filters — search (concurrency keys) + time filter, above everything, like the + Queues list. The time filter scopes the tab charts; search filters the keys table. */} + + {showKeysTab ? ( + + ) : null} + + + + {/* Live "right now" state of the whole queue — independent of the time filter above. + QueueStats renders the stat-tile grid slot (see MetricsLayout.Grid inside it). */} + + {showKeysTab ? ( )} -
-
+ + ); } @@ -300,7 +302,7 @@ function OverviewCharts({ const zoomToTimeFilter = useZoomToTimeFilter(); return ( -
+ -
+
); } @@ -419,7 +421,7 @@ function ConcurrencyKeysView({
{/* Per-key breakdown: which keys hold the backlog / do the work. */} -
+ -
+
-
+ -
+ ); } @@ -888,7 +890,7 @@ function QueueStats({ const oldestWaitDisplayMs = ckWaitLive !== null && ckWaitLive > 0 ? ckWaitLive : oldestWaitMs; return ( -
+ 0 ? `worst ${formatWaitMs(worstWaitMs)}` : undefined} suffixClassName="text-text-dimmed" /> -
+ ); } From e1cdcb11dbdea93ff44be869d5932b65c87b76e4 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 05:50:39 +0000 Subject: [PATCH 06/37] feat(webapp): add Sidebar slot and scroll mode to MetricsLayout Extend the presentational MetricsLayout compound with two optional capabilities so pages with a persistent side panel and independently scrolling regions can adopt it: - MetricsLayout.Sidebar renders a persistent panel beside the main column. Fixed-width by default (width prop); an optional resizable mode uses the shared Resizable primitives with autosaveId/snapshot cookie persistence. - Root gains a scroll prop: "page" (default, single page-level scroll, byte-identical to before) or "regions" (Root only bounds height so the page composes its own scrolling areas). No behavior change for existing pages, which pass neither prop. --- .../app/components/layout/MetricsLayout.tsx | 233 ++++++++++++++++-- 1 file changed, 216 insertions(+), 17 deletions(-) diff --git a/apps/webapp/app/components/layout/MetricsLayout.tsx b/apps/webapp/app/components/layout/MetricsLayout.tsx index edf03936cc8..da7b6b4c6f0 100644 --- a/apps/webapp/app/components/layout/MetricsLayout.tsx +++ b/apps/webapp/app/components/layout/MetricsLayout.tsx @@ -9,12 +9,25 @@ * you pass an explicit `columns` spec. * 3. `MetricsLayout.Content` — the tabs / table / list below the tiles. * - * `MetricsLayout.Root` owns the single, page-level scroll: the WHOLE page (filters, tiles and - * content) scrolls as one, rather than an inner table-only scroll. + * Two optional structural capabilities extend the basic top-to-bottom column: * - * The family is purely presentational — slots, grids and scroll only, no data logic. It is meant - * to live inside a `PageContainer` right after the `NavBar`, mirroring how the `Chart.Root` / - * `Chart.Line` compound composes. + * - `MetricsLayout.Sidebar` — a persistent side panel rendered to the RIGHT of the main column + * (main content left, sidebar right, full height). Drop a single `` + * anywhere among Root's children and Root switches to a `[main | sidebar]` horizontal layout; + * omit it and nothing changes. The sidebar is fixed-width by default (`width`), or set + * `resizable` to make the split draggable via the shared Resizable primitives. + * + * - `scroll` on Root — chooses who owns the vertical scroll: + * - `"page"` (default): Root owns a single `overflow-y-auto`; the WHOLE page (filters, tiles + * and content) scrolls as one. This is what every current metrics page wants. + * - `"regions"`: Root does NOT create a scroll container — it only bounds the height as a + * `flex` column. The page composes its own independently-scrolling areas inside the slots + * (e.g. a fixed toolbar over a scrolling table, or a vertical resizable split). Use this + * when a single page-level scroll would be wrong. + * + * The family is purely presentational — slots, grids, a sidebar and scroll ownership only, no data + * logic. It is meant to live inside a `PageContainer` right after the `NavBar`, mirroring how the + * `Chart.Root` / `Chart.Line` compound composes. * * @example List page (self-padded sections, count-derived grids) * ```tsx @@ -47,9 +60,45 @@ * * * ``` + * + * @example Page with a persistent config sidebar (fixed-width) + * ```tsx + * + * + * + * …config panel… + * + * ``` + * + * @example Resizable sidebar + independently-scrolling regions + * ```tsx + * // In the loader, hydrate the persisted split from a cookie: + * const sidebarSnapshot = await getResizableSnapshot(request, "my-page-sidebar"); + * + * + *
…toolbar…
+ *
…scrolling body…
+ * + * …config panel… + * + *
+ * ``` */ -import { Children, type ReactNode } from "react"; +import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; import { PageBody } from "~/components/layout/AppLayout"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + type ResizableSnapshot, +} from "~/components/primitives/Resizable"; import { cn } from "~/utils/cn"; type ColumnCount = 1 | 2 | 3 | 4 | 5 | 6; @@ -119,29 +168,172 @@ function columnsForCount(count: number): GridColumns { } } +/** + * Who owns the vertical scroll. + * - `"page"` (default): Root owns one `overflow-y-auto` — the whole page scrolls as one. + * - `"regions"`: Root only bounds the height (a `flex` column, no scroll); the page composes its + * own scrolling areas inside the slots. + */ +export type MetricsScroll = "page" | "regions"; + +type MetricsLayoutSidebarProps = { + children: ReactNode; + className?: string; + /** + * Fixed sidebar width for the non-resizable default (any CSS length, e.g. `"380px"`, `"22rem"`). + * Ignored when `resizable` is set. Defaults to `"380px"`. + */ + width?: string; + /** + * When true, the `[main | sidebar]` split becomes draggable using the shared Resizable + * primitives. Persist the split by passing a stable `autosaveId` (the primitive writes the + * split to a cookie of that name) together with a `snapshot` read back from that cookie in the + * loader via `getResizableSnapshot(request, autosaveId)`. + */ + resizable?: boolean; + /** Resizable only: min width of the sidebar panel. Defaults to `"280px"`. */ + min?: string; + /** Resizable only: initial width of the sidebar panel. Defaults to `"380px"`. */ + defaultSize?: string; + /** Resizable only: max width of the sidebar panel. */ + max?: string; + /** Resizable only: min width of the main panel. Defaults to `"300px"`. */ + mainMin?: string; + /** Resizable only: cookie name the split is persisted under (also the panel-group id). */ + autosaveId?: string; + /** Resizable only: server-loaded split snapshot to hydrate from (see `getResizableSnapshot`). */ + snapshot?: ResizableSnapshot; +}; + +/** + * Marker slot for the persistent side panel. Rendered/positioned entirely by `Root` (this + * component is never mounted directly) — Root reads its props to build the `[main | sidebar]` + * layout and drops the children into the panel. + */ +function MetricsLayoutSidebar(_props: MetricsLayoutSidebarProps) { + return null; +} + +function isSidebarElement(child: ReactNode): child is ReactElement { + return isValidElement(child) && child.type === MetricsLayoutSidebar; +} + +// The main (left) column. In `"page"` mode it is the single page-level scroll container — byte +// identical to the pre-sidebar layout. In `"regions"` mode it only bounds the height as a flex +// column so the page's own slots own their scroll. +function MetricsLayoutMain({ + children, + className, + scroll, +}: { + children: ReactNode; + className?: string; + scroll: MetricsScroll; +}) { + return ( +
+ {children} +
+ ); +} + function MetricsLayoutRoot({ children, className, pageBodyClassName, + scroll = "page", }: { children: ReactNode; /** Applied to the inner scroll container (e.g. `flex flex-col gap-4 p-6` for a single column). */ className?: string; /** Applied to the outer, non-scrolling PageBody wrapper. */ pageBodyClassName?: string; + /** Who owns the vertical scroll — see {@link MetricsScroll}. Defaults to `"page"`. */ + scroll?: MetricsScroll; }) { + // A single optional Sidebar slot flips Root into a horizontal `[main | sidebar]` layout. When it + // is absent the output is exactly the original single-column markup (current pages don't pass + // either new prop, so they render unchanged). + const sidebar = Children.toArray(children).find(isSidebarElement); + const mainChildren = sidebar + ? Children.toArray(children).filter((child) => !isSidebarElement(child)) + : children; + + const main = ( + + {mainChildren} + + ); + + if (!sidebar) { + return ( + + {/* The whole page scrolls as one: filters, tiles and content share a single vertical scroll + context, so the tiles scroll out of view with everything else (not an inner content-only + scroll). */} + {main} + + ); + } + + const { + children: sidebarChildren, + className: sidebarClassName, + width = "380px", + resizable, + min = "280px", + defaultSize = "380px", + max, + mainMin = "300px", + autosaveId, + snapshot, + } = sidebar.props; + + if (resizable) { + // Draggable split. `autosaveId`/`snapshot` wire up cookie persistence exactly as the run and + // agent pages do (client writes the cookie, the loader hydrates via getResizableSnapshot). + return ( + + + + {main} + + + + {sidebarChildren} + + + + ); + } + + // Fixed-width sidebar. return ( - {/* The whole page scrolls as one: filters, tiles and content share a single vertical scroll - context, so the tiles scroll out of view with everything else (not an inner content-only - scroll). */} -
- {children} +
+
{main}
+
+ {sidebarChildren} +
); @@ -199,6 +391,13 @@ export const MetricsLayout = { Filters: MetricsLayoutFilters, Grid: MetricsLayoutGrid, Content: MetricsLayoutContent, + Sidebar: MetricsLayoutSidebar, }; -export { MetricsLayoutRoot, MetricsLayoutFilters, MetricsLayoutGrid, MetricsLayoutContent }; +export { + MetricsLayoutRoot, + MetricsLayoutFilters, + MetricsLayoutGrid, + MetricsLayoutContent, + MetricsLayoutSidebar, +}; From 9559c4aed4db7bfeff4a4a16e430369bedc2208a Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 05:50:41 +0000 Subject: [PATCH 07/37] feat(webapp): demo MetricsLayout sidebar and regions scroll in storybook Add a demo selector to the MetricsLayout storybook with fixed and resizable sidebar examples plus a scroll="regions" example showing two independently scrolling areas. Existing overview demo is preserved. --- .../app/routes/storybook.layout/route.tsx | 353 +++++++++++++----- 1 file changed, 259 insertions(+), 94 deletions(-) diff --git a/apps/webapp/app/routes/storybook.layout/route.tsx b/apps/webapp/app/routes/storybook.layout/route.tsx index b1b6b08c69f..bd984a99b7f 100644 --- a/apps/webapp/app/routes/storybook.layout/route.tsx +++ b/apps/webapp/app/routes/storybook.layout/route.tsx @@ -3,8 +3,9 @@ import { PageContainer } from "~/components/layout/AppLayout"; import { MetricsLayout } from "~/components/layout/MetricsLayout"; import { Badge } from "~/components/primitives/Badge"; import { Header3 } from "~/components/primitives/Headers"; -import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; +import SegmentedControl from "~/components/primitives/SegmentedControl"; import { Table, TableBody, @@ -91,112 +92,276 @@ function PlaceholderTable() { ); } -/** - * Demonstrates the MetricsLayout compound: a Filters row, count-adaptive Grids (a 4-tile stat - * grid + a 4-tile chart grid + a 3-tile grid, all with no hand-written grid-cols), and a Content - * slot that toggles between tabs and a table. The whole page scrolls as one via MetricsLayout.Root. - */ -export default function Story() { +// A tall filler so a scroll region visibly overflows its container. +function ScrollFiller({ label, rows }: { label: string; rows: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + {label} row {i + 1} + + {Math.round(Math.abs(Math.sin(i)) * 1000)} +
+ ))} +
+ ); +} + +// The config panel dropped into MetricsLayout.Sidebar in the sidebar demos. +function SidebarPanel({ resizable }: { resizable?: boolean }) { + return ( +
+
+ Sidebar slot +
+
+ + {resizable + ? "This sidebar is resizable — drag the handle on its left edge. Pass an autosaveId + a loader snapshot to persist the split across reloads." + : "This sidebar is fixed-width (width prop). The main column fills the rest and owns the page scroll."} + + {resizable ? "resizable" : "fixed width"} + {Array.from({ length: 6 }).map((_, i) => ( + Config option {i + 1} + ))} +
+
+ ); +} + +// The original overview demo: Filters row, count-adaptive Grids and a Content slot that toggles +// between tabs and a table. The whole page scrolls as one via MetricsLayout.Root (scroll="page"). +function OverviewDemo() { const [tab, setTab] = useState<"tabs" | "table">("tabs"); return ( - - - - - - {/* Filters slot — the row directly under the NavBar. */} - -
- Search… - Period: 7d - Filters slot -
- {"< 1 / 4 >"} -
+ + {/* Filters slot — the row directly under the NavBar. */} + +
+ Search… + Period: 7d + Filters slot +
+ {"< 1 / 4 >"} +
- {/* Grid slot — 4 stat tiles. Columns are derived from the tile count: two-up, four-up + {/* Grid slot — 4 stat tiles. Columns are derived from the tile count: two-up, four-up from lg. */} -
- Grid — 4 tiles (auto: 2-up, 4-up from lg) -
- - - - - - +
+ Grid — 4 tiles (auto: 2-up, 4-up from lg) +
+ + + + + + - {/* Grid slot — 4 chart tiles at a fixed row height, same auto columns as the stats. */} -
- Grid — 4 chart tiles (fixed 280px row) -
-
- - - - - - -
+ {/* Grid slot — 4 chart tiles at a fixed row height, same auto columns as the stats. */} +
+ Grid — 4 chart tiles (fixed 280px row) +
+
+ + + + + + +
- {/* Grid slot — 3 tiles. The same component now lays out one-up, three-up from sm, proving + {/* Grid slot — 3 tiles. The same component now lays out one-up, three-up from sm, proving the grid adapts to the child count. */} -
- Grid — 3 tiles (auto: 1-up, 3-up from sm) -
- - - - - +
+ Grid — 3 tiles (auto: 1-up, 3-up from sm) +
+ + + + + - {/* Grid slot — explicit columns for a chart grid that should always be two-up regardless + {/* Grid slot — explicit columns for a chart grid that should always be two-up regardless of tile count. */} -
- Grid — explicit columns={{ base: 1, sm: 2 }} (5 tiles) +
+ Grid — explicit columns={{ base: 1, sm: 2 }} (5 tiles) +
+
+ + + + + + + +
+ + {/* Content slot — tabs vs. table. */} + + + setTab("tabs")} + > + Tabs content + + setTab("table")} + > + Table content + + + {tab === "table" ? ( + + ) : ( +
+ + The Content slot hosts whatever sits below the tiles — a TabContainer with panels, or + a full-width table. The whole page (filters, tiles and content) shares one vertical + scroll. + +
+ )} +
+ + ); +} + +// Fixed-width sidebar: a child flips Root into a [main | sidebar] layout. +// The main column keeps its normal top-to-bottom slots and owns the page scroll. +function SidebarFixedDemo() { + return ( + + + Search… + main column + + + + + + + + + + + + + + + ); +} + +// Resizable sidebar: same [main | sidebar] layout, but the split is draggable via the shared +// Resizable primitives. autosaveId persists the split to a cookie (in a real page the loader +// hydrates it back through a snapshot); here it persists live within the session. +function SidebarResizableDemo() { + return ( + + + Search… + drag the handle → + + + + + + + + + + + + + + + ); +} + +// scroll="regions": Root does NOT create the page scroll. It only bounds the height as a flex +// column, so the page composes its own independently-scrolling areas — here a fixed toolbar over +// two side-by-side lists that each scroll on their own. +function RegionsDemo() { + return ( + +
+ fixed toolbar (does not scroll) + Period: 7d +
+
+
+
+ Left region — scrolls independently +
+
-
- - - - - - - +
+
+ Right region — scrolls independently +
+
+
+ + ); +} + +type Demo = "overview" | "sidebar-fixed" | "sidebar-resizable" | "regions"; + +const DEMO_OPTIONS: { label: string; value: Demo }[] = [ + { label: "Overview", value: "overview" }, + { label: "Sidebar (fixed)", value: "sidebar-fixed" }, + { label: "Sidebar (resizable)", value: "sidebar-resizable" }, + { label: "Scroll: regions", value: "regions" }, +]; + +/** + * Storybook for the MetricsLayout compound. A segmented control swaps between demos, each filling + * the page: + * - Overview — the base Filters / count-adaptive Grid / Content slots, page scroll. + * - Sidebar (fixed) — a fixed-width MetricsLayout.Sidebar beside the main column. + * - Sidebar (resizable) — the same, but with a draggable split (autosaveId persistence). + * - Scroll: regions — scroll="regions" with two independently-scrolling areas. + */ +export default function Story() { + const [demo, setDemo] = useState("overview"); - {/* Content slot — tabs vs. table. */} - - - setTab("tabs")} - > - Tabs content - - setTab("table")} - > - Table content - - - {tab === "table" ? ( - - ) : ( -
- - The Content slot hosts whatever sits below the tiles — a TabContainer with panels, - or a full-width table. The whole page (filters, tiles and content) shares one - vertical scroll. - -
- )} -
- + return ( + + + + + setDemo(value as Demo)} + /> + + + {demo === "overview" ? ( + + ) : demo === "sidebar-fixed" ? ( + + ) : demo === "sidebar-resizable" ? ( + + ) : ( + + )} ); } From d90c56e49071cb3a13d7a9eb80c210ae82a36e3a Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 06:04:15 +0000 Subject: [PATCH 08/37] feat(webapp): pin the MetricsLayout filter row while the page scrolls Hoist the Filters slot out of the scroll container (the dashboards' auto/1fr pattern) so filters stay visible; the detail page carries the inset on the filter row itself. --- .../app/components/layout/MetricsLayout.tsx | 38 ++++++++++++------- .../route.tsx | 6 ++- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/components/layout/MetricsLayout.tsx b/apps/webapp/app/components/layout/MetricsLayout.tsx index da7b6b4c6f0..4af0d16de7b 100644 --- a/apps/webapp/app/components/layout/MetricsLayout.tsx +++ b/apps/webapp/app/components/layout/MetricsLayout.tsx @@ -218,9 +218,14 @@ function isSidebarElement(child: ReactNode): child is ReactElement !isFiltersElement(child)) : children; + return ( -
- {children} +
+ {filters} +
+ {rest} +
); } @@ -346,7 +358,7 @@ function MetricsLayoutFilters({ children: ReactNode; className?: string; }) { - return
{children}
; + return
{children}
; } function MetricsLayoutGrid({ diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 71401ebf550..b2325e85fdb 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -224,10 +224,12 @@ export default function Page() { - + {/* Filters are hoisted out of the scroll container by the layout (pinned on scroll), so they + carry the page inset themselves; the scrolling column keeps the rest. */} + {/* Filters — search (concurrency keys) + time filter, above everything, like the Queues list. The time filter scopes the tab charts; search filters the keys table. */} - + {showKeysTab ? ( ) : null} From faa61ca25e6647a2e3b84469c3fbb2e34c179946 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 06:05:32 +0000 Subject: [PATCH 09/37] feat(webapp): migrate agent detail page onto MetricsLayout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify the agent detail page with the shared metrics-page skeleton so it reads like every other metrics page (queues list/detail): a filter row on top, a visualization tile row, then tabs + table content, plus a persistent resizable sidebar. - The TimeFilter and pagination move UP into MetricsLayout.Filters, unfused from the tabs (they previously shared one h-10 bar with the tabs). - The Sessions/LLM spend/Tokens charts become a fixed-height MetricsLayout.Grid tile row (three-up), keeping chart sync + drag-to-zoom. - The tabs now sit alone on their row in MetricsLayout.Content, with the table flowing below them in the single page-level scroll. - The agent config panel becomes MetricsLayout.Sidebar (resizable), now wired to autosaveId "agent-detail-sidebar" + a getResizableSnapshot loader snapshot so the split persists across reloads. Intentional behavior change: the old vertical drag-split between the charts row and the table is DROPPED. The unified skeleton has no such split — the charts get a fixed-height row and the table flows in the page scroll. No data or loader logic changed. --- .../agent-detail-metrics-layout.md | 6 + .../route.tsx | 339 ++++++++---------- 2 files changed, 164 insertions(+), 181 deletions(-) create mode 100644 .server-changes/agent-detail-metrics-layout.md diff --git a/.server-changes/agent-detail-metrics-layout.md b/.server-changes/agent-detail-metrics-layout.md new file mode 100644 index 00000000000..0af763ff3f5 --- /dev/null +++ b/.server-changes/agent-detail-metrics-layout.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +The agent detail page now matches the shared metrics-page layout: the time filter and pagination sit in a filter row at the top, the activity charts form a tile row beneath it, and the tabs and table flow below in a single page scroll, with the agent config panel as a resizable sidebar on the right. 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 6ce526ba7b3..5111ce8f87f 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 @@ -6,7 +6,8 @@ import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon"; -import { PageBody } from "~/components/layout/AppLayout"; +import { PageContainer } from "~/components/layout/AppLayout"; +import { MetricsLayout } from "~/components/layout/MetricsLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; import { LinkButton } from "~/components/primitives/Buttons"; import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; @@ -20,11 +21,6 @@ import { Header2 } from "~/components/primitives/Headers"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "~/components/primitives/Resizable"; import { Spinner } from "~/components/primitives/Spinner"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; @@ -45,6 +41,7 @@ import { import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { getResizableSnapshot } from "~/services/resizablePanel.server"; import { requireUser } from "~/services/session.server"; import { docsPath, @@ -174,6 +171,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }) .catch(() => null); + // The [main | config] split is draggable and its width is persisted to a cookie by the + // Resizable primitive; hydrate it here so a reload keeps the user's saved split. + const sidebarSnapshot = await getResizableSnapshot(request, "agent-detail-sidebar"); + return typeddefer({ agent, runActivity, @@ -182,6 +183,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { llmTokenActivity, runList, sessionList, + sidebarSnapshot, }); }; @@ -196,6 +198,7 @@ export default function Page() { llmTokenActivity, runList, sessionList, + sidebarSnapshot, } = useTypedLoaderData(); const organization = useOrganization(); const project = useProject(); @@ -209,7 +212,7 @@ export default function Page() { const tabLabel = tab === "sessions" ? "Sessions" : "Runs"; return ( - <> + - - - -
- {/* Top bar — tabs on the left; TimeFilter + pagination on the right. - h-10 matches the right-hand sidebar header height. */} -
- - setTab("sessions")} - > - Sessions - - setTab("runs")} - > - Runs - - -
- - {tab === "sessions" ? ( - - - {(list) => (list ? : null)} - - - ) : ( - - - {(list) => (list ? : null)} - - - )} -
-
- - - {/* Activity / LLM cost / Token charts */} - -
- -
- - {tab === "sessions" ? ( - }> - } - > - {(result) => } - - - ) : ( - }> - } - > - {(result) => } - - - )} - - - - }> - } - > - {(result) => ( - - )} - - - - - - }> - } - > - {(result) => ( - - )} - - - -
-
-
-
- - - - {/* Table */} - - - -
-
-
- - - - - -
-
- + + {/* Filters — top row, directly under the NavBar: the TimeFilter and pagination that used + to be fused with the tabs now live here, above the charts (Queues list pattern). */} + + + {tab === "sessions" ? ( + + + {(list) => (list ? : null)} + + + ) : ( + + + {(list) => (list ? : null)} + + + )} + + + {/* Activity / LLM spend / Token charts as a fixed-height tile row (three-up), synced + + drag-to-zoom. The old draggable charts/table split is intentionally dropped — the + charts get a fixed row and the table flows below in the page scroll. */} +
+ + + + {tab === "sessions" ? ( + }> + }> + {(result) => } + + + ) : ( + }> + }> + {(result) => } + + + )} + + + + }> + }> + {(result) => ( + + )} + + + + + + }> + }> + {(result) => ( + + )} + + + + + +
+ + {/* Tabs alone on their row (Queue detail pattern), then the table below them. */} + + + setTab("sessions")} + > + Sessions + + setTab("runs")} + > + Runs + + + + + + + + +
+
); } @@ -368,52 +353,44 @@ function AgentContentArea({ sessionList, runList, }: { tab: AgentTab } & Pick) { - return ( -
- {tab === "sessions" ? ( - }> - }> - {(list) => - list ? ( -
- -
- ) : ( - - ) - } -
-
- ) : ( - }> - }> - {(list) => - list ? ( -
- -
- ) : ( - - ) - } -
-
- )} -
+ // The table flows in the page-level scroll (MetricsLayout.Root scroll="page"); a sticky header + // keeps the column labels pinned as the whole column scrolls. + return tab === "sessions" ? ( + }> + }> + {(list) => + list ? ( + + ) : ( + + ) + } + + + ) : ( + }> + }> + {(list) => + list ? ( + + ) : ( + + ) + } + + ); } From f8bbf4dbebdee6919b90eeeea1f8eed136d21b33 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 06:33:54 +0000 Subject: [PATCH 10/37] fix(webapp): render sortable header tooltip as a sibling, not nested in the sort button A header cell that is both sortable and has a tooltip rendered the InfoIconTooltip trigger (a {tooltipNode} - {sortIndicator} - +
) : tooltip ? (
{label} From 9b7b7aa3d5f72534b28c3082f7a55b6b1a029c7f Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 06:34:03 +0000 Subject: [PATCH 11/37] fix(webapp): self-heal a failed percent-limit engine sync on the next recalc recalculatePercentLimits skipped the whole queue when the materialized limit already matched the DB value, so if the DB write succeeded but the engine push had failed on a prior run, the DB and engine stayed diverged forever. Now the DB write is skipped only when unchanged, but the (idempotent) engine push always runs for non-paused queues, so a previously-failed sync converges on the next recalc. Also documents why the paused-env recalc call in AllocateConcurrencyService is intentional (queue pushes on a paused env are inert and avoid reconciliation on resume). Adds a test proving the retry semantics. --- .../v3/services/allocateConcurrency.server.ts | 7 ++- .../v3/services/concurrencySystem.server.ts | 23 ++++--- .../concurrencySystemPercentOverride.test.ts | 60 ++++++++++++++++++- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/apps/webapp/app/v3/services/allocateConcurrency.server.ts b/apps/webapp/app/v3/services/allocateConcurrency.server.ts index 3ed7bbf2b59..78c8b82915a 100644 --- a/apps/webapp/app/v3/services/allocateConcurrency.server.ts +++ b/apps/webapp/app/v3/services/allocateConcurrency.server.ts @@ -91,7 +91,12 @@ export class AllocateConcurrencyService extends BaseService { await updateEnvConcurrencyLimits(updatedEnvironment); } - // Percent-based queue overrides follow the environment limit automatically. + // Percent-based queue overrides follow the environment limit automatically. Note the + // deliberate asymmetry with the env-level push above: `updateEnvConcurrencyLimits` is gated + // on `!paused`, but we recalculate queue limits even for paused environments. Queue-level + // pushes on a paused env are inert (the env-level gate stops dequeueing regardless), and + // keeping the queue limits synced means resume needs no extra reconciliation — skipping + // them here would instead leave stale engine limits after the env resumes. await concurrencySystem.queues.recalculatePercentLimits(updatedEnvironment); // maximumConcurrencyLimit changed in the control-plane; drop any cached copy. diff --git a/apps/webapp/app/v3/services/concurrencySystem.server.ts b/apps/webapp/app/v3/services/concurrencySystem.server.ts index 47317c23763..2260976fd41 100644 --- a/apps/webapp/app/v3/services/concurrencySystem.server.ts +++ b/apps/webapp/app/v3/services/concurrencySystem.server.ts @@ -84,19 +84,24 @@ export class ConcurrencySystem { environment.maximumConcurrencyLimit, percent.toNumber() ); - if (newLimit === queue.concurrencyLimit) continue; - await this.db.taskQueue.update({ - where: { id: queue.id }, - data: { concurrencyLimit: newLimit }, - }); - // Paused queues keep their engine limit at 0 (the pause/resume flow re-syncs from the - // stored value on resume); only push the recomputed limit for active queues so a - // percent recalc never effectively un-pauses a queue. + // Only write the DB when the materialized value actually changed. + if (newLimit !== queue.concurrencyLimit) { + await this.db.taskQueue.update({ + where: { id: queue.id }, + data: { concurrencyLimit: newLimit }, + }); + updated++; + } + + // Always attempt the engine push (it's idempotent) for active queues — even when the + // DB value was unchanged — so a previously-failed sync self-heals on the next recalc + // instead of leaving the DB and engine diverged forever. Paused queues keep their + // engine limit at 0 (the pause/resume flow re-syncs from the stored value on resume); + // push nothing for them so a percent recalc never effectively un-pauses a queue. if (!queue.paused) { await updateQueueConcurrencyLimits(environment, queue.name, newLimit); } - updated++; } catch (error) { logger.error("Failed to recalculate percent queue limit", { queueId: queue.id, diff --git a/apps/webapp/test/concurrencySystemPercentOverride.test.ts b/apps/webapp/test/concurrencySystemPercentOverride.test.ts index 533a530ca7e..bd358f81c94 100644 --- a/apps/webapp/test/concurrencySystemPercentOverride.test.ts +++ b/apps/webapp/test/concurrencySystemPercentOverride.test.ts @@ -10,12 +10,18 @@ import { ConcurrencySystem, materializePercentLimit } from "~/v3/services/concur // stub that satisfies the sync + stats calls the service makes. The engine sync // itself (RunQueue/Redis) is therefore NOT exercised here — see the note in the // verification report. Everything the service persists to Postgres IS real. +// A controllable spy for the engine's queue-limit push so a test can simulate a push failure and +// prove the next recalc self-heals the divergence. +const { updateQueueConcurrencyLimitsMock } = vi.hoisted(() => ({ + updateQueueConcurrencyLimitsMock: vi.fn(async (..._args: unknown[]) => undefined), +})); + vi.mock("~/v3/runEngine.server", () => ({ engine: { lengthOfQueues: async () => ({}), currentConcurrencyOfQueues: async () => ({}), runQueue: { - updateQueueConcurrencyLimits: async () => undefined, + updateQueueConcurrencyLimits: updateQueueConcurrencyLimitsMock, removeQueueConcurrencyLimits: async () => undefined, updateEnvConcurrencyLimits: async () => undefined, }, @@ -304,4 +310,56 @@ describe("ConcurrencySystem percent overrides", () => { expect(row.concurrencyLimit).toBe(5); } ); + + postgresTest( + "recalculatePercentLimits re-syncs the engine on a later recalc after an engine push failed, even when the DB limit is unchanged", + async ({ prisma }) => { + const { queue, authEnv } = await seedEnvAndQueue(prisma, { + maximumConcurrencyLimit: 10, + queueConcurrencyLimit: 8, + }); + + const system = new ConcurrencySystem({ db: prisma, reader: prisma }); + + const overridden = await system.queues.overrideQueueConcurrencyLimit( + authEnv, + queue.friendlyId, + { percent: 50 } + ); + expect(overridden.isOk()).toBe(true); + { + const row = await prisma.taskQueue.findUniqueOrThrow({ where: { id: queue.id } }); + expect(row.concurrencyLimit).toBe(5); // floor(10 * 0.5) + } + + const bumpedEnv = { + id: authEnv.id, + maximumConcurrencyLimit: 20, + } as unknown as AuthenticatedEnvironment; + + // The env-limit bump recalc writes the new DB limit (10) but the engine push fails and is + // swallowed by the per-queue catch: DB and engine are now diverged (DB=10, engine=5). + updateQueueConcurrencyLimitsMock.mockClear(); + updateQueueConcurrencyLimitsMock.mockRejectedValueOnce(new Error("engine push failed")); + + const first = await system.queues.recalculatePercentLimits(bumpedEnv); + expect(first.updated).toBe(1); // DB was written before the push threw + { + const row = await prisma.taskQueue.findUniqueOrThrow({ where: { id: queue.id } }); + expect(row.concurrencyLimit).toBe(10); + } + + // A later recalc at the SAME env limit makes no DB change, but MUST still push to the engine + // so the previously-failed sync self-heals. (Regression guard: the old code `continue`d when + // newLimit === concurrencyLimit and left the engine stuck at 5 forever.) + updateQueueConcurrencyLimitsMock.mockClear(); + const second = await system.queues.recalculatePercentLimits(bumpedEnv); + expect(second.updated).toBe(0); // no DB write + expect(updateQueueConcurrencyLimitsMock).toHaveBeenCalledWith( + expect.anything(), + queue.name, + 10 + ); + } + ); }); From e1dea002b6575c7d3e83d1b944cd1b86f5955022 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 06:34:10 +0000 Subject: [PATCH 12/37] fix(webapp): expose concurrencyLimitOverridePercent from the queue detail presenter The queue detail presenter did not carry the percent source-of-truth, so the detail page's override dialog reopened percent overrides in absolute mode. Surface concurrencyLimitOverridePercent as a sibling field on the returned queue, mirroring QueueListPresenter. --- .../v3/QueueRetrievePresenter.server.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts index b446dfaf626..08e0d751e39 100644 --- a/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueRetrievePresenter.server.ts @@ -106,18 +106,28 @@ export class QueueRetrievePresenter extends BasePresenter { // Transform queues to include running and queued counts return { success: true as const, - queue: toQueueItem({ - friendlyId: queue.friendlyId, - name: queue.name, - type: queue.type, - running: results[1]?.[queue.name] ?? 0, - queued: results[0]?.[queue.name] ?? 0, - concurrencyLimit: queue.concurrencyLimit ?? null, - concurrencyLimitBase: queue.concurrencyLimitBase ?? null, - concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null, - concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy ?? null, - paused: queue.paused, - }), + queue: { + ...toQueueItem({ + friendlyId: queue.friendlyId, + name: queue.name, + type: queue.type, + running: results[1]?.[queue.name] ?? 0, + queued: results[0]?.[queue.name] ?? 0, + concurrencyLimit: queue.concurrencyLimit ?? null, + concurrencyLimitBase: queue.concurrencyLimitBase ?? null, + concurrencyLimitOverriddenAt: queue.concurrencyLimitOverriddenAt ?? null, + concurrencyLimitOverriddenBy: queue.concurrencyLimitOverriddenBy ?? null, + paused: queue.paused, + }), + // The percent source-of-truth for percent-based overrides isn't part of the shared + // `QueueItem` schema (that's a public contract), so we surface it as an extra field on + // the returned queue — mirroring QueueListPresenter. Prisma returns Decimal; the client + // only needs a plain number (null for absolute overrides). + concurrencyLimitOverridePercent: + queue.concurrencyLimitOverridePercent !== null + ? Number(queue.concurrencyLimitOverridePercent) + : null, + }, }; } } From ee90709e70b708484e6c0bfafe0b0b94e228d8ee Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 06:37:29 +0000 Subject: [PATCH 13/37] refactor(webapp): harden MetricsLayout API to closed semantic props Remove freeform className (and pageBodyClassName) from every MetricsLayout slot so pages can no longer restyle the layout's spacing, borders or gutters. Slots now own their chrome and bake one visual rhythm: - Filters: pinned 40px bar with a bottom border and standard insets; pages compose left/right clusters as child divs (justify-between). - Root: owns the scroll and the content column (flex-col, gap-3, vertical padding); no page-specific padding knob. - Grid: baked px-3 gutter + gap-3; adds kind="tiles"|"charts" where "charts" bakes the fixed chart-row height (shrink-0 so it holds in the flex column). - Content: full-bleed by default (edge-to-edge table); inset for a padded column; always bakes a doubled (24px) separation above it. - Sidebar: keeps its semantic props, drops className. Update both queues routes and the storybook to the hardened API, and rewrite the JSDoc to state the philosophy (slots own the chrome; new needs become new semantic variants, not classes). --- .../app/components/layout/MetricsLayout.tsx | 192 ++++++++++-------- .../route.tsx | 123 ++++++----- .../route.tsx | 51 ++--- .../app/routes/storybook.layout/route.tsx | 110 +++++----- 4 files changed, 248 insertions(+), 228 deletions(-) diff --git a/apps/webapp/app/components/layout/MetricsLayout.tsx b/apps/webapp/app/components/layout/MetricsLayout.tsx index 4af0d16de7b..d4108b40203 100644 --- a/apps/webapp/app/components/layout/MetricsLayout.tsx +++ b/apps/webapp/app/components/layout/MetricsLayout.tsx @@ -1,13 +1,28 @@ /** * MetricsLayout — a compound layout for metric / dashboard pages. * - * Every metrics page reads top-to-bottom as the same three slots: + * PHILOSOPHY — slots own their chrome. This family exists so that non-designers (and AI agents) + * can compose a beautiful, consistent metrics page purely by picking slots and setting closed, + * semantic props. There is deliberately NO freeform `className` on any slot: a page cannot restyle + * the spacing, borders or gutters of the layout, because that is exactly how metric pages drifted + * apart before. The slots bake ONE visual rhythm (a 12px grid gap, a 12px page gutter, a doubled + * separation before the content, a pinned 40px filter bar). If you find yourself wanting a class on + * a slot, you probably need a new semantic variant instead — talk to design and add a closed prop + * (like `kind` or `inset`) rather than reopening `className`. * - * 1. `MetricsLayout.Filters` — the row under the NavBar (search, TimeFilter, pagination…). - * 2. `MetricsLayout.Grid` — one or more grids of tiles (stat BigNumbers, chart cards). - * The grid adapts its column count to the number of tiles unless - * you pass an explicit `columns` spec. - * 3. `MetricsLayout.Content` — the tabs / table / list below the tiles. + * Every metrics page reads top-to-bottom as the same slots: + * + * 1. `MetricsLayout.Filters` — the pinned bar under the NavBar (search, TimeFilter, pagination…). + * It is hoisted out of the scroll container so it stays put while the page scrolls. Baked + * chrome: a 40px bar with a bottom border. Pages express left/right clusters as child divs + * (e.g. left = search + TimeFilter, right = pagination); `justify-between` spreads them. + * 2. `MetricsLayout.Grid` — one or more grids of tiles (stat BigNumbers, chart cards). The grid + * adapts its column count to the number of tiles unless you pass an explicit `columns` spec, + * and bakes the page gutter + grid gap. `kind="charts"` bakes the fixed chart-row height. + * 3. `MetricsLayout.Content` — the tabs / table / list below the tiles. Full-bleed by default + * (a list table spans edge to edge with its own top border); pass `inset` for a padded column. + * Content always bakes a doubled separation (24px) above it, so the blocks read as a distinct + * band from the content below. * * Two optional structural capabilities extend the basic top-to-bottom column: * @@ -18,43 +33,44 @@ * `resizable` to make the split draggable via the shared Resizable primitives. * * - `scroll` on Root — chooses who owns the vertical scroll: - * - `"page"` (default): Root owns a single `overflow-y-auto`; the WHOLE page (filters, tiles - * and content) scrolls as one. This is what every current metrics page wants. - * - `"regions"`: Root does NOT create a scroll container — it only bounds the height as a - * `flex` column. The page composes its own independently-scrolling areas inside the slots - * (e.g. a fixed toolbar over a scrolling table, or a vertical resizable split). Use this - * when a single page-level scroll would be wrong. + * - `"page"` (default): Root owns a single `overflow-y-auto` and lays the slots out as a + * `flex` column with the standard vertical rhythm; the WHOLE page (filters aside) scrolls + * as one. This is what every current metrics page wants. + * - `"regions"`: Root does NOT create a scroll container and does NOT impose the column + * rhythm — it only bounds the height as a bare `flex` column. The page composes its own + * independently-scrolling areas inside the slots (e.g. a fixed toolbar over a scrolling + * table, or a vertical resizable split). Use this when a single page-level scroll would be + * wrong. * * The family is purely presentational — slots, grids, a sidebar and scroll ownership only, no data - * logic. It is meant to live inside a `PageContainer` right after the `NavBar`, mirroring how the - * `Chart.Root` / `Chart.Line` compound composes. + * logic. It is meant to live inside a `PageContainer` right after the `NavBar`. * - * @example List page (self-padded sections, count-derived grids) + * @example List page (full-bleed table, count-derived grids) * ```tsx * * * - * + * *
…search + TimeFilter…
* *
- * …4 stat tiles… - *
- * - * …4 chart tiles… - * - *
- * …table… + * …4 stat tiles… + * + * …4 chart tiles… + * + * …full-width table… *
*
* ``` * - * @example Detail page (a single padded, gap-4 column) + * @example Detail page (a single padded column) * ```tsx - * - * …search + TimeFilter… - * …3 stat tiles… - * + * + * + *
…search + TimeFilter…
+ *
+ * …3 stat tiles… + * * * …active view (charts / keys)… * @@ -170,15 +186,15 @@ function columnsForCount(count: number): GridColumns { /** * Who owns the vertical scroll. - * - `"page"` (default): Root owns one `overflow-y-auto` — the whole page scrolls as one. - * - `"regions"`: Root only bounds the height (a `flex` column, no scroll); the page composes its - * own scrolling areas inside the slots. + * - `"page"` (default): Root owns one `overflow-y-auto` and the column rhythm — the whole page + * scrolls as one. + * - `"regions"`: Root only bounds the height (a bare `flex` column, no scroll, no rhythm); the + * page composes its own scrolling areas inside the slots. */ export type MetricsScroll = "page" | "regions"; type MetricsLayoutSidebarProps = { children: ReactNode; - className?: string; /** * Fixed sidebar width for the non-resizable default (any CSS length, e.g. `"380px"`, `"22rem"`). * Ignored when `resizable` is set. Defaults to `"380px"`. @@ -208,7 +224,8 @@ type MetricsLayoutSidebarProps = { /** * Marker slot for the persistent side panel. Rendered/positioned entirely by `Root` (this * component is never mounted directly) — Root reads its props to build the `[main | sidebar]` - * layout and drops the children into the panel. + * layout and drops the children into the panel. The panel itself owns its chrome (border, scroll); + * pass those as part of the children, not as a class on the slot. */ function MetricsLayoutSidebar(_props: MetricsLayoutSidebarProps) { return null; @@ -224,17 +241,10 @@ function isFiltersElement(child: ReactNode): child is ReactElement { // The main (left) column. The Filters slot is hoisted OUT of the scroll container so it stays // pinned while the tiles/content scroll underneath (the dashboards' `[auto | 1fr]` pattern — -// sticky without z-index/backdrop bookkeeping). In `"page"` mode the rest scrolls as one; in -// `"regions"` mode the page composes its own scrolling areas. -function MetricsLayoutMain({ - children, - className, - scroll, -}: { - children: ReactNode; - className?: string; - scroll: MetricsScroll; -}) { +// sticky without z-index/backdrop bookkeeping). In `"page"` mode the rest scrolls as one and gets +// the baked column rhythm (vertical gap + top/bottom padding); in `"regions"` mode the page +// composes its own scrolling areas, so the container stays bare. +function MetricsLayoutMain({ children, scroll }: { children: ReactNode; scroll: MetricsScroll }) { const arr = Children.toArray(children); const filters = arr.find(isFiltersElement); const rest = filters ? arr.filter((child) => !isFiltersElement(child)) : children; @@ -243,12 +253,11 @@ function MetricsLayoutMain({
{filters}
{rest}
@@ -258,38 +267,26 @@ function MetricsLayoutMain({ function MetricsLayoutRoot({ children, - className, - pageBodyClassName, scroll = "page", }: { children: ReactNode; - /** Applied to the inner scroll container (e.g. `flex flex-col gap-4 p-6` for a single column). */ - className?: string; - /** Applied to the outer, non-scrolling PageBody wrapper. */ - pageBodyClassName?: string; /** Who owns the vertical scroll — see {@link MetricsScroll}. Defaults to `"page"`. */ scroll?: MetricsScroll; }) { // A single optional Sidebar slot flips Root into a horizontal `[main | sidebar]` layout. When it - // is absent the output is exactly the original single-column markup (current pages don't pass - // either new prop, so they render unchanged). + // is absent the output is the plain single-column markup. const sidebar = Children.toArray(children).find(isSidebarElement); const mainChildren = sidebar ? Children.toArray(children).filter((child) => !isSidebarElement(child)) : children; - const main = ( - - {mainChildren} - - ); + const main = {mainChildren}; if (!sidebar) { return ( - - {/* The whole page scrolls as one: filters, tiles and content share a single vertical scroll - context, so the tiles scroll out of view with everything else (not an inner content-only - scroll). */} + + {/* The whole page scrolls as one: filters (pinned) aside, the tiles and content share a + single vertical scroll context. */} {main} ); @@ -297,7 +294,6 @@ function MetricsLayoutRoot({ const { children: sidebarChildren, - className: sidebarClassName, width = "380px", resizable, min = "280px", @@ -312,7 +308,7 @@ function MetricsLayoutRoot({ // Draggable split. `autosaveId`/`snapshot` wire up cookie persistence exactly as the run and // agent pages do (client writes the cookie, the loader hydrates via getResizableSnapshot). return ( - + {sidebarChildren} @@ -340,10 +336,10 @@ function MetricsLayoutRoot({ // Fixed-width sidebar. return ( - +
{main}
-
+
{sidebarChildren}
@@ -351,36 +347,51 @@ function MetricsLayoutRoot({ ); } -function MetricsLayoutFilters({ - children, - className, -}: { - children: ReactNode; - className?: string; -}) { - return
{children}
; +/** + * The pinned bar under the NavBar. Baked chrome: a 40px-tall bar with a bottom border and the + * standard page insets. Compose left/right clusters as child divs — `justify-between` spreads them + * (a single child sits at the start). + */ +function MetricsLayoutFilters({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); } +/** Whether a grid holds stat tiles (auto height) or charts (a fixed row height). */ +export type MetricsGridKind = "tiles" | "charts"; + +/** + * A grid of tiles with the baked page gutter and grid gap. Columns are derived from the tile count + * unless you pass an explicit `columns` spec. Pass `kind="charts"` for a row of chart cards — it + * bakes the fixed chart-row height so the cards fill it (no wrapper needed). + */ function MetricsLayoutGrid({ children, columns, - className, + kind = "tiles", }: { children: ReactNode; /** Explicit responsive columns. Omit to derive the layout from the number of tiles. */ columns?: GridColumns; - className?: string; + /** `"tiles"` (default) sizes to content; `"charts"` bakes the fixed chart-row height. */ + kind?: MetricsGridKind; }) { const resolved = columns ?? columnsForCount(Children.toArray(children).length); return (
{children} @@ -388,14 +399,21 @@ function MetricsLayoutGrid({ ); } +/** + * The content region below the tiles (tabs / table / list). Full-bleed by default so a list table + * spans edge to edge with its own top border; pass `inset` for a padded column (the detail page's + * tabs + charts). Either way Content bakes a doubled separation above it, so the tile blocks read + * as a distinct band from the content below. + */ function MetricsLayoutContent({ children, - className, + inset = false, }: { children: ReactNode; - className?: string; + /** Pad the content into a column (page gutter) instead of letting it span edge to edge. */ + inset?: boolean; }) { - return
{children}
; + return
{children}
; } export const MetricsLayout = { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index d7cf6253f8c..19d66037fb9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -486,9 +486,10 @@ function QueuesWithMetricsView() { - {/* Filters — top row, directly under the NavBar */} + {/* Filters — pinned bar directly under the NavBar. Left cluster = search + period; right + cluster = pagination. */} {success ? ( - +
- ) : ( -
- )} + ) : null} {/* Queued + Running + Allocated + Environment limit summary. Four stat tiles: the grid derives its columns from the tile count (two-up, four-up from lg). */} {success && allocation ? ( - + - ) : ( -
- )} + ) : null} {/* Env saturation, Backlog, Scheduling delay p95, Throttled viz — full-size, synced, drag-to-zoom line charts (Agent page pattern). Four chart tiles: 2x2 below lg, 4-up - from lg, derived from the tile count. */} -
- - - {QUEUE_HEADER_TILES.map((tile) => ( - 1 - ? [ - { - y: Math.round(environment.burstFactor * 100), - label: `Burst ${Math.round( - environment.concurrencyLimit * environment.burstFactor - )}`, - labelPlacement: "outside" as const, - }, - ] - : []), - ] - : undefined - } - // Saturation and p95 "step over the line": a per-bucket overlay retraces only - // the over-threshold stretches in warning colour, so under-threshold values stay - // blue. (A gradient split can't do this reliably — an SVG objectBoundingBox - // gradient tracks the line's own bbox, not the y-axis, so a low/flat line reads - // as entirely warning-coloured.) - // All thresholded lines colour warning where they step over the threshold: the - // per-bucket overlay retraces only the over-threshold stretches, so the colour - // change tracks the axis crossing. - warningOverlay={ - tile.id === "saturation" - ? { threshold: 100 } - : tile.id === "p95" - ? { threshold: 60_000 } - : tile.id === "throttled" - ? // Integer counts: threshold 0 warns once a bucket has ≥1 throttle. - { threshold: 0 } - : undefined - } - /> - ))} - - -
+ from lg, derived from the tile count. `kind="charts"` bakes the fixed row height. */} + + + {QUEUE_HEADER_TILES.map((tile) => ( + 1 + ? [ + { + y: Math.round(environment.burstFactor * 100), + label: `Burst ${Math.round( + environment.concurrencyLimit * environment.burstFactor + )}`, + labelPlacement: "outside" as const, + }, + ] + : []), + ] + : undefined + } + // Saturation and p95 "step over the line": a per-bucket overlay retraces only + // the over-threshold stretches in warning colour, so under-threshold values stay + // blue. (A gradient split can't do this reliably — an SVG objectBoundingBox + // gradient tracks the line's own bbox, not the y-axis, so a low/flat line reads + // as entirely warning-coloured.) + // All thresholded lines colour warning where they step over the threshold: the + // per-bucket overlay retraces only the over-threshold stretches, so the colour + // change tracks the axis crossing. + warningOverlay={ + tile.id === "saturation" + ? { threshold: 100 } + : tile.id === "p95" + ? { threshold: 60_000 } + : tile.id === "throttled" + ? // Integer counts: threshold 0 warns once a bucket has ≥1 throttle. + { threshold: 0 } + : undefined + } + /> + ))} + + {success ? ( diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index b2325e85fdb..9f44cca60c0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -224,23 +224,24 @@ export default function Page() { - {/* Filters are hoisted out of the scroll container by the layout (pinned on scroll), so they - carry the page inset themselves; the scrolling column keeps the rest. */} - - {/* Filters — search (concurrency keys) + time filter, above everything, like the - Queues list. The time filter scopes the tab charts; search filters the keys table. */} - - {showKeysTab ? ( - - ) : null} - + + {/* Filters — search (concurrency keys) + time filter in one left cluster, above + everything, like the Queues list. The time filter scopes the tab charts; search filters + the keys table. The bar is pinned by the layout while the page scrolls. */} + +
+ {showKeysTab ? ( + + ) : null} + +
{/* Live "right now" state of the whole queue — independent of the time filter above. @@ -255,7 +256,7 @@ export default function Page() { queueName={fullName} /> - + {showKeysTab ? ( - +
- +
); } @@ -423,7 +424,7 @@ function ConcurrencyKeysView({
{/* Per-key breakdown: which keys hold the backlog / do the work. */} - +
- +
- +
- +
); } @@ -892,7 +893,7 @@ function QueueStats({ const oldestWaitDisplayMs = ckWaitLive !== null && ckWaitLive > 0 ? ckWaitLive : oldestWaitMs; return ( - + ("tabs"); + const [tab, setTab] = useState<"panel" | "table">("panel"); return ( - {/* Filters slot — the row directly under the NavBar. */} - + {/* Filters slot — the pinned bar directly under the NavBar. Left + right clusters as child + divs; the slot bakes the 40px height, border and insets. */} +
Search… Period: 7d @@ -151,36 +154,34 @@ function OverviewDemo() { {/* Grid slot — 4 stat tiles. Columns are derived from the tile count: two-up, four-up - from lg. */} -
+ from lg. The gutter + gap are baked. */} +
Grid — 4 tiles (auto: 2-up, 4-up from lg)
- + - {/* Grid slot — 4 chart tiles at a fixed row height, same auto columns as the stats. */} -
- Grid — 4 chart tiles (fixed 280px row) -
-
- - - - - - + {/* Grid slot — kind="charts" bakes the fixed chart-row height (no wrapper needed). */} +
+ Grid — kind="charts" (fixed row height, auto columns)
+ + + + + + - {/* Grid slot — 3 tiles. The same component now lays out one-up, three-up from sm, proving - the grid adapts to the child count. */} -
+ {/* Grid slot — 3 tiles. The same component lays out one-up, three-up from sm, proving the + grid adapts to the child count. */} +
Grid — 3 tiles (auto: 1-up, 3-up from sm)
- + @@ -188,45 +189,46 @@ function OverviewDemo() { {/* Grid slot — explicit columns for a chart grid that should always be two-up regardless of tile count. */} -
+
Grid — explicit columns={{ base: 1, sm: 2 }} (5 tiles)
-
- - - - - - - -
+ + + + + + + - {/* Content slot — tabs vs. table. */} - - + {/* Content slot — a doubled separation above it is baked in, so the tiles read as their own + band. `inset` toggles between a padded column (panel) and full-bleed (edge-to-edge + table). */} + + setTab("tabs")} + onClick={() => setTab("panel")} > - Tabs content + Panel (inset) setTab("table")} > - Table content + Table (full-bleed) {tab === "table" ? ( ) : ( -
+
- The Content slot hosts whatever sits below the tiles — a TabContainer with panels, or - a full-width table. The whole page (filters, tiles and content) shares one vertical - scroll. + With inset, Content becomes a padded column: this panel and the tabs + above it sit on the standard page gutter. Switch to the table to see Content go + full-bleed — the table spans edge to edge with its own top border. Either way the + whole page (filters aside) shares one vertical scroll.
)} @@ -240,11 +242,13 @@ function OverviewDemo() { function SidebarFixedDemo() { return ( - - Search… - main column + +
+ Search… + main column +
- + @@ -266,11 +270,13 @@ function SidebarFixedDemo() { function SidebarResizableDemo() { return ( - - Search… - drag the handle → + +
+ Search… + drag the handle → +
- + From 2aa48d2b382fd25b5ff75e0570f35ffe4599d4c6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 06:37:56 +0000 Subject: [PATCH 14/37] feat(webapp): migrate org usage page onto MetricsLayout --- .../route.tsx | 331 +++++++++--------- 1 file changed, 165 insertions(+), 166 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx index 2cc8b01110b..5e7082b2411 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.usage/route.tsx @@ -7,7 +7,8 @@ import { redirect, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { URL } from "url"; import { UsageBar } from "~/components/billing/UsageBar"; import { getUsageBarBillingLimitDollars } from "~/components/billing/billingAlertsFormat"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { PageContainer } from "~/components/layout/AppLayout"; +import { MetricsLayout } from "~/components/layout/MetricsLayout"; import { Card } from "~/components/primitives/charts/Card"; import type { ChartConfig } from "~/components/primitives/charts/Chart"; import { Chart } from "~/components/primitives/charts/ChartCompound"; @@ -126,13 +127,12 @@ export default function Page() { - -
-
+ + +
- {promoCredits && ( -
-
-
- Promo credits -

- {formatCurrency(promoCredits.remainingCents / 100, false)} -

-
-
-
-
0 - ? Math.min( - 100, - Math.max( - 0, - (promoCredits.remainingCents / promoCredits.grantedCents) * 100 - ) +
+ + + + {promoCredits && ( +
+
+
+ Promo credits +

+ {formatCurrency(promoCredits.remainingCents / 100, false)} +

+
+
+
+
0 + ? Math.min( + 100, + Math.max( + 0, + (promoCredits.remainingCents / promoCredits.grantedCents) * 100 ) - : 0 - }%`, - }} - /> -
- - {formatCurrency(promoCredits.remainingCents / 100, false)} of{" "} - {formatCurrency(promoCredits.grantedCents / 100, false)} remaining - {promoCredits.expiresAt - ? ` · expires ${creditExpiryFormatter.format(new Date(promoCredits.expiresAt))}` - : ""} - + ) + : 0 + }%`, + }} + />
+ + {formatCurrency(promoCredits.remainingCents / 100, false)} of{" "} + {formatCurrency(promoCredits.grantedCents / 100, false)} remaining + {promoCredits.expiresAt + ? ` · expires ${creditExpiryFormatter.format(new Date(promoCredits.expiresAt))}` + : ""} +
- )} -
- }> +
+ )} +
+ }> + + Failed to load graph. +
+ } + > + {(usage) => ( +
+
+ + {isCurrentMonth ? "Month-to-date" : "Usage"} + +

+ {formatCurrency(usage.overall.current, false)} +

+
+ +
+ )} + + +
+
+ + + + Usage by day + + + +
+ } + > } > - {(usage) => ( -
-
- - {isCurrentMonth ? "Month-to-date" : "Usage"} - -

- {formatCurrency(usage.overall.current, false)} -

-
- -
- )} + {(u) => }
-
-
- -
- - Usage by day - - - -
- } - > - - Failed to load graph. -
- } - > - {(u) => } - - - - -
-
- - Tasks - - }> - - Failed to load. -
- } - > - {(tasks) => { - return ( - <> - - + + + + + Tasks + }> + + Failed to load. + + } + > + {(tasks) => { + return ( + <> +
+ + + Task + Runs + Average duration + Average cost + Total duration + Total cost + + + + {tasks.length === 0 ? ( - Task - Runs - Average duration - Average cost - Total duration - Total cost + +
+ No runs for this period +
+
- - - {tasks.length === 0 ? ( - - -
- - No runs for this period - -
+ ) : ( + tasks.map((task) => ( + + {task.taskIdentifier} + + {formatNumber(task.runCount)} + + + {formatDurationMilliseconds(task.averageDuration, { + style: "short", + })} + + + {formatCurrencyAccurate(task.averageCost)} + + + {formatDurationMilliseconds(task.totalDuration, { + style: "short", + })} + + + {formatCurrencyAccurate(task.totalCost)} - ) : ( - tasks.map((task) => ( - - {task.taskIdentifier} - - {formatNumber(task.runCount)} - - - {formatDurationMilliseconds(task.averageDuration, { - style: "short", - })} - - - {formatCurrencyAccurate(task.averageCost)} - - - {formatDurationMilliseconds(task.totalDuration, { - style: "short", - })} - - - {formatCurrencyAccurate(task.totalCost)} - - - )) - )} -
-
- - Dev environment runs are excluded from the usage data above, since they do - not have an associated compute cost. - - - ); - }} - - -
-
-
+ )) + )} + + + + Dev environment runs are excluded from the usage data above, since they do not + have an associated compute cost. + + + ); + }} + + + +
); } From 9898e833ff57b53f8af8dd5c8d441a7a53f2dae0 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 06:42:32 +0000 Subject: [PATCH 15/37] refactor(webapp): adopt hardened MetricsLayout API on the agent detail page Follow-up to the MetricsLayout hardening (closed semantic props, no className on slots): the Filters bar drops its className in favour of the baked pinned chrome with left/right cluster divs, and the charts grid becomes kind="charts" (baked fixed row height) instead of a classNamed grid inside a sized wrapper. --- .../route.tsx | 133 +++++++++--------- 1 file changed, 68 insertions(+), 65 deletions(-) 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 5111ce8f87f..76beaf297f3 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 @@ -234,81 +234,84 @@ export default function Page() { - {/* Filters — top row, directly under the NavBar: the TimeFilter and pagination that used - to be fused with the tabs now live here, above the charts (Queues list pattern). */} - - - {tab === "sessions" ? ( - - - {(list) => (list ? : null)} - - - ) : ( - - - {(list) => (list ? : null)} - - - )} + {/* Filters — the pinned bar under the NavBar: the TimeFilter and pagination that used to + be fused with the tabs now live here, above the charts (Queues list pattern). Left and + right clusters are child divs; the slot's baked justify-between spreads them. */} + +
+ +
+
+ {tab === "sessions" ? ( + + + {(list) => (list ? : null)} + + + ) : ( + + + {(list) => (list ? : null)} + + + )} +
- {/* Activity / LLM spend / Token charts as a fixed-height tile row (three-up), synced + + {/* Activity / LLM spend / Token charts as a fixed-height chart row (three-up), synced + drag-to-zoom. The old draggable charts/table split is intentionally dropped — the charts get a fixed row and the table flows below in the page scroll. */} -
- - - - {tab === "sessions" ? ( - }> - }> - {(result) => } - - - ) : ( - }> - }> - {(result) => } - - - )} - - - + + + + {tab === "sessions" ? ( }> - }> - {(result) => ( - - )} + }> + {(result) => } - - - + ) : ( }> - }> - {(result) => ( - - )} + }> + {(result) => } - - - -
+ )} + + + + }> + }> + {(result) => ( + + )} + + + + + + }> + }> + {(result) => ( + + )} + + + +
+ {/* Tabs alone on their row (Queue detail pattern), then the table below them. */} From 0a9996b97f42fa81666562cc73611d6e9bf611fc Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 06:47:28 +0000 Subject: [PATCH 16/37] fix(webapp): keep queue summary tiles when allocation fails open The env Queued/Running tiles, Environment-limit tile and pause/increase controls no longer disappear when the allocation summary is unavailable. Only the Allocated tile depends on it, and it now shows a placeholder so the four-tile grid keeps its shape. --- .../route.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 19d66037fb9..2a0fbd597cd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -511,8 +511,11 @@ function QueuesWithMetricsView() { ) : null} {/* Queued + Running + Allocated + Environment limit summary. Four stat tiles: the grid - derives its columns from the tile count (two-up, four-up from lg). */} - {success && allocation ? ( + derives its columns from the tile count (two-up, four-up from lg). The allocation + presenter fails open to null (a ClickHouse/PG hiccup mustn't take down the tiles), so + only the Allocated tile depends on it — the other three + controls always render, and + Allocated shows a "–" placeholder to keep the 4-tile grid shape stable. */} + {success ? ( Allocated - {overAllocated ? ( + {allocation && overAllocated ? ( } - value={allocated} - valueClassName={cn(overAllocated && "text-warning")} - suffix={`${allocationPct}% of the environment limit`} + value={allocation ? allocated : undefined} + formattedValue={allocation ? undefined : "–"} + valueClassName={cn(allocation && overAllocated && "text-warning")} + suffix={allocation ? `${allocationPct}% of the environment limit` : undefined} suffixClassName="text-text-bright" /> Date: Mon, 20 Jul 2026 06:47:30 +0000 Subject: [PATCH 17/37] fix(webapp): harden queue detail page action and live blocks - Reject mutations on archived branches, matching the queues list route. - The live "Oldest wait" now ignores concurrency keys whose backlog has drained, so a stale key can't over-report the wait. - The concurrency override dialog reopens in percent mode for percent overrides instead of always defaulting to an absolute value. --- .../route.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 9f44cca60c0..718d72403e6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -149,6 +149,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { if (!environment) throw new Response(undefined, { status: 404, statusText: "Environment not found" }); + if (environment.archivedAt) { + return redirectWithErrorMessage(redirectPath, request, "This branch is archived"); + } + const formData = await request.formData(); // Pause/resume/override actions are shared with the Queues list route; here we redirect back to @@ -175,8 +179,11 @@ function wholeQueueOldestWaitMs( oldestQueuedAt: number | null, now: number ): number | null { - if (breakdown.keys.length > 0) { - return breakdown.keys.reduce((max, k) => Math.max(max, now - k.oldestEnqueuedAt), 0); + // Only keys with a live backlog (queued > 0) count — a lingering ckIndex entry whose subqueue + // has drained would otherwise over-report the oldest wait. Matches the worstKeyNow guard below. + const waitingKeys = breakdown.keys.filter((k) => k.queued > 0); + if (waitingKeys.length > 0) { + return waitingKeys.reduce((max, k) => Math.max(max, now - k.oldestEnqueuedAt), 0); } return oldestQueuedAt !== null ? Math.max(0, now - oldestQueuedAt) : null; } @@ -842,7 +849,9 @@ function QueueStats({ timeRange, queueName, }: { - queue: QueueItem; + // Carries the percent override source-of-truth (not part of the shared QueueItem contract) so the + // override dialog reopens in percent mode for percent-based overrides. + queue: QueueItem & { concurrencyLimitOverridePercent: number | null }; environmentConcurrencyLimit: number; queuedRunsPath: string; oldestWaitMs: number | null; @@ -906,7 +915,7 @@ function QueueStats({ iconOnly /> From 30f598cf985ec3b64601d3bf86dea54b18cae88c Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 06:54:15 +0000 Subject: [PATCH 18/37] fix(webapp): type resizable sidebar lengths as panel units The panel library's min/default/max props are template-literal Unit types; bare strings failed typecheck (would have failed CI where the billing baseline noise is absent). --- apps/webapp/app/components/layout/MetricsLayout.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/layout/MetricsLayout.tsx b/apps/webapp/app/components/layout/MetricsLayout.tsx index d4108b40203..186d0157269 100644 --- a/apps/webapp/app/components/layout/MetricsLayout.tsx +++ b/apps/webapp/app/components/layout/MetricsLayout.tsx @@ -193,6 +193,9 @@ function columnsForCount(count: number): GridColumns { */ export type MetricsScroll = "page" | "regions"; +/** A length the resizable panels accept: pixels or percent (the panel library's `Unit`). */ +type PanelLength = `${number}px` | `${number}%`; + type MetricsLayoutSidebarProps = { children: ReactNode; /** @@ -208,13 +211,13 @@ type MetricsLayoutSidebarProps = { */ resizable?: boolean; /** Resizable only: min width of the sidebar panel. Defaults to `"280px"`. */ - min?: string; + min?: PanelLength; /** Resizable only: initial width of the sidebar panel. Defaults to `"380px"`. */ - defaultSize?: string; + defaultSize?: PanelLength; /** Resizable only: max width of the sidebar panel. */ - max?: string; + max?: PanelLength; /** Resizable only: min width of the main panel. Defaults to `"300px"`. */ - mainMin?: string; + mainMin?: PanelLength; /** Resizable only: cookie name the split is persisted under (also the panel-group id). */ autosaveId?: string; /** Resizable only: server-loaded split snapshot to hydrate from (see `getResizableSnapshot`). */ From 4b52aacb2267d429912847ecd5b7e048f966e2e6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 07:13:39 +0000 Subject: [PATCH 19/37] refactor(webapp): dedupe activity-chart class and share queue-override percent bounds Reuse the exported ACTIVITY_CHART_PEAK_CLASS on the env index blank state, and extract MIN/MAX_QUEUE_OVERRIDE_PERCENT + isValidQueueOverridePercent so the API schema, the dashboard mutation handler, and the override service validate the same bound. --- .../webapp/app/models/queueMutation.server.ts | 9 ++++++-- .../route.tsx | 5 ++--- ...queues.$queueParam.concurrency.override.ts | 6 +++++- .../v3/services/concurrencySystem.server.ts | 21 +++++++++++++++++-- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/models/queueMutation.server.ts b/apps/webapp/app/models/queueMutation.server.ts index f6e63027d84..5e4dbcd90e4 100644 --- a/apps/webapp/app/models/queueMutation.server.ts +++ b/apps/webapp/app/models/queueMutation.server.ts @@ -2,6 +2,11 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m import { getUserById } from "~/models/user.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server"; +import { + isValidQueueOverridePercent, + MAX_QUEUE_OVERRIDE_PERCENT, + MIN_QUEUE_OVERRIDE_PERCENT, +} from "~/v3/services/concurrencySystem.server"; import { PauseQueueService } from "~/v3/services/pauseQueue.server"; /** @@ -72,11 +77,11 @@ export async function handleQueueMutationAction({ return redirectWithErrorMessage(redirectPath, request, "Percentage is required"); } const percentNumber = Number(percentValue.toString()); - if (!Number.isFinite(percentNumber) || percentNumber <= 0 || percentNumber > 100) { + if (!isValidQueueOverridePercent(percentNumber)) { return redirectWithErrorMessage( redirectPath, request, - "Percentage must be greater than 0 and less than or equal to 100" + `Percentage must be greater than ${MIN_QUEUE_OVERRIDE_PERCENT} and less than or equal to ${MAX_QUEUE_OVERRIDE_PERCENT}` ); } override = { percent: percentNumber }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index 425275b0beb..bdbea4b25c6 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -54,6 +54,7 @@ import TooltipPortal from "~/components/primitives/TooltipPortal"; import { ActivityBarChart, ACTIVITY_CHART_HEIGHT, + ACTIVITY_CHART_PEAK_CLASS, ACTIVITY_CHART_WIDTH, } from "~/components/metrics/ActivityBarChart"; import { TaskFileName } from "~/components/runs/v3/TaskPath"; @@ -631,8 +632,6 @@ const STATUS_BARS: { status: TaskRunStatus; fill: string }[] = [ // chart (112) + gap-1.5 (6) + count min-w (28). Reserved so the column stays put while the chart unmounts. const ACTIVITY_CELL_WIDTH = 146; -const ACTIVITY_CHART_COUNT_CLASS = - "-mt-1 inline-block min-w-7 text-xxs tabular-nums text-text-dimmed"; function TaskActivityGraph({ activity }: { activity: HourlyTaskActivity[string] }) { const maxTotal = Math.max(...activity.map((d) => d.total)); @@ -673,7 +672,7 @@ function TaskActivityBlankState() { strokeWidth={1} /> - 0 + 0
); } diff --git a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts index 099cb9fa204..acc723d61cb 100644 --- a/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts +++ b/apps/webapp/app/routes/api.v1.queues.$queueParam.concurrency.override.ts @@ -4,6 +4,10 @@ import { z } from "zod"; import { toQueueItem } from "~/presenters/v3/QueueRetrievePresenter.server"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; import { concurrencySystem } from "~/v3/services/concurrencySystemInstance.server"; +import { + MAX_QUEUE_OVERRIDE_PERCENT, + MIN_QUEUE_OVERRIDE_PERCENT, +} from "~/v3/services/concurrencySystem.server"; const BodySchema = z .object({ @@ -12,7 +16,7 @@ const BodySchema = z concurrencyLimit: z.number().int().min(0).max(100000).optional(), // Percentage of the environment's maximum concurrency limit (0 < percent <= 100). // Stored as the source of truth; the absolute limit is materialized from it. - percent: z.number().gt(0).max(100).optional(), + percent: z.number().gt(MIN_QUEUE_OVERRIDE_PERCENT).max(MAX_QUEUE_OVERRIDE_PERCENT).optional(), }) .refine((body) => (body.concurrencyLimit === undefined) !== (body.percent === undefined), { message: "Provide exactly one of `concurrencyLimit` or `percent`", diff --git a/apps/webapp/app/v3/services/concurrencySystem.server.ts b/apps/webapp/app/v3/services/concurrencySystem.server.ts index 2260976fd41..f030cb72e5f 100644 --- a/apps/webapp/app/v3/services/concurrencySystem.server.ts +++ b/apps/webapp/app/v3/services/concurrencySystem.server.ts @@ -27,6 +27,23 @@ export type ConcurrencyLimitOverride = number | { limit: number } | { percent: n * Clamped to `>= 1` so a percent-based override never produces a `0` (pause-like) limit, and * to `<= envLimit` so it can never exceed the environment maximum. */ +/** + * Valid range for a percent-based queue concurrency override: greater than 0 and up to 100% of + * the environment limit. Shared by every layer that validates the percent (the API zod schema, + * the dashboard mutation handler, and the override service) so the bound never drifts apart. + */ +export const MIN_QUEUE_OVERRIDE_PERCENT = 0; +export const MAX_QUEUE_OVERRIDE_PERCENT = 100; + +/** Whether `percent` is a valid queue-override percentage (0 < percent <= 100). */ +export function isValidQueueOverridePercent(percent: number): boolean { + return ( + Number.isFinite(percent) && + percent > MIN_QUEUE_OVERRIDE_PERCENT && + percent <= MAX_QUEUE_OVERRIDE_PERCENT + ); +} + export function materializePercentLimit(envLimit: number, percent: number): number { const materialized = Math.floor((envLimit * percent) / 100); return Math.min(Math.max(materialized, 1), envLimit); @@ -197,10 +214,10 @@ function overrideQueueConcurrencyLimit( if (typeof override === "object" && "percent" in override) { const percent = override.percent; - if (!Number.isFinite(percent) || percent <= 0 || percent > 100) { + if (!isValidQueueOverridePercent(percent)) { return errAsync({ type: "invalid_override" as const, - message: "Percent must be greater than 0 and less than or equal to 100", + message: `Percent must be greater than ${MIN_QUEUE_OVERRIDE_PERCENT} and less than or equal to ${MAX_QUEUE_OVERRIDE_PERCENT}`, }); } From 3c7a92dba7e5cd53e42d382e8556cdad03e7bf97 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 07:16:42 +0000 Subject: [PATCH 20/37] fix(webapp): keep the line-style tooltip indicator on the stacked area chart The shared tooltip element dropped indicator="line", so stacked areas fell back to a dot. Add a stable module-level StackedAreaTooltip so the stacked view keeps its line indicator without reintroducing the portaled-tooltip remount flicker. --- .../primitives/charts/ChartLine.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index e32122f3262..a3cf1867958 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -72,6 +72,14 @@ function OverlayFilteredTooltip(props: any) { ); } +// Stable module-level tooltip for the stacked area chart: keeps the line-style indicator the +// stacked view has always used (ChartTooltipContent otherwise defaults to a dot). Module-level for +// the same reconcile-in-place reason as OverlayFilteredTooltip — an inline element would remount +// the portaled tooltip on every hover re-render and flicker. +function StackedAreaTooltip(props: any) { + return ; +} + // ============================================================================ // COMPOUND COMPONENT API // ============================================================================ @@ -455,6 +463,16 @@ export function ChartLineRenderer({ // Render stacked area chart if stacked prop is true if (stacked && visibleSeries.length > 1) { + // Same variants as the line chart's tooltipContent, but the default popup keeps the stacked + // view's line-style indicator (warning overlay never applies to stacked areas). + const stackedTooltipContent = + syncZoomSelection && zoomFrom != null && zoomTo != null ? ( + + ) : showLegend ? ( + () => null + ) : ( + + ); return ( Date: Mon, 20 Jul 2026 07:34:34 +0000 Subject: [PATCH 21/37] test(webapp): document the engine-mock forwarding contract in the recalc self-heal test --- apps/webapp/test/concurrencySystemPercentOverride.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/webapp/test/concurrencySystemPercentOverride.test.ts b/apps/webapp/test/concurrencySystemPercentOverride.test.ts index bd358f81c94..f81f1b1d10b 100644 --- a/apps/webapp/test/concurrencySystemPercentOverride.test.ts +++ b/apps/webapp/test/concurrencySystemPercentOverride.test.ts @@ -12,6 +12,11 @@ import { ConcurrencySystem, materializePercentLimit } from "~/v3/services/concur // verification report. Everything the service persists to Postgres IS real. // A controllable spy for the engine's queue-limit push so a test can simulate a push failure and // prove the next recalc self-heals the divergence. +// This mocks the ENGINE method (`engine.runQueue.updateQueueConcurrencyLimits`). The service calls +// the top-level `updateQueueConcurrencyLimits` from `~/v3/runQueue.server`, which forwards straight +// to this engine method with the same `(environment, queueName, concurrency)` argument order (no +// transformation) — so mocking the engine method genuinely exercises the recalc's sync path and the +// `(env, queueName, 10)` assertion below is exact. const { updateQueueConcurrencyLimitsMock } = vi.hoisted(() => ({ updateQueueConcurrencyLimitsMock: vi.fn(async (..._args: unknown[]) => undefined), })); From cec5905a0ad643a57266cb91ccef542923e8c7a7 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 07:40:06 +0000 Subject: [PATCH 22/37] docs(webapp): note the queue-override reject-above-limit behavior change --- .server-changes/queue-override-reject-above-limit.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/queue-override-reject-above-limit.md diff --git a/.server-changes/queue-override-reject-above-limit.md b/.server-changes/queue-override-reject-above-limit.md new file mode 100644 index 00000000000..9f1b9f8492e --- /dev/null +++ b/.server-changes/queue-override-reject-above-limit.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: breaking +--- + +Setting a queue's concurrency limit higher than the environment limit is now rejected with a clear error instead of being silently reduced to the maximum. This applies to the queue concurrency override API and the dashboard. Existing overrides are unaffected — the change only affects new attempts that exceed the environment limit. From 2c8e0fec54cc7f07a4db560b84481395f2604fa0 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 07:44:55 +0000 Subject: [PATCH 23/37] docs(webapp): tighten metrics layout docs and queue release notes --- .../queue-override-reject-above-limit.md | 2 +- .../queue-pages-live-and-clarity.md | 2 +- .../app/components/layout/MetricsLayout.tsx | 126 ++++-------------- 3 files changed, 25 insertions(+), 105 deletions(-) diff --git a/.server-changes/queue-override-reject-above-limit.md b/.server-changes/queue-override-reject-above-limit.md index 9f1b9f8492e..63d38690ad9 100644 --- a/.server-changes/queue-override-reject-above-limit.md +++ b/.server-changes/queue-override-reject-above-limit.md @@ -3,4 +3,4 @@ area: webapp type: breaking --- -Setting a queue's concurrency limit higher than the environment limit is now rejected with a clear error instead of being silently reduced to the maximum. This applies to the queue concurrency override API and the dashboard. Existing overrides are unaffected — the change only affects new attempts that exceed the environment limit. +Setting a queue's concurrency limit above the environment limit is now rejected with a clear error instead of being silently capped. Existing overrides are unaffected. diff --git a/.server-changes/queue-pages-live-and-clarity.md b/.server-changes/queue-pages-live-and-clarity.md index 3462a008678..97785debe6e 100644 --- a/.server-changes/queue-pages-live-and-clarity.md +++ b/.server-changes/queue-pages-live-and-clarity.md @@ -3,4 +3,4 @@ area: webapp type: improvement --- -The queue pages now keep their live numbers fresh automatically (updates pause while the tab is in the background), and the charts are easier to read: clearer axis labels, hover explanations for the headline stats, and the busiest concurrency key is named right on the chart. +Queue pages now refresh their live numbers automatically, and the charts are clearer: better axis labels, hover explanations for the headline stats, and the busiest concurrency key named on the chart. diff --git a/apps/webapp/app/components/layout/MetricsLayout.tsx b/apps/webapp/app/components/layout/MetricsLayout.tsx index 186d0157269..6ae40778aff 100644 --- a/apps/webapp/app/components/layout/MetricsLayout.tsx +++ b/apps/webapp/app/components/layout/MetricsLayout.tsx @@ -1,109 +1,33 @@ /** * MetricsLayout — a compound layout for metric / dashboard pages. * - * PHILOSOPHY — slots own their chrome. This family exists so that non-designers (and AI agents) - * can compose a beautiful, consistent metrics page purely by picking slots and setting closed, - * semantic props. There is deliberately NO freeform `className` on any slot: a page cannot restyle - * the spacing, borders or gutters of the layout, because that is exactly how metric pages drifted - * apart before. The slots bake ONE visual rhythm (a 12px grid gap, a 12px page gutter, a doubled - * separation before the content, a pinned 40px filter bar). If you find yourself wanting a class on - * a slot, you probably need a new semantic variant instead — talk to design and add a closed prop - * (like `kind` or `inset`) rather than reopening `className`. + * Slots bake all the chrome; there is no `className` on any slot, so pages can't drift apart on + * spacing. Need a variant? Add a closed prop (`kind`, `inset`), don't reopen `className`. * - * Every metrics page reads top-to-bottom as the same slots: + * Slots, top to bottom: + * - `Filters` — pinned 40px bar under the NavBar. Left/right clusters are child divs. + * - `Grid` — tiles; columns derived from tile count unless `columns` is set. `kind="charts"` + * bakes the fixed chart-row height. + * - `Content` — table / tabs below the tiles. Full-bleed by default; `inset` for a padded column. * - * 1. `MetricsLayout.Filters` — the pinned bar under the NavBar (search, TimeFilter, pagination…). - * It is hoisted out of the scroll container so it stays put while the page scrolls. Baked - * chrome: a 40px bar with a bottom border. Pages express left/right clusters as child divs - * (e.g. left = search + TimeFilter, right = pagination); `justify-between` spreads them. - * 2. `MetricsLayout.Grid` — one or more grids of tiles (stat BigNumbers, chart cards). The grid - * adapts its column count to the number of tiles unless you pass an explicit `columns` spec, - * and bakes the page gutter + grid gap. `kind="charts"` bakes the fixed chart-row height. - * 3. `MetricsLayout.Content` — the tabs / table / list below the tiles. Full-bleed by default - * (a list table spans edge to edge with its own top border); pass `inset` for a padded column. - * Content always bakes a doubled separation (24px) above it, so the blocks read as a distinct - * band from the content below. + * Optional: + * - `Sidebar` — a persistent right-hand panel; fixed `width` or `resizable`. Present ⇒ Root + * switches to `[main | sidebar]`; absent ⇒ single column. + * - `scroll` on Root — `"page"` (default): the whole page scrolls as one. `"regions"`: Root owns + * no scroll, the page composes its own scrolling areas. * - * Two optional structural capabilities extend the basic top-to-bottom column: + * Purely presentational. Lives inside a `PageContainer` after the `NavBar`. * - * - `MetricsLayout.Sidebar` — a persistent side panel rendered to the RIGHT of the main column - * (main content left, sidebar right, full height). Drop a single `` - * anywhere among Root's children and Root switches to a `[main | sidebar]` horizontal layout; - * omit it and nothing changes. The sidebar is fixed-width by default (`width`), or set - * `resizable` to make the split draggable via the shared Resizable primitives. - * - * - `scroll` on Root — chooses who owns the vertical scroll: - * - `"page"` (default): Root owns a single `overflow-y-auto` and lays the slots out as a - * `flex` column with the standard vertical rhythm; the WHOLE page (filters aside) scrolls - * as one. This is what every current metrics page wants. - * - `"regions"`: Root does NOT create a scroll container and does NOT impose the column - * rhythm — it only bounds the height as a bare `flex` column. The page composes its own - * independently-scrolling areas inside the slots (e.g. a fixed toolbar over a scrolling - * table, or a vertical resizable split). Use this when a single page-level scroll would be - * wrong. - * - * The family is purely presentational — slots, grids, a sidebar and scroll ownership only, no data - * logic. It is meant to live inside a `PageContainer` right after the `NavBar`. - * - * @example List page (full-bleed table, count-derived grids) - * ```tsx - * - * - * - * - *
…search + TimeFilter…
- * - *
- * …4 stat tiles… - * - * …4 chart tiles… - * - * …full-width table… - *
- *
- * ``` - * - * @example Detail page (a single padded column) + * @example * ```tsx * * *
…search + TimeFilter…
+ * *
- * …3 stat tiles… - * - * - * …active view (charts / keys)… - * - *
- * ``` - * - * @example Page with a persistent config sidebar (fixed-width) - * ```tsx - * - * - * - * …config panel… - * - * ``` - * - * @example Resizable sidebar + independently-scrolling regions - * ```tsx - * // In the loader, hydrate the persisted split from a cookie: - * const sidebarSnapshot = await getResizableSnapshot(request, "my-page-sidebar"); - * - * - *
…toolbar…
- *
…scrolling body…
- * - * …config panel… - * + * …stat tiles… + * …chart tiles… + * …table… *
* ``` */ @@ -204,10 +128,8 @@ type MetricsLayoutSidebarProps = { */ width?: string; /** - * When true, the `[main | sidebar]` split becomes draggable using the shared Resizable - * primitives. Persist the split by passing a stable `autosaveId` (the primitive writes the - * split to a cookie of that name) together with a `snapshot` read back from that cookie in the - * loader via `getResizableSnapshot(request, autosaveId)`. + * Makes the split draggable. To persist it, pass an `autosaveId` (written to a cookie) plus the + * `snapshot` read back in the loader via `getResizableSnapshot(request, autosaveId)`. */ resizable?: boolean; /** Resizable only: min width of the sidebar panel. Defaults to `"280px"`. */ @@ -242,11 +164,9 @@ function isFiltersElement(child: ReactNode): child is ReactElement { return isValidElement(child) && child.type === MetricsLayoutFilters; } -// The main (left) column. The Filters slot is hoisted OUT of the scroll container so it stays -// pinned while the tiles/content scroll underneath (the dashboards' `[auto | 1fr]` pattern — -// sticky without z-index/backdrop bookkeeping). In `"page"` mode the rest scrolls as one and gets -// the baked column rhythm (vertical gap + top/bottom padding); in `"regions"` mode the page -// composes its own scrolling areas, so the container stays bare. +// The main (left) column. Filters is hoisted out of the scroll container so it stays pinned while +// the rest scrolls. `"page"` scrolls as one with the baked column rhythm; `"regions"` stays bare so +// the page owns its own scrolling. function MetricsLayoutMain({ children, scroll }: { children: ReactNode; scroll: MetricsScroll }) { const arr = Children.toArray(children); const filters = arr.find(isFiltersElement); From 2a1a412d6db0e5f76ce470069b0bd6e24a9ece71 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 08:55:08 +0000 Subject: [PATCH 24/37] feat(webapp): key search filters the per-key charts, not just the table --- .../route.tsx | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 718d72403e6..2c8e99e496c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -412,7 +412,9 @@ function ConcurrencyKeysView({ queueName: string; }) { const zoomToTimeFilter = useZoomToTimeFilter(); - const { replace } = useSearchParams(); + const { value, replace } = useSearchParams(); + // Same key search as the table: narrows the per-key charts too. + const keyFilter = value("query")?.trim().toLowerCase() || undefined; // The live most-starved key: among keys with a live backlog, the one whose oldest waiting run // has been waiting longest right now. Names the culprit on the "Worst key wait" card so the chart @@ -439,6 +441,7 @@ function ConcurrencyKeysView({ rankExpr="max(max_queued)" seriesExpr="max(max_queued)" fillGaps + keyFilter={keyFilter} ids={ids} timeRange={timeRange} queueName={queueName} @@ -449,6 +452,7 @@ function ConcurrencyKeysView({ className="aspect-[2/1]" rankExpr="deltaSumTimestampMerge(started_delta)" seriesExpr="deltaSumTimestampMerge(started_delta)" + keyFilter={keyFilter} ids={ids} timeRange={timeRange} queueName={queueName} @@ -538,22 +542,27 @@ type GroupedKeyChartProps = { seriesExpr: string; fillGaps?: boolean; valueFormat?: (value: number) => string; + /** Search substring (from the page's key search) — narrows the charted keys, like the table. */ + keyFilter?: string; ids: Ids; timeRange: TimeRangeParams; queueName: string; }; // Two-step top-N: rank keys over the range, then chart those keys as grouped series -// (the per-key table is activity-bound, so ranking is a cheap scan). +// (the per-key table is activity-bound, so ranking is a cheap scan). Rank wider than we chart so a +// search can match keys outside the top 8; then filter by the search and keep the top 8 of those. function GroupedKeyChartCard(props: GroupedKeyChartProps) { const { rows, showLoading, failed } = useQueueMetric( - `SELECT concurrency_key, ${props.rankExpr} AS peak\nFROM queue_metrics_by_key\nGROUP BY concurrency_key\nORDER BY peak DESC\nLIMIT 8`, + `SELECT concurrency_key, ${props.rankExpr} AS peak\nFROM queue_metrics_by_key\nGROUP BY concurrency_key\nORDER BY peak DESC\nLIMIT 50`, { ids: props.ids, timeRange: props.timeRange, queueName: props.queueName } ); - const keys = useMemo( - () => rows.filter((r) => toNumber(r.peak) > 0).map((r) => String(r.concurrency_key)), - [rows] - ); + const keyFilter = props.keyFilter; + const keys = useMemo(() => { + let names = rows.filter((r) => toNumber(r.peak) > 0).map((r) => String(r.concurrency_key)); + if (keyFilter) names = names.filter((n) => n.toLowerCase().includes(keyFilter)); + return names.slice(0, 8); + }, [rows, keyFilter]); if (showLoading || failed || keys.length === 0) return null; return ; From a417b4d16c025ab3d230b4c0c4fb264809e9ee3f Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 11:37:02 +0000 Subject: [PATCH 25/37] fix(webapp): order table headers title, info icon, then sort arrows --- .../app/components/primitives/Table.tsx | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index 47dbb8acbb7..c947da9a77e 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -271,19 +271,16 @@ export const TableHeaderCell = forwardRef {sortable ? ( - // The tooltip trigger is itself a - {tooltipNode} + {tooltip ? ( + <> + {tooltipNode} + + + ) : null}
) : tooltip ? (
From faac7712fea182ddc5d3c81f559b8abcb29b03be Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 11:44:41 +0000 Subject: [PATCH 26/37] feat(webapp): make queue list rows fully clickable, right-align Backlog header Every data cell in the queues metrics table now links to the queue detail page (mirroring the runs table's full-row navigation), while the pause/resume + override controls, row action menu, and in-cell tooltips punch through without navigating. Also right-aligns the Backlog column header. --- .../route.tsx | 105 +++++++++++------- 1 file changed, 66 insertions(+), 39 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 2a0fbd597cd..28a0b30e2fe 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -7,10 +7,10 @@ import { RectangleStackIcon, } from "@heroicons/react/20/solid"; import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, Link, useNavigation, type MetaFunction } from "@remix-run/react"; +import { Form, useNavigation, type MetaFunction } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { RuntimeEnvironmentType } from "@trigger.dev/database"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; @@ -338,6 +338,14 @@ function getEnvConcurrencyLimitStatus(environment: { return { limitStatus, limitClassName }; } +// The whole queue row is a link to the queue detail page (each data cell carries `to`). In-cell +// controls that live inside a `to` cell (info-icon/status tooltips) call this so a click acts on +// the control instead of following the row link — mirrors CopyableTableCell in the Table primitive. +function punchThroughRowLink(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); +} + export default function Page() { // Per-org flag decides which whole page renders. Off => the classic Queues page, // byte-for-byte the pre-metrics UI. Each branch is its own component (own hooks). @@ -727,6 +735,7 @@ function QueuesWithMetricsView() { Delay p95 @@ -747,23 +756,21 @@ function QueuesWithMetricsView() { queue.queued >= environment.queueSizeLimit; const queueFilterableName = queueMetricsKey(queue); const queueMetric = metricsByQueue[queueFilterableName]; + const queueDetailPath = v3QueuePath(organization, project, env, { + friendlyId: queue.id, + }); return ( - + - - - + {queue.concurrency?.overriddenAt ? ( - + + + ) : null} {queue.paused ? ( @@ -776,19 +783,25 @@ function QueuesWithMetricsView() { ) : null} {isAtConcurrencyLimit ? ( - } - content="At concurrency limit: this queue is running as many runs as its limit allows; new runs wait in the backlog." - className="max-w-xs" - disableHoverableContent - /> + + + } + content="At concurrency limit: this queue is running as many runs as its limit allows; new runs wait in the backlog." + className="max-w-xs" + disableHoverableContent + /> + ) : null} 0 && "text-text-bright" )} @@ -806,9 +821,11 @@ function QueuesWithMetricsView() { {queue.running} {queue.concurrency?.overriddenAt ? ( - Override} - content={ - queue.concurrencyLimitOverridePercent !== null - ? `Overridden at ${formatOverridePercent( - queue.concurrencyLimitOverridePercent - )}% of the environment limit.` - : `This queue's concurrency limit has been manually overridden to ${limit}.` - } - className="max-w-xs" - disableHoverableContent - /> + + Override} + content={ + queue.concurrencyLimitOverridePercent !== null + ? `Overridden at ${formatOverridePercent( + queue.concurrencyLimitOverridePercent + )}% of the environment limit.` + : `This queue's concurrency limit has been manually overridden to ${limit}.` + } + className="max-w-xs" + disableHoverableContent + /> + ) : queue.concurrencyLimit ? ( "User" ) : ( @@ -852,6 +873,7 @@ function QueuesWithMetricsView() { )} @@ -862,7 +884,12 @@ function QueuesWithMetricsView() { limit={limit} /> - + {queueMetric && queueMetric.p95WaitMs !== null ? ( {formatWaitMs(queueMetric.p95WaitMs)} @@ -871,7 +898,7 @@ function QueuesWithMetricsView() { )} - + Date: Mon, 20 Jul 2026 11:47:15 +0000 Subject: [PATCH 27/37] feat(webapp): always show both queue-detail tabs; full-bleed keys table Both Overview and Concurrency keys tabs now always render on the queue detail page. When a queue has no concurrency keys, the keys tab shows a standard empty-state panel with a docs link instead of blank charts. The per-key stats table now spans the full width edge-to-edge (matching the Queues list table) while the per-key charts and the key drill-down keep their padded column. --- .../route.tsx | 387 ++++++++++-------- 1 file changed, 221 insertions(+), 166 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 2c8e99e496c..b2e437d30a2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -4,7 +4,7 @@ import { useMemo, type ReactNode } from "react"; import type { QueueItem } from "@trigger.dev/core/v3/schemas"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; -import { PageContainer } from "~/components/layout/AppLayout"; +import { MainCenteredContainer, PageContainer } from "~/components/layout/AppLayout"; import { MetricsLayout } from "~/components/layout/MetricsLayout"; import { BigNumber } from "~/components/metrics/BigNumber"; import { Header3 } from "~/components/primitives/Headers"; @@ -52,7 +52,7 @@ import { useTableSort, type SortColumn } from "~/components/primitives/useTableS import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server"; import { requireUserId } from "~/services/session.server"; -import { EnvironmentParamSchema, v3RunsPath } from "~/utils/pathBuilder"; +import { docsPath, EnvironmentParamSchema, v3RunsPath } from "~/utils/pathBuilder"; import { formatNumberCompact } from "~/utils/numberFormatter"; import { cn } from "~/utils/cn"; import { redirectWithErrorMessage } from "~/models/message.server"; @@ -63,6 +63,11 @@ import { } from "~/components/queues/QueueControls"; import { LinkButton } from "~/components/primitives/Buttons"; import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { InfoPanel } from "~/components/primitives/InfoPanel"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { TextLink } from "~/components/primitives/TextLink"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; +import { BookOpenIcon } from "@heroicons/react/20/solid"; export const meta: MetaFunction = () => [{ title: `Queue metrics | Trigger.dev` }]; @@ -223,8 +228,12 @@ export default function Page() { const hasHistory = gateRow ? toNumber(gateRow.peak_keys) > 0 || toNumber(gateRow.peak_wait) > 0 : false; - const showKeysTab = ckBreakdown.keys.length > 0 || (!gateLoading && hasHistory); - const view = value("view") === "keys" && showKeysTab ? "keys" : "overview"; + // Whether this queue has any concurrency-key activity to show (live keys in the ckIndex, or + // nonzero CK history in the range). Both tabs always render; when a queue has no keys the + // Concurrency keys tab shows an empty state instead of blank charts/table. + const hasKeys = ckBreakdown.keys.length > 0 || (!gateLoading && hasHistory); + const view = value("view") === "keys" ? "keys" : "overview"; + const selectedKey = value("key"); return ( @@ -237,7 +246,7 @@ export default function Page() { the keys table. The bar is pinned by the layout while the page scrolls. */}
- {showKeysTab ? ( + {view === "keys" && hasKeys ? ( ) : null} + {/* Tabs + charts share the padded (inset) column. Both tabs always render; the keys tab + shows an empty state when the queue has no concurrency keys. */} - {showKeysTab ? ( - - replace({ view: undefined, key: undefined })} - > - Overview - - replace({ view: "keys" })} - > - Concurrency keys - - - ) : null} + + replace({ view: undefined, key: undefined })} + > + Overview + + replace({ view: "keys" })} + > + Concurrency keys + + {view === "keys" ? ( - + hasKeys ? ( + + ) : ( + + ) ) : ( )} + + {/* The per-key table is full-bleed (no inset), matching the Queues list table, so it spans + edge to edge. The drill-down that opens under it is charts, so it stays in the padded + column. */} + {view === "keys" && hasKeys ? ( + <> + + + + {selectedKey ? ( + + + + ) : null} + + ) : null} ); @@ -398,7 +438,37 @@ type CkBreakdown = { }>; }; -function ConcurrencyKeysView({ +// Standard empty state for a queue that has no concurrency keys (no live keys and no CK history). +// Small, centered panel — same pattern as the runs page's "Create your first task" state. +function ConcurrencyKeysBlankState() { + return ( + + + Concurrency docs + + } + > + + This queue doesn't use concurrency keys. Add a concurrencyKey to your task to shard the + queue per tenant/user. See the{" "} + concurrency docs. + + + + ); +} + +function ConcurrencyKeyCharts({ breakdown, loadedAt, ids, @@ -430,78 +500,69 @@ function ConcurrencyKeysView({ }, [breakdown, loadedAt]); return ( -
- {/* Per-key breakdown: which keys hold the backlog / do the work. */} - -
- - - {/* Whole-queue health across keys (single series, tasks-blue). */} - - replace({ key: worstKeyNow.key })} - className="cursor-pointer text-xs font-normal tabular-nums text-text-dimmed transition-colors hover:text-text-bright" - > - {worstKeyNow.key} · {formatWaitMs(worstKeyNow.waitMs)} now - - ) : null - } - className="aspect-[2/1]" - query={`SELECT timeBucket() AS t, max(max_ck_wait_ms) AS wait\nFROM queue_metrics\nGROUP BY t\nORDER BY t`} - fillGaps - ids={ids} - timeRange={timeRange} - queueName={queueName} - valueFormat={formatWaitMs} - series={[{ key: "wait", label: "Max wait", color: COLORS.running }]} - /> -
-
- -
+ /* Per-key breakdown: which keys hold the backlog / do the work. */ + +
+ + + {/* Whole-queue health across keys (single series, tasks-blue). */} + + replace({ key: worstKeyNow.key })} + className="cursor-pointer text-xs font-normal tabular-nums text-text-dimmed transition-colors hover:text-text-bright" + > + {worstKeyNow.key} · {formatWaitMs(worstKeyNow.waitMs)} now + + ) : null + } + className="aspect-[2/1]" + query={`SELECT timeBucket() AS t, max(max_ck_wait_ms) AS wait\nFROM queue_metrics\nGROUP BY t\nORDER BY t`} + fillGaps + ids={ids} + timeRange={timeRange} + queueName={queueName} + valueFormat={formatWaitMs} + series={[{ key: "wait", label: "Max wait", color: COLORS.running }]} + /> +
+
); } @@ -719,69 +780,63 @@ function KeyStatsTable({ if (merged.length === 0) return null; return ( - <> -
- - - - Key - - Queued now - - - Running now - - - Oldest wait - - - Started - - - Peak backlog - - - Mean delay - - - - - {sortedRows.length === 0 ? ( - - No keys match “{query}” - - ) : null} - {sortedRows.map((row) => ( - (selectedKey === row.key ? del("key") : replace({ key: row.key }))} - > - {row.key} - {row.queued.toLocaleString()} - {row.running.toLocaleString()} - - {row.oldestWaitMs === null ? "–" : formatWaitMs(row.oldestWaitMs)} - - - {row.range ? row.range.started.toLocaleString() : showLoading ? "…" : "–"} - - - {row.range ? row.range.peakBacklog.toLocaleString() : showLoading ? "…" : "–"} - - - {row.range && row.range.meanWaitMs > 0 ? formatWaitMs(row.range.meanWaitMs) : "–"} - - - ))} - -
-
- {selectedKey && ( - - )} - + // Full-bleed, edge-to-edge like the Queues list table: a top border, no rounded side box. + + + + Key + + Queued now + + + Running now + + + Oldest wait + + + Started + + + Peak backlog + + + Mean delay + + + + + {sortedRows.length === 0 ? ( + + No keys match “{query}” + + ) : null} + {sortedRows.map((row) => ( + (selectedKey === row.key ? del("key") : replace({ key: row.key }))} + > + {row.key} + {row.queued.toLocaleString()} + {row.running.toLocaleString()} + + {row.oldestWaitMs === null ? "–" : formatWaitMs(row.oldestWaitMs)} + + + {row.range ? row.range.started.toLocaleString() : showLoading ? "…" : "–"} + + + {row.range ? row.range.peakBacklog.toLocaleString() : showLoading ? "…" : "–"} + + + {row.range && row.range.meanWaitMs > 0 ? formatWaitMs(row.range.meanWaitMs) : "–"} + + + ))} + +
); } From c8cbcd091c8d3b6a6161e27496999739bf95b916 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 20 Jul 2026 11:51:52 +0000 Subject: [PATCH 28/37] fix(webapp): valid max-w-full class on the empty-state panel --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index b2e437d30a2..1eacd4d1667 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -446,7 +446,7 @@ function ConcurrencyKeysBlankState() { Date: Mon, 20 Jul 2026 11:58:41 +0000 Subject: [PATCH 29/37] fix(webapp): blue docs button and inline-code concurrencyKey in the keys empty state --- .../route.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 1eacd4d1667..3ce3b701f45 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -65,7 +65,7 @@ import { LinkButton } from "~/components/primitives/Buttons"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import { InfoPanel } from "~/components/primitives/InfoPanel"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { TextLink } from "~/components/primitives/TextLink"; +import { InlineCode } from "~/components/code/InlineCode"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; import { BookOpenIcon } from "@heroicons/react/20/solid"; @@ -451,7 +451,7 @@ function ConcurrencyKeysBlankState() { accessory={ Concurrency docs @@ -459,9 +459,8 @@ function ConcurrencyKeysBlankState() { } > - This queue doesn't use concurrency keys. Add a concurrencyKey to your task to shard the - queue per tenant/user. See the{" "} - concurrency docs. + This queue doesn't use concurrency keys. Add concurrencyKey to + your task to shard the queue per tenant/user. From 73d1196bec382d01433b272bdb8d5820b06daddc Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 21 Jul 2026 14:21:55 +0000 Subject: [PATCH 30/37] fix(webapp): purple in queue chart tooltips, separate limit from utilization % Charts are the queues-purple now, so the tooltips no longer say 'blue'. In the Concurrency block, a separator keeps the limit and the % from reading as one number (e.g. '/ 25' + '44%'). Also drop a stale server-changes note. --- .server-changes/queue-detail-pause-override.md | 6 ------ .../route.tsx | 17 ++++++++++------- 2 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 .server-changes/queue-detail-pause-override.md diff --git a/.server-changes/queue-detail-pause-override.md b/.server-changes/queue-detail-pause-override.md deleted file mode 100644 index 4a9b9b6ee18..00000000000 --- a/.server-changes/queue-detail-pause-override.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -You can now pause or resume a queue and override its concurrency limit directly from the queue's detail page, and jump straight to its queued runs. diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index 3ce3b701f45..f1464383c28 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -355,7 +355,7 @@ function OverviewCharts({
- {/* Whole-queue health across keys (single series, tasks-blue). */} + {/* Whole-queue health across keys (single series, queues-purple). */} {limit !== null && limit > 0 && ( + {/* Separator so the limit and the percentage don't read as one number + (e.g. "/ 25" + "44%" mashing into "2544%"). */} + · {pct}% of limit )} From 96914d143357056f14568c534c57de0728d2644c Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 21 Jul 2026 14:53:26 +0000 Subject: [PATCH 31/37] fix(webapp): back-fill the concurrency limit line so it doesn't start at a false 0 --- .../components/queues/QueueMetricCards.tsx | 25 +++++++++++++++++-- .../route.tsx | 3 +++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/queues/QueueMetricCards.tsx b/apps/webapp/app/components/queues/QueueMetricCards.tsx index 9dc38390131..a01bfa65c37 100644 --- a/apps/webapp/app/components/queues/QueueMetricCards.tsx +++ b/apps/webapp/app/components/queues/QueueMetricCards.tsx @@ -95,6 +95,13 @@ type QueueMetricChartProps = { defaultPeriod?: string; /** Recolor a series warning where it drops below another (e.g. started below enqueued). */ warningOverlay?: { series: string; below: string }; + /** + * Series whose leading zeros should be back-filled with the first real value. Gauge series that + * are only emitted while the queue is active (e.g. the concurrency `limit`) read as 0 before the + * first emission — carry-forward has nothing to carry yet — which draws a false 0→N step. These + * are config values that existed all along, so carry the first value backward instead. + */ + carryBackfill?: string[]; }; // Bare chart (no card chrome) so it can live inside a shared card, e.g. a tabbed panel. @@ -108,6 +115,7 @@ export function QueueMetricChart({ fillGaps, defaultPeriod, warningOverlay, + carryBackfill, }: QueueMetricChartProps) { const { rows, showLoading, failed } = useQueueMetric(query, { ids, @@ -118,7 +126,7 @@ export function QueueMetricChart({ }); const data = useMemo(() => { - return rows + const points = rows .map((r) => { const point: { bucket: number } & Record = { bucket: clickhouseTimeToMs(r.t), @@ -127,7 +135,20 @@ export function QueueMetricChart({ return point; }) .filter((p) => Number.isFinite(p.bucket)); - }, [rows, series]); + + // Back-fill leading zeros for config gauges (see `carryBackfill`): find the first positive + // value and carry it back over the earlier buckets so the line doesn't start at a false 0. + if (carryBackfill?.length) { + for (const key of carryBackfill) { + const first = points.findIndex((p) => p[key] > 0); + if (first > 0) { + const value = points[first]![key]!; + for (let i = 0; i < first; i++) points[i]![key] = value; + } + } + } + return points; + }, [rows, series, carryBackfill]); const chartConfig = useMemo(() => { const cfg: ChartConfig = {}; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index f1464383c28..e9f392b9612 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -367,6 +367,9 @@ function OverviewCharts({ { key: "limit", label: "Limit", color: COLORS.limit }, { key: "running", label: "Running", color: COLORS.running }, ]} + // The limit is a config value emitted only while the queue is active; back-fill its + // leading zeros so the reference line doesn't start with a false 0→limit step. + carryBackfill={["limit"]} /> Date: Tue, 21 Jul 2026 15:12:52 +0000 Subject: [PATCH 32/37] fix(webapp): ignore stale ClickHouse gauges in the live queue blocks Gauges are only emitted while a queue is active, so a drained queue's newest bucket holds its last non-zero reading. Trust the CH gauge only when its newest bucket is recent; past that, fall back to the loader's Redis/PG value instead of lingering on a stale count. --- .../route.tsx | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index e9f392b9612..144bc71b949 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -906,6 +906,10 @@ function KeyDrilldown({ // signals the queue is stuck, not just busy. const OLDEST_WAIT_WARNING_MS = 5 * 60_000; +// How recent the newest ClickHouse gauge bucket must be to drive the live blocks. Above the 10s +// bucket + pipeline lag; past it we treat the queue as idle and fall back to the loader value. +const LIVE_GAUGE_FRESH_MS = 90_000; + function QueueStats({ queue, environmentConcurrencyLimit, @@ -951,11 +955,19 @@ function QueueStats({ refreshIntervalMs: 15_000, } ); + // Gauges are only emitted while the queue is active, so a drained queue's newest bucket is a past + // one holding its last non-zero reading. Trust the CH gauge only when its newest bucket is recent + // (covers the 10s bucket + pipeline lag); once it ages out we fall back to the loader's live + // Redis/PG value instead of lingering on a stale count. const latest = liveRows.length > 0 ? liveRows[liveRows.length - 1] : undefined; - const runningLive = latest ? toNumber(latest.running) : null; - const queuedLive = latest ? toNumber(latest.queued) : null; - const limitLive = latest ? toNumber(latest.q_limit) : null; - const ckWaitLive = latest ? toNumber(latest.ck_wait) : null; + const latestBucketMs = latest ? clickhouseTimeToMs(latest.t) : NaN; + const liveFresh = + Number.isFinite(latestBucketMs) && Date.now() - latestBucketMs < LIVE_GAUGE_FRESH_MS; + const fresh = latest && liveFresh ? latest : undefined; + const runningLive = fresh ? toNumber(fresh.running) : null; + const queuedLive = fresh ? toNumber(fresh.queued) : null; + const limitLive = fresh ? toNumber(fresh.q_limit) : null; + const ckWaitLive = fresh ? toNumber(fresh.ck_wait) : null; // Prefer CH once it has landed; loader values before that. const runningDisplay = runningLive ?? queue.running; From 29d1531d2ca3f48831e7de956b1c9f9c93464507 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Tue, 21 Jul 2026 16:37:19 +0000 Subject: [PATCH 33/37] fix(webapp): stop nesting tooltip buttons inside clickable row links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-row navigation wrapped each cell's content in a , but the queue-type icon, override/at-limit tooltips, and the 'Override' limited-by trigger are real + {trailingContent} +
+ ) : ( + + ) + ) : leadingContent || trailingContent ? ( +
+ {leadingContent} {children} - + {trailingContent} +
) : ( <>{children} )}