From 6aca4d613630f203930b1ae94ea176dd7aa88377 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 1 Jul 2026 00:53:26 +0200 Subject: [PATCH 1/3] feat(alerts): merge signal + checks charts into one "Signal & evaluations" view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alert rule detail page drew the same metric twice across two tabs: the Overview area chart (continuous warehouse signal, which turned into an unreadable "glittery" mush of overlapping translucent areas when grouped by service) and the Checks sparkline (the engine's discrete evaluations). Same metric, same threshold, same window — they read as duplicates even though the underlying sources differ (raw signal vs. the engine's decisions). Replace both with a single AlertSignalChart that stacks the two layers on one shared time axis and one threshold: - Signal layer: single series renders an area with a comparator-aware threshold-split fill (destructive above the line for gt/gte, below for lt/lte); multi-series renders thin lines with no fill — killing the glitter. - Decision layer: a thin per-bucket "evaluation rail" of status cells (breached / healthy / skipped), reusing the page's existing timeline-strip idiom, with a legend. - Incident windows shaded across both via ReferenceArea. - raw_query rules, which previously showed "preview unavailable", now render a checks-driven chart + rail. Also fold the checks stat strip + audit table into Overview and drop the separate Checks tab (rule detail is now Overview + History). The redundant CheckHistorySparkline component is deleted. Co-Authored-By: Claude Opus 4.8 --- .../components/alerts/alert-signal-chart.tsx | 515 ++++++++++++++++++ .../alerts/check-history-sparkline.tsx | 193 ------- apps/web/src/routes/alerts/$ruleId.tsx | 119 +--- 3 files changed, 539 insertions(+), 288 deletions(-) create mode 100644 apps/web/src/components/alerts/alert-signal-chart.tsx delete mode 100644 apps/web/src/components/alerts/check-history-sparkline.tsx diff --git a/apps/web/src/components/alerts/alert-signal-chart.tsx b/apps/web/src/components/alerts/alert-signal-chart.tsx new file mode 100644 index 00000000..77e0dc8b --- /dev/null +++ b/apps/web/src/components/alerts/alert-signal-chart.tsx @@ -0,0 +1,515 @@ +import * as React from "react" +import { + Area, + CartesianGrid, + ComposedChart, + Legend, + Line, + ReferenceArea, + ReferenceLine, + XAxis, + YAxis, +} from "recharts" + +import type { + AlertCheckDocument, + AlertComparator, + AlertIncidentDocument, + AlertSignalType, +} from "@maple/domain/http" +import { formatSignalValue } from "@/lib/alerts/form-utils" +import { normalizeTimestampInput } from "@/lib/timezone-format" +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@maple/ui/components/ui/chart" +import { formatBucketLabel } from "@maple/ui/lib/format" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { SquareTerminalIcon } from "@/components/icons" +import { cn } from "@maple/ui/utils" +import { SERIES_COLORS } from "./chart-colors" + +interface AlertSignalChartProps { + /** Warehouse signal buckets from `useAlertRuleChart` (`{ bucket, }`). */ + data?: Record[] + /** The alert engine's recorded evaluations — drives the rail (and the chart for raw SQL). */ + checks: ReadonlyArray + /** Incidents for this rule — shaded as firing windows across the chart. */ + incidents: ReadonlyArray + threshold: number + thresholdUpper?: number | null + comparator: AlertComparator + signalType: AlertSignalType + /** Page time window in epoch ms — the shared domain for the axis, bands, and rail. */ + window: { min: number; max: number } + loading?: boolean + /** Preview-query failure; non-fatal when recorded checks can still draw the chart. */ + chartError?: string | null + className?: string +} + +const SINGLE_KEY = "value" +const CHART_HEIGHT = 240 + +type ChartPoint = { t: number } & Record +type SignalSource = "warehouse" | "checks" | "none" +const Y_AXIS_WIDTH = 62 +const PLOT_RIGHT = 8 +const RAIL_CELLS = 60 + +type RailStatus = "breached" | "skipped" | "healthy" | "empty" + +const RAIL_COLOR: Record = { + breached: "bg-destructive", + skipped: "bg-muted-foreground/30", + healthy: "bg-chart-apdex/70", + empty: "bg-muted/50", +} + +function num(value: unknown): number { + const parsed = typeof value === "number" ? value : Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +function toMs(value: unknown): number { + if (typeof value !== "string") return Number.NaN + return new Date(normalizeTimestampInput(value)).getTime() +} + +function clamp01(value: number): number { + return value < 0 ? 0 : value > 1 ? 1 : value +} + +export function AlertSignalChart({ + data, + checks, + incidents, + threshold, + thresholdUpper, + comparator, + signalType, + window: domain, + loading, + chartError, + className, +}: AlertSignalChartProps) { + const isRaw = signalType === "raw_query" + + // The warehouse signal is the preferred source; when it's empty (raw SQL, or a + // failed/empty preview) the recorded checks become the series instead, so even + // raw-SQL rules get a chart. `source` lets us caption the fallback. + const { chartData, seriesKeys, isMultiSeries, source } = React.useMemo((): { + chartData: ChartPoint[] + seriesKeys: string[] + isMultiSeries: boolean + source: SignalSource + } => { + if (Array.isArray(data) && data.length > 0) { + const keySet = new Set() + for (const row of data) { + for (const key of Object.keys(row)) if (key !== "bucket") keySet.add(key) + } + const allKeys = Array.from(keySet) + if (allKeys.length === 1) { + const label = allKeys[0]! + const rows: ChartPoint[] = data + .map((row) => ({ t: toMs(row.bucket), [SINGLE_KEY]: num(row[label]) })) + .filter((row) => Number.isFinite(row.t)) + .sort((a, b) => a.t - b.t) + return { chartData: rows, seriesKeys: [SINGLE_KEY], isMultiSeries: false, source: "warehouse" } + } + if (allKeys.length > 1) { + const rows: ChartPoint[] = data + .map((row) => { + const point: ChartPoint = { t: toMs(row.bucket) } + for (const key of allKeys) point[key] = num(row[key]) + return point + }) + .filter((row) => Number.isFinite(row.t)) + .sort((a, b) => a.t - b.t) + return { chartData: rows, seriesKeys: allKeys, isMultiSeries: true, source: "warehouse" } + } + } + + if (checks.length > 0) { + const rows: ChartPoint[] = checks + .map((check) => ({ + t: new Date(normalizeTimestampInput(check.timestamp)).getTime(), + [SINGLE_KEY]: check.observedValue, + })) + .filter((row) => Number.isFinite(row.t)) + .sort((a, b) => a.t - b.t) + return { chartData: rows, seriesKeys: [SINGLE_KEY], isMultiSeries: false, source: "checks" } + } + + return { chartData: [], seriesKeys: [SINGLE_KEY], isMultiSeries: false, source: "none" } + }, [data, checks]) + + const hasSignal = chartData.length > 0 + + // Adaptive time-axis labels reuse the warehouse formatter via an ISO round-trip. + const axisContext = React.useMemo(() => { + if (chartData.length < 2) return { rangeMs: domain.max - domain.min, bucketSeconds: undefined } + const first = chartData[0]!.t + const second = chartData[1]!.t + const last = chartData[chartData.length - 1]!.t + return { rangeMs: last - first, bucketSeconds: (second - first) / 1000 } + }, [chartData, domain]) + + const formatTime = React.useCallback( + (value: number, mode: "tick" | "tooltip") => + formatBucketLabel(new Date(value).toISOString(), axisContext, mode), + [axisContext], + ) + + const chartConfig: ChartConfig = React.useMemo(() => { + const config: ChartConfig = {} + seriesKeys.forEach((key, i) => { + config[key] = { + label: isMultiSeries ? key : "Observed", + color: SERIES_COLORS[i % SERIES_COLORS.length]!, + } + }) + return config + }, [seriesKeys, isMultiSeries]) + + const yDomain = React.useMemo<[number, number]>(() => { + let maxVal = threshold + if (thresholdUpper != null) maxVal = Math.max(maxVal, thresholdUpper) + for (const point of chartData) { + for (const key of seriesKeys) maxVal = Math.max(maxVal, num(point[key])) + } + const upper = Math.max(maxVal * 1.15, threshold * 1.3) + return [0, upper > 0 ? upper : 1] + }, [chartData, seriesKeys, threshold, thresholdUpper]) + + // The breach region — the part of the fill that turns red — is the side of the + // threshold the comparator flags. Range comparators have two bounds, so they + // skip the split and keep a neutral fill plus both reference lines. + const breachAbove = comparator === "gt" || comparator === "gte" + const breachBelow = comparator === "lt" || comparator === "lte" + const splitOffset = clamp01((yDomain[1] - threshold) / (yDomain[1] - yDomain[0] || 1)) + + const incidentBands = React.useMemo( + () => + incidents + .map((incident) => { + const x1 = new Date(incident.firstTriggeredAt).getTime() + const x2 = incident.resolvedAt ? new Date(incident.resolvedAt).getTime() : domain.max + return { x1, x2, open: incident.status === "open" } + }) + .filter((band) => band.x2 >= domain.min && band.x1 <= domain.max) + .map((band) => ({ + ...band, + x1: Math.max(band.x1, domain.min), + x2: Math.min(band.x2, domain.max), + })), + [incidents, domain], + ) + + const railCells = React.useMemo(() => { + const range = Math.max(1, domain.max - domain.min) + const buckets = Array.from({ length: RAIL_CELLS }, () => ({ + breached: 0, + healthy: 0, + skipped: 0, + opened: false, + })) + for (const check of checks) { + const t = new Date(normalizeTimestampInput(check.timestamp)).getTime() + if (!Number.isFinite(t)) continue + const idx = Math.floor(((t - domain.min) / range) * RAIL_CELLS) + if (idx < 0 || idx >= RAIL_CELLS) continue + const bucket = buckets[idx]! + if (check.status === "breached") bucket.breached += 1 + else if (check.status === "healthy") bucket.healthy += 1 + else bucket.skipped += 1 + if (check.incidentTransition === "opened") bucket.opened = true + } + return buckets.map((bucket, i) => { + const total = bucket.breached + bucket.healthy + bucket.skipped + const status: RailStatus = + total === 0 + ? "empty" + : bucket.breached > 0 + ? "breached" + : bucket.healthy > 0 + ? "healthy" + : "skipped" + const start = domain.min + (i / RAIL_CELLS) * range + const end = domain.min + ((i + 1) / RAIL_CELLS) * range + const counts = [ + bucket.breached > 0 ? `${bucket.breached} breached` : null, + bucket.healthy > 0 ? `${bucket.healthy} healthy` : null, + bucket.skipped > 0 ? `${bucket.skipped} skipped` : null, + ] + .filter(Boolean) + .join(", ") + const window = `${formatTime(start, "tick")} – ${formatTime(end, "tick")}` + const title = + total === 0 + ? `${window} · no checks` + : `${window} · ${counts}${bucket.opened ? " · incident opened" : ""}` + return { status, opened: bucket.opened, title } + }) + }, [checks, domain, formatTime]) + + const chartArea = hasSignal ? ( + + + + + {breachAbove ? ( + <> + + + + + + ) : breachBelow ? ( + <> + + + + + + ) : ( + <> + + + + )} + + + + + {incidentBands.map((band, i) => ( + + ))} + + formatTime(value as number, "tick")} + /> + formatSignalValue(signalType, num(value))} + /> + + { + const t = payload?.[0]?.payload?.t + return typeof t === "number" ? formatTime(t, "tooltip") : "" + }} + formatter={(value, name) => ( + + + + {chartConfig[name as string]?.label ?? name} + + + {typeof value === "number" ? formatSignalValue(signalType, value) : "—"} + + + )} + /> + } + /> + + {isMultiSeries && ( + {value}} + /> + )} + + + {thresholdUpper != null && ( + + )} + + {isMultiSeries ? ( + seriesKeys.map((key, i) => ( + + )) + ) : ( + + )} + + + ) : loading ? ( + + ) : isRaw ? ( + +

Chart builds from recorded checks

+

+ Raw SQL has no live preview. Once the scheduler records evaluations they'll plot here. +

+
+ ) : chartError != null ? ( + +

Preview query failed

+

{chartError}

+
+ ) : ( + +

No data in this window. Try widening the range.

+
+ ) + + return ( +
+ {chartArea} + + {hasSignal && chartError != null && source === "checks" && ( +

+ Live signal preview unavailable — showing recorded checks. +

+ )} + + {checks.length > 0 && ( +
+
+ Evaluations + 0} /> +
+
+ {railCells.map((cell, i) => ( +
+ ))} +
+
+ )} +
+ ) +} + +function Placeholder({ + children, + tone, + icon, +}: { + children: React.ReactNode + tone?: "destructive" + icon?: boolean +}) { + return ( +
+
+ {icon && ( +
+ +
+ )} + {children} +
+
+ ) +} + +function RailLegend({ hasIncidents }: { hasIncidents: boolean }) { + return ( +
+ Breached + Healthy + Skipped + {hasIncidents && ( + Incident + )} +
+ ) +} + +function LegendChip({ className, children }: { className?: string; children: React.ReactNode }) { + return ( + + + {children} + + ) +} diff --git a/apps/web/src/components/alerts/check-history-sparkline.tsx b/apps/web/src/components/alerts/check-history-sparkline.tsx deleted file mode 100644 index 98571e54..00000000 --- a/apps/web/src/components/alerts/check-history-sparkline.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import * as React from "react" -import { CartesianGrid, Dot, Legend, Line, LineChart, ReferenceLine, XAxis, YAxis } from "recharts" -import type { AlertCheckDocument, AlertSignalType } from "@maple/domain/http" -import { formatSignalValue } from "@/lib/alerts/form-utils" -import { normalizeTimestampInput } from "@/lib/timezone-format" -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from "@maple/ui/components/ui/chart" -import { SERIES_COLORS } from "./chart-colors" - -interface CheckHistorySparklineProps { - checks: ReadonlyArray - threshold: number - signalType: AlertSignalType - className?: string -} - -type ChartPoint = { t: number } & Record - -const SINGLE_SERIES_KEY = "value" -const SINGLE_SERIES_LABEL = "Observed" - -export function CheckHistorySparkline({ - checks, - threshold, - signalType, - className, -}: CheckHistorySparklineProps) { - const { data, seriesKeys, statusLookup, isMultiSeries } = React.useMemo(() => { - const sorted = checks.toSorted( - (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), - ) - - const groupKeys = new Set() - for (const check of sorted) { - groupKeys.add(check.groupKey) - } - const groupList = Array.from(groupKeys) - const isMulti = groupList.length > 1 - const seriesKeys = isMulti ? groupList : [SINGLE_SERIES_KEY] - - const byTimestamp = new Map() - const statusLookup = new Map() - - for (const check of sorted) { - const t = new Date(normalizeTimestampInput(check.timestamp)).getTime() - const key = isMulti ? check.groupKey : SINGLE_SERIES_KEY - const existing = byTimestamp.get(t) ?? { t } - existing[key] = check.observedValue - byTimestamp.set(t, existing) - statusLookup.set(`${t}|${key}`, check.status) - } - - const data = Array.from(byTimestamp.values()) - for (const point of data) { - for (const key of seriesKeys) { - if (!(key in point)) point[key] = null - } - } - data.sort((a, b) => a.t - b.t) - - return { data, seriesKeys, statusLookup, isMultiSeries: isMulti } - }, [checks]) - - const chartConfig: ChartConfig = React.useMemo(() => { - const config: ChartConfig = {} - seriesKeys.forEach((key, i) => { - config[key] = { - label: isMultiSeries ? key : SINGLE_SERIES_LABEL, - color: SERIES_COLORS[i % SERIES_COLORS.length]!, - } - }) - return config - }, [seriesKeys, isMultiSeries]) - - if (data.length === 0) { - return null - } - - return ( - - - - - new Date(v).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }) - } - fontSize={11} - /> - formatSignalValue(signalType, v)} - fontSize={11} - width={52} - /> - - { - const raw = payload?.[0]?.payload as ChartPoint | undefined - return raw ? new Date(raw.t).toLocaleString() : "" - }} - formatter={(value, name) => ( - - - - {chartConfig[name as string]?.label ?? name} - - - {typeof value === "number" - ? formatSignalValue(signalType, value) - : String(value)} - - - )} - /> - } - /> - {isMultiSeries && ( - ( - {value} - )} - /> - )} - {seriesKeys.map((key, i) => { - const color = SERIES_COLORS[i % SERIES_COLORS.length]! - return ( - { - // recharts v3 passes DotItemDotProps (extra non-SVG fields - // like points/value/dataKey that don't belong on ), so - // pass through only the geometry + our breach styling. - const point = props.payload as ChartPoint - const status = statusLookup.get(`${point.t}|${key}`) - const isBreached = status === "breached" - const dotColor = isBreached ? "var(--destructive)" : color - return ( - - ) - }} - isAnimationActive={false} - connectNulls - /> - ) - })} - - - ) -} diff --git a/apps/web/src/routes/alerts/$ruleId.tsx b/apps/web/src/routes/alerts/$ruleId.tsx index d7627b12..12043f6e 100644 --- a/apps/web/src/routes/alerts/$ruleId.tsx +++ b/apps/web/src/routes/alerts/$ruleId.tsx @@ -13,8 +13,7 @@ import { PageRefreshProvider } from "@/components/time-range-picker/page-refresh import { applyTimeRangeSearch } from "@/components/time-range-picker/search" import { presetLabel, formatTimeRangeDisplay } from "@/lib/time-utils" import { normalizeTimestampInput } from "@/lib/timezone-format" -import { AlertPreviewChart } from "@/components/alerts/alert-preview-chart" -import { CheckHistorySparkline } from "@/components/alerts/check-history-sparkline" +import { AlertSignalChart } from "@/components/alerts/alert-signal-chart" import { AlertStatusBadge } from "@/components/alerts/alert-status-badge" import { AlertSeverityBadge } from "@/components/alerts/alert-severity-badge" import { AlertStatStrip } from "@/components/alerts/alert-stat-card" @@ -45,7 +44,6 @@ import { PencilIcon, DotsVerticalIcon, CircleWarningIcon, - SquareTerminalIcon, ChevronDownIcon, ChatBubbleSparkleIcon, } from "@/components/icons" @@ -65,7 +63,7 @@ import { } from "@maple/ui/components/ui/dropdown-menu" import { useAlertRuleChart } from "@/hooks/use-alert-rule-chart" -const tabValues = ["overview", "history", "checks"] as const +const tabValues = ["overview", "history"] as const type RuleDetailTab = (typeof tabValues)[number] const RuleDetailSearch = Schema.Struct({ @@ -128,7 +126,6 @@ function RuleDetailContent() { reactivityKeys: ["alertChecks", ruleId, since, until], }) const checksResult = useAtomValue(checksQueryAtom) - const refreshChecks = useAtomRefresh(checksQueryAtom) const rules = Result.builder(rulesResult) .onSuccess((response) => response.rules) @@ -355,7 +352,6 @@ function RuleDetailContent() { Overview History - Checks
@@ -425,47 +421,18 @@ function RuleDetailContent() {

{signalLabels[rule.signalType]}: {rangeLabel}

- {rule.signalType === "raw_query" ? ( - // Raw SQL has no structured preview regardless of window, so the - // generic "widen the range" empty-state would mislead — mirror - // RuleLiveChartHero and show a raw-SQL hint instead. -
-
-
- -
-

- Live preview unavailable for raw SQL -

-

- Raw SQL rules don't have a structured chart preview. -

-
-
- ) : chartError != null ? ( -
-
-

- Preview query failed -

-

{chartError}

-
-
- ) : !chartLoading && chartData.length === 0 ? ( -
-

- No data in this window. Try widening the range. -

-
- ) : ( - - )} + {overviewIncident ? ( @@ -596,6 +563,14 @@ function RuleDetailContent() { + + )} @@ -864,34 +839,6 @@ function RuleDetailContent() { )) .render()} - {activeTab === "checks" && - Result.builder(checksResult) - .onError((error) => ( - - - - - - Failed to load checks - - {error.message ?? "Try refreshing or check API logs."} - - - - - )) - .orElse(() => ( - - ))} - ) @@ -966,7 +913,8 @@ function ChecksPanel({ } return ( -
+
+

Checks

-
-
-

Observed values

- - {totals.total} checks · oldest → newest - -
- - - - - -
-

All checks

From d59366037bba72937727ff5ad3be35ce93b350ac Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 1 Jul 2026 01:00:33 +0200 Subject: [PATCH 2/3] fix(alerts): show error + retry when checks query fails on Overview Folding the Checks tab into Overview dropped the checksResult error branch, so a failed checks query silently resolved to [] and ChecksPanel rendered the misleading "No checks in this window" empty state with no way to retry. Restore the error UI (Failed to load checks + Retry) by wrapping the ChecksPanel render in Result.builder(checksResult).onError(...), and re-add the refreshChecks atom refresh that powers the retry. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/routes/alerts/$ruleId.tsx | 37 +++++++++++++++++++++----- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/apps/web/src/routes/alerts/$ruleId.tsx b/apps/web/src/routes/alerts/$ruleId.tsx index 12043f6e..090a5ee6 100644 --- a/apps/web/src/routes/alerts/$ruleId.tsx +++ b/apps/web/src/routes/alerts/$ruleId.tsx @@ -126,6 +126,7 @@ function RuleDetailContent() { reactivityKeys: ["alertChecks", ruleId, since, until], }) const checksResult = useAtomValue(checksQueryAtom) + const refreshChecks = useAtomRefresh(checksQueryAtom) const rules = Result.builder(rulesResult) .onSuccess((response) => response.rules) @@ -564,13 +565,35 @@ function RuleDetailContent() {
- + {Result.builder(checksResult) + .onError((error) => ( +
+

Checks

+ + + + + + Failed to load checks + + {error.message ?? "Try refreshing or check API logs."} + + + + +
+ )) + .orElse(() => ( + + ))}
)} From 27925495da493c4a32fc2d354091ed8ce03dd023 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 1 Jul 2026 01:07:07 +0200 Subject: [PATCH 3/3] fix(alerts): base axis label formatting on drawn domain, not data span The X-axis is explicitly drawn over [domain.min, domain.max], so the adaptive tick-label decision (include date / seconds) should follow the axis width the ticks actually cover, not the data range. Derive axisContext.rangeMs from the domain; keep bucketSeconds data-derived for tick granularity. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/components/alerts/alert-signal-chart.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/alerts/alert-signal-chart.tsx b/apps/web/src/components/alerts/alert-signal-chart.tsx index 77e0dc8b..75db08e8 100644 --- a/apps/web/src/components/alerts/alert-signal-chart.tsx +++ b/apps/web/src/components/alerts/alert-signal-chart.tsx @@ -150,12 +150,13 @@ export function AlertSignalChart({ const hasSignal = chartData.length > 0 // Adaptive time-axis labels reuse the warehouse formatter via an ISO round-trip. + // `rangeMs` follows the drawn axis domain (not the data span) so the include-date + // decision matches the width the ticks actually cover; `bucketSeconds` stays + // data-derived for tick granularity. const axisContext = React.useMemo(() => { - if (chartData.length < 2) return { rangeMs: domain.max - domain.min, bucketSeconds: undefined } - const first = chartData[0]!.t - const second = chartData[1]!.t - const last = chartData[chartData.length - 1]!.t - return { rangeMs: last - first, bucketSeconds: (second - first) / 1000 } + const rangeMs = domain.max - domain.min + if (chartData.length < 2) return { rangeMs, bucketSeconds: undefined } + return { rangeMs, bucketSeconds: (chartData[1]!.t - chartData[0]!.t) / 1000 } }, [chartData, domain]) const formatTime = React.useCallback(