Skip to content

Commit a892684

Browse files
committed
feat(webapp): house style hero charts on the queues list
The queues list header tiles now render the same line chart, grid, and tooltip as the rest of the metrics charts instead of a row sparkline, with the headline value in the tile header. The env saturation tile draws the environment concurrency limit and burst limit as labeled reference lines. Chart tooltips keep a gap between the series label and the value, and the shared line chart gains showDots and referenceLines options.
1 parent cc475c7 commit a892684

3 files changed

Lines changed: 154 additions & 27 deletions

File tree

apps/webapp/app/components/primitives/charts/Chart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ const ChartTooltipContent = React.forwardRef<
216216
)}
217217
<div
218218
className={cn(
219-
"flex flex-1 justify-between leading-none",
219+
"flex flex-1 justify-between gap-3 leading-none",
220220
nestLabel ? "items-end" : "items-center"
221221
)}
222222
>

apps/webapp/app/components/primitives/charts/ChartLine.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
CartesianGrid,
55
Line,
66
LineChart,
7+
ReferenceLine,
78
XAxis,
89
YAxis,
910
type XAxisProps,
@@ -48,12 +49,38 @@ export type ChartLineRendererProps = {
4849
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
4950
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
5051
tooltipValueFormatter?: (value: number) => string;
52+
/** Draw a dot at each data point. Defaults to true; turn off for dense/compact charts. */
53+
showDots?: boolean;
54+
/** Horizontal reference lines (e.g. limits); the y-domain extends to include them. */
55+
referenceLines?: Array<{ y: number; label?: string; color?: string }>;
5156
/** Width injected by ResponsiveContainer */
5257
width?: number;
5358
/** Height injected by ResponsiveContainer */
5459
height?: number;
5560
};
5661

62+
/** Reference-line label: right-aligned just below the line (recharts injects viewBox). */
63+
function ReferenceLineLabel({
64+
viewBox,
65+
value,
66+
}: {
67+
viewBox?: { x: number; y: number; width: number };
68+
value: string;
69+
}) {
70+
if (!viewBox) return null;
71+
return (
72+
<text
73+
x={viewBox.x + viewBox.width - 4}
74+
y={viewBox.y + 12}
75+
textAnchor="end"
76+
fill="#878C99"
77+
fontSize={10}
78+
>
79+
{value}
80+
</text>
81+
);
82+
}
83+
5784
/**
5885
* Line chart renderer for the compound component system.
5986
* Must be used within a Chart.Root.
@@ -73,6 +100,8 @@ export function ChartLineRenderer({
73100
stacked = false,
74101
tooltipLabelFormatter,
75102
tooltipValueFormatter,
103+
showDots = true,
104+
referenceLines,
76105
width,
77106
height,
78107
}: ChartLineRendererProps) {
@@ -176,6 +205,17 @@ export function ChartLineRenderer({
176205
labelFormatter={tooltipLabelFormatter}
177206
/>
178207
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
208+
{referenceLines?.map((line) => (
209+
<ReferenceLine
210+
key={`ref-${line.y}-${line.label ?? ""}`}
211+
y={line.y}
212+
stroke={line.color ?? "#4D525B"}
213+
strokeDasharray="4 4"
214+
strokeWidth={1}
215+
ifOverflow="extendDomain"
216+
label={line.label ? <ReferenceLineLabel value={line.label} /> : undefined}
217+
/>
218+
))}
179219
{visibleSeries.map((key) => (
180220
<Area
181221
key={key}
@@ -222,14 +262,25 @@ export function ChartLineRenderer({
222262
labelFormatter={tooltipLabelFormatter}
223263
/>
224264
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
265+
{referenceLines?.map((line) => (
266+
<ReferenceLine
267+
key={`ref-${line.y}-${line.label ?? ""}`}
268+
y={line.y}
269+
stroke={line.color ?? "#4D525B"}
270+
strokeDasharray="4 4"
271+
strokeWidth={1}
272+
ifOverflow="extendDomain"
273+
label={line.label ? <ReferenceLineLabel value={line.label} /> : undefined}
274+
/>
275+
))}
225276
{visibleSeries.map((key) => (
226277
<Line
227278
key={key}
228279
dataKey={key}
229280
type={lineType}
230281
stroke={config[key]?.color}
231282
strokeWidth={1}
232-
dot={{ r: 1.5, fill: config[key]?.color, strokeWidth: 0 }}
283+
dot={showDots ? { r: 1.5, fill: config[key]?.color, strokeWidth: 0 } : false}
233284
activeDot={{ r: 4 }}
234285
isAnimationActive={false}
235286
/>

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx

Lines changed: 101 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Form, Link, useNavigation, type MetaFunction } from "@remix-run/react";
1111
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
1212
import type { QueueItem } from "@trigger.dev/core/v3/schemas";
1313
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
14-
import { type ReactNode, useEffect, useState } from "react";
14+
import { type ReactNode, useEffect, useMemo, useState } from "react";
1515
import { typedjson, useTypedLoaderData } from "remix-typedjson";
1616
import { z } from "zod";
1717
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
@@ -72,6 +72,8 @@ import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters
7272
import { useSearchParams } from "~/hooks/useSearchParam";
7373
import { parseFiniteInt } from "~/utils/searchParams";
7474
import { UsageSparkline } from "~/components/primitives/UsageSparkline";
75+
import { buildActivityTimeAxis } from "~/components/primitives/charts/activityTimeAxis";
76+
import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound";
7577
import {
7678
useMetricResourceQuery,
7779
type MetricResourceTimeRange,
@@ -452,7 +454,28 @@ function QueuesWithMetricsView() {
452454
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
453455
<div className="grid grid-cols-2 gap-3 p-3 lg:grid-cols-4">
454456
{QUEUE_HEADER_TILES.map((tile) => (
455-
<QueueEnvMetricTile key={tile.id} tile={tile} timeRange={timeRange} />
457+
<QueueEnvMetricTile
458+
key={tile.id}
459+
tile={tile}
460+
timeRange={timeRange}
461+
referenceLines={
462+
tile.id === "saturation"
463+
? [
464+
{ y: 100, label: `Limit ${environment.concurrencyLimit}` },
465+
...(environment.burstFactor > 1
466+
? [
467+
{
468+
y: Math.round(environment.burstFactor * 100),
469+
label: `Burst ${Math.round(
470+
environment.concurrencyLimit * environment.burstFactor
471+
)}`,
472+
},
473+
]
474+
: []),
475+
]
476+
: undefined
477+
}
478+
/>
456479
))}
457480
</div>
458481

@@ -1140,7 +1163,8 @@ type QueueHeaderTile = {
11401163
label: string;
11411164
color: string;
11421165
query: string;
1143-
unitLabel: { singular: string; plural: string };
1166+
/** Formats a single bucket's value in the chart tooltip. */
1167+
formatValue?: (value: number) => string;
11441168
derive: (rows: MetricTileRow[]) => {
11451169
sparkline: number[];
11461170
total: number;
@@ -1167,7 +1191,7 @@ const QUEUE_HEADER_TILES: QueueHeaderTile[] = [
11671191
label: "Env saturation",
11681192
color: "#6366F1",
11691193
query: `SELECT timeBucket() AS t,\n max(max_env_running) AS used,\n max(max_env_limit) AS env_limit\nFROM queue_metrics\nGROUP BY t\nORDER BY t`,
1170-
unitLabel: { singular: "%", plural: "%" },
1194+
formatValue: (v) => `${v}%`,
11711195
derive: (rows) => {
11721196
const sparkline = rows.map((r) => {
11731197
const limit = tileNumber(r.env_limit);
@@ -1182,7 +1206,6 @@ const QUEUE_HEADER_TILES: QueueHeaderTile[] = [
11821206
label: "Backlog",
11831207
color: "#A78BFA",
11841208
query: `SELECT timeBucket() AS t,\n max(max_env_queued) AS queued\nFROM queue_metrics\nGROUP BY t\nORDER BY t`,
1185-
unitLabel: { singular: "queued", plural: "queued" },
11861209
derive: (rows) => {
11871210
const sparkline = rows.map((r) => tileNumber(r.queued));
11881211
const peak = sparkline.reduce((max, v) => Math.max(max, v), 0);
@@ -1194,7 +1217,7 @@ const QUEUE_HEADER_TILES: QueueHeaderTile[] = [
11941217
label: "Scheduling delay p95",
11951218
color: "#F59E0B",
11961219
query: `SELECT timeBucket() AS t,\n round(quantilesMerge(0.5, 0.95, 0.99)(wait_quantiles)[2]) AS p95\nFROM queue_metrics\nGROUP BY t\nORDER BY t`,
1197-
unitLabel: { singular: "ms", plural: "ms" },
1220+
formatValue: formatWaitMs,
11981221
derive: (rows) => {
11991222
const sparkline = rows.map((r) => tileNumber(r.p95));
12001223
const worst = sparkline.reduce((max, v) => Math.max(max, v), 0);
@@ -1211,7 +1234,6 @@ const QUEUE_HEADER_TILES: QueueHeaderTile[] = [
12111234
label: "Throttled",
12121235
color: "#F59E0B",
12131236
query: `SELECT timeBucket() AS t,\n sum(throttled_count) AS throttled\nFROM queue_metrics\nGROUP BY t\nORDER BY t`,
1214-
unitLabel: { singular: "throttled bucket", plural: "throttled buckets" },
12151237
derive: (rows) => {
12161238
const sparkline = rows.map((r) => tileNumber(r.throttled));
12171239
const total = sparkline.reduce((sum, v) => sum + v, 0);
@@ -1229,9 +1251,11 @@ type TileTimeRange = MetricResourceTimeRange;
12291251
function QueueEnvMetricTile({
12301252
tile,
12311253
timeRange,
1254+
referenceLines,
12321255
}: {
12331256
tile: QueueHeaderTile;
12341257
timeRange: TileTimeRange;
1258+
referenceLines?: Array<{ y: number; label?: string }>;
12351259
}) {
12361260
const organization = useOrganization();
12371261
const project = useProject();
@@ -1247,37 +1271,89 @@ function QueueEnvMetricTile({
12471271
});
12481272

12491273
const { sparkline, total, formatTotal, totalClassName } = tile.derive(rows);
1250-
const bucketStartMs = rows.length > 0 ? tileTimeToMs(rows[0].t) : undefined;
1251-
const bucketIntervalMs =
1252-
rows.length >= 2 ? tileTimeToMs(rows[1].t) - tileTimeToMs(rows[0].t) : undefined;
1274+
1275+
// Same point shape the full-size charts use so the shared axis/tooltip helpers apply.
1276+
const data = rows
1277+
.map((r, i) => ({ bucket: tileTimeToMs(r.t), [tile.id]: sparkline[i] ?? 0 }))
1278+
.filter((p) => Number.isFinite(p.bucket));
1279+
1280+
const chartConfig = useMemo<ChartConfig>(
1281+
() => ({ [tile.id]: { label: tile.label, color: tile.color } }),
1282+
[tile.id, tile.label, tile.color]
1283+
);
1284+
1285+
const { tooltipLabelFormatter } = useMemo(() => buildActivityTimeAxis(data), [data]);
1286+
const hasData = data.length > 0 && sparkline.some((v) => v > 0);
12531287

12541288
return (
1255-
<HeaderTile label={tile.label}>
1289+
<HeaderTile
1290+
label={tile.label}
1291+
value={
1292+
showLoading ? (
1293+
<span className="inline-block h-3 w-12 animate-pulse rounded bg-grid-bright" />
1294+
) : failed ? undefined : formatTotal ? (
1295+
formatTotal(total)
1296+
) : (
1297+
total.toLocaleString()
1298+
)
1299+
}
1300+
valueClassName={totalClassName}
1301+
>
12561302
<LoadingBarDivider isLoading={isLoading} className="bg-transparent" />
12571303
{showLoading ? (
1258-
<div className="h-6 w-full animate-pulse rounded bg-grid-bright/60" />
1304+
<div className="h-16 w-full animate-pulse rounded bg-grid-bright/60" />
12591305
) : failed ? (
1260-
<div className="flex h-6 items-center text-xs text-text-dimmed">Unable to load metrics</div>
1306+
<div className="flex h-16 items-center text-xs text-text-dimmed">
1307+
Unable to load metrics
1308+
</div>
1309+
) : hasData ? (
1310+
<div className="h-16 w-full">
1311+
<Chart.Root
1312+
config={chartConfig}
1313+
data={data}
1314+
dataKey="bucket"
1315+
series={[tile.id]}
1316+
fillContainer
1317+
>
1318+
<Chart.Line
1319+
lineType="monotone"
1320+
showDots={false}
1321+
referenceLines={referenceLines}
1322+
xAxisProps={{ hide: true }}
1323+
yAxisProps={{ hide: true }}
1324+
tooltipLabelFormatter={tooltipLabelFormatter}
1325+
tooltipValueFormatter={tile.formatValue}
1326+
/>
1327+
</Chart.Root>
1328+
</div>
12611329
) : (
1262-
<UsageSparkline
1263-
data={sparkline}
1264-
bucketStartMs={bucketStartMs}
1265-
bucketIntervalMs={bucketIntervalMs}
1266-
color={tile.color}
1267-
unitLabel={tile.unitLabel}
1268-
total={total}
1269-
formatTotal={formatTotal}
1270-
totalClassName={totalClassName ?? "text-text-bright"}
1271-
/>
1330+
<div className="flex h-16 items-center text-xs text-text-dimmed">No activity</div>
12721331
)}
12731332
</HeaderTile>
12741333
);
12751334
}
12761335

1277-
function HeaderTile({ label, children }: { label: ReactNode; children: ReactNode }) {
1336+
function HeaderTile({
1337+
label,
1338+
value,
1339+
valueClassName,
1340+
children,
1341+
}: {
1342+
label: ReactNode;
1343+
value?: ReactNode;
1344+
valueClassName?: string;
1345+
children: ReactNode;
1346+
}) {
12781347
return (
12791348
<div className="flex flex-col gap-1.5 rounded-sm border border-grid-dimmed bg-background-bright px-3 py-2">
1280-
<span className="truncate text-xs text-text-dimmed">{label}</span>
1349+
<div className="flex items-baseline justify-between gap-2">
1350+
<span className="truncate text-xs text-text-dimmed">{label}</span>
1351+
{value !== undefined ? (
1352+
<span className={cn("shrink-0 text-sm tabular-nums text-text-bright", valueClassName)}>
1353+
{value}
1354+
</span>
1355+
) : null}
1356+
</div>
12811357
{children}
12821358
</div>
12831359
);

0 commit comments

Comments
 (0)