From 15777dd4faa6f798fda6bfcd5170cedc7e2166c1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 15 Oct 2026 23:41:31 +0200 Subject: [PATCH 1/8] feat(alerts): incident-first alerts hub, timeline incident page, rebuilt rule editor /alerts becomes Inbox / Rules / Routing. Inbox leads with open and held incidents, a 24h fire strip per rule and blind spots (erroring, stale, skipped for no data, unrouted). Rules groups by tag with a headroom meter, a 7-day state ribbon, routes and fired count. Routing holds destinations with delivery health. Legacy tab links redirect. The incident page is now a real detail page built on one timeline: opened, every delivery attempt, reminders, holds, investigation result, resolve. It no longer auto-starts an investigation and redirects. The rule editor puts the preview chart full width with a condition bar under it, then a Built-in / Query builder / SQL switch. Built-in is a form over the same query draft, so switching modes keeps filters. Error rate thresholds use one display unit in every mode (a percent typed in Built-in no longer becomes a 0-1 ratio after switching). A warning shows when min samples or missing data would skip windows. Edit mode shows changed values with the old one and replays the saved rule against the draft. Adds /lab/alerts, rendering all of it from fixtures. --- .../alerts/alert-create-form-surface.tsx | 160 ++-- .../components/alerts/alert-rule-chart.tsx | 25 +- .../components/alerts/alert-tag-controls.tsx | 72 -- .../components/alerts/destination-card.tsx | 170 ---- .../incident-detail/incident-detail-view.tsx | 183 +++++ .../alerts/incident-detail/incident-rail.tsx | 255 ++++++ .../incident-detail/incident-timeline.tsx | 92 +++ .../alerts/incident-timeline-strip.tsx | 103 --- .../overview/active-incidents-table.tsx | 170 ---- .../alerts/overview/alerts-empty-state.tsx | 19 +- .../alerts/overview/alerts-health-summary.tsx | 67 -- .../components/alerts/overview/alerts-hub.tsx | 136 ++++ .../alerts/overview/alerts-overview-tab.tsx | 396 --------- .../alerts/overview/destination-row.tsx | 178 ++++ .../components/alerts/overview/inbox-tab.tsx | 347 ++++++++ .../{settings-tab.tsx => routing-tab.tsx} | 66 +- .../alerts/overview/rules-overview-table.tsx | 247 ------ .../components/alerts/overview/rules-tab.tsx | 431 ++++++++++ .../src/components/alerts/overview/shared.tsx | 101 +-- .../src/components/alerts/rule-action-bar.tsx | 17 +- .../components/alerts/rule-condition-bar.tsx | 237 ++++++ .../rule-detail/rule-diagnosis-panel.tsx | 2 +- .../components/alerts/rule-edit-impact.tsx | 188 +++++ .../alerts/rule-live-chart-hero.tsx | 121 ++- .../components/alerts/rule-noise-section.tsx | 97 +++ .../components/alerts/rule-query-section.tsx | 421 ++++++++++ .../src/components/alerts/scope-section.tsx | 110 --- .../alerts/signal-and-threshold-section.tsx | 762 ------------------ apps/web/src/components/chat/alert-context.ts | 27 - .../settings/escalation-policy-section.tsx | 2 +- apps/web/src/lab/alerts-fixture.ts | 657 +++++++++++++++ apps/web/src/lab/alerts-lab.tsx | 248 ++++++ apps/web/src/lab/registry.ts | 8 + apps/web/src/lab/worst-case-fixture.ts | 18 +- apps/web/src/lab/worst-case-sections.tsx | 37 +- apps/web/src/lib/alerts/form-utils.test.ts | 18 +- apps/web/src/lib/alerts/form-utils.ts | 62 +- .../src/lib/alerts/incident-timeline.test.ts | 115 +++ apps/web/src/lib/alerts/incident-timeline.ts | 231 ++++++ .../src/lib/alerts/overview-derive.test.ts | 172 ++++ apps/web/src/lib/alerts/overview-derive.ts | 198 +++++ .../src/lib/alerts/preview-insights.test.ts | 166 ++++ apps/web/src/lib/alerts/preview-insights.ts | 296 +++++++ apps/web/src/lib/alerts/rule-modes.test.ts | 89 ++ apps/web/src/lib/alerts/rule-modes.ts | 186 +++++ apps/web/src/lib/alerts/tag-grouping.ts | 2 +- .../web/src/lib/alerts/widget-prefill.test.ts | 8 +- apps/web/src/lib/alerts/widget-prefill.ts | 7 +- apps/web/src/routeTree.gen.ts | 21 + .../routes/alerts/incidents/$incidentId.tsx | 295 ++++--- apps/web/src/routes/alerts/index.tsx | 133 ++- apps/web/src/routes/lab/alerts.tsx | 5 + 52 files changed, 5622 insertions(+), 2552 deletions(-) delete mode 100644 apps/web/src/components/alerts/alert-tag-controls.tsx delete mode 100644 apps/web/src/components/alerts/destination-card.tsx create mode 100644 apps/web/src/components/alerts/incident-detail/incident-detail-view.tsx create mode 100644 apps/web/src/components/alerts/incident-detail/incident-rail.tsx create mode 100644 apps/web/src/components/alerts/incident-detail/incident-timeline.tsx delete mode 100644 apps/web/src/components/alerts/incident-timeline-strip.tsx delete mode 100644 apps/web/src/components/alerts/overview/active-incidents-table.tsx delete mode 100644 apps/web/src/components/alerts/overview/alerts-health-summary.tsx create mode 100644 apps/web/src/components/alerts/overview/alerts-hub.tsx delete mode 100644 apps/web/src/components/alerts/overview/alerts-overview-tab.tsx create mode 100644 apps/web/src/components/alerts/overview/destination-row.tsx create mode 100644 apps/web/src/components/alerts/overview/inbox-tab.tsx rename apps/web/src/components/alerts/overview/{settings-tab.tsx => routing-tab.tsx} (89%) delete mode 100644 apps/web/src/components/alerts/overview/rules-overview-table.tsx create mode 100644 apps/web/src/components/alerts/overview/rules-tab.tsx create mode 100644 apps/web/src/components/alerts/rule-condition-bar.tsx create mode 100644 apps/web/src/components/alerts/rule-edit-impact.tsx create mode 100644 apps/web/src/components/alerts/rule-noise-section.tsx create mode 100644 apps/web/src/components/alerts/rule-query-section.tsx delete mode 100644 apps/web/src/components/alerts/scope-section.tsx delete mode 100644 apps/web/src/components/alerts/signal-and-threshold-section.tsx create mode 100644 apps/web/src/lab/alerts-fixture.ts create mode 100644 apps/web/src/lab/alerts-lab.tsx create mode 100644 apps/web/src/lib/alerts/incident-timeline.test.ts create mode 100644 apps/web/src/lib/alerts/incident-timeline.ts create mode 100644 apps/web/src/lib/alerts/overview-derive.test.ts create mode 100644 apps/web/src/lib/alerts/overview-derive.ts create mode 100644 apps/web/src/lib/alerts/preview-insights.test.ts create mode 100644 apps/web/src/lib/alerts/preview-insights.ts create mode 100644 apps/web/src/lib/alerts/rule-modes.test.ts create mode 100644 apps/web/src/lib/alerts/rule-modes.ts create mode 100644 apps/web/src/routes/lab/alerts.tsx diff --git a/apps/web/src/components/alerts/alert-create-form-surface.tsx b/apps/web/src/components/alerts/alert-create-form-surface.tsx index 86735dfaa5..9ef28fef23 100644 --- a/apps/web/src/components/alerts/alert-create-form-surface.tsx +++ b/apps/web/src/components/alerts/alert-create-form-surface.tsx @@ -10,15 +10,16 @@ import { cn } from "@maple/ui/lib/utils" import { DetailsSection } from "@/components/alerts/details-section" import { NotificationsSection } from "@/components/alerts/notifications-section" import { DestinationDialog } from "@/components/alerts/destination-dialog" -import { useDestinationManager } from "@/components/alerts/overview/settings-tab" +import { useDestinationManager } from "@/components/alerts/overview/routing-tab" import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { useAsyncAction } from "@/hooks/use-mutation-action" import { RuleActionBar } from "@/components/alerts/rule-action-bar" import { RULE_FORM_MAX_WIDTH } from "@/components/alerts/rule-form-layout" +import { RuleConditionBar } from "@/components/alerts/rule-condition-bar" import { RuleLiveChartHero } from "@/components/alerts/rule-live-chart-hero" +import { RuleNoiseSection } from "@/components/alerts/rule-noise-section" +import { RuleQuerySection } from "@/components/alerts/rule-query-section" import { RuleTemplatesOverlay } from "@/components/alerts/rule-templates-overlay" -import { ScopeSection } from "@/components/alerts/scope-section" -import { SignalAndThresholdSection } from "@/components/alerts/signal-and-threshold-section" import { WidgetPrefillNoticeBanner } from "@/components/alerts/widget-prefill-notice-banner" import { DashboardPage } from "@/components/layout/dashboard-page" import type { TimeRange } from "@/components/time-range-picker/types" @@ -33,18 +34,20 @@ import { isRangeComparator, isRulePreviewReady, pickDefaultDestination, + ruleToFormState, signalLabels, type RuleFormState, } from "@/lib/alerts/form-utils" +import { diffRuleForms, incidentsClearedByDraft } from "@/lib/alerts/preview-insights" import { toastExit } from "@/lib/error-toast" import { applyTemplate } from "@/lib/alerts/templates" import type { WidgetAlertPrefillNotice } from "@/lib/alerts/widget-prefill" import { Result, useAtomSet } from "@/lib/effect-atom" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" -import { useAlertRulesList } from "@/hooks/use-alerts-list" +import { useAlertIncidentsList, useAlertRulesList } from "@/hooks/use-alerts-list" -/** Preview lookback the form opens on — the rule detail page's default too. */ -const DEFAULT_PREVIEW_PRESET = "24h" +/** Preview lookback the form opens on: a week shows how often the rule would really fire. */ +const DEFAULT_PREVIEW_PRESET = "7d" export function AlertCreateFormSurface({ initialForm, @@ -118,6 +121,29 @@ export function AlertCreateFormSurface({ const { preview, previewLoading, previewError } = useAlertRulePreview(ruleForm, previewRange) + // Edit mode replays the saved rule over the same window, but only once the + // draft evaluates differently; until then the two charts would be identical. + const savedForm = useMemo(() => (editingRule ? ruleToFormState(editingRule) : null), [editingRule]) + const changes = useMemo( + () => (savedForm === null ? [] : diffRuleForms(savedForm, ruleForm)), + [savedForm, ruleForm], + ) + const evaluationChanged = + savedForm !== null && previewIdentityKey(savedForm) !== previewIdentityKey(ruleForm) + const savedReplay = useAlertRulePreview(evaluationChanged ? savedForm : null, previewRange) + const { result: incidentsResult } = useAlertIncidentsList() + const clearedIncidents = useMemo(() => { + if (editingRule === null || !evaluationChanged) return [] + const open = Result.builder(incidentsResult) + .onSuccess((response) => + response.incidents.filter( + (incident) => incident.ruleId === editingRule.id && incident.status === "open", + ), + ) + .orElse(() => []) + return incidentsClearedByDraft(open, preview) + }, [editingRule, evaluationChanged, incidentsResult, preview]) + const validationIssues = useMemo(() => deriveValidationIssues(ruleForm), [ruleForm]) const suggestedName = useMemo(() => makeSuggestedName(ruleForm), [ruleForm]) @@ -192,7 +218,6 @@ export function AlertCreateFormSurface({ const [previewRule, previewingRule] = useAsyncAction(() => runTest(false)) const [sendTestNotification, sendingTestNotification] = useAsyncAction(() => runTest(true)) - const showScope = ruleForm.signalType !== "builder_query" && ruleForm.signalType !== "raw_query" // Drop the verdict as soon as the user edits anything it depended on. const currentPreviewKey = previewIdentityKey(ruleForm) const freshPreviewResult = previewResult?.key === currentPreviewKey ? previewResult : null @@ -205,65 +230,68 @@ export function AlertCreateFormSurface({ { label: editingRule ? "Edit rule" : "New rule" }, ]} > -
+
- {/* The preview rides beside the form, pinned, so every edit to the signal, - scope or threshold redraws where you can see it. Stacked above below lg. */} -
-
- void previewRule()} - testing={previewingRule} - previewResult={freshPreviewResult} - range={previewRange} - timeRange={previewTimeRange} - onTimeRangeChange={setPreviewTimeRange} - /> -
-
- - {showScope && ( - - )} - void sendTestNotification()} - testing={sendingTestNotification} - onAddDestination={ - isAdmin - ? (preset) => destinationManager.openDialog(undefined, preset) - : undefined - } - onQuickCreate={isAdmin ? destinationManager.quickCreate : undefined} - /> - -
+ {/* Chart first and full width: every field in the condition bar under it + redraws the threshold line or the would-fire bands. */} + void previewRule()} + testing={previewingRule} + previewResult={freshPreviewResult} + range={previewRange} + timeRange={previewTimeRange} + onTimeRangeChange={setPreviewTimeRange} + comparison={ + savedForm !== null && changes.length > 0 + ? { + savedPreview: evaluationChanged ? savedReplay.preview : preview, + savedLoading: evaluationChanged && savedReplay.previewLoading, + changes, + clearedIncidents, + } + : null + } + > + + + +
+ + void sendTestNotification()} + testing={sendingTestNotification} + onAddDestination={ + isAdmin + ? (preset) => destinationManager.openDialog(undefined, preset) + : undefined + } + onQuickCreate={isAdmin ? destinationManager.quickCreate : undefined} + /> +
navigate({ to: "/alerts" })} @@ -308,14 +336,14 @@ function deriveValidationIssues(form: RuleFormState): string[] { if (isRangeComparator(form.comparator) && !Number.isFinite(Number(form.thresholdUpper))) { issues.push("Upper threshold") } - // The four timing fields silently fall back to a default when they don't parse + // The timing fields silently fall back to a default when they don't parse // (see `parsePositiveNumber` in form-utils), so name them here rather than // letting a typo save as a different rule than the one on screen. for (const [label, value] of [ - ["Window (min)", form.windowMinutes], - ["Breaches to fire", form.consecutiveBreachesRequired], - ["Healthy to resolve", form.consecutiveHealthyRequired], - ["Renotify (min)", form.renotifyIntervalMinutes], + ["Window", form.windowMinutes], + ["Checks to fire", form.consecutiveBreachesRequired], + ["Checks to resolve", form.consecutiveHealthyRequired], + ["Remind interval", form.renotifyIntervalMinutes], ] as const) { const parsed = Number(value) if (!Number.isFinite(parsed) || parsed <= 0) issues.push(label) @@ -375,5 +403,5 @@ function makeSuggestedName(form: RuleFormState): string | null { : null const env = !queryOwnsScope && form.environments.length > 0 ? form.environments.join(" · ") : null const suffix = [scope, env].filter((part) => part !== null).join(" · ") - return suffix.length > 0 ? `${base} — ${suffix}` : base + return suffix.length > 0 ? `${base} · ${suffix}` : base } diff --git a/apps/web/src/components/alerts/alert-rule-chart.tsx b/apps/web/src/components/alerts/alert-rule-chart.tsx index 613fcd6ef6..52ce107514 100644 --- a/apps/web/src/components/alerts/alert-rule-chart.tsx +++ b/apps/web/src/components/alerts/alert-rule-chart.tsx @@ -270,7 +270,11 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ clipToDomain( incidents.map((incident) => ({ x1: new Date(incident.firstTriggeredAt).getTime(), - x2: incident.resolvedAt ? new Date(incident.resolvedAt).getTime() : domain.max, + x2: incident.resolvedAt + ? new Date(incident.resolvedAt).getTime() + : incident.heldSince + ? Date.parse(incident.heldSince) + : domain.max, open: incident.status === "open", })), domain, @@ -278,6 +282,20 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ [incidents, domain], ) + // An open incident waiting on telemetry: the tail after `heldSince` is not a breach. + const heldBands = React.useMemo( + () => + clipToDomain( + incidents.flatMap((incident) => + incident.status === "open" && incident.heldSince + ? [{ x1: Date.parse(incident.heldSince), x2: domain.max }] + : [], + ), + domain, + ), + [incidents, domain], + ) + const wouldFireBands = React.useMemo(() => { if (!showWouldFire || preview == null) return EMPTY_BANDS return clipToDomain( @@ -517,6 +535,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ 0.06, "incident-closed", ), + ...band(heldBands, palette.muted, 0.16, "incident-held"), ...band(wouldFireBands, palette.destructive, 0.08, "would-fire"), // Threshold rules. The labels deliberately live in the caption below // the plot, so they cannot clip at the right edge. @@ -623,6 +642,7 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ yDomain, noDataBands, incidentBands, + heldBands, wouldFireBands, threshold, thresholdUpper, @@ -758,6 +778,9 @@ export const AlertRuleChart = React.memo(function AlertRuleChart({ Shaded: rule would have fired (approximate — the live scheduler evaluates every minute).

)} + {hasSignal && heldBands.length > 0 && ( +

Grey tail: incident held, waiting on data.

+ )} {hasSignal && source === "preview" && noDataBands.length > 0 && (

Hatched: no data received in these windows.

)} diff --git a/apps/web/src/components/alerts/alert-tag-controls.tsx b/apps/web/src/components/alerts/alert-tag-controls.tsx deleted file mode 100644 index 5bcf657d40..0000000000 --- a/apps/web/src/components/alerts/alert-tag-controls.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useMemo } from "react" - -import { cn } from "@maple/ui/lib/utils" -import { Badge } from "@maple/ui/components/ui/badge" -import { MultiSelectCombobox } from "@maple/ui/components/multi-select-combobox" -import { Toggle } from "@maple/ui/components/ui/toggle" - -import { LayersIcon, TagIcon } from "@/components/icons" -import type { TagFacet } from "@/lib/alerts/tag-grouping" - -interface AlertTagControlsProps { - facets: TagFacet[] - selected: string[] - onSelectedChange: (tags: string[]) => void - grouped: boolean - onGroupedChange: (grouped: boolean) => void -} - -/** - * Tag filter (multi-select combobox) + "Group by tag" toggle, shared by the - * Rules and Monitor tabs. Renders nothing until at least one tag exists so the - * controls stay out of the way for orgs that never tag their rules. - */ -export function AlertTagControls({ - facets, - selected, - onSelectedChange, - grouped, - onGroupedChange, -}: AlertTagControlsProps) { - const options = useMemo(() => facets.map((facet) => ({ value: facet.name, meta: facet.count })), [facets]) - - if (facets.length === 0 && selected.length === 0) return null - - return ( -
- `Clear ${count} selected`} - emptyMessage="No matching tags" - mode="trigger" - onChange={onSelectedChange} - options={options} - searchPlaceholder="Filter tags…" - triggerAriaLabel="Tags" - triggerLabel={ - <> - - Tags - {selected.length > 0 && ( - - {selected.length} - - )} - - } - value={selected} - /> - - - - Group by tag - -
- ) -} diff --git a/apps/web/src/components/alerts/destination-card.tsx b/apps/web/src/components/alerts/destination-card.tsx deleted file mode 100644 index 7af9a98e02..0000000000 --- a/apps/web/src/components/alerts/destination-card.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import type { AlertDestinationDocument } from "@maple/domain/http" -import { destinationProvider, ProviderLogo } from "@/components/alerts/destination-provider" -import { useState } from "react" -import { RelativeTime } from "@/components/common/relative-time" -import { AlertWarningIcon, CheckIcon, PencilIcon, TrashIcon } from "@/components/icons" -import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" -import { Badge } from "@maple/ui/components/ui/badge" -import { Button } from "@maple/ui/components/ui/button" -import { Card } from "@maple/ui/components/ui/card" -import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" -import { DropdownMenuItem, DropdownMenuSeparator } from "@maple/ui/components/ui/dropdown-menu" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" -import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" -import { Switch } from "@maple/ui/components/ui/switch" -import { cn } from "@maple/ui/lib/utils" - -interface DestinationCardProps { - destination: AlertDestinationDocument - isAdmin: boolean - isTesting: boolean - isDeleting: boolean - onToggle: (destination: AlertDestinationDocument) => void - onTest: (destination: AlertDestinationDocument) => void - onEdit: (destination: AlertDestinationDocument) => void - /** Resolves to whether it was deleted; the confirm dialog stays open on failure. */ - onDelete: (destination: AlertDestinationDocument) => Promise -} - -export function DestinationCard({ - destination, - isAdmin, - isTesting, - isDeleting, - onToggle, - onTest, - onEdit, - onDelete, -}: DestinationCardProps) { - const provider = destinationProvider(destination) - const [confirmDelete, setConfirmDelete] = useState(false) - - return ( - -
-
- - -
-
- - {destination.name} - - - {provider.label} - -
- -
- - {destination.summary} - - - · - - - tested{" "} - {destination.lastTestedAt ? ( - - ) : ( - "never" - )} - - {!destination.enabled && ( - <> - - · - - Disabled - - )} -
- - {!destination.enabled && destination.disabledReason && ( - - - - Maple stopped delivering here after repeated failures:{" "} - {destination.disabledReason}. Fix the destination, then turn it back on. - - - )} - - {destination.lastTestError && ( - - - - {destination.lastTestError} - - - )} -
-
- -
- onToggle(destination)} - disabled={!isAdmin} - /> - - {isAdmin && ( - - onEdit(destination)}> - - Edit - - - setConfirmDelete(true)} - disabled={isDeleting} - > - - Delete - - - )} -
-
- onDelete(destination)} - /> -
- ) -} diff --git a/apps/web/src/components/alerts/incident-detail/incident-detail-view.tsx b/apps/web/src/components/alerts/incident-detail/incident-detail-view.tsx new file mode 100644 index 0000000000..53ce3b8923 --- /dev/null +++ b/apps/web/src/components/alerts/incident-detail/incident-detail-view.tsx @@ -0,0 +1,183 @@ +import { useMemo } from "react" +import { Link } from "@tanstack/react-router" +import type { + AlertIncidentDocument, + AlertRulePreviewResponse, + AlertRuleDocument, + InvestigationId, +} from "@maple/domain/http" +import { Button } from "@maple/ui/components/ui/button" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" + +import { AlertRuleChart } from "@/components/alerts/alert-rule-chart" +import { AlertSeverityBadge } from "@/components/alerts/alert-severity-badge" +import { IncidentRail } from "@/components/alerts/incident-detail/incident-rail" +import { IncidentTimeline } from "@/components/alerts/incident-detail/incident-timeline" +import { ChatBubbleSparkleIcon } from "@/components/icons" +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { formatAlertDuration, formatSignalValue, signalLabels } from "@/lib/alerts/form-utils" +import { + conditionPhrase, + holdReasonLabel, + incidentGroup, + type IncidentTimelineEntry, + signalTitle, +} from "@/lib/alerts/incident-timeline" +import type { AlertRuleStateRow } from "@/lib/collections/alerts" +import { formatDateInTimezone, formatTimeInTimezone } from "@/lib/timezone-format" + +function statusLine(incident: AlertIncidentDocument, now: number, timeZone: string): string { + const at = (iso: string) => + formatDateInTimezone(iso, { timeZone }) === formatDateInTimezone(now, { timeZone }) + ? formatTimeInTimezone(iso, { timeZone }) + : `${formatDateInTimezone(iso, { timeZone })} ${formatTimeInTimezone(iso, { timeZone })}` + if (incident.status === "resolved") { + return `Resolved ${incident.resolvedAt ? at(incident.resolvedAt) : ""} · lasted ${formatAlertDuration(incident.firstTriggeredAt, incident.resolvedAt)}` + } + if (incident.holdReason != null) { + return `Waiting on data · ${holdReasonLabel[incident.holdReason]}` + } + return `Open ${formatAlertDuration(incident.firstTriggeredAt, new Date(now).toISOString())} · since ${at(incident.firstTriggeredAt)}` +} + +/** The incident page header: severity, status line, title, condition and the investigation action. */ +export function IncidentHeader({ + incident, + rule, + now, + timeZone, + investigationId, + investigationPending, + onInvestigate, +}: { + incident: AlertIncidentDocument + rule: AlertRuleDocument | null + now: number + timeZone: string + investigationId: InvestigationId | null + investigationPending: boolean + onInvestigate: () => void +}) { + const group = incidentGroup(incident) + const title = `${signalTitle(incident.signalType)} ${formatSignalValue(incident.signalType, incident.lastObservedValue)}${group != null ? ` on ${group}` : ""}` + const subline = [ + `Rule ${rule?.name ?? incident.ruleName}`, + `${incident.signalType === "builder_query" || incident.signalType === "raw_query" ? "value" : signalLabels[incident.signalType].toLowerCase()} ${conditionPhrase(incident)}${rule != null ? ` over ${rule.windowMinutes}m` : ""}`, + rule != null && rule.environments.length > 0 ? rule.environments.join(", ") : null, + ] + .filter((part) => part != null) + .join(" · ") + + return ( + +
+ + + {statusLine(incident, now, timeZone)} + +
+ {title} +

{subline}

+
+ } + > + {investigationId != null ? ( + + ) : ( + + )} + + ) +} + +/** The incident page body: signal chart, timeline and rail, all from props. */ +export function IncidentBody({ + incident, + rule, + ruleIncidents, + states, + preview, + previewLoading, + previewError, + range, + now, + timeZone, + entries, + entriesLoading, + entriesFailed, +}: { + incident: AlertIncidentDocument + rule: AlertRuleDocument | null + ruleIncidents: ReadonlyArray + states: ReadonlyArray + preview: AlertRulePreviewResponse | null + previewLoading: boolean + previewError: string | null + range: { start: number; end: number } + now: number + timeZone: string + entries: ReadonlyArray + entriesLoading: boolean + entriesFailed: boolean +}) { + const group = incidentGroup(incident) + // The chart is memoized; keep its array and window props referentially stable. + const chartIncidents = useMemo(() => [incident], [incident]) + const chartWindow = useMemo(() => ({ min: range.start, max: range.end }), [range.start, range.end]) + return ( +
+
+
+ + {signalTitle(incident.signalType)} + {group != null ? ` · ${group}` : ""} + + {rule != null ? ( + + ) : ( +

+ The rule behind this incident was deleted, so its signal cannot be replayed. +

+ )} +
+ +
+ +
+ ) +} diff --git a/apps/web/src/components/alerts/incident-detail/incident-rail.tsx b/apps/web/src/components/alerts/incident-detail/incident-rail.tsx new file mode 100644 index 0000000000..581e4fa87c --- /dev/null +++ b/apps/web/src/components/alerts/incident-detail/incident-rail.tsx @@ -0,0 +1,255 @@ +import { Link } from "@tanstack/react-router" +import { formatWarehouseDateTime } from "@maple/query-engine" +import type { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { cn } from "@maple/ui/lib/utils" + +import type { AlertRuleStateRow } from "@/lib/collections/alerts" +import { formatSignalValue } from "@/lib/alerts/form-utils" +import { conditionPhrase, incidentGroup, signalTitle } from "@/lib/alerts/incident-timeline" + +const GROUP_ROW_LIMIT = 8 +const DAY_MS = 24 * 60 * 60 * 1000 + +function RailSection({ + title, + action, + children, +}: { + title: React.ReactNode + action?: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
+ {title} + {action} +
+ {children} +
+ ) +} + +function GroupsSection({ + rule, + incident, + states, + openIncidents, +}: { + rule: AlertRuleDocument + incident: AlertIncidentDocument + states: ReadonlyArray + openIncidents: ReadonlyArray +}) { + const rows = states.toSorted((a, b) => { + const breached = Number(b.last_status === "breached") - Number(a.last_status === "breached") + return breached !== 0 ? breached : (b.last_value ?? 0) - (a.last_value ?? 0) + }) + const breaching = rows.filter((row) => row.last_status === "breached").length + const openByGroup = new Map(openIncidents.map((open) => [open.groupKey, open])) + return ( + +
    + {rows.slice(0, GROUP_ROW_LIMIT).map((row) => { + const breached = row.last_status === "breached" + const open = openByGroup.get(row.group_key) + const current = row.group_key === incident.groupKey + return ( +
  • + + {open != null && !current ? ( + + {row.group_key} + + ) : ( + + {row.group_key} + + )} + + {formatSignalValue(rule.signalType, row.last_value)} + +
  • + ) + })} +
+ {rows.length > GROUP_ROW_LIMIT ? ( +

{rows.length - GROUP_ROW_LIMIT} more groups

+ ) : null} +
+ ) +} + +function EvidenceLink({ detail, children }: { detail: string; children: React.ReactElement }) { + return ( +
  • + {children} +

    {detail}

    +
  • + ) +} + +/** Services the incident is about: its group for a per-service rule, else the rule's scope. */ +function incidentServices( + rule: AlertRuleDocument | null, + incident: AlertIncidentDocument, +): ReadonlyArray { + const group = incidentGroup(incident) + if (group != null && rule?.groupBy?.length === 1 && rule.groupBy[0] === "service.name") return [group] + return rule?.serviceNames ?? [] +} + +function EvidenceSection({ + rule, + incident, + window, +}: { + rule: AlertRuleDocument | null + incident: AlertIncidentDocument + window: { start: number; end: number } +}) { + const services = incidentServices(rule, incident) + const range = { + startTime: formatWarehouseDateTime(window.start), + endTime: formatWarehouseDateTime(window.end), + } + const scope = services.length > 0 ? services.join(", ") : "all services" + const servicesSearch = services.length > 0 ? [...services] : undefined + return ( + +
      + {incident.errorIssueId != null ? ( + + + Linked error issue + + + ) : null} + + + {incident.signalType === "error_rate" ? "Error traces" : "Traces"} + + + + + Logs + + +
    +
    + ) +} + +function RuleSection({ + rule, + ruleIncidents, + now, +}: { + rule: AlertRuleDocument + ruleIncidents: ReadonlyArray + now: number +}) { + const recent = ruleIncidents.filter((i) => now - Date.parse(i.firstTriggeredAt) <= 30 * DAY_MS) + const durations = recent + .flatMap((i) => + i.resolvedAt != null ? [Date.parse(i.resolvedAt) - Date.parse(i.firstTriggeredAt)] : [], + ) + .toSorted((a, b) => a - b) + const median = durations.length > 0 ? durations[Math.floor(durations.length / 2)] : undefined + const scope = [ + rule.groupBy != null && rule.groupBy.length > 0 ? `split by ${rule.groupBy.join(", ")}` : null, + rule.environments.length > 0 ? rule.environments.join(", ") : null, + ].filter((part) => part != null) + const breaches = rule.consecutiveBreachesRequired + const summary = `${signalTitle(rule.signalType)} ${conditionPhrase(rule)} over ${rule.windowMinutes}m, ${breaches} ${breaches === 1 ? "check" : "checks in a row"} to open${scope.length > 0 ? `, ${scope.join(", ")}` : ""}.` + return ( + + Edit rule + + } + > +
    + + {rule.name} + +

    {summary}

    +

    + Fired {recent.length} {recent.length === 1 ? "time" : "times"} in 30d + {median != null ? ` · median ${Math.max(1, Math.round(median / 60_000))}m` : ""} +

    +
    +
    + ) +} + +export function IncidentRail({ + rule, + incident, + states, + ruleIncidents, + window, + now, +}: { + rule: AlertRuleDocument | null + incident: AlertIncidentDocument + states: ReadonlyArray + ruleIncidents: ReadonlyArray + window: { start: number; end: number } + now: number +}) { + const grouped = rule != null && rule.groupBy != null && rule.groupBy.length > 0 && states.length > 1 + return ( + + ) +} diff --git a/apps/web/src/components/alerts/incident-detail/incident-timeline.tsx b/apps/web/src/components/alerts/incident-detail/incident-timeline.tsx new file mode 100644 index 0000000000..bafb90f10f --- /dev/null +++ b/apps/web/src/components/alerts/incident-detail/incident-timeline.tsx @@ -0,0 +1,92 @@ +import { Link } from "@tanstack/react-router" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { TONE_BORDER } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" + +import type { IncidentTimelineEntry } from "@/lib/alerts/incident-timeline" +import { formatDateInTimezone, formatTimeInTimezone } from "@/lib/timezone-format" + +function sameDay(a: number, b: number, timeZone: string): boolean { + return formatDateInTimezone(a, { timeZone }) === formatDateInTimezone(b, { timeZone }) +} + +export function IncidentTimeline({ + entries, + timeZone, + loading, + failed, +}: { + entries: ReadonlyArray + timeZone: string + /** Delivery or investigation rows are still loading. */ + loading: boolean + /** Delivery history failed to load, so the list is only partial. */ + failed: boolean +}) { + const firstAt = entries[0]?.at ?? 0 + return ( +
    + Timeline +
      + {entries.map((entry) => ( +
    1. + + + {entry.pending ? ( + + ) : ( + + )} + +
      +

      + {entry.title} +

      + {entry.detail ? ( +

      {entry.detail}

      + ) : null} + {entry.investigation ? ( +
      + {entry.investigation.headline ? ( +

      + {entry.investigation.headline} +

      + ) : null} + + Open investigation + +
      + ) : null} +
      +
    2. + ))} + {loading ? ( +
    3. + +
    4. + ) : null} +
    + {failed ? ( +

    + Delivery history could not be loaded, so notifications are missing. +

    + ) : null} +
    + ) +} diff --git a/apps/web/src/components/alerts/incident-timeline-strip.tsx b/apps/web/src/components/alerts/incident-timeline-strip.tsx deleted file mode 100644 index ad9ff49d74..0000000000 --- a/apps/web/src/components/alerts/incident-timeline-strip.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useMemo } from "react" - -import type { AlertIncidentDocument } from "@maple/domain/http" -import { cn } from "@maple/ui/lib/utils" - -import { useTimezonePreference } from "@/hooks/use-timezone-preference" -import { formatTimestampInTimezone } from "@/lib/timezone-format" - -/** - * Fixed-bucket strip of a rule's incident history over a time range: red where - * an incident was firing (bright while still open), green otherwise. Used at - * two sizes — the rule detail sticky header (default) and a compact per-row - * variant on the alerts overview. - */ -export function IncidentTimelineStrip({ - incidents, - range, - buckets = 45, - compact = false, - showAxisLabels = true, - className, -}: { - incidents: ReadonlyArray - /** Epoch-ms window the strip frames. */ - range: { min: number; max: number } - buckets?: number - /** Row-sized variant: shorter cells, no labels. */ - compact?: boolean - showAxisLabels?: boolean - className?: string -}) { - const { effectiveTimezone } = useTimezonePreference() - - const segments = useMemo( - () => - incidents.map((incident) => ({ - status: incident.status, - start: new Date(incident.firstTriggeredAt).getTime(), - end: incident.resolvedAt ? new Date(incident.resolvedAt).getTime() : range.max, - // From here on the open incident is waiting on telemetry rather than - // observed breaching. Kept on the segment so a bucket is hatched only - // when EVERY open incident in it is held — a sibling group still - // firing keeps the bucket red. - heldSince: - incident.status === "open" && incident.heldSince - ? new Date(incident.heldSince).getTime() - : null, - })), - [incidents, range.max], - ) - - const totalRange = Math.max(1, range.max - range.min) - - return ( -
    -
    - {Array.from({ length: buckets }, (_, i) => { - const bucketStart = range.min + (i / buckets) * totalRange - const bucketEnd = range.min + ((i + 1) / buckets) * totalRange - const hits = segments.filter((seg) => seg.end > bucketStart && seg.start < bucketEnd) - const open = hits.filter((seg) => seg.status === "open") - const hit = open[0] ?? hits[0] - const held = - open.length > 0 && - open.every((seg) => seg.heldSince !== null && seg.heldSince < bucketEnd) - return ( -
    - ) - })} -
    - {showAxisLabels && !compact && ( -
    - - {formatTimestampInTimezone(range.min, { - timeZone: effectiveTimezone, - style: "range", - })} - - - {formatTimestampInTimezone(range.max, { - timeZone: effectiveTimezone, - style: "range", - })} - -
    - )} -
    - ) -} diff --git a/apps/web/src/components/alerts/overview/active-incidents-table.tsx b/apps/web/src/components/alerts/overview/active-incidents-table.tsx deleted file mode 100644 index 9eebe7541b..0000000000 --- a/apps/web/src/components/alerts/overview/active-incidents-table.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { Link } from "@tanstack/react-router" -import { EmptyMessage } from "@maple/ui/components/ui/empty" -import { RelativeTime } from "@/components/common/relative-time" -import { ROW_LINK_LIFT, ROW_STRETCHED_LINK_CLASS } from "@/components/common/data-table" -import { Fragment, useMemo } from "react" - -import type { AlertIncidentDocument, AlertIncidentHoldReason } from "@maple/domain/http" - -import { AlertSeverityBadge } from "@/components/alerts/alert-severity-badge" -import { AlertStatusBadge } from "@/components/alerts/alert-status-badge" -import { sortIncidents, TagChips, TagGroupHeaderRow } from "@/components/alerts/overview/shared" -import { formatSignalValue } from "@/lib/alerts/form-utils" -import { groupByTag as groupItemsByTag } from "@/lib/alerts/tag-grouping" -import { EMPTY_VALUE } from "@maple/ui/lib/format" -import { cn } from "@maple/ui/lib/utils" -import { SectionHeading } from "@/components/common/section-heading" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - TruncatedCell, -} from "@maple/ui/components/ui/table" - -const holdReasonTitle = (reason: AlertIncidentHoldReason): string => { - switch (reason) { - case "volume_collapsed": - return "The breach stopped appearing, but traffic dropped well below its usual level. Resolves when data returns or after a few windows." - case "no_data": - return "The breach stopped appearing because the service stopped reporting. Resolves when telemetry returns or after six hours." - case "sampling_changed": - return "The breach stopped appearing while sampling changed. Resolves when the signal settles or after six hours." - case "probe_failed": - return "Maple could not verify telemetry for this service. Resolves on the next successful check." - } -} - -/** - * Open incidents, severity-sorted, with tag inheritance from the owning rule. - * Shown at the top of the overview whenever anything is firing. - */ -export function ActiveIncidentsTable({ - incidents, - tagsByRuleId, - grouped, -}: { - /** Open incidents, already tag-filtered by the overview toolbar. */ - incidents: readonly AlertIncidentDocument[] - tagsByRuleId: Map - grouped: boolean -}) { - const sorted = useMemo(() => sortIncidents(incidents), [incidents]) - const groups = useMemo( - () => (grouped ? groupItemsByTag(sorted, (i) => tagsByRuleId.get(i.ruleId) ?? []) : null), - [grouped, sorted, tagsByRuleId], - ) - - const renderRow = (incident: AlertIncidentDocument, key: string) => { - const tags = tagsByRuleId.get(incident.ruleId) ?? [] - return ( - - - - - - - {incident.ruleName} - - {!grouped && } - - - - {incident.groupKey ?? "all"} - - - - {incident.holdReason != null ? ( - - - {incident.heldSince ? ( - - ) : null} - - ) : ( - <> - - {formatSignalValue(incident.signalType, incident.lastObservedValue)} - - - / {formatSignalValue(incident.signalType, incident.threshold)} - - - )} - - - {incident.lastTriggeredAt ? ( - - ) : ( - EMPTY_VALUE - )} - - - {incident.lastNotifiedAt ? ( - - ) : ( - "Never" - )} - - - ) - } - - return ( -
    - - - - - - Severity - Rule - Group - Current value - Duration - Last notified - - - - {sorted.length === 0 ? ( - - - No active incidents match the selected tags. - - - ) : groups ? ( - groups.map((group) => ( - - - {group.items.map((incident) => - renderRow(incident, `${group.key}:${incident.id}`), - )} - - )) - ) : ( - sorted.map((incident) => renderRow(incident, incident.id)) - )} - -
    -
    - ) -} diff --git a/apps/web/src/components/alerts/overview/alerts-empty-state.tsx b/apps/web/src/components/alerts/overview/alerts-empty-state.tsx index fc0b27676e..844a0d92b9 100644 --- a/apps/web/src/components/alerts/overview/alerts-empty-state.tsx +++ b/apps/web/src/components/alerts/overview/alerts-empty-state.tsx @@ -12,14 +12,13 @@ import { ALERT_TEMPLATES, type AlertTemplate } from "@/lib/alerts/templates" /** * First-run empty state for the alerts overview (zero rules). Replaces the - * generic bell-badge with an operator-console "quiet monitor" readout — a live - * signal held below a not-yet-placed threshold — then surfaces the five real + * generic bell-badge with an operator-console "quiet monitor" readout: a live + * signal held below a not-yet-placed threshold, then surfaces the five real * starter templates as brand-colored, one-click deep links into a pre-filled * create form. Non-admins get the readout + a nudge, no create affordances. * - * The tile hues mirror `BUILTIN_SIGNAL_OPTIONS` on the create form - * (signal-and-threshold-section.tsx) so a template reads with the same color it - * paints on the live chart. Kept here rather than on the pure `ALERT_TEMPLATES` + * The tile hues are the signals' chart colors, so a template reads with the + * same color it paints on the live chart. Kept here rather than on the pure `ALERT_TEMPLATES` * module so `templates.ts` stays React-free beyond its display icons. */ const TILE_TONE: Record = { @@ -42,7 +41,7 @@ export function AlertsEmptyState({ serviceName, }: { isAdmin: boolean - /** Without one, a rule has nowhere to send its incidents — so that comes first. */ + /** Without one, a rule has nowhere to send its incidents, so that comes first. */ hasDestinations: boolean serviceName?: string }) { @@ -84,7 +83,7 @@ export function AlertsEmptyState({ Add destination ) : ( - @@ -157,7 +156,7 @@ function TemplateTile({ template, serviceName }: { template: AlertTemplate; serv /** * The signature graphic: a live signal (muted, with a pulsing leading edge) - * running steadily below a dashed amber threshold that hasn't been placed yet — + * running steadily below a dashed amber threshold that hasn't been placed yet, * the exact thing a rule adds. The threshold reuses `.infra-ref-line` (dashed * draw-in, with its own `prefers-reduced-motion` guard in styles.css); the leading dot is static. */ @@ -166,7 +165,7 @@ function QuietMonitor() {
    A steady signal running below an unset alert threshold - {/* Dashed amber threshold — the line a rule would place. */} + {/* Dashed amber threshold: the line a rule would place. */} - {/* Pulsing leading edge — signals are flowing, nothing is watching them. */} + {/* Leading edge: signals are flowing, nothing is watching them. */} diff --git a/apps/web/src/components/alerts/overview/alerts-health-summary.tsx b/apps/web/src/components/alerts/overview/alerts-health-summary.tsx deleted file mode 100644 index 428284fae9..0000000000 --- a/apps/web/src/components/alerts/overview/alerts-health-summary.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { StatRail, StatRailItem } from "@/components/common/stat-rail" -import type { Tone } from "@maple/ui/lib/tone" - -/** Health buckets a rule can land in — mirrors the `status` search param. */ -export type AlertsStatusFilter = "firing" | "attention" | "healthy" | "disabled" - -export interface AlertsHealthCounts { - firing: number - attention: number - healthy: number - disabled: number -} - -const cards: Array<{ - key: AlertsStatusFilter - label: string - hint: string - /** Value tone when the bucket is non-empty. */ - tone: Tone -}> = [ - { key: "firing", label: "Firing", hint: "open incidents", tone: "crit" }, - { key: "attention", label: "Needs attention", hint: "errors · stale · unrouted", tone: "warn" }, - { key: "healthy", label: "Healthy", hint: "evaluating normally", tone: "info" }, - { key: "disabled", label: "Disabled", hint: "not evaluating", tone: "neutral" }, -] - -/** - * Row of four clickable health buckets that leads the alerts overview, as a - * `StatRail` of selectable tiles. Clicking the active tile clears the filter. - */ -export function AlertsHealthSummary({ - counts, - active, - onActiveChange, -}: { - counts: AlertsHealthCounts - active: AlertsStatusFilter | undefined - onActiveChange: (status: AlertsStatusFilter | undefined) => void -}) { - return ( - - {cards.map((card) => { - const count = counts[card.key] - const isActive = active === card.key - return ( - onActiveChange(isActive ? undefined : card.key)} - selected={isActive} - /> - ) - })} - - ) -} diff --git a/apps/web/src/components/alerts/overview/alerts-hub.tsx b/apps/web/src/components/alerts/overview/alerts-hub.tsx new file mode 100644 index 0000000000..2af45e37e5 --- /dev/null +++ b/apps/web/src/components/alerts/overview/alerts-hub.tsx @@ -0,0 +1,136 @@ +import * as AsyncResult from "effect/reactivity/AsyncResult" +import { memo, type ReactNode } from "react" + +import type { AlertDestinationDocument, AlertRuleDocument } from "@maple/domain/http" +import { Unitflow, View } from "@maple/unitflow/react" +import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" + +import { InboxTab } from "@/components/alerts/overview/inbox-tab" +import { RulesTab } from "@/components/alerts/overview/rules-tab" +import { ErrorState } from "@/components/common/error-state" +import type { RuleBucket } from "@/lib/alerts/overview-derive" +import { AlertsOverviewModel, type AlertsOverviewReady } from "@/lib/models/alerts-overview-model" +import { unitflowRuntime } from "@/lib/models/runtime" + +export interface AlertsTabCounts { + readonly open: number + readonly rules: number +} + +/** Renders `children` with the open-incident and rule counts once the overview is ready. */ +export function AlertsTabCountsBoundary({ + children, +}: { + children: (counts: AlertsTabCounts | null) => ReactNode +}) { + return ( + children(null)} + > + {(unit) => } + + ) +} + +const TabCountsView = View.make( + AlertsOverviewModel, + ({ overview }, { render }: { render: (counts: AlertsTabCounts | null) => ReactNode }) => + render( + overview.phase === "ready" + ? { open: overview.openIncidents.length, rules: overview.rules.length } + : null, + ), +) + +export interface AlertsHubProps { + readonly tab: "inbox" | "rules" + readonly destinations: ReadonlyArray + readonly isAdmin: boolean + readonly serviceName: string | undefined + readonly status: RuleBucket | undefined + readonly onStatusChange: (status: RuleBucket | undefined) => void +} + +/** Inbox and Rules both render the derived overview model; this owns its lifecycle. */ +export function AlertsHub(props: AlertsHubProps) { + return ( + } + failed={() => } + > + {(unit) => } + + ) +} + +const HUB_SKELETON_ROWS = ["h-14 w-1/2", "h-40 w-full", "h-48 w-full"] + +function HubSkeleton() { + return ( + } + /> + ) +} + +function HubLoadError() { + return ( + window.location.reload()} + className="py-12" + /> + ) +} + +const HubBody = View.make( + AlertsOverviewModel, + ({ overview, toggleRule, toggleState }, props: AlertsHubProps) => { + if (overview.phase === "loading") return + if (overview.phase === "error") return + return + }, +) + +const HubContent = memo(function HubContent({ + data, + onToggleRule, + toggleState, + tab, + destinations, + isAdmin, + serviceName, + status, + onStatusChange, +}: AlertsHubProps & { + data: AlertsOverviewReady + onToggleRule: (rule: AlertRuleDocument) => void + toggleState: AsyncResult.AsyncResult +}) { + if (tab === "inbox") { + return ( + + ) + } + return ( + + ) +}) diff --git a/apps/web/src/components/alerts/overview/alerts-overview-tab.tsx b/apps/web/src/components/alerts/overview/alerts-overview-tab.tsx deleted file mode 100644 index 45bf2d678a..0000000000 --- a/apps/web/src/components/alerts/overview/alerts-overview-tab.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import { useNavigate, useSearch } from "@tanstack/react-router" -import { Exit } from "effect" -import * as AsyncResult from "effect/reactivity/AsyncResult" -import { memo, useEffect, useMemo, useRef, useState } from "react" -import { EMPTY_VALUE, pluralize } from "@maple/ui/lib/format" - -import type { AlertDestinationDocument, AlertRuleDocument } from "@maple/domain/http" -import { Unitflow, View } from "@maple/unitflow/react" - -import { SegmentedSelect } from "@/components/common/segmented-select" -import { StatRail, StatRailItem } from "@/components/common/stat-rail" -import { AlertTagControls } from "@/components/alerts/alert-tag-controls" -import { ActiveIncidentsTable } from "@/components/alerts/overview/active-incidents-table" -import { AlertsEmptyState } from "@/components/alerts/overview/alerts-empty-state" -import { - AlertsHealthSummary, - type AlertsStatusFilter, -} from "@/components/alerts/overview/alerts-health-summary" -import { RulesOverviewTable } from "@/components/alerts/overview/rules-overview-table" -import { MagnifierIcon } from "@/components/icons" -import { FilteredEmpty } from "@/components/common/filtered-empty" -import { toastExit } from "@/lib/error-toast" -import { needsAttention } from "@/lib/alerts/rule-status" -import { formatResolutionDuration } from "@/lib/alerts/form-utils" -import { - filterByTags, - groupByTag as groupItemsByTag, - tagFacets, - type TagGroup, -} from "@/lib/alerts/tag-grouping" -import { useAlertDestinationsList } from "@/hooks/use-alerts-list" -import { Result, useAtomValue } from "@/lib/effect-atom" -import { AlertsOverviewModel, type AlertsOverviewReady } from "@/lib/models/alerts-overview-model" -import { unitflowRuntime } from "@/lib/models/runtime" -import { retainedQuery } from "@/lib/services/common/atom-client" -import { ErrorState } from "@/components/common/error-state" -import { SearchInput } from "@maple/ui/components/ui/search-input" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" -import { Skeleton, SkeletonList } from "@maple/ui/components/ui/skeleton" - -/** Sentinel value for the "Created by" filter meaning no creator restriction. */ -const ANY_CREATOR = "__anyone__" - -const DAY_MS = 24 * 60 * 60 * 1000 - -/** - * The overview tab: health summary → active incidents (when firing) → filter - * toolbar → per-rule status table → summary strip. The route only decides - * which tab renders. - * - * Data + status derivation live in {@link AlertsOverviewModel} (unitflow), - * fed by the Electric-synced collections; this component tree is pure - * presentation over the model's `overview` store. - */ -export function AlertsOverviewTab() { - return ( - } - failed={() => } - > - {(unit) => } - - ) -} - -const OVERVIEW_SKELETON_ROWS = ["h-21 w-full", "h-10 w-full", "h-48 w-full"] - -function OverviewSkeleton() { - return ( - } - /> - ) -} - -function OverviewLoadError() { - return ( - window.location.reload()} - className="py-12" - /> - ) -} - -const OverviewBody = View.make(AlertsOverviewModel, ({ overview, toggleRule, toggleState }) => { - if (overview.phase === "loading") return - if (overview.phase === "error") return - return -}) - -/* ── Presentation ─────────────────────────────────────────────────────────── */ - -/** - * Everything below is pure presentation over the derived overview: URL-backed - * filters, search, the session/destination atoms, and the toggle mutation are - * view concerns and stay here. - * - * Memoized so app-shell renders (Clerk session touches, dialogs, sidebar) stop - * at this boundary: `data` only changes identity when the model re-derives, - * and `onToggleRule` is the runtime's stable fire callback. - */ -const AlertsOverviewContent = memo(function AlertsOverviewContent({ - data, - onToggleRule, - toggleState, -}: { - data: AlertsOverviewReady - /** The model's rule enable/disable Mutation, bound to a fire callback. */ - onToggleRule: (rule: AlertRuleDocument) => void - /** The shared in-flight state of {@link onToggleRule} — only its phase is read. */ - toggleState: AsyncResult.AsyncResult -}) { - const search = useSearch({ from: "/alerts/" }) - const navigate = useNavigate({ from: "/alerts/" }) - - const { - rules, - incidents, - openIncidents, - statesByRule, - incidentsByRule, - derivedByRuleId, - healthCounts, - timelineRange, - } = data - - const sessionResult = useAtomValue(retainedQuery("auth", "session", {})) - const { result: destinationsResult } = useAlertDestinationsList() - - const destinations = Result.builder(destinationsResult) - .onSuccess((response) => [...response.destinations] as AlertDestinationDocument[]) - .orElse(() => []) - - const isAdmin = Result.builder(sessionResult) - .onSuccess((session) => session.roles.some((role) => role === "root" || role === "org:admin")) - .orElse(() => false) - const currentUserId = Result.builder(sessionResult) - .onSuccess((session) => session.userId as string) - .orElse(() => null) - - // Surface toggle failures as a toast by watching the shared mutation state - // transition to a failure — an external side effect on a state change (the - // no-useEffect "sync an external system" case). Success is silent: the - // Electric `alertRules` shape re-renders the row on its own. - const lastToggleState = useRef(toggleState) - useEffect(() => { - if (toggleState === lastToggleState.current) return - lastToggleState.current = toggleState - if (AsyncResult.isFailure(toggleState)) { - toastExit(Exit.failCause(toggleState.cause), { error: "Failed to update rule" }) - } - }, [toggleState]) - const isToggling = AsyncResult.isWaiting(toggleState) - - const [searchQuery, setSearchQuery] = useState("") - - // Tag filter + grouping, URL-backed so views stay shareable. - const selectedTags = useMemo(() => search.tags ?? [], [search.tags]) - const groupByTagOn = search.groupByTag ?? false - const statusFilter = search.status - const setSelectedTags = (tags: string[]) => - navigate({ search: (prev) => ({ ...prev, tags: tags.length > 0 ? tags : undefined }) }) - const setGroupByTagOn = (grouped: boolean) => - navigate({ search: (prev) => ({ ...prev, groupByTag: grouped ? true : undefined }) }) - const setStatusFilter = (status: AlertsStatusFilter | undefined) => - navigate({ search: (prev) => ({ ...prev, status }) }) - - // "Created by" filter options — one entry per distinct rule creator, with the - // current user surfaced as "You". Maple has no org-members endpoint, so other - // creators are shown by their raw identifier. - const creatorOptions = useMemo(() => { - const options: Record = { [ANY_CREATOR]: "Anyone" } satisfies Record - for (const rule of rules) { - if (!(rule.createdBy in options)) { - options[rule.createdBy] = rule.createdBy === currentUserId ? "You" : rule.createdBy - } - } - return options - }, [rules, currentUserId]) - const creatorFilter = search.createdBy ?? ANY_CREATOR - const showCreatorFilter = Object.keys(creatorOptions).length > 2 - - /* ── Filtering ──────────────────────────────────────────────────────────── */ - - const matchesStatusFilter = (rule: AlertRuleDocument): boolean => { - if (statusFilter == null) return true - const derived = derivedByRuleId.get(rule.id) - if (derived == null) return false - if (statusFilter === "attention") return needsAttention(derived) - return derived.status === statusFilter - } - - const filteredRules = useMemo(() => { - let result: ReadonlyArray = rules - if (creatorFilter !== ANY_CREATOR) { - result = result.filter((r) => r.createdBy === creatorFilter) - } - const q = searchQuery.trim().toLowerCase() - if (q) { - result = result.filter( - (r) => - r.name.toLowerCase().includes(q) || - r.serviceNames?.some((s) => s.toLowerCase().includes(q)) || - r.tags.some((t) => t.includes(q)), - ) - } - result = result.filter(matchesStatusFilter) - return [...filterByTags(result, (r) => r.tags, selectedTags)] - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rules, searchQuery, creatorFilter, selectedTags, statusFilter, derivedByRuleId]) - - const ruleGroups: TagGroup[] | null = useMemo( - () => (groupByTagOn ? groupItemsByTag(filteredRules, (r) => r.tags) : null), - [groupByTagOn, filteredRules], - ) - - const ruleTagFacets = useMemo(() => tagFacets(rules, (r) => r.tags), [rules]) - const tagsByRuleId = useMemo( - () => new Map(rules.map((r) => [r.id, r.tags])), - [rules], - ) - const visibleIncidents = useMemo( - () => [...filterByTags(openIncidents, (i) => tagsByRuleId.get(i.ruleId) ?? [], selectedTags)], - [openIncidents, tagsByRuleId, selectedTags], - ) - - const destinationsById = useMemo(() => new Map(destinations.map((d) => [d.id, d])), [destinations]) - - /* ── Summary strip (Triggered window + MTTR) ────────────────────────────── */ - - const [triggeredWindow, setTriggeredWindow] = useState<"24h" | "7d" | "30d">("24h") - const triggeredInWindow = useMemo(() => { - const windowMs = - triggeredWindow === "24h" ? DAY_MS : triggeredWindow === "7d" ? 7 * DAY_MS : 30 * DAY_MS - const cutoff = Date.now() - windowMs - return incidents.filter((i) => { - if (!i.firstTriggeredAt) return false - return new Date(i.firstTriggeredAt).getTime() >= cutoff - }).length - }, [incidents, triggeredWindow]) - - const mttr = useMemo(() => { - const resolved = incidents.filter((i) => i.resolvedAt && i.firstTriggeredAt) - if (resolved.length === 0) return EMPTY_VALUE - const avg = - resolved.reduce((sum, i) => { - return sum + (new Date(i.resolvedAt!).getTime() - new Date(i.firstTriggeredAt).getTime()) - }, 0) / resolved.length - return formatResolutionDuration(avg) - }, [incidents]) - - const enabledRules = rules.filter((r) => r.enabled).length - - /* ── Render ─────────────────────────────────────────────────────────────── */ - - return ( -
    - - - {visibleIncidents.length > 0 && (statusFilter == null || statusFilter === "firing") && ( - - )} - -
    -
    - - {showCreatorFilter && ( - - )} - -
    - - {filteredRules.length === 0 && rules.length === 0 ? ( - 0} - serviceName={search.serviceName} - /> - ) : filteredRules.length === 0 ? ( - } - onClear={() => { - setSearchQuery("") - navigate({ - search: (prev) => ({ - ...prev, - tags: undefined, - createdBy: undefined, - status: undefined, - }), - }) - }} - className="py-12" - /> - ) : ( - - )} -
    - - {/* Slim summary strip — a secondary glance beneath the rules list */} -
    -
    - - options={[ - { value: "24h", label: "24h" }, - { value: "7d", label: "7d" }, - { value: "30d", label: "30d" }, - ]} - value={triggeredWindow} - onChange={setTriggeredWindow} - size="sm" - aria-label="Triggered window" - /> -
    - - - - - -
    -
    - ) -}) diff --git a/apps/web/src/components/alerts/overview/destination-row.tsx b/apps/web/src/components/alerts/overview/destination-row.tsx new file mode 100644 index 0000000000..de84bcaf1b --- /dev/null +++ b/apps/web/src/components/alerts/overview/destination-row.tsx @@ -0,0 +1,178 @@ +import { useState } from "react" + +import type { AlertDeliveryEventDocument, AlertDestinationDocument } from "@maple/domain/http" +import { Button } from "@maple/ui/components/ui/button" +import { ConfirmDialog } from "@maple/ui/components/ui/confirm-dialog" +import { DropdownMenuItem, DropdownMenuSeparator } from "@maple/ui/components/ui/dropdown-menu" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { Switch } from "@maple/ui/components/ui/switch" +import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" +import type { Tone } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" + +import { destinationProvider, ProviderLogo } from "@/components/alerts/destination-provider" +import { RelativeTime } from "@/components/common/relative-time" +import { CheckIcon, PencilIcon, TrashIcon } from "@/components/icons" + +/** How many recent attempts the health strip reads. */ +export const HEALTH_WINDOW = 20 + +export const DESTINATION_ROW_GRID = "grid grid-cols-[minmax(0,1fr)_110px_140px_110px_auto] items-center gap-4" + +function healthTone( + destination: AlertDestinationDocument, + recent: ReadonlyArray, +): Tone { + if (!destination.enabled) return destination.disabledAt != null ? "crit" : "neutral" + if (recent[0]?.status === "failed") return "crit" + if (recent.some((event) => event.status === "failed")) return "warn" + return recent.length > 0 ? "ok" : "neutral" +} + +/** One destination: provider, delivery health over the last attempts, last delivery, actions. */ +export function DestinationRow({ + destination, + recent, + isAdmin, + isTesting, + isDeleting, + onToggle, + onTest, + onEdit, + onDelete, +}: { + destination: AlertDestinationDocument + /** This destination's latest delivery attempts, newest first, at most {@link HEALTH_WINDOW}. */ + recent: ReadonlyArray + isAdmin: boolean + isTesting: boolean + isDeleting: boolean + onToggle: (destination: AlertDestinationDocument) => void + onTest: (destination: AlertDestinationDocument) => void + onEdit: (destination: AlertDestinationDocument) => void + /** Resolves to whether it was deleted; the confirm dialog stays open on failure. */ + onDelete: (destination: AlertDestinationDocument) => Promise +}) { + const [confirmDelete, setConfirmDelete] = useState(false) + const provider = destinationProvider(destination) + const lastSuccess = recent.find((event) => event.status === "success") + const failed = recent.filter((event) => event.status === "failed").length + const problem = + !destination.enabled && destination.disabledReason + ? `Maple stopped delivering here after repeated failures: ${destination.disabledReason}` + : destination.lastTestError + + return ( +
  • +
    +
    + + +
    +

    {destination.name}

    +

    + {destination.summary} +

    +
    +
    + {provider.label} + + + {!destination.enabled ? ( + + {destination.disabledAt != null ? "auto-disabled" : "disabled"} + + ) : lastSuccess ? ( + + ) : ( + "never" + )} + +
    + onToggle(destination)} + disabled={!isAdmin} + aria-label={destination.enabled ? "Disable destination" : "Enable destination"} + /> + + {isAdmin && ( + + onEdit(destination)}> + + Edit + + + setConfirmDelete(true)} + disabled={isDeleting} + > + + Delete + + + )} +
    +
    + {problem &&

    {problem}

    } + onDelete(destination)} + /> +
  • + ) +} + +const ATTEMPT_CLASS: Record = { + success: "bg-severity-info", + failed: "bg-severity-error", + queued: "bg-muted-foreground/40", + processing: "bg-muted-foreground/40", +} satisfies Record + +/** Oldest to newest, one tick per attempt. */ +function DeliveryStrip({ + recent, + failed, +}: { + recent: ReadonlyArray + failed: number +}) { + if (recent.length === 0) return No deliveries yet + return ( + + }> + {[...recent].reverse().map((event) => ( + + ))} + + + {failed === 0 + ? `Last ${recent.length} attempts delivered` + : `${failed} of the last ${recent.length} attempts failed`} + + + ) +} diff --git a/apps/web/src/components/alerts/overview/inbox-tab.tsx b/apps/web/src/components/alerts/overview/inbox-tab.tsx new file mode 100644 index 0000000000..c8b6d3fa95 --- /dev/null +++ b/apps/web/src/components/alerts/overview/inbox-tab.tsx @@ -0,0 +1,347 @@ +import { Link } from "@tanstack/react-router" +import { useMemo } from "react" + +import type { AlertDestinationDocument, AlertIncidentDocument } from "@maple/domain/http" +import { Badge } from "@maple/ui/components/ui/badge" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { countLabel, EMPTY_VALUE } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" + +import { AlertSeverityBadge } from "@/components/alerts/alert-severity-badge" +import { AlertsEmptyState } from "@/components/alerts/overview/alerts-empty-state" +import { sortIncidents } from "@/components/alerts/overview/shared" +import { ROW_STRETCHED_LINK_CLASS } from "@/components/common/data-table" +import { formatAlertDuration, formatResolutionDuration, formatSignalValue } from "@/lib/alerts/form-utils" +import { + type BlindSpot, + type BlindSpotKind, + blindSpots as deriveBlindSpots, + type FireLane, + fireLanes, + incidentTitle, +} from "@/lib/alerts/overview-derive" +import type { AlertsOverviewReady } from "@/lib/models/alerts-overview-model" + +const HOUR_MS = 60 * 60 * 1000 + +/** Inbox: what is open right now, the last 24h of firing, and the rules that cannot fire. */ +export function InboxTab({ + data, + destinations, + isAdmin, + serviceName, +}: { + data: AlertsOverviewReady + destinations: ReadonlyArray + isAdmin: boolean + serviceName: string | undefined +}) { + const { rules, incidents, openIncidents, derivedByRuleId, timelineRange } = data + + const sorted = useMemo(() => { + const all = sortIncidents(openIncidents) + return [...all.filter((i) => i.holdReason === null), ...all.filter((i) => i.holdReason !== null)] + }, [openIncidents]) + const spots = useMemo(() => deriveBlindSpots(rules, derivedByRuleId), [rules, derivedByRuleId]) + const lanes = useMemo(() => fireLanes(incidents, timelineRange), [incidents, timelineRange]) + + const held = sorted.filter((i) => i.holdReason !== null).length + const firing = sorted.length - held + const enabled = rules.filter((r) => r.enabled).length + const disabled = rules.length - enabled + const quiet = sorted.length === 0 + + if (rules.length === 0) { + return ( + 0} + serviceName={serviceName} + /> + ) + } + + const summary = [ + `${countLabel(enabled, "rule")} watching`, + spots.length > 0 ? `${spots.length} can't fire or notify` : "all evaluating and routed", + ...(disabled > 0 ? [`${disabled} disabled`] : []), + ].join(" · ") + + return ( +
    +
    +

    + {quiet ? "Nothing firing" : `${firing} firing`} + {held > 0 && ( + · {held} waiting on data + )} +

    +

    {summary}

    +
    + + {quiet ? ( + <> + + + + ) : ( + <> + +
    + + +
    + + )} +
    + ) +} + +/* ── Open incidents ─────────────────────────────────────────────────────── */ + +const INCIDENT_GRID = "grid grid-cols-[minmax(0,1fr)_150px_90px] items-center gap-4" + +function OpenIncidentsList({ incidents }: { incidents: ReadonlyArray }) { + return ( +
    +
    +
    + Open · {incidents.length} + Now / threshold + Open for +
    +
      + {incidents.map((incident) => ( + + ))} +
    +
    +
    + ) +} + +function OpenIncidentRow({ incident }: { incident: AlertIncidentDocument }) { + const held = incident.holdReason !== null + return ( +
  • + +
    +
    + {held ? ( + + Held + + ) : ( + + )} + + {held ? `${incident.ruleName}: waiting on data` : incidentTitle(incident)} + +
    +

    + {incident.ruleName} + {incident.groupKey !== null && ` · ${incident.groupKey}`} +

    +
    + + {held ? ( + no data + ) : ( + + {formatSignalValue(incident.signalType, incident.lastObservedValue)} + + )} + + {" / "} + {formatSignalValue(incident.signalType, incident.threshold)} + + + + {formatAlertDuration(incident.firstTriggeredAt, null)} + +
  • + ) +} + +/* ── Last 24 hours ──────────────────────────────────────────────────────── */ + +const MAX_LANES = 8 + +function FireStrip({ + lanes, + incidents, + range, + quietRules, +}: { + lanes: ReadonlyArray + incidents: ReadonlyArray + range: { readonly min: number; readonly max: number } + quietRules: number +}) { + const inWindow = incidents.filter((i) => Date.parse(i.firstTriggeredAt) >= range.min) + const resolved = inWindow.filter((i) => i.resolvedAt !== null) + const mttr = + resolved.length === 0 + ? EMPTY_VALUE + : formatResolutionDuration( + resolved.reduce( + (sum, i) => + sum + + (Date.parse(i.resolvedAt ?? i.firstTriggeredAt) - Date.parse(i.firstTriggeredAt)), + 0, + ) / resolved.length, + ) + const shown = lanes.slice(0, MAX_LANES) + const quiet = Math.max(0, quietRules - shown.length) + const span = range.max - range.min + + return ( +
    +
    + Last 24 hours + + {countLabel(inWindow.length, "incident")} · MTTR {mttr} ·{" "} + {countLabel(lanes.length, "rule")} fired + +
    + {shown.length === 0 ? ( +

    + No rule fired in the last 24 hours. +

    + ) : ( +
      + {shown.map((lane) => ( +
    • + + {lane.ruleName} + +
      + {lane.bars.map((bar) => ( + + ))} +
      +
    • + ))} +
    + )} +
    + {quiet > 0 ? `+ ${countLabel(quiet, "quiet rule")}` : ""} +
    + {Math.round(span / HOUR_MS)}h ago + {Math.round(span / HOUR_MS / 2)}h ago + Now +
    +
    +
    + ) +} + +/* ── Blind spots ────────────────────────────────────────────────────────── */ + +const BLIND_SPOT_META: Record = { + error: { label: "Error", action: "Fix", className: "text-severity-error" }, + stale: { label: "Stale", action: "Check", className: "text-severity-warn" }, + "no-data": { label: "No data", action: "Tune", className: "text-muted-foreground" }, + unrouted: { label: "Unrouted", action: "Route", className: "text-muted-foreground" }, + delivery: { label: "Delivery", action: "Check", className: "text-severity-warn" }, +} satisfies Record + +function BlindSpotsList({ spots }: { spots: ReadonlyArray }) { + return ( +
    +
    + Blind spots + + rules that can't fire or notify + +
    + {spots.length === 0 ? ( +

    + Every enabled rule is evaluating and routed somewhere. +

    + ) : ( +
      + {spots.map((spot) => { + const meta = BLIND_SPOT_META[spot.kind] + return ( +
    • + {meta.label} +
      +

      {spot.rule.name}

      +

      + {spot.detail} +

      +
      + + {meta.action} + +
    • + ) + })} +
    + )} +
    + ) +} diff --git a/apps/web/src/components/alerts/overview/settings-tab.tsx b/apps/web/src/components/alerts/overview/routing-tab.tsx similarity index 89% rename from apps/web/src/components/alerts/overview/settings-tab.tsx rename to apps/web/src/components/alerts/overview/routing-tab.tsx index 15dcf480ae..f903e4077a 100644 --- a/apps/web/src/components/alerts/overview/settings-tab.tsx +++ b/apps/web/src/components/alerts/overview/routing-tab.tsx @@ -5,7 +5,11 @@ import { toastManager } from "@maple/ui/components/ui/toast" import type { AlertDestinationDocument, AlertDestinationId } from "@maple/domain/http" -import { DestinationCard } from "@/components/alerts/destination-card" +import { + DESTINATION_ROW_GRID, + DestinationRow, + HEALTH_WINDOW, +} from "@/components/alerts/overview/destination-row" import { ProviderLogo } from "@/components/alerts/destination-provider" import { TagGroupHeaderRow } from "@/components/alerts/overview/shared" import { Link } from "@tanstack/react-router" @@ -72,7 +76,7 @@ export interface DestinationManager { } export function useDestinationManager(options?: { - /** Called with the new destination's id after a create succeeds — a rule form uses it to select the destination it just made. */ + /** Called with the new destination's id after a create succeeds; a rule form uses it to select the destination it just made. */ readonly onCreated?: (id: AlertDestinationId) => void }): DestinationManager { const createDestination = useAtomSet(MapleApiV2AtomClient.mutation("alertDestinations", "create"), { @@ -191,11 +195,10 @@ export function useDestinationManager(options?: { } /** - * Settings tab: the destinations grid + delivery log. The manager and the - * dialog live on the route (the overview's empty state opens it too) so the page header's - * "Add destination" action drives the same dialog. + * Routing tab: destinations with their delivery health, then the delivery log. + * The manager and dialog live on the route so the header's "Add destination" drives the same dialog. */ -export function AlertsSettingsTab({ manager, isAdmin }: { manager: DestinationManager; isAdmin: boolean }) { +export function AlertsRoutingTab({ manager, isAdmin }: { manager: DestinationManager; isAdmin: boolean }) { const { result: destinationsResult } = useAlertDestinationsList() const deliveryEventsResult = useAtomValue( retainedQueryV2("alertDeliveries", "list", { @@ -221,8 +224,8 @@ export function AlertsSettingsTab({ manager, isAdmin }: { manager: DestinationMa

    - Destinations are reusable across rules and keep provider retries and failures - auditable. + Where rules send incidents. Health is read from each destination's last{" "} + {HEALTH_WINDOW} delivery attempts.

    @@ -259,20 +262,39 @@ export function AlertsSettingsTab({ manager, isAdmin }: { manager: DestinationMa ) : ( -
    - {destinations.map((destination) => ( - - ))} +
    +
    +
    + Destination + Type + Last {HEALTH_WINDOW} attempts + Last delivered + +
    +
      + {destinations.map((destination) => ( + event.destinationId === destination.id) + .slice(0, HEALTH_WINDOW)} + isAdmin={isAdmin} + isTesting={manager.isTesting(destination.id)} + isDeleting={manager.isDeleting(destination.id)} + onToggle={manager.toggle} + onTest={manager.test} + onEdit={manager.openDialog} + onDelete={manager.remove} + /> + ))} +
    +
    )} diff --git a/apps/web/src/components/alerts/overview/rules-overview-table.tsx b/apps/web/src/components/alerts/overview/rules-overview-table.tsx deleted file mode 100644 index 3c29934408..0000000000 --- a/apps/web/src/components/alerts/overview/rules-overview-table.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { Link } from "@tanstack/react-router" -import { Fragment, useState } from "react" - -import type { AlertDestinationDocument, AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" -import type { AlertRuleStateRow } from "@/lib/collections/alerts" -import type { DerivedRuleStatus } from "@/lib/alerts/rule-status" -import type { TagGroup } from "@/lib/alerts/tag-grouping" - -import { AlertSeverityBadge } from "@/components/alerts/alert-severity-badge" -import { AlertStatusBadge, type AlertStatusState } from "@/components/alerts/alert-status-badge" -import { IncidentTimelineStrip } from "@/components/alerts/incident-timeline-strip" -import { NotifyChannels, SignalBadge, TagChips, TagGroupHeaderRow } from "@/components/alerts/overview/shared" -import { CircleWarningIcon } from "@/components/icons" -import { comparatorLabels, formatSignalValue } from "@/lib/alerts/form-utils" -import { worstState } from "@/lib/alerts/rule-status" -import { RelativeTime } from "@/components/common/relative-time" -import { ROW_LINK_LIFT, ROW_STRETCHED_LINK_CLASS } from "@/components/common/data-table" -import { EMPTY_VALUE } from "@maple/ui/lib/format" -import { LoadMoreButton } from "@maple/ui/components/ui/list-footer" -import { Switch } from "@maple/ui/components/ui/switch" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - TruncatedCell, -} from "@maple/ui/components/ui/table" -import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" -import { cn } from "@maple/ui/lib/utils" - -const COL_SPAN = 8 -/** Rows rendered before "Show all"; an org can have a thousand rules. */ -const RENDER_LIMIT = 100 - -/** Cap the groups to `limit` rows in total, keeping group order. */ -const capGroups = (groups: TagGroup[], limit: number): TagGroup[] => - groups.reduce<{ out: TagGroup[]; left: number }>( - (acc, group) => - acc.left <= 0 - ? acc - : { - out: [...acc.out, { ...group, items: group.items.slice(0, acc.left) }], - left: acc.left - group.items.length, - }, - { out: [], left: limit }, - ).out - -/** Derived rule status → status-badge state ("healthy" renders as "OK"). */ -const badgeState = (status: DerivedRuleStatus["status"]): AlertStatusState => - status === "healthy" ? "ok" : status - -/** - * The overview rules table: one row per rule with its live derived health, - * a compact 24h incident strip, and the latest observed value vs threshold. - */ -export function RulesOverviewTable({ - rules, - groups, - grouped, - destinationsById, - derivedByRuleId, - statesByRule, - incidentsByRuleId, - timelineRange, - isAdmin, - isToggling, - onToggle, -}: { - /** Filtered rules, in list order. Ignored when {@link groups} is set. */ - rules: readonly AlertRuleDocument[] - groups: TagGroup[] | null - grouped: boolean - destinationsById: Map - derivedByRuleId: Map - statesByRule: Map - /** All incidents per rule — the strip clips to {@link timelineRange}. */ - incidentsByRuleId: Map - timelineRange: { min: number; max: number } - isAdmin: boolean - /** A rule toggle is in flight — briefly disables the switches (the mutation - * serializes writes on one permit). */ - isToggling?: boolean - onToggle: (rule: AlertRuleDocument) => void -}) { - const [showAll, setShowAll] = useState(false) - const totalRows = groups ? groups.reduce((sum, group) => sum + group.items.length, 0) : rules.length - const capped = !showAll && totalRows > RENDER_LIMIT - const visibleGroups = groups && capped ? capGroups(groups, RENDER_LIMIT) : groups - const visibleRules = capped ? rules.slice(0, RENDER_LIMIT) : rules - - const renderRow = (rule: AlertRuleDocument, key: string) => { - const derived = derivedByRuleId.get(rule.id) - const state = worstState(statesByRule.get(rule.id) ?? []) - // Dedupe by id: a rule that lists the same destination twice still - // notifies it once, so show one mark (and keep React keys unique). - const ruleDestinations = [...new Set(rule.destinationIds)] - .map((id) => destinationsById.get(id)) - .filter((d): d is AlertDestinationDocument => d != null) - - const lastEvaluatedAt = state?.last_evaluated_at ?? rule.lastEvaluatedAt - - return ( - - - onToggle(rule)} - disabled={!isAdmin || isToggling} - /> - - -
    - - {rule.name} - - -
    - {!grouped && } -
    - - - - -
    - - {derived?.status === "error" && derived.reason != null && ( - - - } - > - - - - Last evaluation failed: {derived.reason} - - - )} - {derived?.attention.noDestinations && ( - - - } - > - - No destinations - - - Enabled but routed nowhere — this rule can notify no one. - - - )} -
    -
    - - - - - {state?.last_value != null ? ( - - {formatSignalValue(rule.signalType, state.last_value)} - - {comparatorLabels[rule.comparator]}{" "} - {formatSignalValue(rule.signalType, rule.threshold)} - - - ) : ( - {EMPTY_VALUE} - )} - - - {lastEvaluatedAt ? : EMPTY_VALUE} - - - - -
    - ) - } - - return ( - <> - - - - - Name - Severity - Status - Last 24h - Last value - Evaluated - Notify - - - - {visibleGroups - ? visibleGroups.map((group) => ( - - - {group.items.map((rule) => renderRow(rule, `${group.key}:${rule.id}`))} - - )) - : visibleRules.map((rule) => renderRow(rule, rule.id))} - -
    - {capped && ( -
    - setShowAll(true)} - label={`Show all ${totalRows.toLocaleString()}`} - /> -
    - )} - - ) -} diff --git a/apps/web/src/components/alerts/overview/rules-tab.tsx b/apps/web/src/components/alerts/overview/rules-tab.tsx new file mode 100644 index 0000000000..ba89390ef7 --- /dev/null +++ b/apps/web/src/components/alerts/overview/rules-tab.tsx @@ -0,0 +1,431 @@ +import { Link } from "@tanstack/react-router" +import { useMemo, useState } from "react" + +import type { AlertDestinationDocument, AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" +import { Alert, AlertDescription } from "@maple/ui/components/ui/alert" +import { DropdownMenuItem } from "@maple/ui/components/ui/dropdown-menu" +import { FilterChip } from "@maple/ui/components/ui/filter-chip" +import { LoadMoreButton } from "@maple/ui/components/ui/list-footer" +import { RowActionsMenu } from "@maple/ui/components/ui/row-actions-menu" +import { SearchInput } from "@maple/ui/components/ui/search-input" +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { countLabel } from "@maple/ui/lib/format" +import type { Tone } from "@maple/ui/lib/tone" +import { cn } from "@maple/ui/lib/utils" + +import { AlertsEmptyState } from "@/components/alerts/overview/alerts-empty-state" +import { FilteredEmpty } from "@/components/common/filtered-empty" +import { ROW_LINK_LIFT, ROW_STRETCHED_LINK_CLASS } from "@/components/common/data-table" +import { MagnifierIcon } from "@/components/icons" +import { formatSignalValue } from "@/lib/alerts/form-utils" +import { + type RibbonCell, + type RuleBucket, + ruleBucket, + ruleConditionSummary, + ruleHeadroom, + ruleRibbon, +} from "@/lib/alerts/overview-derive" +import { type DerivedRuleStatus, worstState } from "@/lib/alerts/rule-status" +import { groupByTag, type TagGroup, UNTAGGED_KEY } from "@/lib/alerts/tag-grouping" +import type { AlertsOverviewReady } from "@/lib/models/alerts-overview-model" + +const DAY_MS = 24 * 60 * 60 * 1000 +const RIBBON_DAYS = 7 +/** One cell per 6 hours. */ +const RIBBON_CELLS = RIBBON_DAYS * 4 +const FIRED_WINDOW_DAYS = 30 +/** Rows rendered before "Show all"; an org can have a thousand rules. */ +const RENDER_LIMIT = 100 + +const BUCKETS: ReadonlyArray<{ value: RuleBucket; label: string; tone: Tone }> = [ + { value: "firing", label: "Firing", tone: "crit" }, + { value: "blind", label: "Blind", tone: "warn" }, + { value: "healthy", label: "Healthy", tone: "ok" }, + { value: "disabled", label: "Disabled", tone: "neutral" }, +] + +const BUCKET_TONE: Record = { + firing: "crit", + blind: "warn", + healthy: "ok", + disabled: "neutral", +} satisfies Record + +const ROW_GRID = + "grid grid-cols-[12px_minmax(0,1fr)_170px_150px_minmax(0,160px)_48px_32px] items-center gap-4" + +/** Cap the groups to `limit` rows in total, keeping group order. */ +const capGroups = (groups: ReadonlyArray>, limit: number): TagGroup[] => + groups.reduce<{ out: TagGroup[]; left: number }>( + (acc, group) => + acc.left <= 0 + ? acc + : { + out: [...acc.out, { ...group, items: group.items.slice(0, acc.left) }], + left: acc.left - group.items.length, + }, + { out: [], left: limit }, + ).out + +export function RulesTab({ + data, + destinations, + isAdmin, + serviceName, + status, + onStatusChange, + isToggling, + toggleFailed, + onToggleRule, +}: { + data: AlertsOverviewReady + destinations: ReadonlyArray + isAdmin: boolean + serviceName: string | undefined + status: RuleBucket | undefined + onStatusChange: (status: RuleBucket | undefined) => void + isToggling: boolean + /** The last enable/disable write failed. */ + toggleFailed: boolean + onToggleRule: (rule: AlertRuleDocument) => void +}) { + const { rules, derivedByRuleId, incidentsByRule, timelineRange } = data + const now = timelineRange.max + const [query, setQuery] = useState("") + const [showAll, setShowAll] = useState(false) + + const bucketByRuleId = useMemo( + () => new Map(rules.map((rule) => [rule.id, ruleBucket(derivedByRuleId.get(rule.id))])), + [rules, derivedByRuleId], + ) + const bucketCounts = useMemo(() => { + const counts = { firing: 0, blind: 0, healthy: 0, disabled: 0 } satisfies Record + for (const bucket of bucketByRuleId.values()) counts[bucket]++ + return counts + }, [bucketByRuleId]) + + const firedByRuleId = useMemo(() => { + const cutoff = now - FIRED_WINDOW_DAYS * DAY_MS + return new Map( + rules.map((rule) => [ + rule.id, + (incidentsByRule.get(rule.id) ?? []).filter((i) => Date.parse(i.firstTriggeredAt) >= cutoff) + .length, + ]), + ) + }, [rules, incidentsByRule, now]) + const totalFired = [...firedByRuleId.values()].reduce((sum, n) => sum + n, 0) + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + return rules.filter((rule) => { + if (status !== undefined && bucketByRuleId.get(rule.id) !== status) return false + if (q === "") return true + return ( + rule.name.toLowerCase().includes(q) || + rule.serviceNames.some((s) => s.toLowerCase().includes(q)) || + rule.tags.some((t) => t.toLowerCase().includes(q)) + ) + }) + }, [rules, query, status, bucketByRuleId]) + + const groups = useMemo(() => groupByTag(filtered, (rule) => rule.tags), [filtered]) + const totalRows = groups.reduce((sum, group) => sum + group.items.length, 0) + const capped = !showAll && totalRows > RENDER_LIMIT + const visibleGroups = capped ? capGroups(groups, RENDER_LIMIT) : groups + + const destinationsById = useMemo(() => new Map(destinations.map((d) => [d.id, d])), [destinations]) + const ribbonRange = { min: now - RIBBON_DAYS * DAY_MS, max: now } + + if (rules.length === 0) { + return ( + 0} + serviceName={serviceName} + /> + ) + } + + return ( +
    +
    + + {BUCKETS.map((bucket) => ( + onStatusChange(pressed ? bucket.value : undefined)} + count={bucketCounts[bucket.value]} + > + {bucket.label} + + ))} + + {countLabel(rules.length, "rule")} · {totalFired} fired in {FIRED_WINDOW_DAYS}d + +
    + + {toggleFailed && ( + + Failed to update the rule. Try again. + + )} + + {filtered.length === 0 ? ( + } + onClear={() => { + setQuery("") + onStatusChange(undefined) + }} + className="py-12" + /> + ) : ( +
    +
    +
    + + Rule + Headroom now + Last {RIBBON_DAYS} days + Routes to + Fired {FIRED_WINDOW_DAYS}d + +
    + {visibleGroups.map((group) => { + const firingInGroup = group.items.filter( + (r) => bucketByRuleId.get(r.id) === "firing", + ).length + return ( +
    +
    + + {group.key === UNTAGGED_KEY ? "No tag" : group.label} + + + {" "} + {countLabel(group.count, "rule")} + {firingInGroup > 0 && ` · ${firingInGroup} firing`} + +
    +
      + {group.items.map((rule) => ( + all.indexOf(id) === i) + .map((id) => destinationsById.get(id)) + .filter( + (d): d is AlertDestinationDocument => d !== undefined, + )} + fired={firedByRuleId.get(rule.id) ?? 0} + isAdmin={isAdmin} + isToggling={isToggling} + onToggle={onToggleRule} + /> + ))} +
    +
    + ) + })} +
    +
    + )} + {capped && ( +
    + setShowAll(true)} + label={`Show all ${totalRows.toLocaleString()}`} + /> +
    + )} +
    + ) +} + +function RuleRow({ + rule, + derived, + bucket, + lastValue, + incidents, + ribbonRange, + destinations, + fired, + isAdmin, + isToggling, + onToggle, +}: { + rule: AlertRuleDocument + derived: DerivedRuleStatus | undefined + bucket: RuleBucket + lastValue: number | null + incidents: ReadonlyArray + ribbonRange: { min: number; max: number } + destinations: ReadonlyArray + fired: number + isAdmin: boolean + isToggling: boolean + onToggle: (rule: AlertRuleDocument) => void +}) { + return ( +
  • + +
    + + {rule.name} + +

    + {ruleConditionSummary(rule)} +

    +
    + + + + + {fired} + + + {isAdmin && ( + + }> + Open rule + + onToggle(rule)}> + {rule.enabled ? "Disable rule" : "Enable rule"} + + + )} + +
  • + ) +} + +function HeadroomMeter({ + rule, + derived, + value, +}: { + rule: AlertRuleDocument + derived: DerivedRuleStatus | undefined + value: number | null +}) { + if (!rule.enabled) return Not evaluating + if (derived?.status === "error") + return Query fails to run + if (value === null) return Waiting on data + + const meter = ruleHeadroom(rule, value) + const tone = meter.breached + ? "bg-severity-error" + : meter.headroom !== null && meter.headroom < 0.2 + ? "bg-severity-warn" + : "bg-severity-info" + const label = meter.breached + ? "over threshold" + : meter.headroom !== null + ? `${Math.round(meter.headroom * 100)}% headroom` + : "within threshold" + + return ( +
    +
    + + {meter.ticks.map((tick) => ( + + ))} +
    +

    + {formatSignalValue(rule.signalType, value)} · {label} +

    +
    + ) +} + +const RIBBON_CLASS: Record = { + quiet: "bg-muted", + warning: "bg-severity-warn", + critical: "bg-severity-error", +} satisfies Record + +function StateRibbon({ cells }: { cells: ReadonlyArray }) { + return ( +
    + {cells.map((cell, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length positional ribbon + + ))} +
    + ) +} + +function RoutesTo({ + destinations, + enabled, +}: { + destinations: ReadonlyArray + enabled: boolean +}) { + if (destinations.length === 0) { + return ( + + Nobody + + ) + } + const names = destinations.map((d) => d.name).join(", ") + return ( + + {names} + + ) +} diff --git a/apps/web/src/components/alerts/overview/shared.tsx b/apps/web/src/components/alerts/overview/shared.tsx index 8476384e92..17f73fb4ab 100644 --- a/apps/web/src/components/alerts/overview/shared.tsx +++ b/apps/web/src/components/alerts/overview/shared.tsx @@ -1,54 +1,10 @@ -import type { AlertDestinationDocument, AlertIncidentDocument } from "@maple/domain/http" +import type { AlertIncidentDocument } from "@maple/domain/http" -import { CircleWarningIcon } from "@/components/icons" -import { ProviderLogo } from "@/components/alerts/destination-provider" -import { signalLabels } from "@/lib/alerts/form-utils" -import { Badge } from "@maple/ui/components/ui/badge" import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { pluralize } from "@maple/ui/lib/format" import { TableCell, TableRow } from "@maple/ui/components/ui/table" -import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" -import { cn } from "@maple/ui/lib/utils" -/* -------------------------------------------------------------------------- */ -/* Signal badge tone */ -/* -------------------------------------------------------------------------- */ - -const signalBadgeClass: Record = { - error_rate: "border-severity-error/30 text-severity-error", - p95_latency: "border-primary/30 text-primary", - p99_latency: "border-primary/30 text-primary", - apdex: "border-severity-warn/30 text-severity-warn", - // Throughput rides the dedicated chart-throughput hue so the chip stays - // distinguishable from the amber p95/apdex chips (see DESIGN.md). - throughput: "border-[var(--chart-throughput)]/30 text-[var(--chart-throughput)]", - builder_query: "border-muted-foreground/30 text-muted-foreground", - raw_query: "border-muted-foreground/30 text-muted-foreground", -} satisfies Record - -export function SignalBadge({ signalType }: { signalType: string }) { - return ( - - {signalLabels[signalType as keyof typeof signalLabels] ?? signalType} - - ) -} - -/** Secondary-tone tag chips, kept visually distinct from outline service badges. */ -export function TagChips({ tags }: { tags: readonly string[] }) { - if (tags.length === 0) return null - return ( -
    - {tags.map((tag) => ( - - {tag} - - ))} -
    - ) -} - -/** Group-header row reused by the grouped rules and incidents tables. */ +/** Group-header row for grouped tables (the delivery log's per-day groups). */ export function TagGroupHeaderRow({ label, count, @@ -74,59 +30,6 @@ export function TagGroupHeaderRow({ ) } -/** - * Notify cell. Shows the real provider marks a rule routes to (joined from the - * already-loaded destinations) instead of an opaque count. An enabled rule with - * no destination is surfaced as a warning — it can page no one. - */ -export function NotifyChannels({ - destinations, - enabled, -}: { - destinations: AlertDestinationDocument[] - enabled: boolean -}) { - if (destinations.length === 0) { - if (!enabled) return No channel - return ( - - - } - > - - No channel - - Enabled but routed nowhere — this rule can notify no one. - - ) - } - - const shown = destinations.slice(0, 3) - const extra = destinations.length - shown.length - return ( - - }> - - {shown.map((d) => ( - - ))} - - {extra > 0 && +{extra}} - - {destinations.map((d) => d.name).join(", ")} - - ) -} - /** Critical before warning, then most-recently-triggered first. */ const severityRank: Record = { critical: 0, warning: 1 } satisfies Record export function sortIncidents(incidents: readonly AlertIncidentDocument[]): AlertIncidentDocument[] { diff --git a/apps/web/src/components/alerts/rule-action-bar.tsx b/apps/web/src/components/alerts/rule-action-bar.tsx index 302b67ef43..40266b553a 100644 --- a/apps/web/src/components/alerts/rule-action-bar.tsx +++ b/apps/web/src/components/alerts/rule-action-bar.tsx @@ -9,6 +9,8 @@ import { RULE_FORM_MAX_WIDTH } from "@/components/alerts/rule-form-layout" interface RuleActionBarProps { editing: boolean + /** Unsaved field changes while editing; null on a new rule. */ + changeCount?: number | null saving: boolean validationIssues: string[] onCancel: () => void @@ -29,6 +31,7 @@ const MAX_VISIBLE_ISSUES = 3 */ export function RuleActionBar({ editing, + changeCount = null, saving, validationIssues, onCancel, @@ -74,12 +77,24 @@ export function RuleActionBar({ />
    + {changeCount !== null && ( + + {changeCount === 0 + ? "No changes" + : `${changeCount} unsaved ${changeCount === 1 ? "change" : "changes"}`} + + )} {cancelSlot ?? ( )} - diff --git a/apps/web/src/components/alerts/rule-condition-bar.tsx b/apps/web/src/components/alerts/rule-condition-bar.tsx new file mode 100644 index 0000000000..e6deacd565 --- /dev/null +++ b/apps/web/src/components/alerts/rule-condition-bar.tsx @@ -0,0 +1,237 @@ +import type { Dispatch, SetStateAction } from "react" + +import type { AlertComparator, AlertSeverity } from "@maple/domain/http" +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@maple/ui/components/ui/input-group" +import { RadioGroupPrimitive, RadioPrimitive } from "@maple/ui/components/ui/radio-group" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { StatusDot } from "@maple/ui/components/ui/status-dot" +import { cn } from "@maple/ui/lib/utils" + +import { + comparatorLabels, + isRangeComparator, + thresholdUnitLabel, + type RuleFormState, +} from "@/lib/alerts/form-utils" + +/* Strip the native number spinners: the bar reads as a sentence, not a stepper. */ +const NUMERIC_CONTROL_CLASS = + "font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + +const COMPARATOR_WORDS: Record = { + gt: "is above", + gte: "is at or above", + lt: "is below", + lte: "is at or below", + eq: "equals", + neq: "is not", + between: "is between", + not_between: "is outside", +} satisfies Record + +const COMPARATOR_ITEMS = (Object.keys(comparatorLabels) as AlertComparator[]).map((value) => ({ + value, + label: COMPARATOR_WORDS[value], +})) + +const SEVERITY_OPTIONS: ReadonlyArray<{ + value: AlertSeverity + label: string + selected: string + dot: string +}> = [ + { + value: "warning", + label: "Warning", + selected: "border-severity-warn/60 bg-severity-warn/10 text-severity-warn", + dot: "bg-severity-warn", + }, + { + value: "critical", + label: "Critical", + selected: "border-severity-error/60 bg-severity-error/10 text-severity-error", + dot: "bg-severity-error", + }, +] + +interface RuleConditionBarProps { + form: RuleFormState + onChange: Dispatch> + /** The saved rule while editing: a changed field shows its old value struck through. */ + saved: RuleFormState | null +} + +/** + * The rule's condition as one sentence under the preview chart: "Alert when + * the value is above 5% over 5 min in 2 checks in a row". Every field here + * redraws the threshold line or the would-fire bands above it. + */ +export function RuleConditionBar({ form, onChange, saved }: RuleConditionBarProps) { + const rangeMode = isRangeComparator(form.comparator) + const unit = thresholdUnitLabel(form) + const was = (pick: (f: RuleFormState) => string) => + saved !== null && pick(saved).trim() !== pick(form).trim() ? pick(saved) : null + + return ( +
    + Alert when the value + + f.threshold)} + onChange={(value) => onChange((c) => ({ ...c, threshold: value }))} + /> + {rangeMode && ( + <> + and + f.thresholdUpper)} + onChange={(value) => onChange((c) => ({ ...c, thresholdUpper: value }))} + /> + + )} + over + f.windowMinutes)} + onChange={(value) => onChange((c) => ({ ...c, windowMinutes: value }))} + /> + in + f.consecutiveBreachesRequired)} + onChange={(value) => onChange((c) => ({ ...c, consecutiveBreachesRequired: value }))} + narrow + /> + with at least + f.minimumSampleCount)} + onChange={(value) => onChange((c) => ({ ...c, minimumSampleCount: value }))} + /> +
    + + as + + onChange((c) => ({ ...c, severity: value }))} + aria-labelledby="rule-severity-label" + className="inline-flex items-center gap-1 rounded-md bg-muted/30 p-0.5" + > + {SEVERITY_OPTIONS.map((option) => { + const selected = form.severity === option.value + return ( + + + {option.label} + + ) + })} + +
    +
    + ) +} + +function ConditionInput({ + id, + label, + value, + unit, + previous, + onChange, + narrow = false, +}: { + id: string + label: string + value: string + unit: string | null + /** The saved value when it differs, struck through inside the field. */ + previous: string | null + onChange: (value: string) => void + /** Small counts (checks) need less room than thresholds. */ + narrow?: boolean +}) { + return ( + + {previous !== null && ( + + + {previous.length > 0 ? previous : "none"} + + + )} + onChange(event.target.value)} + className={narrow ? "w-10" : "w-16"} + controlClassName={NUMERIC_CONTROL_CLASS} + /> + {unit !== null && ( + + {unit} + + )} + + ) +} diff --git a/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx b/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx index 55f88d1422..4bc2b93d0e 100644 --- a/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx +++ b/apps/web/src/components/alerts/rule-detail/rule-diagnosis-panel.tsx @@ -274,7 +274,7 @@ function DiagnosisAction({ } if (action.kind === "destinations") { return ( - ) diff --git a/apps/web/src/components/alerts/rule-edit-impact.tsx b/apps/web/src/components/alerts/rule-edit-impact.tsx new file mode 100644 index 0000000000..950b2e335b --- /dev/null +++ b/apps/web/src/components/alerts/rule-edit-impact.tsx @@ -0,0 +1,188 @@ +import { useMemo } from "react" + +import type { AlertIncidentDocument, AlertRulePreviewResponse } from "@maple/domain/http" +import { UNGROUPED_GROUP_KEY } from "@maple/domain/http" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { cn } from "@maple/ui/lib/utils" + +import { breachStatsFromPreview, formatBreachDuration } from "@/lib/alerts/breach-stats" +import { formatSignalValue, type RuleFormState, displaySignalType } from "@/lib/alerts/form-utils" +import type { RuleFieldChange } from "@/lib/alerts/preview-insights" + +interface RuleEditImpactProps { + /** The saved rule replayed over the same window as the draft. */ + savedPreview: AlertRulePreviewResponse | null + savedLoading: boolean + draftPreview: AlertRulePreviewResponse | null + draftLoading: boolean + changes: readonly RuleFieldChange[] + clearedIncidents: readonly AlertIncidentDocument[] + form: RuleFormState + /** The chart's domain in epoch ms, so the fire marks line up with the axis below. */ + domain: { min: number; max: number } +} + +/** + * Edit mode's answer to "what does this change do": the saved rule and the + * draft replayed over the same window, fire for fire, plus every changed field + * and the open incidents the draft would no longer hold. + */ +export function RuleEditImpact({ + savedPreview, + savedLoading, + draftPreview, + draftLoading, + changes, + clearedIncidents, + form, + domain, +}: RuleEditImpactProps) { + const before = useMemo(() => breachStatsFromPreview(savedPreview), [savedPreview]) + const after = useMemo(() => breachStatsFromPreview(draftPreview), [draftPreview]) + const loading = savedLoading || draftLoading + const delta = after.breachCount - before.breachCount + const display = displaySignalType(form) + + return ( +
    + + Before and after · {changes.length} unsaved {changes.length === 1 ? "change" : "changes"} + + {loading ? ( + + ) : ( +
    +
    0 ? "warn" : "neutral"} + /> +
    +
    + )} + +
    + + +
    + +
      + {changes.map((change) => ( +
    • + {change.field} + {change.before} + → + {change.after} +
    • + ))} +
    + + {clearedIncidents.length > 0 && ( +
    +

    + {clearedIncidents.length} open{" "} + {clearedIncidents.length === 1 ? "incident" : "incidents"} would clear under this edit +

    +
      + {clearedIncidents.slice(0, 3).map((incident) => ( +
    • + {incident.groupKey === null || incident.groupKey === UNGROUPED_GROUP_KEY + ? incident.ruleName + : incident.groupKey} + {incident.lastObservedValue !== null && + ` at ${formatSignalValue(display, incident.lastObservedValue)}`} +
    • + ))} + {clearedIncidents.length > 3 &&
    • and {clearedIncidents.length - 3} more
    • } +
    +

    + Takes effect on the next check after saving. +

    +
    + )} +
    + ) +} + +function Figure({ + before, + after, + caption, + tone, +}: { + before: string + after: string + caption: string + tone: "good" | "warn" | "neutral" +}) { + return ( +
    +

    + {before} + + {after} + +

    +

    {caption}

    +
    + ) +} + +/** One row of would-fire marks across the window, placed by start time. */ +function FireRail({ + label, + preview, + domain, + tone, +}: { + label: string + preview: AlertRulePreviewResponse | null + domain: { min: number; max: number } + tone: "muted" | "crit" +}) { + const span = Math.max(domain.max - domain.min, 1) + const marks = (preview?.wouldFire ?? []).flatMap((fire) => { + const start = Date.parse(fire.start) + const end = Date.parse(fire.end) + if (!Number.isFinite(start) || !Number.isFinite(end)) return [] + const left = Math.min(Math.max((start - domain.min) / span, 0), 1) + const width = Math.max((end - start) / span, 0.004) + return [{ key: `${fire.groupKey}:${fire.start}`, left, width: Math.min(width, 1 - left) }] + }) + return ( +
    + {label} +
    + {marks.map((mark) => ( + + ))} +
    +
    + ) +} diff --git a/apps/web/src/components/alerts/rule-live-chart-hero.tsx b/apps/web/src/components/alerts/rule-live-chart-hero.tsx index 9d4eb4ef04..0d336da2be 100644 --- a/apps/web/src/components/alerts/rule-live-chart-hero.tsx +++ b/apps/web/src/components/alerts/rule-live-chart-hero.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react" +import { useMemo, type ReactNode } from "react" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" @@ -9,13 +9,22 @@ import { cn } from "@maple/ui/lib/utils" import type { AlertRulePreviewResponse } from "@maple/domain/http" import { AlertRuleChart } from "@/components/alerts/alert-rule-chart" import { AlertStatusBadge } from "@/components/alerts/alert-status-badge" -import { CheckIcon, EyeIcon, FireIcon } from "@/components/icons" +import { RuleEditImpact } from "@/components/alerts/rule-edit-impact" +import { CheckIcon, CircleWarningIcon, EyeIcon, FireIcon } from "@/components/icons" import { breachStatsFromPreview, formatBreachDuration, type BreachStats } from "@/lib/alerts/breach-stats" +import { + countSkippedWindows, + draftThresholds, + focusPreviewOnClosestGroups, + type RuleFieldChange, + type SkippedWindows, +} from "@/lib/alerts/preview-insights" import { normalizeTimestampInput } from "@/lib/timezone-format" import { TimeRangePicker } from "@/components/time-range-picker/time-range-picker" import type { TimeRange } from "@/components/time-range-picker/types" +import type { AlertIncidentDocument } from "@maple/domain/http" import { - formThresholdToDomain, + displaySignalType, formatSignalValue, signalLabels, type RuleFormState, @@ -38,15 +47,22 @@ interface RuleLiveChartHeroProps { /** The picker's own (unresolved) selection — preset or custom bounds. */ timeRange: TimeRange onTimeRangeChange: (range: TimeRange) => void + /** Edit mode: the saved rule's replay and what the edit changes, drawn above the chart. */ + comparison?: { + savedPreview: AlertRulePreviewResponse | null + savedLoading: boolean + changes: readonly RuleFieldChange[] + clearedIncidents: readonly AlertIncidentDocument[] + } | null + /** The condition bar, docked under the chart it redraws. */ + children?: ReactNode } /** - * Hero block sitting above the form: the SAME chart the rule detail page - * renders, fed by the evaluator's own preview over a user-picked lookback — - * threshold line, per-window observations, and shaded "would have fired" - * spans. The would-have-fired count is folded into the header strip as a - * compact pill so the entire hero stays inside one short card. Raw-SQL rules - * get a real preview too (the endpoint replays the SQL per evaluation window). + * The editor's preview: the same chart the rule detail page renders, fed by + * the evaluator's own replay over a user-picked lookback, with the threshold + * line, would-fire bands and the verdict. The condition bar docks under it, and + * edit mode adds the saved-vs-draft replay above it. */ export function RuleLiveChartHero({ form, @@ -59,15 +75,13 @@ export function RuleLiveChartHero({ range, timeRange, onTimeRangeChange, + comparison = null, + children, }: RuleLiveChartHeroProps) { - // The preview plots observed signal data in domain units (error_rate as a - // 0–1 ratio), so the threshold line must be converted from the form's percent - // input to the same domain units to line up with the data. - const threshold = formThresholdToDomain(form.signalType, form.threshold) - const thresholdUpper = - form.thresholdUpper.trim().length > 0 && Number.isFinite(Number(form.thresholdUpper)) - ? formThresholdToDomain(form.signalType, form.thresholdUpper) - : null + // The preview plots domain units (error rate as a 0–1 ratio); the form's + // threshold goes through the same conversion the save does. + const { threshold, upper: thresholdUpper } = draftThresholds(form) + const displaySignal = displaySignalType(form) // The chart's domain is the exact window the preview query ran over, so the // axis, threshold line, and would-fire bands stay in step with the picker. @@ -80,12 +94,13 @@ export function RuleLiveChartHero({ ) const stats = useMemo(() => breachStatsFromPreview(preview), [preview]) - - const safeThreshold = Number.isFinite(threshold) ? threshold : 0 + const skipped = useMemo(() => countSkippedWindows(preview), [preview]) + // A split rule draws only the groups nearest to firing; the verdict still counts them all. + const focused = useMemo(() => focusPreviewOnClosestGroups(preview), [preview]) const groupBySummary = formatGroupBySummary(form) return ( - +
    @@ -93,7 +108,7 @@ export function RuleLiveChartHero({ {groupBySummary && ( - Grouped by {groupBySummary} + per {groupBySummary} )} @@ -105,7 +120,7 @@ export function RuleLiveChartHero({ )} {/* Preview-only lookback: it re-runs the preview query, never the @@ -132,23 +147,77 @@ export function RuleLiveChartHero({
    -
    + {comparison !== null && ( + + )} + +
    + {focused.hiddenGroupCount > 0 && ( +

    + Showing the {focused.shownGroups.length} groups closest to the threshold ·{" "} + {focused.hiddenGroupCount} more +

    + )} +
    + + {children &&
    {children}
    } ) } +/** + * Said while editing, not after saving: windows the evaluator would skip can + * never fire, so a rule that skips most of them is quieter than it looks. + */ +function SkippedWindowsWarning({ skipped, form }: { skipped: SkippedWindows; form: RuleFormState }) { + if (skipped.skipped === 0) return null + const share = skipped.skipped / Math.max(skipped.total, 1) + const reasons = [ + skipped.byReason.below_min_samples > 0 && + `${skipped.byReason.below_min_samples} under ${form.minimumSampleCount} samples`, + skipped.byReason.no_data > 0 && `${skipped.byReason.no_data} with no data`, + skipped.byReason.no_value > 0 && `${skipped.byReason.no_value} with no value`, + ].filter((reason): reason is string => reason !== false) + const advice = + skipped.byReason.below_min_samples >= skipped.byReason.no_data + ? "Lower Min samples so they count." + : form.alertOnNoData + ? null + : "Turn on alerting when there is no data to count them." + return ( +
    = 0.25 ? "text-severity-warn" : "text-muted-foreground", + )} + > + + + {skipped.skipped} of {skipped.total} windows would be skipped ({reasons.join(", ")}), and a + skipped window can't fire.{advice && ` ${advice}`} + +
    + ) +} + function formatGroupBySummary(form: RuleFormState): string | null { const groupBy = form.signalType === "builder_query" && form.queryBuilderDraft.addOns?.groupBy diff --git a/apps/web/src/components/alerts/rule-noise-section.tsx b/apps/web/src/components/alerts/rule-noise-section.tsx new file mode 100644 index 0000000000..fe28871faf --- /dev/null +++ b/apps/web/src/components/alerts/rule-noise-section.tsx @@ -0,0 +1,97 @@ +import type { Dispatch, SetStateAction } from "react" + +import { Card } from "@maple/ui/components/ui/card" +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@maple/ui/components/ui/input-group" +import { SettingRow } from "@maple/ui/components/ui/setting-row" +import { Switch } from "@maple/ui/components/ui/switch" + +import { SectionHeading } from "@/components/common/section-heading" +import { ruleFormIsGrouped, type RuleFormState } from "@/lib/alerts/form-utils" + +interface RuleNoiseSectionProps { + form: RuleFormState + onChange: Dispatch> +} + +/** How an open incident behaves once it fires: when it resolves, how often it repeats, what no data means. */ +export function RuleNoiseSection({ form, onChange }: RuleNoiseSectionProps) { + const grouped = ruleFormIsGrouped(form) + return ( + + +
    + onChange((c) => ({ ...c, consecutiveHealthyRequired: value }))} + /> + onChange((c) => ({ ...c, renotifyIntervalMinutes: value }))} + /> + onChange((c) => ({ ...c, alertOnNoData: checked }))} + /> + } + /> +
    +
    + ) +} + +function NoiseRow({ + id, + label, + unit, + value, + onChange, +}: { + id: string + label: string + unit: string + value: string + onChange: (value: string) => void +}) { + return ( +
    + + + onChange(event.target.value)} + className="w-14" + controlClassName="font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" + /> + + {unit} + + +
    + ) +} diff --git a/apps/web/src/components/alerts/rule-query-section.tsx b/apps/web/src/components/alerts/rule-query-section.tsx new file mode 100644 index 0000000000..69b37b833b --- /dev/null +++ b/apps/web/src/components/alerts/rule-query-section.tsx @@ -0,0 +1,421 @@ +import { + useDeferredValue, + useMemo, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react" + +import type { AlertSignalType } from "@maple/domain/http" +import { rawAlertSampleCountWarning } from "@maple/domain/raw-sql" +import { Button } from "@maple/ui/components/ui/button" +import { Card } from "@maple/ui/components/ui/card" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { InlineCode } from "@maple/ui/components/ui/inline-code" +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} from "@maple/ui/components/ui/input-group" +import { RadioGroupPrimitive, RadioPrimitive } from "@maple/ui/components/ui/radio-group" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" +import { cn } from "@maple/ui/lib/utils" + +import { EnvironmentCombobox, ServiceCombobox } from "@/components/alerts/service-combobox" +import { SegmentedSelect } from "@/components/common/segmented-select" +import { QueryPanel } from "@/components/dashboard-builder/config/query-panel" +import { RawSqlEditorPanel } from "@/components/dashboard-builder/config/raw-sql-editor-panel" +import { GroupByMultiSelect } from "@/components/query-builder/group-by-multi-select" +import type { AutocompleteValuesContextType } from "@/hooks/use-autocomplete-values" +import { RAW_QUERY_REDUCER_LABELS, type RuleFormState } from "@/lib/alerts/form-utils" +import { signalTypeToKind, switchSignalKind, type SignalKind } from "@/lib/alerts/rule-modes" +import { Result, useAtomValue } from "@/lib/effect-atom" +import { + resetAggregationForMetricType, + resetQueryForDataSource, + type QueryBuilderDataSource, + type QueryBuilderQueryDraft, +} from "@maple/query-engine/query-builder" +import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom" +import { listMetricsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" + +interface RuleQuerySectionProps { + form: RuleFormState + onChange: Dispatch> + autocompleteValues: AutocompleteValuesContextType + serviceNameOptions: string[] + environmentOptions: string[] +} + +const KIND_OPTIONS: ReadonlyArray<{ value: SignalKind; label: string }> = [ + { value: "builtin", label: "Built-in" }, + { value: "builder_query", label: "Query builder" }, + { value: "raw_query", label: "SQL" }, +] + +const KIND_HINT = { + builtin: "Five common signals on root spans. Switching modes carries the filters over.", + builder_query: "Same builder as dashboards. Switching to Built-in keeps what it can express.", + raw_query: "ClickHouse SQL, replayed per evaluation window.", +} satisfies Record + +const BUILTIN_SIGNALS: ReadonlyArray<{ value: AlertSignalType; label: string; hint: string }> = [ + { value: "error_rate", label: "Error rate", hint: "failed / all" }, + { value: "p95_latency", label: "P95", hint: "latency" }, + { value: "p99_latency", label: "P99", hint: "tail latency" }, + { value: "apdex", label: "Apdex", hint: "satisfaction" }, + { value: "throughput", label: "Throughput", hint: "requests / window" }, +] + +/** + * The rule's query, in one of three modes over the same draft: Built-in is a + * form for the canned trace signals, Query builder is the dashboard + * `QueryPanel`, SQL is the raw editor. Switching carries filters and keeps the + * threshold's meaning (`switchSignalKind`). + */ +export function RuleQuerySection(props: RuleQuerySectionProps) { + const { form, onChange } = props + const kind = signalTypeToKind(form.signalType) + // Set by the switch itself (an event), cleared by the next one. + const [notice, setNotice] = useState(null) + + const setKind = (next: SignalKind) => { + const moved = switchSignalKind(form, next) + setNotice(moved.notice) + onChange(moved.form) + } + + return ( +
    +
    + + Query + + + options={KIND_OPTIONS} + value={kind} + onChange={setKind} + aria-label="Query mode" + size="sm" + /> +

    {notice ?? KIND_HINT[kind]}

    +
    + {kind === "builtin" && ( + setKind("builder_query")} /> + )} + {kind === "builder_query" && } + {kind === "raw_query" && } +
    + ) +} + +/* -------------------------------------------------------------------------- */ +/* Built-in */ +/* -------------------------------------------------------------------------- */ + +function BuiltinQueryForm({ + form, + onChange, + autocompleteValues, + serviceNameOptions, + environmentOptions, + onOpenBuilder, +}: RuleQuerySectionProps & { onOpenBuilder: () => void }) { + const hasServices = form.serviceNames.length > 0 + return ( + + onChange((c) => ({ ...c, signalType: value }))} + aria-label="Built-in signal" + className="grid grid-cols-2 border-b sm:grid-cols-5" + > + {BUILTIN_SIGNALS.map((signal) => ( + + {signal.label} + {signal.hint} + + ))} + + +
    + + + onChange((c) => ({ + ...c, + serviceNames: values, + // Each named service is already its own group, and exclude only + // narrows "all services". + groupBy: values.length > 0 ? [] : c.groupBy, + excludeServiceNames: values.length > 0 ? [] : c.excludeServiceNames, + })) + } + placeholder={hasServices ? "Add service..." : "All services"} + /> + + + onChange((c) => ({ ...c, environments: values }))} + /> + + + onChange((c) => ({ ...c, groupBy: values }))} + attributeKeys={autocompleteValues.traces?.attributeKeys} + placeholder="service.name" + className="w-full" + disabled={hasServices} + /> + + + onChange((c) => ({ ...c, excludeServiceNames: values }))} + disabled={hasServices} + placeholder={hasServices ? "Only applies to all services" : "Skip these services"} + /> + + {form.signalType === "apdex" && ( + + + + onChange((c) => ({ ...c, apdexThresholdMs: e.target.value })) + } + controlClassName="font-mono tabular-nums" + /> + + ms + + + + )} +
    + +
    + + Measured on root spans. A service that records failures on child spans and returns success + from its entry point stays healthy here. + + +
    +
    + ) +} + +function BuiltinRow({ + label, + htmlFor, + hint, + children, +}: { + label: string + htmlFor: string + hint?: string + children: ReactNode +}) { + return ( +
    + +
    + {children} + {hint &&

    {hint}

    } +
    +
    + ) +} + +/* -------------------------------------------------------------------------- */ +/* Query builder */ +/* -------------------------------------------------------------------------- */ + +type MetricRow = { + metricName: string + metricType: string + serviceName: string + isMonotonic: boolean +} + +function useAlertMetricSelectionOptions(query: QueryBuilderQueryDraft) { + const [metricSearch, setMetricSearch] = useState("") + const deferredMetricSearch = useDeferredValue(metricSearch) + const metricsResult = useAtomValue( + query.dataSource === "metrics" + ? listMetricsResultAtom({ data: { limit: 100, search: deferredMetricSearch || undefined } }) + : disabledResultAtom(), + ) + + const metricRows = useMemo( + (): MetricRow[] => + Result.builder(metricsResult) + .onSuccess((response) => (response as { data: MetricRow[] }).data) + .orElse(() => []), + [metricsResult], + ) + + const metricSelectionOptions = useMemo(() => { + const seen = new Set() + const options: Array<{ value: string; label: string; isMonotonic: boolean }> = [] + for (const row of metricRows) { + if ( + row.metricType !== "sum" && + row.metricType !== "gauge" && + row.metricType !== "histogram" && + row.metricType !== "exponential_histogram" + ) { + continue + } + const value = `${row.metricName}::${row.metricType}` + if (seen.has(value)) continue + seen.add(value) + options.push({ + value, + label: `${row.metricName} (${row.metricType})`, + isMonotonic: row.isMonotonic, + }) + } + return options + }, [metricRows]) + + return { metricSelectionOptions, setMetricSearch } +} + +function AlertQueryPanel({ form, onChange, autocompleteValues }: RuleQuerySectionProps) { + const query = form.queryBuilderDraft + const { metricSelectionOptions, setMetricSearch } = useAlertMetricSelectionOptions(query) + + const updateQuery = (updater: (query: QueryBuilderQueryDraft) => QueryBuilderQueryDraft) => { + onChange((current) => ({ + ...current, + queryBuilderDraft: updater(current.queryBuilderDraft), + })) + } + + return ( +
    + updateQuery((current) => ({ ...current, aggregation }))} + onMetricSelectionChange={(selection) => + updateQuery((current) => + current.dataSource === "metrics" + ? { + ...current, + metricName: selection.metricName, + metricType: selection.metricType, + isMonotonic: selection.isMonotonic, + aggregation: resetAggregationForMetricType( + current.aggregation, + selection.metricType, + selection.isMonotonic, + ), + } + : current, + ) + } + onClone={() => {}} + onRemove={() => {}} + onDataSourceChange={(dataSource: QueryBuilderDataSource) => + updateQuery((current) => resetQueryForDataSource(current, dataSource)) + } + showHeaderActions={false} + showVisibilityToggle={false} + /> +

    + Each group is checked on its own and gets its own incident. +

    +
    + ) +} + +/* -------------------------------------------------------------------------- */ +/* SQL */ +/* -------------------------------------------------------------------------- */ + +function RawQueryEditor({ + form, + onChange, +}: { + form: RuleFormState + onChange: Dispatch> +}) { + const sampleWarning = rawAlertSampleCountWarning(form.rawQuerySql, Number(form.minimumSampleCount) || 0) + return ( +
    + onChange((current) => ({ ...current, rawQuerySql: draft.sql }))} + showBucketControl={false} + targetLabel="alert rule" + /> +
    + + +

    + Return a numeric value. Optional: group{" "} + to split, samples for the count Min samples sums. No rows is a + no-data check. +

    +
    + {sampleWarning &&

    {sampleWarning}

    } +
    + ) +} diff --git a/apps/web/src/components/alerts/scope-section.tsx b/apps/web/src/components/alerts/scope-section.tsx deleted file mode 100644 index 91a611ecd4..0000000000 --- a/apps/web/src/components/alerts/scope-section.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import type { Dispatch, SetStateAction } from "react" - -import { Card } from "@maple/ui/components/ui/card" -import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" - -import { EnvironmentCombobox, ServiceCombobox } from "@/components/alerts/service-combobox" -import { GroupByMultiSelect } from "@/components/query-builder/group-by-multi-select" -import { SectionHeading } from "@/components/common/section-heading" -import type { AutocompleteValuesContextType } from "@/hooks/use-autocomplete-values" -import type { RuleFormState } from "@/lib/alerts/form-utils" - -interface ScopeSectionProps { - form: RuleFormState - onChange: Dispatch> - serviceNameOptions: string[] - environmentOptions: string[] - autocompleteValues: AutocompleteValuesContextType -} - -/** - * Choose which services and environments the rule covers. Group-by and Exclude - * stay mounted at all times — when a specific service is selected they disable - * themselves and surface a one-line hint, so the form never silently grows or - * shrinks as the user edits. - */ -export function ScopeSection({ - form, - onChange, - serviceNameOptions, - environmentOptions, - autocompleteValues, -}: ScopeSectionProps) { - const hasSpecificServices = form.serviceNames.length > 0 - const groupByAttributeKeys = autocompleteValues.traces?.attributeKeys - - return ( - - - -
    - - Services - - onChange((c) => ({ - ...c, - serviceNames: values, - // Clear group/exclude when narrowing to specific services so the - // disabled fields don't carry stale state into the submitted rule. - groupBy: values.length > 0 ? [] : c.groupBy, - excludeServiceNames: values.length > 0 ? [] : c.excludeServiceNames, - })) - } - placeholder={form.serviceNames.length === 0 ? "All services" : "Add service..."} - /> - - - - Environments - onChange((c) => ({ ...c, environments: values }))} - /> - Leave empty to evaluate across every environment. - - - - Group by - onChange((c) => ({ ...c, groupBy: values }))} - attributeKeys={groupByAttributeKeys} - placeholder="service.name" - className="w-full" - disabled={hasSpecificServices} - /> - {hasSpecificServices && ( - - Disabled: each selected service is already its own group. - - )} - - - - Exclude services - onChange((c) => ({ ...c, excludeServiceNames: values }))} - disabled={hasSpecificServices} - placeholder={hasSpecificServices ? "—" : "Skip these services"} - /> - {hasSpecificServices && ( - - Disabled: only applies when scoping to all services. - - )} - -
    -
    - ) -} diff --git a/apps/web/src/components/alerts/signal-and-threshold-section.tsx b/apps/web/src/components/alerts/signal-and-threshold-section.tsx deleted file mode 100644 index 6b91d0c4cd..0000000000 --- a/apps/web/src/components/alerts/signal-and-threshold-section.tsx +++ /dev/null @@ -1,762 +0,0 @@ -import { InlineCode } from "@maple/ui/components/ui/inline-code" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" -import { RadioGroupPrimitive, RadioPrimitive } from "@maple/ui/components/ui/radio-group" -import { StatusDot } from "@maple/ui/components/ui/status-dot" -import { - useDeferredValue, - useMemo, - useState, - type Dispatch, - type ReactNode, - type SetStateAction, -} from "react" -import type { AlertComparator, AlertSeverity, AlertSignalType } from "@maple/domain/http" -import { rawAlertSampleCountWarning } from "@maple/domain/raw-sql" - -import { Card } from "@maple/ui/components/ui/card" -import { Input } from "@maple/ui/components/ui/input" -import { Label } from "@maple/ui/components/ui/label" -import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@maple/ui/components/ui/select" -import { Switch } from "@maple/ui/components/ui/switch" -import { SettingRow } from "@maple/ui/components/ui/setting-row" -import { cn } from "@maple/ui/lib/utils" - -import { SegmentedSelect } from "@/components/common/segmented-select" -import { SectionHeading } from "@/components/common/section-heading" -import { QueryPanel } from "@/components/dashboard-builder/config/query-panel" -import { RawSqlEditorPanel } from "@/components/dashboard-builder/config/raw-sql-editor-panel" -import { - BoltIcon, - BracketsCurlyIcon, - ChartLineIcon, - ChevronDownIcon, - CirclePercentageIcon, - FireIcon, - PulseIcon, - SlidersIcon, -} from "@/components/icons" -import type { AutocompleteValuesContextType } from "@/hooks/use-autocomplete-values" -import { - comparatorLabels, - isRangeComparator, - RAW_QUERY_REDUCER_LABELS, - ruleFormIsGrouped, - type RuleFormState, -} from "@/lib/alerts/form-utils" -import { Result, useAtomValue } from "@/lib/effect-atom" -import { - resetAggregationForMetricType, - resetQueryForDataSource, - type QueryBuilderDataSource, - type QueryBuilderQueryDraft, -} from "@maple/query-engine/query-builder" -import { disabledResultAtom } from "@/lib/services/atoms/disabled-result-atom" -import { listMetricsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" - -interface SignalAndThresholdSectionProps { - form: RuleFormState - onChange: Dispatch> - autocompleteValues: AutocompleteValuesContextType -} - -/* Seven signal types is too many for a single segmented bar — they wrap and - every option looks equally weighted even though five of them are "I want a - common metric" and the other two are "I'll define my own". We split the - choice into two tiers: - - Tier 1 (always visible): the *kind* of signal — built-in, query builder, - or raw SQL. - - Tier 2 (only when "built-in" is active): which of the five canned - metrics to watch. - The mapping back to `AlertSignalType` happens in `signalTypeToKind` and the - default-on-kind-switch logic in `setKind`. */ -type SignalKind = "builtin" | "builder_query" | "raw_query" - -function signalTypeToKind(signalType: AlertSignalType): SignalKind { - if (signalType === "builder_query") return "builder_query" - if (signalType === "raw_query") return "raw_query" - return "builtin" -} - -/* Strip the browser's native number-input spin buttons. Applied to every - numeric input on the page — we don't want the ▲▼ overlay competing with the - form's clean monospace numerals. Lives as a const so the Tailwind arbitrary - selectors stay readable and we can add it consistently everywhere. */ -const NUMERIC_INPUT_CLASS = - "font-mono [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0" - -const SIGNAL_KIND_OPTIONS: ReadonlyArray<{ - value: SignalKind - label: string - icon: ReactNode -}> = [ - { value: "builtin", label: "Built-in", icon: }, - { value: "builder_query", label: "Query", icon: }, - { value: "raw_query", label: "Raw SQL", icon: }, -] - -/* The "built-in" tier-2 row: 5 small chips with icons. Icons are deliberately - reused from the templates overlay so the picker visually rhymes with the - first-touch flow. Each signal also carries its own brand color drawn from - the chart-* CSS variables, so the chip's tint matches the series color the - live chart paints when that signal is selected. */ -const BUILTIN_SIGNAL_OPTIONS: ReadonlyArray<{ - value: AlertSignalType - label: string - icon: (props: { size?: number; className?: string }) => ReactNode - /** Applied to the chip's container when selected (border + bg). */ - selectedClass: string - /** Applied to the icon at all times so the *unselected* chips still hint - at their identity with a colored glyph (think: a muted version of the - selected state). */ - iconClass: string -}> = [ - { - value: "error_rate", - label: "Error rate", - icon: FireIcon, - selectedClass: "border-chart-error/50 bg-chart-error/10 text-foreground", - iconClass: "text-chart-error", - }, - { - value: "p95_latency", - label: "P95", - icon: ChartLineIcon, - selectedClass: "border-chart-p95/50 bg-chart-p95/10 text-foreground", - iconClass: "text-chart-p95", - }, - { - value: "p99_latency", - label: "P99", - icon: ChartLineIcon, - selectedClass: "border-chart-p99/50 bg-chart-p99/10 text-foreground", - iconClass: "text-chart-p99", - }, - { - value: "apdex", - label: "Apdex", - icon: CirclePercentageIcon, - selectedClass: "border-chart-apdex/50 bg-chart-apdex/10 text-foreground", - iconClass: "text-chart-apdex", - }, - { - value: "throughput", - label: "Throughput", - icon: PulseIcon, - selectedClass: "border-chart-throughput/50 bg-chart-throughput/10 text-foreground", - iconClass: "text-chart-throughput", - }, -] - -const COMPARATOR_OPTIONS: ReadonlyArray<{ value: AlertComparator; label: string }> = ( - Object.keys(comparatorLabels) as AlertComparator[] -).map((value) => ({ value, label: comparatorLabels[value] })) - -/* Severity is rendered with severity colour (warn / error) instead of - the default neutral segmented toggle, because severity is the one field on - the page that should *feel* like its outcome. */ -const SEVERITY_OPTIONS: ReadonlyArray<{ - value: AlertSeverity - label: string - selectedClass: string - dotClass: string -}> = [ - { - value: "warning", - label: "Warning", - selectedClass: - "border-severity-warn/60 bg-severity-warn/10 text-severity-warn hover:bg-severity-warn/15 focus-visible:ring-severity-warn/40", - dotClass: - "bg-severity-warn shadow-[0_0_0_2px_color-mix(in_oklch,var(--color-severity-warn)_25%,transparent)]", - }, - { - value: "critical", - label: "Critical", - selectedClass: - "border-severity-error/60 bg-severity-error/10 text-severity-error hover:bg-severity-error/15 focus-visible:ring-severity-error/40", - dotClass: - "bg-severity-error shadow-[0_0_0_2px_color-mix(in_oklch,var(--color-severity-error)_25%,transparent)]", - }, -] - -export function SignalAndThresholdSection({ - form, - onChange, - autocompleteValues, -}: SignalAndThresholdSectionProps) { - const rangeMode = isRangeComparator(form.comparator) - // error_rate thresholds are entered as a percent (the form↔domain helpers in - // form-utils convert to/from the stored 0–1 ratio). - const isErrorRate = form.signalType === "error_rate" - // Builder-query thresholds compare against the query's raw output — for an - // error_rate aggregation that's the engine's 0–1 ratio, so surface the unit - // to head off "5 means 5%" mistakes (5 would mean a 500% error rate). - const isBuilderErrorRate = - form.signalType === "builder_query" && form.queryBuilderDraft.aggregation === "error_rate" - const [advancedOpen, setAdvancedOpen] = useState(false) - - const kind = signalTypeToKind(form.signalType) - const grouped = ruleFormIsGrouped(form) - - /* Switching tier-1 has to seed a valid signalType for the new kind. - Built-in defaults to error_rate; the other three map 1:1 since - AlertSignalType already has matching string values. */ - function setKind(next: SignalKind) { - if (next === kind) return - onChange((c) => ({ - ...c, - signalType: next === "builtin" ? "error_rate" : next, - })) - } - - return ( - - - -
    - {/* Tier 1: signal kind. Always visible. */} - - options={SIGNAL_KIND_OPTIONS} - value={kind} - onChange={setKind} - aria-label="Signal kind" - size="sm" - className="[&_[data-pressed]_svg]:opacity-100 [&_[data-slot=toggle]]:gap-1.5" - /> - - {/* Tier 2: only for built-ins. Icon chips, single short row. */} - {kind === "builtin" && ( - onChange((c) => ({ ...c, signalType: value }))} - /> - )} - - - - {/* Threshold row — comparator + value(s). Upper threshold stays mounted but - disabled outside range mode so the grid never reflows. The - `min-w-0` on every grid child overrides the Select's baked-in - `min-w-36` so a narrow Condition column doesn't push its - chevron into the next field. */} -
    - - Condition - - - - - {rangeMode ? "Lower" : "Threshold"} - {isErrorRate && (%)} - {isBuilderErrorRate && ( - (0–1 ratio, 0.05 = 5%) - )} - - onChange((c) => ({ ...c, threshold: e.target.value }))} - className={NUMERIC_INPUT_CLASS} - placeholder="0" - /> - - - - Upper - {isErrorRate && (%)} - {isBuilderErrorRate && ( - (0–1 ratio) - )} - - onChange((c) => ({ ...c, thresholdUpper: e.target.value }))} - disabled={!rangeMode} - className={NUMERIC_INPUT_CLASS} - placeholder={rangeMode ? "0" : "—"} - /> - -
    - - {/* Severity inline — branded pills, not neutral toggle. */} -
    - - onChange((c) => ({ ...c, severity: value }))} - /> -
    - - {/* Advanced timing — collapsed by default. Most users never tune these. */} -
    - - {advancedOpen && ( -
    - onChange((c) => ({ ...c, windowMinutes: value }))} - /> - - onChange((c) => ({ - ...c, - consecutiveBreachesRequired: value, - })) - } - /> - - onChange((c) => ({ - ...c, - consecutiveHealthyRequired: value, - })) - } - /> - onChange((c) => ({ ...c, minimumSampleCount: value }))} - /> - - onChange((c) => ({ - ...c, - renotifyIntervalMinutes: value, - })) - } - /> - - onChange((c) => ({ ...c, alertOnNoData: checked })) - } - /> - } - /> -
    - )} -
    -
    -
    - ) -} - -/** - * Tier-2 picker for the five built-in signals. Renders as small icon chips - * inside a subtly tinted rail so the visual relationship to the tier-1 bar - * above reads as "drill-in", not "another peer choice". - */ -function BuiltinSignalChips({ - value, - onChange, -}: { - value: AlertSignalType - onChange: (next: AlertSignalType) => void -}) { - return ( -
    - - {BUILTIN_SIGNAL_OPTIONS.map((opt) => { - const selected = value === opt.value - const Icon = opt.icon - return ( - - - {opt.label} - - ) - })} - -

    - Built-in signals measure entry-point (root) spans only. A service that records failures on - child spans and returns success from its entry point — cron jobs and workers typically do — - stays healthy here at any threshold. Use Raw SQL for those, or rely on error notifications. -

    -
    - ) -} - -/** - * Inline severity picker — two pills side by side that adopt the severity's - * brand color when selected (amber for warning, red for critical). Designed - * to be the most visually deliberate control on the form, since severity is - * the one knob that actually changes who hears about a breach. - */ -function SeverityToggle({ - value, - onChange, -}: { - value: AlertSeverity - onChange: (next: AlertSeverity) => void -}) { - return ( - - {SEVERITY_OPTIONS.map((opt) => { - const selected = value === opt.value - return ( - - - {opt.label} - - ) - })} - - ) -} - -/** One option in a chip radiogroup; the selected chip takes its option's brand colour. */ -function RadioChip({ - value, - selected, - selectedClass, - children, -}: { - value: V - selected: boolean - selectedClass: string - children: ReactNode -}) { - return ( - - {children} - - ) -} - -function NumericField({ - id, - label, - hint, - value, - onChange, -}: { - id: string - label: string - hint?: string - value: string - onChange: (value: string) => void -}) { - return ( - - {label} - onChange(e.target.value)} - className={NUMERIC_INPUT_CLASS} - /> - {hint && {hint}} - - ) -} - -/* -------------------------------------------------------------------------- */ -/* Signal-specific sub-config */ -/* -------------------------------------------------------------------------- */ - -type MetricRow = { - metricName: string - metricType: string - serviceName: string - isMonotonic: boolean -} - -function useAlertMetricSelectionOptions(query: QueryBuilderQueryDraft) { - const [metricSearch, setMetricSearch] = useState("") - const deferredMetricSearch = useDeferredValue(metricSearch) - const metricsResult = useAtomValue( - query.dataSource === "metrics" - ? listMetricsResultAtom({ data: { limit: 100, search: deferredMetricSearch || undefined } }) - : disabledResultAtom(), - ) - - const metricRows = useMemo( - (): MetricRow[] => - Result.builder(metricsResult) - .onSuccess((response) => (response as { data: MetricRow[] }).data) - .orElse(() => []), - [metricsResult], - ) - - const metricSelectionOptions = useMemo(() => { - const seen = new Set() - const options: Array<{ value: string; label: string; isMonotonic: boolean }> = [] - for (const row of metricRows) { - if ( - row.metricType !== "sum" && - row.metricType !== "gauge" && - row.metricType !== "histogram" && - row.metricType !== "exponential_histogram" - ) { - continue - } - const value = `${row.metricName}::${row.metricType}` - if (seen.has(value)) continue - seen.add(value) - options.push({ - value, - label: `${row.metricName} (${row.metricType})`, - isMonotonic: row.isMonotonic, - }) - } - return options - }, [metricRows]) - - return { metricSelectionOptions, setMetricSearch } -} - -function AlertQueryPanel({ form, onChange, autocompleteValues }: SignalAndThresholdSectionProps) { - const query = form.queryBuilderDraft - const { metricSelectionOptions, setMetricSearch } = useAlertMetricSelectionOptions(query) - - const updateQuery = (updater: (query: QueryBuilderQueryDraft) => QueryBuilderQueryDraft) => { - onChange((current) => ({ - ...current, - queryBuilderDraft: updater(current.queryBuilderDraft), - })) - } - - return ( -
    - updateQuery((current) => ({ ...current, aggregation }))} - onMetricSelectionChange={(selection) => - updateQuery((current) => - current.dataSource === "metrics" - ? { - ...current, - metricName: selection.metricName, - metricType: selection.metricType, - isMonotonic: selection.isMonotonic, - aggregation: resetAggregationForMetricType( - current.aggregation, - selection.metricType, - selection.isMonotonic, - ), - } - : current, - ) - } - onClone={() => {}} - onRemove={() => {}} - onDataSourceChange={(dataSource: QueryBuilderDataSource) => - updateQuery((current) => resetQueryForDataSource(current, dataSource)) - } - showHeaderActions={false} - showVisibilityToggle={false} - /> -
    - ) -} - -function SignalSubConfig({ form, onChange, autocompleteValues }: SignalAndThresholdSectionProps) { - switch (form.signalType) { - case "apdex": - return ( -
    - - Apdex target (ms) - onChange((c) => ({ ...c, apdexThresholdMs: e.target.value }))} - className={cn("w-[180px]", NUMERIC_INPUT_CLASS)} - /> - -

    - Requests under this duration count as fully satisfied. -

    -
    - ) - - case "builder_query": - return - - case "raw_query": { - const sampleWarning = rawAlertSampleCountWarning( - form.rawQuerySql, - Number(form.minimumSampleCount) || 0, - ) - return ( -
    - - onChange((current) => ({ ...current, rawQuerySql: draft.sql })) - } - showBucketControl={false} - targetLabel="alert rule" - /> -

    - Return a numeric value column. Optional:{" "} - group for one series per value, and{" "} - samples for the event count behind each row (Min samples sums - it; without it each row counts as 1). A query that returns no rows is a no-data check. -

    - {sampleWarning &&

    {sampleWarning}

    } -
    - - Reduce buckets by - - -
    -
    - ) - } - - default: - return null - } -} diff --git a/apps/web/src/components/chat/alert-context.ts b/apps/web/src/components/chat/alert-context.ts index 91f5ff3459..fa23cb8f54 100644 --- a/apps/web/src/components/chat/alert-context.ts +++ b/apps/web/src/components/chat/alert-context.ts @@ -1,4 +1,3 @@ -import type { AiTriageResult, AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" import { Option, Schema } from "effect" import { fromBase64Url } from "@/lib/base64url" @@ -24,32 +23,6 @@ export type AlertContext = typeof AlertContextSchema.Type const decodeAlertContext = Schema.decodeUnknownOption(AlertContextSchema) -/** - * Build the chat `AlertContext` from a rule + the incident under investigation, - * optionally folding in a prior triage result so the chat opens already aware of - * the AI's findings. - */ -export const toAlertContext = ( - rule: AlertRuleDocument, - incident: AlertIncidentDocument, - result?: AiTriageResult | null, -): AlertContext => ({ - ruleId: rule.id, - ruleName: rule.name, - incidentId: incident.id, - eventType: incident.status === "open" ? "trigger" : "resolve", - signalType: rule.signalType, - severity: incident.severity, - comparator: rule.comparator, - threshold: incident.threshold, - value: incident.lastObservedValue, - windowMinutes: rule.windowMinutes, - groupKey: incident.groupKey, - sampleCount: incident.lastSampleCount, - ...(result?.summary ? { aiSummary: result.summary } : undefined), - ...(result?.suspectedCause ? { aiSuspectedCause: result.suspectedCause } : undefined), -}) - export const decodeAlertContextFromSearchParam = (raw: string): AlertContext | undefined => { try { return Option.getOrUndefined(decodeAlertContext(JSON.parse(fromBase64Url(raw)))) diff --git a/apps/web/src/components/settings/escalation-policy-section.tsx b/apps/web/src/components/settings/escalation-policy-section.tsx index 3e8daae460..4b70e2977c 100644 --- a/apps/web/src/components/settings/escalation-policy-section.tsx +++ b/apps/web/src/components/settings/escalation-policy-section.tsx @@ -152,7 +152,7 @@ export function EscalationPolicySection({ isAdmin }: { isAdmin: boolean }) { diff --git a/apps/web/src/lab/alerts-fixture.ts b/apps/web/src/lab/alerts-fixture.ts new file mode 100644 index 0000000000..9fcec0d4d7 --- /dev/null +++ b/apps/web/src/lab/alerts-fixture.ts @@ -0,0 +1,657 @@ +/** + * Fixture documents for `/lab/alerts`, a pure function of `nowMs`. Every + * document is decoded through its real domain schema, so the fixture cannot + * drift from the wire format, and the overview runs through `deriveOverview`. + */ +import { Schema } from "effect" + +import { + AlertDeliveryEventDocument, + AlertDestinationDocument, + AlertIncidentDocument, + AlertRuleDocument, + AlertRulePreviewResponse, + type AlertDestinationType, + type AlertSeverity, + type AlertSignalType, +} from "@maple/domain/http" +import { V2Investigation } from "@maple/domain/http/v2" + +import type { AlertRuleStateRow } from "@/lib/collections/alerts" +import { type AlertsOverviewReady, deriveOverview } from "@/lib/models/alerts-overview-model" +import { VERDICT_LAB_CASES } from "@/lab/verdict-fixture" + +const decodeRule = Schema.decodeUnknownSync(AlertRuleDocument) +const decodeIncident = Schema.decodeUnknownSync(AlertIncidentDocument) +const decodeDestination = Schema.decodeUnknownSync(AlertDestinationDocument) +const decodeDelivery = Schema.decodeUnknownSync(AlertDeliveryEventDocument) +const decodePreview = Schema.decodeUnknownSync(AlertRulePreviewResponse) +const decodeInvestigation = Schema.decodeUnknownSync(Schema.toType(V2Investigation)) + +const MIN = 60_000 +const HOUR = 60 * MIN +const DAY = 24 * HOUR +const ORG = "org_lab_alerts" + +const uuid = (n: number): string => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}` + +/* Destinations ---------------------------------------------------------------------------- */ + +const DEST = { + discord: uuid(101), + pagerduty: uuid(102), + email: uuid(103), + bridge: uuid(104), + legacy: uuid(105), +} as const + +const destinationsAt = (nowMs: number): AlertDestinationDocument[] => { + const iso = (ms: number) => new Date(ms).toISOString() + const base = { + enabled: true, + channelLabel: null, + memberUserIds: null, + lastTestedAt: iso(nowMs - 9 * DAY), + lastTestError: null, + consecutiveFailures: 0, + lastFailureAt: null, + disabledAt: null, + disabledReason: null, + createdAt: iso(nowMs - 120 * DAY), + updatedAt: iso(nowMs - 9 * DAY), + } + return [ + decodeDestination({ + ...base, + id: DEST.discord, + name: "#alerts", + type: "discord", + summary: "Discord webhook · maple-eng/#alerts", + channelLabel: "#alerts", + }), + decodeDestination({ + ...base, + id: DEST.pagerduty, + name: "PagerDuty Platform on-call", + type: "pagerduty", + summary: "Routing key ••••7f3a · Platform service", + }), + decodeDestination({ + ...base, + id: DEST.email, + name: "data-oncall", + type: "email", + summary: "data-oncall@maple.dev", + }), + decodeDestination({ + ...base, + id: DEST.bridge, + name: "status-bridge", + type: "webhook", + summary: "POST https://status.maple.dev/hooks/alerts", + lastFailureAt: iso(nowMs - 22 * MIN), + }), + decodeDestination({ + ...base, + id: DEST.legacy, + name: "Legacy ops webhook", + type: "webhook", + summary: "POST https://ops-legacy.internal/alert", + enabled: false, + consecutiveFailures: 12, + lastFailureAt: iso(nowMs - 3 * DAY), + disabledAt: iso(nowMs - 3 * DAY), + disabledReason: "HTTP 410 Gone on 12 consecutive attempts", + lastTestError: "HTTP 410 Gone", + }), + ] +} + +/* Rules ----------------------------------------------------------------------------------- */ + +const RULE = { + errorRate: uuid(201), + slowApi: uuid(202), + ingestSilent: uuid(203), + scraper: uuid(204), + errorTick: uuid(205), + checkoutDrop: uuid(206), + pgPermission: uuid(207), + alertingP99: uuid(208), + walBacklog: uuid(209), + tinybirdMirror: uuid(210), + apdex: uuid(211), +} as const + +interface RuleSpec { + readonly id: string + readonly name: string + readonly signalType: AlertSignalType + readonly threshold: number + readonly comparator?: "gt" | "lt" + readonly severity: AlertSeverity + readonly tags: ReadonlyArray + readonly services: ReadonlyArray + readonly destinations: ReadonlyArray + readonly grouped?: boolean + readonly enabled?: boolean + readonly minSamples?: number + readonly error?: string + readonly lastEvaluatedAgoMs?: number + readonly updatedAgoDays: number +} + +const RULE_SPECS: ReadonlyArray = [ + { + id: RULE.errorRate, + name: "High error rate", + signalType: "error_rate", + threshold: 0.05, + severity: "critical", + tags: ["platform"], + services: [], + destinations: [DEST.discord, DEST.pagerduty, DEST.bridge], + grouped: true, + updatedAgoDays: 4, + }, + { + id: RULE.slowApi, + name: "Slow API", + signalType: "p95_latency", + threshold: 1500, + severity: "warning", + tags: ["api"], + services: ["maple-api"], + destinations: [DEST.discord], + grouped: true, + updatedAgoDays: 12, + }, + { + id: RULE.ingestSilent, + name: "Ingest gateway silent", + signalType: "throughput", + threshold: 50, + comparator: "lt", + severity: "critical", + tags: ["ingest"], + services: ["maple-ingest"], + destinations: [DEST.pagerduty], + updatedAgoDays: 20, + }, + { + id: RULE.scraper, + name: "Scraper PlanetScale timeout", + signalType: "error_rate", + threshold: 0.02, + severity: "warning", + tags: ["data"], + services: ["maple-scraper"], + destinations: [DEST.email], + error: "unknown column ScrapeTarget", + updatedAgoDays: 2, + }, + { + id: RULE.errorTick, + name: "Error tick cursor lagging", + signalType: "p99_latency", + threshold: 60_000, + severity: "warning", + tags: ["platform"], + services: ["maple-alerting"], + destinations: [DEST.discord], + lastEvaluatedAgoMs: 2 * DAY, + updatedAgoDays: 30, + }, + { + id: RULE.checkoutDrop, + name: "Checkout throughput drop", + signalType: "throughput", + threshold: 20, + comparator: "lt", + severity: "warning", + tags: ["api"], + services: ["maple-api"], + destinations: [DEST.discord], + minSamples: 200, + updatedAgoDays: 6, + }, + { + id: RULE.pgPermission, + name: "Postgres permission denied", + signalType: "error_rate", + threshold: 0.001, + severity: "critical", + tags: ["data"], + services: ["maple-api"], + destinations: [], + updatedAgoDays: 1, + }, + { + id: RULE.alertingP99, + name: "Alerting worker p99", + signalType: "p99_latency", + threshold: 4000, + severity: "warning", + tags: ["platform"], + services: ["maple-alerting"], + destinations: [DEST.discord], + updatedAgoDays: 15, + }, + { + id: RULE.walBacklog, + name: "Ingest WAL backlog", + signalType: "p95_latency", + threshold: 2500, + severity: "critical", + tags: ["ingest"], + services: ["maple-ingest"], + destinations: [DEST.pagerduty, DEST.email], + updatedAgoDays: 8, + }, + { + id: RULE.tinybirdMirror, + name: "Warehouse mirror lag", + signalType: "p95_latency", + threshold: 30_000, + severity: "warning", + tags: [], + services: ["maple-ingest"], + destinations: [DEST.legacy], + enabled: false, + updatedAgoDays: 3, + }, + { + id: RULE.apdex, + name: "Web apdex", + signalType: "apdex", + threshold: 0.85, + comparator: "lt", + severity: "warning", + tags: [], + services: ["maple-web"], + destinations: [DEST.discord], + updatedAgoDays: 40, + }, +] + +const ruleFrom = (spec: RuleSpec, nowMs: number): AlertRuleDocument => { + const iso = (ms: number) => new Date(ms).toISOString() + return decodeRule({ + id: spec.id, + name: spec.name, + notes: null, + notificationTemplate: null, + enabled: spec.enabled ?? true, + severity: spec.severity, + serviceNames: spec.services, + excludeServiceNames: [], + environments: ["production"], + tags: spec.tags, + groupBy: spec.grouped ? ["service.name"] : null, + signalType: spec.signalType, + comparator: spec.comparator ?? "gt", + threshold: spec.threshold, + thresholdUpper: null, + windowMinutes: 5, + minimumSampleCount: spec.minSamples ?? 0, + consecutiveBreachesRequired: 2, + consecutiveHealthyRequired: 2, + renotifyIntervalMinutes: 15, + apdexThresholdMs: spec.signalType === "apdex" ? 500 : null, + queryBuilderDraft: null, + rawQuerySql: null, + rawQueryReducer: null, + destinationIds: spec.destinations, + noDataBehavior: "skip", + lastEvaluationError: spec.error ?? null, + lastEvaluatedAt: iso(nowMs - (spec.lastEvaluatedAgoMs ?? MIN)), + lastScheduledAt: iso(nowMs - (spec.lastEvaluatedAgoMs ?? MIN)), + createdAt: iso(nowMs - 90 * DAY), + updatedAt: iso(nowMs - spec.updatedAgoDays * DAY), + createdBy: "user_lab", + updatedBy: "user_lab", + }) +} + +/* States ---------------------------------------------------------------------------------- */ + +const state = ( + nowMs: number, + ruleId: string, + groupKey: string, + status: "breached" | "healthy" | "skipped", + value: number | null, + opts: { agoMs?: number; error?: string; samples?: number } = {}, +): AlertRuleStateRow => { + const at = new Date(nowMs - (opts.agoMs ?? MIN)).toISOString() + return { + org_id: ORG, + rule_id: ruleId, + group_key: groupKey, + consecutive_breaches: status === "breached" ? 6 : 0, + consecutive_healthy: status === "healthy" ? 30 : 0, + last_status: status, + last_value: value, + last_sample_count: opts.samples ?? 4_200, + last_evaluated_at: at, + last_error: opts.error ?? null, + updated_at: at, + } +} + +const statesAt = (nowMs: number): AlertRuleStateRow[] => [ + state(nowMs, RULE.errorRate, "maple-chat", "breached", 0.084, { samples: 6_750 }), + state(nowMs, RULE.errorRate, "maple-api", "healthy", 0.012), + state(nowMs, RULE.errorRate, "maple-ingest", "healthy", 0.004), + state(nowMs, RULE.errorRate, "maple-alerting", "healthy", 0.001), + state(nowMs, RULE.errorRate, "maple-web", "healthy", 0), + state(nowMs, RULE.slowApi, "maple-api", "breached", 1920), + state(nowMs, RULE.ingestSilent, "__total__", "skipped", null, { samples: 0 }), + state(nowMs, RULE.scraper, "__total__", "healthy", null, { error: "unknown column ScrapeTarget" }), + state(nowMs, RULE.errorTick, "__total__", "healthy", 8_400, { agoMs: 2 * DAY }), + state(nowMs, RULE.checkoutDrop, "__total__", "skipped", null, { samples: 37 }), + state(nowMs, RULE.pgPermission, "__total__", "healthy", 0), + state(nowMs, RULE.alertingP99, "__total__", "healthy", 1_840), + state(nowMs, RULE.walBacklog, "__total__", "healthy", 640), + state(nowMs, RULE.apdex, "__total__", "healthy", 0.94), +] + +/* Incidents ------------------------------------------------------------------------------- */ + +const INCIDENT = { + chat: uuid(301), + slowApi: uuid(302), + ingest: uuid(303), +} as const + +interface IncidentSpec { + readonly id: string + readonly ruleId: string + readonly groupKey: string | null + /** Start and end as offsets back from now; a null end leaves the incident open. */ + readonly startAgo: number + readonly endAgo: number | null + readonly value: number + readonly held?: boolean +} + +const OPEN_INCIDENTS: ReadonlyArray = [ + { id: INCIDENT.chat, ruleId: RULE.errorRate, groupKey: "maple-chat", startAgo: 23 * MIN, endAgo: null, value: 0.084 }, + { id: INCIDENT.slowApi, ruleId: RULE.slowApi, groupKey: "maple-api", startAgo: 41 * MIN, endAgo: null, value: 1920 }, + { id: INCIDENT.ingest, ruleId: RULE.ingestSilent, groupKey: null, startAgo: 72 * MIN, endAgo: null, value: 3, held: true }, +] + +/** Resolved history: four in the last day (seven with the open ones), the rest across the week. */ +const RESOLVED_INCIDENTS: ReadonlyArray = [ + { id: uuid(311), ruleId: RULE.errorRate, groupKey: "maple-api", startAgo: 19 * HOUR, endAgo: 18.4 * HOUR, value: 0.061 }, + { id: uuid(312), ruleId: RULE.errorRate, groupKey: "maple-chat", startAgo: 7 * HOUR, endAgo: 6.2 * HOUR, value: 0.072 }, + { id: uuid(313), ruleId: RULE.slowApi, groupKey: "maple-api", startAgo: 13 * HOUR, endAgo: 12.1 * HOUR, value: 1710 }, + { id: uuid(314), ruleId: RULE.walBacklog, groupKey: null, startAgo: 10 * HOUR, endAgo: 9.7 * HOUR, value: 3100 }, + { id: uuid(321), ruleId: RULE.errorRate, groupKey: "maple-ingest", startAgo: 2.3 * DAY, endAgo: 2.25 * DAY, value: 0.058 }, + { id: uuid(322), ruleId: RULE.slowApi, groupKey: "maple-api", startAgo: 3.1 * DAY, endAgo: 3 * DAY, value: 1640 }, + { id: uuid(323), ruleId: RULE.slowApi, groupKey: "maple-api", startAgo: 5.4 * DAY, endAgo: 5.3 * DAY, value: 2050 }, + { id: uuid(324), ruleId: RULE.walBacklog, groupKey: null, startAgo: 4.2 * DAY, endAgo: 4.1 * DAY, value: 2900 }, + { id: uuid(325), ruleId: RULE.alertingP99, groupKey: null, startAgo: 6.2 * DAY, endAgo: 6.1 * DAY, value: 4800 }, + { id: uuid(326), ruleId: RULE.ingestSilent, groupKey: null, startAgo: 1.8 * DAY, endAgo: 1.75 * DAY, value: 0 }, + { id: uuid(327), ruleId: RULE.tinybirdMirror, groupKey: null, startAgo: 4.6 * DAY, endAgo: 3.9 * DAY, value: 48_000 }, + { id: uuid(328), ruleId: RULE.apdex, groupKey: null, startAgo: 6.6 * DAY, endAgo: 6.55 * DAY, value: 0.79 }, +] + +const incidentFrom = (spec: IncidentSpec, rule: AlertRuleDocument, nowMs: number): AlertIncidentDocument => { + const iso = (ms: number) => new Date(ms).toISOString() + const open = spec.endAgo === null + return decodeIncident({ + id: spec.id, + ruleId: rule.id, + ruleName: rule.name, + groupKey: spec.groupKey, + signalType: rule.signalType, + severity: rule.severity, + status: open ? "open" : "resolved", + comparator: rule.comparator, + threshold: rule.threshold, + thresholdUpper: rule.thresholdUpper, + firstTriggeredAt: iso(nowMs - spec.startAgo), + lastTriggeredAt: iso(nowMs - (spec.held ? 58 * MIN : (spec.endAgo ?? 0) + MIN)), + resolvedAt: open ? null : iso(nowMs - (spec.endAgo ?? 0)), + lastObservedValue: spec.value, + lastSampleCount: spec.held ? 12 : 6_750, + dedupeKey: `${rule.id}:${spec.groupKey ?? "__total__"}`, + lastDeliveredEventType: open ? "renotify" : "resolve", + lastNotifiedAt: iso(nowMs - (open ? 8 * MIN : (spec.endAgo ?? 0))), + holdReason: spec.held ? "volume_collapsed" : null, + heldSince: spec.held ? iso(nowMs - 58 * MIN) : null, + errorIssueId: null, + }) +} + +/* Deliveries ------------------------------------------------------------------------------ */ + +type DestKey = keyof typeof DEST + +const DESTINATION_META = { + discord: { name: "#alerts", type: "discord" }, + pagerduty: { name: "PagerDuty Platform on-call", type: "pagerduty" }, + email: { name: "data-oncall", type: "email" }, + bridge: { name: "status-bridge", type: "webhook" }, + legacy: { name: "Legacy ops webhook", type: "webhook" }, +} as const satisfies Record + +interface DeliverySpec { + readonly destination: DestKey + readonly ruleId: string + readonly incidentId: string | null + readonly eventType: "trigger" | "resolve" | "renotify" + readonly atMs: number + readonly failed?: { code: number; message: string } + readonly attempt?: number +} + +const deliveryFrom = (spec: DeliverySpec, n: number): AlertDeliveryEventDocument => { + const meta = DESTINATION_META[spec.destination] + const destinationId = DEST[spec.destination] + const at = new Date(spec.atMs).toISOString() + return decodeDelivery({ + id: uuid(5000 + n), + incidentId: spec.incidentId, + ruleId: spec.ruleId, + destinationId, + destinationName: meta.name, + destinationType: meta.type, + deliveryKey: `${spec.incidentId ?? spec.ruleId}:${spec.eventType}:${destinationId}`, + eventType: spec.eventType, + attemptNumber: spec.attempt ?? 1, + status: spec.failed ? "failed" : "success", + scheduledAt: at, + attemptedAt: at, + providerMessage: spec.failed ? null : "Delivered", + providerReference: null, + responseCode: spec.failed ? spec.failed.code : 200, + errorMessage: spec.failed ? spec.failed.message : null, + }) +} + +/** The critical maple-chat incident's own delivery story. */ +const chatIncidentDeliveries = (nowMs: number): DeliverySpec[] => { + const opened = nowMs - 23 * MIN + const base = { ruleId: RULE.errorRate, incidentId: INCIDENT.chat } + return [ + { ...base, destination: "discord", eventType: "trigger", atMs: opened + 20_000 }, + { ...base, destination: "pagerduty", eventType: "trigger", atMs: opened + 22_000 }, + { + ...base, + destination: "bridge", + eventType: "trigger", + atMs: opened + 24_000, + failed: { code: 502, message: "HTTP 502 Bad Gateway" }, + }, + { ...base, destination: "bridge", eventType: "trigger", atMs: opened + 84_000, attempt: 2 }, + { ...base, destination: "discord", eventType: "renotify", atMs: opened + 15 * MIN + 20_000 }, + { ...base, destination: "pagerduty", eventType: "renotify", atMs: opened + 15 * MIN + 22_000 }, + ] +} + +const allDeliveries = (nowMs: number): AlertDeliveryEventDocument[] => { + const specs = chatIncidentDeliveries(nowMs) + const history: ReadonlyArray<[DestKey, string, number]> = [ + ["discord", RULE.slowApi, 41 * MIN], + ["pagerduty", RULE.ingestSilent, 72 * MIN], + ["discord", RULE.errorRate, 7 * HOUR], + ["pagerduty", RULE.errorRate, 7 * HOUR], + ["bridge", RULE.errorRate, 7 * HOUR], + ["pagerduty", RULE.walBacklog, 10 * HOUR], + ["email", RULE.walBacklog, 10 * HOUR], + ["discord", RULE.slowApi, 13 * HOUR], + ["discord", RULE.errorRate, 19 * HOUR], + ["pagerduty", RULE.errorRate, 19 * HOUR], + ["bridge", RULE.errorRate, 19 * HOUR], + ["email", RULE.walBacklog, 4.2 * DAY], + ["email", RULE.scraper, 5 * DAY], + ["discord", RULE.alertingP99, 6.2 * DAY], + ] + const routine = history.map( + ([destination, ruleId, ago]): DeliverySpec => ({ + destination, + ruleId, + incidentId: null, + eventType: "trigger", + atMs: nowMs - ago, + }), + ) + // The legacy webhook failed until Maple auto-disabled it. + const legacy = Array.from( + { length: 8 }, + (_, i): DeliverySpec => ({ + destination: "legacy", + ruleId: RULE.tinybirdMirror, + incidentId: null, + eventType: i === 0 ? "trigger" : "renotify", + atMs: nowMs - 4.6 * DAY + i * 2 * HOUR, + failed: { code: 410, message: "HTTP 410 Gone" }, + }), + ) + return [...specs, ...routine, ...legacy] + .toSorted((a, b) => b.atMs - a.atMs) + .map((spec, i) => deliveryFrom(spec, i)) +} + +/* Incident page --------------------------------------------------------------------------- */ + +const chatPreview = (nowMs: number, range: { start: number; end: number }): AlertRulePreviewResponse => { + const opened = nowMs - 23 * MIN + const buckets = Math.floor((range.end - range.start) / MIN) + const points = Array.from({ length: buckets }, (_, i) => { + const t = range.start + i * MIN + const wobble = Math.sin(i * 1.7) * 0.003 + Math.cos(i * 0.6) * 0.002 + const value = + t < opened - 4 * MIN + ? 0.009 + wobble + : t < opened + ? 0.03 + (i % 3) * 0.008 + : 0.074 + wobble * 3 + (t > opened + 15 * MIN ? 0.006 : 0) + return { + bucket: new Date(t).toISOString(), + value, + sampleCount: 260 + (i % 7) * 12, + status: value > 0.05 ? "breached" : "healthy", + provisional: i === buckets - 1, + } + }) + return decodePreview({ + bucketSeconds: 60, + windowMinutes: 5, + threshold: 0.05, + thresholdUpper: null, + comparator: "gt", + truncatedToStart: null, + series: [{ groupKey: "maple-chat", points }], + wouldFire: [], + }) +} + +const chatInvestigation = (nowMs: number): V2Investigation => { + const base = VERDICT_LAB_CASES[0]!.investigation + const opened = nowMs - 23 * MIN + const iso = (ms: number) => new Date(ms).toISOString() + return decodeInvestigation({ + ...base, + subject: { type: "incident", incident_kind: "alert", incident_id: INCIDENT.chat, issue_id: null }, + created_at: iso(opened + MIN), + started_at: iso(opened + MIN), + diagnosed_at: iso(opened + 5 * MIN), + updated_at: iso(opened + 5 * MIN), + report: { + ...base.report, + headline: "412 of 6,750 /chat/stream requests fail with TimeoutError from maple-ai after deploy 4f2c1a", + }, + }) +} + +/* Assembly -------------------------------------------------------------------------------- */ + +export interface AlertsLabFixture { + readonly overview: AlertsOverviewReady + readonly quiet: AlertsOverviewReady + readonly destinations: ReadonlyArray + readonly deliveries: ReadonlyArray + readonly incident: { + readonly incident: AlertIncidentDocument + readonly rule: AlertRuleDocument + readonly ruleIncidents: ReadonlyArray + readonly states: ReadonlyArray + readonly preview: AlertRulePreviewResponse + readonly range: { start: number; end: number } + readonly deliveries: ReadonlyArray + readonly investigation: V2Investigation + } +} + +export function buildAlertsLabFixture(nowMs: number): AlertsLabFixture { + const rules = RULE_SPECS.map((spec) => ruleFrom(spec, nowMs)) + const ruleById = new Map(rules.map((rule) => [rule.id, rule])) + const incidentsOf = (specs: ReadonlyArray) => + specs.flatMap((spec) => { + const rule = ruleById.get(spec.ruleId) + return rule === undefined ? [] : [incidentFrom(spec, rule, nowMs)] + }) + const incidents = incidentsOf([...OPEN_INCIDENTS, ...RESOLVED_INCIDENTS]) + const states = statesAt(nowMs) + const deliveries = allDeliveries(nowMs) + const overview = deriveOverview({ rules, incidents, states, deliveryEvents: deliveries, now: nowMs }) + + // Quiet inbox: the open incidents resolved and two blind spots fixed, leaving two. + const fixed = new Set([RULE.errorTick, RULE.pgPermission, RULE.ingestSilent]) + const quietIncidents = incidentsOf([ + ...OPEN_INCIDENTS.map((spec) => ({ ...spec, startAgo: spec.startAgo + 3 * HOUR, endAgo: 2 * HOUR, held: false })), + ...RESOLVED_INCIDENTS, + ]) + const quiet = deriveOverview({ + rules: rules.filter((rule) => rule.id !== RULE.errorTick && rule.id !== RULE.pgPermission), + incidents: quietIncidents, + states: states.filter((row) => row.last_status !== "breached" && !fixed.has(row.rule_id)), + deliveryEvents: deliveries, + now: nowMs, + }) + + const chatRule = ruleById.get(RULE.errorRate) ?? rules[0]! + const chat = incidents.find((incident) => incident.id === INCIDENT.chat) ?? incidents[0]! + const opened = Date.parse(chat.firstTriggeredAt) + const range = { start: opened - 30 * MIN, end: nowMs } + + return { + overview, + quiet, + destinations: destinationsAt(nowMs), + deliveries, + incident: { + incident: chat, + rule: chatRule, + ruleIncidents: incidents.filter((incident) => incident.ruleId === chatRule.id), + states: states.filter((row) => row.rule_id === chatRule.id), + preview: chatPreview(nowMs, range), + range, + deliveries: deliveries.filter((event) => event.incidentId === INCIDENT.chat), + investigation: chatInvestigation(nowMs), + }, + } +} diff --git a/apps/web/src/lab/alerts-lab.tsx b/apps/web/src/lab/alerts-lab.tsx new file mode 100644 index 0000000000..2f67c24648 --- /dev/null +++ b/apps/web/src/lab/alerts-lab.tsx @@ -0,0 +1,248 @@ +import { useMemo, useState, type ReactNode } from "react" + +import type { AlertDestinationType } from "@maple/domain/http" +import { Button } from "@maple/ui/components/ui/button" +import { cn } from "@maple/ui/lib/utils" + +import { PROVIDERS } from "@/components/alerts/destination-provider" +import { IncidentBody, IncidentHeader } from "@/components/alerts/incident-detail/incident-detail-view" +import { DestinationRow, DESTINATION_ROW_GRID, HEALTH_WINDOW } from "@/components/alerts/overview/destination-row" +import { InboxTab } from "@/components/alerts/overview/inbox-tab" +import { RulesTab } from "@/components/alerts/overview/rules-tab" +import { UnderlineTabStrip, underlineTabClass } from "@/components/common/underline-link-tabs" +import { PlusIcon } from "@/components/icons" +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { buildIncidentTimeline } from "@/lib/alerts/incident-timeline" +import type { RuleBucket } from "@/lib/alerts/overview-derive" +import type { AlertsOverviewReady } from "@/lib/models/alerts-overview-model" +import { type AlertsLabFixture, buildAlertsLabFixture } from "@/lab/alerts-fixture" + +/** + * The `/alerts` tabs and the incident page over one fixture org, with no API + * and no session. Each section renders at a desktop content width inside the + * page's own tab chrome, so it screenshots like the product. + */ + +const FRAME_WIDTH = 1200 +const destinationTypeLabel = (type: AlertDestinationType) => PROVIDERS[type].label +const noop = () => undefined + +type AlertsTab = "inbox" | "rules" | "routing" + +export function AlertsLab() { + const [nowMs] = useState(() => Date.now()) + const fixture = useMemo(() => buildAlertsLabFixture(nowMs), [nowMs]) + const [status, setStatus] = useState(undefined) + + return ( + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    +
    + ) +} + +function LabSection({ id, title, children }: { id: string; title: string; children: ReactNode }) { + return ( +
    +

    + #{id} {title} +

    + {children} +
    + ) +} + +const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [ + { value: "inbox", label: "Inbox" }, + { value: "rules", label: "Rules" }, + { value: "routing", label: "Routing" }, +] + +/** The `/alerts` header and tab strip around one tab's body, at a fixed desktop width. */ +function AlertsFrame({ + tab, + data, + children, +}: { + tab: AlertsTab + data: AlertsOverviewReady + children: ReactNode +}) { + const counts = { inbox: data.openIncidents.length, rules: data.rules.length, routing: 0 } + return ( +
    +
    +
    +

    Alerts

    + +
    + + {ALERTS_TABS.map((entry) => ( + + {entry.label} + {counts[entry.value] > 0 && ( + + {counts[entry.value]} + + )} + + ))} + +
    +
    {children}
    +
    + ) +} + +/** The routing tab's destinations table; the live tab reads its rows from hooks. */ +function RoutingDestinations({ fixture }: { fixture: AlertsLabFixture }) { + return ( +
    +
    +
    + Destination + Type + Last {HEALTH_WINDOW} attempts + Last delivered + +
    +
      + {fixture.destinations.map((destination) => ( + event.destinationId === destination.id) + .slice(0, HEALTH_WINDOW)} + isAdmin + isTesting={false} + isDeleting={false} + onToggle={noop} + onTest={noop} + onEdit={noop} + onDelete={() => Promise.resolve(false)} + /> + ))} +
    +
    +
    + ) +} + +function IncidentFrame({ + fixture, + nowMs, +}: { + fixture: AlertsLabFixture + nowMs: number +}) { + const { incident, rule, ruleIncidents, states, preview, range, deliveries, investigation } = + fixture.incident + const timeZone = useMemo(() => Intl.DateTimeFormat().resolvedOptions().timeZone, []) + const entries = useMemo( + () => + buildIncidentTimeline({ + incident, + deliveries, + investigation, + renotifyIntervalMinutes: rule.renotifyIntervalMinutes, + destinationTypeLabel, + }), + [incident, deliveries, investigation, rule], + ) + return ( +
    +
    + +
    +
    + +
    +
    + ) +} diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 16b7be250b..25032f298a 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -178,6 +178,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/alerts", + title: "Alerts", + description: + "The `/alerts` Inbox, Rules and Routing tabs and the incident page over one fixture org: two firing, one held, four blind spots, a delivery retry and a diagnosed investigation.", + kind: "lab", + session: "none", + }, { path: "/lab/worst-case", title: "Worst case", diff --git a/apps/web/src/lab/worst-case-fixture.ts b/apps/web/src/lab/worst-case-fixture.ts index 53fc552040..6047b5ac75 100644 --- a/apps/web/src/lab/worst-case-fixture.ts +++ b/apps/web/src/lab/worst-case-fixture.ts @@ -57,7 +57,11 @@ export function formatterCases(mode: WorstCaseMode): ReadonlyArray Date.now()) return ( -
    - undefined} - /> -
    + undefined} + isToggling={false} + toggleFailed={false} + onToggleRule={() => undefined} + /> diff --git a/apps/web/src/lib/alerts/form-utils.test.ts b/apps/web/src/lib/alerts/form-utils.test.ts index cf980f402e..42d0a18044 100644 --- a/apps/web/src/lib/alerts/form-utils.test.ts +++ b/apps/web/src/lib/alerts/form-utils.test.ts @@ -51,15 +51,15 @@ describe("threshold unit conversion", () => { it("converts error_rate thresholds between percent (form) and ratio (domain)", () => { // User enters 5 (%), domain stores 0.05 (ratio) — the unit the query // engine emits and AlertsService compares against. - expect(formThresholdToDomain("error_rate", "5")).toBe(0.05) - expect(formThresholdToDomain("error_rate", "50")).toBe(0.5) - expect(domainThresholdToForm("error_rate", 0.05)).toBe("5") - expect(domainThresholdToForm("error_rate", 0.5)).toBe("50") + expect(formThresholdToDomain({ signalType: "error_rate" }, "5")).toBe(0.05) + expect(formThresholdToDomain({ signalType: "error_rate" }, "50")).toBe(0.5) + expect(domainThresholdToForm({ signalType: "error_rate" }, 0.05)).toBe("5") + expect(domainThresholdToForm({ signalType: "error_rate" }, 0.5)).toBe("50") }) it("drops float noise when converting error_rate ratios to percent", () => { - expect(domainThresholdToForm("error_rate", 0.07)).toBe("7") - expect(domainThresholdToForm("error_rate", 0.29)).toBe("29") + expect(domainThresholdToForm({ signalType: "error_rate" }, 0.07)).toBe("7") + expect(domainThresholdToForm({ signalType: "error_rate" }, 0.29)).toBe("29") }) it("keeps precision for error rates below 0.01%", () => { @@ -69,9 +69,9 @@ describe("threshold unit conversion", () => { }) it("passes non-error_rate thresholds through unchanged", () => { - expect(formThresholdToDomain("p95_latency", "500")).toBe(500) - expect(domainThresholdToForm("p95_latency", 500)).toBe("500") - expect(formThresholdToDomain("throughput", "1000")).toBe(1000) + expect(formThresholdToDomain({ signalType: "p95_latency" }, "500")).toBe(500) + expect(domainThresholdToForm({ signalType: "p95_latency" }, 500)).toBe("500") + expect(formThresholdToDomain({ signalType: "throughput" }, "1000")).toBe(1000) }) it("stores error_rate threshold as a ratio on the create params", () => { diff --git a/apps/web/src/lib/alerts/form-utils.ts b/apps/web/src/lib/alerts/form-utils.ts index 95f42d7ffc..87974d5394 100644 --- a/apps/web/src/lib/alerts/form-utils.ts +++ b/apps/web/src/lib/alerts/form-utils.ts @@ -168,22 +168,55 @@ export function formatSignalValue(signalType: AlertSignalType, value: number | n } } +/** What decides a threshold's unit: the signal, and for builder rules the aggregation. */ +export type ThresholdSubject = { + readonly signalType: AlertSignalType + readonly queryBuilderDraft?: { readonly dataSource: string; readonly aggregation: string } +} + +const isPercentSignal = (subject: ThresholdSubject): boolean => + subject.signalType === "error_rate" || + (subject.signalType === "builder_query" && + subject.queryBuilderDraft?.dataSource === "traces" && + subject.queryBuilderDraft.aggregation === "error_rate") + +const BUILDER_LATENCY_AGGREGATIONS = new Set(["avg_duration", "p50_duration", "p95_duration", "p99_duration"]) + +/** + * The signal a rule's values read as, for formatting. A builder query over + * `error_rate` or a duration percentile reads like the matching built-in, so + * the chart, the threshold and the input all speak one unit. + */ +export function displaySignalType(subject: ThresholdSubject): AlertSignalType { + if (subject.signalType !== "builder_query" || subject.queryBuilderDraft?.dataSource !== "traces") { + return subject.signalType + } + if (isPercentSignal(subject)) return "error_rate" + if (BUILDER_LATENCY_AGGREGATIONS.has(subject.queryBuilderDraft.aggregation)) return "p95_latency" + return "builder_query" +} + +/** The suffix the threshold input shows: `%` for error rates, `ms` for latencies. */ +export function thresholdUnitLabel(subject: ThresholdSubject): string | null { + const display = displaySignalType(subject) + if (display === "error_rate") return "%" + if (display === "p95_latency" || display === "p99_latency") return "ms" + return null +} + /** - * Threshold unit conversion at the form↔domain boundary. - * - * The domain stores `error_rate` thresholds as a 0–1 ratio (matching the query - * engine's `countIf(Error)/count()` and the evaluation comparison). The form - * lets users enter a percent (e.g. `5` = 5%), so we divide by 100 on submit and - * multiply by 100 on load. All other signals pass through unchanged. + * Threshold unit conversion at the form↔domain boundary, the one place it + * happens. The domain stores error rates as a 0–1 ratio (built-in and builder + * `error_rate` alike); the form takes a percent, so `5` means 5% in every mode. */ -export function formThresholdToDomain(signalType: AlertSignalType, value: string): number { +export function formThresholdToDomain(subject: ThresholdSubject, value: string): number { const n = Number(value) - return signalType === "error_rate" ? n / 100 : n + return isPercentSignal(subject) ? n / 100 : n } -export function domainThresholdToForm(signalType: AlertSignalType, value: number): string { +export function domainThresholdToForm(subject: ThresholdSubject, value: number): string { // toPrecision(12) drops float noise: 0.07 * 100 is 7.000000000000001. - return signalType === "error_rate" ? String(Number((value * 100).toPrecision(12))) : String(value) + return isPercentSignal(subject) ? String(Number((value * 100).toPrecision(12))) : String(value) } function parsePositiveNumber(value: string, fallback: number): number { @@ -276,6 +309,7 @@ export function ruleFormIsGrouped(form: RuleFormState): boolean { export function ruleToFormState(rule: AlertRuleDocument): RuleFormState { const queryBuilderDraft = normalizeRuleQueryDraft(rule.queryBuilderDraft) + const unitSubject = { signalType: rule.signalType, queryBuilderDraft } return { name: rule.name, notes: rule.notes ?? "", @@ -288,9 +322,9 @@ export function ruleToFormState(rule: AlertRuleDocument): RuleFormState { groupBy: rule.groupBy ? [...rule.groupBy] : [], signalType: rule.signalType, comparator: rule.comparator, - threshold: domainThresholdToForm(rule.signalType, rule.threshold), + threshold: domainThresholdToForm(unitSubject, rule.threshold), thresholdUpper: - rule.thresholdUpper == null ? "" : domainThresholdToForm(rule.signalType, rule.thresholdUpper), + rule.thresholdUpper == null ? "" : domainThresholdToForm(unitSubject, rule.thresholdUpper), windowMinutes: String(rule.windowMinutes), minimumSampleCount: String(rule.minimumSampleCount), alertOnNoData: rule.noDataBehavior === "alert", @@ -368,10 +402,10 @@ export function buildRuleCreateParamsV2(form: RuleFormState): V2AlertRuleCreateP group_by: queryOwnsScope ? null : form.groupBy.length > 0 ? form.groupBy : null, signal_type: signalType, comparator: form.comparator, - threshold: formThresholdToDomain(signalType, form.threshold), + threshold: formThresholdToDomain(form, form.threshold), threshold_upper: isRangeComparator(form.comparator) ? Number.isFinite(Number(form.thresholdUpper)) - ? formThresholdToDomain(signalType, form.thresholdUpper) + ? formThresholdToDomain(form, form.thresholdUpper) : null : null, window_minutes: parsePositiveNumber(form.windowMinutes, 5), diff --git a/apps/web/src/lib/alerts/incident-timeline.test.ts b/apps/web/src/lib/alerts/incident-timeline.test.ts new file mode 100644 index 0000000000..71a3823541 --- /dev/null +++ b/apps/web/src/lib/alerts/incident-timeline.test.ts @@ -0,0 +1,115 @@ +import { AlertDeliveryEventDocument, AlertIncidentDocument } from "@maple/domain/http" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" +import { buildIncidentTimeline } from "./incident-timeline" + +const decodeIncident = Schema.decodeUnknownSync(AlertIncidentDocument) +const decodeDelivery = Schema.decodeUnknownSync(AlertDeliveryEventDocument) + +const INCIDENT_ID = "00000000-0000-4000-8000-0000000000f1" +const RULE_ID = "00000000-0000-4000-8000-00000000000a" + +function makeIncident(overrides: Record = {}): AlertIncidentDocument { + return decodeIncident({ + id: INCIDENT_ID, + ruleId: RULE_ID, + ruleName: "High error rate", + groupKey: "checkout", + signalType: "error_rate", + severity: "critical", + status: "open", + comparator: "gt", + threshold: 0.05, + thresholdUpper: null, + firstTriggeredAt: "2026-10-15T14:14:00.000Z", + lastTriggeredAt: "2026-10-15T14:30:00.000Z", + resolvedAt: null, + lastObservedValue: 0.084, + lastSampleCount: 100, + dedupeKey: "dedupe", + lastDeliveredEventType: "trigger", + lastNotifiedAt: "2026-10-15T14:14:05.000Z", + holdReason: null, + heldSince: null, + errorIssueId: null, + ...overrides, + }) +} + +function makeDelivery(overrides: Record = {}): AlertDeliveryEventDocument { + return decodeDelivery({ + id: "00000000-0000-4000-8000-0000000000e1", + incidentId: INCIDENT_ID, + ruleId: RULE_ID, + destinationId: "00000000-0000-4000-8000-00000000dddd", + destinationName: "#alerts", + destinationType: "discord", + deliveryKey: "key", + eventType: "trigger", + attemptNumber: 1, + status: "success", + scheduledAt: "2026-10-15T14:14:02.000Z", + attemptedAt: "2026-10-15T14:14:03.000Z", + providerMessage: null, + providerReference: null, + responseCode: 200, + errorMessage: null, + ...overrides, + }) +} + +const build = (incident: AlertIncidentDocument, deliveries: ReadonlyArray = []) => + buildIncidentTimeline({ + incident, + deliveries, + investigation: null, + renotifyIntervalMinutes: 30, + destinationTypeLabel: (type) => type, + }) + +describe("buildIncidentTimeline", () => { + it("orders the open, deliveries and current state chronologically", () => { + const entries = build(makeIncident(), [ + makeDelivery({ + id: "00000000-0000-4000-8000-0000000000e2", + eventType: "renotify", + scheduledAt: "2026-10-15T14:24:00.000Z", + attemptedAt: "2026-10-15T14:24:01.000Z", + }), + makeDelivery(), + ]) + expect(entries.map((entry) => entry.title)).toEqual([ + "Incident opened", + "Notified #alerts", + "Reminded #alerts", + "Still breaching", + ]) + expect(entries[0]?.detail).toBe("Error rate above 5.0% on checkout") + }) + + it("reports a failed delivery with its error", () => { + const [, failed] = build(makeIncident(), [ + makeDelivery({ status: "failed", errorMessage: "webhook returned 500", responseCode: 500 }), + ]) + expect(failed?.title).toBe("Could not notify #alerts") + expect(failed?.tone).toBe("crit") + expect(failed?.detail).toBe("discord · webhook returned 500") + }) + + it("ends a held incident on the hold instead of a breach", () => { + const entries = build(makeIncident({ holdReason: "no_data", heldSince: "2026-10-15T14:40:00.000Z" })) + expect(entries.at(-1)?.title).toBe("Waiting on data") + expect(entries.at(-1)?.pending).toBe(true) + }) + + it("ends a resolved incident with its duration", () => { + const entries = build( + makeIncident({ + status: "resolved", + resolvedAt: "2026-10-15T14:52:00.000Z", + lastDeliveredEventType: "resolve", + }), + ) + expect(entries.at(-1)).toMatchObject({ title: "Resolved", detail: "After 38m" }) + }) +}) diff --git a/apps/web/src/lib/alerts/incident-timeline.ts b/apps/web/src/lib/alerts/incident-timeline.ts new file mode 100644 index 0000000000..7877c0b052 --- /dev/null +++ b/apps/web/src/lib/alerts/incident-timeline.ts @@ -0,0 +1,231 @@ +import type { + AlertComparator, + AlertDeliveryEventDocument, + AlertDestinationType, + AlertIncidentDocument, + AlertIncidentHoldReason, + AlertSignalType, + InvestigationId, +} from "@maple/domain/http" +import type { V2Investigation } from "@maple/domain/http/v2" +import type { Tone } from "@maple/ui/lib/tone" +import { UNGROUPED_GROUP_KEY } from "@maple/domain/http" +import { formatAlertDuration, formatSignalValue, signalLabels } from "./form-utils" + +export interface IncidentTimelineEntry { + readonly key: string + /** Epoch ms. */ + readonly at: number + readonly tone: Tone + /** A state that is still in effect rather than an event that happened. */ + readonly pending: boolean + readonly title: string + readonly detail: string | null + readonly investigation: { readonly id: InvestigationId; readonly headline: string | null } | null +} + +export const holdReasonLabel: Record = { + no_data: "The service stopped reporting. Resolves when telemetry returns or after six hours.", + volume_collapsed: "Traffic dropped well below its usual level. Resolves when data returns.", + sampling_changed: "Sampling changed while the breach cleared. Resolves when the signal settles.", + probe_failed: "Maple could not verify telemetry. Resolves on the next successful check.", +} satisfies Record + +const comparatorWords: Record = { + gt: "above", + gte: "at or above", + lt: "below", + lte: "at or below", + eq: "equal to", + neq: "not equal to", + between: "between", + not_between: "outside", +} satisfies Record + +/** "above 5%", "between 200ms and 400ms". */ +export function conditionPhrase(input: { + signalType: AlertSignalType + comparator: AlertComparator + threshold: number + thresholdUpper: number | null +}): string { + const value = (n: number) => formatSignalValue(input.signalType, n) + const words = comparatorWords[input.comparator] + return input.thresholdUpper != null + ? `${words} ${value(input.threshold)} and ${value(input.thresholdUpper)}` + : `${words} ${value(input.threshold)}` +} + +/** The incident's group, or null for an ungrouped rule. */ +export function incidentGroup(incident: Pick): string | null { + return incident.groupKey == null || incident.groupKey === UNGROUPED_GROUP_KEY ? null : incident.groupKey +} + +export function signalTitle(signalType: AlertSignalType): string { + return signalType === "builder_query" || signalType === "raw_query" + ? "Query value" + : signalLabels[signalType] +} + +function deliveryEntry( + event: AlertDeliveryEventDocument, + typeLabel: (type: AlertDestinationType) => string, +): IncidentTimelineEntry | null { + if (event.eventType === "test") return null + const name = event.destinationName + const failed = event.status === "failed" + const sending = event.status === "queued" || event.status === "processing" + const title = sending + ? `Sending to ${name}` + : event.eventType === "resolve" + ? failed + ? `All-clear to ${name} failed` + : `Sent all-clear to ${name}` + : event.eventType === "renotify" + ? failed + ? `Reminder to ${name} failed` + : `Reminded ${name}` + : failed + ? `Could not notify ${name}` + : `Notified ${name}` + const reason = failed + ? (event.errorMessage ?? (event.responseCode != null ? `HTTP ${event.responseCode}` : null)) + : null + const parts = [ + typeLabel(event.destinationType), + reason, + event.attemptNumber > 1 ? `attempt ${event.attemptNumber}` : null, + ] + return { + key: `delivery:${event.id}`, + at: Date.parse(event.attemptedAt ?? event.scheduledAt), + tone: failed ? "crit" : sending ? "neutral" : event.eventType === "resolve" ? "ok" : "info", + pending: sending, + title, + detail: parts.filter((part) => part != null).join(" · "), + investigation: null, + } +} + +function investigationEntries(investigation: V2Investigation): ReadonlyArray { + const started: IncidentTimelineEntry = { + key: `investigation:${investigation.id}:started`, + at: Date.parse(investigation.created_at), + tone: "neutral", + pending: investigation.status === "investigating", + title: + investigation.seeded_by === "system" + ? "Investigation started automatically" + : "Investigation started", + detail: investigation.status === "investigating" ? "Still running" : null, + investigation: + investigation.status === "investigating" ? { id: investigation.id, headline: null } : null, + } + if (investigation.status === "failed") { + return [ + started, + { + key: `investigation:${investigation.id}:failed`, + at: Date.parse(investigation.updated_at), + tone: "crit", + pending: false, + title: "Investigation failed", + detail: investigation.error, + investigation: { id: investigation.id, headline: null }, + }, + ] + } + if (investigation.diagnosed_at == null || investigation.report == null) return [started] + return [ + started, + { + key: `investigation:${investigation.id}:diagnosed`, + at: Date.parse(investigation.diagnosed_at), + tone: "warn", + pending: false, + title: "Investigation finished", + detail: investigation.confidence != null ? `${investigation.confidence} confidence` : null, + investigation: { + id: investigation.id, + headline: investigation.report.headline ?? investigation.report.summary, + }, + }, + ] +} + +/** + * One chronological story of an incident from the rows that exist today: the + * incident's own lifecycle stamps, its delivery attempts and its investigation. + */ +export function buildIncidentTimeline(input: { + incident: AlertIncidentDocument + deliveries: ReadonlyArray + investigation: V2Investigation | null + renotifyIntervalMinutes: number | null + destinationTypeLabel: (type: AlertDestinationType) => string +}): ReadonlyArray { + const { incident } = input + const group = incidentGroup(incident) + const condition = `${signalTitle(incident.signalType)} ${conditionPhrase(incident)}` + + const opened: IncidentTimelineEntry = { + key: "opened", + at: Date.parse(incident.firstTriggeredAt), + tone: incident.severity === "critical" ? "crit" : "warn", + pending: false, + title: "Incident opened", + detail: group != null ? `${condition} on ${group}` : condition, + investigation: null, + } + + const events: Array = [opened] + for (const delivery of input.deliveries) { + const entry = deliveryEntry(delivery, input.destinationTypeLabel) + if (entry != null) events.push(entry) + } + if (input.investigation != null) events.push(...investigationEntries(input.investigation)) + + if (incident.status === "resolved" && incident.resolvedAt != null) { + events.push({ + key: "resolved", + at: Date.parse(incident.resolvedAt), + tone: "ok", + pending: false, + title: "Resolved", + detail: `After ${formatAlertDuration(incident.firstTriggeredAt, incident.resolvedAt)}`, + investigation: null, + }) + } else if (incident.holdReason != null && incident.heldSince != null) { + events.push({ + key: "held", + at: Date.parse(incident.heldSince), + tone: "neutral", + pending: true, + title: "Waiting on data", + detail: holdReasonLabel[incident.holdReason], + investigation: null, + }) + } else if (incident.status === "open") { + const reminder = + input.renotifyIntervalMinutes != null + ? `reminders every ${input.renotifyIntervalMinutes}m while open` + : null + events.push({ + key: "breaching", + at: Date.parse(incident.lastTriggeredAt), + tone: "crit", + pending: true, + title: "Still breaching", + detail: [ + `Last value ${formatSignalValue(incident.signalType, incident.lastObservedValue)}`, + reminder, + ] + .filter((part) => part != null) + .join(" · "), + investigation: null, + }) + } + + // Stable sort keeps the insertion order (opened first) for identical stamps. + return events.filter((entry) => Number.isFinite(entry.at)).toSorted((a, b) => a.at - b.at) +} diff --git a/apps/web/src/lib/alerts/overview-derive.test.ts b/apps/web/src/lib/alerts/overview-derive.test.ts new file mode 100644 index 0000000000..0d3928723f --- /dev/null +++ b/apps/web/src/lib/alerts/overview-derive.test.ts @@ -0,0 +1,172 @@ +import { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" + +import { blindSpots, fireLanes, ruleBucket, ruleHeadroom, ruleRibbon } from "./overview-derive" +import type { DerivedRuleStatus } from "./rule-status" + +const decodeRule = Schema.decodeUnknownSync(AlertRuleDocument) +const decodeIncident = Schema.decodeUnknownSync(AlertIncidentDocument) + +const NOW = Date.parse("2026-07-06T12:00:00.000Z") +const HOUR = 60 * 60 * 1000 +const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString() +const RULE_ID = "00000000-0000-4000-8000-000000000000" + +function makeRule(overrides: Record = {}): AlertRuleDocument { + return decodeRule({ + id: RULE_ID, + name: "High error rate", + notes: null, + notificationTemplate: null, + enabled: true, + severity: "critical", + serviceNames: [], + excludeServiceNames: [], + environments: [], + tags: [], + groupBy: null, + signalType: "error_rate", + comparator: "gt", + threshold: 0.05, + thresholdUpper: null, + windowMinutes: 5, + minimumSampleCount: 0, + consecutiveBreachesRequired: 1, + consecutiveHealthyRequired: 1, + renotifyIntervalMinutes: 30, + apdexThresholdMs: null, + queryBuilderDraft: null, + rawQuerySql: null, + rawQueryReducer: null, + destinationIds: [], + noDataBehavior: "skip", + lastEvaluationError: null, + lastEvaluatedAt: iso(60_000), + lastScheduledAt: iso(60_000), + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + createdBy: "user_test", + updatedBy: "user_test", + ...overrides, + }) +} + +function makeIncident(overrides: Record = {}): AlertIncidentDocument { + return decodeIncident({ + id: "00000000-0000-4000-8000-0000000000aa", + ruleId: RULE_ID, + ruleName: "High error rate", + groupKey: null, + signalType: "error_rate", + severity: "critical", + status: "resolved", + comparator: "gt", + threshold: 0.05, + thresholdUpper: null, + firstTriggeredAt: iso(6 * HOUR), + lastTriggeredAt: iso(5 * HOUR), + resolvedAt: iso(4 * HOUR), + lastObservedValue: 0.08, + lastSampleCount: 100, + dedupeKey: "k", + lastDeliveredEventType: null, + lastNotifiedAt: null, + holdReason: null, + heldSince: null, + errorIssueId: null, + ...overrides, + }) +} + +const derived = (overrides: Partial = {}): DerivedRuleStatus => ({ + status: "healthy", + attention: { noDestinations: false, recentDeliveryFailure: false }, + reason: null, + ...overrides, +}) + +describe("ruleBucket", () => { + it("puts an unrouted healthy rule in blind and a firing one in firing", () => { + expect( + ruleBucket(derived({ attention: { noDestinations: true, recentDeliveryFailure: false } })), + ).toBe("blind") + expect( + ruleBucket( + derived({ + status: "firing", + attention: { noDestinations: true, recentDeliveryFailure: false }, + }), + ), + ).toBe("firing") + }) +}) + +describe("ruleHeadroom", () => { + it("reports a breach and no headroom above a > threshold", () => { + const meter = ruleHeadroom(makeRule(), 0.08) + expect(meter.breached).toBe(true) + expect(meter.headroom).toBeLessThan(0) + expect(meter.fill).toBeGreaterThan(meter.ticks[0] ?? 1) + }) + + it("measures headroom from below for a < threshold", () => { + const meter = ruleHeadroom( + makeRule({ comparator: "lt", signalType: "throughput", threshold: 100 }), + 150, + ) + expect(meter.breached).toBe(false) + expect(meter.headroom).toBeCloseTo(0.5) + }) +}) + +describe("fireLanes", () => { + it("clips bars to the window and skips incidents outside it", () => { + const lanes = fireLanes( + [ + makeIncident(), + makeIncident({ + id: "00000000-0000-4000-8000-0000000000bb", + firstTriggeredAt: iso(48 * HOUR), + resolvedAt: iso(47 * HOUR), + }), + ], + { min: NOW - 24 * HOUR, max: NOW }, + ) + expect(lanes).toHaveLength(1) + expect(lanes[0]?.bars).toHaveLength(1) + expect(lanes[0]?.bars[0]?.start).toBeCloseTo(18 / 24) + expect(lanes[0]?.bars[0]?.end).toBeCloseTo(20 / 24) + }) +}) + +describe("ruleRibbon", () => { + it("marks only the cells an incident overlaps", () => { + const cells = ruleRibbon( + [makeIncident({ severity: "warning" })], + { min: NOW - 24 * HOUR, max: NOW }, + 4, + ) + expect(cells).toEqual(["quiet", "quiet", "quiet", "warning"]) + }) +}) + +describe("blindSpots", () => { + it("lists errors before unrouted rules and skips disabled ones", () => { + const erroring = makeRule({ id: "00000000-0000-4000-8000-000000000001" }) + const unrouted = makeRule({ id: "00000000-0000-4000-8000-000000000002" }) + const disabled = makeRule({ id: "00000000-0000-4000-8000-000000000003", enabled: false }) + const spots = blindSpots( + [unrouted, erroring, disabled], + new Map([ + [erroring.id, derived({ status: "error", reason: "boom" })], + [unrouted.id, derived({ attention: { noDestinations: true, recentDeliveryFailure: false } })], + [disabled.id, derived({ status: "disabled" })], + ]), + ) + expect(spots.map((s) => [s.rule.id, s.kind])).toEqual([ + [erroring.id, "error"], + [unrouted.id, "unrouted"], + ]) + }) +}) diff --git a/apps/web/src/lib/alerts/overview-derive.ts b/apps/web/src/lib/alerts/overview-derive.ts new file mode 100644 index 0000000000..df3f8759fd --- /dev/null +++ b/apps/web/src/lib/alerts/overview-derive.ts @@ -0,0 +1,198 @@ +import type { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" + +import { comparatorLabels, formatSignalValue, isRangeComparator, signalLabels } from "@/lib/alerts/form-utils" +import { type DerivedRuleStatus, needsAttention } from "@/lib/alerts/rule-status" + +/** The four mutually exclusive buckets the Rules tab filters by. */ +export type RuleBucket = "firing" | "blind" | "healthy" | "disabled" + +export function ruleBucket(derived: DerivedRuleStatus | undefined): RuleBucket { + if (derived === undefined) return "healthy" + if (derived.status === "disabled") return "disabled" + if (derived.status === "firing") return "firing" + if (needsAttention(derived)) return "blind" + return "healthy" +} + +/** "Error rate > 5% · 5m · per service.name · checkout" */ +export function ruleConditionSummary(rule: AlertRuleDocument): string { + const threshold = isRangeComparator(rule.comparator) + ? `${formatSignalValue(rule.signalType, rule.threshold)} and ${formatSignalValue(rule.signalType, rule.thresholdUpper)}` + : formatSignalValue(rule.signalType, rule.threshold) + const parts = [ + `${signalLabels[rule.signalType]} ${comparatorLabels[rule.comparator]} ${threshold}`, + `${rule.windowMinutes}m`, + ] + if (rule.groupBy !== null) parts.push(`per ${rule.groupBy.join(", ")}`) + if (rule.serviceNames.length === 1) parts.push(rule.serviceNames[0] ?? "") + else if (rule.serviceNames.length > 1) parts.push(`${rule.serviceNames.length} services`) + return parts.join(" · ") +} + +/** "Error rate 8.4% on checkout": what an open incident is about, in one line. */ +export function incidentTitle(incident: AlertIncidentDocument): string { + const value = formatSignalValue(incident.signalType, incident.lastObservedValue) + const base = `${signalLabels[incident.signalType]} ${value}` + return incident.groupKey === null ? base : `${base} on ${incident.groupKey}` +} + +export interface Headroom { + /** Fill fraction 0..1 for the current value. */ + readonly fill: number + /** Tick positions 0..1 for the threshold(s). */ + readonly ticks: ReadonlyArray + readonly breached: boolean + /** Fraction of the threshold left before breaching; null for range/equality comparators. */ + readonly headroom: number | null +} + +const breaches = (rule: AlertRuleDocument, value: number): boolean => { + const upper = rule.thresholdUpper ?? rule.threshold + switch (rule.comparator) { + case "gt": + return value > rule.threshold + case "gte": + return value >= rule.threshold + case "lt": + return value < rule.threshold + case "lte": + return value <= rule.threshold + case "eq": + return value === rule.threshold + case "neq": + return value !== rule.threshold + case "between": + return value >= rule.threshold && value <= upper + case "not_between": + return value < rule.threshold || value > upper + } +} + +/** Current value against the threshold on a shared 0..max scale. */ +export function ruleHeadroom(rule: AlertRuleDocument, value: number): Headroom { + const thresholds = [rule.threshold, ...(rule.thresholdUpper !== null ? [rule.thresholdUpper] : [])] + const max = Math.max(value * 1.1, ...thresholds.map((t) => t * 1.5), Number.EPSILON) + const above = rule.comparator === "gt" || rule.comparator === "gte" + const below = rule.comparator === "lt" || rule.comparator === "lte" + const headroom = + rule.threshold === 0 + ? null + : above + ? (rule.threshold - value) / rule.threshold + : below + ? (value - rule.threshold) / rule.threshold + : null + return { + fill: Math.min(1, Math.max(0, value / max)), + ticks: thresholds.map((t) => Math.min(1, Math.max(0, t / max))), + breached: breaches(rule, value), + headroom, + } +} + +export interface FireBar { + readonly id: string + /** 0..1 offsets inside the window. */ + readonly start: number + readonly end: number + readonly open: boolean + readonly held: boolean + readonly severity: AlertIncidentDocument["severity"] +} + +export interface FireLane { + readonly ruleId: string + readonly ruleName: string + readonly bars: ReadonlyArray +} + +const MIN_BAR_FRACTION = 0.004 + +/** One lane per rule that had an incident overlapping the window, busiest first. */ +export function fireLanes( + incidents: ReadonlyArray, + range: { readonly min: number; readonly max: number }, +): FireLane[] { + const span = Math.max(1, range.max - range.min) + const lanes = new Map() + for (const incident of incidents) { + const start = Date.parse(incident.firstTriggeredAt) + const end = incident.resolvedAt === null ? range.max : Date.parse(incident.resolvedAt) + if (end < range.min || start > range.max) continue + const s = (Math.max(start, range.min) - range.min) / span + const e = (Math.min(end, range.max) - range.min) / span + const bar: FireBar = { + id: incident.id, + start: s, + end: Math.max(e, s + MIN_BAR_FRACTION), + open: incident.status === "open", + held: incident.status === "open" && incident.holdReason !== null, + severity: incident.severity, + } + const lane = lanes.get(incident.ruleId) + if (lane) lane.bars.push(bar) + else lanes.set(incident.ruleId, { ruleName: incident.ruleName, bars: [bar] }) + } + return [...lanes.entries()] + .map(([ruleId, lane]) => ({ ruleId, ruleName: lane.ruleName, bars: lane.bars })) + .sort((a, b) => b.bars.length - a.bars.length || a.ruleName.localeCompare(b.ruleName)) +} + +export type RibbonCell = "quiet" | "warning" | "critical" + +/** Fixed-width cells over the window; the worst incident severity in a cell wins. */ +export function ruleRibbon( + incidents: ReadonlyArray, + range: { readonly min: number; readonly max: number }, + cells: number, +): RibbonCell[] { + const width = (range.max - range.min) / cells + return Array.from({ length: cells }, (_, i) => { + const cellStart = range.min + i * width + const cellEnd = cellStart + width + const hits = incidents.filter((incident) => { + const start = Date.parse(incident.firstTriggeredAt) + const end = incident.resolvedAt === null ? range.max : Date.parse(incident.resolvedAt) + return end > cellStart && start < cellEnd + }) + if (hits.some((incident) => incident.severity === "critical")) return "critical" + return hits.length > 0 ? "warning" : "quiet" + }) +} + +export type BlindSpotKind = "error" | "stale" | "no-data" | "unrouted" | "delivery" + +export interface BlindSpot { + readonly rule: AlertRuleDocument + readonly kind: BlindSpotKind + readonly detail: string +} + +const BLIND_SPOT_ORDER: Record = { + error: 0, + stale: 1, + "no-data": 2, + unrouted: 3, + delivery: 4, +} satisfies Record + +/** Enabled rules that cannot fire, or can fire but reach nobody. One entry per rule, worst first. */ +export function blindSpots( + rules: ReadonlyArray, + derivedByRuleId: ReadonlyMap, +): BlindSpot[] { + const spots: BlindSpot[] = [] + for (const rule of rules) { + const derived = derivedByRuleId.get(rule.id) + if (derived === undefined || derived.status === "disabled") continue + const reason = derived.reason ?? "" + if (derived.status === "error") spots.push({ rule, kind: "error", detail: reason }) + else if (derived.status === "stale") spots.push({ rule, kind: "stale", detail: reason }) + else if (derived.status === "no-data") spots.push({ rule, kind: "no-data", detail: reason }) + else if (derived.attention.noDestinations) + spots.push({ rule, kind: "unrouted", detail: "No destinations, so nobody is notified" }) + else if (derived.attention.recentDeliveryFailure) + spots.push({ rule, kind: "delivery", detail: "The last notification failed to deliver" }) + } + return spots.sort((a, b) => BLIND_SPOT_ORDER[a.kind] - BLIND_SPOT_ORDER[b.kind]) +} diff --git a/apps/web/src/lib/alerts/preview-insights.test.ts b/apps/web/src/lib/alerts/preview-insights.test.ts new file mode 100644 index 0000000000..3844c5145b --- /dev/null +++ b/apps/web/src/lib/alerts/preview-insights.test.ts @@ -0,0 +1,166 @@ +import { AlertIncidentDocument, AlertRulePreviewResponse } from "@maple/domain/http" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" + +import { defaultRuleForm } from "@/lib/alerts/form-utils" +import { + countSkippedWindows, + diffRuleForms, + focusPreviewOnClosestGroups, + incidentsClearedByDraft, +} from "./preview-insights" + +const decodePreview = Schema.decodeUnknownSync(AlertRulePreviewResponse) +const decodeIncident = Schema.decodeUnknownSync(AlertIncidentDocument) + +const T0 = Date.parse("2026-10-15T12:00:00.000Z") +const MIN = 60_000 +const at = (minutes: number) => new Date(T0 + minutes * MIN).toISOString() + +type PointSpec = { value: number | null; status?: string; skipReason?: string } + +function preview( + series: Record, + wouldFire: Array<{ groupKey: string; start: number; end: number }> = [], +): AlertRulePreviewResponse { + return decodePreview({ + bucketSeconds: 300, + windowMinutes: 5, + threshold: 0.05, + thresholdUpper: null, + comparator: "gt", + truncatedToStart: null, + series: Object.entries(series).map(([groupKey, points]) => ({ + groupKey, + points: points.map((point, i) => ({ + bucket: at(i * 5), + value: point.value, + sampleCount: 100, + status: point.status ?? "healthy", + ...(point.skipReason ? { skipReason: point.skipReason } : undefined), + })), + })), + wouldFire: wouldFire.map((span) => ({ + groupKey: span.groupKey, + start: at(span.start), + end: at(span.end), + })), + }) +} + +function incident(groupKey: string | null, status = "open"): AlertIncidentDocument { + return decodeIncident({ + id: "00000000-0000-4000-8000-0000000000f1", + ruleId: "00000000-0000-4000-8000-00000000000a", + ruleName: "Checkout errors", + groupKey, + signalType: "error_rate", + severity: "critical", + status, + comparator: "gt", + threshold: 0.05, + thresholdUpper: null, + firstTriggeredAt: at(0), + lastTriggeredAt: at(10), + resolvedAt: null, + lastObservedValue: 0.08, + lastSampleCount: 100, + dedupeKey: "dedupe", + lastDeliveredEventType: "trigger", + lastNotifiedAt: at(0), + holdReason: null, + heldSince: null, + errorIssueId: null, + }) +} + +describe("focusPreviewOnClosestGroups", () => { + it("keeps the groups that breach or come closest to the threshold, and counts the rest", () => { + const response = preview( + { + quiet: [{ value: 0.001 }, { value: 0.002 }], + close: [{ value: 0.049 }, { value: 0.01 }], + breaching: [{ value: 0.09, status: "breached" }, { value: 0.01 }], + far: [{ value: 0.0 }, { value: 0.0 }], + }, + [{ groupKey: "breaching", start: 0, end: 5 }], + ) + const focused = focusPreviewOnClosestGroups(response, 2) + + expect(focused.shownGroups).toEqual(["breaching", "close"]) + expect(focused.hiddenGroupCount).toBe(2) + // The verdict reads would-fire spans from every group, shown or not. + expect(focused.preview?.wouldFire).toHaveLength(1) + }) + + it("leaves a preview within the limit untouched", () => { + const response = preview({ a: [{ value: 1 }] }) + expect(focusPreviewOnClosestGroups(response, 3)).toEqual({ + preview: response, + shownGroups: [], + hiddenGroupCount: 0, + }) + }) +}) + +describe("countSkippedWindows", () => { + it("counts skipped windows by reason across groups", () => { + const response = preview({ + a: [ + { value: 0.01, status: "skipped", skipReason: "below_min_samples" }, + { value: null, status: "skipped", skipReason: "no_data" }, + ], + b: [{ value: 0.02 }, { value: 0.01, status: "skipped", skipReason: "below_min_samples" }], + }) + expect(countSkippedWindows(response)).toEqual({ + total: 4, + skipped: 3, + byReason: { below_min_samples: 2, no_data: 1, no_value: 0 }, + }) + }) +}) + +describe("incidentsClearedByDraft", () => { + const draft = preview( + { + checkout: [ + { value: 0.09, status: "breached" }, + { value: 0.09, status: "breached" }, + ], + payments: [{ value: 0.09, status: "breached" }, { value: 0.01 }], + }, + [ + // Still open at the last window (closes at its end). + { groupKey: "checkout", start: 0, end: 10 }, + // Resolved before the window ended. + { groupKey: "payments", start: 0, end: 5 }, + ], + ) + + it("lists open incidents whose group the draft no longer holds open", () => { + const cleared = incidentsClearedByDraft([incident("checkout"), incident("payments")], draft) + expect(cleared.map((i) => i.groupKey)).toEqual(["payments"]) + }) + + it("ignores resolved incidents and says nothing before the draft replay loads", () => { + expect(incidentsClearedByDraft([incident("payments", "resolved")], draft)).toEqual([]) + expect(incidentsClearedByDraft([incident("payments")], null)).toEqual([]) + }) +}) + +describe("diffRuleForms", () => { + it("lists only the fields that change what gets saved, in display units", () => { + const saved = { ...defaultRuleForm(), name: "Checkout errors", threshold: "1.5" } + const draft = { ...saved, threshold: "2", windowMinutes: "15" } + + expect(diffRuleForms(saved, draft)).toEqual([ + { field: "Threshold", before: "1.5%", after: "2.0%" }, + { field: "Window", before: "5m", after: "15m" }, + ]) + }) + + it("finds nothing when the draft equals the saved rule", () => { + const saved = { ...defaultRuleForm(), name: "Checkout errors" } + expect(diffRuleForms(saved, { ...saved })).toEqual([]) + }) +}) diff --git a/apps/web/src/lib/alerts/preview-insights.ts b/apps/web/src/lib/alerts/preview-insights.ts new file mode 100644 index 0000000000..11415c6c97 --- /dev/null +++ b/apps/web/src/lib/alerts/preview-insights.ts @@ -0,0 +1,296 @@ +import { + AlertRulePreviewResponse, + UNGROUPED_GROUP_KEY, + type AlertIncidentDocument, + type AlertRulePreviewSeries, + type AlertSkipReason, +} from "@maple/domain/http" + +import { + buildRuleCreateParamsV2, + comparatorLabels, + displaySignalType, + formThresholdToDomain, + formatSignalValue, + isRangeComparator, + RAW_QUERY_REDUCER_LABELS, + signalLabels, + type RuleFormState, +} from "@/lib/alerts/form-utils" + +/** How many groups the editor's preview chart draws before collapsing the rest into "N more". */ +export const PREVIEW_GROUP_LIMIT = 3 + +export interface FocusedPreview { + readonly preview: AlertRulePreviewResponse | null + readonly shownGroups: readonly string[] + readonly hiddenGroupCount: number +} + +/** + * Narrow a grouped preview to the groups closest to firing: most breached + * windows first, then the smallest gap to the threshold. The would-fire spans + * stay whole so the verdict still counts every group. + */ +export function focusPreviewOnClosestGroups( + preview: AlertRulePreviewResponse | null, + limit: number = PREVIEW_GROUP_LIMIT, +): FocusedPreview { + if (preview === null || preview.series.length <= limit) { + return { preview, shownGroups: [], hiddenGroupCount: 0 } + } + const ranked = preview.series + .map((series) => ({ series, ...thresholdProximity(series, preview) })) + .sort((a, b) => b.breaches - a.breaches || a.gap - b.gap) + .slice(0, limit) + .map((entry) => entry.series) + return { + preview: new AlertRulePreviewResponse({ ...preview, series: ranked }), + shownGroups: ranked.map((series) => series.groupKey), + hiddenGroupCount: preview.series.length - ranked.length, + } +} + +function thresholdProximity( + series: AlertRulePreviewSeries, + preview: AlertRulePreviewResponse, +): { breaches: number; gap: number } { + const scale = Math.max(Math.abs(preview.threshold), Number.EPSILON) + const bounds = [preview.threshold, ...(preview.thresholdUpper === null ? [] : [preview.thresholdUpper])] + const values = series.points.flatMap((point) => (point.value === null ? [] : [point.value])) + return { + breaches: series.points.filter((point) => point.status === "breached").length, + gap: Math.min( + Number.POSITIVE_INFINITY, + ...values.flatMap((value) => bounds.map((bound) => Math.abs(value - bound) / scale)), + ), + } +} + +export interface SkippedWindows { + readonly total: number + readonly skipped: number + readonly byReason: Readonly> +} + +/** Windows the evaluator would have skipped, across every group, by reason. */ +export function countSkippedWindows(preview: AlertRulePreviewResponse | null): SkippedWindows { + const empty: SkippedWindows = { + total: 0, + skipped: 0, + byReason: { below_min_samples: 0, no_data: 0, no_value: 0 }, + } + if (preview === null) return empty + return preview.series + .flatMap((series) => series.points) + .reduce( + (acc, point) => + point.status !== "skipped" + ? { ...acc, total: acc.total + 1 } + : { + total: acc.total + 1, + skipped: acc.skipped + 1, + byReason: { + ...acc.byReason, + [point.skipReason ?? "no_data"]: + acc.byReason[point.skipReason ?? "no_data"] + 1, + }, + }, + empty, + ) +} + +/** + * Open incidents of the rule that the draft would not keep open: their group + * has no would-fire span reaching the last complete window of the draft's + * replay. Unknown (empty) while the draft preview is not loaded. + */ +export function incidentsClearedByDraft( + incidents: readonly AlertIncidentDocument[], + draftPreview: AlertRulePreviewResponse | null, +): AlertIncidentDocument[] { + if (draftPreview === null) return [] + const windowMs = draftPreview.windowMinutes * 60_000 + const stillOpen = new Set( + draftPreview.series + .filter((series) => { + const lastComplete = series.points + .filter((point) => point.provisional !== true) + .reduce((max, point) => Math.max(max, Date.parse(point.bucket)), Number.NEGATIVE_INFINITY) + return draftPreview.wouldFire.some( + (span) => + span.groupKey === series.groupKey && Date.parse(span.end) >= lastComplete + windowMs, + ) + }) + .map((series) => series.groupKey), + ) + return incidents.filter( + (incident) => incident.status === "open" && !stillOpen.has(incident.groupKey ?? UNGROUPED_GROUP_KEY), + ) +} + +export interface RuleFieldChange { + readonly field: string + readonly before: string + readonly after: string +} + +/** + * What an edit changes, field by field, in the form's own display units. Built + * from the save payload so a field only counts as changed when saving it would. + */ +export function diffRuleForms(saved: RuleFormState, draft: RuleFormState): RuleFieldChange[] { + const a = buildRuleCreateParamsV2(saved) + const b = buildRuleCreateParamsV2(draft) + const threshold = (form: RuleFormState, value: number | null | undefined) => + value == null ? "none" : formatSignalValue(displaySignalType(form), value) + const list = (values: readonly string[] | null | undefined) => + values == null || values.length === 0 ? "all" : values.join(", ") + const query = (form: RuleFormState) => + form.signalType === "builder_query" + ? `${form.queryBuilderDraft.aggregation} where ${form.queryBuilderDraft.whereClause || "(none)"}` + : form.signalType === "raw_query" + ? "SQL" + : signalLabels[form.signalType] + + const rows: Array = [ + { + field: "Query", + before: query(saved), + after: query(draft), + same: + a.signal_type === b.signal_type && + JSON.stringify(a.query_builder_draft) === JSON.stringify(b.query_builder_draft) && + a.raw_query_sql === b.raw_query_sql, + }, + { + field: "Reducer", + before: a.raw_query_reducer == null ? "none" : RAW_QUERY_REDUCER_LABELS[a.raw_query_reducer], + after: b.raw_query_reducer == null ? "none" : RAW_QUERY_REDUCER_LABELS[b.raw_query_reducer], + same: a.raw_query_reducer === b.raw_query_reducer, + }, + { + field: "Services", + before: list(a.service_names), + after: list(b.service_names), + same: list(a.service_names) === list(b.service_names), + }, + { + field: "Environments", + before: list(a.environments), + after: list(b.environments), + same: list(a.environments) === list(b.environments), + }, + { + field: "Split by", + before: list(a.group_by), + after: list(b.group_by), + same: list(a.group_by) === list(b.group_by), + }, + { + field: "Condition", + before: comparatorLabels[a.comparator], + after: comparatorLabels[b.comparator], + same: a.comparator === b.comparator, + }, + { + field: "Threshold", + before: threshold(saved, a.threshold), + after: threshold(draft, b.threshold), + same: a.threshold === b.threshold, + }, + { + field: "Upper threshold", + before: threshold(saved, a.threshold_upper), + after: threshold(draft, b.threshold_upper), + same: + a.threshold_upper === b.threshold_upper || + (!isRangeComparator(a.comparator) && !isRangeComparator(b.comparator)), + }, + { + field: "Window", + before: `${a.window_minutes}m`, + after: `${b.window_minutes}m`, + same: a.window_minutes === b.window_minutes, + }, + { + field: "Checks to fire", + before: String(a.consecutive_breaches_required), + after: String(b.consecutive_breaches_required), + same: a.consecutive_breaches_required === b.consecutive_breaches_required, + }, + { + field: "Checks to resolve", + before: String(a.consecutive_healthy_required), + after: String(b.consecutive_healthy_required), + same: a.consecutive_healthy_required === b.consecutive_healthy_required, + }, + { + field: "Min samples", + before: String(a.minimum_sample_count), + after: String(b.minimum_sample_count), + same: a.minimum_sample_count === b.minimum_sample_count, + }, + { + field: "No data", + before: a.alert_on_no_data ? "alert" : "skip", + after: b.alert_on_no_data ? "alert" : "skip", + same: a.alert_on_no_data === b.alert_on_no_data, + }, + { + field: "Apdex target", + before: `${a.apdex_threshold_ms ?? "none"}`, + after: `${b.apdex_threshold_ms ?? "none"}`, + same: a.apdex_threshold_ms === b.apdex_threshold_ms, + }, + { field: "Severity", before: a.severity, after: b.severity, same: a.severity === b.severity }, + { + field: "Renotify", + before: `${a.renotify_interval_minutes}m`, + after: `${b.renotify_interval_minutes}m`, + same: a.renotify_interval_minutes === b.renotify_interval_minutes, + }, + { + field: "Destinations", + before: String(a.destination_ids.length), + after: String(b.destination_ids.length), + same: [...a.destination_ids].sort().join() === [...b.destination_ids].sort().join(), + }, + { field: "Name", before: a.name, after: b.name, same: a.name === b.name }, + { + field: "Notes", + before: a.notes ?? "", + after: b.notes ?? "", + same: (a.notes ?? "") === (b.notes ?? ""), + }, + { + field: "Tags", + before: saved.tags.join(", "), + after: draft.tags.join(", "), + same: saved.tags.join() === draft.tags.join(), + }, + { + field: "Message", + before: a.notification_template === null ? "default" : "custom", + after: b.notification_template === null ? "default" : "custom", + same: JSON.stringify(a.notification_template) === JSON.stringify(b.notification_template), + }, + { + field: "Enabled", + before: String(a.enabled), + after: String(b.enabled), + same: a.enabled === b.enabled, + }, + ] + return rows.filter((row) => !row.same).map(({ same: _same, ...change }) => change) +} + +/** The draft's threshold as a domain value, for the chart and the verdict. */ +export function draftThresholds(form: RuleFormState): { threshold: number; upper: number | null } { + const threshold = formThresholdToDomain(form, form.threshold) + const upper = + form.thresholdUpper.trim().length > 0 && Number.isFinite(Number(form.thresholdUpper)) + ? formThresholdToDomain(form, form.thresholdUpper) + : null + return { threshold: Number.isFinite(threshold) ? threshold : 0, upper } +} diff --git a/apps/web/src/lib/alerts/rule-modes.test.ts b/apps/web/src/lib/alerts/rule-modes.test.ts new file mode 100644 index 0000000000..6e8ed775fd --- /dev/null +++ b/apps/web/src/lib/alerts/rule-modes.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest" + +import { buildRuleCreateParamsV2, defaultRuleForm, type RuleFormState } from "@/lib/alerts/form-utils" +import { builtinToQueryDraft, queryDraftToBuiltin, switchSignalKind } from "@/lib/alerts/rule-modes" + +const builtin = (overrides: Partial = {}): RuleFormState => ({ + ...defaultRuleForm(), + name: "Checkout errors", + ...overrides, +}) + +describe("switching query modes", () => { + it("keeps a 5% error-rate threshold at 5% in the builder and saves the same ratio", () => { + const form = builtin({ threshold: "5" }) + const moved = switchSignalKind(form, "builder_query").form + + expect(moved.threshold).toBe("5") + expect(buildRuleCreateParamsV2(moved).threshold).toBe(buildRuleCreateParamsV2(form).threshold) + expect(buildRuleCreateParamsV2(moved).threshold).toBe(0.05) + }) + + it("re-expresses a percent threshold as a ratio in SQL", () => { + const moved = switchSignalKind(builtin({ threshold: "5" }), "raw_query").form + expect(moved.threshold).toBe("0.05") + expect(buildRuleCreateParamsV2(moved).threshold).toBe(0.05) + }) + + it("carries services, environments and exclusions into the builder's where clause", () => { + const moved = switchSignalKind( + builtin({ + signalType: "p95_latency", + serviceNames: ["checkout", "payments"], + environments: ["production"], + }), + "builder_query", + ).form + + expect(moved.signalType).toBe("builder_query") + expect(moved.queryBuilderDraft.aggregation).toBe("p95_duration") + expect(moved.queryBuilderDraft.whereClause).toBe( + 'root_only = true AND service.name = "checkout,payments" AND deployment.environment = "production"', + ) + // Built-in evaluates several services one by one, so the builder splits by service. + expect(moved.queryBuilderDraft.groupBy).toEqual(["service.name"]) + }) + + it("round-trips built-in filters through the builder and back", () => { + const form = builtin({ + signalType: "throughput", + excludeServiceNames: ["batch"], + environments: ["staging", "production"], + groupBy: ["span.name"], + }) + const back = switchSignalKind(switchSignalKind(form, "builder_query").form, "builtin") + + expect(back.notice).toBeNull() + expect(back.form.signalType).toBe("throughput") + expect(back.form.excludeServiceNames).toEqual(["batch"]) + expect(back.form.environments).toEqual(["staging", "production"]) + expect(back.form.groupBy).toEqual(["span.name"]) + }) + + it("names what Built-in can't express when leaving the builder", () => { + const form = builtin({ + signalType: "builder_query", + queryBuilderDraft: { + ...builtinToQueryDraft(builtin()), + whereClause: 'service.name = "api" AND http.route = "/v1/query"', + }, + }) + const back = switchSignalKind(form, "builtin") + + expect(back.form.serviceNames).toEqual(["api"]) + expect(back.notice).toContain("http.route = /v1/query") + }) + + it("reports a non-trace data source as dropped", () => { + const draft = builtinToQueryDraft(builtin()) + const result = queryDraftToBuiltin( + builtin({ signalType: "builder_query", queryBuilderDraft: { ...draft, dataSource: "logs" } }), + ) + expect(result.dropped).toContain("logs data source") + }) + + it("leaves the form alone when the mode doesn't change", () => { + const form = builtin() + expect(switchSignalKind(form, "builtin")).toEqual({ form, notice: null }) + }) +}) diff --git a/apps/web/src/lib/alerts/rule-modes.ts b/apps/web/src/lib/alerts/rule-modes.ts new file mode 100644 index 0000000000..4de8bc306e --- /dev/null +++ b/apps/web/src/lib/alerts/rule-modes.ts @@ -0,0 +1,186 @@ +import type { AlertSignalType } from "@maple/domain/http" +import { normalizeKey, parseWhereClause, quoteWhereValue, splitCsv } from "@maple/domain/where-clause" +import { createQueryDraft, type QueryBuilderQueryDraft } from "@maple/query-engine/query-builder" + +import { domainThresholdToForm, formThresholdToDomain, type RuleFormState } from "@/lib/alerts/form-utils" + +/** The three ways to write a rule's query. Built-in covers the five canned trace signals. */ +export type SignalKind = "builtin" | "builder_query" | "raw_query" + +export const signalTypeToKind = (signalType: AlertSignalType): SignalKind => + signalType === "builder_query" || signalType === "raw_query" ? signalType : "builtin" + +/** + * The query-builder aggregation each built-in signal evaluates. Mirrors the + * evaluator's own table (`compileRulePlan`), so a switch lands on the same + * numbers. Apdex has no builder aggregation. + */ +function builtinAggregation(signalType: AlertSignalType): string | null { + switch (signalType) { + case "error_rate": + return "error_rate" + case "p95_latency": + return "p95_duration" + case "p99_latency": + return "p99_duration" + case "throughput": + return "count" + default: + return null + } +} + +function aggregationBuiltin(aggregation: string): AlertSignalType | null { + switch (aggregation) { + case "error_rate": + return "error_rate" + case "p95_duration": + return "p95_latency" + case "p99_duration": + return "p99_latency" + case "count": + return "throughput" + default: + return null + } +} + +/** What a built-in rule filters on, as the editable query-builder draft. */ +export function builtinToQueryDraft(form: RuleFormState): QueryBuilderQueryDraft { + const base = form.queryBuilderDraft + const clauses = ["root_only = true"] + if (form.serviceNames.length > 0) { + clauses.push(`service.name = ${quoteWhereValue(form.serviceNames.join(","))}`) + } + if (form.environments.length > 0) { + clauses.push(`deployment.environment = ${quoteWhereValue(form.environments.join(","))}`) + } + for (const excluded of form.excludeServiceNames) { + clauses.push(`service.name != ${quoteWhereValue(excluded)}`) + } + // A built-in rule over several services is evaluated once per service. + const groupBy = + form.serviceNames.length > 1 && !form.groupBy.includes("service.name") + ? ["service.name", ...form.groupBy] + : form.groupBy + const fresh = createQueryDraft(0) + return { + ...fresh, + id: base.id, + name: base.name, + dataSource: "traces", + aggregation: builtinAggregation(form.signalType) ?? base.aggregation, + whereClause: clauses.join(" AND "), + addOns: { ...fresh.addOns, groupBy: groupBy.length > 0 }, + groupBy: groupBy.length > 0 ? [...groupBy] : [], + } +} + +export interface BuiltinFromDraft { + readonly fields: Pick< + RuleFormState, + "signalType" | "serviceNames" | "excludeServiceNames" | "environments" | "groupBy" + > + /** Parts of the query the built-in form cannot express, dropped by the switch. */ + readonly dropped: readonly string[] +} + +/** The built-in form's reading of a query-builder draft, plus what it had to drop. */ +export function queryDraftToBuiltin(form: RuleFormState): BuiltinFromDraft { + const draft = form.queryBuilderDraft + const dropped: string[] = [] + if (draft.dataSource !== "traces") dropped.push(`${draft.dataSource} data source`) + const mapped = draft.dataSource === "traces" ? aggregationBuiltin(draft.aggregation) : null + if (mapped === null) dropped.push(`${draft.aggregation} aggregation`) + + const serviceNames: string[] = [] + const excludeServiceNames: string[] = [] + const environments: string[] = [] + const parsed = parseWhereClause(draft.whereClause) + for (const warning of parsed.warnings) dropped.push(warning.clause) + for (const clause of parsed.clauses) { + const key = normalizeKey(clause.key) + if (key === "root_only" && clause.operator === "=" && clause.value === "true") continue + if (key === "service.name" && clause.operator === "=") serviceNames.push(...splitCsv(clause.value)) + else if (key === "service.name" && clause.operator === "!=") excludeServiceNames.push(clause.value) + else if (key === "deployment.environment" && clause.operator === "=") + environments.push(...splitCsv(clause.value)) + else dropped.push(`${clause.key} ${clause.operator} ${clause.value}`) + } + + const groupTokens = draft.addOns.groupBy ? draft.groupBy.filter((t) => t !== "none" && t.length > 0) : [] + // Several services already split per service; the token is implied, not a choice. + const groupBy = serviceNames.length > 1 ? groupTokens.filter((t) => t !== "service.name") : groupTokens + + return { + fields: { + signalType: mapped ?? (form.signalType === "builder_query" ? "error_rate" : form.signalType), + serviceNames, + excludeServiceNames: serviceNames.length > 0 ? [] : excludeServiceNames, + environments, + groupBy: serviceNames.length > 0 ? [] : groupBy, + }, + dropped, + } +} + +export interface KindSwitch { + readonly form: RuleFormState + /** Said once, next to the switch, when the move lost or could not carry something. */ + readonly notice: string | null +} + +/** + * Move the rule to another query mode, carrying its filters and keeping the + * threshold's meaning: the value is converted through the stored unit, so 5% + * on Built-in stays 5% in the builder and becomes 0.05 in SQL. + */ +export function switchSignalKind(form: RuleFormState, next: SignalKind): KindSwitch { + if (next === signalTypeToKind(form.signalType)) return { form, notice: null } + const moved = moveToKind(form, next) + return { form: carryThreshold(form, moved.form), notice: moved.notice } +} + +function moveToKind(form: RuleFormState, next: SignalKind): KindSwitch { + const current = signalTypeToKind(form.signalType) + if (next === "raw_query") return { form: { ...form, signalType: "raw_query" }, notice: null } + if (next === "builder_query") { + if (current !== "builtin") return { form: { ...form, signalType: "builder_query" }, notice: null } + return { + form: { ...form, signalType: "builder_query", queryBuilderDraft: builtinToQueryDraft(form) }, + notice: + form.signalType === "apdex" + ? "Apdex has no query builder aggregation. Filters carried over; pick an aggregation." + : null, + } + } + if (current === "builder_query") { + const fromQuery = queryDraftToBuiltin(form) + return { + form: { ...form, ...fromQuery.fields }, + notice: + fromQuery.dropped.length > 0 + ? `Built-in can't express ${fromQuery.dropped.join(", ")}. Dropped on switch.` + : null, + } + } + return { + form: { + ...form, + signalType: "error_rate", + serviceNames: [], + excludeServiceNames: [], + environments: [], + }, + notice: "SQL doesn't carry over to Built-in. Started from error rate on every service.", + } +} + +/** Re-express both thresholds in the moved form's unit, through the stored value. */ +function carryThreshold(from: RuleFormState, to: RuleFormState): RuleFormState { + const convert = (value: string) => { + if (value.trim().length === 0 || !Number.isFinite(Number(value))) return value + return domainThresholdToForm(to, formThresholdToDomain(from, value)) + } + return { ...to, threshold: convert(from.threshold), thresholdUpper: convert(from.thresholdUpper) } +} diff --git a/apps/web/src/lib/alerts/tag-grouping.ts b/apps/web/src/lib/alerts/tag-grouping.ts index dbabfa7d34..e2e8e9e058 100644 --- a/apps/web/src/lib/alerts/tag-grouping.ts +++ b/apps/web/src/lib/alerts/tag-grouping.ts @@ -6,7 +6,7 @@ */ /** Stable key for the bucket holding items with no tags. */ -const UNTAGGED_KEY = "__untagged__" +export const UNTAGGED_KEY = "__untagged__" const UNTAGGED_LABEL = "Untagged" export interface TagFacet { diff --git a/apps/web/src/lib/alerts/widget-prefill.test.ts b/apps/web/src/lib/alerts/widget-prefill.test.ts index c67e041818..6387278d54 100644 --- a/apps/web/src/lib/alerts/widget-prefill.test.ts +++ b/apps/web/src/lib/alerts/widget-prefill.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { defaultRuleForm } from "@/lib/alerts/form-utils" +import { buildRuleCreateParamsV2, defaultRuleForm } from "@/lib/alerts/form-utils" import { createWidgetAlertPrefill, resolveWidgetAlertPrefill } from "./widget-prefill" function builderQuery(overrides: Record = {}) { @@ -147,9 +147,9 @@ describe("createWidgetAlertPrefill", () => { defaultRuleForm(), ) - // error_rate evaluates as a 0–1 ratio; the blank-form default of "5" - // (percent entry for the built-in signal) would mean 500%. - expect(result.form.threshold).toBe("0.05") + // Entered as a percent like the built-in signal; saved as the 0.05 ratio. + expect(result.form.threshold).toBe("5") + expect(buildRuleCreateParamsV2(result.form).threshold).toBe(0.05) }) it("keeps the default threshold for non-ratio aggregations", () => { diff --git a/apps/web/src/lib/alerts/widget-prefill.ts b/apps/web/src/lib/alerts/widget-prefill.ts index 04ddd87d57..d4a046de9c 100644 --- a/apps/web/src/lib/alerts/widget-prefill.ts +++ b/apps/web/src/lib/alerts/widget-prefill.ts @@ -84,10 +84,9 @@ function queryToForm( name: widgetAlertName(widget), signalType: "builder_query", queryBuilderDraft, - // Builder thresholds compare against the query's raw output. error_rate - // is a 0–1 ratio, so the blank-form default of "5" (tuned for the - // percent-entry error_rate signal) would mean a 500% error rate. - threshold: query.aggregation === "error_rate" ? "0.05" : base.threshold, + // error_rate is entered as a percent in every mode (see formThresholdToDomain), + // so the blank form's "5" carries over as 5%. + threshold: base.threshold, groupBy: [], } } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a57f389c5b..a2acd187dc 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -52,6 +52,7 @@ import { Route as LabIndexRouteImport } from './routes/lab/index' import { Route as LabAgentSessionRouteImport } from './routes/lab/agent-session' import { Route as LabAgentSessionsRouteImport } from './routes/lab/agent-sessions' import { Route as LabAgentToolsRouteImport } from './routes/lab/agent-tools' +import { Route as LabAlertsRouteImport } from './routes/lab/alerts' import { Route as LabChartsRouteImport } from './routes/lab/charts' import { Route as LabChatRouteImport } from './routes/lab/chat' import { Route as LabCloudflareRouteImport } from './routes/lab/cloudflare' @@ -335,6 +336,11 @@ const LabAgentToolsRoute = LabAgentToolsRouteImport.update({ path: '/agent-tools', getParentRoute: () => LabRouteRoute, } as any) +const LabAlertsRoute = LabAlertsRouteImport.update({ + id: '/alerts', + path: '/alerts', + getParentRoute: () => LabRouteRoute, +} as any) const LabChartsRoute = LabChartsRouteImport.update({ id: '/charts', path: '/charts', @@ -719,6 +725,7 @@ export interface FileRoutesByFullPath { '/lab/agent-session': typeof LabAgentSessionRoute '/lab/agent-sessions': typeof LabAgentSessionsRoute '/lab/agent-tools': typeof LabAgentToolsRoute + '/lab/alerts': typeof LabAlertsRoute '/lab/charts': typeof LabChartsRoute '/lab/chat': typeof LabChatRoute '/lab/cloudflare': typeof LabCloudflareRoute @@ -830,6 +837,7 @@ export interface FileRoutesByTo { '/lab/agent-session': typeof LabAgentSessionRoute '/lab/agent-sessions': typeof LabAgentSessionsRoute '/lab/agent-tools': typeof LabAgentToolsRoute + '/lab/alerts': typeof LabAlertsRoute '/lab/charts': typeof LabChartsRoute '/lab/chat': typeof LabChatRoute '/lab/cloudflare': typeof LabCloudflareRoute @@ -943,6 +951,7 @@ export interface FileRoutesById { '/lab/agent-session': typeof LabAgentSessionRoute '/lab/agent-sessions': typeof LabAgentSessionsRoute '/lab/agent-tools': typeof LabAgentToolsRoute + '/lab/alerts': typeof LabAlertsRoute '/lab/charts': typeof LabChartsRoute '/lab/chat': typeof LabChatRoute '/lab/cloudflare': typeof LabCloudflareRoute @@ -1057,6 +1066,7 @@ export interface FileRouteTypes { | '/lab/agent-session' | '/lab/agent-sessions' | '/lab/agent-tools' + | '/lab/alerts' | '/lab/charts' | '/lab/chat' | '/lab/cloudflare' @@ -1168,6 +1178,7 @@ export interface FileRouteTypes { | '/lab/agent-session' | '/lab/agent-sessions' | '/lab/agent-tools' + | '/lab/alerts' | '/lab/charts' | '/lab/chat' | '/lab/cloudflare' @@ -1280,6 +1291,7 @@ export interface FileRouteTypes { | '/lab/agent-session' | '/lab/agent-sessions' | '/lab/agent-tools' + | '/lab/alerts' | '/lab/charts' | '/lab/chat' | '/lab/cloudflare' @@ -1744,6 +1756,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LabAgentToolsRouteImport parentRoute: typeof LabRouteRoute } + '/lab/alerts': { + id: '/lab/alerts' + path: '/alerts' + fullPath: '/lab/alerts' + preLoaderRoute: typeof LabAlertsRouteImport + parentRoute: typeof LabRouteRoute + } '/lab/charts': { id: '/lab/charts' path: '/charts' @@ -2220,6 +2239,7 @@ interface LabRouteRouteChildren { LabAgentSessionRoute: typeof LabAgentSessionRoute LabAgentSessionsRoute: typeof LabAgentSessionsRoute LabAgentToolsRoute: typeof LabAgentToolsRoute + LabAlertsRoute: typeof LabAlertsRoute LabChartsRoute: typeof LabChartsRoute LabChatRoute: typeof LabChatRoute LabCloudflareRoute: typeof LabCloudflareRoute @@ -2254,6 +2274,7 @@ const LabRouteRouteChildren: LabRouteRouteChildren = { LabAgentSessionRoute: LabAgentSessionRoute, LabAgentSessionsRoute: LabAgentSessionsRoute, LabAgentToolsRoute: LabAgentToolsRoute, + LabAlertsRoute: LabAlertsRoute, LabChartsRoute: LabChartsRoute, LabChatRoute: LabChatRoute, LabCloudflareRoute: LabCloudflareRoute, diff --git a/apps/web/src/routes/alerts/incidents/$incidentId.tsx b/apps/web/src/routes/alerts/incidents/$incidentId.tsx index 9f988f93d6..5f0fd5770f 100644 --- a/apps/web/src/routes/alerts/incidents/$incidentId.tsx +++ b/apps/web/src/routes/alerts/incidents/$incidentId.tsx @@ -1,25 +1,41 @@ -import { useMemo, useState } from "react" +import { useMemo } from "react" import { createFileRoute, Link, useNavigate } from "@tanstack/react-router" -import { Result, useAtomSet } from "@/lib/effect-atom" -import { type Cause, Exit, Schema } from "effect" - +import { Exit, Schema } from "effect" +import { formatWarehouseDateTime } from "@maple/query-engine" import { - decodeAlertContextFromSearchParam, - toAlertContext, - type AlertContext, -} from "@/components/chat/alert-context" -import { DashboardPage } from "@/components/layout/dashboard-page" + AlertRulePreviewResponse, + type AlertDestinationType, + type AlertIncidentDocument, + type AlertRuleDocument, +} from "@maple/domain/http" +import { Skeleton } from "@maple/ui/components/ui/skeleton" + +import { decodeAlertContextFromSearchParam } from "@/components/chat/alert-context" +import { PROVIDERS } from "@/components/alerts/destination-provider" +import { IncidentBody, IncidentHeader } from "@/components/alerts/incident-detail/incident-detail-view" import { DetailHeaderSkeleton } from "@/components/common/detail-header" -import { ErrorState } from "@/components/common/error-state" import { ResourceNotFound } from "@/components/common/resource-not-found" -import { useMountEffect } from "@/hooks/use-mount-effect" -import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" +import { DashboardPage } from "@/components/layout/dashboard-page" +import { useAlertRulePreview } from "@/hooks/use-alert-rule-preview" +import { useAlertRuleStates } from "@/hooks/use-alert-rule-states" import { useAlertIncidentsList, useAlertRulesList } from "@/hooks/use-alerts-list" -import { Skeleton } from "@maple/ui/components/ui/skeleton" -import type { AlertIncidentDocument, ErrorIssueId } from "@maple/domain/http" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { + formatSignalValue, + ruleToFormState, + signalLabels, + v2DeliveryToDocument, +} from "@/lib/alerts/form-utils" +import { + buildIncidentTimeline, + incidentGroup, +} from "@/lib/alerts/incident-timeline" +import { Result, useAtomSet, useAtomValue } from "@/lib/effect-atom" +import { toastExit } from "@/lib/error-toast" +import { MapleApiV2AtomClient, retainedQueryV2 } from "@/lib/services/common/v2-atom-client" const SearchSchema = Schema.Struct({ - /** Base64url alert context carried by the "Ask Maple AI" notification link. */ + /** Base64url alert context carried by notification links; only used to point back at the rule. */ alert: Schema.optional(Schema.String), }) @@ -28,17 +44,12 @@ export const Route = createFileRoute("/alerts/incidents/$incidentId")({ validateSearch: Schema.toStandardSchemaV1(SearchSchema), }) +const MINUTE_MS = 60_000 +const destinationTypeLabel = (type: AlertDestinationType) => PROVIDERS[type].label + function AlertIncidentPage() { const { incidentId } = Route.useParams() const { alert: alertParam } = Route.useSearch() - - // The notification link carries the alert context inline, so the page can - // render instantly without waiting on a fetch. - const paramContext = useMemo( - () => (alertParam ? decodeAlertContextFromSearchParam(alertParam) : undefined), - [alertParam], - ) - const { result: incidentsResult } = useAlertIncidentsList() const { result: rulesResult } = useAlertRulesList() @@ -48,135 +59,197 @@ function AlertIncidentPage() { const rules = Result.builder(rulesResult) .onSuccess((r) => r.rules) .orElse(() => []) - const incident = incidents.find((i) => i.id === incidentId) ?? null - const rule = incident - ? (rules.find((r) => r.id === incident.ruleId) ?? null) - : paramContext - ? (rules.find((r) => r.id === paramContext.ruleId) ?? null) - : null - const loading = Result.isInitial(incidentsResult) || Result.isInitial(rulesResult) - - // Prefer the authoritative fetched rows; fall back to the link's inline context - // (e.g. a stale link to a since-pruned incident still opens the report). - const alertContext: AlertContext | null = - incident && rule ? toAlertContext(rule, incident) : (paramContext ?? null) - const issueId: ErrorIssueId | undefined = incident?.errorIssueId ?? undefined - - if (loading && !alertContext) { + if (incident == null && Result.isInitial(incidentsResult)) { return ( } - width="narrow" - gap="md" + breadcrumbs={[{ label: "Alerts", href: "/alerts" }, { label: "Loading" }]} + header={} > - - + + ) } - if (!alertContext) { + if (incident == null) { + const ruleId = alertParam ? decodeAlertContextFromSearchParam(alertParam)?.ruleId : undefined return ( } - backLabel="Back to alerts" + description="It may have been pruned, or the link is stale." + backLink={ + ruleId ? : + } + backLabel={ruleId ? "Open the rule" : "Back to alerts"} /> ) } return ( - r.id === incident.ruleId) ?? null} + ruleIncidents={incidents.filter((i) => i.ruleId === incident.ruleId)} /> ) } -function AlertInvestigationRedirect({ - alertContext, - incidentId, - issueId, +/** The minute this incident row was last seen; the argument only keys the memo. */ +const clockAtSync = (_incident: AlertIncidentDocument) => Math.floor(Date.now() / MINUTE_MS) * MINUTE_MS + +function IncidentDetail({ incident, + rule, + ruleIncidents, }: { - alertContext: AlertContext - incidentId: string - issueId?: ErrorIssueId - /** - * The incident row, purely for its window. `AlertContext` is the *chat* - * preamble shape and carries no timestamps, so without this the snapshot's - * interval was hardcoded null — and the agent's first instruction is to scope - * every query to the incident interval. - */ - incident: AlertIncidentDocument | null + incident: AlertIncidentDocument + rule: AlertRuleDocument | null + ruleIncidents: ReadonlyArray }) { const navigate = useNavigate() - const create = useAtomSet(MapleApiV2AtomClient.mutation("investigations", "create"), { + const { effectiveTimezone: timeZone } = useTimezonePreference() + + // Wall clock to the minute, refreshed only when the incident row changes, so the + // preview query key holds steady between syncs. + const now = useMemo(() => clockAtSync(incident), [incident]) + + const range = useMemo(() => { + const start = Date.parse(incident.firstTriggeredAt) + const pad = Math.max(30, (rule?.windowMinutes ?? 5) * 4) * MINUTE_MS + const end = incident.resolvedAt != null ? Math.min(Date.parse(incident.resolvedAt) + pad, now) : now + return { start: start - pad, end: Math.max(end, start + MINUTE_MS) } + }, [incident.firstTriggeredAt, incident.resolvedAt, rule?.windowMinutes, now]) + + const formState = useMemo(() => (rule ? ruleToFormState(rule) : null), [rule]) + const { preview, previewLoading, previewError } = useAlertRulePreview(formState, { + startTime: formatWarehouseDateTime(range.start), + endTime: formatWarehouseDateTime(range.end), + }) + // A grouped rule previews every group; the incident is about one of them. + const groupPreview = useMemo(() => { + if (preview == null || incident.groupKey == null) return preview + const series = preview.series.filter((s) => s.groupKey === incident.groupKey) + if (series.length === 0 || series.length === preview.series.length) return preview + return new AlertRulePreviewResponse({ ...preview, series, wouldFire: [] }) + }, [preview, incident.groupKey]) + + const deliveriesResult = useAtomValue( + retainedQueryV2("alertDeliveries", "list", { + query: { incident_id: incident.id, limit: 100 }, + reactivityKeys: ["alertDeliveryEvents"], + }), + ) + const investigationsResult = useAtomValue( + retainedQueryV2("investigations", "list", { + query: { incident_kind: "alert", incident_id: incident.id, limit: 1 }, + reactivityKeys: ["investigations"], + }), + ) + const investigation = Result.builder(investigationsResult) + .onSuccess((page) => page.data[0] ?? null) + .orElse(() => null) + const states = useAlertRuleStates(incident.ruleId) + + const entries = useMemo( + () => + buildIncidentTimeline({ + incident, + deliveries: Result.builder(deliveriesResult) + .onSuccess((page) => page.data.map(v2DeliveryToDocument)) + .orElse(() => []), + investigation, + renotifyIntervalMinutes: + rule != null && rule.destinationIds.length > 0 ? rule.renotifyIntervalMinutes : null, + destinationTypeLabel, + }), + [incident, deliveriesResult, investigation, rule], + ) + + const createInvestigation = useAtomSet(MapleApiV2AtomClient.mutation("investigations", "create"), { mode: "promiseExit", }) - const [failure, setFailure] = useState | null>(null) - const openInvestigation = () => { - setFailure(null) - void create({ + const startInvestigation = async () => { + const result = await createInvestigation({ payload: { - subject: { - type: "incident", - incident_kind: "alert", - incident_id: incidentId, - ...(issueId ? { issue_id: issueId } : undefined), - } as never, + subject: + incident.errorIssueId != null + ? { + type: "incident", + incident_kind: "alert", + incident_id: incident.id, + issue_id: incident.errorIssueId, + } + : { type: "incident", incident_kind: "alert", incident_id: incident.id }, snapshot: { - title: alertContext.ruleName, - scope: alertContext.groupKey, - status: alertContext.eventType === "resolve" ? "resolved" : "open", - severity: alertContext.severity === "critical" ? "critical" : "medium", + title: incident.ruleName, + scope: incidentGroup(incident) ?? "all", + status: incident.status, + severity: incident.severity === "critical" ? "critical" : "medium", facts: [ - { label: "Signal", value: alertContext.signalType }, - { label: "Observed", value: String(alertContext.value ?? "no data") }, + { label: "Signal", value: signalLabels[incident.signalType] }, + { + label: "Observed", + value: formatSignalValue(incident.signalType, incident.lastObservedValue), + }, + { + label: "Threshold", + value: formatSignalValue(incident.signalType, incident.threshold), + }, ], - references: issueId ? [{ label: "Issue", url: `/errors/issues/${issueId}` }] : [], - incidentStartedAt: incident?.firstTriggeredAt ?? null, - incidentEndedAt: incident?.resolvedAt ?? incident?.lastTriggeredAt ?? null, - signalType: alertContext.signalType, - observedValue: alertContext.value, - thresholdValue: alertContext.threshold, - serviceName: alertContext.groupKey, + references: incident.errorIssueId + ? [{ label: "Issue", url: `/errors/issues/${incident.errorIssueId}` }] + : [], + incidentStartedAt: incident.firstTriggeredAt, + incidentEndedAt: incident.resolvedAt, }, }, reactivityKeys: ["investigations"], - }).then((result) => { - if (Exit.isSuccess(result)) { - void navigate({ - to: "/investigations/$id", - params: { id: result.value.id }, - replace: true, - }) - } else { - setFailure(result.cause) - } }) + if (Exit.isSuccess(result)) { + await navigate({ to: "/investigations/$id", params: { id: result.value.id } }) + return + } + toastExit(result, { error: "Failed to start investigation" }) } - useMountEffect(openInvestigation) + return ( } - width="narrow" - gap="md" + breadcrumbs={[ + { label: "Alerts", href: "/alerts" }, + { label: rule?.name ?? incident.ruleName, href: `/alerts/${incident.ruleId}` }, + { label: "Incident" }, + ]} + header={ + void startInvestigation()} + /> + } > - {failure ? ( - - ) : ( - - )} + ) } diff --git a/apps/web/src/routes/alerts/index.tsx b/apps/web/src/routes/alerts/index.tsx index 5723d549f4..9adb791e68 100644 --- a/apps/web/src/routes/alerts/index.tsx +++ b/apps/web/src/routes/alerts/index.tsx @@ -1,41 +1,61 @@ import { createFileRoute, Link } from "@tanstack/react-router" import { Schema } from "effect" -import { AlertsOverviewTab } from "@/components/alerts/overview/alerts-overview-tab" -import { AlertsSettingsTab, useDestinationManager } from "@/components/alerts/overview/settings-tab" +import { AlertsHub, AlertsTabCountsBoundary } from "@/components/alerts/overview/alerts-hub" +import { AlertsRoutingTab, useDestinationManager } from "@/components/alerts/overview/routing-tab" import { DestinationDialog } from "@/components/alerts/destination-dialog" import { OpenDestinationDialogProvider } from "@/components/alerts/destination-manager-context" import { DashboardPage } from "@/components/layout/dashboard-page" import { PlusIcon } from "@/components/icons" import { useAlertDestinationsList } from "@/hooks/use-alerts-list" +import type { RuleBucket } from "@/lib/alerts/overview-derive" import { retainedQuery } from "@/lib/services/common/atom-client" -import { BooleanFromStringParam, OptionalStringArrayParam } from "@/lib/search-params" import { Result, useAtomValue } from "@/lib/effect-atom" import { Button } from "@maple/ui/components/ui/button" +import { cn } from "@maple/ui/lib/utils" import { UnderlineTabStrip, underlineTabClass } from "@/components/common/underline-link-tabs" -type AlertsTab = "overview" | "settings" +type AlertsTab = "inbox" | "rules" | "routing" -// The URL value stays `settings` so existing links keep resolving. const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [ - { value: "overview", label: "Overview" }, - { value: "settings", label: "Destinations" }, + { value: "inbox", label: "Inbox" }, + { value: "rules", label: "Rules" }, + { value: "routing", label: "Routing" }, ] +/** Legacy `tab` values map onto the current tabs so old links keep resolving. */ +const resolveTab = (tab: string | undefined): AlertsTab => { + switch (tab) { + case "rules": + return "rules" + case "routing": + case "settings": + return "routing" + default: + return "inbox" + } +} + +/** `attention` is the legacy name of the blind bucket. */ +const resolveStatus = (status: string | undefined): RuleBucket | undefined => { + switch (status) { + case "firing": + case "blind": + case "healthy": + case "disabled": + return status + case "attention": + return "blind" + default: + return undefined + } +} + const AlertsSearch = Schema.Struct({ - /** - * Accepts any string so legacy deep links (`tab=monitor`, `tab=rules`) keep - * resolving — anything that isn't "settings" lands on the overview. - */ tab: Schema.optional(Schema.String), serviceName: Schema.optional(Schema.String), - createdBy: Schema.optional(Schema.String), - /** Health-summary filter over the rules list. */ - status: Schema.optional(Schema.Literals(["firing", "attention", "healthy", "disabled"])), - /** Tag filter, shared by the incidents and rules lists. */ - tags: OptionalStringArrayParam, - /** When set, the active list is grouped into per-tag sections. */ - groupByTag: Schema.optional(Schema.Union([Schema.Boolean, BooleanFromStringParam])), + /** Rules tab status filter. */ + status: Schema.optional(Schema.String), }) export const Route = createFileRoute("/alerts/")({ @@ -46,7 +66,8 @@ export const Route = createFileRoute("/alerts/")({ function AlertsPage() { const search = Route.useSearch() - const activeTab: AlertsTab = search.tab === "settings" ? "settings" : "overview" + const activeTab = resolveTab(search.tab) + const navigate = Route.useNavigate() // Session + destinations back the header action only; the tabs own the rest // of their data (the atoms are shared, so this costs no extra requests). @@ -55,31 +76,56 @@ function AlertsPage() { const isAdmin = Result.builder(sessionResult) .onSuccess((session) => session.roles.some((role) => role === "root" || role === "org:admin")) .orElse(() => false) - const hasDestinations = Result.builder(destinationsResult) - .onSuccess((response) => response.destinations.length > 0) - .orElse(() => false) + const destinations = Result.builder(destinationsResult) + .onSuccess((response) => response.destinations) + .orElse(() => []) + const hasDestinations = destinations.length > 0 const destinationManager = useDestinationManager() const tabBar = ( - - {ALERTS_TABS.map((tab) => ( - ) => ({ ...prev, tab: tab.value })} - aria-current={tab.value === activeTab ? "page" : undefined} - className={underlineTabClass(tab.value === activeTab)} - > - {tab.label} - - ))} - + + {(counts) => ( + + {ALERTS_TABS.map((tab) => { + const count = + tab.value === "inbox" + ? counts?.open + : tab.value === "rules" + ? counts?.rules + : undefined + return ( + ) => ({ ...prev, tab: tab.value })} + aria-current={tab.value === activeTab ? "page" : undefined} + className={underlineTabClass(tab.value === activeTab)} + > + {tab.label} + {count !== undefined && count > 0 && ( + + {count} + + )} + + ) + })} + + )} + ) const headerActions = - activeTab === "settings" ? ( - // Settings: the header owns the add action only once destinations exist. + activeTab === "routing" ? ( + // The header owns the add action only once destinations exist. // While empty, the empty-state CTA is the single add affordance (avoids a duplicate). isAdmin && hasDestinations ? ( )} - + {summary && ( +

    + {summary} +

    + )} + +
    {changeCount !== null && ( diff --git a/apps/web/src/components/alerts/rule-condition-bar.tsx b/apps/web/src/components/alerts/rule-condition-bar.tsx index e6deacd565..2021d9bd49 100644 --- a/apps/web/src/components/alerts/rule-condition-bar.tsx +++ b/apps/web/src/components/alerts/rule-condition-bar.tsx @@ -1,6 +1,8 @@ -import type { Dispatch, SetStateAction } from "react" +import type { Dispatch, ReactNode, SetStateAction } from "react" import type { AlertComparator, AlertSeverity } from "@maple/domain/http" +import { Card } from "@maple/ui/components/ui/card" +import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" import { InputGroup, InputGroupAddon, @@ -15,11 +17,11 @@ import { cn } from "@maple/ui/lib/utils" import { comparatorLabels, isRangeComparator, + signalLabels, thresholdUnitLabel, type RuleFormState, } from "@/lib/alerts/form-utils" -/* Strip the native number spinners: the bar reads as a sentence, not a stepper. */ const NUMERIC_CONTROL_CLASS = "font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" @@ -59,179 +61,204 @@ const SEVERITY_OPTIONS: ReadonlyArray<{ }, ] -interface RuleConditionBarProps { +/** The rule in one plain sentence, shown in the action bar so the whole rule reads at a glance. */ +export function describeRuleCondition(form: RuleFormState): string { + const unit = thresholdUnitLabel(form) ?? "" + const value = (raw: string) => `${raw.trim() || "?"}${unit === "%" ? "%" : unit ? ` ${unit}` : ""}` + const threshold = isRangeComparator(form.comparator) + ? `${value(form.threshold)} and ${value(form.thresholdUpper)}` + : value(form.threshold) + const checks = form.consecutiveBreachesRequired.trim() || "?" + const windowText = `${form.windowMinutes.trim() || "?"} min` + const when = checks === "1" ? `over ${windowText}` : `in ${checks} checks in a row (${windowText} each)` + return `${form.severity === "critical" ? "Critical" : "Warning"} when ${signalLabels[form.signalType].toLowerCase()} ${COMPARATOR_WORDS[form.comparator]} ${threshold} ${when}` +} + +interface RuleConditionFieldsProps { form: RuleFormState onChange: Dispatch> - /** The saved rule while editing: a changed field shows its old value struck through. */ + /** The saved rule while editing: a changed field says what it was. */ saved: RuleFormState | null } -/** - * The rule's condition as one sentence under the preview chart: "Alert when - * the value is above 5% over 5 min in 2 checks in a row". Every field here - * redraws the threshold line or the would-fire bands above it. - */ -export function RuleConditionBar({ form, onChange, saved }: RuleConditionBarProps) { +/** Threshold, severity and evaluation as labeled fields; every one redraws the chart beside it. */ +export function RuleConditionFields({ form, onChange, saved }: RuleConditionFieldsProps) { const rangeMode = isRangeComparator(form.comparator) const unit = thresholdUnitLabel(form) const was = (pick: (f: RuleFormState) => string) => saved !== null && pick(saved).trim() !== pick(form).trim() ? pick(saved) : null + const comparatorWas = was((f) => f.comparator) return ( -
    - Alert when the value - value && onChange((c) => ({ ...c, comparator: value }))} + > + + + + + {COMPARATOR_ITEMS.map((item) => ( + + {item.label} + + ))} + + + {comparatorWas !== null && ( + {COMPARATOR_WORDS[saved?.comparator ?? form.comparator]} )} - > - - - - {COMPARATOR_ITEMS.map((item) => ( - - {item.label} - - ))} - - - f.threshold)} - onChange={(value) => onChange((c) => ({ ...c, threshold: value }))} - /> - {rangeMode && ( - <> - and - + +
    + f.thresholdUpper)} - onChange={(value) => onChange((c) => ({ ...c, thresholdUpper: value }))} + previous={was((f) => f.threshold)} + onChange={(value) => onChange((c) => ({ ...c, threshold: value }))} /> - - )} - over - f.windowMinutes)} - onChange={(value) => onChange((c) => ({ ...c, windowMinutes: value }))} - /> - in - f.consecutiveBreachesRequired)} - onChange={(value) => onChange((c) => ({ ...c, consecutiveBreachesRequired: value }))} - narrow - /> - with at least - f.minimumSampleCount)} - onChange={(value) => onChange((c) => ({ ...c, minimumSampleCount: value }))} - /> -
    - - as - - onChange((c) => ({ ...c, severity: value }))} - aria-labelledby="rule-severity-label" - className="inline-flex items-center gap-1 rounded-md bg-muted/30 p-0.5" - > - {SEVERITY_OPTIONS.map((option) => { - const selected = form.severity === option.value - return ( - - - {option.label} - - ) - })} - + {rangeMode && ( + f.thresholdUpper)} + onChange={(value) => onChange((c) => ({ ...c, thresholdUpper: value }))} + /> + )} +
    + + + Severity + onChange((c) => ({ ...c, severity: value }))} + aria-labelledby="rule-severity-label" + className="grid grid-cols-2 gap-1 rounded-md bg-muted/30 p-0.5" + > + {SEVERITY_OPTIONS.map((option) => { + const selected = form.severity === option.value + return ( + + + {option.label} + + ) + })} + +
    -
    + +
    +

    How it is checked

    + f.windowMinutes)} + onChange={(value) => onChange((c) => ({ ...c, windowMinutes: value }))} + /> + f.consecutiveBreachesRequired)} + onChange={(value) => onChange((c) => ({ ...c, consecutiveBreachesRequired: value }))} + /> + f.minimumSampleCount)} + onChange={(value) => onChange((c) => ({ ...c, minimumSampleCount: value }))} + /> +
    + + ) +} + +function WasNote({ children }: { children: ReactNode }) { + return ( +

    + Was {children} +

    ) } -function ConditionInput({ +function NumberField({ id, label, + description, value, unit, previous, onChange, - narrow = false, }: { id: string label: string + description?: string value: string unit: string | null - /** The saved value when it differs, struck through inside the field. */ + /** The saved value when it differs. */ previous: string | null onChange: (value: string) => void - /** Small counts (checks) need less room than thresholds. */ - narrow?: boolean }) { return ( - - {previous !== null && ( - - - {previous.length > 0 ? previous : "none"} - - - )} - onChange(event.target.value)} - className={narrow ? "w-10" : "w-16"} - controlClassName={NUMERIC_CONTROL_CLASS} - /> - {unit !== null && ( - - {unit} - + + {label} + + onChange(event.target.value)} + controlClassName={NUMERIC_CONTROL_CLASS} + /> + {unit !== null && ( + + {unit} + + )} + + {previous !== null ? ( + + {previous.length > 0 ? previous : "none"} + {previous.length > 0 && unit ? (unit === "%" ? "%" : ` ${unit}`) : ""} + + ) : ( + description && {description} )} - + ) } diff --git a/apps/web/src/components/alerts/rule-noise-section.tsx b/apps/web/src/components/alerts/rule-noise-section.tsx index fe28871faf..38660d429b 100644 --- a/apps/web/src/components/alerts/rule-noise-section.tsx +++ b/apps/web/src/components/alerts/rule-noise-section.tsx @@ -1,6 +1,7 @@ import type { Dispatch, SetStateAction } from "react" import { Card } from "@maple/ui/components/ui/card" +import { Field, FieldLabel } from "@maple/ui/components/ui/field" import { InputGroup, InputGroupAddon, @@ -23,7 +24,7 @@ export function RuleNoiseSection({ form, onChange }: RuleNoiseSectionProps) { const grouped = ruleFormIsGrouped(form) return ( - +
    onChange((c) => ({ ...c, renotifyIntervalMinutes: value }))} @@ -73,11 +74,9 @@ function NoiseRow({ onChange: (value: string) => void }) { return ( -
    - - + + {label} + onChange(event.target.value)} - className="w-14" controlClassName="font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" /> {unit} -
    + ) } diff --git a/apps/web/src/components/alerts/rule-query-section.tsx b/apps/web/src/components/alerts/rule-query-section.tsx index 69b37b833b..f8bab2481b 100644 --- a/apps/web/src/components/alerts/rule-query-section.tsx +++ b/apps/web/src/components/alerts/rule-query-section.tsx @@ -11,7 +11,6 @@ import type { AlertSignalType } from "@maple/domain/http" import { rawAlertSampleCountWarning } from "@maple/domain/raw-sql" import { Button } from "@maple/ui/components/ui/button" import { Card } from "@maple/ui/components/ui/card" -import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { InlineCode } from "@maple/ui/components/ui/inline-code" import { InputGroup, @@ -88,11 +87,8 @@ export function RuleQuerySection(props: RuleQuerySectionProps) { } return ( -
    +
    - - Query - options={KIND_OPTIONS} value={kind} @@ -107,7 +103,7 @@ export function RuleQuerySection(props: RuleQuerySectionProps) { )} {kind === "builder_query" && } {kind === "raw_query" && } -
    +
    ) } diff --git a/apps/web/src/components/alerts/rule-step.tsx b/apps/web/src/components/alerts/rule-step.tsx new file mode 100644 index 0000000000..512722c2f3 --- /dev/null +++ b/apps/web/src/components/alerts/rule-step.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react" + +import { cn } from "@maple/ui/lib/utils" + +import { CheckIcon } from "@/components/icons" + +export type RuleStepStatus = "done" | "missing" | "optional" + +/** One numbered step of the rule editor: what it asks for, and whether it is filled in. */ +export function RuleStep({ + number, + title, + hint, + status, + actions, + children, +}: { + number: number + title: string + hint?: string + status: RuleStepStatus + actions?: ReactNode + children: ReactNode +}) { + const headingId = `rule-step-${number}` + return ( +
    +
    + + {status === "done" ? : number} + +

    + {title} +

    + {status === "missing" && Required} + {hint &&

    {hint}

    } + {actions} +
    + {children} +
    + ) +} From c0132956f08a716eff13597fbd1a8dcd9288771f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 15 Oct 2026 23:52:25 +0200 Subject: [PATCH 3/8] feat(alerts): default new rules to the query builder, icons on built-in signals New rules and templates (except Apdex, which has no builder aggregation) open in the query builder with the default signal carried over as a query. Built-in signal cards get an icon each. Step headings drop the check circles for a plain number, and the rule summary names the aggregation instead of saying "query builder". --- .../alerts/alert-create-form-surface.tsx | 9 ++++- .../alerts/alert-create-page-content.test.ts | 4 ++- .../alerts/alert-create-page-content.tsx | 4 ++- .../components/alerts/rule-condition-bar.tsx | 12 ++++++- .../components/alerts/rule-query-section.tsx | 34 ++++++++++++++----- apps/web/src/components/alerts/rule-step.tsx | 20 ++--------- 6 files changed, 53 insertions(+), 30 deletions(-) diff --git a/apps/web/src/components/alerts/alert-create-form-surface.tsx b/apps/web/src/components/alerts/alert-create-form-surface.tsx index d557740051..291358965b 100644 --- a/apps/web/src/components/alerts/alert-create-form-surface.tsx +++ b/apps/web/src/components/alerts/alert-create-form-surface.tsx @@ -42,6 +42,7 @@ import { import { diffRuleForms, incidentsClearedByDraft } from "@/lib/alerts/preview-insights" import { toastExit } from "@/lib/error-toast" import { applyTemplate } from "@/lib/alerts/templates" +import { switchSignalKind } from "@/lib/alerts/rule-modes" import type { WidgetAlertPrefillNotice } from "@/lib/alerts/widget-prefill" import { Result, useAtomSet } from "@/lib/effect-atom" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" @@ -340,7 +341,13 @@ export function AlertCreateFormSurface({ open={templatesOpen} onOpenChange={setTemplatesOpen} onPick={(template) => { - setRuleForm((current) => applyTemplate(template, current)) + setRuleForm((current) => { + const applied = applyTemplate(template, current) + // Templates fill the query builder; Apdex has no builder aggregation, so it stays Built-in. + return applied.signalType === "apdex" + ? applied + : switchSignalKind(applied, "builder_query").form + }) setTemplatesOpen(false) }} onStartBlank={() => setTemplatesOpen(false)} diff --git a/apps/web/src/components/alerts/alert-create-page-content.test.ts b/apps/web/src/components/alerts/alert-create-page-content.test.ts index 4d06857a69..5b4d3cc7e3 100644 --- a/apps/web/src/components/alerts/alert-create-page-content.test.ts +++ b/apps/web/src/components/alerts/alert-create-page-content.test.ts @@ -215,7 +215,9 @@ describe("deriveRuleInitialization — template deep link", () => { }), ) - expect(draft.form.signalType).toBe("error_rate") + // A blank rule opens in the query builder with the error-rate aggregation. + expect(draft.form.signalType).toBe("builder_query") + expect(draft.form.queryBuilderDraft.aggregation).toBe("error_rate") expect(draft.form.name).toBe("") // No serviceName + unknown template → the overlay still leads the flow. expect(draft.showTemplatesInitially).toBe(true) diff --git a/apps/web/src/components/alerts/alert-create-page-content.tsx b/apps/web/src/components/alerts/alert-create-page-content.tsx index 082cd59151..aabc748b7b 100644 --- a/apps/web/src/components/alerts/alert-create-page-content.tsx +++ b/apps/web/src/components/alerts/alert-create-page-content.tsx @@ -14,6 +14,7 @@ import { RULE_FORM_MAX_WIDTH } from "@/components/alerts/rule-form-layout" import { DashboardPage } from "@/components/layout/dashboard-page" import { useAutocompleteValuesContext } from "@/hooks/use-autocomplete-values" import { defaultRuleForm, ruleToFormState, type RuleFormState } from "@/lib/alerts/form-utils" +import { switchSignalKind } from "@/lib/alerts/rule-modes" import { ALERT_TEMPLATES, applyTemplate } from "@/lib/alerts/templates" import { decodeAlertChartFromSearchParam, type AlertChartContext } from "@/lib/alerts/widget-chart-param" import { @@ -210,7 +211,8 @@ export function deriveRuleInitialization({ unknown > }): RuleInitialization { - const base = defaultRuleForm(search.serviceName) + // New rules open in the query builder; the default error-rate signal carries over as a query. + const base = switchSignalKind(defaultRuleForm(search.serviceName), "builder_query").form if (search.ruleId) { // Terminal: the stream is gone, so "which rule is this?" has no answer diff --git a/apps/web/src/components/alerts/rule-condition-bar.tsx b/apps/web/src/components/alerts/rule-condition-bar.tsx index 2021d9bd49..ed450c0c2c 100644 --- a/apps/web/src/components/alerts/rule-condition-bar.tsx +++ b/apps/web/src/components/alerts/rule-condition-bar.tsx @@ -61,6 +61,16 @@ const SEVERITY_OPTIONS: ReadonlyArray<{ }, ] +/** What the threshold is compared against, in words: the aggregation for a query, the signal otherwise. */ +function conditionSubject(form: RuleFormState): string { + if (form.signalType === "raw_query") return "the SQL value" + if (form.signalType === "builder_query") { + const aggregation = form.queryBuilderDraft.aggregation.replaceAll("_", " ").trim() + return aggregation.length > 0 ? aggregation : "the query value" + } + return signalLabels[form.signalType].toLowerCase() +} + /** The rule in one plain sentence, shown in the action bar so the whole rule reads at a glance. */ export function describeRuleCondition(form: RuleFormState): string { const unit = thresholdUnitLabel(form) ?? "" @@ -71,7 +81,7 @@ export function describeRuleCondition(form: RuleFormState): string { const checks = form.consecutiveBreachesRequired.trim() || "?" const windowText = `${form.windowMinutes.trim() || "?"} min` const when = checks === "1" ? `over ${windowText}` : `in ${checks} checks in a row (${windowText} each)` - return `${form.severity === "critical" ? "Critical" : "Warning"} when ${signalLabels[form.signalType].toLowerCase()} ${COMPARATOR_WORDS[form.comparator]} ${threshold} ${when}` + return `${form.severity === "critical" ? "Critical" : "Warning"} when ${conditionSubject(form)} ${COMPARATOR_WORDS[form.comparator]} ${threshold} ${when}` } interface RuleConditionFieldsProps { diff --git a/apps/web/src/components/alerts/rule-query-section.tsx b/apps/web/src/components/alerts/rule-query-section.tsx index f8bab2481b..87dc59f082 100644 --- a/apps/web/src/components/alerts/rule-query-section.tsx +++ b/apps/web/src/components/alerts/rule-query-section.tsx @@ -23,6 +23,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { cn } from "@maple/ui/lib/utils" import { EnvironmentCombobox, ServiceCombobox } from "@/components/alerts/service-combobox" +import { + ArrowTrendUpIcon, + CircleCheckIcon, + CircleXmarkIcon, + ClockIcon, + PulseIcon, + type IconComponent, +} from "@/components/icons" import { SegmentedSelect } from "@/components/common/segmented-select" import { QueryPanel } from "@/components/dashboard-builder/config/query-panel" import { RawSqlEditorPanel } from "@/components/dashboard-builder/config/raw-sql-editor-panel" @@ -60,12 +68,17 @@ const KIND_HINT = { raw_query: "ClickHouse SQL, replayed per evaluation window.", } satisfies Record -const BUILTIN_SIGNALS: ReadonlyArray<{ value: AlertSignalType; label: string; hint: string }> = [ - { value: "error_rate", label: "Error rate", hint: "failed / all" }, - { value: "p95_latency", label: "P95", hint: "latency" }, - { value: "p99_latency", label: "P99", hint: "tail latency" }, - { value: "apdex", label: "Apdex", hint: "satisfaction" }, - { value: "throughput", label: "Throughput", hint: "requests / window" }, +const BUILTIN_SIGNALS: ReadonlyArray<{ + value: AlertSignalType + label: string + hint: string + icon: IconComponent +}> = [ + { value: "error_rate", label: "Error rate", hint: "failed / all", icon: CircleXmarkIcon }, + { value: "p95_latency", label: "P95", hint: "latency", icon: ClockIcon }, + { value: "p99_latency", label: "P99", hint: "tail latency", icon: ArrowTrendUpIcon }, + { value: "apdex", label: "Apdex", hint: "satisfaction", icon: CircleCheckIcon }, + { value: "throughput", label: "Throughput", hint: "requests / window", icon: PulseIcon }, ] /** @@ -133,14 +146,17 @@ function BuiltinQueryForm({ key={signal.value} value={signal.value} className={cn( - "flex cursor-pointer flex-col items-start gap-0.5 border-b-2 border-transparent px-3 py-2.5 text-left", + "group flex cursor-pointer flex-col items-start gap-0.5 border-b-2 border-transparent px-3 py-2.5 text-left", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", "data-[checked]:border-primary data-[checked]:bg-muted/40", "[&:not([data-checked])]:hover:bg-muted/20", )} > - {signal.label} - {signal.hint} + + + {signal.label} + + {signal.hint} ))} diff --git a/apps/web/src/components/alerts/rule-step.tsx b/apps/web/src/components/alerts/rule-step.tsx index 512722c2f3..35a2b5123f 100644 --- a/apps/web/src/components/alerts/rule-step.tsx +++ b/apps/web/src/components/alerts/rule-step.tsx @@ -1,12 +1,8 @@ import type { ReactNode } from "react" -import { cn } from "@maple/ui/lib/utils" - -import { CheckIcon } from "@/components/icons" - export type RuleStepStatus = "done" | "missing" | "optional" -/** One numbered step of the rule editor: what it asks for, and whether it is filled in. */ +/** One numbered step of the rule editor; says "Required" until it is filled in. */ export function RuleStep({ number, title, @@ -25,19 +21,9 @@ export function RuleStep({ const headingId = `rule-step-${number}` return (
    -
    - - {status === "done" ? : number} - +

    + {number} {title}

    {status === "missing" && Required} From 5403e5ea828f7dda9663f1ad7e6a4e1f7d568cf8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 15 Oct 2026 23:57:41 +0200 Subject: [PATCH 4/8] feat(alerts): clearer hierarchy and spacing in the rule editor Step titles are now display headings with the hint on its own line and a divider between steps, so they no longer read at the same weight as field labels. When to alert puts the chart full width and the fields under it in three-column rows (condition, threshold, severity; how it is checked; after it fires), which removes the tall left column and the empty space beside the chart. The chart badge and suggested name use the aggregation instead of "Query builder", and the name placeholder loses its em dash. --- .../alerts/alert-create-form-surface.tsx | 73 ++++++++++--------- .../src/components/alerts/details-section.tsx | 2 +- .../components/alerts/rule-condition-bar.tsx | 15 ++-- .../alerts/rule-live-chart-hero.tsx | 4 +- .../components/alerts/rule-noise-section.tsx | 68 ++++++++--------- apps/web/src/components/alerts/rule-step.tsx | 30 ++++++-- 6 files changed, 107 insertions(+), 85 deletions(-) diff --git a/apps/web/src/components/alerts/alert-create-form-surface.tsx b/apps/web/src/components/alerts/alert-create-form-surface.tsx index 291358965b..9bd60117a1 100644 --- a/apps/web/src/components/alerts/alert-create-form-surface.tsx +++ b/apps/web/src/components/alerts/alert-create-form-surface.tsx @@ -15,7 +15,11 @@ import { useIsOrgAdmin } from "@/hooks/use-is-org-admin" import { useAsyncAction } from "@/hooks/use-mutation-action" import { RuleActionBar } from "@/components/alerts/rule-action-bar" import { RULE_FORM_MAX_WIDTH } from "@/components/alerts/rule-form-layout" -import { RuleConditionFields, describeRuleCondition } from "@/components/alerts/rule-condition-bar" +import { + RuleConditionFields, + conditionSubject, + describeRuleCondition, +} from "@/components/alerts/rule-condition-bar" import { RuleStep } from "@/components/alerts/rule-step" import { RuleLiveChartHero } from "@/components/alerts/rule-live-chart-hero" import { RuleNoiseSection } from "@/components/alerts/rule-noise-section" @@ -36,7 +40,6 @@ import { isRulePreviewReady, pickDefaultDestination, ruleToFormState, - signalLabels, type RuleFormState, } from "@/lib/alerts/form-utils" import { diffRuleForms, incidentsClearedByDraft } from "@/lib/alerts/preview-insights" @@ -233,10 +236,11 @@ export function AlertCreateFormSurface({ { label: editingRule ? "Edit rule" : "New rule" }, ]} > -
    +
    -
    -
    - - -
    -
    - void previewRule()} - testing={previewingRule} - previewResult={freshPreviewResult} - range={previewRange} - timeRange={previewTimeRange} - onTimeRangeChange={setPreviewTimeRange} - comparison={ - savedForm !== null && changes.length > 0 - ? { - savedPreview: evaluationChanged ? savedReplay.preview : preview, - savedLoading: evaluationChanged && savedReplay.previewLoading, - changes, - clearedIncidents, - } - : null - } - /> -
    +
    + void previewRule()} + testing={previewingRule} + previewResult={freshPreviewResult} + range={previewRange} + timeRange={previewTimeRange} + onTimeRangeChange={setPreviewTimeRange} + comparison={ + savedForm !== null && changes.length > 0 + ? { + savedPreview: evaluationChanged ? savedReplay.preview : preview, + savedLoading: evaluationChanged && savedReplay.previewLoading, + changes, + clearedIncidents, + } + : null + } + /> + +
    -
    +
    @@ -308,6 +309,7 @@ export function AlertCreateFormSurface({ @@ -423,7 +425,8 @@ function previewIdentityKey(form: RuleFormState): string { function makeSuggestedName(form: RuleFormState): string | null { if (form.name.trim().length > 0) return null - const base = signalLabels[form.signalType] + const subject = conditionSubject(form) + const base = subject.charAt(0).toUpperCase() + subject.slice(1) const queryGroupBy = form.signalType === "builder_query" && form.queryBuilderDraft.addOns?.groupBy ? (form.queryBuilderDraft.groupBy ?? []) diff --git a/apps/web/src/components/alerts/details-section.tsx b/apps/web/src/components/alerts/details-section.tsx index a40b6cfe93..42fb656101 100644 --- a/apps/web/src/components/alerts/details-section.tsx +++ b/apps/web/src/components/alerts/details-section.tsx @@ -50,7 +50,7 @@ export function DetailsSection({ form, onChange, suggestedName, tagSuggestions } id="rule-name" value={form.name} onChange={(e) => onChange((c) => ({ ...c, name: e.target.value }))} - placeholder="Error Rate — Payments" + placeholder="Error rate on payments" /> diff --git a/apps/web/src/components/alerts/rule-condition-bar.tsx b/apps/web/src/components/alerts/rule-condition-bar.tsx index ed450c0c2c..a52de2bea3 100644 --- a/apps/web/src/components/alerts/rule-condition-bar.tsx +++ b/apps/web/src/components/alerts/rule-condition-bar.tsx @@ -2,6 +2,7 @@ import type { Dispatch, ReactNode, SetStateAction } from "react" import type { AlertComparator, AlertSeverity } from "@maple/domain/http" import { Card } from "@maple/ui/components/ui/card" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" import { InputGroup, @@ -62,7 +63,7 @@ const SEVERITY_OPTIONS: ReadonlyArray<{ ] /** What the threshold is compared against, in words: the aggregation for a query, the signal otherwise. */ -function conditionSubject(form: RuleFormState): string { +export function conditionSubject(form: RuleFormState): string { if (form.signalType === "raw_query") return "the SQL value" if (form.signalType === "builder_query") { const aggregation = form.queryBuilderDraft.aggregation.replaceAll("_", " ").trim() @@ -91,7 +92,7 @@ interface RuleConditionFieldsProps { saved: RuleFormState | null } -/** Threshold, severity and evaluation as labeled fields; every one redraws the chart beside it. */ +/** Threshold, severity and evaluation as labeled fields in a grid under the chart; each redraws it. */ export function RuleConditionFields({ form, onChange, saved }: RuleConditionFieldsProps) { const rangeMode = isRangeComparator(form.comparator) const unit = thresholdUnitLabel(form) @@ -101,7 +102,7 @@ export function RuleConditionFields({ form, onChange, saved }: RuleConditionFiel return ( -
    +
    Alert when the value