diff --git a/apps/webapp/app/components/queues/QueueMetricCards.tsx b/apps/webapp/app/components/queues/QueueMetricCards.tsx
index 98525d23e..9f26894a0 100644
--- a/apps/webapp/app/components/queues/QueueMetricCards.tsx
+++ b/apps/webapp/app/components/queues/QueueMetricCards.tsx
@@ -104,6 +104,20 @@ type QueueMetricChartProps = {
* are config values that existed all along, so carry the first value backward instead.
*/
carryBackfill?: string[];
+ /** Show the series legend below the chart (use for multi-series charts). */
+ showLegend?: boolean;
+ /**
+ * Recolour a series' stroke above a threshold with a gradient split (colour only above the
+ * line). `value` sets a constant threshold; `valueFromSeries` reads a (roughly constant)
+ * threshold off another series — e.g. the concurrency limit. `series` targets which line is
+ * recoloured; the others keep their own colour.
+ */
+ thresholdStroke?: {
+ aboveColor: string;
+ series?: string;
+ value?: number;
+ valueFromSeries?: string;
+ };
};
// Bare chart (no card chrome) so it can live inside a shared card, e.g. a tabbed panel.
@@ -118,6 +132,8 @@ export function QueueMetricChart({
defaultPeriod,
warningOverlay,
carryBackfill,
+ showLegend,
+ thresholdStroke,
}: QueueMetricChartProps) {
const { rows, showLoading, failed } = useQueueMetric(query, {
ids,
@@ -163,6 +179,26 @@ export function QueueMetricChart({
[data]
);
+ // Resolve the threshold value: a constant, or the max of another series (e.g. the limit line,
+ // which is effectively constant). A gradient split then colours the target series only above it.
+ // `valueFromSeries` targets integer-count series (concurrency limit), so split half a unit below
+ // the limit — that way the line renders warning *at or above* the limit (saturated), matching
+ // "turns yellow at the limit", rather than only when it strictly exceeds it.
+ const resolvedThresholdStroke = useMemo(() => {
+ if (!thresholdStroke) return undefined;
+ let value = thresholdStroke.value;
+ if (value == null && thresholdStroke.valueFromSeries) {
+ let max = -Infinity;
+ for (const p of data) {
+ const v = Number(p[thresholdStroke.valueFromSeries]);
+ if (Number.isFinite(v) && v > max) max = v;
+ }
+ value = max > 0 ? max - 0.5 : undefined;
+ }
+ if (value == null || !Number.isFinite(value)) return undefined;
+ return { value, aboveColor: thresholdStroke.aboveColor, series: thresholdStroke.series };
+ }, [thresholdStroke, data]);
+
const state: ChartState = showLoading ? "loading" : failed ? "invalid" : undefined;
return (
@@ -173,6 +209,7 @@ export function QueueMetricChart({
series={series.map((s) => s.key)}
state={state}
fillContainer
+ showLegend={showLegend}
>
);
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 9be77036b..d390a4721 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
@@ -348,6 +348,17 @@ export default function Page() {
);
}
+// Inline colour swatch for tooltip copy — matches the chart legend swatch (rounded-[2px]) and is
+// nudged up 1px so it sits on the text baseline.
+function ColorSwatch({ color }: { color: string }) {
+ return (
+
+ );
+}
+
function OverviewCharts({
ids,
timeRange,
@@ -363,7 +374,14 @@ function OverviewCharts({
+ How many runs are going at once ( color) versus
+ the queue's limit ( color). Turns{" "}
+ color when it reaches the limit.
+ >
+ }
+ showLegend
className="aspect-[2/1]"
query={`SELECT timeBucket() AS t, max(max_running) AS running, max(max_limit) AS limit\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
fillGaps
@@ -375,15 +393,26 @@ function OverviewCharts({
{ key: "limit", label: "Limit", color: COLORS.limit },
{ key: "running", label: "Running", color: COLORS.running },
]}
- // Recolour Running warning where it reaches the limit (saturated), matching the tooltip.
- warningOverlay={{ series: "running", atOrAbove: "limit" }}
+ // Recolour Running above the limit line with a gradient split, so it's orange only where
+ // it's actually over the limit — not on the way up. The threshold reads off the (roughly
+ // constant) limit series.
+ thresholdStroke={{
+ series: "running",
+ valueFromSeries: "limit",
+ aboveColor: "var(--color-warning)",
+ }}
// The limit is a config value emitted only while the queue is active; back-fill its
// leading zeros so the reference line doesn't start with a false 0→limit step.
carryBackfill={["limit"]}
/>
+ How many runs are waiting in this queue over time (
+ color).
+ >
+ }
className="aspect-[2/1]"
query={`SELECT timeBucket() AS t, max(max_queued) AS queued\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
fillGaps
@@ -394,7 +423,14 @@ function OverviewCharts({
/>
+ Runs arriving (Enqueued, color) versus starting
+ (Started, color). Turns{" "}
+ color when Started falls behind.
+ >
+ }
+ showLegend
className="aspect-[2/1]"
query={`SELECT timeBucket() AS t,\n deltaSumTimestampMerge(enqueue_delta) AS enqueued,\n deltaSumTimestampMerge(started_delta) AS started\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
fillGaps
@@ -411,7 +447,14 @@ function OverviewCharts({
/>
+ How long runs wait before they start: p50 ( color),
+ p95 ( color), and p99 (
+ color).
+ >
+ }
+ showLegend
className="aspect-[2/1]"
query={`SELECT timeBucket() AS t,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[1]) AS p50,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[3]) AS p95,\n round(quantilesMerge(0.5, 0.9, 0.95, 0.99)(wait_quantiles)[4]) AS p99\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
fillGaps
@@ -427,7 +470,12 @@ function OverviewCharts({
/>
+ How often runs were held back by a limit ({" "}
+ color).
+ >
+ }
className="aspect-[2/1] sm:col-span-2 sm:aspect-[4/1]"
query={`SELECT timeBucket() AS t, sum(throttled_count) AS throttled\nFROM queue_metrics\nGROUP BY t\nORDER BY t`}
fillGaps
@@ -844,7 +892,12 @@ function KeyDrilldown({
+ This key: waiting (Queued, color) vs running (
+ color).
+ >
+ }
className="aspect-[2/1]"
query={`SELECT timeBucket() AS t, max(max_queued) AS queued, max(max_running) AS running\nFROM queue_metrics_by_key\nWHERE ${pin}\nGROUP BY t\nORDER BY t`}
fillGaps