diff --git a/apps/web/src/page/overview/overview.scene.test.ts b/apps/web/src/page/overview/overview.scene.test.ts index 1c55ece980..b349b4927a 100644 --- a/apps/web/src/page/overview/overview.scene.test.ts +++ b/apps/web/src/page/overview/overview.scene.test.ts @@ -292,8 +292,7 @@ describe("overview", () => { given(loaded([snapshotRow("checkout")], [anomaly])), themeResolved(), cursorMounted(), - expectView(text("Open:")).toExist(), - expectView(text("1 anomaly")).toExist(), + expectView(role("link", { name: /1 open anomaly/ })).toHaveAttr("href", "/alerts?signal=anomaly"), expectView(text("Firing now:")).not.toExist(), ) scene( @@ -311,8 +310,7 @@ describe("overview", () => { cursorMounted(), expectView(text("Firing now:")).toExist(), expectView(text("1 incident · 1 warning")).toExist(), - expectView(text("Open:")).toExist(), - expectView(text("1 anomaly")).toExist(), + expectView(role("link", { name: /1 open anomaly/ })).toHaveAttr("href", "/alerts?signal=anomaly"), ) }) diff --git a/apps/web/src/page/overview/view-hero.ts b/apps/web/src/page/overview/view-hero.ts index 1e3f1f5467..88d7ff0af7 100644 --- a/apps/web/src/page/overview/view-hero.ts +++ b/apps/web/src/page/overview/view-hero.ts @@ -4,11 +4,13 @@ import { card, cardPanel } from "@maple/views/ui/card" import { statusDot } from "@maple/views/ui/status-dot" import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { alertsHref } from "../alerts/shared/links" import { Array as Arr } from "effect" import type { Incident } from "./model" // The one-line alert strip above the health rail; nothing when all is clear. -// Open anomalies count apart from firing incidents. +// Open anomalies count apart from firing incidents and stay quiet. export type HeroCounts = Readonly<{ /** Open alert incidents, anomaly rules' excluded. */ @@ -38,14 +40,6 @@ export const heroCountsOf = ( } } -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 !== ""), @@ -55,70 +49,59 @@ const joinParts = (parts: ReadonlyArray): string => const countOf = (count: number, singular: string, plural?: string): string => count > 0 ? `${count} ${pluralize(count, singular, plural)}` : "" -type Segment = Readonly<{ tone: Tone; title: string; detail: string }> +// Anomalies are hints, not pages: a quiet link to the inbox, never a tinted card of their own. +const anomalyLink = (h: HtmlBuilder, count: number): Html => + textLink(h, { href: alertsHref({ signal: "anomaly" }), tone: "muted", className: "shrink-0 text-xs" }, [ + statusDot(h, { tone: "warn" }), + `${countOf(count, "open anomaly", "open anomalies")} →`, + ]) -// 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 rulesCount = (h: HtmlBuilder, counts: HeroCounts): Html => + 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`, ]) -const strip = ( - h: HtmlBuilder, - segments: Arr.NonEmptyReadonlyArray, - counts: HeroCounts, -): Html => +const firingStrip = (h: HtmlBuilder, counts: HeroCounts): Html => card( h, [ cardPanel( h, [ - 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), + hstack(h, { gap: "2", shrink: true }, [ + statusDot(h, { tone: "crit" }), + typography(h, { size: "sm", truncate: true }, [ + typography(h, { weight: "medium", className: "text-severity-error" }, [ + "Firing now:", + ]), + typography(h, { tone: "muted", className: "ml-1" }, [ + joinParts([ + countOf(counts.openCount, "incident"), + countOf(counts.criticalCount, "critical", "critical"), + countOf(counts.warningCount, "warning"), + ]), + ]), ]), - typography(h, { tone: "subtle" }, ["/"]), - `${counts.rulesTotal} rules`, + ]), + hstack(h, { gap: "4" }, [ + ...(counts.anomalyCount > 0 ? [anomalyLink(h, counts.anomalyCount)] : []), + rulesCount(h, counts), ]), ], - "flex items-center justify-between gap-4 px-4 py-2", + "flex flex-wrap items-center justify-between gap-x-4 gap-y-1 px-4 py-2", ), ], - // The strip takes the worst segment's tone. - TONE_CARD[segments[0].tone], + "border-severity-error-a3 bg-severity-error/[0.04]", ) /** - * 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. + * The alert strip, absent when all clear. Firing incidents get the red card; open anomalies (anomaly + * rules' incidents, never firing) are a muted link beside it, or alone on a line when nothing fires. */ 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)] : [] + if (counts.openCount > 0) return [firingStrip(h, counts)] + if (counts.anomalyCount > 0) return [hstack(h, { justify: "end" }, [anomalyLink(h, counts.anomalyCount)])] + return [] }