From 8b1e62321a6dc416e0d7f12df590c7e8393aeb96 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:22:44 +0100 Subject: [PATCH 1/5] feat(alerts): acknowledge incidents from the incident page and the inbox The incident page gets an Acknowledge / Unacknowledge button in its header and says who acknowledged it in the status line. The timeline shows each acknowledgement, unacknowledgement and escalation-cleared ack from the incident's events, refetched whenever the synced row's ack changes, and notes that reminders are paused while acknowledged. The alerts inbox sorts what still needs someone above what someone is on, counts acknowledged incidents apart from firing ones, and gives every open row an acknowledge button. The alerts lab shows both states. --- apps/web/src/lab/alerts-fixture.ts | 9 +- apps/web/src/lab/alerts-lab/view-page.ts | 87 ++++++++++--- apps/web/src/lab/alerts-lab/view.ts | 20 ++- apps/web/src/page/alerts/hub/command.ts | 16 ++- .../web/src/page/alerts/hub/hub.scene.test.ts | 52 +++++++- apps/web/src/page/alerts/hub/message.ts | 15 ++- apps/web/src/page/alerts/hub/model.ts | 9 +- apps/web/src/page/alerts/hub/update.ts | 39 ++++++ apps/web/src/page/alerts/hub/view-inbox.ts | 81 +++++++++++-- apps/web/src/page/alerts/hub/view.ts | 8 +- apps/web/src/page/alerts/incident/command.ts | 22 +++- .../alerts/incident/incident.scene.test.ts | 114 +++++++++++++++++- apps/web/src/page/alerts/incident/message.ts | 11 +- apps/web/src/page/alerts/incident/model.ts | 6 +- apps/web/src/page/alerts/incident/update.ts | 59 +++++++++ apps/web/src/page/alerts/incident/view.ts | 57 +++++++-- apps/web/src/page/alerts/shared/commands.ts | 41 ++++++- .../src/alerts/incident-timeline.test.ts | 76 +++++++++++- .../web-data/src/alerts/incident-timeline.ts | 77 +++++++++++- 19 files changed, 738 insertions(+), 61 deletions(-) diff --git a/apps/web/src/lab/alerts-fixture.ts b/apps/web/src/lab/alerts-fixture.ts index 9e7ccfa707..b49ffcfa3c 100644 --- a/apps/web/src/lab/alerts-fixture.ts +++ b/apps/web/src/lab/alerts-fixture.ts @@ -387,6 +387,8 @@ interface IncidentSpec { readonly endAgo: number | null readonly value: number readonly held?: boolean + /** Acknowledged this long ago, from Slack. */ + readonly ackedAgo?: number } const OPEN_INCIDENTS: ReadonlyArray = [ @@ -405,6 +407,7 @@ const OPEN_INCIDENTS: ReadonlyArray = [ startAgo: 41 * MIN, endAgo: null, value: 1920, + ackedAgo: 33 * MIN, }, { id: INCIDENT.ingest, @@ -542,10 +545,10 @@ const incidentFrom = (spec: IncidentSpec, rule: AlertRuleDocument, nowMs: number lastNotifiedAt: iso(nowMs - (open ? 8 * MIN : (spec.endAgo ?? 0))), holdReason: spec.held ? "volume_collapsed" : null, heldSince: spec.held ? iso(nowMs - 58 * MIN) : null, - acknowledgedAt: null, + acknowledgedAt: spec.ackedAgo === undefined ? null : iso(nowMs - spec.ackedAgo), acknowledgedByUserId: null, - acknowledgedByName: null, - acknowledgedVia: null, + acknowledgedByName: spec.ackedAgo === undefined ? null : "Dana Kim", + acknowledgedVia: spec.ackedAgo === undefined ? null : "slack", errorIssueId: null, }) } diff --git a/apps/web/src/lab/alerts-lab/view-page.ts b/apps/web/src/lab/alerts-lab/view-page.ts index 32716fe69d..ee4cec7866 100644 --- a/apps/web/src/lab/alerts-lab/view-page.ts +++ b/apps/web/src/lab/alerts-lab/view-page.ts @@ -3,9 +3,14 @@ import { buildIncidentTimeline } from "@maple/web-data/alerts/incident-timeline" import type { Html, HtmlBuilder } from "foldkit/html" import { isChartRuntimeReady } from "@maple/views/charts/chart-runtime" import { ResolveThemeColors } from "@maple/views/charts/theme-colors" -import { headerTitle, incidentBody } from "../../page/alerts/incident/view" +import { acknowledgeAction, headerTitle, incidentBody } from "../../page/alerts/incident/view" +import { AlertIncidentDocument, IsoDateTimeString } from "@maple/domain/http" +import { V2AlertIncidentEvent } from "@maple/domain/http/v2" +import { Array as Arr, Schema } from "effect" +import { hstack } from "@maple/views/ui/stack" import { providerOf } from "../../page/alerts/shared/providers" import { RULE_CHART_TOKENS } from "../../page/alerts/shared/rule-chart" +import { isoMs } from "../../page/alerts/shared/time" import { dashboardPage, pageHeader } from "../../page/kit/dashboard-page" import { AppRoute, hrefOf } from "../../route" import { linkButton } from "@maple/views/ui/button" @@ -16,11 +21,45 @@ import { FRAME_CLASS, hubSections, section } from "./view" type H = HtmlBuilder +/** The fixture incident as Dana acknowledged it from Slack, a minute after its last notification. */ +const acknowledgedByDana = ( + fixture: AlertsLabFixture, +): Readonly<{ incident: AlertIncidentDocument; activity: ReadonlyArray }> => { + const { incident, deliveries } = fixture.incident + const lastNotifiedMs = Arr.reduce(deliveries, isoMs(incident.firstTriggeredAt), (latest, delivery) => + Math.max(latest, isoMs(delivery.attemptedAt ?? delivery.scheduledAt)), + ) + const acknowledgedAt = new Date(lastNotifiedMs + 60_000).toISOString() + return { + incident: new AlertIncidentDocument({ + ...incident, + acknowledgedAt: Schema.decodeSync(IsoDateTimeString)(acknowledgedAt), + acknowledgedByUserId: null, + acknowledgedByName: "Dana Kim", + acknowledgedVia: "slack", + }), + activity: [ + Schema.decodeSync(V2AlertIncidentEvent)({ + id: "evt_tC4d9V79DCDzgbGKhAnff9", + object: "alert_incident_event", + incident_id: "inc_tC4d9V79DCDzgbGKhAnff9", + type: "acknowledged", + actor: { user_id: null, name: "Dana Kim" }, + source: "slack", + created_at: acknowledgedAt, + }), + ], + } +} + /** `IncidentFrame`: `IncidentHeader` (a page header) over `IncidentBody`, from the fixture. */ -const incidentFrame = (h: H, model: Model, fixture: AlertsLabFixture): Html => { - const { incident, rule, ruleIncidents, states, preview, range, deliveries, investigation } = - fixture.incident +const incidentFrame = (h: H, model: Model, fixture: AlertsLabFixture, isAcknowledged: boolean): Html => { + const { rule, ruleIncidents, states, preview, range, deliveries, investigation } = fixture.incident + const { incident, activity } = isAcknowledged + ? acknowledgedByDana(fixture) + : { incident: fixture.incident.incident, activity: [] } const entries = buildIncidentTimeline({ + activity, incident, deliveries, investigation, @@ -37,17 +76,26 @@ const incidentFrame = (h: H, model: Model, fixture: AlertsLabFixture): Html => { pageHeader( h, headerTitle(h, incident, rule, model.nowMs, model.timeZone), - linkButton( - h, - { - variant: "outline", - size: "sm", - href: hrefOf( - AppRoute.Investigation({ id: String(investigation.id), search: {} }), - ), - }, - [icon(h, "ChatBubbleSparkleIcon"), "Open investigation"], - ), + hstack(h, { gap: "2", wrap: true }, [ + // The lab has no API: the button shows its state and its click goes nowhere. + ...acknowledgeAction(h, { + incident, + isLoading: false, + onAcknowledge: () => + Message.ResolvedThemeColors({ colors: model.themeColors ?? {} }), + }), + linkButton( + h, + { + variant: "outline", + size: "sm", + href: hrefOf( + AppRoute.Investigation({ id: String(investigation.id), search: {} }), + ), + }, + [icon(h, "ChatBubbleSparkleIcon"), "Open investigation"], + ), + ]), ), ], ), @@ -69,7 +117,9 @@ const incidentFrame = (h: H, model: Model, fixture: AlertsLabFixture): Html => { entries, isEntriesLoading: false, isEntriesFailed: false, - chartId: "alerts-lab-incident-chart", + chartId: isAcknowledged + ? "alerts-lab-incident-chart-acknowledged" + : "alerts-lab-incident-chart", chart: model.chart, toChartMessage: (message) => Message.GotChartMessage({ message }), colors: model.themeColors ?? {}, @@ -102,7 +152,10 @@ const pageBody = (h: H, model: Model): Html => { toParentMessage: (message) => Message.GotHubMessage({ message }), }), section(h, "incident", "Incident: critical error rate on maple-chat", [ - incidentFrame(h, model, fixture), + incidentFrame(h, model, fixture, false), + ]), + section(h, "incident-acknowledged", "Incident: acknowledged from Slack", [ + incidentFrame(h, model, fixture, true), ]), ], ), diff --git a/apps/web/src/lab/alerts-lab/view.ts b/apps/web/src/lab/alerts-lab/view.ts index 7a11e92ebf..9c7c678b6b 100644 --- a/apps/web/src/lab/alerts-lab/view.ts +++ b/apps/web/src/lab/alerts-lab/view.ts @@ -7,7 +7,8 @@ import { alertsEmptyState } from "../../page/alerts/hub/view-empty" import { inboxTab } from "../../page/alerts/hub/view-inbox" import { DESTINATION_ROW_GRID, destinationRow, HEALTH_WINDOW } from "../../page/alerts/hub/view-routing" import { rulesTab } from "../../page/alerts/hub/view-rules" -import type { Message as HubMessage } from "../../page/alerts/hub/message" +import { Message as HubMessageUnion, type Message as HubMessage } from "../../page/alerts/hub/message" +import type { AlertIncidentId } from "@maple/domain/http" import type { Model as HubModel } from "../../page/alerts/hub/model" import { underlineTabClass, underlineTabStrip } from "../../page/kit/underline-tabs" import { button } from "@maple/views/ui/button" @@ -110,6 +111,12 @@ type HubInputs = Readonly<{ nowMs: number }> +const inboxAcknowledge = (hub: HubModel) => ({ + onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => + HubMessageUnion.ClickedAcknowledgeIncident({ incidentId, acknowledged }), + pendingIds: hub.acknowledgingIncidentIds, +}) + /** The sections the hub's views draw, run against the lab's hub Model. */ export const hubSections = Submodel.defineView((hub, inputs, h) => { const { fixture } = inputs @@ -117,11 +124,16 @@ export const hubSections = Submodel.defineView( return h.div( [h.Class("space-y-12")], [ - section(h, "inbox", "Inbox: two firing, one held", [ - alertsFrame(h, "inbox", fixture.overview, inboxTab(h, fixture.overview)), + section(h, "inbox", "Inbox: one firing, one acknowledged, one held", [ + alertsFrame( + h, + "inbox", + fixture.overview, + inboxTab(h, fixture.overview, inboxAcknowledge(hub)), + ), ]), section(h, "inbox-quiet", "Inbox: nothing firing, two blind spots", [ - alertsFrame(h, "inbox", fixture.quiet, inboxTab(h, fixture.quiet)), + alertsFrame(h, "inbox", fixture.quiet, inboxTab(h, fixture.quiet, inboxAcknowledge(hub))), ]), section(h, "rules", "Rules", [ alertsFrame( diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index c60a1f4b36..71a8a5515a 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -1,5 +1,5 @@ import { UptimeMonitorId } from "@maple/domain" -import { AlertDestinationId, AlertRuleId } from "@maple/domain/http" +import { AlertDestinationId, AlertIncidentId, AlertRuleId } 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" @@ -16,6 +16,7 @@ import { setRuleEnabled, testDestination, updateDestination, + setIncidentAcknowledged, } from "../shared/commands" import { deleteMonitor, listMonitors, setMonitorEnabled } from "../shared/uptime-commands" import { Message } from "./message" @@ -68,6 +69,19 @@ export const ToggleRule = Command.define("ToggleAlertRule", { ), }) +/** The inbox row's acknowledge button. */ +export const AcknowledgeIncident = Command.define("AcknowledgeAlertIncident", { + args: { incidentId: AlertIncidentId, acknowledged: Schema.Boolean }, + messages: [Message.CompletedAcknowledgeIncident], + execute: ({ incidentId, acknowledged }) => + setIncidentAcknowledged(incidentId, acknowledged).pipe( + Effect.result, + Effect.map((result) => + Message.CompletedAcknowledgeIncident({ incidentId, acknowledged, result }), + ), + ), +}) + /** `DestinationManager.toggle` and the dialog's save: PATCH the destination. */ export const UpdateDestination = Command.define("UpdateAlertDestination", { args: { destinationId: AlertDestinationId, payload: V2AlertDestinationUpdateParams }, 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 d233915929..a577990ed0 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" -import { AlertDestinationId, AlertRuleId } from "@maple/domain/http" +import { AlertDestinationId, AlertIncidentId, AlertRuleId } from "@maple/domain/http" import { Result, Schema, Array as Arr } from "effect" import { click, @@ -94,6 +94,56 @@ describe("alerts hub", () => { ) }) + test("the inbox acknowledges a firing incident and moves it out of the firing count", () => { + const incidentId = Schema.decodeSync(AlertIncidentId)(incidentRow().id) + const acknowledged = pageUpdate( + loaded(), + Message.UpdatedIncidents({ + orgId: ORG, + rows: [ + incidentRow({ + rule_name: "Payment p95 latency", + acknowledged_at: new Date(NOW - 60_000).toISOString(), + acknowledged_by_name: "Dana Kim", + acknowledged_via: "slack", + }), + ], + }), + ).model + scene( + { update: pageUpdate, view }, + given(acknowledged), + expectView(text("0 firing")).toExist(), + expectView(text(/1 acknowledged/)).toExist(), + expectView(text(/^Acknowledged by Dana Kim ·/)).toExist(), + expectView(role("button", { name: "Unacknowledge P95 latency 1.84s on all" })).toExist(), + ) + const clicked = pageUpdate( + loaded(), + Message.ClickedAcknowledgeIncident({ incidentId, acknowledged: true }), + ) + expect(names(clicked.commands)).toEqual(["AcknowledgeAlertIncident"]) + expect(clicked.model.acknowledgingIncidentIds).toEqual([incidentId]) + const twice = pageUpdate( + clicked.model, + Message.ClickedAcknowledgeIncident({ incidentId, acknowledged: true }), + ) + expect(twice.commands ?? []).toHaveLength(0) + const failed = pageUpdate( + clicked.model, + Message.CompletedAcknowledgeIncident({ + incidentId, + acknowledged: true, + result: Result.fail(toLoadFailure(new Error("boom"))), + }), + ) + expect(failed.model.acknowledgingIncidentIds).toEqual([]) + expect(failed.outMessage).toMatchObject({ + _tag: "RequestedToast", + title: "Couldn't acknowledge the incident", + }) + }) + test("a live sync that stops before its first answer shows the failure with a retry, not a skeleton", () => { const clocked = pageUpdate(entered().model, Message.ReadClock({ nowMs: NOW })).model const failed = pageUpdate(clocked, Message.FailedLoadAlerts({ orgId: ORG })).model diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 8423043357..093bf2a416 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -1,7 +1,12 @@ import * as Policy from "@maple/domain/policy" import { UptimeMonitorId } from "@maple/domain" -import { AlertDeliveryEventDocument, AlertDestinationId, AlertRuleId } from "@maple/domain/http" -import { V2UptimeMonitor } from "@maple/domain/http/v2" +import { + AlertDeliveryEventDocument, + AlertDestinationId, + AlertIncidentId, + AlertRuleId, +} from "@maple/domain/http" +import { V2AlertIncident, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -46,6 +51,12 @@ export const Message = defineMessageUnion({ ClickedShowAllRules: {}, ClickedToggleRule: { ruleId: AlertRuleId, isEnabled: Schema.Boolean }, CompletedToggleRule: { ruleId: AlertRuleId, result: outcome(Schema.Void) }, + ClickedAcknowledgeIncident: { incidentId: AlertIncidentId, acknowledged: Schema.Boolean }, + CompletedAcknowledgeIncident: { + incidentId: AlertIncidentId, + acknowledged: Schema.Boolean, + result: outcome(V2AlertIncident), + }, ClickedToggleDestination: { destinationId: AlertDestinationId }, CompletedUpdateDestination: { destinationId: AlertDestinationId, result: outcome(Schema.Void) }, ClickedTestDestination: { destinationId: AlertDestinationId }, diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 6b460b6489..994780c32a 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -1,6 +1,11 @@ import * as Policy from "@maple/domain/policy" import { UptimeMonitorId } from "@maple/domain" -import { AlertDeliveryEventDocument, AlertDestinationId, AlertRuleId } from "@maple/domain/http" +import { + AlertDeliveryEventDocument, + AlertDestinationId, + AlertIncidentId, + AlertRuleId, +} from "@maple/domain/http" import { V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, @@ -52,6 +57,8 @@ export const Model = Schema.Struct({ isLoadFailed: Schema.Boolean, /** Routing: destinations with a test or delete in flight, and the one awaiting delete confirmation. */ testingDestinationIds: Schema.Array(AlertDestinationId), + /** Inbox: incidents with an acknowledge or unacknowledge request in flight. */ + acknowledgingIncidentIds: Schema.Array(AlertIncidentId), deletingDestinationIds: Schema.Array(AlertDestinationId), confirmDeleteId: Schema.NullOr(AlertDestinationId), deleteDialog: Dialog.Model, diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 7319acbf6a..95179c7f3c 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -18,6 +18,7 @@ import { type PageContext, PageOutMessage, type PageReturn } from "../../contrac import { alertsHref, createHref, ruleHref } from "../shared/links" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { + AcknowledgeIncident, DeleteDestination, DeleteMonitor, FetchHubMonitors, @@ -31,6 +32,7 @@ import { ToggleRule, UpdateDestination, } from "./command" +import { withAcknowledgement } from "../shared/commands" import { resolveStatus, resolveTab } from "./derive" import { Message, type RuleBucket } from "./message" import type { Model } from "./model" @@ -138,6 +140,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { hasToggleFailed: false, isLoadFailed: false, testingDestinationIds: [], + acknowledgingIncidentIds: [], deletingDestinationIds: [], confirmDeleteId: null, deleteDialog: Dialog.init(DELETE_DIALOG_ID, "alert-dialog"), @@ -449,6 +452,42 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedToggleRule: ({ result }) => ({ model: { ...model, togglingRuleId: null, hasToggleFailed: Result.isFailure(result) }, }), + ClickedAcknowledgeIncident: ({ incidentId, acknowledged }) => + model.acknowledgingIncidentIds.includes(incidentId) + ? { model } + : { + model: { + ...model, + acknowledgingIncidentIds: [...model.acknowledgingIncidentIds, incidentId], + }, + commands: [AcknowledgeIncident({ incidentId, acknowledged })], + }, + CompletedAcknowledgeIncident: ({ incidentId, acknowledged, result }) => { + const settled = { + ...model, + acknowledgingIncidentIds: withoutId(model.acknowledgingIncidentIds, incidentId), + } + return Result.match(result, { + onSuccess: (incident) => ({ + model: { + ...settled, + incidents: + settled.incidents === null + ? null + : withAcknowledgement(settled.incidents, incident), + }, + }), + onFailure: (failure) => ({ + model: settled, + outMessage: errorToast( + acknowledged + ? "Couldn't acknowledge the incident" + : "Couldn't unacknowledge the incident", + failure, + ), + }), + }) + }, ClickedToggleDestination: ({ destinationId }) => toggleDestination(model, destinationId), CompletedUpdateDestination: ({ result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 2a357fc55c..d4ee172504 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -1,4 +1,5 @@ -import type { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" +import type { AlertIncidentDocument, AlertIncidentId, AlertRuleDocument } from "@maple/domain/http" +import { acknowledgedLine } from "@maple/web-data/alerts/incident-timeline" import { countLabel, EMPTY_VALUE } from "@maple/ui/lib/format" import { formatRelativeShortFrom } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" @@ -21,12 +22,13 @@ import { incidentValues, } from "@maple/web-data/alerts/overview-derive" import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" import { badge } from "@maple/views/ui/display" import { emptyMessage } from "@maple/views/ui/empty" import { heading } from "@maple/views/ui/heading" import { eyebrow } from "@maple/views/ui/eyebrow" import { icon } from "@maple/views/ui/icon" -import { rowLink } from "@maple/views/ui/table" +import { ROW_LINK_LIFT, rowLink } from "@maple/views/ui/table" import { severityBadge } from "../shared/badges" import { incidentHref, ruleHref } from "../shared/links" import { sortIncidents } from "../shared/sort" @@ -41,7 +43,7 @@ import { Array as Arr } from "effect" // `InboxTab` and `HeadroomList`: the hub's Inbox tab. const HOUR_MS = 60 * 60 * 1000 -const INCIDENT_GRID = "grid grid-cols-[minmax(0,1fr)_150px_90px] items-center gap-4" +const INCIDENT_GRID = "grid grid-cols-[minmax(0,1fr)_150px_90px_120px] items-center gap-4" const MAX_LANES = 8 const MAX_HEADROOM_ROWS = 8 const HEADROOM_GRID = "grid grid-cols-[minmax(0,220px)_minmax(0,1fr)_130px_44px] items-center gap-4" @@ -49,18 +51,33 @@ const SECTIONS_GRID = "grid gap-8 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]" type Range = Readonly<{ min: number; max: number }> -export const inboxTab = (h: HtmlBuilder, data: AlertsOverviewReady): Html => { +/** The inbox rows' acknowledge button: the message it sends, and the rows with a request in flight. */ +export type InboxAcknowledge = Readonly<{ + onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => M + pendingIds: ReadonlyArray +}> + +const isAcknowledged = (incident: AlertIncidentDocument) => incident.acknowledgedAt !== null + +export const inboxTab = ( + h: HtmlBuilder, + data: AlertsOverviewReady, + acknowledge: InboxAcknowledge, +): Html => { const { rules, incidents, openIncidents, derivedByRuleId, statesByRule, timelineRange } = data const now = timelineRange.max const all = sortIncidents(openIncidents) + // What still needs someone first, then what someone is on, then what waits on data. const sorted = [ - ...Arr.filter(all, (i) => i.holdReason === null), + ...Arr.filter(all, (i) => i.holdReason === null && !isAcknowledged(i)), + ...Arr.filter(all, (i) => i.holdReason === null && isAcknowledged(i)), ...Arr.filter(all, (i) => i.holdReason !== null), ] const spots = deriveBlindSpots(rules, derivedByRuleId) const lanes = fireLanes(incidents, timelineRange) const held = Arr.filter(sorted, (i) => i.holdReason !== null).length - const firing = sorted.length - held + const acknowledged = Arr.filter(sorted, (i) => i.holdReason === null && isAcknowledged(i)).length + const firing = sorted.length - held - acknowledged const enabledRuleIds = Arr.map( Arr.filter(rules, (r) => r.enabled), (r) => r.id, @@ -93,6 +110,15 @@ export const inboxTab = (h: HtmlBuilder, data: AlertsOverviewReady): Html heading(h, { level: 2, variant: "title", className: "tabular-nums" }, [ isQuiet ? "Nothing firing" : `${firing} firing`, ...(isQuiet ? [quietStreak(h, incidents, now)] : []), + ...(acknowledged > 0 + ? [ + typography(h, { tone: "muted", weight: "normal" }, [ + " · ", + String(acknowledged), + " acknowledged", + ]), + ] + : []), ...(held > 0 ? [ typography(h, { tone: "muted", weight: "normal" }, [ @@ -115,7 +141,7 @@ export const inboxTab = (h: HtmlBuilder, data: AlertsOverviewReady): Html ]) : sections, ] - : [openIncidentsList(h, sorted, now), sections]), + : [openIncidentsList(h, sorted, now, acknowledge), sections]), ], ) } @@ -138,10 +164,11 @@ const openIncidentsList = ( h: HtmlBuilder, incidents: ReadonlyArray, now: number, + acknowledge: InboxAcknowledge, ) => borderedBox(h, { overflow: "x-auto" }, [ h.div( - [h.Class("min-w-[560px]")], + [h.Class("min-w-[680px]")], [ h.div( [ @@ -156,18 +183,25 @@ const openIncidentsList = ( h.span([], ["Open · ", String(incidents.length)]), h.span([], ["Now / threshold"]), h.span([], ["Open for"]), + h.span([h.Class("sr-only")], ["Acknowledge"]), ], ), h.ul( [h.Class("divide-y")], - Arr.map(incidents, (incident) => openIncidentRow(h, incident, now)), + Arr.map(incidents, (incident) => openIncidentRow(h, incident, now, acknowledge)), ), ], ), ]) -const openIncidentRow = (h: HtmlBuilder, incident: AlertIncidentDocument, now: number): Html => { +const openIncidentRow = ( + h: HtmlBuilder, + incident: AlertIncidentDocument, + now: number, + acknowledge: InboxAcknowledge, +): Html => { const isHeld = incident.holdReason !== null + const acked = acknowledgedLine(incident) const tone = incident.severity === "critical" ? "text-severity-error" : "text-severity-warn" return h.li( [ @@ -180,7 +214,7 @@ const openIncidentRow = (h: HtmlBuilder, incident: AlertIncidentDocument, h.Class( cn( "absolute inset-y-2 left-0 w-0.5 rounded-full", - isHeld + isHeld || acked !== null ? "bg-foreground-subtle" : incident.severity === "critical" ? "bg-severity-error" @@ -206,7 +240,9 @@ const openIncidentRow = (h: HtmlBuilder, incident: AlertIncidentDocument, ), ]), typography(h, { size: "xs", tone: "muted", truncate: true, as: "p" }, [ - incidentSubline(incident), + acked === null + ? incidentSubline(incident) + : `${acked} · ${incidentSubline(incident)}`, ]), ], ), @@ -219,6 +255,27 @@ const openIncidentRow = (h: HtmlBuilder, incident: AlertIncidentDocument, typography(h, { size: "xs", mono: true, numeric: true }, [ formatAlertDuration(incident.firstTriggeredAt, msIso(now)), ]), + // Lifted above the row's stretched link, so the click acknowledges instead of navigating. + h.div( + [h.Class(cn(ROW_LINK_LIFT, "flex justify-end"))], + [ + button( + h, + { + variant: acked === null ? "outline" : "ghost", + size: "xs", + isLoading: acknowledge.pendingIds.includes(incident.id), + attributes: [ + h.OnClick(acknowledge.onAcknowledge(incident.id, acked === null)), + h.AriaLabel( + `${acked === null ? "Acknowledge" : "Unacknowledge"} ${incidentTitle(incident)}`, + ), + ], + }, + acked === null ? [icon(h, "CheckIcon"), "Acknowledge"] : ["Unacknowledge"], + ), + ], + ), ], ) } diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 6c1cd9174a..52c7a737be 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -224,7 +224,13 @@ const hubBody = ( (incident) => incident.signalType === "uptime", ) if (tab === "inbox") - return data.rules.length === 0 && !hasUptimeIncidents ? emptyState : inboxTab(h, data) + return data.rules.length === 0 && !hasUptimeIncidents + ? emptyState + : inboxTab(h, data, { + onAcknowledge: (incidentId, acknowledged) => + Message.ClickedAcknowledgeIncident({ incidentId, acknowledged }), + pendingIds: model.acknowledgingIncidentIds, + }) return rulesTab(h, model, { data, destinations, diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index 2e03a05593..54c63eb1c4 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -4,7 +4,7 @@ import { Clock, Effect, Schema } from "effect" import { Command } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" -import { listDeliveries } from "../shared/commands" +import { listDeliveries, listIncidentActivity, setIncidentAcknowledged } from "../shared/commands" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { Message } from "./message" @@ -45,6 +45,26 @@ export const FetchIncidentDeliveries = Command.define("FetchAlertIncidentDeliver ), }) +export const FetchIncidentActivity = Command.define("FetchAlertIncidentActivity", { + args: { key: Schema.String, incidentId: AlertIncidentId }, + messages: [Message.CompletedFetchActivity], + execute: ({ key, incidentId }) => + listIncidentActivity(incidentId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchActivity({ key, result })), + ), +}) + +export const SetIncidentAcknowledged = Command.define("SetAlertIncidentAcknowledged", { + args: { incidentId: AlertIncidentId, acknowledged: Schema.Boolean }, + messages: [Message.CompletedSetAcknowledged], + execute: ({ incidentId, acknowledged }) => + setIncidentAcknowledged(incidentId, acknowledged).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSetAcknowledged({ acknowledged, result })), + ), +}) + /** `investigations.list({ incident_kind: "alert", incident_id, limit: 1 })`: the incident's one. */ export const FetchIncidentInvestigation = Command.define("FetchAlertIncidentInvestigation", { args: { key: Schema.String, incidentId: AlertIncidentId }, 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 85b8e1ffdd..1c6f11fb1f 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -1,5 +1,5 @@ import { OrgId } from "@maple/domain" -import { AlertRulePreviewResponse } from "@maple/domain/http" +import { AlertRulePreviewResponse, AlertIncidentId } from "@maple/domain/http" import { Result, Schema, Array as Arr } from "effect" import { click, @@ -13,7 +13,8 @@ import { text, withViewInputs, } from "foldkit/scene" -import { CreateInvestigation } from "./command" +import { CreateInvestigation, FetchIncidentActivity, SetIncidentAcknowledged } from "./command" +import { V2AlertIncident } from "@maple/domain/http/v2" import { describe, expect, test } from "vitest" import * as Chart from "@maple/views/charts/chart" import { MountChart } from "@maple/views/charts/chart-mounts" @@ -311,4 +312,113 @@ describe("alert incident", () => { PageOutMessage.RequestedNavigation({ url: "/investigations/inv-1", isReplace: false }), ) }) + + const ackedIncident = (acknowledgedAt: string | null) => + Schema.decodeSync(V2AlertIncident)({ + id: "inc_tC4d9V79DCDzgbGKhAnff9", + object: "alert_incident", + rule_id: "alrt_gU26thvJECdQvu54Ad9jiz", + rule_name: "x", + group_key: null, + signal_type: "error_rate", + severity: "critical", + peak_severity: null, + status: "open", + comparator: "gt", + threshold: 1, + threshold_upper: null, + first_triggered_at: new Date(NOW).toISOString(), + last_triggered_at: new Date(NOW).toISOString(), + resolved_at: null, + last_observed_value: null, + last_sample_count: null, + dedupe_key: "d", + last_delivered_event_type: null, + last_notified_at: null, + hold_reason: null, + held_since: null, + acknowledged_at: acknowledgedAt, + acknowledged_by: acknowledgedAt === null ? null : { user_id: "user_dana", name: "Dana Kim" }, + acknowledged_via: acknowledgedAt === null ? null : "web", + error_issue_id: null, + }) + + test("Acknowledge marks the open incident and shows who is on it; Unacknowledge takes it back", () => { + const loaded = Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), + ], + init(route(), context).model, + (model, message) => step(model, message).model, + ) + const ackedAt = new Date(NOW - 5 * 60_000).toISOString() + // The response carries the public id; the row patch matches it by the internal id. + const response = { ...ackedIncident(ackedAt), id: Schema.decodeSync(AlertIncidentId)(INCIDENT_ID) } + scene( + { update: pageUpdate, view }, + given(loaded), + themeResolved(), + click(role("button", { name: "Acknowledge" })), + Command.resolve( + SetIncidentAcknowledged, + Message.CompletedSetAcknowledged({ acknowledged: true, result: Result.succeed(response) }), + ), + Command.resolve( + FetchIncidentActivity, + Message.CompletedFetchActivity({ key: "", result: Result.succeed([]) }), + ), + expectView(text(/Acknowledged by Dana Kim at/)).toExist(), + expectView(role("button", { name: "Unacknowledge" })).toExist(), + ) + const clicked = step(loaded, Message.ClickedAcknowledge({ acknowledged: true })) + expect(names(clicked.commands)).toEqual(["SetAlertIncidentAcknowledged"]) + // A second click while the request runs sends nothing. + expect( + step(clicked.model, Message.ClickedAcknowledge({ acknowledged: true })).commands ?? [], + ).toHaveLength(0) + const done = step( + clicked.model, + Message.CompletedSetAcknowledged({ acknowledged: true, result: Result.succeed(response) }), + ) + // The acknowledgement changed, so its history is read again. + expect(names(done.commands)).toContain("FetchAlertIncidentActivity") + const failed = step( + clicked.model, + Message.CompletedSetAcknowledged({ + acknowledged: true, + result: Result.fail(toLoadFailure(new Error("boom"))), + }), + ) + expect(failed.outMessage).toMatchObject({ + _tag: "RequestedToast", + title: "Couldn't acknowledge the incident", + }) + expect(failed.model.isUpdatingAcknowledgement).toBe(false) + }) + + test("a resolved incident offers no acknowledge button", () => { + const loaded = Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ + orgId: ORG, + rows: [ + incidentRow({ + status: "resolved", + resolved_at: new Date(NOW - 60_000).toISOString(), + }), + ], + }), + ], + init(route(), context).model, + (model, message) => step(model, message).model, + ) + scene( + { update: pageUpdate, view }, + given(loaded), + themeResolved(), + expectView(role("button", { name: "Acknowledge" })).not.toExist(), + ) + }) }) diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index 9aff3da93a..44ab859589 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -1,5 +1,5 @@ import { AlertDeliveryEventDocument, AlertRulePreviewResponse } from "@maple/domain/http" -import { V2Investigation } from "@maple/domain/http/v2" +import { V2AlertIncident, V2AlertIncidentEvent, V2Investigation } from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -33,7 +33,16 @@ export const Message = defineMessageUnion({ key: Schema.String, result: Schema.Result(Schema.NullOr(V2Investigation), LoadFailure), }, + CompletedFetchActivity: { + key: Schema.String, + result: Schema.Result(Schema.Array(V2AlertIncidentEvent), LoadFailure), + }, ClickedInvestigate: {}, + ClickedAcknowledge: { acknowledged: Schema.Boolean }, + CompletedSetAcknowledged: { + acknowledged: Schema.Boolean, + result: Schema.Result(V2AlertIncident, LoadFailure), + }, CompletedCreateInvestigation: { result: Schema.Result(Schema.String, LoadFailure) }, CompletedLoadChartRuntime: {}, FailedLoadChartRuntime: { reason: Schema.String }, diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index e8d0dadd7d..c233262694 100644 --- a/apps/web/src/page/alerts/incident/model.ts +++ b/apps/web/src/page/alerts/incident/model.ts @@ -1,5 +1,5 @@ import { AlertDeliveryEventDocument } from "@maple/domain/http" -import { V2Investigation } from "@maple/domain/http/v2" +import { V2AlertIncidentEvent, V2Investigation } from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -30,6 +30,10 @@ export const Model = Schema.Struct({ checks: KeyedSchema(CheckSummary, LoadFailure), deliveries: KeyedSchema(Schema.Array(AlertDeliveryEventDocument), LoadFailure), investigation: KeyedSchema(Schema.NullOr(V2Investigation), LoadFailure), + /** Acknowledgement history, refetched whenever the incident's acknowledgement changes. */ + activity: KeyedSchema(Schema.Array(V2AlertIncidentEvent), LoadFailure), + /** An acknowledge or unacknowledge request is running. */ + isUpdatingAcknowledgement: Schema.Boolean, /** `useAsyncAction`: the create request is running, so repeat clicks cannot start duplicates. */ isStartingInvestigation: Schema.Boolean, chart: Chart.Model, diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index dbd17b7557..dcc541c3ed 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -17,12 +17,15 @@ import { AppRoute, hrefOf } from "../../../route" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { CreateInvestigation, + FetchIncidentActivity, FetchIncidentChecks, FetchIncidentDeliveries, FetchIncidentInvestigation, FetchIncidentPreview, ReadIncidentClock, + SetIncidentAcknowledged, } from "./command" +import { withAcknowledgement } from "../shared/commands" import { incidentViewOf, minuteOf, ruleStatesOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" @@ -42,6 +45,8 @@ export const init = (route: IncidentRoute, _context: PageContext): Return => ({ checks: Keyed.idle(), deliveries: Keyed.idle(), investigation: Keyed.idle(), + activity: Keyed.idle(), + isUpdatingAcknowledgement: false, isStartingInvestigation: false, chart: Chart.init(), chartRuntime: { _tag: "Loading" }, @@ -75,7 +80,23 @@ const followData = (model: Model, context: PageContext): Return => const investigationKey = requestKey(context.orgId, "investigations.list", { query: { incident_kind: "alert", incident_id: incidentId, limit: 1 }, }) + // Keyed on the acknowledgement, so an ack from anywhere (Slack, the API) refetches the history. + const activityKey = requestKey(context.orgId, "alertIncidents.listEvents", { + id: incidentId, + acknowledgedAt: view.incident.acknowledgedAt, + }) const steps: ReadonlyArray<{ patch: Partial; commands: Commands }> = [ + ...Option.toArray( + Option.map( + sameKey(model.activity, activityKey) + ? Option.none() + : Keyed.request(model.activity, activityKey, "Load"), + (activity) => ({ + patch: { activity }, + commands: [FetchIncidentActivity({ key: activityKey, incidentId })], + }), + ), + ), ...Option.toArray( Option.map( sameKey(model.deliveries, deliveriesKey) @@ -242,6 +263,44 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchDeliveries: ({ key, result }) => ({ model: { ...model, deliveries: Keyed.accept(model.deliveries, key, result) }, }), + CompletedFetchActivity: ({ key, result }) => ({ + model: { ...model, activity: Keyed.accept(model.activity, key, result) }, + }), + ClickedAcknowledge: ({ acknowledged }) => + model.isUpdatingAcknowledgement + ? { model } + : Option.match(incidentViewOf(model), { + onNone: () => ({ model }), + onSome: ({ incident }) => ({ + model: { ...model, isUpdatingAcknowledgement: true }, + commands: [SetIncidentAcknowledged({ incidentId: incident.id, acknowledged })], + }), + }), + CompletedSetAcknowledged: ({ acknowledged, result }) => + Result.match(result, { + onSuccess: (incident) => + followData( + { + ...model, + isUpdatingAcknowledgement: false, + incidents: + model.incidents === null + ? null + : withAcknowledgement(model.incidents, incident), + }, + context, + ), + onFailure: (failure) => ({ + model: { ...model, isUpdatingAcknowledgement: false }, + outMessage: PageOutMessage.RequestedToast({ + type: "error", + title: acknowledged + ? "Couldn't acknowledge the incident" + : "Couldn't unacknowledge the incident", + description: failure.message, + }), + }), + }), CompletedFetchInvestigation: ({ key, result }) => ({ model: { ...model, investigation: Keyed.accept(model.investigation, key, result) }, }), diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 112d0c880d..43168d844a 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -13,6 +13,7 @@ import { signalLabels, } from "@maple/web-data/alerts/form-utils" import { + acknowledgedLine, buildIncidentTimeline, conditionPhrase, holdReasonLabel, @@ -50,6 +51,7 @@ import type { Model } from "./model" import { incidentRail } from "./view-rail" import { incidentTimeline } from "./view-timeline" import { typography } from "@maple/views/ui/typography" +import { hstack } from "@maple/views/ui/stack" type H = HtmlBuilder @@ -60,8 +62,14 @@ const statusLine = (incident: AlertIncidentDocument, now: number, timeZone: stri : `${formatDateInTimezone(iso, { timeZone })} ${formatTimeInTimezone(iso, { timeZone })}` if (incident.status === "resolved") return `Resolved ${incident.resolvedAt ? at(incident.resolvedAt) : ""} · lasted ${formatAlertDuration(incident.firstTriggeredAt, incident.resolvedAt)}` - if (incident.holdReason != null) return `Waiting on data · ${holdReasonLabel[incident.holdReason]}` - return `Open ${formatAlertDuration(incident.firstTriggeredAt, msIso(now))} · since ${at(incident.firstTriggeredAt)}` + const acked = acknowledgedLine(incident) + const ackSuffix = + acked === null || incident.acknowledgedAt === null + ? "" + : ` · ${acked} at ${at(incident.acknowledgedAt)}` + if (incident.holdReason != null) + return `Waiting on data · ${holdReasonLabel[incident.holdReason]}${ackSuffix}` + return `Open ${formatAlertDuration(incident.firstTriggeredAt, msIso(now))} · since ${at(incident.firstTriggeredAt)}${ackSuffix}` } /** `IncidentHeader`'s title side. */ @@ -111,7 +119,32 @@ export const headerTitle = ( ) } -const headerAction = (h: H, model: Model, investigation: V2Investigation | null): Html => +/** `IncidentHeader`'s Acknowledge or Unacknowledge button; nothing once the incident resolved. */ +export const acknowledgeAction = ( + h: HtmlBuilder, + options: Readonly<{ + incident: AlertIncidentDocument + isLoading: boolean + onAcknowledge: (acknowledged: boolean) => M + }>, +): ReadonlyArray => { + if (options.incident.status !== "open") return [] + const acknowledged = options.incident.acknowledgedAt !== null + return [ + button( + h, + { + variant: acknowledged ? "outline" : "default", + size: "sm", + isLoading: options.isLoading, + attributes: [h.OnClick(options.onAcknowledge(!acknowledged))], + }, + acknowledged ? ["Unacknowledge"] : [icon(h, "CheckIcon"), "Acknowledge"], + ), + ] +} + +const investigateAction = (h: H, model: Model, investigation: V2Investigation | null): Html => investigation !== null ? linkButton( h, @@ -258,6 +291,7 @@ const body = (h: H, model: Model, view: IncidentView, timeZone: string, now: num entries: buildIncidentTimeline({ incident, deliveries, + activity: AsyncData.getOrElse(model.activity.data, () => []), investigation, renotifyIntervalMinutes: rule != null && rule.destinationIds.length > 0 ? rule.renotifyIntervalMinutes : null, @@ -304,11 +338,18 @@ export const view = Submodel.defineView((model, h, { titleContent: headerTitle(h, view.incident, view.rule, now, timeZone), - actions: headerAction( - h, - model, - AsyncData.getOrElse(model.investigation.data, () => null), - ), + actions: hstack(h, { gap: "2", wrap: true }, [ + ...acknowledgeAction(h, { + incident: view.incident, + isLoading: model.isUpdatingAcknowledgement, + onAcknowledge: (acknowledged) => Message.ClickedAcknowledge({ acknowledged }), + }), + investigateAction( + h, + model, + AsyncData.getOrElse(model.investigation.data, () => null), + ), + ]), }, [body(h, model, view, timeZone, now)], ) diff --git a/apps/web/src/page/alerts/shared/commands.ts b/apps/web/src/page/alerts/shared/commands.ts index 86dcd1ed88..8034613dad 100644 --- a/apps/web/src/page/alerts/shared/commands.ts +++ b/apps/web/src/page/alerts/shared/commands.ts @@ -1,5 +1,6 @@ import type { AlertDestinationId, AlertIncidentId, AlertRuleId } from "@maple/domain/http" -import type { V2AlertDestinationUpdateParams } from "@maple/domain/http/v2" +import type { V2AlertDestinationUpdateParams, V2AlertIncident } from "@maple/domain/http/v2" +import type { AlertIncidentRow } from "@maple/web-data/collections/alerts" import { v2DeliveryToDocument } from "@maple/web-data/alerts/form-utils" import { Effect, Array as Arr } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" @@ -67,3 +68,41 @@ export const deleteDestination = (destinationId: AlertDestinationId) => const client = yield* MapleApiV2Client yield* client.alertDestinations.delete({ params: { id: destinationId } }) }).pipe(Effect.mapError(toLoadFailure)) + +/** Acknowledge or unacknowledge an open incident; the updated incident comes back. */ +export const setIncidentAcknowledged = (incidentId: AlertIncidentId, acknowledged: boolean) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const params = { params: { id: incidentId } } + return yield* acknowledged + ? client.alertIncidents.acknowledge(params) + : client.alertIncidents.unacknowledge(params) + }).pipe(Effect.mapError(toLoadFailure)) + +/** The incident's acknowledgement history, oldest first. */ +export const listIncidentActivity = (incidentId: AlertIncidentId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const response = yield* client.alertIncidents.listEvents({ params: { id: incidentId } }) + return response.data + }).pipe(Effect.mapError(toLoadFailure)) + +/** + * The synced row with the acknowledgement a request just returned, so the page shows it before + * the sync catches up with the same values. + */ +export const withAcknowledgement = ( + rows: ReadonlyArray, + incident: V2AlertIncident, +) => + Arr.map(rows, (row) => + row.id === incident.id + ? { + ...row, + acknowledged_at: incident.acknowledged_at, + acknowledged_by_user_id: incident.acknowledged_by?.user_id ?? null, + acknowledged_by_name: incident.acknowledged_by?.name ?? null, + acknowledged_via: incident.acknowledged_via, + } + : row, + ) diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index 4ae08e547f..847ba608d5 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -1,4 +1,5 @@ import { AlertDeliveryEventDocument, AlertIncidentDocument } from "@maple/domain/http" +import { V2AlertIncidentEvent } from "@maple/domain/http/v2" import { Schema, Array as Arr } from "effect" import { describe, expect, it } from "vitest" import { buildIncidentTimeline } from "./incident-timeline" @@ -63,10 +64,29 @@ function makeDelivery(overrides: Record = {}): AlertDeliveryEve }) } -const build = (incident: AlertIncidentDocument, deliveries: ReadonlyArray = []) => +const decodeActivity = Schema.decodeUnknownSync(V2AlertIncidentEvent) + +const makeActivity = (overrides: Record): V2AlertIncidentEvent => + decodeActivity({ + id: "evt_tC4d9V79DCDzgbGKhAnff9", + object: "alert_incident_event", + incident_id: "inc_tC4d9V79DCDzgbGKhAnff9", + type: "acknowledged", + actor: { user_id: "user_dana", name: "Dana Kim" }, + source: "slack", + created_at: "2026-10-15T14:20:00.000Z", + ...overrides, + }) + +const build = ( + incident: AlertIncidentDocument, + deliveries: ReadonlyArray = [], + activity: ReadonlyArray = [], +) => buildIncidentTimeline({ incident, deliveries, + activity, investigation: null, renotifyIntervalMinutes: 30, destinationTypeLabel: (type) => type, @@ -117,4 +137,58 @@ describe("buildIncidentTimeline", () => { ) expect(entries.at(-1)).toMatchObject({ title: "Resolved", detail: "After 38m" }) }) + + it("places acknowledgements in order and pauses the reminder note while acknowledged", () => { + const entries = build( + makeIncident({ + acknowledgedAt: "2026-10-15T14:26:00.000Z", + acknowledgedByName: "Dana Kim", + acknowledgedVia: "web", + }), + [makeDelivery()], + [ + makeActivity({}), + makeActivity({ + id: "evt_tC4d9V79DCDzgbGKhAnffA", + type: "unacknowledged", + actor: { user_id: null, name: "sam" }, + source: "slack", + created_at: "2026-10-15T14:22:00.000Z", + }), + makeActivity({ + id: "evt_tC4d9V79DCDzgbGKhAnffB", + actor: { user_id: "user_dana", name: null }, + source: "mcp", + created_at: "2026-10-15T14:26:00.000Z", + }), + ], + ) + expect(Arr.map(entries, (entry) => entry.title)).toEqual([ + "Incident opened", + "Notified #alerts", + "Acknowledged by Dana Kim", + "Unacknowledged by sam", + "Acknowledged by an agent over MCP", + "Still breaching", + ]) + expect(entries[2]?.detail).toBe("From Slack · reminders paused") + expect(entries.at(-1)?.detail).toBe("Last value 8.4% · reminders paused while acknowledged") + }) + + it("explains an acknowledgement the escalation cleared", () => { + const entries = build( + makeIncident(), + [], + [ + makeActivity({ + type: "ack_cleared", + actor: null, + source: null, + created_at: "2026-10-15T14:28:00.000Z", + }), + ], + ) + expect(entries[1]).toMatchObject({ title: "Acknowledgement cleared", tone: "warn" }) + expect(entries.at(-1)?.detail).toBe("Last value 8.4% · reminders every 30m while open") + }) }) diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 754627e41f..6291a21730 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -1,13 +1,14 @@ import type { AlertComparator, AlertDeliveryEventDocument, + AlertIncidentAckSource, AlertDestinationType, AlertIncidentDocument, AlertIncidentHoldReason, AlertSignalType, InvestigationId, } from "@maple/domain/http" -import type { V2Investigation } from "@maple/domain/http/v2" +import type { V2AlertIncidentEvent, V2Investigation } from "@maple/domain/http/v2" import type { Tone } from "@maple/ui/lib/tone" import { UNGROUPED_GROUP_KEY } from "@maple/domain/http" import { formatAlertDuration, formatSignalValue, signalLabels } from "./form-utils" @@ -32,6 +33,69 @@ export const holdReasonLabel: Record = { probe_failed: "Maple could not verify telemetry. Resolves on the next successful check.", } satisfies Record +const ackSourceLabel: Record = { + web: "the dashboard", + api: "the API", + mcp: "MCP", + slack: "Slack", + discord: "Discord", +} satisfies Record + +/** "Dana Kim", or who it was when the source named nobody: an MCP agent or a teammate. */ +export function ackActorLabel(name: string | null, source: AlertIncidentAckSource | null): string { + if (name != null) return name + return source === "mcp" ? "an agent over MCP" : "a teammate" +} + +/** "Acknowledged by Dana Kim", or null when nobody has acknowledged the incident. */ +export function acknowledgedLine( + incident: Pick, +): string | null { + return incident.acknowledgedAt == null + ? null + : `Acknowledged by ${ackActorLabel(incident.acknowledgedByName, incident.acknowledgedVia)}` +} + +function activityEntry(event: V2AlertIncidentEvent): IncidentTimelineEntry { + const who = ackActorLabel(event.actor?.name ?? null, event.source) + const from = event.source != null ? `From ${ackSourceLabel[event.source]}` : null + const base = { + key: `activity:${event.id}`, + at: Date.parse(event.created_at), + pending: false, + investigation: null, + } + switch (event.type) { + case "acknowledged": + return { + ...base, + tone: "info", + title: `Acknowledged by ${who}`, + detail: Arr.join( + Arr.filter([from, "reminders paused"], (p) => p != null), + " · ", + ), + } + case "unacknowledged": + return { + ...base, + tone: "neutral", + title: `Unacknowledged by ${who}`, + detail: Arr.join( + Arr.filter([from, "reminders resume"], (p) => p != null), + " · ", + ), + } + case "ack_cleared": + return { + ...base, + tone: "warn", + title: "Acknowledgement cleared", + detail: "The incident escalated, so it notified again", + } + } +} + const comparatorWords: Record = { gt: "above", gte: "at or above", @@ -164,6 +228,8 @@ function investigationEntries(investigation: V2Investigation): ReadonlyArray + /** Acknowledgement history, oldest first. */ + activity: ReadonlyArray investigation: V2Investigation | null renotifyIntervalMinutes: number | null destinationTypeLabel: (type: AlertDestinationType) => string @@ -191,6 +257,7 @@ export function buildIncidentTimeline(input: { const entry = deliveryEntry(delivery, input.destinationTypeLabel) if (entry != null) events.push(entry) } + for (const event of input.activity) events.push(activityEntry(event)) if (input.investigation != null) events.push(...investigationEntries(input.investigation)) if (incident.status === "resolved" && incident.resolvedAt != null) { @@ -215,9 +282,11 @@ export function buildIncidentTimeline(input: { }) } else if (incident.status === "open") { const reminder = - input.renotifyIntervalMinutes != null - ? `reminders every ${input.renotifyIntervalMinutes}m while open` - : null + incident.acknowledgedAt != null + ? "reminders paused while acknowledged" + : input.renotifyIntervalMinutes != null + ? `reminders every ${input.renotifyIntervalMinutes}m while open` + : null events.push({ key: "breaching", at: Date.parse(incident.lastTriggeredAt), From f177c670e4d5ac0eb4d983e71039e6e8f80000c7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:24:45 +0100 Subject: [PATCH 2/5] fix(alerts): key the incident activity read on the ack instant, not its spelling --- apps/web/src/page/alerts/incident/update.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index dcc541c3ed..97a854b8fd 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -26,6 +26,7 @@ import { SetIncidentAcknowledged, } from "./command" import { withAcknowledgement } from "../shared/commands" +import { isoMs } from "../shared/time" import { incidentViewOf, minuteOf, ruleStatesOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" @@ -83,7 +84,9 @@ const followData = (model: Model, context: PageContext): Return => // Keyed on the acknowledgement, so an ack from anywhere (Slack, the API) refetches the history. const activityKey = requestKey(context.orgId, "alertIncidents.listEvents", { id: incidentId, - acknowledgedAt: view.incident.acknowledgedAt, + // Epoch ms: the synced row and the API spell the same instant differently. + acknowledgedAt: + view.incident.acknowledgedAt === null ? null : isoMs(view.incident.acknowledgedAt), }) const steps: ReadonlyArray<{ patch: Partial; commands: Commands }> = [ ...Option.toArray( From 4245c51a17312aeb664c2a406fb9a1e38a0d0b4f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:30:02 +0100 Subject: [PATCH 3/5] fix(alerts): keep the live state last on the incident timeline, show a failed history read An ack later than the last check sorted below "Still breaching" (and below "Waiting on data" for good), so the state still in effect now always ends the timeline. A failed activity read now shows the timeline's error line instead of silently dropping the acknowledgements. The lab's acknowledge button sends a hub message the lab drops, not a theme message. Tests now round-trip the API's inc_ id, draw the fetched history and unacknowledge. --- apps/web/src/lab/alerts-lab/view-page.ts | 12 ++- .../web/src/page/alerts/hub/hub.scene.test.ts | 57 ++++++++++++ .../alerts/incident/incident.scene.test.ts | 91 ++++++++++++++++--- .../src/page/alerts/incident/view-timeline.ts | 2 +- apps/web/src/page/alerts/incident/view.ts | 3 +- .../src/alerts/incident-timeline.test.ts | 13 +++ .../web-data/src/alerts/incident-timeline.ts | 17 +++- 7 files changed, 172 insertions(+), 23 deletions(-) diff --git a/apps/web/src/lab/alerts-lab/view-page.ts b/apps/web/src/lab/alerts-lab/view-page.ts index ee4cec7866..9e9ff5d39b 100644 --- a/apps/web/src/lab/alerts-lab/view-page.ts +++ b/apps/web/src/lab/alerts-lab/view-page.ts @@ -17,6 +17,7 @@ import { linkButton } from "@maple/views/ui/button" import { icon } from "@maple/views/ui/icon" import { labShellView } from "../app-shell" import { Message, type Model } from "./model" +import { Message as HubMessage } from "../../page/alerts/hub/message" import { FRAME_CLASS, hubSections, section } from "./view" type H = HtmlBuilder @@ -77,12 +78,17 @@ const incidentFrame = (h: H, model: Model, fixture: AlertsLabFixture, isAcknowle h, headerTitle(h, incident, rule, model.nowMs, model.timeZone), hstack(h, { gap: "2", wrap: true }, [ - // The lab has no API: the button shows its state and its click goes nowhere. + // The lab hub drops this message, as it does the inbox buttons': the lab has no API. ...acknowledgeAction(h, { incident, isLoading: false, - onAcknowledge: () => - Message.ResolvedThemeColors({ colors: model.themeColors ?? {} }), + onAcknowledge: (acknowledged) => + Message.GotHubMessage({ + message: HubMessage.ClickedAcknowledgeIncident({ + incidentId: incident.id, + acknowledged, + }), + }), }), linkButton( h, 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 a577990ed0..96d6b632de 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -13,6 +13,7 @@ import { withViewInputs, } from "foldkit/scene" import { describe, expect, test } from "vitest" +import { encodePublicId, PublicIdPrefixes, V2AlertIncident } from "@maple/domain/http/v2" import { toLoadFailure } from "@maple/views/data/load-failure" import * as Menu from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" @@ -94,6 +95,35 @@ describe("alerts hub", () => { ) }) + const v2Incident = Schema.decodeSync(V2AlertIncident)({ + id: encodePublicId(PublicIdPrefixes.alertIncident, incidentRow().id), + object: "alert_incident", + rule_id: encodePublicId(PublicIdPrefixes.alertRule, ruleId(2)), + rule_name: "Payment p95 latency", + group_key: null, + signal_type: "p95_latency", + severity: "critical", + peak_severity: null, + status: "open", + comparator: "gt", + threshold: 1500, + threshold_upper: null, + first_triggered_at: new Date(NOW).toISOString(), + last_triggered_at: new Date(NOW).toISOString(), + resolved_at: null, + last_observed_value: 1840, + last_sample_count: 10, + dedupe_key: "d", + last_delivered_event_type: null, + last_notified_at: null, + hold_reason: null, + held_since: null, + acknowledged_at: null, + acknowledged_by: null, + acknowledged_via: null, + error_issue_id: null, + }) + test("the inbox acknowledges a firing incident and moves it out of the firing count", () => { const incidentId = Schema.decodeSync(AlertIncidentId)(incidentRow().id) const acknowledged = pageUpdate( @@ -138,6 +168,33 @@ describe("alerts hub", () => { }), ) expect(failed.model.acknowledgingIncidentIds).toEqual([]) + // Success patches the synced row from the answer, so the row moves before the sync catches up. + const answered = Schema.decodeSync(V2AlertIncident)({ + ...Schema.encodeSync(V2AlertIncident)(v2Incident), + acknowledged_at: new Date(NOW - 60_000).toISOString(), + acknowledged_by: { user_id: null, name: "Dana Kim" }, + acknowledged_via: "web", + }) + scene( + { update: pageUpdate, view }, + given(clicked.model), + expectView(text(/1 acknowledged/)).not.toExist(), + ) + const succeeded = pageUpdate( + clicked.model, + Message.CompletedAcknowledgeIncident({ + incidentId, + acknowledged: true, + result: Result.succeed(answered), + }), + ) + expect(succeeded.model.acknowledgingIncidentIds).toEqual([]) + scene( + { update: pageUpdate, view }, + given(succeeded.model), + expectView(text(/1 acknowledged/)).toExist(), + expectView(text(/^Acknowledged by Dana Kim ·/)).toExist(), + ) expect(failed.outMessage).toMatchObject({ _tag: "RequestedToast", title: "Couldn't acknowledge the incident", 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 1c6f11fb1f..810a89de45 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -1,5 +1,5 @@ import { OrgId } from "@maple/domain" -import { AlertRulePreviewResponse, AlertIncidentId } from "@maple/domain/http" +import { AlertRulePreviewResponse } from "@maple/domain/http" import { Result, Schema, Array as Arr } from "effect" import { click, @@ -14,9 +14,16 @@ import { withViewInputs, } from "foldkit/scene" import { CreateInvestigation, FetchIncidentActivity, SetIncidentAcknowledged } from "./command" -import { V2AlertIncident } from "@maple/domain/http/v2" +import { + encodePublicId, + PublicIdPrefixes, + V2AlertIncident, + V2AlertIncidentEvent, +} from "@maple/domain/http/v2" import { describe, expect, test } from "vitest" import * as Chart from "@maple/views/charts/chart" +import { requestKey } from "@maple/views/data/request-key" +import { isoMs } from "../shared/time" import { MountChart } from "@maple/views/charts/chart-mounts" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import { toLoadFailure } from "@maple/views/data/load-failure" @@ -202,7 +209,11 @@ describe("alert incident", () => { { update: pageUpdate, view }, given(updated.model), themeResolved(), - expectView(text("Couldn't load delivery history, so notifications are missing.")).toExist(), + expectView( + text( + "Couldn't load the whole history, so some notifications or acknowledgements are missing.", + ), + ).toExist(), ) }) @@ -313,9 +324,10 @@ describe("alert incident", () => { ) }) + /** The API's answer for the page's incident: its `inc_` id decodes back to the row's id. */ const ackedIncident = (acknowledgedAt: string | null) => Schema.decodeSync(V2AlertIncident)({ - id: "inc_tC4d9V79DCDzgbGKhAnff9", + id: encodePublicId(PublicIdPrefixes.alertIncident, INCIDENT_ID), object: "alert_incident", rule_id: "alrt_gU26thvJECdQvu54Ad9jiz", rule_name: "x", @@ -343,6 +355,26 @@ describe("alert incident", () => { error_issue_id: null, }) + const ackEvent = (type: "acknowledged" | "unacknowledged", at: string) => + Schema.decodeSync(V2AlertIncidentEvent)({ + id: encodePublicId( + PublicIdPrefixes.event, + `0199a003-0000-7000-8000-00000000000${type === "acknowledged" ? 2 : 3}`, + ), + object: "alert_incident_event", + incident_id: encodePublicId(PublicIdPrefixes.alertIncident, INCIDENT_ID), + type, + actor: { user_id: "user_dana", name: "Dana Kim" }, + source: "web", + created_at: at, + }) + + const activityKey = (acknowledgedAt: string | null) => + requestKey(ORG, "alertIncidents.listEvents", { + id: INCIDENT_ID, + acknowledgedAt: acknowledgedAt === null ? null : isoMs(acknowledgedAt), + }) + test("Acknowledge marks the open incident and shows who is on it; Unacknowledge takes it back", () => { const loaded = Arr.reduce( [ @@ -353,8 +385,7 @@ describe("alert incident", () => { (model, message) => step(model, message).model, ) const ackedAt = new Date(NOW - 5 * 60_000).toISOString() - // The response carries the public id; the row patch matches it by the internal id. - const response = { ...ackedIncident(ackedAt), id: Schema.decodeSync(AlertIncidentId)(INCIDENT_ID) } + const unackedAt = new Date(NOW - 2 * 60_000).toISOString() scene( { update: pageUpdate, view }, given(loaded), @@ -362,27 +393,61 @@ describe("alert incident", () => { click(role("button", { name: "Acknowledge" })), Command.resolve( SetIncidentAcknowledged, - Message.CompletedSetAcknowledged({ acknowledged: true, result: Result.succeed(response) }), + Message.CompletedSetAcknowledged({ + acknowledged: true, + result: Result.succeed(ackedIncident(ackedAt)), + }), ), + // The patched row's ack moved the history's key, so the history is read again and drawn. Command.resolve( FetchIncidentActivity, - Message.CompletedFetchActivity({ key: "", result: Result.succeed([]) }), + Message.CompletedFetchActivity({ + key: activityKey(ackedAt), + result: Result.succeed([ackEvent("acknowledged", ackedAt)]), + }), ), expectView(text(/Acknowledged by Dana Kim at/)).toExist(), - expectView(role("button", { name: "Unacknowledge" })).toExist(), + expectView(text("From the dashboard · reminders paused")).toExist(), + click(role("button", { name: "Unacknowledge" })), + Command.resolve( + SetIncidentAcknowledged, + Message.CompletedSetAcknowledged({ + acknowledged: false, + result: Result.succeed(ackedIncident(null)), + }), + ), + Command.resolve( + FetchIncidentActivity, + Message.CompletedFetchActivity({ + key: activityKey(null), + result: Result.succeed([ + ackEvent("acknowledged", ackedAt), + ackEvent("unacknowledged", unackedAt), + ]), + }), + ), + expectView(text("Unacknowledged by Dana Kim")).toExist(), + expectView(role("button", { name: "Acknowledge" })).toExist(), + expectView(text(/Acknowledged by Dana Kim at/)).not.toExist(), ) const clicked = step(loaded, Message.ClickedAcknowledge({ acknowledged: true })) - expect(names(clicked.commands)).toEqual(["SetAlertIncidentAcknowledged"]) // A second click while the request runs sends nothing. expect( step(clicked.model, Message.ClickedAcknowledge({ acknowledged: true })).commands ?? [], ).toHaveLength(0) const done = step( clicked.model, - Message.CompletedSetAcknowledged({ acknowledged: true, result: Result.succeed(response) }), + Message.CompletedSetAcknowledged({ + acknowledged: true, + result: Result.succeed(ackedIncident(ackedAt)), + }), + ) + // The sync then delivers the same acknowledgement: no second read of the history. + const synced = step( + done.model, + Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow({ acknowledged_at: ackedAt })] }), ) - // The acknowledgement changed, so its history is read again. - expect(names(done.commands)).toContain("FetchAlertIncidentActivity") + expect(names(synced.commands)).not.toContain("FetchAlertIncidentActivity") const failed = step( clicked.model, Message.CompletedSetAcknowledged({ diff --git a/apps/web/src/page/alerts/incident/view-timeline.ts b/apps/web/src/page/alerts/incident/view-timeline.ts index c68b9e0cc1..ce5d79ead2 100644 --- a/apps/web/src/page/alerts/incident/view-timeline.ts +++ b/apps/web/src/page/alerts/incident/view-timeline.ts @@ -41,7 +41,7 @@ export const incidentTimeline = ( ...(options.isFailed ? [ typography(h, { size: "xs", tone: "muted", as: "p" }, [ - "Couldn't load delivery history, so notifications are missing.", + "Couldn't load the whole history, so some notifications or acknowledgements are missing.", ]), ] : []), diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 43168d844a..f421b16817 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -299,7 +299,8 @@ const body = (h: H, model: Model, view: IncidentView, timeZone: string, now: num }), isEntriesLoading: AsyncData.isLoading(model.deliveries.data) || AsyncData.isIdle(model.deliveries.data), - isEntriesFailed: AsyncData.isFailure(model.deliveries.data), + isEntriesFailed: + AsyncData.isFailure(model.deliveries.data) || AsyncData.isFailure(model.activity.data), chartId: "alert-incident-chart", chart: model.chart, toChartMessage: (message) => Message.GotChartMessage({ message }), diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index 847ba608d5..6469272efe 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -191,4 +191,17 @@ describe("buildIncidentTimeline", () => { expect(entries[1]).toMatchObject({ title: "Acknowledgement cleared", tone: "warn" }) expect(entries.at(-1)?.detail).toBe("Last value 8.4% · reminders every 30m while open") }) + + it("keeps the state still in effect last when an ack came after it", () => { + const entries = build( + makeIncident({ holdReason: "no_data", heldSince: "2026-10-15T14:40:00.000Z" }), + [], + [makeActivity({ created_at: "2026-10-15T14:45:00.000Z" })], + ) + expect(Arr.map(entries, (entry) => entry.title)).toEqual([ + "Incident opened", + "Acknowledged by Dana Kim", + "Waiting on data", + ]) + }) }) diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 6291a21730..3285a52992 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -307,9 +307,16 @@ export function buildIncidentTimeline(input: { }) } - // Stable sort keeps the insertion order (opened first) for identical stamps. - return Arr.sort( - Arr.filter(events, (entry) => Number.isFinite(entry.at)), - Order.mapInput(Order.Number, (entry: { readonly at: number }) => entry.at), - ) + // Stable sort keeps the insertion order (opened first) for identical stamps. The state still in + // effect ends the story even when an ack came after its stamp (a hold's start, the last check). + const isCurrent = (entry: IncidentTimelineEntry) => CURRENT_STATE_KEYS.has(entry.key) + return [ + ...Arr.sort( + Arr.filter(events, (entry) => !isCurrent(entry) && Number.isFinite(entry.at)), + Order.mapInput(Order.Number, (entry: { readonly at: number }) => entry.at), + ), + ...Arr.filter(events, isCurrent), + ] } + +const CURRENT_STATE_KEYS: ReadonlySet = new Set(["held", "breaching"]) From 064987ee0f4bd9669d963f65bd8b625b63f6c453 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 12:28:08 +0100 Subject: [PATCH 4/5] refactor(alerts): lay out the inbox ack cell with hstack --- apps/web/src/page/alerts/hub/view-inbox.ts | 37 ++++++++++------------ 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index d4ee172504..0c6b99bdd6 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -256,26 +256,23 @@ const openIncidentRow = ( formatAlertDuration(incident.firstTriggeredAt, msIso(now)), ]), // Lifted above the row's stretched link, so the click acknowledges instead of navigating. - h.div( - [h.Class(cn(ROW_LINK_LIFT, "flex justify-end"))], - [ - button( - h, - { - variant: acked === null ? "outline" : "ghost", - size: "xs", - isLoading: acknowledge.pendingIds.includes(incident.id), - attributes: [ - h.OnClick(acknowledge.onAcknowledge(incident.id, acked === null)), - h.AriaLabel( - `${acked === null ? "Acknowledge" : "Unacknowledge"} ${incidentTitle(incident)}`, - ), - ], - }, - acked === null ? [icon(h, "CheckIcon"), "Acknowledge"] : ["Unacknowledge"], - ), - ], - ), + hstack(h, { justify: "end", className: ROW_LINK_LIFT }, [ + button( + h, + { + variant: acked === null ? "outline" : "ghost", + size: "xs", + isLoading: acknowledge.pendingIds.includes(incident.id), + attributes: [ + h.OnClick(acknowledge.onAcknowledge(incident.id, acked === null)), + h.AriaLabel( + `${acked === null ? "Acknowledge" : "Unacknowledge"} ${incidentTitle(incident)}`, + ), + ], + }, + acked === null ? [icon(h, "CheckIcon"), "Acknowledge"] : ["Unacknowledge"], + ), + ]), ], ) } From 50f2efea6535db8160321c4d72bd18c59b151344 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 13:10:31 +0100 Subject: [PATCH 5/5] fix(alerts): leave acknowledged incidents out of the inbox tab badge --- apps/web/src/page/alerts/hub/hub.scene.test.ts | 3 +++ apps/web/src/page/alerts/hub/view-inbox.ts | 4 ++++ apps/web/src/page/alerts/hub/view.ts | 4 ++-- 3 files changed, 9 insertions(+), 2 deletions(-) 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 96d6b632de..988febcb10 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -88,6 +88,7 @@ describe("alerts hub", () => { { update: pageUpdate, view }, given(loaded()), expectView(text("1 firing")).toExist(), + expectView(role("link", { name: /^Inbox\s*1$/ })).toExist(), expectView(role("link", { name: "P95 latency 1.84s on all" })).toHaveAttr( "href", "/alerts/incidents/0199a003-0000-7000-8000-000000000001", @@ -144,6 +145,8 @@ describe("alerts hub", () => { { update: pageUpdate, view }, given(acknowledged), expectView(text("0 firing")).toExist(), + // The tab's red badge counts only what nobody is on yet. + expectView(role("link", { name: "Inbox" })).toExist(), expectView(text(/1 acknowledged/)).toExist(), expectView(text(/^Acknowledged by Dana Kim ·/)).toExist(), expectView(role("button", { name: "Unacknowledge P95 latency 1.84s on all" })).toExist(), diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 0c6b99bdd6..dabd9b7d7b 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -59,6 +59,10 @@ export type InboxAcknowledge = Readonly<{ const isAcknowledged = (incident: AlertIncidentDocument) => incident.acknowledgedAt !== null +/** The inbox tab's badge: open incidents nobody has acknowledged yet. */ +export const unacknowledgedCount = (incidents: ReadonlyArray): number => + Arr.filter(incidents, (incident) => !isAcknowledged(incident)).length + export const inboxTab = ( h: HtmlBuilder, data: AlertsOverviewReady, diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 9e494d738f..21dee8d739 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -20,7 +20,7 @@ import { type AlertsTab, destinationsOf, overviewOf, resolveStatus, resolveTab } import { Message } from "./message" import { canManage, type Model } from "./model" import { alertsEmptyState } from "./view-empty" -import { inboxTab } from "./view-inbox" +import { inboxTab, unacknowledgedCount } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" import { rulesTab } from "./view-rules" @@ -63,7 +63,7 @@ const tabBar = ( onNone: () => undefined, onSome: (data) => tab.value === "inbox" - ? data.openIncidents.length + ? unacknowledgedCount(data.openIncidents) : tab.value === "rules" ? data.rules.length : undefined,