Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions apps/api/src/routes/v2/alert-silences.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ export const HttpV2AlertSilencesLive = HttpApiBuilder.group(MapleApiV2, "alertSi
silences
.list(tenant.orgId, {
...(query.status === undefined ? undefined : { status: query.status }),
...(query.maintenance_window_id === undefined
? undefined
: { maintenanceWindowId: query.maintenance_window_id }),
limit,
offset,
})
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/routes/v2/alerts.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2215,6 +2215,30 @@ describe("v2 alerts over HTTP", () => {
expect(occurrences.length).toBeGreaterThan(0)
expect(occurrences[0].reason).toBe("Maintenance: Nightly vacuum")

// A manual silence is left out when the list is filtered to the window.
const manual = await harness.request("POST", "/v2/alerts/silences", key.secret, {
reason: "Deploy",
matchers: { services: ["cart"] },
ends_at: new Date(Date.now() + 60 * 60_000).toISOString(),
})
expect(manual.status).toBe(200)
const filtered = await harness.request(
"GET",
`/v2/alerts/silences?maintenance_window_id=${id}`,
key.secret,
)
expect(filtered.status).toBe(200)
expect(filtered.body.data.map((silence: { id: string }) => silence.id)).toEqual(
occurrences.map((silence: { id: string }) => silence.id),
)
expect(filtered.body.data.map((silence: { id: string }) => silence.id)).not.toContain(manual.body.id)
const badFilter = await harness.request(
"GET",
"/v2/alerts/silences?maintenance_window_id=nope",
key.secret,
)
expect(badFilter.status).toBe(400)

// The window owns its silences: ending or editing one through the silence API is refused.
const silenceId: string = occurrences[0].id
const endRefused = await harness.request("POST", `/v2/alerts/silences/${silenceId}/end`, key.secret)
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/lab/entries/maintenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineShellLab } from "../shell-lab"
import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../maintenance-lab"

// `/lab/maintenance`: maintenance windows in the alerts hub, their dialog and filtered silences (`?state=`).

export const lab = defineShellLab("Maintenance windows", {
Model,
Message,
Flags,
flags,
init,
update,
view,
currentPath: "/lab/maintenance",
breadcrumbs,
})
167 changes: 167 additions & 0 deletions apps/web/src/lab/maintenance-lab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import * as Policy from "@maple/domain/policy"
import { OrgId } from "@maple/domain"
import { Effect, Option, Result, Schema, Array as Arr } from "effect"
import { Command, type Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { defineMessageUnion } from "foldkit/message"
import * as WindowDialog from "../page/alerts/maintenance-dialog/dialog"
import * as HubMessage from "../page/alerts/hub/message"
import * as HubModel from "../page/alerts/hub/model"
import * as HubUpdate from "../page/alerts/hub/update"
import { view as hubView } from "../page/alerts/hub/view"
import {
populatedWindows,
silencesWithWindows,
weeklyWindow,
} from "../page/alerts/shared/maintenance-test-fixtures"
import { windowPublicId } from "../page/alerts/shared/maintenance-windows"
import { NOW, ORG, ruleRow } from "../page/alerts/shared/test-rows"
import type { PageContext } from "../page/contract"
import type { Resources } from "../platform/resources"
import { keepLocal } from "./local-commands"

// `/lab/maintenance`: the alerts hub's Maintenance tab, its dialog and the Silences tab filtered
// to one window, over fixtures. `?state=` picks one.

export const LabState = Schema.Literals([
"list",
"empty",
"dialog-weekly",
"dialog-once-invalid",
"silences",
"silences-filtered",
])
export type LabState = typeof LabState.Type

export const Flags = Schema.Struct({ state: LabState })
export type Flags = typeof Flags.Type

export const flags: Effect.Effect<Flags> = Effect.sync(() => ({
state: Option.getOrElse(
Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")),
(): LabState => "list",
),
}))

export const Model = Schema.Struct({ state: LabState, hub: HubModel.Model })
export type Model = typeof Model.Type

export const Message = defineMessageUnion({ GotHubMessage: { message: HubMessage.Message } })
export type Message = typeof Message.Type

type Return = Update.Return<Model, Message, Resources>
type Step = Readonly<{
model: HubModel.Model
commands?: ReadonlyArray<Command.Command<HubMessage.Message, never, Resources>>
}>

const CONTEXT: PageContext = {
orgId: Schema.decodeSync(OrgId)(ORG),
pinnedNamespace: null,
timeZone: "Europe/Berlin",
}
const GRANTS = Policy.grantsOf(["org:engineer"])
const RULES = [ruleRow(1, { name: "Checkout error rate" }), ruleRow(2, { name: "Payments p95 latency" })]
const ZONES = ["Europe/Berlin", "Europe/London", "America/New_York", "Asia/Tokyo", "UTC"]

const hubStep = (model: HubModel.Model, message: HubMessage.Message) =>
HubUpdate.update(model, message, CONTEXT)

/** Runs messages through the hub, keeping only its local commands (a dialog's open animation). */
const fold = (start: HubModel.Model, messages: ReadonlyArray<HubMessage.Message>): Step => {
const initial: Step = { model: start, commands: [] }
return Arr.reduce(messages, initial, (current, message) => {
const next = hubStep(current.model, message)
return { model: next.model, commands: [...(current.commands ?? []), ...keepLocal(next.commands)] }
})
}

const dialog = (message: WindowDialog.Message) => HubMessage.Message.GotWindowDialogMessage({ message })

const searchFor = (state: LabState): HubModel.Model["search"] =>
state === "silences"
? { tab: "silences" }
: state === "silences-filtered"
? { tab: "silences", window: windowPublicId(weeklyWindow.id) }
: { tab: "maintenance" }

const extraFor = (state: LabState): ReadonlyArray<HubMessage.Message> =>
state === "dialog-weekly"
? [
HubMessage.Message.ClickedNewWindow(),
dialog(WindowDialog.Message.ReadZones({ zones: ZONES })),
dialog(WindowDialog.Message.ChangedName({ value: "Nightly database backup" })),
dialog(
WindowDialog.Message.ChangedMatcher({ field: "services", value: "checkout, payments" }),
),
dialog(WindowDialog.Message.ChangedMatcher({ field: "environments", value: "production" })),
dialog(WindowDialog.Message.ToggledDay({ day: "mon" })),
dialog(WindowDialog.Message.ToggledDay({ day: "thu" })),
dialog(WindowDialog.Message.ChangedReason({ value: "Snapshot and vacuum" })),
]
: state === "dialog-once-invalid"
? [
HubMessage.Message.ClickedNewWindow(),
dialog(WindowDialog.Message.ReadZones({ zones: ZONES })),
dialog(WindowDialog.Message.ChangedName({ value: "Datacenter move" })),
dialog(WindowDialog.Message.ChangedKind({ kind: "once" })),
dialog(WindowDialog.Message.ChangedStartsAt({ value: "2026-10-03T22:00" })),
dialog(WindowDialog.Message.ChangedEndsAt({ value: "2026-10-03T20:00" })),
dialog(WindowDialog.Message.ClickedSave()),
]
: []

const hubFor = (state: LabState): Step => {
const entered = HubUpdate.init({ _tag: "Alerts", search: searchFor(state) }, CONTEXT).model
const windows = state === "empty" ? [] : populatedWindows
return fold(entered, [
HubMessage.Message.ReadClock({ nowMs: NOW }),
HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }),
HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }),
HubMessage.Message.CompletedFetchWindows({
key: entered.windows.key ?? "",
result: Result.succeed(windows),
}),
...(entered.silences.key === undefined || entered.silences.key === null
? []
: [
HubMessage.Message.CompletedFetchSilences({
key: entered.silences.key,
result: Result.succeed(silencesWithWindows),
}),
]),
...extraFor(state),
])
}

export const init = (flags: Flags): Return => {
const hub = hubFor(flags.state)
return {
model: { state: flags.state, hub: hub.model },
commands: Command.mapMessages(hub.commands ?? [], (message) => Message.GotHubMessage({ message })),
}
}

export const update = (model: Model, message: Message): Return =>
Message.match<Return>(message, {
GotHubMessage: ({ message: child }) => {
const next = hubStep(model.hub, child)
return {
model: { ...model, hub: next.model },
commands: Command.mapMessages(keepLocal(next.commands), (inner) =>
Message.GotHubMessage({ message: inner }),
),
}
},
})

export const breadcrumbs = () => [{ label: "Alerts" }]

export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
h.submodel({
slotId: "lab-maintenance-hub",
model: model.hub,
view: hubView,
viewInputs: { context: CONTEXT },
toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }),
})
8 changes: 8 additions & 0 deletions apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
kind: "lab",
session: "none",
},
{
path: "/lab/maintenance",
title: "Maintenance windows",
description:
"The Maintenance tab populated and empty, the window dialog weekly and once with a validation error, and the Silences tab with window silences, unfiltered and filtered to one window (`?state=`).",
kind: "lab",
session: "none",
},
{
path: "/lab/anomaly-alerts",
title: "Anomaly alerts",
Expand Down
44 changes: 41 additions & 3 deletions apps/web/src/page/alerts/hub/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { UptimeMonitorId } from "@maple/domain"
import {
AlertDestinationId,
AlertIncidentId,
AlertMaintenanceWindowId,
AlertRuleId,
AlertSilenceId,
EscalationChainId,
Expand Down Expand Up @@ -31,6 +32,12 @@ import { listOrgMembers } from "../shared/org-members"
import { listSloRows } from "../shared/slo-commands"
import { deleteMonitor, listMonitors, setMonitorEnabled } from "../shared/uptime-commands"
import { endSilence, listSilences } from "../shared/silences"
import {
deleteMaintenanceWindow,
listMaintenanceWindows,
listWindowSilences,
updateMaintenanceWindow,
} from "../shared/maintenance-windows"
import { Message } from "./message"
import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups"

Expand Down Expand Up @@ -242,11 +249,12 @@ export const DeleteSchedule = Command.define("DeleteOnCallSchedule", {
),
})

/** The org's silences, or every silence one maintenance window wrote. */
export const FetchHubSilences = Command.define("FetchAlertsHubSilences", {
args: { key: Schema.String },
args: { key: Schema.String, windowId: Schema.NullOr(AlertMaintenanceWindowId) },
messages: [Message.CompletedFetchSilences],
execute: ({ key }) =>
listSilences.pipe(
execute: ({ key, windowId }) =>
(windowId === null ? listSilences : listWindowSilences(windowId)).pipe(
Effect.result,
Effect.map((result) => Message.CompletedFetchSilences({ key, result })),
),
Expand All @@ -261,3 +269,33 @@ export const EndHubSilence = Command.define("EndAlertSilence", {
Effect.map((result) => Message.CompletedEndSilence({ silenceId, result })),
),
})

export const FetchHubWindows = Command.define("FetchAlertsHubMaintenanceWindows", {
args: { key: Schema.String },
messages: [Message.CompletedFetchWindows],
execute: ({ key }) =>
listMaintenanceWindows.pipe(
Effect.result,
Effect.map((result) => Message.CompletedFetchWindows({ key, result })),
),
})

export const ToggleWindow = Command.define("ToggleMaintenanceWindow", {
args: { windowId: AlertMaintenanceWindowId, enabled: Schema.Boolean },
messages: [Message.CompletedToggleWindow],
execute: ({ windowId, enabled }) =>
updateMaintenanceWindow(windowId, { enabled }).pipe(
Effect.result,
Effect.map((result) => Message.CompletedToggleWindow({ windowId, result })),
),
})

export const DeleteWindow = Command.define("DeleteMaintenanceWindow", {
args: { windowId: AlertMaintenanceWindowId },
messages: [Message.CompletedDeleteWindow],
execute: ({ windowId }) =>
deleteMaintenanceWindow(windowId).pipe(
Effect.result,
Effect.map((result) => Message.CompletedDeleteWindow({ windowId, result })),
),
})
12 changes: 11 additions & 1 deletion apps/web/src/page/alerts/hub/derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,15 @@ const destinationsFor = lastBy(
export const destinationsOf = (model: Model): ReadonlyArray<AlertDestinationDocument> =>
destinationsFor([model.destinations])

export type AlertsTab = "inbox" | "rules" | "uptime" | "slos" | "silences" | "oncall" | "routing"
export type AlertsTab =
| "inbox"
| "rules"
| "uptime"
| "slos"
| "silences"
| "maintenance"
| "oncall"
| "routing"

/** Legacy `tab` values map onto the current tabs so old links keep resolving. */
export const resolveTab = (tab: string | undefined): AlertsTab => {
Expand All @@ -125,6 +133,8 @@ export const resolveTab = (tab: string | undefined): AlertsTab => {
return "slos"
case "silences":
return "silences"
case "maintenance":
return "maintenance"
case "oncall":
return "oncall"
case "routing":
Expand Down
Loading
Loading