Skip to content

Commit 9ed0076

Browse files
kathiekiwiTrigger.dev RepoOps
authored andcommitted
feat(alerts): Limits area
Alerts: new Limits area. Alert on the limits that can interrupt or degrade runtime flow — API rate limit, environment concurrency, max queued runs and webhook limits — one at a time or combined into a single "Watch all limits" alert that raises when any or all of them fire. Every alert now gets a prefilled name step. Mono-RevId: 3205ce384c434af6a768fa2a81101e2d3b06e010
1 parent 3c1cf65 commit 9ed0076

84 files changed

Lines changed: 4290 additions & 377 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.server-changes/limits-alerts.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
New Limits alerts: watch the limits that can interrupt or degrade runtime flow, one at a time or combined into a single alert with "any" or "all" and the signals you choose (limit reached or approaching), in the dashboard, the API and the agent.

‎apps/webapp/app/components/alerts/AlertDetailChart.query.dom.test.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,18 @@ vi.mock("./ActivityBars", async (importOriginal) => ({
6666
}));
6767
vi.mock("./AlertThresholdChart", async (importOriginal) => ({
6868
...(await importOriginal<object>()),
69-
AlertMultiSeriesChart: ({ seriesMap }: { seriesMap: Map<string, unknown> }) =>
70-
createElement("i", { "data-chart": "multi", "data-keys": [...seriesMap.keys()].join(",") }),
69+
AlertMultiSeriesChart: ({
70+
seriesMap,
71+
label,
72+
}: {
73+
seriesMap: Map<string, unknown>;
74+
label: string;
75+
}) =>
76+
createElement("i", {
77+
"data-chart": "multi",
78+
"data-keys": [...seriesMap.keys()].join(","),
79+
"data-label": label,
80+
}),
7181
}));
7282

7383
const HOUR = 60 * 60 * 1000;
@@ -203,6 +213,7 @@ describe("AlertDetailChart query groups", () => {
203213
expect(document.querySelector<HTMLInputElement>("input")!.placeholder).not.toContain("Up to");
204214
});
205215

216+
// Mutation: labelling a sole selected group with the alert title fails the label check.
206217
it("shows the only remaining group even after it was deselected", () => {
207218
const c = mount({ queryGroups: ["g9", "g8"] });
208219
act(() => c.querySelector<HTMLElement>("button")!.click());
@@ -211,6 +222,7 @@ describe("AlertDetailChart query groups", () => {
211222
)!;
212223
act(() => g9.click());
213224
expect(keys(c)).toEqual(["g8"]);
225+
expect(c.querySelector("[data-chart=multi]")!.getAttribute("data-label")).toBe("g8");
214226

215227
render({ queryGroups: ["g9"] });
216228
expect(keys(c)).toEqual(["g9"]);

‎apps/webapp/app/components/alerts/AlertDetailChart.tsx‎

Lines changed: 91 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useState } from "react";
1+
import { useState } from "react";
22
import { useUserTimeZone } from "~/components/primitives/DateTime";
33
import { DetailChartBlock } from "~/components/primitives/DetailChartBlock";
44
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
@@ -42,6 +42,12 @@ import {
4242
assignGroupColors,
4343
thresholdChartLegend,
4444
} from "./AlertThresholdChart";
45+
import {
46+
hasTileData,
47+
isLimitComponent,
48+
isLimitsComposite,
49+
type LimitTileSeries,
50+
} from "./limitTiles";
4551
import { defaultGroupSelection, MAX_VISIBLE_GROUPS } from "./queryGroupSelection";
4652

4753
const METRIC_LABEL: Record<string, string> = {
@@ -90,33 +96,68 @@ type AlertDetailChartProps = {
9096
metricSeriesMap: Map<string, MetricSeriesPoint[]>;
9197
healthBands: HealthBandRow[];
9298
componentSeries?: HealthQuerySeries[];
99+
limitTiles?: LimitTileSeries[];
93100
window: DetailWindow & { isLifetime?: boolean; explicitTo?: boolean };
94101
now: Date;
95102
createdAt?: Date;
96103
queryGroups?: string[];
97104
querySeriesMode?: "threshold" | "absence" | "match";
98105
};
99106

100-
export function AlertDetailChart(props: AlertDetailChartProps) {
107+
function limitsAsQueryProps(
108+
props: AlertDetailChartProps,
109+
tiles: LimitTileSeries[]
110+
): AlertDetailChartProps {
111+
const { alert } = props;
112+
const single = tiles.length === 1 ? tiles[0] : undefined;
113+
return {
114+
...props,
115+
alert: {
116+
...alert,
117+
kind: "threshold",
118+
operator: ">",
119+
thresholdValue: tiles.find((t) => t.threshold !== null)?.threshold ?? undefined,
120+
unitSuffix: "%",
121+
scope: { type: "all" },
122+
currentReading: { status: "unavailable" },
123+
capabilityTitle: single?.label ?? alert.capabilityTitle,
124+
} as AlertDetail,
125+
metricSeriesMap: single
126+
? new Map([["__agg__", single.points]])
127+
: new Map(tiles.map((t) => [t.label, t.points])),
128+
queryGroups: tiles.length > 1 ? tiles.map((t) => t.label) : [],
129+
querySeriesMode: "threshold",
130+
};
131+
}
132+
133+
export function AlertDetailChart(rawProps: AlertDetailChartProps) {
101134
const [healthView, setHealthView] = useState<HealthView>("together");
102135
const [picked, setPicked] = useState<string[] | null>(null);
136+
const isLimits =
137+
rawProps.alert.kind === "state" &&
138+
(isLimitComponent(rawProps.alert.capabilityKey) ||
139+
isLimitsComposite(rawProps.alert.capabilityKey));
140+
const props = isLimits
141+
? limitsAsQueryProps(
142+
rawProps,
143+
(rawProps.limitTiles ?? []).filter((t) => !t.failed && hasTileData(t.points))
144+
)
145+
: rawProps;
103146
const { alert, window, metricSeriesMap, queryGroups = [] } = props;
104147
const showToggle = alert.kind === "state" && (props.componentSeries?.length ?? 0) > 1;
148+
const groups = queryGroups;
149+
const groupSeries = metricSeriesMap;
105150
const grouped =
106151
alert.kind === "threshold" && props.querySeriesMode !== "match" && queryGroups.length > 0;
107-
const defaults = useMemo(
108-
() =>
109-
grouped
110-
? defaultGroupSelection({
111-
groups: queryGroups,
112-
seriesMap: metricSeriesMap,
113-
operator: alert.operator,
114-
range: { since: window.since.getTime(), until: window.until.getTime() },
115-
})
116-
: [],
117-
[grouped, queryGroups, metricSeriesMap, alert.operator, window.since, window.until]
118-
);
119-
const showPicker = grouped && queryGroups.length > 1;
152+
const defaults = grouped
153+
? defaultGroupSelection({
154+
groups,
155+
seriesMap: groupSeries,
156+
operator: alert.operator,
157+
range: { since: window.since.getTime(), until: window.until.getTime() },
158+
})
159+
: [];
160+
const showPicker = grouped && groups.length > 1;
120161
const selected = showPicker ? (picked ?? defaults) : defaults;
121162
// Colours carry over from the previous selection, so a toggle doesn't repaint other groups.
122163
const [colorState, setColorState] = useState({ key: "", colors: new Map<string, string>() });
@@ -135,29 +176,42 @@ export function AlertDetailChart(props: AlertDetailChartProps) {
135176
: [...selected, group]
136177
);
137178

179+
const loadedTiles = rawProps.limitTiles ?? [];
180+
const limitsUnloaded =
181+
isLimits &&
182+
metricSeriesMap.size === 0 &&
183+
(loadedTiles.length === 0 || loadedTiles.some((t) => t.failed));
184+
138185
return (
139186
<DetailChartBlock
140187
toolbar={<AlertDetailFilterBar lifetime={window.isLifetime ? window : undefined} />}
141188
>
142189
<div className="min-h-[210px] flex-1">
143-
<AlertDetailChartBody
144-
{...props}
145-
healthView={healthView}
146-
selectedGroups={grouped ? selected : undefined}
147-
groupColors={groupColors}
148-
accessory={
149-
showToggle ? (
150-
<HealthViewToggle value={healthView} onChange={setHealthView} />
151-
) : showPicker ? (
152-
<GroupsDropdown
153-
groups={queryGroups}
154-
selected={selected}
155-
onToggle={toggleGroup}
156-
colors={groupColors}
157-
/>
158-
) : undefined
159-
}
160-
/>
190+
{limitsUnloaded ? (
191+
<div className="flex h-full items-center justify-center text-xs text-text-dimmed">
192+
Couldn't load this data. Try again in a moment.
193+
</div>
194+
) : (
195+
<AlertDetailChartBody
196+
{...props}
197+
healthView={healthView}
198+
selectedGroups={grouped ? selected : undefined}
199+
groupColors={groupColors}
200+
accessory={
201+
showToggle ? (
202+
<HealthViewToggle value={healthView} onChange={setHealthView} />
203+
) : showPicker ? (
204+
<GroupsDropdown
205+
groups={groups}
206+
selected={selected}
207+
onToggle={toggleGroup}
208+
colors={groupColors}
209+
noun={isLimits ? "limits" : undefined}
210+
/>
211+
) : undefined
212+
}
213+
/>
214+
)}
161215
</div>
162216
</DetailChartBlock>
163217
);
@@ -168,6 +222,7 @@ function AlertDetailChartBody({
168222
metricSeriesMap,
169223
healthBands,
170224
componentSeries = [],
225+
limitTiles = [],
171226
window,
172227
now,
173228
createdAt,
@@ -234,7 +289,9 @@ function AlertDetailChartBody({
234289
: undefined;
235290
const threshold =
236291
typeof facts?.threshold === "number" ? facts.threshold : (alert.thresholdValue ?? null);
237-
const label = METRIC_LABEL[alert.capabilityKey] ?? alert.capabilityTitle;
292+
const soleGroup =
293+
pick.multi && pick.seriesMap.size === 1 ? [...pick.seriesMap.keys()][0] : undefined;
294+
const label = soleGroup ?? METRIC_LABEL[alert.capabilityKey] ?? alert.capabilityTitle;
238295
const chart = pick.multi ? (
239296
<AlertMultiSeriesChart
240297
seriesMap={pick.seriesMap}

‎apps/webapp/app/components/alerts/AlertsList.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export const AREA_OPTIONS: { value: AreaFilterValue; label: string }[] = [
173173
{ value: "errors", label: "Errors" },
174174
{ value: "runs", label: "Runs" },
175175
{ value: "health", label: "Health" },
176+
{ value: "limits", label: "Limits" },
176177
{ value: "query", label: "Custom" },
177178
{ value: "unknown", label: "Unknown" },
178179
];
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// @vitest-environment jsdom
2+
import { createElement } from "react";
3+
import { createRoot, type Root } from "react-dom/client";
4+
import { act } from "react-dom/test-utils";
5+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
6+
import { ChartLegend, warningLegendItem, type ChartLegendItem } from "./ChartLegend";
7+
8+
let container: HTMLDivElement;
9+
let root: Root;
10+
11+
beforeEach(() => {
12+
globalThis.ResizeObserver ??= class {
13+
observe() {}
14+
unobserve() {}
15+
disconnect() {}
16+
};
17+
container = document.createElement("div");
18+
document.body.appendChild(container);
19+
root = createRoot(container);
20+
});
21+
22+
afterEach(() => {
23+
act(() => root.unmount());
24+
container.remove();
25+
});
26+
27+
const series = (n: number): ChartLegendItem[] =>
28+
Array.from({ length: n }, (_, i) => ({
29+
swatch: "line" as const,
30+
color: `#00000${i}`,
31+
label: `Series ${i + 1}`,
32+
}));
33+
34+
const render = (items: ChartLegendItem[]) =>
35+
act(() => root.render(createElement(ChartLegend, { items })));
36+
37+
describe("ChartLegend", () => {
38+
it("compacts more than three series into dots and a +N popover listing all of them", () => {
39+
render([...series(7), warningLegendItem("Above threshold")]);
40+
const entries = [...container.firstElementChild!.children].map((el) => el.textContent);
41+
expect(entries.slice(0, 4)).toEqual(["Above threshold", "Series 1", "Series 2", "Series 3"]);
42+
expect(container.textContent).not.toContain("Series 4");
43+
expect(container.firstElementChild!.firstElementChild!.textContent).toBe("Above threshold");
44+
const chip = container.querySelector("button")!;
45+
expect(chip.textContent).toBe("See all");
46+
act(() => chip.click());
47+
const text = document.body.textContent ?? "";
48+
for (let i = 1; i <= 7; i += 1) expect(text).toContain(`Series ${i}`);
49+
});
50+
51+
it("keeps labels inline for three series or fewer", () => {
52+
render([...series(3), warningLegendItem("Above threshold")]);
53+
expect(container.querySelector("button")).toBeNull();
54+
expect(container.textContent).toContain("Series 3");
55+
expect(container.textContent).toContain("Above threshold");
56+
});
57+
});

‎apps/webapp/app/components/alerts/ChartLegend.tsx‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
12
import { cn } from "~/utils/cn";
23

34
export const THRESHOLD_LINE_COLOR = "var(--color-border-brighter)";
@@ -31,6 +32,51 @@ function Swatch({ swatch, color }: Pick<ChartLegendItem, "swatch" | "color">) {
3132
return <span className="size-2.5 rounded-[2px]" style={{ backgroundColor: color }} />;
3233
}
3334

35+
const COMPACT_AFTER = 3;
36+
37+
function LegendEntry({ item }: { item: ChartLegendItem }) {
38+
return (
39+
<span className="flex items-center gap-1 text-xs font-normal text-text-dimmed">
40+
<Swatch swatch={item.swatch} color={item.color} />
41+
{item.label}
42+
</span>
43+
);
44+
}
45+
46+
function CompactLegend({
47+
warning,
48+
series,
49+
}: {
50+
warning: ChartLegendItem[];
51+
series: ChartLegendItem[];
52+
}) {
53+
return (
54+
<>
55+
{warning.map((item) => (
56+
<LegendEntry key={`${item.swatch}-${item.label}`} item={item} />
57+
))}
58+
{series.slice(0, COMPACT_AFTER).map((item) => (
59+
<LegendEntry key={`${item.swatch}-${item.label}`} item={item} />
60+
))}
61+
<Popover>
62+
<PopoverTrigger asChild>
63+
<button
64+
type="button"
65+
className="rounded-sm bg-background-hover px-1.5 text-xs font-normal text-text-dimmed hover:text-text-bright"
66+
>
67+
See all
68+
</button>
69+
</PopoverTrigger>
70+
<PopoverContent align="start" className="flex flex-col gap-1.5 p-2">
71+
{series.map((item) => (
72+
<LegendEntry key={`${item.swatch}-${item.label}`} item={item} />
73+
))}
74+
</PopoverContent>
75+
</Popover>
76+
</>
77+
);
78+
}
79+
3480
export function ChartLegend({
3581
items,
3682
className,
@@ -39,17 +85,15 @@ export function ChartLegend({
3985
className?: string;
4086
}) {
4187
if (items.length === 0) return null;
88+
const warning = items.filter((item) => item.color === WARNING_COLOR);
89+
const series = items.filter((item) => item.color !== WARNING_COLOR);
4290
return (
4391
<span className={cn("flex flex-wrap items-center gap-x-2 gap-y-1", className)}>
44-
{items.map((item) => (
45-
<span
46-
key={`${item.swatch}-${item.label}`}
47-
className="flex items-center gap-1 text-xs font-normal text-text-dimmed"
48-
>
49-
<Swatch swatch={item.swatch} color={item.color} />
50-
{item.label}
51-
</span>
52-
))}
92+
{series.length > COMPACT_AFTER ? (
93+
<CompactLegend warning={warning} series={series} />
94+
) : (
95+
items.map((item) => <LegendEntry key={`${item.swatch}-${item.label}`} item={item} />)
96+
)}
5397
</span>
5498
);
5599
}

‎apps/webapp/app/components/alerts/CreationHandoff.dom.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ describe("creation card hands off to the chat and back", () => {
303303
});
304304
act(() => button("Continue setup")!.click());
305305
expect(card()?.getAttribute("aria-label")).toBe("How long should it stay active?");
306-
expect(card()?.textContent).toContain("4 of 5");
306+
expect(card()?.textContent).toContain("4 of 6");
307307
});
308308

309309
// Mutation: dropping the selected check from hasNext lets Next pass an unanswered step.

0 commit comments

Comments
 (0)