diff --git a/apps/api/src/routes/v2/alert-incidents.http.ts b/apps/api/src/routes/v2/alert-incidents.http.ts index 4e4b17ed88..d0dbd8bae6 100644 --- a/apps/api/src/routes/v2/alert-incidents.http.ts +++ b/apps/api/src/routes/v2/alert-incidents.http.ts @@ -1,7 +1,13 @@ import { HttpApiBuilder } from "effect/http-api" -import type { AlertIncidentDocument, AlertIncidentEventDocument } from "@maple/domain/http" +import type { + AlertIncidentDocument, + AlertIncidentEscalationDocument, + AlertIncidentEventDocument, + AlertIncidentId, + OrgId, +} from "@maple/domain/http" import { CurrentTenant, isAlertNotificationEventType } from "@maple/domain/http" -import type { V2AlertIncident, V2AlertIncidentEvent } from "@maple/domain/http/v2" +import type { V2AlertIncident, V2AlertIncidentEscalation, V2AlertIncidentEvent } from "@maple/domain/http/v2" import { MapleApiV2, paginateOffsetQuery, timestampOrNull } from "@maple/domain/http/v2" import { Duration, Effect, Option, Array as Arr } from "effect" import { AlertReadModelsService } from "@maple/backend/services/alerts/AlertReadModelsService" @@ -16,7 +22,33 @@ import { import { OrgMembersService } from "@maple/backend/services/org/OrgMembersService" import { CurrentAuditActor } from "@maple/backend/services/auth/audit-actor" -const toV2Incident = (doc: AlertIncidentDocument): V2AlertIncident => ({ +/** + * An ack pauses the run on the scheduler's next tick; until then the wire already reads it paused, + * so a caller never sees a page coming that will not. + */ +const toV2Escalation = ( + doc: AlertIncidentEscalationDocument, + isAcknowledged: boolean, +): V2AlertIncidentEscalation => { + const isPausing = isAcknowledged && doc.status === "active" + return { + chain_id: doc.chainId, + chain_name: doc.chainName, + status: isPausing ? "acknowledged" : doc.status, + // The scheduler leaves an exhausted run past its last step; the wire never reads past the end. + step: Math.min(doc.stepIndex + 1, doc.stepCount), + step_count: doc.stepCount, + pass: Math.min(doc.passIndex + 1, doc.repeatCount + 1), + pass_count: doc.repeatCount + 1, + steps_sent: doc.stepsSent, + next_step_at: isPausing ? null : doc.nextStepAt, + } +} + +const toV2Incident = ( + doc: AlertIncidentDocument, + escalation: AlertIncidentEscalationDocument | undefined, +): V2AlertIncident => ({ id: doc.id, object: "alert_incident", rule_id: doc.ruleId, @@ -50,6 +82,7 @@ const toV2Incident = (doc: AlertIncidentDocument): V2AlertIncident => ({ : { user_id: doc.acknowledgedByUserId, name: doc.acknowledgedByName }, acknowledged_via: doc.acknowledgedVia, error_issue_id: doc.errorIssueId, + escalation: escalation === undefined ? null : toV2Escalation(escalation, doc.acknowledgedAt !== null), ...(doc.anomalyIncidentId === undefined ? undefined : { anomaly_incident_id: doc.anomalyIncidentId }), ...(doc.groupLabel === undefined ? undefined : { group_label: doc.groupLabel }), ...(doc.labels === undefined ? undefined : { labels: doc.labels }), @@ -88,6 +121,24 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI const pagesService = yield* AlertIncidentPagesService const members = yield* OrgMembersService + // One escalation read for a whole page of incidents, never one per incident. + const withEscalations = (orgId: OrgId, docs: ReadonlyArray) => + readModels + .listIncidentEscalations( + orgId, + Arr.map(docs, (doc): AlertIncidentId => doc.id), + ) + .pipe( + Effect.map((escalations) => { + const byIncident = new Map(Arr.map(escalations, (row) => [row.incidentId, row])) + return Arr.map(docs, (doc) => toV2Incident(doc, byIncident.get(doc.id))) + }), + ) + const withEscalation = (orgId: OrgId, doc: AlertIncidentDocument) => + Effect.map(readModels.listIncidentEscalations(orgId, [doc.id]), (rows) => + toV2Incident(doc, rows[0]), + ) + // Who is acknowledging, with a name recorded alongside so every surface can show it without a // directory lookup. An API key is not a person: it is named by its label and has no user. A // session is named by the member directory, best effort: a slow one records no name. @@ -128,7 +179,11 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI limit, offset, }) - .pipe(Effect.map((response) => Arr.map(response.incidents, toV2Incident))), + .pipe( + Effect.flatMap((response) => + withEscalations(tenant.orgId, response.incidents), + ), + ), ) return { object: "list" as const, ...page } }), @@ -137,22 +192,21 @@ export const HttpV2AlertIncidentsLive = HttpApiBuilder.group(MapleApiV2, "alertI Effect.gen(function* () { const tenant = yield* CurrentTenant.Context const incident = yield* readModels.getIncident(tenant.orgId, params.id) - - return toV2Incident(incident) + return yield* withEscalation(tenant.orgId, incident) }), ) .handle("acknowledge", ({ params }) => Effect.gen(function* () { const { orgId, actor } = yield* callerActor const result = yield* acks.acknowledge(orgId, params.id, actor) - return toV2Incident(result.incident) + return yield* withEscalation(orgId, result.incident) }).pipe(Effect.withSpan("HttpV2AlertIncidents.acknowledge")), ) .handle("unacknowledge", ({ params }) => Effect.gen(function* () { const { orgId, actor } = yield* callerActor const result = yield* acks.unacknowledge(orgId, params.id, actor) - return toV2Incident(result.incident) + return yield* withEscalation(orgId, result.incident) }).pipe(Effect.withSpan("HttpV2AlertIncidents.unacknowledge")), ) .handle("listEvents", ({ params }) => diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 2264b96b6d..0e5871f01b 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -1363,6 +1363,102 @@ describe("v2 alerts over HTTP", () => { await harness.dispose() }) + it("reads an incident's latest escalation run on retrieve and list, and null without one", async () => { + const harness = makeHarness() + const key = await harness.bootstrapKey(["alerts:write"]) + await createWebhookAndRule(harness, key.secret) + await executeSql( + harness.testDb, + `insert into alert_incidents ( + id, org_id, rule_id, incident_key, rule_name, group_key, signal_type, severity, status, + comparator, threshold, first_triggered_at, last_triggered_at, resolved_at, dedupe_key, + created_at, updated_at + ) + select v.id, rules.org_id, rules.id, v.id, 'Checkout error rate', v.group_key, 'error_rate', + 'critical', 'open', 'gt', 0.05, now(), now(), null, v.id, now(), now() + from (values + ('00000000-0000-4000-8000-0000000000b1', 'checkout'), + ('00000000-0000-4000-8000-0000000000b2', 'payments') + ) as v(id, group_key) + cross join lateral ( + select id, org_id from alert_rules where org_id = 'org_alerts_e2e' limit 1 + ) as rules`, + ) + const steps = JSON.stringify([ + { + targets: [{ type: "destination", destinationId: "00000000-0000-4000-8000-0000000000d1" }], + waitMinutes: 10, + }, + { + targets: [{ type: "destination", destinationId: "00000000-0000-4000-8000-0000000000d2" }], + waitMinutes: 15, + }, + { + targets: [{ type: "destination", destinationId: "00000000-0000-4000-8000-0000000000d3" }], + waitMinutes: 5, + }, + ]) + // An older superseded run and the live one: the read shows the newer. + await executeSql( + harness.testDb, + `insert into alert_escalation_runs ( + id, org_id, incident_id, chain_id, chain_name, severity, steps_json, repeat_count, + step_index, pass_index, next_step_at, status, payload_json, steps_sent, created_at, updated_at + ) values + ('00000000-0000-4000-8000-0000000000c1', 'org_alerts_e2e', '00000000-0000-4000-8000-0000000000b1', + '00000000-0000-4000-8000-0000000000e1', 'Old chain', 'warning', '${steps}'::jsonb, 0, + 1, 0, '2026-11-06T14:00:00.000Z', 'superseded', '{}'::jsonb, 1, + '2026-11-06T13:00:00.000Z', '2026-11-06T13:00:00.000Z'), + ('00000000-0000-4000-8000-0000000000c2', 'org_alerts_e2e', '00000000-0000-4000-8000-0000000000b1', + '00000000-0000-4000-8000-0000000000e2', 'Checkout on-call', 'critical', '${steps}'::jsonb, 1, + 1, 0, '2026-11-06T14:25:00.000Z', 'active', '{}'::jsonb, 1, + '2026-11-06T14:10:00.000Z', '2026-11-06T14:10:00.000Z')`, + ) + const list = await harness.request("GET", "/v2/alerts/incidents?status=open", key.secret) + expect(list.status).toBe(200) + const isEscalating = (incident: { escalation: unknown }) => incident.escalation !== null + const without = Arr.filter(list.body.data, (incident) => !isEscalating(incident)) + const withRuns = Arr.filter(list.body.data, isEscalating) + expect(without).toHaveLength(1) + expect(without[0].escalation).toBeNull() + expect(withRuns).toHaveLength(1) + const withRun = withRuns[0] + const expected = { + chain_id: expect.stringMatching(/^esc_/), + chain_name: "Checkout on-call", + status: "active", + step: 2, + step_count: 3, + pass: 1, + pass_count: 2, + steps_sent: 1, + next_step_at: "2026-11-06T14:25:00.000Z", + } + expect(withRun.escalation).toEqual(expected) + + const retrieved = await harness.request("GET", `/v2/alerts/incidents/${withRun.id}`, key.secret) + expect(retrieved.status).toBe(200) + expect(retrieved.body.escalation).toEqual(expected) + + // Acked before the scheduler pauses the run: it already reads paused, with no next page. + await executeSql( + harness.testDb, + `update alert_incidents set acknowledged_at = now() where id = '00000000-0000-4000-8000-0000000000b1'`, + ) + const pausing = await harness.request("GET", `/v2/alerts/incidents/${withRun.id}`, key.secret) + expect(pausing.body.escalation).toMatchObject({ status: "acknowledged", next_step_at: null }) + + // Paused while acknowledged: no next page time. + await executeSql( + harness.testDb, + `update alert_escalation_runs set status = 'acknowledged' where id = '00000000-0000-4000-8000-0000000000c2'`, + ) + const paused = await harness.request("GET", `/v2/alerts/incidents/${withRun.id}`, key.secret) + expect(paused.body.escalation).toMatchObject({ status: "acknowledged", next_step_at: null }) + + await harness.dispose() + }) + it("enforces the alerts scope family and rejects malformed ids", async () => { const harness = makeHarness() const readOnly = await harness.bootstrapKey(["alerts:read"]) diff --git a/apps/api/src/routes/v2/v2-test-support.ts b/apps/api/src/routes/v2/v2-test-support.ts index c353b4f409..ce72f7bb45 100644 --- a/apps/api/src/routes/v2/v2-test-support.ts +++ b/apps/api/src/routes/v2/v2-test-support.ts @@ -628,6 +628,7 @@ export const AlertDestinationsServiceStubLayer = Layer.succeed( const alertReadModelStubs = { listIncidents: die, getIncident: die, + listIncidentEscalations: die, listRuleChecks: die, summarizeRuleChecks: die, listDeliveryEvents: die, diff --git a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json index fb8d62ffbe..5e2d0fb098 100644 --- a/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json +++ b/apps/ios/Packages/MapleAPI/Sources/MapleAPI/openapi.json @@ -313,6 +313,17 @@ "comparator": "gt", "dedupe_key": "alrt_gU26thvJECdQvu54Ad9jiz:__total__", "error_issue_id": null, + "escalation": { + "chain_id": "esc_gQtGH2s65G6a7sJmuayXL6", + "chain_name": "Checkout on-call", + "next_step_at": null, + "pass": 1, + "pass_count": 2, + "status": "acknowledged", + "step": 2, + "step_count": 3, + "steps_sent": 1 + }, "first_triggered_at": "2026-07-15T09:10:00.000Z", "group_key": null, "held_since": null, @@ -368,6 +379,10 @@ "$ref": "#/components/schemas/_maple_ErrorIssueId", "description": "The linked error issue (`iss_…`) when the incident was correlated to one, or `null`." }, + "escalation": { + "$ref": "#/components/schemas/AlertIncidentEscalation", + "description": "The incident's latest escalation chain run, or `null` when its severity tier names no chain." + }, "first_triggered_at": { "type": "string" }, @@ -523,6 +538,86 @@ "title": "Alert incident actor", "type": "object" }, + "AlertIncidentEscalation": { + "additionalProperties": false, + "description": "How far the incident's latest escalation chain run has got.", + "properties": { + "chain_id": { + "$ref": "#/components/schemas/_maple_EscalationChainId" + }, + "chain_name": { + "description": "The chain's name when the run started.", + "examples": [ + "Checkout on-call" + ], + "type": "string" + }, + "next_step_at": { + "description": "When the next step pages, or `null` while paused or once the run has ended.", + "type": "string" + }, + "pass": { + "description": "The pass the next step belongs to, from 1.", + "examples": [ + 1 + ], + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "pass_count": { + "description": "Passes over the chain in all: its repeat count plus one.", + "examples": [ + 2 + ], + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "status": { + "$ref": "#/components/schemas/_maple_AlertEscalationRunStatus" + }, + "step": { + "description": "The next step to page, from 1.", + "examples": [ + 2 + ], + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "step_count": { + "description": "Steps in the chain the run started with.", + "examples": [ + 3 + ], + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "steps_sent": { + "description": "Steps paged so far, across passes.", + "examples": [ + 1 + ], + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "chain_id", + "chain_name", + "status", + "step", + "step_count", + "pass", + "pass_count", + "steps_sent" + ], + "title": "Alert incident escalation", + "type": "object" + }, "AlertIncidentList": { "additionalProperties": false, "description": "A cursor-paginated page of alert incidents.", @@ -6951,6 +7046,21 @@ "title": "Alert Destination Type", "type": "string" }, + "_maple_AlertEscalationRunStatus": { + "description": "`active` waits on `next_step_at`; `acknowledged` is paused until the incident is unacknowledged; `resolved`, `exhausted` (every step and repeat sent) and `superseded` (a higher tier started its own chain) have ended.", + "enum": [ + "active", + "acknowledged", + "resolved", + "exhausted", + "superseded" + ], + "examples": [ + "active" + ], + "title": "Alert Escalation Run Status", + "type": "string" + }, "_maple_AlertEvaluationStatus": { "description": "The evaluation verdict: `breached`, `healthy`, or `skipped` (not enough samples / no data).", "enum": [ diff --git a/apps/web/src/data/poll.ts b/apps/web/src/data/poll.ts index 560ca8b7a0..981bccaf99 100644 --- a/apps/web/src/data/poll.ts +++ b/apps/web/src/data/poll.ts @@ -8,7 +8,10 @@ export interface Presence { } /** A target's events, or none off the browser (tests, SSR), where `document` and `window` do not exist. */ -const changesOf = (target: () => EventTarget | undefined, type: "visibilitychange" | "online"): Stream.Stream => +const changesOf = ( + target: () => EventTarget | undefined, + type: "visibilitychange" | "online", +): Stream.Stream => Stream.unwrap( Effect.sync(() => { const resolved = target() @@ -36,6 +39,10 @@ export const whenPresent = (presence: Presence = browserPresence): Stream.Stream Stream.take(1), ) +/** Each return to the tab (shown again, or back online), e.g. to reread what another tab changed. */ +export const returns = (presence: Presence = browserPresence): Stream.Stream => + Stream.filter(presence.changes, () => presence.isPresent()) + type Signal = "Tick" | "Change" type PollState = Readonly<{ isMissed: boolean; caughtUpAtMs: number | null }> @@ -46,9 +53,16 @@ type PollState = Readonly<{ isMissed: boolean; caughtUpAtMs: number | null }> * once, and a regular tick within half an interval of that catch-up is dropped, so the next * fetch comes 0.5x to 1.5x the interval later. */ -export const pollTicks = (every: Duration.Input, presence: Presence = browserPresence): Stream.Stream => { +export const pollTicks = ( + every: Duration.Input, + presence: Presence = browserPresence, +): Stream.Stream => { const everyMs = Duration.toMillis(every) - const next = (state: PollState, signal: Signal, nowMs: number): readonly [PollState, ReadonlyArray] => { + const next = ( + state: PollState, + signal: Signal, + nowMs: number, + ): readonly [PollState, ReadonlyArray] => { if (!presence.isPresent()) return [{ ...state, isMissed: state.isMissed || signal === "Tick" }, []] if (signal === "Change") return state.isMissed ? [{ isMissed: false, caughtUpAtMs: nowMs }, [undefined]] : [state, []] @@ -75,4 +89,5 @@ export const backoffPollTicks = ( fastTicks: number, slow: Duration.Input, presence: Presence = browserPresence, -): Stream.Stream => Stream.concat(Stream.take(pollTicks(fast, presence), fastTicks), pollTicks(slow, presence)) +): Stream.Stream => + Stream.concat(Stream.take(pollTicks(fast, presence), fastTicks), pollTicks(slow, presence)) diff --git a/apps/web/src/lab/entries/escalation.ts b/apps/web/src/lab/entries/escalation.ts new file mode 100644 index 0000000000..462078241e --- /dev/null +++ b/apps/web/src/lab/entries/escalation.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../escalation-lab" + +// `/lab/escalation`: escalation chains, their dialog, the rule tier picker and the incident rail (`?state=`). + +export const lab = defineShellLab("Escalation chains", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/escalation", + breadcrumbs, +}) diff --git a/apps/web/src/lab/escalation-lab.test.ts b/apps/web/src/lab/escalation-lab.test.ts new file mode 100644 index 0000000000..6cde280598 --- /dev/null +++ b/apps/web/src/lab/escalation-lab.test.ts @@ -0,0 +1,21 @@ +import { Array as Arr } from "effect" +import { describe, expect, it } from "vitest" +import * as IncidentMessage from "../page/alerts/incident/message" +import { init, Message, update } from "./escalation-lab" + +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +describe("escalation lab", () => { + it("drops page writes so fixture ids never reach the API", () => { + const { model } = init({ state: "incident-escalating" }) + const incident = (message: IncidentMessage.Message) => + names(update(model, Message.GotIncidentMessage({ message })).commands) + expect(incident(IncidentMessage.Message.ClickedAcknowledge({ acknowledged: true }))).toEqual([]) + expect(incident(IncidentMessage.Message.ClickedInvestigate())).toEqual([]) + }) + + it("keeps the chart runtime the incident rail needs", () => { + expect(names(init({ state: "incident-escalating" }).commands)).toContain("LoadChartRuntime") + }) +}) diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts new file mode 100644 index 0000000000..6c9ed3de41 --- /dev/null +++ b/apps/web/src/lab/escalation-lab.ts @@ -0,0 +1,369 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import type { LoadFailure } from "@maple/views/data/load-failure" +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 * as Animation from "@foldkit/ui/animation" +import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import * as Popover from "@maple/views/ui/popover" +import * as Select from "@maple/views/ui/select" +import * as Tooltip from "@maple/views/ui/tooltip" +import * as ChainDialog from "../page/alerts/escalation-dialog/dialog" +import * as EditorMessage from "../page/alerts/editor/message" +import * as EditorModel from "../page/alerts/editor/model" +import { init as editorInit } from "../page/alerts/editor/init" +import { update as editorUpdate } from "../page/alerts/editor/update" +import { breadcrumbsOf as editorBreadcrumbs, view as editorView } from "../page/alerts/editor/view" +import { chainSelectIdOf } from "../page/alerts/editor/form-steps" +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 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 incidentBreadcrumbs, view as incidentView } from "../page/alerts/incident/view" +import { + chainDestinations, + chainUuid, + populatedChains, + runOf, +} from "../page/alerts/shared/escalation-test-fixtures" +import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import type { PageContext } from "../page/contract" +import type { Resources } from "../platform/resources" + +// `/lab/escalation`: escalation chains in the Routing tab, their dialog, a rule tier's chain picker +// and the incident rail, over fixtures. `?state=` picks one. + +export const LabState = Schema.Literals([ + "chains-empty", + "chains", + "dialog-new", + "dialog-edit", + "dialog-invalid", + "dialog-error", + "rule-picker", + "incident-escalating", + "incident-paused", + "incident-finished", +]) +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 => "chains", + ), +})) + +export const Model = Schema.Struct({ + state: LabState, + hub: HubModel.Model, + editor: EditorModel.Model, + incident: IncidentModel.Model, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotHubMessage: { message: HubMessage.Message }, + GotEditorMessage: { message: EditorMessage.Message }, + GotIncidentMessage: { message: IncidentMessage.Message }, +}) +export type Message = typeof Message.Type + +type Return = Update.Return +type Step = Readonly<{ model: PM; commands?: ReadonlyArray> }> + +const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const GRANTS = Policy.grantsOf(["org:engineer"]) +const RULES = [ + ruleRow(1, { name: "Checkout error rate" }), + ruleRow(2, { name: "Checkout p95 latency" }), + ruleRow(3, { name: "Payments webhook failures" }), +] + +/** The kit's own UI commands and the chart runtime; every page read and write is dropped. */ +const LOCAL_COMMANDS: ReadonlySet = new Set([ + LoadChartRuntime.name, + Animation.WaitForPaint.name, + Animation.WaitForAnimationSettled.name, + Dialog.WaitForDialogTransition.name, + Menu.FocusMenuItem.name, + Menu.WaitSubmenuDelay.name, + Menu.WaitForMenuExit.name, + Select.FocusSelectItem.name, + Select.WaitForSelectExit.name, + Popover.WaitHoverDelay.name, + Popover.WaitForPopoverExit.name, + Tooltip.WaitOpenDelay.name, + Tooltip.CheckFocusVisible.name, +]) + +/** An allowlist, not a denylist: a new API command stays off the real API with fixture ids. */ +const keepLocal = (commands: ReadonlyArray> | undefined) => + Arr.filter(commands ?? [], (command) => LOCAL_COMMANDS.has(command.name)) + +/** Runs messages through a page's update, keeping the local commands (a dialog's open animation). */ +const fold = ( + start: Step, + messages: ReadonlyArray, + step: (model: PM, message: PS) => Step, +): Step => + Arr.reduce(messages, { model: start.model, commands: keepLocal(start.commands) }, (current, message) => { + const next = step(current.model, message) + return { model: next.model, commands: [...(current.commands ?? []), ...keepLocal(next.commands)] } + }) + +const hubStep = (model: HubModel.Model, message: HubMessage.Message) => + HubUpdate.update(model, message, CONTEXT) +const dialog = (message: ChainDialog.Message) => HubMessage.Message.GotChainDialogMessage({ message }) + +const SAVE_FAILURE: LoadFailure = { + _tag: "@maple/http/errors/AlertValidationError", + type: "invalid_request_error", + code: "invalid_request", + title: "Invalid request", + message: "Step 2 names a destination that no longer exists.", + retryable: false, + recovery: "fix_request", +} + +const hubFor = (state: LabState): Step => { + const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "routing" } }, CONTEXT).model + const chains = state === "chains-empty" ? [] : populatedChains([ruleId(1), ruleId(2), ruleId(3)]) + const base = [ + HubMessage.Message.ReadClock({ nowMs: NOW }), + HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + HubMessage.Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), + HubMessage.Message.CompletedFetchRoutingDeliveries({ + key: entered.routingDeliveries.key ?? "", + result: Result.succeed([]), + }), + HubMessage.Message.CompletedFetchChains({ + key: entered.chains.key ?? "", + result: Result.succeed(chains), + }), + ] + const firstStepKey = "s0" + const extra: ReadonlyArray = + state === "dialog-new" + ? [HubMessage.Message.ClickedNewChain()] + : state === "dialog-invalid" + ? [ + HubMessage.Message.ClickedNewChain(), + dialog(ChainDialog.Message.ChangedWait({ key: firstStepKey, value: "2000" })), + dialog(ChainDialog.Message.ClickedSave()), + ] + : state === "dialog-error" + ? [ + HubMessage.Message.GotRowMenuMessage({ + menuId: `chain:${chainUuid(1)}`, + message: Menu.Message.ClickedItem({ + index: 0, + item: { kind: "Item", value: "edit" }, + }), + }), + dialog(ChainDialog.Message.ClickedSave()), + dialog( + ChainDialog.Message.CompletedSave({ + openCount: 1, + result: Result.fail(SAVE_FAILURE), + }), + ), + ] + : state === "dialog-edit" + ? [ + HubMessage.Message.GotRowMenuMessage({ + menuId: `chain:${chainUuid(3)}`, + message: Menu.Message.ClickedItem({ + index: 0, + item: { kind: "Item", value: "edit" }, + }), + }), + ] + : [] + return fold({ model: entered }, [...base, ...extra], hubStep) +} + +const editorStep = (model: EditorModel.Model, message: EditorMessage.Message) => + editorUpdate(model, message, CONTEXT) + +const editorFor = (): Step => { + const start = editorInit({ _tag: "AlertsCreate", search: { template: "high_error_rate" } }, CONTEXT) + const pick = (tier: "warning" | "critical", value: string) => + EditorMessage.Message.GotSelectMessage({ + id: chainSelectIdOf(tier), + message: Select.Message.SelectedItem({ index: 1, value }), + }) + return fold( + start, + [ + EditorMessage.Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + EditorMessage.Message.CompletedReadClock({ nowMs: NOW }), + EditorMessage.Message.CompletedReadGrants({ grants: GRANTS }), + EditorMessage.Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + EditorMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), + EditorMessage.Message.CompletedFetchConnectors({ connectors: [] }), + EditorMessage.Message.CompletedFetchEscalationChains({ + chains: populatedChains([ruleId(1), ruleId(2), ruleId(3)]), + }), + EditorMessage.Message.ChangedField({ field: "name", value: "Checkout error rate" }), + EditorMessage.Message.ClickedAddCriticalTier(), + pick("warning", chainUuid(1)), + ], + editorStep, + ) +} + +const incidentStep = (model: IncidentModel.Model, message: IncidentMessage.Message) => + IncidentUpdate.update(model, message, CONTEXT) + +const incidentFor = (state: LabState): Step => { + const start = IncidentUpdate.init({ _tag: "AlertIncident", incidentId: INCIDENT_ID, search: {} }, CONTEXT) + const isPaused = state === "incident-paused" + const row = incidentRow({ + rule_name: "Checkout p95 latency", + acknowledged_at: isPaused ? new Date(NOW - 4 * 60_000).toISOString() : null, + acknowledged_by_name: isPaused ? "Dana Kim" : null, + }) + const synced = fold( + start, + [ + IncidentMessage.Message.ReadClock({ nowMs: NOW }), + IncidentMessage.Message.UpdatedIncidents({ orgId: ORG, rows: [row] }), + IncidentMessage.Message.UpdatedRules({ + orgId: ORG, + rows: [ruleRow(2, { name: "Checkout p95 latency" })], + }), + IncidentMessage.Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + ], + incidentStep, + ) + const run = + state === "incident-finished" + ? runOf("exhausted", { step: 3, stepsSent: 9 }) + : isPaused + ? runOf("acknowledged") + : runOf("active") + const settled = fold( + synced, + [ + IncidentMessage.Message.CompletedFetchEscalation({ + key: synced.model.escalation.key ?? "", + result: Result.succeed(run), + }), + IncidentMessage.Message.CompletedFetchDeliveries({ + key: synced.model.deliveries.key ?? "", + result: Result.succeed([]), + }), + IncidentMessage.Message.CompletedFetchInvestigation({ + key: synced.model.investigation.key ?? "", + result: Result.succeed(null), + }), + ], + incidentStep, + ) + return settled +} + +const isHub = (state: LabState) => + state === "chains" || state === "chains-empty" || Str.startsWith("dialog-")(state) +const isIncident = (state: LabState) => Str.startsWith("incident-")(state) + +export const init = (flags: Flags): Return => { + const hub = hubFor(flags.state) + const editor = editorFor() + const incident = incidentFor(flags.state) + return { + model: { state: flags.state, hub: hub.model, editor: editor.model, incident: incident.model }, + commands: [ + ...(isHub(flags.state) + ? Command.mapMessages(hub.commands ?? [], (message) => Message.GotHubMessage({ message })) + : []), + ...(flags.state === "rule-picker" + ? Command.mapMessages(editor.commands ?? [], (message) => + Message.GotEditorMessage({ message }), + ) + : []), + ...(isIncident(flags.state) + ? Command.mapMessages(incident.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 = editorUpdate(model.editor, child, CONTEXT) + return { + model: { ...model, editor: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotEditorMessage({ 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) => + isHub(model.state) + ? [{ label: "Alerts" }] + : isIncident(model.state) + ? incidentBreadcrumbs(model.incident) + : editorBreadcrumbs(model.editor) + +export const view = (model: Model, h: HtmlBuilder): Html => + isHub(model.state) + ? h.submodel({ + slotId: "lab-escalation-hub", + model: model.hub, + view: hubView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), + }) + : isIncident(model.state) + ? h.submodel({ + slotId: "lab-escalation-incident", + model: model.incident, + view: incidentView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: IncidentMessage.Message) => + Message.GotIncidentMessage({ message }), + }) + : h.submodel({ + slotId: "lab-escalation-editor", + model: model.editor, + view: editorView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: EditorMessage.Message) => + Message.GotEditorMessage({ message }), + }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 48917ad274..4b3e994df4 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/escalation", + title: "Escalation chains", + description: + "The Routing tab's escalation chains empty and populated, the chain dialog new, editing, invalid and failing, a rule tier's chain picker, and the incident rail escalating, paused or finished (`?state=`).", + kind: "lab", + session: "none", + }, { path: "/lab/anomaly-alerts", title: "Anomaly alerts", diff --git a/apps/web/src/page/alerts/editor/command.ts b/apps/web/src/page/alerts/editor/command.ts index c5c147c62e..7fdc661fc5 100644 --- a/apps/web/src/page/alerts/editor/command.ts +++ b/apps/web/src/page/alerts/editor/command.ts @@ -17,6 +17,7 @@ import { AutocompleteLookup, autocompleteLookup } from "../../kit/autocomplete-l import { trackProduct } from "../../../data/analytics" import { readGrants } from "../../../data/session-tenant" import { fetchPreview } from "../shared/preview" +import { listEscalationChains } from "../shared/escalation-chains" import { Message } from "./message" export const GenerateDraftId = Command.define("GenerateAlertDraftId", { @@ -50,6 +51,15 @@ export const FetchEditorConnectors = Command.define("FetchAlertEditorConnectors" }).pipe(Effect.orElseSucceed(() => Message.CompletedFetchConnectors({ connectors: [] }))), }) +/** The org's escalation chains for the tier pickers; `null` when the read failed. */ +export const FetchEditorChains = Command.define("FetchAlertEditorChains", { + messages: [Message.CompletedFetchEscalationChains], + execute: listEscalationChains.pipe( + Effect.map((chains) => Message.CompletedFetchEscalationChains({ chains })), + Effect.orElseSucceed(() => Message.CompletedFetchEscalationChains({ chains: null })), + ), +}) + /** The provider's traces facets: the scope pickers' services and environments. */ export const FetchTraceFacets = Command.define("FetchAlertEditorTraceFacets", { args: { key: Schema.String, window: TimeWindow }, 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 index 259d87d7ce..603a765e60 100644 --- a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts @@ -75,6 +75,20 @@ describe("alert rule editor: anomaly rules", () => { ) }) + test("a tier left unrouted is no error on an anomaly rule, which only needs someone on either tier", () => { + const anomaly = run(opened({ template: "anomaly" }), [ + Message.ChangedField({ field: "name", value: "Checkout anomalies" }), + Message.ToggledAnomalySignal({ signal: "error_rate" }), + pickExisting("critical", DESTINATION), + ]) + scene( + { update: pageUpdate, view }, + given(anomaly), + themeResolved(), + expectView(text("Pick a destination or an escalation chain")).not.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" }), diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts new file mode 100644 index 0000000000..26d0b8a590 --- /dev/null +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -0,0 +1,151 @@ +import { OrgId } from "@maple/domain" +import { AlertDestinationId } from "@maple/domain/http" +import { 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 * as Select from "@maple/views/ui/select" +import type { PageContext } from "../../contract" +import { chainDestinations, chainUuid, populatedChains } from "../shared/escalation-test-fixtures" +import { destinationRow, NOW, ORG, ruleRow } from "../shared/test-rows" +import { chainSelectIdOf } from "./form-steps" +import { init } from "./init" +import { Message, type Tier } from "./message" +import type { Model } from "./model" +import { saveIssues } from "./derive" +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 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 CHAINS = populatedChains([]) +const issuesOf = (model: Model) => (model.form === null ? ["no draft"] : saveIssues(model.form, null)) +const pickChain = (tier: Tier, value: string) => + Message.GotSelectMessage({ + id: chainSelectIdOf(tier), + message: Select.Message.SelectedItem({ index: 1, value }), + }) + +/** A slow-p95 draft on the org's default destination, with the org's chains read. */ +const ready = () => { + const start = init({ _tag: "AlertsCreate", search: { template: "slow_p95" } }, context) + expect(names(start.commands)).toContain("FetchAlertEditorChains") + return run(start.model, [ + Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + Message.CompletedReadClock({ nowMs: NOW }), + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow(), ...chainDestinations] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2)] }), + Message.CompletedFetchEscalationChains({ chains: CHAINS }), + ]) +} +const withoutDestination = (model: Model) => + run(model, [ + Message.ClickedRemoveDestination({ + tier: "warning", + id: Schema.decodeSync(AlertDestinationId)(destinationRow().id), + }), + ]) + +describe("alert rule editor, escalation chains", () => { + test("each tier offers None and the org's chains, with a link to create one", () => { + scene( + { update: pageUpdate, view }, + given(ready()), + themeResolved(), + expectView(role("combobox", { name: "Escalation chain" })).toExist(), + expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts?tab=routing"), + ) + }) + + test("a two-tier rule names each picker by its tier", () => { + scene( + { update: pageUpdate, view }, + given(run(ready(), [Message.ClickedAddCriticalTier()])), + themeResolved(), + expectView(role("combobox", { name: "Warning escalation chain" })).toExist(), + expectView(role("combobox", { name: "Critical escalation chain" })).toExist(), + expectView(role("combobox", { name: "Escalation chain" })).not.toExist(), + ) + }) + + test("a tier with neither a destination nor a chain says so and cannot be saved", () => { + const model = withoutDestination(ready()) + expect(issuesOf(model)).toContain("Who gets notified") + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual([]) + scene( + { update: pageUpdate, view }, + given(model), + themeResolved(), + expectView(text("Pick a destination or an escalation chain")).toExist(), + expectView(role("combobox", { name: "Escalation chain" })).toHaveAttr("aria-invalid", "true"), + ) + }) + + test("a chain alone routes the tier, and its steps read under the picker", () => { + const model = run(withoutDestination(ready()), [pickChain("warning", chainUuid(1))]) + expect(model.form?.escalationChainId).toBe(chainUuid(1)) + expect(issuesOf(model)).not.toContain("Who gets notified") + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual(["SaveAlertRule"]) + scene( + { update: pageUpdate, view }, + given(model), + themeResolved(), + expectView(text(/^Slack #alerts-api, then PagerDuty Checkout after 10m/u)).toExist(), + expectView(text("Pick a destination or an escalation chain")).not.toExist(), + ) + expect(run(model, [pickChain("warning", "none")]).form?.escalationChainId).toBeNull() + }) + + test("a failed chain read keeps the tier's chain and says the list did not load, not that the chain is gone", () => { + const model = run(ready(), [ + pickChain("warning", chainUuid(1)), + Message.CompletedFetchEscalationChains({ chains: null }), + ]) + expect(model.form?.escalationChainId).toBe(chainUuid(1)) + scene( + { update: pageUpdate, view }, + given(model), + themeResolved(), + expectView(text("Couldn't load escalation chains; reload the page to change this one")).toExist(), + expectView(text("Unavailable chain")).not.toExist(), + ) + }) + + test("a failed chain read says so on a tier with no chain, rather than looking like the org has none", () => { + scene( + { update: pageUpdate, view }, + given(run(ready(), [Message.CompletedFetchEscalationChains({ chains: null })])), + themeResolved(), + expectView(text("Couldn't load escalation chains; reload the page to pick one")).toExist(), + ) + }) + + test("returning to the tab reads the chains again, so one made in the Create a chain tab can be picked", () => { + expect(names(pageUpdate(ready(), Message.ReturnedToTab()).commands)).toEqual([ + "FetchAlertEditorChains", + ]) + }) + + test("the critical tier needs a destination or a chain of its own", () => { + const tiered = run(ready(), [Message.ClickedAddCriticalTier()]) + expect(issuesOf(tiered)).toContain("Critical destinations") + scene( + { update: pageUpdate, view }, + given(tiered), + themeResolved(), + expectView(text("Pick a destination or an escalation chain")).toExist(), + ) + const routed = run(tiered, [pickChain("critical", chainUuid(2))]) + expect(routed.form?.escalation?.escalationChainId).toBe(chainUuid(2)) + expect(issuesOf(routed)).not.toContain("Critical destinations") + }) +}) diff --git a/apps/web/src/page/alerts/editor/form-steps.ts b/apps/web/src/page/alerts/editor/form-steps.ts index 48f7305022..9f3e5d0a46 100644 --- a/apps/web/src/page/alerts/editor/form-steps.ts +++ b/apps/web/src/page/alerts/editor/form-steps.ts @@ -3,6 +3,7 @@ import type { AlertSeverity, AlertSignalType, AnomalySignalType, + EscalationChainId, } from "@maple/domain/http" import { ANOMALY_SIGNALS } from "@maple/web-data/alerts/anomaly-rule" import { switchSignalKind } from "@maple/web-data/alerts/rule-modes" @@ -26,6 +27,18 @@ export const setEscalationField = ( ): RuleForm => form.escalation === null ? form : { ...form, escalation: { ...form.escalation, [field]: value } } +/** A tier's escalation chain select, and its "None" value. */ +export const chainSelectIdOf = (tier: Tier): string => `rule-chain-${tier}` +export const NO_CHAIN = "none" + +/** The escalation chain a tier starts, or none. */ +export const setTierChain = (form: RuleForm, tier: Tier, chainId: EscalationChainId | null): RuleForm => + tier === "warning" + ? { ...form, escalationChainId: chainId } + : form.escalation === null + ? form + : { ...form, escalation: { ...form.escalation, escalationChainId: chainId } } + /** `setKind`: the switch carries filters and the threshold's meaning, and may leave a notice. */ export const switchKind = (form: RuleForm, kind: SignalKind): { form: RuleForm; notice: string | null } => { const moved = switchSignalKind(asFormState(form), kind) diff --git a/apps/web/src/page/alerts/editor/index.ts b/apps/web/src/page/alerts/editor/index.ts index 2b70f3a3bd..3f5d73364c 100644 --- a/apps/web/src/page/alerts/editor/index.ts +++ b/apps/web/src/page/alerts/editor/index.ts @@ -1,5 +1,6 @@ import { Option, Schema, Stream } from "effect" import { Subscription } from "foldkit" +import { returns } from "../../../data/poll" import type { Resources } from "../../../platform/resources" import { definePage, type PageSubscriptionInput } from "../../contract" import { documentStream } from "../../dashboards/kit/document-stream" @@ -83,6 +84,14 @@ const subscriptions = Subscription.make, Message, R ? collectionLoadFailed(`alert_rules:${orgId}`, () => Message.FailedLoadRules({ orgId })) : Stream.empty, }), + /** Chains are read again on each return to the tab, so one made from "Create a chain" shows up. */ + chainsRefresh: entry( + {}, + { + modelToDependencies: () => ({}), + dependenciesToStream: () => Stream.map(returns(), () => Message.ReturnedToTab()), + }, + ), /** `useDashboardsRead`, only for the id-lookup fallback (a widget link without its snapshot). */ sourceDashboard: entry( { orgId: Schema.String, dashboardId: Schema.NullOr(Schema.String) }, diff --git a/apps/web/src/page/alerts/editor/init.ts b/apps/web/src/page/alerts/editor/init.ts index 2be612fcbe..2b03c68552 100644 --- a/apps/web/src/page/alerts/editor/init.ts +++ b/apps/web/src/page/alerts/editor/init.ts @@ -10,7 +10,13 @@ import * as QueryPanel from "../../kit/query-builder/panel" import * as TimeRangePicker from "../../kit/time-range-picker" import * as DestinationDialog from "../destination/dialog" import * as ChatConnect from "../shared/chat-connect" -import { FetchEditorConnectors, GenerateDraftId, ReadEditorClock, ReadEditorGrants } from "./command" +import { + FetchEditorChains, + FetchEditorConnectors, + GenerateDraftId, + ReadEditorClock, + ReadEditorGrants, +} from "./command" import { Message } from "./message" import type { Model } from "./model" @@ -41,6 +47,8 @@ export const init = (route: CreateRoute, _context: PageContext): PageReturn message._tag === "CompletedLoadChartRuntime" ? Message.CompletedLoadChartRuntime() diff --git a/apps/web/src/page/alerts/editor/message.ts b/apps/web/src/page/alerts/editor/message.ts index fef6e26ca9..e963e757b9 100644 --- a/apps/web/src/page/alerts/editor/message.ts +++ b/apps/web/src/page/alerts/editor/message.ts @@ -5,7 +5,7 @@ import { AnomalySignalType, DashboardDocument, } from "@maple/domain/http" -import { V2ChatConnector } from "@maple/domain/http/v2" +import { V2ChatConnector, V2EscalationChain } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -76,6 +76,9 @@ export const Message = defineMessageUnion({ CompletedReadClock: { nowMs: Schema.Number }, CompletedReadGrants: { grants: Schema.Array(Policy.PermissionName) }, CompletedFetchConnectors: { connectors: Schema.Array(V2ChatConnector) }, + /** Back on this tab: a chain made from "Create a chain" (a new tab) can now be picked. */ + ReturnedToTab: {}, + CompletedFetchEscalationChains: { chains: Schema.NullOr(Schema.Array(V2EscalationChain)) }, CompletedFetchTraceFacets: { key: Schema.String, result: Schema.Result(TraceFacets, LoadFailure) }, CompletedFetchAutocomplete: { key: Schema.String, diff --git a/apps/web/src/page/alerts/editor/model.ts b/apps/web/src/page/alerts/editor/model.ts index c573c875d5..b91eb07e6e 100644 --- a/apps/web/src/page/alerts/editor/model.ts +++ b/apps/web/src/page/alerts/editor/model.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { AlertDestinationId, AlertRuleId, DashboardDocument } from "@maple/domain/http" -import { V2ChatConnector } from "@maple/domain/http/v2" +import { V2ChatConnector, V2EscalationChain } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -90,6 +90,10 @@ export const Model = Schema.Struct({ autoDestinationId: Schema.NullOr(AlertDestinationId), /** `useChatConnectors`: `null` until the list answers. */ connectors: Schema.NullOr(Schema.Array(V2ChatConnector)), + /** The org's escalation chains a tier can start; `null` until the list answers, or when it failed. */ + escalationChains: Schema.NullOr(Schema.Array(V2EscalationChain)), + /** The chain list could not be read: a saved chain is kept, not called deleted. */ + isEscalationChainsFailed: Schema.Boolean, /** The 24h window the autocomplete provider reads, and the preview's own lookback. */ autocompleteWindow: Schema.NullOr(TimeWindow), previewPreset: Schema.String, diff --git a/apps/web/src/page/alerts/editor/plan.ts b/apps/web/src/page/alerts/editor/plan.ts index f07f454183..28e5cb487e 100644 --- a/apps/web/src/page/alerts/editor/plan.ts +++ b/apps/web/src/page/alerts/editor/plan.ts @@ -115,7 +115,8 @@ 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 + // A tier with a chain is routed already; it gets no default destination on top. + const isApplied = fallback !== null && form.destinationIds.length === 0 && form.escalationChainId === null return { ...model, isDefaultDestinationApplied: true, diff --git a/apps/web/src/page/alerts/editor/sub-steps.ts b/apps/web/src/page/alerts/editor/sub-steps.ts index 8eaf0e687a..bd19e02c0e 100644 --- a/apps/web/src/page/alerts/editor/sub-steps.ts +++ b/apps/web/src/page/alerts/editor/sub-steps.ts @@ -1,4 +1,4 @@ -import { AlertComparator, AlertSeverity, AlertSignalType } from "@maple/domain/http" +import { AlertComparator, AlertSeverity, AlertSignalType, EscalationChainId } from "@maple/domain/http" import { QueryEngineAlertReducer } from "@maple/domain/query-engine" import { Schema } from "effect" import { Command } from "foldkit" @@ -11,7 +11,14 @@ 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 { + chainSelectIdOf, + NO_CHAIN, + setBuiltinSignal, + setChips, + setSeverity, + setTierChain, +} from "./form-steps" import { AUTO_INTERVAL, intervalRadioValue } from "./interval" import { Message } from "./message" import { panelInputsOf } from "./where-inputs" @@ -26,6 +33,7 @@ const decodeComparator = Schema.decodeUnknownOption(AlertComparator) const decodeReducer = Schema.decodeUnknownOption(QueryEngineAlertReducer) const decodeSignal = Schema.decodeUnknownOption(AlertSignalType) const decodeSeverity = Schema.decodeUnknownOption(AlertSeverity) +const decodeChainId = Schema.decodeUnknownOption(EscalationChainId) /** The radio groups: built-in signal, severity and check interval; their value is the form's. */ export const radioStep = ( @@ -82,6 +90,17 @@ export const selectStep = ( if (next.outMessage === undefined) return { model: lifted, commands } const value = next.outMessage.value const edited = editForm(lifted, context, (form) => { + const tier = + id === chainSelectIdOf("warning") + ? "warning" + : id === chainSelectIdOf("critical") + ? "critical" + : null + if (tier !== null) { + if (value === NO_CHAIN) return setTierChain(form, tier, null) + const chainId = decodeChainId(value) + return chainId._tag === "Some" ? setTierChain(form, tier, chainId.value) : form + } if (id === "rule-comparator") { const comparator = decodeComparator(value) return comparator._tag === "Some" ? { ...form, comparator: comparator.value } : form diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index f22433d58c..39ecdf346e 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -6,7 +6,7 @@ import { Command } from "foldkit" import type { PageContext } from "../../contract" import * as DestinationDialog from "../destination/dialog" import { completeSave, completeTest, runTest, save } from "./actions" -import { FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" +import { FetchEditorChains, FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" import { SQL_TEXTAREA_ID } from "./view-sql" import { chatConnectStep, connectorsAnswered, liftDialog, menuStep, removeDestination } from "./dest-steps" import { isProtectedDefault, suggestedName } from "./derive" @@ -82,6 +82,10 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedReadClock: ({ nowMs }) => readClock(model, context, nowMs), CompletedReadGrants: ({ grants }) => ({ model: { ...model, grants } }), CompletedFetchConnectors: ({ connectors }) => connectorsAnswered(model, connectors), + ReturnedToTab: () => ({ model, commands: [FetchEditorChains()] }), + CompletedFetchEscalationChains: ({ chains }) => ({ + model: { ...model, escalationChains: chains, isEscalationChainsFailed: chains === null }, + }), GotChatConnectMessage: ({ message: inner }) => chatConnectStep(model, inner), CompletedFetchTraceFacets: ({ key, result }) => ({ model: { ...model, traceFacets: Keyed.accept(model.traceFacets, key, result) }, diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index 6633e92a72..1c2fc2ca65 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -1,6 +1,6 @@ -import { ALERT_TEMPLATE_VARIABLES } from "@maple/domain/http" +import { ALERT_TEMPLATE_VARIABLES, type AlertDestinationId, type EscalationChainId } from "@maple/domain/http" import { comparatorSupportsTiers } from "@maple/web-data/alerts/form-utils" -import { Struct, Array as Arr } from "effect" +import { Option, Struct, Array as Arr } from "effect" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" import { card } from "@maple/views/ui/card" @@ -19,7 +19,12 @@ import type { Model } from "./model" import { fieldStateOf, idsOf } from "./view-fields" import { destinationList, type NotifyView } from "./view-notify" import { typography, typographyClass } from "@maple/views/ui/typography" -import { hstack } from "@maple/views/ui/stack" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { chainSummary, routingTabHref } from "../shared/escalation-chains" +import { chainSelectIdOf, NO_CHAIN } from "./form-steps" +import type { Tier } from "./message" +import { formSelect } from "./view-select" // `NotificationsSection`: who gets notified, per tier, // and the optional message template. @@ -28,10 +33,25 @@ type H = HtmlBuilder /** What the section reads off the Model and the draft. */ type NotificationsView = NotifyView & - Pick + Pick< + Model, + | "destinations" + | "isSendingTest" + | "isMessageTemplateOpen" + | "mountedForm" + | "escalationChains" + | "isEscalationChainsFailed" + | "selects" + > type NotificationsForm = Pick< RuleForm, - "signalType" | "comparator" | "escalation" | "destinationIds" | "notificationTitle" | "notificationBody" + | "signalType" + | "comparator" + | "escalation" + | "destinationIds" + | "escalationChainId" + | "notificationTitle" + | "notificationBody" > const TITLE_PLACEHOLDER = "{{ event.emoji }} {{ rule.name }} — {{ event.label }}" @@ -55,6 +75,126 @@ const tierHeading = (h: H, severity: "warning" | "critical", text: string): Html ], ) +/** The chain select's options: None, the org's chains, and a saved one the list no longer has. */ +const chainOptions = (model: NotificationsView, chainId: EscalationChainId | null) => { + const chains = model.escalationChains ?? [] + const isMissing = chainId !== null && !Arr.some(chains, (chain) => chain.id === chainId) + return [ + { value: NO_CHAIN, label: "None" }, + ...(isMissing + ? [ + { + value: chainId, + label: model.escalationChains === null ? "Saved chain" : "Unavailable chain", + }, + ] + : []), + ...Arr.map(chains, (chain) => ({ value: chain.id, label: chain.name })), + ] +} + +/** `TierChainPicker`: the escalation chain a tier starts on top of its destinations. */ +const chainPicker = ( + h: H, + model: NotificationsView, + tier: Tier, + chainId: EscalationChainId | null, + destinationIds: ReadonlyArray, + label: string, + /** Each tier needs its own route; an anomaly rule only needs someone on either tier. */ + isRouteRequired: boolean, +): Html => { + const id = chainSelectIdOf(tier) + const ids = idsOf(id, false) + const value = chainId ?? NO_CHAIN + const isUnrouted = isRouteRequired && chainId === null && destinationIds.length === 0 + const state = { ...fieldStateOf(value, undefined), isInvalid: isUnrouted } + const destinations = destinationsOf(model) + const nameOf = (destinationId: AlertDestinationId) => + Option.getOrElse( + Option.map( + Arr.findFirst(destinations, (destination) => destination.id === destinationId), + (destination) => destination.name, + ), + () => "an unavailable destination", + ) + const selected = Arr.findFirst(model.escalationChains ?? [], (chain) => chain.id === chainId) + const note = Option.match(selected, { + onSome: (chain) => [ + typography(h, { size: "xs", tone: "muted", as: "p", className: "break-words" }, [ + chainSummary(chain, nameOf), + ]), + ], + onNone: () => [ + ...(model.isEscalationChainsFailed + ? [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + chainId === null + ? "Couldn't load escalation chains; reload the page to pick one" + : "Couldn't load escalation chains; reload the page to change this one", + ]), + ] + : []), + ...(isUnrouted + ? [ + typography( + h, + { size: "xs", tone: "destructive", as: "p", attributes: [h.Id(ids.error)] }, + ["Pick a destination or an escalation chain"], + ), + ] + : []), + ], + }) + return formSelect(h, model.selects[id], id, value, chainOptions(model, chainId), { + className: "h-9 w-full", + labelledBy: ids.label, + ...(isUnrouted ? { errorId: ids.error } : undefined), + toView: (parts) => + field(h, { className: "items-stretch gap-1.5", state }, [ + hstack(h, { gap: "2", justify: "between" }, [ + fieldLabel(h, { ids, state, className: "text-xs" }, label), + textLink( + h, + { + href: routingTabHref(), + tone: "muted", + className: "text-xs", + attributes: [h.Target("_blank"), h.Rel("noopener")], + }, + ["Create a chain"], + ), + ]), + ...parts, + ...note, + ]), + }) +} + +/** One tier's destinations, then the chain it starts; `tiered` names the picker by its tier. */ +const tierRouting = ( + h: H, + model: NotificationsView, + tier: Tier, + destinationIds: ReadonlyArray, + chainId: EscalationChainId | null, + tiered: boolean, + isRouteRequired: boolean, +): Html => + vstack(h, { gap: "3" }, [ + destinationList(h, model, tier, destinationIds, destinationsOf(model)), + chainPicker( + h, + model, + tier, + chainId, + destinationIds, + // Two tiers render two pickers, so each needs its own accessible name. + tiered ? `${tier === "warning" ? "Warning" : "Critical"} escalation chain` : "Escalation chain", + isRouteRequired, + ), + ]) + /** `MessageTemplate`: collapsed until opened, or open when the rule has one. */ const messageTemplate = (h: H, model: NotificationsView, form: NotificationsForm): Html => { const hasTemplate = form.notificationTitle.length > 0 || form.notificationBody.length > 0 @@ -142,6 +282,7 @@ const messageTemplate = (h: H, model: NotificationsView, form: NotificationsForm const notifications = (h: H, model: NotificationsView, form: NotificationsForm): Html => { const destinations = destinationsOf(model) const escalation = comparatorSupportsTiers(form.comparator) ? form.escalation : null + const isRouteRequired = form.signalType !== "anomaly" const isAnySelected = Arr.some( destinations, (d) => form.destinationIds.includes(d.id) || (escalation?.destinationIds.includes(d.id) ?? false), @@ -171,7 +312,17 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): ] : []), ...(escalation === null - ? [destinationList(h, model, "warning", form.destinationIds, destinations)] + ? [ + tierRouting( + h, + model, + "warning", + form.destinationIds, + form.escalationChainId, + false, + isRouteRequired, + ), + ] : [ h.div( [h.Class("mt-1 space-y-4")], @@ -187,12 +338,14 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): [h.Class("space-y-1.5")], [ tierHeading(h, "warning", "Warning"), - destinationList( + tierRouting( h, model, "warning", form.destinationIds, - destinations, + form.escalationChainId, + true, + isRouteRequired, ), ], ), @@ -204,12 +357,14 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): "critical", "Critical, on top of the warning destinations", ), - destinationList( + tierRouting( h, model, "critical", escalation.destinationIds, - destinations, + escalation.escalationChainId, + true, + isRouteRequired, ), ], ), @@ -262,6 +417,9 @@ const viewOf = stableSlice((model: Model) => "chatConnect", "chatConnectMenuId", "brokenLogos", + "escalationChains", + "isEscalationChainsFailed", + "selects", ]), ) const formOf = stableSlice((form: RuleForm) => @@ -270,6 +428,7 @@ const formOf = stableSlice((form: RuleForm) => "comparator", "escalation", "destinationIds", + "escalationChainId", "notificationTitle", "notificationBody", ]), diff --git a/apps/web/src/page/alerts/editor/view-select.ts b/apps/web/src/page/alerts/editor/view-select.ts index 2c6477fd56..b102b291ca 100644 --- a/apps/web/src/page/alerts/editor/view-select.ts +++ b/apps/web/src/page/alerts/editor/view-select.ts @@ -17,6 +17,8 @@ export const formSelect = ( className?: string ariaLabel?: string labelledBy?: string + /** The error text the trigger is described by, and marked invalid with. */ + errorId?: string toView: (parts: ReadonlyArray) => Html }>, ): Html => { @@ -32,6 +34,7 @@ export const formSelect = ( className: inputs.className, ariaLabel: inputs.ariaLabel, labelledBy: inputs.labelledBy, + ...(inputs.errorId === undefined ? undefined : { describedBy: inputs.errorId, isInvalid: true }), toView: inputs.toView, }, }) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts new file mode 100644 index 0000000000..dbdc4ebc83 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts @@ -0,0 +1,71 @@ +import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" +import { expect as expectView, given, Mount, role, scene, withViewInputs } from "foldkit/scene" +import { describe, test } from "vitest" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { chainDestinations, chainOf } from "../shared/escalation-test-fixtures" +import { init, Message, openEdit, update } from "./dialog" +import { view as dialogView } from "./view" + +const view = withViewInputs(dialogView, { id: "chain", destinations: chainDestinations })() +const editing = () => + openEdit( + init("chain"), + chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + ], + repeat: 1, + }), + ).model + +describe("escalation chain dialog view", () => { + test("while a save is in flight the fields and step controls are disabled", () => { + scene( + { update, view }, + given(update(editing(), Message.ClickedSave()).model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("textbox", { name: "Name" })).toBeDisabled(), + expectView(role("textbox", { name: "Description" })).toBeDisabled(), + expectView(role("spinbutton", { name: "Repeat the whole chain" })).toBeDisabled(), + expectView(role("button", { name: "Move step 1 down" })).toBeDisabled(), + expectView(role("button", { name: "Remove step 1" })).toBeDisabled(), + expectView(role("button", { name: "Add step" })).toBeDisabled(), + expectView(role("button", { name: /^Stop notifying/ })).toBeDisabled(), + ) + }) + + test("before a save the same controls are editable", () => { + scene( + { update, view }, + given(editing()), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("textbox", { name: "Name" })).not.toBeDisabled(), + expectView(role("spinbutton", { name: "Repeat the whole chain" })).not.toBeDisabled(), + expectView(role("button", { name: "Add step" })).not.toBeDisabled(), + expectView(role("button", { name: /^Stop notifying .+ in step 1$/u })).toExist(), + ) + }) + + test("the wait and repeat inputs take their range and copy from the API's limits", () => { + scene( + { update, view }, + given(editing()), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAttr( + "max", + String(MAX_ESCALATION_REPEATS), + ), + expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAccessibleDescription( + `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, + ), + expectView(role("spinbutton", { name: /^Step 1: then wait/ })).toHaveAttr( + "max", + String(MAX_ESCALATION_WAIT_MINUTES), + ), + ) + }) +}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts new file mode 100644 index 0000000000..ff9cdd0598 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts @@ -0,0 +1,130 @@ +import { Option, Array as Arr, Schema } from "effect" +import { AlertDestinationId } from "@maple/domain/http" +import { describe, expect, it } from "vitest" +import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" +import { init, Message, type Model, openEdit, payloadOf, update } from "./dialog" + +const asDestination = Schema.decodeSync(AlertDestinationId) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => update(current, message).model) +const opened = (): Model => + openEdit( + init("chain"), + chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + ], + repeat: 1, + }), + ).model + +describe("escalation chain dialog", () => { + it("an edited chain round-trips to the payload it was read from", () => { + expect(payloadOf(opened())).toEqual( + Option.some({ + name: "Checkout on-call", + description: null, + steps: [ + { + targets: [{ type: "destination", destination_id: chainDestinationUuid(1) }], + wait_minutes: 10, + }, + { + targets: [{ type: "destination", destination_id: chainDestinationUuid(2) }], + wait_minutes: 15, + }, + ], + repeat_count: 1, + }), + ) + }) + + it("holds the API's limits: a name, a destination per step, a 1 to 1440 minute wait, 0 to 5 repeats", () => { + const model = opened() + const key = model.steps[0]?.key ?? "" + const broken = (messages: ReadonlyArray) => Option.isNone(payloadOf(run(model, messages))) + expect(broken([Message.ChangedName({ value: " " })])).toBe(true) + expect(broken([Message.ChangedWait({ key, value: "0" })])).toBe(true) + expect(broken([Message.ChangedWait({ key, value: "1441" })])).toBe(true) + expect(broken([Message.ChangedWait({ key, value: "2.5" })])).toBe(true) + expect(broken([Message.ChangedRepeatCount({ value: "6" })])).toBe(true) + expect( + broken([Message.ClickedRemoveDestination({ key, id: asDestination(chainDestinationUuid(1)) })]), + ).toBe(true) + expect( + broken([Message.ChangedWait({ key, value: "1440" }), Message.ChangedRepeatCount({ value: "5" })]), + ).toBe(false) + }) + + it("a save with errors sends nothing and shows them; a valid one sends once", () => { + const model = run(opened(), [Message.ChangedName({ value: "" })]) + const refused = update(model, Message.ClickedSave()) + expect(refused.commands ?? []).toEqual([]) + expect(refused.model.isShowingErrors).toBe(true) + const saving = update(opened(), Message.ClickedSave()) + expect(Arr.map(saving.commands ?? [], ({ name }) => name)).toEqual(["SaveEscalationChain"]) + expect(update(saving.model, Message.ClickedSave()).commands ?? []).toEqual([]) + }) + + it("edits sent while a save is in flight leave the form as it was sent", () => { + const saving = update(opened(), Message.ClickedSave()).model + const key = saving.steps[0]?.key ?? "" + const edited = run(saving, [ + Message.ChangedName({ value: "Renamed mid-save" }), + Message.ChangedDescription({ value: "typed while saving" }), + Message.ChangedWait({ key, value: "30" }), + Message.ChangedRepeatCount({ value: "3" }), + Message.ClickedAddStep(), + Message.ClickedRemoveStep({ key }), + Message.ClickedMoveStep({ key, direction: "down" }), + Message.ClickedRemoveDestination({ key, id: asDestination(chainDestinationUuid(1)) }), + ]) + expect(edited).toEqual(saving) + }) + + it("steps reorder up and down, add up to ten, and the last one cannot move further", () => { + const model = opened() + const [first, second] = model.steps + const moved = run(model, [Message.ClickedMoveStep({ key: first?.key ?? "", direction: "down" })]) + expect(Arr.map(moved.steps, (step) => step.key)).toEqual([second?.key, first?.key]) + expect( + run(moved, [Message.ClickedMoveStep({ key: first?.key ?? "", direction: "down" })]).steps, + ).toEqual(moved.steps) + const full = run( + model, + Arr.makeBy(12, () => Message.ClickedAddStep()), + ) + expect(full.steps).toHaveLength(10) + }) + + it("an edit sends a step's member and schedule targets back, and a schedule alone is enough", () => { + const chain = chainOf({ + n: 2, + name: "Checkout on-call", + steps: [ + { destinations: [1], users: ["user_ben"], wait: 10 }, + { destinations: [], schedules: [1], wait: 15 }, + ], + }) + const renamed = run(openEdit(init("chain"), chain).model, [ + Message.ChangedName({ value: "Checkout" }), + ]) + const saving = update(renamed, Message.ClickedSave()) + expect(saving.commands?.[0]?.args).toMatchObject({ + payload: { + name: "Checkout", + steps: Arr.map(chain.steps, (step) => ({ targets: step.targets })), + }, + }) + const key = renamed.steps[0]?.key ?? "" + const withoutDestination = run(renamed, [ + Message.ClickedRemoveDestination({ key, id: asDestination(chainDestinationUuid(1)) }), + ]) + expect(Option.map(payloadOf(withoutDestination), ({ steps }) => steps[0].targets)).toEqual( + Option.some([{ type: "user", user_id: "user_ben" }]), + ) + }) +}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.ts new file mode 100644 index 0000000000..317c8fe6b7 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-dialog/dialog.ts @@ -0,0 +1,383 @@ +import { + AlertDestinationId, + EscalationChainDescription, + EscalationChainId, + EscalationChainName, + EscalationRepeatCount, + EscalationWaitMinutes, + MAX_ESCALATION_REPEATS, + MAX_ESCALATION_STEPS, + MAX_ESCALATION_STEP_TARGETS, + MAX_ESCALATION_WAIT_MINUTES, +} from "@maple/domain/http" +import { + V2EscalationChain, + V2EscalationChainCreateParams, + type V2EscalationStep, + V2EscalationTarget, +} from "@maple/domain/http/v2" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import type { Resources } from "../../../platform/resources" +import { saveEscalationChain } from "../shared/escalation-chains" + +// "New escalation chain" / "Edit escalation chain": the Routing tab's dialog, as one Submodel. + +/** One step as the form holds it; `key` keeps a step's row (and its menu) across reorders. */ +export const StepDraft = Schema.Struct({ + key: Schema.String, + destinationIds: Schema.Array(AlertDestinationId), + /** Member and schedule targets: not editable here yet, so a save sends them back unchanged. */ + otherTargets: Schema.Array(V2EscalationTarget), + waitMinutes: Schema.String, +}) +export type StepDraft = typeof StepDraft.Type + +export const Model = Schema.Struct({ + dialog: Dialog.Model, + editingId: Schema.NullOr(EscalationChainId), + name: Schema.String, + description: Schema.String, + steps: Schema.Array(StepDraft), + repeatCount: Schema.String, + nextKey: Schema.Number, + /** Each step's "Add destination" menu, by step key. */ + menus: Schema.Record(Schema.String, Menu.Model), + isSaving: Schema.Boolean, + /** A save was tried: every field shows what is wrong with it. */ + isShowingErrors: Schema.Boolean, + saveError: Schema.NullOr(Schema.String), + /** Bumped on every open: a save's result only settles the form it was sent from. */ + openCount: Schema.Number, +}) +export type Model = typeof Model.Type + +export const DEFAULT_WAIT_MINUTES = "10" + +const emptyStep = (key: number): StepDraft => ({ + key: `s${key}`, + destinationIds: [], + otherTargets: [], + waitMinutes: DEFAULT_WAIT_MINUTES, +}) + +export const init = (id: string): Model => ({ + dialog: Dialog.init(id, "dialog"), + editingId: null, + name: "", + description: "", + steps: [emptyStep(0)], + repeatCount: "0", + nextKey: 1, + menus: {}, + isSaving: false, + isShowingErrors: false, + saveError: null, + openCount: 0, +}) + +export const Direction = Schema.Literals(["up", "down"]) + +export const Message = defineMessageUnion({ + GotDialogMessage: { message: Dialog.Message }, + ChangedName: { value: Schema.String }, + ChangedDescription: { value: Schema.String }, + ChangedRepeatCount: { value: Schema.String }, + ChangedWait: { key: Schema.String, value: Schema.String }, + ClickedAddStep: {}, + ClickedRemoveStep: { key: Schema.String }, + ClickedMoveStep: { key: Schema.String, direction: Direction }, + GotStepMenuMessage: { key: Schema.String, message: Menu.Message }, + ClickedRemoveDestination: { key: Schema.String, id: AlertDestinationId }, + ClickedSave: {}, + CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2EscalationChain, LoadFailure) }, +}) +export type Message = typeof Message.Type + +export const OutMessage = defineMessageUnion({ + SavedChain: { chain: V2EscalationChain, isNew: Schema.Boolean }, +}) +export type OutMessage = typeof OutMessage.Type + +type Return = Update.ReturnWithOutMessage + +export const menuIdOf = (model: Pick, key: string): string => + `${model.dialog.id}-step-${key}` + +// VALIDATION: the same limits the API's schemas hold. + +const isName = Schema.is(EscalationChainName) +const isDescription = Schema.is(EscalationChainDescription) +const isWait = Schema.is(EscalationWaitMinutes) +const isRepeat = Schema.is(EscalationRepeatCount) + +export const numberOf = (raw: string): number | null => + Str.isEmpty(Str.trim(raw)) ? null : Number(Str.trim(raw)) + +export const nameError = (name: string): Option.Option => + Str.isEmpty(Str.trim(name)) + ? Option.some("Name the chain") + : isName(Str.trim(name)) + ? Option.none() + : Option.some("Keep the name to 120 characters") + +export const descriptionError = (description: string): Option.Option => + isDescription(Str.trim(description)) + ? Option.none() + : Option.some("Keep the description to 500 characters") + +export const waitError = (raw: string): Option.Option => + isWait(numberOf(raw)) ? Option.none() : Option.some(`Wait 1 to ${MAX_ESCALATION_WAIT_MINUTES} minutes`) + +export const repeatError = (raw: string): Option.Option => + isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) + +/** A step needs one target; its carried member and schedule targets count toward both limits. */ +export const targetsError = ( + step: Pick, +): Option.Option => { + const total = step.destinationIds.length + step.otherTargets.length + return total === 0 + ? Option.some("Pick at least one destination") + : total <= MAX_ESCALATION_STEP_TARGETS + ? Option.none() + : Option.some( + step.otherTargets.length === 0 + ? `A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} destinations` + : `A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets, members and schedules included`, + ) +} + +export const stepsError = (steps: ReadonlyArray): Option.Option => + steps.length === 0 + ? Option.some("Add at least one step") + : steps.length > MAX_ESCALATION_STEPS + ? Option.some(`A chain has at most ${MAX_ESCALATION_STEPS} steps`) + : Option.none() + +const stepOf = (step: StepDraft): Option.Option => { + const targets: ReadonlyArray = [ + ...Arr.map(step.destinationIds, (id) => ({ type: "destination" as const, destination_id: id })), + ...step.otherTargets, + ] + const minutes = numberOf(step.waitMinutes) + return Arr.isReadonlyArrayNonEmpty(targets) && + Option.isNone(targetsError(step)) && + minutes !== null && + isWait(minutes) + ? Option.some({ targets, wait_minutes: minutes }) + : Option.none() +} + +/** The create payload the form describes, or none while any field is wrong. */ +export const payloadOf = (model: Model): Option.Option => { + const steps = Option.all(Arr.map(model.steps, stepOf)) + const repeat = numberOf(model.repeatCount) + if ( + Option.isSome(nameError(model.name)) || + Option.isSome(descriptionError(model.description)) || + Option.isSome(stepsError(model.steps)) || + repeat === null || + !isRepeat(repeat) + ) + return Option.none() + return Option.flatMap(steps, (all) => + Arr.isReadonlyArrayNonEmpty(all) + ? Option.some({ + name: Str.trim(model.name), + description: Str.isEmpty(Str.trim(model.description)) + ? null + : Str.trim(model.description), + steps: all, + repeat_count: repeat, + }) + : Option.none(), + ) +} + +// COMMANDS + +export const SaveEscalationChain = Command.define("SaveEscalationChain", { + args: { + openCount: Schema.Number, + editingId: Schema.NullOr(EscalationChainId), + payload: V2EscalationChainCreateParams, + }, + messages: [Message.CompletedSave], + execute: ({ openCount, editingId, payload }) => + saveEscalationChain(editingId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSave({ openCount, result })), + ), +}) + +// UPDATE + +const liftDialog = (model: Model, result: ReturnType): Return => ({ + model: { ...model, dialog: result.model }, + commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotDialogMessage({ message })), +}) + +const open = ( + model: Model, + fields: Pick, +): Return => + liftDialog( + { + ...model, + ...fields, + menus: {}, + isSaving: false, + isShowingErrors: false, + saveError: null, + openCount: model.openCount + 1, + }, + Dialog.open(model.dialog), + ) + +export const openNew = (model: Model): Return => + open(model, { + editingId: null, + name: "", + description: "", + steps: [emptyStep(0)], + repeatCount: "0", + nextKey: 1, + }) + +export const openEdit = (model: Model, chain: V2EscalationChain): Return => + open(model, { + editingId: chain.id, + name: chain.name, + description: chain.description ?? "", + steps: Arr.map(chain.steps, (step, index) => ({ + key: `s${index}`, + destinationIds: Arr.flatMap(step.targets, (target) => + target.type === "destination" ? [target.destination_id] : [], + ), + otherTargets: Arr.filter(step.targets, (target) => target.type !== "destination"), + waitMinutes: String(step.wait_minutes), + })), + repeatCount: String(chain.repeat_count), + nextKey: chain.steps.length, + }) + +const editStep = (model: Model, key: string, edit: (step: StepDraft) => StepDraft): Return => ({ + model: { ...model, steps: Arr.map(model.steps, (step) => (step.key === key ? edit(step) : step)) }, +}) + +const moveStep = (steps: ReadonlyArray, key: string, direction: "up" | "down") => { + const from = Arr.findFirstIndex(steps, (step) => step.key === key) + if (Option.isNone(from)) return steps + const to = direction === "up" ? from.value - 1 : from.value + 1 + const moving = steps[from.value] + const other = steps[to] + if (moving === undefined || other === undefined) return steps + return Arr.map(steps, (step, index) => (index === from.value ? other : index === to ? moving : step)) +} + +const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) + +const stepMenu = (model: Model, key: string, message: Menu.Message): Return => { + const id = menuIdOf(model, key) + const result = Menu.update(model.menus[key] ?? Menu.init(id), message) + const next: Model = { ...model, menus: { ...model.menus, [key]: result.model } } + const commands = Command.mapMessages(result.commands ?? [], (child) => + Message.GotStepMenuMessage({ key, message: child }), + ) + const picked = Option.flatMap( + result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), + decodeDestinationId, + ) + // The menu still opens and closes while saving, but a pick no longer edits the step. + return Option.match(model.isSaving ? Option.none() : picked, { + onNone: () => ({ model: next, commands }), + onSome: (destinationId) => ({ + ...editStep(next, key, (step) => + step.destinationIds.includes(destinationId) + ? step + : { ...step, destinationIds: [...step.destinationIds, destinationId] }, + ), + commands, + }), + }) +} + +const save = (model: Model): Return => + model.isSaving + ? { model } + : Option.match(payloadOf(model), { + onNone: () => ({ model: { ...model, isShowingErrors: true } }), + onSome: (payload) => ({ + model: { ...model, isSaving: true, isShowingErrors: true, saveError: null }, + commands: [ + SaveEscalationChain({ + openCount: model.openCount, + editingId: model.editingId, + payload, + }), + ], + }), + }) + +/** A form edit is dropped while a save is in flight: the saved chain is what the dialog shows. */ +const unlessSaving = (model: Model, edit: () => Return): Return => (model.isSaving ? { model } : edit()) + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotDialogMessage: ({ message: child }) => + // The dialog stays open while a save is in flight. + model.isSaving ? { model } : liftDialog(model, Dialog.update(model.dialog, child)), + ChangedName: ({ value }) => unlessSaving(model, () => ({ model: { ...model, name: value } })), + ChangedDescription: ({ value }) => + unlessSaving(model, () => ({ model: { ...model, description: value } })), + ChangedRepeatCount: ({ value }) => + unlessSaving(model, () => ({ model: { ...model, repeatCount: value } })), + ChangedWait: ({ key, value }) => + unlessSaving(model, () => editStep(model, key, (step) => ({ ...step, waitMinutes: value }))), + ClickedAddStep: () => + unlessSaving(model, () => + model.steps.length >= MAX_ESCALATION_STEPS + ? { model } + : { + model: { + ...model, + steps: [...model.steps, emptyStep(model.nextKey)], + nextKey: model.nextKey + 1, + }, + }, + ), + ClickedRemoveStep: ({ key }) => + unlessSaving(model, () => ({ + model: { ...model, steps: Arr.filter(model.steps, (step) => step.key !== key) }, + })), + ClickedMoveStep: ({ key, direction }) => + unlessSaving(model, () => ({ + model: { ...model, steps: moveStep(model.steps, key, direction) }, + })), + GotStepMenuMessage: ({ key, message: child }) => stepMenu(model, key, child), + ClickedRemoveDestination: ({ key, id }) => + unlessSaving(model, () => + editStep(model, key, (step) => ({ + ...step, + destinationIds: Arr.filter(step.destinationIds, (existing) => existing !== id), + })), + ), + ClickedSave: () => save(model), + CompletedSave: ({ openCount, result }) => + openCount !== model.openCount + ? { model } + : Result.match(result, { + onSuccess: (chain) => ({ + ...liftDialog({ ...model, isSaving: false }, Dialog.close(model.dialog)), + outMessage: OutMessage.SavedChain({ chain, isNew: model.editingId === null }), + }), + onFailure: (failure) => ({ + model: { ...model, isSaving: false, saveError: failure.message }, + }), + }), + }) diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts new file mode 100644 index 0000000000..eb624f216e --- /dev/null +++ b/apps/web/src/page/alerts/escalation-dialog/view.ts @@ -0,0 +1,362 @@ +import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_STEPS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" +import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" +import { Option, Array as Arr } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { button } from "@maple/views/ui/button" +import { + fieldDescription, + fieldError, + fieldErrorText, + type FieldIds, + fieldLabel, + input, +} from "@maple/views/ui/field" +import { formDialog } from "@maple/views/ui/form-dialog" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import type { FieldState } from "@maple/views/ui/input" +import * as Menu from "@maple/views/ui/menu" +import { panel } from "@maple/views/ui/panel" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textarea } from "@maple/views/ui/textarea" +import { typography } from "@maple/views/ui/typography" +import { destinationPicker } from "../../kit/destination-picker" +import { formatWait } from "../shared/escalation-chains" +import { + descriptionError, + menuIdOf, + Message, + type Model, + nameError, + numberOf, + repeatError, + type StepDraft, + stepsError, + targetsError, + waitError, +} from "./dialog" + +type H = HtmlBuilder + +export type ViewInputs = Readonly<{ + id: string + /** The synced destinations steps pick from; `null` until they arrive. */ + destinations: ReadonlyArray | null +}> + +const stateOf = (value: string, isInvalid: boolean): FieldState => ({ + isInvalid, + isDirty: false, + isFilled: value !== "", + isFocused: false, + isTouched: false, +}) + +const idsOf = (id: string, name: string, hasDescription = false): FieldIds => ({ + control: `${id}-${name}`, + label: `${id}-${name}-label`, + error: `${id}-${name}-error`, + ...(hasDescription ? { description: `${id}-${name}-description` } : undefined), +}) + +/** A field's error once a save was tried, or at once for a number typed out of range. */ +const shownError = (model: Model, error: Option.Option, isImmediate = false): Option.Option => + model.isShowingErrors || isImmediate ? error : Option.none() + +const errorLine = ( + h: H, + ids: FieldIds, + state: FieldState, + error: Option.Option, +): ReadonlyArray => + Option.match(error, { + onNone: () => [], + onSome: (text) => [fieldError(h, { ids, state, attributes: [h.Role("alert")] }, [text])], + }) + +const textField = ( + h: H, + options: Readonly<{ + ids: FieldIds + label: string + value: string + placeholder: string + error: Option.Option + isDisabled: boolean + onInput: (value: string) => Message + }>, +): Html => { + const state = stateOf(options.value, Option.isSome(options.error)) + return vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids: options.ids, state }, options.label), + input(h, { + ids: options.ids, + state, + isDisabled: options.isDisabled, + attributes: [ + h.Value(options.value), + h.Placeholder(options.placeholder), + h.OnInput(options.onInput), + h.Attribute("autocomplete", "off"), + ], + }), + ...errorLine(h, options.ids, state, options.error), + ]) +} + +/** A short number input on one line with its label. */ +const numberField = ( + h: H, + options: Readonly<{ + ids: FieldIds + label: string + value: string + min: number + max: number + description?: string + error: Option.Option + isDisabled: boolean + onInput: (value: string) => Message + }>, +): Html => { + const state = stateOf(options.value, Option.isSome(options.error)) + return vstack(h, { gap: "1.5" }, [ + hstack(h, { gap: "3", wrap: true }, [ + fieldLabel(h, { ids: options.ids, state, className: "font-normal" }, options.label), + input(h, { + ids: options.ids, + state, + className: "w-20", + isDisabled: options.isDisabled, + attributes: [ + h.Type("number"), + h.Min(String(options.min)), + h.Attribute("max", String(options.max)), + h.Attribute("step", "1"), + h.Value(options.value), + h.OnInput(options.onInput), + ], + }), + ]), + ...(options.description === undefined + ? [] + : [fieldDescription(h, { ids: options.ids, state }, [options.description])]), + ...errorLine(h, options.ids, state, options.error), + ]) +} + +const isOutOfRange = (raw: string, error: Option.Option) => raw !== "" && Option.isSome(error) + +// Numbered, so each step's wait input has its own accessible name. +const waitLabel = (model: Model, index: number): string => + `Step ${index + 1}: ${ + index < model.steps.length - 1 + ? "then wait, in minutes" + : (numberOf(model.repeatCount) ?? 0) > 0 + ? "wait before repeating, in minutes" + : // The run ends as soon as the last step pages, so this wait only matters once it repeats. + "wait before repeating, in minutes (unused unless the chain repeats)" + }` + +const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}` + +/** The step's member and schedule targets, which this dialog keeps but cannot edit yet. */ +const carriedLine = (h: H, step: StepDraft): ReadonlyArray => { + const members = Arr.filter(step.otherTargets, (target) => target.type === "user").length + const schedules = Arr.filter(step.otherTargets, (target) => target.type === "schedule").length + const parts = [ + ...(members === 0 ? [] : [plural(members, "member", "members")]), + ...(schedules === 0 ? [] : [`whoever is on call on ${plural(schedules, "schedule", "schedules")}`]), + ] + return parts.length === 0 + ? [] + : [ + typography(h, { size: "xs", tone: "muted" }, [ + `Also pages ${Arr.join(parts, " and ")}. Saving keeps them.`, + ]), + ] +} + +const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, index: number): Html => { + const count = model.steps.length + const previous = index === 0 ? undefined : model.steps[index - 1] + const wait = waitError(step.waitMinutes) + const destinations = shownError(model, targetsError(step)) + const key = step.key + return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ + hstack(h, { gap: "2", justify: "between" }, [ + hstack(h, { gap: "2", shrink: true }, [ + typography(h, { size: "sm", weight: "medium", className: "shrink-0 whitespace-nowrap" }, [ + `Step ${index + 1}`, + ]), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + previous === undefined + ? "pages at once" + : Option.isNone(waitError(previous.waitMinutes)) + ? `after ${formatWait(Number(previous.waitMinutes))} without an ack` + : "after the wait above", + ]), + ]), + hstack(h, { gap: "0.5" }, [ + iconButton( + h, + { + label: `Move step ${index + 1} up`, + isDisabled: model.isSaving || index === 0, + attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "up" }))], + }, + [icon(h, "ArrowUpIcon")], + ), + iconButton( + h, + { + label: `Move step ${index + 1} down`, + isDisabled: model.isSaving || index === count - 1, + attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "down" }))], + }, + [icon(h, "ArrowDownIcon")], + ), + iconButton( + h, + { + label: `Remove step ${index + 1}`, + isDisabled: model.isSaving || count === 1, + attributes: [h.OnClick(Message.ClickedRemoveStep({ key }))], + }, + [icon(h, "TrashIcon")], + ), + ]), + ]), + destinationPicker(h, { + rows: inputs.destinations, + selectedIds: step.destinationIds, + menu: model.menus[key] ?? Menu.init(menuIdOf(model, key)), + toMenuMessage: (message) => Message.GotStepMenuMessage({ key, message }), + onRemove: (id) => Message.ClickedRemoveDestination({ key, id }), + isDisabled: model.isSaving, + context: `step ${index + 1}`, + emptyText: + inputs.destinations !== null && inputs.destinations.length === 0 + ? "No destinations exist yet. Add one in Routing first." + : "Pick who this step pages", + }), + ...carriedLine(h, step), + ...Option.match(destinations, { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], + }), + numberField(h, { + ids: idsOf(model.dialog.id, `wait-${key}`), + label: waitLabel(model, index), + value: step.waitMinutes, + min: 1, + max: MAX_ESCALATION_WAIT_MINUTES, + error: shownError(model, wait, isOutOfRange(step.waitMinutes, wait)), + isDisabled: model.isSaving, + onInput: (value) => Message.ChangedWait({ key, value }), + }), + ]) +} + +const stepsSection = (h: H, model: Model, inputs: ViewInputs): Html => + vstack(h, { gap: "3" }, [ + hstack(h, { gap: "2", justify: "between" }, [ + typography(h, { size: "sm", weight: "medium" }, ["Steps"]), + typography(h, { size: "xs", tone: "muted", numeric: true }, [ + `${model.steps.length} of ${MAX_ESCALATION_STEPS}`, + ]), + ]), + ...Arr.map(model.steps, (step, index) => stepBlock(h, model, inputs, step, index)), + ...Option.match(shownError(model, stepsError(model.steps)), { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], + }), + button( + h, + { + variant: "outline", + size: "sm", + className: "self-start", + isDisabled: model.isSaving || model.steps.length >= MAX_ESCALATION_STEPS, + attributes: [h.OnClick(Message.ClickedAddStep())], + }, + [icon(h, "PlusIcon"), "Add step"], + ), + ]) + +const descriptionField = (h: H, model: Model): Html => { + const ids = idsOf(model.dialog.id, "description") + const error = shownError(model, descriptionError(model.description)) + const state = stateOf(model.description, Option.isSome(error)) + return vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids, state }, "Description"), + textarea(h, { + id: ids.control, + defaultValue: model.description, + isDisabled: model.isSaving, + attributes: [ + // Uncontrolled: keyed on the open, so a reopened dialog rebuilds it with that chain's text. + h.Key(`${ids.control}-${model.openCount}`), + h.AriaLabelledBy(ids.label), + h.Placeholder("Optional: who is on this chain and when to change it"), + h.Rows(2), + h.OnInput((value) => Message.ChangedDescription({ value })), + ], + }), + ...errorLine(h, ids, state, error), + ]) +} + +/** The escalation chain dialog: name, ordered steps of destinations and waits, and repeats. */ +export const view = Submodel.defineView((model, inputs, h) => { + const repeat = repeatError(model.repeatCount) + const isEditing = model.editingId !== null + return formDialog(h, { + slotId: `${inputs.id}-dialog`, + model: model.dialog, + toParentMessage: (message) => Message.GotDialogMessage({ message }), + className: "sm:max-w-xl", + title: isEditing ? "Edit escalation chain" : "New escalation chain", + description: + "Each step pages its destinations, then waits for someone to acknowledge before paging the next. Acknowledging pauses the chain and resolving ends it.", + panelClassName: "space-y-5", + fields: [ + textField(h, { + ids: idsOf(model.dialog.id, "name"), + label: "Name", + value: model.name, + placeholder: "Checkout on-call", + error: shownError(model, nameError(model.name)), + isDisabled: model.isSaving, + onInput: (value) => Message.ChangedName({ value }), + }), + descriptionField(h, model), + stepsSection(h, model, inputs), + numberField(h, { + ids: idsOf(model.dialog.id, "repeat", true), + label: "Repeat the whole chain", + value: model.repeatCount, + min: 0, + max: MAX_ESCALATION_REPEATS, + description: `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, + error: shownError(model, repeat, isOutOfRange(model.repeatCount, repeat)), + isDisabled: model.isSaving, + onInput: (value) => Message.ChangedRepeatCount({ value }), + }), + ...(model.saveError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [isEditing ? "Couldn't save chain" : "Couldn't create chain"]), + alertDescription(h, [model.saveError]), + ]), + ]), + ], + onSubmit: Message.ClickedSave(), + submitLabel: isEditing ? "Save chain" : "Create chain", + isPending: model.isSaving, + }) +}) diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index cd919b185c..df43135cdd 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -1,5 +1,11 @@ import { UptimeMonitorId } from "@maple/domain" -import { AlertDestinationId, AlertIncidentId, AlertRuleId, AlertSilenceId } from "@maple/domain/http" +import { + AlertDestinationId, + AlertIncidentId, + AlertRuleId, + AlertSilenceId, + EscalationChainId, +} from "@maple/domain/http" import { V2AlertDestinationUpdateParams } from "@maple/domain/http/v2" import { formatWarehouseDateTime, snapRangeForCache } from "@maple/query-engine" import { getServiceOverview } from "@maple/web-data/warehouse/services" @@ -18,6 +24,7 @@ import { updateDestination, setIncidentAcknowledged, } from "../shared/commands" +import { deleteEscalationChain, listEscalationChains } from "../shared/escalation-chains" import { listSloRows } from "../shared/slo-commands" import { deleteMonitor, listMonitors, setMonitorEnabled } from "../shared/uptime-commands" import { endSilence, listSilences } from "../shared/silences" @@ -180,6 +187,27 @@ export const FetchHubSlos = Command.define("FetchAlertsHubSlos", { ), }) +/** The Routing tab's escalation chains. */ +export const FetchHubChains = Command.define("FetchAlertsHubChains", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchChains], + execute: ({ key }) => + listEscalationChains.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchChains({ key, result })), + ), +}) + +export const DeleteChain = Command.define("DeleteEscalationChain", { + args: { chainId: EscalationChainId }, + messages: [Message.CompletedDeleteChain], + execute: ({ chainId }) => + deleteEscalationChain(chainId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDeleteChain({ chainId, result })), + ), +}) + export const FetchHubSilences = Command.define("FetchAlertsHubSilences", { args: { key: Schema.String }, messages: [Message.CompletedFetchSilences], diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts new file mode 100644 index 0000000000..0ef298a0bb --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -0,0 +1,201 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { Result, Schema, Array as Arr } from "effect" +import * as Animation from "@foldkit/ui/animation" +import { EscalationChainId } from "@maple/domain/http" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import { MountDialog } from "@maple/views/ui/dialog" +import { + click, + Command, + expect as expectView, + Mount, + given, + role, + scene, + selector, + submit, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { V2EscalationChain } from "@maple/domain/http/v2" +import type { PageContext } from "../../contract" +import { CHAIN_IN_USE_CODE } from "../shared/escalation-chains" +import { chainDestinations, chainUuid, populatedChains } from "../shared/escalation-test-fixtures" +import { NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { chainMenuId, 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 ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) +const RULES = [ruleRow(1, { name: "Checkout errors" }), ruleRow(2, { name: "Checkout p95" }), ruleRow(3)] +const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) + +const onRouting = (grants: ReturnType, chains: ReadonlyArray) => { + const entered = init({ _tag: "Alerts", search: { tab: "routing" } }, context) + expect(names(entered.commands)).toContain("FetchAlertsHubChains") + return Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + Message.UpdatedRules({ orgId: ORG, rows: RULES }), + Message.CompletedFetchChains({ + key: entered.model.chains.key ?? "", + result: Result.succeed(chains), + }), + ], + entered.model, + (model, message) => pageUpdate(model, message).model, + ) +} + +const openDialog = () => [ + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation: 1 })), + Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation: 1 })), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), +] + +describe("alerts hub, escalation chains", () => { + test("an org without chains learns what one is and can create the first", () => { + scene( + { update: pageUpdate, view }, + given(onRouting(ENGINEER, [])), + expectView(text("No escalation chains")).toExist(), + expectView(text(/waits for someone to acknowledge, then pages the next/u)).toExist(), + expectView(role("button", { name: "Create chain" })).toExist(), + ) + }) + + test("each chain reads as its steps in one line and the rules that start it", () => { + scene( + { update: pageUpdate, view }, + given(onRouting(ENGINEER, CHAINS)), + expectView(text("Checkout on-call")).toExist(), + expectView( + text( + "Slack #alerts-api, then PagerDuty Checkout after 10m, then Email on-call after 15m; repeats twice", + ), + ).toExist(), + expectView(text("Used by 2 rules")).toExist(), + expectView(text("Not used by any rule")).toExist(), + expectView(role("button", { name: "Actions for Checkout on-call" })).toExist(), + expectView(role("button", { name: "New chain" })).toExist(), + ) + }) + + test("a chain counts the rules that start it before the rules list has loaded", () => { + scene( + { update: pageUpdate, view }, + given({ ...onRouting(ENGINEER, CHAINS), rules: null }), + expectView(text("Used by 2 rules")).toExist(), + ) + }) + + test("a member sees the chains but cannot create, edit or delete one", () => { + scene( + { update: pageUpdate, view }, + given(onRouting(MEMBER, [])), + expectView(role("button", { name: "Create chain" })).not.toExist(), + expectView(text(/Ask an org admin to create one/u)).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(onRouting(MEMBER, CHAINS)), + expectView(role("button", { name: "Actions for Checkout on-call" })).not.toExist(), + ) + }) + + test("saving a new chain without a name or destinations shows what is missing and sends nothing", () => { + scene( + { update: pageUpdate, view }, + given(onRouting(ENGINEER, CHAINS)), + click(role("button", { name: "New chain" })), + ...openDialog(), + expectView(role("dialog", { name: "New escalation chain" })).toExist(), + submit(selector("form")), + Command.expectNone(), + expectView(text("Name the chain")).toExist(), + expectView(text("Pick at least one destination")).toExist(), + ) + }) + + test("a refused delete stays open and names the rules that still start the chain", () => { + const model = onRouting(ENGINEER, CHAINS) + const chainId = Schema.decodeSync(EscalationChainId)(chainUuid(1)) + const asked = pageUpdate( + pageUpdate( + model, + Message.GotRowMenuMessage({ + menuId: chainMenuId(chainId), + message: Menu.Message.ClickedItem({ index: 2, item: { kind: "Item", value: "delete" } }), + }), + ).model, + Message.ClickedConfirmDeleteChain(), + ) + expect(names(asked.commands)).toEqual(["DeleteEscalationChain"]) + const refused = pageUpdate( + asked.model, + Message.CompletedDeleteChain({ + chainId, + result: Result.fail({ + _tag: "@maple/http/errors/EscalationChainInUseError", + type: "conflict_error", + code: CHAIN_IN_USE_CODE, + title: "Escalation chain is in use", + message: "The escalation chain is used by one or more alert rules.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + expect(refused.model.deleteChainError).toBe( + "Still used by Checkout errors, Checkout p95. Remove it from their severity tiers first.", + ) + expect(refused.model.confirmDeleteChainId).toBe(chainId) + // The chains are read again, so a retry names a rule attached since the tab opened. + expect(names(refused.commands)).toContain("FetchAlertsHubChains") + const reread = pageUpdate( + refused.model, + Message.CompletedFetchChains({ + key: refused.model.chains.key ?? "", + // A rule attached since the tab opened now holds the chain. + result: Result.succeed(populatedChains([ruleId(3)])), + }), + ) + expect(reread.model.deleteChainError).toBe( + "Still used by Rule 3. Remove it from its severity tiers first.", + ) + + // Before the rules list has loaded the rules cannot be named, only counted. + const unloaded = pageUpdate( + { ...asked.model, rules: null }, + Message.CompletedDeleteChain({ + chainId, + result: Result.fail({ + _tag: "@maple/http/errors/EscalationChainInUseError", + type: "conflict_error", + code: CHAIN_IN_USE_CODE, + title: "Escalation chain is in use", + message: "The escalation chain is used by one or more alert rules.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + expect(unloaded.model.deleteChainError).toBe( + "Alert rules still start this chain. Remove it from their severity tiers first.", + ) + }) +}) diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 842cc5275d..ce28bfbaf9 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -123,6 +123,7 @@ describe("alerts hub", () => { acknowledged_by: null, acknowledged_via: null, error_issue_id: null, + escalation: null, silence_id: null, silenced_at: null, }) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 46de9793cc..386d58250e 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -6,8 +6,9 @@ import { AlertIncidentId, AlertRuleId, AlertSilenceId, + EscalationChainId, } from "@maple/domain/http" -import { V2AlertIncident, V2AlertSilence, V2UptimeMonitor } from "@maple/domain/http/v2" +import { V2AlertIncident, V2AlertSilence, V2EscalationChain, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -20,6 +21,7 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as ChainDialog from "../escalation-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { SloListRow, TestOutcome, UptimeKindFilter, UptimeStatusFilter } from "./model" import { ServiceNamespaces } from "./uptime-groups" @@ -95,6 +97,13 @@ export const Message = defineMessageUnion({ GotDeleteMonitorDialogMessage: { message: Dialog.Message }, CompletedFetchSlos: { key: Schema.String, result: outcome(Schema.Array(SloListRow)) }, ClickedRetrySlos: {}, + CompletedFetchChains: { key: Schema.String, result: outcome(Schema.Array(V2EscalationChain)) }, + ClickedRetryChains: {}, + ClickedNewChain: {}, + GotChainDialogMessage: { message: ChainDialog.Message }, + ClickedConfirmDeleteChain: {}, + CompletedDeleteChain: { chainId: EscalationChainId, result: outcome(Schema.Void) }, + GotDeleteChainDialogMessage: { message: Dialog.Message }, CompletedFetchSilences: { key: Schema.String, result: outcome(Schema.Array(V2AlertSilence)) }, ClickedRetrySilences: {}, ClickedNewSilence: {}, diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 1b633d7753..6372cb8ef7 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -6,8 +6,9 @@ import { AlertIncidentId, AlertRuleId, AlertSilenceId, + EscalationChainId, } from "@maple/domain/http" -import { V2AlertSilence, V2Slo, V2SloStatus, V2UptimeMonitor } from "@maple/domain/http/v2" +import { V2AlertSilence, V2EscalationChain, V2Slo, V2SloStatus, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -21,6 +22,7 @@ import { AlertsSearch } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as ChainDialog from "../escalation-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { ServiceNamespaces } from "./uptime-groups" @@ -89,6 +91,16 @@ export const Model = Schema.Struct({ deleteMonitorDialog: Dialog.Model, /** SLOs tab: `slos.list` and each SLO's status, read only while the tab is open. */ slos: KeyedSchema(Schema.Array(SloListRow), LoadFailure), + /** Routing tab: `escalationChains.list`, read only while the tab is open. */ + chains: KeyedSchema(Schema.Array(V2EscalationChain), LoadFailure), + chainDialog: ChainDialog.Model, + /** The chain awaiting delete confirmation, the delete in flight, and why the last one was refused. */ + confirmDeleteChainId: Schema.NullOr(EscalationChainId), + deletingChainId: Schema.NullOr(EscalationChainId), + deleteChainError: Schema.NullOr(Schema.String), + /** The refusal was "in use": its message is rebuilt once the re-read chains land. */ + isDeleteChainInUse: Schema.Boolean, + deleteChainDialog: Dialog.Model, /** Silences tab: `alertSilences.list`, read while the tab is open. */ silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), endingSilenceIds: Schema.Array(AlertSilenceId), diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 5f8f84999b..7febe0962a 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 type { AlertDestinationId, AlertRuleId, AlertSilenceId, EscalationChainId } from "@maple/domain/http" +import type { V2EscalationChain } from "@maple/domain/http/v2" 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" @@ -15,15 +16,19 @@ import type { AppRoute } from "../../../route" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as ChainDialog from "../escalation-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { endsAtMs, startsAtMs } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" +import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT } from "../shared/escalation-chains" import { newSloHref, SLO_LIST_LIMIT } from "../shared/slo-commands" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { + DeleteChain, AcknowledgeIncident, DeleteDestination, + FetchHubChains, DeleteMonitor, FetchHubMonitors, FetchHubSlos, @@ -51,6 +56,8 @@ type HubRoute = Extract const DELETE_DIALOG_ID = "alerts-delete-destination" const DELETE_MONITOR_DIALOG_ID = "alerts-delete-monitor" +const DELETE_CHAIN_DIALOG_ID = "alerts-delete-chain" +export const CHAIN_DIALOG_ID = "alerts-escalation-chain" export const NEW_RULE_MENU_ID = "alerts-new-rule-menu" /** Runs the delete dialog's update; `Closed` (Escape, Cancel, success) forgets the target. */ @@ -155,6 +162,21 @@ const requestSlos = (model: Model, context: PageContext, mode: Keyed.ReadMode): }) } +const chainsKey = (context: PageContext) => + requestKey(context.orgId, "escalationChains.list", { query: { limit: CHAIN_LIST_LIMIT } }) + +/** The Routing tab reads the escalation chains only while it is open. */ +const requestChains = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { + if (resolveTab(model.search.tab) !== "routing") return { model, commands: [] } + return Option.match(Keyed.requestFor(model.chains, chainsKey(context), mode), { + onNone: () => ({ model, commands: [] }), + onSome: (chains) => ({ + model: { ...model, chains }, + commands: [FetchHubChains({ key: chains.key })], + }), + }) +} + /** `reactivityKeys: ["alertDeliveryEvents"]`: both reads revalidate. */ const refreshDeliveries = (model: Model, context: PageContext) => { const overview = requestDeliveries(model, context, "Refresh") @@ -197,6 +219,13 @@ export const init = (route: HubRoute, context: PageContext): Return => { confirmDeleteMonitorId: null, deleteMonitorDialog: Dialog.init(DELETE_MONITOR_DIALOG_ID, "alert-dialog"), slos: Keyed.idle(), + chains: Keyed.idle(), + chainDialog: ChainDialog.init(CHAIN_DIALOG_ID), + confirmDeleteChainId: null, + deletingChainId: null, + deleteChainError: null, + isDeleteChainInUse: false, + deleteChainDialog: Dialog.init(DELETE_CHAIN_DIALOG_ID, "alert-dialog"), silences: Keyed.idle(), endingSilenceIds: [], silenceDialog: SilenceDialog.init("alerts-silence"), @@ -206,8 +235,9 @@ export const init = (route: HubRoute, context: PageContext): Return => { const monitors = requestMonitors(routing.model, context, "Load") const silences = requestSilences(monitors.model, context, "Load") const slos = requestSlos(silences.model, context, "Load") + const chains = requestChains(slos.model, context, "Load") return { - model: slos.model, + model: chains.model, commands: [ ReadHubClock(), FetchHubSession({ orgId: context.orgId }), @@ -216,6 +246,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { ...monitors.commands, ...silences.commands, ...slos.commands, + ...chains.commands, ], } } @@ -225,13 +256,15 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext const monitors = requestMonitors(routing.model, context, "Load") const silences = requestSilences(monitors.model, context, "Load") const slos = requestSlos(silences.model, context, "Load") + const chains = requestChains(slos.model, context, "Load") return { - model: slos.model, + model: chains.model, commands: [ ...routing.commands, ...monitors.commands, ...silences.commands, ...slos.commands, + ...chains.commands, ReadHubClock(), ], } @@ -313,6 +346,152 @@ const liftDestinationDialog = (model: Model, step: ReturnType => + Option.getOrElse(AsyncData.getData(model.chains.data), () => []) + +/** A write changed the chains: read them again under a new generation. */ +const refreshChains = (model: Model, context: PageContext): Return => { + const step = requestChains(model, context, "Supersede") + return { model: step.model, commands: step.commands } +} + +/** The chain dialog's outcome: a toast, and the list read again. */ +const liftChainDialog = ( + model: Model, + context: PageContext, + step: ReturnType, +): Return => { + const lifted: Model = { ...model, chainDialog: step.model } + const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotChainDialogMessage({ message }), + ) + if (step.outMessage === undefined) return { model: lifted, commands } + const refreshed = refreshChains(lifted, context) + return { + model: refreshed.model, + commands: [...commands, ...(refreshed.commands ?? [])], + outMessage: toast( + "success", + step.outMessage.isNew ? "Escalation chain created" : "Escalation chain saved", + ), + } +} + +/** The names of the rules that start `chain` that this tab has loaded; the rest are counted, not named. */ +export const chainRuleNames = ( + model: Pick, + chain: V2EscalationChain, +): ReadonlyArray => + Array.filterMap(chain.rule_ids, (id) => + Option.match( + Array.findFirst(model.rules ?? [], (row) => row.id === id), + { onNone: () => Result.failVoid, onSome: (row) => Result.succeed(row.name) }, + ), + ) + +/** Why a delete was refused: the API's 409 names no rules, so they come from the chain's own list. */ +// The cached chain can name no rules when one was attached after this tab read the list. +const inUseMessage = (model: Model, chainId: EscalationChainId): string => { + const chain = Array.findFirst(chainsOf(model), (candidate) => candidate.id === chainId) + const names = Option.match(chain, { onNone: () => [], onSome: (c) => chainRuleNames(model, c) }) + const total = Math.max( + Option.match(chain, { onNone: () => 0, onSome: (c) => c.rule_ids.length }), + names.length, + ) + const others = total - names.length + const tiers = total > 1 ? "their severity tiers" : "its severity tiers" + // No count when the cached chain names none: one rule is the usual blocker, not several. + if (names.length === 0) { + return total > 1 + ? `Alert rules still start this chain. Remove it from ${tiers} first.` + : "An alert rule still starts this chain. Remove it from its severity tiers first." + } + const listed = others === 0 ? Array.join(names, ", ") : `${Array.join(names, ", ")} and ${others} more` + return `Still used by ${listed}. Remove it from ${tiers} first.` +} + +/** Runs the chain delete dialog's update; `Closed` forgets the target and its error. */ +const updateDeleteChainDialog = (model: Model, step: ReturnType): Return => { + const isClosed = step.outMessage?._tag === "Closed" + return { + model: { + ...model, + deleteChainDialog: step.model, + confirmDeleteChainId: isClosed ? null : model.confirmDeleteChainId, + deleteChainError: isClosed ? null : model.deleteChainError, + isDeleteChainInUse: isClosed ? false : model.isDeleteChainInUse, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteChainDialogMessage({ message }), + ), + } +} + +const pickedChainAction = (model: Model, context: PageContext, rawId: string, value: string): Return => + Option.match( + Array.findFirst(chainsOf(model), (chain) => chain.id === rawId), + { + onNone: () => ({ model }), + onSome: (chain): Return => + value === "delete" + ? updateDeleteChainDialog( + { + ...model, + confirmDeleteChainId: chain.id, + deleteChainError: null, + isDeleteChainInUse: false, + }, + Dialog.open(model.deleteChainDialog), + ) + : liftChainDialog(model, context, ChainDialog.openEdit(model.chainDialog, chain)), + }, + ) + +const confirmDeleteChain = (model: Model): Return => { + const id = model.confirmDeleteChainId + return id === null || model.deletingChainId !== null + ? { model } + : { + model: { ...model, deletingChainId: id, deleteChainError: null, isDeleteChainInUse: false }, + commands: [DeleteChain({ chainId: id })], + } +} + +const deletedChain = ( + model: Model, + context: PageContext, + chainId: EscalationChainId, + result: Result.Result, +): Return => { + const settled = { ...model, deletingChainId: null } + return Result.match(result, { + onSuccess: (): Return => { + const closed = updateDeleteChainDialog(settled, Dialog.close(settled.deleteChainDialog)) + const refreshed = refreshChains(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast("success", "Escalation chain deleted"), + } + }, + // The dialog stays open on a refusal, saying which rules hold the chain. The chains are read + // again, so a retry names a rule attached since the tab opened. + onFailure: (failure): Return => + failure.code === CHAIN_IN_USE_CODE + ? refreshChains( + { + ...settled, + deleteChainError: inUseMessage(settled, chainId), + isDeleteChainInUse: true, + }, + context, + ) + : { model: { ...settled, deleteChainError: failure.message } }, + }) +} + // SILENCES const liftSilenceDialog = ( @@ -359,6 +538,7 @@ const endSilence = (model: Model, silenceId: AlertSilenceId): Return => export const ruleMenuId = (ruleId: AlertRuleId, groupKey: string) => `rule:${ruleId}@${groupKey}` export const destinationMenuId = (destinationId: AlertDestinationId) => `destination:${destinationId}` export const monitorMenuId = (monitorId: UptimeMonitorId) => `monitor:${monitorId}` +export const chainMenuId = (chainId: EscalationChainId) => `chain:${chainId}` const monitorsOf = (model: Model) => Option.getOrElse(AsyncData.getData(model.monitors.data), () => []) @@ -446,10 +626,11 @@ const updateNewRuleMenu = (model: Model, message: Menu.Message): Return => { } } -const pickedFromMenu = (model: Model, menuId: string, value: string): Return => { +const pickedFromMenu = (model: Model, context: PageContext, menuId: string, value: string): Return => { const [kind, ...rest] = Str.split(":")(menuId) const target = Array.join(rest, ":") if (kind === "monitor") return pickedMonitorAction(model, target, value) + if (kind === "chain") return pickedChainAction(model, context, target, value) if (kind === "rule") { const id = Str.split("@")(target)[0] const rule = Option.map( @@ -491,7 +672,7 @@ const pickedFromMenu = (model: Model, menuId: string, value: string): Return => ) } -const updateMenu = (model: Model, menuId: string, message: Menu.Message): Return => { +const updateMenu = (model: Model, context: PageContext, menuId: string, message: Menu.Message): Return => { const result = Menu.update(model.menus[menuId] ?? Menu.init(menuId), message) const next: Model = { ...model, menus: { ...model.menus, [menuId]: result.model } } const commands = FoldkitCommand.mapMessages(result.commands ?? [], (child) => @@ -503,7 +684,7 @@ const updateMenu = (model: Model, menuId: string, message: Menu.Message): Return return Option.match(picked, { onNone: () => ({ model: next, commands }), onSome: (value) => { - const after = pickedFromMenu(next, menuId, value) + const after = pickedFromMenu(next, context, menuId, value) return { ...after, commands: [...commands, ...(after.commands ?? [])] } }, }) @@ -665,7 +846,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re liftDestinationDialog(model, DestinationDialog.openNew(model.destinationDialog)), GotDestinationDialogMessage: ({ message: child }) => liftDestinationDialog(model, DestinationDialog.update(model.destinationDialog, child)), - GotRowMenuMessage: ({ menuId, message: child }) => updateMenu(model, menuId, child), + GotRowMenuMessage: ({ menuId, message: child }) => updateMenu(model, context, menuId, child), GotNewRuleMenuMessage: ({ message: child }) => updateNewRuleMenu(model, child), CompletedFetchMonitors: ({ key, result }) => ({ model: { ...model, monitors: Keyed.accept(model.monitors, key, result) }, @@ -683,6 +864,27 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchSlos: ({ key, result }) => ({ model: { ...model, slos: Keyed.accept(model.slos, key, result) }, }), + CompletedFetchChains: ({ key, result }) => { + const next = { ...model, chains: Keyed.accept(model.chains, key, result) } + const refusedId = next.confirmDeleteChainId + // A refusal's message is rebuilt from the chains read after it, naming who holds it now. + return { + model: + next.isDeleteChainInUse && refusedId !== null + ? { ...next, deleteChainError: inUseMessage(next, refusedId) } + : next, + } + }, + ClickedRetryChains: () => refreshChains(model, context), + ClickedNewChain: () => liftChainDialog(model, context, ChainDialog.openNew(model.chainDialog)), + GotChainDialogMessage: ({ message: child }) => + liftChainDialog(model, context, ChainDialog.update(model.chainDialog, child)), + ClickedConfirmDeleteChain: () => confirmDeleteChain(model), + CompletedDeleteChain: ({ chainId, result }) => deletedChain(model, context, chainId, result), + GotDeleteChainDialogMessage: ({ message: child }) => + model.deletingChainId !== null + ? { model } + : updateDeleteChainDialog(model, Dialog.update(model.deleteChainDialog, child)), ClickedRetrySlos: () => { const step = requestSlos(model, context, "Supersede") return { model: step.model, commands: step.commands } diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts new file mode 100644 index 0000000000..781a28bbda --- /dev/null +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -0,0 +1,246 @@ +import type { AlertDestinationId } from "@maple/domain/http" +import type { V2EscalationChain } from "@maple/domain/http/v2" +import { pluralize } from "@maple/ui/lib/format" +import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" +import { Array as Arr, Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { borderedBox } from "@maple/views/ui/bordered-box" +import { button } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" +import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" +import { emptyActions } from "@maple/views/ui/signal-empty-state" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { tooltipTrigger } from "@maple/views/ui/tooltip-host" +import { typography } from "@maple/views/ui/typography" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { sectionHeading } from "../../kit/section-heading" +import { chainSummary } from "../shared/escalation-chains" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { chainMenuId, chainRuleNames } from "./update" + +// The Routing tab's "Escalation chains" section and its delete confirmation. + +type H = HtmlBuilder + +const nameOfDestination = + (rows: ReadonlyArray) => + (id: AlertDestinationId): string => + Option.getOrElse( + Option.map( + Arr.findFirst(rows, (row) => row.id === id), + (row) => row.name, + ), + () => "an unavailable destination", + ) + +/** `ChainRow`: name, the steps in one line, and how many rules start it. */ +export const chainRow = ( + h: H, + model: Model, + chain: V2EscalationChain, + destinations: ReadonlyArray, +): Html => { + const summary = chainSummary(chain, nameOfDestination(destinations)) + const ruleCount = chain.rule_ids.length + const ruleNames = chainRuleNames(model, chain) + const menuId = chainMenuId(chain.id) + const entries: ReadonlyArray = [ + { _tag: "Item", value: "edit", label: "Edit", icon: "PencilIcon" }, + { _tag: "Separator" }, + { + _tag: "Item", + value: "delete", + label: "Delete", + icon: "TrashIcon", + variant: "destructive", + isDisabled: model.deletingChainId === chain.id, + }, + ] + const usage = + ruleCount === 0 ? "Not used by any rule" : `Used by ${ruleCount} ${pluralize(ruleCount, "rule")}` + return h.li( + [h.Key(chain.id), h.Class("px-4 py-3")], + [ + hstack(h, { gap: "3", align: "start" }, [ + icon(h, "BellIcon", { className: "mt-0.5 shrink-0 text-muted-foreground" }), + vstack(h, { gap: "0.5", shrink: true, grow: true }, [ + typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [chain.name]), + typography( + h, + { + size: "xs", + tone: "muted", + as: "p", + className: "line-clamp-2 break-words", + attributes: [h.Title(summary)], + }, + [summary], + ), + ]), + hstack(h, { gap: "2", className: "shrink-0" }, [ + typography( + h, + { + size: "xs", + tone: "muted", + numeric: true, + className: "hidden whitespace-nowrap sm:inline", + attributes: + ruleNames.length === 0 + ? [] + : [...tooltipTrigger(h, Arr.join(ruleNames, ", "))], + }, + [usage], + ), + ...(canManage(model) + ? [ + rowActionsMenu(h, { + slotId: menuId, + model: model.menus[menuId] ?? Menu.init(menuId), + entries, + label: `Actions for ${chain.name}`, + toParentMessage: (message) => + Message.GotRowMenuMessage({ menuId, message }), + }), + ] + : []), + ]), + ]), + ], + ) +} + +const chainsEmpty = (h: H, model: Model): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "BellIcon", { size: 18 })]), + emptyTitle(h, ["No escalation chains"]), + emptyDescription(h, [ + "A chain pages one set of destinations, waits for someone to acknowledge, then pages the next. A rule's severity tier can start one on top of its own destinations.", + ...(canManage(model) ? [] : [" Ask an org admin to create one."]), + ]), + ]), + ...(canManage(model) + ? [ + emptyActions(h, [ + button(h, { size: "sm", attributes: [h.OnClick(Message.ClickedNewChain())] }, [ + icon(h, "PlusIcon"), + "Create chain", + ]), + ]), + ] + : []), + ], + "py-12", + ) + +/** The chains list, or its empty, loading or failed state. */ +export const chainsList = ( + h: H, + model: Model, + chains: ReadonlyArray, + destinations: ReadonlyArray, +): Html => + chains.length === 0 + ? chainsEmpty(h, model) + : borderedBox(h, { overflow: "hidden" }, [ + h.ul( + [h.Class("divide-y")], + Arr.map(chains, (chain) => chainRow(h, model, chain, destinations)), + ), + ]) + +const chainsBody = (h: H, model: Model): Html => { + const destinations = model.destinations ?? [] + const loading = h.div([h.Class("space-y-3")], [skeleton(h, "h-16 w-full"), skeleton(h, "h-16 w-full")]) + return AsyncData.match(model.chains.data, { + onIdle: () => loading, + onLoading: () => loading, + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't load escalation chains", + onRetry: Message.ClickedRetryChains(), + }), + onRefreshing: (chains) => chainsList(h, model, chains, destinations), + onStale: ({ data }) => chainsList(h, model, data, destinations), + onSuccess: (chains) => chainsList(h, model, chains, destinations), + }) +} + +const hasChains = (model: Model): boolean => + Option.exists(AsyncData.getData(model.chains.data), (chains) => chains.length > 0) + +/** "Escalation chains": the heading, its New chain button once one exists, and the list. */ +export const chainsSection = (h: H, model: Model): Html => + h.section( + [h.Class("space-y-4")], + [ + h.div( + [], + [ + sectionHeading(h, { + title: "Escalation chains", + actions: + canManage(model) && hasChains(model) + ? button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedNewChain())], + }, + [icon(h, "PlusIcon"), "New chain"], + ) + : undefined, + }), + typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "Page step by step until someone acknowledges. Acknowledging pauses a chain and resolving ends it.", + ]), + ], + ), + chainsBody(h, model), + ], + ) + +/** "Delete {chain}?": the 409 that names the rules holding it stays in the dialog. */ +export const deleteChainDialog = (h: H, model: Model): Html => { + const chain = Arr.findFirst( + Option.getOrElse(AsyncData.getData(model.chains.data), () => []), + (candidate) => candidate.id === model.confirmDeleteChainId, + ) + const name = Option.getOrElse( + Option.map(chain, (found) => found.name), + () => "escalation chain", + ) + return confirmDialog(h, { + slotId: "alerts-delete-chain", + model: model.deleteChainDialog, + toParentMessage: (message) => Message.GotDeleteChainDialogMessage({ message }), + title: `Delete ${name}?`, + description: "Escalations already running finish on their own copy. This cannot be undone.", + children: + model.deleteChainError === null + ? undefined + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't delete chain"]), + alertDescription(h, [model.deleteChainError]), + ]), + ], + confirmLabel: "Delete chain", + isPending: model.deletingChainId !== null, + onConfirm: Message.ClickedConfirmDeleteChain(), + }) +} diff --git a/apps/web/src/page/alerts/hub/view-routing.ts b/apps/web/src/page/alerts/hub/view-routing.ts index f29264b68a..badecd257d 100644 --- a/apps/web/src/page/alerts/hub/view-routing.ts +++ b/apps/web/src/page/alerts/hub/view-routing.ts @@ -31,6 +31,7 @@ import { createHref } from "../shared/links" import { Message } from "./message" import { canManage, type Model } from "./model" import { destinationMenuId } from "./update" +import { chainsSection } from "./view-chains" import { relativeTime } from "../../kit/relative-time" import { sectionHeading } from "../../kit/section-heading" import { typography, typographyClass } from "@maple/views/ui/typography" @@ -78,6 +79,8 @@ export const routingTab = (h: H, model: Model, inputs: RoutingInputs): Html => { ], ), separator(h, "horizontal", undefined), + chainsSection(h, model), + separator(h, "horizontal", undefined), h.section( [h.Class("space-y-4")], [ diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index fcdce8995f..bfcd3d34d0 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -34,6 +34,9 @@ import { anomalyInbox, inboxFilters } from "./view-anomalies" import { inboxTab, unacknowledgedCount } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" +import * as ChainDialogView from "../escalation-dialog/view" +import { deleteChainDialog } from "./view-chains" +import { CHAIN_DIALOG_ID } from "./update" import * as SilenceDialogView from "../silence-dialog/view" import { silencesTab } from "./view-silences" import { rulesTab } from "./view-rules" @@ -390,6 +393,14 @@ export const view = Submodel.defineView((model, ), deleteDialog(h, model), deleteMonitorDialog(h, model), + deleteChainDialog(h, model), + h.submodel({ + slotId: CHAIN_DIALOG_ID, + model: model.chainDialog, + view: ChainDialogView.view, + viewInputs: { id: CHAIN_DIALOG_ID, destinations: model.destinations }, + toParentMessage: (message) => Message.GotChainDialogMessage({ message }), + }), h.submodel({ slotId: "alerts-destination", model: model.destinationDialog, diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index 3d6bf95de3..27d3691824 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -7,6 +7,7 @@ import { MapleApiV2Client } from "../../../platform/api-clients" import { listDeliveries, listIncidentActivity, setIncidentAcknowledged } from "../shared/commands" import { listSlos } from "../shared/slo-commands" import { listMonitors } from "../shared/uptime-commands" +import { retrieveIncidentEscalation } from "../shared/escalation-chains" import { listActiveSilences } from "../shared/silences" import { readGrants } from "../../../data/session-tenant" import { fetchCheckSummary } from "../shared/check-summary" @@ -102,6 +103,16 @@ export const CreateInvestigation = Command.define("CreateAlertInvestigation", { ), }) +export const FetchIncidentEscalation = Command.define("FetchAlertIncidentEscalation", { + args: { key: Schema.String, incidentId: AlertIncidentId }, + messages: [Message.CompletedFetchEscalation], + execute: ({ key, incidentId }) => + retrieveIncidentEscalation(incidentId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchEscalation({ key, result })), + ), +}) + /** An SLO incident's SLOs, to find the one whose burn-rate rule opened it. */ export const FetchIncidentSlos = Command.define("FetchAlertIncidentSlos", { args: { key: Schema.String }, diff --git a/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts new file mode 100644 index 0000000000..bd7393322c --- /dev/null +++ b/apps/web/src/page/alerts/incident/incident-escalation.scene.test.ts @@ -0,0 +1,157 @@ +import { OrgId } from "@maple/domain" +import type { V2AlertIncidentEscalation } from "@maple/domain/http/v2" +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 { runOf } from "../shared/escalation-test-fixtures" +import { incidentRow, INCIDENT_ID, NOW, ORG, ruleRow } 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 route = { _tag: "AlertIncident" as const, incidentId: INCIDENT_ID, search: {} } +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const synced = (row = incidentRow()) => + run(init(route, context).model, [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [row] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2)] }), + ]) + +const withRun = (model: Model, run: V2AlertIncidentEscalation | null) => + pageUpdate( + model, + Message.CompletedFetchEscalation({ key: model.escalation.key ?? "", result: Result.succeed(run) }), + ).model + +describe("alert incident, escalation", () => { + test("the incident reads its escalation once, and again when it is acknowledged", () => { + const entered = run(init(route, context).model, [Message.ReadClock({ nowMs: NOW })]) + const first = pageUpdate(entered, Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] })) + expect(names(first.commands)).toContain("FetchAlertIncidentEscalation") + const same = pageUpdate(first.model, Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] })) + expect(names(same.commands)).not.toContain("FetchAlertIncidentEscalation") + const acked = pageUpdate( + first.model, + Message.UpdatedIncidents({ + orgId: ORG, + rows: [incidentRow({ acknowledged_at: "2026-10-01T11:58:00.000Z" })], + }), + ) + expect(names(acked.commands)).toContain("FetchAlertIncidentEscalation") + }) + + test("the clock reads a running chain again once its step is due, which pages people the rows do not show", () => { + const running = withRun( + synced(), + runOf("active", { nextStepAt: new Date(NOW + 10 * 60_000).toISOString() }), + ) + expect( + names(pageUpdate(running, Message.TickedClock({ nowMs: NOW + 5 * 60_000 })).commands), + ).not.toContain("FetchAlertIncidentEscalation") + expect( + names(pageUpdate(running, Message.TickedClock({ nowMs: NOW + 11 * 60_000 })).commands), + ).toContain("FetchAlertIncidentEscalation") + const finished = withRun(synced(), runOf("exhausted")) + expect( + names(pageUpdate(finished, Message.TickedClock({ nowMs: NOW + 60 * 60_000 })).commands), + ).not.toContain("FetchAlertIncidentEscalation") + }) + + test("a running chain shows its step and when it pages next", () => { + scene( + { update: pageUpdate, view }, + given(withRun(synced(), runOf("active", { nextStepAt: "2026-10-01T12:10:00.000Z" }))), + themeResolved(), + expectView(role("heading", { name: "Escalation" })).toExist(), + expectView(text("Escalating")).toExist(), + expectView(text("Checkout on-call")).toExist(), + expectView(text(/^Step 2 of 3, pass 1 of 3, next page at /u)).toExist(), + expectView(text("1 step paged so far")).toExist(), + ) + }) + + test("a page due later today reads as a time, one due tomorrow carries its date", () => { + scene( + { update: pageUpdate, view }, + given(withRun(synced(), runOf("active", { nextStepAt: "2026-10-01T23:30:00.000Z" }))), + themeResolved(), + expectView(text(/next page at 11:30\s?PM$/u)).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withRun(synced(), runOf("active", { nextStepAt: "2026-10-02T09:30:00.000Z" }))), + themeResolved(), + expectView(text(/next page at Oct 2, 2026, 09:30\s?AM$/u)).toExist(), + ) + }) + + test("a failed read keeps the section and says so; a pending one adds nothing yet", () => { + const failed = pageUpdate( + synced(), + Message.CompletedFetchEscalation({ + key: synced().escalation.key ?? "", + result: Result.fail(toLoadFailure("upstream down")), + }), + ).model + scene( + { update: pageUpdate, view }, + given(failed), + themeResolved(), + expectView(role("heading", { name: "Escalation" })).toExist(), + expectView(text("Couldn't load the escalation.")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(synced()), + themeResolved(), + expectView(role("heading", { name: "Escalation" })).not.toExist(), + ) + }) + + test("an acknowledged incident pauses the chain", () => { + scene( + { update: pageUpdate, view }, + given( + withRun( + synced(incidentRow({ acknowledged_at: "2026-10-01T11:58:00.000Z" })), + runOf("active"), + ), + ), + themeResolved(), + expectView(text("Paused: acknowledged")).toExist(), + expectView(text(/Resumes if the incident is unacknowledged/u)).toExist(), + ) + }) + + test("a finished chain says why, and an incident without one shows no section", () => { + scene( + { update: pageUpdate, view }, + given(withRun(synced(), runOf("exhausted", { step: 3, stepsSent: 9 }))), + themeResolved(), + expectView(text("Finished")).toExist(), + expectView(text("Every step was sent")).toExist(), + expectView(text("9 steps paged")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withRun(synced(), null)), + themeResolved(), + expectView(role("heading", { name: "Escalation" })).not.toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/incident/incident.scene.test.ts b/apps/web/src/page/alerts/incident/incident.scene.test.ts index 8f8095cbd7..51d642ef1c 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -15,7 +15,12 @@ import { text, withViewInputs, } from "foldkit/scene" -import { CreateInvestigation, FetchIncidentActivity, SetIncidentAcknowledged } from "./command" +import { + CreateInvestigation, + FetchIncidentActivity, + FetchIncidentEscalation, + SetIncidentAcknowledged, +} from "./command" import { encodePublicId, PublicIdPrefixes, @@ -493,6 +498,7 @@ describe("alert incident", () => { acknowledged_by: acknowledgedAt === null ? null : { user_id: "user_dana", name: "Dana Kim" }, acknowledged_via: acknowledgedAt === null ? null : "web", error_issue_id: null, + escalation: null, silence_id: null, silenced_at: null, }) @@ -548,6 +554,11 @@ describe("alert incident", () => { result: Result.succeed([ackEvent("acknowledged", ackedAt)]), }), ), + // The ack also moves the escalation's key; this scene draws no chain, so a null read. + Command.resolve( + FetchIncidentEscalation, + Message.CompletedFetchEscalation({ key: "", result: Result.succeed(null) }), + ), expectView(text(/Acknowledged by Dana Kim at/)).toExist(), expectView(text("From the dashboard · reminders paused")).toExist(), click(role("button", { name: "Unacknowledge" })), @@ -568,6 +579,11 @@ describe("alert incident", () => { ]), }), ), + // The ack also moves the escalation's key; this scene draws no chain, so a null read. + Command.resolve( + FetchIncidentEscalation, + Message.CompletedFetchEscalation({ key: "", result: Result.succeed(null) }), + ), expectView(text("Unacknowledged by Dana Kim")).toExist(), expectView(role("button", { name: "Acknowledge" })).toExist(), expectView(text(/Acknowledged by Dana Kim at/)).not.toExist(), diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index c5192ade9c..069c0b9d09 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -1,6 +1,7 @@ import { AlertDeliveryEventDocument, AlertRulePreviewResponse } from "@maple/domain/http" import { V2AlertIncident, + V2AlertIncidentEscalation, V2AlertIncidentEvent, V2AlertSilence, V2AnomalyIncident, @@ -46,6 +47,10 @@ export const Message = defineMessageUnion({ key: Schema.String, result: Schema.Result(Schema.NullOr(V2Investigation), LoadFailure), }, + CompletedFetchEscalation: { + key: Schema.String, + result: Schema.Result(Schema.NullOr(V2AlertIncidentEscalation), LoadFailure), + }, CompletedFetchActivity: { key: Schema.String, result: Schema.Result(Schema.Array(V2AlertIncidentEvent), LoadFailure), diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index dde17a5897..8962a865c3 100644 --- a/apps/web/src/page/alerts/incident/model.ts +++ b/apps/web/src/page/alerts/incident/model.ts @@ -1,5 +1,6 @@ import { AlertDeliveryEventDocument } from "@maple/domain/http" import { + V2AlertIncidentEscalation, V2AlertIncidentEvent, V2AlertSilence, V2AnomalyIncident, @@ -40,6 +41,8 @@ export const Model = Schema.Struct({ checks: KeyedSchema(CheckSummary, LoadFailure), deliveries: KeyedSchema(Schema.Array(AlertDeliveryEventDocument), LoadFailure), investigation: KeyedSchema(Schema.NullOr(V2Investigation), LoadFailure), + /** The incident's latest escalation run (`alertIncidents.retrieve`), re-read as the incident changes. */ + escalation: KeyedSchema(Schema.NullOr(V2AlertIncidentEscalation), LoadFailure), /** Acknowledgement history, refetched whenever the incident's acknowledgement changes. */ activity: KeyedSchema(Schema.Array(V2AlertIncidentEvent), LoadFailure), /** An acknowledge or unacknowledge request is running. */ diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index b58d214ea2..cf44fcbdb9 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -27,6 +27,7 @@ import { FetchIncidentAnomalyTimeseries, FetchIncidentChecks, FetchIncidentDeliveries, + FetchIncidentEscalation, FetchIncidentInvestigation, FetchIncidentMonitors, FetchIncidentPreview, @@ -150,6 +151,7 @@ export const init = (route: IncidentRoute, context: PageContext): Return => { checks: Keyed.idle(), deliveries: Keyed.idle(), investigation: Keyed.idle(), + escalation: Keyed.idle(), activity: Keyed.idle(), isUpdatingAcknowledgement: false, slos: Keyed.idle(), @@ -206,6 +208,14 @@ const followData = (model: Model, context: PageContext): Return => const investigationKey = requestKey(context.orgId, "investigations.list", { query: { incident_kind: "alert", incident_id: incidentId, limit: 1 }, }) + // What moves an escalation: a new page, an ack, a severity change, the resolve. + const escalationKey = requestKey(context.orgId, "alertIncidents.retrieve", { + id: incidentId, + status: view.incident.status, + severity: view.incident.severity, + lastNotifiedAt: view.incident.lastNotifiedAt, + acknowledgedAt: view.incident.acknowledgedAt, + }) // Keyed on the acknowledgement, so an ack from anywhere (Slack, the API) refetches the history. const activityKey = requestKey(context.orgId, "alertIncidents.listEvents", { id: incidentId, @@ -221,6 +231,17 @@ const followData = (model: Model, context: PageContext): Return => lastTriggeredAt: isoMs(view.incident.lastTriggeredAt), } const steps: ReadonlyArray<{ patch: Partial; commands: Commands }> = [ + ...Option.toArray( + Option.map( + sameKey(model.escalation, escalationKey) + ? Option.none() + : Keyed.request(model.escalation, escalationKey, "Load"), + (escalation) => ({ + patch: { escalation }, + commands: [FetchIncidentEscalation({ key: escalationKey, incidentId })], + }), + ), + ), ...Option.toArray( Option.map( sameKey(model.activity, activityKey) @@ -462,10 +483,32 @@ const withIncidents = ( return isChanged ? { ...next, commands: [...(next.commands ?? []), ReadIncidentClock()] } : next } +/** A step that only pages people changes no synced row, so the clock re-reads a run once its step is due. */ +const refreshDueEscalation = (model: Model, nowMs: number): Return => { + const key = model.escalation.key + const run = Option.getOrNull(AsyncData.getData(model.escalation.data)) + const isDue = + key !== null && + run !== null && + run.status === "active" && + run.next_step_at !== null && + isoMs(run.next_step_at) <= nowMs + const view = incidentViewOf(model) + if (!isDue || Option.isNone(view)) return { model } + const incidentId = view.value.incident.id + return Option.match(Keyed.request(model.escalation, key, "Refresh"), { + onNone: () => ({ model }), + onSome: (escalation): Return => ({ + model: { ...model, escalation }, + commands: [FetchIncidentEscalation({ key: escalation.key, incidentId })], + }), + }) +} + export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { ReadClock: ({ nowMs }) => followData({ ...model, nowMs: minuteOf(nowMs), clockMs: nowMs }, context), - TickedClock: ({ nowMs }) => ({ model: { ...model, clockMs: nowMs } }), + TickedClock: ({ nowMs }) => refreshDueEscalation({ ...model, clockMs: nowMs }, nowMs), UpdatedIncidents: ({ orgId, rows }) => withIncidents(model, context, orgId, rows), UpdatedRules: ({ orgId, rows }) => withRuleRows(model, context, orgId, { rules: rows }), UpdatedRuleStates: ({ orgId, rows }) => withRuleRows(model, context, orgId, { states: rows }), @@ -519,6 +562,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchInvestigation: ({ key, result }) => ({ model: { ...model, investigation: Keyed.accept(model.investigation, key, result) }, }), + CompletedFetchEscalation: ({ key, result }) => ({ + model: { ...model, escalation: Keyed.accept(model.escalation, key, result) }, + }), CompletedFetchSlos: ({ key, result }) => ({ model: { ...model, slos: Keyed.accept(model.slos, key, result) }, }), diff --git a/apps/web/src/page/alerts/incident/view-rail.ts b/apps/web/src/page/alerts/incident/view-rail.ts index feb020fe92..484d8238e7 100644 --- a/apps/web/src/page/alerts/incident/view-rail.ts +++ b/apps/web/src/page/alerts/incident/view-rail.ts @@ -1,5 +1,6 @@ import { pluralize } from "@maple/ui/lib/format" import { type AlertIncidentDocument, type AlertRuleDocument, isManagedSignalType } from "@maple/domain/http" +import type { V2AlertIncidentEscalation } from "@maple/domain/http/v2" import { formatWarehouseDateTime } from "@maple/query-engine" import { cn } from "@maple/ui/lib/utils" import { anomalyScopeSummary } from "@maple/web-data/alerts/anomaly-rule" @@ -11,10 +12,14 @@ import { AppRoute, hrefOf } from "../../../route" import { eyebrow } from "@maple/views/ui/eyebrow" import { statusDot } from "@maple/views/ui/status-dot" import { createHref, incidentHref, ruleHref } from "../shared/links" +import { routingTabHref, runView } from "../shared/escalation-chains" +import { textLink } from "@maple/views/ui/text-link" +import type { Tone } from "@maple/ui/lib/tone" import { isoMs } from "../shared/time" import { typography, typographyClass } from "@maple/views/ui/typography" -import { hstack } from "@maple/views/ui/stack" -import { Array as Arr } from "effect" +import { hstack, vstack } from "@maple/views/ui/stack" +import { Array as Arr, Option } from "effect" +import { AsyncData } from "foldkit" // `IncidentRail`: the incident detail's side rail. @@ -285,6 +290,67 @@ const ruleSection = ( ) } +const RUN_TONE = { escalating: "warn", paused: "neutral", finished: "neutral" } as const satisfies Record< + ReturnType["state"], + Tone +> + +/** `EscalationSection`: the chain paging this incident, where it stands, and when it pages next. */ +export const escalationSection = ( + h: HtmlBuilder, + run: V2AlertIncidentEscalation, + incident: AlertIncidentDocument, + timeZone: string, + now: number, +): Html => { + const view = runView(run, incident.acknowledgedAt !== null, timeZone, now) + return railSection( + h, + ["Escalation"], + textLink(h, { href: routingTabHref(), tone: "muted", className: "text-xs" }, ["View chains"]), + [ + vstack(h, { gap: "1.5" }, [ + hstack(h, { gap: "2" }, [ + statusDot(h, { tone: RUN_TONE[view.state] }), + typography(h, { size: "sm", weight: "medium" }, [view.label]), + ]), + typography( + h, + { size: "sm", truncate: true, as: "p", attributes: [h.Title(run.chain_name)] }, + [run.chain_name], + ), + typography(h, { size: "xs", tone: "muted", as: "p", numeric: true }, [view.detail]), + typography(h, { size: "xs", tone: "muted", as: "p", numeric: true }, [ + String(run.steps_sent), + " ", + pluralize(run.steps_sent, "step"), + view.state === "finished" ? " paged" : " paged so far", + ]), + ]), + ], + ) +} + +export type EscalationData = AsyncData.AsyncData + +const escalationNote = (h: HtmlBuilder, text: string): Html => + railSection(h, ["Escalation"], null, [typography(h, { size: "xs", tone: "muted", as: "p" }, [text])]) + +/** The run once read, a note while it loads or after it failed, nothing for an incident without one. */ +const escalationBlock = ( + h: HtmlBuilder, + escalation: EscalationData, + incident: AlertIncidentDocument, + timeZone: string, + now: number, +): ReadonlyArray => + Option.match(AsyncData.getData(escalation), { + onSome: (run) => (run === null ? [] : [escalationSection(h, run, incident, timeZone, now)]), + // While loading, most incidents have no chain, so the rail stays as it is rather than shift. + onNone: () => + AsyncData.isFailure(escalation) ? [escalationNote(h, "Couldn't load the escalation.")] : [], + }) + export const incidentRail = ( h: HtmlBuilder, options: Readonly<{ @@ -294,6 +360,8 @@ export const incidentRail = ( ruleIncidents: ReadonlyArray window: Readonly<{ start: number; end: number }> now: number + escalation: EscalationData + timeZone: string }>, ): Html => { const { rule, incident, states } = options @@ -301,6 +369,7 @@ export const incidentRail = ( return h.aside( [h.Class("space-y-5 lg:border-l lg:pl-6")], [ + ...escalationBlock(h, options.escalation, incident, options.timeZone, options.now), ...(isGrouped && rule !== null ? [ groupsSection( diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index fb2cca6406..14f9f1a6ec 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -51,7 +51,7 @@ import { msIso } from "../shared/time" import { groupPreviewOf, type IncidentView, incidentViewOf, ruleStatesOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" -import { incidentRail } from "./view-rail" +import { type EscalationData, incidentRail } from "./view-rail" import { activeSilenceFor } from "../shared/silences" import { silenceBadge, silenceButton, silenceCheckFailed } from "../shared/silence-view" import * as Policy from "@maple/domain/policy" @@ -265,6 +265,8 @@ export type IncidentBodyInputs = Readonly<{ entries: ReadonlyArray isEntriesLoading: boolean isEntriesFailed: boolean + /** The read of the incident's latest escalation run; a success of `null` means it has none. */ + escalation?: EscalationData /** An SLO or uptime incident's SLO or monitor page; its tab stands in until it is known. */ sourceHref?: string | null chartId: string @@ -378,6 +380,8 @@ export const incidentBody = (h: HtmlBuilder, inputs: IncidentBodyInputs ruleIncidents: inputs.ruleIncidents, window: inputs.range, now: inputs.now, + escalation: inputs.escalation ?? AsyncData.succeed(null), + timeZone: inputs.timeZone, }), ], ) @@ -410,6 +414,7 @@ const body = (h: H, model: Model, view: IncidentView, timeZone: string, now: num incident, rule, sourceHref: Option.getOrNull(incident.signalType === "uptime" ? monitorLink : sloLink), + escalation: model.escalation.data, ruleIncidents: view.ruleIncidents, states: ruleStatesOf(model, incident.ruleId), preview: groupPreviewOf(previewState.preview, incident), diff --git a/apps/web/src/page/alerts/shared/escalation-chains.test.ts b/apps/web/src/page/alerts/shared/escalation-chains.test.ts new file mode 100644 index 0000000000..b3e408cbf9 --- /dev/null +++ b/apps/web/src/page/alerts/shared/escalation-chains.test.ts @@ -0,0 +1,88 @@ +import { Array as Arr, Option } from "effect" +import { describe, expect, it } from "vitest" +import { chainSummary, formatWait, runView } from "./escalation-chains" +import { chainDestinations, chainOf, runOf } from "./escalation-test-fixtures" +import { NOW } from "./test-rows" + +const nameOf = (id: string) => + Option.getOrElse( + Option.map( + Arr.findFirst(chainDestinations, (row) => row.id === id), + (row) => row.name, + ), + () => "an unavailable destination", + ) + +describe("chainSummary", () => { + it("reads each step with the wait before it, and the repeats", () => { + const chain = chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + { destinations: [3], wait: 30 }, + ], + repeat: 2, + }) + expect(chainSummary(chain, nameOf)).toBe( + "Slack #alerts-api, then PagerDuty Checkout after 10m, then Email on-call after 15m; repeats twice", + ) + }) + + it("names two destinations of a step, shortens more, and says nothing of no repeats", () => { + const chain = chainOf({ + n: 2, + name: "Wide", + steps: [ + { destinations: [1, 2], wait: 90 }, + { destinations: [3, 4, 5], wait: 5 }, + ], + }) + expect(chainSummary(chain, nameOf)).toBe( + "Slack #alerts-api and PagerDuty Checkout, then Email on-call and 2 more after 1h 30m", + ) + }) +}) + +describe("formatWait", () => { + it("reads minutes, hours and a day", () => { + expect([formatWait(1), formatWait(60), formatWait(75), formatWait(1440)]).toEqual([ + "1m", + "1h", + "1h 15m", + "1d", + ]) + }) +}) + +describe("runView", () => { + it("an active run says where it is and when it pages next", () => { + const view = runView(runOf("active", { nextStepAt: "2026-10-01T14:25:00.000Z" }), false, "UTC", NOW) + expect(view.state).toBe("escalating") + expect(view.detail).toMatch(/^Step 2 of 3, pass 1 of 3, next page at .*2:25/u) + }) + + it("an acknowledged incident reads as paused even before the run itself pauses", () => { + expect(runView(runOf("active"), true, "UTC", NOW)).toMatchObject({ + state: "paused", + label: "Paused: acknowledged", + }) + }) + + it("an unacknowledged incident whose run the scheduler has not resumed yet reads as resuming", () => { + expect(runView(runOf("acknowledged"), false, "UTC", NOW)).toMatchObject({ + state: "escalating", + label: "Resuming", + }) + }) + + it("an ended run says why it ended", () => { + expect(runView(runOf("exhausted"), false, "UTC", NOW)).toEqual({ + state: "finished", + label: "Finished", + detail: "Every step was sent", + }) + expect(runView(runOf("resolved"), true, "UTC", NOW).detail).toBe("Ended when the incident resolved") + }) +}) diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts new file mode 100644 index 0000000000..82a3d643c6 --- /dev/null +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -0,0 +1,152 @@ +import type { AlertDestinationId, AlertIncidentId, EscalationChainId } from "@maple/domain/http" +import type { + V2AlertIncidentEscalation, + V2EscalationChain, + V2EscalationChainCreateParams, +} from "@maple/domain/http/v2" +import { formatAlertTimeNear } from "@maple/web-data/alerts/form-utils" +import { Array as Arr, Effect } from "effect" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { AppRoute, hrefOf } from "../../../route" + +// Escalation chain reads and writes as Effects the pages' Commands wrap, and how a chain and an +// incident's run read in one line. + +/** An org holds at most 100 chains, one v2 page. */ +export const CHAIN_LIST_LIMIT = 100 + +/** 409 on delete while a rule tier still names the chain. */ +export const CHAIN_IN_USE_CODE = "escalation_chain_in_use" + +export const listEscalationChains = Effect.gen(function* () { + const client = yield* MapleApiV2Client + const page = yield* client.escalationChains.list({ query: { limit: CHAIN_LIST_LIMIT } }) + return page.data +}).pipe(Effect.mapError(toLoadFailure)) + +/** Creates a chain, or replaces `editingId`'s fields with the form's. */ +export const saveEscalationChain = ( + editingId: EscalationChainId | null, + payload: V2EscalationChainCreateParams, +) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return editingId === null + ? yield* client.escalationChains.create({ payload }) + : yield* client.escalationChains.update({ + params: { id: editingId }, + payload: { ...payload, repeat_count: payload.repeat_count ?? 0 }, + }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deleteEscalationChain = (id: EscalationChainId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.escalationChains.delete({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** The incident's latest escalation run, from its v2 document. */ +export const retrieveIncidentEscalation = (id: AlertIncidentId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const incident = yield* client.alertIncidents.retrieve({ params: { id } }) + return incident.escalation + }).pipe(Effect.mapError(toLoadFailure)) + +export const routingTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "routing" } })) + +// SUMMARY + +/** `10m`, `1h`, `1h 30m`, `1d`. */ +export const formatWait = (minutes: number): string => { + if (minutes < 60) return `${minutes}m` + if (minutes === 1440) return "1d" + const hours = Math.floor(minutes / 60) + const rest = minutes % 60 + return rest === 0 ? `${hours}h` : `${hours}h ${rest}m` +} + +/** "A", "A and B", "A and 2 more". */ +const namesOf = (names: ReadonlyArray): string => + names.length <= 2 ? Arr.join(names, " and ") : `${names[0]} and ${names.length - 1} more` + +const repeatPhrase = (count: number): string => + count === 0 + ? "" + : count === 1 + ? "; repeats once" + : count === 2 + ? "; repeats twice" + : `; repeats ${count} times` + +/** + * One line for a chain: "Slack #alerts-api, then PagerDuty after 10m, then email after 15m; + * repeats twice". Each step waits for an ack before the next, so a step's delay is the one before it. + */ +export const chainSummary = ( + chain: Pick, + nameOf: (id: AlertDestinationId) => string, +): string => { + const parts = Arr.map(chain.steps, (step, index) => { + const names = namesOf( + Arr.map(step.targets, (target) => + target.type === "destination" + ? nameOf(target.destination_id) + : target.type === "user" + ? "a team member" + : "the on-call", + ), + ) + const previous = index === 0 ? undefined : chain.steps[index - 1] + return previous === undefined ? names : `then ${names} after ${formatWait(previous.wait_minutes)}` + }) + return `${Arr.join(parts, ", ")}${repeatPhrase(chain.repeat_count)}` +} + +// INCIDENT RUN + +export type RunState = "escalating" | "paused" | "finished" + +export type RunView = Readonly<{ + state: RunState + /** "Escalating", "Paused: acknowledged", "Finished". */ + label: string + /** "Step 2 of 3, next page at 14:25", or why it ended. */ + detail: string +}> + +const ENDED = { + resolved: "Ended when the incident resolved", + exhausted: "Every step was sent", + superseded: "Stopped when the incident changed severity", +} as const + +/** How an incident's run reads in the rail. An acknowledged incident pauses it before the scheduler's next tick does. */ +export const runView = ( + run: V2AlertIncidentEscalation, + isAcknowledged: boolean, + timeZone: string, + nowMs: number, +): RunView => { + const position = `Step ${run.step} of ${run.step_count}${run.pass_count > 1 ? `, pass ${run.pass} of ${run.pass_count}` : ""}` + if (run.status === "resolved" || run.status === "exhausted" || run.status === "superseded") + return { state: "finished", label: "Finished", detail: ENDED[run.status] } + if (isAcknowledged) + return { + state: "paused", + label: "Paused: acknowledged", + detail: `${position}. Resumes if the incident is unacknowledged`, + } + // Unacknowledged, but the scheduler has not picked the run back up yet. + if (run.status === "acknowledged") + return { state: "escalating", label: "Resuming", detail: `${position}, on the next check` } + return { + state: "escalating", + label: "Escalating", + detail: + run.next_step_at === null + ? position + : `${position}, next page at ${formatAlertTimeNear(run.next_step_at, timeZone, nowMs)}`, + } +} diff --git a/apps/web/src/page/alerts/shared/escalation-test-fixtures.ts b/apps/web/src/page/alerts/shared/escalation-test-fixtures.ts new file mode 100644 index 0000000000..109b00485d --- /dev/null +++ b/apps/web/src/page/alerts/shared/escalation-test-fixtures.ts @@ -0,0 +1,129 @@ +import { encodePublicId, V2AlertIncidentEscalation, V2EscalationChain } from "@maple/domain/http/v2" +import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" +import { Schema, Array as Arr, String as Str } from "effect" +import { destinationRow, NOW } from "./test-rows" + +// Escalation chains, their destinations and incident runs for the scene tests and `/lab/escalation`. + +const pad = (n: number) => Str.padStart(12, "0")(String(n)) +export const chainUuid = (n: number) => `0199a00e-0000-7000-8000-${pad(n)}` +export const chainDestinationUuid = (n: number) => `0199a001-0000-7000-8000-${pad(100 + n)}` +export const chainScheduleUuid = (n: number) => `0199a00f-0000-7000-8000-${pad(200 + n)}` + +const NAMES = [ + "Slack #alerts-api", + "PagerDuty Checkout", + "Email on-call", + "Discord #incidents", + "Webhook to status page", + "PagerDuty Payments", + "Slack #eng-leads", + "Email CTO", + "Microsoft Teams SRE", + "Opsgenie platform", +] as const + +/** Ten destinations, named after where they page. */ +export const chainDestinations: ReadonlyArray = Arr.map(NAMES, (name, index) => + destinationRow({ id: chainDestinationUuid(index + 1), name }), +) + +export type ChainSpec = Readonly<{ + n: number + name: string + description?: string | null + /** Each step's destinations, by `chainDestinationUuid` number, and its wait. */ + steps: ReadonlyArray< + Readonly<{ + destinations: ReadonlyArray + wait: number + /** On-call schedules, by `chainScheduleUuid` number, and member user ids. */ + schedules?: ReadonlyArray + users?: ReadonlyArray + }> + > + repeat?: number + ruleIds?: ReadonlyArray +}> + +const decodeChain = Schema.decodeUnknownSync(V2EscalationChain) +const iso = new Date(NOW).toISOString() + +export const chainOf = (spec: ChainSpec): V2EscalationChain => + decodeChain({ + id: encodePublicId("esc", chainUuid(spec.n)), + object: "escalation_chain", + name: spec.name, + description: spec.description ?? null, + steps: Arr.map(spec.steps, (step) => ({ + targets: [ + ...Arr.map(step.destinations, (d) => ({ + type: "destination", + destination_id: encodePublicId("dest", chainDestinationUuid(d)), + })), + ...Arr.map(step.users ?? [], (userId) => ({ type: "user", user_id: userId })), + ...Arr.map(step.schedules ?? [], (n) => ({ + type: "schedule", + schedule_id: encodePublicId("onc", chainScheduleUuid(n)), + })), + ], + wait_minutes: step.wait, + })), + repeat_count: spec.repeat ?? 0, + rule_ids: Arr.map(spec.ruleIds ?? [], (id) => encodePublicId("alrt", id)), + created_at: iso, + updated_at: iso, + }) + +/** Three chains: an everyday one, one with a long name, one with every step it can hold. */ +export const populatedChains = (ruleIds: ReadonlyArray): ReadonlyArray => [ + chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + { destinations: [3], wait: 30 }, + ], + repeat: 2, + ruleIds: ruleIds.slice(0, 2), + }), + chainOf({ + n: 2, + name: "Payments platform after-hours escalation for the EU region with leadership on the last step", + steps: [ + { destinations: [6, 7], wait: 5 }, + { destinations: [8], wait: 60 }, + ], + ruleIds: ruleIds.slice(2, 3), + }), + chainOf({ + n: 3, + name: "Everyone, eventually", + steps: Arr.map(Arr.range(1, 10), (d) => ({ destinations: [d], wait: d * 5 })), + repeat: 5, + }), +] + +const decodeRun = Schema.decodeUnknownSync(V2AlertIncidentEscalation) + +export const runOf = ( + status: V2AlertIncidentEscalation["status"], + fields: Partial<{ step: number; stepsSent: number; nextStepAt: string | null }> = {}, +): V2AlertIncidentEscalation => + decodeRun({ + chain_id: encodePublicId("esc", chainUuid(1)), + chain_name: "Checkout on-call", + status, + step: fields.step ?? 2, + step_count: 3, + pass: 1, + pass_count: 3, + steps_sent: fields.stepsSent ?? 1, + next_step_at: + fields.nextStepAt === undefined + ? status === "active" + ? new Date(NOW + 10 * 60_000).toISOString() + : null + : fields.nextStepAt, + }) diff --git a/apps/web/src/page/kit/destination-picker.ts b/apps/web/src/page/kit/destination-picker.ts index 51e67080f3..80238cd3ee 100644 --- a/apps/web/src/page/kit/destination-picker.ts +++ b/apps/web/src/page/kit/destination-picker.ts @@ -25,9 +25,15 @@ export const destinationPicker = ( onRemove: (id: Id) => M /** What an empty pick means, in one line. */ emptyText: string + /** The pick is shown but cannot change (a form whose save is in flight). */ + isDisabled?: boolean + /** Names the list in its buttons ("step 2") when one form shows several pickers. */ + context?: string }>, ): Html => { const rows = options.rows ?? [] + const isDisabled = options.isDisabled ?? false + const inContext = options.context === undefined ? "" : ` in ${options.context}` // `null` until destinations sync: a neutral name, not "unavailable", for the saved ones. const fallback = options.rows === null ? "Destination" : "Unavailable destination" const selected = Arr.map(options.selectedIds, (id) => ({ @@ -51,8 +57,9 @@ export const destinationPicker = ( iconButton( h, { - label: `Stop notifying ${destination.name}`, + label: `Stop notifying ${destination.name}${inContext}`, className: "text-muted-foreground", + isDisabled, attributes: [h.OnClick(options.onRemove(destination.id))], }, [icon(h, "XmarkIcon")], @@ -90,7 +97,16 @@ export const destinationPicker = ( { className: "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", - attributes: trigger, + isDisabled, + attributes: + options.context === undefined + ? trigger + : [ + ...trigger, + h.AriaLabel( + `Add destination to ${options.context}`, + ), + ], }, [icon(h, "PlusIcon", { size: 14 }), "Add destination"], ), diff --git a/packages/backend/src/services/alerts/AlertReadModelsService.ts b/packages/backend/src/services/alerts/AlertReadModelsService.ts index 4ff22a040b..9843d48fe6 100644 --- a/packages/backend/src/services/alerts/AlertReadModelsService.ts +++ b/packages/backend/src/services/alerts/AlertReadModelsService.ts @@ -10,6 +10,7 @@ import { AlertDestinationType as AlertDestinationTypeSchema, AlertEventType as AlertEventTypeSchema, AlertIncidentDocument, + AlertIncidentEscalationDocument, AlertIncidentsListResponse, AlertIncidentAckSource, AlertIncidentHoldReason, @@ -33,11 +34,21 @@ import * as PG from "@maple-dev/effect-orm/postgres" import { AlertDeliveryEvents, AlertDestinations, + AlertEscalationRuns, AlertIncidents, AlertRules, type AlertIncidentRow, } from "@maple/db/tables" -import { Context, DateTime, Duration, Effect, Layer, Option, Schema } from "effect" +import { + Array as Arr, + Context, + DateTime, + Duration, + Effect, + Layer, + Option, + Schema, +} from "effect" import { Database } from "@maple/backend/platform/DatabaseLive" import { makeDbExecute } from "@maple/backend/platform/db-execute" import { makePersistenceError } from "./alert-persistence" @@ -99,6 +110,11 @@ export interface AlertReadModelsServiceApi { orgId: OrgId, incidentId: AlertIncidentId, ) => Effect.Effect + /** Each incident's latest escalation run, in one query for the whole set; incidents without one are absent. */ + readonly listIncidentEscalations: ( + orgId: OrgId, + incidentIds: ReadonlyArray, + ) => Effect.Effect, AlertPersistenceError> readonly listRuleChecks: ( orgId: OrgId, ruleId: AlertRuleId, @@ -276,6 +292,55 @@ export class AlertReadModelsService extends Context.Service< return rowToIncidentDocument(incident) }) + const listIncidentEscalations = Effect.fn("AlertReadModelsService.listIncidentEscalations")( + function* (orgId: OrgId, incidentIds: ReadonlyArray) { + yield* Effect.annotateCurrentSpan({ orgId, "maple.alert.incident_count": incidentIds.length }) + if (incidentIds.length === 0) return [] + const rows = yield* dbExecute((db) => + db.run( + PG.from(AlertEscalationRuns) + .select(($) => ({ + incidentId: $.incidentId, + chainId: $.chainId, + chainName: $.chainName, + status: $.status, + stepIndex: $.stepIndex, + passIndex: $.passIndex, + // Counted in SQL, so the list never loads the steps themselves. + stepCount: PG.sql(PG.int4)`jsonb_array_length(${$.stepsJson})::int`, + repeatCount: $.repeatCount, + stepsSent: $.stepsSent, + nextStepAt: $.nextStepAt, + })) + // One row per incident: its latest run (severity changes leave older ones behind). + .distinctOn("incidentId") + .where(($) => [$.orgId.eq(orgId), $.incidentId.in_(...incidentIds)]) + .orderBy(($) => [ + [$.incidentId, "asc"], + [$.createdAt, "desc"], + [$.id, "desc"], + ]), + ), + ) + return Arr.map( + rows, + (row) => + new AlertIncidentEscalationDocument({ + incidentId: row.incidentId, + chainId: row.chainId, + chainName: row.chainName, + status: row.status, + stepIndex: row.stepIndex, + passIndex: row.passIndex, + stepCount: row.stepCount, + repeatCount: row.repeatCount, + stepsSent: row.stepsSent, + nextStepAt: row.status === "active" ? toIso(row.nextStepAt) : null, + }), + ) + }, + ) + const listRuleChecks = Effect.fn("AlertReadModelsService.listRuleChecks")(function* ( orgId: OrgId, ruleId: AlertRuleId, @@ -547,6 +612,7 @@ export class AlertReadModelsService extends Context.Service< return { listIncidents, getIncident, + listIncidentEscalations, listRuleChecks, summarizeRuleChecks, listDeliveryEvents, diff --git a/packages/backend/src/services/alerts/AlertsService.ts b/packages/backend/src/services/alerts/AlertsService.ts index 5fc2922b2a..0b231a72b7 100644 --- a/packages/backend/src/services/alerts/AlertsService.ts +++ b/packages/backend/src/services/alerts/AlertsService.ts @@ -6272,6 +6272,7 @@ export class AlertsService extends Context.Service +/** An incident's latest escalation run, as the incident reads show it. Steps and passes count from 0. */ +export class AlertIncidentEscalationDocument extends Schema.Class( + "AlertIncidentEscalationDocument", +)({ + incidentId: AlertIncidentId, + chainId: EscalationChainId, + chainName: Schema.String, + status: AlertEscalationRunStatus, + stepIndex: Schema.Number, + passIndex: Schema.Number, + stepCount: Schema.Number, + repeatCount: Schema.Number, + stepsSent: Schema.Number, + /** When the next step pages; `null` once the run is no longer `active`. */ + nextStepAt: Schema.NullOr(IsoDateTimeString), +}) {} + export class EscalationChainNotFoundError extends HttpTaggedError()( "@maple/http/errors/EscalationChainNotFoundError", { message: Schema.String, chainId: EscalationChainId }, diff --git a/packages/domain/src/http/v2/alert-incidents.ts b/packages/domain/src/http/v2/alert-incidents.ts index 731b25416e..9d0f71ad35 100644 --- a/packages/domain/src/http/v2/alert-incidents.ts +++ b/packages/domain/src/http/v2/alert-incidents.ts @@ -12,6 +12,7 @@ import { AlertSeverity, AlertSignalType, } from "../alerts" +import { AlertEscalationRunStatus } from "../escalation-chains" import { AuthorizationV2 } from "./auth" import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" @@ -23,6 +24,7 @@ import { AlertRulePublicId, AnomalyIncidentPublicId, ErrorIssuePublicId, + EscalationChainPublicId, } from "./resource-ids" import { AlertSilencePublicId } from "./alert-silences" @@ -53,6 +55,17 @@ const alertIncidentExample = { acknowledged_by: { user_id: "user_2Nk8mXqPfR3yZ1aB4cD5eF6g", name: "Dana Kim" }, acknowledged_via: "slack", error_issue_id: null, + escalation: { + chain_id: "esc_gQtGH2s65G6a7sJmuayXL6", + chain_name: "Checkout on-call", + status: "acknowledged", + step: 2, + step_count: 3, + pass: 1, + pass_count: 2, + steps_sent: 1, + next_step_at: null, + }, silence_id: null, silenced_at: null, } as const @@ -67,6 +80,40 @@ const V2AlertIncidentActor = Schema.Struct({ }), }).annotate({ identifier: "AlertIncidentActor", title: "Alert incident actor" }) +const Count = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) + +export const V2AlertIncidentEscalation = Schema.Struct({ + chain_id: EscalationChainPublicId.annotate({ + description: + "The escalation chain (`esc_…`) the run walks. It may since have been edited or deleted.", + }), + chain_name: Schema.String.annotate({ + description: "The chain's name when the run started.", + examples: ["Checkout on-call"], + }), + status: AlertEscalationRunStatus.annotate({ + description: + "`active` waits on `next_step_at`; `acknowledged` is paused until the incident is unacknowledged; `resolved`, `exhausted` (every step and repeat sent) and `superseded` (a higher tier started its own chain) have ended.", + examples: ["active"], + }), + step: Count.annotate({ description: "The next step to page, from 1.", examples: [2] }), + step_count: Count.annotate({ description: "Steps in the chain the run started with.", examples: [3] }), + pass: Count.annotate({ description: "The pass the next step belongs to, from 1.", examples: [1] }), + pass_count: Count.annotate({ + description: "Passes over the chain in all: its repeat count plus one.", + examples: [2], + }), + steps_sent: Count.annotate({ description: "Steps paged so far, across passes.", examples: [1] }), + next_step_at: Schema.NullOr(Timestamp).annotate({ + description: "When the next step pages, or `null` while paused or once the run has ended.", + }), +}).annotate({ + identifier: "AlertIncidentEscalation", + title: "Alert incident escalation", + description: "How far the incident's latest escalation chain run has got.", +}) +export type V2AlertIncidentEscalation = Schema.Schema.Type + // v2 wire schemas are annotated `Schema.Struct`s (not `Schema.Class`) — see the // note in api-keys.ts. export const V2AlertIncident = Schema.Struct({ @@ -154,6 +201,10 @@ export const V2AlertIncident = Schema.Struct({ error_issue_id: Schema.NullOr(ErrorIssuePublicId).annotate({ description: "The linked error issue (`iss_…`) when the incident was correlated to one, or `null`.", }), + escalation: Schema.NullOr(V2AlertIncidentEscalation).annotate({ + description: + "The incident's latest escalation chain run, or `null` when its severity tier names no chain.", + }), group_label: Schema.optionalKey( Schema.NullOr(Schema.String).annotate({ description: diff --git a/packages/domain/src/http/v2/v2-contract.test.ts b/packages/domain/src/http/v2/v2-contract.test.ts index 6016956bca..70dba46e27 100644 --- a/packages/domain/src/http/v2/v2-contract.test.ts +++ b/packages/domain/src/http/v2/v2-contract.test.ts @@ -33,6 +33,7 @@ import { } from "./telemetry" const UUID = "0f8fad5b-d9cb-469f-a165-70867728950e" +const INCIDENT_CHAIN_UUID = "3f2a1b4c-5d6e-4f70-8a9b-0c1d2e3f4a5b" describe("V2PlanetScaleIntegration wire format", () => { it("decodes snake_case fields, ISO timestamps, and a scrp_ public ID", () => { @@ -634,17 +635,49 @@ describe("V2 alerts wire format", () => { acknowledged_by: null, acknowledged_via: null, error_issue_id: null, + escalation: { + chain_id: encodePublicId("esc", INCIDENT_CHAIN_UUID), + chain_name: "Checkout on-call", + status: "active", + step: 2, + step_count: 3, + pass: 1, + pass_count: 2, + steps_sent: 1, + next_step_at: "2026-07-15T09:25:00.000Z", + }, silence_id: null, silenced_at: null, }) expect(incident.id).toBe(INCIDENT_UUID) expect(incident.rule_id).toBe(UUID) + expect(incident.escalation?.chain_id).toBe(INCIDENT_CHAIN_UUID) const wire = Schema.encodeSync(V2AlertIncident)(incident) expect(wire.id).toMatch(/^inc_/) expect(wire.rule_id).toMatch(/^alrt_/) + expect(wire.escalation?.chain_id).toMatch(/^esc_/) expect("ruleId" in wire).toBe(false) }) + + it("refuses an incident escalation with an unknown run status or a negative step", () => { + const decode = Schema.decodeUnknownResult(V2AlertIncident.fields.escalation) + const escalation = { + chain_id: encodePublicId("esc", INCIDENT_CHAIN_UUID), + chain_name: "Checkout on-call", + status: "active", + step: 1, + step_count: 1, + pass: 1, + pass_count: 1, + steps_sent: 0, + next_step_at: null, + } + expect(Result.isSuccess(decode(escalation))).toBe(true) + expect(Result.isSuccess(decode(null))).toBe(true) + expect(Result.isFailure(decode({ ...escalation, status: "paused" }))).toBe(true) + expect(Result.isFailure(decode({ ...escalation, step: -1 }))).toBe(true) + }) }) describe("v2 error envelope", () => { diff --git a/packages/views/src/ui/field.ts b/packages/views/src/ui/field.ts index 4858aef33b..b60ade9e1e 100644 --- a/packages/views/src/ui/field.ts +++ b/packages/views/src/ui/field.ts @@ -156,6 +156,8 @@ export const input = ( attributes: ReadonlyArray> /** ``: on the control wrapper, as @maple/ui puts it. */ className?: string + /** Base UI's `disabled`: shown, not editable (a form whose save is in flight). */ + isDisabled?: boolean }>, ): Html => { const described = describedBy(options.ids, options.state) @@ -163,6 +165,7 @@ export const input = ( id: options.ids.control, fieldState: options.state, className: options.className, + isDisabled: options.isDisabled, attributes: [ ...options.attributes, h.AriaLabelledBy(options.ids.label), diff --git a/packages/views/src/ui/select-view.ts b/packages/views/src/ui/select-view.ts index 66ae728ba9..7dc9c6d2c0 100644 --- a/packages/views/src/ui/select-view.ts +++ b/packages/views/src/ui/select-view.ts @@ -51,6 +51,8 @@ export type ViewInputs = Readonly<{ /** Inside a Base UI field: the field label (and description) that name the trigger. */ labelledBy?: string describedBy?: string + /** The field shows an error: the trigger reads `aria-invalid`. */ + isInvalid?: boolean /** ``. */ size?: "sm" | "default" | "lg" className?: string @@ -298,6 +300,7 @@ export const view = Submodel.defineView((model, inpu ...(inputs.ariaLabel === undefined ? [] : [h.AriaLabel(inputs.ariaLabel)]), ...(inputs.labelledBy === undefined ? [] : [h.AriaLabelledBy(inputs.labelledBy)]), ...(inputs.describedBy === undefined ? [] : [h.AriaDescribedBy(inputs.describedBy)]), + ...(inputs.isInvalid === true ? [h.AriaInvalid(true)] : []), h.Class(cn(selectTriggerVariants({ size: inputs.size ?? "default" }), inputs.className)), ...(isDisabled ? [h.DataAttribute("disabled", ""), h.Disabled(true)] : []), ...(model.isOpen ? [h.DataAttribute("popup-open", "")] : []), diff --git a/packages/web-data/src/alerts/form-utils.ts b/packages/web-data/src/alerts/form-utils.ts index d0854512a2..5b51b2d372 100644 --- a/packages/web-data/src/alerts/form-utils.ts +++ b/packages/web-data/src/alerts/form-utils.ts @@ -1180,6 +1180,13 @@ const startOfDay = (ms: number, timeZone: string | undefined): number => { return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() } +/** Time of day when `value` falls on the same local day as `nowMs`, else date and time. */ +export function formatAlertTimeNear(value: string, timeZone: string, nowMs: number): string { + return startOfDay(new Date(value).getTime(), timeZone) === startOfDay(nowMs, timeZone) + ? formatAlertTime(value, timeZone) + : formatAlertDateTime(value, timeZone) +} + /** Day-bucket heading: `Today` / `Yesterday` / `Jun 4, 2026`. */ function formatAlertDayHeading(value: string, timeZone: string | undefined, nowMs: number): string { const date = new Date(value)