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/.server-changes/queue-override-reject-above-limit.md b/.server-changes/queue-override-reject-above-limit.md new file mode 100644 index 00000000000..63d38690ad9 --- /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 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 new file mode 100644 index 00000000000..97785debe6e --- /dev/null +++ b/.server-changes/queue-pages-live-and-clarity.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +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 new file mode 100644 index 00000000000..6ae40778aff --- /dev/null +++ b/apps/webapp/app/components/layout/MetricsLayout.tsx @@ -0,0 +1,356 @@ +/** + * MetricsLayout — a compound layout for metric / dashboard pages. + * + * 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`. + * + * 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. + * + * 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. + * + * Purely presentational. Lives inside a `PageContainer` after the `NavBar`. + * + * @example + * ```tsx + * + * + *
…search + TimeFilter…
+ * + *
+ * …stat tiles… + * …chart tiles… + * …table… + *
+ * ``` + */ +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; + +/** + * 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 }; + } +} + +/** + * Who owns the vertical scroll. + * - `"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"; + +/** A length the resizable panels accept: pixels or percent (the panel library's `Unit`). */ +type PanelLength = `${number}px` | `${number}%`; + +type MetricsLayoutSidebarProps = { + children: ReactNode; + /** + * 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; + /** + * 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"`. */ + min?: PanelLength; + /** Resizable only: initial width of the sidebar panel. Defaults to `"380px"`. */ + defaultSize?: PanelLength; + /** Resizable only: max width of the sidebar panel. */ + max?: PanelLength; + /** Resizable only: min width of the main panel. Defaults to `"300px"`. */ + 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`). */ + 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. 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; +} + +function isSidebarElement(child: ReactNode): child is ReactElement { + return isValidElement(child) && child.type === MetricsLayoutSidebar; +} + +function isFiltersElement(child: ReactNode): child is ReactElement { + return isValidElement(child) && child.type === MetricsLayoutFilters; +} + +// 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); + const rest = filters ? arr.filter((child) => !isFiltersElement(child)) : children; + + return ( +
+ {filters} +
+ {rest} +
+
+ ); +} + +function MetricsLayoutRoot({ + children, + scroll = "page", +}: { + children: ReactNode; + /** 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 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}; + + if (!sidebar) { + return ( + + {/* The whole page scrolls as one: filters (pinned) aside, the tiles and content share a + single vertical scroll context. */} + {main} + + ); + } + + const { + children: sidebarChildren, + 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 ( + +
+
{main}
+
+ {sidebarChildren} +
+
+
+ ); +} + +/** + * 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, + kind = "tiles", +}: { + children: ReactNode; + /** Explicit responsive columns. Omit to derive the layout from the number of tiles. */ + columns?: GridColumns; + /** `"tiles"` (default) sizes to content; `"charts"` bakes the fixed chart-row height. */ + kind?: MetricsGridKind; +}) { + const resolved = columns ?? columnsForCount(Children.toArray(children).length); + return ( +
+ {children} +
+ ); +} + +/** + * 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, + inset = false, +}: { + children: ReactNode; + /** Pad the content into a column (page gutter) instead of letting it span edge to edge. */ + inset?: boolean; +}) { + return
{children}
; +} + +export const MetricsLayout = { + Root: MetricsLayoutRoot, + Filters: MetricsLayoutFilters, + Grid: MetricsLayoutGrid, + Content: MetricsLayoutContent, + Sidebar: MetricsLayoutSidebar, +}; + +export { + MetricsLayoutRoot, + MetricsLayoutFilters, + MetricsLayoutGrid, + MetricsLayoutContent, + MetricsLayoutSidebar, +}; 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..93372f7f8e4 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} + {sortable ? ( + // Order is always title → info icon → sort arrows. The info trigger is itself a + {tooltip ? ( + <> + {tooltipNode} + + + ) : null} +
) : tooltip ? ( -
- {children} - +
+ {label} + {tooltipNode}
) : ( - children + label )} ); @@ -259,6 +329,14 @@ type TableCellProps = TableCellBasicProps & { isSelected?: boolean; isTabbableCell?: boolean; children?: ReactNode; + /** + * Content rendered beside the cell's link/button but OUTSIDE it, so interactive adornments + * (tooltip triggers, badges that are themselves buttons) don't nest inside the ``/` + {trailingContent} +
+ ) : ( + + ) + ) : leadingContent || trailingContent ? ( +
+ {leadingContent} {children} - + {trailingContent} +
) : ( <>{children} )} 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..6adcf0484a6 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,42 @@ 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)} + /> + ); +} + +// 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 // ============================================================================ @@ -51,40 +99,113 @@ 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 } + | { series: string; atOrAbove: 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 +223,8 @@ export function ChartLineRenderer({ tooltipValueFormatter, showDots = true, referenceLines, + thresholdStroke, + warningOverlay, width, height, }: ChartLineRendererProps) { @@ -117,6 +240,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 +276,63 @@ 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; + } + if ("atOrAbove" in warningOverlay!) { + // "At the limit": the series reaches or exceeds its companion (e.g. running >= limit). + const b = Number(r[warningOverlay.atOrAbove]); + return Number.isFinite(b) && b > 0 && 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,33 +344,149 @@ 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) { + // 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 ( { - if (e?.activePayload?.length) { - setActivePayload(e.activePayload, e.activeTooltipIndex); - highlight.setTooltipActive(true); - } else { - highlight.setTooltipActive(false); - } - }} - onMouseLeave={handleMouseLeave} + margin={chartMargin} + {...sharedMouseHandlers} > @@ -195,27 +494,14 @@ export function ChartLineRenderer({ {/* When legend is shown below, render tooltip with cursor only (no content popup) */} null - ) : ( - - ) - } + content={stackedTooltipContent} 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..d85b703daa6 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,15 @@ 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 } | { series: string; atOrAbove: 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. @@ -101,6 +114,8 @@ export function QueueMetricChart({ valueFormat, fillGaps, defaultPeriod, + warningOverlay, + carryBackfill, }: QueueMetricChartProps) { const { rows, showLoading, failed } = useQueueMetric(query, { ids, @@ -111,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), @@ -120,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 = {}; @@ -150,6 +178,7 @@ export function QueueMetricChart({ yAxisProps={valueFormat ? { tickFormatter: (v: number) => valueFormat(v) } : undefined} tooltipLabelFormatter={tooltipLabelFormatter} tooltipValueFormatter={valueFormat} + warningOverlay={warningOverlay} />
); @@ -157,12 +186,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..5e4dbcd90e4 --- /dev/null +++ b/apps/webapp/app/models/queueMutation.server.ts @@ -0,0 +1,158 @@ +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 { + isValidQueueOverridePercent, + MAX_QUEUE_OVERRIDE_PERCENT, + MIN_QUEUE_OVERRIDE_PERCENT, +} from "~/v3/services/concurrencySystem.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 (!isValidQueueOverridePercent(percentNumber)) { + return redirectWithErrorMessage( + redirectPath, + request, + `Percentage must be greater than ${MIN_QUEUE_OVERRIDE_PERCENT} and less than or equal to ${MAX_QUEUE_OVERRIDE_PERCENT}` + ); + } + 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/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, + }, }; } } 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..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 @@ -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,12 @@ import { } from "~/components/primitives/Table"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; 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"; import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; import { @@ -624,64 +630,31 @@ 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 = - "-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)); 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 }) => ( + + ))} + ); } @@ -699,7 +672,7 @@ function TaskActivityBlankState() { strokeWidth={1} /> - 0 + 0 ); } 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..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 @@ -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)} - - + + {/* 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 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) => } + + + )} + + + + }> + }> + {(result) => ( + )} -
-
- - - {/* Activity / LLM cost / Token charts */} - -
- -
- - {tab === "sessions" ? ( - }> - } - > - {(result) => } - - - ) : ( - }> - } - > - {(result) => } - - - )} - - - - }> - } - > - {(result) => ( - - )} - - - - - - }> - } - > - {(result) => ( - - )} - - - -
-
-
-
- - - - {/* Table */} - - - -
-
-
- - - - - -
-
- + + + + + + }> + }> + {(result) => ( + + )} + + + + +
+ + {/* Tabs alone on their row (Queue detail pattern), then the table below them. */} + + + setTab("sessions")} + > + Sessions + + setTab("runs")} + > + Runs + + + + + + + + + + ); } @@ -368,52 +356,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 ? ( + + ) : ( + + ) + } + + ); } 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..669dd49f273 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,17 +1,16 @@ import { - AdjustmentsHorizontalIcon, ArrowUpCircleIcon, BookOpenIcon, + ExclamationTriangleIcon, PauseIcon, PlayIcon, 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 { 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"; @@ -21,13 +20,13 @@ 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, 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"; 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"; @@ -43,6 +42,7 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { useTableSort, type SortColumn } from "~/components/primitives/useTableSort"; import { InfoIconTooltip, SimpleTooltip, @@ -51,32 +51,31 @@ import { TooltipProvider, TooltipTrigger, } from "~/components/primitives/Tooltip"; +import { TasksIcon } from "~/assets/icons/TasksIcon"; 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 - } + + {/* Filters — pinned bar directly under the NavBar. Left cluster = search + period; right + cluster = pagination. */} + {success ? ( + +
+ + - ))} -
- - {success ? ( - - replace({ view: undefined })} - > - Queues - - replace({ view: "allocation", page: undefined })} - > - Allocation - - - ) : ( -
- )} +
+ +
+ ) : null} - {success && view === "allocation" ? ( - allocation ? ( - - ) : ( -
- -
- ) - ) : success ? ( -
-
-
- - - + paused : undefined} + animate + accessory={ +
+ {environment.runsEnabled && + env.pauseSource !== ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT ? ( + + ) : null} +
- + + Including {envRunningLive - environment.concurrencyLimit} burst runs{" "} + + + ) : limitStatus === "limit" ? ( + "At concurrency limit" + ) : undefined + } + accessory={ + -
- - - - 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. - -
- - } - > - Limited by -
- Health - - Delay p95 - - Backlog - - Pause/resume - -
-
- - {queues.length > 0 ? ( - queues.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 queueMetric = metricsByQueue[queueFilterableName]; - return ( - - - - - - - {queue.concurrency?.overriddenAt ? ( - - Concurrency limit overridden - - } - content="This queue's concurrency limit has been manually overridden from the dashboard or API." - className="max-w-xs" - disableHoverableContent - /> - ) : null} - {queue.paused ? ( - - Paused - - ) : null} - {isAtQueueLimit ? ( - - At queue limit - - ) : null} - {isAtConcurrencyLimit ? ( - - At concurrency limit - - ) : null} - - - - {queue.queued} - - 0 && "text-text-bright", - isAtConcurrencyLimit && "text-warning" - )} - > - {queue.running} - - - {limit} - - - {queue.concurrency?.overriddenAt ? ( - Override - ) : queue.concurrencyLimit ? ( - "User" - ) : ( - "Environment" - )} - - - - - - {queueMetric && queueMetric.p95WaitMs !== null ? ( - = 60_000 - ? "text-warning" - : "text-text-bright" - )} - > - {formatWaitMs(queueMetric.p95WaitMs)} - - ) : ( - - )} - - - v.toLocaleString()} - /> - - - } - hiddenButtons={ - !queue.paused && - } - popoverContent={ - <> - {queue.paused ? ( - - ) : ( - - )} + } + compactThreshold={1000000} + /> + + Allocated + {allocation && overAllocated ? ( + + ) : null} + + } + 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" + /> + 1 ? `bursts up to ${burstLimit}` : undefined} + suffixClassName="text-text-dimmed" + accessory={ + plan ? ( + plan?.v3Subscription?.plan?.limits.concurrentRuns.canExceed ? ( + + ) : ( + + ) + ) : undefined + } + /> + + ) : null} - - - + + {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. +

+ + } + > + 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]; + const queueDetailPath = v3QueuePath(organization, project, env, { + friendlyId: queue.id, + }); + return ( + + s, so + // they render beside the link (leading/trailing), never inside it — + // otherwise the cell is invalid
-
- ) : ( -
- {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 + )} +
+ )} +
); } @@ -921,16 +1053,19 @@ function EnvironmentPauseResumeButton({ -
+
+ aria-label={ + env.paused + ? `Resume processing runs in ${environmentFullTitle(env)}` + : `Pause processing runs in ${environmentFullTitle(env)}` + } + />
@@ -987,225 +1122,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 +1169,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 +1211,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 +1298,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 +1548,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 +1574,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..a2792b4f4d6 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,15 @@ 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 { MainCenteredContainer, 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"; +import { Spinner } from "~/components/primitives/Spinner"; import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis"; import { Chart, @@ -12,11 +17,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 +35,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { QueueRetrievePresenter } from "~/presenters/v3/QueueRetrievePresenter.server"; import { Table, + TableBlankRow, TableBody, TableCell, TableHeader, @@ -36,13 +43,31 @@ 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 { docsPath, 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"; +import { InfoPanel } from "~/components/primitives/InfoPanel"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { InlineCode } from "~/components/code/InlineCode"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; +import { BookOpenIcon } from "@heroicons/react/20/solid"; export const meta: MetaFunction = () => [{ title: `Queue metrics | Trigger.dev` }]; @@ -75,17 +100,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 +136,80 @@ 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" }); + + 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 + // 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 { + // 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; +} + 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 = { @@ -121,23 +228,27 @@ 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 ( - -
-
- + + {/* 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. */} + +
+ {view === "keys" && hasKeys ? ( + + ) : null}
+
- {showKeysTab && ( - - replace({ view: undefined, key: undefined })} - > - Overview - - replace({ view: "keys" })} - > - Concurrency keys - - - )} + {/* 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). */} + + + {/* 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. */} + + + 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} + ); } @@ -193,53 +349,87 @@ function OverviewCharts({ timeRange: TimeRangeParams; queueName: string; }) { + const zoomToTimeFilter = useZoomToTimeFilter(); return ( - <> - - - - - + +
+ + + + + +
+
); } @@ -253,7 +443,36 @@ 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 concurrencyKey to + your task to shard the queue per tenant/user. + + + + ); +} + +function ConcurrencyKeyCharts({ breakdown, loadedAt, ids, @@ -266,52 +485,88 @@ function ConcurrencyKeysView({ timeRange: TimeRangeParams; queueName: string; }) { + const zoomToTimeFilter = useZoomToTimeFilter(); + 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 + // (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, queues-purple). */} + + 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,30 +586,48 @@ 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). */ 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 ; @@ -363,6 +636,8 @@ function GroupedKeyChartCard(props: GroupedKeyChartProps) { function GroupedKeySeries({ keys, title, + info, + className, seriesExpr, fillGaps, valueFormat, @@ -406,8 +681,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 - - - - {merged.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) : "–"} + + + ))} + +
); } @@ -558,85 +856,250 @@ function KeyDrilldown({ queueName: string; }) { const pin = `concurrency_key = '${trqlString(keyName)}'`; + const zoomToTimeFilter = useZoomToTimeFilter(); return ( - <> - - - 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; + +// 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, + queuedRunsPath, + oldestWaitMs, ids, timeRange, queueName, }: { - queue: { running: number; queued: number }; + // 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; 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, + } + ); + // 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 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; + 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 && ( + + {/* Separator so the limit and the percentage don't read as one number + (e.g. "/ 25" + "44%" mashing into "2544%"). */} + · + {pct}% of limit + + )} +
+ {limit !== null && limit > 0 && ( +
+
+
+ )} +
+ )}
); } 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. + + + ); + }} + + + + ); } 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..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,11 +4,23 @@ 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({ - 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(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`", + }); export const { action } = createActionApiRoute( { @@ -26,8 +38,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 +66,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/routes/storybook.layout/route.tsx b/apps/webapp/app/routes/storybook.layout/route.tsx new file mode 100644 index 00000000000..08242fe51da --- /dev/null +++ b/apps/webapp/app/routes/storybook.layout/route.tsx @@ -0,0 +1,373 @@ +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, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import SegmentedControl from "~/components/primitives/SegmentedControl"; +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} + + ))} + +
+ ); +} + +// 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 overview demo. Every slot carries its baked chrome — no className is passed to Root, +// Filters, Grid or Content. The pinned Filters bar spreads a left and right cluster; the Grids +// bake the page gutter and adapt their columns (or take an explicit `columns` / `kind="charts"`); +// Content toggles between a full-bleed table and an inset panel. The whole page scrolls as one. +function OverviewDemo() { + const [tab, setTab] = useState<"panel" | "table">("panel"); + + return ( + + {/* 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 + Filters slot +
+ {"< 1 / 4 >"} +
+ + {/* Grid slot — 4 stat tiles. Columns are derived from the tile count: two-up, four-up + from lg. The gutter + gap are baked. */} +
+ Grid — 4 tiles (auto: 2-up, 4-up from lg) +
+ + + + + + + + {/* 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 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 — 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("panel")} + > + Panel (inset) + + setTab("table")} + > + Table (full-bleed) + + + {tab === "table" ? ( + + ) : ( +
+ + 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. + +
+ )} +
+
+ ); +} + +// 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"); + + return ( + + + + + setDemo(value as Demo)} + /> + + + {demo === "overview" ? ( + + ) : demo === "sidebar-fixed" ? ( + + ) : demo === "sidebar-resizable" ? ( + + ) : ( + + )} + + ); +} 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", diff --git a/apps/webapp/app/v3/services/allocateConcurrency.server.ts b/apps/webapp/app/v3/services/allocateConcurrency.server.ts index b9a28daba60..78c8b82915a 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,14 @@ export class AllocateConcurrencyService extends BaseService { await updateEnvConcurrencyLimits(updatedEnvironment); } + // 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. 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..f030cb72e5f 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,42 @@ 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. + */ +/** + * 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); +} + export class ConcurrencySystem { constructor(private readonly options: ConcurrencySystemOptions) {} @@ -24,18 +61,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 +77,59 @@ 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() + ); + + // 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); + } + } catch (error) { + logger.error("Failed to recalculate percent queue limit", { + queueId: queue.id, + environmentId: environment.id, + error, + }); + } + } + + return { total: queues.length, updated }; + }, }; } } @@ -117,13 +201,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 (!isValidQueueOverridePercent(percent)) { + return errAsync({ + type: "invalid_override" as const, + message: `Percent must be greater than ${MIN_QUEUE_OVERRIDE_PERCENT} and less than or equal to ${MAX_QUEUE_OVERRIDE_PERCENT}`, + }); + } + + 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 +257,7 @@ function overrideQueueConcurrencyLimit( data: { concurrencyLimit: newConcurrencyLimit, concurrencyLimitBase: concurrencyLimitBase ?? null, + concurrencyLimitOverridePercent: overridePercent, concurrencyLimitOverriddenAt: new Date(), concurrencyLimitOverriddenBy: overriddenBy?.id ?? null, }, @@ -162,6 +283,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..911ce51d9c6 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 @@ -733,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, + }, }); } diff --git a/apps/webapp/test/concurrencySystemPercentOverride.test.ts b/apps/webapp/test/concurrencySystemPercentOverride.test.ts new file mode 100644 index 00000000000..f81f1b1d10b --- /dev/null +++ b/apps/webapp/test/concurrencySystemPercentOverride.test.ts @@ -0,0 +1,370 @@ +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. +// 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), +})); + +vi.mock("~/v3/runEngine.server", () => ({ + engine: { + lengthOfQueues: async () => ({}), + currentConcurrencyOfQueues: async () => ({}), + runQueue: { + updateQueueConcurrencyLimits: updateQueueConcurrencyLimitsMock, + 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); + } + ); + + 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 + ); + } + ); +}); 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)