diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index 607c828295..8950a95878 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -18,6 +18,10 @@ import * as HubMessage from "../page/alerts/hub/message" import * as HubModel from "../page/alerts/hub/model" import * as HubUpdate from "../page/alerts/hub/update" import { view as hubView } from "../page/alerts/hub/view" +import type { Incident } from "../page/overview/model" +import { alertFiringHero, heroCountsOf } from "../page/overview/view-hero" +import { vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" import * as RuleMessage from "../page/alerts/rule/message" import * as RuleModel from "../page/alerts/rule/model" import * as RuleUpdate from "../page/alerts/rule/update" @@ -48,7 +52,7 @@ import type { Resources } from "../platform/resources" // `/lab/anomaly-alerts`: anomalies paging through alert rules, over fixtures. `?state=` picks one: // editor-new, editor-blank, editor-scoped, editor-default, editor-critical-destination, incident-warning, incident-critical, incident-spike, -// hub-anomalies, hub-anomalies-resolved, hub-anomalies-empty, hub-rules, rule-anomaly. +// hub-anomalies, hub-anomalies-resolved, hub-anomalies-empty, hub-rules, rule-anomaly, overview-banner. export const LabState = Schema.Literals([ "editor-new", @@ -64,6 +68,7 @@ export const LabState = Schema.Literals([ "hub-anomalies-empty", "hub-rules", "rule-anomaly", + "overview-banner", ]) export type LabState = typeof LabState.Type @@ -414,37 +419,68 @@ export const breadcrumbs = (model: Model) => ? incidentCrumbs(model.incident) : [{ label: "Alerts" }] +const openIncident = (severity: Incident["severity"], signalType: Incident["signalType"]): Incident => ({ + status: "open", + groupKey: null, + severity, + signalType, +}) +const BANNER_RULES = [...Arr.replicate(true, 18), false] + +// The Overview's alert strip in each case it draws (all clear draws none), each one captioned. +const BANNER_CASES: ReadonlyArray]> = [ + ["Only an anomaly open", [openIncident("critical", "anomaly")]], + ["Only an incident firing", [openIncident("critical", "error_rate")]], + [ + "An incident firing and an anomaly open", + [openIncident("critical", "anomaly"), openIncident("warning", "error_rate")], + ], +] + +const overviewBanners = (h: HtmlBuilder): Html => + vstack(h, { gap: "6", className: "p-6" }, [ + ...Arr.map(BANNER_CASES, ([caption, incidents]) => + vstack(h, { gap: "2" }, [ + typography(h, { size: "xs", tone: "muted" }, [caption]), + ...alertFiringHero(h, heroCountsOf(incidents, BANNER_RULES)), + ]), + ), + ]) + export const view = (model: Model, h: HtmlBuilder): Html => - model.state === "rule-anomaly" - ? h.submodel({ - slotId: "lab-anomaly-rule", - model: model.rule, - view: ruleView, - viewInputs: { context: CONTEXT }, - toParentMessage: (message: RuleMessage.Message) => Message.GotRuleMessage({ message }), - }) - : isEditor(model.state) + model.state === "overview-banner" + ? overviewBanners(h) + : model.state === "rule-anomaly" ? h.submodel({ - slotId: "lab-anomaly-editor", - model: model.editor, - view: editorView, + slotId: "lab-anomaly-rule", + model: model.rule, + view: ruleView, viewInputs: { context: CONTEXT }, - toParentMessage: (message: EditorMessage.Message) => - Message.GotEditorMessage({ message }), + toParentMessage: (message: RuleMessage.Message) => Message.GotRuleMessage({ message }), }) - : isIncident(model.state) + : isEditor(model.state) ? h.submodel({ - slotId: "lab-anomaly-incident", - model: model.incident, - view: incidentView, - viewInputs: { context: CONTEXT }, - toParentMessage: (message: IncidentMessage.Message) => - Message.GotIncidentMessage({ message }), - }) - : h.submodel({ - slotId: "lab-anomaly-hub", - model: model.hub, - view: hubView, + slotId: "lab-anomaly-editor", + model: model.editor, + view: editorView, viewInputs: { context: CONTEXT }, - toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), + toParentMessage: (message: EditorMessage.Message) => + Message.GotEditorMessage({ message }), }) + : isIncident(model.state) + ? h.submodel({ + slotId: "lab-anomaly-incident", + model: model.incident, + view: incidentView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: IncidentMessage.Message) => + Message.GotIncidentMessage({ message }), + }) + : h.submodel({ + slotId: "lab-anomaly-hub", + model: model.hub, + view: hubView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: HubMessage.Message) => + Message.GotHubMessage({ message }), + }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index f67eaadb1f..c3a1997846 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -97,7 +97,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/anomaly-alerts", title: "Anomaly alerts", description: - "Anomaly rules in the rule editor (scoped and the protected default), an anomaly rule's incident with the detector's evidence, and the Inbox's Anomalies view (`?state=`).", + "Anomaly rules in the rule editor (scoped and the protected default), an anomaly rule's incident with the detector's evidence, the Inbox's Anomalies view and the Overview's alert banner with open anomalies (`?state=`).", kind: "lab", session: "none", }, diff --git a/apps/web/src/page/overview/overview.scene.test.ts b/apps/web/src/page/overview/overview.scene.test.ts index 4e53658107..1c55ece980 100644 --- a/apps/web/src/page/overview/overview.scene.test.ts +++ b/apps/web/src/page/overview/overview.scene.test.ts @@ -8,7 +8,7 @@ import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colo import { toLoadFailure } from "@maple/views/data/load-failure" import { PageOutMessage, type PageContext } from "../contract" import { Message } from "./message" -import type { FacetsHint, HealthSnapshot, Model } from "./model" +import type { FacetsHint, HealthSnapshot, Incident, Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" import { healthData } from "./view-health" @@ -46,15 +46,17 @@ const withFacets = (model: Model, environments: ReadonlyArray) => ) /** Everything the health sections block on has loaded, with `rows` in the snapshot. */ -const loaded = (rows: ReadonlyArray): Model => { +const loaded = ( + rows: ReadonlyArray, + incidents: ReadonlyArray = [ + { status: "open", groupKey: null, severity: "critical", signalType: "error_rate" }, + ], +): Model => { const facets = withFacets(entered().model, ["production"]).model const steps: ReadonlyArray = [ Message.CompletedFetchSnapshot({ key: facets.snapshot.key ?? "", result: Result.succeed(rows) }), Message.CompletedFetchAnomalies({ key: facets.anomalies.key ?? "", result: Result.succeed([]) }), - Message.UpdatedIncidents({ - orgId, - incidents: [{ status: "open", groupKey: null, severity: "critical", signalType: "error_rate" }], - }), + Message.UpdatedIncidents({ orgId, incidents }), Message.UpdatedRules({ orgId, enabled: [true, false] }), ] return Arr.reduce(steps, facets, (model, message) => pageUpdate(model, message).model) @@ -241,7 +243,7 @@ describe("overview", () => { [ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })], [MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()], ), - expectView(text("All clear")).toBeAbsent(), + expectView(text("Firing now:")).toBeAbsent(), ) }) @@ -271,13 +273,49 @@ describe("overview", () => { given(loaded([snapshotRow("checkout"), snapshotRow("cart")])), themeResolved(), cursorMounted(), - expectView(text("Firing now")).toExist(), - expectView(text("1 critical")).toExist(), + expectView(text("Firing now:")).toExist(), + expectView(text("1 incident · 1 critical")).toExist(), expectView(role("link", { name: "View healthy services" })).toExist(), expectView(role("link", { name: /checkout/ })).toExist(), ) }) + test("an open anomaly-rule incident is an open anomaly, not a firing incident", () => { + const anomaly: Incident = { + status: "open", + groupKey: null, + severity: "critical", + signalType: "anomaly", + } + scene( + { update: pageUpdate, view }, + given(loaded([snapshotRow("checkout")], [anomaly])), + themeResolved(), + cursorMounted(), + expectView(text("Open:")).toExist(), + expectView(text("1 anomaly")).toExist(), + expectView(text("Firing now:")).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given( + loaded( + [snapshotRow("checkout")], + [ + anomaly, + { status: "open", groupKey: null, severity: "warning", signalType: "error_rate" }, + ], + ), + ), + themeResolved(), + cursorMounted(), + expectView(text("Firing now:")).toExist(), + expectView(text("1 incident · 1 warning")).toExist(), + expectView(text("Open:")).toExist(), + expectView(text("1 anomaly")).toExist(), + ) + }) + test("a card handed no rows says so instead of drawing bare axes", () => { const start = withPresence(loaded([snapshotRow("checkout")]), "present") const empty = pageUpdate( diff --git a/apps/web/src/page/overview/view-health.ts b/apps/web/src/page/overview/view-health.ts index 6ae43954ee..25a8460020 100644 --- a/apps/web/src/page/overview/view-health.ts +++ b/apps/web/src/page/overview/view-health.ts @@ -27,7 +27,7 @@ import { type ServiceHealthCause, } from "./derive" import type { HealthSnapshot, Incident, Model } from "./model" -import { alertFiringHero } from "./view-hero" +import { alertFiringHero, heroCountsOf } from "./view-hero" import { statRail, statRailItem, statRailLoading } from "../kit/stat-rail" import { hstack, vstack } from "@maple/views/ui/stack" import { Array as Arr, Option } from "effect" @@ -105,21 +105,10 @@ export type HealthOverviewInputs = Readonly<{ /** `ServiceHealthOverview`: the banner once alerts are known; the rail loading, absent on error, counted on success. */ export const healthOverview = (h: HtmlBuilder, { model, slice, nowMs }: HealthOverviewInputs): Html => { const incidents = AsyncData.getOrElse(model.incidents, () => []) - const open = Arr.filter(incidents, (incident) => incident.status === "open") const rules = AsyncData.getOrElse(model.rules, () => []) - // "All clear" claims to know: without live alerts (loading or sync down) the banner stays out. + // Without live alerts (loading or sync down) the strip stays out. const isAlertsKnown = AsyncData.isSuccess(model.incidents) && AsyncData.isSuccess(model.rules) - const banner = !isAlertsKnown - ? [] - : [ - alertFiringHero(h, { - openCount: open.length, - criticalCount: Arr.filter(open, (incident) => incident.severity === "critical").length, - warningCount: Arr.filter(open, (incident) => incident.severity === "warning").length, - rulesEnabled: Arr.filter(rules, (enabled) => enabled).length, - rulesTotal: rules.length, - }), - ] + const banner = !isAlertsKnown ? [] : alertFiringHero(h, heroCountsOf(incidents, rules)) const data = healthData(model) return AsyncData.matchData(data, { onEmpty: () => section(h, "mb-4 space-y-3", false, [...banner, statRailLoading(h, 4)]), diff --git a/apps/web/src/page/overview/view-hero.ts b/apps/web/src/page/overview/view-hero.ts index 08285b7c0f..1e3f1f5467 100644 --- a/apps/web/src/page/overview/view-hero.ts +++ b/apps/web/src/page/overview/view-hero.ts @@ -1,111 +1,124 @@ import { pluralize } from "@maple/ui/lib/format" import type { Html, HtmlBuilder } from "foldkit/html" import { card, cardPanel } from "@maple/views/ui/card" -import { eyebrow } from "@maple/views/ui/eyebrow" import { statusDot } from "@maple/views/ui/status-dot" import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { Array as Arr } from "effect" +import type { Incident } from "./model" -// The firing-alerts hero, without the last-evaluated hint. +// The one-line alert strip above the health rail; nothing when all is clear. +// Open anomalies count apart from firing incidents. export type HeroCounts = Readonly<{ + /** Open alert incidents, anomaly rules' excluded. */ openCount: number criticalCount: number warningCount: number + /** Open incidents of anomaly rules. */ + anomalyCount: number rulesEnabled: number rulesTotal: number }> -const PANEL = "flex flex-wrap items-center justify-between gap-x-6 gap-y-2 px-5 py-3.5" +/** The strip's counts from the alert incidents and each rule's `enabled`. Anomaly rules' incidents are open anomalies. */ +export const heroCountsOf = ( + incidents: ReadonlyArray, + rules: ReadonlyArray, +): HeroCounts => { + const openAll = Arr.filter(incidents, (incident) => incident.status === "open") + const open = Arr.filter(openAll, (incident) => incident.signalType !== "anomaly") + return { + openCount: open.length, + criticalCount: Arr.filter(open, (incident) => incident.severity === "critical").length, + warningCount: Arr.filter(open, (incident) => incident.severity === "warning").length, + anomalyCount: openAll.length - open.length, + rulesEnabled: Arr.filter(rules, (enabled) => enabled).length, + rulesTotal: rules.length, + } +} -const allClear = (h: HtmlBuilder, counts: HeroCounts): Html => - card(h, [ - cardPanel( - h, - [ - hstack(h, { gap: "3", shrink: true }, [ - statusDot(h, { tone: "ok", size: "lg" }), - hstack(h, { align: "baseline", wrap: true, className: "gap-x-2 gap-y-0.5" }, [ - typography(h, { size: "base", weight: "semibold", className: "tracking-tight" }, [ - "All clear", - ]), - typography(h, { size: "sm", tone: "muted" }, [ - typography(h, { tone: "foreground", weight: "medium", numeric: true }, [ - String(counts.rulesEnabled), - ]), - typography(h, { tone: "subtle" }, [" / "]), - typography(h, { numeric: true }, [String(counts.rulesTotal)]), - h.span([h.Class("ml-1")], ["rules watching"]), - ]), - ]), - ]), - ], - PANEL, - ), - ]) +type Tone = "crit" | "warn" + +const TONE_TEXT = { crit: "text-severity-error", warn: "text-severity-warn" } satisfies Record +const TONE_CARD = { + crit: "border-severity-error-a3 bg-severity-error/[0.04]", + warn: "border-severity-warn-a3 bg-severity-warn/[0.04]", +} satisfies Record + +const joinParts = (parts: ReadonlyArray): string => + Arr.join( + Arr.filter(parts, (part) => part !== ""), + " · ", + ) + +const countOf = (count: number, singular: string, plural?: string): string => + count > 0 ? `${count} ${pluralize(count, singular, plural)}` : "" -const rulesSummary = (h: HtmlBuilder, counts: HeroCounts): Html => - typography(h, { numeric: true }, [ - typography(h, { tone: "foreground", weight: "medium" }, [String(counts.rulesEnabled)]), - typography(h, { tone: "subtle" }, ["/"]), - h.span([], [String(counts.rulesTotal)]), - h.span([h.Class("ml-1")], ["rules"]), +type Segment = Readonly<{ tone: Tone; title: string; detail: string }> + +// One segment of the strip: its dot, its title in its tone, then the muted detail. +const segment = (h: HtmlBuilder, { tone, title, detail }: Segment): Html => + hstack(h, { gap: "2", shrink: true }, [ + statusDot(h, { tone }), + typography(h, { size: "sm", truncate: true }, [ + typography(h, { weight: "medium", className: TONE_TEXT[tone] }, [title]), + typography(h, { tone: "muted", className: "ml-1" }, [detail]), + ]), ]) -const firing = (h: HtmlBuilder, counts: HeroCounts): Html => { - const label = - Arr.join( - Arr.filter( - [ - counts.criticalCount > 0 ? `${counts.criticalCount} critical` : "", - counts.warningCount > 0 ? `${counts.warningCount} warning` : "", - ], - (part) => part !== "", - ), - " · ", - ) || `${counts.openCount} open` - return card( +const strip = ( + h: HtmlBuilder, + segments: Arr.NonEmptyReadonlyArray, + counts: HeroCounts, +): Html => + card( h, [ cardPanel( h, [ - hstack(h, { gap: "3", shrink: true }, [ - statusDot(h, { tone: "crit", size: "lg" }), - hstack(h, { align: "baseline", wrap: true, className: "gap-x-2.5 gap-y-0.5" }, [ - eyebrow(h, { variant: "label", className: "text-severity-error" }, [ - "Firing now", - ]), - hstack(h, { gap: "1.5", align: "baseline", as: "span" }, [ - h.span( - [ - h.Class( - "text-severity-error text-2xl font-semibold tabular-nums leading-none", - ), - ], - [String(counts.openCount)], - ), - typography(h, { size: "sm", tone: "muted" }, [ - pluralize(counts.openCount, "incident"), - ]), - ]), - typography(h, { tone: "faint" }, ["·"]), - typography(h, { size: "sm", tone: "muted" }, [label]), + hstack(h, { gap: "6", shrink: true, wrap: true, className: "gap-y-1" }, [ + ...Arr.map(segments, (part) => segment(h, part)), + ]), + typography(h, { size: "xs", tone: "muted", numeric: true, className: "shrink-0" }, [ + typography(h, { tone: "foreground", weight: "medium" }, [ + String(counts.rulesEnabled), ]), + typography(h, { tone: "subtle" }, ["/"]), + `${counts.rulesTotal} rules`, ]), - hstack( - h, - { gap: "2", align: "center", className: "text-muted-foreground shrink-0 text-xs" }, - [rulesSummary(h, counts)], - ), ], - PANEL, + "flex items-center justify-between gap-4 px-4 py-2", ), ], - "border-severity-error-a3 bg-severity-error/[0.04]", + // The strip takes the worst segment's tone. + TONE_CARD[segments[0].tone], ) -} -export const alertFiringHero = (h: HtmlBuilder, counts: HeroCounts): Html => - counts.openCount > 0 ? firing(h, counts) : allClear(h, counts) +/** + * The alert strip, absent when all clear: firing incidents (red) and open anomalies (amber) side by + * side, one rules count. Anomaly rules' incidents are open anomalies, never firing. + */ +export const alertFiringHero = (h: HtmlBuilder, counts: HeroCounts): ReadonlyArray => { + const firing: ReadonlyArray = + counts.openCount > 0 + ? [ + { + tone: "crit", + title: "Firing now:", + detail: joinParts([ + countOf(counts.openCount, "incident"), + countOf(counts.criticalCount, "critical", "critical"), + countOf(counts.warningCount, "warning"), + ]), + }, + ] + : [] + const anomalies: ReadonlyArray = + counts.anomalyCount > 0 + ? [{ tone: "warn", title: "Open:", detail: countOf(counts.anomalyCount, "anomaly", "anomalies") }] + : [] + const segments = [...firing, ...anomalies] + return Arr.isReadonlyArrayNonEmpty(segments) ? [strip(h, segments, counts)] : [] +}