diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index ba11843a63..cd919b185c 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, AlertIncidentId, AlertRuleId } from "@maple/domain/http" +import { AlertDestinationId, AlertIncidentId, AlertRuleId, AlertSilenceId } 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" @@ -20,6 +20,7 @@ import { } from "../shared/commands" import { listSloRows } from "../shared/slo-commands" import { deleteMonitor, listMonitors, setMonitorEnabled } from "../shared/uptime-commands" +import { endSilence, listSilences } from "../shared/silences" import { Message } from "./message" import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" @@ -178,3 +179,23 @@ export const FetchHubSlos = Command.define("FetchAlertsHubSlos", { Effect.map((result) => Message.CompletedFetchSlos({ key, result })), ), }) + +export const FetchHubSilences = Command.define("FetchAlertsHubSilences", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSilences], + execute: ({ key }) => + listSilences.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSilences({ key, result })), + ), +}) + +export const EndHubSilence = Command.define("EndAlertSilence", { + args: { silenceId: AlertSilenceId }, + messages: [Message.CompletedEndSilence], + execute: ({ silenceId }) => + endSilence(silenceId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedEndSilence({ silenceId, result })), + ), +}) diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 200125c77b..a63a72c43b 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -53,7 +53,7 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) -export type AlertsTab = "inbox" | "rules" | "uptime" | "slos" | "routing" +export type AlertsTab = "inbox" | "rules" | "uptime" | "slos" | "silences" | "routing" /** Legacy `tab` values map onto the current tabs so old links keep resolving. */ export const resolveTab = (tab: string | undefined): AlertsTab => { @@ -64,6 +64,8 @@ export const resolveTab = (tab: string | undefined): AlertsTab => { return "uptime" case "slos": return "slos" + case "silences": + return "silences" case "routing": case "settings": return "routing" diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts new file mode 100644 index 0000000000..886703f853 --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -0,0 +1,192 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { Result, Schema, Array as Arr } from "effect" +import * as Animation from "@foldkit/ui/animation" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { + click, + Command, + expect as expectView, + Mount, + given, + role, + scene, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { NOW, ORG } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const asSilenceId = Schema.decodeSync(AlertSilenceId) +const asRuleId = Schema.decodeSync(AlertRuleId) +const iso = (ms: number) => new Date(ms).toISOString() + +const silence = ( + overrides: Partial & { readonly startMin: number; readonly endMin: number }, +): V2AlertSilence => { + const { startMin, endMin, ...rest } = overrides + return { + id: asSilenceId("22222222-2222-4222-8222-222222222222"), + object: "alert_silence", + reason: "Deploying checkout v2.3", + matchers: { services: ["checkout"] }, + status: "active", + starts_at: iso(NOW + startMin * 60_000), + ends_at: iso(NOW + endMin * 60_000), + created_by: "user_1", + created_at: iso(NOW), + updated_at: iso(NOW), + ...rest, + } +} + +const onSilences = (grants: ReturnType, silences: ReadonlyArray) => { + const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context) + expect(Arr.map(entered.commands ?? [], ({ name }) => name)).toContain("FetchAlertsHubSilences") + return Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.CompletedFetchSilences({ + key: entered.model.silences.key ?? "", + result: Result.succeed(silences), + }), + ], + entered.model, + (model, message) => pageUpdate(model, message).model, + ) +} + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) + +describe("alerts hub, Silences tab", () => { + test("an org without silences explains them and offers the first one", () => { + scene( + { update: pageUpdate, view }, + given(onSilences(ENGINEER, [])), + expectView(text("No silences")).toExist(), + expectView(role("button", { name: "New silence" })).toExist(), + ) + }) + + test("groups silences by where they stand, with their window and scope", () => { + scene( + { update: pageUpdate, view }, + given( + onSilences(ENGINEER, [ + silence({ startMin: -10, endMin: 50 }), + silence({ + id: asSilenceId("33333333-3333-4333-8333-333333333333"), + reason: "Database migration", + matchers: { tags: ["db"] }, + startMin: 120, + endMin: 180, + }), + ]), + ), + expectView(text("Deploying checkout v2.3")).toExist(), + expectView(text(/Until .+ · service checkout/u)).toExist(), + expectView(text(/tag db/u)).toExist(), + expectView(role("button", { name: "End now" })).toExist(), + expectView(role("button", { name: "Cancel" })).toExist(), + ) + }) + + test("ending a silence sends it once and a member cannot end one", () => { + const model = onSilences(ENGINEER, [silence({ startMin: -10, endMin: 50 })]) + const step = pageUpdate( + model, + Message.ClickedEndSilence({ silenceId: asSilenceId("22222222-2222-4222-8222-222222222222") }), + ) + expect(Arr.map(step.commands ?? [], ({ name }) => name)).toEqual(["EndAlertSilence"]) + expect( + pageUpdate( + step.model, + Message.ClickedEndSilence({ silenceId: asSilenceId("22222222-2222-4222-8222-222222222222") }), + ).commands ?? [], + ).toEqual([]) + + scene( + { update: pageUpdate, view }, + given(onSilences(MEMBER, [silence({ startMin: -10, endMin: 50 })])), + expectView(role("button", { name: "End now" })).not.toExist(), + expectView(role("button", { name: "New silence" })).not.toExist(), + ) + }) + + test("confirms a cancelled scheduled silence as cancelled, an ended active one as ended", () => { + const id = asSilenceId("22222222-2222-4222-8222-222222222222") + const model = onSilences(ENGINEER, [silence({ startMin: 60, endMin: 120 })]) + const toastOf = (result: V2AlertSilence) => + pageUpdate(model, Message.CompletedEndSilence({ silenceId: id, result: Result.succeed(result) })) + .outMessage + expect(toastOf(silence({ startMin: 0, endMin: 0, status: "ended" }))).toMatchObject({ + title: "Silence cancelled", + }) + expect(toastOf(silence({ startMin: -30, endMin: 0, status: "ended" }))).toMatchObject({ + title: "Silence ended", + }) + }) + + test("reads the clock again when the list lands, so a new silence is placed by it", () => { + const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context).model + const step = pageUpdate( + entered, + Message.CompletedFetchSilences({ key: entered.silences.key ?? "", result: Result.succeed([]) }), + ) + expect(Arr.map(step.commands ?? [], ({ name }) => name)).toContain("ReadAlertsHubClock") + }) + + test("names a cancelled silence as cancelled, and a rule scope before rules sync", () => { + scene( + { update: pageUpdate, view }, + given( + onSilences(ENGINEER, [ + silence({ startMin: -5, endMin: -5, status: "ended" }), + silence({ + id: asSilenceId("44444444-4444-4444-8444-444444444444"), + reason: "Rule only", + matchers: { rule_ids: [asRuleId("11111111-1111-4111-8111-111111111111")] }, + startMin: -10, + endMin: 50, + }), + ]), + ), + expectView(text(/^Cancelled /u)).toExist(), + expectView(text(/· 1 rule$/u)).toExist(), + ) + }) + + test("New silence opens the dialog with typed scope fields", () => { + scene( + { update: pageUpdate, view }, + given(onSilences(ENGINEER, [])), + click(role("button", { name: "New silence" })), + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 1 }), + ), + Command.resolve( + Animation.WaitForAnimationSettled, + Animation.Message.EndedAnimation({ generation: 1 }), + ), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("dialog", { name: "Silence notifications" })).toExist(), + expectView(role("textbox", { name: "Services" })).toExist(), + expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index d5b87c712f..a59fc947e0 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -5,8 +5,9 @@ import { AlertDestinationId, AlertIncidentId, AlertRuleId, + AlertSilenceId, } from "@maple/domain/http" -import { V2AlertIncident, V2UptimeMonitor } from "@maple/domain/http/v2" +import { V2AlertIncident, V2AlertSilence, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -19,6 +20,7 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as SilenceDialog from "../silence-dialog/dialog" import { SloListRow, TestOutcome, UptimeKindFilter, UptimeStatusFilter } from "./model" import { ServiceNamespaces } from "./uptime-groups" @@ -82,5 +84,11 @@ export const Message = defineMessageUnion({ GotDeleteMonitorDialogMessage: { message: Dialog.Message }, CompletedFetchSlos: { key: Schema.String, result: outcome(Schema.Array(SloListRow)) }, ClickedRetrySlos: {}, + CompletedFetchSilences: { key: Schema.String, result: outcome(Schema.Array(V2AlertSilence)) }, + ClickedRetrySilences: {}, + ClickedNewSilence: {}, + GotSilenceDialogMessage: { message: SilenceDialog.Message }, + ClickedEndSilence: { silenceId: AlertSilenceId }, + CompletedEndSilence: { silenceId: AlertSilenceId, result: outcome(V2AlertSilence) }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index ea28328f20..1b633d7753 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -5,8 +5,9 @@ import { AlertDestinationId, AlertIncidentId, AlertRuleId, + AlertSilenceId, } from "@maple/domain/http" -import { V2Slo, V2SloStatus, V2UptimeMonitor } from "@maple/domain/http/v2" +import { V2AlertSilence, V2Slo, V2SloStatus, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -20,6 +21,7 @@ import { AlertsSearch } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as SilenceDialog from "../silence-dialog/dialog" import { ServiceNamespaces } from "./uptime-groups" // The `/alerts` hub. @@ -87,6 +89,10 @@ export const Model = Schema.Struct({ deleteMonitorDialog: Dialog.Model, /** SLOs tab: `slos.list` and each SLO's status, read only while the tab is open. */ slos: KeyedSchema(Schema.Array(SloListRow), LoadFailure), + /** Silences tab: `alertSilences.list`, read while the tab is open. */ + silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), + endingSilenceIds: Schema.Array(AlertSilenceId), + silenceDialog: SilenceDialog.Model, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 9e1c3c6f3a..b7058b9986 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -1,5 +1,5 @@ import type { UptimeMonitorId } from "@maple/domain" -import type { AlertDestinationId, AlertRuleId } from "@maple/domain/http" +import type { AlertDestinationId, AlertRuleId, AlertSilenceId } from "@maple/domain/http" import { buildDestinationUpdateParamsV2, destinationToFormState } from "@maple/web-data/alerts/form-utils" import { rowToAlertDestinationDocument, rowToAlertRuleDocument } from "@maple/web-data/collections/alerts" import { Array, Option, Result, String as Str } from "effect" @@ -14,6 +14,8 @@ import type { AppRoute } from "../../../route" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" +import * as SilenceDialog from "../silence-dialog/dialog" +import { endsAtMs, startsAtMs } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" import { newSloHref, SLO_LIST_LIMIT } from "../shared/slo-commands" @@ -33,6 +35,8 @@ import { TestDestination, ToggleRule, UpdateDestination, + EndHubSilence, + FetchHubSilences, } from "./command" import { withAcknowledgement } from "../shared/commands" import { resolveStatus, resolveTab } from "./derive" @@ -89,6 +93,26 @@ const requestRoutingDeliveries = (model: Model, context: PageContext, mode: Keye }) } +const silencesKey = (context: PageContext) => + requestKey(context.orgId, "alertSilences.list", { query: { limit: 100 } }) + +/** The Silences tab reads the org's silences only while it is open. */ +const requestSilences = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { + if (resolveTab(model.search.tab) !== "silences") return { model, commands: [] } + return Option.match(Keyed.requestFor(model.silences, silencesKey(context), mode), { + onNone: () => ({ model, commands: [] }), + onSome: (silences) => ({ + model: { ...model, silences }, + commands: [FetchHubSilences({ key: silences.key })], + }), + }) +} + +const refreshSilences = (model: Model, context: PageContext): Return => { + const step = requestSilences(model, context, "Supersede") + return { model: step.model, commands: step.commands } +} + const monitorsKey = (context: PageContext) => requestKey(context.orgId, "uptimeMonitors.list", { query: { limit: UPTIME_LIST_LIMIT } }) @@ -172,11 +196,15 @@ export const init = (route: HubRoute, context: PageContext): Return => { confirmDeleteMonitorId: null, deleteMonitorDialog: Dialog.init(DELETE_MONITOR_DIALOG_ID, "alert-dialog"), slos: Keyed.idle(), + silences: Keyed.idle(), + endingSilenceIds: [], + silenceDialog: SilenceDialog.init("alerts-silence"), } const deliveries = requestDeliveries(model, context, "Load") const routing = requestRoutingDeliveries(deliveries.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") - const slos = requestSlos(monitors.model, context, "Load") + const silences = requestSilences(monitors.model, context, "Load") + const slos = requestSlos(silences.model, context, "Load") return { model: slos.model, commands: [ @@ -185,6 +213,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { ...deliveries.commands, ...routing.commands, ...monitors.commands, + ...silences.commands, ...slos.commands, ], } @@ -193,10 +222,17 @@ export const init = (route: HubRoute, context: PageContext): Return => { export const routeChanged = (model: Model, route: HubRoute, context: PageContext): Return => { const routing = requestRoutingDeliveries({ ...model, search: route.search }, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") - const slos = requestSlos(monitors.model, context, "Load") + const silences = requestSilences(monitors.model, context, "Load") + const slos = requestSlos(silences.model, context, "Load") return { model: slos.model, - commands: [...routing.commands, ...monitors.commands, ...slos.commands], + commands: [ + ...routing.commands, + ...monitors.commands, + ...silences.commands, + ...slos.commands, + ReadHubClock(), + ], } } @@ -276,6 +312,43 @@ const liftDestinationDialog = (model: Model, step: ReturnType, +): Return => { + const next = { ...model, silenceDialog: step.model } + const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotSilenceDialogMessage({ message }), + ) + if (step.outMessage === undefined) return { model: next, commands } + return SilenceDialog.OutMessage.match(step.outMessage, { + RequestedToast: ({ type, title, description }) => ({ + model: next, + commands, + outMessage: toast(type, title, description), + }), + CreatedSilence: () => { + const refreshed = refreshSilences(next, context) + return { + model: refreshed.model, + commands: [...commands, ...(refreshed.commands ?? [])], + outMessage: toast("success", "Alerts silenced"), + } + }, + }) +} + +const endSilence = (model: Model, silenceId: AlertSilenceId): Return => + model.endingSilenceIds.includes(silenceId) + ? { model } + : { + model: { ...model, endingSilenceIds: [...model.endingSilenceIds, silenceId] }, + commands: [EndHubSilence({ silenceId })], + } + // ROW MENUS /** @@ -607,6 +680,39 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), }) }, + // Rows are placed by the clock: read it again once the list lands, so a silence created or + // ended a moment ago is in its section now, not at the next 30-second tick. + CompletedFetchSilences: ({ key, result }) => ({ + model: { ...model, silences: Keyed.accept(model.silences, key, result) }, + commands: [ReadHubClock()], + }), + ClickedRetrySilences: () => refreshSilences(model, context), + ClickedNewSilence: () => + liftSilenceDialog( + model, + context, + SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs ?? 0), + ), + GotSilenceDialogMessage: ({ message: child }) => + liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), + ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), + CompletedEndSilence: ({ silenceId, result }) => { + const settled = { ...model, endingSilenceIds: withoutId(model.endingSilenceIds, silenceId) } + return Result.match(result, { + // A scheduled silence comes back cancelled: its window collapses to one instant. + onSuccess: (ended): Return => ({ + ...refreshSilences(settled, context), + outMessage: toast( + "success", + startsAtMs(ended) === endsAtMs(ended) ? "Silence cancelled" : "Silence ended", + ), + }), + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't end silence", failure), + }), + }) + }, ClickedConfirmDeleteMonitor: () => confirmDeleteMonitor(model), GotDeleteMonitorDialogMessage: ({ message: child }) => model.deletingMonitorId !== null diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts new file mode 100644 index 0000000000..b28020f776 --- /dev/null +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -0,0 +1,189 @@ +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { Option, Array as Arr } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { badge } from "@maple/views/ui/display" +import { button } from "@maple/views/ui/button" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import { listRow } from "@maple/views/ui/list-row" +import { panel } from "@maple/views/ui/panel" +import { skeleton } from "@maple/views/ui/skeleton" +import { vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { sectionHeading } from "../../kit/section-heading" +import { + endsAtMs, + formatSilenceTime, + silenceScopeParts, + silenceStatusAt, + startsAtMs, +} from "../shared/silences" +import { Message } from "./message" +import { canManage, type Model } from "./model" + +// The Silences tab: what is silenced now, what is scheduled, and what ended recently. + +type H = HtmlBuilder + +/** Ended silences stay listed for a week, so a recent maintenance window is still visible. */ +const ENDED_VISIBLE_MS = 7 * 24 * 60 * 60_000 + +const silencesEmptyState = (h: H): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "BellSlashIcon", { size: 18 })]), + emptyTitle(h, ["No silences"]), + emptyDescription(h, [ + "Silence a rule, a service or an environment during a deploy or a planned migration. Rules keep checking and incidents still open, but no one is paged.", + ]), + ]), + ], + "py-12", + ) + +const windowText = (silence: V2AlertSilence, nowMs: number, timeZone: string): string => { + const status = silenceStatusAt(silence, nowMs) + const start = formatSilenceTime(startsAtMs(silence), nowMs, timeZone) + const end = formatSilenceTime(endsAtMs(silence), nowMs, timeZone) + // A cancelled silence comes back with its window collapsed to one instant: it never ran. + return status === "active" + ? `Until ${end}` + : status === "scheduled" + ? `${start} to ${end}` + : startsAtMs(silence) === endsAtMs(silence) + ? `Cancelled ${end}` + : `Ended ${end}` +} + +const silenceRow = ( + h: H, + model: Model, + silence: V2AlertSilence, + options: Readonly<{ + nowMs: number + timeZone: string + ruleName: (id: string) => string + rulesLoaded: boolean + }>, +): Html => { + const status = silenceStatusAt(silence, options.nowMs) + const isEnding = model.endingSilenceIds.includes(silence.id) + return listRow(h, { + divided: true, + leading: icon(h, "BellSlashIcon", { size: 16 }), + title: h.span([h.Title(silence.reason)], [silence.reason]), + meta: Arr.join( + [ + windowText(silence, options.nowMs, options.timeZone), + // Rule names wait for the rules to sync rather than read as a bare "rule". + ...silenceScopeParts( + options.rulesLoaded ? silence.matchers : { ...silence.matchers, rule_ids: [] }, + options.ruleName, + ), + // Until rules sync, a rule-scoped silence still says it covers rules. + ...(options.rulesLoaded || (silence.matchers.rule_ids ?? []).length === 0 + ? [] + : [ + (silence.matchers.rule_ids ?? []).length === 1 + ? "1 rule" + : `${(silence.matchers.rule_ids ?? []).length} rules`, + ]), + ], + " · ", + ), + trailing: + status !== "ended" && canManage(model) + ? [ + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: isEnding, + attributes: [h.OnClick(Message.ClickedEndSilence({ silenceId: silence.id }))], + }, + [status === "scheduled" ? "Cancel" : "End now"], + ), + ] + : status === "ended" + ? [badge(h, { variant: "outline" }, ["Ended"])] + : [], + }) +} + +const section = ( + h: H, + model: Model, + title: string, + silences: ReadonlyArray, + options: Readonly<{ + nowMs: number + timeZone: string + ruleName: (id: string) => string + rulesLoaded: boolean + }>, +): ReadonlyArray => + silences.length === 0 + ? [] + : [ + vstack(h, { gap: "2" }, [ + sectionHeading(h, { title, count: String(silences.length) }), + panel( + h, + {}, + Arr.map(silences, (silence) => silenceRow(h, model, silence, options)), + ), + ]), + ] + +const loaded = ( + h: H, + model: Model, + silences: ReadonlyArray, + nowMs: number, + timeZone: string, +): Html => { + const rules = model.rules ?? [] + const ruleName = (id: string): string => + Option.getOrElse( + Option.map( + Arr.findFirst(rules, (rule) => rule.id === id), + (rule) => rule.name, + ), + () => "deleted rule", + ) + const byStatus = (status: V2AlertSilence["status"]) => + Arr.filter(silences, (silence) => silenceStatusAt(silence, nowMs) === status) + const ended = Arr.filter(byStatus("ended"), (silence) => endsAtMs(silence) > nowMs - ENDED_VISIBLE_MS) + const active = byStatus("active") + const scheduled = byStatus("scheduled") + if (active.length + scheduled.length + ended.length === 0) return silencesEmptyState(h) + const options = { nowMs, timeZone, ruleName, rulesLoaded: model.rules !== null } + return vstack(h, { gap: "6" }, [ + ...(active.length === 0 + ? [typography(h, { size: "xs", tone: "muted" }, ["Nothing is silenced right now"])] + : []), + ...section(h, model, "Active", active, options), + ...section(h, model, "Scheduled", scheduled, options), + ...section(h, model, "Ended in the last 7 days", ended, options), + ]) +} + +export const silencesTab = (h: H, model: Model, nowMs: number, timeZone: string): Html => { + const data = model.silences.data + if (AsyncData.isFailure(data)) + return errorState(h, { + failure: data.error, + title: "Couldn't load silences", + onRetry: Message.ClickedRetrySilences(), + className: "py-12", + }) + return Option.match(AsyncData.getData(data), { + onNone: () => vstack(h, { gap: "4" }, [skeleton(h, "h-6 w-40"), skeleton(h, "h-40 w-full")]), + onSome: (silences) => loaded(h, model, silences, nowMs, timeZone), + }) +} diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 21dee8d739..f575b2f9b6 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -23,6 +23,8 @@ import { alertsEmptyState } from "./view-empty" import { inboxTab, unacknowledgedCount } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" +import * as SilenceDialogView from "../silence-dialog/view" +import { silencesTab } from "./view-silences" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" import { slosTab } from "./view-slos" @@ -37,6 +39,7 @@ const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [ { value: "rules", label: "Rules" }, { value: "uptime", label: "Uptime" }, { value: "slos", label: "SLOs" }, + { value: "silences", label: "Silences" }, { value: "routing", label: "Routing" }, ] @@ -179,6 +182,19 @@ const hasSlos = (model: Model): boolean => // Destinations are a Routing concern, so "New destination" only shows on that tab; "New SLO" likewise. const headerActions = (h: H, model: Model, tab: AlertsTab): Html => hstack(h, { gap: "2" }, [ + ...(canManage(model) && tab === "silences" + ? [ + button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedNewSilence())], + }, + [icon(h, "BellSlashIcon"), "New silence"], + ), + ] + : []), ...(canManage(model) && tab === "slos" && hasSlos(model) ? [ linkButton( @@ -315,7 +331,9 @@ export const view = Submodel.defineView((model, ? uptimeTab(h, model, model.nowMs ?? 0, inputs.context.pinnedNamespace) : tab === "slos" ? slosTab(h, model) - : hubBody(h, model, overview, tab), + : tab === "silences" + ? silencesTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) + : hubBody(h, model, overview, tab), ], ), deleteDialog(h, model), @@ -327,6 +345,13 @@ export const view = Submodel.defineView((model, viewInputs: { id: "alerts-destination" }, toParentMessage: (message) => Message.GotDestinationDialogMessage({ message }), }), + h.submodel({ + slotId: "alerts-silence", + model: model.silenceDialog, + view: SilenceDialogView.view, + viewInputs: { id: "alerts-silence" }, + toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), + }), ], ) }) diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index 791c201b28..efd9aadf03 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -7,6 +7,8 @@ import { MapleApiV2Client } from "../../../platform/api-clients" import { listDeliveries, listIncidentActivity, setIncidentAcknowledged } from "../shared/commands" import { listSlos } from "../shared/slo-commands" import { listMonitors } from "../shared/uptime-commands" +import { listActiveSilences } from "../shared/silences" +import { readGrants } from "../../../data/session-tenant" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { Message } from "./message" @@ -121,3 +123,18 @@ export const FetchIncidentMonitors = Command.define("FetchAlertIncidentMonitors" Effect.map((result) => Message.CompletedFetchMonitors({ key, result })), ), }) + +export const FetchIncidentSilences = Command.define("FetchAlertIncidentSilences", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSilences], + execute: ({ key }) => + listActiveSilences.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSilences({ key, result })), + ), +}) + +export const FetchIncidentSession = Command.define("FetchAlertIncidentSession", { + messages: [Message.CompletedFetchSession], + execute: Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ grants })), +}) 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 57d5cf8157..8f8095cbd7 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,6 @@ import { OrgId } from "@maple/domain" -import { AlertRulePreviewResponse } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import { AlertRuleId, AlertRulePreviewResponse, AlertSilenceId } from "@maple/domain/http" import { sloRuleName } from "@maple/domain/slo" import { Result, Schema, Array as Arr } from "effect" import { @@ -628,3 +629,96 @@ describe("alert incident", () => { ) }) }) + +describe("alert incident, silences", () => { + const loaded = (roles: ReadonlyArray, withRules = true) => + Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow({ group_key: "checkout" })] }), + ...(withRules + ? [ + Message.UpdatedRules({ + orgId: ORG, + rows: [ruleRow(2, { group_by: JSON.stringify(["service.name"]) })], + }), + ] + : []), + Message.CompletedFetchSession({ grants: Policy.grantsOf(roles) }), + ], + init(route(), context).model, + (model, message) => step(model, message).model, + ) + + test("Silence on a service-grouped rule's incident silences only that group", () => { + const opened = step(loaded(["org:engineer"]), Message.ClickedSilence()).model + expect(opened.silenceDialog.scope).toEqual({ + _tag: "Fixed", + label: "Rule 2 · checkout", + matchers: { rule_ids: [ruleRow(2).id], services: ["checkout"] }, + }) + }) + + test("shows the badge for a silence that started earlier in the current minute", () => { + // The page floors its clock to the minute; a silence created just now starts after that floor. + const clock = Math.floor(NOW / 60_000) * 60_000 + 40_000 + const model = Arr.reduce( + [ + Message.ReadClock({ nowMs: clock }), + Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2)] }), + ], + init(route(), context).model, + (current, message) => step(current, message).model, + ) + const silence = { + id: Schema.decodeSync(AlertSilenceId)("22222222-2222-4222-8222-222222222222"), + object: "alert_silence" as const, + reason: "Deploy", + matchers: { rule_ids: [Schema.decodeSync(AlertRuleId)(ruleRow(2).id)] }, + status: "active" as const, + starts_at: new Date(clock - 10_000).toISOString(), + ends_at: new Date(clock + 60 * 60_000).toISOString(), + created_by: "user_1", + created_at: new Date(clock).toISOString(), + updated_at: new Date(clock).toISOString(), + } + scene( + { update: pageUpdate, view }, + given( + step( + model, + Message.CompletedFetchSilences({ + key: model.silences.key ?? "", + result: Result.succeed([silence]), + }), + ).model, + ), + themeResolved(), + expectView(text(/^Silenced until /u)).toExist(), + ) + }) + + test("offers Silence to managers once the rule is known, and never to members", () => { + scene( + { update: pageUpdate, view }, + given(loaded(["org:engineer"])), + themeResolved(), + expectView(role("button", { name: "Silence" })).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(loaded(["org:engineer"], false)), + themeResolved(), + expectView(role("button", { name: "Silence" })).toBeDisabled(), + ) + scene( + { update: pageUpdate, view }, + given(loaded(["org:member"])), + themeResolved(), + expectView(role("button", { name: "Silence" })).not.toExist(), + ) + // Before rules load a click does nothing, so the group can't be silenced as the whole rule. + expect(step(loaded(["org:engineer"], false), Message.ClickedSilence()).commands ?? []).toEqual([]) + }) +}) diff --git a/apps/web/src/page/alerts/incident/index.ts b/apps/web/src/page/alerts/incident/index.ts index 0413e147d6..49249d4b5a 100644 --- a/apps/web/src/page/alerts/incident/index.ts +++ b/apps/web/src/page/alerts/incident/index.ts @@ -1,8 +1,10 @@ -import { Schema } from "effect" +import { Clock, Duration, Effect, Option, Schema, Stream } from "effect" import { Subscription } from "foldkit" +import { pollTicks } from "../../../data/poll" import type { Resources } from "../../../platform/resources" import { definePage, type PageSubscriptionInput } from "../../contract" import { incidentRows, ruleRows, ruleStateRows } from "../shared/collections" +import { incidentViewOf } from "./derive" import { Message } from "./message" import { Model } from "./model" import { init, update } from "./update" @@ -13,7 +15,8 @@ const byOrg = { orgId: Schema.String } const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ incidents: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), }), rules: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), @@ -21,8 +24,27 @@ const subscriptions = Subscription.make, Message, R }), states: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), + dependenciesToStream: ({ orgId }) => + ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), }), + clock: entry( + { isOpen: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + isOpen: Option.exists(incidentViewOf(model), ({ incident }) => incident.status === "open"), + }), + dependenciesToStream: ({ isOpen }) => + isOpen + ? pollTicks(Duration.minutes(1)).pipe( + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => + Message.TickedClock({ nowMs }), + ), + ), + ) + : Stream.empty, + }, + ), })) /** `/alerts/incidents/$incidentId`: one instance per incident. */ diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index 5a806c2d53..26c47d9bb7 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -2,6 +2,7 @@ import { AlertDeliveryEventDocument, AlertRulePreviewResponse } from "@maple/dom import { V2AlertIncident, V2AlertIncidentEvent, + V2AlertSilence, V2Investigation, V2Slo, V2UptimeMonitor, @@ -12,17 +13,21 @@ import { AlertRuleStateRowSchema, } from "@maple/web-data/collections/alerts" import { Schema } from "effect" +import * as Policy from "@maple/domain/policy" import { defineMessageUnion } from "foldkit/message" import * as Chart from "@maple/views/charts/chart" import { ThemeColors } from "@maple/views/charts/theme-colors" import { LoadFailure } from "@maple/views/data/load-failure" import { CheckSummary } from "../shared/check-summary" import { PreviewFailure } from "../shared/preview" +import * as SilenceDialog from "../silence-dialog/dialog" const rows = (row: S) => ({ orgId: Schema.String, rows: Schema.Array(row) }) export const Message = defineMessageUnion({ ReadClock: { nowMs: Schema.Number }, + /** The minute clock while the incident is open, so a silence badge ends on time. */ + TickedClock: { nowMs: Schema.Number }, UpdatedIncidents: rows(AlertIncidentRowSchema), UpdatedRules: rows(AlertRuleRowSchema), UpdatedRuleStates: rows(AlertRuleStateRowSchema), @@ -59,5 +64,12 @@ export const Message = defineMessageUnion({ FailedLoadChartRuntime: { reason: Schema.String }, ResolvedThemeColors: { colors: ThemeColors }, GotChartMessage: { message: Chart.Message }, + CompletedFetchSilences: { + key: Schema.String, + result: Schema.Result(Schema.Array(V2AlertSilence), LoadFailure), + }, + ClickedSilence: {}, + GotSilenceDialogMessage: { message: SilenceDialog.Message }, + CompletedFetchSession: { grants: Schema.Array(Policy.PermissionName) }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index 49293a4d1f..876a740f82 100644 --- a/apps/web/src/page/alerts/incident/model.ts +++ b/apps/web/src/page/alerts/incident/model.ts @@ -1,11 +1,18 @@ import { AlertDeliveryEventDocument } from "@maple/domain/http" -import { V2AlertIncidentEvent, V2Investigation, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" +import { + V2AlertIncidentEvent, + V2AlertSilence, + V2Investigation, + V2Slo, + V2UptimeMonitor, +} from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, AlertRuleStateRowSchema, } from "@maple/web-data/collections/alerts" import { Schema } from "effect" +import * as Policy from "@maple/domain/policy" import * as Chart from "@maple/views/charts/chart" import { ChartRuntime } from "@maple/views/charts/chart-runtime" import { ThemeColors } from "@maple/views/charts/theme-colors" @@ -13,6 +20,7 @@ import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import { CheckSummary } from "../shared/check-summary" import { PreviewSlot } from "../shared/preview" +import * as SilenceDialog from "../silence-dialog/dialog" // `/alerts/incidents/$incidentId`. @@ -43,5 +51,12 @@ export const Model = Schema.Struct({ chart: Chart.Model, chartRuntime: ChartRuntime, themeColors: Schema.NullOr(ThemeColors), + /** `alertSilences.list`: which silence, if any, holds back this incident's notifications. */ + silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), + silenceDialog: SilenceDialog.Model, + /** What the session may do (`Policy.grantsOf`); none until it answers, so Silence stays hidden. */ + grants: Schema.Array(Policy.PermissionName), + /** The clock as read, not floored: a silence created this minute is already in force. */ + clockMs: Schema.NullOr(Schema.Number), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index 3bce573d36..84407319cb 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -23,6 +23,8 @@ import { FetchIncidentInvestigation, FetchIncidentMonitors, FetchIncidentPreview, + FetchIncidentSession, + FetchIncidentSilences, FetchIncidentSlos, ReadIncidentClock, SetIncidentAcknowledged, @@ -34,40 +36,127 @@ import { UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { incidentViewOf, minuteOf, ruleStatesOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" +import * as SilenceDialog from "../silence-dialog/dialog" +import { matchersForRule } from "../shared/silences" type Return = PageReturn type Commands = ReadonlyArray> type IncidentRoute = Extract -export const init = (route: IncidentRoute, _context: PageContext): Return => ({ - model: { - incidentId: route.incidentId, - nowMs: null, - incidents: null, - rules: null, - states: null, - preview: Keyed.idle(), - checks: Keyed.idle(), - deliveries: Keyed.idle(), - investigation: Keyed.idle(), - activity: Keyed.idle(), - isUpdatingAcknowledgement: false, - slos: Keyed.idle(), - monitors: Keyed.idle(), - isStartingInvestigation: false, - chart: Chart.init(), - chartRuntime: { _tag: "Loading" }, - themeColors: null, - }, - commands: [ - ReadIncidentClock(), - Command.mapMessage(LoadChartRuntime(), (message) => - message._tag === "CompletedLoadChartRuntime" - ? Message.CompletedLoadChartRuntime() - : Message.FailedLoadChartRuntime({ reason: message.reason }), - ), - ], -}) +const silencesKey = (context: PageContext) => + requestKey(context.orgId, "alertSilences.list", { query: { status: "active", limit: 100 } }) + +const requestSilences = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => + Option.match(Keyed.requestFor(model.silences, silencesKey(context), mode), { + onNone: () => ({ model }), + onSome: (silences) => ({ + model: { ...model, silences }, + commands: [FetchIncidentSilences({ key: silences.key })], + }), + }) + +/** The silence dialog's outcome: a toast, and a re-read so the header shows the new silence. */ +const liftSilenceDialog = ( + model: Model, + context: PageContext, + step: ReturnType, +): Return => { + const next = { ...model, silenceDialog: step.model } + const commands = Command.mapMessages(step.commands ?? [], (message) => + Message.GotSilenceDialogMessage({ message }), + ) + if (step.outMessage === undefined) return { model: next, commands } + return SilenceDialog.OutMessage.match(step.outMessage, { + RequestedToast: ({ type, title, description }) => ({ + model: next, + commands, + outMessage: PageOutMessage.RequestedToast({ type, title, description }), + }), + CreatedSilence: () => { + const refreshed = requestSilences(next, context, "Supersede") + return { + model: refreshed.model, + commands: [...commands, ...(refreshed.commands ?? []), ReadIncidentClock()], + outMessage: PageOutMessage.RequestedToast({ + type: "success", + title: "Alerts silenced", + description: "", + }), + } + }, + }) +} + +/** Silence this incident's rule, or only its group when the rule is grouped by service. */ +const openSilence = (model: Model, context: PageContext): Return => + // The rule decides whether one group or the whole rule is silenced, so wait for it. + model.rules === null + ? { model } + : Option.match(incidentViewOf(model), { + onNone: () => ({ model }), + onSome: ({ incident, rule }) => { + const group = incidentGroup(incident) + const name = rule?.name ?? incident.ruleName + const target = + rule === null + ? { label: name, matchers: { rule_ids: [incident.ruleId] } } + : { + label: group === null ? name : `${name} · ${group}`, + matchers: matchersForRule(rule, group), + } + return liftSilenceDialog( + model, + context, + SilenceDialog.openFor( + model.silenceDialog, + target, + context.timeZone, + model.clockMs ?? 0, + ), + ) + }, + }) + +export const init = (route: IncidentRoute, context: PageContext): Return => { + const initial: Return = { + model: { + incidentId: route.incidentId, + nowMs: null, + incidents: null, + rules: null, + states: null, + preview: Keyed.idle(), + checks: Keyed.idle(), + deliveries: Keyed.idle(), + investigation: Keyed.idle(), + activity: Keyed.idle(), + isUpdatingAcknowledgement: false, + slos: Keyed.idle(), + monitors: Keyed.idle(), + isStartingInvestigation: false, + chart: Chart.init(), + chartRuntime: { _tag: "Loading" }, + themeColors: null, + silences: Keyed.idle(), + silenceDialog: SilenceDialog.init("alert-incident-silence"), + grants: [], + clockMs: null, + }, + commands: [ + ReadIncidentClock(), + Command.mapMessage(LoadChartRuntime(), (message) => + message._tag === "CompletedLoadChartRuntime" + ? Message.CompletedLoadChartRuntime() + : Message.FailedLoadChartRuntime({ reason: message.reason }), + ), + ], + } + const silences = requestSilences(initial.model, context, "Load") + return { + model: silences.model, + commands: [...(initial.commands ?? []), ...(silences.commands ?? []), FetchIncidentSession()], + } +} const slosKey = (context: PageContext) => requestKey(context.orgId, "slos.list", { query: { limit: SLO_LIST_LIMIT } }) @@ -296,7 +385,8 @@ const withIncidents = ( export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { - ReadClock: ({ nowMs }) => followData({ ...model, nowMs: minuteOf(nowMs) }, context), + ReadClock: ({ nowMs }) => followData({ ...model, nowMs: minuteOf(nowMs), clockMs: nowMs }, context), + TickedClock: ({ nowMs }) => ({ model: { ...model, clockMs: nowMs } }), UpdatedIncidents: ({ orgId, rows }) => withIncidents(model, context, orgId, rows), UpdatedRules: ({ orgId, rows }) => withRuleRows(model, context, orgId, { rules: rows }), UpdatedRuleStates: ({ orgId, rows }) => withRuleRows(model, context, orgId, { states: rows }), @@ -389,6 +479,13 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, chartRuntime: { _tag: "Failed", reason } }, }), ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + CompletedFetchSilences: ({ key, result }) => ({ + model: { ...model, silences: Keyed.accept(model.silences, key, result) }, + }), + ClickedSilence: () => openSilence(model, context), + CompletedFetchSession: ({ grants }) => ({ model: { ...model, grants } }), + GotSilenceDialogMessage: ({ message: child }) => + liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), GotChartMessage: ({ message: child }) => { const next = Chart.update(model.chart, child) return { diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 0c49f5c314..b901bca4f6 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -52,6 +52,10 @@ import { groupPreviewOf, type IncidentView, incidentViewOf, ruleStatesOf } from import { Message } from "./message" import type { Model } from "./model" import { incidentRail } from "./view-rail" +import { activeSilenceFor } from "../shared/silences" +import { silenceBadge, silenceButton, silenceCheckFailed } from "../shared/silence-view" +import * as Policy from "@maple/domain/policy" +import * as SilenceDialogView from "../silence-dialog/view" import { incidentTimeline } from "./view-timeline" import { timestamp } from "@maple/views/ui/timestamp" import { typography } from "@maple/views/ui/typography" @@ -102,6 +106,7 @@ export const headerTitle = ( rule: AlertRuleDocument | null, now: number, timeZone: string, + badges: ReadonlyArray = [], ) => { const group = incidentGroup(incident) const isUptime = incident.signalType === "uptime" @@ -143,6 +148,7 @@ export const headerTitle = ( [h.Class("flex flex-wrap items-center gap-2.5")], [ severityBadge(h, incident.severity), + ...badges, typography(h, { size: "xs", tone: "muted" }, statusLine(h, incident, now, timeZone)), ], ), @@ -152,6 +158,33 @@ export const headerTitle = ( ) } +const isOpenIncident = (model: Model): boolean => + Option.match(incidentViewOf(model), { + onNone: () => false, + onSome: ({ incident }) => incident.status === "open", + }) + +/** "Silenced until 15:00" over an open incident, or a note when the silences could not be read. */ +const silenceBadges = (h: H, model: Model, view: IncidentView, timeZone: string) => { + if (view.incident.status !== "open") return [] + // The unfloored clock: a silence created this minute is in force already. + const now = model.clockMs ?? model.nowMs ?? 0 + if (AsyncData.isFailure(model.silences.data)) return [silenceCheckFailed(h)] + return Option.toArray( + Option.map( + Option.flatMap(Option.fromNullishOr(view.rule), (rule) => + activeSilenceFor( + AsyncData.getOrElse(model.silences.data, () => []), + rule, + view.incident.groupKey, + now, + ), + ), + (silence) => silenceBadge(h, silence, now, timeZone), + ), + ) +} + /** `IncidentHeader`'s Acknowledge or Unacknowledge button; nothing once the incident resolved. */ export const acknowledgeAction = ( h: HtmlBuilder, @@ -415,13 +448,23 @@ export const view = Submodel.defineView((model, return dashboardPage( h, { - titleContent: headerTitle(h, view.incident, view.rule, now, timeZone), + titleContent: headerTitle( + h, + view.incident, + view.rule, + now, + timeZone, + silenceBadges(h, model, view, timeZone), + ), actions: hstack(h, { gap: "2", wrap: true }, [ ...acknowledgeAction(h, { incident: view.incident, isLoading: model.isUpdatingAcknowledgement, onAcknowledge: (acknowledged) => Message.ClickedAcknowledge({ acknowledged }), }), + ...(Policy.has(model.grants, "alerts:manage") && isOpenIncident(model) + ? [silenceButton(h, Message.ClickedSilence(), model.rules === null)] + : []), investigateAction( h, model, @@ -433,7 +476,19 @@ export const view = Submodel.defineView((model, ) }, }) - return h.div([h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: RULE_CHART_TOKENS }))], [page]) + return h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: RULE_CHART_TOKENS }))], + [ + page, + h.submodel({ + slotId: "alert-incident-silence", + model: model.silenceDialog, + view: SilenceDialogView.view, + viewInputs: { id: "alert-incident-silence" }, + toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), + }), + ], + ) }) /** `DashboardPage`'s breadcrumbs per state. */ diff --git a/apps/web/src/page/alerts/rule/command.ts b/apps/web/src/page/alerts/rule/command.ts index 3b31bfcf6f..a4b2518ba3 100644 --- a/apps/web/src/page/alerts/rule/command.ts +++ b/apps/web/src/page/alerts/rule/command.ts @@ -7,6 +7,8 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { resolveWindow } from "../../../data/time-range" import { MapleApiV2Client } from "../../../platform/api-clients" import { listDeliveries, setRuleEnabled } from "../shared/commands" +import { listActiveSilences } from "../shared/silences" +import { readGrants } from "../../../data/session-tenant" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { CheckStatusFilter } from "./model" @@ -166,3 +168,18 @@ export const OpenRuleInvestigation = Command.define("OpenAlertRuleInvestigation" Effect.map((result) => Message.CompletedOpenInvestigation({ result })), ), }) + +export const FetchRuleSilences = Command.define("FetchAlertRuleSilences", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSilences], + execute: ({ key }) => + listActiveSilences.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSilences({ key, result })), + ), +}) + +export const FetchRuleSession = Command.define("FetchAlertRuleSession", { + messages: [Message.CompletedFetchSession], + execute: Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ grants })), +}) diff --git a/apps/web/src/page/alerts/rule/index.ts b/apps/web/src/page/alerts/rule/index.ts index 04e1d0107e..2c10c34d2e 100644 --- a/apps/web/src/page/alerts/rule/index.ts +++ b/apps/web/src/page/alerts/rule/index.ts @@ -1,5 +1,6 @@ -import { Schema } from "effect" +import { Clock, Duration, Effect, Schema, Stream } from "effect" import { Subscription } from "foldkit" +import { pollTicks } from "../../../data/poll" import type { Resources } from "../../../platform/resources" import { definePage, type PageSubscriptionInput } from "../../contract" import { destinationRows, incidentRows, ruleRows, ruleStateRows } from "../shared/collections" @@ -17,17 +18,32 @@ const subscriptions = Subscription.make, Message, R }), incidents: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), }), states: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }) => ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), + dependenciesToStream: ({ orgId }) => + ruleStateRows(orgId, (rows) => Message.UpdatedRuleStates({ orgId, rows })), }), destinations: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), dependenciesToStream: ({ orgId }) => destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), }), + // The minute clock, so a silence badge starts and ends on time while the page stays open. + clock: entry( + {}, + { + modelToDependencies: () => ({}), + dependenciesToStream: () => + pollTicks(Duration.minutes(1)).pipe( + Stream.mapEffect(() => + Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadDiagnosisClock({ nowMs })), + ), + ), + }, + ), })) /** `/alerts/$ruleId`: one instance per rule; the tab and window are search. */ diff --git a/apps/web/src/page/alerts/rule/message.ts b/apps/web/src/page/alerts/rule/message.ts index 9a455c8273..ce228257ba 100644 --- a/apps/web/src/page/alerts/rule/message.ts +++ b/apps/web/src/page/alerts/rule/message.ts @@ -1,4 +1,9 @@ -import { AlertCheckDocument, AlertDeliveryEventDocument, AlertIncidentId, AlertRulePreviewResponse } from "@maple/domain/http" +import { + AlertCheckDocument, + AlertDeliveryEventDocument, + AlertIncidentId, + AlertRulePreviewResponse, +} from "@maple/domain/http" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -6,10 +11,13 @@ import { AlertRuleStateRowSchema, } from "@maple/web-data/collections/alerts" import { Schema } from "effect" +import * as Policy from "@maple/domain/policy" import { defineMessageUnion } from "foldkit/message" import * as Chart from "@maple/views/charts/chart" import { ThemeColors } from "@maple/views/charts/theme-colors" import { LoadFailure } from "@maple/views/data/load-failure" +import { V2AlertSilence } from "@maple/domain/http/v2" +import * as SilenceDialog from "../silence-dialog/dialog" import { TimeWindow } from "../../../data/time-range" import * as Menu from "@maple/views/ui/menu" import * as TimeRangePicker from "../../kit/time-range-picker" @@ -33,7 +41,10 @@ export const Message = defineMessageUnion({ result: Schema.Result(Schema.Array(AlertDeliveryEventDocument), LoadFailure), }, CompletedFetchChecks: { key: Schema.String, result: Schema.Result(ChecksResponse, LoadFailure) }, - CompletedFetchPreview: { key: Schema.String, result: Schema.Result(AlertRulePreviewResponse, PreviewFailure) }, + CompletedFetchPreview: { + key: Schema.String, + result: Schema.Result(AlertRulePreviewResponse, PreviewFailure), + }, ClickedStateFilter: { filter: StateFilter }, ClickedCheckStatusFilter: { filter: CheckStatusFilter }, ClickedSignalSource: { source: SignalSource }, @@ -48,7 +59,10 @@ export const Message = defineMessageUnion({ CompletedLoadMoreChecks: { generation: Schema.Number, result: Schema.Result( - Schema.Struct({ checks: Schema.Array(AlertCheckDocument), nextCursor: Schema.NullOr(Schema.String) }), + Schema.Struct({ + checks: Schema.Array(AlertCheckDocument), + nextCursor: Schema.NullOr(Schema.String), + }), LoadFailure, ), }, @@ -71,5 +85,12 @@ export const Message = defineMessageUnion({ FailedLoadChartRuntime: { reason: Schema.String }, ResolvedThemeColors: { colors: ThemeColors }, GotChartMessage: { message: Chart.Message }, + CompletedFetchSilences: { + key: Schema.String, + result: Schema.Result(Schema.Array(V2AlertSilence), LoadFailure), + }, + ClickedSilence: {}, + GotSilenceDialogMessage: { message: SilenceDialog.Message }, + CompletedFetchSession: { grants: Schema.Array(Policy.PermissionName) }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/rule/model.ts b/apps/web/src/page/alerts/rule/model.ts index c37a563c38..5c571dc3f1 100644 --- a/apps/web/src/page/alerts/rule/model.ts +++ b/apps/web/src/page/alerts/rule/model.ts @@ -6,6 +6,7 @@ import { AlertRuleStateRowSchema, } from "@maple/web-data/collections/alerts" import { Schema } from "effect" +import * as Policy from "@maple/domain/policy" import * as Chart from "@maple/views/charts/chart" import { ChartRuntime } from "@maple/views/charts/chart-runtime" import { ThemeColors } from "@maple/views/charts/theme-colors" @@ -13,6 +14,8 @@ import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import { TimeWindow } from "../../../data/time-range" import { AlertRuleSearch } from "../../../route/search" +import { V2AlertSilence } from "@maple/domain/http/v2" +import * as SilenceDialog from "../silence-dialog/dialog" import * as Menu from "@maple/views/ui/menu" import * as TimeRangePicker from "../../kit/time-range-picker" import { CheckSummary } from "../shared/check-summary" @@ -88,5 +91,10 @@ export const Model = Schema.Struct({ chart: Chart.Model, chartRuntime: ChartRuntime, themeColors: Schema.NullOr(ThemeColors), + /** `alertSilences.list`: which silence, if any, holds back this rule's notifications. */ + silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), + silenceDialog: SilenceDialog.Model, + /** What the session may do (`Policy.grantsOf`); none until it answers, so Silence stays hidden. */ + grants: Schema.Array(Policy.PermissionName), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/rule/rule.scene.test.ts b/apps/web/src/page/alerts/rule/rule.scene.test.ts index 21827a40ea..43a8422656 100644 --- a/apps/web/src/page/alerts/rule/rule.scene.test.ts +++ b/apps/web/src/page/alerts/rule/rule.scene.test.ts @@ -1,5 +1,6 @@ import { OrgId } from "@maple/domain" -import { AlertCheckDocument, AlertRuleId } from "@maple/domain/http" +import { AlertCheckDocument, AlertRuleId, AlertSilenceId } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" import { Result, Schema, Array as Arr } from "effect" import { all, @@ -351,3 +352,57 @@ describe("alert rule", () => { ) }) }) + +describe("alert rule page, silences", () => { + const silence = { + id: Schema.decodeSync(AlertSilenceId)("22222222-2222-4222-8222-222222222222"), + object: "alert_silence" as const, + reason: "Deploying payments", + matchers: { rule_ids: [RULE] }, + status: "active" as const, + starts_at: new Date(NOW - 60_000).toISOString(), + ends_at: new Date(NOW + 60 * 60_000).toISOString(), + created_by: "user_1", + created_at: new Date(NOW).toISOString(), + updated_at: new Date(NOW).toISOString(), + } + const withGrants = (model: Model, roles: ReadonlyArray) => + pageUpdate(model, Message.CompletedFetchSession({ grants: Policy.grantsOf(roles) })).model + const withSilences = ( + model: Model, + result: Parameters[0]["result"], + ) => pageUpdate(model, Message.CompletedFetchSilences({ key: model.silences.key ?? "", result })).model + + test("offers Silence only to a role that can manage alerts", () => { + expect(names(init(route(), context).commands)).toEqual( + expect.arrayContaining(["FetchAlertRuleSilences", "FetchAlertRuleSession"]), + ) + scene( + { update: pageUpdate, view }, + given(withGrants(loaded(), ["org:member"])), + themeResolved(), + expectView(role("button", { name: "Silence" })).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withGrants(loaded(), ["org:engineer"])), + themeResolved(), + expectView(role("button", { name: "Silence" })).toExist(), + ) + }) + + test("shows the silence in force, and says so when silences could not be read", () => { + scene( + { update: pageUpdate, view }, + given(withSilences(loaded(), Result.succeed([silence]))), + themeResolved(), + expectView(text(/^Silenced until /u)).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withSilences(loaded(), Result.fail(toLoadFailure(new Error("down"))))), + themeResolved(), + expectView(text("Couldn't check silences")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/rule/update.ts b/apps/web/src/page/alerts/rule/update.ts index 670f944545..06177371b6 100644 --- a/apps/web/src/page/alerts/rule/update.ts +++ b/apps/web/src/page/alerts/rule/update.ts @@ -26,6 +26,8 @@ import { ReadDiagnosisClock, ResolveRuleWindow, ToggleRuleEnabled, + FetchRuleSession, + FetchRuleSilences, } from "./command" import { checksPageOf, @@ -38,6 +40,68 @@ import { } from "./derive" import { Message, type RequestMode } from "./message" import type { Model } from "./model" +import * as SilenceDialog from "../silence-dialog/dialog" +import { matchersForRule } from "../shared/silences" + +const silencesKey = (context: PageContext) => + requestKey(context.orgId, "alertSilences.list", { query: { status: "active", limit: 100 } }) + +const requestSilences = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => + Option.match(Keyed.requestFor(model.silences, silencesKey(context), mode), { + onNone: () => ({ model }), + onSome: (silences) => ({ + model: { ...model, silences }, + commands: [FetchRuleSilences({ key: silences.key })], + }), + }) + +/** The silence dialog's outcome: a toast, and a re-read so the header shows the new silence. */ +const liftSilenceDialog = ( + model: Model, + context: PageContext, + step: ReturnType, +): Return => { + const next = { ...model, silenceDialog: step.model } + const commands = Command.mapMessages(step.commands ?? [], (message) => + Message.GotSilenceDialogMessage({ message }), + ) + if (step.outMessage === undefined) return { model: next, commands } + return SilenceDialog.OutMessage.match(step.outMessage, { + RequestedToast: ({ type, title, description }) => ({ + model: next, + commands, + outMessage: PageOutMessage.RequestedToast({ type, title, description }), + }), + CreatedSilence: () => { + const refreshed = requestSilences(next, context, "Supersede") + return { + model: refreshed.model, + commands: [...commands, ...(refreshed.commands ?? []), ReadDiagnosisClock()], + outMessage: PageOutMessage.RequestedToast({ + type: "success", + title: "Alerts silenced", + description: "", + }), + } + }, + }) +} + +const openSilence = (model: Model, context: PageContext): Return => { + const rule = ruleOf(model) + return rule === null + ? { model } + : liftSilenceDialog( + model, + context, + SilenceDialog.openFor( + model.silenceDialog, + { label: rule.name, matchers: matchersForRule(rule, null) }, + context.timeZone, + model.nowMs, + ), + ) +} type Return = PageReturn type Commands = ReadonlyArray> @@ -150,11 +214,17 @@ export const init = (route: RuleRoute, context: PageContext): Return => { chart: Chart.init(), chartRuntime: { _tag: "Loading" }, themeColors: null, + silences: Keyed.idle(), + silenceDialog: SilenceDialog.init("alert-rule-silence"), + grants: [], } - const followed = followData(model, context, "Load") + const silences = requestSilences(model, context, "Load") + const followed = followData(silences.model, context, "Load") return { model: followed.model, commands: [ + ...(silences.commands ?? []), + FetchRuleSession(), resolveCommand(model, context, "Load"), ReadDiagnosisClock(), ...(followed.commands ?? []), @@ -495,6 +565,13 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, chartRuntime: { _tag: "Failed", reason } }, }), ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + CompletedFetchSilences: ({ key, result }) => ({ + model: { ...model, silences: Keyed.accept(model.silences, key, result) }, + }), + ClickedSilence: () => openSilence(model, context), + CompletedFetchSession: ({ grants }) => ({ model: { ...model, grants } }), + GotSilenceDialogMessage: ({ message: child }) => + liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), GotChartMessage: ({ message: child }) => { const next = Chart.update(model.chart, child) return { diff --git a/apps/web/src/page/alerts/rule/view.ts b/apps/web/src/page/alerts/rule/view.ts index c6532a0e1b..bc4851c344 100644 --- a/apps/web/src/page/alerts/rule/view.ts +++ b/apps/web/src/page/alerts/rule/view.ts @@ -1,6 +1,6 @@ -import type { AlertRuleDocument } from "@maple/domain/http" +import type { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" import { comparatorLabels, formatSignalValue, signalLabels } from "@maple/web-data/alerts/form-utils" -import { Submodel } from "foldkit" +import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { ResolveThemeColors } from "@maple/views/charts/theme-colors" import { linkButton } from "@maple/views/ui/button" @@ -24,7 +24,13 @@ import { historyTab } from "./view-history" import { overviewTab } from "./view-overview" import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" -import { Array as Arr } from "effect" +import { Option, Array as Arr } from "effect" +import { activeSilenceFor } from "../shared/silences" +import { silenceBadge, silenceButton, silenceCheckFailed } from "../shared/silence-view" +import * as Policy from "@maple/domain/policy" +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { incidentGroup } from "@maple/web-data/alerts/incident-timeline" +import * as SilenceDialogView from "../silence-dialog/view" // The rule detail page through `ResultPage` and `DetailHeader`. @@ -48,10 +54,41 @@ export const ruleSubtitle = (rule: AlertRuleDocument): string => { const toTimePicker = (message: TimeRangePicker.Message) => Message.GotTimePickerMessage({ message }) +/** The silence over the whole rule, or else over one open incident's group (named in the badge). */ +const ruleSilence = ( + model: Model, + rule: AlertRuleDocument, + openIncidents: ReadonlyArray, +): Option.Option<{ readonly silence: V2AlertSilence; readonly group: string | null }> => { + const silences = AsyncData.getOrElse(model.silences.data, () => []) + return Option.orElse( + Option.map(activeSilenceFor(silences, rule, null, model.nowMs), (silence) => ({ + silence, + group: null, + })), + () => + Arr.head( + Arr.flatMap(openIncidents, (incident) => + Option.toArray( + Option.map( + activeSilenceFor(silences, rule, incident.groupKey, model.nowMs), + (silence) => ({ + silence, + group: incidentGroup(incident), + }), + ), + ), + ), + ), + ) +} + /** `DetailHeader` (kind eyebrow, title, meta row) with the time controls and Edit rule. */ -const header = (h: H, model: Model, rule: AlertRuleDocument) => { + +const header = (h: H, model: Model, rule: AlertRuleDocument, timeZone: string) => { const openIncidents = Arr.filter(ruleIncidentsOf(model), (i) => i.status === "open") const isFiring = openIncidents.length > 0 + const silence = ruleSilence(model, rule, openIncidents) return { titleContent: h.div( [h.Class("min-w-0 space-y-2.5")], @@ -68,6 +105,13 @@ const header = (h: H, model: Model, rule: AlertRuleDocument) => { Arr.every(openIncidents, (i) => i.holdReason != null) ? "held" : "firing", ) : alertStatusBadge(h, rule.enabled ? "ok" : "disabled"), + ...(AsyncData.isFailure(model.silences.data) + ? [silenceCheckFailed(h)] + : Option.toArray( + Option.map(silence, ({ silence: active, group }) => + silenceBadge(h, active, model.nowMs, timeZone, group), + ), + )), typography(h, { size: "sm", tone: "muted" }, [ruleSubtitle(rule)]), ]), ], @@ -95,6 +139,9 @@ const header = (h: H, model: Model, rule: AlertRuleDocument) => { }, }), }), + ...(Policy.has(model.grants, "alerts:manage") + ? [silenceButton(h, Message.ClickedSilence())] + : []), linkButton(h, { variant: "outline", size: "sm", href: createHref({ ruleId: rule.id }) }, [ icon(h, "PencilIcon"), "Edit rule", @@ -147,14 +194,26 @@ export const view = Submodel.defineView((model, back: { href: alertsHref({}), label: "Back to rules" }, }), ]) - : dashboardPage(h, { ...header(h, model, rule), tabs: tabStrip(h, model) }, [ + : dashboardPage(h, { ...header(h, model, rule, timeZone), tabs: tabStrip(h, model) }, [ activeTabOf(model) === "overview" ? overviewTab(h, model, rule, timeZone) : model.incidents === null ? historyLoading(h) : historyTab(h, model, rule, ruleIncidentsOf(model), timeZone, model.nowMs), ]) - return h.div([h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: RULE_CHART_TOKENS }))], [page]) + return h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: RULE_CHART_TOKENS }))], + [ + page, + h.submodel({ + slotId: "alert-rule-silence", + model: model.silenceDialog, + view: SilenceDialogView.view, + viewInputs: { id: "alert-rule-silence" }, + toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), + }), + ], + ) }) export const breadcrumbsOf = (model: Model) => { diff --git a/apps/web/src/page/alerts/shared/silence-view.ts b/apps/web/src/page/alerts/shared/silence-view.ts new file mode 100644 index 0000000000..27b524d695 --- /dev/null +++ b/apps/web/src/page/alerts/shared/silence-view.ts @@ -0,0 +1,32 @@ +import type { V2AlertSilence } from "@maple/domain/http/v2" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { badge } from "@maple/views/ui/display" +import { icon } from "@maple/views/ui/icon" +import { typography } from "@maple/views/ui/typography" +import { endsAtMs, formatSilenceTime } from "./silences" + +/** "Silenced until 15:00": the silence holding back a rule's or an incident's notifications. */ +export const silenceBadge = ( + h: HtmlBuilder, + silence: V2AlertSilence, + nowMs: number, + timeZone: string, + /** Names the one group silenced when the rule as a whole is not ("checkout silenced until"). */ + group: string | null = null, +): Html => + badge(h, { variant: "outline", attributes: [h.Title(silence.reason)] }, [ + icon(h, "BellSlashIcon"), + `${group === null ? "Silenced" : `${group} silenced`} until ${formatSilenceTime(endsAtMs(silence), nowMs, timeZone)}`, + ]) + +/** Shown in place of the badge when the silences could not be read, so "no badge" never reads as "not silenced". */ +export const silenceCheckFailed = (h: HtmlBuilder): Html => + typography(h, { size: "xs", tone: "muted" }, ["Couldn't check silences"]) + +/** The header's "Silence" action, which opens the silence dialog scoped to the page's rule or incident. */ +export const silenceButton = (h: HtmlBuilder, onClick: M, isDisabled = false): Html => + button(h, { variant: "outline", size: "sm", isDisabled, attributes: [h.OnClick(onClick)] }, [ + icon(h, "BellSlashIcon"), + "Silence", + ]) diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts new file mode 100644 index 0000000000..075271638d --- /dev/null +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest" +import { Option, Schema } from "effect" +import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { activeSilenceFor, matchersForRule } from "./silences" + +const ruleId = Schema.decodeSync(AlertRuleId)("11111111-1111-4111-8111-111111111111") +const asSilenceId = Schema.decodeSync(AlertSilenceId) + +const NOW = Date.UTC(2026, 10, 6, 12, 0) +const iso = (ms: number) => new Date(ms).toISOString() + +const silence = ( + id: string, + matchers: V2AlertSilence["matchers"], + startOffsetMin: number, + endOffsetMin: number, +): V2AlertSilence => ({ + id: asSilenceId(id), + object: "alert_silence", + reason: "deploy", + matchers, + status: "active", + starts_at: iso(NOW + startOffsetMin * 60_000), + ends_at: iso(NOW + endOffsetMin * 60_000), + created_by: "user_1", + created_at: iso(NOW), + updated_at: iso(NOW), +}) + +const groupedRule = { + id: ruleId, + tags: [], + serviceNames: [], + environments: [], + groupBy: ["service.name"], + signalType: "error_rate" as const, + queryBuilderDraft: null, +} + +describe("activeSilenceFor", () => { + it("picks the latest-ending silence in force, from the window not the stored status", () => { + const long = silence("22222222-2222-4222-8222-222222222222", { rule_ids: [ruleId] }, -10, 120) + const short = silence("33333333-3333-4333-8333-333333333333", { rule_ids: [ruleId] }, -10, 30) + const ended = silence("44444444-4444-4444-8444-444444444444", { rule_ids: [ruleId] }, -60, -1) + const scheduled = silence("55555555-5555-4555-8555-555555555555", { rule_ids: [ruleId] }, 5, 10) + expect( + Option.map( + activeSilenceFor([long, ended, scheduled, short], groupedRule, null, NOW), + (s) => s.id, + ), + ).toEqual(Option.some(long.id)) + }) + + it("covers only the silenced group of a rule grouped by service", () => { + const checkout = silence( + "66666666-6666-4666-8666-666666666666", + matchersForRule(groupedRule, "checkout"), + -1, + 60, + ) + expect(Option.isSome(activeSilenceFor([checkout], groupedRule, "checkout", NOW))).toBe(true) + expect(Option.isSome(activeSilenceFor([checkout], groupedRule, "payments", NOW))).toBe(false) + }) +}) + +describe("matchersForRule", () => { + const rule = ( + overrides: Partial<{ groupBy: ReadonlyArray | null; serviceNames: ReadonlyArray }>, + ) => ({ + id: ruleId, + groupBy: null, + serviceNames: [], + signalType: "error_rate" as const, + queryBuilderDraft: null, + ...overrides, + }) + + it("narrows to the incident's group the way the server matches it", () => { + expect(matchersForRule(rule({ groupBy: ["service.name"] }), "checkout")).toEqual({ + rule_ids: [ruleId], + services: ["checkout"], + }) + expect(matchersForRule(rule({ serviceNames: ["checkout", "payments"] }), "payments")).toEqual({ + rule_ids: [ruleId], + services: ["payments"], + }) + expect(matchersForRule(rule({ groupBy: ["deployment.environment"] }), "staging")).toEqual({ + rule_ids: [ruleId], + environments: ["staging"], + }) + expect(matchersForRule(rule({ groupBy: ["service.name"] }), "__total__")).toEqual({ + rule_ids: [ruleId], + }) + expect(matchersForRule(rule({ groupBy: ["http.route"] }), "/cart")).toEqual({ rule_ids: [ruleId] }) + }) +}) diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts new file mode 100644 index 0000000000..1501407d08 --- /dev/null +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -0,0 +1,152 @@ +import { + alertSilenceGroupBy, + alertSilenceMatches, + type AlertRuleDocument, + type AlertSilenceId, + type AlertSilenceMatchers, + UNGROUPED_GROUP_KEY, +} from "@maple/domain/http" +import type { + V2AlertSilence, + V2AlertSilenceCreateParams, + V2AlertSilenceMatchers, +} from "@maple/domain/http/v2" +import { Effect, Option, Order, Array as Arr } from "effect" +import { formatDateInTimezone, formatTimeInTimezone } from "@maple/web-data/lib/timezone-format" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { isoMs } from "./time" + +// Alert silences: the reads and writes the hub, rule and incident pages share, and how a page +// tells which silence covers a rule or an incident. + +const listByStatus = (status?: V2AlertSilence["status"]) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const response = yield* client.alertSilences.list({ + query: status === undefined ? { limit: 100 } : { status, limit: 100 }, + }) + return response.data + }) + +/** The hub's list: active and scheduled in full, plus the most recently ended. */ +export const listSilences = Effect.all( + [listByStatus("active"), listByStatus("scheduled"), listByStatus("ended")], + { concurrency: "unbounded" }, +).pipe( + Effect.mapBoth({ + onSuccess: ([active, scheduled, ended]) => [...active, ...scheduled, ...ended], + onFailure: toLoadFailure, + }), +) + +/** What a rule or incident page needs for its badge: those in force, and those that start later. */ +export const listActiveSilences = Effect.all([listByStatus("active"), listByStatus("scheduled")], { + concurrency: "unbounded", +}).pipe( + Effect.mapBoth({ + onSuccess: ([active, scheduled]) => [...active, ...scheduled], + onFailure: toLoadFailure, + }), +) + +export const createSilence = (payload: V2AlertSilenceCreateParams) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.alertSilences.create({ payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const endSilence = (id: AlertSilenceId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.alertSilences.end({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const startsAtMs = (silence: V2AlertSilence): number => isoMs(silence.starts_at) +export const endsAtMs = (silence: V2AlertSilence): number => isoMs(silence.ends_at) + +/** "15:00" today, "Nov 7, 09:00" on another day, in the viewer's zone. */ +export const formatSilenceTime = (ms: number, nowMs: number, timeZone: string): string => { + const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false }) + return formatDateInTimezone(ms, { timeZone }) === formatDateInTimezone(nowMs, { timeZone }) + ? time + : `${formatDateInTimezone(ms, { timeZone, withYear: false })}, ${time}` +} + +/** Where a silence stands at `nowMs`, from its window rather than the status it was read with. */ +export const silenceStatusAt = (silence: V2AlertSilence, nowMs: number): V2AlertSilence["status"] => + nowMs < startsAtMs(silence) ? "scheduled" : nowMs < endsAtMs(silence) ? "active" : "ended" + +const toDomainMatchers = (matchers: V2AlertSilenceMatchers): AlertSilenceMatchers => ({ + ...(matchers.rule_ids === undefined ? undefined : { ruleIds: matchers.rule_ids }), + ...(matchers.tags === undefined ? undefined : { tags: matchers.tags }), + ...(matchers.services === undefined ? undefined : { services: matchers.services }), + ...(matchers.environments === undefined ? undefined : { environments: matchers.environments }), +}) + +const byLatestEnd = Order.flip(Order.mapInput(Order.Number, endsAtMs)) + +/** + * The silence in force over a rule (and one incident group of it) at `nowMs`. The latest-ending + * one wins: notifications resume only when every matching silence has ended. + */ +export const activeSilenceFor = ( + silences: ReadonlyArray, + rule: Pick< + AlertRuleDocument, + "id" | "tags" | "serviceNames" | "environments" | "groupBy" | "signalType" | "queryBuilderDraft" + >, + groupKey: string | null, + nowMs: number, +): Option.Option => + Arr.head( + Arr.sort( + Arr.filter( + silences, + (silence) => + silenceStatusAt(silence, nowMs) === "active" && + alertSilenceMatches(toDomainMatchers(silence.matchers), { + ruleId: rule.id, + tags: rule.tags, + serviceNames: rule.serviceNames, + environments: rule.environments, + groupBy: alertSilenceGroupBy(rule), + groupKey, + }), + ), + byLatestEnd, + ), + ) + +/** "service checkout · tag db": what a silence covers, for a list row. Rule ids read as their names. */ +export const silenceScopeParts = ( + matchers: V2AlertSilenceMatchers, + ruleName: (id: string) => string, +): ReadonlyArray => [ + ...Arr.map(matchers.rule_ids ?? [], (id) => `rule ${ruleName(id)}`), + ...Arr.map(matchers.services ?? [], (service) => `service ${service}`), + ...Arr.map(matchers.environments ?? [], (environment) => `env ${environment}`), + ...Arr.map(matchers.tags ?? [], (tag) => `tag ${tag}`), +] + +type GroupedRule = Pick + +const groupedBy = (rule: GroupedRule, dimension: string): boolean => { + const groupBy = alertSilenceGroupBy(rule) + return groupBy !== null && groupBy.length === 1 && groupBy[0] === dimension +} + +/** + * The matchers that silence one rule, or just one incident group of it: a service group (grouped + * by service, or a multi-service rule keyed by service) or an environment group, as the server reads them. + */ +export const matchersForRule = ( + rule: Pick & GroupedRule, + groupKey: string | null, +): V2AlertSilenceMatchers => { + if (groupKey === null || groupKey === UNGROUPED_GROUP_KEY) return { rule_ids: [rule.id] } + if (groupedBy(rule, "service.name") || rule.serviceNames.includes(groupKey)) + return { rule_ids: [rule.id], services: [groupKey] } + if (groupedBy(rule, "deployment.environment")) return { rule_ids: [rule.id], environments: [groupKey] } + return { rule_ids: [rule.id] } +} diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts new file mode 100644 index 0000000000..1083a79abf --- /dev/null +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest" +import { AlertSilenceId } from "@maple/domain/http" +import type { V2AlertSilence } from "@maple/domain/http/v2" +import { Option, Result, Schema } from "effect" +import { + init, + isFormReady, + matchersOf, + Message, + openNew, + OutMessage, + silenceEndMs, + untilError, + update, +} from "./dialog" + +// 2026-11-06 14:20 in Berlin (UTC+1). +const NOW = Date.UTC(2026, 10, 6, 13, 20) +const BERLIN = "Europe/Berlin" + +describe("silenceEndMs", () => { + it("ends an hour or four hours from now", () => { + expect(silenceEndMs("1h", "", NOW, BERLIN)).toEqual(Option.some(NOW + 60 * 60_000)) + expect(silenceEndMs("4h", "", NOW, BERLIN)).toEqual(Option.some(NOW + 4 * 60 * 60_000)) + }) + + it("ends at the next 09:00 on the viewer's wall clock", () => { + expect(silenceEndMs("morning", "", NOW, BERLIN)).toEqual(Option.some(Date.UTC(2026, 10, 7, 8, 0))) + // At 00:30 the next 09:00 is this morning's, and across a month's end it rolls over. + expect(silenceEndMs("morning", "", Date.UTC(2026, 9, 31, 23, 30), BERLIN)).toEqual( + Option.some(Date.UTC(2026, 10, 1, 8, 0)), + ) + // Across the DST change (Berlin falls back on 2026-10-25): 09:00 CET, not CEST. + expect(silenceEndMs("morning", "", Date.UTC(2026, 9, 24, 20, 0), BERLIN)).toEqual( + Option.some(Date.UTC(2026, 9, 25, 8, 0)), + ) + }) + + it("reads a custom end in the viewer's zone and refuses one in the past", () => { + expect(silenceEndMs("custom", "2026-11-06T18:30", NOW, BERLIN)).toEqual( + Option.some(Date.UTC(2026, 10, 6, 17, 30)), + ) + expect(silenceEndMs("custom", "2026-11-06T09:00", NOW, BERLIN)).toEqual(Option.none()) + expect(silenceEndMs("custom", "tomorrow", NOW, BERLIN)).toEqual(Option.none()) + }) +}) + +describe("matchersOf", () => { + it("splits typed lists and drops empty fields", () => { + expect( + matchersOf({ + _tag: "Typed", + services: " checkout, payments ,checkout", + tags: "", + environments: "production", + }), + ).toEqual({ services: ["checkout", "payments"], environments: ["production"] }) + }) +}) + +describe("isFormReady", () => { + const typed = (services: string) => ({ _tag: "Typed" as const, services, tags: "", environments: "" }) + + it("needs a reason and something to match", () => { + const model = init("silence") + expect(isFormReady({ ...model, reason: "deploy", scope: typed("") })).toBe(false) + expect(isFormReady({ ...model, reason: " ", scope: typed("checkout") })).toBe(false) + expect(isFormReady({ ...model, reason: "deploy", scope: typed("checkout") })).toBe(true) + }) + + it("needs a readable end for a custom duration", () => { + const model = { + ...init("silence"), + reason: "deploy", + scope: typed("checkout"), + duration: "custom" as const, + timeZone: BERLIN, + nowMs: NOW, + } + expect(isFormReady({ ...model, until: "" })).toBe(false) + expect(isFormReady({ ...model, until: "2026-11-07T09:00" })).toBe(true) + expect(isFormReady({ ...model, until: "2026-11-06T09:00" })).toBe(false) + expect(untilError({ ...model, until: "2026-11-06T09:00" })).toEqual( + Option.some("Pick a time in the future"), + ) + expect(isFormReady({ ...model, until: "2026-12-24T09:00" })).toBe(false) + expect(untilError({ ...model, until: "2026-12-24T09:00" })).toEqual( + Option.some("A silence can last at most 30 days"), + ) + }) +}) + +describe("saving", () => { + const SILENCE: V2AlertSilence = { + id: Schema.decodeSync(AlertSilenceId)("22222222-2222-4222-8222-222222222222"), + object: "alert_silence", + reason: "deploy", + matchers: { services: ["checkout"] }, + status: "active", + starts_at: new Date(NOW).toISOString(), + ends_at: new Date(NOW + 60 * 60_000).toISOString(), + created_by: "user_1", + created_at: new Date(NOW).toISOString(), + updated_at: new Date(NOW).toISOString(), + } + const saving = () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const typed = { ...opened, reason: "deploy", scope: { ...opened.scope, services: "checkout" } } + return update(typed, Message.ClickedSave()).model + } + + it("closes the form and reports the new silence", () => { + const model = saving() + expect(model.isSaving).toBe(true) + const saved = update( + model, + Message.CompletedSave({ openCount: model.openCount, result: Result.succeed(SILENCE) }), + ) + expect(saved.model.isSaving).toBe(false) + expect(saved.model.dialog.isOpen).toBe(false) + expect(saved.outMessage).toEqual(OutMessage.CreatedSilence({ silence: SILENCE })) + }) + + it("leaves a reopened form alone when an older save lands", () => { + const model = saving() + const saved = update( + model, + Message.CompletedSave({ openCount: model.openCount - 1, result: Result.succeed(SILENCE) }), + ) + expect(saved.model.dialog.isOpen).toBe(true) + expect(saved.model.isSaving).toBe(true) + expect(saved.outMessage).toEqual(OutMessage.CreatedSilence({ silence: SILENCE })) + }) + + it("leaves a reopened form saving when an older save fails", () => { + const model = saving() + const failed = update( + model, + Message.CompletedSave({ openCount: model.openCount - 1, result: Result.fail("nope") }), + ) + expect(failed.model.isSaving).toBe(true) + }) + + it("keeps the form open and toasts the failure", () => { + const model = saving() + const failed = update( + model, + Message.CompletedSave({ openCount: model.openCount, result: Result.fail("nope") }), + ) + expect(failed.model.isSaving).toBe(false) + expect(failed.model.dialog.isOpen).toBe(true) + expect(failed.outMessage).toEqual( + OutMessage.RequestedToast({ + type: "error", + title: "Couldn't silence alerts", + description: "nope", + }), + ) + }) +}) diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts new file mode 100644 index 0000000000..3589f53f5c --- /dev/null +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -0,0 +1,283 @@ +import { V2AlertSilence, V2AlertSilenceMatchers } from "@maple/domain/http/v2" +import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" +import { Clock, DateTime, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import * as Dialog from "@maple/views/ui/dialog" +import type { Resources } from "../../../platform/resources" +import { MAX_ALERT_SILENCE_DURATION_MS } from "@maple/domain/http" +import { createSilence } from "../shared/silences" + +// "Silence notifications": the dialog the hub, a rule and an incident open, as one Submodel. + +export const SilenceDuration = Schema.Literals(["1h", "4h", "morning", "custom"]) +export type SilenceDuration = typeof SilenceDuration.Type + +export const ScopeField = Schema.Literals(["services", "tags", "environments"]) +export type ScopeField = typeof ScopeField.Type + +/** What the silence covers: fixed by the page that opened it, or typed in (the hub's "New silence"). */ +export const Scope = Schema.Union([ + Schema.TaggedStruct("Fixed", { label: Schema.String, matchers: V2AlertSilenceMatchers }), + Schema.TaggedStruct("Typed", { + services: Schema.String, + tags: Schema.String, + environments: Schema.String, + }), +]) +export type Scope = typeof Scope.Type + +export const Model = Schema.Struct({ + dialog: Dialog.Model, + scope: Scope, + reason: Schema.String, + duration: SilenceDuration, + /** The custom end, as a `datetime-local` input holds it (`2026-11-06T18:30`), in `timeZone`. */ + until: Schema.String, + timeZone: Schema.String, + /** The wall clock when the dialog opened: a custom end is checked against it. */ + nowMs: Schema.Number, + isSaving: Schema.Boolean, + /** Bumped on every open: a save's result only settles the form it was sent from. */ + openCount: Schema.Number, +}) +export type Model = typeof Model.Type + +export const init = (id: string): Model => ({ + dialog: Dialog.init(id, "dialog"), + scope: { _tag: "Typed", services: "", tags: "", environments: "" }, + reason: "", + duration: "1h", + until: "", + timeZone: "UTC", + nowMs: 0, + isSaving: false, + openCount: 0, +}) + +export const Message = defineMessageUnion({ + GotDialogMessage: { message: Dialog.Message }, + ChangedReason: { value: Schema.String }, + ChangedDuration: { duration: SilenceDuration }, + ChangedUntil: { value: Schema.String }, + ChangedScopeField: { field: ScopeField, value: Schema.String }, + ClickedCancel: {}, + ClickedSave: {}, + CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2AlertSilence, Schema.String) }, +}) +export type Message = typeof Message.Type + +export const OutMessage = defineMessageUnion({ + CreatedSilence: { silence: V2AlertSilence }, + RequestedToast: { + type: Schema.Literals(["success", "error"]), + title: Schema.String, + description: Schema.String, + }, +}) +export type OutMessage = typeof OutMessage.Type + +type Return = Update.ReturnWithOutMessage + +const HOUR_MS = 60 * 60_000 +const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/ + +/** A `datetime-local` value read as a wall clock in `timeZone`. */ +export const parseLocalDateTime = (value: string, timeZone: string): Option.Option => { + const match = LOCAL_DATE_TIME.exec(value) + if (match === null) return Option.none() + const [year, month, day, hour, minute] = Arr.map(match.slice(1), Number) + if ( + year === undefined || + month === undefined || + day === undefined || + hour === undefined || + minute === undefined + ) + return Option.none() + return Option.some(zonedPartsToEpochMs({ year, month, day, hour, minute, second: 0 }, timeZone)) +} + +/** When a silence picked at `nowMs` ends; none for a custom end that is missing, past, or over 30 days out. */ +export const silenceEndMs = ( + duration: SilenceDuration, + until: string, + nowMs: number, + timeZone: string, +): Option.Option => { + switch (duration) { + case "1h": + return Option.some(nowMs + HOUR_MS) + case "4h": + return Option.some(nowMs + 4 * HOUR_MS) + case "morning": { + // The next 09:00 on the viewer's wall clock: this morning's if it is still ahead. + const today = zonedDateParts(nowMs, timeZone) + const at = (day: number) => + zonedPartsToEpochMs({ ...today, day, hour: 9, minute: 0, second: 0 }, timeZone) + return Option.some(at(today.day) > nowMs ? at(today.day) : at(today.day + 1)) + } + case "custom": + return Option.filter( + parseLocalDateTime(until, timeZone), + (endMs) => endMs > nowMs && endMs - nowMs <= MAX_ALERT_SILENCE_DURATION_MS, + ) + } +} + +const splitList = (value: string): ReadonlyArray => + Arr.dedupe(Arr.filter(Arr.map(Str.split(value, ","), Str.trim), Str.isNonEmpty)) + +/** The matchers the form describes; empty matchers mean there is nothing to save yet. */ +export const matchersOf = (scope: Scope): V2AlertSilenceMatchers => { + if (scope._tag === "Fixed") return scope.matchers + const services = splitList(scope.services) + const tags = splitList(scope.tags) + const environments = splitList(scope.environments) + return { + ...(services.length === 0 ? undefined : { services }), + ...(tags.length === 0 ? undefined : { tags }), + ...(environments.length === 0 ? undefined : { environments }), + } +} + +const hasMatchers = (matchers: V2AlertSilenceMatchers) => + (matchers.rule_ids?.length ?? 0) + + (matchers.services?.length ?? 0) + + (matchers.tags?.length ?? 0) + + (matchers.environments?.length ?? 0) > + 0 + +/** Why a typed custom end cannot be used, or none when it can (or nothing is typed yet). */ +export const untilError = (model: Model): Option.Option => { + if (model.duration !== "custom" || model.until === "") return Option.none() + return Option.match(parseLocalDateTime(model.until, model.timeZone), { + onNone: () => Option.some("Pick a date and time"), + onSome: (endMs) => + endMs <= model.nowMs + ? Option.some("Pick a time in the future") + : endMs - model.nowMs > MAX_ALERT_SILENCE_DURATION_MS + ? Option.some("A silence can last at most 30 days") + : Option.none(), + }) +} + +/** Ready to save: a reason, something to match and an end that is ahead and within 30 days. */ +export const isFormReady = (model: Model): boolean => + Str.isNonEmpty(Str.trim(model.reason)) && + hasMatchers(matchersOf(model.scope)) && + Option.isSome(silenceEndMs(model.duration, model.until, model.nowMs, model.timeZone)) + +// COMMANDS + +export const SaveSilence = Command.define("SaveAlertSilence", { + args: { + openCount: Schema.Number, + reason: Schema.String, + matchers: V2AlertSilenceMatchers, + duration: SilenceDuration, + until: Schema.String, + timeZone: Schema.String, + }, + messages: [Message.CompletedSave], + execute: ({ openCount, reason, matchers, duration, until, timeZone }) => + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis + const endsAt = yield* Effect.fromOption(silenceEndMs(duration, until, nowMs, timeZone)).pipe( + Effect.mapError(() => "Pick an end time in the future"), + ) + return yield* createSilence({ + reason, + matchers, + ends_at: DateTime.formatIso(DateTime.makeUnsafe(endsAt)), + }).pipe(Effect.mapError((failure) => failure.message)) + }).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSave({ openCount, result })), + ), +}) + +// UPDATE + +const liftDialog = (model: Model, result: ReturnType): Return => ({ + model: { ...model, dialog: result.model }, + commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotDialogMessage({ message })), +}) + +const open = (model: Model, scope: Scope, timeZone: string, nowMs: number): Return => + liftDialog( + { + ...model, + scope, + timeZone, + nowMs, + reason: "", + duration: "1h", + until: "", + isSaving: false, + openCount: model.openCount + 1, + }, + Dialog.open(model.dialog), + ) + +/** Silence one rule, or one group of it: the scope is fixed and named in the dialog. */ +export const openFor = ( + model: Model, + target: { readonly label: string; readonly matchers: V2AlertSilenceMatchers }, + timeZone: string, + nowMs: number, +): Return => open(model, { _tag: "Fixed", ...target }, timeZone, nowMs) + +/** A silence the user scopes by service, tag and environment. */ +export const openNew = (model: Model, timeZone: string, nowMs: number): Return => + open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone, nowMs) + +const save = (model: Model): Return => + model.isSaving || !isFormReady(model) + ? { model } + : { + model: { ...model, isSaving: true }, + commands: [ + SaveSilence({ + openCount: model.openCount, + reason: Str.trim(model.reason), + matchers: matchersOf(model.scope), + duration: model.duration, + until: model.until, + timeZone: model.timeZone, + }), + ], + } + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotDialogMessage: ({ message: child }) => liftDialog(model, Dialog.update(model.dialog, child)), + ChangedReason: ({ value }) => ({ model: { ...model, reason: value } }), + ChangedDuration: ({ duration }) => ({ model: { ...model, duration } }), + ChangedUntil: ({ value }) => ({ model: { ...model, until: value } }), + ChangedScopeField: ({ field, value }) => + model.scope._tag === "Fixed" + ? { model } + : { model: { ...model, scope: { ...model.scope, [field]: value } } }, + ClickedCancel: () => liftDialog(model, Dialog.close(model.dialog)), + ClickedSave: () => save(model), + CompletedSave: ({ openCount, result }) => + Result.match(result, { + onSuccess: (silence) => { + // A result from an earlier open still refreshes the list, but leaves this form alone. + const closed = + openCount === model.openCount + ? liftDialog({ ...model, isSaving: false }, Dialog.close(model.dialog)) + : { model } + return { ...closed, outMessage: OutMessage.CreatedSilence({ silence }) } + }, + onFailure: (failure) => ({ + model: openCount === model.openCount ? { ...model, isSaving: false } : model, + outMessage: OutMessage.RequestedToast({ + type: "error", + title: "Couldn't silence alerts", + description: failure, + }), + }), + }), + }) diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts new file mode 100644 index 0000000000..9e748ea70e --- /dev/null +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -0,0 +1,192 @@ +import { Option, Array as Arr } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { eyebrow } from "@maple/views/ui/eyebrow" +import { fieldDescription, fieldError, fieldLabel, type FieldIds, input } from "@maple/views/ui/field" +import { formDialog } from "@maple/views/ui/form-dialog" +import type { FieldState } from "@maple/views/ui/input" +import { panel } from "@maple/views/ui/panel" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" +import { + isFormReady, + Message, + type Model, + type ScopeField, + type SilenceDuration, + silenceEndMs, + untilError, +} from "./dialog" +import { formatSilenceTime } from "../shared/silences" + +type H = HtmlBuilder + +const DURATIONS: ReadonlyArray<{ value: SilenceDuration; label: string }> = [ + { value: "1h", label: "1 hour" }, + { value: "4h", label: "4 hours" }, + { value: "morning", label: "Until 09:00" }, + { value: "custom", label: "Pick time" }, +] + +/** "Silence for 1 hour", or the end it will stop at: "Silence until Nov 7, 18:30". */ +const submitLabel = (model: Model): string => + model.duration === "1h" + ? "Silence for 1 hour" + : model.duration === "4h" + ? "Silence for 4 hours" + : Option.match(silenceEndMs(model.duration, model.until, model.nowMs, model.timeZone), { + onNone: () => "Silence", + onSome: (endMs) => + `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone)}`, + }) + +const stateOf = (value: string, isInvalid = false): FieldState => ({ + isInvalid, + isDirty: false, + isFilled: value !== "", + isFocused: false, + isTouched: false, +}) + +const ids = (name: string, hasDescription = false): FieldIds => ({ + control: `silence-${name}`, + label: `silence-${name}-label`, + error: `silence-${name}-error`, + ...(hasDescription ? { description: `silence-${name}-description` } : undefined), +}) + +const textField = ( + h: H, + options: Readonly<{ + name: string + label: string + value: string + placeholder: string + onInput: (value: string) => Message + type?: string + description?: string + error?: Option.Option + }>, +): Html => { + const fieldIds = ids(options.name, options.description !== undefined) + const error = options.error ?? Option.none() + const state = stateOf(options.value, Option.isSome(error)) + return vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids: fieldIds, className: "text-xs", state }, [options.label]), + input(h, { + ids: fieldIds, + state, + attributes: [ + h.Value(options.value), + h.Placeholder(options.placeholder), + h.OnInput(options.onInput), + h.Attribute("autocomplete", "off"), + ...(options.type === undefined ? [] : [h.Type(options.type)]), + ], + }), + ...(options.description === undefined + ? [] + : [fieldDescription(h, { ids: fieldIds, state }, [options.description])]), + ...Option.toArray( + Option.map(error, (message) => + fieldError(h, { ids: fieldIds, state, attributes: [h.Role("alert")] }, [message]), + ), + ), + ]) +} + +const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholder: string }> = [ + { field: "services", label: "Services", placeholder: "checkout, payments" }, + { field: "environments", label: "Environments", placeholder: "production" }, + { field: "tags", label: "Tags", placeholder: "database" }, +] + +/** What a scope fixed by a rule or an incident covers, in a sentence. */ +const fixedScopeLine = (matchers: V2AlertSilenceMatchers): string => { + const groups = [...(matchers.services ?? []), ...(matchers.environments ?? [])] + return groups.length > 0 + ? `Only the ${Arr.join(groups, ", ")} group of this rule` + : "Every notification of this rule" +} + +const scopeSection = (h: H, model: Model): Html => + vstack(h, { gap: "2" }, [ + eyebrow(h, { variant: "label", as: "div" }, ["Covers"]), + model.scope._tag === "Fixed" + ? panel(h, { padded: true, className: "gap-1" }, [ + typography(h, { size: "sm", weight: "medium" }, [model.scope.label]), + typography(h, { size: "xs", tone: "muted" }, [fixedScopeLine(model.scope.matchers)]), + ]) + : panel(h, { padded: true, className: "gap-3" }, [ + ...Arr.map(SCOPE_FIELDS, ({ field, label, placeholder }) => + textField(h, { + name: field, + label, + value: model.scope._tag === "Typed" ? model.scope[field] : "", + placeholder, + onInput: (value) => Message.ChangedScopeField({ field, value }), + }), + ), + typography(h, { size: "xs", tone: "muted" }, [ + "Fill in at least one. Every listed field must match: a rule's services and environments all listed, and one of its tags.", + ]), + ]), + ]) + +const durationSection = (h: H, model: Model): Html => + vstack(h, { gap: "2" }, [ + eyebrow(h, { variant: "label", as: "div" }, ["For"]), + segmentedSelect(h, { + ariaLabel: "Silence duration", + options: DURATIONS, + value: model.duration, + onChange: (duration) => Message.ChangedDuration({ duration }), + size: "sm", + className: "max-w-full overflow-x-auto", + }), + ...(model.duration === "custom" + ? [ + textField(h, { + name: "until", + label: "Ends", + value: model.until, + placeholder: "", + type: "datetime-local", + onInput: (value) => Message.ChangedUntil({ value }), + description: `In ${model.timeZone}. At most 30 days from now.`, + error: untilError(model), + }), + ] + : []), + ]) + +/** The "Silence notifications" dialog: what it covers, for how long, and why. */ +export const view = Submodel.defineView((model, inputs, h) => + formDialog(h, { + slotId: `${inputs.id}-dialog`, + model: model.dialog, + toParentMessage: (message) => Message.GotDialogMessage({ message }), + className: "sm:max-w-lg", + title: "Silence notifications", + description: + "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", + panelClassName: "space-y-5", + fields: [ + scopeSection(h, model), + durationSection(h, model), + textField(h, { + name: "reason", + label: "Reason", + value: model.reason, + placeholder: "Deploying checkout v2.3", + onInput: (value) => Message.ChangedReason({ value }), + }), + ], + onSubmit: Message.ClickedSave(), + submitLabel: submitLabel(model), + isPending: model.isSaving, + isSubmitDisabled: !isFormReady(model), + }), +) diff --git a/packages/views/src/ui/icons.generated.ts b/packages/views/src/ui/icons.generated.ts index 51f068d86c..757bddd947 100644 --- a/packages/views/src/ui/icons.generated.ts +++ b/packages/views/src/ui/icons.generated.ts @@ -1860,6 +1860,230 @@ export const ICON_TREES = { ], ], ], + BellSlashIcon: [ + "svg", + [ + ["xmlns", "http://www.w3.org/2000/svg"], + ["viewBox", "0 0 24 24"], + ["width", "7777"], + ["height", "7777"], + ["class", "__icon_class__"], + ["fill", "none"], + ["aria-hidden", "true"], + ], + [ + [ + "path", + [ + ["d", "M9 20H9.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M15 20H15.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M16 4H16.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M8 4H8.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M11 22H13"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M10 2H14"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M21 16L21 14"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M3 16L3 14"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M18 6L18 12L19 12"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M6 9L6 12L5 12"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M5 18H14"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M3 3H3.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M5 5H5.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M7 7H7.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M9 9H9.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M11 11H11.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M13 13H13.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M15 15H15.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M17 17H17.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M19 19H19.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + [ + "path", + [ + ["d", "M21 21H21.01"], + ["stroke", "currentColor"], + ["stroke-width", "2"], + ["stroke-linecap", "square"], + ], + [], + ], + ], + ], BellIcon: [ "svg", [ diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index bbd16923f3..57fb256b6f 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -142,6 +142,17 @@ describe("buildIncidentTimeline", () => { expect(failed?.detail).toBe("discord · webhook returned 500") }) + it("names a page a silence held back", () => { + const [, silenced] = build(makeIncident(), [makeDelivery({ status: "silenced" })]) + expect(silenced?.title).toBe("Page to #alerts held back by a silence") + expect(silenced?.tone).toBe("neutral") + expect(silenced?.pending).toBe(false) + const [, reminder] = build(makeIncident(), [ + makeDelivery({ status: "silenced", eventType: "renotify" }), + ]) + expect(reminder?.title).toBe("Reminder to #alerts held back by a silence") + }) + it("ends a held incident on the hold instead of a breach", () => { const entries = build(makeIncident({ holdReason: "no_data", heldSince: "2026-10-15T14:40:00.000Z" })) expect(entries.at(-1)?.title).toBe("Waiting on data") diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 659c371716..2c0316d60e 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -140,19 +140,26 @@ function deliveryEntry( const name = event.destinationName const failed = event.status === "failed" const sending = event.status === "queued" || event.status === "processing" - const title = sending - ? `Sending to ${name}` - : event.eventType === "resolve" - ? failed - ? `All-clear to ${name} failed` - : `Sent all-clear to ${name}` + const silenced = event.status === "silenced" + const title = silenced + ? event.eventType === "resolve" + ? `All-clear to ${name} held back by a silence` : event.eventType === "renotify" + ? `Reminder to ${name} held back by a silence` + : `Page to ${name} held back by a silence` + : sending + ? `Sending to ${name}` + : event.eventType === "resolve" ? failed - ? `Reminder to ${name} failed` - : `Reminded ${name}` - : failed - ? `Could not notify ${name}` - : `Notified ${name}` + ? `All-clear to ${name} failed` + : `Sent all-clear to ${name}` + : event.eventType === "renotify" + ? failed + ? `Reminder to ${name} failed` + : `Reminded ${name}` + : failed + ? `Could not notify ${name}` + : `Notified ${name}` const reason = failed ? (event.errorMessage ?? (event.responseCode != null ? `HTTP ${event.responseCode}` : null)) : null @@ -164,7 +171,13 @@ function deliveryEntry( return { key: `delivery:${event.id}`, at: Date.parse(event.attemptedAt ?? event.scheduledAt), - tone: failed ? "crit" : sending ? "neutral" : event.eventType === "resolve" ? "ok" : "info", + tone: failed + ? "crit" + : sending || silenced + ? "neutral" + : event.eventType === "resolve" + ? "ok" + : "info", pending: sending, title, detail: Arr.join(