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..75db08e8 --- /dev/null +++ b/apps/web/src/components/alerts/alert-signal-chart.tsx @@ -0,0 +1,516 @@ +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. + // `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(() => { + 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( + (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..090a5ee6 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({ @@ -355,7 +353,6 @@ function RuleDetailContent() { Overview History - Checks
@@ -425,47 +422,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 +564,36 @@ function RuleDetailContent() { + + {Result.builder(checksResult) + .onError((error) => ( +
+

Checks

+ + + + + + Failed to load checks + + {error.message ?? "Try refreshing or check API logs."} + + + + +
+ )) + .orElse(() => ( + + ))} )} @@ -864,34 +862,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 +936,8 @@ function ChecksPanel({ } return ( -
+
+

Checks

-
-
-

Observed values

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

All checks