Skip to content

Commit 85ab16b

Browse files
kathiekiwiTrigger.dev RepoOps
authored andcommitted
fix(alerts): create alerts without the dashboard agent
Mono-RevId: 615bea82de2114ed834a33e16c25ad17efa9a4a5
1 parent 6699e58 commit 85ab16b

13 files changed

Lines changed: 1252 additions & 152 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Creating an alert works without the dashboard agent.

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { BellAlertIcon } from "@heroicons/react/20/solid";
12
import { TrashIcon } from "~/assets/icons/TrashIcon";
23
import { useState } from "react";
34
import { AgentIcon } from "~/components/dashboard-agent/agent-identity";
@@ -48,13 +49,15 @@ export function LegacyAlertsSection({
4849
canWrite = true,
4950
canDelete = true,
5051
replacing = false,
52+
manualReplace = false,
5153
}: {
5254
channels: LegacyAlertChannelRow[];
5355
onReplace: (row: LegacySubscriptionRow) => void;
5456
onDelete: (row: LegacySubscriptionRow) => void;
5557
canWrite?: boolean;
5658
canDelete?: boolean;
5759
replacing?: boolean;
60+
manualReplace?: boolean;
5861
}) {
5962
const [deleting, setDeleting] = useState<LegacySubscriptionRow | undefined>();
6063
const rows = legacySubscriptions(channels);
@@ -87,8 +90,9 @@ export function LegacyAlertsSection({
8790
<TableCell actionClassName="py-1.5">
8891
{row.migration.kind === "replace" ? (
8992
<Button
90-
variant="ask-trigger/small"
91-
LeadingIcon={AgentIcon}
93+
variant={manualReplace ? "secondary/small" : "ask-trigger/small"}
94+
LeadingIcon={manualReplace ? BellAlertIcon : AgentIcon}
95+
leadingIconClassName={manualReplace ? "text-alerts" : undefined}
9296
className="whitespace-nowrap"
9397
disabled={replacing || !(canWrite && (row.canWrite ?? true))}
9498
onClick={() => onReplace(row)}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// @vitest-environment jsdom
2+
import type * as RemixReactModule from "@remix-run/react";
3+
import { createElement, useState, type ComponentProps, type ReactNode } from "react";
4+
import { act } from "react-dom/test-utils";
5+
import { createRoot, type Root } from "react-dom/client";
6+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7+
import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider";
8+
import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider";
9+
import type { DraftPrefillParams } from "~/components/dashboard-agent/alert-creation/flow-state";
10+
import { ManualAlertCreationDock } from "./ManualAlertCreation";
11+
12+
const CURRENT_ENV_CHANNELS = [{ id: "ch1", name: "Ops webhook", type: "WEBHOOK" }];
13+
const PROJECT_CHANNELS = [
14+
...CURRENT_ENV_CHANNELS,
15+
{ id: "ch_prod", name: "Prod pager", type: "WEBHOOK" },
16+
];
17+
18+
vi.mock("@remix-run/react", async (importOriginal) => ({
19+
...(await importOriginal<typeof RemixReactModule>()),
20+
useInRouterContext: () => false,
21+
Link: ({ to, children }: { to: string; children: ReactNode }) =>
22+
createElement("a", { href: to }, children),
23+
useNavigate: () => vi.fn(),
24+
useLocation: () => ({ pathname: "/", search: "" }),
25+
useFetcher: () => {
26+
const [url, setUrl] = useState<string>();
27+
return {
28+
data: url?.startsWith("/resources/alerts/channels")
29+
? {
30+
channels: url.includes("environmentId=") ? CURRENT_ENV_CHANNELS : PROJECT_CHANNELS,
31+
canWrite: true,
32+
}
33+
: undefined,
34+
load: setUrl,
35+
};
36+
},
37+
}));
38+
vi.mock("~/hooks/useOrganizations", () => ({
39+
useOptionalOrganization: () => ({ slug: "org", id: "org1" }),
40+
useOrganization: () => ({ slug: "org", id: "org1" }),
41+
}));
42+
vi.mock("~/hooks/useProject", () => ({
43+
useOptionalProject: () => ({ slug: "proj", id: "proj1" }),
44+
useProject: () => ({ slug: "proj", id: "proj1" }),
45+
}));
46+
vi.mock("~/hooks/useEnvironment", () => ({
47+
useOptionalEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }),
48+
useEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }),
49+
}));
50+
vi.mock("~/hooks/useTypedMatchData", () => ({ useTypedMatchesData: () => undefined }));
51+
vi.mock("~/hooks/useFeatures", () => ({ useFeatures: () => ({ queueMetricsEnabled: true }) }));
52+
53+
const fetchMock = vi.fn(async (url: string | URL | Request) => {
54+
if (String(url) === "/resources/alerts/create") {
55+
return new Response(JSON.stringify({ ok: true, alertId: "a1" }));
56+
}
57+
if (String(url).includes("/alerts/legacy?_data=")) {
58+
return new Response(
59+
JSON.stringify({ count: 1, message: "Created 1 alert", legacyRemaining: false })
60+
);
61+
}
62+
return new Response("{}");
63+
});
64+
65+
Element.prototype.scrollIntoView ??= () => {};
66+
globalThis.ResizeObserver ??= class {
67+
observe() {}
68+
unobserve() {}
69+
disconnect() {}
70+
} as unknown as typeof ResizeObserver;
71+
72+
const fieldset = (step: string) =>
73+
document.querySelector<HTMLFieldSetElement>(`fieldset[data-step="${step}"]`);
74+
const combobox = (step: string) =>
75+
fieldset(step)?.querySelector<HTMLButtonElement>("button[role=combobox]") ?? null;
76+
const popover = (select: HTMLElement) =>
77+
document.getElementById(select.getAttribute("aria-controls") ?? "");
78+
const options = (select: HTMLElement) =>
79+
Array.from(popover(select)?.querySelectorAll<HTMLElement>("[role=option]") ?? []);
80+
const click = (el: HTMLElement) => act(() => el.click());
81+
const open = (select: HTMLElement) => {
82+
act(() => select.focus());
83+
act(() => select.click());
84+
};
85+
function pick(step: string, label: string) {
86+
const select = combobox(step)!;
87+
open(select);
88+
const found = options(select).find((o) => o.textContent === label);
89+
if (!found) throw new Error(`no ${label}: ${options(select).map((o) => o.textContent)}`);
90+
click(found);
91+
}
92+
const checkbox = (step: string, label: string) =>
93+
Array.from(fieldset(step)!.querySelectorAll<HTMLLabelElement>("[data-checkbox-list] label"))
94+
.find((l) => l.textContent === label)!
95+
.querySelector<HTMLInputElement>("input")!;
96+
const button = (label: string) =>
97+
Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(
98+
(b) => b.textContent === label
99+
);
100+
101+
let container: HTMLElement;
102+
let root: Root;
103+
const onCreated = vi.fn();
104+
const onClose = vi.fn();
105+
const onReplaced = vi.fn();
106+
107+
function render(prefill: DraftPrefillParams) {
108+
act(() =>
109+
root.render(
110+
createElement(
111+
OperatingSystemContextProvider,
112+
{ platform: "mac" } as ComponentProps<typeof OperatingSystemContextProvider>,
113+
createElement(
114+
ShortcutsProvider,
115+
null,
116+
createElement(
117+
ManualAlertCreationDock,
118+
{
119+
open: true,
120+
prefill,
121+
alertDetailPath: (id: string) => `/alerts/${id}`,
122+
onCreated,
123+
onReplaced,
124+
onClose,
125+
},
126+
createElement("div", { "data-testid": "page" }, "page")
127+
)
128+
)
129+
)
130+
)
131+
);
132+
}
133+
134+
describe("ManualAlertCreation", () => {
135+
beforeEach(() => {
136+
fetchMock.mockClear();
137+
vi.stubGlobal("fetch", fetchMock);
138+
container = document.createElement("div");
139+
document.body.appendChild(container);
140+
root = createRoot(container);
141+
});
142+
afterEach(() => {
143+
act(() => root.unmount());
144+
container.remove();
145+
vi.unstubAllGlobals();
146+
});
147+
148+
it("walks the real form, posts the alert and reports the created id", async () => {
149+
vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-01-01T00:00:00.000Z") });
150+
try {
151+
render({ area: "runs", capability: "runs.failed", scope: "task", scopeValue: "x" });
152+
pick("condition", "50");
153+
pick("expiry", "For 24 hours");
154+
click(checkbox("notifiers", "Ops webhook"));
155+
click(button("Done")!);
156+
await act(async () => button("Create alert")!.click());
157+
const calls = fetchMock.mock.calls.filter(([url]) => url === "/resources/alerts/create");
158+
expect(calls).toHaveLength(1);
159+
const init = (calls[0] as unknown as [string, RequestInit])[1];
160+
expect(init.method).toBe("POST");
161+
expect(JSON.parse(init.body as string)).toEqual({
162+
organizationId: "org1",
163+
projectId: "proj1",
164+
environmentId: "env1",
165+
name: "Failed runs above 50 on x",
166+
expiry: "24h",
167+
form: {
168+
capabilityKey: "runs.failed",
169+
scopeType: "task",
170+
scopeValue: "x",
171+
conditionKind: "threshold",
172+
operator: ">",
173+
value: 50,
174+
windowMinutes: 10,
175+
expiresAt: "2026-01-02T00:00:00.000Z",
176+
notifierChannelIds: ["ch1"],
177+
},
178+
});
179+
expect(onCreated).toHaveBeenCalledWith("a1");
180+
} finally {
181+
vi.useRealTimers();
182+
}
183+
});
184+
185+
it("starts a fresh draft when the prefill changes", () => {
186+
render({ area: "runs", capability: "runs.failed", scope: "task", scopeValue: "x" });
187+
pick("condition", "50");
188+
expect(combobox("condition")!.textContent).toBe("50");
189+
render({ area: "runs", capability: "runs.failed", scope: "task", scopeValue: "y" });
190+
expect(combobox("condition")!.textContent).toBe("Select…");
191+
pick("condition", "50");
192+
expect(fieldset("scope")!.textContent).toContain("y");
193+
expect(fieldset("scope")!.textContent).not.toContain("x");
194+
});
195+
196+
it("docks beside the page with the panel header and no chat controls", () => {
197+
render({ area: "runs", capability: "runs.failed", scope: "task", scopeValue: "x" });
198+
expect(document.querySelector("[data-testid=page]")).not.toBeNull();
199+
const panel = document.querySelector("[data-testid=manual-alert-panel]")!;
200+
const title = Array.from(panel.querySelectorAll("span")).find(
201+
(span) => span.textContent === "New alert"
202+
)!;
203+
expect(title.className).toContain("truncate text-sm font-medium text-text-bright");
204+
expect(panel.querySelector("[aria-label^='Change layout']")).toBeNull();
205+
expect(panel.querySelector("[aria-haspopup]:not([role=combobox])")).toBeNull();
206+
expect(panel.querySelector("[data-testid=manual-mode-toggle]")).toBeNull();
207+
expect(panel.querySelector("textarea")).toBeNull();
208+
click(panel.querySelector<HTMLButtonElement>("[aria-label='Close (Esc)']")!);
209+
expect(onClose).toHaveBeenCalledTimes(1);
210+
});
211+
212+
it("replaces a legacy subscription through the legacy route", async () => {
213+
render({
214+
capability: "runs.failed",
215+
scope: "all",
216+
replace: {
217+
channelId: "ch_prod",
218+
channelName: "Prod pager",
219+
alertType: "TASK_RUN",
220+
environments: [
221+
{ id: "env1", type: "PRODUCTION", slug: "prod", branchName: null },
222+
{ id: "env2", type: "DEVELOPMENT", slug: "dev", branchName: null },
223+
],
224+
},
225+
});
226+
pick("expiry", "No expiry");
227+
expect(checkbox("notifiers", "Prod pager").checked).toBe(true);
228+
click(button("Done")!);
229+
await act(async () => button("Create alert")!.click());
230+
const calls = fetchMock.mock.calls.filter(([url]) => String(url).includes("/alerts/legacy"));
231+
expect(calls).toHaveLength(1);
232+
const [url, init] = calls[0] as unknown as [string, RequestInit];
233+
expect(url).toBe(
234+
`/orgs/org/projects/proj/env/prod/alerts/legacy?_data=${encodeURIComponent(
235+
"routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts_.legacy"
236+
)}`
237+
);
238+
expect(init.method).toBe("POST");
239+
expect(Object.fromEntries(init.body as FormData)).toEqual({
240+
intent: "replace",
241+
channelId: "ch_prod",
242+
alertType: "TASK_RUN",
243+
environmentIds: "env1,env2",
244+
value: "0",
245+
windowMinutes: "10",
246+
scopeType: "all",
247+
notifierChannelIds: "ch_prod",
248+
});
249+
expect(fetchMock.mock.calls.some(([u]) => u === "/resources/alerts/create")).toBe(false);
250+
expect(onReplaced).toHaveBeenCalledWith({
251+
count: 1,
252+
message: "Created 1 alert",
253+
legacyRemaining: false,
254+
});
255+
});
256+
});

0 commit comments

Comments
 (0)