Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
29c2aa9
feat(alerts): alert silences that hold back notifications
Makisuo Nov 6, 2026
d7e995b
feat(alerts): MCP tools for alert silences
Makisuo Nov 6, 2026
ea07154
feat(web): silence alerts from the hub, a rule or an incident
Makisuo Nov 6, 2026
2e8a246
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
2351fb0
fix(web): silence review fixes
Makisuo Nov 6, 2026
227eba3
fix(alerts): record held-back pages under the claim; count in-flight …
Makisuo Nov 6, 2026
fb5754e
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
be74c29
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
5a58f05
Merge remote-tracking branch 'origin/main' into feat/alert-silences
Makisuo Nov 6, 2026
4d3a1f1
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
6ec8298
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
6a15781
chore(domain): regenerate anticipated error identifiers for AlertSile…
Makisuo Nov 6, 2026
c3f3817
fix(ai): list_alert_silences keeps scheduled silences in view behind …
Makisuo Nov 6, 2026
8bfef02
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
606a185
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
2a847e4
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
fddf076
chore(ios): regenerate the OpenAPI spec for alert silences
Makisuo Nov 6, 2026
fa062ab
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
76da4b8
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
8a9aee6
fix(alerts): purge silences with their org; exact silence durations
Makisuo Nov 6, 2026
cbab22e
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
8ce2686
fix(web): name environment groups in the silence dialog; cancel copy;…
Makisuo Nov 6, 2026
fa0e701
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
3618c51
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
80004c5
fix(alerts): match silences on a builder query's grouping; commit cat…
Makisuo Nov 6, 2026
7be3a4e
fix(web): match silences on a builder query's draft grouping, like th…
Makisuo Nov 6, 2026
bcc1b82
test(web): incident page Silence gating and group-scoped target
Makisuo Nov 6, 2026
7b6b193
test(ai): create_alert_silence with ends_at alone and a non-positive …
Makisuo Nov 6, 2026
e3988fe
fix(web): the incident page shows a silence created this minute right…
Makisuo Nov 6, 2026
83ec593
fix(web): silences land in the right section at once; preview test fixes
Makisuo Nov 6, 2026
8b542eb
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
f757e49
Merge remote-tracking branch 'origin/main' into feat/alert-silences
Makisuo Nov 6, 2026
2087c76
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
5c1371b
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
115cdff
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
07ea490
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
d8401e1
test(web): add the silence fields to main's v2 incident fixtures
Makisuo Nov 6, 2026
3c6ac02
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
7e00c63
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
083cbf2
fix(alerts): no catch-up page for an incident acknowledged during its…
Makisuo Nov 6, 2026
6ca5cef
fix(ai): create_alert_silence keeps the validation details (which rul…
Makisuo Nov 6, 2026
737b134
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
b772d82
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
4a135b6
fix(alerts): claim the catch-up page in SQL against a late ack; an es…
Makisuo Nov 6, 2026
cb6c0e0
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
44cab13
fix(ai): log a failed silence reason lookup in get_incident_timeline
Makisuo Nov 6, 2026
000d3a3
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
ea50321
fix(alerts): a catch-up's notification anchor moves only when the cat…
Makisuo Nov 6, 2026
0078626
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
ddf435f
fix(ai): the timeline reads a settled silence hold as past, not as th…
Makisuo Nov 6, 2026
e9f0de8
fix(alerts): a phone paged before a silence still gets the all-clear …
Makisuo Nov 6, 2026
0dc8523
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
e57027d
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
4c5022f
fix(alerts): withhold the phone all-clear only for an incident opened…
Makisuo Nov 6, 2026
f3a350d
Merge branch 'feat/alert-silences' into feat/alert-silences-mcp
Makisuo Nov 6, 2026
0265aa1
Merge branch 'feat/alert-silences-mcp' into feat/alert-silences-ui
Makisuo Nov 6, 2026
d59117c
Merge remote-tracking branch 'origin/main' into feat/alert-silences-ui
Makisuo Nov 6, 2026
af5bc38
test(web): cover the silence dialog's save result
Makisuo Nov 6, 2026
86e0151
fix(web): tick the incident clock, keep a reopened silence form savin…
Makisuo Nov 6, 2026
dc49d67
fix(web): badges see scheduled silences and the rule page clock ticks
Makisuo Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/web/src/page/alerts/hub/command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { UptimeMonitorId } from "@maple/domain"
import { AlertDestinationId, 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"
Expand All @@ -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"

Expand Down Expand Up @@ -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 })),
),
})
4 changes: 3 additions & 1 deletion apps/web/src/page/alerts/hub/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const destinationsFor = lastBy(
export const destinationsOf = (model: Model): ReadonlyArray<AlertDestinationDocument> =>
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 => {
Expand All @@ -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"
Expand Down
192 changes: 192 additions & 0 deletions apps/web/src/page/alerts/hub/hub-silences.scene.test.ts
Original file line number Diff line number Diff line change
@@ -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<V2AlertSilence> & { 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<typeof Policy.grantsOf>, silences: ReadonlyArray<V2AlertSilence>) => {
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(),
)
})
})
10 changes: 9 additions & 1 deletion apps/web/src/page/alerts/hub/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"

Expand Down Expand Up @@ -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
8 changes: 7 additions & 1 deletion apps/web/src/page/alerts/hub/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading