diff --git a/.env.schema b/.env.schema index 88faa7013e0..b4a7b2c74f2 100644 --- a/.env.schema +++ b/.env.schema @@ -205,7 +205,7 @@ POSTHOG_PROJECT_KEY= # @sensitive=false ALERTS_ENABLED=0 # @sensitive=false -ALERT_QUERY_CONCURRENCY=1 +ALERTS_QUERY_CONCURRENCY_LIMIT=2 # @sensitive=false ALERT_FAILURE_RATE_MIN_VOLUME=10 # @sensitive=false diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index fe7f88a3d3c..7e2a145f6df 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -13,7 +13,15 @@ import { Callout } from "~/components/primitives/Callout"; import { LockClosedIcon } from "@heroicons/react/20/solid"; import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { cn } from "~/utils/cn"; -import { FEATURE_FLAG, ORG_LOCKED_FLAGS, type FlagControlType } from "~/v3/featureFlags"; +import { + FEATURE_FLAG, + ORG_LOCKED_FLAG_EFFECT, + ORG_LOCKED_FLAGS, + isUnchangedInvalidFlag, + type FeatureFlagKey, + type FlagControlType, + type OrgLockedFlagEffect, +} from "~/v3/featureFlags"; import { UNSET_VALUE, BooleanControl, @@ -33,6 +41,7 @@ import { type LoaderData = { org: { id: string; title: string; slug: string }; orgFlags: Record; + invalidOrgFlags?: Record; globalFlags: Record; controlTypes: Record; workerGroupName?: string; @@ -41,6 +50,22 @@ type LoaderData = { isManagedCloud?: boolean; }; +const LOCKED_EFFECT_LABEL: Record = { + live: "overrides global", + ignored: "stored, ignored", + derived: "grace stamp", +}; + +// Most flags fall back to global when the org value fails its schema, but not all of them. +const INVALID_VALUE_LABEL: Partial> = { + [FEATURE_FLAG.dashboardAgentTurnEvalsEnabled]: "invalid, treated as off", + [FEATURE_FLAG.workerQueueEnvironmentOverrides]: "invalid, valid entries still apply", +}; + +function formatStoredValue(value: unknown): string { + return typeof value === "string" ? value : JSON.stringify(value); +} + type ActionData = { success?: boolean; error?: string; @@ -85,7 +110,8 @@ export function FeatureFlagsDialog({ useEffect(() => { if (loadFetcher.data) { - const loaded = loadFetcher.data.orgFlags ?? {}; + // Invalid stored values ride along unchanged, so a save keeps them unless unset. + const loaded = { ...loadFetcher.data.orgFlags, ...loadFetcher.data.invalidOrgFlags }; // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setOverrides({ ...loaded }); setInitialOverrides({ ...loaded }); @@ -102,8 +128,16 @@ export function FeatureFlagsDialog({ }, [saveFetcher.data]); const isDirty = stableStringify(overrides) !== stableStringify(initialOverrides); - const environmentOverrides = (overrides[FEATURE_FLAG.workerQueueEnvironmentOverrides] ?? - {}) as WorkerQueueEnvironmentOverrides; + // An untouched invalid stored value isn't a draft: it renders as an invalid row, not the editor. + const environmentOverrides = ( + isUnchangedInvalidFlag( + FEATURE_FLAG.workerQueueEnvironmentOverrides, + overrides, + loadFetcher.data?.invalidOrgFlags + ) + ? {} + : (overrides[FEATURE_FLAG.workerQueueEnvironmentOverrides] ?? {}) + ) as WorkerQueueEnvironmentOverrides; const hasIncompleteOverrides = incompleteOverrideScopes(environmentOverrides).length > 0; const setFlagValue = (key: string, value: unknown) => { @@ -179,18 +213,86 @@ export function FeatureFlagsDialog({ ? String(globalValue) : "unset"; + const effect = ORG_LOCKED_FLAG_EFFECT[key as FeatureFlagKey]; + const invalidStored = data.invalidOrgFlags?.[key]; + const isInvalidStored = isUnchangedInvalidFlag( + key, + overrides, + data.invalidOrgFlags + ); + + if (isInvalidStored) { + return ( +
+
+
{key}
+
+ org: {formatStoredValue(invalidStored)} ( + {INVALID_VALUE_LABEL[key] ?? "invalid"}) · global: {globalDisplay} +
+
+ {locked && effect === "derived" ? ( + + ) : ( + + )} +
+ ); + } + if (locked) { + const hasOrgValue = key in overrides; + const orgValue = overrides[key]; + const orgWorkerGroup = + isWorkerGroup && hasOrgValue + ? data.workerGroups?.find((group) => group.id === orgValue) + : undefined; + const orgDisplay = orgWorkerGroup + ? `${orgWorkerGroup.name} (${String(orgValue).slice(0, 8)}...)` + : formatStoredValue(orgValue); + const canUnset = hasOrgValue && effect !== "derived"; + return (
-
{key}
-
global: {globalDisplay}
+
+ {key} +
+ {hasOrgValue ? ( +
+ org: {orgDisplay} ({effect ? LOCKED_EFFECT_LABEL[effect] : "stored"}) · + global: {globalDisplay} +
+ ) : ( +
global: {globalDisplay}
+ )}
- + {canUnset ? ( + + ) : ( + + )}
); } diff --git a/apps/webapp/app/components/alerts/AlertDetailChart.query.dom.test.ts b/apps/webapp/app/components/alerts/AlertDetailChart.query.dom.test.ts new file mode 100644 index 00000000000..caf51f48333 --- /dev/null +++ b/apps/webapp/app/components/alerts/AlertDetailChart.query.dom.test.ts @@ -0,0 +1,331 @@ +// @vitest-environment jsdom +import { act, createElement, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; +import type { AlertDetail } from "~/presenters/v3/AlertDetailPresenter.server"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import { AlertDetailChart } from "./AlertDetailChart"; +import { AREA_OPTIONS } from "./AlertsList"; +import { WARNING_COLOR } from "./ChartLegend"; +import { AREA_ICON, AREA_LABEL, isAlertArea } from "./domainIcons"; +import type { MetricSeriesPoint } from "./alertSeriesTypes"; +import { defaultGroupSelection } from "./queryGroupSelection"; +import type * as AlertThresholdChartModule from "./AlertThresholdChart"; + +const { assignGroupColors, AlertMultiSeriesChart, SERIES_COLORS, thresholdChartLegend } = + await vi.importActual("./AlertThresholdChart"); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +}; + +vi.mock("~/components/primitives/charts/ChartCompound", () => ({ + Chart: { + Root: ({ + children, + series, + config, + }: { + children: ReactNode; + series: string[]; + config: Record; + }) => + createElement( + "div", + { + "data-series": series.join(","), + "data-colors": Object.values(config) + .map((c) => c.color) + .join(","), + }, + children + ), + Line: ({ warningOverlay }: { warningOverlay?: unknown }) => + createElement("i", { "data-overlay": JSON.stringify(warningOverlay) }), + }, +})); +vi.mock("~/components/primitives/DateTime", () => ({ useUserTimeZone: () => "UTC" })); +vi.mock("~/components/runs/v3/SharedFilters", () => ({ TimeFilter: () => null })); +vi.mock("~/components/primitives/DetailChartBlock", () => ({ + DetailChartBlock: ({ children }: { children: ReactNode }) => children, +})); +vi.mock("./PreviewChartCard", () => ({ + PreviewChartCard: ({ accessory, children }: { accessory?: ReactNode; children: ReactNode }) => + createElement("div", null, accessory, children), +})); +vi.mock("./ActivityBars", async (importOriginal) => ({ + ...(await importOriginal()), + ActivityBars: ({ data }: { data: unknown[] }) => + createElement("i", { "data-chart": "bars", "data-points": data.length }), +})); +vi.mock("./AlertThresholdChart", async (importOriginal) => ({ + ...(await importOriginal()), + AlertMultiSeriesChart: ({ seriesMap }: { seriesMap: Map }) => + createElement("i", { "data-chart": "multi", "data-keys": [...seriesMap.keys()].join(",") }), +})); + +const HOUR = 60 * 60 * 1000; +const T0 = Date.UTC(2026, 0, 1); + +const alert = { + kind: "threshold", + capabilityTitle: "Custom", + capabilityKey: "custom.query", + operator: ">", + thresholdValue: 10, + scope: { type: "all" }, + status: { status: "active" }, + episode: { raisedAt: new Date(T0), outcome: "raised" }, + events: [], + currentReading: { status: "unavailable" }, +} as unknown as AlertDetail; + +const points = (...values: number[]): MetricSeriesPoint[] => + values.map((value, i) => ({ bucket: T0 + i * HOUR, value })); + +// g8 and g9 cross the threshold; the others stay below and rise with their index +const groups = Array.from({ length: 10 }, (_, i) => `g${i}`); +const seriesMap = new Map(groups.map((g, i) => [g, points(i >= 8 ? 50 + i : i, 1)])); + +let root: Root | undefined; +let container: HTMLElement | undefined; + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + document.body.innerHTML = ""; +}); + +function mount(props: Record = {}) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + render(props); + return container; +} + +function render(props: Record = {}) { + act(() => + root!.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" }, + createElement( + ShortcutsProvider, + null, + createElement(AlertDetailChart, { + alert, + metricSeriesMap: seriesMap, + healthBands: [], + window: { since: new Date(T0), until: new Date(T0 + 4 * HOUR) }, + now: new Date(T0 + 4 * HOUR), + queryGroups: groups, + querySeriesMode: "threshold", + ...props, + } as never) + ) + ) + ) + ); +} + +const keys = (c: HTMLElement) => + c.querySelector("[data-chart=multi]")!.getAttribute("data-keys")!.split(","); + +describe("defaultGroupSelection", () => { + it("puts crossed groups first, then closest to the threshold, capped at 8", () => { + const picked = defaultGroupSelection({ groups, seriesMap, operator: ">" }); + expect(picked).toEqual(["g9", "g8", "g7", "g6", "g5", "g4", "g3", "g2"]); + }); + + it("orders toward the threshold for a below-threshold operator", () => { + const picked = defaultGroupSelection({ + groups: ["a", "b", "c"], + seriesMap: new Map([ + ["a", points(5)], + ["b", points(1)], + ["c", points(3)], + ]), + operator: "<", + }); + expect(picked).toEqual(["b", "c", "a"]); + }); +}); + +describe("AlertDetailChart query groups", () => { + it("renders at most 8 series by default, crossed groups first", () => { + const c = mount(); + expect(keys(c)).toEqual(["g9", "g8", "g7", "g6", "g5", "g4", "g3", "g2"]); + expect(c.textContent).toContain("Groups 8/10"); + }); + + it("hides a series when its group is toggled off and searches the list", () => { + const c = mount(); + act(() => c.querySelector("button")!.click()); + + const search = document.querySelector("input")!; + expect(search.placeholder).toContain("Up to 8 visible"); + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + const type = (value: string) => + act(() => { + setter.call(search, value); + search.dispatchEvent(new Event("input", { bubbles: true })); + }); + const rows = () => [...document.querySelectorAll('[role="option"]')].map((o) => o.textContent); + const g0 = [...document.querySelectorAll('[role="option"]')].find( + (o) => o.textContent === "g0" + )!; + expect(g0.getAttribute("aria-disabled")).toBe("true"); + type("g1"); + expect(rows()).toEqual(["g1"]); + + type(""); + const row = [...document.querySelectorAll('[role="option"]')].find( + (o) => o.textContent === "g9" + )!; + act(() => row.click()); + expect(keys(c)).not.toContain("g9"); + expect(c.textContent).toContain("Groups 7/10"); + }); + + it("shows the picker only with at least two groups", () => { + expect(mount({ queryGroups: ["g9"] }).textContent).not.toContain("Groups"); + act(() => root?.unmount()); + container?.remove(); + expect(mount({ queryGroups: ["g9", "g8"] }).textContent).toContain("Groups 2/2"); + act(() => container!.querySelector("button")!.click()); + expect(document.querySelector("input")!.placeholder).not.toContain("Up to"); + }); + + it("shows the only remaining group even after it was deselected", () => { + const c = mount({ queryGroups: ["g9", "g8"] }); + act(() => c.querySelector("button")!.click()); + const g9 = [...document.querySelectorAll('[role="option"]')].find( + (o) => o.textContent === "g9" + )!; + act(() => g9.click()); + expect(keys(c)).toEqual(["g8"]); + + render({ queryGroups: ["g9"] }); + expect(keys(c)).toEqual(["g9"]); + expect(c.textContent).not.toContain("Groups"); + }); + + it("renders the occurrence bar chart in match mode", () => { + const c = mount({ + querySeriesMode: "match", + metricSeriesMap: new Map([["__agg__", points(2, 3, 4)]]), + }); + expect(c.querySelector("[data-chart=bars]")!.getAttribute("data-points")).toBe("3"); + expect(c.querySelector("[data-chart=multi]")).toBeNull(); + }); +}); + +describe("Watching area for custom queries", () => { + it("shows Custom with the Query page icon", () => { + expect(AREA_LABEL.query).toBe("Custom"); + expect(AREA_ICON.query).toBe(CodeSquareIcon); + expect(isAlertArea("query")).toBe(true); + expect(AREA_OPTIONS).toContainEqual({ value: "query", label: "Custom" }); + }); +}); + +function hue(hex: string): number { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as [ + number, + number, + number, + ]; + const max = Math.max(r, g, b); + const d = max - Math.min(r, g, b); + if (d === 0) return 0; + const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4; + return (h * 60 + 360) % 360; +} + +describe("grouped chart threshold highlight", () => { + it("keeps yellow and amber out of the series colours", () => { + expect(SERIES_COLORS).toHaveLength(8); + for (const color of SERIES_COLORS) { + const h = hue(color); + expect(h < 20 || h > 75, color).toBe(true); + } + }); + + it("applies the warning overlay to the chart holding every group", () => { + const html = renderToStaticMarkup( + createElement(AlertMultiSeriesChart, { + seriesMap: new Map([ + ["a", points(1, 20, 25)], + ["b", points(2, 30, 15)], + ]), + threshold: 10, + operator: ">", + }) + ); + expect(html).toContain('data-series="a,b"'); + expect(html).toContain(""direction":"above""); + expect(html).toContain("var(--color-warning)"); + }); +}); + +describe("assignGroupColors", () => { + const keys = Array.from({ length: 12 }, (_, i) => `g${i}`); + + it("gives 8 visible groups 8 distinct colours", () => { + const colors = assignGroupColors(new Map(), keys.slice(0, 8)); + expect(new Set(colors.values()).size).toBe(8); + }); + + it("stays distinct for 12 groups with 8 selected", () => { + const colors = assignGroupColors(new Map(), keys.slice(2, 10)); + expect(colors.size).toBe(8); + expect(new Set(colors.values()).size).toBe(8); + }); + + it("keeps untouched colours when one is toggled off and another on", () => { + const first = assignGroupColors(new Map(), keys.slice(0, 8)); + const next = assignGroupColors(first, [...keys.slice(1, 8), "g10"]); + expect(new Set(next.values()).size).toBe(8); + for (const key of keys.slice(1, 8)) expect(next.get(key)).toBe(first.get(key)); + expect(next.get("g10")).toBe(first.get("g0")); + }); +}); + +describe("grouped chart legend", () => { + const seriesMap = new Map([ + ["a", points(1, 20)], + ["b", points(2, 30)], + ]); + + it("lists every colour it draws, warning entry included", () => { + const input = { seriesMap, label: "x", threshold: 10, operator: ">" as const }; + const legend = thresholdChartLegend(input); + expect(legend.map((i) => i.label)).toEqual(["a", "b", "Above threshold"]); + expect(legend.map((i) => i.color)).toEqual([SERIES_COLORS[0], SERIES_COLORS[1], WARNING_COLOR]); + }); + + it("omits it without a threshold", () => { + const labels = thresholdChartLegend({ seriesMap, label: "x", threshold: null }).map( + (i) => i.label + ); + expect(labels).toEqual(["a", "b"]); + }); + + it("keeps a lone group's assigned colour in the line and the legend", () => { + const colors = new Map([["b", SERIES_COLORS[1]!]]); + const single = new Map([["b", points(2, 30)]]); + const html = renderToStaticMarkup( + createElement(AlertMultiSeriesChart, { seriesMap: single, threshold: 10, colors }) + ); + expect(html).toContain(`data-colors="${SERIES_COLORS[1]}"`); + const legend = thresholdChartLegend({ seriesMap: single, label: "x", threshold: null, colors }); + expect(legend.map((i) => i.color)).toEqual([SERIES_COLORS[1]]); + }); +}); diff --git a/apps/webapp/app/components/alerts/AlertDetailChart.tsx b/apps/webapp/app/components/alerts/AlertDetailChart.tsx index c485d358c0c..6c4b5a43919 100644 --- a/apps/webapp/app/components/alerts/AlertDetailChart.tsx +++ b/apps/webapp/app/components/alerts/AlertDetailChart.tsx @@ -1,9 +1,10 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useUserTimeZone } from "~/components/primitives/DateTime"; import { DetailChartBlock } from "~/components/primitives/DetailChartBlock"; import { TimeFilter } from "~/components/runs/v3/SharedFilters"; import type { AlertDetail } from "~/presenters/v3/AlertDetailPresenter.server"; import { HealthViewToggle, type HealthView } from "./HealthViewToggle"; +import { GroupsDropdown } from "./GroupsDropdown"; import { PreviewChartCard } from "./PreviewChartCard"; import { alertChartInfo } from "./alertSummaries"; import { ActivityBars, EVENT_CHART_COLOR } from "./ActivityBars"; @@ -38,8 +39,10 @@ import { import { AlertMultiSeriesChart, AlertThresholdChart, + assignGroupColors, thresholdChartLegend, } from "./AlertThresholdChart"; +import { defaultGroupSelection, MAX_VISIBLE_GROUPS } from "./queryGroupSelection"; const METRIC_LABEL: Record = { "queue.depth": "Depth", @@ -90,12 +93,47 @@ type AlertDetailChartProps = { window: DetailWindow & { isLifetime?: boolean; explicitTo?: boolean }; now: Date; createdAt?: Date; + queryGroups?: string[]; + querySeriesMode?: "threshold" | "absence" | "match"; }; export function AlertDetailChart(props: AlertDetailChartProps) { const [healthView, setHealthView] = useState("together"); - const { alert, window } = props; + const [picked, setPicked] = useState(null); + const { alert, window, metricSeriesMap, queryGroups = [] } = props; const showToggle = alert.kind === "state" && (props.componentSeries?.length ?? 0) > 1; + const grouped = + alert.kind === "threshold" && props.querySeriesMode !== "match" && queryGroups.length > 0; + const defaults = useMemo( + () => + grouped + ? defaultGroupSelection({ + groups: queryGroups, + seriesMap: metricSeriesMap, + operator: alert.operator, + range: { since: window.since.getTime(), until: window.until.getTime() }, + }) + : [], + [grouped, queryGroups, metricSeriesMap, alert.operator, window.since, window.until] + ); + const showPicker = grouped && queryGroups.length > 1; + const selected = showPicker ? (picked ?? defaults) : defaults; + // Colours carry over from the previous selection, so a toggle doesn't repaint other groups. + const [colorState, setColorState] = useState({ key: "", colors: new Map() }); + const selectedKey = JSON.stringify(selected); + let groupColors = colorState.colors; + if (colorState.key !== selectedKey) { + groupColors = assignGroupColors(colorState.colors, selected); + setColorState({ key: selectedKey, colors: groupColors }); + } + const toggleGroup = (group: string) => + setPicked( + selected.includes(group) + ? selected.filter((g) => g !== group) + : selected.length >= MAX_VISIBLE_GROUPS + ? selected + : [...selected, group] + ); return ( + ) : showPicker ? ( + ) : undefined } /> @@ -125,15 +172,50 @@ function AlertDetailChartBody({ now, createdAt, healthView, + selectedGroups, + groupColors, + querySeriesMode, accessory, -}: AlertDetailChartProps & { healthView: HealthView; accessory?: React.ReactNode }) { +}: AlertDetailChartProps & { + healthView: HealthView; + selectedGroups?: string[]; + groupColors?: Map; + accessory?: React.ReactNode; +}) { const title = alertChartTitle(alert); const info = alertChartInfo(alert); const raisedAt = alert.episode.raisedAt; const timeZone = useUserTimeZone(); + if (querySeriesMode === "match") { + const occurrences = (metricSeriesMap.get("__agg__") ?? []).map((p) => ({ + bucket: p.bucket, + count: p.value ?? 0, + })); + return ( + + ); + } + if (alert.kind === "threshold") { - const raw = pickThresholdSeries(metricSeriesMap, alert.scope.type); + const raw = selectedGroups + ? { + multi: true as const, + seriesMap: new Map( + selectedGroups.flatMap((g) => { + const points = metricSeriesMap.get(g); + return points ? [[g, points] as const] : []; + }) + ), + } + : pickThresholdSeries(metricSeriesMap, alert.scope.type); const start = firstDataAt(window, createdAt, { series: raw.multi ? [...raw.seriesMap.values()] : [raw.series], events: alert.events, @@ -162,6 +244,7 @@ function AlertDetailChartBody({ operator={alert.operator} raisedAt={raisedAt} timeZone={timeZone} + colors={groupColors} fill /> ) : ( @@ -186,6 +269,7 @@ function AlertDetailChartBody({ label, threshold, operator: alert.operator, + colors: groupColors, })} alwaysShowControls accessory={accessory} @@ -199,6 +283,7 @@ function AlertDetailChartBody({ operator={alert.operator} raisedAt={raisedAt} timeZone={timeZone} + colors={groupColors} fill showLegend /> @@ -296,10 +381,39 @@ function AlertDetailChartBody({ ); } - const occurrences = (metricSeriesMap.get("__agg__") ?? []).map((p) => ({ - bucket: p.bucket, - count: p.value ?? 0, - })); + return ( + ({ + bucket: p.bucket, + count: p.value ?? 0, + }))} + window={window} + createdAt={createdAt} + timeZone={timeZone} + accessory={accessory} + /> + ); +} + +function OccurrenceBarsCard({ + alert, + occurrences, + window, + createdAt, + timeZone, + accessory, +}: { + alert: AlertDetail; + occurrences: { bucket: number; count: number }[]; + window: DetailWindow; + createdAt?: Date; + timeZone: string; + accessory?: React.ReactNode; +}) { + const raisedAt = alert.episode.raisedAt; + const title = alertChartTitle(alert); + const info = alertChartInfo(alert); const start = firstDataAt(window, createdAt, { series: [occurrences.map((p) => ({ bucket: p.bucket, value: p.count }))], events: alert.events, diff --git a/apps/webapp/app/components/alerts/AlertThresholdChart.tsx b/apps/webapp/app/components/alerts/AlertThresholdChart.tsx index 226cb8fa55a..f75e7f5c6a2 100644 --- a/apps/webapp/app/components/alerts/AlertThresholdChart.tsx +++ b/apps/webapp/app/components/alerts/AlertThresholdChart.tsx @@ -41,7 +41,17 @@ export function thresholdWarningOverlay(threshold: number | null, operator?: ">" }; } -const SERIES_COLORS = [EVENT_CHART_COLOR, "#6366F1", "#EC4899", "#F59E0B", "#10B981"]; +// Yellow/amber is reserved for the over-threshold highlight (WARNING_COLOR). +export const SERIES_COLORS = [ + EVENT_CHART_COLOR, + "#6366F1", + "#EC4899", + "#10B981", + "#06B6D4", + "#3B82F6", + "#D946EF", + "#14B8A6", +]; const OTHER_COLOR = "var(--color-text-dimmed)"; function orderedSeriesKeys(seriesMap: Map): string[] { @@ -50,13 +60,37 @@ function orderedSeriesKeys(seriesMap: Map): string[] { return keys; } -function multiSeriesConfig(keys: string[]): Record { +export function assignGroupColors( + prev: Map, + selected: string[] +): Map { + const next = new Map(); + for (const key of selected) { + const color = prev.get(key); + if (color) next.set(key, color); + } + const used = new Set(next.values()); + const free = SERIES_COLORS.filter((c) => !used.has(c)); + for (const key of selected) { + if (!next.has(key)) + next.set(key, free.shift() ?? SERIES_COLORS[next.size % SERIES_COLORS.length]!); + } + return next; +} + +function multiSeriesConfig( + keys: string[], + colors?: Map +): Record { return Object.fromEntries( keys.map((key, i) => [ key, { label: key === "__other__" ? "Other" : key, - color: key === "__other__" ? OTHER_COLOR : SERIES_COLORS[i % SERIES_COLORS.length]!, + color: + key === "__other__" + ? OTHER_COLOR + : (colors?.get(key) ?? SERIES_COLORS[i % SERIES_COLORS.length]!), }, ]) ); @@ -67,17 +101,21 @@ function thresholdChartParts({ label, threshold, operator, + colors, }: { seriesMap?: Map; label: string; threshold: number | null; operator?: ">" | "<"; + colors?: Map; }) { const keys = seriesMap ? orderedSeriesKeys(seriesMap) : []; const multi = keys.length > 1; return { - config: multi ? multiSeriesConfig(keys) : { value: { label, color: EVENT_CHART_COLOR } }, - overlay: multi ? undefined : thresholdWarningOverlay(threshold, operator), + config: multi + ? multiSeriesConfig(keys, colors) + : { value: { label, color: colors?.get(keys[0] ?? "") ?? EVENT_CHART_COLOR } }, + overlay: thresholdWarningOverlay(threshold, operator), referenceLineColor: threshold === null ? undefined : THRESHOLD_LINE_COLOR, }; } @@ -87,6 +125,7 @@ export function thresholdChartLegend(input: { label: string; threshold: number | null; operator?: ">" | "<"; + colors?: Map; }): ChartLegendItem[] { const { config, overlay } = thresholdChartParts(input); return [ @@ -120,6 +159,7 @@ export function AlertThresholdChart({ operator, raisedAt, timeZone, + color, }: { series: MetricSeriesPoint[]; threshold: number | null; @@ -130,6 +170,7 @@ export function AlertThresholdChart({ operator?: ">" | "<"; raisedAt?: Date | null; timeZone?: string; + color?: string; }) { if (!hasReadings(series)) { return ; @@ -137,7 +178,7 @@ export function AlertThresholdChart({ const data = series.map((p) => ({ bucket: p.bucket, value: p.value })); - const config: ChartConfig = thresholdChartParts({ label, threshold, operator }).config; + const config: ChartConfig = { value: { label, color: color ?? EVENT_CHART_COLOR } }; const { tickFormatter, tooltipLabelFormatter } = buildActivityTimeAxis( data, @@ -205,6 +246,7 @@ export function AlertMultiSeriesChart({ operator, raisedAt, timeZone, + colors, }: { seriesMap: Map; threshold: number | null; @@ -216,6 +258,7 @@ export function AlertMultiSeriesChart({ operator?: ">" | "<"; raisedAt?: Date | null; timeZone?: string; + colors?: Map; }) { const keys = orderedSeriesKeys(seriesMap); if (keys.length === 0 || keys.every((k) => !hasReadings(seriesMap.get(k) ?? []))) { @@ -235,6 +278,7 @@ export function AlertMultiSeriesChart({ operator={operator} raisedAt={raisedAt} timeZone={timeZone} + color={colors?.get(keys[0]!)} /> ); } @@ -249,7 +293,7 @@ export function AlertMultiSeriesChart({ merged.push(point); } - const configEntries = multiSeriesConfig(keys); + const configEntries = multiSeriesConfig(keys, colors); const { tickFormatter, tooltipLabelFormatter } = buildActivityTimeAxis( merged, @@ -294,6 +338,7 @@ export function AlertMultiSeriesChart({ }, ] } + warningOverlay={thresholdWarningOverlay(threshold, operator)} xAxisProps={{ tickFormatter: (value: number) => value === raiseTick ? `▲ raised ${tickFormatter(value)}` : tickFormatter(value), diff --git a/apps/webapp/app/components/alerts/AlertsList.tsx b/apps/webapp/app/components/alerts/AlertsList.tsx index 01a2e0f02d0..0858fa0aef1 100644 --- a/apps/webapp/app/components/alerts/AlertsList.tsx +++ b/apps/webapp/app/components/alerts/AlertsList.tsx @@ -173,6 +173,7 @@ export const AREA_OPTIONS: { value: AreaFilterValue; label: string }[] = [ { value: "errors", label: "Errors" }, { value: "runs", label: "Runs" }, { value: "health", label: "Health" }, + { value: "query", label: "Custom" }, { value: "unknown", label: "Unknown" }, ]; diff --git a/apps/webapp/app/components/alerts/GroupsDropdown.tsx b/apps/webapp/app/components/alerts/GroupsDropdown.tsx new file mode 100644 index 00000000000..01f24f84df2 --- /dev/null +++ b/apps/webapp/app/components/alerts/GroupsDropdown.tsx @@ -0,0 +1,63 @@ +import { Select, SelectItem } from "~/components/primitives/Select"; +import { MAX_VISIBLE_GROUPS } from "./queryGroupSelection"; + +type GroupsProps = { + groups: string[]; + selected: string[]; + onToggle: (group: string) => void; + max?: number; + colors?: Map; +}; + +export function GroupsDropdown({ + groups, + selected, + onToggle, + max = MAX_VISIBLE_GROUPS, + colors, +}: GroupsProps) { + const full = selected.length >= max; + + return ( + + ); +} diff --git a/apps/webapp/app/components/alerts/ManualAlertCreation.tsx b/apps/webapp/app/components/alerts/ManualAlertCreation.tsx index ee362d28fbe..5b2622971c6 100644 --- a/apps/webapp/app/components/alerts/ManualAlertCreation.tsx +++ b/apps/webapp/app/components/alerts/ManualAlertCreation.tsx @@ -9,6 +9,8 @@ import { replaceRequestBody, } from "~/components/alerts/createAlertRequest"; import { LEGACY_ALERTS_ROUTE_ID } from "~/components/alerts/legacyAlertChannels"; +import { QueryAlertForm } from "~/components/alerts/QueryAlertForm"; +import type { QueryAlertPrefill } from "~/components/alerts/queryAlertLink"; import type { AlertChannel } from "~/components/dashboard-agent/alert-creation/CreationFlow"; import { useCreationFlowState, @@ -151,12 +153,14 @@ function ManualAlertCreation({ /* oxlint-disable jsx-a11y/no-static-element-interactions -- Escape bubbles from focused controls inside the panel. */ function ManualAlertCreationPanel({ prefill, + queryPrefill, alertDetailPath, onCreated, onReplaced, onClose, }: { prefill: DraftPrefillParams; + queryPrefill?: QueryAlertPrefill; alertDetailPath: (alertId: string) => string; onCreated: (alertId: string | undefined) => void; onReplaced?: (result: ReplacedResult) => void; @@ -190,13 +194,21 @@ function ManualAlertCreationPanel({ />
- + {queryPrefill ? ( + + ) : ( + + )}
@@ -208,6 +220,7 @@ function ManualAlertCreationPanel({ export function ManualAlertCreationDock({ open, prefill, + queryPrefill, alertDetailPath, onCreated, onReplaced, @@ -216,6 +229,7 @@ export function ManualAlertCreationDock({ }: { open: boolean; prefill: DraftPrefillParams; + queryPrefill?: QueryAlertPrefill; alertDetailPath: (alertId: string) => string; onCreated: (alertId: string | undefined) => void; onReplaced?: (result: ReplacedResult) => void; @@ -262,6 +276,7 @@ export function ManualAlertCreationDock({ > ({ + ...(await importOriginal()), + useInRouterContext: () => false, + Link: ({ to, children }: { to: string; children: ReactNode }) => + createElement("a", { href: to }, children), + useNavigate: () => vi.fn(), + useLocation: () => ({ pathname: "/", search: "" }), + useFetcher: () => { + const [url, setUrl] = useState(); + return { + data: url?.startsWith("/resources/alerts/channels") + ? { channels: [{ id: "ch1", name: "Ops webhook", type: "WEBHOOK" }], canWrite: true } + : undefined, + load: setUrl, + }; + }, +})); +vi.mock("~/hooks/useOrganizations", () => ({ + useOptionalOrganization: () => ({ slug: "org", id: "org1" }), + useOrganization: () => ({ slug: "org", id: "org1" }), +})); +vi.mock("~/hooks/useProject", () => ({ + useOptionalProject: () => ({ slug: "proj", id: "proj1" }), + useProject: () => ({ slug: "proj", id: "proj1" }), +})); +vi.mock("~/hooks/useEnvironment", () => ({ + useOptionalEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), + useEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), +})); +vi.mock("~/hooks/useTypedMatchData", () => ({ useTypedMatchesData: () => undefined })); +vi.mock("~/hooks/useFeatures", () => ({ useFeatures: () => ({ queueMetricsEnabled: true }) })); + +const PREVIEW_PATH = "/resources/orgs/org/projects/proj/env/prod/alerts/preview-query"; +let previewResponse = () => new Response("{}"); +let createResponse = () => new Response(JSON.stringify({ ok: true, alertId: "a1" })); +const fetchMock = vi.fn(async (url: string | URL | Request) => { + if (String(url) === "/resources/alerts/create") return createResponse(); + if (String(url) === PREVIEW_PATH) return previewResponse(); + return new Response("{}"); +}); + +Element.prototype.scrollIntoView ??= () => {}; +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +const QUERY = "SELECT count() AS failures, avg(duration) AS took FROM runs"; +const LIMITS = { + minCadenceSec: 600, + maxWindowSec: 86_400, + maxGroups: 500, + maxMatchRowsPerEval: 200, + detailRows: 10, +}; +const FAILURES = { column: "failures", type: "UInt64", countLike: true }; +const TOOK = { column: "took", type: "Float64", countLike: false }; + +function analysis(partial: Partial): QueryAlertAnalysis { + return { + ok: true, + analyzerVersion: 1, + sources: [{ sourceId: "runs" }], + columns: [], + modes: [], + unsupported: [], + errors: [], + limits: LIMITS, + suggestedName: "failures on runs", + ...partial, + }; +} + +function thresholdAnalysis(measures = [FAILURES], absenceAllowed = true) { + return analysis({ + modes: [{ mode: "threshold", shape: "scalar", measures, groupColumns: [], absenceAllowed }], + }); +} + +function prefill(partial: Partial = {}): QueryAlertPrefill { + return { + query: QUERY, + period: "7d", + analysis: thresholdAnalysis(), + names: { + "threshold:failures": "failures on runs", + "absence:failures": "No failures on runs", + "threshold:took": "took on runs", + }, + ...partial, + }; +} + +const fieldset = (step: string) => + document.querySelector(`fieldset[data-step="${step}"]`); +const combobox = (step: string, label?: string) => + Array.from( + fieldset(step)?.querySelectorAll("button[role=combobox]") ?? [] + ).find((b) => !label || b.getAttribute("aria-label") === label) ?? null; +const popover = (select: HTMLElement) => + document.getElementById(select.getAttribute("aria-controls") ?? ""); +const options = (select: HTMLElement) => + Array.from(popover(select)?.querySelectorAll("[role=option]") ?? []); +const click = (el: HTMLElement) => act(() => el.click()); +function openSelect(select: HTMLElement) { + act(() => select.focus()); + act(() => select.click()); +} +function optionLabels(step: string, label?: string) { + const select = combobox(step, label)!; + openSelect(select); + const labels = options(select).map((o) => o.textContent); + act(() => select.click()); + return labels; +} +function pick(step: string, label: string, aria?: string) { + const select = combobox(step, aria)!; + openSelect(select); + const found = options(select).find((o) => o.textContent === label); + if (!found) throw new Error(`no ${label}: ${options(select).map((o) => o.textContent)}`); + click(found); +} +const checkbox = (step: string, label: string) => + Array.from(fieldset(step)!.querySelectorAll("[data-checkbox-list] label")) + .find((l) => l.textContent === label)! + .querySelector("input")!; +const button = (label: string) => + Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === label + ); +const input = (label: string) => + document.querySelector(`input[aria-label="${label}"]`)!; +function type(el: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +let container: HTMLElement; +let root: Root; +const onCreated = vi.fn(); + +function render(queryPrefill: QueryAlertPrefill) { + act(() => + root.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" } as ComponentProps, + createElement( + ShortcutsProvider, + null, + createElement( + ManualAlertCreationDock, + { + open: true, + prefill: {}, + queryPrefill, + alertDetailPath: (id: string) => `/alerts/${id}`, + onCreated, + onClose: vi.fn(), + }, + createElement("div", { "data-testid": "page" }, "page") + ) + ) + ) + ) + ); +} + +describe("QueryAlertForm", () => { + beforeEach(() => { + fetchMock.mockClear(); + onCreated.mockClear(); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("shows the query read-only as the first section", () => { + render(prefill()); + const form = document.querySelector("[data-testid=query-alert-form]")!; + const first = form.firstElementChild as HTMLElement; + expect(first.getAttribute("data-step")).toBe("query"); + expect(first.querySelector("[data-testid=query-alert-query]")!.textContent).toBe(QUERY); + expect(first.querySelector("input, textarea")).toBeNull(); + }); + + it("hides Signal with one measure and shows it with two measures or two modes", () => { + render(prefill()); + expect(document.querySelector("[data-testid=query-alert-form]")).not.toBeNull(); + expect(fieldset("signal")).toBeNull(); + + render(prefill({ query: `${QUERY} `, analysis: thresholdAnalysis([FAILURES, TOOK]) })); + expect(optionLabels("signal")).toEqual(["failures", "took"]); + + render( + prefill({ + query: `${QUERY} `, + analysis: analysis({ + modes: [ + ...thresholdAnalysis().modes, + { mode: "match", sourceId: "runs", rowIdColumn: "run_id", eventColumn: "created_at" }, + ], + }), + }) + ); + expect(optionLabels("signal")).toEqual(["failures", "New runs rows"]); + }); + + it("offers the absence condition only when the analysis allows it", () => { + render(prefill()); + expect(optionLabels("condition", "Condition")).toEqual([ + "Value crosses a threshold", + "No matching results for the window", + ]); + + render(prefill({ query: `${QUERY} `, analysis: thresholdAnalysis([FAILURES], false) })); + expect(combobox("condition", "Condition")).toBeNull(); + expect(fieldset("condition")!.textContent).toContain("Value crosses a threshold"); + expect(fieldset("condition")!.textContent).not.toContain("No matching results"); + }); + + it("defaults the window to min(period, 24h) floored at the minimum cadence (catches a removed floor)", () => { + render(prefill({ period: "5m" })); + expect(input("Window").value).toBe("10"); + expect(fieldset("window")!.textContent).toContain("At least 10 min, at most 1440 min."); + + render(prefill({ query: `${QUERY} `, period: "7d" })); + expect(input("Window").value).toBe("1440"); + + render(prefill({ query: `${QUERY} `, period: "30m" })); + expect(input("Window").value).toBe("30"); + }); + + it("caps the default window at the analysis max window (catches a removed cap)", () => { + render( + prefill({ + analysis: { ...thresholdAnalysis(), limits: { ...LIMITS, maxWindowSec: 3600 } }, + }) + ); + expect(input("Window").value).toBe("60"); + expect(fieldset("window")!.textContent).toContain("at most 60 min."); + }); + + it.each([400, 422, 500])("shows the server error on a %i create response", async (status) => { + const message = "Quiet window must be at least 600 seconds"; + createResponse = () => new Response(JSON.stringify({ error: message }), { status }); + try { + render(prefill()); + type(input("Threshold"), "3"); + pick("expiry", "For 24 hours"); + click(checkbox("notifiers", "Ops webhook")); + await act(async () => button("Create alert")!.click()); + expect(document.body.textContent).toContain( + status === 500 ? "Failed to create the alert. Try again." : message + ); + expect(onCreated).not.toHaveBeenCalled(); + } finally { + createResponse = () => new Response(JSON.stringify({ ok: true, alertId: "a1" })); + } + }); + + it("has no Window in match mode and previews with the default lookback", async () => { + render( + prefill({ + period: "2h", + analysis: analysis({ + modes: [ + { mode: "match", sourceId: "runs", rowIdColumn: "run_id", eventColumn: "created_at" }, + ], + }), + }) + ); + expect(fieldset("condition")!.textContent).toContain("New matching results appear"); + expect(fieldset("window")).toBeNull(); + previewResponse = () => + new Response(JSON.stringify({ ok: false, reason: "The query could not be evaluated." })); + await act(async () => button("Preview")!.click()); + const call = fetchMock.mock.calls.find(([url]) => url === PREVIEW_PATH)!; + expect(JSON.parse((call as unknown as [string, RequestInit])[1].body as string)).toEqual({ + query: QUERY, + mode: "match", + windowMinutes: 120, + }); + expect(document.body.textContent).toContain("The query could not be evaluated."); + }); + + it("blocks Create until the threshold is a number", () => { + render(prefill()); + pick("expiry", "For 24 hours"); + click(checkbox("notifiers", "Ops webhook")); + expect(button("Create alert")!.disabled).toBe(true); + type(input("Threshold"), "abc"); + expect(button("Create alert")!.disabled).toBe(true); + type(input("Threshold"), "3"); + expect(button("Create alert")!.disabled).toBe(false); + }); + + it("has no Frequency control", () => { + render( + prefill({ + analysis: analysis({ + modes: [ + ...thresholdAnalysis([FAILURES, TOOK]).modes, + { mode: "match", sourceId: "runs", rowIdColumn: "run_id", eventColumn: "created_at" }, + ], + }), + }) + ); + const panel = document.querySelector("[data-testid=manual-alert-panel]")!; + expect(panel.textContent).not.toMatch(/frequency/i); + }); + + it("posts the query and the signal choice and reports the created id", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-01-01T00:00:00.000Z") }); + try { + render(prefill({ analysis: thresholdAnalysis([FAILURES, TOOK]) })); + pick("signal", "took"); + expect(input("Name").value).toBe("took on runs"); + type(input("Threshold"), "5"); + pick("condition", "<", "Operator"); + pick("expiry", "For 24 hours"); + click(checkbox("notifiers", "Ops webhook")); + await act(async () => button("Create alert")!.click()); + const calls = fetchMock.mock.calls.filter(([url]) => url === "/resources/alerts/create"); + expect(calls).toHaveLength(1); + expect(JSON.parse((calls[0] as unknown as [string, RequestInit])[1].body as string)).toEqual({ + organizationId: "org1", + projectId: "proj1", + environmentId: "env1", + name: "took on runs", + expiry: "24h", + form: { + capabilityKey: "custom.query", + scopeType: "all", + conditionKind: "threshold", + operator: "<", + value: 5, + windowMinutes: 1440, + query: QUERY, + querySignalChoice: { mode: "threshold", measure: "took" }, + expiresAt: "2026-01-02T00:00:00.000Z", + notifierChannelIds: ["ch1"], + }, + }); + expect(onCreated).toHaveBeenCalledWith("a1"); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps an edited name when the signal changes", () => { + render(prefill({ analysis: thresholdAnalysis([FAILURES, TOOK]) })); + expect(input("Name").value).toBe("failures on runs"); + type(input("Name"), "My alert"); + pick("signal", "took"); + expect(input("Name").value).toBe("My alert"); + }); + + it("previews the alert and reports a busy lane", async () => { + render(prefill()); + pick("condition", "No matching results for the window", "Condition"); + previewResponse = () => + new Response( + JSON.stringify({ + ok: true, + mode: "absence", + value: 0, + wouldFire: true, + window: { minutes: 1440, from: "", to: "" }, + }) + ); + await act(async () => button("Preview")!.click()); + const call = fetchMock.mock.calls.find(([url]) => url === PREVIEW_PATH)!; + expect(JSON.parse((call as unknown as [string, RequestInit])[1].body as string)).toEqual({ + query: QUERY, + mode: "absence", + measure: "failures", + windowMinutes: 1440, + }); + expect(document.body.textContent).toContain("Matching rows in the window: 0"); + expect(document.body.textContent).toContain("Would fire now"); + + previewResponse = () => new Response(JSON.stringify({ error: "busy" }), { status: 429 }); + type(input("Window"), "60"); + await act(async () => button("Preview")!.click()); + expect(document.body.textContent).toContain("Another query is running, try again"); + }); + + it("shows the analyzer messages and a way back when the query has no alert mode", () => { + render( + prefill({ + analysis: analysis({ + ok: false, + errors: [{ code: "parse_error", message: "Syntax error near FROM." }], + }), + }) + ); + expect(document.querySelector("[data-testid=query-alert-form]")).toBeNull(); + const unavailable = document.querySelector("[data-testid=query-alert-unavailable]")!; + expect(unavailable.textContent).toContain("Syntax error near FROM."); + expect(unavailable.textContent).not.toContain("parse_error"); + const back = Array.from(unavailable.querySelectorAll("a")).find( + (a) => a.textContent === "Back to query" + )!; + expect(back.getAttribute("href")).toBe("/orgs/org/projects/proj/env/prod/query"); + }); +}); diff --git a/apps/webapp/app/components/alerts/QueryAlertForm.tsx b/apps/webapp/app/components/alerts/QueryAlertForm.tsx new file mode 100644 index 00000000000..156ef207c54 --- /dev/null +++ b/apps/webapp/app/components/alerts/QueryAlertForm.tsx @@ -0,0 +1,279 @@ +import type { ReactNode } from "react"; +import { AlertChannelsDialog } from "~/components/alerts/AlertChannelsDialog"; +import { + QUERY_CONDITION_LABELS, + querySignalOptions, + watchBlockedReasons, + type QueryAlertMode, + type QueryAlertPrefill, +} from "~/components/alerts/queryAlertLink"; +import { QueryBlock } from "~/components/alerts/QueryBlock"; +import { QueryPreviewResult } from "~/components/alerts/QueryPreviewResult"; +import { useQueryAlertDraft } from "~/components/alerts/useQueryAlertDraft"; +import { NO_NOTIFIER } from "~/components/alerts/wizardSteps"; +import { ManualStep } from "~/components/dashboard-agent/alert-creation/ManualCreationForm"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Input } from "~/components/primitives/Input"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { queryPath } from "~/utils/pathBuilder"; + +const SINGLE_FIELD = "w-full max-w-[372px]"; +const OPERATORS = [ + { id: ">", label: ">" }, + { id: "<", label: "<" }, +]; + +type Choice = { id: string; label: string }; + +function Section({ + id, + title, + hint, + children, +}: { + id: string; + title: string; + hint?: string; + children: ReactNode; +}) { + return ( +
+ {title} + {children} + {hint ?

{hint}

: null} +
+ ); +} + +function ChoiceSelect({ + label, + value, + options, + onChange, + className = SINGLE_FIELD, +}: { + label: string; + value: string; + options: Choice[]; + onChange: (value: string) => void; + className?: string; +}) { + return ( + + ); +} + +function QueryAlertUnavailable({ reasons }: { reasons: string[] }) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + return ( +
+ + {reasons.map((reason) => ( +

{reason}

+ ))} +
+ + Back to query + +
+ ); +} + +export function QueryAlertForm({ + prefill, + onCreated, +}: { + prefill: QueryAlertPrefill; + onCreated: (alertId: string | undefined) => void; +}) { + const reasons = watchBlockedReasons(prefill.analysis, prefill.query); + if (reasons.length || querySignalOptions(prefill.analysis).length === 0) { + return ; + } + return ; +} + +function QueryAlertFields({ + prefill, + onCreated, +}: { + prefill: QueryAlertPrefill; + onCreated: (alertId: string | undefined) => void; +}) { + const draft = useQueryAlertDraft(prefill, onCreated); + const { + analysis, + signals, + signal, + setSignalId, + modes, + mode, + setModeChoice, + operator, + setOperator, + valueText, + setValueText, + bounds, + windowText, + setWindowText, + windowValid, + name, + setEditedName, + flow, + addChannel, + addingChannel, + setAddingChannel, + expiryStep, + notifiersStep, + hasNotifier, + selectStep, + } = draft; + + return ( +
+
+ +
+ {signals.length > 1 ? ( +
+ +
+ ) : null} +
+ {modes.length > 1 ? ( + ({ id: m, label: QUERY_CONDITION_LABELS[m] }))} + onChange={(next) => setModeChoice(next as QueryAlertMode)} + /> + ) : ( +

{QUERY_CONDITION_LABELS[mode]}

+ )} + {mode === "threshold" ? ( +
+ setOperator(next as ">" | "<")} + className="w-16 shrink-0" + /> + setValueText(e.target.value)} + /> +
+ ) : null} +
+ {mode === "match" ? null : ( +
+
+ setWindowText(e.target.value)} + /> + min +
+
+ )} + + +
+ setEditedName(e.target.value)} + /> +
+ {draft.preview ? ( + + ) : null} + {draft.createError ? {draft.createError} : null} +
+ + +
+ {addingChannel ? ( + { + if (!flow.answers.notifiers?.includes(channel.id)) flow.toggleNotifier(channel.id); + }} + onClose={() => { + setAddingChannel(false); + addChannel.onCreated(); + }} + /> + ) : null} +
+ ); +} diff --git a/apps/webapp/app/components/alerts/QueryAlertTranscript.dom.test.ts b/apps/webapp/app/components/alerts/QueryAlertTranscript.dom.test.ts new file mode 100644 index 00000000000..5ae848fb06c --- /dev/null +++ b/apps/webapp/app/components/alerts/QueryAlertTranscript.dom.test.ts @@ -0,0 +1,377 @@ +// @vitest-environment jsdom +import { EyeIcon } from "@heroicons/react/20/solid"; +import type * as RemixReactModule from "@remix-run/react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createElement, useState, type ComponentProps, type ReactNode } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import type { QueryAlertAnalysis } from "~/v3/services/alerts/queryAlerts/analyzeQueryForAlert.server"; +import { QueryAlertTranscript, type QueryWatchPrefill } from "./QueryAlertTranscript"; + +vi.mock("@remix-run/react", async (importOriginal) => ({ + ...(await importOriginal()), + useInRouterContext: () => false, + Link: ({ to, children }: { to: string; children: ReactNode }) => + createElement("a", { href: to }, children), + useNavigate: () => vi.fn(), + useLocation: () => ({ pathname: "/", search: "" }), + useFetcher: () => { + const [url, setUrl] = useState(); + return { + data: url?.startsWith("/resources/alerts/channels") + ? { channels: [{ id: "ch1", name: "Ops webhook", type: "WEBHOOK" }], canWrite: true } + : undefined, + load: setUrl, + }; + }, +})); +vi.mock("~/hooks/useOrganizations", () => ({ + useOptionalOrganization: () => ({ slug: "org", id: "org1" }), + useOrganization: () => ({ slug: "org", id: "org1" }), +})); +vi.mock("~/hooks/useProject", () => ({ + useOptionalProject: () => ({ slug: "proj", id: "proj1" }), + useProject: () => ({ slug: "proj", id: "proj1" }), +})); +vi.mock("~/hooks/useEnvironment", () => ({ + useOptionalEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), + useEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), +})); + +const ANALYZE_PATH = "/resources/orgs/org/projects/proj/env/prod/alerts/analyze-query"; +const PREVIEW_PATH = "/resources/orgs/org/projects/proj/env/prod/alerts/preview-query"; +const QUERY = "SELECT count() AS failures, avg(duration) AS took FROM runs"; +const LIMITS = { + minCadenceSec: 600, + maxWindowSec: 86_400, + maxGroups: 500, + maxMatchRowsPerEval: 200, + detailRows: 10, +}; +const FAILURES = { column: "failures", type: "UInt64", countLike: true }; +const TOOK = { column: "took", type: "Float64", countLike: false }; +const MATCH = { + mode: "match" as const, + sourceId: "runs", + rowIdColumn: "run_id", + eventColumn: "created_at", +}; + +function analysis(partial: Partial): QueryAlertAnalysis { + return { + ok: true, + analyzerVersion: 1, + sources: [{ sourceId: "runs" }], + columns: [], + modes: [], + unsupported: [], + errors: [], + limits: LIMITS, + suggestedName: "failures on runs", + ...partial, + }; +} + +function thresholdAnalysis(measures = [FAILURES]) { + return analysis({ + modes: [ + { mode: "threshold", shape: "scalar", measures, groupColumns: [], absenceAllowed: true }, + ], + }); +} + +const NAMES = { + "threshold:failures": "failures on runs", + "absence:failures": "No failures on runs", + "threshold:took": "took on runs", + "match:": "New runs rows", +}; + +let analyzed: QueryAlertAnalysis = thresholdAnalysis(); +const fetchMock = vi.fn(async (url: string | URL | Request, _init?: RequestInit) => { + if (String(url) === ANALYZE_PATH) { + return new Response(JSON.stringify({ analysis: analyzed, names: NAMES })); + } + if (String(url) === "/resources/alerts/create") { + return new Response(JSON.stringify({ ok: true, alertId: "a1" })); + } + if (String(url) === PREVIEW_PATH) { + return new Response( + JSON.stringify({ + ok: true, + mode: "threshold", + value: 7, + wouldFire: true, + window: { minutes: 60, from: "", to: "" }, + }) + ); + } + return new Response("{}"); +}); + +Element.prototype.scrollIntoView ??= () => {}; +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +let container: HTMLElement; +let root: Root; + +async function mount( + prefill: Partial = {}, + chat?: ComponentProps["chat"] +) { + await act(async () => + root.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" } as ComponentProps, + createElement( + ShortcutsProvider, + null, + createElement(QueryAlertTranscript, { + prefill: { query: QUERY, period: "1h", ...prefill }, + alertDetailPath: (id: string) => `/alerts/${id}`, + environmentTitle: "Production", + chat, + }) + ) + ) + ) + ); +} + +const card = () => document.querySelector("[role=dialog]"); +const question = () => card()?.getAttribute("aria-label"); +const rows = () => + Array.from(card()!.querySelectorAll("li button")).map((b) => b.textContent); +const row = (label: string) => { + const found = Array.from(card()!.querySelectorAll("li button")).find( + (b) => b.textContent === label + ); + if (!found) throw new Error(`no ${label}: ${rows()}`); + return found; +}; +const choose = (label: string) => act(() => row(label).click()); +const button = (label: string) => + Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent === label + )!; +function type(el: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); + }); +} +const input = (label: string) => + document.querySelector(`input[aria-label="${label}"]`)!; + +describe("QueryAlertTranscript", () => { + beforeEach(() => { + analyzed = thresholdAnalysis(); + fetchMock.mockClear(); + vi.stubGlobal("fetch", fetchMock); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("analyzes on the server and opens on a read-only Query step", async () => { + await mount(); + const call = fetchMock.mock.calls.find(([url]) => url === ANALYZE_PATH)!; + expect(JSON.parse(call[1]!.body as string)).toEqual({ query: QUERY }); + expect(question()).toBe("Watch this query?"); + expect(card()!.querySelector("[data-testid=query-alert-query]")!.textContent).toBe(QUERY); + expect(card()!.querySelector("input, textarea")).toBeNull(); + const block = card()!.querySelector("[data-testid=query-alert-query]")!; + const keywords = Array.from(block.querySelectorAll(".token.keyword")).map((t) => t.textContent); + expect(keywords).toEqual(expect.arrayContaining(["SELECT", "AS", "FROM"])); + expect(block.querySelector("button")).toBeNull(); + }); + + it("skips Signal with one choice and asks for it with two", async () => { + await mount(); + choose("Continue"); + expect(question()).toBe("When should it fire?"); + + act(() => root.unmount()); + root = createRoot(container); + analyzed = thresholdAnalysis([FAILURES, TOOK]); + await mount(); + choose("Continue"); + expect(question()).toBe("Which value should it watch?"); + expect(rows()).toEqual(["failures", "took"]); + }); + + it("has no Window step in match mode", async () => { + analyzed = analysis({ modes: [MATCH] }); + await mount(); + choose("Continue"); + expect(rows()).toEqual(["New matching results appear"]); + choose("New matching results appear"); + expect(question()).toBe("How long should it stay active?"); + }); + + it("shows the analyzer messages and no steps when nothing is supported", async () => { + analyzed = analysis({ + ok: false, + unsupported: [ + { mode: "match", code: "limit", message: "Remove LIMIT to watch new rows." }, + { mode: "threshold", code: "no_measure", message: "Threshold alerts need a number." }, + ], + }); + await mount(); + expect(card()).toBeNull(); + const unavailable = document.querySelector("[data-testid=query-alert-unavailable]")!; + expect(unavailable.textContent).toContain("Remove LIMIT to watch new rows."); + expect(unavailable.textContent).toContain("Threshold alerts need a number."); + }); + + it("reviews, previews and creates with the query and the signal choice", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-01-01T00:00:00.000Z") }); + try { + analyzed = thresholdAnalysis([FAILURES, TOOK]); + await mount(); + choose("Continue"); + choose("took"); + choose("Value crosses a threshold"); + type(input("Threshold"), "5"); + choose("Below"); + expect(question()).toBe("Over what window?"); + expect(input("Window").value).toBe("60"); + choose("Continue"); + choose("For 24 hours"); + choose("Ops webhook"); + act(() => button("Continue").click()); + expect(question()).toBe("What should it be called?"); + expect(input("Name").value).toBe("took on runs"); + choose("Continue"); + + expect(card()).toBeNull(); + const review = document.querySelector("[data-testid=query-alert-review]")!; + expect(review.querySelector("[data-testid=query-alert-query]")!.textContent).toBe(QUERY); + expect(review.textContent).toContain("took < 5 for 60m"); + expect(review.textContent).toContain("Watch"); + expect(review.textContent).toContain("Production · Custom query · Recurring"); + expect(review.textContent).toContain("Notify: Ops webhook"); + const eye = renderToStaticMarkup(createElement(EyeIcon)).match(/ d="([^"]+)"/)![1]; + expect(review.querySelector("svg path")!.getAttribute("d")).toBe(eye); + expect(Array.from(review.querySelectorAll("button")).map((b) => b.textContent)).toEqual([ + "Create alert", + "Preview", + "Customize", + "Start over", + ]); + + await act(async () => button("Preview").click()); + const preview = fetchMock.mock.calls.find(([url]) => url === PREVIEW_PATH)!; + expect(JSON.parse(preview[1]!.body as string)).toEqual({ + query: QUERY, + mode: "threshold", + measure: "took", + windowMinutes: 60, + threshold: 5, + operator: "<", + }); + expect(review.textContent).toContain("Current value: 7"); + + await act(async () => button("Create alert").click()); + const calls = fetchMock.mock.calls.filter(([url]) => url === "/resources/alerts/create"); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0]![1]!.body as string)).toEqual({ + organizationId: "org1", + projectId: "proj1", + environmentId: "env1", + name: "took on runs", + expiry: "24h", + form: { + capabilityKey: "custom.query", + scopeType: "all", + conditionKind: "threshold", + operator: "<", + value: 5, + windowMinutes: 60, + query: QUERY, + querySignalChoice: { mode: "threshold", measure: "took" }, + expiresAt: "2026-01-02T00:00:00.000Z", + notifierChannelIds: ["ch1"], + }, + }); + expect(document.querySelector("[data-testid=query-alert-review]")).toBeNull(); + const created = document.querySelector("[data-testid=alert-created]")!; + expect(created.textContent).toContain("took on runs is active."); + expect(created.textContent).toContain("It expires in 24h."); + expect(created.textContent).toContain("took < 5 for 60m · Production · Custom query"); + expect(created.textContent).toContain("Notifying: Ops webhook."); + expect(created.textContent).toContain("It's at the top of your alerts list."); + const view = Array.from(created.querySelectorAll("a")).find( + (a) => a.textContent === "View alert" + )!; + expect(view.getAttribute("href")).toBe("/alerts/a1"); + } finally { + vi.useRealTimers(); + } + }); + + async function reachReview() { + choose("Continue"); + choose("Value crosses a threshold"); + type(input("Threshold"), "5"); + choose("Above"); + choose("Continue"); + choose("For 24 hours"); + } + + it("offers This chat in the agent, preselected, and sends the chat ids", async () => { + const release = vi.fn(); + await mount({}, { chatId: "chat_on_use", ensure: async () => ({ chatId: "chat_1", release }) }); + reachReview(); + expect(rows()).toContain("This chat"); + act(() => button("Continue").click()); + choose("Continue"); + await act(async () => button("Create alert").click()); + const call = fetchMock.mock.calls.find(([url]) => url === "/resources/alerts/create")!; + expect(JSON.parse(call[1]!.body as string)).toMatchObject({ + chatNotifier: "on", + chatId: "chat_1", + transcriptChatId: "chat_1", + form: { notifierChannelIds: [] }, + }); + expect(release).not.toHaveBeenCalled(); + }); + + it("keeps transcriptChatId when only a channel is chosen", async () => { + await mount({}, { chatId: "chat_on_use", ensure: async () => ({ chatId: "chat_1" }) }); + reachReview(); + choose("This chat"); + choose("Ops webhook"); + act(() => button("Continue").click()); + choose("Continue"); + await act(async () => button("Create alert").click()); + const call = fetchMock.mock.calls.find(([url]) => url === "/resources/alerts/create")!; + const body = JSON.parse(call[1]!.body as string); + expect(body).toMatchObject({ + transcriptChatId: "chat_1", + form: { notifierChannelIds: ["ch1"] }, + }); + expect(body.chatNotifier).toBeUndefined(); + expect(body.chatId).toBeUndefined(); + }); + + it("offers no This chat in the manual dock", async () => { + await mount(); + reachReview(); + expect(rows()).not.toContain("This chat"); + }); +}); diff --git a/apps/webapp/app/components/alerts/QueryAlertTranscript.tsx b/apps/webapp/app/components/alerts/QueryAlertTranscript.tsx new file mode 100644 index 00000000000..9e71a8b2afd --- /dev/null +++ b/apps/webapp/app/components/alerts/QueryAlertTranscript.tsx @@ -0,0 +1,477 @@ +import { type ReactNode, useEffect, useState } from "react"; +import { AlertChannelsDialog } from "~/components/alerts/AlertChannelsDialog"; +import { alertCreatedBlock, REVIEW_MESSAGE } from "~/components/alerts/alertCreatedCard"; +import { AlertCreatedCard, WatchStyleReviewCard } from "~/components/alerts/creationCards"; +import { NewChatButton } from "~/components/alerts/creationControls"; +import { + QUERY_CONDITION_LABELS, + queryConditionModes, + querySignalOptions, + UNUSABLE_QUERY, + watchBlockedReasons, + type QueryAlertMode, + type QueryAlertPrefill, +} from "~/components/alerts/queryAlertLink"; +import { QueryBlock } from "~/components/alerts/QueryBlock"; +import { QueryPreviewResult } from "~/components/alerts/QueryPreviewResult"; +import { useQueryAlertDraft, type QueryAlertChat } from "~/components/alerts/useQueryAlertDraft"; +import { CONTINUE_OPTION, NO_NOTIFIER } from "~/components/alerts/wizardSteps"; +import { notifyLabel } from "~/components/dashboard-agent/alert-creation/CreationFlow"; +import { + CUSTOM_EXPIRY, + expiryLabel, + isExpiryTimestamp, + type DraftPrefillParams, +} from "~/components/dashboard-agent/alert-creation/flow-state"; +import { ChatText, ChatTranscript, ChatTurn } from "~/components/dashboard-agent/chat-layout"; +import { ChatWizardCard, type ChatWizardStep } from "~/components/dashboard-agent/ChatWizardCard"; +import { Button } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Spinner } from "~/components/primitives/Spinner"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { v3EnvironmentPath } from "~/utils/pathBuilder"; + +export type QueryWatchPrefill = { + query: string; + period?: string; + mode?: QueryAlertMode; + measure?: string; + operator?: ">" | "<"; +}; + +export function queryWatchPrefill( + prefill: DraftPrefillParams | undefined +): QueryWatchPrefill | undefined { + if (prefill?.source !== "query" || !prefill.query) return undefined; + const { query, period, mode, measure } = prefill; + return { query, period, mode, measure }; +} + +type Analyzed = Pick; + +const DEFAULT_PERIOD = "7d"; +const QUERY_SCOPE_LABEL = "Custom query"; +const NEEDS_THRESHOLD = "Enter a threshold first"; +const OPERATORS = [ + { id: ">", label: "Above" }, + { id: "<", label: "Below" }, +]; + +export function QueryAlertTranscript({ + prefill, + alertDetailPath, + onNewChat, + onCreated, + environmentTitle, + chat, +}: { + prefill: QueryWatchPrefill; + alertDetailPath: (alertId: string) => string; + onNewChat?: () => void; + onCreated?: (info: { chatId?: string; name: string }) => void; + environmentTitle?: string; + chat?: QueryAlertChat; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const analyzePath = `/resources${v3EnvironmentPath(organization, project, environment)}/alerts/analyze-query`; + const [analyzed, setAnalyzed] = useState(); + + useEffect(() => { + let current = true; + fetch(analyzePath, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: prefill.query }), + }) + .then(async (resp) => (resp.ok ? ((await resp.json()) as Analyzed) : "error")) + .catch(() => "error" as const) + .then((result) => { + if (current) setAnalyzed(result); + }); + return () => { + current = false; + }; + }, [analyzePath, prefill.query]); + + const full: QueryAlertPrefill | undefined = + analyzed && analyzed !== "error" + ? { + query: prefill.query, + period: prefill.period ?? DEFAULT_PERIOD, + mode: prefill.mode, + measure: prefill.measure, + operator: prefill.operator, + ...analyzed, + } + : undefined; + const blocked = full ? watchBlockedReasons(full.analysis, full.query) : []; + const reasons = + blocked.length || !full || querySignalOptions(full.analysis).length + ? blocked + : [UNUSABLE_QUERY]; + + const header = ( + + + + ); + + if (!analyzed) { + return ( + +
+ {header} + + + +
+
+ ); + } + + if (!full || reasons.length) { + return ( + +
+ {header} + +
+ + + {(full ? reasons : [UNUSABLE_QUERY]).map((reason) => ( +

{reason}

+ ))} +
+
+
+
+
+ ); + } + + return ( + + ); +} + +function stepIds(signalCount: number, mode: QueryAlertMode): string[] { + return [ + "query", + ...(signalCount > 1 ? ["signal"] : []), + "condition", + ...(mode === "threshold" ? ["threshold"] : []), + ...(mode === "match" ? [] : ["window"]), + "expiry", + "notifiers", + "name", + ]; +} + +function QueryAlertChat({ + prefill, + header, + alertDetailPath, + onNewChat, + onCreated, + environmentTitle, + chat, +}: { + prefill: QueryAlertPrefill; + header: ReactNode; + alertDetailPath: (alertId: string) => string; + onNewChat?: () => void; + onCreated?: (info: { chatId?: string; name: string }) => void; + environmentTitle?: string; + chat?: QueryAlertChat; +}) { + const [created, setCreated] = useState<{ id?: string }>(); + const draft = useQueryAlertDraft( + prefill, + (id, chatId) => { + setCreated({ id }); + onCreated?.({ chatId, name: draft.name }); + }, + chat + ); + const { analysis, signals, mode, flow, bounds } = draft; + const [stepId, setStepId] = useState("query"); + const [answered, setAnswered] = useState([]); + const [reviewing, setReviewing] = useState(false); + const [closed, setClosed] = useState(false); + + const ids = stepIds(signals.length, mode); + const done = (id: string) => answered.includes(id); + const advance = (from: string, nextMode: QueryAlertMode = mode) => { + setAnswered((prev) => (prev.includes(from) ? prev : [...prev, from])); + const order = stepIds(signals.length, nextMode); + const next = order[order.indexOf(from) + 1]; + if (next) setStepId(next); + else setReviewing(true); + }; + const startOver = () => { + setAnswered([]); + setStepId("query"); + setReviewing(false); + setClosed(false); + }; + + const continueOption = (disabledReason?: string) => [ + { id: CONTINUE_OPTION, label: "Continue", disabledReason }, + ]; + const stepsById: Record = { + query: { + id: "query", + question: "Watch this query?", + allowSkip: false, + content: , + options: continueOption(), + selected: done("query") ? [CONTINUE_OPTION] : [], + }, + signal: { + id: "signal", + question: "Which value should it watch?", + allowSkip: false, + options: signals.map((s) => ({ id: s.id, label: s.label })), + selected: done("signal") ? [draft.signal.id] : [], + }, + condition: { + id: "condition", + question: "When should it fire?", + allowSkip: false, + options: draft.modes.map((m) => ({ id: m, label: QUERY_CONDITION_LABELS[m] })), + selected: done("condition") ? [mode] : [], + }, + threshold: { + id: "threshold", + question: `Fire when ${draft.signal.measure ?? "the value"} is`, + allowSkip: false, + fields: [ + { + id: "value", + ariaLabel: "Threshold", + placeholder: "1000", + numeric: true, + live: true, + current: draft.valueText || undefined, + parse: (text) => (text.trim() && Number.isFinite(Number(text)) ? text.trim() : undefined), + onSubmit: draft.setValueText, + onClear: () => draft.setValueText(""), + }, + ], + options: OPERATORS.map((o) => ({ + ...o, + disabledReason: draft.valueValid ? undefined : NEEDS_THRESHOLD, + })), + selected: (done("threshold") || prefill.operator) && draft.valueValid ? [draft.operator] : [], + }, + window: { + id: "window", + question: "Over what window?", + description: `At least ${bounds.min} min, at most ${bounds.max} min.`, + allowSkip: false, + fields: [ + { + id: "window", + ariaLabel: "Window", + unit: "min", + numeric: true, + live: true, + current: draft.windowText, + rule: { min: bounds.min, max: bounds.max }, + parse: (text) => { + const n = Number(text.trim()); + return Number.isInteger(n) && n >= bounds.min && n <= bounds.max + ? String(n) + : undefined; + }, + onSubmit: draft.setWindowText, + }, + ], + options: continueOption(draft.windowValid ? undefined : "Enter a valid window"), + selected: done("window") && draft.windowValid ? [CONTINUE_OPTION] : [], + }, + expiry: draft.expiryStep, + notifiers: { + ...draft.notifiersStep, + confirmLabel: "Continue", + confirmDisabled: !draft.hasNotifier || flow.answers.expiry === undefined, + error: draft.hasNotifier ? undefined : NO_NOTIFIER, + }, + name: { + id: "name", + question: "What should it be called?", + allowSkip: false, + fields: [ + { + id: "name", + ariaLabel: "Name", + live: true, + current: draft.name, + parse: (text) => text.trim() || undefined, + onSubmit: draft.setEditedName, + }, + ], + options: continueOption(draft.name.trim() ? undefined : "Enter a name"), + selected: done("name") ? [CONTINUE_OPTION] : [], + }, + }; + const steps = ids.map((id) => stepsById[id]!); + const stepIndex = Math.max(0, ids.indexOf(stepId)); + + function select(id: string, value: string) { + switch (id) { + case "signal": { + draft.setSignalId(value); + const signal = signals.find((s) => s.id === value); + const modes = queryConditionModes(analysis, signal); + advance(id, modes.includes(mode) ? mode : modes[0]); + return; + } + case "condition": + draft.setModeChoice(value as QueryAlertMode); + advance(id, value as QueryAlertMode); + return; + case "threshold": + if (!draft.valueValid) return; + draft.setOperator(value as ">" | "<"); + advance(id); + return; + case "expiry": + draft.selectStep(id, value); + if (value !== CUSTOM_EXPIRY) advance(id); + return; + case "notifiers": + draft.selectStep(id, value); + return; + default: + advance(id); + } + } + + const summary = + mode === "threshold" + ? `${draft.measure} ${draft.operator} ${draft.valueText} for ${draft.windowMinutes}m` + : mode === "absence" + ? `No matching results for ${draft.windowMinutes}m` + : QUERY_CONDITION_LABELS.match; + const expiry = flow.answers.expiry; + const createdBlock = created + ? alertCreatedBlock(created.id ?? "", { + name: draft.name, + conditionSummary: summary, + scopeLabel: QUERY_SCOPE_LABEL, + environmentTitle, + behavior: "recurring", + notifyLabel: notifyLabel(flow.answers, draft.channels), + ...(isExpiryTimestamp(expiry) + ? { expiresAt: new Date(expiry!).toISOString() } + : { expiryLabel: expiryLabel(expiry) }), + }) + : undefined; + + return ( + <> + +
+ {header} + {reviewing && !created ? ( + +
+ void draft.create(), + submitting: draft.submitting, + createDisabled: !draft.canCreate, + onPreview: () => void draft.runPreview(), + previewDisabled: !draft.canPreview || draft.previewing, + onCustomize: () => { + setReviewing(false); + setClosed(false); + }, + onStartOver: onNewChat ?? startOver, + }} + detail={} + /> + {draft.preview ? ( + + ) : null} + {draft.createError ? {draft.createError} : null} +
+
+ ) : null} + {created && createdBlock ? ( + + + + + ) : null} + {closed && !reviewing && !created ? ( + + + + ) : null} +
+
+ {draft.addingChannel ? ( + { + if (!flow.answers.notifiers?.includes(channel.id)) flow.toggleNotifier(channel.id); + }} + onClose={() => { + draft.setAddingChannel(false); + draft.addChannel.onCreated(); + }} + /> + ) : null} +
+ {!closed && !reviewing && !created ? ( +
+ setStepId(steps[index]?.id ?? "query")} + onSelect={select} + onSkip={() => {}} + onClose={() => setClosed(true)} + onConfirm={(id) => advance(id)} + /> +
+ ) : null} + {onNewChat ? ( +
+ +
+ ) : null} +
+ + ); +} diff --git a/apps/webapp/app/components/alerts/QueryBlock.tsx b/apps/webapp/app/components/alerts/QueryBlock.tsx new file mode 100644 index 00000000000..a570874a1e4 --- /dev/null +++ b/apps/webapp/app/components/alerts/QueryBlock.tsx @@ -0,0 +1,17 @@ +import { CodeBlock } from "~/components/code/CodeBlock"; + +export function QueryBlock({ query }: { query: string }) { + return ( +
+ +
+ ); +} diff --git a/apps/webapp/app/components/alerts/QueryPreviewResult.tsx b/apps/webapp/app/components/alerts/QueryPreviewResult.tsx new file mode 100644 index 00000000000..2422d972ce3 --- /dev/null +++ b/apps/webapp/app/components/alerts/QueryPreviewResult.tsx @@ -0,0 +1,74 @@ +import type { PreviewQueryResponse } from "~/v3/services/alerts/previewQuery.server"; + +export type QueryPreviewOutcome = + | { kind: "result"; response: PreviewQueryResponse } + | { kind: "busy" } + | { kind: "error"; message: string }; + +function formatCell(value: unknown): string { + if (value === null || value === undefined) return ""; + return typeof value === "object" ? JSON.stringify(value) : String(value); +} + +export function QueryPreviewResult({ + outcome, + detailRows, +}: { + outcome: QueryPreviewOutcome; + detailRows: number; +}) { + if (outcome.kind === "busy") { + return

Another query is running, try again

; + } + if (outcome.kind === "error") { + return

{outcome.message}

; + } + const response = outcome.response; + if (!response.ok) { + return

{response.reason}

; + } + const rows = response.rows ?? []; + const count = response.total ?? rows.length; + return ( +
+ {response.mode === "threshold" ? ( + <> +

Current value: {response.value ?? "–"}

+ {response.offenders?.length ? ( +
    + {response.offenders.map((offender) => ( +
  • + {offender.key} + {offender.value} +
  • + ))} +
+ ) : null} + + ) : response.mode === "match" ? ( + <> +

+ {count} + {response.capped ? "+" : ""} new matching rows in the window +

+ {rows.length ? ( +
    + {rows.slice(0, detailRows).map((row) => ( +
  • + {Object.entries(row.columns) + .map(([column, value]) => `${column}: ${formatCell(value)}`) + .join(", ")} +
  • + ))} +
+ ) : null} + + ) : ( +

Matching rows in the window: {response.value ?? count}

+ )} +

+ {response.wouldFire ? "Would fire now" : "Would not fire now"} +

+
+ ); +} diff --git a/apps/webapp/app/components/alerts/WatchQueryButton.dom.test.ts b/apps/webapp/app/components/alerts/WatchQueryButton.dom.test.ts new file mode 100644 index 00000000000..857949da752 --- /dev/null +++ b/apps/webapp/app/components/alerts/WatchQueryButton.dom.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment jsdom +import type * as RemixReactModule from "@remix-run/react"; +import { createElement, type ComponentProps, type ReactNode } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import type { QueryAlertAnalysis } from "~/v3/services/alerts/queryAlerts/analyzeQueryForAlert.server"; +import { DashboardAgentProvider } from "~/components/dashboard-agent/dashboardAgentLauncher"; +import { decodeQueryParam } from "./queryAlertLink"; +import { WatchQueryButton } from "./WatchQueryButton"; + +vi.mock("@remix-run/react", async (importOriginal) => ({ + ...(await importOriginal()), + Link: ({ to, children }: { to: string; children: ReactNode }) => + createElement("a", { href: to }, children), +})); + +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +const LIMITS = { + minCadenceSec: 600, + maxWindowSec: 86_400, + maxGroups: 500, + maxMatchRowsPerEval: 200, + detailRows: 10, +}; + +function analysis(partial: Partial): QueryAlertAnalysis { + return { + ok: true, + analyzerVersion: 1, + sources: [{ sourceId: "runs" }], + columns: [], + modes: [], + unsupported: [], + errors: [], + limits: LIMITS, + suggestedName: "failures on runs", + ...partial, + }; +} + +const THRESHOLD = analysis({ + modes: [ + { + mode: "threshold", + shape: "scalar", + measures: [{ column: "failures", type: "UInt64", countLike: true }], + groupColumns: [], + absenceAllowed: true, + }, + ], +}); + +let container: HTMLElement; +let root: Root; + +function render( + props: ComponentProps, + agent?: ComponentProps["value"] +) { + act(() => + root.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" } as ComponentProps, + createElement( + ShortcutsProvider, + null, + createElement( + DashboardAgentProvider, + { value: agent ?? null }, + createElement(WatchQueryButton, props) + ) + ) + ) + ) + ); +} + +describe("WatchQueryButton", () => { + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("renders nothing before the query has been run", () => { + render({ alertsPath: "/alerts", period: "7d", watch: undefined }); + expect(container.textContent).toBe(""); + }); + + it("renders nothing when the analysis is ok but has no modes", () => { + render({ + alertsPath: "/alerts", + period: "7d", + watch: { query: "SELECT 1", analysis: analysis({}) }, + }); + expect(container.textContent).toBe(""); + }); + + it("links to the alerts page with the query as base64url (catches dropped encoding)", () => { + const query = "SELECT count() AS failures FROM runs WHERE status = 'Failed' -- ü?/+"; + render({ alertsPath: "/alerts", period: "1h", watch: { query, analysis: THRESHOLD } }); + const href = container.querySelector("a")!.getAttribute("href")!; + expect(container.querySelector("a")!.textContent).toBe("Watch"); + expect(container.querySelector("[data-testid=watch-query] a")).not.toBeNull(); + expect(container.querySelector("a svg")).not.toBeNull(); + const params = new URL(href, "http://localhost").searchParams; + expect(params.get("create")).toBe("1"); + expect(params.get("source")).toBe("query"); + expect(params.get("qperiod")).toBe("1h"); + expect(params.has("period")).toBe(false); + expect(params.get("mode")).toBe("threshold"); + expect(params.get("measure")).toBe("failures"); + const q = params.get("q")!; + expect(q).toMatch(/^[A-Za-z0-9_-]+$/); + expect(decodeQueryParam(q)).toBe(query); + }); + + it("opens the agent with the query prefill instead of navigating when the agent is on", () => { + const openCreateAlert = vi.fn(); + const query = "SELECT count() AS failures FROM runs"; + render({ alertsPath: "/alerts", period: "1h", watch: { query, analysis: THRESHOLD } }, { + openCreateAlert, + } as unknown as ComponentProps["value"]); + expect(container.querySelector("a")).toBeNull(); + const button = container.querySelector("[data-testid=watch-query] button")!; + expect(button.textContent).toBe("Watch"); + act(() => button.click()); + expect(openCreateAlert).toHaveBeenCalledWith({ + source: "query", + query, + period: "1h", + mode: "threshold", + measure: "failures", + }); + }); + + it("does not submit the surrounding query form when opening the agent", () => { + const openCreateAlert = vi.fn(); + const onSubmit = vi.fn((e: Event) => e.preventDefault()); + const form = document.createElement("form"); + form.addEventListener("submit", onSubmit); + document.body.appendChild(form); + form.appendChild(container); + render( + { + alertsPath: "/alerts", + period: "1h", + watch: { query: "SELECT count() AS failures FROM runs", analysis: THRESHOLD }, + }, + { openCreateAlert } as unknown as ComponentProps["value"] + ); + act(() => form.querySelector("button")!.click()); + expect(openCreateAlert).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + form.remove(); + }); + + it("renders nothing when the encoded query exceeds 8 KB even though it has fewer characters", () => { + const query = `SELECT count() AS failures FROM runs -- ${"é".repeat(3500)}`; + expect(query.length).toBeLessThan(8192); + render({ alertsPath: "/alerts", period: "1h", watch: { query, analysis: THRESHOLD } }); + expect(container.textContent).toBe(""); + }); + + it("renders nothing when the analysis failed", () => { + render({ alertsPath: "/alerts", period: "1h", watch: { query: "SELECT 1", analysis: null } }); + expect(container.textContent).toBe(""); + }); +}); diff --git a/apps/webapp/app/components/alerts/WatchQueryButton.tsx b/apps/webapp/app/components/alerts/WatchQueryButton.tsx new file mode 100644 index 00000000000..4b636b77917 --- /dev/null +++ b/apps/webapp/app/components/alerts/WatchQueryButton.tsx @@ -0,0 +1,58 @@ +import { BellAlertIcon } from "@heroicons/react/20/solid"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { + watchBlockedReasons, + watchQueryChoice, + watchQueryHref, +} from "~/components/alerts/queryAlertLink"; +import { useDashboardAgent } from "~/components/dashboard-agent/dashboardAgentLauncher"; +import type { QueryAlertAnalysis } from "~/v3/services/alerts/queryAlerts/analyzeQueryForAlert.server"; + +export type WatchQueryAnalysis = { query: string; analysis: QueryAlertAnalysis | null }; + +export function WatchQueryButton({ + alertsPath, + period, + watch, +}: { + alertsPath: string; + period: string; + watch: WatchQueryAnalysis | null | undefined; +}) { + const agent = useDashboardAgent(); + const analysis = watch?.analysis; + if (!watch || !analysis?.ok || watchBlockedReasons(analysis, watch.query).length > 0) { + return null; + } + return ( + + {agent ? ( + + ) : ( + + Watch + + )} + + ); +} diff --git a/apps/webapp/app/components/alerts/alertsListFilters.ts b/apps/webapp/app/components/alerts/alertsListFilters.ts index 35c00c2a7bd..e92124fdc18 100644 --- a/apps/webapp/app/components/alerts/alertsListFilters.ts +++ b/apps/webapp/app/components/alerts/alertsListFilters.ts @@ -29,7 +29,7 @@ export const MAX_LIST_ALERTS = 500; export const DEFAULT_PERIOD = "7d"; const ALL_PERIODS = "all"; const STATUS_VALUES = ["needs_attention", "active", "inactive"]; -const AREA_VALUES = ["queues", "errors", "runs", "health", "unknown"]; +const AREA_VALUES = ["queues", "errors", "runs", "health", "query", "unknown"]; function splitList(value: string | null, allowed: string[]): string[] { if (!value) return []; diff --git a/apps/webapp/app/components/alerts/createAlertRequest.ts b/apps/webapp/app/components/alerts/createAlertRequest.ts index b65ff29eaaf..2b93bbe76e8 100644 --- a/apps/webapp/app/components/alerts/createAlertRequest.ts +++ b/apps/webapp/app/components/alerts/createAlertRequest.ts @@ -66,3 +66,53 @@ export function createAlertRequestBody( }, }; } + +export type QueryAlertDraft = { + query: string; + name: string; + mode: "threshold" | "absence" | "match"; + measure?: string; + operator: ">" | "<"; + value?: number; + windowMinutes: number; + expiry?: string; + notifiers: string[]; +}; + +export type QueryAlertChatRequest = { chatId: string; transcriptChatId: string }; + +export function queryAlertRequestBody( + ids: { organizationId: string; projectId: string; environmentId: string }, + draft: QueryAlertDraft, + chat?: QueryAlertChatRequest +) { + const wantsChat = Boolean(chat) && draft.notifiers.includes("chat"); + const conditionKind = + draft.mode === "match" ? "occurrence" : draft.mode === "absence" ? "absence" : "threshold"; + return { + organizationId: ids.organizationId, + projectId: ids.projectId, + environmentId: ids.environmentId, + name: draft.name, + chatNotifier: wantsChat ? ("on" as const) : undefined, + chatId: wantsChat ? chat!.chatId : undefined, + transcriptChatId: chat?.transcriptChatId, + expiry: draft.expiry && EXPIRY_TOKEN_PATTERN.test(draft.expiry) ? draft.expiry : undefined, + form: { + capabilityKey: "custom.query", + scopeType: "all", + conditionKind, + operator: draft.mode === "threshold" ? draft.operator : undefined, + value: draft.mode === "threshold" ? draft.value : undefined, + windowMinutes: draft.mode === "threshold" ? draft.windowMinutes : undefined, + quietWindowMinutes: draft.mode === "absence" ? draft.windowMinutes : undefined, + query: draft.query, + querySignalChoice: { + mode: draft.mode, + measure: draft.mode === "match" ? undefined : draft.measure, + }, + expiresAt: expiryToIso(draft.expiry), + notifierChannelIds: draft.notifiers.filter((n) => n !== "chat"), + }, + }; +} diff --git a/apps/webapp/app/components/alerts/creationCards.tsx b/apps/webapp/app/components/alerts/creationCards.tsx index 9db137936e5..e8df630afe6 100644 --- a/apps/webapp/app/components/alerts/creationCards.tsx +++ b/apps/webapp/app/components/alerts/creationCards.tsx @@ -1,4 +1,5 @@ import { EyeIcon } from "@heroicons/react/20/solid"; +import type { ReactNode } from "react"; import { ChatActionsRow, ChatSystemBlock } from "~/components/dashboard-agent/chat-layout"; import type { AlertCreatedBlock } from "@internal/dashboard-agent-contracts"; import { WatchResultBlock } from "~/components/dashboard-agent/WatchResultBlock"; @@ -20,14 +21,19 @@ export type ReviewCardActions = { onStartOver: () => void; submitting?: boolean; blockedReason?: string; + createDisabled?: boolean; + onPreview?: () => void; + previewDisabled?: boolean; }; export function WatchStyleReviewCard({ fields, actions, + detail, }: { fields: ReviewCardFields; actions: ReviewCardActions; + detail?: ReactNode; }) { const scopeVal = fields.scopeValue ? ` · ${fields.scopeValue}` : ""; @@ -39,11 +45,22 @@ export function WatchStyleReviewCard({ + {actions.onPreview ? ( + + ) : null} @@ -54,6 +71,7 @@ export function WatchStyleReviewCard({ } >

{fields.name}

+ {detail}

{fields.conditionSummary}

{fields.environmentTitle ? `${fields.environmentTitle} · ` : null} diff --git a/apps/webapp/app/components/alerts/domainIcons.ts b/apps/webapp/app/components/alerts/domainIcons.ts index 43bb39dfc1a..b17ed052485 100644 --- a/apps/webapp/app/components/alerts/domainIcons.ts +++ b/apps/webapp/app/components/alerts/domainIcons.ts @@ -1,10 +1,11 @@ import { BugIcon } from "~/assets/icons/BugIcon"; +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; import { QueuesIcon } from "~/assets/icons/QueuesIcon"; import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon"; import { RunsIcon } from "~/assets/icons/RunsIcon"; import type { RenderIcon } from "~/components/primitives/Icon"; -export type AlertArea = "queues" | "errors" | "health" | "runs"; +export type AlertArea = "queues" | "errors" | "health" | "runs" | "query"; export const WATCHING_TOOLTIP = "What this alert keeps an eye on: queues, runs, errors, or environment health. It decides which metrics you can pick and which scopes (queue, task, error group) are available."; @@ -14,6 +15,7 @@ export const AREA_LABEL: Record = { errors: "Errors", health: "Health", runs: "Runs", + query: "Custom", }; export const AREA_ICON: Record = { @@ -21,6 +23,7 @@ export const AREA_ICON: Record = { errors: BugIcon, health: RadarPulseIcon, runs: RunsIcon, + query: CodeSquareIcon, }; export function isAlertArea(value: string): value is AlertArea { diff --git a/apps/webapp/app/components/alerts/queryAlertLink.ts b/apps/webapp/app/components/alerts/queryAlertLink.ts new file mode 100644 index 00000000000..fe3470848dc --- /dev/null +++ b/apps/webapp/app/components/alerts/queryAlertLink.ts @@ -0,0 +1,133 @@ +import type { QueryAlertAnalysis } from "~/v3/services/alerts/queryAlerts/analyzeQueryForAlert.server"; +import { parsePeriodToMs } from "~/utils/periods"; + +export const MAX_WATCH_QUERY_LENGTH = 8192; +const MAX_DEFAULT_WINDOW_MINUTES = 24 * 60; +export const UNUSABLE_QUERY = "This query can't be used for an alert."; + +export type QueryAlertMode = "threshold" | "absence" | "match"; + +export type QueryAlertPrefill = { + query: string; + period: string; + analysis: QueryAlertAnalysis; + names: Record; + mode?: QueryAlertMode; + measure?: string; + operator?: ">" | "<"; +}; + +export function queryAlertNameKey(mode: QueryAlertMode, measure?: string) { + return `${mode}:${measure ?? ""}`; +} + +export function encodeQueryParam(query: string): string { + let binary = ""; + for (const byte of new TextEncoder().encode(query)) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function decodeQueryParam(value: string): string | undefined { + try { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4)); + return new TextDecoder("utf-8", { fatal: true }).decode( + Uint8Array.from(binary, (char) => char.charCodeAt(0)) + ); + } catch { + return undefined; + } +} + +export function watchBlockedReasons(analysis: QueryAlertAnalysis, query: string): string[] { + if (encodeQueryParam(query).length > MAX_WATCH_QUERY_LENGTH) + return ["Query is too long to watch"]; + if (analysis.ok && analysis.modes.length > 0) return []; + const messages = [...analysis.errors, ...analysis.unsupported].map((issue) => issue.message); + const unique = [...new Set(messages)].slice(0, 2); + return unique.length ? unique : [UNUSABLE_QUERY]; +} + +export function watchQueryHref( + alertsPath: string, + query: string, + period: string, + analysis: QueryAlertAnalysis +): string { + const params = new URLSearchParams({ + create: "1", + source: "query", + q: encodeQueryParam(query), + qperiod: period, + }); + const choice = watchQueryChoice(analysis); + if (choice.mode) params.set("mode", choice.mode); + if (choice.measure) params.set("measure", choice.measure); + return `${alertsPath}?${params.toString()}`; +} + +export function watchQueryChoice(analysis: QueryAlertAnalysis): { + mode?: QueryAlertMode; + measure?: string; +} { + const [only] = analysis.modes; + if (analysis.modes.length !== 1 || !only) return {}; + if (only.mode === "threshold" && only.measures.length === 1 && only.measures[0]) { + return { mode: only.mode, measure: only.measures[0].column }; + } + return { mode: only.mode }; +} + +export function queryWindowBounds(limits: QueryAlertAnalysis["limits"]) { + return { + min: Math.max(1, Math.ceil(limits.minCadenceSec / 60)), + max: Math.floor(limits.maxWindowSec / 60), + }; +} + +export function defaultQueryWindowMinutes( + period: string, + limits: QueryAlertAnalysis["limits"] +): number { + const { min, max } = queryWindowBounds(limits); + const periodMinutes = Math.floor(parsePeriodToMs(period) / 60_000); + return Math.min(Math.max(Math.min(periodMinutes, MAX_DEFAULT_WINDOW_MINUTES), min), max); +} + +export type QuerySignalOption = { + id: string; + label: string; + mode: "threshold" | "match"; + measure?: string; +}; + +export function querySignalOptions(analysis: QueryAlertAnalysis): QuerySignalOption[] { + return analysis.modes.flatMap((mode): QuerySignalOption[] => + mode.mode === "threshold" + ? mode.measures.map((measure) => ({ + id: `threshold:${measure.column}`, + label: measure.column, + mode: "threshold", + measure: measure.column, + })) + : [{ id: "match", label: `New ${mode.sourceId} rows`, mode: "match" }] + ); +} + +export const QUERY_CONDITION_LABELS: Record = { + threshold: "Value crosses a threshold", + match: "New matching results appear", + absence: "No matching results for the window", +}; + +export function queryConditionModes( + analysis: QueryAlertAnalysis, + signal: QuerySignalOption | undefined +): QueryAlertMode[] { + if (!signal) return []; + if (signal.mode === "match") return ["match"]; + const threshold = analysis.modes.find((mode) => mode.mode === "threshold"); + if (threshold?.mode !== "threshold") return ["threshold"]; + const measure = threshold.measures.find((m) => m.column === signal.measure); + return threshold.absenceAllowed && measure?.countLike ? ["threshold", "absence"] : ["threshold"]; +} diff --git a/apps/webapp/app/components/alerts/queryGroupSelection.ts b/apps/webapp/app/components/alerts/queryGroupSelection.ts new file mode 100644 index 00000000000..2ff19dc89e1 --- /dev/null +++ b/apps/webapp/app/components/alerts/queryGroupSelection.ts @@ -0,0 +1,34 @@ +import type { MetricSeriesPoint } from "./alertSeriesTypes"; + +export const MAX_VISIBLE_GROUPS = 8; + +// Groups that crossed the threshold have the best values toward it, so they sort first. +export function defaultGroupSelection({ + groups, + seriesMap, + operator, + range, + max = MAX_VISIBLE_GROUPS, +}: { + groups: string[]; + seriesMap: Map; + operator?: ">" | "<"; + range?: { since: number; until: number }; + max?: number; +}): string[] { + const below = operator === "<"; + const scored = groups.map((group, index) => { + let best: number | null = null; + for (const p of seriesMap.get(group) ?? []) { + if (p.value === null) continue; + if (range && (p.bucket < range.since || p.bucket > range.until)) continue; + if (best === null || (below ? p.value < best : p.value > best)) best = p.value; + } + return { group, index, best }; + }); + const toward = (best: number | null) => (best === null ? -Infinity : below ? -best : best); + return scored + .sort((a, b) => toward(b.best) - toward(a.best) || a.index - b.index) + .slice(0, max) + .map((s) => s.group); +} diff --git a/apps/webapp/app/components/alerts/useQueryAlertDraft.ts b/apps/webapp/app/components/alerts/useQueryAlertDraft.ts new file mode 100644 index 00000000000..6fc3e3ba899 --- /dev/null +++ b/apps/webapp/app/components/alerts/useQueryAlertDraft.ts @@ -0,0 +1,245 @@ +import { useFetcher } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import { alertChannelsResourcePath } from "~/components/alerts/alertChannelsResourcePath"; +import { CREATE_ALERT_PATH, queryAlertRequestBody } from "~/components/alerts/createAlertRequest"; +import { + defaultQueryWindowMinutes, + queryAlertNameKey, + queryConditionModes, + querySignalOptions, + queryWindowBounds, + type QueryAlertMode, + type QueryAlertPrefill, +} from "~/components/alerts/queryAlertLink"; +import type { QueryPreviewOutcome } from "~/components/alerts/QueryPreviewResult"; +import { allSteps, applyStepSelection } from "~/components/alerts/wizardSteps"; +import { useCreationFlowState } from "~/components/dashboard-agent/alert-creation"; +import type { AlertChannel } from "~/components/dashboard-agent/alert-creation/CreationFlow"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { v3EnvironmentPath } from "~/utils/pathBuilder"; +import type { PreviewQueryResponse } from "~/v3/services/alerts/previewQuery.server"; + +export type QueryAlertChat = { + chatId: string; + ensure: () => Promise<{ chatId: string; release?: () => void } | undefined>; +}; + +const PREVIEW_FAILED = "Preview failed. Try again."; +const CHAT_FAILED = "We couldn't set up this chat to receive the alert. Try again."; +const CREATE_FAILED = "Failed to create the alert. Try again."; + +export function useQueryAlertDraft( + prefill: QueryAlertPrefill, + onCreated: (alertId: string | undefined, chatId?: string) => void, + chat?: QueryAlertChat +) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { analysis, query } = prefill; + + const signals = querySignalOptions(analysis); + const [signalId, setSignalId] = useState( + () => + signals.find((s) => + prefill.mode === "match" + ? s.mode === "match" + : s.mode === "threshold" && s.measure === prefill.measure + )?.id ?? signals[0]!.id + ); + const signal = signals.find((s) => s.id === signalId) ?? signals[0]!; + const modes = queryConditionModes(analysis, signal); + const [modeChoice, setModeChoice] = useState(prefill.mode); + const mode = modeChoice && modes.includes(modeChoice) ? modeChoice : modes[0]!; + const measure = mode === "match" ? undefined : signal.measure; + + const [operator, setOperator] = useState<">" | "<">(prefill.operator ?? ">"); + const [valueText, setValueText] = useState(""); + const value = valueText.trim() === "" ? undefined : Number(valueText); + const valueValid = mode !== "threshold" || (value !== undefined && Number.isFinite(value)); + + const bounds = queryWindowBounds(analysis.limits); + const defaultWindow = defaultQueryWindowMinutes(prefill.period, analysis.limits); + const [windowText, setWindowText] = useState(() => String(defaultWindow)); + const windowMinutes = mode === "match" ? defaultWindow : Number(windowText); + const windowValid = + mode === "match" || + (Number.isInteger(windowMinutes) && windowMinutes >= bounds.min && windowMinutes <= bounds.max); + + const suggestedName = prefill.names[queryAlertNameKey(mode, measure)] ?? analysis.suggestedName; + const [editedName, setEditedName] = useState(); + const name = editedName ?? suggestedName; + + const flow = useCreationFlowState(undefined, { notifiers: chat ? ["chat"] : [] }); + const [addingChannel, setAddingChannel] = useState(false); + const channelsFetcher = useFetcher<{ + channels: AlertChannel[]; + canWrite?: boolean; + limits?: { used: number; limit: number }; + }>(); + const channelsPath = alertChannelsResourcePath(project.id, false, environment.id); + useEffect(() => { + channelsFetcher.load(channelsPath); + }, [channelsPath]); // eslint-disable-line react-hooks/exhaustive-deps -- fetcher is stable + const limits = channelsFetcher.data?.limits; + const addChannel = { + projectId: project.id, + environmentId: environment.id, + canWrite: channelsFetcher.data?.canWrite ?? false, + atLimit: limits ? limits.used >= limits.limit : false, + onCreated: () => channelsFetcher.load(channelsPath), + }; + const channels = channelsFetcher.data?.channels ?? []; + const steps = allSteps(flow, channels, chat?.chatId, addChannel); + const expiryStep = steps.find((step) => step.id === "expiry")!; + const notifiersStep = steps.find((step) => step.id === "notifiers")!; + const hasNotifier = notifiersStep.options.some( + (option) => !option.disabledReason && flow.answers.notifiers?.includes(option.id) + ); + const selectStep = (stepId: string, optionId: string) => + applyStepSelection(flow, stepId, optionId, { + onShowAll: () => {}, + onAddChannel: () => setAddingChannel(true), + }); + + const previewBody = { + query, + mode, + measure, + windowMinutes, + threshold: mode === "threshold" ? value : undefined, + operator: mode === "threshold" ? operator : undefined, + }; + const previewKey = JSON.stringify(previewBody); + const [preview, setPreview] = useState<{ key: string; outcome: QueryPreviewOutcome }>(); + const [previewing, setPreviewing] = useState(false); + const canPreview = windowValid && valueValid; + + const [submitting, setSubmitting] = useState(false); + const [createError, setCreateError] = useState(); + const canCreate = + canPreview && hasNotifier && flow.answers.expiry !== undefined && name.trim() !== ""; + + async function runPreview() { + setPreviewing(true); + let outcome: QueryPreviewOutcome; + try { + const resp = await fetch( + `/resources${v3EnvironmentPath(organization, project, environment)}/alerts/preview-query`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: previewKey, + } + ); + outcome = + resp.status === 429 + ? { kind: "busy" } + : resp.ok + ? { kind: "result", response: (await resp.json()) as PreviewQueryResponse } + : { kind: "error", message: PREVIEW_FAILED }; + } catch { + outcome = { kind: "error", message: PREVIEW_FAILED }; + } + setPreview({ key: previewKey, outcome }); + setPreviewing(false); + } + + async function create() { + setSubmitting(true); + setCreateError(undefined); + let session: Awaited>; + try { + session = await chat?.ensure(); + if (chat && !session && flow.answers.notifiers?.includes("chat")) { + setCreateError(CHAT_FAILED); + setSubmitting(false); + return; + } + const resp = await fetch(CREATE_ALERT_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + queryAlertRequestBody( + { + organizationId: organization.id, + projectId: project.id, + environmentId: environment.id, + }, + { + query, + name: name.trim(), + mode, + measure, + operator, + value, + windowMinutes, + expiry: flow.answers.expiry, + notifiers: flow.answers.notifiers ?? [], + }, + session && { chatId: session.chatId, transcriptChatId: session.chatId } + ) + ), + }); + const data = (await resp.json().catch(() => ({}))) as { alertId?: string; error?: string }; + if (!resp.ok) { + setCreateError( + (resp.status === 400 || resp.status === 422) && data.error ? data.error : CREATE_FAILED + ); + setSubmitting(false); + session?.release?.(); + return; + } + if (session) onCreated(data.alertId, session.chatId); + else onCreated(data.alertId); + } catch { + session?.release?.(); + setCreateError(CREATE_FAILED); + setSubmitting(false); + } + } + + return { + analysis, + query, + signals, + signal, + setSignalId, + modes, + mode, + setModeChoice, + measure, + operator, + setOperator, + valueText, + setValueText, + value, + valueValid, + bounds, + windowText, + setWindowText, + windowMinutes, + windowValid, + name, + setEditedName, + flow, + channels, + addChannel, + addingChannel, + setAddingChannel, + expiryStep, + notifiersStep, + hasNotifier, + selectStep, + preview: preview && preview.key === previewKey ? preview.outcome : undefined, + previewing, + canPreview, + runPreview, + submitting, + createError, + canCreate, + create, + }; +} diff --git a/apps/webapp/app/components/dashboard-agent/ChatWizardCard.tsx b/apps/webapp/app/components/dashboard-agent/ChatWizardCard.tsx index 3a991e4dc1b..03a879c2f16 100644 --- a/apps/webapp/app/components/dashboard-agent/ChatWizardCard.tsx +++ b/apps/webapp/app/components/dashboard-agent/ChatWizardCard.tsx @@ -74,6 +74,7 @@ export type ChatWizardStep = { question: string; description?: string; descriptionAction?: { label: string }; + content?: ReactNode; options: ChatWizardOption[]; allowSkip?: boolean; multiSelect?: boolean; @@ -843,6 +844,7 @@ export function ChatWizardCard({

) : null} + {step.content ?
{step.content}
: null}
    + openSource === "alerts" ? queryWatchPrefill(createAlertPrefill) : undefined + ); const [creationEntrySeq, setCreationEntrySeq] = useState(0); + const [queryWatchSeq, setQueryWatchSeq] = useState(0); + const queryWatchKey = `${creationEntrySeq}:${queryWatchSeq}`; + const [queryCreatedKey, setQueryCreatedKey] = useState(); + const queryCreatedSig = useRef(); + const setQueryCreatedSig = (sig: string) => { + queryCreatedSig.current = sig; + }; const [creationGeneration, setCreationGeneration] = useState(0); const creationAgentChat = useRef({ seq: -1 }); const [manualMode, setManualMode] = useState(Boolean(slackResume?.manual)); @@ -571,22 +582,33 @@ export function DashboardAgentPanel({ const prevOpenSourceRef = useRef(openSource); if (prevOpenSourceRef.current !== openSource || prevPrefillRef.current !== createAlertPrefill) { + const sameCreatedQuery = + openSource === "alerts" && + prevOpenSourceRef.current === openSource && + queryCreatedSig.current !== undefined && + queryCreatedSig.current === JSON.stringify(queryWatchPrefill(createAlertPrefill)); prevOpenSourceRef.current = openSource; prevPrefillRef.current = createAlertPrefill; - if (openSource === "alerts") { + if (sameCreatedQuery) { + // The created card stays; don't offer the same alert again. + } else if (openSource === "alerts") { + queryCreatedSig.current = undefined; setCreationMode(true); setManualMode(false); const draft = createAlertPrefill ? prefillToAnswers(createAlertPrefill) : draftFromSearch(location.search, true); setCreationDraft(draft); + setQueryWatch(queryWatchPrefill(createAlertPrefill)); prevCreateParamKeyRef.current = createSearchKey(location.search); setCreationEntrySeq((s) => s + 1); setCreationGeneration((g) => g + 1); creationAgentChat.current = { seq: -1 }; } else { + queryCreatedSig.current = undefined; setCreationMode(false); setCreationDraft(undefined); + setQueryWatch(undefined); prevCreateParamKeyRef.current = ""; } } else if ( @@ -597,6 +619,7 @@ export function DashboardAgentPanel({ prevCreateParamKeyRef.current = createParamKey; setManualMode(false); setCreationDraft(draftFromSearch(location.search, true)); + setQueryWatch(undefined); setCreationEntrySeq((s) => s + 1); setCreationGeneration((g) => g + 1); creationAgentChat.current = { seq: -1 }; @@ -774,6 +797,7 @@ export function DashboardAgentPanel({ newChat(); setCreationMode(true); setCreationDraft(draftFromSearch(location.search, true)); + setQueryWatch(undefined); setCreationEntrySeq((s) => s + 1); setCreationGeneration((g) => g + 1); }, [newChat, location.search]); @@ -1022,6 +1046,16 @@ export function DashboardAgentPanel({ ] ); + const ensureQueryChat = useCallback(async () => { + const seq = creationEntrySeqRef.current; + const reused = creationChatToReuse(creationAgentChat.current, creationEntrySeq, active?.chatId); + const chatId = reused ?? (await creationSession.ensure()); + if (!chatId) return undefined; + const release = + !reused && seq === creationEntrySeqRef.current ? creationSession.keep() : undefined; + return { chatId, release }; + }, [active?.chatId, creationEntrySeq, creationSession]); + const creationFlow = useCreationFlowState( async (answers: FlowAnswers) => { const { ok, chatId, release } = await handleAlertCreated(answers); @@ -1053,7 +1087,9 @@ export function DashboardAgentPanel({ navigate({ search: params.toString() }, { replace: true }); }, [slackResume, project.id, environment.id, setExpiry]); // eslint-disable-line react-hooks/exhaustive-deps -- once per resume - const creationTitle = creationFlow.created ? "Alert created" : "New alert"; + const queryCreated = queryCreatedKey === queryWatchKey; + const curatedTitle = creationFlow.created ? "Alert created" : "New alert"; + const creationTitle = queryWatch ? (queryCreated ? "Alert created" : "New alert") : curatedTitle; const headerTitle = inEditMode ? "Edit alert" : inCreationMode @@ -1071,6 +1107,10 @@ export function DashboardAgentPanel({ onPatchApplied: creationFlow.patchAnswers, resetKey: creationEntrySeq, resolveErrorGroup: errorGroupSource.resolve, + onQueryWatch: (watch) => { + setQueryWatch(watch); + setQueryWatchSeq((s) => s + 1); + }, }); const { handleDraftPatches, handleTextReply, handleError } = creationAgentSend; @@ -1216,28 +1256,54 @@ export function DashboardAgentPanel({ ) : null} {inCreationMode ? ( - v3AlertPath(organization, project, environment, id)} - onFreeText={sendCreationText} - systemLines={creationAgentSend.systemLines} - agentStreaming={thinkingChatId !== null && thinkingChatId === active?.chatId} - manual={manualMode} - onManualChange={setManualMode} - onNewChat={restartCreation} - density="compact" - addChannel={{ - ...addChannelAccess, - resumed: Boolean(slackResume) && creationEntrySeq === 0, - onResumeConsumed: () => setSlackResume(undefined), - }} - errorGroupSource={errorGroupSource} - environmentTitle={environmentFullTitle(environment)} - /> + {queryWatch ? ( + { + setQueryCreatedKey(queryWatchKey); + setQueryCreatedSig(JSON.stringify(queryWatch)); + revalidator.revalidate(); + if (chatId) { + const body = new FormData(); + body.set("intent", "rename"); + body.set("chatId", chatId); + body.set("title", name); + void fetch(actionPath, { method: "POST", body }).catch(() => {}); + } + }} + environmentTitle={environmentFullTitle(environment)} + alertDetailPath={(id) => v3AlertPath(organization, project, environment, id)} + onNewChat={restartCreation} + /> + ) : ( + v3AlertPath(organization, project, environment, id)} + onFreeText={sendCreationText} + systemLines={creationAgentSend.systemLines} + agentStreaming={thinkingChatId !== null && thinkingChatId === active?.chatId} + manual={manualMode} + onManualChange={setManualMode} + onNewChat={restartCreation} + density="compact" + addChannel={{ + ...addChannelAccess, + resumed: Boolean(slackResume) && creationEntrySeq === 0, + onResumeConsumed: () => setSlackResume(undefined), + }} + errorGroupSource={errorGroupSource} + environmentTitle={environmentFullTitle(environment)} + /> + )} ) : null} {loading ? ( diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/ManualCreationForm.tsx b/apps/webapp/app/components/dashboard-agent/alert-creation/ManualCreationForm.tsx index b37e14ed926..e7ef33b170e 100644 --- a/apps/webapp/app/components/dashboard-agent/alert-creation/ManualCreationForm.tsx +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/ManualCreationForm.tsx @@ -175,7 +175,7 @@ function ChannelCheckboxes({ ); } -function ManualStep({ +export function ManualStep({ step, onSelect, onConfirm, diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.test.ts b/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.test.ts index 5b92f8cc9c2..5e85b128bc4 100644 --- a/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.test.ts +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.test.ts @@ -255,12 +255,16 @@ describe("scope value on scope type change", () => { }); describe("below-threshold operator", () => { - it("rejects operator < and surfaces an error line", () => { + it("rejects operator < outside a query patch and surfaces an error line", () => { const patch = { condition: { operator: "<", value: 10 } }; expect(alertDraftPatchSchema.safeParse(patch).success).toBe(false); - expect(contractPatchSchema.safeParse(patch).success).toBe(false); expect(draftResultLines({ ok: true, patch: patch as never })[0]?.variant).toBe("error"); }); + + it("accepts operator < on a query patch", () => { + const patch = { area: "query", query: "SELECT 1", condition: { operator: "<" } }; + expect(alertDraftPatchSchema.safeParse(patch).success).toBe(true); + }); }); describe("unknown-key rejection", () => { diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.ts b/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.ts index 635827991ea..8f24aa78e21 100644 --- a/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.ts +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/draftPatch.ts @@ -1,6 +1,8 @@ import type { AlertCreationDraft } from "@internal/dashboard-agent-contracts"; import type { AlertScopeType } from "@internal/alert-contracts"; import { z } from "zod"; +import { MAX_WATCH_QUERY_LENGTH } from "~/components/alerts/queryAlertLink"; +import type { QueryWatchPrefill } from "~/components/alerts/QueryAlertTranscript"; import { encodeErrorGroupScope, errorFingerprint } from "~/utils/errorGroupScope"; import { needsScopeValue, resolveCapability, type FlowAnswers } from "./flow-state"; @@ -20,7 +22,7 @@ export const alertDraftPatchSchema = z condition: z .object({ kind: z.string().optional(), - operator: z.literal(">").optional(), + operator: z.enum([">", "<"]).optional(), value: z.number().optional(), unit: z.string().optional(), windowSec: z.number().optional(), @@ -42,12 +44,32 @@ export const alertDraftPatchSchema = z }) ) .optional(), + query: z.string().max(MAX_WATCH_QUERY_LENGTH).optional(), + querySignalChoice: z + .object({ + mode: z.enum(["threshold", "absence", "match"]), + measure: z.string().optional(), + }) + .optional(), reason: z.string().optional(), }) - .strict(); + .strict() + .refine((patch) => patch.condition?.operator !== "<" || patch.area === "query", { + message: "Below-threshold alerts aren't supported yet", + }); export type AlertDraftPatch = z.infer; +export function queryWatchFromPatch(patch: AlertDraftPatch): QueryWatchPrefill | undefined { + if (patch.area !== "query" || !patch.query?.trim()) return undefined; + return { + query: patch.query, + mode: patch.querySignalChoice?.mode, + measure: patch.querySignalChoice?.measure, + operator: patch.condition?.operator, + }; +} + /** The id to look up when an errorGroup patch didn't carry the task identifier. */ export function errorGroupToResolve(patch: AlertDraftPatch): string | undefined { const scope = patch.scope; diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/flow-state.ts b/apps/webapp/app/components/dashboard-agent/alert-creation/flow-state.ts index 5b0d2871eba..35da3e024e6 100644 --- a/apps/webapp/app/components/dashboard-agent/alert-creation/flow-state.ts +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/flow-state.ts @@ -1,6 +1,7 @@ import type { AlertArea, AlertCapability, CapabilityParam } from "@internal/alert-contracts"; import { listCapabilities } from "@internal/alert-contracts"; import { AREA_ICON, AREA_LABEL } from "~/components/alerts/domainIcons"; +import type { QueryAlertMode } from "~/components/alerts/queryAlertLink"; import type { RenderIcon } from "~/components/primitives/Icon"; import { formatNumber } from "~/utils/numberFormatter"; import { @@ -492,6 +493,11 @@ export type DraftPrefillParams = { scope?: string | ErrorGroupScope; scopeValue?: string; replace?: ReplaceTarget; + source?: "query"; + query?: string; + period?: string; + mode?: QueryAlertMode; + measure?: string; }; export function asArea(value: string | undefined): DraftPrefillParams["area"] { diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/queryDraftPatch.dom.test.ts b/apps/webapp/app/components/dashboard-agent/alert-creation/queryDraftPatch.dom.test.ts new file mode 100644 index 00000000000..a064633f733 --- /dev/null +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/queryDraftPatch.dom.test.ts @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +import type * as RemixReactModule from "@remix-run/react"; +import { createElement, useEffect, useState, type ComponentProps } from "react"; +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + QueryAlertTranscript, + type QueryWatchPrefill, +} from "~/components/alerts/QueryAlertTranscript"; +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; +import type { ToolResultPayload } from "./draftPatch"; +import { useCreationAgentSend, type CreationAgentSend } from "./useCreationAgentSend"; + +vi.mock("@remix-run/react", async (importOriginal) => ({ + ...(await importOriginal()), + useFetcher: () => ({ data: undefined, load: () => {} }), +})); +vi.mock("~/hooks/useOrganizations", () => ({ + useOrganization: () => ({ slug: "org", id: "org1" }), +})); +vi.mock("~/hooks/useProject", () => ({ + useProject: () => ({ slug: "proj", id: "proj1" }), +})); +vi.mock("~/hooks/useEnvironment", () => ({ + useEnvironment: () => ({ slug: "prod", id: "env1", type: "PRODUCTION" }), +})); + +const QUERY = "SELECT count() AS failures FROM runs"; +const ANALYSIS = { + ok: true, + analyzerVersion: 1, + sources: [{ sourceId: "runs" }], + columns: [], + modes: [ + { + mode: "threshold", + shape: "scalar", + measures: [{ column: "failures", type: "UInt64", countLike: true }], + groupColumns: [], + absenceAllowed: true, + }, + ], + unsupported: [], + errors: [], + limits: { + minCadenceSec: 600, + maxWindowSec: 86_400, + maxGroups: 500, + maxMatchRowsPerEval: 200, + detailRows: 10, + }, + suggestedName: "failures on runs", +}; + +Element.prototype.scrollIntoView ??= () => {}; +globalThis.ResizeObserver ??= class { + observe() {} + unobserve() {} + disconnect() {} +} as unknown as typeof ResizeObserver; + +const harness: { send?: CreationAgentSend } = {}; +let applied: unknown[] = []; + +function Harness({ onSend }: { onSend: (send: CreationAgentSend) => void }) { + const [watch, setWatch] = useState(); + const send = useCreationAgentSend({ + answers: {}, + onPatchApplied: (next) => applied.push(next), + onQueryWatch: setWatch, + }); + useEffect(() => onSend(send)); + return watch + ? createElement(QueryAlertTranscript, { prefill: watch, alertDetailPath: (id) => id }) + : createElement( + "div", + { "data-testid": "curated" }, + send.systemLines.map((line) => line.text).join("\n") + ); +} + +let container: HTMLElement; +let root: Root; + +describe("a query draft patch from the agent", () => { + beforeEach(() => { + applied = []; + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ analysis: ANALYSIS, names: {} }))) + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => + root.render( + createElement( + OperatingSystemContextProvider, + { platform: "mac" } as ComponentProps, + createElement( + ShortcutsProvider, + null, + createElement(Harness, { + onSend: (send) => { + harness.send = send; + }, + }) + ) + ) + ) + ); + }); + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("opens the query card instead of patching the curated draft", async () => { + const result: ToolResultPayload = { + ok: true, + patch: { area: "query", query: QUERY, querySignalChoice: { mode: "threshold" } } as never, + }; + await act(async () => harness.send!.handleDraftPatches([result])); + + expect(document.querySelector("[data-testid=curated]")).toBeNull(); + expect(applied).toEqual([]); + expect(document.querySelector("[data-testid=query-alert-flow]")).not.toBeNull(); + expect(document.querySelector("[role=dialog]")!.getAttribute("aria-label")).toBe( + "Watch this query?" + ); + expect(document.querySelector("[data-testid=query-alert-query]")!.textContent).toBe(QUERY); + }); + + it("carries a below-threshold operator into the card", async () => { + const result: ToolResultPayload = { + ok: true, + patch: { + area: "query", + query: QUERY, + querySignalChoice: { mode: "threshold", measure: "failures" }, + condition: { operator: "<" }, + } as never, + }; + await act(async () => harness.send!.handleDraftPatches([result])); + const row = (label: string) => + Array.from(document.querySelectorAll("[role=dialog] li button")).find( + (b) => b.textContent === label + )!; + act(() => row("Continue").click()); + act(() => row("Value crosses a threshold").click()); + const input = document.querySelector('input[aria-label="Threshold"]')!; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + act(() => { + setter.call(input, "3"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(row("Below").querySelector("[aria-label=Selected]")).not.toBeNull(); + expect(row("Above").querySelector("[aria-label=Selected]")).toBeNull(); + }); +}); diff --git a/apps/webapp/app/components/dashboard-agent/alert-creation/useCreationAgentSend.ts b/apps/webapp/app/components/dashboard-agent/alert-creation/useCreationAgentSend.ts index 50f6768798d..5a875981c8a 100644 --- a/apps/webapp/app/components/dashboard-agent/alert-creation/useCreationAgentSend.ts +++ b/apps/webapp/app/components/dashboard-agent/alert-creation/useCreationAgentSend.ts @@ -4,9 +4,11 @@ import { alertDraftPatchSchema, applyDraftPatch, errorGroupToResolve, + queryWatchFromPatch, type ToolResultPayload, } from "./draftPatch"; import type { SystemLine } from "~/components/alerts/CreationTranscript"; +import type { QueryWatchPrefill } from "~/components/alerts/QueryAlertTranscript"; import type { ResolveErrorGroup } from "~/components/alerts/errorGroupOptions"; import { encodeErrorGroupScope } from "~/utils/errorGroupScope"; @@ -38,6 +40,7 @@ export type CreationAgentSendDeps = { onPatchApplied: (next: FlowAnswers) => void; resetKey?: number; resolveErrorGroup?: ResolveErrorGroup; + onQueryWatch?: (watch: QueryWatchPrefill) => void; }; export type CreationAgentSend = { @@ -58,6 +61,7 @@ export function useCreationAgentSend({ onPatchApplied, resetKey, resolveErrorGroup, + onQueryWatch, }: CreationAgentSendDeps): CreationAgentSend { const [systemLines, setSystemLines] = useState([]); useEffect(() => { @@ -80,16 +84,21 @@ export function useCreationAgentSend({ }); const onPatchAppliedRef = useRef(onPatchApplied); const resolveErrorGroupRef = useRef(resolveErrorGroup); + const onQueryWatchRef = useRef(onQueryWatch); useEffect(() => { onPatchAppliedRef.current = onPatchApplied; resolveErrorGroupRef.current = resolveErrorGroup; + onQueryWatchRef.current = onQueryWatch; }); const handleDraftPatches = useCallback((results: ToolResultPayload[]) => { for (const result of results) { const parsed = result.ok && result.patch ? alertDraftPatchSchema.safeParse(result.patch) : undefined; - if (parsed?.success) { + const queryWatch = parsed?.success ? queryWatchFromPatch(parsed.data) : undefined; + if (queryWatch) { + onQueryWatchRef.current?.(queryWatch); + } else if (parsed?.success) { const patched = applyDraftPatch(answersRef.current, parsed.data); onPatchAppliedRef.current(patched); answersRef.current = patched; diff --git a/apps/webapp/app/components/dashboard-agent/tool-labels.ts b/apps/webapp/app/components/dashboard-agent/tool-labels.ts index 1096292171f..f5ebf4ab441 100644 --- a/apps/webapp/app/components/dashboard-agent/tool-labels.ts +++ b/apps/webapp/app/components/dashboard-agent/tool-labels.ts @@ -27,6 +27,7 @@ const TOOL_LABELS: Record = { delete_watch_subscription: "Deleting an alert", describe_alert_capabilities: "Listing alert capabilities", create_monitoring_alert: "Creating an alert", + analyze_query_alert: "Checking the query for an alert", update_alert_draft: "Updating the alert draft", list_monitoring_alerts: "Listing alerts", resolve_monitoring_alert: "Resolving an alert", diff --git a/apps/webapp/app/components/primitives/charts/Chart.tsx b/apps/webapp/app/components/primitives/charts/Chart.tsx index e3c6a8d205b..3afadfc5232 100644 --- a/apps/webapp/app/components/primitives/charts/Chart.tsx +++ b/apps/webapp/app/components/primitives/charts/Chart.tsx @@ -114,6 +114,8 @@ const ChartTooltipContent = React.forwardRef< labelKey?: string; /** Optional formatter for numeric values (e.g. bytes, duration) */ valueFormatter?: (value: number) => string; + /** Per-item indicator colour; wins over `color` when it returns a value. */ + itemColor?: (item: { dataKey?: unknown; value?: unknown }) => string | undefined; } >( ( @@ -132,6 +134,7 @@ const ChartTooltipContent = React.forwardRef< nameKey, labelKey, valueFormatter, + itemColor, }, ref ) => { @@ -186,7 +189,8 @@ const ChartTooltipContent = React.forwardRef< // Prefer the series' configured colour over item.color: a threshold/gradient line's // recharts colour is a `url(#…)` gradient ref, which is invalid as a CSS background and // renders no swatch. The config colour is the intended solid series colour. - const indicatorColor = color || itemConfig?.color || item.payload.fill || item.color; + const indicatorColor = + itemColor?.(item) || color || itemConfig?.color || item.payload.fill || item.color; return (
    string | undefined; +let captured: { itemColor?: ItemColor; payload?: { dataKey: string }[] } = {}; + +vi.mock("./Chart", async (importOriginal) => ({ + ...(await importOriginal()), + ChartTooltipContent: (props: typeof captured) => { + captured = props; + return null; + }, +})); + +import { OverlayFilteredTooltip } from "./ChartLine"; + +const WARN = "var(--color-warning)"; + +function render(direction: "above" | "below" = "above") { + renderToStaticMarkup( + createElement(OverlayFilteredTooltip, { + active: true, + overlayThreshold: 3, + overlayDirection: direction, + payload: [ + { dataKey: "a", value: 5 }, + { dataKey: "b", value: 1 }, + { dataKey: "__warningOverlay_a", value: 5 }, + ], + }) + ); + return captured.itemColor!; +} + +describe("overlay tooltip swatches", () => { + it("highlights only the series whose own value crosses the threshold", () => { + const itemColor = render(); + expect(itemColor({ dataKey: "a", value: 5 })).toBe(WARN); + expect(itemColor({ dataKey: "b", value: 1 })).toBeUndefined(); + expect(captured.payload!.map((p) => p.dataKey)).toEqual(["a", "b"]); + }); + + it("honours a below-threshold operator per series", () => { + const itemColor = render("below"); + expect(itemColor({ dataKey: "a", value: 5 })).toBeUndefined(); + expect(itemColor({ dataKey: "b", value: 1 })).toBe(WARN); + }); +}); diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index ef0455430be..77dc4a97516 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -86,7 +86,7 @@ export function warningSwatchColor( : undefined; } -function OverlayFilteredTooltip({ +export function OverlayFilteredTooltip({ overlayThreshold, overlayColor, overlayDirection, @@ -101,12 +101,9 @@ function OverlayFilteredTooltip({ + warningSwatchColor(item.value, overlayThreshold, overlayColor, overlayDirection) + } /> ); } @@ -737,7 +734,8 @@ export function ChartLineRenderer({ (props: ActiveDotProps) => props.cx != null && props.cy != null && - overlayDotVisible(props.payload, key, over) ? ( + overlayDotVisible(props.payload, key, over) && + isOver(props.payload as (typeof data)[number], key) ? ( ReactNode; onClose?: () => void; @@ -169,6 +173,7 @@ const QueryEditorForm = forwardRef< isAdmin: boolean; queryActionUrl: string; maxPeriodDays?: number; + watchAlertsPath?: string; onQuerySubmit?: () => void; onHistorySelected?: (item: QueryHistoryItem) => void; } @@ -183,6 +188,7 @@ const QueryEditorForm = forwardRef< isAdmin, queryActionUrl, maxPeriodDays, + watchAlertsPath, onQuerySubmit, onHistorySelected, }, @@ -327,6 +333,13 @@ const QueryEditorForm = forwardRef< maxPeriodDays={maxPeriodDays} /> )} + {watchAlertsPath ? ( + + ) : null}