Skip to content
Merged
6 changes: 6 additions & 0 deletions .server-changes/queues-page-environment-wide-charts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

The four charts at the top of the Queues page now always cover the whole environment, so paging through or re-sorting your queues no longer changes them. The scheduling delay chart also leaves a gap where no runs started, instead of dropping to zero.
32 changes: 26 additions & 6 deletions apps/webapp/app/components/queues/QueueMetricCards.tsx
Comment thread
ericallam marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export function useQueueMetric(
defaultPeriod?: string;
/** Poll ClickHouse on this cadence (ms). Omit to use the query's default interval. */
refreshIntervalMs?: number;
/** Floor for the bucket width, for series too sparse to read at the range's natural width. */
minBucketSeconds?: number;
}
) {
return useMetricResourceQuery(query, {
Expand All @@ -62,6 +64,7 @@ export function useQueueMetric(
defaultPeriod: opts.defaultPeriod ?? QUEUE_METRICS_DEFAULT_PERIOD,
queues: [opts.queueName],
fillGaps: opts.fillGaps,
minBucketSeconds: opts.minBucketSeconds,
refreshIntervalMs: opts.refreshIntervalMs,
});
}
Expand Down Expand Up @@ -120,6 +123,14 @@ type QueueMetricChartProps = {
/** Reports whether the chart has data to plot (false once it settles on the "no activity" state),
* so a wrapping card can hide the legend to match. */
onHasDataChange?: (hasData: boolean) => void;
/** Floor for the bucket width, for series too sparse to read at the range's natural width. */
minBucketSeconds?: number;
/**
* Column whose value counts the samples behind the plotted series. Where it is zero the metric
* has nothing to report, so every series breaks there instead of reading as a real zero. Keep it
* out of `series` — it is read for this test only, never drawn.
*/
sampleCountColumn?: string;
};

// Bare chart (no card chrome) so it can live inside a shared card, e.g. a tabbed panel.
Expand All @@ -136,22 +147,26 @@ export function QueueMetricChart({
carryBackfill,
thresholdStroke,
onHasDataChange,
minBucketSeconds,
sampleCountColumn,
}: QueueMetricChartProps) {
const { rows, showLoading, failed } = useQueueMetric(query, {
ids,
timeRange,
queueName,
fillGaps,
defaultPeriod,
minBucketSeconds,
});

const data = useMemo(() => {
const points = rows
.map((r) => {
const point: { bucket: number } & Record<string, number> = {
const point: { bucket: number } & Record<string, number | null> = {
bucket: clickhouseTimeToMs(r.t),
};
for (const s of series) point[s.key] = toNumber(r[s.key]);
const hasSamples = sampleCountColumn ? toNumber(r[sampleCountColumn]) > 0 : true;
for (const s of series) point[s.key] = hasSamples ? toNumber(r[s.key]) : null;
Comment thread
ericallam marked this conversation as resolved.
return point;
})
.filter((p) => Number.isFinite(p.bucket));
Expand All @@ -160,15 +175,15 @@ export function QueueMetricChart({
// 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);
const first = points.findIndex((p) => toNumber(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]);
}, [rows, series, carryBackfill, sampleCountColumn]);

const chartConfig = useMemo(() => {
const cfg: ChartConfig = {};
Expand Down Expand Up @@ -205,9 +220,14 @@ export function QueueMetricChart({

// Report data presence so a wrapping card can hide its legend when the chart settles on the
// "no activity" state. Only report once loaded, so the legend stays put while loading.
const hasPlottedData = useMemo(
() => data.some((point) => series.some((s) => point[s.key] != null)),
[data, series]
);

useEffect(() => {
if (!showLoading) onHasDataChange?.(!failed && data.length > 0);
}, [showLoading, failed, data.length, onHasDataChange]);
if (!showLoading) onHasDataChange?.(!failed && hasPlottedData);
}, [showLoading, failed, hasPlottedData, onHasDataChange]);

return (
<Chart.Root
Expand Down
32 changes: 31 additions & 1 deletion apps/webapp/app/hooks/useMetricResourceQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type MetricResourceQueryOptions = {
defaultPeriod: string;
queues?: string[];
fillGaps?: boolean;
/** Floor for the query's bucket width, for series too sparse to read at the range's width. */
minBucketSeconds?: number;
refreshIntervalMs?: number;
};

Expand Down Expand Up @@ -49,13 +51,19 @@ function cacheSet(key: string, rows: MetricResourceRow[]) {
* back-navigation to the queues list) shows its last data immediately and revalidates in the
* background rather than flashing a loading skeleton.
*/
/**
* An empty query means the caller has nothing to ask for, so no request is made and any rows or
* failure left by a previous query are dropped — a caller that stops asking must not keep reading
* the last answer, or a stale failure would outlive the query that caused it.
*/
export function useMetricResourceQuery(query: string, opts: MetricResourceQueryOptions) {
const {
organizationId,
projectId,
environmentId,
defaultPeriod,
fillGaps,
minBucketSeconds,
refreshIntervalMs = 60_000,
} = opts;
const { period, from, to } = opts.timeRange;
Expand All @@ -71,10 +79,22 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
from ?? "",
to ?? "",
fillGaps ? 1 : 0,
minBucketSeconds ?? "",
queuesKey ?? "",
query,
].join("|"),
[organizationId, projectId, environmentId, resolvedPeriod, from, to, fillGaps, queuesKey, query]
[
organizationId,
projectId,
environmentId,
resolvedPeriod,
from,
to,
fillGaps,
minBucketSeconds,
queuesKey,
query,
]
);

const [rows, setRows] = useState<MetricResourceRow[] | null>(
Expand All @@ -86,6 +106,14 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
const loadedKeyRef = useRef<string | null>(null);

const load = useCallback(() => {
if (!query) {
abortRef.current?.abort();
loadedKeyRef.current = cacheKey;
setRows(null);
setFailed(false);
setIsLoading(false);
return;
}
Comment thread
ericallam marked this conversation as resolved.
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
Expand All @@ -112,6 +140,7 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
organizationId,
projectId,
environmentId,
...(minBucketSeconds !== undefined ? { minBucketSeconds } : {}),
...(queuesKey !== undefined ? { queues: queuesKey.split(",") } : {}),
}),
signal: controller.signal,
Expand Down Expand Up @@ -142,6 +171,7 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO
from,
to,
fillGaps,
minBucketSeconds,
organizationId,
projectId,
environmentId,
Expand Down
Loading
Loading