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 && (
-
- navigate({
- search: (prev) => ({
- ...prev,
- createdBy: value === ANY_CREATOR ? undefined : (value as string),
- }),
- })
- }
- >
-
-
-
-
- {Object.entries(creatorOptions).map(([value, label]) => (
-
- {label}
-
- ))}
-
-
- )}
-
-
-
- {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"}
+ />
+ onTest(destination)}
+ disabled={!isAdmin}
+ loading={isTesting}
+ >
+
+ Test
+
+ {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.
+
+ ) : (
+
+ )}
+
+
{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 ?? (
Cancel
)}
-
+
{editing ? "Save changes" : "Create rule"}
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
+ value && onChange((c) => ({ ...c, comparator: value }))}
+ >
+
+
+
+
+ {COMPARATOR_ITEMS.map((item) => (
+
+ {item.label}
+
+ ))}
+
+
+ {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 (
- }>
+ }>
{action.label}
)
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.
+
+
+ Open in query builder
+
+
+
+ )
+}
+
+function BuiltinRow({
+ label,
+ htmlFor,
+ hint,
+ children,
+}: {
+ label: string
+ htmlFor: string
+ hint?: string
+ children: ReactNode
+}) {
+ return (
+
+
+ {label}
+
+
+ {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"
+ />
+
+
+ Reduce each window by
+
+
value && onChange((c) => ({ ...c, rawQueryReducer: value }))}
+ >
+
+
+
+
+ {Object.entries(RAW_QUERY_REDUCER_LABELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ 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
-
- onChange((c) => ({ ...c, comparator: value as AlertComparator }))
- }
- >
-
-
-
-
- {COMPARATOR_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
-
-
-
- {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. */}
-
- Severity
- onChange((c) => ({ ...c, severity: value }))}
- />
-
-
- {/* Advanced timing — collapsed by default. Most users never tune these. */}
-
-
setAdvancedOpen((o) => !o)}
- className="flex w-full items-center justify-between gap-2 text-left text-xs text-muted-foreground hover:text-foreground"
- aria-expanded={advancedOpen}
- >
-
- Evaluation timing
-
-
-
- {form.windowMinutes}min · {form.consecutiveBreachesRequired}× · renotify{" "}
- {form.renotifyIntervalMinutes}min
-
-
-
-
- {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
-
- value &&
- onChange((c) => ({
- ...c,
- rawQueryReducer: value as RuleFormState["rawQueryReducer"],
- }))
- }
- >
-
-
-
-
- {Object.entries(RAW_QUERY_REDUCER_LABELS).map(([val, label]) => (
-
- {label}
-
- ))}
-
-
-
-
-
- )
- }
-
- 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 }) {
}
+ render={ }
>
Add a destination
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
+
+
+ {tab === "routing" ? "Add destination" : "New rule"}
+
+
+
+ {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 ? (
- destinationManager.openDialog()}>
+ // Both actions on every tab: creating a destination is admin-only server side.
+ const headerActions = (
+
+ {isAdmin && (
+
destinationManager.openDialog()}>
- Add destination
+ New destination
- ) : undefined
- ) : (
+ )}
}
@@ -95,15 +138,23 @@ function AlertsPage() {
New rule
- )
+
+ )
return (
destinationManager.openDialog() : null}>
- {activeTab === "overview" ? (
-
+ {activeTab === "routing" ? (
+
) : (
-
+ navigate({ search: (prev) => ({ ...prev, status }) })}
+ />
)}