diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts new file mode 100644 index 0000000000..607c828295 --- /dev/null +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -0,0 +1,450 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { AlertDestinationId, AlertRuleId } from "@maple/domain/http" +import { formatWarehouseDateTime } from "@maple/query-engine" +import * as Menu from "@maple/views/ui/menu" +import * as DestinationDialog from "../page/alerts/destination/dialog" +import { menuIdOf } from "../page/alerts/editor/dest-steps" +import * as EditorMessage from "../page/alerts/editor/message" +import * as EditorModel from "../page/alerts/editor/model" +import { init as initEditor } from "../page/alerts/editor/init" +import { update as updateEditor } from "../page/alerts/editor/update" +import { breadcrumbsOf as editorCrumbs, view as editorView } from "../page/alerts/editor/view" +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 * as RuleMessage from "../page/alerts/rule/message" +import * as RuleModel from "../page/alerts/rule/model" +import * as RuleUpdate from "../page/alerts/rule/update" +import { breadcrumbsOf as ruleCrumbs, view as ruleView } from "../page/alerts/rule/view" +import * as IncidentMessage from "../page/alerts/incident/message" +import * as IncidentModel from "../page/alerts/incident/model" +import * as IncidentUpdate from "../page/alerts/incident/update" +import { breadcrumbsOf as incidentCrumbs, view as incidentView } from "../page/alerts/incident/view" +import { + anomalyAlertIncidentId, + anomalyIncidentRow, + anomalyTimeseriesWire, + anomalyWire, + defaultAnomalyRuleRow, + scopedAnomalyRuleRow, +} from "../page/alerts/shared/anomaly-test-fixtures" +import { + destinationRow, + incidentRow, + NOW, + ORG, + ruleId, + ruleRow, + stateRow, +} from "../page/alerts/shared/test-rows" +import type { PageContext } from "../page/contract" +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. + +export const LabState = Schema.Literals([ + "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", +]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "hub-anomalies", + ), +})) + +export const Model = Schema.Struct({ + state: LabState, + hub: HubModel.Model, + editor: EditorModel.Model, + incident: IncidentModel.Model, + rule: RuleModel.Model, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotHubMessage: { message: HubMessage.Message }, + GotEditorMessage: { message: EditorMessage.Message }, + GotIncidentMessage: { message: IncidentMessage.Message }, + GotRuleMessage: { message: RuleMessage.Message }, +}) +export type Message = typeof Message.Type + +type Return = Update.Return + +const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } + +/** The pages' reads and writes are dropped: the fixture is all the data there is. */ +const keepLocal = (commands: ReadonlyArray> | undefined) => + Arr.filter( + commands ?? [], + (command) => !/^(Fetch|Toggle|Delete|Save|Read|Generate|Run|Set|Create)/.test(command.name), + ) + +const fold = ( + model: PM, + messages: ReadonlyArray, + step: (model: PM, message: PS) => { readonly model: PM }, +) => Arr.reduce(messages, model, (current, message) => step(current, message).model) + +const RULES = [ + ruleRow(2, { name: "Payment p95 latency", tags_json: ["team:payments"] }), + defaultAnomalyRuleRow(), + scopedAnomalyRuleRow(), +] + +const OPEN = [ + anomalyIncidentRow({ + n: 1, + ruleN: 8, + severity: "critical", + service: "checkout", + environment: "production", + }), + anomalyIncidentRow({ n: 2, ruleN: 7, severity: "warning", service: "search", environment: "production" }), + anomalyIncidentRow( + { n: 5, ruleN: 7, severity: "critical", service: "payments-worker", environment: "production" }, + { acknowledged_at: new Date(NOW - 20 * 60_000).toISOString(), acknowledged_by_name: "Ada Lovelace" }, + ), +] +const RESOLVED = [ + anomalyIncidentRow({ + n: 3, + ruleN: 8, + severity: "warning", + service: "checkout", + environment: "staging", + status: "resolved", + }), + anomalyIncidentRow({ + n: 6, + ruleN: 7, + severity: "critical", + service: "search", + environment: "production", + status: "resolved", + }), +] + +const hubFor = (state: LabState) => { + const search: HubModel.Model["search"] = + state === "hub-rules" + ? { tab: "rules" } + : state === "hub-anomalies-resolved" + ? { signal: "anomaly", anomalyStatus: "all" } + : { signal: "anomaly" } + const incidents = + state === "hub-anomalies-empty" + ? [incidentRow({ rule_name: "Payment p95 latency" })] + : [incidentRow({ rule_name: "Payment p95 latency" }), ...OPEN, ...RESOLVED] + return fold( + HubUpdate.init({ _tag: "Alerts", search }, CONTEXT).model, + [ + HubMessage.Message.ReadClock({ nowMs: NOW }), + HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), + HubMessage.Message.UpdatedRuleStates({ + orgId: ORG, + rows: [ + stateRow(2, { last_status: "breached", last_value: 1840 }), + stateRow(7, { last_value: 2 }), + stateRow(8, { last_value: 0 }), + ], + }), + HubMessage.Message.UpdatedIncidents({ orgId: ORG, rows: incidents }), + HubMessage.Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: Policy.allPermissions }), + ], + (model, message) => HubUpdate.update(model, message, CONTEXT), + ) +} + +const editorSearch = (state: LabState): EditorModel.Model["search"] => + state === "editor-scoped" + ? { ruleId: ruleId(8) } + : state === "editor-default" + ? { ruleId: ruleId(7) } + : { template: "anomaly" } + +const editorFor = (state: LabState) => + fold( + initEditor({ _tag: "AlertsCreate", search: editorSearch(state) }, CONTEXT).model, + [ + EditorMessage.Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + EditorMessage.Message.CompletedReadClock({ nowMs: NOW }), + EditorMessage.Message.CompletedReadGrants({ grants: Policy.allPermissions }), + EditorMessage.Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + EditorMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), + ...(state === "editor-critical-destination" + ? [ + EditorMessage.Message.ChangedField({ field: "name", value: "Search anomalies" }), + EditorMessage.Message.ToggledAnomalySignal({ signal: "latency_p95" }), + EditorMessage.Message.GotMenuMessage({ + id: menuIdOf("critical"), + message: Menu.Message.ClickedItem({ + index: 0, + item: { kind: "Item", value: "type:webhook" }, + }), + }), + EditorMessage.Message.GotDestinationDialogMessage({ + message: DestinationDialog.Message.CompletedSave({ + openCount: 1, + isCreate: true, + result: Result.succeed(PAGER_ID), + }), + }), + EditorMessage.Message.UpdatedDestinations({ + orgId: ORG, + rows: [ + destinationRow(), + destinationRow({ + id: PAGER_ID, + name: "Search pager", + config_json: { + summary: "https://pager.acme.test/search", + channelLabel: null, + }, + }), + ], + }), + ] + : []), + ...(state === "editor-new" + ? [ + EditorMessage.Message.ChangedField({ field: "name", value: "Search anomalies" }), + EditorMessage.Message.ToggledAnomalySignal({ signal: "latency_p95" }), + EditorMessage.Message.ToggledAnomalySignal({ signal: "throughput" }), + ] + : []), + ], + (model, message) => updateEditor(model, message, CONTEXT), + ) + +const INCIDENTS = { + "incident-warning": { n: 4, signal: "latency_p95", severity: "warning", service: "search" }, + "incident-critical": { n: 1, signal: "error_rate", severity: "critical", service: "checkout" }, + "incident-spike": { n: 3, signal: "error_spike", severity: "critical", service: "payments-worker" }, +} as const + +const incidentFor = (state: LabState) => { + const scenario = + state === "incident-warning" || state === "incident-critical" || state === "incident-spike" + ? INCIDENTS[state] + : INCIDENTS["incident-critical"] + const row = anomalyIncidentRow({ + n: scenario.n, + ruleN: scenario.service === "checkout" ? 8 : 7, + severity: scenario.severity, + service: scenario.service, + environment: "production", + }) + const entered = IncidentUpdate.init( + { _tag: "AlertIncident", incidentId: anomalyAlertIncidentId(scenario.n), search: {} }, + CONTEXT, + ).model + const synced = fold( + entered, + [ + IncidentMessage.Message.ReadClock({ nowMs: NOW }), + IncidentMessage.Message.UpdatedIncidents({ orgId: ORG, rows: [row] }), + IncidentMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), + ], + (model, message) => IncidentUpdate.update(model, message, CONTEXT), + ) + return fold( + synced, + [ + IncidentMessage.Message.CompletedFetchAnomaly({ + key: synced.anomaly.key ?? "", + result: Result.succeed( + anomalyWire(scenario.n, scenario.signal, scenario.severity, scenario.service), + ), + }), + IncidentMessage.Message.CompletedFetchAnomalyTimeseries({ + key: synced.anomalyTimeseries.key ?? "", + result: Result.succeed(anomalyTimeseriesWire(scenario.signal)), + }), + IncidentMessage.Message.CompletedFetchDeliveries({ + key: synced.deliveries.key ?? "", + result: Result.succeed([]), + }), + IncidentMessage.Message.CompletedFetchActivity({ + key: synced.activity.key ?? "", + result: Result.succeed([]), + }), + IncidentMessage.Message.CompletedFetchSession({ grants: Policy.allPermissions }), + IncidentMessage.Message.CompletedFetchSilences({ + key: synced.silences.key ?? "", + result: Result.succeed([]), + }), + IncidentMessage.Message.CompletedFetchInvestigation({ + key: synced.investigation.key ?? "", + result: Result.succeed(null), + }), + ], + (model, message) => IncidentUpdate.update(model, message, CONTEXT), + ) +} + +const PAGER_ID = Schema.decodeSync(AlertDestinationId)("0199a001-0000-7000-8000-000000000009") + +/** The scoped anomaly rule's page, with its anomalies in the window. */ +const ruleFor = () => { + const rule = scopedAnomalyRuleRow() + const entered = RuleUpdate.init( + { _tag: "AlertRule", ruleId: Schema.decodeSync(AlertRuleId)(rule.id), search: {} }, + CONTEXT, + ).model + return fold( + entered, + [ + RuleMessage.Message.CompletedResolveWindow({ + window: { + startTime: formatWarehouseDateTime(NOW - 24 * 3_600_000), + endTime: formatWarehouseDateTime(NOW), + }, + nowMs: NOW, + mode: "Load", + }), + RuleMessage.Message.ReadDiagnosisClock({ nowMs: NOW }), + RuleMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), + RuleMessage.Message.UpdatedRuleStates({ + orgId: ORG, + rows: [stateRow(8, { group_key: "__total__", last_value: 2 })], + }), + RuleMessage.Message.UpdatedIncidents({ orgId: ORG, rows: [...OPEN, ...RESOLVED] }), + RuleMessage.Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + ], + (model, message) => RuleUpdate.update(model, message, CONTEXT), + ) +} + +const isEditor = Str.startsWith("editor") +const isIncident = Str.startsWith("incident") + +export const init = (flags: Flags): Return => ({ + model: { + state: flags.state, + hub: hubFor(flags.state), + editor: editorFor(flags.state), + incident: incidentFor(flags.state), + rule: ruleFor(), + }, + // The incident page's chart runtime still loads, so its anomaly chart draws. + commands: Command.mapMessages( + keepLocal( + IncidentUpdate.init( + { _tag: "AlertIncident", incidentId: anomalyAlertIncidentId(1), search: {} }, + CONTEXT, + ).commands, + ), + (message) => Message.GotIncidentMessage({ message }), + ), +}) + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotHubMessage: ({ message: child }) => { + const next = HubUpdate.update(model.hub, child, CONTEXT) + return { + model: { ...model, hub: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotHubMessage({ message: inner }), + ), + } + }, + GotEditorMessage: ({ message: child }) => { + const next = updateEditor(model.editor, child, CONTEXT) + return { + model: { ...model, editor: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotEditorMessage({ message: inner }), + ), + } + }, + GotRuleMessage: ({ message: child }) => { + const next = RuleUpdate.update(model.rule, child, CONTEXT) + return { + model: { ...model, rule: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotRuleMessage({ message: inner }), + ), + } + }, + GotIncidentMessage: ({ message: child }) => { + const next = IncidentUpdate.update(model.incident, child, CONTEXT) + return { + model: { ...model, incident: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotIncidentMessage({ message: inner }), + ), + } + }, + }) + +export const breadcrumbs = (model: Model) => + model.state === "rule-anomaly" + ? ruleCrumbs(model.rule) + : isEditor(model.state) + ? editorCrumbs(model.editor) + : isIncident(model.state) + ? incidentCrumbs(model.incident) + : [{ label: "Alerts" }] + +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) + ? h.submodel({ + slotId: "lab-anomaly-editor", + model: model.editor, + view: editorView, + viewInputs: { context: CONTEXT }, + 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/entries/anomaly-alerts.ts b/apps/web/src/lab/entries/anomaly-alerts.ts new file mode 100644 index 0000000000..90d8392e6e --- /dev/null +++ b/apps/web/src/lab/entries/anomaly-alerts.ts @@ -0,0 +1,17 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../anomaly-alerts-lab" + +// `/lab/anomaly-alerts`: anomaly rules in the editor, an anomaly rule's incident and the Inbox's +// Anomalies view over fixtures (`?state=`). + +export const lab = defineShellLab("Anomaly alerts", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/anomaly-alerts", + breadcrumbs, +}) diff --git a/apps/web/src/lab/entries/empty-states.ts b/apps/web/src/lab/entries/empty-states.ts index a20348efa9..89fc354102 100644 --- a/apps/web/src/lab/entries/empty-states.ts +++ b/apps/web/src/lab/entries/empty-states.ts @@ -1,5 +1,5 @@ import { OrgId } from "@maple/domain" -import { Clock, Effect, Result, Schema, Array as Arr } from "effect" +import { Clock, Effect, Result, Schema } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" @@ -8,10 +8,6 @@ import * as AgentSessionMessage from "../../page/agent-sessions/session/message" import { dividerBlock } from "../../page/agent-sessions/session/transcript-lanes" import { init as initSession } from "../../page/agent-sessions/session/update" import { loadIndicator } from "../../page/agent-sessions/session/view-load" -import * as AnomaliesMessage from "../../page/anomalies/list/message" -import * as Anomalies from "../../page/anomalies/list/model" -import { init as initAnomalies, update as updateAnomalies } from "../../page/anomalies/list/update" -import { view as anomaliesView } from "../../page/anomalies/list/view" import { notFoundContent } from "../../page/chrome" import type { PageContext } from "../../page/contract" import * as ErrorsMessage from "../../page/errors/hub/message" @@ -40,8 +36,6 @@ export const ITEMS = [ "releases-first-run", "releases-quiet", "releases-filtered", - "anomalies-first-run", - "anomalies-loading", "session-rest-failed", "recommendations-loading", "not-found", @@ -124,23 +118,6 @@ const releasesModel = ( return { ...loaded, presence: presenceOf(presence) } } -const anomaliesModel = (nowMs: number, presence: Presence | null): Anomalies.Model => { - const entered = initAnomalies({ search: {} }, context).model - if (presence === null) return entered - const loaded = Arr.reduce( - [ - AnomaliesMessage.Message.ReadClock({ nowMs }), - AnomaliesMessage.Message.CompletedFetchFirstPage({ - key: entered.firstPage.key ?? "", - result: Result.succeed({ data: [], nextCursor: null }), - }), - ], - entered, - (model, message) => updateAnomalies(model, message, context).model, - ) - return { ...loaded, presence: presenceOf(presence) } -} - const FAILED_PROGRESS = { phase: "failed", agentSpansComplete: false, @@ -199,15 +176,6 @@ const itemView = (model: Model, h: HtmlBuilder): Html => { toParentMessage: ignore, viewInputs: { context }, }) - case "anomalies-first-run": - case "anomalies-loading": - return h.submodel({ - slotId: "lab-anomalies", - model: anomaliesModel(nowMs, model.item === "anomalies-first-run" ? "absent" : null), - view: anomaliesView, - toParentMessage: ignore, - viewInputs: { context }, - }) case "session-rest-failed": return h.submodel({ slotId: "lab-session", diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index fdb9ebac27..48917ad274 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -61,6 +61,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + 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=`).", + kind: "lab", + session: "none", + }, { path: "/lab/billing-orgs", title: "Billing orgs", diff --git a/apps/web/src/page/alerts/destination/email-fields.scene.test.ts b/apps/web/src/page/alerts/destination/email-fields.scene.test.ts index fb1cf447cb..2fa4743f4d 100644 --- a/apps/web/src/page/alerts/destination/email-fields.scene.test.ts +++ b/apps/web/src/page/alerts/destination/email-fields.scene.test.ts @@ -63,7 +63,7 @@ describe("email member picker", () => { }, given(model), expectView(text("Ada Lovelace")).toExist(), - expectView(role("button", { name: "Remove" })).toExist(), + expectView(role("button", { name: /^Remove / })).toExist(), ) }) }) diff --git a/apps/web/src/page/alerts/editor/actions.ts b/apps/web/src/page/alerts/editor/actions.ts index 6c6221bbf2..0e6108183b 100644 --- a/apps/web/src/page/alerts/editor/actions.ts +++ b/apps/web/src/page/alerts/editor/actions.ts @@ -7,7 +7,7 @@ import { Result } from "effect" import { PageOutMessage } from "../../contract" import { alertsHref } from "../shared/links" import { RunRuleTest, SaveRule, TrackRuleCreated } from "./command" -import { previewIdentityKey, saveIssues } from "./derive" +import { isProtectedDefault, previewIdentityKey, saveIssues } from "./derive" import { draftTimingOf } from "./derive-rows" import type { Message } from "./message" import type { Model } from "./model" @@ -23,15 +23,22 @@ export const runTest = (model: Model, isSending: boolean): Return => { const form = model.form if (form === null || model.isDryRunning || model.isSendingTest) return { model } if (!isRulePreviewReady(form)) - return { model, outMessage: errorToast("Complete the rule name, query, and threshold before testing") } + return { + model, + outMessage: errorToast("Complete the rule name, query, and threshold before testing"), + } return { model: isSending ? { ...model, isSendingTest: true } : { ...model, isDryRunning: true }, - commands: [RunRuleTest({ key: previewIdentityKey(form), payload: buildRuleTestParamsV2(form, isSending) })], + commands: [ + RunRuleTest({ key: previewIdentityKey(form), payload: buildRuleTestParamsV2(form, isSending) }), + ], } } export const completeTest = (model: Model, message: Extract): Return => { - const settled: Model = message.isSending ? { ...model, isSendingTest: false } : { ...model, isDryRunning: false } + const settled: Model = message.isSending + ? { ...model, isSendingTest: false } + : { ...model, isDryRunning: false } return Result.match(message.result, { onSuccess: (dryRun) => ({ model: { ...settled, dryRun }, @@ -41,7 +48,14 @@ export const completeTest = (model: Model, message: Extract ({ model: settled, outMessage: errorToast("Couldn't preview rule", error.message) }), + onFailure: (error) => ({ + model: settled, + // A failed Send test is a delivery failure, not a preview one: say which. + outMessage: errorToast( + message.isSending ? "Couldn't send test notification" : "Couldn't preview rule", + error.message, + ), + }), }) } @@ -49,7 +63,7 @@ export const completeTest = (model: Model, message: Extract { const form = model.form if (form === null || model.isSaving) return { model } - if (saveIssues(form, draftTimingOf(model, form)).length > 0) return { model } + if (saveIssues(form, draftTimingOf(model, form), isProtectedDefault(model)).length > 0) return { model } return { model: { ...model, isSaving: true }, commands: [SaveRule({ ruleId: model.editingRuleId, payload: buildRuleCreateParamsV2(form) })], @@ -61,7 +75,9 @@ export const completeSave = (model: Model, message: Extract ({ model: { ...model, isSaving: false }, commands: - model.editingRuleId === null && model.form !== null ? [TrackRuleCreated({ signal: model.form.signalType })] : [], + model.editingRuleId === null && model.form !== null + ? [TrackRuleCreated({ signal: model.form.signalType })] + : [], outMessage: PageOutMessage.RequestedNavigation({ url: alertsHref({}), isReplace: false, diff --git a/apps/web/src/page/alerts/editor/derive.ts b/apps/web/src/page/alerts/editor/derive.ts index 0832e80a93..107115cdc4 100644 --- a/apps/web/src/page/alerts/editor/derive.ts +++ b/apps/web/src/page/alerts/editor/derive.ts @@ -10,6 +10,7 @@ import { signalLabels, thresholdUnitLabel, } from "@maple/web-data/alerts/form-utils" +import { anomalyScopeSummary, isDefaultAnomalyRule } from "@maple/web-data/alerts/anomaly-rule" import { destinationProvider } from "../shared/providers" import type { RuleForm } from "./form" import { intervalWindowIssue, queryTimingIssue } from "./interval" @@ -19,6 +20,12 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)) // The rule editor's pure helpers: form surface, condition bar, live chart hero, notifications. +/** Editing the org's default anomaly rule: its scope, kind and enabled state are the server's. */ +export const isProtectedDefault = ( + model: Readonly<{ editingRuleId: string | null; savedForm: RuleForm | null }>, +): boolean => + model.editingRuleId !== null && model.savedForm !== null && isDefaultAnomalyRule(model.savedForm) + export const COMPARATOR_WORDS = new Map([ ["gt", "is above"], ["gte", "is at or above"], @@ -33,6 +40,7 @@ export const comparatorWords = (comparator: AlertComparator): string => COMPARAT /** What the threshold is compared against, in words: the aggregation for a query, the signal otherwise. */ export const conditionSubject = (form: RuleForm): string => { + if (form.signalType === "anomaly") return "anomalies" if (form.signalType === "raw_query") return "the SQL value" if (form.signalType === "builder_query") { const aggregation = Str.trim(Str.replaceAll("_", " ")(form.queryBuilderDraft.aggregation)) @@ -42,7 +50,13 @@ export const conditionSubject = (form: RuleForm): string => { } /** The rule in one plain sentence, shown in the action bar. */ -export const describeRuleCondition = (form: RuleForm): string => { +export const describeRuleCondition = (form: RuleForm, isDefault = false): string => { + if (form.signalType === "anomaly") + return isDefault + ? "Pages on every anomaly no narrower rule handles" + : isDefaultAnomalyRule(form) + ? "Anomalies in a service, environment or signal" + : `Pages on anomalies in ${anomalyScopeSummary(form)}` const unit = thresholdUnitLabel(form) ?? "" const value = (raw: string) => `${Str.trim(raw) || "?"}${unit === "%" ? "%" : unit ? ` ${unit}` : ""}` const threshold = isRangeComparator(form.comparator) @@ -80,7 +94,8 @@ export const suggestCriticalThreshold = (form: RuleForm): string => { } /** `deriveValidationIssues`: what the action bar lists as missing. */ -export const validationIssues = (form: RuleForm): ReadonlyArray => { +export const validationIssues = (form: RuleForm, isDefault = false): ReadonlyArray => { + if (form.signalType === "anomaly") return anomalyIssues(form, isDefault) const issues: Array = [] if (Str.trim(form.name).length === 0) issues.push("Rule name") if (!isFilledNumber(form.threshold)) issues.push("Threshold") @@ -110,10 +125,28 @@ export const validationIssues = (form: RuleForm): ReadonlyArray => { return issues } +/** + * An anomaly rule has no query or timing to get wrong: a name, a scope and someone to page. Only + * Maple creates the unscoped default rule, which may stay unrouted (anomaly paging is opt-in); the + * server refuses any other unscoped or unrouted enabled anomaly rule. + */ +const anomalyIssues = (form: RuleForm, isDefault: boolean): ReadonlyArray => [ + ...(Str.trim(form.name).length === 0 ? ["Rule name"] : []), + ...(!isDefault && isDefaultAnomalyRule(form) ? ["Scope"] : []), + ...(!isDefault && form.destinationIds.length === 0 && (form.escalation?.destinationIds.length ?? 0) === 0 + ? ["Who gets notified"] + : []), +] + /** What blocks saving: the form's own issues and a query too slow for its interval. */ -export const saveIssues = (form: RuleForm, timing: AlertQueryTiming | null): ReadonlyArray => { +export const saveIssues = ( + form: RuleForm, + timing: AlertQueryTiming | null, + isDefault = false, +): ReadonlyArray => { const timingIssue = queryTimingIssue(form, timing) - return timingIssue === null ? validationIssues(form) : [...validationIssues(form), timingIssue] + const issues = validationIssues(form, isDefault) + return timingIssue === null ? issues : [...issues, timingIssue] } /** `previewIdentityKey`: the rule as evaluated; a dry-run verdict is only shown while it matches. */ @@ -138,6 +171,7 @@ export const previewIdentityKey = (form: RuleForm): string => { /** `makeSuggestedName`: signal and scope, offered while the name is empty. */ export const suggestedName = (form: RuleForm): string | null => { if (Str.trim(form.name).length > 0) return null + if (form.signalType === "anomaly") return `Anomalies · ${anomalyScopeSummary(form)}` const subject = conditionSubject(form) const base = Str.toUpperCase(Str.slice(0, 1)(subject)) + Str.slice(1)(subject) const queryGroupBy = diff --git a/apps/web/src/page/alerts/editor/dest-steps.ts b/apps/web/src/page/alerts/editor/dest-steps.ts index 2a1e615451..4aff415c1c 100644 --- a/apps/web/src/page/alerts/editor/dest-steps.ts +++ b/apps/web/src/page/alerts/editor/dest-steps.ts @@ -8,7 +8,7 @@ import { PageOutMessage } from "../../contract" import * as DestinationDialog from "../destination/dialog" import * as ChatConnect from "../shared/chat-connect" import { FetchEditorConnectors } from "./command" -import { addId, removeId, updateTierIds } from "./form-steps" +import { assignToTier, removeId, updateTierIds } from "./form-steps" import { Message, type Tier } from "./message" import type { Model } from "./model" import type { Return } from "./steps" @@ -31,7 +31,13 @@ export const liftDialog = (model: Model, step: ReturnType ({ model: - next.form === null ? next : { ...next, form: updateTierIds(next.form, "warning", addId(id)) }, + next.form === null + ? next + : { + ...next, + autoDestinationId: null, + form: assignToTier(next.form, next.destinationTier, id), + }, commands, outMessage: PageOutMessage.RequestedToast({ type: "success", @@ -58,7 +64,7 @@ const picked = (model: Model, menuId: string, value: string): Return => { model: model.form === null ? model - : { ...model, form: updateTierIds(model.form, tier, addId(id)) }, + : { ...model, autoDestinationId: null, form: assignToTier(model.form, tier, id) }, }), }) if (kind === "type") @@ -66,7 +72,7 @@ const picked = (model: Model, menuId: string, value: string): Return => { onNone: () => ({ model }), onSome: (type) => liftDialog( - model, + { ...model, destinationTier: tier }, DestinationDialog.openNew(model.destinationDialog, defaultDestinationForm(type)), ), }) @@ -81,7 +87,7 @@ const picked = (model: Model, menuId: string, value: string): Return => { onNone: (): Return => ({ model }), onSome: (workspace) => liftDialog( - model, + { ...model, destinationTier: tier }, DestinationDialog.openNew( model.destinationDialog, chatDestinationForm(connector, workspace.id), @@ -127,7 +133,13 @@ export const connectorsAnswered = (model: Model, connectors: ReadonlyArray ({ model: next }), onSome: (linked) => liftDialog( - { ...next, chatConnect: ChatConnect.finish(next.chatConnect), chatConnectMenuId: null }, + { + ...next, + chatConnect: ChatConnect.finish(next.chatConnect), + chatConnectMenuId: null, + destinationTier: + model.chatConnectMenuId === null ? "warning" : tierOfMenu(model.chatConnectMenuId), + }, DestinationDialog.openNew( next.destinationDialog, chatDestinationForm(linked.connector, linked.id), @@ -151,4 +163,10 @@ export const menuStep = (model: Model, menuId: string, message: Menu.Message): R export const removeDestination = (model: Model, tier: Tier, id: AlertDestinationId): Return => model.form === null ? { model } - : { model: { ...model, form: updateTierIds(model.form, tier, removeId(id)) } } + : { + model: { + ...model, + autoDestinationId: null, + form: updateTierIds(model.form, tier, removeId(id)), + }, + } diff --git a/apps/web/src/page/alerts/editor/draft.ts b/apps/web/src/page/alerts/editor/draft.ts index 5258c72700..b5e3ea59a2 100644 --- a/apps/web/src/page/alerts/editor/draft.ts +++ b/apps/web/src/page/alerts/editor/draft.ts @@ -133,6 +133,9 @@ export const initialization = (model: Model, draftId: string): Initialization => { severity: "warning", message: "Couldn't load dashboards. Starting from a blank alert." }, ]) } + // "New rule → Anomaly rule" in the hub: the editor opens on the anomaly kind. + if (search.template === "anomaly") + return ready("new:anomaly", fromFormState(switchSignalKind(base, "anomaly").form)) const template = Arr.findFirst(ALERT_TEMPLATES, (candidate) => candidate.id === search.template) if (Option.isSome(template)) return ready(`new:template:${template.value.id}`, fromFormState(template.value.apply(base))) diff --git a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts new file mode 100644 index 0000000000..259d87d7ce --- /dev/null +++ b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts @@ -0,0 +1,341 @@ +import { OrgId } from "@maple/domain" +import { AlertDestinationId } from "@maple/domain/http" +import { Result, Schema, Array as Arr } from "effect" +import { + click, + expect as expectView, + given, + Mount, + placeholder, + role, + scene, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import * as Menu from "@maple/views/ui/menu" +import * as DestinationDialog from "../destination/dialog" +import { menuIdOf } from "./dest-steps" +import type { PageContext } from "../../contract" +import { defaultAnomalyRuleRow, scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" +import { DESTINATION_ID, destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { init } from "./init" +import { Message } from "./message" +import type { Model } from "./model" +import { update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const DESTINATION = Schema.decodeSync(AlertDestinationId)(DESTINATION_ID) +const DRAFT_ID = "6f1c2e3a-0000-4000-8000-000000000001" +const themeResolved = () => + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) +const opened = ( + search: Model["search"], + rules = [ruleRow(2), defaultAnomalyRuleRow(), scopedAnomalyRuleRow()], +) => + run(init({ _tag: "AlertsCreate", search }, context).model, [ + Message.GeneratedDraftId({ id: DRAFT_ID }), + Message.CompletedReadClock({ nowMs: NOW }), + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + Message.UpdatedRules({ orgId: ORG, rows: rules }), + ]) + +/** A pick from a tier's "Add destination" menu of a destination the org already has. */ +const pickExisting = (tier: "warning" | "critical", id: string) => + Message.GotMenuMessage({ + id: menuIdOf(tier), + message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value: `existing:${id}` } }), + }) + +const DESCRIPTION = /^Pages when Maple detects unusual error rate, latency, throughput/ +const DEFAULT_NOTE = /^This is the default anomaly rule/ + +describe("alert rule editor: anomaly rules", () => { + test("the Anomaly kind keeps scope and per-tier destinations and drops the condition and chart", () => { + const blank = opened({ template: "slow_p95" }) + scene( + { update: pageUpdate, view }, + given(blank), + themeResolved(), + expectView(text("When to alert")).toExist(), + click(role("button", { name: "Anomaly" })), + expectView(text(DESCRIPTION)).toExist(), + expectView(text("When to alert")).not.toExist(), + expectView(role("button", { name: "Dry run" })).not.toExist(), + expectView(role("group", { name: "Anomaly signals" })).toExist(), + expectView(text("None picked: every signal pages.")).toExist(), + expectView(text("Critical, on top of the warning destinations")).toExist(), + ) + }) + + test("picking signals narrows the rule, and saving sends the scope with both pinned tiers", () => { + const anomaly = run(opened({ template: "anomaly" }), [ + Message.ChangedField({ field: "name", value: "Checkout anomalies" }), + Message.ToggledAnomalySignal({ signal: "error_spike" }), + Message.ToggledAnomalySignal({ signal: "error_rate" }), + pickExisting("critical", DESTINATION), + ]) + // Kept in the editor's order, whatever the click order. + expect(anomaly.form?.anomalySignals).toEqual(["error_rate", "error_spike"]) + const saved = pageUpdate(anomaly, Message.ClickedSave()) + const payload = saved.commands?.[0]?.args + expect(saved.commands?.[0]?.name).toBe("SaveAlertRule") + expect(payload).toMatchObject({ + payload: { + signal_type: "anomaly", + anomaly_signals: ["error_rate", "error_spike"], + thresholds: [ + expect.objectContaining({ severity: "warning", value: 1 }), + expect.objectContaining({ severity: "critical", value: 2 }), + ], + }, + }) + }) + + test("a scoped anomaly rule with nobody to page cannot be saved", () => { + const routed = run(opened({ template: "anomaly" }), [ + Message.ChangedField({ field: "name", value: "Error rate anomalies" }), + Message.ToggledAnomalySignal({ signal: "error_rate" }), + pickExisting("warning", DESTINATION), + ]) + // Scoped and routed: the only thing left to take away is the destination. + scene( + { update: pageUpdate, view }, + given(routed), + themeResolved(), + expectView(role("button", { name: "Create rule" })).toBeEnabled(), + ) + const unrouted = pageUpdate( + routed, + Message.ClickedRemoveDestination({ tier: "warning", id: DESTINATION }), + ).model + scene( + { update: pageUpdate, view }, + given(unrouted), + themeResolved(), + expectView(text("Who gets notified")).toExist(), + expectView(role("button", { name: "Create rule" })).toBeDisabled(), + ) + }) + + test("editing a scoped anomaly rule opens on its scope and keeps the save unchanged", () => { + const scoped = opened({ ruleId: ruleId(8) }) + expect(scoped.form?.signalType).toBe("anomaly") + expect(scoped.form?.serviceNames).toEqual(["checkout"]) + expect(scoped.form?.anomalySignals).toEqual(["error_rate", "latency_p95"]) + scene( + { update: pageUpdate, view }, + given(scoped), + themeResolved(), + expectView(text("No changes")).toExist(), + expectView(text(DEFAULT_NOTE)).not.toExist(), + // Each chip's remove button names its chip. + expectView(role("button", { name: "Remove checkout" })).toExist(), + expectView(role("button", { name: "Error rate" })).not.toBeDisabled(), + ) + }) + + test("the default anomaly rule locks its kind and scope and says why", () => { + const fallback = opened({ ruleId: ruleId(7) }) + scene( + { update: pageUpdate, view }, + given(fallback), + themeResolved(), + expectView(text(DEFAULT_NOTE)).toExist(), + expectView(role("button", { name: "Error rate" })).toBeDisabled(), + expectView(role("button", { name: "Built-in" })).toBeDisabled(), + ) + // A stale click still cannot scope it or turn it into a query rule. + const poked = run(fallback, [ + Message.ToggledAnomalySignal({ signal: "throughput" }), + Message.ClickedSignalKind({ kind: "builtin" }), + ]) + expect(poked.form).toEqual(fallback.form) + }) + + test("the default anomaly rule saves unrouted, since anomaly paging is opt-in", () => { + const renamed = run(opened({ ruleId: ruleId(7) }), [ + Message.ChangedField({ field: "name", value: "Anomalies (everything else)" }), + ]) + scene( + { update: pageUpdate, view }, + given(renamed), + themeResolved(), + expectView(text("Ready to save")).toExist(), + expectView(role("button", { name: "Save changes" })).not.toBeDisabled(), + ) + const saved = pageUpdate(renamed, Message.ClickedSave()) + expect(saved.commands?.[0]?.name).toBe("SaveAlertRule") + }) + + test("the server's refusal is shown as the save error", () => { + const fallback = run(opened({ ruleId: ruleId(7) }), [ + Message.ClickedAddNotes(), + Message.ChangedField({ field: "notes", value: "Pages the platform team" }), + ]) + const saving = pageUpdate(fallback, Message.ClickedSave()).model + const message = + "The default anomaly rule cannot be deleted or disabled; turn anomaly detection off in anomaly settings instead" + const failed = pageUpdate( + saving, + Message.CompletedSave({ result: Result.fail({ title: "Couldn't save rule", message }) }), + ) + expect(failed.model.isSaving).toBe(false) + expect(failed.outMessage).toMatchObject({ title: "Couldn't save rule", description: message }) + }) + + test("a destination created from the Critical tier joins the Critical tier", () => { + const NEW_ID = Schema.decodeSync(AlertDestinationId)("0199a001-0000-7000-8000-000000000009") + const draft = run(opened({ template: "anomaly", serviceName: "checkout" }), [ + Message.ChangedField({ field: "name", value: "Checkout anomalies" }), + pickExisting("warning", DESTINATION), + ]) + const opening = run(draft, [ + Message.GotMenuMessage({ + id: menuIdOf("critical"), + message: Menu.Message.ClickedItem({ + index: 0, + item: { kind: "Item", value: "type:webhook" }, + }), + }), + ]) + expect(opening.destinationDialog.dialog.isOpen).toBe(true) + const created = run(opening, [ + Message.GotDestinationDialogMessage({ + message: DestinationDialog.Message.CompletedSave({ + openCount: opening.destinationDialog.openCount, + isCreate: true, + result: Result.succeed(NEW_ID), + }), + }), + Message.UpdatedDestinations({ + orgId: ORG, + rows: [destinationRow(), destinationRow({ id: NEW_ID, name: "Pager bridge" })], + }), + ]) + expect(created.form?.escalation?.destinationIds).toEqual([NEW_ID]) + expect(created.form?.destinationIds).not.toContain(NEW_ID) + scene( + { update: pageUpdate, view }, + // The dialog's close transition is the dialog's own story: the tier lists are what matter here. + given({ ...created, destinationDialog: draft.destinationDialog }), + themeResolved(), + expectView(text(/Pager bridge/)).toExist(), + ) + const saved = pageUpdate(created, Message.ClickedSave()) + expect(saved.commands?.[0]?.args).toMatchObject({ + payload: { + thresholds: [ + expect.objectContaining({ severity: "warning", destination_ids: [DESTINATION] }), + expect.objectContaining({ severity: "critical", destination_ids: [NEW_ID] }), + ], + }, + }) + }) + + test("a new anomaly rule needs a service, environment or signal; the default rule covers the rest", () => { + const unscoped = run(opened({ template: "anomaly" }), [ + Message.ChangedField({ field: "name", value: "Everything" }), + pickExisting("critical", DESTINATION), + ]) + scene( + { update: pageUpdate, view }, + given(unscoped), + themeResolved(), + expectView(text(/^Pick at least one service, environment or signal/)).toExist(), + expectView(role("button", { name: "Create rule" })).toBeDisabled(), + click(role("button", { name: "Throughput" })), + expectView(text(/^Pick at least one service, environment or signal/)).not.toExist(), + expectView(role("button", { name: "Create rule" })).not.toBeDisabled(), + ) + expect(pageUpdate(unscoped, Message.ClickedSave()).commands ?? []).toHaveLength(0) + }) + + test("an anomaly rule's name suggests what it covers", () => { + scene( + { update: pageUpdate, view }, + given(opened({ template: "anomaly" })), + themeResolved(), + expectView(placeholder("Checkout anomalies")).toExist(), + ) + }) + + test("a new anomaly rule pages nobody until picked, and a destination picked for Critical leaves Warning", () => { + const fresh = opened({ template: "anomaly", serviceName: "checkout" }) + // Anomaly paging is opt-in: the org's usual destination is not pre-selected. + expect(fresh.form?.destinationIds).toEqual([]) + expect(fresh.form?.escalation?.destinationIds).toEqual([]) + const onWarning = run(fresh, [pickExisting("warning", DESTINATION)]) + expect(onWarning.form?.destinationIds).toEqual([DESTINATION]) + const moved = run(onWarning, [pickExisting("critical", DESTINATION)]) + expect(moved.form?.destinationIds).toEqual([]) + expect(moved.form?.escalation?.destinationIds).toEqual([DESTINATION]) + }) + + test("a failed Send test says the notification failed, not the preview", () => { + const draft = run(opened({ template: "anomaly", serviceName: "checkout" }), [ + Message.ChangedField({ field: "name", value: "Checkout anomalies" }), + pickExisting("critical", DESTINATION), + ]) + const sending = pageUpdate(draft, Message.ClickedSendTest()) + expect(sending.commands?.[0]?.name).toBe("RunAlertRuleTest") + const failed = pageUpdate( + sending.model, + Message.CompletedTest({ + isSending: true, + key: "", + result: Result.fail({ + title: "Alert provider rejected the request", + message: "The alert provider rejected the request.", + }), + }), + ) + expect(failed.outMessage).toMatchObject({ + title: "Couldn't send test notification", + description: "The alert provider rejected the request.", + }) + const dryRun = pageUpdate( + draft, + Message.CompletedTest({ + isSending: false, + key: "", + result: Result.fail({ title: "Bad", message: "The query timed out." }), + }), + ) + expect(dryRun.outMessage).toMatchObject({ title: "Couldn't preview rule" }) + }) + + test("a tiered threshold rule keeps one destination on both tiers and saves", () => { + const tiered = run(opened({ template: "slow_p95" }), [ + Message.ChangedField({ field: "name", value: "Slow checkout" }), + Message.ClickedAddCriticalTier(), + pickExisting("critical", DESTINATION), + ]) + expect(tiered.form?.destinationIds).toEqual([DESTINATION]) + expect(tiered.form?.escalation?.destinationIds).toEqual([DESTINATION]) + const saved = pageUpdate(tiered, Message.ClickedSave()) + expect(saved.commands?.[0]?.name).toBe("SaveAlertRule") + }) + + test("switching to Anomaly drops the destination the editor pre-selected, not one the user picked", () => { + const blank = opened({}) + // A new query rule starts on the org's usual destination. + expect(blank.form?.destinationIds).toEqual([DESTINATION]) + const anomaly = pageUpdate(blank, Message.ClickedSignalKind({ kind: "anomaly" })).model + expect(anomaly.form?.destinationIds).toEqual([]) + expect(anomaly.form?.escalation?.destinationIds).toEqual([]) + const chosen = run(blank, [ + Message.ClickedRemoveDestination({ tier: "warning", id: DESTINATION }), + pickExisting("warning", DESTINATION), + Message.ClickedSignalKind({ kind: "anomaly" }), + ]) + expect(chosen.form?.destinationIds).toEqual([DESTINATION]) + }) +}) diff --git a/apps/web/src/page/alerts/editor/form-steps.ts b/apps/web/src/page/alerts/editor/form-steps.ts index f3c2ea3263..48f7305022 100644 --- a/apps/web/src/page/alerts/editor/form-steps.ts +++ b/apps/web/src/page/alerts/editor/form-steps.ts @@ -1,4 +1,10 @@ -import type { AlertDestinationId, AlertSeverity, AlertSignalType } from "@maple/domain/http" +import type { + AlertDestinationId, + AlertSeverity, + AlertSignalType, + AnomalySignalType, +} from "@maple/domain/http" +import { ANOMALY_SIGNALS } from "@maple/web-data/alerts/anomaly-rule" import { switchSignalKind } from "@maple/web-data/alerts/rule-modes" import { ALERT_TEMPLATES } from "../shared/templates" import { suggestCriticalThreshold } from "./derive" @@ -44,6 +50,17 @@ export const addCriticalTier = (form: RuleForm): RuleForm => ({ }, }) +/** An anomaly rule's signal toggled in or out of its scope, kept in the editor's order. */ +export const toggleAnomalySignal = (form: RuleForm, signal: AnomalySignalType): RuleForm => ({ + ...form, + anomalySignals: form.anomalySignals.includes(signal) + ? Arr.filter(form.anomalySignals, (existing) => existing !== signal) + : Arr.filter( + ANOMALY_SIGNALS, + (candidate) => candidate === signal || form.anomalySignals.includes(candidate), + ), +}) + /** Services: each named service is already its own group, and exclude only narrows "all services". */ export const setServices = (form: RuleForm, serviceNames: ReadonlyArray): RuleForm => ({ ...form, @@ -86,6 +103,20 @@ export const updateTierIds = ( }, } +/** + * A destination picked for a tier joins it. On an anomaly rule it also leaves the other tier: + * critical anomaly pages already reach the warning destinations, so being on both only doubles + * the page. A threshold rule keeps it on both, since each of its tiers needs its own destinations. + */ +export const assignToTier = (form: RuleForm, tier: Tier, id: AlertDestinationId): RuleForm => + updateTierIds( + form.signalType === "anomaly" + ? updateTierIds(form, tier === "warning" ? "critical" : "warning", removeId(id)) + : form, + tier, + addId(id), + ) + export const addId = (id: AlertDestinationId) => (ids: ReadonlyArray) => [ ...new Set([...ids, id]), ] diff --git a/apps/web/src/page/alerts/editor/form.ts b/apps/web/src/page/alerts/editor/form.ts index 489145a7d2..aad6b6dcb7 100644 --- a/apps/web/src/page/alerts/editor/form.ts +++ b/apps/web/src/page/alerts/editor/form.ts @@ -3,6 +3,7 @@ import { AlertDestinationId, AlertSeverity, AlertSignalType, + AnomalySignalType, EscalationChainId, } from "@maple/domain/http" import { QueryEngineAlertReducer } from "@maple/domain/query-engine" @@ -31,6 +32,7 @@ export const RuleForm = Schema.Struct({ serviceNames: Strings, excludeServiceNames: Strings, environments: Strings, + anomalySignals: Schema.mutable(Schema.Array(AnomalySignalType)), tags: Strings, groupBy: Strings, signalType: AlertSignalType, @@ -61,3 +63,7 @@ export type RuleForm = typeof RuleForm.Type /** Both directions are checked: a field added to one side fails the build. */ export const asFormState = (form: RuleForm): RuleFormState => form export const fromFormState = (form: RuleFormState): RuleForm => form + +/** The warning tier's destinations, or the critical tier's. */ +export const Tier = Schema.Literals(["warning", "critical"]) +export type Tier = typeof Tier.Type diff --git a/apps/web/src/page/alerts/editor/init.ts b/apps/web/src/page/alerts/editor/init.ts index 54757ad7e2..2be612fcbe 100644 --- a/apps/web/src/page/alerts/editor/init.ts +++ b/apps/web/src/page/alerts/editor/init.ts @@ -39,6 +39,7 @@ export const init = (route: CreateRoute, _context: PageContext): PageReturn { if ( model.isDefaultDestinationApplied || model.form === null || + // Anomaly paging is opt-in: a new anomaly rule pages nobody until someone is picked. + model.form.signalType === "anomaly" || destinations.length === 0 || model.rules === null ) @@ -113,12 +115,11 @@ export const applyDefaultDestination = (model: Model): Model => { Arr.map(model.rules, (row) => rowToAlertRuleDocument(row, states)), ) const form = model.form + const isApplied = fallback !== null && form.destinationIds.length === 0 return { ...model, isDefaultDestinationApplied: true, - form: - fallback !== null && form.destinationIds.length === 0 - ? { ...form, destinationIds: [fallback] } - : form, + autoDestinationId: isApplied ? fallback : null, + form: isApplied ? { ...form, destinationIds: [fallback] } : form, } } diff --git a/apps/web/src/page/alerts/editor/sub-steps.ts b/apps/web/src/page/alerts/editor/sub-steps.ts index 9b0f9c7e16..8eaf0e687a 100644 --- a/apps/web/src/page/alerts/editor/sub-steps.ts +++ b/apps/web/src/page/alerts/editor/sub-steps.ts @@ -10,6 +10,7 @@ import type { PageContext } from "../../contract" import { applyQueryChange } from "../../kit/query-builder/change" import * as QueryPanel from "../../kit/query-builder/panel" import * as TimeRangePicker from "../../kit/time-range-picker" +import { isProtectedDefault } from "./derive" import { setBuiltinSignal, setChips, setSeverity } from "./form-steps" import { AUTO_INTERVAL, intervalRadioValue } from "./interval" import { Message } from "./message" @@ -42,7 +43,9 @@ export const radioStep = ( ? model.form.severity : intervalRadioValue(model.form.evaluationIntervalMinutes) const next = RadioGroup.update(RadioGroup.init(current ?? undefined), message) - const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotRadioMessage({ group, message: inner })) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotRadioMessage({ group, message: inner }), + ) const value = next.model.maybeValue if (value._tag === "None" || value.value === current) return { model, commands } const edited = editForm(model, context, (form) => { @@ -64,11 +67,18 @@ const withCommands = (step: Return, commands: Return["commands"]): Return => ({ commands: [...(commands ?? []), ...(step.commands ?? [])], }) -export const selectStep = (model: Model, context: PageContext, id: string, message: Select.Message): Return => { +export const selectStep = ( + model: Model, + context: PageContext, + id: string, + message: Select.Message, +): Return => { const current = model.selects[id] ?? Select.init(id, { value: "", selectedIndex: null }) const next = Select.update(current, message) const lifted: Model = { ...model, selects: { ...model.selects, [id]: next.model } } - const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotSelectMessage({ id, message: inner })) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotSelectMessage({ id, message: inner }), + ) if (next.outMessage === undefined) return { model: lifted, commands } const value = next.outMessage.value const edited = editForm(lifted, context, (form) => { @@ -82,14 +92,27 @@ export const selectStep = (model: Model, context: PageContext, id: string, messa return withCommands(edited, commands) } -export const comboStep = (model: Model, context: PageContext, id: string, message: Combobox.Message): Return => { +export const comboStep = ( + model: Model, + context: PageContext, + id: string, + message: Combobox.Message, +): Return => { const current = model.combos[id] ?? Combobox.init(id, "multiple") const next = Combobox.update(current, message) const lifted: Model = { ...model, combos: { ...model.combos, [id]: next.model } } - const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotComboMessage({ id, message: inner })) - if (next.outMessage === undefined || next.outMessage._tag !== "ChangedSelection") return { model: lifted, commands } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotComboMessage({ id, message: inner }), + ) + if (next.outMessage === undefined || next.outMessage._tag !== "ChangedSelection") + return { model: lifted, commands } const selected = next.outMessage.selected - return withCommands(editForm(lifted, context, (form) => setChips(form, id, selected)), commands) + // The default anomaly rule's scope stays empty: the server refuses any other. + if (isProtectedDefault(model)) return { model: lifted, commands } + return withCommands( + editForm(lifted, context, (form) => setChips(form, id, selected)), + commands, + ) } /** The rule's one query panel; its edits land on `queryBuilderDraft`. */ @@ -112,14 +135,18 @@ export const chartStep = (model: Model, message: Chart.Message): Return => { const next = Chart.update(model.chart, message) return { model: { ...model, chart: next.model }, - commands: Command.mapMessages(next.commands ?? [], (inner) => Message.GotChartMessage({ message: inner })), + commands: Command.mapMessages(next.commands ?? [], (inner) => + Message.GotChartMessage({ message: inner }), + ), } } /** The preview-only lookback: it re-runs the preview, never the rule being saved. */ export const timePickerStep = (model: Model, message: TimeRangePicker.Message): Return => { const next = TimeRangePicker.update(model.timePicker, message) - const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotTimePickerMessage({ message: inner })) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotTimePickerMessage({ message: inner }), + ) const lifted = { ...model, timePicker: next.model } // TODO(alert-preview): keep a custom preview window too; only presets are stored here. if (next.outMessage?._tag !== "SelectedPreset") return { model: lifted, commands } diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index 050d03cffa..f22433d58c 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -9,7 +9,7 @@ import { completeSave, completeTest, runTest, save } from "./actions" import { FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" import { SQL_TEXTAREA_ID } from "./view-sql" import { chatConnectStep, connectorsAnswered, liftDialog, menuStep, removeDestination } from "./dest-steps" -import { suggestedName } from "./derive" +import { isProtectedDefault, suggestedName } from "./derive" import { tagSuggestionsOf } from "./derive-rows" import * as F from "./form-steps" import { Message } from "./message" @@ -119,9 +119,23 @@ export const update = (model: Model, message: Message, context: PageContext): Re GotChartMessage: ({ message: inner }) => chartStep(model, inner), GotTimePickerMessage: ({ message: inner }) => timePickerStep(model, inner), ClickedSignalKind: ({ kind }) => { - if (model.form === null) return { model } + if (model.form === null || isProtectedDefault(model)) return { model } const moved = F.switchKind(model.form, kind) - return editForm({ ...model, kindNotice: moved.notice }, context, () => moved.form) + // Anomaly paging is opt-in: a destination the editor chose, not the user, does not carry over. + const auto = model.autoDestinationId + const form = + kind === "anomaly" && auto !== null + ? F.updateTierIds( + F.updateTierIds(moved.form, "warning", F.removeId(auto)), + "critical", + F.removeId(auto), + ) + : moved.form + return editForm( + { ...model, kindNotice: moved.notice, autoDestinationId: kind === "anomaly" ? null : auto }, + context, + () => form, + ) }, GotRadioMessage: ({ group, message: inner }) => radioStep(model, context, group, inner), ClickedOpenInBuilder: () => { @@ -129,6 +143,10 @@ export const update = (model: Model, message: Message, context: PageContext): Re const moved = F.switchKind(model.form, "builder_query") return editForm({ ...model, kindNotice: moved.notice }, context, () => moved.form) }, + ToggledAnomalySignal: ({ signal }) => + isProtectedDefault(model) + ? { model } + : editForm(model, context, (form) => F.toggleAnomalySignal(form, signal)), GotQueryPanelMessage: ({ message: inner }) => queryPanelStep(model, context, inner), GotSelectMessage: ({ id, message: inner }) => selectStep(model, context, id, inner), GotComboMessage: ({ id, message: inner }) => comboStep(model, context, id, inner), diff --git a/apps/web/src/page/alerts/editor/view-action-bar.ts b/apps/web/src/page/alerts/editor/view-action-bar.ts index f54f83861c..5ae1e545ab 100644 --- a/apps/web/src/page/alerts/editor/view-action-bar.ts +++ b/apps/web/src/page/alerts/editor/view-action-bar.ts @@ -9,7 +9,7 @@ import * as Tooltip from "@maple/views/ui/tooltip" import { stableSlice } from "../../kit/stable-slice" import { alertsHref } from "../shared/links" import type { AlertQueryTiming } from "@maple/domain/http" -import { describeRuleCondition, saveIssues } from "./derive" +import { describeRuleCondition, isProtectedDefault, saveIssues } from "./derive" import { draftTimingOf } from "./derive-rows" import type { RuleForm } from "./form" import { Message } from "./message" @@ -94,9 +94,10 @@ const validationSummary = (h: H, model: BarView, issues: ReadonlyArray): const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | null): Html => { // Unsaved field changes while editing, `null` on a new rule. const changeCount = model.savedForm === null ? null : diffRuleForms(model.savedForm, form).length - const issues = saveIssues(form, timing) + const isDefault = isProtectedDefault(model) + const issues = saveIssues(form, timing, isDefault) const isEditing = model.editingRuleId !== null - const summary = describeRuleCondition(form) + const summary = describeRuleCondition(form, isDefault) return h.div( [ h.Class( diff --git a/apps/web/src/page/alerts/editor/view-details.ts b/apps/web/src/page/alerts/editor/view-details.ts index af7f626354..a3b30a6fb2 100644 --- a/apps/web/src/page/alerts/editor/view-details.ts +++ b/apps/web/src/page/alerts/editor/view-details.ts @@ -16,6 +16,7 @@ import { Message } from "./message" import type { Model } from "./model" import { fieldStateOf, idsOf } from "./view-fields" import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" // `DetailsSection` and `TagInput`: the rule's name, description and tags. @@ -91,8 +92,8 @@ const nameField = (h: H, model: DetailsView, form: RuleForm): Html => { const state = fieldStateOf(form.name, model.mountedForm?.name) const ids = idsOf("rule-name", false) return field(h, { className: "items-stretch gap-1.5", state }, [ - hstack(h, { gap: "2", justify: "between" }, [ - fieldLabel(h, { ids, state }, "Rule name"), + hstack(h, { gap: "2", justify: "between", className: "min-w-0" }, [ + fieldLabel(h, { ids, state, className: "shrink-0" }, "Rule name"), ...(isSuggesting ? [ button( @@ -100,10 +101,14 @@ const nameField = (h: H, model: DetailsView, form: RuleForm): Html => { { variant: "ghost", size: "xs", - className: "px-1.5 text-2xs", - attributes: [h.OnClick(Message.ClickedSuggestName())], + // A long suggestion truncates instead of widening the card past a phone's width. + className: "min-w-0 shrink px-1.5 text-2xs", + attributes: [ + h.OnClick(Message.ClickedSuggestName()), + h.Title(suggestion ?? ""), + ], }, - ["Suggest: ", suggestion ?? ""], + [typography(h, { truncate: true }, ["Suggest: ", suggestion ?? ""])], ), ] : []), @@ -112,7 +117,9 @@ const nameField = (h: H, model: DetailsView, form: RuleForm): Html => { ids, state, attributes: [ - h.Placeholder("Error rate on payments"), + h.Placeholder( + form.signalType === "anomaly" ? "Checkout anomalies" : "Error rate on payments", + ), h.Value(form.name), h.OnInput((value) => Message.ChangedField({ field: "name", value })), ], diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index f3f8463d83..6633e92a72 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -31,7 +31,7 @@ type NotificationsView = NotifyView & Pick type NotificationsForm = Pick< RuleForm, - "comparator" | "escalation" | "destinationIds" | "notificationTitle" | "notificationBody" + "signalType" | "comparator" | "escalation" | "destinationIds" | "notificationTitle" | "notificationBody" > const TITLE_PLACEHOLDER = "{{ event.emoji }} {{ rule.name }} — {{ event.label }}" @@ -176,6 +176,13 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): h.div( [h.Class("mt-1 space-y-4")], [ + ...(form.signalType === "anomaly" + ? [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "Maple rates each anomaly warning or critical by how far it strays from its baseline.", + ]), + ] + : []), h.div( [h.Class("space-y-1.5")], [ @@ -259,6 +266,7 @@ const viewOf = stableSlice((model: Model) => ) const formOf = stableSlice((form: RuleForm) => Struct.pick(form, [ + "signalType", "comparator", "escalation", "destinationIds", diff --git a/apps/web/src/page/alerts/editor/view-query.ts b/apps/web/src/page/alerts/editor/view-query.ts index 3e19082666..692ed209ed 100644 --- a/apps/web/src/page/alerts/editor/view-query.ts +++ b/apps/web/src/page/alerts/editor/view-query.ts @@ -1,4 +1,5 @@ -import type { AlertSignalType } from "@maple/domain/http" +import type { AlertSignalType, AnomalySignalType } from "@maple/domain/http" +import { ANOMALY_SIGNAL_LABELS, ANOMALY_SIGNALS } from "@maple/web-data/alerts/anomaly-rule" import { GROUP_BY_OPTIONS } from "@maple/query-engine/query-builder" import { signalTypeToKind } from "@maple/web-data/alerts/rule-modes" import { cn } from "@maple/ui/lib/utils" @@ -10,9 +11,10 @@ import { chipsView } from "@maple/views/ui/combobox-view" import { icon, type IconName } from "@maple/views/ui/icon" import { label } from "@maple/views/ui/label" import * as RadioGroup from "@maple/views/ui/radio-group" -import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { multiSegmentedSelect, segmentedSelect } from "@maple/views/ui/segmented-select" import { stableSlice } from "../../kit/stable-slice" import { queryPanelView } from "../../kit/query-builder/view" +import { isProtectedDefault } from "./derive" import { spanAttributeKeysOf, traceFacetsOf } from "./derive-rows" import { baseValuesOf } from "./where-inputs" import type { RuleForm } from "./form" @@ -33,20 +35,34 @@ type H = HtmlBuilder /** What the section reads off the Model. */ type QueryView = SqlView & - Pick + Pick & { + /** Editing the org's default anomaly rule: its kind and scope are fixed. */ + readonly isDefaultAnomaly: boolean + } const KIND_OPTIONS: ReadonlyArray<{ value: SignalKind; label: string }> = [ { value: "builtin", label: "Built-in" }, { value: "builder_query", label: "Query builder" }, { value: "raw_query", label: "SQL" }, + { value: "anomaly", label: "Anomaly" }, ] const KIND_HINT = new Map([ ["builtin", "Five common signals on root spans. Switching modes carries the filters over."], ["builder_query", "Same builder as dashboards. Switching to Built-in keeps what it can express."], ["raw_query", "ClickHouse SQL, replayed per evaluation window."], + ["anomaly", "No query to write: Maple's anomaly detector decides what is unusual."], ]) +const ANOMALY_DESCRIPTION = + "Pages when Maple detects unusual error rate, latency, throughput, error spikes or log volume. The most specific anomaly rule handles each anomaly." + +const SCOPE_NEEDED_NOTE = + "Pick at least one service, environment or signal. Maple's default anomaly rule already covers everything." + +const DEFAULT_ANOMALY_NOTE = + "This is the default anomaly rule. It handles every anomaly no narrower rule covers, so its scope stays empty and it stays on." + const BUILTIN_SIGNALS: ReadonlyArray<{ value: AlertSignalType label: string @@ -298,6 +314,102 @@ const builtinForm = (h: H, model: QueryView, form: RuleForm): Html => { ) } +/** An anomaly rule's scope: services, environments and detector signals, each empty for all. */ +const anomalyForm = (h: H, model: QueryView, form: RuleForm): Html => { + const facets = traceFacetsOf(model) + const isLocked = model.isDefaultAnomaly + const isUnscoped = + form.serviceNames.length === 0 && form.environments.length === 0 && form.anomalySignals.length === 0 + return card( + h, + [ + h.div( + [h.Class("space-y-1 border-b px-3 py-2.5")], + [ + typography(h, { size: "sm", tone: "foreground", as: "p" }, [ANOMALY_DESCRIPTION]), + ...(isLocked + ? [ + hstack( + h, + { + gap: "1.5", + align: "start", + className: typographyClass({ size: "xs", tone: "muted" }), + }, + [ + icon(h, "LockIcon", { size: 12, className: "mt-0.5 shrink-0" }), + h.span([], [DEFAULT_ANOMALY_NOTE]), + ], + ), + ] + : isUnscoped + ? [typography(h, { size: "xs", tone: "warning", as: "p" }, [SCOPE_NEEDED_NOTE])] + : []), + ], + ), + h.div( + [h.Class("space-y-2 p-3")], + [ + builtinRow( + h, + "Services", + inputOf("rule-services"), + "Empty covers every service.", + chipsPicker(h, model, "rule-services", form.serviceNames, { + items: facets.services, + placeholder: form.serviceNames.length > 0 ? "Add service…" : "All services", + emptyText: "No services found", + isDisabled: isLocked, + }), + ), + builtinRow( + h, + "Environment", + inputOf("rule-environments"), + "Empty covers every environment.", + chipsPicker(h, model, "rule-environments", form.environments, { + items: facets.environments, + placeholder: + form.environments.length === 0 ? "All environments" : "Add environment…", + emptyText: "No environments found", + isDisabled: isLocked, + }), + ), + builtinRow( + h, + "Signals", + ANOMALY_SIGNALS_ID, + form.anomalySignals.length === 0 + ? "None picked: every signal pages." + : "Only the picked signals page.", + h.div( + [h.Id(ANOMALY_SIGNALS_ID)], + [ + multiSegmentedSelect(h, { + options: Arr.map(ANOMALY_SIGNALS, (signal) => ({ + value: signal, + label: ANOMALY_SIGNAL_LABELS[signal], + ariaLabel: ANOMALY_SIGNAL_LABELS[signal], + })), + value: form.anomalySignals, + onToggle: (signal) => Message.ToggledAnomalySignal({ signal }), + size: "sm", + ariaLabel: "Anomaly signals", + isDisabled: isLocked, + className: "flex-wrap", + }), + ], + ), + ), + ], + ), + ], + "gap-0 overflow-hidden p-0", + ) +} + +const ANOMALY_SIGNALS_ID = "rule-anomaly-signals" + /** `AlertQueryPanel`: the dashboard `QueryPanel` without Clone, Remove or the visibility box. */ const builderPanel = (h: H, model: QueryView, form: RuleForm): Html => h.div( @@ -331,21 +443,30 @@ const query = (h: H, model: QueryView, form: RuleForm, timing: AlertQueryTiming [ hstack(h, { gap: "3", wrap: true }, [ segmentedSelect(h, { - options: KIND_OPTIONS, + options: model.isDefaultAnomaly + ? Arr.map(KIND_OPTIONS, (option) => ({ + ...option, + isDisabled: option.value !== "anomaly", + })) + : KIND_OPTIONS, value: kind, onChange: (next) => Message.ClickedSignalKind({ kind: next }), ariaLabel: "Query mode", size: "sm", + // Four modes outgrow a phone's width: the track wraps instead of clipping the last one. + className: "max-w-full flex-wrap", }), typography(h, { size: "xs", tone: "muted", as: "p", className: "min-w-0 flex-1" }, [ model.kindNotice ?? KIND_HINT.get(kind) ?? "", ]), ]), - kind === "builtin" - ? builtinForm(h, model, form) - : kind === "builder_query" - ? builderPanel(h, model, form) - : rawQueryEditor(h, model, form, timing), + kind === "anomaly" + ? anomalyForm(h, model, form) + : kind === "builtin" + ? builtinForm(h, model, form) + : kind === "builder_query" + ? builderPanel(h, model, form) + : rawQueryEditor(h, model, form, timing), ], ) } @@ -359,6 +480,7 @@ const viewOf = stableSlice((model: Model): QueryView => ({ autocomplete: model.autocomplete, isSqlCollapsed: model.isSqlCollapsed, reducerSelect: model.selects[REDUCER_SELECT_ID], + isDefaultAnomaly: isProtectedDefault(model), })) /** Step 1, "What to watch"; `form` is the evaluated draft (`evaluatedFormOf`). */ diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index 19100c6c88..421e039618 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -16,6 +16,7 @@ import { RULE_CHART_TOKENS } from "../shared/rule-chart" import type { RuleForm } from "./form" import { Message } from "./message" import type { Model, Notice } from "./model" +import { isProtectedDefault } from "./derive" import { actionBar } from "./view-action-bar" import { conditionFields } from "./view-condition" import { detailsSection } from "./view-details" @@ -107,11 +108,22 @@ const evaluatedFormOf = stableSlice((form: RuleForm): RuleForm => ({ notificationBody: "", })) +/** Who gets paged is missing: no destination or escalation chain on a tier that needs one. */ +const isNotifyMissingOf = (form: RuleForm, isDefault: boolean): boolean => + form.signalType === "anomaly" + ? !isDefault && + form.destinationIds.length === 0 && + form.escalationChainId === null && + (form.escalation?.destinationIds.length ?? 0) === 0 && + (form.escalation?.escalationChainId ?? null) === null + : (form.destinationIds.length === 0 && form.escalationChainId === null) || + deriveTierIssues(form).includes("Critical destinations") + const surface = (h: H, model: Model, form: RuleForm, timeZone: string): ReadonlyArray => { const evaluated = evaluatedFormOf(form) - const isNotifyMissing = - (form.destinationIds.length === 0 && form.escalationChainId === null) || - deriveTierIssues(form).includes("Critical destinations") + // An anomaly rule has no condition to tune: the detector decides, so "When to alert" goes. + const isAnomaly = form.signalType === "anomaly" + const stepAfterQuery = isAnomaly ? 2 : 3 return [ h.div( [h.Class(cn("mx-auto w-full space-y-10", RULE_FORM_MAX_WIDTH))], @@ -123,47 +135,55 @@ const surface = (h: H, model: Model, form: RuleForm, timeZone: string): Readonly number: 1, hasDivider: false, title: "What to watch", - hint: "Pick a built-in signal, or build the query like a dashboard widget.", + hint: isAnomaly + ? "Pick which anomalies this rule pages on." + : "Pick a built-in signal, or build the query like a dashboard widget.", status: "done", }, [querySection(h, model, evaluated)], ), - ruleStep( - h, - { - number: 2, - title: "When to alert", - hint: "The dashed line is your threshold. Shaded bands show when the rule would have fired.", - status: isFilledNumber(form.threshold) ? "done" : "missing", - }, - [ - h.div( - [h.Class("space-y-4")], - [ - liveChartHero(h, model, form, evaluated, timeZone), - conditionFields(h, model, evaluated), - noiseSection(h, model, evaluated), - ], - ), - ], - ), + ...(isAnomaly + ? [] + : [ + ruleStep( + h, + { + number: 2, + title: "When to alert", + hint: "The dashed line is your threshold. Shaded bands show when the rule would have fired.", + status: isFilledNumber(form.threshold) ? "done" : "missing", + }, + [ + h.div( + [h.Class("space-y-4")], + [ + liveChartHero(h, model, form, evaluated, timeZone), + conditionFields(h, model, evaluated), + noiseSection(h, model, evaluated), + ], + ), + ], + ), + ]), h.div( - [h.Class("grid gap-6 border-t pt-8 lg:grid-cols-2 lg:items-start")], + [h.Class("grid grid-cols-1 gap-6 border-t pt-8 lg:grid-cols-2 lg:items-start")], [ ruleStep( h, { - number: 3, + number: stepAfterQuery, hasDivider: false, title: "Who gets notified", - status: isNotifyMissing ? "missing" : "done", + status: isNotifyMissingOf(form, isProtectedDefault(model)) + ? "missing" + : "done", }, [notificationsSection(h, model, form)], ), ruleStep( h, { - number: 4, + number: stepAfterQuery + 1, hasDivider: false, title: "Name it", status: Str.trim(form.name).length === 0 ? "missing" : "done", diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index a63a72c43b..d60fb79a67 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -1,4 +1,4 @@ -import type { AlertDestinationDocument } from "@maple/domain/http" +import type { AlertDestinationDocument, AlertIncidentDocument } from "@maple/domain/http" import { type AlertsOverviewReady, deriveOverview } from "@maple/web-data/alerts/overview" import { buildRuleStatesByRuleId, @@ -6,10 +6,10 @@ import { rowToAlertIncidentDocument, rowToAlertRuleDocument, } from "@maple/web-data/collections/alerts" -import { Option, Array as Arr } from "effect" +import { Option, Array as Arr, String as Str } from "effect" import { AsyncData } from "foldkit" import { lastBy } from "../../kit/last-by" -import type { RuleBucket } from "./message" +import type { InboxSignal, IncidentStatusFilter, RuleBucket } from "./message" import type { Model } from "./model" type OverviewKey = readonly [ @@ -44,6 +44,68 @@ const overviewFor = lastBy( export const overviewOf = (model: Model): Option.Option => overviewFor([model.rules, model.states, model.incidents, model.deliveries.data, model.nowMs]) +/** An incident is about a service: its labels say so, else its stored "service (env)" label. */ +export const isAboutService = (incident: AlertIncidentDocument, serviceName: string): boolean => + incident.labels?.service !== undefined + ? incident.labels.service === serviceName + : incident.groupLabel === serviceName || + (incident.groupLabel != null && Str.startsWith(`${serviceName} (`)(incident.groupLabel)) + +type SignalKey = readonly [...OverviewKey, string | undefined] + +const anomalyOverviewFor = lastBy( + ([ + rules, + states, + incidents, + deliveries, + nowMs, + serviceName, + ]: SignalKey): Option.Option => { + if (rules === null || incidents === null || nowMs === null) return Option.none() + const stateRows = states ?? [] + const statesByRule = buildRuleStatesByRuleId(stateRows) + const anomalyIncidents = Arr.filter( + Arr.map( + Arr.filter(incidents, (row) => row.signal_type === "anomaly"), + rowToAlertIncidentDocument, + ), + (incident) => serviceName === undefined || isAboutService(incident, serviceName), + ) + return Option.some( + deriveOverview({ + rules: Arr.map( + Arr.filter(rules, (row) => row.signal_type === "anomaly"), + (row) => rowToAlertRuleDocument(row, statesByRule), + ), + incidents: anomalyIncidents, + states: stateRows, + deliveryEvents: Option.getOrElse(AsyncData.getData(deliveries), () => []), + now: nowMs, + }), + ) + }, +) + +/** The overview narrowed to anomaly rules and incidents, and to one service when the view names one. */ +export const anomalyOverviewOf = (model: Model): Option.Option => + anomalyOverviewFor([ + model.rules, + model.states, + model.incidents, + model.deliveries.data, + model.nowMs, + model.search.serviceName, + ]) + +/** The Inbox's signal view; old anomaly links say "anomalies". */ +export const resolveSignal = (signal: string | undefined): InboxSignal => + signal === "anomaly" || signal === "anomalies" ? "anomaly" : null + +/** A signal view lists open incidents unless the link asks for resolved or all of them. */ +export const resolveIncidentStatus = (status: string | undefined): IncidentStatusFilter => + status === "resolved" || status === "all" ? status : "open" + const destinationsFor = lastBy( ([rows]: readonly [Model["destinations"]]): ReadonlyArray => rowsToAlertDestinationDocuments(rows ?? []), diff --git a/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts b/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts new file mode 100644 index 0000000000..9d6e02aedf --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts @@ -0,0 +1,263 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { AlertRuleId } from "@maple/domain/http" +import { UNTAGGED_KEY } from "@maple/web-data/alerts/tag-grouping" +import { Schema, Array as Arr } from "effect" +import { + click, + expect as expectView, + expectOutMessage, + given, + role, + scene, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import * as Menu from "@maple/views/ui/menu" +import { PageOutMessage, type PageContext } from "../../contract" +import { + anomalyIncidentRow, + defaultAnomalyRuleRow, + scopedAnomalyRuleRow, +} from "../shared/anomaly-test-fixtures" +import { destinationRow, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, ruleMenuId, update } from "./update" +import { view as pageView } from "./view" + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const OPEN_CHECKOUT = anomalyIncidentRow({ + n: 1, + ruleN: 8, + severity: "critical", + service: "checkout", + environment: "production", +}) +const OPEN_SEARCH = anomalyIncidentRow({ + n: 2, + ruleN: 7, + severity: "warning", + service: "search", + environment: "production", +}) +const RESOLVED_CHECKOUT = anomalyIncidentRow({ + n: 3, + ruleN: 8, + severity: "warning", + service: "checkout", + environment: "staging", + status: "resolved", +}) + +const loaded = ( + search: Model["search"], + incidents = [ + incidentRow({ rule_name: "Payment p95 latency" }), + OPEN_CHECKOUT, + OPEN_SEARCH, + RESOLVED_CHECKOUT, + ], +): Model => + Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedRules({ + orgId: ORG, + rows: [ + ruleRow(2, { name: "Payment p95 latency" }), + defaultAnomalyRuleRow(), + scopedAnomalyRuleRow(), + ], + }), + Message.UpdatedRuleStates({ + orgId: ORG, + rows: [ + stateRow(2, { last_status: "breached", last_value: 1840 }), + stateRow(7, { last_value: 1 }), + ], + }), + Message.UpdatedIncidents({ orgId: ORG, rows: incidents }), + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + ], + init({ _tag: "Alerts", search }, context).model, + (model, message) => pageUpdate(model, message).model, + ) + +describe("alerts hub: anomalies", () => { + test("the Anomalies filter lists anomaly incidents alone, and turning it off drops its status and service", () => { + scene( + { update: pageUpdate, view }, + given(loaded({})), + expectView(text("Payment p95 latency")).toExist(), + click(role("button", { name: /^Anomalies/ })), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + url: "/alerts?tab=inbox&signal=anomaly", + isReplace: false, + }), + ), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ signal: "anomaly", serviceName: "checkout", anomalyStatus: "all" })), + click(role("button", { name: /^Anomalies/ })), + expectOutMessage(PageOutMessage.RequestedNavigation({ url: "/alerts", isReplace: false })), + ) + }) + + test("the anomaly view shows open anomalies by their group, without other rules' incidents", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ signal: "anomaly" })), + expectView(text("2 firing")).toExist(), + expectView(role("link", { name: "Anomaly on checkout (production)" })).toExist(), + expectView(role("link", { name: "Anomaly on search (production)" })).toExist(), + expectView(role("link", { name: "Payment p95 latency" })).not.toExist(), + // The severity is the whole reading: no threshold, no dash placeholder. + expectView(text("Critical")).toExist(), + expectView(text(/^ \/ /)).not.toExist(), + // Resolved anomalies wait behind the status switch. + expectView(role("link", { name: "Anomaly on checkout (staging)" })).not.toExist(), + ) + }) + + test("a service and the resolved status narrow the view, as the old anomalies links did", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ signal: "anomaly", serviceName: "checkout", anomalyStatus: "resolved" })), + expectView(role("link", { name: "Anomaly on checkout (staging)" })).toExist(), + expectView(role("link", { name: "Anomaly on checkout (production)" })).not.toExist(), + // The hub holds 30 days of incidents, and the heading says so. + expectView(text("Resolved in the last 30 days")).toExist(), + expectView(role("link", { name: "Anomaly on search (production)" })).not.toExist(), + click(role("button", { name: "Remove checkout" })), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + url: "/alerts?signal=anomaly&anomalyStatus=resolved", + isReplace: false, + }), + ), + ) + }) + + test("with no anomalies the view says what the detector watches", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ signal: "anomaly" }, [incidentRow({ rule_name: "Payment p95 latency" })])), + expectView(text("No anomalies")).toExist(), + expectView(text(/^Maple watches error rate, latency, throughput/)).toExist(), + ) + }) + + test("narrowed to a service, the chip counts and the empty state speak for that service", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ signal: "anomaly", serviceName: "checkout" })), + expectView(role("button", { name: /^Anomalies\s*1$/ })).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ signal: "anomaly", serviceName: "payments" })), + expectView(text(/on payments, and opens an anomaly/)).toExist(), + expectView(text(/every service/)).not.toExist(), + ) + }) + + test("with only resolved anomalies the open view still says there are none", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ signal: "anomaly" }, [RESOLVED_CHECKOUT])), + expectView(text("No anomalies")).toExist(), + expectView(text(/^No rule fired/)).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ signal: "anomaly", anomalyStatus: "all" }, [RESOLVED_CHECKOUT])), + expectView(role("link", { name: "Anomaly on checkout (staging)" })).toExist(), + expectView(text(/^No rule fired/)).not.toExist(), + ) + }) + + test("the rules list marks the default anomaly rule, reads scopes, and offers no way to turn it off", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "rules" })), + expectView(text("Default")).toExist(), + expectView(text("Anomaly · all services · all environments · all signals")).toExist(), + expectView(text("Anomaly · checkout · production · Error rate, p95 latency")).toExist(), + expectView(text("Warning anomaly open")).toExist(), + ) + const model = loaded({ tab: "rules" }) + const toggle = (n: number) => + pageUpdate( + model, + Message.GotRowMenuMessage({ + menuId: ruleMenuId(Schema.decodeSync(AlertRuleId)(ruleId(n)), UNTAGGED_KEY), + message: Menu.Message.ClickedItem({ index: 1, item: { kind: "Item", value: "toggle" } }), + }), + ) + // A scoped anomaly rule turns off like any rule; the default one does not. + expect(toggle(8).model.togglingRuleId).toBe(ruleId(8)) + expect(toggle(7).model.togglingRuleId).toBeNull() + }) + + test("an unrouted default anomaly rule is normal; an unrouted scoped one is a blind spot", () => { + const withScoped = (rows: Parameters[0]["rows"]) => + Arr.reduce( + [ + Message.UpdatedRules({ orgId: ORG, rows }), + Message.UpdatedRuleStates({ + orgId: ORG, + rows: [stateRow(7, { last_value: 0 }), stateRow(9, { last_value: 0 })], + }), + ], + loaded({}), + (model, message) => pageUpdate(model, message).model, + ) + scene( + { update: pageUpdate, view }, + given(withScoped([defaultAnomalyRuleRow()])), + expectView(text("Unrouted")).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given( + withScoped([ + defaultAnomalyRuleRow(), + scopedAnomalyRuleRow(8), + { + ...scopedAnomalyRuleRow(9), + name: "Search anomalies", + thresholds_json: null, + destination_ids_json: [], + }, + ]), + ), + expectView(text("Unrouted")).toExist(), + expectView(text("Search anomalies")).toExist(), + ) + }) + + test("leaving the Inbox drops the Anomalies view's filters from the tab links", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ signal: "anomaly", anomalyStatus: "resolved", serviceName: "checkout" })), + expectView(role("link", { name: /^Rules/ })).toHaveAttr( + "href", + "/alerts?tab=rules&serviceName=checkout", + ), + expectView(role("link", { name: /^Inbox/ })).toHaveAttr( + "href", + "/alerts?tab=inbox&serviceName=checkout&signal=anomaly&anomalyStatus=resolved", + ), + ) + }) +}) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index a59fc947e0..46de9793cc 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -27,6 +27,14 @@ import { ServiceNamespaces } from "./uptime-groups" export const RuleBucket = Schema.Literals(["firing", "blind", "healthy", "disabled"]) export type RuleBucket = typeof RuleBucket.Type +/** The Inbox's signal view: every incident, or anomalies alone. */ +export const InboxSignal = Schema.NullOr(Schema.Literal("anomaly")) +export type InboxSignal = typeof InboxSignal.Type + +/** Which incidents a signal view lists. */ +export const IncidentStatusFilter = Schema.Literals(["open", "resolved", "all"]) +export type IncidentStatusFilter = typeof IncidentStatusFilter.Type + const rows = (row: S) => ({ orgId: Schema.String, rows: Schema.Array(row) }) const outcome = (success: S) => Schema.Result(success, LoadFailure) @@ -50,6 +58,9 @@ export const Message = defineMessageUnion({ ChangedRulesQuery: { value: Schema.String }, ClickedStatusFilter: { bucket: RuleBucket }, ClickedClearRuleFilters: {}, + ClickedInboxSignal: { signal: InboxSignal }, + ClickedIncidentStatus: { status: IncidentStatusFilter }, + ClickedClearInboxService: {}, ClickedShowAllRules: {}, ClickedToggleRule: { ruleId: AlertRuleId, isEnabled: Schema.Boolean }, CompletedToggleRule: { ruleId: AlertRuleId, result: outcome(Schema.Void) }, diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index b7058b9986..5f8f84999b 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -1,5 +1,6 @@ import type { UptimeMonitorId } from "@maple/domain" import type { AlertDestinationId, AlertRuleId, AlertSilenceId } from "@maple/domain/http" +import { isDefaultAnomalyRule } from "@maple/web-data/alerts/anomaly-rule" import { buildDestinationUpdateParamsV2, destinationToFormState } from "@maple/web-data/alerts/form-utils" import { rowToAlertDestinationDocument, rowToAlertRuleDocument } from "@maple/web-data/collections/alerts" import { Array, Option, Result, String as Str } from "effect" @@ -435,7 +436,9 @@ const updateNewRuleMenu = (model: Model, message: Menu.Message): Return => { ? newMonitorHref({ serviceName }) : result.outMessage.value === "slo" ? newSloHref({ serviceName }) - : createHref({ serviceName }) + : result.outMessage.value === "anomaly" + ? createHref({ serviceName, template: "anomaly" }) + : createHref({ serviceName }) return { model: next, commands, @@ -456,15 +459,18 @@ const pickedFromMenu = (model: Model, menuId: string, value: string): Return => return Option.match(rule, { onNone: () => ({ model }), onSome: (row) => - value === "open" - ? { - model, - outMessage: PageOutMessage.RequestedNavigation({ - url: ruleHref(row.id), - isReplace: false, - }), - } - : toggleRule(model, row.id, !row.enabled), + // The default anomaly rule stays on; its menu offers no toggle, and a stale pick does nothing. + value === "toggle" && isDefaultAnomalyRule(row) + ? { model } + : value === "open" + ? { + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: ruleHref(row.id), + isReplace: false, + }), + } + : toggleRule(model, row.id, !row.enabled), }) } return Option.match( @@ -545,6 +551,23 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, rulesQuery: "" }, outMessage: navigate({ ...model.search, status: undefined }), }), + ClickedInboxSignal: ({ signal }) => ({ + model, + // The status and service belong to the signal view: plain Inbox drops them. + outMessage: navigate( + signal === null + ? { ...model.search, signal: undefined, anomalyStatus: undefined, serviceName: undefined } + : { ...model.search, tab: "inbox", signal, anomalyStatus: undefined }, + ), + }), + ClickedIncidentStatus: ({ status }) => ({ + model, + outMessage: navigate({ ...model.search, anomalyStatus: status === "open" ? undefined : status }), + }), + ClickedClearInboxService: () => ({ + model, + outMessage: navigate({ ...model.search, serviceName: undefined }), + }), ClickedShowAllRules: () => ({ model: { ...model, isShowingAllRules: true } }), ClickedToggleRule: ({ ruleId, isEnabled }) => toggleRule(model, ruleId, isEnabled), CompletedToggleRule: ({ result }) => ({ diff --git a/apps/web/src/page/alerts/hub/view-anomalies.ts b/apps/web/src/page/alerts/hub/view-anomalies.ts new file mode 100644 index 0000000000..ce4fc9c39d --- /dev/null +++ b/apps/web/src/page/alerts/hub/view-anomalies.ts @@ -0,0 +1,217 @@ +import type { AlertIncidentDocument } from "@maple/domain/http" +import { formatRelativeFrom } from "@maple/ui/lib/time-format" +import { cn } from "@maple/ui/lib/utils" +import { formatAlertDuration } from "@maple/web-data/alerts/form-utils" +import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" +import { incidentSubline, incidentTitle } from "@maple/web-data/alerts/overview-derive" +import { Array as Arr, Order } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { borderedBox } from "@maple/views/ui/bordered-box" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" +import { eyebrow } from "@maple/views/ui/eyebrow" +import { filterChip } from "@maple/views/ui/filter-chip" +import { icon } from "@maple/views/ui/icon" +import { removableChip } from "@maple/views/ui/removable-chip" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { stripClass } from "@maple/views/ui/rule" +import { hstack } from "@maple/views/ui/stack" +import { rowLink } from "@maple/views/ui/table" +import { gridListRow } from "../../kit/grid-list-row" +import { typography, typographyClass } from "@maple/views/ui/typography" +import { listToolbar } from "../../kit/list-toolbar" +import { severityBadge } from "../shared/badges" +import { incidentHref } from "../shared/links" +import { isoMs } from "../shared/time" +import { inboxTab, type InboxAcknowledge } from "./view-inbox" +import { Message, type InboxSignal, type IncidentStatusFilter } from "./message" + +// The Inbox's signal view: anomaly incidents alone, open, resolved or both (what `/anomalies` was). + +type H = HtmlBuilder + +const RESOLVED_LIMIT = 50 +const RESOLVED_GRID = "grid grid-cols-[minmax(0,1fr)_110px_90px] items-center gap-4" + +const STATUS_OPTIONS: ReadonlyArray<{ value: IncidentStatusFilter; label: string }> = [ + { value: "open", label: "Open" }, + { value: "resolved", label: "Resolved" }, + { value: "all", label: "All" }, +] + +/** The Inbox's filter row: the Anomalies view toggle and, inside it, the status and service. */ +export const inboxFilters = ( + h: H, + options: Readonly<{ + signal: InboxSignal + status: IncidentStatusFilter + serviceName: string | undefined + openAnomalies: number + }>, +): Html => + listToolbar(h, { + className: "pb-0", + leading: hstack(h, { gap: "2", wrap: true }, [ + filterChip( + h, + { + isPressed: options.signal === "anomaly", + onClick: Message.ClickedInboxSignal({ + signal: options.signal === "anomaly" ? null : "anomaly", + }), + count: String(options.openAnomalies), + }, + ["Anomalies"], + ), + ...(options.signal === "anomaly" + ? [ + segmentedSelect(h, { + options: STATUS_OPTIONS, + value: options.status, + onChange: (status) => Message.ClickedIncidentStatus({ status }), + ariaLabel: "Anomaly status", + size: "xs", + }), + ...(options.serviceName === undefined + ? [] + : [ + removableChip(h, { + label: options.serviceName, + onRemove: Message.ClickedClearInboxService(), + }), + ]), + ] + : []), + ]), + }) + +/** Nothing to list: the detector watches every service and opens an anomaly here when it finds one. */ +const anomaliesEmpty = (h: H, status: IncidentStatusFilter, serviceName: string | undefined): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "PulseIcon", { size: 16 })]), + emptyTitle(h, [ + // The hub reads the last 30 days of incidents; older resolved anomalies are not here. + status === "resolved" ? "No anomalies resolved in the last 30 days" : "No anomalies", + ...(serviceName === undefined ? [] : [` on ${serviceName}`]), + ]), + emptyDescription(h, [ + `Maple watches error rate, latency, throughput, error spikes and log volume on ${serviceName ?? "every service"}, and opens an anomaly here when one looks unusual against its baseline.`, + ]), + ]), + ], + "rounded-md border border-dashed py-12", + ) + +const byResolvedDesc = Order.flip( + Order.mapInput(Order.Number, (incident: AlertIncidentDocument) => + isoMs(incident.resolvedAt ?? incident.lastTriggeredAt), + ), +) + +const resolvedRow = (h: H, incident: AlertIncidentDocument, now: number): Html => + gridListRow(h, { key: incident.id, grid: RESOLVED_GRID }, [ + h.div( + [h.Class("min-w-0 space-y-1")], + [ + hstack(h, { gap: "2", shrink: true }, [ + severityBadge(h, incident.peakSeverity ?? incident.severity), + rowLink( + h, + { + href: incidentHref(incident.id), + className: "min-w-0 truncate font-medium", + attributes: [h.Title(incidentTitle(incident))], + }, + [incidentTitle(incident)], + ), + ]), + typography(h, { size: "xs", tone: "muted", truncate: true, as: "p" }, [ + incidentSubline(incident), + ]), + ], + ), + typography(h, { size: "xs", tone: "muted", mono: true, numeric: true }, [ + incident.resolvedAt === null ? "" : formatRelativeFrom(isoMs(incident.resolvedAt), now), + ]), + typography(h, { size: "xs", mono: true, numeric: true }, [ + formatAlertDuration(incident.firstTriggeredAt, incident.resolvedAt), + ]), + ]) + +const resolvedList = (h: H, incidents: ReadonlyArray, now: number): Html => { + const shown = Arr.take(Arr.sort(incidents, byResolvedDesc), RESOLVED_LIMIT) + return h.section( + [h.Class("space-y-3")], + [ + eyebrow(h, { as: "h3" }, ["Resolved in the last 30 days"]), + borderedBox(h, { overflow: "x-auto" }, [ + h.div( + [h.Class("min-w-[520px]")], + [ + h.div( + [ + h.Class( + cn( + RESOLVED_GRID, + typographyClass({ + size: "xs", + tone: "muted", + className: stripClass("head"), + }), + ), + ), + ], + [ + h.span( + [], + [ + "Resolved · ", + String(incidents.length), + ...(incidents.length > shown.length + ? [`, newest ${shown.length}`] + : []), + ], + ), + h.span([], ["Resolved"]), + h.span([], ["Lasted"]), + ], + ), + h.ul( + [h.Class("divide-y")], + Arr.map(shown, (incident) => resolvedRow(h, incident, now)), + ), + ], + ), + ]), + ], + ) +} + +/** The Anomalies view of the Inbox, over the overview narrowed to anomalies. */ +export const anomalyInbox = ( + h: H, + data: AlertsOverviewReady, + options: Readonly<{ + status: IncidentStatusFilter + serviceName: string | undefined + acknowledge: InboxAcknowledge + }>, +): Html => { + const resolved = Arr.filter(data.incidents, (incident) => incident.status === "resolved") + const now = data.timelineRange.max + const hasOpenView = options.status !== "resolved" + const hasResolvedView = options.status !== "open" + // `incidents` also holds recently resolved ones, so the open side reads `openIncidents`. + const showOpen = hasOpenView && data.openIncidents.length > 0 + const showResolved = hasResolvedView && resolved.length > 0 + if (!showOpen && !showResolved) return anomaliesEmpty(h, options.status, options.serviceName) + return h.div( + [h.Class("space-y-8")], + [ + ...(showOpen ? [inboxTab(h, data, options.acknowledge)] : []), + ...(showResolved ? [resolvedList(h, resolved, now)] : []), + ], + ) +} diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 53a907d3a6..16d36e87bd 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -33,6 +33,7 @@ import { severityBadge } from "../shared/badges" import { incidentHref, ruleHref } from "../shared/links" import { sortIncidents } from "../shared/sort" import { isoMs, msIso } from "../shared/time" +import { gridListRow } from "../../kit/grid-list-row" import { typography, typographyClass } from "@maple/views/ui/typography" import { stripClass } from "@maple/views/ui/rule" import { hstack } from "@maple/views/ui/stack" @@ -207,11 +208,7 @@ const openIncidentRow = ( const isHeld = incident.holdReason !== null const acked = acknowledgedLine(incident) const tone = incident.severity === "critical" ? "text-severity-error" : "text-severity-warn" - return h.li( - [ - h.Key(incident.id), - h.Class(cn(INCIDENT_GRID, "relative px-4 py-3 hover:bg-surface-sunken active:bg-surface-sunken")), - ], + return gridListRow(h, { key: incident.id, grid: INCIDENT_GRID }, [ h.span([ h.AriaHidden(true), @@ -254,7 +251,9 @@ const openIncidentRow = ( isHeld ? h.span([h.Class("text-muted-foreground")], ["no data"]) : h.span([h.Class(tone)], [incidentValues(incident).now]), - h.span([h.Class("text-muted-foreground")], [" / ", incidentValues(incident).threshold]), + ...(incidentValues(incident).threshold === null + ? [] + : [h.span([h.Class("text-muted-foreground")], [" / ", incidentValues(incident).threshold])]), ]), typography(h, { size: "xs", mono: true, numeric: true }, [ formatAlertDuration(incident.firstTriggeredAt, msIso(now)), diff --git a/apps/web/src/page/alerts/hub/view-rules.ts b/apps/web/src/page/alerts/hub/view-rules.ts index d4294e063a..076400dc95 100644 --- a/apps/web/src/page/alerts/hub/view-rules.ts +++ b/apps/web/src/page/alerts/hub/view-rules.ts @@ -2,6 +2,7 @@ import type { AlertDestinationDocument, AlertIncidentDocument, AlertRuleDocument import { countLabel } from "@maple/ui/lib/format" import type { Tone } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" +import { anomalySeverityLabel, isDefaultAnomalyRule } from "@maple/web-data/alerts/anomaly-rule" import { formatSignalValue } from "@maple/web-data/alerts/form-utils" import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" import { @@ -15,6 +16,7 @@ import { type DerivedRuleStatus, worstState } from "@maple/web-data/alerts/rule- import { groupByTag, type TagGroup, UNTAGGED_KEY } from "@maple/web-data/alerts/tag-grouping" import type { Html, HtmlBuilder } from "foldkit/html" import { alert, alertDescription } from "@maple/views/ui/alert" +import { badge } from "@maple/views/ui/display" import { button } from "@maple/views/ui/button" import { filterChip } from "@maple/views/ui/filter-chip" import * as Menu from "@maple/views/ui/menu" @@ -30,6 +32,7 @@ import { Message, type RuleBucket } from "./message" import { canManage, type Model } from "./model" import { ruleMenuId } from "./update" import { searchInput } from "@maple/views/ui/search-input" +import { gridListRow } from "../../kit/grid-list-row" import { typography, typographyClass } from "@maple/views/ui/typography" import { stripClass } from "@maple/views/ui/rule" import { hstack } from "@maple/views/ui/stack" @@ -293,25 +296,27 @@ type RowInputs = Readonly<{ const ruleRow = (h: H, model: Model, row: RowInputs): Html => { const { rule } = row const menuId = ruleMenuId(rule.id, row.groupKey) + const isDefault = isDefaultAnomalyRule(rule) + // The default anomaly rule stays on: anomaly detection itself is what turns it off. const entries: ReadonlyArray = [ { _tag: "Item", value: "open", label: "Open rule" }, - { - _tag: "Item", - value: "toggle", - label: rule.enabled ? "Disable rule" : "Enable rule", - isDisabled: model.togglingRuleId !== null, - }, + ...(isDefault + ? [] + : [ + { + _tag: "Item" as const, + value: "toggle", + label: rule.enabled ? "Disable rule" : "Enable rule", + isDisabled: model.togglingRuleId !== null, + }, + ]), ] - return h.li( - [ - h.Key(rule.id), - h.Class(cn(ROW_GRID, "relative px-4 py-3 hover:bg-surface-sunken active:bg-surface-sunken")), - ], - [ - statusDot(h, { tone: BUCKET_TONE.get(row.bucket) ?? "neutral", size: "lg" }), - h.div( - [h.Class("min-w-0 space-y-0.5")], - [ + return gridListRow(h, { key: rule.id, grid: ROW_GRID }, [ + statusDot(h, { tone: BUCKET_TONE.get(row.bucket) ?? "neutral", size: "lg" }), + h.div( + [h.Class("min-w-0 space-y-0.5")], + [ + hstack(h, { gap: "2", shrink: true }, [ rowLink( h, { @@ -324,46 +329,49 @@ const ruleRow = (h: H, model: Model, row: RowInputs): Html => { }, [rule.name], ), - typography(h, { size: "xs", tone: "muted", mono: true, truncate: true, as: "p" }, [ - ruleConditionSummary(rule), - ]), - ], - ), - headroomMeter(h, rule, row.derived, row.lastValue), - stateRibbon(h, ruleRibbon(row.incidents, row.ribbonRange, RIBBON_CELLS)), - routesTo(h, row.destinations, rule.enabled), - h.span( - [ - h.Class( - cn( - typographyClass({ - size: "xs", - mono: true, - numeric: true, - className: "text-right", - }), - row.fired === 0 && "text-muted-foreground", - ), + ...(isDefault + ? [badge(h, { variant: "outline", size: "sm", className: "shrink-0" }, ["Default"])] + : []), + ]), + typography(h, { size: "xs", tone: "muted", mono: true, truncate: true, as: "p" }, [ + ruleConditionSummary(rule), + ]), + ], + ), + headroomMeter(h, rule, row.derived, row.lastValue), + stateRibbon(h, ruleRibbon(row.incidents, row.ribbonRange, RIBBON_CELLS)), + routesTo(h, row.destinations, rule.enabled && !isDefault), + h.span( + [ + h.Class( + cn( + typographyClass({ + size: "xs", + mono: true, + numeric: true, + className: "text-right", + }), + row.fired === 0 && "text-muted-foreground", ), - ], - [String(row.fired)], - ), - h.span( - [h.Class(ROW_LINK_LIFT)], - canManage(model) - ? [ - rowActionsMenu(h, { - slotId: menuId, - model: model.menus[menuId] ?? Menu.init(menuId), - entries, - label: `Actions for ${rule.name}`, - toParentMessage: (message) => Message.GotRowMenuMessage({ menuId, message }), - }), - ] - : [], - ), - ], - ) + ), + ], + [String(row.fired)], + ), + h.span( + [h.Class(ROW_LINK_LIFT)], + canManage(model) + ? [ + rowActionsMenu(h, { + slotId: menuId, + model: model.menus[menuId] ?? Menu.init(menuId), + entries, + label: `Actions for ${rule.name}`, + toParentMessage: (message) => Message.GotRowMenuMessage({ menuId, message }), + }), + ] + : [], + ), + ]) } const headroomMeter = ( @@ -375,6 +383,13 @@ const headroomMeter = ( if (!rule.enabled) return typography(h, { size: "xs", tone: "muted" }, ["Not evaluating"]) if (derived?.status === "error") return typography(h, { size: "xs", tone: "error" }, ["Query fails to run"]) + // An anomaly rule's reading is the worst open anomaly's severity, not a value against a threshold. + if (rule.signalType === "anomaly") + return value === null || value < 1 + ? typography(h, { size: "xs", tone: "muted" }, ["No open anomaly"]) + : typography(h, { size: "xs", tone: value >= 2 ? "error" : "warning" }, [ + `${anomalySeverityLabel(value)} anomaly open`, + ]) if (value === null) return typography(h, { size: "xs", tone: "muted" }, ["Waiting on data"]) const meter = ruleHeadroom(rule, value) const tone = meter.breached @@ -434,10 +449,15 @@ const stateRibbon = (h: H, cells: ReadonlyArray): Html => Arr.map(cells, (cell) => h.span([h.Class(cn("flex-1 rounded-2xs", RIBBON_CLASS.get(cell)))])), ) -const routesTo = (h: H, destinations: ReadonlyArray, isEnabled: boolean): Html => { +/** `warnWhenEmpty` is off for rules that may sit unrouted, like the default anomaly rule. */ +const routesTo = ( + h: H, + destinations: ReadonlyArray, + warnWhenEmpty: boolean, +): Html => { if (destinations.length === 0) return h.span( - [h.Class(cn("text-xs", isEnabled ? "text-severity-warn" : "text-muted-foreground"))], + [h.Class(cn("text-xs", warnWhenEmpty ? "text-severity-warn" : "text-muted-foreground"))], ["Nobody"], ) const names = Arr.join( diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index f575b2f9b6..fcdce8995f 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -1,4 +1,4 @@ -import { isManagedSignalType } from "@maple/domain/http" +import { type AlertIncidentId, isManagedSignalType } from "@maple/domain/http" import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" import { makeClientErrorBody } from "@maple/web-data/lib/error-messages" import { Option, Array as Arr } from "effect" @@ -16,10 +16,21 @@ import { dashboardPage } from "../../kit/dashboard-page" import { linkTabStrip } from "../../kit/underline-tabs" import { alertsHref, createHref } from "../shared/links" import { newSloHref } from "../shared/slo-commands" -import { type AlertsTab, destinationsOf, overviewOf, resolveStatus, resolveTab } from "./derive" +import { + type AlertsTab, + anomalyOverviewOf, + destinationsOf, + isAboutService, + overviewOf, + resolveIncidentStatus, + resolveSignal, + resolveStatus, + resolveTab, +} from "./derive" import { Message } from "./message" import { canManage, type Model } from "./model" import { alertsEmptyState } from "./view-empty" +import { anomalyInbox, inboxFilters } from "./view-anomalies" import { inboxTab, unacknowledgedCount } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" @@ -73,7 +84,12 @@ const tabBar = ( }) return { key: tab.value, - href: alertsHref({ ...model.search, tab: tab.value }), + // The Anomalies view belongs to the Inbox: other tabs drop its filters. + href: alertsHref( + tab.value === "inbox" + ? { ...model.search, tab: tab.value } + : { ...model.search, tab: tab.value, signal: undefined, anomalyStatus: undefined }, + ), label: tab.label, count: count !== undefined && count > 0 ? count : undefined, countTone: tab.value === "inbox" ? "error" : "muted", @@ -131,6 +147,13 @@ const newRuleButton = (h: H, model: Model): Html => "ChartLineIcon", "Error rate, latency or any query on traces, logs and metrics.", ), + newRuleItem( + h, + "anomaly", + "Anomaly rule", + "PulseIcon", + "Route what Maple's anomaly detector finds for some services, environments or signals.", + ), // Uptime monitors are admin-only to create; the API refuses members too. ...(canManage(model) ? [ @@ -269,14 +292,43 @@ const hubBody = ( const hasManagedIncidents = Arr.some(data.openIncidents, (incident) => isManagedSignalType(incident.signalType), ) - if (tab === "inbox") - return data.rules.length === 0 && !hasManagedIncidents - ? emptyState - : inboxTab(h, data, { - onAcknowledge: (incidentId, acknowledged) => - Message.ClickedAcknowledgeIncident({ incidentId, acknowledged }), - pendingIds: model.acknowledgingIncidentIds, - }) + const acknowledge = { + onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => + Message.ClickedAcknowledgeIncident({ incidentId, acknowledged }), + pendingIds: model.acknowledgingIncidentIds, + } + if (tab === "inbox") { + const signal = resolveSignal(model.search.signal) + const hasAnomalies = Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") + if (data.rules.length === 0 && !hasManagedIncidents && !hasAnomalies && signal === null) + return emptyState + const filters = inboxFilters(h, { + signal, + status: resolveIncidentStatus(model.search.anomalyStatus), + serviceName: model.search.serviceName, + // Counted under the same service filter the list applies. + openAnomalies: Arr.filter( + data.openIncidents, + (incident) => + incident.signalType === "anomaly" && + (model.search.serviceName === undefined || + isAboutService(incident, model.search.serviceName)), + ).length, + }) + const body = + signal === "anomaly" + ? Option.match(anomalyOverviewOf(model), { + onNone: () => hubSkeleton(h), + onSome: (anomalies) => + anomalyInbox(h, anomalies, { + status: resolveIncidentStatus(model.search.anomalyStatus), + serviceName: model.search.serviceName, + acknowledge, + }), + }) + : inboxTab(h, data, acknowledge) + return h.div([h.Class("space-y-6")], [filters, body]) + } return rulesTab(h, model, { data, destinations, diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index efd9aadf03..3d6bf95de3 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -1,4 +1,4 @@ -import { AlertIncidentId, AlertRuleId, IsoDateTimeString } from "@maple/domain/http" +import { AlertIncidentId, AlertRuleId, AnomalyIncidentId, IsoDateTimeString } from "@maple/domain/http" import { V2AlertRulePreviewParams, V2InvestigationCreateParams } from "@maple/domain/http/v2" import { Clock, Effect, Schema } from "effect" import { Command } from "foldkit" @@ -124,6 +124,34 @@ export const FetchIncidentMonitors = Command.define("FetchAlertIncidentMonitors" ), }) +/** An anomaly rule's incident: the anomaly the detector opened, its evidence. */ +export const FetchIncidentAnomaly = Command.define("FetchAlertIncidentAnomaly", { + args: { key: Schema.String, id: AnomalyIncidentId }, + messages: [Message.CompletedFetchAnomaly], + execute: ({ key, id }) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const result = yield* client.anomalies + .getIncident({ params: { id } }) + .pipe(Effect.mapError(toLoadFailure), Effect.result) + return Message.CompletedFetchAnomaly({ key, result }) + }), +}) + +/** The anomaly's signal around it, with its baseline and threshold. */ +export const FetchIncidentAnomalyTimeseries = Command.define("FetchAlertIncidentAnomalyTimeseries", { + args: { key: Schema.String, id: AnomalyIncidentId }, + messages: [Message.CompletedFetchAnomalyTimeseries], + execute: ({ key, id }) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const result = yield* client.anomalies + .getIncidentTimeseries({ params: { id }, query: {} }) + .pipe(Effect.mapError(toLoadFailure), Effect.result) + return Message.CompletedFetchAnomalyTimeseries({ key, result }) + }), +}) + export const FetchIncidentSilences = Command.define("FetchAlertIncidentSilences", { args: { key: Schema.String }, messages: [Message.CompletedFetchSilences], diff --git a/apps/web/src/page/alerts/incident/derive.ts b/apps/web/src/page/alerts/incident/derive.ts index 1fca929685..8a7171bcd2 100644 --- a/apps/web/src/page/alerts/incident/derive.ts +++ b/apps/web/src/page/alerts/incident/derive.ts @@ -88,8 +88,9 @@ const deriveIncidentView = (model: ViewKey): Option.Option => { rowToAlertIncidentDocument, ), range, + // An anomaly rule records only severity codes: the detector's own series is the evidence. checksQuery: - rule === null || isManagedSignalType(incident.signalType) + rule === null || isManagedSignalType(incident.signalType) || incident.signalType === "anomaly" ? Option.none() : Option.some({ ruleId: rule.id, @@ -98,7 +99,7 @@ const deriveIncidentView = (model: ViewKey): Option.Option => { }), // A monitor's or SLO's rule has no query to replay: its signal is the checks or the burn rate. payload: - rule === null || isManagedSignalType(incident.signalType) + rule === null || isManagedSignalType(incident.signalType) || incident.signalType === "anomaly" ? Option.none() : previewPayload(ruleToFormState(rule), { startTime: formatWarehouseDateTime(range.start), diff --git a/apps/web/src/page/alerts/incident/incident-anomaly.scene.test.ts b/apps/web/src/page/alerts/incident/incident-anomaly.scene.test.ts new file mode 100644 index 0000000000..40fc95111f --- /dev/null +++ b/apps/web/src/page/alerts/incident/incident-anomaly.scene.test.ts @@ -0,0 +1,234 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { AlertSilenceId } from "@maple/domain/http" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import { toLoadFailure } from "@maple/views/data/load-failure" +import type { PageContext } from "../../contract" +import { + anomalyAlertIncidentId, + anomalyId, + anomalyIncidentRow, + anomalyTimeseriesWire, + anomalyWire, + defaultAnomalyRuleRow, +} from "../shared/anomaly-test-fixtures" +import { NOW, ORG, ruleId } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const themeResolved = () => + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const SPIKE = { + n: 3, + ruleN: 7, + severity: "critical", + service: "checkout", + environment: "production", +} as const + +const synced = (scenario: Parameters[0]) => + run( + init({ _tag: "AlertIncident", incidentId: anomalyAlertIncidentId(scenario.n), search: {} }, context) + .model, + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [anomalyIncidentRow(scenario)] }), + Message.UpdatedRules({ orgId: ORG, rows: [defaultAnomalyRuleRow()] }), + ], + ) + +describe("alert incident: anomaly rules", () => { + test("an anomaly incident reads its anomaly and series, never a rule preview or checks", () => { + const entered = init( + { _tag: "AlertIncident", incidentId: anomalyAlertIncidentId(3), search: {} }, + context, + ) + const clocked = pageUpdate(entered.model, Message.ReadClock({ nowMs: NOW })).model + const withIncident = pageUpdate( + clocked, + Message.UpdatedIncidents({ orgId: ORG, rows: [anomalyIncidentRow(SPIKE)] }), + ) + expect(names(withIncident.commands)).toEqual( + expect.arrayContaining(["FetchAlertIncidentAnomaly", "FetchAlertIncidentAnomalyTimeseries"]), + ) + const withRule = pageUpdate( + withIncident.model, + Message.UpdatedRules({ orgId: ORG, rows: [defaultAnomalyRuleRow()] }), + ) + expect(names(withRule.commands)).not.toContain("FetchAlertIncidentPreview") + expect(names(withRule.commands)).not.toContain("FetchAlertIncidentChecks") + }) + + test("the evidence shows the detector's values, grouped errors and the way to the anomaly", () => { + const model = synced(SPIKE) + const loaded = run(model, [ + Message.CompletedFetchAnomaly({ + key: model.anomaly.key ?? "", + result: Result.succeed(anomalyWire(3, "error_spike", "critical", "checkout")), + }), + Message.CompletedFetchAnomalyTimeseries({ + key: model.anomalyTimeseries.key ?? "", + result: Result.succeed(anomalyTimeseriesWire("error_spike")), + }), + ]) + scene( + { update: pageUpdate, view }, + given(loaded), + themeResolved(), + expectView(text("Anomaly on checkout (production)")).toExist(), + expectView(text("Anomaly · checkout (production)")).toExist(), + expectView(text(/^Observed/)).toExist(), + expectView(text("Error frequency increase")).toExist(), + expectView(text("Grouped errors · 2")).toExist(), + expectView(text("triaging…")).toExist(), + expectView(role("link", { name: /Open anomaly/ })).toHaveAttr( + "href", + `/anomalies/${anomalyId(3)}`, + ), + expectView(role("link", { name: /Open issue/ })).toExist(), + // The rule's pinned severity code is never read out as a threshold. + expectView(text(/≥ 1|at or above 1/)).not.toExist(), + ) + }) + + test("an investigation of an anomaly sends the detector's readings, never severity codes", () => { + const model = synced(SPIKE) + const asked = pageUpdate( + model, + Message.CompletedFetchInvestigation({ + key: model.investigation.key ?? "", + result: Result.succeed(null), + }), + ).model + const factsOf = (from: typeof model) => + JSON.stringify(pageUpdate(from, Message.ClickedInvestigate()).commands?.[0]?.args ?? {}) + // Before the evidence loads there are no facts to send rather than "Observed: Critical". + expect(factsOf(asked)).not.toMatch(/Critical|Warning|Threshold/) + const loaded = pageUpdate( + asked, + Message.CompletedFetchAnomaly({ + key: asked.anomaly.key ?? "", + result: Result.succeed(anomalyWire(3, "error_spike", "critical", "checkout")), + }), + ).model + const facts = factsOf(loaded) + expect(facts).toContain("Baseline") + expect(facts).toContain("Error frequency increase") + expect(facts).not.toMatch(/"Threshold"|"Critical"/) + }) + + test("a warning anomaly without grouped errors has no fingerprint list or issue link", () => { + const scenario = { ...SPIKE, n: 4, severity: "warning" } as const + const model = synced(scenario) + const loaded = run(model, [ + Message.CompletedFetchAnomaly({ + key: model.anomaly.key ?? "", + result: Result.succeed(anomalyWire(4, "latency_p95", "warning", "checkout")), + }), + ]) + scene( + { update: pageUpdate, view }, + given(loaded), + themeResolved(), + expectView(text("p95 latency")).toExist(), + expectView(text(/^Grouped errors/)).not.toExist(), + expectView(role("link", { name: /Open issue/ })).not.toExist(), + ) + }) + + test("an anomaly that cannot be read still links to its page", () => { + const model = synced(SPIKE) + const failed = pageUpdate( + model, + Message.CompletedFetchAnomaly({ + key: model.anomaly.key ?? "", + result: Result.fail(toLoadFailure(new Error("timed out"))), + }), + ).model + scene( + { update: pageUpdate, view }, + given(failed), + themeResolved(), + expectView(text("Couldn't load the anomaly behind this incident.")).toExist(), + expectView(role("link", { name: /Open anomaly/ })).toExist(), + ) + }) + + test("Silence on an anomaly incident silences its service and environment, and its badge shows", () => { + const managed = run(synced(SPIKE), [ + Message.CompletedFetchSession({ grants: Policy.grantsOf(["org:engineer"]) }), + ]) + const opened = pageUpdate(managed, Message.ClickedSilence()).model + expect(opened.silenceDialog.scope).toEqual({ + _tag: "Fixed", + label: "Anomaly detection · checkout (production)", + matchers: { rule_ids: [ruleId(7)], services: ["checkout"], environments: ["production"] }, + }) + const silence = { + id: Schema.decodeSync(AlertSilenceId)("22222222-2222-4222-8222-222222222222"), + object: "alert_silence" as const, + reason: "Checkout deploy", + matchers: { services: ["checkout"] }, + status: "active" as const, + starts_at: new Date(NOW - 60_000).toISOString(), + ends_at: new Date(NOW + 60 * 60_000).toISOString(), + created_by: "user_1", + created_at: new Date(NOW - 60_000).toISOString(), + updated_at: new Date(NOW - 60_000).toISOString(), + } + scene( + { update: pageUpdate, view }, + given( + pageUpdate( + managed, + Message.CompletedFetchSilences({ + key: managed.silences.key ?? "", + result: Result.succeed([silence]), + }), + ).model, + ), + themeResolved(), + expectView(role("button", { name: "Silence" })).toExist(), + // The default rule has no scope: the incident's own service is what the silence covers. + expectView(text(/^Silenced until /u)).toExist(), + ) + }) + + test("the anomaly evidence is read again when its alert incident moves", () => { + const model = synced(SPIKE) + const quiet = pageUpdate( + model, + Message.UpdatedIncidents({ orgId: ORG, rows: [anomalyIncidentRow(SPIKE)] }), + ) + expect(names(quiet.commands)).not.toContain("FetchAlertIncidentAnomaly") + const escalated = pageUpdate( + model, + Message.UpdatedIncidents({ + orgId: ORG, + rows: [ + anomalyIncidentRow(SPIKE, { + last_triggered_at: new Date(NOW + 60_000).toISOString(), + severity: "warning", + }), + ], + }), + ) + expect(names(escalated.commands)).toEqual( + expect.arrayContaining(["FetchAlertIncidentAnomaly", "FetchAlertIncidentAnomalyTimeseries"]), + ) + }) +}) diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index 26c47d9bb7..c5192ade9c 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -3,6 +3,8 @@ import { V2AlertIncident, V2AlertIncidentEvent, V2AlertSilence, + V2AnomalyIncident, + V2AnomalyIncidentTimeseries, V2Investigation, V2Slo, V2UptimeMonitor, @@ -53,6 +55,12 @@ export const Message = defineMessageUnion({ key: Schema.String, result: Schema.Result(Schema.Array(V2UptimeMonitor), LoadFailure), }, + CompletedFetchAnomaly: { key: Schema.String, result: Schema.Result(V2AnomalyIncident, LoadFailure) }, + CompletedFetchAnomalyTimeseries: { + key: Schema.String, + result: Schema.Result(V2AnomalyIncidentTimeseries, LoadFailure), + }, + GotAnomalyChartMessage: { message: Chart.Message }, ClickedInvestigate: {}, ClickedAcknowledge: { acknowledged: Schema.Boolean }, CompletedSetAcknowledged: { diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index 876a740f82..dde17a5897 100644 --- a/apps/web/src/page/alerts/incident/model.ts +++ b/apps/web/src/page/alerts/incident/model.ts @@ -2,6 +2,8 @@ import { AlertDeliveryEventDocument } from "@maple/domain/http" import { V2AlertIncidentEvent, V2AlertSilence, + V2AnomalyIncident, + V2AnomalyIncidentTimeseries, V2Investigation, V2Slo, V2UptimeMonitor, @@ -46,6 +48,10 @@ export const Model = Schema.Struct({ slos: KeyedSchema(Schema.Array(V2Slo), LoadFailure), /** An uptime incident's link to its monitor: `uptimeMonitors.list`, read only for one. */ monitors: KeyedSchema(Schema.Array(V2UptimeMonitor), LoadFailure), + /** An anomaly rule's incident: the detector's anomaly and its signal, read only for one. */ + anomaly: KeyedSchema(V2AnomalyIncident, LoadFailure), + anomalyTimeseries: KeyedSchema(V2AnomalyIncidentTimeseries, LoadFailure), + anomalyChart: Chart.Model, /** `useAsyncAction`: the create request is running, so repeat clicks cannot start duplicates. */ isStartingInvestigation: Schema.Boolean, chart: Chart.Model, diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index 84407319cb..b58d214ea2 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -1,5 +1,10 @@ +import type { AlertIncidentDocument } from "@maple/domain/http" import type { V2InvestigationCreateParams } from "@maple/domain/http/v2" import { formatSignalValue, signalLabels } from "@maple/web-data/alerts/form-utils" +import { + formatSignalValue as formatAnomalyValue, + SIGNAL_LABEL as ANOMALY_SIGNAL_LABEL, +} from "@maple/web-data/anomalies/format" import { incidentGroup } from "@maple/web-data/alerts/incident-timeline" import { AlertIncidentRowSchema, @@ -7,7 +12,7 @@ import { AlertRuleStateRowSchema, } from "@maple/web-data/collections/alerts" import { Option, Result, Schema, Array as Arr } from "effect" -import { Command } from "foldkit" +import { AsyncData, Command } from "foldkit" import * as Chart from "@maple/views/charts/chart" import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" import * as Keyed from "@maple/views/data/keyed" @@ -18,6 +23,8 @@ import { type PageContext, PageOutMessage, type PageReturn } from "../../contrac import { CreateInvestigation, FetchIncidentActivity, + FetchIncidentAnomaly, + FetchIncidentAnomalyTimeseries, FetchIncidentChecks, FetchIncidentDeliveries, FetchIncidentInvestigation, @@ -97,13 +104,27 @@ const openSilence = (model: Model, context: PageContext): Return => onSome: ({ incident, rule }) => { const group = incidentGroup(incident) const name = rule?.name ?? incident.ruleName + const service = incident.labels?.service + const environment = incident.labels?.environment + // An anomaly's group is its service and environment: silence those, not the whole rule. const target = - rule === null - ? { label: name, matchers: { rule_ids: [incident.ruleId] } } - : { + incident.signalType === "anomaly" && service !== undefined + ? { label: group === null ? name : `${name} · ${group}`, - matchers: matchersForRule(rule, group), + matchers: { + rule_ids: [incident.ruleId], + services: [service], + ...(environment === undefined + ? undefined + : { environments: [environment] }), + }, } + : rule === null + ? { label: name, matchers: { rule_ids: [incident.ruleId] } } + : { + label: group === null ? name : `${name} · ${group}`, + matchers: matchersForRule(rule, group), + } return liftSilenceDialog( model, context, @@ -133,6 +154,9 @@ export const init = (route: IncidentRoute, context: PageContext): Return => { isUpdatingAcknowledgement: false, slos: Keyed.idle(), monitors: Keyed.idle(), + anomaly: Keyed.idle(), + anomalyTimeseries: Keyed.idle(), + anomalyChart: Chart.init(), isStartingInvestigation: false, chart: Chart.init(), chartRuntime: { _tag: "Loading" }, @@ -189,6 +213,13 @@ const followData = (model: Model, context: PageContext): Return => acknowledgedAt: view.incident.acknowledgedAt === null ? null : isoMs(view.incident.acknowledgedAt), }) + // The anomaly's evidence is re-read whenever its alert incident moves (status, severity, a + // new breach): the synced row is the change signal, and the previous answer stays on screen. + const evidenceVersion = { + status: view.incident.status, + severity: view.incident.severity, + lastTriggeredAt: isoMs(view.incident.lastTriggeredAt), + } const steps: ReadonlyArray<{ patch: Partial; commands: Commands }> = [ ...Option.toArray( Option.map( @@ -268,6 +299,40 @@ const followData = (model: Model, context: PageContext): Return => ), ), ), + ...Option.toArray( + Option.flatMap(Option.fromNullishOr(view.incident.anomalyIncidentId), (id) => { + const key = requestKey(context.orgId, "anomalies.getIncident", { + id, + ...evidenceVersion, + }) + return Option.map( + sameKey(model.anomaly, key) + ? Option.none() + : Keyed.request(model.anomaly, key, "Load"), + (anomaly) => ({ + patch: { anomaly }, + commands: [FetchIncidentAnomaly({ key, id })], + }), + ) + }), + ), + ...Option.toArray( + Option.flatMap(Option.fromNullishOr(view.incident.anomalyIncidentId), (id) => { + const key = requestKey(context.orgId, "anomalies.getIncidentTimeseries", { + id, + ...evidenceVersion, + }) + return Option.map( + sameKey(model.anomalyTimeseries, key) + ? Option.none() + : Keyed.request(model.anomalyTimeseries, key, "Load"), + (anomalyTimeseries) => ({ + patch: { anomalyTimeseries }, + commands: [FetchIncidentAnomalyTimeseries({ key, id })], + }), + ) + }), + ), ...Option.toArray( Option.flatMap(view.payload, (payload) => { const key = requestKey(context.orgId, "alertRules.preview", payload) @@ -290,6 +355,27 @@ const followData = (model: Model, context: PageContext): Return => }, }) +const thresholdFacts = (incident: AlertIncidentDocument) => [ + { label: "Signal", value: signalLabels[incident.signalType] }, + { label: "Observed", value: formatSignalValue(incident.signalType, incident.lastObservedValue) }, + { label: "Threshold", value: formatSignalValue(incident.signalType, incident.threshold) }, +] + +/** An anomaly's facts are the detector's readings; its alert values are only severity codes, so nothing is sent until those load. */ +const anomalyFacts = (model: Model) => + // Readings kept on screen while newer evidence loads are not sent as facts. + Option.match(model.anomaly.retained === true ? Option.none() : AsyncData.getData(model.anomaly.data), { + onNone: () => [], + onSome: (anomaly) => [ + { label: "Signal", value: ANOMALY_SIGNAL_LABEL[anomaly.signal_type] }, + { + label: "Observed", + value: formatAnomalyValue(anomaly.signal_type, anomaly.last_observed_value), + }, + { label: "Baseline", value: formatAnomalyValue(anomaly.signal_type, anomaly.baseline_median) }, + ], + }) + const createPayload = (model: Model): Option.Option => Option.map(incidentViewOf(model), ({ incident }) => ({ subject: @@ -306,14 +392,7 @@ const createPayload = (model: Model): Option.Option scope: incidentGroup(incident) ?? "all", status: incident.status, severity: incident.severity === "critical" ? ("critical" as const) : ("medium" as const), - facts: [ - { label: "Signal", value: signalLabels[incident.signalType] }, - { - label: "Observed", - value: formatSignalValue(incident.signalType, incident.lastObservedValue), - }, - { label: "Threshold", value: formatSignalValue(incident.signalType, incident.threshold) }, - ], + facts: incident.signalType === "anomaly" ? anomalyFacts(model) : thresholdFacts(incident), references: incident.errorIssueId ? [{ label: "Issue", url: `/errors/issues/${incident.errorIssueId}` }] : [], @@ -479,6 +558,21 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, chartRuntime: { _tag: "Failed", reason } }, }), ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + CompletedFetchAnomaly: ({ key, result }) => ({ + model: { ...model, anomaly: Keyed.accept(model.anomaly, key, result) }, + }), + CompletedFetchAnomalyTimeseries: ({ key, result }) => ({ + model: { ...model, anomalyTimeseries: Keyed.accept(model.anomalyTimeseries, key, result) }, + }), + GotAnomalyChartMessage: ({ message: child }) => { + const next = Chart.update(model.anomalyChart, child) + return { + model: { ...model, anomalyChart: next.model }, + commands: Command.mapMessages(next.commands ?? [], (message) => + Message.GotAnomalyChartMessage({ message }), + ), + } + }, CompletedFetchSilences: ({ key, result }) => ({ model: { ...model, silences: Keyed.accept(model.silences, key, result) }, }), diff --git a/apps/web/src/page/alerts/incident/view-anomaly.ts b/apps/web/src/page/alerts/incident/view-anomaly.ts new file mode 100644 index 0000000000..fb5d1d9793 --- /dev/null +++ b/apps/web/src/page/alerts/incident/view-anomaly.ts @@ -0,0 +1,164 @@ +import type { AnomalyIncidentId } from "@maple/domain/http" +import type { V2AnomalyIncident, V2AnomalyIncidentTimeseries } from "@maple/domain/http/v2" +import { EMPTY_VALUE } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { + META_CHIP_CLASS, + SIGNAL_LABEL, + TRIAGE_STATUS_CHIP, + deviation, + formatSignalValue, +} from "@maple/web-data/anomalies/format" +import { Array as Arr } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type * as Chart from "@maple/views/charts/chart" +import type { ChartRuntime } from "@maple/views/charts/chart-runtime" +import type { ThemeColors } from "@maple/views/charts/theme-colors" +import type { LoadFailure } from "@maple/views/data/load-failure" +import { linkButton } from "@maple/views/ui/button" +import { badge } from "@maple/views/ui/display" +import { eyebrow } from "@maple/views/ui/eyebrow" +import { icon } from "@maple/views/ui/icon" +import { keyValue, keyValueList } from "@maple/views/ui/key-value" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { anomalyIncidentFromV2 } from "../../../lib/services/anomalies" +import { AppRoute, hrefOf } from "../../../route" +import { anomalyFingerprintRow, anomalySignalChart, anomalySummary } from "../../anomalies/kit/evidence" + +// An anomaly rule's incident: the detector's evidence (its signal against the baseline, the +// values, the grouped errors) and the way to the anomaly, its issue and its triage. + +/** What the evidence section draws from: the page reads it off its Model, the lab off a fixture. */ +export type AnomalyEvidenceInputs = Readonly<{ + anomalyIncidentId: AnomalyIncidentId + anomaly: AsyncData.AsyncData + timeseries: AsyncData.AsyncData + chart: Chart.Model + toChartMessage: (message: Chart.Message) => M + chartRuntime: ChartRuntime + colors: ThemeColors | null + now: number + timeZone: string +}> + +const anomalyHref = (id: AnomalyIncidentId): string => hrefOf(AppRoute.Anomaly({ incidentId: id })) + +const openAnomalyLink = (h: HtmlBuilder, id: AnomalyIncidentId): Html => + linkButton(h, { size: "sm", variant: "outline", href: anomalyHref(id) }, [ + "Open anomaly", + icon(h, "ArrowRightIcon"), + ]) + +const loaded = (h: HtmlBuilder, inputs: AnomalyEvidenceInputs, wire: V2AnomalyIncident): Html => { + const anomaly = anomalyIncidentFromV2(wire) + const fmt = (value: number) => formatSignalValue(anomaly.signalType, value) + const triage = TRIAGE_STATUS_CHIP[anomaly.triageStatus] + return h.div( + [h.Class("space-y-4")], + [ + anomalySummary(h, anomaly, inputs.now, inputs.timeZone), + anomalySignalChart(h, { + chartId: "alert-incident-anomaly", + chart: inputs.chart, + toChartMessage: inputs.toChartMessage, + timeseries: inputs.timeseries, + incident: anomaly, + colors: inputs.colors, + chartRuntime: inputs.chartRuntime, + timeZone: inputs.timeZone, + }), + keyValueList(h, { layout: "split", className: "max-w-md" }, [ + keyValue(h, { label: "Signal" }, [SIGNAL_LABEL[anomaly.signalType]]), + keyValue(h, { label: "Service", title: anomaly.serviceName }, [ + anomaly.serviceName || EMPTY_VALUE, + ]), + keyValue(h, { label: "Environment" }, [anomaly.deploymentEnv || EMPTY_VALUE]), + keyValue(h, { label: "Observed", mono: true }, [fmt(anomaly.lastObservedValue)]), + keyValue(h, { label: "Baseline", mono: true }, [fmt(anomaly.baselineMedian)]), + keyValue(h, { label: "Deviation", mono: true }, [deviation(anomaly).label]), + ]), + ...(anomaly.fingerprints.length > 0 + ? [ + h.div( + [h.Class("space-y-1")], + [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + `Grouped errors · ${anomaly.fingerprints.length}`, + ]), + h.div( + [h.Class("max-w-md")], + Arr.map(anomaly.fingerprints, (fingerprint) => + anomalyFingerprintRow(h, anomaly, fingerprint), + ), + ), + ], + ), + ] + : []), + hstack(h, { gap: "2", wrap: true }, [ + openAnomalyLink(h, inputs.anomalyIncidentId), + ...(anomaly.errorIssueId === null + ? [] + : [ + linkButton( + h, + { + size: "sm", + variant: "outline", + href: hrefOf( + AppRoute.ErrorIssue({ issueId: anomaly.errorIssueId, search: {} }), + ), + }, + ["Open issue", icon(h, "ArrowRightIcon")], + ), + ]), + triage === null + ? typography(h, { size: "xs", tone: "muted" }, ["No AI triage yet"]) + : badge( + h, + { + pill: true, + size: "sm", + className: cn(META_CHIP_CLASS, "font-medium", triage.tone), + }, + [triage.label], + ), + ]), + ], + ) +} + +/** The anomaly evidence section of an anomaly rule's incident, under its "Anomaly · group" heading. */ +export const anomalyEvidence = ( + h: HtmlBuilder, + heading: string, + inputs: AnomalyEvidenceInputs, +): Html => + h.section( + [h.Class("space-y-3")], + [ + eyebrow(h, { as: "h2" }, [heading]), + AsyncData.match(inputs.anomaly, { + onIdle: () => skeleton(h, "h-[300px] w-full"), + onLoading: () => skeleton(h, "h-[300px] w-full"), + onFailure: () => failed(h, inputs.anomalyIncidentId), + onStale: () => failed(h, inputs.anomalyIncidentId), + onSuccess: (wire) => loaded(h, inputs, wire), + onRefreshing: (wire) => loaded(h, inputs, wire), + }), + ], + ) + +const failed = (h: HtmlBuilder, id: AnomalyIncidentId): Html => + h.div( + [h.Class("space-y-3")], + [ + typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "Couldn't load the anomaly behind this incident.", + ]), + openAnomalyLink(h, id), + ], + ) diff --git a/apps/web/src/page/alerts/incident/view-rail.ts b/apps/web/src/page/alerts/incident/view-rail.ts index 905c586de9..feb020fe92 100644 --- a/apps/web/src/page/alerts/incident/view-rail.ts +++ b/apps/web/src/page/alerts/incident/view-rail.ts @@ -2,6 +2,7 @@ import { pluralize } from "@maple/ui/lib/format" import { type AlertIncidentDocument, type AlertRuleDocument, isManagedSignalType } from "@maple/domain/http" import { formatWarehouseDateTime } from "@maple/query-engine" import { cn } from "@maple/ui/lib/utils" +import { anomalyScopeSummary } from "@maple/web-data/alerts/anomaly-rule" import { formatSignalValue } from "@maple/web-data/alerts/form-utils" import { conditionPhrase, incidentGroup, signalTitle } from "@maple/web-data/alerts/incident-timeline" import type { AlertRuleStateRow } from "@maple/web-data/collections/alerts" @@ -123,6 +124,7 @@ const incidentServices = ( rule: AlertRuleDocument | null, incident: AlertIncidentDocument, ): ReadonlyArray => { + if (incident.labels?.service != null) return [incident.labels.service] const group = incidentGroup(incident) if (group != null && rule?.groupBy?.length === 1 && rule.groupBy[0] === "service.name") return [group] return rule?.serviceNames ?? [] @@ -225,7 +227,10 @@ const ruleSection = ( (part) => part != null, ) const breaches = rule.consecutiveBreachesRequired - const summary = `${signalTitle(rule.signalType)} ${conditionPhrase(rule)} over ${rule.windowMinutes}m, ${breaches} ${breaches === 1 ? "check" : "checks in a row"} to open${scope.length > 0 ? `, ${Arr.join(scope, ", ")}` : ""}.` + const summary = + rule.signalType === "anomaly" + ? `Pages on anomalies in ${anomalyScopeSummary(rule)}.` + : `${signalTitle(rule.signalType)} ${conditionPhrase(rule)} over ${rule.windowMinutes}m, ${breaches} ${breaches === 1 ? "check" : "checks in a row"} to open${scope.length > 0 ? `, ${Arr.join(scope, ", ")}` : ""}.` // A monitor's or SLO's rule is edited through its owner, so it keeps only the history. const isManaged = isManagedSignalType(rule.signalType) return railSection( diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index b901bca4f6..fb2cca6406 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -57,12 +57,20 @@ import { silenceBadge, silenceButton, silenceCheckFailed } from "../shared/silen import * as Policy from "@maple/domain/policy" import * as SilenceDialogView from "../silence-dialog/view" import { incidentTimeline } from "./view-timeline" +import { anomalyEvidence, type AnomalyEvidenceInputs } from "./view-anomaly" +import { ANOMALY_CHART_TOKENS } from "../../anomalies/kit/evidence" import { timestamp } from "@maple/views/ui/timestamp" import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" type H = HtmlBuilder +/** The rule chart's tokens and the anomaly chart's, which an anomaly rule's incident draws instead. */ +export const INCIDENT_CHART_TOKENS: ReadonlyArray = Arr.dedupe([ + ...RULE_CHART_TOKENS, + ...ANOMALY_CHART_TOKENS, +]) + const statusLine = ( h: HtmlBuilder, incident: AlertIncidentDocument, @@ -112,31 +120,35 @@ export const headerTitle = ( const isUptime = incident.signalType === "uptime" const isSlo = incident.signalType === "slo_burn_rate" const isQuery = isQueryIncident(incident) - // An uptime or SLO incident names its monitor or SLO and a query incident its rule. + const isAnomaly = incident.signalType === "anomaly" + // An uptime or SLO incident names its monitor or SLO, a query incident its rule, an anomaly its group. const title = - isUptime || isSlo || isQuery + isUptime || isSlo || isQuery || isAnomaly ? incidentTitle(incident) : `${signalTitle(incident.signalType)} ${formatSignalValue(incident.signalType, incident.lastObservedValue)}${group != null ? ` on ${group}` : ""}` const subline = Arr.join( Arr.filter( isUptime ? ["Uptime check", group] - : isSlo - ? [ - "SLO burn rate", - // A resolved incident's last value is the healthy burn it ended on. - incident.status === "resolved" - ? null - : `now ${formatSignalValue(incident.signalType, incident.lastObservedValue)}`, - group, - ] - : [ - isQuery ? group : `Rule ${rule?.name ?? incident.ruleName}`, - `${isQuery ? "value" : Str.toLowerCase(signalLabels[incident.signalType])} ${conditionPhrase(incident)}${rule != null ? ` over ${rule.windowMinutes}m` : ""}`, - rule != null && rule.environments.length > 0 - ? Arr.join(rule.environments, ", ") - : null, - ], + : // An anomaly's severity badge says how bad it is: no threshold to read out. + isAnomaly + ? [`Rule ${rule?.name ?? incident.ruleName}`, "detected by Maple's anomaly detector"] + : isSlo + ? [ + "SLO burn rate", + // A resolved incident's last value is the healthy burn it ended on. + incident.status === "resolved" + ? null + : `now ${formatSignalValue(incident.signalType, incident.lastObservedValue)}`, + group, + ] + : [ + isQuery ? group : `Rule ${rule?.name ?? incident.ruleName}`, + `${isQuery ? "value" : Str.toLowerCase(signalLabels[incident.signalType])} ${conditionPhrase(incident)}${rule != null ? ` over ${rule.windowMinutes}m` : ""}`, + rule != null && rule.environments.length > 0 + ? Arr.join(rule.environments, ", ") + : null, + ], (part) => part != null, ), " · ", @@ -178,6 +190,7 @@ const silenceBadges = (h: H, model: Model, view: IncidentView, timeZone: string) rule, view.incident.groupKey, now, + view.incident.labels ?? null, ), ), (silence) => silenceBadge(h, silence, now, timeZone), @@ -258,6 +271,8 @@ export type IncidentBodyInputs = Readonly<{ chart: Chart.Model toChartMessage: (message: Chart.Message) => M colors: ThemeColors + /** An anomaly rule's incident: the detector's evidence, in place of the rule's chart. */ + anomaly?: AnomalyEvidenceInputs | null }> /** A managed incident's note on what opened it, and a link to its SLO or monitor (its tab until known). */ @@ -282,57 +297,71 @@ const sourceNote = ( export const incidentBody = (h: HtmlBuilder, inputs: IncidentBodyInputs): Html => { const { incident, rule } = inputs const group = incidentGroup(incident) + const heading = `${signalTitle(incident.signalType)}${group != null ? ` · ${group}` : ""}` + const anomaly = inputs.anomaly ?? null return h.div( [h.Class("grid gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]")], [ h.div( [h.Class("min-w-0 space-y-8")], [ - h.section( - [h.Class("space-y-3")], - [ - eyebrow(h, { as: "h2" }, [ - signalTitle(incident.signalType), - ...(group != null ? [` · ${group}`] : []), - ]), - incident.signalType === "uptime" - ? sourceNote(h, { - text: "This incident follows an uptime monitor: it opened when the monitor's checks went down and resolves when one passes again. The monitor's page lists each check with its failing trace.", - href: inputs.sourceHref ?? null, - label: "Open monitor", - fallback: { href: uptimeTabHref(), label: "Open monitors" }, - }) - : incident.signalType === "slo_burn_rate" - ? sourceNote(h, { - text: "This incident follows an SLO burning its error budget: it opened when the burn rate held at a tier's factor over both of its windows, and resolves when the short window cools. The SLO's page charts the budget and each window's burn rate.", - href: inputs.sourceHref ?? null, - label: "Open SLO", - fallback: { href: slosTabHref(), label: "Open SLOs" }, - }) - : rule === null + incident.signalType === "anomaly" && anomaly !== null + ? anomalyEvidence(h, heading, anomaly) + : h.section( + [h.Class("space-y-3")], + [ + eyebrow(h, { as: "h2" }, [ + signalTitle(incident.signalType), + ...(group != null ? [` · ${group}`] : []), + ]), + incident.signalType === "anomaly" ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ - "The rule behind this incident was deleted, so its signal cannot be replayed.", + "The anomaly behind this incident is no longer recorded, so its signal cannot be shown.", ]) - : ruleChart(h, { - chartId: inputs.chartId, - chart: inputs.chart, - toChartMessage: inputs.toChartMessage, - colors: inputs.colors, - timeZone: inputs.timeZone, - preview: inputs.preview, - checks: inputs.checks, - incidents: [incident], - threshold: incident.threshold, - thresholdUpper: incident.thresholdUpper, - comparator: incident.comparator, - signalType: incident.signalType, - window: { min: inputs.range.start, max: inputs.range.end }, - source: "preview", - isLoading: inputs.isPreviewLoading, - error: inputs.previewError, - }), - ], - ), + : incident.signalType === "uptime" + ? sourceNote(h, { + text: "This incident follows an uptime monitor: it opened when the monitor's checks went down and resolves when one passes again. The monitor's page lists each check with its failing trace.", + href: inputs.sourceHref ?? null, + label: "Open monitor", + fallback: { + href: uptimeTabHref(), + label: "Open monitors", + }, + }) + : incident.signalType === "slo_burn_rate" + ? sourceNote(h, { + text: "This incident follows an SLO burning its error budget: it opened when the burn rate held at a tier's factor over both of its windows, and resolves when the short window cools. The SLO's page charts the budget and each window's burn rate.", + href: inputs.sourceHref ?? null, + label: "Open SLO", + fallback: { href: slosTabHref(), label: "Open SLOs" }, + }) + : rule === null + ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "The rule behind this incident was deleted, so its signal cannot be replayed.", + ]) + : ruleChart(h, { + chartId: inputs.chartId, + chart: inputs.chart, + toChartMessage: inputs.toChartMessage, + colors: inputs.colors, + timeZone: inputs.timeZone, + preview: inputs.preview, + checks: inputs.checks, + incidents: [incident], + threshold: incident.threshold, + thresholdUpper: incident.thresholdUpper, + comparator: incident.comparator, + signalType: incident.signalType, + window: { + min: inputs.range.start, + max: inputs.range.end, + }, + source: "preview", + isLoading: inputs.isPreviewLoading, + error: inputs.previewError, + }), + ], + ), incidentTimeline(h, { entries: inputs.entries, timeZone: inputs.timeZone, @@ -415,6 +444,20 @@ const body = (h: H, model: Model, view: IncidentView, timeZone: string, now: num chart: model.chart, toChartMessage: (message) => Message.GotChartMessage({ message }), colors: model.themeColors ?? {}, + anomaly: + incident.anomalyIncidentId == null + ? null + : { + anomalyIncidentId: incident.anomalyIncidentId, + anomaly: model.anomaly.data, + timeseries: model.anomalyTimeseries.data, + chart: model.anomalyChart, + toChartMessage: (message) => Message.GotAnomalyChartMessage({ message }), + chartRuntime: model.chartRuntime, + colors: model.themeColors, + now, + timeZone, + }, }) } @@ -477,7 +520,7 @@ export const view = Submodel.defineView((model, }, }) return h.div( - [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: RULE_CHART_TOKENS }))], + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: INCIDENT_CHART_TOKENS }))], [ page, h.submodel({ diff --git a/apps/web/src/page/alerts/rule/rule-anomaly.scene.test.ts b/apps/web/src/page/alerts/rule/rule-anomaly.scene.test.ts new file mode 100644 index 0000000000..6602e74ab8 --- /dev/null +++ b/apps/web/src/page/alerts/rule/rule-anomaly.scene.test.ts @@ -0,0 +1,151 @@ +import { OrgId } from "@maple/domain" +import { AlertRuleId } from "@maple/domain/http" +import { Schema, Array as Arr } from "effect" +import { + all, + expect as expectView, + expectAll, + first, + given, + Mount, + scene, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import type { PageContext } from "../../contract" +import { anomalyIncidentRow, anomalyRuleRow, defaultAnomalyRuleRow } from "../shared/anomaly-test-fixtures" +import { DESTINATION_ID, destinationRow, NOW, ORG, stateRow } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const window = { startTime: "2026-09-30 12:00:00", endTime: "2026-10-01 12:00:00" } +const themeResolved = () => + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) + +const SCOPED = anomalyRuleRow(8, { + name: "Demo API anomalies", + service_names_json: ["demo-api"], + anomaly_signals_json: ["error_rate", "latency_p95"], + criticalIds: [DESTINATION_ID], +}) + +const loaded = ( + rule = SCOPED, + incidents = [ + anomalyIncidentRow({ + n: 1, + ruleN: 8, + severity: "critical", + service: "demo-api", + environment: "production", + }), + ], +) => { + const entered = init( + { _tag: "AlertRule", ruleId: Schema.decodeSync(AlertRuleId)(rule.id), search: {} }, + context, + ) + return Arr.reduce( + [ + Message.CompletedResolveWindow({ window, nowMs: NOW, mode: "Load" }), + Message.ReadDiagnosisClock({ nowMs: NOW }), + Message.UpdatedRules({ orgId: ORG, rows: [rule] }), + // The anomaly heartbeat row: its key is the ungrouped one, not a group anyone picked. + Message.UpdatedRuleStates({ + orgId: ORG, + rows: [ + stateRow(0, { + rule_id: rule.id, + group_key: "__total__", + last_value: 2, + }), + ], + }), + Message.UpdatedIncidents({ orgId: ORG, rows: incidents }), + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + ], + entered.model, + (model, message) => pageUpdate(model, message).model, + ) +} + +describe("alert rule page: anomaly rules", () => { + test("says what the rule pages on, and never asks for a query replay", () => { + const model = loaded() + expect(model.preview.key).toBeNull() + scene( + { update: pageUpdate, view }, + given(model), + themeResolved(), + expectView( + text("Pages on anomalies in demo-api · all environments · Error rate, p95 latency"), + ).toExist(), + expectView(text(/^Anomalies: /)).toExist(), + expectView(text("Anomaly on demo-api (production)")).toExist(), + expectView(text(/^The rule's query, replayed/)).not.toExist(), + expectView(text("Breaches to fire")).not.toExist(), + expectView(text("Min samples")).not.toExist(), + expectView(text("Threshold compared")).not.toExist(), + expectView(text("Group")).not.toExist(), + expectView(text("__total__")).not.toExist(), + ) + }) + + test("configuration lists scope, signals, each tier's destinations and the cadence", () => { + scene( + { update: pageUpdate, view }, + given(loaded()), + themeResolved(), + expectView(text("Signals")).toExist(), + expectView(text("p95 latency")).toExist(), + expectView(text("Nobody")).toExist(), + expectView(text("On-call webhook")).toExist(), + expectView(text("every 1m")).toExist(), + ) + }) + + test("an unrouted default rule reads as opt-in, with no anomalies in the window said plainly", () => { + const fallback = loaded(defaultAnomalyRuleRow(), []) + expect(names(pageUpdate(fallback, Message.ClickedReload()).commands)).not.toContain( + "FetchAlertRulePreview", + ) + scene( + { update: pageUpdate, view }, + given(fallback), + themeResolved(), + expectView(text("No anomalies in this window.")).toExist(), + expectView(text(/fires into the void/)).not.toExist(), + expectView(text("Configure destinations")).not.toExist(), + ) + }) + + test("lists the window's anomalies newest opening first", () => { + const older = anomalyIncidentRow( + { n: 2, ruleN: 8, severity: "warning", service: "demo-api", environment: "staging" }, + { first_triggered_at: new Date(NOW - 5 * 3_600_000).toISOString() }, + ) + const newer = anomalyIncidentRow( + { n: 3, ruleN: 8, severity: "critical", service: "demo-api", environment: "production" }, + { first_triggered_at: new Date(NOW - 10 * 60_000).toISOString() }, + ) + scene( + { update: pageUpdate, view }, + given(loaded(SCOPED, [older, newer])), + themeResolved(), + expectAll(all.role("link", { name: /^Anomaly on demo-api/ })).toHaveCount(2), + expectView(first(all.role("link", { name: /^Anomaly on demo-api/ }))).toHaveText( + "Anomaly on demo-api (production)", + ), + ) + }) +}) diff --git a/apps/web/src/page/alerts/rule/view-anomaly.ts b/apps/web/src/page/alerts/rule/view-anomaly.ts new file mode 100644 index 0000000000..38a87aa5e7 --- /dev/null +++ b/apps/web/src/page/alerts/rule/view-anomaly.ts @@ -0,0 +1,187 @@ +import type { AlertDestinationDocument, AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" +import { ANOMALY_SIGNAL_LABELS } from "@maple/web-data/alerts/anomaly-rule" +import { formatAlertDuration } from "@maple/web-data/alerts/form-utils" +import { incidentTitle } from "@maple/web-data/alerts/overview-derive" +import { formatDateInTimezone, formatTimeInTimezone } from "@maple/web-data/lib/timezone-format" +import { Array as Arr, Order } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { card, cardPanel } from "@maple/views/ui/card" +import { badge } from "@maple/views/ui/display" +import { eyebrow } from "@maple/views/ui/eyebrow" +import { keyValue, keyValueList } from "@maple/views/ui/key-value" +import { dividedList } from "@maple/views/ui/rule" +import { hstack } from "@maple/views/ui/stack" +import { rowLink } from "@maple/views/ui/table" +import { typography } from "@maple/views/ui/typography" +import { sectionHeading } from "../../kit/section-heading" +import { alertStatusBadge, severityBadge } from "../shared/badges" +import { incidentHref } from "../shared/links" +import { isoMs } from "../shared/time" + +// An anomaly rule's Overview: the anomalies it paged on in the window, and its scope and routing. +// It has no query to replay and no threshold, window or sample count of its own. + +const INCIDENT_LIMIT = 20 + +/** The list shows when each anomaly opened, so the newest opening leads. */ +const newestFirst = Order.flip( + Order.mapInput(Order.Number, (incident: AlertIncidentDocument) => isoMs(incident.firstTriggeredAt)), +) + +const overlaps = (incident: AlertIncidentDocument, range: Readonly<{ min: number; max: number }>): boolean => + isoMs(incident.firstTriggeredAt) <= range.max && + (incident.resolvedAt === null || isoMs(incident.resolvedAt) >= range.min) + +/** The rule's anomaly incidents that were open at some point in the window, newest first. */ +export const anomalyIncidentsSection = ( + h: HtmlBuilder, + options: Readonly<{ + incidents: ReadonlyArray + range: Readonly<{ min: number; max: number }> + rangeLabel: string + now: number + timeZone: string + }>, +): Html => { + const inWindow = Arr.take( + Arr.sort( + Arr.filter(options.incidents, (incident) => overlaps(incident, options.range)), + newestFirst, + ), + INCIDENT_LIMIT, + ) + const at = (iso: string) => + `${formatDateInTimezone(iso, { timeZone: options.timeZone })} ${formatTimeInTimezone(iso, { timeZone: options.timeZone })}` + return h.section( + [h.Class("space-y-3")], + [ + h.div( + [h.Class("space-y-1")], + [ + eyebrow(h, { as: "h2", className: "mb-0 block" }, [`Anomalies: ${options.rangeLabel}`]), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "What Maple's anomaly detector found in this rule's scope. The detector decides; this rule only routes.", + ]), + ], + ), + inWindow.length === 0 + ? typography(h, { size: "sm", tone: "muted", as: "p" }, ["No anomalies in this window."]) + : dividedList( + h, + { as: "ul" }, + Arr.map(inWindow, (incident) => + h.li( + [ + h.Key(incident.id), + h.Class("relative flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3"), + ], + [ + severityBadge(h, incident.peakSeverity ?? incident.severity), + rowLink( + h, + { + href: incidentHref(incident.id), + className: "min-w-0 flex-1 truncate font-medium", + }, + [incidentTitle(incident)], + ), + typography(h, { size: "xs", tone: "muted", mono: true, numeric: true }, [ + incident.status === "open" + ? `open since ${at(incident.firstTriggeredAt)}` + : `${at(incident.firstTriggeredAt)} · lasted ${formatAlertDuration(incident.firstTriggeredAt, incident.resolvedAt)}`, + ]), + ], + ), + ), + ), + ], + ) +} + +const valueList = (h: HtmlBuilder, values: ReadonlyArray, all: string): Html => + values.length === 0 + ? typography(h, { weight: "medium", mono: true }, [all]) + : h.div( + [h.Class("flex flex-wrap justify-end gap-1")], + Arr.map(values, (value) => badge(h, { variant: "outline", className: "text-xs" }, [value])), + ) + +const tierDestinations = ( + h: HtmlBuilder, + ids: ReadonlyArray, + destinations: ReadonlyArray, +): Html => { + const byId = new Map( + Arr.map(destinations, (destination) => [destination.id, destination.name]), + ) + const names = Arr.flatMap(ids, (id): ReadonlyArray => { + const name = byId.get(id) + return name === undefined ? [] : [name] + }) + return names.length === 0 ? typography(h, { tone: "muted" }, ["Nobody"]) : valueList(h, names, "") +} + +const row = (h: HtmlBuilder, label: string, children: ReadonlyArray): Html => + keyValue(h, { label, wrap: true, className: "items-center" }, children) + +/** An anomaly rule's configuration: its scope, each tier's destinations and its cadence. */ +export const anomalyConfiguration = ( + h: HtmlBuilder, + rule: AlertRuleDocument, + destinations: ReadonlyArray, +): Html => { + const tier = (severity: "warning" | "critical"): ReadonlyArray => + Arr.flatMap(rule.thresholds, (threshold): ReadonlyArray => + threshold.severity === severity ? threshold.destinationIds : [], + ) + return h.div( + [h.Class("space-y-3")], + [ + sectionHeading(h, { title: "Configuration" }), + card(h, [ + cardPanel( + h, + [ + keyValueList( + h, + { className: "grid grid-cols-1 gap-x-8 gap-y-2 text-sm sm:grid-cols-2" }, + [ + ...(rule.notes + ? [ + row(h, "Notes", [ + h.span([h.Class("whitespace-pre-wrap")], [rule.notes]), + ]), + ] + : []), + row(h, "Signal", [typography(h, { weight: "medium" }, ["Anomaly"])]), + row(h, "Services", [valueList(h, rule.serviceNames, "all")]), + row(h, "Environments", [valueList(h, rule.environments, "all")]), + row(h, "Signals", [ + valueList( + h, + Arr.map( + rule.anomalySignals ?? [], + (signal) => ANOMALY_SIGNAL_LABELS[signal], + ), + "all", + ), + ]), + row(h, "Warning", [tierDestinations(h, tier("warning"), destinations)]), + row(h, "Critical", [tierDestinations(h, tier("critical"), destinations)]), + row(h, "Checked", [typography(h, { weight: "medium" }, ["every 1m"])]), + row(h, "Status", [ + hstack(h, { gap: "2", justify: "end" }, [ + alertStatusBadge(h, rule.enabled ? "ok" : "disabled", { + label: rule.enabled ? "Enabled" : "Disabled", + }), + ]), + ]), + ], + ), + ], + "p-5", + ), + ]), + ], + ) +} diff --git a/apps/web/src/page/alerts/rule/view-overview.ts b/apps/web/src/page/alerts/rule/view-overview.ts index 91576001b5..40eb57d5a7 100644 --- a/apps/web/src/page/alerts/rule/view-overview.ts +++ b/apps/web/src/page/alerts/rule/view-overview.ts @@ -40,6 +40,7 @@ import { } from "./derive" import { Message } from "./message" import type { Model, SignalSource } from "./model" +import { anomalyConfiguration, anomalyIncidentsSection } from "./view-anomaly" import { checksPanel } from "./view-checks" import { diagnosisPanel } from "./view-diagnosis" import { queryTimeCard } from "./view-query-time" @@ -318,6 +319,73 @@ export const overviewTab = (h: H, model: Model, rule: AlertRuleDocument, timeZon const isChartReady = model.themeColors !== null && isChartRuntimeReady(model.chartRuntime) const isChecksLoading = AsyncData.isIdle(model.checks.data) || AsyncData.isLoading(model.checks.data) + const isAnomaly = rule.signalType === "anomaly" + // An anomaly rule has no query to replay: its incidents stand in for the chart. + const signalSection = h.div( + [h.Class("space-y-3")], + [ + h.div( + [h.Class("flex flex-wrap items-end justify-between gap-x-4 gap-y-2")], + [ + h.div( + [h.Class("space-y-1")], + [ + eyebrow(h, { as: "h2", className: "mb-0 block" }, [ + `${signalLabels[rule.signalType]}: ${rangeLabel}`, + ]), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + SIGNAL_SOURCE_DESCRIPTION.get(model.signalSource) ?? "", + ]), + ], + ), + segmentedSelect(h, { + options: [ + { + value: "preview" as const, + label: SIGNAL_SOURCE_LABEL.preview, + isDisabled: !isPreviewAvailable, + }, + { + value: "checks" as const, + label: SIGNAL_SOURCE_LABEL.checks, + isDisabled: !isChecksAvailable, + }, + ], + value: model.signalSource, + onChange: (source) => Message.ClickedSignalSource({ source }), + size: "sm", + ariaLabel: "Chart data source", + }), + ], + ), + ruleChart(h, { + chartId: "alert-rule-chart", + chart: model.chart, + toChartMessage: (message) => Message.GotChartMessage({ message }), + colors: model.themeColors ?? {}, + timeZone, + preview: preview.preview, + checks: chartChecks, + incidents: ruleIncidents, + threshold: rule.threshold, + thresholdUpper: rule.thresholdUpper, + criticalThreshold: rule.thresholds[1]?.value ?? null, + comparator: rule.comparator, + signalType: rule.signalType, + window: timelineRangeOf(model), + source: model.signalSource, + railCoverage, + selectedBucket: model.selectedBucket?.index ?? null, + onSelectBucket: (index) => Message.ClickedRailBucket({ index }), + isLoading: + preview.isLoading || + !isChartReady || + (rule.signalType === "raw_query" && isChecksLoading), + error: preview.error, + }), + ], + ) + return h.div( [h.Class("space-y-6")], [ @@ -331,71 +399,17 @@ export const overviewTab = (h: H, model: Model, rule: AlertRuleDocument, timeZon now: model.nowMs, timeZone, }), - ...Arr.fromNullishOr(queryTimeCard(h, rule, checks, timeZone)), - h.div( - [h.Class("space-y-3")], - [ - h.div( - [h.Class("flex flex-wrap items-end justify-between gap-x-4 gap-y-2")], - [ - h.div( - [h.Class("space-y-1")], - [ - eyebrow(h, { as: "h2", className: "mb-0 block" }, [ - `${signalLabels[rule.signalType]}: ${rangeLabel}`, - ]), - typography(h, { size: "xs", tone: "muted", as: "p" }, [ - SIGNAL_SOURCE_DESCRIPTION.get(model.signalSource) ?? "", - ]), - ], - ), - segmentedSelect(h, { - options: [ - { - value: "preview" as const, - label: SIGNAL_SOURCE_LABEL.preview, - isDisabled: !isPreviewAvailable, - }, - { - value: "checks" as const, - label: SIGNAL_SOURCE_LABEL.checks, - isDisabled: !isChecksAvailable, - }, - ], - value: model.signalSource, - onChange: (source) => Message.ClickedSignalSource({ source }), - size: "sm", - ariaLabel: "Chart data source", - }), - ], - ), - ruleChart(h, { - chartId: "alert-rule-chart", - chart: model.chart, - toChartMessage: (message) => Message.GotChartMessage({ message }), - colors: model.themeColors ?? {}, - timeZone, - preview: preview.preview, - checks: chartChecks, - incidents: ruleIncidents, - threshold: rule.threshold, - thresholdUpper: rule.thresholdUpper, - criticalThreshold: rule.thresholds[1]?.value ?? null, - comparator: rule.comparator, - signalType: rule.signalType, - window: timelineRangeOf(model), - source: model.signalSource, - railCoverage, - selectedBucket: model.selectedBucket?.index ?? null, - onSelectBucket: (index) => Message.ClickedRailBucket({ index }), - isLoading: - preview.isLoading || - !isChartReady || - (rule.signalType === "raw_query" && isChecksLoading), - error: preview.error, - }), - ], - ), + ...(isAnomaly + ? [ + anomalyIncidentsSection(h, { + incidents: ruleIncidents, + range: timelineRangeOf(model), + rangeLabel, + now: model.nowMs, + timeZone, + }), + ] + : [...Arr.fromNullishOr(queryTimeCard(h, rule, checks, timeZone)), signalSection]), model.incidents === null ? skeleton(h, "h-40 w-full") : overviewIncident === null @@ -425,7 +439,7 @@ export const overviewTab = (h: H, model: Model, rule: AlertRuleDocument, timeZon [icon(h, "ChatBubbleSparkleIcon"), "Open investigation"], ), ]), - configuration(h, rule), + isAnomaly ? anomalyConfiguration(h, rule, destinationsOf(model)) : configuration(h, rule), Option.match(checksFailureOf(model), { onSome: (failure) => h.div( @@ -439,7 +453,11 @@ export const overviewTab = (h: H, model: Model, rule: AlertRuleDocument, timeZon }), ], ), - onNone: () => checksPanel(h, model, { rule, page, isLoading: isChecksLoading, timeZone }), + // An anomaly rule's checks only record a severity code: its incidents say more. + onNone: () => + isAnomaly + ? h.empty + : checksPanel(h, model, { rule, page, isLoading: isChecksLoading, timeZone }), }), ], ) diff --git a/apps/web/src/page/alerts/rule/view.ts b/apps/web/src/page/alerts/rule/view.ts index bc4851c344..d4d69ecbd2 100644 --- a/apps/web/src/page/alerts/rule/view.ts +++ b/apps/web/src/page/alerts/rule/view.ts @@ -1,4 +1,5 @@ import type { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" +import { anomalyScopeSummary } from "@maple/web-data/alerts/anomaly-rule" import { comparatorLabels, formatSignalValue, signalLabels } from "@maple/web-data/alerts/form-utils" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -41,6 +42,8 @@ type Tab = (typeof TABS)[number] const activeTabOf = (model: Model): Tab => (model.search.tab === "history" ? "history" : "overview") export const ruleSubtitle = (rule: AlertRuleDocument): string => { + // An anomaly rule has no threshold or window: what it pages on is the whole story. + if (rule.signalType === "anomaly") return `Pages on anomalies in ${anomalyScopeSummary(rule)}` const tiers = Arr.join( Arr.map(rule.thresholds, (tier) => rule.thresholds.length > 1 @@ -71,7 +74,13 @@ const ruleSilence = ( Arr.flatMap(openIncidents, (incident) => Option.toArray( Option.map( - activeSilenceFor(silences, rule, incident.groupKey, model.nowMs), + activeSilenceFor( + silences, + rule, + incident.groupKey, + model.nowMs, + incident.labels ?? null, + ), (silence) => ({ silence, group: incidentGroup(incident), diff --git a/apps/web/src/page/alerts/shared/anomaly-test-fixtures.ts b/apps/web/src/page/alerts/shared/anomaly-test-fixtures.ts new file mode 100644 index 0000000000..ddb22dc35b --- /dev/null +++ b/apps/web/src/page/alerts/shared/anomaly-test-fixtures.ts @@ -0,0 +1,197 @@ +import { AnomalyIncidentId, ErrorIssueId } from "@maple/domain/http" +import { + encodePublicId, + PublicIdPrefixes, + V2AnomalyIncident, + V2AnomalyIncidentTimeseries, +} from "@maple/domain/http/v2" +import type { AlertIncidentRow, AlertRuleRow } from "@maple/web-data/collections/alerts" +import { Array as Arr, Schema } from "effect" +import { DESTINATION_ID, incidentRow, NOW, ruleId, ruleRow } from "./test-rows" + +// Anomaly rules, their alert incidents and the detector's anomaly behind one, for the scene tests +// and `/lab/anomaly-alerts`. + +const MINUTE = 60_000 +const iso = (msBefore: number) => new Date(NOW - msBefore).toISOString() + +export const anomalyId = (n: number) => + Schema.decodeSync(AnomalyIncidentId)(`0199a060-0000-7000-8000-00000000000${n}`) +export const anomalyAlertIncidentId = (n: number) => `0199a003-0000-7000-8000-0000000000a${n}` +const ISSUE_ID = Schema.decodeSync(ErrorIssueId)("0199a070-0000-7000-8000-000000000001") + +const TIERS = (warning: ReadonlyArray, critical: ReadonlyArray) => [ + { severity: "warning", value: 1, upper: null, recoveryValue: null, destinationIds: warning }, + { severity: "critical", value: 2, upper: null, recoveryValue: null, destinationIds: critical }, +] + +/** An anomaly rule's synced row; no scope fields makes it the org's default rule. */ +export const anomalyRuleRow = ( + n: number, + fields: Partial & { + readonly warningIds?: ReadonlyArray + readonly criticalIds?: ReadonlyArray + } = {}, +): AlertRuleRow => { + const { warningIds = [], criticalIds = [], ...rest } = fields + return ruleRow(n, { + name: "Anomaly detection", + severity: "critical", + signal_type: "anomaly", + service_names_json: null, + exclude_service_names_json: null, + environments_json: null, + anomaly_signals_json: null, + comparator: "gte", + threshold: 1, + thresholds_json: TIERS(warningIds, criticalIds), + window_minutes: 5, + evaluation_interval_minutes: 1, + minimum_sample_count: 0, + consecutive_breaches_required: 1, + consecutive_healthy_required: 1, + renotify_interval_minutes: 30, + destination_ids_json: Arr.dedupe([...warningIds, ...criticalIds]), + ...rest, + }) +} + +export const defaultAnomalyRuleRow = (n = 7) => anomalyRuleRow(n) + +/** "checkout (production)" on the payments team's scoped rule, routed to the on-call webhook. */ +export const scopedAnomalyRuleRow = (n = 8) => + anomalyRuleRow(n, { + name: "Checkout anomalies", + service_names_json: ["checkout"], + environments_json: ["production"], + anomaly_signals_json: ["error_rate", "latency_p95"], + warningIds: [DESTINATION_ID], + criticalIds: [DESTINATION_ID], + }) + +export type AnomalyIncidentScenario = Readonly<{ + n: number + ruleN: number + severity: "warning" | "critical" + service: string + environment: string + status?: "open" | "resolved" +}> + +/** The alert incident an anomaly rule opened for an anomaly. */ +export const anomalyIncidentRow = ( + scenario: AnomalyIncidentScenario, + fields: Partial = {}, +) => { + const resolved = scenario.status === "resolved" + return incidentRow({ + id: anomalyAlertIncidentId(scenario.n), + rule_id: ruleId(scenario.ruleN), + rule_name: scenario.ruleN === 7 ? "Anomaly detection" : "Checkout anomalies", + incident_key: `anomaly:${anomalyId(scenario.n)}`, + group_key: anomalyId(scenario.n), + group_label: `${scenario.service} (${scenario.environment})`, + labels_json: { service: scenario.service, environment: scenario.environment }, + anomaly_incident_id: anomalyId(scenario.n), + signal_type: "anomaly", + severity: scenario.severity, + comparator: "gte", + threshold: 1, + last_observed_value: resolved ? 0 : scenario.severity === "critical" ? 2 : 1, + dedupe_key: `anomaly:${anomalyId(scenario.n)}`, + status: resolved ? "resolved" : "open", + resolved_at: resolved ? iso(20 * MINUTE) : null, + first_triggered_at: iso((resolved ? 90 : 42) * MINUTE), + ...fields, + }) +} + +type SignalCase = "error_rate" | "latency_p95" | "error_spike" + +const SIGNAL_VALUES = { + error_rate: { opened: 0.061, baseline: 0.004, threshold: 0.02, observed: 0.083, unit: "ratio" }, + latency_p95: { opened: 1620, baseline: 412, threshold: 640, observed: 1840, unit: "milliseconds" }, + error_spike: { opened: 96, baseline: 6, threshold: 30, observed: 148, unit: "count_per_30m" }, +} as const + +/** The detector's anomaly behind an incident, as `anomalies.getIncident` returns it. */ +export const anomalyWire = ( + n: number, + signal: SignalCase, + severity: "warning" | "critical", + service: string, + environment = "production", +): V2AnomalyIncident => { + const values = SIGNAL_VALUES[signal] + const isSpike = signal === "error_spike" + return Schema.decodeSync(V2AnomalyIncident)({ + id: encodePublicId(PublicIdPrefixes.anomalyIncident, anomalyId(n)), + object: "anomaly_incident", + detector_key: `${signal}:${service}:${environment}`, + signal_type: signal, + service_name: service, + deployment_env: environment, + fingerprint_hash: isSpike ? "fp_7c1e9a40d2b5" : null, + error_issue_id: isSpike ? encodePublicId(PublicIdPrefixes.errorIssue, ISSUE_ID) : null, + status: "open", + severity, + opened_value: values.opened, + baseline_median: values.baseline, + baseline_sigma: values.baseline / 2, + threshold_value: values.threshold, + last_observed_value: values.observed, + last_sample_count: 3100, + first_triggered_at: iso(42 * MINUTE), + last_triggered_at: iso(MINUTE), + resolved_at: null, + resolve_reason: null, + triage_status: isSpike ? "pending" : "completed", + fingerprints: isSpike + ? [ + { + fingerprint_hash: "fp_7c1e9a40d2b5", + error_issue_id: encodePublicId(PublicIdPrefixes.errorIssue, ISSUE_ID), + opened_value: 96, + last_value: 148, + severity: "critical", + attached_at: iso(42 * MINUTE), + resolved_at: null, + }, + { + fingerprint_hash: "fp_02be61f9aa13", + error_issue_id: null, + opened_value: 34, + last_value: 41, + severity: "warning", + attached_at: iso(30 * MINUTE), + resolved_at: null, + }, + ] + : [], + reopen_count: 0, + last_reopened_at: null, + }) +} + +/** Two hours of the signal at five-minute buckets: on its baseline, then the jump. */ +export const anomalyTimeseriesWire = (signal: SignalCase): V2AnomalyIncidentTimeseries => { + const values = SIGNAL_VALUES[signal] + const buckets = Arr.makeBy(24, (index) => { + const isAnomalous = index >= 16 + const wobble = 1 + 0.12 * Math.sin(index * 1.7) + return { + bucket: new Date(NOW - (24 - index) * 5 * MINUTE).toISOString(), + value: isAnomalous ? values.observed * (0.85 + 0.03 * (index - 16)) : values.baseline * wobble, + sample_count: 900, + } + }) + return Schema.decodeSync(V2AnomalyIncidentTimeseries)({ + object: "anomaly_incident.timeseries", + signal_type: signal, + unit: values.unit, + bucket_seconds: 300, + buckets, + baseline_median: values.baseline, + threshold_value: values.threshold, + }) +} diff --git a/apps/web/src/page/alerts/shared/preview.ts b/apps/web/src/page/alerts/shared/preview.ts index 6cff49f916..31726f85ae 100644 --- a/apps/web/src/page/alerts/shared/preview.ts +++ b/apps/web/src/page/alerts/shared/preview.ts @@ -26,6 +26,8 @@ export const PreviewSlot = KeyedSchema(AlertRulePreviewResponse, PreviewFailure) export type PreviewSlot = typeof PreviewSlot.Type const isPreviewQueryReady = (form: RuleFormState): boolean => { + // An anomaly rule has no query: the detector's own chart is its evidence. + if (form.signalType === "anomaly") return false if (!Number.isFinite(Number(form.threshold))) return false if (isRangeComparator(form.comparator) && !Number.isFinite(Number(form.thresholdUpper))) return false if (form.signalType === "raw_query") { diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 1501407d08..4cd5360ee8 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -4,6 +4,7 @@ import { type AlertRuleDocument, type AlertSilenceId, type AlertSilenceMatchers, + type AlertSilenceTarget, UNGROUPED_GROUP_KEY, } from "@maple/domain/http" import type { @@ -98,6 +99,8 @@ export const activeSilenceFor = ( >, groupKey: string | null, nowMs: number, + /** The incident's own service and environment (anomaly incidents): they win over the rule's scope. */ + labels: AlertSilenceTarget["labels"] = null, ): Option.Option => Arr.head( Arr.sort( @@ -112,6 +115,7 @@ export const activeSilenceFor = ( environments: rule.environments, groupBy: alertSilenceGroupBy(rule), groupKey, + labels, }), ), byLatestEnd, diff --git a/apps/web/src/page/anomalies/detail/anomaly.scene.test.ts b/apps/web/src/page/anomalies/detail/anomaly.scene.test.ts index 39115ad874..1bffe95ad9 100644 --- a/apps/web/src/page/anomalies/detail/anomaly.scene.test.ts +++ b/apps/web/src/page/anomalies/detail/anomaly.scene.test.ts @@ -4,8 +4,10 @@ import { encodePublicId, PublicIdPrefixes, V2AnomalyIncident, V2ErrorIssue } fro import { errorIssueFromV2 } from "../../../lib/services/error-issues" import { Option, Result, Schema, Array as Arr } from "effect" import { + all, Command, expect as expectView, + expectAll, given, keydown, Mount, @@ -25,7 +27,7 @@ import { incidentOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" import { init, update } from "./update" -import { view as pageView } from "./view" +import { breadcrumbsOf, view as pageView } from "./view" const context: PageContext = { orgId: Schema.decodeSync(OrgId)("org_parity"), @@ -105,6 +107,11 @@ describe("anomaly detail", () => { given(fail(notFound)), themed(), expectView(text("Anomaly not found")).toExist(), + // It may be resolved: the way back lists every anomaly, not only open ones. + expectView(role("link", { name: /Back to anomalies/ })).toHaveAttr( + "href", + "/alerts?signal=anomaly&anomalyStatus=all", + ), ) scene( { update: pageUpdate, view }, @@ -276,3 +283,66 @@ describe("anomaly link dialog by keyboard", () => { ) }) }) + +describe("anomaly detail: the way back and grouped errors", () => { + const withIncident = (wire: typeof incident) => { + const entered = init(route, context) + const clocked = pageUpdate(entered.model, Message.ReadClock({ nowMs: NOW })).model + return pageUpdate( + clocked, + Message.CompletedFetchIncident({ key: clocked.incident.key ?? "", result: Result.succeed(wire) }), + ).model + } + const anomaliesCrumb = (model: Model) => + Arr.findFirst(breadcrumbsOf(model), (crumb) => crumb.label === "Anomalies") + + test("the Anomalies crumb opens on the incident's own status", () => { + expect(Option.map(anomaliesCrumb(withIncident(incident)), (crumb) => crumb.href)).toEqual( + Option.some("/alerts?signal=anomaly"), + ) + const resolved = Schema.decodeSync(V2AnomalyIncident)({ + ...Schema.encodeSync(V2AnomalyIncident)(incident), + status: "resolved", + resolved_at: new Date(NOW - 60_000).toISOString(), + }) + expect(Option.map(anomaliesCrumb(withIncident(resolved)), (crumb) => crumb.href)).toEqual( + Option.some("/alerts?signal=anomaly&anomalyStatus=all"), + ) + }) + + test("each grouped error fingerprint renders once", () => { + const spike = Schema.decodeSync(V2AnomalyIncident)({ + ...Schema.encodeSync(V2AnomalyIncident)(incident), + signal_type: "error_spike", + fingerprint_hash: "fp_primary", + fingerprints: [ + { + fingerprint_hash: "fp_primary", + error_issue_id: null, + opened_value: 40, + last_value: 52, + severity: "critical", + attached_at: new Date(NOW - 3_600_000).toISOString(), + resolved_at: null, + }, + { + fingerprint_hash: "fp_second", + error_issue_id: null, + opened_value: 12, + last_value: 19, + severity: "warning", + attached_at: new Date(NOW - 1_800_000).toISOString(), + resolved_at: null, + }, + ], + }) + scene( + { update: pageUpdate, view }, + given(withIncident(spike)), + themed(), + expectView(text("Grouped errors · 2")).toExist(), + // The second fingerprint's last value appears in its one row only. + expectAll(all.text("19 occurrences / 30m")).toHaveCount(1), + ) + }) +}) diff --git a/apps/web/src/page/anomalies/detail/view-body.ts b/apps/web/src/page/anomalies/detail/view-body.ts index c7f44b1df1..c4a5f1f029 100644 --- a/apps/web/src/page/anomalies/detail/view-body.ts +++ b/apps/web/src/page/anomalies/detail/view-body.ts @@ -1,42 +1,23 @@ import type { AnomalyIncidentDocument } from "@maple/domain/http" import { formatNumber, pluralize } from "@maple/ui/lib/format" -import { relativeTime } from "../../kit/relative-time" -import { cn } from "@maple/ui/lib/utils" -import { anomalyTimeseriesFromV2 } from "../../../lib/services/anomalies" import { errorIssueFromV2 } from "../../../lib/services/error-issues" -import { - deviation, - formatSignalValue, - isStaleOpenIncident, - META_CHIP_CLASS, - SIGNAL_LABEL, - severityToneFor, -} from "@maple/web-data/anomalies/format" +import { META_CHIP_CLASS, SIGNAL_LABEL } from "@maple/web-data/anomalies/format" import { TONE_SOFT } from "@maple/ui/lib/tone" -import { Option, Array as Arr } from "effect" -import { AsyncData, Submodel } from "foldkit" +import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { bucketTooltipBody } from "@maple/views/charts/bucket-tooltip" -import type * as Chart from "@maple/views/charts/chart" -import { isChartRuntimeReady } from "@maple/views/charts/chart-runtime" -import { ChartSpec } from "@maple/views/charts/chart-spec" -import { plotFrame } from "@maple/views/charts/plot-frame" -import { resolveColor } from "@maple/views/charts/theme-colors" import { AppRoute, hrefOf } from "../../../route" import { button, linkButton } from "@maple/views/ui/button" import { badge } from "@maple/views/ui/display" import { card, cardPanel } from "@maple/views/ui/card" -import { emptyMessage } from "@maple/views/ui/empty" import { detailTitle } from "../../kit/detail-header" import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import { inlineCode } from "@maple/views/ui/inline-code" import { skeleton } from "@maple/views/ui/skeleton" -import { chartError, chartLoading } from "@maple/views/ui/chart-state" import { serviceDot } from "@maple/views/ui/values" -import { seriesLegend } from "../../kit/series-legend" import { WORKFLOW_LABEL } from "../../errors/kit/workflow" import { actorAvatar } from "../../errors/kit/actor" +import { ANOMALY_CHART_TOKENS, anomalySignalChart } from "../kit/evidence" import { shortIssueId } from "../kit/row" import { Message } from "./message" import type { Model } from "./model" @@ -47,16 +28,8 @@ import { hstack } from "@maple/views/ui/stack" type H = HtmlBuilder -export const CHART_TOKENS: ReadonlyArray = [ - "--destructive", - "--severity-error", - "--severity-warn", - "--muted-foreground", - "--border", - "--background", -] - -const SEVERITY_STROKE = { critical: "--severity-error", warning: "--severity-warn" } as const +/** The theme tokens the signal chart reads. */ +export const CHART_TOKENS = ANOMALY_CHART_TOKENS /** The detail header's title side: the signal and service under the "Anomaly" eyebrow. */ export const anomalyTitle = (h: H, incident: AnomalyIncidentDocument): Html => @@ -70,152 +43,18 @@ export const anomalyTitle = (h: H, incident: AnomalyIncidentDocument): Html => titleText: `${SIGNAL_LABEL[incident.signalType]} · ${incident.serviceName}`, }) -/** The observed value against its baseline, above the signal chart. */ -export const anomalySummary = ( - h: H, - incident: AnomalyIncidentDocument, - nowMs: number, - timeZone: string, -): Html => { - const tone = severityToneFor(incident) - const isStale = isStaleOpenIncident(incident, nowMs) - const dev = deviation(incident) - const accent = cn( - typographyClass({ weight: "medium", mono: true }), - incident.status === "open" && !isStale ? tone.text : "text-foreground", - ) - const fmt = (value: number) => formatSignalValue(incident.signalType, value) - return h.p( - [h.Class("max-w-2xl text-sm text-muted-foreground")], - [ - isStale ? "Last recorded" : "Observed", - " ", - h.span([h.Class(accent)], [fmt(incident.lastObservedValue)]), - " ", - "against a ", - typography(h, { tone: "foreground", mono: true }, [fmt(incident.baselineMedian)]), - " 7-day baseline:", - " ", - h.span([h.Class(accent)], [dev.label]), - dev.kind === "sigma" ? " above median" : dev.kind === "percent" ? " vs baseline" : "", - ", threshold ", - typography(h, { tone: "foreground", mono: true }, [fmt(incident.thresholdValue)]), - ".", - ...(isStale - ? [ - " Last triggered ", - relativeTime(h, { value: incident.lastTriggeredAt, nowMs, timeZone }), - "; this stale detector state does not represent current service health.", - ] - : []), - ], - ) -} - -const chartView = Submodel.defineView>( - (model, inputs, h) => - plotFrame(h, { - chartId: "anomaly-signal", - spec: inputs.spec, - ariaLabel: "Observed signal", - className: "h-64 w-full", - tooltipBody: (portal) => - Option.flatMap(model.tooltip, (tooltip) => - tooltip._tag === "Bucket" - ? Option.some(bucketTooltipBody(h, tooltip, portal)) - : Option.none(), - ), - }), -) - -/** `AnomalySignalChart`: loading until the series, the theme and the chart runtime are all in. */ -export const signalChart = ( - h: H, - model: Model, - incident: AnomalyIncidentDocument, - timeZone: string, -): Html => { - const colors = model.themeColors - const isReady = colors !== null && isChartRuntimeReady(model.chartRuntime) - return AsyncData.match(model.timeseries.data, { - onIdle: () => chartLoading(h, { variant: "line", height: 256 }), - onLoading: () => chartLoading(h, { variant: "line", height: 256 }), - onFailure: () => chartError(h, { height: 256 }, ["Couldn't load signal data."]), - onStale: () => chartError(h, { height: 256 }, ["Couldn't load signal data."]), - onSuccess: (wire) => loaded(wire), - onRefreshing: (wire) => loaded(wire), +/** `AnomalySignalChart` over the page's series, theme and chart runtime. */ +export const signalChart = (h: H, model: Model, incident: AnomalyIncidentDocument, timeZone: string): Html => + anomalySignalChart(h, { + chartId: "anomaly-signal", + chart: model.chart, + toChartMessage: (message) => Message.GotChartMessage({ message }), + timeseries: model.timeseries.data, + incident, + colors: model.themeColors, + chartRuntime: model.chartRuntime, + timeZone, }) - function loaded(wire: Parameters[0]): Html { - if (!isReady || colors === null) return chartLoading(h, { variant: "line", height: 256 }) - const series = anomalyTimeseriesFromV2(wire) - if (series.buckets.length === 0) - return emptyMessage( - h, - { - dashed: true, - className: - "flex h-64 w-full items-center justify-center border-border-subtle py-0 text-xs", - }, - ["No signal data in this window"], - ) - const stroke = resolveColor(colors, SEVERITY_STROKE[incident.severity]) - // Both threshold rules draw in `--destructive`. - const rule = resolveColor(colors, "--destructive") - const fmt = (value: number) => formatSignalValue(series.signalType, value) - return h.div( - [h.Class("space-y-2")], - [ - h.submodel({ - slotId: "anomaly-signal-chart", - model: model.chart, - view: chartView, - viewInputs: { - spec: ChartSpec.AnomalySignal({ - points: Arr.map(series.buckets, (bucket) => ({ - bucket: bucket.bucket, - value: bucket.value, - })), - signalType: series.signalType, - baselineMedian: series.baselineMedian, - thresholdValue: series.thresholdValue, - firstTriggeredAt: incident.firstTriggeredAt, - resolvedAt: incident.resolvedAt, - colors: { - stroke, - muted: rule, - threshold: rule, - border: resolveColor(colors, "--border"), - background: resolveColor(colors, "--background"), - }, - gradientId: "anomaly-observed", - timeZone, - }), - }, - toParentMessage: (message) => Message.GotChartMessage({ message }), - }), - seriesLegend(h, { - className: "justify-start gap-x-4", - swatch: "dashed", - items: [ - { key: "observed", label: "Observed", color: stroke, swatch: "line" }, - { - key: "baseline", - label: "Baseline median", - value: fmt(series.baselineMedian), - color: "var(--muted-foreground)", - }, - { - key: "threshold", - label: "Threshold", - value: fmt(series.thresholdValue), - color: "var(--severity-error)", - }, - ], - }), - ], - ) - } -} const IN_FLIGHT_SOFT = "bg-severity-debug-a2 text-severity-debug" const WORKFLOW_TONE = { diff --git a/apps/web/src/page/anomalies/detail/view-sidebar.ts b/apps/web/src/page/anomalies/detail/view-sidebar.ts index 7b7484f4ae..083ca64584 100644 --- a/apps/web/src/page/anomalies/detail/view-sidebar.ts +++ b/apps/web/src/page/anomalies/detail/view-sidebar.ts @@ -12,15 +12,13 @@ import { TRIAGE_STATUS_CHIP, } from "@maple/web-data/anomalies/format" import type { Html, HtmlBuilder } from "foldkit/html" -import { AppRoute, hrefOf } from "../../../route" import { button } from "@maple/views/ui/button" import { detailRailGroup, detailRailRow } from "@maple/views/ui/detail-rail" import { badge } from "@maple/views/ui/display" import { icon } from "@maple/views/ui/icon" -import { statusDot } from "@maple/views/ui/status-dot" -import { truncatedId } from "@maple/views/ui/truncated-id" import { serviceDot } from "@maple/views/ui/values" import { relativeTime } from "../../kit/relative-time" +import { anomalyFingerprintRow } from "../kit/evidence" import { shortIssueId } from "../kit/row" import { Message } from "./message" import { typography, typographyClass } from "@maple/views/ui/typography" @@ -196,62 +194,7 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { h, `Grouped errors · ${incident.fingerprints.length}`, Arr.map(incident.fingerprints, (fingerprint) => - h.div( - [ - h.Key(fingerprint.fingerprintHash), - h.Class( - "grid min-h-7 grid-cols-[1fr_auto] items-center gap-x-2 py-0.5", - ), - h.Title(fingerprint.fingerprintHash), - ], - [ - hstack(h, { gap: "1.5", shrink: true, as: "span" }, [ - statusDot(h, { - tone: - fingerprint.resolvedAt !== null - ? "neutral" - : fingerprint.severity === "critical" - ? "crit" - : "warn", - }), - fingerprint.errorIssueId !== null - ? h.a( - [ - h.Href( - hrefOf( - AppRoute.ErrorIssue({ - issueId: fingerprint.errorIssueId, - search: {}, - }), - ), - ), - h.Class( - "truncate font-mono text-xs text-muted-foreground hover:text-foreground active:text-foreground", - ), - ], - [shortIssueId(fingerprint.errorIssueId)], - ) - : truncatedId(h, { - value: fingerprint.fingerprintHash, - length: 10, - className: typographyClass({ - size: "xs", - tone: "muted", - truncate: true, - }), - }), - ]), - typography( - h, - { size: "xs", tone: "muted", mono: true, numeric: true }, - [ - fingerprint.resolvedAt !== null - ? "resolved" - : fmt(fingerprint.lastValue), - ], - ), - ], - ), + anomalyFingerprintRow(h, incident, fingerprint), ), ), ] diff --git a/apps/web/src/page/anomalies/detail/view.ts b/apps/web/src/page/anomalies/detail/view.ts index b4069c385b..0fa63f2ead 100644 --- a/apps/web/src/page/anomalies/detail/view.ts +++ b/apps/web/src/page/anomalies/detail/view.ts @@ -21,11 +21,13 @@ import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { relativeTime } from "../../kit/relative-time" import { resourceNotFound } from "../../kit/resource-not-found" +import { alertsHref } from "../../alerts/shared/links" import { crumbOf, pageStateOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" import { detailTitleSkeleton } from "../../kit/detail-header" -import { anomalySummary, anomalyTitle, CHART_TOKENS, linkedIssueCard, signalChart } from "./view-body" +import { anomalySummary } from "../kit/evidence" +import { anomalyTitle, CHART_TOKENS, linkedIssueCard, signalChart } from "./view-body" import { linkIssueDialog } from "./view-link-dialog" import { anomalySidebar } from "./view-sidebar" import { hstack } from "@maple/views/ui/stack" @@ -35,6 +37,13 @@ import { Array as Arr } from "effect" type H = HtmlBuilder +/** + * The anomalies list is the Alerts Inbox's Anomalies view, which opens on open anomalies: a resolved + * one, or one that could not be found, links to all of them so the list can include it. + */ +const anomaliesHref = (status: AnomalyIncidentDocument["status"] | null): string => + alertsHref(status === "open" ? { signal: "anomaly" } : { signal: "anomaly", anomalyStatus: "all" }) + const headerActions = (h: H, model: Model, incident: AnomalyIncidentDocument, timeZone: string): Html => { const isOpen = incident.status === "open" const isStale = isStaleOpenIncident(incident, model.nowMs) @@ -172,7 +181,7 @@ export const view = Submodel.defineView((model, resourceNotFound(h, { title: "Anomaly not found", description: "It may have been pruned, or the link is stale.", - back: { href: "/anomalies", label: "Back to anomalies" }, + back: { href: anomaliesHref(null), label: "Back to anomalies" }, }), ]) : state._tag === "Failed" @@ -199,7 +208,11 @@ export const view = Submodel.defineView((model, export const breadcrumbsOf = (model: Model): ReadonlyArray => { const state = pageStateOf(model) return [ - { label: "Anomalies", href: "/anomalies" }, + { label: "Alerts", href: alertsHref({}) }, + { + label: "Anomalies", + href: anomaliesHref(state._tag === "Loaded" ? state.incident.status : null), + }, { label: state._tag === "Loaded" diff --git a/apps/web/src/page/anomalies/kit/evidence.ts b/apps/web/src/page/anomalies/kit/evidence.ts new file mode 100644 index 0000000000..2d4728afac --- /dev/null +++ b/apps/web/src/page/anomalies/kit/evidence.ts @@ -0,0 +1,255 @@ +import type { AnomalyIncidentDocument } from "@maple/domain/http" +import type { V2AnomalyIncidentTimeseries } from "@maple/domain/http/v2" +import { cn } from "@maple/ui/lib/utils" +import { + deviation, + formatSignalValue, + isStaleOpenIncident, + severityToneFor, +} from "@maple/web-data/anomalies/format" +import { Option, Array as Arr } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { bucketTooltipBody } from "@maple/views/charts/bucket-tooltip" +import type * as Chart from "@maple/views/charts/chart" +import { type ChartRuntime, isChartRuntimeReady } from "@maple/views/charts/chart-runtime" +import { ChartSpec } from "@maple/views/charts/chart-spec" +import { plotFrame } from "@maple/views/charts/plot-frame" +import { resolveColor, type ThemeColors } from "@maple/views/charts/theme-colors" +import type { LoadFailure } from "@maple/views/data/load-failure" +import { chartError, chartLoading } from "@maple/views/ui/chart-state" +import { emptyMessage } from "@maple/views/ui/empty" +import { statusDot } from "@maple/views/ui/status-dot" +import { truncatedId } from "@maple/views/ui/truncated-id" +import { typography, typographyClass } from "@maple/views/ui/typography" +import { hstack } from "@maple/views/ui/stack" +import { anomalyTimeseriesFromV2 } from "../../../lib/services/anomalies" +import { AppRoute, hrefOf } from "../../../route" +import { relativeTime } from "../../kit/relative-time" +import { seriesLegend } from "../../kit/series-legend" +import { shortIssueId } from "./row" + +// The anomaly detector's evidence: the observed value against its baseline, the signal chart with +// its baseline and threshold, and the error fingerprints grouped into it. The anomaly page and the +// alert incident page of an anomaly rule both draw these. + +/** The theme tokens the signal chart resolves; a page that draws it resolves these on mount. */ +export const ANOMALY_CHART_TOKENS: ReadonlyArray = [ + "--destructive", + "--severity-error", + "--severity-warn", + "--muted-foreground", + "--border", + "--background", +] + +const SEVERITY_STROKE = { critical: "--severity-error", warning: "--severity-warn" } as const + +/** One sentence: the observed value against its 7-day baseline, the deviation and the threshold. */ +export const anomalySummary = ( + h: HtmlBuilder, + incident: AnomalyIncidentDocument, + nowMs: number, + timeZone: string, +): Html => { + const tone = severityToneFor(incident) + const isStale = isStaleOpenIncident(incident, nowMs) + const dev = deviation(incident) + const accent = cn( + typographyClass({ weight: "medium", mono: true }), + incident.status === "open" && !isStale ? tone.text : "text-foreground", + ) + const fmt = (value: number) => formatSignalValue(incident.signalType, value) + return h.p( + [h.Class("max-w-2xl text-sm text-muted-foreground")], + [ + isStale ? "Last recorded" : "Observed", + " ", + h.span([h.Class(accent)], [fmt(incident.lastObservedValue)]), + " ", + "against a ", + typography(h, { tone: "foreground", mono: true }, [fmt(incident.baselineMedian)]), + " 7-day baseline:", + " ", + h.span([h.Class(accent)], [dev.label]), + dev.kind === "sigma" ? " above median" : dev.kind === "percent" ? " vs baseline" : "", + ", threshold ", + typography(h, { tone: "foreground", mono: true }, [fmt(incident.thresholdValue)]), + ".", + ...(isStale + ? [ + " Last triggered ", + relativeTime(h, { value: incident.lastTriggeredAt, nowMs, timeZone }), + "; this stale detector state does not represent current service health.", + ] + : []), + ], + ) +} + +type ChartViewInputs = Readonly<{ spec: ChartSpec; chartId: string }> + +const chartView = Submodel.defineView((model, inputs, h) => + plotFrame(h, { + chartId: inputs.chartId, + spec: inputs.spec, + ariaLabel: "Observed signal", + className: "h-64 w-full", + tooltipBody: (portal) => + Option.flatMap(model.tooltip, (tooltip) => + tooltip._tag === "Bucket" + ? Option.some(bucketTooltipBody(h, tooltip, portal)) + : Option.none(), + ), + }), +) + +export type AnomalySignalChartInputs = Readonly<{ + /** Unique per page: the chart's submodel slot and plot id derive from it. */ + chartId: string + chart: Chart.Model + toChartMessage: (message: Chart.Message) => M + timeseries: AsyncData.AsyncData + incident: AnomalyIncidentDocument + colors: ThemeColors | null + chartRuntime: ChartRuntime + timeZone: string +}> + +/** The observed signal with its baseline and threshold; loading until the series, theme and runtime are in. */ +export const anomalySignalChart = (h: HtmlBuilder, inputs: AnomalySignalChartInputs): Html => { + const { incident, colors } = inputs + const isReady = colors !== null && isChartRuntimeReady(inputs.chartRuntime) + const loaded = (wire: V2AnomalyIncidentTimeseries): Html => { + if (!isReady || colors === null) return chartLoading(h, { variant: "line", height: 256 }) + const series = anomalyTimeseriesFromV2(wire) + if (series.buckets.length === 0) + return emptyMessage( + h, + { + dashed: true, + className: + "flex h-64 w-full items-center justify-center border-border-subtle py-0 text-xs", + }, + ["No signal data in this window"], + ) + const stroke = resolveColor(colors, SEVERITY_STROKE[incident.severity]) + // Both threshold rules draw in `--destructive`. + const rule = resolveColor(colors, "--destructive") + const fmt = (value: number) => formatSignalValue(series.signalType, value) + return h.div( + [h.Class("space-y-2")], + [ + h.submodel({ + slotId: `${inputs.chartId}-chart`, + model: inputs.chart, + view: chartView, + viewInputs: { + chartId: inputs.chartId, + spec: ChartSpec.AnomalySignal({ + points: Arr.map(series.buckets, (bucket) => ({ + bucket: bucket.bucket, + value: bucket.value, + })), + signalType: series.signalType, + baselineMedian: series.baselineMedian, + thresholdValue: series.thresholdValue, + firstTriggeredAt: incident.firstTriggeredAt, + resolvedAt: incident.resolvedAt, + colors: { + stroke, + muted: rule, + threshold: rule, + border: resolveColor(colors, "--border"), + background: resolveColor(colors, "--background"), + }, + gradientId: `${inputs.chartId}-observed`, + timeZone: inputs.timeZone, + }), + }, + toParentMessage: inputs.toChartMessage, + }), + seriesLegend(h, { + className: "justify-start gap-x-4", + swatch: "dashed", + items: [ + { key: "observed", label: "Observed", color: stroke, swatch: "line" }, + { + key: "baseline", + label: "Baseline median", + value: fmt(series.baselineMedian), + color: "var(--muted-foreground)", + }, + { + key: "threshold", + label: "Threshold", + value: fmt(series.thresholdValue), + color: "var(--severity-error)", + }, + ], + }), + ], + ) + } + return AsyncData.match(inputs.timeseries, { + onIdle: () => chartLoading(h, { variant: "line", height: 256 }), + onLoading: () => chartLoading(h, { variant: "line", height: 256 }), + onFailure: () => chartError(h, { height: 256 }, ["Couldn't load signal data."]), + onStale: () => chartError(h, { height: 256 }, ["Couldn't load signal data."]), + onSuccess: loaded, + onRefreshing: loaded, + }) +} + +/** One grouped error fingerprint: its issue (or hash) and its last value, or "resolved". */ +export const anomalyFingerprintRow = ( + h: HtmlBuilder, + incident: AnomalyIncidentDocument, + fingerprint: AnomalyIncidentDocument["fingerprints"][number], +): Html => + h.div( + [ + h.Key(fingerprint.fingerprintHash), + h.Class("grid min-h-7 grid-cols-[1fr_auto] items-center gap-x-2 py-0.5"), + h.Title(fingerprint.fingerprintHash), + ], + [ + hstack(h, { gap: "1.5", shrink: true, as: "span" }, [ + statusDot(h, { + tone: + fingerprint.resolvedAt !== null + ? "neutral" + : fingerprint.severity === "critical" + ? "crit" + : "warn", + }), + fingerprint.errorIssueId !== null + ? h.a( + [ + h.Href( + hrefOf( + AppRoute.ErrorIssue({ + issueId: fingerprint.errorIssueId, + search: {}, + }), + ), + ), + h.Class( + "truncate font-mono text-xs text-muted-foreground hover:text-foreground active:text-foreground", + ), + ], + [shortIssueId(fingerprint.errorIssueId)], + ) + : truncatedId(h, { + value: fingerprint.fingerprintHash, + length: 10, + className: typographyClass({ size: "xs", tone: "muted", truncate: true }), + }), + ]), + typography(h, { size: "xs", tone: "muted", mono: true, numeric: true }, [ + fingerprint.resolvedAt !== null + ? "resolved" + : formatSignalValue(incident.signalType, fingerprint.lastValue), + ]), + ], + ) diff --git a/apps/web/src/page/anomalies/list/anomalies.scene.test.ts b/apps/web/src/page/anomalies/list/anomalies.scene.test.ts deleted file mode 100644 index a1b9dc2005..0000000000 --- a/apps/web/src/page/anomalies/list/anomalies.scene.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { OrgId } from "@maple/domain" -import { encodePublicId, PublicIdPrefixes, V2AnomalyIncident } from "@maple/domain/http/v2" -import { Result, Schema, Array as Arr, String as Str } from "effect" -import { - click, - expect as expectView, - expectOutMessage, - given, - label, - Mount, - role, - scene, - text, - withViewInputs, -} from "foldkit/scene" -import { MeasureTabIndicator, Message as TabsMessage } from "@maple/views/ui/tabs" -import { describe, expect, test } from "vitest" -import { toLoadFailure } from "@maple/views/data/load-failure" -import { type PageContext, PageOutMessage } from "../../contract" -import { formatRelativeTime } from "@maple/ui/lib/time-format" -import { allIncidentsOf } from "./derive" -import { Message } from "./message" -import type { Model } from "./model" -import { init, routeChanged, update } from "./update" -import { view as pageView } from "./view" - -const context: PageContext = { - orgId: Schema.decodeSync(OrgId)("org_parity"), - pinnedNamespace: null, - timeZone: "UTC", -} -const pageUpdate = (model: Model, message: Message) => update(model, message, context) -const view = withViewInputs(pageView, { context })() -const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => - Arr.map(commands ?? [], ({ name }) => name) -const NOW = Date.UTC(2026, 9, 1, 12) -const measured = () => - Mount.resolve( - MeasureTabIndicator, - TabsMessage.MeasuredIndicator({ - rect: { left: 2, right: 115, top: 2, bottom: 2, width: 51, height: 28 }, - }), - ) -const withPresence = (model: Model, status: "present" | "absent"): Model => - pageUpdate( - model, - Message.CompletedLoadSignalPresence({ - result: Result.succeed({ traces: { status, lastSeenMs: null } }), - }), - ).model -const iso = (agoMs: number) => new Date(NOW - agoMs).toISOString() - -const incident = (index: number, severity: "critical" | "warning", resolved = false) => - Schema.decodeSync(V2AnomalyIncident)({ - id: encodePublicId(PublicIdPrefixes.anomalyIncident, `0199a060-0000-7000-8000-00000000000${index}`), - object: "anomaly_incident", - detector_key: `latency_p95:payment-svc:production`, - signal_type: "latency_p95", - service_name: "payment-svc", - deployment_env: "production", - fingerprint_hash: null, - error_issue_id: null, - status: resolved ? "resolved" : "open", - severity, - opened_value: 1000, - baseline_median: 400, - baseline_sigma: 100, - threshold_value: 800, - last_observed_value: 1800, - last_sample_count: 100, - first_triggered_at: iso(60 * 60_000), - last_triggered_at: iso(60_000), - resolved_at: resolved ? iso(30_000) : null, - resolve_reason: resolved ? "returned_to_baseline" : null, - triage_status: "none", - fingerprints: [], - reopen_count: 0, - last_reopened_at: null, - }) - -const loaded = (rows: ReadonlyArray, search = {}) => { - const entered = init({ search }, context).model - return Arr.reduce( - [ - Message.ReadClock({ nowMs: NOW }), - Message.CompletedFetchFirstPage({ - key: entered.firstPage.key ?? "", - result: Result.succeed({ data: rows, nextCursor: null }), - }), - ], - entered, - (model, message) => pageUpdate(model, message).model, - ) -} - -describe("anomalies list", () => { - test("entry reads the clock, the open incidents and the remembered filter sections", () => { - expect(names(init({ search: {} }, context).commands)).toEqual( - expect.arrayContaining([ - "LoadAnomaliesSignalPresence", - "ReadAnomaliesClock", - "FetchAnomalyIncidents", - "ReadRememberedFilterSections", - ]), - ) - }) - - test("a stale answer is dropped; a failure renders the error state", () => { - const entered = init({ search: {} }, context).model - expect( - pageUpdate( - entered, - Message.CompletedFetchFirstPage({ - key: "other", - result: Result.succeed({ data: [], nextCursor: null }), - }), - ).model.firstPage, - ).toEqual(entered.firstPage) - const failed = pageUpdate( - entered, - Message.CompletedFetchFirstPage({ - key: entered.firstPage.key ?? "", - result: Result.fail(toLoadFailure(new Error("down"))), - }), - ).model - scene( - { update: pageUpdate, view }, - given(failed), - measured(), - expectView(text("Couldn't load anomalies")).toExist(), - ) - }) - - test("j and k walk the rows in group order, Enter opens the focused one, Escape clears it", () => { - const model = loaded([incident(2, "warning"), incident(1, "critical")]) - const first = pageUpdate(model, Message.PressedListKey({ key: "Next" })) - expect(first.model.focusedId).toContain("00000000001") - expect(names(first.commands)).toEqual(["ScrollAnomalyIncidentIntoView"]) - const second = pageUpdate(first.model, Message.PressedListKey({ key: "Next" })).model - expect(second.focusedId).not.toBe(first.model.focusedId) - expect(pageUpdate(second, Message.PressedListKey({ key: "Previous" })).model.focusedId).toBe( - first.model.focusedId, - ) - const opened = pageUpdate(second, Message.PressedListKey({ key: "Open" })) - expect(opened.outMessage?._tag).toBe("RequestedNavigation") - expect(pageUpdate(second, Message.PressedListKey({ key: "Escape" })).model.focusedId).toBeNull() - }) - - test("rows group by severity and a group collapses", () => { - scene( - { update: pageUpdate, view }, - given(loaded([incident(1, "critical"), incident(2, "warning")])), - measured(), - expectView(text("2 anomalies")).toExist(), - click(role("button", { name: /^Critical/ })), - expectView(role("button", { name: /^Critical/ })).toHaveAttr("aria-expanded", "false"), - ) - }) - - test("the Live chip pauses through the URL; a tab switch keeps the rows while it loads", () => { - const model = loaded([incident(1, "critical")]) - scene( - { update: pageUpdate, view }, - given(model), - measured(), - click(role("button", { name: "Live" })), - expectOutMessage( - PageOutMessage.RequestedNavigation({ url: "/anomalies?live=false", isReplace: false }), - ), - ) - const switched = routeChanged(model, { search: { status: "all" } }, context) - expect(names(switched.commands)).toEqual(["FetchAnomalyIncidents"]) - expect(switched.model.firstPage.data._tag).toBe("Refreshing") - }) - - test("an empty list shows a skeleton until presence is known, then setup or the quiet copy", () => { - scene( - { update: pageUpdate, view }, - given(loaded([])), - measured(), - expectView(text("No open anomalies")).not.toExist(), - expectView(label("Loading")).toExist(), - ) - scene( - { update: pageUpdate, view }, - given(withPresence(loaded([]), "absent")), - measured(), - expectView(text("No anomalies yet")).toExist(), - expectView(role("link", { name: "Set up tracing" })).toExist(), - ) - scene( - { update: pageUpdate, view }, - given(withPresence(loaded([]), "present")), - measured(), - expectView(role("link", { name: "Create an alert rule" })).toExist(), - ) - }) - - test("a live tick reads the clock, so a quiet open row turns stale", () => { - const model = loaded([incident(1, "critical")]) - const ticked = pageUpdate(model, Message.TickedLiveRefresh({ nowMs: NOW + 3 * 3_600_000 })).model - scene( - { update: pageUpdate, view }, - given(ticked), - measured(), - expectView(text(`last seen ${formatRelativeTime(NOW - 60_000, NOW + 3 * 3_600_000)}`)).toExist(), - ) - }) - - test("during a tab switch Load more waits for the new tab's first page and drops the old tab's cursor", () => { - const entered = init({ search: {} }, context).model - const open = pageUpdate( - entered, - Message.CompletedFetchFirstPage({ - key: entered.firstPage.key ?? "", - result: Result.succeed({ data: [incident(1, "critical")], nextCursor: "c1" }), - }), - ).model - const switched = routeChanged(open, { search: { status: "all" } }, context).model - expect(pageUpdate(switched, Message.ClickedLoadMore()).commands ?? []).toHaveLength(0) - const mixed = pageUpdate( - switched, - Message.CompletedLoadMore({ - status: "all", - cursor: "c1", - result: Result.succeed({ data: [incident(2, "critical")], nextCursor: null }), - }), - ).model - expect(Arr.map(allIncidentsOf(mixed), (row) => Str.slice(-1)(row.id))).toEqual(["1"]) - const landed = pageUpdate( - mixed, - Message.CompletedFetchFirstPage({ - key: switched.firstPage.key ?? "", - result: Result.succeed({ data: [incident(3, "critical")], nextCursor: "a1" }), - }), - ).model - expect(names(pageUpdate(landed, Message.ClickedLoadMore()).commands)).toEqual([ - "LoadMoreAnomalyIncidents", - ]) - }) - - test("a load-more answer for a cursor a live tick discarded is dropped", () => { - const entered = init({ search: {} }, context).model - const first = (rows: ReadonlyArray) => - Message.CompletedFetchFirstPage({ - key: entered.firstPage.key ?? "", - result: Result.succeed({ data: rows, nextCursor: "c1" }), - }) - const model = Arr.reduce( - [ - first([incident(1, "critical")]), - Message.CompletedLoadMore({ - status: "open", - cursor: "c1", - result: Result.succeed({ data: [incident(2, "critical")], nextCursor: "c2" }), - }), - Message.TickedLiveRefresh({ nowMs: NOW }), - first([incident(1, "critical")]), - Message.CompletedLoadMore({ - status: "open", - cursor: "c2", - result: Result.succeed({ data: [incident(3, "critical")], nextCursor: null }), - }), - ], - entered, - (next, message) => pageUpdate(next, message).model, - ) - expect(Arr.map(allIncidentsOf(model), (row) => Str.slice(-1)(row.id))).toEqual(["1"]) - expect(model.isLoadingMore).toBe(false) - }) -}) diff --git a/apps/web/src/page/anomalies/list/command.ts b/apps/web/src/page/anomalies/list/command.ts deleted file mode 100644 index 12fbabe0f5..0000000000 --- a/apps/web/src/page/anomalies/list/command.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Clock, Effect, Schema, Option, Array as Arr } from "effect" -import { Command } from "foldkit" -import { toLoadFailure } from "@maple/views/data/load-failure" -import { MapleApiV2Client } from "../../../platform/api-clients" -import { loadSignalPresences } from "../../kit/signal-presence" -import { Message } from "./message" -import { StatusTab } from "./model" - -// v2 lists cap at 100; further pages arrive through the explicit "Load more". -export const INCIDENTS_PAGE_LIMIT = 100 - -/** `listQuery`: every status but "all" filters server-side. */ -export const listQueryOf = (status: StatusTab) => - status === "all" ? { limit: INCIDENTS_PAGE_LIMIT } : { status, limit: INCIDENTS_PAGE_LIMIT } - -const fetchPage = (status: StatusTab, cursor: string | undefined) => - Effect.gen(function* () { - const client = yield* MapleApiV2Client - const page = yield* client.anomalies.listIncidents({ - query: { ...listQueryOf(status), ...(cursor === undefined ? undefined : { cursor }) }, - }) - return { data: page.data, nextCursor: page.next_cursor } - }).pipe(Effect.mapError(toLoadFailure), Effect.result) - -/** `scrollIntoView(id)`: the focused row, nearest edge, smoothly. Ids are read off the rows. */ -export const ScrollIncidentIntoView = Command.define("ScrollAnomalyIncidentIntoView", { - args: { incidentId: Schema.String }, - messages: [Message.CompletedScrollRow], - execute: ({ incidentId }) => - Effect.sync(() => { - Option.map( - Arr.findFirst( - Array.from(document.querySelectorAll("[data-incident-id]")), - (row) => row.getAttribute("data-incident-id") === incidentId, - ), - (row) => row.scrollIntoView({ block: "nearest", behavior: "smooth" }), - ) - return Message.CompletedScrollRow() - }), -}) - -export const ReadAnomaliesClock = Command.define("ReadAnomaliesClock", { - messages: [Message.ReadClock], - execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), -}) - -export const FetchFirstPage = Command.define("FetchAnomalyIncidents", { - args: { key: Schema.String, status: StatusTab }, - messages: [Message.CompletedFetchFirstPage], - execute: ({ key, status }) => - Effect.map(fetchPage(status, undefined), (result) => - Message.CompletedFetchFirstPage({ key, result }), - ), -}) - -export const LoadMoreIncidents = Command.define("LoadMoreAnomalyIncidents", { - args: { status: StatusTab, cursor: Schema.String }, - messages: [Message.CompletedLoadMore], - execute: ({ status, cursor }) => - Effect.map(fetchPage(status, cursor), (result) => - Message.CompletedLoadMore({ status, cursor, result }), - ), -}) - -export const LoadSignalPresence = Command.define("LoadAnomaliesSignalPresence", { - args: { orgId: Schema.String }, - messages: [Message.CompletedLoadSignalPresence], - execute: ({ orgId }) => - Effect.map(loadSignalPresences(orgId), (result) => Message.CompletedLoadSignalPresence({ result })), -}) diff --git a/apps/web/src/page/anomalies/list/derive.ts b/apps/web/src/page/anomalies/list/derive.ts deleted file mode 100644 index 62aa278b94..0000000000 --- a/apps/web/src/page/anomalies/list/derive.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { AnomalyIncidentDocument, AnomalySignalType } from "@maple/domain/http" -import { - anomalyFilterChips, - hasAnomalyFilters, - matchesAnomalyFilters, - type AnomalyFilters, -} from "../../../lib/anomalies/anomaly-filters" -import { anomalyIncidentFromV2 } from "../../../lib/services/anomalies" -import { SIGNAL_LABEL } from "@maple/web-data/anomalies/format" -import { AsyncData } from "foldkit" -import type { AnomaliesSearch } from "../../../route/search" -import type { GroupKey, Model, StatusTab } from "./model" -import { Array as Arr, String as Str, Option, Order } from "effect" - -export type Search = typeof AnomaliesSearch.Type - -export const statusOf = (search: Search): StatusTab => search.status ?? "open" -export const isLiveOf = (search: Search): boolean => search.live ?? statusOf(search) === "open" - -export const filtersOf = (search: Search): AnomalyFilters => ({ - severity: search.severity, - signals: search.signals, - services: search.services, - envs: search.envs, - excludedSeverity: search.excludedSeverity, - excludedSignals: search.excludedSignals, - excludedServices: search.excludedServices, - excludedEnvs: search.excludedEnvs, -}) - -export { anomalyFilterChips, hasAnomalyFilters } - -/** The first page plus "Load more" pages for this tab, deduped by id (first page wins). */ -export const allIncidentsOf = (model: Model): ReadonlyArray => { - const first = AsyncData.getOrElse(model.firstPage.data, () => null) - const firstRows = first === null ? [] : Arr.map(first.data, anomalyIncidentFromV2) - const pages = model.loadedPages?.key === statusOf(model.search) ? model.loadedPages : null - if (pages === null) return firstRows - const byId = new Map(Arr.map(firstRows, (incident) => [incident.id, incident])) - Arr.forEach(Arr.map(pages.rows, anomalyIncidentFromV2), (incident) => { - if (!byId.has(incident.id)) byId.set(incident.id, incident) - }) - return [...byId.values()] -} - -/** - * The cursor "Load more" continues from: none while a tab switch keeps the previous tab's first - * page on screen (`retained`), so a load-more never pairs the new tab with the old tab's cursor. - */ -export const nextCursorOf = (model: Model): string | null => { - if (model.firstPage.retained === true) return null - const pages = model.loadedPages?.key === statusOf(model.search) ? model.loadedPages : null - if (pages !== null) return pages.nextCursor - return AsyncData.getOrElse(model.firstPage.data, () => null)?.nextCursor ?? null -} - -export const filteredOf = (model: Model, all: ReadonlyArray) => { - const filters = filtersOf(model.search) - return Arr.filter(all, (incident) => matchesAnomalyFilters(incident, filters)) -} - -export const GROUP_ORDER: ReadonlyArray = ["critical", "warning", "resolved"] -export const GROUP_LABEL = { critical: "Critical", warning: "Warning", resolved: "Resolved" } as const - -export const groupKeyOf = (incident: AnomalyIncidentDocument): GroupKey => - incident.status !== "open" ? "resolved" : incident.severity - -const clusterKey = (incident: AnomalyIncidentDocument) => - `${incident.serviceName}\u0000${incident.deploymentEnv}` - -/** Buckets by group; each bucket clusters by service+env, clusters by freshest incident. */ -export const groupIncidents = ( - incidents: ReadonlyArray, -): ReadonlyArray]> => - Arr.flatMap(GROUP_ORDER, (group) => { - const bucket = Arr.filter(incidents, (incident) => groupKeyOf(incident) === group) - if (bucket.length === 0) return [] - const latest = new Map() - Arr.forEach(bucket, (incident) => { - const current = latest.get(clusterKey(incident)) - if (current === undefined || Str.localeCompare(current)(incident.lastTriggeredAt) > 0) - latest.set(clusterKey(incident), incident.lastTriggeredAt) - }) - const sorted = Arr.sort(bucket, (a: AnomalyIncidentDocument, b: AnomalyIncidentDocument) => { - const clusterA = latest.get(clusterKey(a)) ?? "" - const clusterB = latest.get(clusterKey(b)) ?? "" - if (clusterA !== clusterB) return Str.localeCompare(clusterA)(clusterB) - const keyCompare = Str.localeCompare(clusterKey(b))(clusterKey(a)) - if (keyCompare !== 0) return keyCompare - return Str.localeCompare(a.lastTriggeredAt)(b.lastTriggeredAt) - }) - return [[group, sorted] as const] - }) - -export interface ServiceCluster { - readonly key: string - readonly serviceName: string - readonly deploymentEnv: string - readonly incidents: ReadonlyArray -} - -/** `clusterByService`: consecutive same-service runs. */ -export const clusterByService = ( - incidents: ReadonlyArray, -): ReadonlyArray => - Arr.reduce>( - incidents, - [], - (clusters, incident) => { - const key = clusterKey(incident) - const last = clusters[clusters.length - 1] - return last !== undefined && last.key === key - ? [...clusters.slice(0, -1), { ...last, incidents: [...last.incidents, incident] }] - : [ - ...clusters, - { - key, - serviceName: incident.serviceName, - deploymentEnv: incident.deploymentEnv, - incidents: [incident], - }, - ] - }, - ) - -const SEVERITY_LABEL = { critical: "Critical", warning: "Warning" } as const - -const byCount = (entries: ReadonlyArray): ReadonlyArray => { - const counts = new Map() - Arr.forEach(entries, (entry) => counts.set(entry, (counts.get(entry) ?? 0) + 1)) - return Arr.sort( - [...counts.entries()], - Order.flip(Order.mapInput(Order.Number, (entry: readonly [K, number]) => entry[1])), - ) -} - -/** The sidebar facets, derived from the unfiltered incidents of the tab. */ -export const facetsOf = (incidents: ReadonlyArray) => { - const severity = byCount(Arr.map(incidents, (incident) => incident.severity)) - const signals = byCount(Arr.map(incidents, (incident) => incident.signalType)) - return { - severity, - signals, - services: byCount(Arr.map(incidents, (incident) => incident.serviceName)), - envs: byCount( - Arr.map( - Arr.filter(incidents, (incident) => incident.deploymentEnv !== ""), - (incident) => incident.deploymentEnv, - ), - ), - severityLabel: (name: string) => - name === "critical" || name === "warning" ? SEVERITY_LABEL[name] : name, - signalLabel: (name: string) => { - return Option.match( - Arr.findFirst(signals, ([signal]) => signal === name), - { onNone: () => name, onSome: (entry) => SIGNAL_LABEL[entry[0] satisfies AnomalySignalType] }, - ) - }, - } -} diff --git a/apps/web/src/page/anomalies/list/index.ts b/apps/web/src/page/anomalies/list/index.ts deleted file mode 100644 index a6d5a4b624..0000000000 --- a/apps/web/src/page/anomalies/list/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { isDialogOpen, isEditableTarget } from "@maple/ui/lib/keyboard" -import { Clock, Duration, Effect, Option, Schema, Stream } from "effect" -import { AsyncData, Dom, Subscription } from "foldkit" -import { definePage, type PageSubscriptionInput } from "../../contract" -import { isLiveOf } from "./derive" -import { Message } from "./message" -import { Model } from "./model" -import { init, routeChanged, update } from "./update" -import { view } from "./view" -import { pollTicks } from "../../../data/poll" - -const LIVE_REFRESH_INTERVAL = Duration.seconds(15) - -const LIST_KEYS = new Map([ - ["j", "Next"], - ["J", "Next"], - ["ArrowDown", "Next"], - ["k", "Previous"], - ["K", "Previous"], - ["ArrowUp", "Previous"], - ["Enter", "Open"], - ["Escape", "Escape"], -]) - -const isInteractiveTarget = (target: EventTarget | null): boolean => - target instanceof HTMLElement && - target.closest("button, a, [role='button'], [role='menuitem'], [role='option']") !== null - -/** `useListNavigation`'s hotkeys; suspended while a dialog owns the keyboard or a field has focus. */ -const listKeys: Stream.Stream = Dom.streamFromEventFilterMap({ - target: () => document, - type: "keydown", - filterMapEvent: (event) => { - const key = LIST_KEYS.get(event.key) - if (key === undefined || event.metaKey || event.ctrlKey || event.altKey) return Option.none() - if ((key === "Next" || key === "Previous") && event.shiftKey) return Option.none() - if (isEditableTarget(event.target) || isDialogOpen()) return Option.none() - if (key === "Open" && isInteractiveTarget(event.target)) return Option.none() - if (key === "Next" || key === "Previous") event.preventDefault() - return Option.some(Message.PressedListKey({ key })) - }, -}) - -/** `useIntervalRefresh`: the first page again every 15 s while Live (paused while hidden or offline). */ -const subscriptions = Subscription.make, Message>()((entry) => ({ - listKeys: entry( - { hasRows: Schema.Boolean }, - { - modelToDependencies: ({ model }) => ({ hasRows: AsyncData.hasData(model.firstPage.data) }), - dependenciesToStream: ({ hasRows }) => (hasRows ? listKeys : Stream.empty), - }, - ), - live: entry( - { isLive: Schema.Boolean }, - { - modelToDependencies: ({ model }) => ({ isLive: isLiveOf(model.search) }), - dependenciesToStream: ({ isLive }) => - isLive - ? pollTicks(LIVE_REFRESH_INTERVAL).pipe( - Stream.mapEffect(() => - Effect.map(Clock.currentTimeMillis, (nowMs) => Message.TickedLiveRefresh({ nowMs })), - ), - ) - : Stream.empty, - }, - ), -})) - -/** `/anomalies`: one instance; a search change arrives as `routeChanged`. */ -export const page = definePage( - "AnomaliesList", - { Model, Message }, - { - routes: ["Anomalies"], - init, - update, - routeChanged, - view, - subscriptions, - breadcrumbs: () => [{ label: "Anomalies", isPageHeading: true }], - }, -) diff --git a/apps/web/src/page/anomalies/list/message.ts b/apps/web/src/page/anomalies/list/message.ts deleted file mode 100644 index d6e9b5d8d8..0000000000 --- a/apps/web/src/page/anomalies/list/message.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Schema } from "effect" -import { defineMessageUnion } from "foldkit/message" -import { LoadFailure } from "@maple/views/data/load-failure" -import * as FilterSections from "@maple/views/ui/filters/filter-sections" -import * as Tabs from "@maple/views/ui/tabs" -import { SignalPresences } from "@maple/views/data/signal-presence" -import { GroupKey, IncidentPage, StatusTab } from "./model" - -/** The sidebar facets, each with its excluded twin in the URL. */ -export const Facet = Schema.Literals(["severity", "signals", "services", "envs"]) -export type Facet = typeof Facet.Type - -/** A chip's URL param (`AnomalyFilterChipDescriptor.param`). */ -export const FilterParam = Schema.Literals([ - "severity", - "signals", - "services", - "envs", - "excludedSeverity", - "excludedSignals", - "excludedServices", - "excludedEnvs", -]) -export type FilterParam = typeof FilterParam.Type - -export const Message = defineMessageUnion({ - ReadClock: { nowMs: Schema.Number }, - CompletedFetchFirstPage: { key: Schema.String, result: Schema.Result(IncidentPage, LoadFailure) }, - ClickedLoadMore: {}, - /** `cursor` is the page it continues; a refresh in between moves the cursor and drops it. */ - CompletedLoadMore: { status: StatusTab, cursor: Schema.String, result: Schema.Result(IncidentPage, LoadFailure) }, - ClickedRetry: {}, - /** Each live tick reads the clock, so relative times age with the data. */ - TickedLiveRefresh: { nowMs: Schema.Number }, - ToggledLive: {}, - ToggledFacetOption: { facet: Facet, name: Schema.String }, - ClickedOnlyFacetOption: { facet: Facet, name: Schema.String }, - ClickedExcludeFacetOption: { facet: Facet, name: Schema.String }, - RemovedFilterChip: { param: FilterParam }, - ClickedClearFilters: {}, - ClickedClearExclusions: {}, - ToggledGroup: { group: GroupKey }, - HoveredRow: { id: Schema.String }, - /** `useListNavigation`: j/k and the arrows move the focus, Enter opens it, Escape clears it. */ - PressedListKey: { key: Schema.Literals(["Next", "Previous", "Open", "Escape"]) }, - CompletedScrollRow: {}, - CompletedLoadSignalPresence: { result: Schema.Result(SignalPresences, LoadFailure) }, - GotTabsMessage: { message: Tabs.Message }, - GotFilterSectionsMessage: { message: FilterSections.Message }, -}) -export type Message = typeof Message.Type diff --git a/apps/web/src/page/anomalies/list/model.ts b/apps/web/src/page/anomalies/list/model.ts deleted file mode 100644 index b175f7570a..0000000000 --- a/apps/web/src/page/anomalies/list/model.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { V2AnomalyIncident } from "@maple/domain/http/v2" -import { Schema } from "effect" -import { KeyedSchema } from "@maple/views/data/keyed" -import { LoadFailure } from "@maple/views/data/load-failure" -import { AnomaliesSearch } from "../../../route/search" -import * as FilterSections from "@maple/views/ui/filters/filter-sections" -import * as Tabs from "@maple/views/ui/tabs" -import { SignalPresenceData } from "@maple/views/data/signal-presence" - -// `/anomalies`. - -export const StatusTab = Schema.Literals(["open", "resolved", "all"]) -export type StatusTab = typeof StatusTab.Type - -export const GroupKey = Schema.Literals(["critical", "warning", "resolved"]) -export type GroupKey = typeof GroupKey.Type - -/** One page of `anomalies.listIncidents`. */ -export const IncidentPage = Schema.Struct({ - data: Schema.Array(V2AnomalyIncident), - nextCursor: Schema.NullOr(Schema.String), -}) -export type IncidentPage = typeof IncidentPage.Type - -/** `LoadedPages`: pages past the first, for the tab they were fetched for. */ -export const LoadedPages = Schema.Struct({ - key: StatusTab, - rows: Schema.Array(V2AnomalyIncident), - nextCursor: Schema.NullOr(Schema.String), -}) -export type LoadedPages = typeof LoadedPages.Type - -export const Model = Schema.Struct({ - search: AnomaliesSearch, - /** `Date.now()` at render: relative times and the stale-incident cut. */ - nowMs: Schema.Number, - firstPage: KeyedSchema(IncidentPage, LoadFailure), - loadedPages: Schema.NullOr(LoadedPages), - isLoadingMore: Schema.Boolean, - presence: SignalPresenceData.schema, - tabs: Tabs.Model, - filterSections: FilterSections.Model, - /** `AnomalyGroup`'s `isOpen`, per group: the collapsed ones. */ - collapsedGroups: Schema.Array(GroupKey), - focusedId: Schema.NullOr(Schema.String), -}) -export type Model = typeof Model.Type diff --git a/apps/web/src/page/anomalies/list/update.ts b/apps/web/src/page/anomalies/list/update.ts deleted file mode 100644 index fabf8c9759..0000000000 --- a/apps/web/src/page/anomalies/list/update.ts +++ /dev/null @@ -1,336 +0,0 @@ -import type { AnomalyIncidentDocument } from "@maple/domain/http" -import { Option, Result, Array as Arr, Record as Rec } from "effect" -import { AsyncData, Command } from "foldkit" -import * as Keyed from "@maple/views/data/keyed" -import { requestKey } from "@maple/views/data/request-key" -import type { Resources } from "../../../platform/resources" -import { AppRoute, hrefOf } from "../../../route" -import * as FilterSections from "@maple/views/ui/filters/filter-sections" -import * as Tabs from "@maple/views/ui/tabs" -import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" -import { SignalPresenceData } from "@maple/views/data/signal-presence" -import { - FetchFirstPage, - listQueryOf, - LoadMoreIncidents, - LoadSignalPresence, - ReadAnomaliesClock, - ScrollIncidentIntoView, -} from "./command" -import { - allIncidentsOf, - anomalyFilterChips, - facetsOf, - filteredOf, - filtersOf, - groupIncidents, - isLiveOf, - nextCursorOf, - type Search, - statusOf, -} from "./derive" -import { type Facet, Message } from "./message" -import type { Model, StatusTab } from "./model" -import { anomalyHref } from "../kit/row" - -type Return = PageReturn -type Commands = ReadonlyArray> - -export const STATUS_TABS: ReadonlyArray = ["open", "resolved", "all"] - -const EXCLUDED_OF = { - severity: "excludedSeverity", - signals: "excludedSignals", - services: "excludedServices", - envs: "excludedEnvs", -} as const satisfies Record - -/** Requests the tab's first page under `mode`; a refresh drops the "Load more" pages. */ -const loadFirstPage = (model: Model, context: PageContext, mode: Keyed.RequestMode): Return => { - const status = statusOf(model.search) - const key = requestKey(context.orgId, "anomalies.listIncidents", { query: listQueryOf(status) }) - const base = mode === "Refresh" ? { ...model, loadedPages: null } : model - return Option.match(Keyed.request(model.firstPage, key, mode), { - onNone: () => ({ model: base }), - onSome: (firstPage) => ({ - model: { ...base, firstPage }, - commands: [FetchFirstPage({ key, status })], - }), - }) -} - -export const init = (route: { readonly search: Search }, context: PageContext): Return => { - const model: Model = { - search: route.search, - nowMs: 0, - firstPage: Keyed.idle(), - loadedPages: null, - isLoadingMore: false, - presence: SignalPresenceData.Loading(), - tabs: Tabs.init({ idBase: 0, values: STATUS_TABS, selected: statusOf(route.search) }), - filterSections: FilterSections.init(), - collapsedGroups: [], - focusedId: null, - } - const loaded = loadFirstPage(model, context, "Load") - return { - model: loaded.model, - commands: [ - LoadSignalPresence({ orgId: context.orgId }), - ReadAnomaliesClock(), - ...(loaded.commands ?? []), - Command.mapMessage(FilterSections.ReadRemembered(), (message) => - Message.GotFilterSectionsMessage({ message }), - ), - ], - } -} - -export const routeChanged = ( - model: Model, - route: { readonly search: Search }, - context: PageContext, -): Return => { - const status = statusOf(route.search) - const next: Model = { - ...model, - search: route.search, - tabs: { ...model.tabs, selected: status, highlightedIndex: STATUS_TABS.indexOf(status) }, - } - return loadFirstPage(next, context, "Load") -} - -const navigate = (search: Search): Return["outMessage"] => - PageOutMessage.RequestedNavigation({ url: hrefOf(AppRoute.Anomalies({ search })), isReplace: false }) - -const toNavigation = (model: Model, search: Search): Return => ({ model, outMessage: navigate(search) }) - -const listOrUndefined = (values: ReadonlyArray) => (values.length === 0 ? undefined : values) - -/** Severity and signal keep the sidebar's order and drop values the tab has no rows for. */ -const inFacetOrder = ( - entries: ReadonlyArray, - values: ReadonlyArray, -) => - listOrUndefined( - Arr.filter( - Arr.map(entries, ([name]) => name), - (name) => values.includes(name), - ), - ) - -const withFacet = ( - model: Model, - facet: Facet, - selected: ReadonlyArray, - excluded: ReadonlyArray, -): Search => { - const facets = facetsOf(allIncidentsOf(model)) - switch (facet) { - case "severity": - return { - ...model.search, - severity: inFacetOrder(facets.severity, selected), - excludedSeverity: inFacetOrder(facets.severity, excluded), - } - case "signals": - return { - ...model.search, - signals: inFacetOrder(facets.signals, selected), - excludedSignals: inFacetOrder(facets.signals, excluded), - } - case "services": - return { - ...model.search, - services: listOrUndefined(selected), - excludedServices: listOrUndefined(excluded), - } - case "envs": - return { - ...model.search, - envs: listOrUndefined(selected), - excludedEnvs: listOrUndefined(excluded), - } - } -} - -const facetValues = (model: Model, facet: Facet) => { - const selected: ReadonlyArray = model.search[facet] ?? [] - const excluded: ReadonlyArray = model.search[EXCLUDED_OF[facet]] ?? [] - return { selected, excluded } -} - -const toggledFacet = (model: Model, facet: Facet, name: string): Search => { - const { selected, excluded } = facetValues(model, facet) - return withFacet( - model, - facet, - selected.includes(name) ? Arr.filter(selected, (value) => value !== name) : [...selected, name], - Arr.filter(excluded, (value) => value !== name), - ) -} - -const excludedFacet = (model: Model, facet: Facet, name: string): Search => { - const { selected, excluded } = facetValues(model, facet) - return withFacet( - model, - facet, - Arr.filter(selected, (value) => value !== name), - excluded.includes(name) ? Arr.filter(excluded, (value) => value !== name) : [...excluded, name], - ) -} - -const tabsStep = (model: Model, message: Tabs.Message): Return => { - const next = Tabs.update(model.tabs, message) - const updated = { ...model, tabs: next.model } - const commands: Commands = Command.mapMessages(next.commands ?? [], (tabs) => - Message.GotTabsMessage({ message: tabs }), - ) - if (message._tag !== "ClickedTab") return { model: updated, commands } - const value = model.tabs.values[message.index] - if (value === undefined || value === model.tabs.selected) return { model: updated, commands } - const status = value === "resolved" || value === "all" ? value : undefined - return { model: updated, commands, outMessage: navigate({ ...model.search, status }) } -} - -/** The rows in reading order: every visible group's incidents, collapsed groups included. */ -const rowsInOrder = (model: Model): ReadonlyArray => - Arr.flatMap(groupIncidents(filteredOf(model, allIncidentsOf(model))), ([, incidents]) => incidents) - -/** `useListNavigation` over the incident rows. */ -const pressedListKey = (model: Model, key: "Next" | "Previous" | "Open" | "Escape"): Return => { - if (key === "Escape") - return model.focusedId === null ? { model } : { model: { ...model, focusedId: null } } - const incidents = rowsInOrder(model) - if (key === "Open") { - return Option.match( - Arr.findFirst(incidents, (incident) => incident.id === model.focusedId), - { - onNone: (): Return => ({ model }), - onSome: (focused): Return => ({ - model, - outMessage: PageOutMessage.RequestedNavigation({ - url: anomalyHref(focused.id), - isReplace: false, - }), - }), - }, - ) - } - if (incidents.length === 0) return { model } - const current = - model.focusedId === null - ? -1 - : Option.getOrElse( - Arr.findFirstIndex(incidents, (incident) => incident.id === model.focusedId), - () => -1, - ) - const next = - key === "Next" - ? current < 0 - ? 0 - : Math.min(current + 1, incidents.length - 1) - : current <= 0 - ? 0 - : current - 1 - const target = incidents[next] - if (target === undefined) return { model } - return { - model: { ...model, focusedId: target.id }, - commands: [ScrollIncidentIntoView({ incidentId: target.id })], - } -} - -export const update = (model: Model, message: Message, context: PageContext): Return => - Message.match(message, { - ReadClock: ({ nowMs }) => ({ model: { ...model, nowMs } }), - CompletedFetchFirstPage: ({ key, result }) => ({ - model: { ...model, firstPage: Keyed.accept(model.firstPage, key, result) }, - }), - ClickedLoadMore: () => { - const cursor = nextCursorOf(model) - return cursor === null || model.isLoadingMore - ? { model } - : { - model: { ...model, isLoadingMore: true }, - commands: [LoadMoreIncidents({ status: statusOf(model.search), cursor })], - } - }, - CompletedLoadMore: ({ status, cursor, result }) => - status !== statusOf(model.search) || cursor !== nextCursorOf(model) - ? { model: { ...model, isLoadingMore: false } } - : Result.match(result, { - onFailure: () => ({ - model: { ...model, isLoadingMore: false }, - outMessage: PageOutMessage.RequestedToast({ - type: "error", - title: "Couldn't load more anomalies", - description: "", - }), - }), - onSuccess: (page) => ({ - model: { - ...model, - isLoadingMore: false, - loadedPages: { - key: status, - rows: [ - ...(model.loadedPages?.key === status ? model.loadedPages.rows : []), - ...page.data, - ], - nextCursor: page.nextCursor, - }, - }, - }), - }), - ClickedRetry: () => loadFirstPage(model, context, "Refresh"), - TickedLiveRefresh: ({ nowMs }) => loadFirstPage({ ...model, nowMs }, context, "Refresh"), - ToggledLive: () => { - const next = !isLiveOf(model.search) - return toNavigation(model, { - ...model.search, - live: next === (statusOf(model.search) === "open") ? undefined : next, - }) - }, - ToggledFacetOption: ({ facet, name }) => toNavigation(model, toggledFacet(model, facet, name)), - ClickedOnlyFacetOption: ({ facet, name }) => toNavigation(model, withFacet(model, facet, [name], [])), - ClickedExcludeFacetOption: ({ facet, name }) => - toNavigation(model, excludedFacet(model, facet, name)), - RemovedFilterChip: ({ param }) => toNavigation(model, { ...model.search, [param]: undefined }), - ClickedClearFilters: () => - toNavigation(model, { status: model.search.status, live: model.search.live }), - ClickedClearExclusions: () => - toNavigation(model, { - ...model.search, - ...Rec.fromEntries( - Arr.map( - Arr.filter(anomalyFilterChips(filtersOf(model.search)), (chip) => chip.negated), - (chip) => [chip.param, undefined], - ), - ), - }), - ToggledGroup: ({ group }) => ({ - model: { - ...model, - collapsedGroups: model.collapsedGroups.includes(group) - ? Arr.filter(model.collapsedGroups, (key) => key !== group) - : [...model.collapsedGroups, group], - }, - }), - HoveredRow: ({ id }) => (model.focusedId === id ? { model } : { model: { ...model, focusedId: id } }), - PressedListKey: ({ key }) => pressedListKey(model, key), - CompletedScrollRow: () => ({ model }), - CompletedLoadSignalPresence: ({ result }) => ({ - model: { ...model, presence: AsyncData.settle(model.presence, result) }, - }), - GotTabsMessage: ({ message: inner }) => tabsStep(model, inner), - GotFilterSectionsMessage: ({ message: inner }) => { - const next = FilterSections.update(model.filterSections, inner) - return { - model: { ...model, filterSections: next.model }, - commands: Command.mapMessages(next.commands ?? [], (sections) => - Message.GotFilterSectionsMessage({ message: sections }), - ), - } - }, - }) diff --git a/apps/web/src/page/anomalies/list/view-filters.ts b/apps/web/src/page/anomalies/list/view-filters.ts deleted file mode 100644 index 6ccea6d9a9..0000000000 --- a/apps/web/src/page/anomalies/list/view-filters.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { AnomalyIncidentDocument } from "@maple/domain/http" -import { getServiceColor } from "@maple/ui/lib/colors" -import type { Html, HtmlBuilder } from "foldkit/html" -import * as FilterSections from "@maple/views/ui/filters/filter-sections" -import { type FilterOption, filterSection } from "@maple/views/ui/filters/filter-section" -import { - filterSidebarBody, - filterSidebarEmpty, - filterSidebarFrame, - filterSidebarHeader, - filterSidebarLoading, -} from "@maple/views/ui/filters/filter-sidebar" -import { facetsOf, filtersOf, hasAnomalyFilters } from "./derive" -import { type Facet, Message } from "./message" -import type { Model } from "./model" -import { Array as Arr } from "effect" - -// `AnomaliesFilterSidebar`: the list's filter rail. - -type H = HtmlBuilder - -const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) - -const options = (entries: ReadonlyArray): ReadonlyArray => - Arr.map(entries, ([name, count]) => ({ name, count })) - -export const filtersRail = ( - h: H, - model: Model, - incidents: ReadonlyArray, - isLoading: boolean, -): Html => { - const filters = filtersOf(model.search) - const hasFilters = hasAnomalyFilters(filters) - if (isLoading) return filterSidebarLoading(h, 4) - if (incidents.length === 0 && !hasFilters) return filterSidebarEmpty(h) - const facets = facetsOf(incidents) - const section = ( - title: string, - facet: Facet, - entries: ReadonlyArray, - extra: Partial>[1]> = {}, - ) => - filterSection(h, { - title, - options: options(entries), - selected: model.search[facet] ?? [], - excluded: - (facet === "severity" - ? model.search.excludedSeverity - : facet === "signals" - ? model.search.excludedSignals - : facet === "services" - ? model.search.excludedServices - : model.search.excludedEnvs) ?? [], - sections: model.filterSections, - toSectionsMessage: toSections, - onToggle: (name) => Message.ToggledFacetOption({ facet, name }), - onOnly: (name) => Message.ClickedOnlyFacetOption({ facet, name }), - onExclude: (name) => Message.ClickedExcludeFacetOption({ facet, name }), - ...extra, - }) - return filterSidebarFrame(h, { isWaiting: false }, [ - filterSidebarHeader(h, hasFilters ? { onClear: Message.ClickedClearFilters() } : {}), - ...filterSidebarBody( - h, - "anomalies-filters", - Arr.filter( - [ - section("Severity", "severity", facets.severity, { - getOptionLabel: facets.severityLabel, - }), - section("Signal", "signals", facets.signals, { getOptionLabel: facets.signalLabel }), - section("Service", "services", facets.services, { - colorOf: (name) => getServiceColor(name), - }), - section("Environment", "envs", facets.envs), - ], - (node): node is Html => node !== null, - ), - ), - ]) -} diff --git a/apps/web/src/page/anomalies/list/view-list.ts b/apps/web/src/page/anomalies/list/view-list.ts deleted file mode 100644 index dca99ea546..0000000000 --- a/apps/web/src/page/anomalies/list/view-list.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { AnomalyIncidentDocument } from "@maple/domain/http" -import { SEVERITY_TONE } from "@maple/web-data/anomalies/format" -import { cn } from "@maple/ui/lib/utils" -import type { Html, HtmlBuilder } from "foldkit/html" -import { AppRoute, hrefOf } from "../../../route" -import { linkButton } from "@maple/views/ui/button" -import { empty, emptyContent, emptyDescription, emptyHeader, emptyTitle } from "@maple/views/ui/empty" -import { excludedEmptyHint } from "@maple/views/ui/filters/excluded-empty-hint" -import { icon } from "@maple/views/ui/icon" -import { listFooter } from "@maple/views/ui/list-footer" -import { pressable } from "@maple/views/ui/pressable" -import { statusDot } from "@maple/views/ui/status-dot" -import { serviceDot } from "@maple/views/ui/values" -import { docsLink, signalEmptyState } from "../../kit/signal-empty-state" -import { emptyActions, filteredEmpty, presenceLoading } from "@maple/views/ui/signal-empty-state" -import { presenceOf } from "@maple/views/data/signal-presence" -import { anomalyRow } from "../kit/row" -import { clusterByService, GROUP_LABEL, groupIncidents } from "./derive" -import { Message } from "./message" -import type { GroupKey, Model, StatusTab } from "./model" -import { typography } from "@maple/views/ui/typography" -import { Array as Arr } from "effect" - -// `AnomaliesPageBody` and `AnomalyGroup`: the anomalies, grouped. - -type H = HtmlBuilder - -const group = ( - h: H, - model: Model, - key: GroupKey, - incidents: ReadonlyArray, - timeZone: string, -): Html => { - const isOpen = !model.collapsedGroups.includes(key) - return h.section( - [h.Key(key)], - [ - pressable( - h, - { - onClick: Message.ToggledGroup({ group: key }), - className: cn( - "sticky top-0 z-10 flex h-8 w-full items-center gap-2 border-b border-border-subtle bg-surface-sunken pr-2 pl-2 text-left outline-none", - "backdrop-blur supports-[backdrop-filter]:bg-surface-strong", - "hover:bg-surface-strong active:bg-surface-strong", - ), - attributes: [h.AriaExpanded(isOpen), h.AriaControls(`anomaly-group-${key}`)], - }, - [ - h.span( - [h.Class("flex size-4 shrink-0 items-center justify-center text-muted-foreground")], - [icon(h, isOpen ? "ChevronDownIcon" : "ChevronRightIcon", { size: 14 })], - ), - statusDot(h, { tone: SEVERITY_TONE[key].tone, size: "lg" }), - h.span([h.Class("shrink-0 text-sm font-medium text-foreground")], [GROUP_LABEL[key]]), - typography(h, { size: "xs", tone: "muted", numeric: true }, [String(incidents.length)]), - ], - ), - ...(isOpen - ? [ - h.div( - [ - h.Id(`anomaly-group-${key}`), - h.Role("list"), - h.Class("divide-y divide-border-subtle"), - ], - Arr.map(clusterByService(incidents), (cluster) => - h.div( - [h.Key(cluster.key + (cluster.incidents[0]?.id ?? ""))], - [ - ...(cluster.incidents.length >= 2 - ? [ - h.div( - [ - h.Class( - "flex h-7 items-center gap-1.5 border-b border-border-subtle bg-surface-faint pl-5 text-2xs text-muted-foreground", - ), - ], - [ - serviceDot(h, { - serviceName: cluster.serviceName, - size: "sm", - }), - h.span( - [h.Class("font-medium text-foreground-soft")], - [cluster.serviceName], - ), - ...(cluster.deploymentEnv - ? [h.span([], [cluster.deploymentEnv])] - : []), - h.span( - [h.Class("tabular-nums")], - [ - String(cluster.incidents.length), - " anomalies", - ], - ), - ], - ), - ] - : []), - h.div( - [h.Class("divide-y divide-border-subtle")], - Arr.map(cluster.incidents, (incident) => - h.div( - [h.Key(incident.id), h.Role("listitem")], - [ - anomalyRow(h, { - incident, - nowMs: model.nowMs, - timeZone, - isFocused: model.focusedId === incident.id, - onHover: Message.HoveredRow({ id: incident.id }), - }), - ], - ), - ), - ), - ], - ), - ), - ), - ] - : []), - ], - ) -} - -const PURPOSE = - "The detector compares every service's error rate, latency, throughput, error fingerprints, and log volume against its own 7-day baseline. Incidents appear here when something deviates." - -const quietEmpty = ( - h: H, - model: Model, - status: StatusTab, - excludedHint: Html | null, - timeZone: string, -): Html => { - const presence = presenceOf(model.presence, "traces") - if (presence.status === "loading") return h.div([h.Class("p-4")], [presenceLoading(h)]) - if (presence.status === "absent") - return h.div( - [h.Class("p-4")], - [ - signalEmptyState(h, { - signal: "traces", - presence, - nowMs: model.nowMs, - timeZone, - noun: "anomalies", - purpose: PURPOSE, - detail: excludedHint, - }), - ], - ) - return h.div( - [h.Class("p-4")], - [ - empty(h, [ - emptyHeader(h, [ - emptyTitle(h, [status === "open" ? "No open anomalies" : "No anomalies"]), - emptyDescription(h, [PURPOSE]), - ]), - ...(presence.status === "present" - ? [ - emptyContent(h, [ - emptyActions(h, [ - linkButton( - h, - { - variant: "outline", - size: "sm", - href: hrefOf(AppRoute.AlertsCreate({ search: {} })), - }, - ["Create an alert rule"], - ), - docsLink(h, "alertRules", "Alert rules"), - ]), - ]), - ] - : []), - ...(excludedHint === null ? [] : [excludedHint]), - ]), - ], - ) -} - -export type BodyInput = Readonly<{ - incidents: ReadonlyArray - status: StatusTab - hasActiveFilters: boolean - excludedValues: ReadonlyArray - hasMore: boolean - timeZone: string -}> - -export const listBody = (h: H, model: Model, input: BodyInput): Html => { - const excludedHint = excludedEmptyHint(h, { - excluded: input.excludedValues, - onClear: Message.ClickedClearExclusions(), - className: "max-w-lg", - }) - if (input.incidents.length === 0 && input.hasActiveFilters) - return h.div( - [h.Class("p-4")], - [ - filteredEmpty(h, { - noun: "anomalies", - description: "Try widening or clearing the filters.", - onClear: Message.ClickedClearFilters(), - detail: excludedHint, - }), - ], - ) - if (input.incidents.length === 0) return quietEmpty(h, model, input.status, excludedHint, input.timeZone) - const footer = input.hasMore - ? listFooter(h, { - hasMore: true, - isLoading: model.isLoadingMore, - onLoadMore: Message.ClickedLoadMore(), - className: "p-4", - }) - : null - return h.div( - [], - [ - ...Arr.map(groupIncidents(input.incidents), ([key, incidents]) => - group(h, model, key, incidents, input.timeZone), - ), - ...(footer === null ? [] : [footer]), - ], - ) -} diff --git a/apps/web/src/page/anomalies/list/view.ts b/apps/web/src/page/anomalies/list/view.ts deleted file mode 100644 index c1e15ddfb9..0000000000 --- a/apps/web/src/page/anomalies/list/view.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { pluralize } from "@maple/ui/lib/format" -import { AsyncData, Submodel } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { errorState } from "@maple/views/ui/error-state" -import { filterChip } from "@maple/views/ui/filter-chip" -import { activeFilterChips } from "@maple/views/ui/filters/active-filter-chips" -import { skeleton } from "@maple/views/ui/skeleton" -import * as TabsView from "@maple/views/ui/tabs-view" -import { tooltipTrigger } from "@maple/views/ui/tooltip-host" -import type { PageViewInputs } from "../../contract" -import { dashboardPage } from "../../kit/dashboard-page" -import { listToolbar, toolbarCount } from "../../kit/list-toolbar" -import { - allIncidentsOf, - anomalyFilterChips, - filteredOf, - filtersOf, - hasAnomalyFilters, - isLiveOf, - nextCursorOf, - statusOf, -} from "./derive" -import { Message } from "./message" -import type { Model } from "./model" -import { filtersRail } from "./view-filters" -import { listBody } from "./view-list" -import { Array as Arr } from "effect" - -// The anomalies page. - -type H = HtmlBuilder - -const TAB_LABELS = ["Open", "Resolved", "All"] - -const liveChip = (h: H, isLive: boolean): Html => - filterChip( - h, - { - isPressed: isLive, - onClick: Message.ToggledLive(), - tone: "ok", - hasDot: true, - slot: "tooltip-trigger", - attributes: [ - h.DataAttribute("base-ui-tooltip-trigger", ""), - ...tooltipTrigger( - h, - isLive - ? "Auto-refreshing every 15s. Click to pause." - : "Paused. Click to resume auto-refresh.", - ), - ], - }, - [isLive ? "Live" : "Paused"], - ) - -/** `ListToolbar`: the status tabs and the count readout. */ -// The sticky area pads the page edge, so the toolbar adds no gap under itself. -const toolbar = (h: H, model: Model, count: number | null): Html => - listToolbar(h, { - className: "pb-0", - leading: h.submodel({ - slotId: "anomalies-status-tabs", - model: model.tabs, - view: TabsView.view, - viewInputs: { - labels: TAB_LABELS, - ariaLabel: "Filter anomalies", - tabClassName: "group/tab h-7 grow-0 px-2.5 text-xs sm:h-7 sm:text-xs", - panel: () => undefined, - }, - toParentMessage: (message) => Message.GotTabsMessage({ message }), - }), - trailing: - count === null - ? [] - : [toolbarCount(h, [String(count), " ", pluralize(count, "anomaly", "anomalies")])], - }) - -const SKELETON_WIDTHS = ["w-24", "w-20", "w-28", "w-16", "w-24", "w-20"] - -/** The list before its first page: a group header and rows in the lanes `anomalyRow` draws. */ -const listSkeleton = (h: H): Html => - h.div( - [h.AriaBusy(true), h.AriaLabel("Loading anomalies")], - [ - h.div( - [h.Class("flex h-8 items-center gap-2 border-b border-border-subtle bg-surface-sunken px-2")], - [skeleton(h, "size-3.5 rounded-sm"), skeleton(h, "h-3 w-16")], - ), - h.div( - [h.Class("divide-y divide-border-subtle")], - Arr.map(SKELETON_WIDTHS, (width) => - h.div( - [h.Class("flex h-9 items-center gap-2 px-3"), h.AriaHidden(true)], - [ - h.span( - [h.Class("flex w-3 shrink-0 justify-center")], - [skeleton(h, "size-2 rounded-full")], - ), - h.span( - [h.Class("flex flex-1 items-center sm:w-32 sm:flex-none")], - [skeleton(h, `h-3.5 ${width}`)], - ), - h.span([h.Class("flex w-28 shrink-0 justify-end")], [skeleton(h, "h-3 w-14")]), - h.span([h.Class("hidden md:flex")], [skeleton(h, "h-4 w-24 rounded-full")]), - h.span([h.Class("hidden min-w-0 flex-1 sm:block")]), - h.span([h.Class("flex w-28 shrink-0 justify-end")], [skeleton(h, "h-3 w-12")]), - ], - ), - ), - ), - ], - ) - -export const view = Submodel.defineView((model, inputs, h) => { - const all = allIncidentsOf(model) - const filtered = filteredOf(model, all) - const filters = filtersOf(model.search) - const chips = anomalyFilterChips(filters) - const excludedChips = Arr.filter(chips, (chip) => chip.negated) - const data = model.firstPage.data - const hasFirstPage = AsyncData.hasData(data) && !AsyncData.isFailure(data) - const body = AsyncData.match(data, { - onIdle: () => listSkeleton(h), - onLoading: () => listSkeleton(h), - onFailure: (failure) => failed(h, failure), - onStale: ({ error }) => failed(h, error), - onSuccess: () => loaded(), - onRefreshing: () => loaded(), - }) - function loaded(): Html { - return h.div( - [h.Key("loaded"), h.Class("contents")], - [ - listBody(h, model, { - incidents: filtered, - status: statusOf(model.search), - hasActiveFilters: hasAnomalyFilters(filters), - excludedValues: Arr.flatMap(excludedChips, (chip) => chip.values), - hasMore: nextCursorOf(model) !== null, - timeZone: inputs.context.timeZone, - }), - ], - ) - } - const chipBar = activeFilterChips(h, { - chips: Arr.map(chips, (chip) => ({ - id: chip.param, - label: chip.label, - values: chip.values, - isNegated: chip.negated, - onRemove: Message.RemovedFilterChip({ param: chip.param }), - })), - onClearAll: Message.ClickedClearFilters(), - className: "mb-0 px-2 pb-2", - }) - return dashboardPage( - h, - { - title: "Anomalies", - filters: filtersRail(h, model, all, AsyncData.isLoading(data) || AsyncData.isIdle(data)), - // The live toggle is an action; while reads fail the error block owns the retry status. - actions: AsyncData.hasError(data) ? undefined : liveChip(h, isLiveOf(model.search)), - sticky: toolbar(h, model, hasFirstPage ? filtered.length : null), - }, - [h.div([], [...(chipBar === null ? [] : [chipBar]), body])], - ) -}) - -const failed = (h: H, failure: Parameters>[1]["failure"]): Html => - h.div( - [h.Class("pt-4")], - [errorState(h, { failure, title: "Couldn't load anomalies", onRetry: Message.ClickedRetry() })], - ) diff --git a/apps/web/src/page/kit/grid-list-row.ts b/apps/web/src/page/kit/grid-list-row.ts new file mode 100644 index 0000000000..3c75a99e6d --- /dev/null +++ b/apps/web/src/page/kit/grid-list-row.ts @@ -0,0 +1,20 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" + +/** + * `GridListRow`: one `
  • ` of a bordered grid list (the alerts inbox, rules and resolved lists), padded, + * tinted on hover and press, positioned so a stretched row link covers it. + */ +export const gridListRow = ( + h: HtmlBuilder, + options: Readonly<{ key: string; grid: string; attributes?: ReadonlyArray> }>, + children: ReadonlyArray, +): Html => + h.li( + [ + h.Key(options.key), + h.Class(cn(options.grid, "relative px-4 py-3 hover:bg-surface-sunken active:bg-surface-sunken")), + ...(options.attributes ?? []), + ], + children, + ) diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index f5b22298e9..63b58aea1e 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -22,7 +22,6 @@ import * as AgentSessionTools from "./agent-sessions/tools" import * as McpServers from "./mcp/hub" import * as AlertEditor from "./alerts/editor" import * as AlertsHub from "./alerts/hub" -import * as AnomaliesList from "./anomalies/list" import * as AnomalyDetail from "./anomalies/detail" import * as AlertIncident from "./alerts/incident" import * as AlertRule from "./alerts/rule" @@ -170,7 +169,6 @@ export const pages = [ AgentSession.page, Integrations.page, Recommendation.page, - AnomaliesList.page, AnomalyDetail.page, ReleasesList.page, ReleaseDetail.page, diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index 694379d4d2..813b9acb05 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -1,9 +1,9 @@ import type { OrganizationFeatureFlags } from "@maple/domain/organization-feature-flags" -import { Option, Schema } from "effect" +import { Option, Predicate, Schema } from "effect" import { AppRoute } from "./app-route" import { hrefOf } from "./routers" import { DATA_INTEGRATIONS } from "./data-integrations" -import { ErrorsSearch } from "./search" +import { type AlertsSearch, type AnomaliesSearch, ErrorsSearch } from "./search" /** Where a route sends the browser instead of rendering, and whether it replaces history. */ export const Redirect = Schema.Struct({ url: Schema.String, isReplace: Schema.Boolean }) @@ -36,12 +36,36 @@ const errorsSearchOf = ({ search }: typeof AppRoute.ErrorIssues.Type): typeof Er ) } +const decodeServices = Schema.decodeUnknownOption( + Schema.Union([Schema.NonEmptyString, Schema.Array(Schema.String)]), +) + +/** The one service an old anomalies link named, under either spelling, or none. */ +const anomalyServiceOf = (search: typeof AnomaliesSearch.Type): string | undefined => { + const named = Option.match(decodeServices(search.services ?? search.service), { + onNone: (): ReadonlyArray => [], + onSome: (value): ReadonlyArray => (Predicate.isString(value) ? [value] : value), + }) + return named.length === 1 ? named[0] : undefined +} + +const anomaliesViewSearch = (search: typeof AnomaliesSearch.Type): typeof AlertsSearch.Type => { + const serviceName = anomalyServiceOf(search) + return { + signal: "anomaly", + ...(serviceName === undefined ? undefined : { serviceName }), + ...(search.status === "resolved" || search.status === "all" + ? { anomalyStatus: search.status } + : undefined), + } +} + const DATA_INTEGRATION_IDS: ReadonlySet = new Set(DATA_INTEGRATIONS) const redirect = (to: AppRoute, isReplace: boolean) => Option.some(Redirect.make({ url: hrefOf(to), isReplace })) -/** Redirects of retired paths (connectors, developer, infra, errors/issues, alerts status pages, security) and moved integrations. */ +/** Redirects of retired paths (connectors, developer, infra, errors/issues, alerts status pages, anomalies, security) and moved integrations. */ export const routeRedirect = (route: AppRoute): Option.Option => AppRoute.matchOrElse>( route, @@ -73,6 +97,9 @@ export const routeRedirect = (route: AppRoute): Option.Option => InfraDiscover: () => redirect(AppRoute.Infra({ search: {} }), true), InfraLegacyHost: ({ hostName }) => redirect(AppRoute.InfraHost({ hostName, search: {} }), true), AlertIncidentsIndex: () => redirect(AppRoute.Alerts({ search: {} }), true), + /** The anomalies list is the Alerts Inbox's Anomalies view; one service and the status carry over. */ + Anomalies: ({ search }) => + redirect(AppRoute.Alerts({ search: anomaliesViewSearch(search) }), true), /** Status pages left the Alerts hub for their own sidebar entry. */ Alerts: ({ search }) => search.tab === "status-pages" ? redirect(AppRoute.StatusPages(), true) : Option.none(), diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 0fe63a400a..4775655275 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -85,6 +85,41 @@ describe("routes", () => { ) }) + test("the anomalies list opens the Alerts Inbox's Anomalies view, carrying a service and status", () => { + expect(routeRedirect(parse("/anomalies"))).toEqual( + Option.some({ url: "/alerts?signal=anomaly", isReplace: true }), + ) + const narrowed = routeRedirect( + parse("/anomalies?services=%5B%22checkout%22%5D&status=%22resolved%22"), + ) + expect(Option.map(narrowed, ({ url }) => parse(url))).toEqual( + Option.some( + AppRoute.Alerts({ + search: { signal: "anomaly", serviceName: "checkout", anomalyStatus: "resolved" }, + }), + ), + ) + // "open" is the view's default, and several services have no one-service filter to land on. + const several = routeRedirect(parse("/anomalies?services=%5B%22a%22%2C%22b%22%5D&status=%22open%22")) + expect(Option.map(several, ({ url }) => parse(url))).toEqual( + Option.some(AppRoute.Alerts({ search: { signal: "anomaly" } })), + ) + expect(routeRedirect(parse("/anomalies/00000000-0000-4000-8000-000000000004"))).toEqual(Option.none()) + }) + + test("old anomalies links with a plain or singular service still land on that service", () => { + const landing = (path: string) => Option.map(routeRedirect(parse(path)), ({ url }) => parse(url)) + const checkout = Option.some( + AppRoute.Alerts({ search: { signal: "anomaly", serviceName: "demo-api" } }), + ) + expect(landing("/anomalies?services=demo-api")).toEqual(checkout) + expect(landing("/anomalies?service=demo-api")).toEqual(checkout) + // A value it cannot read is dropped: the view opens unfiltered instead of "Page not found". + expect(landing("/anomalies?services=%7B%22a%22%3A1%7D&status=nope")).toEqual( + Option.some(AppRoute.Alerts({ search: { signal: "anomaly" } })), + ) + }) + test("the old Hazel integration link opens the Hazel chat connector", () => { expect(routeRedirect(parse("/integrations?integration=hazel"))).toEqual( Option.some({ url: "/integrations?integration=chat-hazel", isReplace: true }), diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 7803dd621d..f07913dd33 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -510,6 +510,10 @@ export const AlertsSearch = Schema.Struct({ tab: Schema.optional(Schema.String), serviceName: Schema.optional(Schema.String), status: Schema.optional(Schema.String), + /** The Inbox narrowed to one kind of incident: "anomaly" lists anomalies alone. */ + signal: Schema.optional(Schema.String), + /** The Anomalies view's incidents: open (the default), "resolved" or "all". */ + anomalyStatus: Schema.optional(Schema.String), }) // sessionTimeRangeSearchMiddleware() @@ -519,26 +523,15 @@ export const AnalyticsSearch = Schema.Struct({ tab: Schema.optional(Schema.String), }) -const AnomalySeverityLiteral = Schema.Literals(["warning", "critical"]) -const AnomalySignalLiteral = Schema.Literals([ - "error_rate", - "latency_p95", - "throughput", - "error_spike", - "log_volume", -]) - +/** + * `/anomalies`, retired: it only redirects to the Alerts Inbox's Anomalies view. Old links spelled + * the filters several ways (`services=["a"]`, `services=a`, `service=a`), so every value is read + * as given and the redirect keeps what it can understand; a malformed one is dropped, never a 404. + */ export const AnomaliesSearch = Schema.Struct({ - status: Schema.optional(Schema.Literals(["open", "resolved", "all"])), - severity: Schema.optional(Schema.Array(AnomalySeverityLiteral)), - signals: Schema.optional(Schema.Array(AnomalySignalLiteral)), - services: Schema.optional(Schema.Array(Schema.String)), - envs: Schema.optional(Schema.Array(Schema.String)), - excludedSeverity: Schema.optional(Schema.Array(AnomalySeverityLiteral)), - excludedSignals: Schema.optional(Schema.Array(AnomalySignalLiteral)), - excludedServices: Schema.optional(Schema.Array(Schema.String)), - excludedEnvs: Schema.optional(Schema.Array(Schema.String)), - live: Schema.optional(Schema.Boolean), + status: Schema.optional(Schema.Unknown), + services: Schema.optional(Schema.Unknown), + service: Schema.optional(Schema.Unknown), }) export const CodeReviewSearch = Schema.Struct(CodeReviewSearchFields) diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 8aea4963a0..404050eca0 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -96,7 +96,6 @@ const SAMPLE_PATHS = [ "/mcp-servers/maple-ai/tools/run_sql", "/integrations", "/recommendations/rec_1", - "/anomalies", "/anomalies/00000000-0000-4000-8000-000000000004", "/releases", "/releases/abc123", diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index b49512e004..c5b9eba5a4 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -186,6 +186,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/destination-picker.ts` `destinationPicker`, `pickedDestination`: `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add destination" menu of the rest; the owner keeps the menu's Model and the chosen ids. - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: `DetailHeader`'s title side and `DetailHeaderSkeleton`. - `apps/web/src/page/kit/form-section.ts` `formSection`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. +- `apps/web/src/page/kit/grid-list-row.ts` `gridListRow`: `GridListRow`: one `
  • ` of a bordered grid list (the alerts inbox, rules and resolved lists), padded, tinted on hover and press, positioned so a stretched row link covers it. - `apps/web/src/page/kit/guided-setup.ts` `frameworkIconStrip`, `frameworkPicker`, `agentPromptButton`, `connectInstructions`: `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. - `apps/web/src/page/kit/infra-correlation/command.ts` `operationOf`: The four `*InfraTimeseries` reads the panel's charts make, through the read cache under the atom key (`requestKey(org, "", { data })`), so a reopened panel reuses them. - `apps/web/src/page/kit/infra-correlation/update.ts` `chartIdOf`, `requestsOf` (stateful: Model, init, update): A chart's slot and chart id, `-` (the linked-cursor id). diff --git a/packages/views/src/ui/combobox-view.ts b/packages/views/src/ui/combobox-view.ts index 4ef65f32e4..28819cdbea 100644 --- a/packages/views/src/ui/combobox-view.ts +++ b/packages/views/src/ui/combobox-view.ts @@ -409,12 +409,24 @@ export type ChipsViewInputs = Readonly<{ isUnfiltered?: boolean }> +/** "Remove checkout", or "Remove Ada Lovelace (user_ada)" when another chip has the same label. */ +const removeLabel = ( + value: string, + chipLabel: ChipsViewInputs["chipLabel"], + isLabelShared: boolean, +): string => { + const label = chipLabel?.(value) ?? value + return isLabelShared && label !== value ? `Remove ${label} (${value})` : `Remove ${label}` +} + /** Base UI hides the chips from assistive tech while the list is open. */ const chip = ( h: H, value: string, isOpen: boolean, parts: Pick = {}, + /** Another chip reads the same: the remove button names the value too, so the two differ. */ + isLabelShared = false, ): Html => h.div( [ @@ -433,7 +445,8 @@ const chip = ( h.button( [ h.AriaDisabled(false), - h.AriaLabel("Remove"), + // Named for its chip, so a screen reader can tell several remove buttons apart. + h.AriaLabel(removeLabel(value, parts.chipLabel, isLabelShared)), h.Class(C.comboboxChipRemoveStyles), h.DataAttribute("slot", "combobox-chip-remove"), h.Tabindex(-1), @@ -462,7 +475,14 @@ export const chipsView = Submodel.defineView((m ...(model.selected.length === 0 ? [] : [h.Role("toolbar")]), ], [ - ...Arr.map(model.selected, (value) => chip(h, value, isOpen, inputs)), + ...Arr.map(model.selected, (value) => { + const label = inputs.chipLabel?.(value) ?? value + const isLabelShared = Arr.some( + model.selected, + (other) => other !== value && (inputs.chipLabel?.(other) ?? other) === label, + ) + return chip(h, value, isOpen, inputs, isLabelShared) + }), ...(isOpen ? [dismissButton(h)] : []), h.input([ h.Class(cn(C.comboboxChipsInputStyles, C.comboboxChipsInputDefaultStyles)), diff --git a/packages/views/src/ui/combobox.scene.test.ts b/packages/views/src/ui/combobox.scene.test.ts index a42ea4527f..78e926812a 100644 --- a/packages/views/src/ui/combobox.scene.test.ts +++ b/packages/views/src/ui/combobox.scene.test.ts @@ -67,12 +67,38 @@ const exited = (id: string, generation: number) => [ ] describe("combobox scene (chips)", () => { + test("chips that read the same name their value on the remove button; distinct ones do not", () => { + const NAMES = new Map([ + ["ada@acme.test", "Ada Lovelace"], + ["ada@other.test", "Ada Lovelace"], + ["grace@acme.test", "Grace Hopper"], + ]) + scene( + { + update, + view: withViewInputs(chipsView, { + items: [...NAMES.keys()], + ariaLabel: "Members", + placeholder: "Add a member", + emptyText: "No members found.", + chipLabel: (value) => NAMES.get(value) ?? value, + toView: (parts) => ih.main([], parts), + })(), + }, + given(init("members", "multiple", { selected: [...NAMES.keys()] })), + expect(role("button", { name: "Remove Ada Lovelace (ada@acme.test)" })).toExist(), + expect(role("button", { name: "Remove Ada Lovelace (ada@other.test)" })).toExist(), + expect(role("button", { name: "Remove Grace Hopper" })).toExist(), + ) + }) + test("the selection shows as chips and as selected options", () => { scene( chips, given(init("services", "multiple", { selected: ["checkout-svc", "payment-svc"], isOpen: true })), Mount.resolve(MountCombobox, Message.CompletedMountCombobox()), - expectAll(all.role("button", { name: "Remove" })).toHaveCount(2), + expectAll(all.role("button", { name: /^Remove / })).toHaveCount(2), + expect(role("button", { name: "Remove checkout-svc" })).toExist(), expect(option("checkout-svc")).toHaveAttr("aria-selected", "true"), expect(option("api-gateway")).toHaveAttr("aria-selected", "false"), expect(role("listbox")).toHaveAttr("aria-multiselectable", "true"), @@ -100,7 +126,7 @@ describe("combobox scene (chips)", () => { scene( chips, given(init("services", "multiple", { selected: ["checkout-svc", "payment-svc"] })), - click(first(all.role("button", { name: "Remove" }))), + click(first(all.role("button", { name: /^Remove / }))), expectOutMessage(OutMessage.ChangedSelection({ selected: ["payment-svc"] })), ) }) diff --git a/packages/web-data/src/alerts/anomaly-rule.test.ts b/packages/web-data/src/alerts/anomaly-rule.test.ts new file mode 100644 index 0000000000..e223f9eb46 --- /dev/null +++ b/packages/web-data/src/alerts/anomaly-rule.test.ts @@ -0,0 +1,66 @@ +import { AlertDestinationId } from "@maple/domain/http" + +import { Array as Arr } from "effect" +import { describe, expect, it } from "vitest" +import { anomalyScopeSummary, isDefaultAnomalyRule } from "./anomaly-rule" +import { buildRuleCreateParamsV2, defaultRuleForm, deriveTierIssues } from "./form-utils" +import { switchSignalKind } from "./rule-modes" + +const anomalyForm = (overrides: Partial> = {}) => + switchSignalKind({ ...defaultRuleForm(), name: "Checkout anomalies", ...overrides }, "anomaly").form + +describe("anomaly rules", () => { + it("only an unscoped anomaly rule is the default one", () => { + expect(isDefaultAnomalyRule(anomalyForm())).toBe(true) + expect(isDefaultAnomalyRule(anomalyForm({ serviceNames: ["checkout"] }))).toBe(false) + expect(isDefaultAnomalyRule({ ...anomalyForm(), anomalySignals: ["throughput"] })).toBe(false) + expect(isDefaultAnomalyRule(defaultRuleForm())).toBe(false) + }) + + it("saves both tiers at the severity codes and sends its signals and scope", () => { + const form = { + ...anomalyForm({ serviceNames: ["checkout"], environments: ["production"] }), + anomalySignals: ["error_spike" as const], + } + const params = buildRuleCreateParamsV2(form) + expect(params.signal_type).toBe("anomaly") + expect(params.anomaly_signals).toEqual(["error_spike"]) + expect(params.service_names).toEqual(["checkout"]) + expect(params.environments).toEqual(["production"]) + expect(Arr.map(params.thresholds ?? [], (tier) => [tier.severity, tier.value])).toEqual([ + ["warning", 1], + ["critical", 2], + ]) + }) + + it("lets an anomaly tier stay unrouted, unlike a threshold rule's critical tier", () => { + expect(deriveTierIssues(anomalyForm())).toEqual([]) + }) + + it("leaving the anomaly kind starts a query from error rate with both tiers' destinations", () => { + const warning = AlertDestinationId.make("00000000-0000-4000-8000-0000000000a1") + const critical = AlertDestinationId.make("00000000-0000-4000-8000-0000000000b2") + const scoped = anomalyForm({ serviceNames: ["checkout"], destinationIds: [warning] }) + const anomaly = { + ...scoped, + escalation: + scoped.escalation === null ? null : { ...scoped.escalation, destinationIds: [critical] }, + } + const left = switchSignalKind(anomaly, "builtin").form + expect(left.signalType).toBe("error_rate") + expect(left.escalation).toBeNull() + expect(left.destinationIds).toEqual([warning, critical]) + expect(left.serviceNames).toEqual(["checkout"]) + expect(left.threshold).toBe(defaultRuleForm().threshold) + }) + + it("summarises the scope, counting long lists", () => { + expect( + anomalyScopeSummary({ + serviceNames: ["a", "b", "c"], + environments: [], + anomalySignals: ["log_volume"], + }), + ).toBe("3 services · all environments · Log volume") + }) +}) diff --git a/packages/web-data/src/alerts/anomaly-rule.ts b/packages/web-data/src/alerts/anomaly-rule.ts new file mode 100644 index 0000000000..ead02814c3 --- /dev/null +++ b/packages/web-data/src/alerts/anomaly-rule.ts @@ -0,0 +1,61 @@ +import type { AlertRuleDocument, AnomalySignalType } from "@maple/domain/http" +import { Array as Arr, String as Str } from "effect" + +/** Every detector signal, in the order the editor offers them. */ +export const ANOMALY_SIGNALS: ReadonlyArray = [ + "error_rate", + "latency_p95", + "throughput", + "error_spike", + "log_volume", +] + +/** A signal as the rule editor and the rules list name it. */ +export const ANOMALY_SIGNAL_LABELS: Record = { + error_rate: "Error rate", + latency_p95: "p95 latency", + throughput: "Throughput", + error_spike: "Error spikes", + log_volume: "Log volume", +} satisfies Record + +/** What an anomaly rule pages on. Each empty list means every value. */ +export type AnomalyRuleScope = Readonly<{ + serviceNames: ReadonlyArray + environments: ReadonlyArray + anomalySignals?: ReadonlyArray | undefined +}> + +/** The org's default anomaly rule: an anomaly rule with no scope. The server keeps exactly one. */ +export const isDefaultAnomalyRule = ( + rule: AnomalyRuleScope & Readonly<{ signalType: AlertRuleDocument["signalType"] }>, +): boolean => + rule.signalType === "anomaly" && + rule.serviceNames.length === 0 && + rule.environments.length === 0 && + (rule.anomalySignals ?? []).length === 0 + +const listOr = (values: ReadonlyArray, all: string): string => + values.length === 0 + ? all + : values.length <= 2 + ? Arr.join(values, ", ") + : `${values.length} ${Str.replace(/^all /, "")(all)}` + +/** "All services · production · Error rate, p95 latency": an anomaly rule's scope in one line. */ +export const anomalyScopeSummary = (scope: AnomalyRuleScope): string => + Arr.join( + [ + listOr(scope.serviceNames, "all services"), + listOr(scope.environments, "all environments"), + listOr( + Arr.map(scope.anomalySignals ?? [], (signal) => ANOMALY_SIGNAL_LABELS[signal]), + "all signals", + ), + ], + " · ", + ) + +/** The incident's severity in words: an anomaly rule observes 1 for warning and 2 for critical. */ +export const anomalySeverityLabel = (value: number | null): string => + value === null ? "Normal" : value >= 2 ? "Critical" : value >= 1 ? "Warning" : "Normal" diff --git a/packages/web-data/src/alerts/diagnosis.test.ts b/packages/web-data/src/alerts/diagnosis.test.ts index a764964f66..c69397c4dc 100644 --- a/packages/web-data/src/alerts/diagnosis.test.ts +++ b/packages/web-data/src/alerts/diagnosis.test.ts @@ -312,3 +312,21 @@ describe("scheduled but never evaluated", () => { expect(diagnosisVerdict(stages).status).toBe("warn") }) }) + +describe("buildDiagnosis for anomaly rules", () => { + const anomalyRule = (overrides: Record = {}) => + makeRule({ signalType: "anomaly", comparator: "gte", threshold: 1, destinationIds: [], ...overrides }) + + it("drops the data and threshold stages: the detector decides, not a query or threshold", () => { + const ids = Arr.map(diagnose({ rule: anomalyRule() }), (s) => s.id) + expect(ids).not.toContain("data") + expect(ids).not.toContain("threshold") + }) + + it("an unrouted default rule passes notification as opt-in; an unrouted scoped one still fails", () => { + expect(stage(diagnose({ rule: anomalyRule() }), "notification").status).toBe("pass") + expect( + stage(diagnose({ rule: anomalyRule({ serviceNames: ["checkout"] }) }), "notification").status, + ).toBe("fail") + }) +}) diff --git a/packages/web-data/src/alerts/diagnosis.ts b/packages/web-data/src/alerts/diagnosis.ts index 56c061d00a..e60aa593a1 100644 --- a/packages/web-data/src/alerts/diagnosis.ts +++ b/packages/web-data/src/alerts/diagnosis.ts @@ -16,6 +16,7 @@ import { formatRelativeFrom } from "@maple/ui/lib/time-format" import { countLabel } from "@maple/ui/lib/format" import type { AlertRuleStateRow } from "../collections/alerts" import { comparatorLabels, formatSignalValue } from "./form-utils" +import { isDefaultAnomalyRule } from "./anomaly-rule" import { staleThresholdMs } from "./rule-status" import { formatTimestampInTimezone } from "../lib/timezone-format" @@ -439,7 +440,7 @@ export function buildDiagnosis(input: DiagnosisInput): DiagnosisStage[] { evidence: Arr.map( groupIncidents.slice(0, 5), (i) => - `${i.groupKey ?? "all"} — opened ${relative(now, new Date(i.firstTriggeredAt).getTime())}`, + `${i.groupLabel ?? i.groupKey ?? "all"}, opened ${relative(now, new Date(i.firstTriggeredAt).getTime())}`, ), }) } else { @@ -511,9 +512,37 @@ export function buildDiagnosis(input: DiagnosisInput): DiagnosisStage[] { }) } - return stages + return rule.signalType === "anomaly" ? anomalyStages(rule, stages) : stages } +/** + * An anomaly rule has no query, samples or threshold of its own: the detector decides. Its checks + * only read the detector's open anomalies, and the default rule may stay unrouted (paging is opt-in). + */ +const anomalyStages = ( + rule: DiagnosisInput["rule"], + stages: ReadonlyArray, +): DiagnosisStage[] => + Arr.flatMap(stages, (stage): ReadonlyArray => { + if (stage.id === "data" || stage.id === "threshold") return [] + if (stage.id === "query") return [{ ...stage, label: "Detector read" }] + if (stage.id === "incident" && stage.status === "pass") + return [{ ...stage, summary: "No open anomaly in this rule's scope" }] + if (stage.id === "notification" && rule.destinationIds.length === 0 && isDefaultAnomalyRule(rule)) + return [ + { + id: "notification", + label: "Notification delivered", + status: "pass", + summary: "Not routed: anomaly paging is opt-in", + evidence: [ + "Add destinations to this rule to page someone when Maple detects an anomaly.", + ], + }, + ] + return [stage] + }) + /** The verdict line for the panel header: the first failing/warning stage's summary. */ export function diagnosisVerdict(stages: ReadonlyArray): { readonly status: DiagnosisStageStatus diff --git a/packages/web-data/src/alerts/form-utils.ts b/packages/web-data/src/alerts/form-utils.ts index 7310c70f88..d0854512a2 100644 --- a/packages/web-data/src/alerts/form-utils.ts +++ b/packages/web-data/src/alerts/form-utils.ts @@ -21,6 +21,7 @@ import { type AlertEventType, type AlertSeverity, type AlertSignalType, + type AnomalySignalType, type ChatWorkspaceId, type QueryBuilderQueryDraftPayload, MAX_ALERT_BREACH_WINDOW, @@ -69,6 +70,8 @@ export type RuleFormState = { * carry their own filters. */ environments: string[] + /** Anomaly rules only: the detector signals the rule pages on. Empty means every signal. */ + anomalySignals: AnomalySignalType[] /** Free-form tags used to group and filter rules in the alerts list. */ tags: string[] /** @@ -318,6 +321,7 @@ export function defaultRuleForm(serviceName?: string): RuleFormState { serviceNames: serviceName ? [serviceName] : [], excludeServiceNames: [], environments: [], + anomalySignals: [], tags: [], groupBy: [], signalType: "error_rate", @@ -345,6 +349,38 @@ export function defaultRuleForm(serviceName?: string): RuleFormState { } } +/** + * The form as an anomaly rule: both tiers (their values are the severity codes the server pins) and + * the evaluation fields the server pins, so the change list and the save agree with the stored rule. + */ +export function anomalyRuleForm(form: RuleFormState): RuleFormState { + return { + ...form, + signalType: "anomaly", + severity: "warning", + comparator: "gte", + threshold: "1", + thresholdUpper: "", + recoveryValue: "", + escalation: { + threshold: "2", + recoveryValue: "", + destinationIds: form.escalation?.destinationIds ?? [], + escalationChainId: form.escalation?.escalationChainId ?? null, + }, + excludeServiceNames: [], + groupBy: [], + windowMinutes: "5", + evaluationIntervalMinutes: "1", + minimumSampleCount: "0", + alertOnNoData: false, + consecutiveBreachesRequired: "1", + breachesOutOf: "", + consecutiveHealthyRequired: "1", + renotifyIntervalMinutes: "30", + } +} + /** Grouped rules cannot alert on no data: a group that stops reporting is held by its incident. */ export function ruleFormIsGrouped(form: RuleFormState): boolean { if (form.signalType === "raw_query") return false @@ -368,6 +404,7 @@ export function ruleToFormState(rule: AlertRuleDocument): RuleFormState { serviceNames: rule.serviceNames?.length > 0 ? [...rule.serviceNames] : [], excludeServiceNames: rule.excludeServiceNames?.length > 0 ? [...rule.excludeServiceNames] : [], environments: rule.environments?.length > 0 ? [...rule.environments] : [], + anomalySignals: [...(rule.anomalySignals ?? [])], tags: rule.tags?.length > 0 ? [...rule.tags] : [], groupBy: rule.groupBy ? [...rule.groupBy] : [], signalType: rule.signalType, @@ -496,8 +533,13 @@ export function deriveTierIssues(form: RuleFormState): string[] { issues.push("Critical recovery past warning") } recoveryIssue("Critical recovery value", escalation.threshold, escalation.recoveryValue) - // An escalation chain routes the tier on its own, as on the server. - if (escalation.destinationIds.length === 0 && escalation.escalationChainId === null) { + // An escalation chain routes the tier on its own, as on the server; an anomaly tier may + // stay unrouted, as the default rule ships. + if ( + escalation.destinationIds.length === 0 && + escalation.escalationChainId === null && + form.signalType !== "anomaly" + ) { issues.push("Critical destinations") } } @@ -572,6 +614,7 @@ export function buildRuleCreateParamsV2(form: RuleFormState): V2AlertRuleCreateP ? [] : Arr.filter(form.excludeServiceNames, (s) => Str.trim(s).length > 0), environments: queryOwnsScope ? [] : Arr.filter(form.environments, (s) => Str.trim(s).length > 0), + ...(signalType === "anomaly" ? { anomaly_signals: Arr.dedupe(form.anomalySignals) } : undefined), group_by: queryOwnsScope ? null : form.groupBy.length > 0 ? form.groupBy : null, signal_type: signalType, comparator: form.comparator, diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 2c0316d60e..87a7398a67 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -122,7 +122,13 @@ export function conditionPhrase(input: { } /** The incident's group, or null for an ungrouped rule. */ -export function incidentGroup(incident: Pick): string | null { +export function incidentGroup( + incident: Pick & + Partial>, +): string | null { + // An anomaly's key is the anomaly incident id: its stored label is what people read. + if (incident.groupLabel != null) return incident.groupLabel + if (incident.signalType === "anomaly") return null return incident.groupKey == null || incident.groupKey === UNGROUPED_GROUP_KEY ? null : incident.groupKey } @@ -255,7 +261,9 @@ export function buildIncidentTimeline(input: { ? `${incident.ruleName} went down` : incident.signalType === "slo_burn_rate" ? `SLO burning its error budget at ${formatSignalValue(incident.signalType, incident.threshold)} or more` - : `${signalTitle(incident.signalType)} ${conditionPhrase(incident)}` + : incident.signalType === "anomaly" + ? "Maple detected an anomaly" + : `${signalTitle(incident.signalType)} ${conditionPhrase(incident)}` const opened: IncidentTimelineEntry = { key: "opened", diff --git a/packages/web-data/src/alerts/overview-derive.test.ts b/packages/web-data/src/alerts/overview-derive.test.ts index 51ffb59565..8ccb5cf779 100644 --- a/packages/web-data/src/alerts/overview-derive.test.ts +++ b/packages/web-data/src/alerts/overview-derive.test.ts @@ -181,12 +181,12 @@ describe("incident lines", () => { resolvedAt: null, ...(groupLabel === undefined ? undefined : { groupLabel }), }) - expect(incidentTitle(anomaly("checkout (production)"))).toBe( - "Anomaly Critical on checkout (production)", - ) + expect(incidentTitle(anomaly("checkout (production)"))).toBe("Anomaly on checkout (production)") expect(incidentScope(anomaly("checkout (production)"))).toBe("checkout (production)") - expect(incidentTitle(anomaly())).toBe("Anomaly Critical") + expect(incidentTitle(anomaly())).toBe("Anomaly") expect(incidentScope(anomaly())).toBeNull() + // The severity is the reading; the detector's threshold is not the rule's to show. + expect(incidentValues(anomaly())).toEqual({ now: "Critical", threshold: null }) }) it("names a raw SQL incident by its rule, with the group under it", () => { @@ -348,3 +348,21 @@ describe("ruleConditionSummary", () => { expect(ruleConditionSummary(rule)).toBe("error rate > 5.0% · 5m") }) }) + +describe("anomaly rules in the rules list", () => { + it("reads an anomaly rule's scope instead of a threshold, all of it when unscoped", () => { + expect(ruleConditionSummary(makeRule({ signalType: "anomaly" }))).toBe( + "Anomaly · all services · all environments · all signals", + ) + expect( + ruleConditionSummary( + makeRule({ + signalType: "anomaly", + serviceNames: ["checkout"], + environments: ["production"], + anomalySignals: ["error_rate", "latency_p95"], + }), + ), + ).toBe("Anomaly · checkout · production · Error rate, p95 latency") + }) +}) diff --git a/packages/web-data/src/alerts/overview-derive.ts b/packages/web-data/src/alerts/overview-derive.ts index 0cdc56f594..21de7895ef 100644 --- a/packages/web-data/src/alerts/overview-derive.ts +++ b/packages/web-data/src/alerts/overview-derive.ts @@ -8,6 +8,7 @@ import { isRangeComparator, signalLabels, } from "./form-utils" +import { anomalyScopeSummary, anomalySeverityLabel } from "./anomaly-rule" import { incidentGroup } from "./incident-timeline" import { type DerivedRuleStatus, needsAttention } from "./rule-status" import { Array as Arr, String as Str } from "effect" @@ -25,6 +26,8 @@ export function ruleBucket(derived: DerivedRuleStatus | undefined): RuleBucket { /** "Error rate > 5% · 5m · per service.name · checkout" */ export function ruleConditionSummary(rule: AlertRuleDocument): string { + // An anomaly rule has no threshold of its own: what it covers is the whole condition. + if (rule.signalType === "anomaly") return `Anomaly · ${anomalyScopeSummary(rule)}` // Builder rules format in the display unit (5%, not the stored 0.05) and name their aggregation. const display = displaySignalType({ signalType: rule.signalType, @@ -73,6 +76,10 @@ export function incidentTitle(incident: IncidentLine): string { if (isSlo(incident)) return `${sloNameOf(incident.ruleName)} ${incident.status === "resolved" ? "stopped burning its error budget" : "is burning its error budget"}` if (isQueryIncident(incident)) return incident.ruleName + if (incident.signalType === "anomaly") { + const group = shownGroup(incident) + return group === null ? "Anomaly" : `Anomaly on ${group}` + } const value = formatSignalValue(incident.signalType, incident.lastObservedValue) const base = `${signalLabels[incident.signalType]} ${value}` const group = shownGroup(incident) @@ -95,9 +102,12 @@ export function incidentSubline(incident: IncidentLine): string { export const NO_THRESHOLD = "–" /** The Now / threshold cells: an uptime incident is "Down" (or "Up") with no threshold. */ -export function incidentValues(incident: IncidentLine): Readonly<{ now: string; threshold: string }> { +export function incidentValues(incident: IncidentLine): Readonly<{ now: string; threshold: string | null }> { if (isUptime(incident)) return { now: incident.status === "resolved" ? "Up" : "Down", threshold: NO_THRESHOLD } + // An anomaly's threshold is the detector's, not the rule's: its severity is the whole reading. + if (incident.signalType === "anomaly") + return { now: anomalySeverityLabel(incident.lastObservedValue), threshold: null } return { now: formatSignalValue(incident.signalType, incident.lastObservedValue), threshold: formatSignalValue(incident.signalType, incident.threshold), diff --git a/packages/web-data/src/alerts/preview-insights.ts b/packages/web-data/src/alerts/preview-insights.ts index 2d697f7ee7..c4dcbae5b0 100644 --- a/packages/web-data/src/alerts/preview-insights.ts +++ b/packages/web-data/src/alerts/preview-insights.ts @@ -196,6 +196,12 @@ export function diffRuleForms(saved: RuleFormState, draft: RuleFormState): RuleF after: list(b.environments), same: list(a.environments) === list(b.environments), }, + { + field: "Signals", + before: list(a.anomaly_signals), + after: list(b.anomaly_signals), + same: list(a.anomaly_signals) === list(b.anomaly_signals), + }, { field: "Split by", before: list(a.group_by), diff --git a/packages/web-data/src/alerts/rule-modes.ts b/packages/web-data/src/alerts/rule-modes.ts index ad98a2a8e0..73eea6c341 100644 --- a/packages/web-data/src/alerts/rule-modes.ts +++ b/packages/web-data/src/alerts/rule-modes.ts @@ -2,14 +2,25 @@ import type { AlertSignalType } from "@maple/domain/http" import { normalizeKey, parseWhereClause, quoteWhereValue, splitCsv } from "@maple/domain/where-clause" import { createQueryDraft, type QueryBuilderQueryDraft } from "@maple/query-engine/query-builder" -import { domainThresholdToForm, formThresholdToDomain, type RuleFormState } from "./form-utils" +import { + anomalyRuleForm, + defaultRuleForm, + domainThresholdToForm, + formThresholdToDomain, + type RuleFormState, +} from "./form-utils" import { Array as Arr, String as Str } from "effect" -/** The three ways to write a rule's query. Built-in covers the five canned trace signals. */ -export type SignalKind = "builtin" | "builder_query" | "raw_query" +/** + * The ways to write a rule. Built-in covers the five canned trace signals; Anomaly pages on what + * the anomaly detector finds and has no query. + */ +export type SignalKind = "builtin" | "builder_query" | "raw_query" | "anomaly" export const signalTypeToKind = (signalType: AlertSignalType): SignalKind => - signalType === "builder_query" || signalType === "raw_query" ? signalType : "builtin" + signalType === "builder_query" || signalType === "raw_query" || signalType === "anomaly" + ? signalType + : "builtin" /** * The query-builder aggregation each built-in signal evaluates. Mirrors the @@ -143,11 +154,41 @@ export interface KindSwitch { * on Built-in stays 5% in the builder and becomes 0.05 in SQL. */ export function switchSignalKind(form: RuleFormState, next: SignalKind): KindSwitch { - if (next === signalTypeToKind(form.signalType)) return { form, notice: null } + const current = signalTypeToKind(form.signalType) + if (next === current) return { form, notice: null } + if (next === "anomaly") return { form: anomalyRuleForm(form), notice: null } + if (current === "anomaly") { + const left = leaveAnomaly(form) + return next === "builtin" ? { form: left, notice: null } : moveToKind(left, next) + } const moved = moveToKind(form, next) return { form: carryThreshold(form, moved.form), notice: moved.notice } } +/** An anomaly rule's pinned tiers and timing mean nothing to a query: it starts from error rate. */ +function leaveAnomaly(form: RuleFormState): RuleFormState { + const fresh = defaultRuleForm() + return { + ...form, + signalType: "error_rate", + anomalySignals: [], + severity: fresh.severity, + comparator: fresh.comparator, + threshold: fresh.threshold, + thresholdUpper: fresh.thresholdUpper, + recoveryValue: fresh.recoveryValue, + escalation: null, + windowMinutes: fresh.windowMinutes, + evaluationIntervalMinutes: fresh.evaluationIntervalMinutes, + minimumSampleCount: fresh.minimumSampleCount, + consecutiveBreachesRequired: fresh.consecutiveBreachesRequired, + consecutiveHealthyRequired: fresh.consecutiveHealthyRequired, + renotifyIntervalMinutes: fresh.renotifyIntervalMinutes, + // Both tiers' destinations stay where people expect them: on the one remaining tier. + destinationIds: Arr.dedupe([...form.destinationIds, ...(form.escalation?.destinationIds ?? [])]), + } +} + function moveToKind(form: RuleFormState, next: SignalKind): KindSwitch { const current = signalTypeToKind(form.signalType) if (next === "raw_query") return { form: { ...form, signalType: "raw_query" }, notice: null } diff --git a/packages/web-data/src/alerts/rule-status.ts b/packages/web-data/src/alerts/rule-status.ts index 3fca5af508..7b7113da0a 100644 --- a/packages/web-data/src/alerts/rule-status.ts +++ b/packages/web-data/src/alerts/rule-status.ts @@ -1,3 +1,4 @@ +import { isDefaultAnomalyRule } from "./anomaly-rule" import { ALERT_MAX_EVALUATION_INTERVAL_MINUTES, alertRuleIntervalMinutes } from "@maple/query-engine/datetime" import type { AlertDeliveryEventDocument, AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" import type { AlertRuleStateRow } from "../collections/alerts" @@ -32,14 +33,6 @@ export interface DerivedRuleStatus { * scheduler outages for up to 3x the window (72h on a daily rule). * Shared with the diagnosis panel so both surfaces agree on "stale". */ -const isDefaultAnomalyRule = ( - rule: Pick, -): boolean => - rule.signalType === "anomaly" && - rule.serviceNames.length === 0 && - rule.environments.length === 0 && - (rule.anomalySignals ?? []).length === 0 - export const staleThresholdMs = ( rule: Pick, ): number => 3 * Math.max(ALERT_MAX_EVALUATION_INTERVAL_MINUTES, alertRuleIntervalMinutes(rule)) * 60_000 @@ -101,7 +94,7 @@ export function deriveRuleStatus(input: { const latestDelivery = deliveryEvents[0] ?? null const attention: RuleAttention = { - // The default anomaly rule (unscoped) is unrouted by design: it shows anomalies in the app only. + // Anomaly paging is opt-in: the default anomaly rule starts unrouted and that is normal. noDestinations: rule.enabled && rule.destinationIds.length === 0 && !isDefaultAnomalyRule(rule), recentDeliveryFailure: latestDelivery?.status === "failed", slowedDown: rule.enabled && rule.slowedIntervalMinutes !== null,