From d0eb76f623266c15eac83ce8fa251a8945540c90 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 23:51:50 +0100 Subject: [PATCH 1/3] fix(web): count open anomalies apart from firing incidents on the overview banner Anomaly rules open alert incidents, so a single open anomaly showed as a red "Firing now 1 incident". The banner now counts only non-anomaly incidents as firing, shows an amber "Open anomalies" banner when only anomalies are open, and appends the open anomaly count to the firing one. --- apps/web/src/lab/anomaly-alerts-lab.ts | 85 ++++++++---- apps/web/src/lab/registry.ts | 2 +- .../src/page/overview/overview.scene.test.ts | 47 ++++++- apps/web/src/page/overview/view-health.ts | 15 +-- apps/web/src/page/overview/view-hero.ts | 122 ++++++++++++++---- 5 files changed, 196 insertions(+), 75 deletions(-) diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index 607c828295..953202e9f0 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -18,6 +18,9 @@ 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 * 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 +51,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 +67,7 @@ export const LabState = Schema.Literals([ "hub-anomalies-empty", "hub-rules", "rule-anomaly", + "overview-banner", ]) export type LabState = typeof LabState.Type @@ -414,37 +418,62 @@ 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 banner: only an anomaly open, an incident firing beside an anomaly, all clear. +const overviewBanners = (h: HtmlBuilder): Html => + vstack(h, { gap: "4", className: "p-6" }, [ + alertFiringHero(h, heroCountsOf([openIncident("critical", "anomaly")], BANNER_RULES)), + alertFiringHero( + h, + heroCountsOf( + [openIncident("critical", "anomaly"), openIncident("warning", "error_rate")], + BANNER_RULES, + ), + ), + alertFiringHero(h, heroCountsOf([], 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..246ec477bc 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) @@ -278,6 +280,39 @@ describe("overview", () => { ) }) + 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 anomalies")).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 warning · 1 open 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..a1c8e9df24 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. 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..edb7b5cbd3 100644 --- a/apps/web/src/page/overview/view-hero.ts +++ b/apps/web/src/page/overview/view-hero.ts @@ -6,17 +6,38 @@ 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 firing-alerts hero, without the last-evaluated hint. Open anomalies count apart from 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 }> +/** The banner'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 PANEL = "flex flex-wrap items-center justify-between gap-x-6 gap-y-2 px-5 py-3.5" const allClear = (h: HtmlBuilder, counts: HeroCounts): Html => @@ -53,45 +74,50 @@ const rulesSummary = (h: HtmlBuilder, counts: HeroCounts): Html => h.span([h.Class("ml-1")], ["rules"]), ]) -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( +type ActiveTone = "crit" | "warn" + +const TONE_TEXT = { crit: "text-severity-error", warn: "text-severity-warn" } satisfies Record< + ActiveTone, + string +> +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 + +type ActiveBanner = Readonly<{ tone: ActiveTone; title: string; count: number; noun: string; detail: string }> + +// A banner for something open: the title, a big count and its noun, a detail, and the rules. +const active = (h: HtmlBuilder, banner: ActiveBanner, counts: HeroCounts): Html => + card( h, [ cardPanel( h, [ hstack(h, { gap: "3", shrink: true }, [ - statusDot(h, { tone: "crit", size: "lg" }), + statusDot(h, { tone: banner.tone, 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", + eyebrow(h, { variant: "label", className: TONE_TEXT[banner.tone] }, [ + banner.title, ]), hstack(h, { gap: "1.5", align: "baseline", as: "span" }, [ h.span( [ h.Class( - "text-severity-error text-2xl font-semibold tabular-nums leading-none", + `${TONE_TEXT[banner.tone]} text-2xl font-semibold tabular-nums leading-none`, ), ], - [String(counts.openCount)], + [String(banner.count)], ), - typography(h, { size: "sm", tone: "muted" }, [ - pluralize(counts.openCount, "incident"), - ]), + typography(h, { size: "sm", tone: "muted" }, [banner.noun]), ]), - typography(h, { tone: "faint" }, ["·"]), - typography(h, { size: "sm", tone: "muted" }, [label]), + ...(banner.detail === "" + ? [] + : [ + typography(h, { tone: "faint" }, ["·"]), + typography(h, { size: "sm", tone: "muted" }, [banner.detail]), + ]), ]), ]), hstack( @@ -103,9 +129,51 @@ const firing = (h: HtmlBuilder, counts: HeroCounts): Html => { PANEL, ), ], - "border-severity-error-a3 bg-severity-error/[0.04]", + TONE_CARD[banner.tone], + ) + +const joinParts = (parts: ReadonlyArray): string => + Arr.join( + Arr.filter(parts, (part) => part !== ""), + " · ", ) -} +const openAnomalies = (count: number): string => + count > 0 ? `${count} ${pluralize(count, "open anomaly", "open anomalies")}` : "" + +/** + * Alert incidents fire; anomaly-rule incidents are open anomalies, never counted as firing. + * Only anomalies open: an amber banner, not the red one. + */ export const alertFiringHero = (h: HtmlBuilder, counts: HeroCounts): Html => - counts.openCount > 0 ? firing(h, counts) : allClear(h, counts) + counts.openCount > 0 + ? active( + h, + { + tone: "crit", + title: "Firing now", + count: counts.openCount, + noun: pluralize(counts.openCount, "incident"), + detail: joinParts([ + joinParts([ + counts.criticalCount > 0 ? `${counts.criticalCount} critical` : "", + counts.warningCount > 0 ? `${counts.warningCount} warning` : "", + ]) || `${counts.openCount} open`, + openAnomalies(counts.anomalyCount), + ]), + }, + counts, + ) + : counts.anomalyCount > 0 + ? active( + h, + { + tone: "warn", + title: "Open anomalies", + count: counts.anomalyCount, + noun: pluralize(counts.anomalyCount, "anomaly", "anomalies"), + detail: "", + }, + counts, + ) + : allClear(h, counts) From acfa37f05b55a451301b21162cb124ff0b512768 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 23:56:01 +0100 Subject: [PATCH 2/3] fix(web): slim the overview alert banner to one line, hidden when all clear --- apps/web/src/lab/anomaly-alerts-lab.ts | 8 +- .../src/page/overview/overview.scene.test.ts | 15 +- apps/web/src/page/overview/view-health.ts | 4 +- apps/web/src/page/overview/view-hero.ts | 166 +++++------------- 4 files changed, 60 insertions(+), 133 deletions(-) diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index 953202e9f0..0803290632 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -426,18 +426,18 @@ const openIncident = (severity: Incident["severity"], signalType: Incident["sign }) const BANNER_RULES = [...Arr.replicate(true, 18), false] -// The Overview banner: only an anomaly open, an incident firing beside an anomaly, all clear. +// The Overview's alert strip: only an anomaly open, then an incident firing beside an anomaly. +// All clear draws no strip. const overviewBanners = (h: HtmlBuilder): Html => vstack(h, { gap: "4", className: "p-6" }, [ - alertFiringHero(h, heroCountsOf([openIncident("critical", "anomaly")], BANNER_RULES)), - alertFiringHero( + ...alertFiringHero(h, heroCountsOf([openIncident("critical", "anomaly")], BANNER_RULES)), + ...alertFiringHero( h, heroCountsOf( [openIncident("critical", "anomaly"), openIncident("warning", "error_rate")], BANNER_RULES, ), ), - alertFiringHero(h, heroCountsOf([], BANNER_RULES)), ]) export const view = (model: Model, h: HtmlBuilder): Html => diff --git a/apps/web/src/page/overview/overview.scene.test.ts b/apps/web/src/page/overview/overview.scene.test.ts index 246ec477bc..5854af74c0 100644 --- a/apps/web/src/page/overview/overview.scene.test.ts +++ b/apps/web/src/page/overview/overview.scene.test.ts @@ -243,7 +243,7 @@ describe("overview", () => { [ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })], [MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()], ), - expectView(text("All clear")).toBeAbsent(), + expectView(text("Firing now:")).toBeAbsent(), ) }) @@ -273,8 +273,8 @@ 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(), ) @@ -292,8 +292,9 @@ describe("overview", () => { given(loaded([snapshotRow("checkout")], [anomaly])), themeResolved(), cursorMounted(), - expectView(text("Open anomalies")).toExist(), - expectView(text("Firing now")).not.toExist(), + expectView(text("Open:")).toExist(), + expectView(text("1 anomaly")).toExist(), + expectView(text("Firing now:")).not.toExist(), ) scene( { update: pageUpdate, view }, @@ -308,8 +309,8 @@ describe("overview", () => { ), themeResolved(), cursorMounted(), - expectView(text("Firing now")).toExist(), - expectView(text("1 warning · 1 open anomaly")).toExist(), + expectView(text("Firing now:")).toExist(), + expectView(text("1 incident · 1 warning · 1 open anomaly")).toExist(), ) }) diff --git a/apps/web/src/page/overview/view-health.ts b/apps/web/src/page/overview/view-health.ts index a1c8e9df24..25a8460020 100644 --- a/apps/web/src/page/overview/view-health.ts +++ b/apps/web/src/page/overview/view-health.ts @@ -106,9 +106,9 @@ export type HealthOverviewInputs = Readonly<{ export const healthOverview = (h: HtmlBuilder, { model, slice, nowMs }: HealthOverviewInputs): Html => { const incidents = AsyncData.getOrElse(model.incidents, () => []) 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, heroCountsOf(incidents, rules))] + 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 edb7b5cbd3..0937fda622 100644 --- a/apps/web/src/page/overview/view-hero.ts +++ b/apps/web/src/page/overview/view-hero.ts @@ -1,14 +1,14 @@ 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. Open anomalies count apart from incidents. +// 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. */ @@ -21,7 +21,7 @@ export type HeroCounts = Readonly<{ rulesTotal: number }> -/** The banner's counts from the alert incidents and each rule's `enabled`. Anomaly rules' incidents are open anomalies. */ +/** 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, @@ -38,142 +38,68 @@ export const heroCountsOf = ( } } -const PANEL = "flex flex-wrap items-center justify-between gap-x-6 gap-y-2 px-5 py-3.5" +type Tone = "crit" | "warn" -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, - ), - ]) - -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 ActiveTone = "crit" | "warn" - -const TONE_TEXT = { crit: "text-severity-error", warn: "text-severity-warn" } satisfies Record< - ActiveTone, - string -> +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 +} satisfies Record -type ActiveBanner = Readonly<{ tone: ActiveTone; title: string; count: number; noun: string; detail: string }> +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)}` : "" -// A banner for something open: the title, a big count and its noun, a detail, and the rules. -const active = (h: HtmlBuilder, banner: ActiveBanner, counts: HeroCounts): Html => +const strip = (h: HtmlBuilder, tone: Tone, title: string, detail: string, counts: HeroCounts): Html => card( h, [ cardPanel( h, [ - hstack(h, { gap: "3", shrink: true }, [ - statusDot(h, { tone: banner.tone, size: "lg" }), - hstack(h, { align: "baseline", wrap: true, className: "gap-x-2.5 gap-y-0.5" }, [ - eyebrow(h, { variant: "label", className: TONE_TEXT[banner.tone] }, [ - banner.title, - ]), - hstack(h, { gap: "1.5", align: "baseline", as: "span" }, [ - h.span( - [ - h.Class( - `${TONE_TEXT[banner.tone]} text-2xl font-semibold tabular-nums leading-none`, - ), - ], - [String(banner.count)], - ), - typography(h, { size: "sm", tone: "muted" }, [banner.noun]), - ]), - ...(banner.detail === "" - ? [] - : [ - typography(h, { tone: "faint" }, ["·"]), - typography(h, { size: "sm", tone: "muted" }, [banner.detail]), - ]), + 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]), + ]), + ]), + 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", ), ], - TONE_CARD[banner.tone], - ) - -const joinParts = (parts: ReadonlyArray): string => - Arr.join( - Arr.filter(parts, (part) => part !== ""), - " · ", + TONE_CARD[tone], ) -const openAnomalies = (count: number): string => - count > 0 ? `${count} ${pluralize(count, "open anomaly", "open anomalies")}` : "" - -/** - * Alert incidents fire; anomaly-rule incidents are open anomalies, never counted as firing. - * Only anomalies open: an amber banner, not the red one. - */ -export const alertFiringHero = (h: HtmlBuilder, counts: HeroCounts): Html => +/** The alert strip: red while incidents fire, amber when only anomalies are open, absent when all clear. */ +export const alertFiringHero = (h: HtmlBuilder, counts: HeroCounts): ReadonlyArray => counts.openCount > 0 - ? active( - h, - { - tone: "crit", - title: "Firing now", - count: counts.openCount, - noun: pluralize(counts.openCount, "incident"), - detail: joinParts([ - joinParts([ - counts.criticalCount > 0 ? `${counts.criticalCount} critical` : "", - counts.warningCount > 0 ? `${counts.warningCount} warning` : "", - ]) || `${counts.openCount} open`, - openAnomalies(counts.anomalyCount), - ]), - }, - counts, - ) - : counts.anomalyCount > 0 - ? active( + ? [ + strip( h, - { - tone: "warn", - title: "Open anomalies", - count: counts.anomalyCount, - noun: pluralize(counts.anomalyCount, "anomaly", "anomalies"), - detail: "", - }, + "crit", + "Firing now:", + joinParts([ + countOf(counts.openCount, "incident"), + countOf(counts.criticalCount, "critical", "critical"), + countOf(counts.warningCount, "warning"), + countOf(counts.anomalyCount, "open anomaly", "open anomalies"), + ]), counts, - ) - : allClear(h, counts) + ), + ] + : counts.anomalyCount > 0 + ? [strip(h, "warn", "Open:", countOf(counts.anomalyCount, "anomaly", "anomalies"), counts)] + : [] From eaab70e4cff60dfc5498bd9162ecdf4c40ad4e4b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 8 Nov 2026 00:01:12 +0100 Subject: [PATCH 3/3] fix(web): firing incidents and open anomalies side by side in one alert strip --- apps/web/src/lab/anomaly-alerts-lab.ts | 27 ++++--- .../src/page/overview/overview.scene.test.ts | 4 +- apps/web/src/page/overview/view-hero.ts | 73 ++++++++++++------- 3 files changed, 66 insertions(+), 38 deletions(-) diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index 0803290632..8950a95878 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -21,6 +21,7 @@ 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" @@ -426,17 +427,23 @@ const openIncident = (severity: Incident["severity"], signalType: Incident["sign }) const BANNER_RULES = [...Arr.replicate(true, 18), false] -// The Overview's alert strip: only an anomaly open, then an incident firing beside an anomaly. -// All clear draws no strip. +// 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: "4", className: "p-6" }, [ - ...alertFiringHero(h, heroCountsOf([openIncident("critical", "anomaly")], BANNER_RULES)), - ...alertFiringHero( - h, - heroCountsOf( - [openIncident("critical", "anomaly"), openIncident("warning", "error_rate")], - BANNER_RULES, - ), + 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)), + ]), ), ]) diff --git a/apps/web/src/page/overview/overview.scene.test.ts b/apps/web/src/page/overview/overview.scene.test.ts index 5854af74c0..1c55ece980 100644 --- a/apps/web/src/page/overview/overview.scene.test.ts +++ b/apps/web/src/page/overview/overview.scene.test.ts @@ -310,7 +310,9 @@ describe("overview", () => { themeResolved(), cursorMounted(), expectView(text("Firing now:")).toExist(), - expectView(text("1 incident · 1 warning · 1 open anomaly")).toExist(), + expectView(text("1 incident · 1 warning")).toExist(), + expectView(text("Open:")).toExist(), + expectView(text("1 anomaly")).toExist(), ) }) diff --git a/apps/web/src/page/overview/view-hero.ts b/apps/web/src/page/overview/view-hero.ts index 0937fda622..1e3f1f5467 100644 --- a/apps/web/src/page/overview/view-hero.ts +++ b/apps/web/src/page/overview/view-hero.ts @@ -55,19 +55,31 @@ const joinParts = (parts: ReadonlyArray): string => const countOf = (count: number, singular: string, plural?: string): string => count > 0 ? `${count} ${pluralize(count, singular, plural)}` : "" -const strip = (h: HtmlBuilder, tone: Tone, title: string, detail: string, counts: HeroCounts): Html => +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 strip = ( + h: HtmlBuilder, + segments: Arr.NonEmptyReadonlyArray, + counts: HeroCounts, +): Html => card( h, [ cardPanel( h, [ - 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]), - ]), + 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" }, [ @@ -80,26 +92,33 @@ const strip = (h: HtmlBuilder, tone: Tone, title: string, detail: string, "flex items-center justify-between gap-4 px-4 py-2", ), ], - TONE_CARD[tone], + // The strip takes the worst segment's tone. + TONE_CARD[segments[0].tone], ) -/** The alert strip: red while incidents fire, amber when only anomalies are open, absent when all clear. */ -export const alertFiringHero = (h: HtmlBuilder, counts: HeroCounts): ReadonlyArray => - counts.openCount > 0 - ? [ - strip( - h, - "crit", - "Firing now:", - joinParts([ - countOf(counts.openCount, "incident"), - countOf(counts.criticalCount, "critical", "critical"), - countOf(counts.warningCount, "warning"), - countOf(counts.anomalyCount, "open anomaly", "open anomalies"), - ]), - counts, - ), - ] - : counts.anomalyCount > 0 - ? [strip(h, "warn", "Open:", countOf(counts.anomalyCount, "anomaly", "anomalies"), 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)] : [] +}