Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions apps/web/src/lab/alerts-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IncidentSpec> = [
Expand All @@ -405,6 +407,7 @@ const OPEN_INCIDENTS: ReadonlyArray<IncidentSpec> = [
startAgo: 41 * MIN,
endAgo: null,
value: 1920,
ackedAgo: 33 * MIN,
},
{
id: INCIDENT.ingest,
Expand Down Expand Up @@ -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,
})
}
Expand Down
93 changes: 76 additions & 17 deletions apps/web/src/lab/alerts-lab/view-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message>

/** The fixture incident as Dana acknowledged it from Slack, a minute after its last notification. */
const acknowledgedByDana = (
fixture: AlertsLabFixture,
): Readonly<{ incident: AlertIncidentDocument; activity: ReadonlyArray<V2AlertIncidentEvent> }> => {
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,
Expand All @@ -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"],
),
]),
),
],
),
Expand All @@ -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 ?? {},
Expand Down Expand Up @@ -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),
]),
],
),
Expand Down
20 changes: 16 additions & 4 deletions apps/web/src/lab/alerts-lab/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -110,18 +111,29 @@ 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<HubModel, HubMessage, HubInputs>((hub, inputs, h) => {
const { fixture } = inputs
const routing = { destinations: fixture.destinations, timeZone: inputs.timeZone, nowMs: inputs.nowMs }
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(
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/page/alerts/hub/command.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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 },
Expand Down
112 changes: 111 additions & 1 deletion apps/web/src/page/alerts/hub/hub.scene.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -87,13 +88,122 @@ 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",
),
)
})

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
Expand Down
Loading
Loading