Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 64 additions & 28 deletions apps/web/src/lab/anomaly-alerts-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -64,6 +68,7 @@ export const LabState = Schema.Literals([
"hub-anomalies-empty",
"hub-rules",
"rule-anomaly",
"overview-banner",
])
export type LabState = typeof LabState.Type

Expand Down Expand Up @@ -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<readonly [string, ReadonlyArray<Incident>]> = [
["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<Message>): 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<Message>): 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 }),
})
2 changes: 1 addition & 1 deletion apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
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",
},
Expand Down
56 changes: 47 additions & 9 deletions apps/web/src/page/overview/overview.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -46,15 +46,17 @@ const withFacets = (model: Model, environments: ReadonlyArray<string>) =>
)

/** Everything the health sections block on has loaded, with `rows` in the snapshot. */
const loaded = (rows: ReadonlyArray<HealthSnapshot>): Model => {
const loaded = (
rows: ReadonlyArray<HealthSnapshot>,
incidents: ReadonlyArray<Incident> = [
{ status: "open", groupKey: null, severity: "critical", signalType: "error_rate" },
],
): Model => {
const facets = withFacets(entered().model, ["production"]).model
const steps: ReadonlyArray<Message> = [
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)
Expand Down Expand Up @@ -241,7 +243,7 @@ describe("overview", () => {
[ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })],
[MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()],
),
expectView(text("All clear")).toBeAbsent(),
expectView(text("Firing now:")).toBeAbsent(),
)
})

Expand Down Expand Up @@ -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(
Expand Down
17 changes: 3 additions & 14 deletions apps/web/src/page/overview/view-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = <M>(h: HtmlBuilder<M>, { 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)]),
Expand Down
Loading
Loading