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..9e9ff5d39b 100644 --- a/apps/web/src/lab/alerts-lab/view-page.ts +++ b/apps/web/src/lab/alerts-lab/view-page.ts @@ -3,24 +3,64 @@ 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" 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 +/** 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 +77,31 @@ 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 hub drops this message, as it does the inbox buttons': the lab has no API. + ...acknowledgeAction(h, { + incident, + isLoading: false, + onAcknowledge: (acknowledged) => + Message.GotHubMessage({ + message: HubMessage.ClickedAcknowledgeIncident({ + incidentId: incident.id, + acknowledged, + }), + }), + }), + linkButton( + h, + { + variant: "outline", + size: "sm", + href: hrefOf( + AppRoute.Investigation({ id: String(investigation.id), search: {} }), + ), + }, + [icon(h, "ChatBubbleSparkleIcon"), "Open investigation"], + ), + ]), ), ], ), @@ -69,7 +123,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 +158,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 060548be35..ba11843a63 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 { listSloRows } from "../shared/slo-commands" import { deleteMonitor, listMonitors, setMonitorEnabled } from "../shared/uptime-commands" @@ -69,6 +70,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..988febcb10 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, @@ -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" @@ -87,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", @@ -94,6 +96,114 @@ 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( + 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(), + // 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(), + ) + 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([]) + // 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", + }) + }) + 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 d33bbad627..d5b87c712f 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 2eedc2959c..ea28328f20 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 { V2Slo, V2SloStatus, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, @@ -56,6 +61,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 466ac5520d..9e1c3c6f3a 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -19,6 +19,7 @@ import { alertsHref, createHref, ruleHref } from "../shared/links" import { newSloHref, SLO_LIST_LIMIT } from "../shared/slo-commands" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { + AcknowledgeIncident, DeleteDestination, DeleteMonitor, FetchHubMonitors, @@ -33,6 +34,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" @@ -152,6 +154,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"), @@ -474,6 +477,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..dabd9b7d7b 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,37 @@ 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 + +/** 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, + 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 +114,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 +145,7 @@ export const inboxTab = (h: HtmlBuilder, data: AlertsOverviewReady): Html ]) : sections, ] - : [openIncidentsList(h, sorted, now), sections]), + : [openIncidentsList(h, sorted, now, acknowledge), sections]), ], ) } @@ -138,10 +168,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 +187,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 +218,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 +244,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 +259,24 @@ 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. + 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"], + ), + ]), ], ) } diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index a1ba9b7ba2..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, @@ -254,7 +254,13 @@ const hubBody = ( isManagedSignalType(incident.signalType), ) if (tab === "inbox") - return data.rules.length === 0 && !hasManagedIncidents ? emptyState : inboxTab(h, data) + return data.rules.length === 0 && !hasManagedIncidents + ? 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 69606def4b..791c201b28 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 { listSlos } from "../shared/slo-commands" import { listMonitors } from "../shared/uptime-commands" import { fetchCheckSummary } from "../shared/check-summary" @@ -47,6 +47,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 6a84321514..e1c64065d0 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -14,9 +14,17 @@ import { text, withViewInputs, } from "foldkit/scene" -import { CreateInvestigation } from "./command" +import { CreateInvestigation, FetchIncidentActivity, SetIncidentAcknowledged } from "./command" +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" @@ -340,7 +348,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(), ) }) @@ -450,4 +462,167 @@ describe("alert incident", () => { PageOutMessage.RequestedNavigation({ url: "/investigations/inv-1", isReplace: false }), ) }) + + /** 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: encodePublicId(PublicIdPrefixes.alertIncident, INCIDENT_ID), + 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, + }) + + 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( + [ + 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() + const unackedAt = new Date(NOW - 2 * 60_000).toISOString() + scene( + { update: pageUpdate, view }, + given(loaded), + themeResolved(), + click(role("button", { name: "Acknowledge" })), + Command.resolve( + SetIncidentAcknowledged, + 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: activityKey(ackedAt), + result: Result.succeed([ackEvent("acknowledged", ackedAt)]), + }), + ), + expectView(text(/Acknowledged by Dana Kim at/)).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 })) + // 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(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 })] }), + ) + expect(names(synced.commands)).not.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 3595bb470b..5a806c2d53 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -1,5 +1,11 @@ import { AlertDeliveryEventDocument, AlertRulePreviewResponse } from "@maple/domain/http" -import { V2Investigation, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" +import { + V2AlertIncident, + V2AlertIncidentEvent, + V2Investigation, + V2Slo, + V2UptimeMonitor, +} from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -33,12 +39,21 @@ 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), + }, CompletedFetchSlos: { key: Schema.String, result: Schema.Result(Schema.Array(V2Slo), LoadFailure) }, CompletedFetchMonitors: { key: Schema.String, result: Schema.Result(Schema.Array(V2UptimeMonitor), 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 1ad02afa89..49293a4d1f 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, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" +import { V2AlertIncidentEvent, V2Investigation, V2Slo, V2UptimeMonitor } 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, /** An SLO incident's link to its SLO: `slos.list`, read only for one. */ slos: KeyedSchema(Schema.Array(V2Slo), LoadFailure), /** An uptime incident's link to its monitor: `uptimeMonitors.list`, read only for one. */ diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index 7fe78fb1f2..3bce573d36 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -17,6 +17,7 @@ import { AppRoute, hrefOf } from "../../../route" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { CreateInvestigation, + FetchIncidentActivity, FetchIncidentChecks, FetchIncidentDeliveries, FetchIncidentInvestigation, @@ -24,7 +25,10 @@ import { FetchIncidentPreview, FetchIncidentSlos, ReadIncidentClock, + SetIncidentAcknowledged, } from "./command" +import { withAcknowledgement } from "../shared/commands" +import { isoMs } from "../shared/time" import { SLO_LIST_LIMIT } from "../shared/slo-commands" import { UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { incidentViewOf, minuteOf, ruleStatesOf } from "./derive" @@ -46,6 +50,8 @@ export const init = (route: IncidentRoute, _context: PageContext): Return => ({ checks: Keyed.idle(), deliveries: Keyed.idle(), investigation: Keyed.idle(), + activity: Keyed.idle(), + isUpdatingAcknowledgement: false, slos: Keyed.idle(), monitors: Keyed.idle(), isStartingInvestigation: false, @@ -87,7 +93,25 @@ 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, + // 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( + 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) @@ -285,6 +309,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-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 34c8ca54d7..9b1f8e8354 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, @@ -53,6 +54,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 @@ -63,8 +65,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. */ @@ -124,7 +132,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, @@ -314,6 +347,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, @@ -321,7 +355,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 }), @@ -360,11 +395,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 6ec1d114a9..c06f4c43bf 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" @@ -71,3 +72,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 d13de42ccf..3e555d2144 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 { Option, 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, @@ -136,4 +156,71 @@ 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") + }) + + 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 a7d927609d..659c371716 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 @@ -193,6 +259,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) { @@ -217,9 +284,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), @@ -240,9 +309,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"])