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..9bd60117a1 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,21 @@ 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 { + 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" +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 +39,20 @@ import { isRangeComparator, isRulePreviewReady, pickDefaultDestination, - signalLabels, + ruleToFormState, 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 { 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" -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 +126,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,10 +223,10 @@ 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 + const thresholdMissing = !Number.isFinite(Number(ruleForm.threshold)) || ruleForm.threshold.trim() === "" return ( <> @@ -205,40 +236,63 @@ 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} - /> + + + + +
+ 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 + } + /> + +
-
- - {showScope && ( - - )} + +
+ + + -
+
navigate({ to: "/alerts" })} onSave={() => void handleSave()} onShowTemplates={editingRule ? undefined : () => setTemplatesOpen(true)} @@ -280,7 +343,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)} @@ -308,14 +377,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) @@ -356,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 ?? []) @@ -375,5 +445,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-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/alert-rule-chart.tsx b/apps/web/src/components/alerts/alert-rule-chart.tsx index 613fcd6ef6..b70aa2f67f 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,15 +778,17 @@ 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.

)} {preview?.truncatedToStart != null && (

{clampedToPreview ? "Axis starts at " : "Query series starts at "} - {formatTime(Date.parse(preview.truncatedToStart), "tooltip")} — the selected range needs - more evaluation windows than one preview replays. Widen the rule's window or shorten the - range for full coverage. + {formatTime(Date.parse(preview.truncatedToStart), "tooltip")}. The range needs more + windows than one preview replays: widen the rule's window or shorten the range.

)} 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/details-section.tsx b/apps/web/src/components/alerts/details-section.tsx index 445c6abfbd..42fb656101 100644 --- a/apps/web/src/components/alerts/details-section.tsx +++ b/apps/web/src/components/alerts/details-section.tsx @@ -6,7 +6,6 @@ import { Input } from "@maple/ui/components/ui/input" import { Field, FieldDescription, FieldLabel } from "@maple/ui/components/ui/field" import { Textarea } from "@maple/ui/components/ui/textarea" -import { SectionHeading } from "@/components/common/section-heading" import { TagInput } from "@/components/alerts/tag-input" import { PlusIcon } from "@/components/icons" import type { RuleFormState } from "@/lib/alerts/form-utils" @@ -31,8 +30,6 @@ export function DetailsSection({ form, onChange, suggestedName, tagSuggestions } return ( - -
@@ -53,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/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/notifications-section.tsx b/apps/web/src/components/alerts/notifications-section.tsx index 018173bd53..71263aa7e9 100644 --- a/apps/web/src/components/alerts/notifications-section.tsx +++ b/apps/web/src/components/alerts/notifications-section.tsx @@ -30,7 +30,6 @@ import { ProviderLogo, } from "@/components/alerts/destination-provider" import { useChatConnectors, useChatWorkspaceConnect } from "@/components/alerts/use-chat-workspaces" -import { SectionHeading } from "@/components/common/section-heading" import { chatDestinationForm, defaultDestinationForm, @@ -116,14 +115,9 @@ export function NotificationsSection({ return ( -
    - - {selected.length > 0 && ( + {selected.length > 0 && ( +
    + {( )}
    + )} {selected.map((destination) => ( 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/headroom-list.tsx b/apps/web/src/components/alerts/overview/headroom-list.tsx new file mode 100644 index 0000000000..819ea8ca4c --- /dev/null +++ b/apps/web/src/components/alerts/overview/headroom-list.tsx @@ -0,0 +1,117 @@ +import { Link } from "@tanstack/react-router" + +import type { AlertRuleDocument } from "@maple/domain/http" +import { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { countLabel } from "@maple/ui/lib/format" +import { formatRelativeShortFrom } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" + +import { ROW_STRETCHED_LINK_CLASS } from "@/components/common/data-table" +import { displaySignalType, formatSignalValue } from "@/lib/alerts/form-utils" +import type { RuleHeadroom } from "@/lib/alerts/headroom" + +const MAX_ROWS = 8 +const ROW_GRID = "grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_130px_44px] items-center gap-4" + +/** Formats a rule's values in the unit its threshold is entered in. */ +function ruleValue(rule: AlertRuleDocument, value: number): string { + const draft = rule.queryBuilderDraft + return formatSignalValue( + displaySignalType({ + signalType: rule.signalType, + queryBuilderDraft: + draft == null + ? undefined + : { dataSource: draft.dataSource ?? "", aggregation: draft.aggregation ?? "" }, + }), + value, + ) +} + +/** + * The quiet inbox's main read: how close every rule is to firing, nearest + * first. One flat meter per rule, filled toward its threshold. + */ +export function HeadroomList({ rows, now }: { rows: ReadonlyArray; now: number }) { + const shown = rows.slice(0, MAX_ROWS) + const hidden = rows.length - shown.length + return ( +
    +
    + Headroom + + latest check against each threshold + +
    + {shown.length === 0 ? ( +

    + No rule has reported a value yet. The first checks land within a minute. +

    + ) : ( +
      + {shown.map((row) => ( + + ))} +
    + )} + {hidden > 0 && ( +

    + + {countLabel(hidden, "more rule")} +

    + )} +
    + ) +} + +function HeadroomRow({ row, now }: { row: RuleHeadroom; now: number }) { + const { rule, closeness } = row + const near = closeness !== null && closeness >= 0.75 + const floor = rule.comparator === "lt" || rule.comparator === "lte" + return ( +
  • +
    + + {rule.name} + + {row.groupKey !== null && ( +

    {row.groupKey}

    + )} +
    + {/* Decorative: the value column beside it says the same in words. */} +
    + {closeness !== null && ( + + )} + {/* The threshold sits at the right edge; the tick wears the rule's severity. */} + +
    + + {ruleValue(rule, row.value)} + + {floor ? " / min " : " / "} + {ruleValue(rule, rule.threshold)} + + + + {row.checkedAt === null ? "" : formatRelativeShortFrom(row.checkedAt, now)} + +
  • + ) +} 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..04ce27a714 --- /dev/null +++ b/apps/web/src/components/alerts/overview/inbox-tab.tsx @@ -0,0 +1,385 @@ +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 { HeadroomList } from "@/components/alerts/overview/headroom-list" +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 { quietForMs, ruleHeadroom } from "@/lib/alerts/headroom" +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, statesByRule, timelineRange } = data + const now = timelineRange.max + + 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 headroom = useMemo(() => ruleHeadroom(rules, statesByRule), [rules, statesByRule]) + + const held = sorted.filter((i) => i.holdReason !== null).length + const firing = sorted.length - held + const enabledRuleIds = rules.filter((r) => r.enabled).map((r) => r.id) + const enabled = enabledRuleIds.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`} + {quiet && } + {held > 0 && ( + · {held} waiting on data + )} +

    +

    {summary}

    +
    + + {quiet ? ( + <> + + {lanes.length === 0 && spots.length === 0 ? ( + // Two empty sections read as broken tables; one line says the same. +

    + No rule fired in the last 24 hours · no blind spots: every enabled rule is + evaluating and routed. +

    + ) : ( +
    + + +
    + )} + + ) : ( + <> + +
    + + +
    + + )} +
    + ) +} + +/* ── Quiet state ────────────────────────────────────────────────────────── */ + +function QuietStreak({ incidents, now }: { incidents: ReadonlyArray; now: number }) { + const quietFor = quietForMs(incidents, now) + return ( + + {" · "} + {quietFor === null ? "quiet for 24h+" : `quiet for ${formatResolutionDuration(quietFor)}`} + + ) +} + +/* ── 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, + enabledRuleIds, +}: { + lanes: ReadonlyArray + incidents: ReadonlyArray + range: { readonly min: number; readonly max: number } + /** Enabled rules only: a disabled rule is not "quiet", it is off. */ + enabledRuleIds: ReadonlyArray +}) { + 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 fired = new Set(lanes.map((lane) => lane.ruleId)) + const quiet = enabledRuleIds.filter((id) => !fired.has(id)).length + const hiddenFired = lanes.length - shown.length + const footer = [ + hiddenFired > 0 ? `+ ${hiddenFired} more fired` : null, + quiet > 0 ? countLabel(quiet, "quiet rule") : null, + ] + .filter((part) => part !== null) + .join(" · ") + 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) => ( + + ))} +
      +
    • + ))} +
    + )} +
    + {footer} +
    + {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..41db702c23 100644 --- a/apps/web/src/components/alerts/rule-action-bar.tsx +++ b/apps/web/src/components/alerts/rule-action-bar.tsx @@ -9,8 +9,12 @@ 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[] + /** The rule as one sentence, so the whole condition reads without scrolling. */ + summary?: string onCancel: () => void onSave: () => void onShowTemplates?: () => void @@ -29,8 +33,10 @@ const MAX_VISIBLE_ISSUES = 3 */ export function RuleActionBar({ editing, + changeCount = null, saving, validationIssues, + summary, onCancel, onSave, onShowTemplates, @@ -66,20 +72,39 @@ export function RuleActionBar({ Templates )} - + {summary && ( +

    + {summary} +

    + )} + +
    + {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..a52de2bea3 --- /dev/null +++ b/apps/web/src/components/alerts/rule-condition-bar.tsx @@ -0,0 +1,277 @@ +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, + 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, + signalLabels, + thresholdUnitLabel, + type RuleFormState, +} from "@/lib/alerts/form-utils" + +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", + }, +] + +/** What the threshold is compared against, in words: the aggregation for a query, the signal otherwise. */ +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() + 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) ?? "" + 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 ${conditionSubject(form)} ${COMPARATOR_WORDS[form.comparator]} ${threshold} ${when}` +} + +interface RuleConditionFieldsProps { + form: RuleFormState + onChange: Dispatch> + /** The saved rule while editing: a changed field says what it was. */ + saved: RuleFormState | null +} + +/** 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) + 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 + + {comparatorWas !== null && ( + {COMPARATOR_WORDS[saved?.comparator ?? form.comparator]} + )} + + +
    + f.threshold)} + onChange={(value) => onChange((c) => ({ ...c, threshold: value }))} + /> + {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 NumberField({ + id, + label, + description, + value, + unit, + previous, + onChange, +}: { + id: string + label: string + description?: string + value: string + unit: string | null + /** The saved value when it differs. */ + previous: string | null + onChange: (value: string) => void +}) { + return ( + + {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-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..c2e7c8061c 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,5 @@ -import { useMemo } from "react" +import { conditionSubject } from "@/components/alerts/rule-condition-bar" +import { useMemo, type ReactNode } from "react" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" @@ -9,15 +10,23 @@ 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, } from "@/lib/alerts/form-utils" @@ -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,20 +94,21 @@ 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 ( - +
    - {signalLabels[form.signalType]} + {conditionSubject(form)} {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..3e30dccf46 --- /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 { Eyebrow } from "@maple/ui/components/ui/eyebrow" +import { Field, FieldLabel } from "@maple/ui/components/ui/field" +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 { 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 ( + +
    + After it fires +
    + 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 ( + + {label} + + onChange(event.target.value)} + 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..87dc59f082 --- /dev/null +++ b/apps/web/src/components/alerts/rule-query-section.tsx @@ -0,0 +1,433 @@ +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 { 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 { + 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" +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 + 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 }, +] + +/** + * 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 ( +
    +
    + + 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/rule-step.tsx b/apps/web/src/components/alerts/rule-step.tsx new file mode 100644 index 0000000000..ee9a30fd64 --- /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" + +export type RuleStepStatus = "done" | "missing" | "optional" + +/** One numbered step of the rule editor; says "Required" until it is filled in. */ +export function RuleStep({ + number, + title, + hint, + status, + actions, + divider = true, + children, +}: { + number: number + title: string + hint?: string + status: RuleStepStatus + actions?: ReactNode + /** A rule above the step; off for the first step and for steps sharing a row. */ + divider?: boolean + children: ReactNode +}) { + const headingId = `rule-step-${number}` + return ( +
    +
    +
    +
    +

    + + {number} + + {title} +

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

    {hint}

    } +
    + {actions} +
    + {children} +
    + ) +} 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/headroom.test.ts b/apps/web/src/lib/alerts/headroom.test.ts new file mode 100644 index 0000000000..b1b15ed640 --- /dev/null +++ b/apps/web/src/lib/alerts/headroom.test.ts @@ -0,0 +1,133 @@ +import { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" + +import type { AlertRuleStateRow } from "@/lib/collections/alerts" +import { quietForMs, ruleHeadroom } from "./headroom" + +const decodeRule = Schema.decodeUnknownSync(AlertRuleDocument) +const decodeIncident = Schema.decodeUnknownSync(AlertIncidentDocument) + +const RULE_A = "00000000-0000-4000-8000-00000000000a" +const RULE_B = "00000000-0000-4000-8000-00000000000b" +const NOW = Date.parse("2026-10-16T00:00:00.000Z") + +function rule(id: string, overrides: Record = {}): AlertRuleDocument { + return decodeRule({ + id, + name: `rule ${id.slice(-1)}`, + notes: null, + notificationTemplate: null, + enabled: true, + severity: "warning", + serviceNames: [], + excludeServiceNames: [], + environments: [], + tags: [], + groupBy: null, + signalType: "error_rate", + comparator: "gt", + threshold: 0.05, + thresholdUpper: null, + windowMinutes: 5, + minimumSampleCount: 0, + noDataBehavior: "skip", + consecutiveBreachesRequired: 2, + consecutiveHealthyRequired: 2, + renotifyIntervalMinutes: 30, + apdexThresholdMs: null, + queryBuilderDraft: null, + rawQuerySql: null, + rawQueryReducer: null, + destinationIds: [], + lastEvaluationError: null, + lastEvaluatedAt: "2026-10-15T23:59:00.000Z", + lastScheduledAt: "2026-10-15T23:59:00.000Z", + createdAt: "2026-10-15T00:00:00.000Z", + updatedAt: "2026-10-15T00:00:00.000Z", + createdBy: "user_1", + updatedBy: "user_1", + ...overrides, + }) +} + +function state(ruleId: string, groupKey: string, value: number | null): AlertRuleStateRow { + return { + org_id: "org_1", + rule_id: ruleId, + group_key: groupKey, + consecutive_breaches: 0, + consecutive_healthy: 3, + last_status: "healthy", + last_value: value, + last_sample_count: 100, + last_evaluated_at: "2026-10-15T23:59:00.000Z", + last_error: null, + updated_at: "2026-10-15T23:59:00.000Z", + } +} + +describe("ruleHeadroom", () => { + it("keeps each rule's group nearest its threshold, nearest rule first", () => { + const rows = ruleHeadroom( + [rule(RULE_A), rule(RULE_B, { comparator: "lt", threshold: 10 })], + new Map([ + [RULE_A, [state(RULE_A, "api", 0.01), state(RULE_A, "web", 0.04)]], + [RULE_B, [state(RULE_B, "__total__", 40)]], + ]), + ) + + const rounded = (n: number | null) => (n === null ? null : Math.round(n * 100) / 100) + expect(rows.map((row) => [row.rule.id, row.groupKey, rounded(row.closeness)])).toEqual([ + [RULE_A, "web", 0.8], + // A floor: 10 needed, 40 seen. + [RULE_B, null, 0.25], + ]) + }) + + it("leaves out disabled rules and rules with no value yet", () => { + const rows = ruleHeadroom( + [rule(RULE_A, { enabled: false }), rule(RULE_B)], + new Map([ + [RULE_A, [state(RULE_A, "api", 0.04)]], + [RULE_B, [state(RULE_B, "api", null)]], + ]), + ) + expect(rows).toEqual([]) + }) +}) + +describe("quietForMs", () => { + const incident = (resolvedAt: string | null) => + decodeIncident({ + id: "00000000-0000-4000-8000-0000000000f1", + ruleId: RULE_A, + ruleName: "rule a", + groupKey: null, + signalType: "error_rate", + severity: "warning", + status: resolvedAt === null ? "open" : "resolved", + comparator: "gt", + threshold: 0.05, + thresholdUpper: null, + firstTriggeredAt: "2026-10-15T20:00:00.000Z", + lastTriggeredAt: "2026-10-15T20:10:00.000Z", + resolvedAt, + lastObservedValue: 0.08, + lastSampleCount: 100, + dedupeKey: "d", + lastDeliveredEventType: null, + lastNotifiedAt: null, + holdReason: null, + heldSince: null, + errorIssueId: null, + }) + + it("counts from the latest resolve", () => { + expect(quietForMs([incident("2026-10-15T22:00:00.000Z")], NOW)).toBe(2 * 3_600_000) + }) + + it("is null when nothing has resolved", () => { + expect(quietForMs([], NOW)).toBeNull() + }) +}) diff --git a/apps/web/src/lib/alerts/headroom.ts b/apps/web/src/lib/alerts/headroom.ts new file mode 100644 index 0000000000..0072b743e4 --- /dev/null +++ b/apps/web/src/lib/alerts/headroom.ts @@ -0,0 +1,78 @@ +import { UNGROUPED_GROUP_KEY, type AlertIncidentDocument, type AlertRuleDocument } from "@maple/domain/http" + +import type { AlertRuleStateRow } from "@/lib/collections/alerts" + +/** How close one enabled rule's nearest group sits to firing, from its last recorded check. */ +export interface RuleHeadroom { + readonly rule: AlertRuleDocument + /** The group nearest its threshold; null for an ungrouped rule. */ + readonly groupKey: string | null + readonly value: number + /** + * 0 = far from firing, 1 = at the threshold. Null for comparators with no + * single direction (equality and ranges), which still list their value. + */ + readonly closeness: number | null + readonly checkedAt: number | null +} + +/** Value over threshold for a ceiling, threshold over value for a floor; clamped to [0, 1]. */ +function closenessOf(rule: AlertRuleDocument, value: number): number | null { + const clamp = (n: number) => (Number.isFinite(n) ? Math.min(Math.max(n, 0), 1) : 1) + switch (rule.comparator) { + case "gt": + case "gte": + return rule.threshold <= 0 ? null : clamp(value / rule.threshold) + case "lt": + case "lte": + return value <= 0 ? 1 : clamp(rule.threshold / value) + default: + return null + } +} + +/** + * Every enabled rule's latest value against its threshold, nearest to firing + * first. Rules that have never produced a value are left out: there is nothing + * to measure yet, and the blind-spots list already names them. + */ +export function ruleHeadroom( + rules: ReadonlyArray, + statesByRule: ReadonlyMap>, +): RuleHeadroom[] { + return rules + .filter((rule) => rule.enabled) + .flatMap((rule) => { + const measured = (statesByRule.get(rule.id) ?? []).flatMap((state) => + state.last_value === null + ? [] + : [ + { + groupKey: state.group_key === UNGROUPED_GROUP_KEY ? null : state.group_key, + value: state.last_value, + closeness: closenessOf(rule, state.last_value), + checkedAt: + state.last_evaluated_at === null + ? null + : Date.parse(state.last_evaluated_at), + }, + ], + ) + const nearest = measured.toSorted((a, b) => (b.closeness ?? -1) - (a.closeness ?? -1))[0] + return nearest === undefined ? [] : [{ rule, ...nearest }] + }) + .toSorted((a, b) => (b.closeness ?? -1) - (a.closeness ?? -1)) +} + +/** + * Milliseconds since the org was last firing: since the latest resolve, or + * null when nothing has fired in the incidents on hand. + */ +export function quietForMs(incidents: ReadonlyArray, now: number): number | null { + const lastResolve = incidents.reduce( + (latest, incident) => + incident.resolvedAt === null ? latest : Math.max(latest, Date.parse(incident.resolvedAt)), + Number.NEGATIVE_INFINITY, + ) + return Number.isFinite(lastResolve) ? Math.max(0, now - lastResolve) : null +} 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..61a5a6da27 --- /dev/null +++ b/apps/web/src/lib/alerts/overview-derive.test.ts @@ -0,0 +1,185 @@ +import { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" + +import { defaultRuleForm } from "./form-utils" +import { blindSpots, fireLanes, ruleBucket, ruleConditionSummary, ruleHeadroom, ruleRibbon } from "./overview-derive" +import { builtinToQueryDraft } from "./rule-modes" +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"], + ]) + }) +}) + +describe("ruleConditionSummary", () => { + it("prints a builder error-rate threshold as a percent and names the aggregation", () => { + const rule = makeRule({ + signalType: "builder_query", + threshold: 0.05, + queryBuilderDraft: builtinToQueryDraft(defaultRuleForm()), + }) + expect(ruleConditionSummary(rule)).toBe("error rate > 5.0% · 5m") + }) +}) 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..5bdfd85ace --- /dev/null +++ b/apps/web/src/lib/alerts/overview-derive.ts @@ -0,0 +1,210 @@ +import type { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" + +import { + comparatorLabels, + displaySignalType, + 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 { + // Builder rules format in the display unit (5%, not the stored 0.05) and name their aggregation. + const display = displaySignalType({ + signalType: rule.signalType, + queryBuilderDraft: rule.queryBuilderDraft ?? undefined, + }) + const threshold = isRangeComparator(rule.comparator) + ? `${formatSignalValue(display, rule.threshold)} and ${formatSignalValue(display, rule.thresholdUpper)}` + : formatSignalValue(display, rule.threshold) + const subject = + rule.signalType === "builder_query" && rule.queryBuilderDraft + ? rule.queryBuilderDraft.aggregation.replaceAll("_", " ") + : signalLabels[rule.signalType] + const parts = [`${subject} ${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..90d2523626 --- /dev/null +++ b/apps/web/src/lib/alerts/rule-modes.test.ts @@ -0,0 +1,107 @@ +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("says so when a split can't survive a named service", () => { + const draft = builtinToQueryDraft(builtin()) + const form = builtin({ + signalType: "builder_query", + queryBuilderDraft: { + ...draft, + whereClause: 'service.name = "api"', + groupBy: ["http.route"], + addOns: { ...draft.addOns, groupBy: true }, + }, + }) + const back = switchSignalKind(form, "builtin") + + expect(back.form.serviceNames).toEqual(["api"]) + expect(back.form.groupBy).toEqual([]) + expect(back.notice).toContain("split by http.route") + }) + + 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..a1302334ba --- /dev/null +++ b/apps/web/src/lib/alerts/rule-modes.ts @@ -0,0 +1,189 @@ +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 + // Built-in splits per named service and cannot add another split on top, so say what goes. + const lostSplit = serviceNames.length > 0 ? groupBy.filter((t) => t !== "service.name") : [] + if (lostSplit.length > 0) dropped.push(`split by ${lostSplit.join(", ")}`) + + 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..96298bee67 100644 --- a/apps/web/src/routes/alerts/incidents/$incidentId.tsx +++ b/apps/web/src/routes/alerts/incidents/$incidentId.tsx @@ -1,25 +1,42 @@ -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 { useAsyncAction } from "@/hooks/use-mutation-action" +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 +45,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 +60,198 @@ 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({ + // Pending while the create request runs, so repeat clicks cannot start duplicates. + const [startInvestigation, startingInvestigation] = useAsyncAction(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) - } }) - } - useMountEffect(openInvestigation) + if (Exit.isSuccess(result)) { + await navigate({ to: "/investigations/$id", params: { id: result.value.id } }) + return + } + toastExit(result, { error: "Failed to start investigation" }) + }) + 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..38ccefe3e8 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,48 +66,71 @@ 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 + // Session gates the header actions; destinations feed the hub. The tabs own the rest // of their data (the atoms are shared, so this costs no extra requests). const sessionResult = useAtomValue(retainedQuery("auth", "session", {})) const { result: destinationsResult } = useAlertDestinationsList() 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 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. - // While empty, the empty-state CTA is the single add affordance (avoids a duplicate). - isAdmin && hasDestinations ? ( - - ) : undefined - ) : ( + )} - ) +
    + ) return ( destinationManager.openDialog() : null}> - {activeTab === "overview" ? ( - + {activeTab === "routing" ? ( + ) : ( - + navigate({ search: (prev) => ({ ...prev, status }) })} + /> )}