diff --git a/apps/api/src/routes/v2/alert-silences.http.ts b/apps/api/src/routes/v2/alert-silences.http.ts index 4e42298c38..d1a194a74c 100644 --- a/apps/api/src/routes/v2/alert-silences.http.ts +++ b/apps/api/src/routes/v2/alert-silences.http.ts @@ -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, }) diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index e2aea42df6..bc9c148634 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -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) diff --git a/apps/web/src/lab/entries/maintenance.ts b/apps/web/src/lab/entries/maintenance.ts new file mode 100644 index 0000000000..671599009c --- /dev/null +++ b/apps/web/src/lab/entries/maintenance.ts @@ -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, +}) diff --git a/apps/web/src/lab/maintenance-lab.ts b/apps/web/src/lab/maintenance-lab.ts new file mode 100644 index 0000000000..6c83ac933f --- /dev/null +++ b/apps/web/src/lab/maintenance-lab.ts @@ -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 = 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 +type Step = Readonly<{ + model: HubModel.Model + commands?: ReadonlyArray> +}> + +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): 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 => + 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(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): Html => + h.submodel({ + slotId: "lab-maintenance-hub", + model: model.hub, + view: hubView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), + }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 6f6cdb62b0..f67eaadb1f 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -85,6 +85,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ 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", diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index 36c308d34d..785a833772 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -2,6 +2,7 @@ import { UptimeMonitorId } from "@maple/domain" import { AlertDestinationId, AlertIncidentId, + AlertMaintenanceWindowId, AlertRuleId, AlertSilenceId, EscalationChainId, @@ -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" @@ -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 })), ), @@ -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 })), + ), +}) diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 493a3b3f2e..572950d044 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -112,7 +112,15 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => 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 => { @@ -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": diff --git a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts new file mode 100644 index 0000000000..085e40b704 --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts @@ -0,0 +1,337 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import type { V2AlertMaintenanceWindow, V2AlertSilence } from "@maple/domain/http/v2" +import { Result, Schema, Array as Arr } from "effect" +import * as Menu from "@maple/views/ui/menu" +import { + click, + expect as expectView, + expectOutMessage, + given, + role, + scene, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { PageOutMessage, type PageContext } from "../../contract" +import { alertsHref } from "../shared/links" +import { + disabledWindow, + endedWindow, + populatedWindows, + runningWindow, + silenceOf, + silencesWithWindows, + weeklyWindow, +} from "../shared/maintenance-test-fixtures" +import { windowPublicId, windowSilencesHref } from "../shared/maintenance-windows" +import { NOW, ORG, ruleRow } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, routeChanged, update, windowMenuId } 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 ENGINEER = Policy.grantsOf(["org:engineer"]) +/** The running window's next occurrence, written ahead and not yet started. */ +const scheduledWindowSilence = silenceOf(5, { + reason: "Maintenance: Checkout v3 migration (part 2)", + startMin: 120, + endMin: 180, + window: runningWindow, +}) +const MEMBER = Policy.grantsOf(["org:member"]) +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +const onTab = ( + search: Model["search"], + options: Readonly<{ + grants?: ReturnType + windows: ReadonlyArray + silences?: ReadonlyArray + }>, +): Model => { + const entered = init({ _tag: "Alerts", search }, context) + const loaded = Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.CompletedFetchSession({ orgId: ORG, grants: options.grants ?? ENGINEER }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1, { name: "Checkout error rate" })] }), + Message.CompletedFetchWindows({ + key: entered.model.windows.key ?? "", + result: Result.succeed(options.windows), + }), + ], + entered.model, + (model, message) => pageUpdate(model, message).model, + ) + return options.silences === undefined + ? loaded + : pageUpdate( + loaded, + Message.CompletedFetchSilences({ + key: loaded.silences.key ?? "", + result: Result.succeed(options.silences), + }), + ).model +} + +const onMaintenance = (windows: ReadonlyArray, grants = ENGINEER) => + onTab({ tab: "maintenance" }, { windows, grants }) + +describe("alerts hub, Maintenance tab", () => { + test("reads the windows only while the Maintenance or Silences tab is open", () => { + const fetched = (tab: string) => names(init({ _tag: "Alerts", search: { tab } }, context).commands) + expect(fetched("maintenance")).toContain("FetchAlertsHubMaintenanceWindows") + expect(fetched("silences")).toContain("FetchAlertsHubMaintenanceWindows") + expect(fetched("rules")).not.toContain("FetchAlertsHubMaintenanceWindows") + }) + + test("lists weekly, running, disabled and ended windows with their schedule and next run", () => { + scene( + { update: pageUpdate, view }, + given(onMaintenance(populatedWindows)), + expectView(text("Nightly database backup")).toExist(), + expectView(text(/^Weekly, Mon and Thu at .+ for 2h \(Europe\/Berlin\)$/u)).toExist(), + expectView( + text(/^service checkout · service payments · env production · Next Oct 5, /u), + ).toExist(), + expectView(text("Checkout v3 migration")).toExist(), + expectView(text("Running")).toExist(), + expectView(text(/^rule Checkout error rate · env production · Running until /u)).toExist(), + expectView(text(/^Once, Sep 25, .+ to Sep 25, /u)).toExist(), + expectView(text("tag infra · No more occurrences")).toExist(), + expectView(text("tag db · Disabled")).toExist(), + expectView(role("switch", { name: "Enable Weekend reindex" })).toExist(), + ) + }) + + test("an ended one-off window says so and offers no switch to turn it on", () => { + scene( + { update: pageUpdate, view }, + given(onMaintenance([endedWindow, weeklyWindow])), + expectView(text("Ended")).toExist(), + expectView(role("switch", { name: "Disable Datacenter move" })).toHaveAttr( + "aria-disabled", + "true", + ), + expectView(role("switch", { name: "Disable Nightly database backup" })).not.toHaveAttr( + "aria-disabled", + ), + ) + }) + + test("a window change on the Maintenance tab makes the Silences tab read its silences again", () => { + const model = onTab({ tab: "silences" }, { windows: [weeklyWindow], silences: silencesWithWindows }) + const onMaint = update(model, Message.ReadClock({ nowMs: NOW }), context).model + const moved = routeChanged(onMaint, { _tag: "Alerts", search: { tab: "maintenance" } }, context).model + const toggled = pageUpdate(moved, Message.ClickedToggleWindow({ windowId: weeklyWindow.id })).model + const settled = pageUpdate( + toggled, + Message.CompletedToggleWindow({ + windowId: weeklyWindow.id, + result: Result.succeed({ ...weeklyWindow, enabled: false }), + }), + ).model + // Following the row's Occurrences link lands on the Silences tab filtered to the window. + const back = routeChanged( + settled, + { _tag: "Alerts", search: { tab: "silences", window: windowPublicId(weeklyWindow.id) } }, + context, + ) + expect(back.commands ?? []).toContainEqual( + expect.objectContaining({ + name: "FetchAlertsHubSilences", + args: expect.objectContaining({ windowId: weeklyWindow.id }), + }), + ) + const unfiltered = routeChanged(settled, { _tag: "Alerts", search: { tab: "silences" } }, context) + expect(names(unfiltered.commands)).toContain("FetchAlertsHubSilences") + }) + + test("marks only windows whose baseline exclusion applies", () => { + scene( + { update: pageUpdate, view }, + given(onMaintenance([weeklyWindow])), + expectView(text("Excluded from baselines")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given( + onMaintenance([ + { ...weeklyWindow, matchers: { tags: ["db"] }, exclude_from_baselines: true }, + ]), + ), + expectView(text("Excluded from baselines")).not.toExist(), + ) + }) + + test("an org without windows explains them and offers the first one", () => { + scene( + { update: pageUpdate, view }, + given(onMaintenance([])), + expectView(text("No maintenance windows")).toExist(), + expectView(role("button", { name: "Create window" })).toExist(), + expectView(role("button", { name: "New window" })).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given(onMaintenance([], MEMBER)), + expectView(role("button", { name: "Create window" })).not.toExist(), + ) + }) + + test("the switch sends one update that flips the window", () => { + const model = onMaintenance([weeklyWindow]) + const step = pageUpdate(model, Message.ClickedToggleWindow({ windowId: weeklyWindow.id })) + expect(step.commands ?? []).toEqual([ + expect.objectContaining({ + name: "ToggleMaintenanceWindow", + args: { windowId: weeklyWindow.id, enabled: false }, + }), + ]) + expect( + pageUpdate(step.model, Message.ClickedToggleWindow({ windowId: weeklyWindow.id })).commands ?? [], + ).toEqual([]) + const settled = pageUpdate( + step.model, + Message.CompletedToggleWindow({ + windowId: weeklyWindow.id, + result: Result.succeed({ ...weeklyWindow, enabled: false }), + }), + ) + expect(settled.outMessage).toMatchObject({ title: "Maintenance window disabled" }) + expect(names(settled.commands)).toContain("FetchAlertsHubMaintenanceWindows") + }) + + test("delete asks first, then sends the delete once", () => { + const menuId = windowMenuId(endedWindow.id) + const picked = pageUpdate( + onMaintenance([endedWindow]), + Message.GotRowMenuMessage({ + menuId, + message: Menu.Message.ClickedItem({ index: 2, item: { kind: "Item", value: "delete" } }), + }), + ) + expect(picked.model.confirmDeleteWindowId).toBe(endedWindow.id) + expect(names(picked.commands)).not.toContain("DeleteMaintenanceWindow") + const confirmed = pageUpdate(picked.model, Message.ClickedConfirmDeleteWindow()) + expect(names(confirmed.commands)).toEqual(["DeleteMaintenanceWindow"]) + expect(names(pageUpdate(confirmed.model, Message.ClickedConfirmDeleteWindow()).commands)).toEqual([]) + const deleted = pageUpdate( + confirmed.model, + Message.CompletedDeleteWindow({ windowId: endedWindow.id, result: Result.void }), + ) + expect(deleted.outMessage).toMatchObject({ title: "Maintenance window deleted" }) + expect(deleted.model.confirmDeleteWindowId).toBeNull() + }) + + test("each window links to its occurrences on the Silences tab", () => { + scene( + { update: pageUpdate, view }, + given(onMaintenance([weeklyWindow])), + expectView(role("link", { name: "Occurrences" })).toHaveAttr( + "href", + windowSilencesHref(weeklyWindow.id), + ), + ) + expect(windowSilencesHref(weeklyWindow.id)).toContain(`window=${windowPublicId(weeklyWindow.id)}`) + }) + + test("New window opens the dialog, but only once the clock is read", () => { + const step = pageUpdate(onMaintenance([weeklyWindow]), Message.ClickedNewWindow()) + expect(step.model.windowDialog.dialog.isOpen).toBe(true) + expect(names(step.commands)).toContain("ReadMaintenanceWindowZones") + const early = init({ _tag: "Alerts", search: { tab: "maintenance" } }, context).model + expect(pageUpdate(early, Message.ClickedNewWindow()).model.windowDialog.dialog.isOpen).toBe(false) + }) +}) + +describe("alerts hub, Silences tab with maintenance windows", () => { + test("filters to one window's silences and shows a removable chip naming it", () => { + const model = onTab( + { tab: "silences", window: windowPublicId(weeklyWindow.id) }, + { windows: populatedWindows, silences: silencesWithWindows }, + ) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Window: Nightly database backup")).toExist(), + expectView(text("Maintenance: Nightly database backup (Snapshot and vacuum)")).toExist(), + expectView(text("Deploying payments v2.3")).not.toExist(), + expectView(text("Maintenance: Checkout v3 migration")).not.toExist(), + click(role("button", { name: "Remove Window: Nightly database backup" })), + expectOutMessage( + PageOutMessage.RequestedNavigation({ + url: alertsHref({ tab: "silences" }), + isReplace: false, + }), + ), + ) + // The route without the param reads every silence and drops the chip. + const cleared = routeChanged(model, { _tag: "Alerts", search: { tab: "silences" } }, context) + const fetch = Arr.findFirst(cleared.commands ?? [], ({ name }) => name === "FetchAlertsHubSilences") + expect(fetch).toMatchObject({ value: { args: { windowId: null } } }) + const shown = pageUpdate( + cleared.model, + Message.CompletedFetchSilences({ + key: cleared.model.silences.key ?? "", + result: Result.succeed(silencesWithWindows), + }), + ).model + scene( + { update: pageUpdate, view }, + given(shown), + expectView(text(/^Window: /u)).not.toExist(), + expectView(text("Deploying payments v2.3")).toExist(), + expectView(text("Maintenance: Checkout v3 migration")).toExist(), + ) + }) + + test("a window's silences say which window manages them and offer no end or cancel", () => { + scene( + { update: pageUpdate, view }, + given( + onTab( + { tab: "silences" }, + { windows: populatedWindows, silences: [...silencesWithWindows, scheduledWindowSilence] }, + ), + ), + expectView(text("Managed by Checkout v3 migration")).toExist(), + // The scheduled occurrence is listed but offers no Cancel; the manual silence still ends. + expectView(text("Maintenance: Checkout v3 migration (part 2)")).toExist(), + expectView(role("button", { name: "End now" })).toExist(), + expectView(role("button", { name: "Cancel" })).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given( + onTab( + { tab: "silences", window: windowPublicId(runningWindow.id) }, + { windows: populatedWindows, silences: silencesWithWindows }, + ), + ), + expectView(text("Managed by Checkout v3 migration")).toExist(), + expectView(role("button", { name: "End now" })).not.toExist(), + ) + }) + + test("a window with no occurrences written yet says so", () => { + scene( + { update: pageUpdate, view }, + given( + onTab( + { tab: "silences", window: windowPublicId(disabledWindow.id) }, + { windows: populatedWindows, silences: silencesWithWindows }, + ), + ), + expectView(text("Window: Weekend reindex")).toExist(), + expectView(text("No occurrences listed")).toExist(), + ) + }) +}) 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 index 6c853b3408..92790e0960 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -123,7 +123,7 @@ describe("alerts hub, Silences tab", () => { ]), ), expectView(text("Maintenance: Nightly DB")).toExist(), - expectView(text("Maintenance window")).toExist(), + expectView(text("Managed by a maintenance window")).toExist(), expectView(role("button", { name: "End now" })).not.toExist(), ) }) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 0a677a246b..75a499ae12 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -4,6 +4,7 @@ import { AlertDeliveryEventDocument, AlertDestinationId, AlertIncidentId, + AlertMaintenanceWindowId, AlertRuleId, AlertSilenceId, EscalationChainId, @@ -11,6 +12,7 @@ import { } from "@maple/domain/http" import { V2AlertIncident, + V2AlertMaintenanceWindow, V2AlertSilence, V2EscalationChain, V2OnCallSchedule, @@ -30,6 +32,7 @@ import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" import * as ChainDialog from "../escalation-dialog/dialog" import { OrgMember } from "../shared/org-members" +import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { SloListRow, TestOutcome, UptimeKindFilter, UptimeStatusFilter } from "./model" import { ServiceNamespaces } from "./uptime-groups" @@ -128,5 +131,21 @@ export const Message = defineMessageUnion({ GotSilenceDialogMessage: { message: SilenceDialog.Message }, ClickedEndSilence: { silenceId: AlertSilenceId }, CompletedEndSilence: { silenceId: AlertSilenceId, result: outcome(V2AlertSilence) }, + CompletedFetchWindows: { + key: Schema.String, + result: outcome(Schema.Array(V2AlertMaintenanceWindow)), + }, + ClickedRetryWindows: {}, + ClickedNewWindow: {}, + GotWindowDialogMessage: { message: WindowDialog.Message }, + ClickedToggleWindow: { windowId: AlertMaintenanceWindowId }, + CompletedToggleWindow: { + windowId: AlertMaintenanceWindowId, + result: outcome(V2AlertMaintenanceWindow), + }, + ClickedConfirmDeleteWindow: {}, + CompletedDeleteWindow: { windowId: AlertMaintenanceWindowId, result: outcome(Schema.Void) }, + GotDeleteWindowDialogMessage: { message: Dialog.Message }, + ClickedClearWindowFilter: {}, }) 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 ff0af0bc6e..4c448ddcad 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -4,12 +4,14 @@ import { AlertDeliveryEventDocument, AlertDestinationId, AlertIncidentId, + AlertMaintenanceWindowId, AlertRuleId, AlertSilenceId, EscalationChainId, OnCallScheduleId, } from "@maple/domain/http" import { + V2AlertMaintenanceWindow, V2AlertSilence, V2EscalationChain, V2OnCallSchedule, @@ -32,6 +34,7 @@ import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" import * as ChainDialog from "../escalation-dialog/dialog" import { OrgMember } from "../shared/org-members" +import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { ServiceNamespaces } from "./uptime-groups" @@ -126,6 +129,14 @@ export const Model = Schema.Struct({ silences: KeyedSchema(Schema.Array(V2AlertSilence), LoadFailure), endingSilenceIds: Schema.Array(AlertSilenceId), silenceDialog: SilenceDialog.Model, + /** Maintenance and Silences tabs: `alertMaintenanceWindows.list`, read while either is open. */ + windows: KeyedSchema(Schema.Array(V2AlertMaintenanceWindow), LoadFailure), + /** Enable/disable PATCHes in flight, the delete in flight and the window awaiting confirmation. */ + togglingWindowIds: Schema.Array(AlertMaintenanceWindowId), + deletingWindowId: Schema.NullOr(AlertMaintenanceWindowId), + confirmDeleteWindowId: Schema.NullOr(AlertMaintenanceWindowId), + deleteWindowDialog: Dialog.Model, + windowDialog: WindowDialog.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 b36a7a36a8..935b7a6cb4 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -1,12 +1,13 @@ import type { UptimeMonitorId } from "@maple/domain" import type { AlertDestinationId, + AlertMaintenanceWindowId, AlertRuleId, AlertSilenceId, EscalationChainId, OnCallScheduleId, } from "@maple/domain/http" -import type { V2EscalationChain, V2OnCallSchedule } from "@maple/domain/http/v2" +import type { V2AlertMaintenanceWindow, V2EscalationChain, V2OnCallSchedule } from "@maple/domain/http/v2" import { isDefaultAnomalyRule } from "@maple/web-data/alerts/anomaly-rule" import { buildDestinationUpdateParamsV2, destinationToFormState } from "@maple/web-data/alerts/form-utils" import { rowToAlertDestinationDocument, rowToAlertRuleDocument } from "@maple/web-data/collections/alerts" @@ -23,6 +24,7 @@ import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" import * as ChainDialog from "../escalation-dialog/dialog" +import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { isCancelledSilence } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" @@ -34,6 +36,7 @@ import { SCHEDULE_IN_USE_CODE, SCHEDULE_LIST_LIMIT, } from "../shared/oncall-schedules" +import { WINDOW_LIST_LIMIT, windowIdOfParam } from "../shared/maintenance-windows" import { newSloHref, SLO_LIST_LIMIT } from "../shared/slo-commands" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { @@ -58,6 +61,9 @@ import { UpdateDestination, EndHubSilence, FetchHubSilences, + DeleteWindow, + FetchHubWindows, + ToggleWindow, } from "./command" import { withAcknowledgement } from "../shared/commands" import { testSendFailureMessage } from "../shared/delivery-failure" @@ -76,6 +82,8 @@ const DELETE_CHAIN_DIALOG_ID = "alerts-delete-chain" export const DELETE_SCHEDULE_DIALOG_ID = "alerts-delete-schedule" export const CHAIN_DIALOG_ID = "alerts-escalation-chain" export const NEW_RULE_MENU_ID = "alerts-new-rule-menu" +export const WINDOW_DIALOG_ID = "alerts-maintenance-window" +const DELETE_WINDOW_DIALOG_ID = "alerts-delete-window" /** Runs the delete dialog's update; `Closed` (Escape, Cancel, success) forgets the target. */ const updateDeleteDialog = (model: Model, step: ReturnType): Return => ({ @@ -118,17 +126,22 @@ const requestRoutingDeliveries = (model: Model, context: PageContext, mode: Keye }) } -const silencesKey = (context: PageContext) => - requestKey(context.orgId, "alertSilences.list", { query: { limit: 100 } }) +const silencesKey = (context: PageContext, windowId: AlertMaintenanceWindowId | null) => + requestKey( + context.orgId, + "alertSilences.list", + windowId === null ? { query: { limit: 100 } } : { query: { maintenance_window_id: windowId } }, + ) -/** The Silences tab reads the org's silences only while it is open. */ +/** The Silences tab reads the org's silences, or one window's, 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), { + const windowId = Option.getOrNull(windowIdOfParam(model.search.window)) + return Option.match(Keyed.requestFor(model.silences, silencesKey(context, windowId), mode), { onNone: () => ({ model, commands: [] }), onSome: (silences) => ({ model: { ...model, silences }, - commands: [FetchHubSilences({ key: silences.key })], + commands: [FetchHubSilences({ key: silences.key, windowId })], }), }) } @@ -138,6 +151,35 @@ const refreshSilences = (model: Model, context: PageContext): Return => { return { model: step.model, commands: step.commands } } +const windowsKey = (context: PageContext) => + requestKey(context.orgId, "alertMaintenanceWindows.list", { query: { limit: WINDOW_LIST_LIMIT } }) + +/** The Maintenance tab lists the windows; the Silences tab names the windows its silences belong to. */ +const requestWindows = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { + const tab = resolveTab(model.search.tab) + if (tab !== "maintenance" && tab !== "silences") return { model, commands: [] } + return Option.match(Keyed.requestFor(model.windows, windowsKey(context), mode), { + onNone: () => ({ model, commands: [] }), + onSome: (windows) => ({ + model: { ...model, windows }, + commands: [FetchHubWindows({ key: windows.key })], + }), + }) +} + +/** + * A window changed: its occurrences are silences too. Off the Silences tab the cached silences + * are dropped, so the next visit reads them again rather than showing the old ones. + */ +const refreshWindows = (model: Model, context: PageContext): Return => { + const windows = requestWindows(model, context, "Supersede") + const silences: Requested = + resolveTab(windows.model.search.tab) === "silences" + ? requestSilences(windows.model, context, "Supersede") + : { model: { ...windows.model, silences: Keyed.idle() }, commands: [] } + return { model: silences.model, commands: [...windows.commands, ...silences.commands] } +} + const monitorsKey = (context: PageContext) => requestKey(context.orgId, "uptimeMonitors.list", { query: { limit: UPTIME_LIST_LIMIT } }) @@ -303,12 +345,19 @@ export const init = (route: HubRoute, context: PageContext): Return => { silences: Keyed.idle(), endingSilenceIds: [], silenceDialog: SilenceDialog.init("alerts-silence"), + windows: Keyed.idle(), + togglingWindowIds: [], + deletingWindowId: null, + confirmDeleteWindowId: null, + deleteWindowDialog: Dialog.init(DELETE_WINDOW_DIALOG_ID, "alert-dialog"), + windowDialog: WindowDialog.init(WINDOW_DIALOG_ID), } const deliveries = requestDeliveries(model, context, "Load") const routing = requestRoutingDeliveries(deliveries.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") const silences = requestSilences(monitors.model, context, "Load") - const slos = requestSlos(silences.model, context, "Load") + const windows = requestWindows(silences.model, context, "Load") + const slos = requestSlos(windows.model, context, "Load") const chains = requestChains(slos.model, context, "Load") const schedules = requestSchedules(chains.model, context, "Load") const members = requestMembers(schedules.model) @@ -316,6 +365,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { model: members.model, commands: [ ReadHubClock(), + ...windows.commands, FetchHubSession({ orgId: context.orgId }), ...members.commands, ...schedules.commands, @@ -333,7 +383,8 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext const routing = requestRoutingDeliveries({ ...model, search: route.search }, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") const silences = requestSilences(monitors.model, context, "Load") - const slos = requestSlos(silences.model, context, "Load") + const windows = requestWindows(silences.model, context, "Load") + const slos = requestSlos(windows.model, context, "Load") const chains = requestChains(slos.model, context, "Load") const schedules = requestSchedules(chains.model, context, "Load") const members = requestMembers(schedules.model) @@ -341,6 +392,7 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext model: members.model, commands: [ ...routing.commands, + ...windows.commands, ...monitors.commands, ...silences.commands, ...slos.commands, @@ -717,9 +769,117 @@ const endSilence = (model: Model, silenceId: AlertSilenceId): Return => commands: [EndHubSilence({ silenceId })], } +// MAINTENANCE WINDOWS + +const windowsOf = (model: Model): ReadonlyArray => + Option.getOrElse(AsyncData.getData(model.windows.data), () => []) + +const liftWindowDialog = ( + model: Model, + context: PageContext, + step: ReturnType, +): Return => { + const next = { ...model, windowDialog: step.model } + const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotWindowDialogMessage({ message }), + ) + if (step.outMessage === undefined) return { model: next, commands } + const refreshed = refreshWindows(next, context) + return { + model: refreshed.model, + commands: [...commands, ...(refreshed.commands ?? [])], + outMessage: toast( + "success", + step.outMessage.isNew ? "Maintenance window created" : "Maintenance window saved", + ), + } +} + +const toggleWindow = (model: Model, windowId: AlertMaintenanceWindowId): Return => + Option.match( + Array.findFirst(windowsOf(model), (window) => window.id === windowId), + { + onNone: () => ({ model }), + onSome: (window): Return => + model.togglingWindowIds.includes(windowId) + ? { model } + : { + model: { ...model, togglingWindowIds: [...model.togglingWindowIds, windowId] }, + commands: [ToggleWindow({ windowId, enabled: !window.enabled })], + }, + }, + ) + +/** Runs the window delete dialog's update; `Closed` forgets the target. */ +const updateDeleteWindowDialog = (model: Model, step: ReturnType): Return => ({ + model: { + ...model, + deleteWindowDialog: step.model, + confirmDeleteWindowId: step.outMessage?._tag === "Closed" ? null : model.confirmDeleteWindowId, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteWindowDialogMessage({ message }), + ), +}) + +const pickedWindowAction = (model: Model, context: PageContext, rawId: string, value: string): Return => + Option.match( + Array.findFirst(windowsOf(model), (window) => window.id === rawId), + { + onNone: () => ({ model }), + onSome: (window): Return => + value === "delete" + ? updateDeleteWindowDialog( + { ...model, confirmDeleteWindowId: window.id }, + Dialog.open(model.deleteWindowDialog), + ) + : liftWindowDialog( + model, + context, + WindowDialog.openEdit( + model.windowDialog, + window, + model.nowMs ?? 0, + context.timeZone, + ), + ), + }, + ) + +const confirmDeleteWindow = (model: Model): Return => { + const id = model.confirmDeleteWindowId + return id === null || model.deletingWindowId !== null + ? { model } + : { model: { ...model, deletingWindowId: id }, commands: [DeleteWindow({ windowId: id })] } +} + +const deletedWindow = ( + model: Model, + context: PageContext, + result: Result.Result, +): Return => { + const settled = { ...model, deletingWindowId: null } + return Result.match(result, { + onSuccess: (): Return => { + const closed = updateDeleteWindowDialog(settled, Dialog.close(settled.deleteWindowDialog)) + const refreshed = refreshWindows(closed.model, context) + return { + model: refreshed.model, + commands: [...(closed.commands ?? []), ...(refreshed.commands ?? [])], + outMessage: toast("success", "Maintenance window deleted"), + } + }, + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't delete the maintenance window", failure), + }), + }) +} + // ROW MENUS /** + * Row menu ids carry what they act on: `rule:@` (a rule is listed under each of * its tags) or `destination:`. */ @@ -728,6 +888,7 @@ export const destinationMenuId = (destinationId: AlertDestinationId) => `destina export const monitorMenuId = (monitorId: UptimeMonitorId) => `monitor:${monitorId}` export const chainMenuId = (chainId: EscalationChainId) => `chain:${chainId}` export const scheduleMenuId = (scheduleId: OnCallScheduleId) => `schedule:${scheduleId}` +export const windowMenuId = (windowId: AlertMaintenanceWindowId) => `window:${windowId}` const monitorsOf = (model: Model) => Option.getOrElse(AsyncData.getData(model.monitors.data), () => []) @@ -821,6 +982,7 @@ const pickedFromMenu = (model: Model, context: PageContext, menuId: string, valu if (kind === "monitor") return pickedMonitorAction(model, target, value) if (kind === "chain") return pickedChainAction(model, context, target, value) if (kind === "schedule") return pickedScheduleAction(model, target, value) + if (kind === "window") return pickedWindowAction(model, context, target, value) if (kind === "rule") { const id = Str.split("@")(target)[0] const rule = Option.flatMap( @@ -1147,6 +1309,52 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), }) }, + CompletedFetchWindows: ({ key, result }) => ({ + model: { ...model, windows: Keyed.accept(model.windows, key, result) }, + commands: [ReadHubClock()], + }), + ClickedRetryWindows: () => { + const step = requestWindows(model, context, "Supersede") + return { model: step.model, commands: step.commands } + }, + ClickedNewWindow: () => + // Defaults are read off the clock: wait for it rather than open on 1970. + model.nowMs === null + ? { model } + : liftWindowDialog( + model, + context, + WindowDialog.openNew(model.windowDialog, model.nowMs ?? 0, context.timeZone), + ), + GotWindowDialogMessage: ({ message: child }) => + liftWindowDialog(model, context, WindowDialog.update(model.windowDialog, child)), + ClickedToggleWindow: ({ windowId }) => toggleWindow(model, windowId), + CompletedToggleWindow: ({ windowId, result }) => { + const settled = { ...model, togglingWindowIds: withoutId(model.togglingWindowIds, windowId) } + return Result.match(result, { + onSuccess: (window): Return => ({ + ...refreshWindows(settled, context), + outMessage: toast( + "success", + window.enabled ? "Maintenance window enabled" : "Maintenance window disabled", + ), + }), + onFailure: (failure): Return => ({ + model: settled, + outMessage: errorToast("Couldn't update the maintenance window", failure), + }), + }) + }, + ClickedConfirmDeleteWindow: () => confirmDeleteWindow(model), + CompletedDeleteWindow: ({ result }) => deletedWindow(model, context, result), + GotDeleteWindowDialogMessage: ({ message: child }) => + model.deletingWindowId !== null + ? { model } + : updateDeleteWindowDialog(model, Dialog.update(model.deleteWindowDialog, child)), + ClickedClearWindowFilter: () => ({ + model, + outMessage: navigate({ ...model.search, window: undefined }), + }), ClickedConfirmDeleteMonitor: () => confirmDeleteMonitor(model), GotDeleteMonitorDialogMessage: ({ message: child }) => model.deletingMonitorId !== null diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index 6b6ec8c30e..bf84d77880 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -10,7 +10,7 @@ import { button } from "@maple/views/ui/button" import { confirmDialog } from "@maple/views/ui/confirm-dialog" 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 { icon, rowIcon } from "@maple/views/ui/icon" import * as Menu from "@maple/views/ui/menu" import type { MenuEntry } from "@maple/views/ui/menu-view" import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" @@ -87,7 +87,7 @@ export const chainRow = ( [h.Key(chain.id), h.Class("px-4 py-3")], [ hstack(h, { gap: "3", align: "start" }, [ - icon(h, "BellIcon", { className: "mt-0.5 shrink-0 text-muted-foreground" }), + rowIcon(h, "BellIcon"), vstack(h, { gap: "0.5", shrink: true, grow: true }, [ typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [chain.name]), typography( diff --git a/apps/web/src/page/alerts/hub/view-maintenance.ts b/apps/web/src/page/alerts/hub/view-maintenance.ts new file mode 100644 index 0000000000..5e7e775f9e --- /dev/null +++ b/apps/web/src/page/alerts/hub/view-maintenance.ts @@ -0,0 +1,242 @@ +import type { V2AlertMaintenanceWindow } from "@maple/domain/http/v2" +import { Array as Arr, Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { borderedBox } from "@maple/views/ui/bordered-box" +import { dividedList } from "@maple/views/ui/rule" +import { button } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { badge } from "@maple/views/ui/display" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { icon, rowIcon } from "@maple/views/ui/icon" +import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" +import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" +import { emptyActions } from "@maple/views/ui/signal-empty-state" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { switchControl } from "@maple/views/ui/switch" +import { textLink } from "@maple/views/ui/text-link" +import { typography } from "@maple/views/ui/typography" +import { + excludesBaselines, + isEndedOnce, + occurrenceStateAt, + occurrenceText, + scheduleSummary, + windowSilencesHref, +} from "../shared/maintenance-windows" +import { silenceScopeParts } from "../shared/silences" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { windowMenuId } from "./update" + +// The Maintenance tab: planned, recurring quiet periods, each written ahead as silences. + +type H = HtmlBuilder + +/** "service checkout · rule Checkout errors"; before rules sync, rules are counted rather than named. */ +const scopeText = (model: Model, window: V2AlertMaintenanceWindow): string => { + const ruleIds = window.matchers.rule_ids ?? [] + const rules = model.rules + if (rules === null) + return Arr.join( + [ + ...silenceScopeParts({ ...window.matchers, rule_ids: [] }, () => ""), + ...(ruleIds.length === 0 + ? [] + : [ruleIds.length === 1 ? "1 rule" : `${ruleIds.length} rules`]), + ], + " · ", + ) + const ruleName = (id: string): string => + Option.getOrElse( + Option.map( + Arr.findFirst(rules, (rule) => rule.id === id), + (rule) => rule.name, + ), + () => "deleted rule", + ) + return Arr.join(silenceScopeParts(window.matchers, ruleName), " · ") +} + +const rowMenu = (h: H, model: Model, window: V2AlertMaintenanceWindow): Html => { + const menuId = windowMenuId(window.id) + const entries: ReadonlyArray = [ + { _tag: "Item", value: "edit", label: "Edit", icon: "PencilIcon" }, + { _tag: "Separator" }, + { + _tag: "Item", + value: "delete", + label: "Delete", + icon: "TrashIcon", + variant: "destructive", + isDisabled: model.deletingWindowId === window.id, + }, + ] + return rowActionsMenu(h, { + slotId: menuId, + model: model.menus[menuId] ?? Menu.init(menuId), + entries, + label: `Actions for ${window.name}`, + toParentMessage: (message) => Message.GotRowMenuMessage({ menuId, message }), + }) +} + +/** One window: name and markers, its schedule, what it covers and when it next runs. */ +export const windowRow = ( + h: H, + model: Model, + window: V2AlertMaintenanceWindow, + options: Readonly<{ nowMs: number; timeZone: string }>, +): Html => { + const isRunning = window.enabled && occurrenceStateAt(window, options.nowMs)._tag === "Running" + // A one-off window that has ended cannot run again, so there is nothing to switch on. + const isEnded = isEndedOnce(window, options.nowMs) + const scope = scopeText(model, window) + return h.li( + [h.Key(window.id), h.Class("px-4 py-3")], + [ + hstack(h, { gap: "3", align: "start" }, [ + rowIcon(h, "CalendarIcon"), + vstack(h, { gap: "0.5", shrink: true, grow: true }, [ + hstack(h, { gap: "2", wrap: true }, [ + typography( + h, + { size: "sm", weight: "medium", truncate: true, as: "p", className: "min-w-0" }, + [window.name], + ), + ...(isRunning ? [badge(h, { variant: "live" }, ["Running"])] : []), + ...(isEnded ? [badge(h, { variant: "muted" }, ["Ended"])] : []), + ...(excludesBaselines(window) + ? [badge(h, { variant: "meta" }, ["Excluded from baselines"])] + : []), + ]), + typography(h, { size: "xs", tone: "muted", as: "p", className: "break-words" }, [ + scheduleSummary(window.schedule, options.nowMs, options.timeZone), + ]), + typography(h, { size: "xs", tone: "muted", as: "p", className: "break-words" }, [ + Arr.join([scope, occurrenceText(window, options.nowMs, options.timeZone)], " · "), + ]), + textLink( + h, + { + href: windowSilencesHref(window.id), + tone: "muted", + className: "self-start text-xs", + }, + ["Occurrences"], + ), + ]), + hstack(h, { gap: "2", className: "shrink-0" }, [ + ...switchControl(h, { + id: `window-switch-${window.id}`, + inputId: `window-switch-input-${window.id}`, + isChecked: window.enabled, + isDisabled: + isEnded || !canManage(model) || model.togglingWindowIds.includes(window.id), + ariaLabel: window.enabled ? `Disable ${window.name}` : `Enable ${window.name}`, + onToggle: Message.ClickedToggleWindow({ windowId: window.id }), + }), + ...(canManage(model) ? [rowMenu(h, model, window)] : []), + ]), + ]), + ], + ) +} + +const windowsEmpty = (h: H, model: Model): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "CalendarIcon", { size: 18 })]), + emptyTitle(h, ["No maintenance windows"]), + emptyDescription(h, [ + "Plan quiet periods once or every week, like a nightly backup or a weekend migration. Each occurrence silences the rules it covers, so no one is paged.", + ...(canManage(model) ? [] : [" Ask an org admin to create one."]), + ]), + ]), + ...(canManage(model) + ? [ + emptyActions(h, [ + button( + h, + { + size: "sm", + isDisabled: model.nowMs === null, + attributes: [h.OnClick(Message.ClickedNewWindow())], + }, + [icon(h, "PlusIcon"), "Create window"], + ), + ]), + ] + : []), + ], + "py-12", + ) + +const windowsList = ( + h: H, + model: Model, + windows: ReadonlyArray, + options: Readonly<{ nowMs: number; timeZone: string }>, +): Html => + windows.length === 0 + ? windowsEmpty(h, model) + : borderedBox(h, { overflow: "hidden" }, [ + dividedList( + h, + { as: "ul", bordered: false }, + Arr.map(windows, (window) => windowRow(h, model, window, options)), + ), + ]) + +export const hasWindows = (model: Model): boolean => + Option.exists(AsyncData.getData(model.windows.data), (windows) => windows.length > 0) + +/** The Maintenance tab: the windows, or their empty, loading or failed state. */ +export const maintenanceTab = (h: H, model: Model, nowMs: number, timeZone: string): Html => { + const loading = vstack(h, { gap: "3" }, [skeleton(h, "h-20 w-full"), skeleton(h, "h-20 w-full")]) + const options = { nowMs, timeZone } + return AsyncData.match(model.windows.data, { + onIdle: () => loading, + onLoading: () => loading, + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't load maintenance windows", + onRetry: Message.ClickedRetryWindows(), + className: "py-12", + }), + onRefreshing: (windows) => windowsList(h, model, windows, options), + onStale: ({ data }) => windowsList(h, model, data, options), + onSuccess: (windows) => windowsList(h, model, windows, options), + }) +} + +/** "Delete {window}?": ends a running occurrence and drops the ones ahead. */ +export const deleteWindowDialog = (h: H, model: Model): Html => { + const name = Option.getOrElse( + Option.map( + Arr.findFirst( + Option.getOrElse(AsyncData.getData(model.windows.data), () => []), + (window) => window.id === model.confirmDeleteWindowId, + ), + (window) => window.name, + ), + () => "maintenance window", + ) + return confirmDialog(h, { + slotId: "alerts-delete-window", + model: model.deleteWindowDialog, + toParentMessage: (message) => Message.GotDeleteWindowDialogMessage({ message }), + title: `Delete ${name}?`, + description: + "An occurrence in progress ends now and the ones ahead are dropped. Past occurrences stay listed as ended silences.", + confirmLabel: "Delete window", + isPending: model.deletingWindowId !== null, + onConfirm: Message.ClickedConfirmDeleteWindow(), + }) +} diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts index 787855389a..e1e7b4529a 100644 --- a/apps/web/src/page/alerts/hub/view-oncall.ts +++ b/apps/web/src/page/alerts/hub/view-oncall.ts @@ -10,7 +10,7 @@ import { confirmDialog } from "@maple/views/ui/confirm-dialog" import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" import { errorState } from "@maple/views/ui/error-state" import { eyebrow } from "@maple/views/ui/eyebrow" -import { icon } from "@maple/views/ui/icon" +import { icon, rowIcon } from "@maple/views/ui/icon" import * as Menu from "@maple/views/ui/menu" import type { MenuEntry } from "@maple/views/ui/menu-view" import { personLabel } from "@maple/views/ui/person-avatar" @@ -84,7 +84,7 @@ const scheduleRow = (h: H, model: Model, schedule: V2OnCallSchedule, nowMs: numb [ hstack(h, { gap: "3", wrap: true }, [ hstack(h, { gap: "3", align: "start", grow: true, shrink: true, className: "basis-60" }, [ - icon(h, "CalendarIcon", { className: "mt-0.5 shrink-0 text-muted-foreground" }), + rowIcon(h, "CalendarIcon"), vstack(h, { gap: "0.5", shrink: true }, [ textLink( h, diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index 7e2dce8743..5e0a0e2e0d 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -1,4 +1,5 @@ -import type { V2AlertSilence } from "@maple/domain/http/v2" +import type { AlertMaintenanceWindowId } from "@maple/domain/http" +import type { V2AlertMaintenanceWindow, V2AlertSilence } from "@maple/domain/http/v2" import { Option, Array as Arr } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -9,10 +10,12 @@ 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 { removableChip } from "@maple/views/ui/removable-chip" import { skeleton } from "@maple/views/ui/skeleton" -import { vstack } from "@maple/views/ui/stack" +import { hstack, vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" import { sectionHeading } from "../../kit/section-heading" +import { windowIdOfParam } from "../shared/maintenance-windows" import { endsAtMs, formatSilenceTime, @@ -68,6 +71,7 @@ const silenceRow = ( timeZone: string ruleName: (id: string) => string rulesLoaded: boolean + windowName: (id: AlertMaintenanceWindowId) => string }>, ): Html => { const status = silenceStatusAt(silence, options.nowMs) @@ -98,7 +102,21 @@ const silenceRow = ( trailing: // A maintenance window owns its silences; they end or change only through the window. status !== "ended" && silence.maintenance_window_id !== null - ? [badge(h, { variant: "outline" }, ["Maintenance window"])] + ? [ + badge( + h, + { + variant: "outline", + attributes: [h.Title("Change it from the Maintenance tab")], + }, + [ + icon(h, "CalendarIcon"), + typography(h, { as: "span", truncate: true, className: "min-w-0 max-w-40" }, [ + `Managed by ${options.windowName(silence.maintenance_window_id)}`, + ]), + ], + ), + ] : status !== "ended" && canManage(model) ? [ button( @@ -134,6 +152,7 @@ const section = ( timeZone: string ruleName: (id: string) => string rulesLoaded: boolean + windowName: (id: AlertMaintenanceWindowId) => string }>, ): ReadonlyArray => silences.length === 0 @@ -149,6 +168,34 @@ const section = ( ]), ] +/** A window's name; "a maintenance window" until the windows load, or once it is deleted. */ +const nameOfWindow = ( + windows: ReadonlyArray, + id: AlertMaintenanceWindowId, +): string => + Option.getOrElse( + Option.map( + Arr.findFirst(windows, (window) => window.id === id), + (window) => window.name, + ), + () => "a maintenance window", + ) + +const windowOccurrencesEmpty = (h: H): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "CalendarIcon", { size: 18 })]), + emptyTitle(h, ["No occurrences listed"]), + emptyDescription(h, [ + "Each occurrence is written as a silence up to 48 hours before it starts, and stays listed for a week after it ends.", + ]), + ]), + ], + "py-12", + ) + const loaded = ( h: H, model: Model, @@ -165,14 +212,34 @@ const loaded = ( ), () => "deleted rule", ) + const windows = Option.getOrElse(AsyncData.getData(model.windows.data), () => []) + const windowName = (id: AlertMaintenanceWindowId): string => nameOfWindow(windows, id) + const filter = windowIdOfParam(model.search.window) + const shown = Option.match(filter, { + onNone: () => silences, + onSome: (id) => Arr.filter(silences, (silence) => silence.maintenance_window_id === id), + }) const byStatus = (status: V2AlertSilence["status"]) => - Arr.filter(silences, (silence) => silenceStatusAt(silence, nowMs) === status) + Arr.filter(shown, (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 } + const chip = Option.map(filter, (id) => + hstack(h, { gap: "2", wrap: true }, [ + removableChip(h, { + label: `Window: ${windowName(id)}`, + onRemove: Message.ClickedClearWindowFilter(), + }), + ]), + ) + if (active.length + scheduled.length + ended.length === 0) + return Option.match(chip, { + onNone: () => silencesEmptyState(h), + onSome: (row) => vstack(h, { gap: "4" }, [row, windowOccurrencesEmpty(h)]), + }) + const options = { nowMs, timeZone, ruleName, rulesLoaded: model.rules !== null, windowName } return vstack(h, { gap: "6" }, [ + ...Option.toArray(chip), ...(active.length === 0 ? [typography(h, { size: "xs", tone: "muted" }, ["Nothing is silenced right now"])] : []), diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index ec3ad330bb..f8fcf1e68f 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -36,9 +36,11 @@ import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" import * as ChainDialogView from "../escalation-dialog/view" import { deleteChainDialog } from "./view-chains" -import { CHAIN_DIALOG_ID } from "./update" +import { CHAIN_DIALOG_ID, WINDOW_DIALOG_ID } from "./update" import * as SilenceDialogView from "../silence-dialog/view" import { silencesTab } from "./view-silences" +import { deleteWindowDialog, hasWindows, maintenanceTab } from "./view-maintenance" +import * as WindowDialogView from "../maintenance-dialog/view" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" import { slosTab } from "./view-slos" @@ -62,6 +64,7 @@ const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [ { value: "uptime", label: "Uptime" }, { value: "slos", label: "SLOs" }, { value: "silences", label: "Silences" }, + { value: "maintenance", label: "Maintenance" }, { value: "oncall", label: "On-call" }, { value: "routing", label: "Routing" }, ] @@ -103,7 +106,13 @@ const tabBar = ( href: alertsHref( tab.value === "inbox" ? { ...model.search, tab: tab.value } - : { ...model.search, tab: tab.value, signal: undefined, anomalyStatus: undefined }, + : { + ...model.search, + tab: tab.value, + signal: undefined, + anomalyStatus: undefined, + window: undefined, + }, ), label: tab.label, count: count !== undefined && count > 0 ? count : undefined, @@ -230,6 +239,20 @@ const headerActions = (h: H, model: Model, tab: AlertsTab): Html => ), ] : []), + ...(canManage(model) && tab === "maintenance" && hasWindows(model) + ? [ + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: model.nowMs === null, + attributes: [h.OnClick(Message.ClickedNewWindow())], + }, + [icon(h, "CalendarIcon"), "New window"], + ), + ] + : []), ...(canManage(model) && tab === "slos" && hasSlos(model) ? [ linkButton( @@ -399,15 +422,18 @@ export const view = Submodel.defineView((model, ? slosTab(h, model) : tab === "silences" ? silencesTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) - : tab === "oncall" - ? oncallTab(h, model, model.nowMs ?? 0) - : hubBody(h, model, overview, tab), + : tab === "maintenance" + ? maintenanceTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) + : tab === "oncall" + ? oncallTab(h, model, model.nowMs ?? 0) + : hubBody(h, model, overview, tab), ], ), deleteDialog(h, model), deleteMonitorDialog(h, model), deleteChainDialog(h, model), deleteScheduleDialog(h, model), + deleteWindowDialog(h, model), h.submodel({ slotId: CHAIN_DIALOG_ID, model: model.chainDialog, @@ -435,6 +461,17 @@ export const view = Submodel.defineView((model, viewInputs: { id: "alerts-silence" }, toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), }), + h.submodel({ + slotId: WINDOW_DIALOG_ID, + model: model.windowDialog, + view: WindowDialogView.view, + viewInputs: { + id: WINDOW_DIALOG_ID, + rules: + model.rules === null ? null : Arr.map(model.rules, ({ id, name }) => ({ id, name })), + }, + toParentMessage: (message) => Message.GotWindowDialogMessage({ message }), + }), ], ) }) diff --git a/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts b/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts new file mode 100644 index 0000000000..4a52eb5b9d --- /dev/null +++ b/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts @@ -0,0 +1,303 @@ +import type { LoadFailure } from "@maple/views/data/load-failure" +import { Array as Arr, Option, Result } from "effect" +import { describe, expect, it } from "vitest" +import * as Combobox from "@maple/views/ui/combobox" +import type { V2AlertMaintenanceWindow } from "@maple/domain/http/v2" +import { endedWindow, runningWindow, weeklyWindow } from "../shared/maintenance-test-fixtures" +import { + daysError, + durationError, + endsAtError, + formErrors, + init, + Message, + type Model, + openEdit, + openNew, + 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" + +const run = (model: Model, messages: ReadonlyArray): Model => + Arr.reduce(messages, model, (current, message) => update(current, message).model) + +const opened = (): Model => openNew(init("test-window"), NOW, BERLIN).model + +const saveArgs = (model: Model) => { + const step = update(model, Message.ClickedSave()) + return { step, commands: step.commands ?? [] } +} + +describe("weekly validation", () => { + it("asks for at least one day", () => { + expect(daysError([])).toEqual(Option.some("Pick at least one day")) + expect(daysError(["sat"])).toEqual(Option.none()) + }) + + it("holds the duration to 5 minutes through 7 days, in whole minutes", () => { + expect(durationError("120")).toEqual(Option.none()) + expect(durationError("10080")).toEqual(Option.none()) + expect(Option.isSome(durationError("4"))).toBe(true) + expect(Option.isSome(durationError("10081"))).toBe(true) + expect(Option.isSome(durationError("1.5"))).toBe(true) + expect(Option.isSome(durationError("two hours"))).toBe(true) + }) + + it("refuses to save without days and shows every error instead", () => { + const model = run(opened(), [ + Message.ChangedName({ value: "Nightly backup" }), + Message.ChangedMatcher({ field: "services", value: "checkout" }), + Message.ChangedDuration({ value: "3" }), + ]) + const { step, commands } = saveArgs(model) + expect(commands).toEqual([]) + expect(step.model.isShowingErrors).toBe(true) + expect(formErrors(step.model)).toEqual(["Pick at least one day", "Enter 5 to 10080 minutes (7 days)"]) + }) +}) + +describe("once validation", () => { + it("needs the end after the start, within 30 days and, for a new schedule, ahead of now", () => { + const at = (startsAt: string, endsAt: string, requiresFuture = true) => + endsAtError({ startsAt, endsAt, timeZone: BERLIN, nowMs: NOW }, requiresFuture) + expect(at("2026-11-07T22:00", "2026-11-07T21:00")).toEqual( + Option.some("End the window after it starts"), + ) + expect(at("2026-11-07T22:00", "2026-12-10T22:00")).toEqual( + Option.some("A one-off window can last at most 30 days"), + ) + expect(at("2026-11-01T22:00", "2026-11-02T01:00")).toEqual(Option.some("Pick an end in the future")) + expect(at("2026-11-01T22:00", "2026-11-02T01:00", false)).toEqual(Option.none()) + expect(at("2026-11-07T22:00", "2026-11-08T01:00")).toEqual(Option.none()) + }) + + it("saves nothing while the end comes first", () => { + const model = run(opened(), [ + Message.ChangedName({ value: "Datacenter move" }), + Message.ChangedMatcher({ field: "tags", value: "infra" }), + Message.ChangedKind({ kind: "once" }), + Message.ChangedStartsAt({ value: "2026-11-07T22:00" }), + Message.ChangedEndsAt({ value: "2026-11-07T20:00" }), + ]) + expect(saveArgs(model).commands).toEqual([]) + }) +}) + +describe("payload", () => { + it("creates a weekly window with days in week order and the defaults on", () => { + const model = run(opened(), [ + Message.ReadZones({ zones: [BERLIN, "UTC"] }), + Message.ChangedName({ value: " Nightly backup " }), + Message.ChangedMatcher({ field: "services", value: "checkout, payments" }), + Message.ToggledDay({ day: "thu" }), + Message.ToggledDay({ day: "mon" }), + ]) + expect(saveArgs(model).commands).toEqual([ + expect.objectContaining({ + name: "SaveMaintenanceWindow", + args: { + openCount: 1, + request: { + _tag: "Create", + payload: { + name: "Nightly backup", + reason: null, + matchers: { services: ["checkout", "payments"] }, + schedule: { + type: "weekly", + time_zone: BERLIN, + days: ["mon", "thu"], + start_time: "02:00", + duration_minutes: 120, + }, + exclude_from_baselines: true, + enabled: true, + }, + }, + }, + }), + ]) + }) + + it("creates a one-off window with its times read in the page's zone", () => { + const model = run(opened(), [ + Message.ChangedName({ value: "Datacenter move" }), + Message.ChangedReason({ value: "Rack swap" }), + Message.ChangedMatcher({ field: "environments", value: "production" }), + Message.ChangedKind({ kind: "once" }), + Message.ChangedStartsAt({ value: "2026-11-07T22:00" }), + Message.ChangedEndsAt({ value: "2026-11-08T01:00" }), + Message.ToggledExcludeFromBaselines(), + ]) + expect(saveArgs(model).commands[0]).toMatchObject({ + args: { + request: { + payload: { + reason: "Rack swap", + schedule: { + type: "once", + starts_at: "2026-11-07T21:00:00.000Z", + ends_at: "2026-11-08T00:00:00.000Z", + }, + exclude_from_baselines: false, + }, + }, + }, + }) + }) + + it("defaults a one-off window to the next full hour in the page's zone and saves that instant", () => { + // 13:20 UTC: the next full hour is 14:00 UTC, the end two hours later. + const model = run(openNew(init("test-window"), NOW, "UTC").model, [ + Message.ReadZones({ zones: [BERLIN, "UTC"] }), + Message.ChangedName({ value: "Rack swap" }), + Message.ChangedMatcher({ field: "tags", value: "infra" }), + Message.ChangedKind({ kind: "once" }), + ]) + expect(model.timeZone).toBe("UTC") + expect(model.startsAt).toBe("2026-11-06T14:00") + expect(saveArgs(model).commands[0]).toMatchObject({ + args: { + request: { + payload: { + schedule: { + type: "once", + starts_at: "2026-11-06T14:00:00.000Z", + ends_at: "2026-11-06T16:00:00.000Z", + }, + }, + }, + }, + }) + }) + + it("renames a one-off window that already ran without resending its schedule or matchers", () => { + // NOW is a month after the window ended; its rule may since be deleted. + const editing = openEdit(init("test-window"), runningWindow, NOW, "UTC").model + const model = run(editing, [Message.ChangedName({ value: "Checkout v3 cutover" })]) + expect(saveArgs(model).commands).toEqual([ + expect.objectContaining({ + args: { + openCount: 1, + request: { + _tag: "Update", + id: runningWindow.id, + payload: { name: "Checkout v3 cutover" }, + }, + }, + }), + ]) + }) + + it("renames an ended one-off window whose API-written times carry seconds", () => { + const withSeconds: V2AlertMaintenanceWindow = { + ...endedWindow, + schedule: { + type: "once", + starts_at: "2026-10-01T09:15:42.517Z", + ends_at: "2026-10-01T11:15:42.517Z", + }, + } + const editing = openEdit(init("test-window"), withSeconds, NOW, "UTC").model + const model = run(editing, [Message.ChangedName({ value: "Rack move" })]) + expect(saveArgs(model).commands).toEqual([ + expect.objectContaining({ + args: { + openCount: 1, + request: { _tag: "Update", id: withSeconds.id, payload: { name: "Rack move" } }, + }, + }), + ]) + }) + + it("keeps a one-off window's instants when the zone changes", () => { + const startsAt = "2026-10-01T09:00:00.000Z" + const endsAt = "2026-10-01T11:00:00.000Z" + const ended: V2AlertMaintenanceWindow = { + ...endedWindow, + schedule: { type: "once", starts_at: startsAt, ends_at: endsAt }, + } + const editing = openEdit(init("test-window"), ended, NOW, BERLIN).model + const model = run(editing, [ + Message.ChangedName({ value: "Rack move" }), + Message.GotZoneMessage({ message: Combobox.Message.ClickedItem({ value: "UTC" }) }), + ]) + expect([model.startsAt, model.endsAt]).toEqual(["2026-10-01T09:00", "2026-10-01T11:00"]) + expect(saveArgs(model).commands).toEqual([ + expect.objectContaining({ + args: { + openCount: 1, + request: { _tag: "Update", id: ended.id, payload: { name: "Rack move" } }, + }, + }), + ]) + }) + + it("sends only the reason when only the reason changed", () => { + const editing = openEdit(init("test-window"), weeklyWindow, NOW, "UTC").model + const model = run(editing, [ + Message.ReadZones({ zones: ["UTC"] }), + Message.ChangedReason({ value: "Weekly index rebuild" }), + ]) + expect(saveArgs(model).commands).toEqual([ + expect.objectContaining({ + args: { + openCount: 1, + request: { + _tag: "Update", + id: weeklyWindow.id, + payload: { reason: "Weekly index rebuild" }, + }, + }, + }), + ]) + }) + + it("sends a changed schedule, and then requires a one-off end ahead of now", () => { + const editing = openEdit(init("test-window"), runningWindow, NOW, "UTC").model + const moved = run(editing, [Message.ChangedEndsAt({ value: "2026-10-01T15:00" })]) + expect(saveArgs(moved).commands).toEqual([]) + const ahead = run(editing, [ + Message.ChangedStartsAt({ value: "2026-11-10T22:00" }), + Message.ChangedEndsAt({ value: "2026-11-11T01:00" }), + ]) + expect(saveArgs(ahead).commands[0]).toMatchObject({ + args: { + request: { + payload: { + schedule: { + type: "once", + starts_at: "2026-11-10T22:00:00.000Z", + ends_at: "2026-11-11T01:00:00.000Z", + }, + }, + }, + }, + }) + }) + + it("keeps the dialog open with the server's refusal inline", () => { + const model = run(opened(), [ + Message.ChangedName({ value: "Nightly backup" }), + Message.ChangedMatcher({ field: "services", value: "checkout" }), + Message.ToggledDay({ day: "sat" }), + ]) + const saving = saveArgs(model).step.model + const failure: LoadFailure = { + _tag: "@maple/http/errors/AlertValidationError", + type: "invalid_request_error", + code: "invalid_request", + title: "Invalid request", + message: "Unknown rule in matchers.", + retryable: false, + recovery: "fix_request", + } + const failed = update(saving, Message.CompletedSave({ openCount: 1, result: Result.fail(failure) })) + expect(failed.model.saveError).toBe("Unknown rule in matchers.") + expect(failed.model.isSaving).toBe(false) + expect(failed.model.dialog.isOpen).toBe(true) + }) +}) diff --git a/apps/web/src/page/alerts/maintenance-dialog/dialog.ts b/apps/web/src/page/alerts/maintenance-dialog/dialog.ts new file mode 100644 index 0000000000..0afff28018 --- /dev/null +++ b/apps/web/src/page/alerts/maintenance-dialog/dialog.ts @@ -0,0 +1,569 @@ +import { + AlertMaintenanceWindowName, + AlertMaintenanceWindowReason, + AlertRuleId, + MAX_ALERT_SILENCE_DURATION_MS, + MaintenanceDurationMinutes, + MaintenanceStartTime, + MaintenanceTimeZone, + MaintenanceWeekday, +} from "@maple/domain/http" +import { + V2AlertMaintenanceWindow, + type V2AlertMaintenanceWindowCreateParams, + type V2AlertMaintenanceWindowUpdateParams, + type V2AlertSilenceMatchers, + type V2MaintenanceSchedule, +} from "@maple/domain/http/v2" +import { Effect, Option, Order, Result, Schema, Array as Arr, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Combobox from "@maple/views/ui/combobox" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import type { Resources } from "../../../platform/resources" +import { readSupportedTimeZones } from "../../../platform/time-zone" +import { + emptyTypedMatchers, + hasMatchers, + MatcherField, + matchersFromTyped, + TypedMatchers, + typedFromMatchers, +} from "../shared/matcher-fields" +import { SaveRequest, saveMaintenanceWindow, WEEKDAYS } from "../shared/maintenance-windows" +import { isoMs, msIso } from "../shared/time" +import { localInputOf } from "../schedule-form/form" +import { parseLocalDateTime } from "../silence-dialog/dialog" + +// "New maintenance window" / "Edit maintenance window": the hub's dialog, as one Submodel. + +export const ScheduleKind = Schema.Literals(["once", "weekly"]) +export type ScheduleKind = typeof ScheduleKind.Type + +export const Model = Schema.Struct({ + dialog: Dialog.Model, + /** The window being edited, as loaded: an edit sends only what differs from it. */ + editing: Schema.NullOr(V2AlertMaintenanceWindow), + name: Schema.String, + reason: Schema.String, + typed: TypedMatchers, + /** Rules the window names; picked from the rules the hub has synced. */ + ruleIds: Schema.Array(AlertRuleId), + ruleMenu: Menu.Model, + kind: ScheduleKind, + /** A one-off window's start and end, as `datetime-local` values in `timeZone`. */ + startsAt: Schema.String, + endsAt: Schema.String, + days: Schema.Array(MaintenanceWeekday), + /** `HH:MM`, read in `timeZone`. */ + startTime: Schema.String, + durationMinutes: Schema.String, + timeZone: Schema.String, + /** Every IANA zone the browser knows, for the zone picker. */ + zones: Schema.Array(Schema.String), + zonePicker: Combobox.Model, + excludeFromBaselines: Schema.Boolean, + enabled: Schema.Boolean, + /** The wall clock when the dialog opened: a one-off end is checked against it. */ + nowMs: Schema.Number, + isSaving: Schema.Boolean, + /** A save was tried: every field shows what is wrong with it. */ + isShowingErrors: Schema.Boolean, + saveError: Schema.NullOr(Schema.String), + /** 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 DEFAULT_START_TIME = "02:00" +export const DEFAULT_DURATION_MINUTES = "120" + +export const ruleMenuIdOf = (id: string): string => `${id}-rules` +export const zonePickerIdOf = (id: string): string => `${id}-zone` + +export const init = (id: string): Model => ({ + dialog: Dialog.init(id, "dialog"), + editing: null, + name: "", + reason: "", + typed: { services: "", tags: "", environments: "" }, + ruleIds: [], + ruleMenu: Menu.init(ruleMenuIdOf(id)), + kind: "weekly", + startsAt: "", + endsAt: "", + days: [], + startTime: DEFAULT_START_TIME, + durationMinutes: DEFAULT_DURATION_MINUTES, + timeZone: "UTC", + zones: [], + zonePicker: Combobox.init(zonePickerIdOf(id), "single", { selected: ["UTC"] }), + excludeFromBaselines: true, + enabled: true, + nowMs: 0, + isSaving: false, + isShowingErrors: false, + saveError: null, + openCount: 0, +}) + +/** The picker's zones: the browser's list, plus `UTC` (which it leaves out) and the form's own zone. */ +export const zoneItems = (model: Pick): ReadonlyArray => + Arr.sort(Arr.dedupe(Arr.filter([...model.zones, "UTC", model.timeZone], Str.isNonEmpty)), Order.String) + +// VALIDATION: the limits the API's schemas hold. + +const isName = Schema.is(AlertMaintenanceWindowName) +const isReason = Schema.is(AlertMaintenanceWindowReason) +const isStartTime = Schema.is(MaintenanceStartTime) +const isDuration = Schema.is(MaintenanceDurationMinutes) +const isZone = Schema.is(MaintenanceTimeZone) + +export const nameError = (name: string): Option.Option => + Str.isEmpty(Str.trim(name)) + ? Option.some("Name the window") + : isName(Str.trim(name)) + ? Option.none() + : Option.some("Keep the name to 120 characters") + +export const reasonError = (reason: string): Option.Option => + Str.isEmpty(Str.trim(reason)) || isReason(Str.trim(reason)) + ? Option.none() + : Option.some("Keep the reason to 400 characters") + +export const matchersOf = (model: Pick) => ({ + ...matchersFromTyped(model.typed), + ...(model.ruleIds.length === 0 ? undefined : { rule_ids: model.ruleIds }), +}) + +export const matchersError = (model: Pick): Option.Option => + hasMatchers(matchersOf(model)) ? Option.none() : Option.some("Pick a rule or fill in at least one field") + +export const zoneError = (zone: string): Option.Option => + isZone(zone) ? Option.none() : Option.some("Pick a time zone from the list") + +export const startsAtError = (model: Pick): Option.Option => + Option.isSome(parseLocalDateTime(model.startsAt, model.timeZone)) + ? Option.none() + : Option.some("Pick a start date and time") + +/** A new or rescheduled window must end ahead of now; an edit that keeps the schedule need not. */ +export const endsAtError = ( + model: Pick, + requiresFuture = true, +): Option.Option => + Option.match(parseLocalDateTime(model.endsAt, model.timeZone), { + onNone: () => Option.some("Pick an end date and time"), + onSome: (endMs) => + Option.match(parseLocalDateTime(model.startsAt, model.timeZone), { + onNone: () => Option.none(), + onSome: (startMs) => + endMs <= startMs + ? Option.some("End the window after it starts") + : endMs - startMs > MAX_ALERT_SILENCE_DURATION_MS + ? Option.some("A one-off window can last at most 30 days") + : requiresFuture && endMs <= model.nowMs + ? Option.some("Pick an end in the future") + : Option.none(), + }), + }) + +export const daysError = (days: ReadonlyArray): Option.Option => + days.length === 0 ? Option.some("Pick at least one day") : Option.none() + +export const startTimeError = (startTime: string): Option.Option => + isStartTime(startTime) ? Option.none() : Option.some("Enter a time as HH:MM") + +/** The duration as a whole number of minutes; none when it is not one. */ +export const durationOf = (raw: string): Option.Option => { + const trimmed = Str.trim(raw) + return /^\d+$/.test(trimmed) ? Option.some(Number(trimmed)) : Option.none() +} + +export const durationError = (raw: string): Option.Option => + Option.exists(durationOf(raw), isDuration) + ? Option.none() + : Option.some("Enter 5 to 10080 minutes (7 days)") + +// EDIT DIFF: an edit sends only what changed, so a window that already ran or names a deleted +// rule can still be renamed (the API checks a schedule or matchers whenever they are sent). + +const weekOrder = (days: ReadonlyArray): ReadonlyArray => + Arr.map( + Arr.filter(WEEKDAYS, (day) => days.includes(day.value)), + (day) => day.value, + ) + +/** The dialog edits one-off times to the minute, so seconds from an API-created window never count as a change. */ +const minuteOf = (iso: string): number => Math.floor(isoMs(iso) / 60_000) + +const scheduleKey = (schedule: V2MaintenanceSchedule): string => + schedule.type === "once" + ? `once|${minuteOf(schedule.starts_at)}|${minuteOf(schedule.ends_at)}` + : `weekly|${schedule.time_zone}|${Arr.join(weekOrder(schedule.days), ",")}|${schedule.start_time}|${schedule.duration_minutes}` + +const sorted = (values: ReadonlyArray | undefined): string => + Arr.join(Arr.sort(values ?? [], Order.String), ",") + +const matchersKey = (matchers: V2AlertSilenceMatchers): string => + Arr.join( + [ + sorted(matchers.rule_ids), + sorted(matchers.services), + sorted(matchers.tags), + sorted(matchers.environments), + ], + "|", + ) + +/** The form's schedule differs from the loaded window's (always true for a new window). */ +export const isScheduleChanged = (model: Model): boolean => { + const editing = model.editing + return ( + editing === null || + Option.match(scheduleOf(model), { + onNone: () => true, + onSome: (schedule) => scheduleKey(schedule) !== scheduleKey(editing.schedule), + }) + ) +} + +/** The fields of `payload` that differ from the loaded window. */ +const changedFields = ( + window: V2AlertMaintenanceWindow, + payload: V2AlertMaintenanceWindowCreateParams, +): V2AlertMaintenanceWindowUpdateParams => ({ + ...(payload.name === window.name ? undefined : { name: payload.name }), + ...((payload.reason ?? null) === window.reason ? undefined : { reason: payload.reason ?? null }), + ...(matchersKey(payload.matchers) === matchersKey(window.matchers) + ? undefined + : { matchers: payload.matchers }), + ...(scheduleKey(payload.schedule) === scheduleKey(window.schedule) + ? undefined + : { schedule: payload.schedule }), + ...(payload.exclude_from_baselines === window.exclude_from_baselines + ? undefined + : { exclude_from_baselines: payload.exclude_from_baselines }), + ...(payload.enabled === window.enabled ? undefined : { enabled: payload.enabled }), +}) + +/** Every field's error, in form order, for the checks the schedule kind applies. */ +export const formErrors = (model: Model): ReadonlyArray => + Arr.getSomes([ + nameError(model.name), + matchersError(model), + ...(model.kind === "once" + ? [startsAtError(model), endsAtError(model, isScheduleChanged(model))] + : [daysError(model.days), startTimeError(model.startTime), durationError(model.durationMinutes)]), + zoneError(model.timeZone), + reasonError(model.reason), + ]) + +const scheduleOf = (model: Model): Option.Option => + model.kind === "once" + ? Option.zipWith( + parseLocalDateTime(model.startsAt, model.timeZone), + parseLocalDateTime(model.endsAt, model.timeZone), + (startMs, endMs): V2MaintenanceSchedule => ({ + type: "once", + starts_at: msIso(startMs), + ends_at: msIso(endMs), + }), + ) + : Option.map(durationOf(model.durationMinutes), (duration): V2MaintenanceSchedule => ({ + type: "weekly", + time_zone: model.timeZone, + days: Arr.map( + Arr.filter(WEEKDAYS, (day) => model.days.includes(day.value)), + (day) => day.value, + ), + start_time: model.startTime, + duration_minutes: duration, + })) + +const fullPayloadOf = (model: Model): Option.Option => + Option.map(scheduleOf(model), (schedule) => ({ + name: Str.trim(model.name), + reason: Str.isEmpty(Str.trim(model.reason)) ? null : Str.trim(model.reason), + matchers: matchersOf(model), + schedule, + exclude_from_baselines: model.excludeFromBaselines, + enabled: model.enabled, + })) + +/** What the form saves: a new window in full, an edit only the fields it changed. */ +export const requestOf = (model: Model): Option.Option => { + const editing = model.editing + if (formErrors(model).length > 0) return Option.none() + return Option.map(fullPayloadOf(model), (payload): SaveRequest => + editing === null + ? { _tag: "Create", payload } + : { _tag: "Update", id: editing.id, payload: changedFields(editing, payload) }, + ) +} + +// MESSAGES + +export const Message = defineMessageUnion({ + GotDialogMessage: { message: Dialog.Message }, + ReadZones: { zones: Schema.Array(Schema.String) }, + ChangedName: { value: Schema.String }, + ChangedReason: { value: Schema.String }, + ChangedMatcher: { field: MatcherField, value: Schema.String }, + GotRuleMenuMessage: { message: Menu.Message }, + ClickedRemoveRule: { ruleId: AlertRuleId }, + ChangedKind: { kind: ScheduleKind }, + ChangedStartsAt: { value: Schema.String }, + ChangedEndsAt: { value: Schema.String }, + ToggledDay: { day: MaintenanceWeekday }, + ChangedStartTime: { value: Schema.String }, + ChangedDuration: { value: Schema.String }, + GotZoneMessage: { message: Combobox.Message }, + ToggledExcludeFromBaselines: {}, + ToggledEnabled: {}, + ClickedSave: {}, + CompletedSave: { + openCount: Schema.Number, + result: Schema.Result(V2AlertMaintenanceWindow, LoadFailure), + }, +}) +export type Message = typeof Message.Type + +export const OutMessage = defineMessageUnion({ + SavedWindow: { window: V2AlertMaintenanceWindow, isNew: Schema.Boolean }, +}) +export type OutMessage = typeof OutMessage.Type + +type Return = Update.ReturnWithOutMessage + +// COMMANDS + +/** Every zone to pick from. */ +export const ReadWindowZones = Command.define("ReadMaintenanceWindowZones", { + messages: [Message.ReadZones], + execute: Effect.map(readSupportedTimeZones, (zones) => Message.ReadZones({ zones })), +}) + +export const SaveWindow = Command.define("SaveMaintenanceWindow", { + args: { + openCount: Schema.Number, + request: SaveRequest, + }, + messages: [Message.CompletedSave], + execute: ({ openCount, request }) => + saveMaintenanceWindow(request).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 })), +}) + +type Fields = Omit< + Model, + | "dialog" + | "ruleMenu" + | "zones" + | "zonePicker" + | "nowMs" + | "isSaving" + | "isShowingErrors" + | "saveError" + | "openCount" +> + +const open = (model: Model, fields: Fields, nowMs: number): Return => { + const opened = liftDialog( + { + ...model, + ...fields, + nowMs, + ruleMenu: Menu.init(ruleMenuIdOf(model.dialog.id)), + zonePicker: Combobox.init(zonePickerIdOf(model.dialog.id), "single", { + selected: [fields.timeZone], + }), + isSaving: false, + isShowingErrors: false, + saveError: null, + openCount: model.openCount + 1, + }, + Dialog.open(model.dialog), + ) + return { ...opened, commands: [...(opened.commands ?? []), ReadWindowZones()] } +} + +const HOUR_MS = 60 * 60_000 + +/** A new window: weekly at 02:00 for two hours, or once from the next full hour, in `timeZone`. */ +export const openNew = (model: Model, nowMs: number, timeZone: string): Return => { + const nextHourMs = Math.ceil((nowMs + 1) / HOUR_MS) * HOUR_MS + return open( + model, + { + editing: null, + name: "", + reason: "", + typed: emptyTypedMatchers, + ruleIds: [], + kind: "weekly", + startsAt: localInputOf(nextHourMs, timeZone), + endsAt: localInputOf(nextHourMs + 2 * HOUR_MS, timeZone), + days: [], + startTime: DEFAULT_START_TIME, + durationMinutes: DEFAULT_DURATION_MINUTES, + timeZone, + excludeFromBaselines: true, + enabled: true, + }, + nowMs, + ) +} + +/** A saved window; a one-off one reads in the viewer's zone, a weekly one in its own. */ +export const openEdit = ( + model: Model, + window: V2AlertMaintenanceWindow, + nowMs: number, + timeZone: string, +): Return => { + const schedule = window.schedule + const zone = schedule.type === "weekly" ? schedule.time_zone : timeZone + return open( + model, + { + editing: window, + name: window.name, + reason: window.reason ?? "", + typed: typedFromMatchers(window.matchers), + ruleIds: window.matchers.rule_ids ?? [], + kind: schedule.type, + startsAt: schedule.type === "once" ? localInputOf(isoMs(schedule.starts_at), zone) : "", + endsAt: schedule.type === "once" ? localInputOf(isoMs(schedule.ends_at), zone) : "", + days: schedule.type === "weekly" ? schedule.days : [], + startTime: schedule.type === "weekly" ? schedule.start_time : DEFAULT_START_TIME, + durationMinutes: + schedule.type === "weekly" ? String(schedule.duration_minutes) : DEFAULT_DURATION_MINUTES, + timeZone: zone, + excludeFromBaselines: window.exclude_from_baselines, + enabled: window.enabled, + }, + nowMs, + ) +} + +/** A form edit is dropped while a save is in flight: the saved window is what the dialog shows. */ +const unlessSaving = (model: Model, edit: () => Model): Return => + model.isSaving ? { model } : { model: edit() } + +const ruleMenuStep = (model: Model, message: Menu.Message): Return => { + const result = Menu.update(model.ruleMenu, message) + const commands = Command.mapMessages(result.commands ?? [], (child) => + Message.GotRuleMenuMessage({ message: child }), + ) + const picked = Option.flatMap( + result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), + Schema.decodeUnknownOption(AlertRuleId), + ) + const next: Model = { ...model, ruleMenu: result.model } + return Option.match(model.isSaving ? Option.none() : picked, { + onNone: () => ({ model: next, commands }), + onSome: (ruleId) => ({ + model: next.ruleIds.includes(ruleId) ? next : { ...next, ruleIds: [...next.ruleIds, ruleId] }, + commands, + }), + }) +} + +/** A zone picked from the list; anything typed that is not a zone leaves the form's zone alone. */ +const zoneStep = (model: Model, message: Combobox.Message): Return => { + const step = Combobox.update(model.zonePicker, message) + const commands = Command.mapMessages(step.commands ?? [], (child) => + Message.GotZoneMessage({ message: child }), + ) + const zone = + step.outMessage?._tag === "ChangedValue" && zoneItems(model).includes(step.outMessage.value) + ? step.outMessage.value + : null + if (zone === null || model.isSaving || zone === model.timeZone) + return { model: { ...model, zonePicker: step.model }, commands } + // A one-off window stores instants, so a new zone re-reads the same instants, not the same wall clock. + const inZone = (value: string): string => + model.kind === "once" + ? Option.match(parseLocalDateTime(value, model.timeZone), { + onNone: () => value, + onSome: (ms) => localInputOf(ms, zone), + }) + : value + return { + model: { + ...model, + zonePicker: step.model, + timeZone: zone, + startsAt: inZone(model.startsAt), + endsAt: inZone(model.endsAt), + }, + commands, + } +} + +const save = (model: Model): Return => + model.isSaving + ? { model } + : Option.match(requestOf(model), { + onNone: () => ({ model: { ...model, isShowingErrors: true } }), + onSome: (request) => ({ + model: { ...model, isSaving: true, isShowingErrors: true, saveError: null }, + commands: [SaveWindow({ openCount: model.openCount, request })], + }), + }) + +const toggled = (values: ReadonlyArray, value: A): ReadonlyArray => + values.includes(value) ? Arr.filter(values, (existing) => existing !== value) : [...values, value] + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotDialogMessage: ({ message: child }) => + // The dialog stays open while a save is in flight. + model.isSaving ? { model } : liftDialog(model, Dialog.update(model.dialog, child)), + ReadZones: ({ zones }) => ({ model: { ...model, zones } }), + ChangedName: ({ value }) => unlessSaving(model, () => ({ ...model, name: value })), + ChangedReason: ({ value }) => unlessSaving(model, () => ({ ...model, reason: value })), + ChangedMatcher: ({ field, value }) => + unlessSaving(model, () => ({ ...model, typed: { ...model.typed, [field]: value } })), + GotRuleMenuMessage: ({ message: child }) => ruleMenuStep(model, child), + ClickedRemoveRule: ({ ruleId }) => + unlessSaving(model, () => ({ + ...model, + ruleIds: Arr.filter(model.ruleIds, (id) => id !== ruleId), + })), + ChangedKind: ({ kind }) => unlessSaving(model, () => ({ ...model, kind })), + ChangedStartsAt: ({ value }) => unlessSaving(model, () => ({ ...model, startsAt: value })), + ChangedEndsAt: ({ value }) => unlessSaving(model, () => ({ ...model, endsAt: value })), + ToggledDay: ({ day }) => unlessSaving(model, () => ({ ...model, days: toggled(model.days, day) })), + ChangedStartTime: ({ value }) => unlessSaving(model, () => ({ ...model, startTime: value })), + ChangedDuration: ({ value }) => unlessSaving(model, () => ({ ...model, durationMinutes: value })), + GotZoneMessage: ({ message: child }) => zoneStep(model, child), + ToggledExcludeFromBaselines: () => + unlessSaving(model, () => ({ ...model, excludeFromBaselines: !model.excludeFromBaselines })), + ToggledEnabled: () => unlessSaving(model, () => ({ ...model, enabled: !model.enabled })), + ClickedSave: () => save(model), + CompletedSave: ({ openCount, result }) => + openCount !== model.openCount + ? { model } + : Result.match(result, { + onSuccess: (window) => ({ + ...liftDialog({ ...model, isSaving: false }, Dialog.close(model.dialog)), + outMessage: OutMessage.SavedWindow({ window, isNew: model.editing === null }), + }), + onFailure: (failure) => ({ + model: { ...model, isSaving: false, saveError: failure.message }, + }), + }), + }) diff --git a/apps/web/src/page/alerts/maintenance-dialog/view.ts b/apps/web/src/page/alerts/maintenance-dialog/view.ts new file mode 100644 index 0000000000..2819aa4179 --- /dev/null +++ b/apps/web/src/page/alerts/maintenance-dialog/view.ts @@ -0,0 +1,316 @@ +import type { AlertRuleId } from "@maple/domain/http" +import { Option, Array as Arr } from "effect" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import * as Checkbox from "@maple/views/ui/checkbox" +import * as ComboboxView from "@maple/views/ui/combobox-view" +import { eyebrow } from "@maple/views/ui/eyebrow" +import { fieldErrorText } from "@maple/views/ui/field" +import { formDialog } from "@maple/views/ui/form-dialog" +import { icon } from "@maple/views/ui/icon" +import { label } from "@maple/views/ui/label" +import { panel } from "@maple/views/ui/panel" +import { multiSegmentedSelect, segmentedSelect } from "@maple/views/ui/segmented-select" +import { hstack, vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { pickList } from "../../kit/pick-list" +import { dialogTextField, type DialogTextFieldOptions, matcherFields } from "../shared/matcher-fields" +import { formatDurationMinutes, WEEKDAYS } from "../shared/maintenance-windows" +import { + daysError, + durationError, + durationOf, + endsAtError, + isScheduleChanged, + matchersError, + Message, + type Model, + nameError, + reasonError, + type ScheduleKind, + startsAtError, + startTimeError, + zoneError, + zoneItems, +} from "./dialog" + +type H = HtmlBuilder + +export type ViewInputs = Readonly<{ + id: string + /** The org's rules, to pick from and to name the picked ones; `null` until they sync. */ + rules: ReadonlyArray<{ readonly id: string; readonly name: string }> | null +}> + +const ID_PREFIX = "maintenance" + +const KINDS: ReadonlyArray<{ value: ScheduleKind; label: string }> = [ + { value: "weekly", label: "Weekly" }, + { value: "once", label: "Once" }, +] + +/** An error once a save was tried. */ +const shown = (model: Model, error: Option.Option): Option.Option => + model.isShowingErrors ? error : Option.none() + +const textField = (h: H, model: Model, options: Omit, "idPrefix">): Html => + dialogTextField(h, { ...options, idPrefix: ID_PREFIX, isDisabled: model.isSaving }) + +/** An error that belongs to a group of controls rather than one input. */ +const groupError = (h: H, id: string, error: Option.Option): ReadonlyArray => + Option.toArray( + Option.map(error, (message) => + fieldErrorText(h, { attributes: [h.Id(id), h.Role("alert")] }, message), + ), + ) + +const ruleName = (inputs: ViewInputs, id: AlertRuleId): string => + Option.getOrElse( + Option.map( + Arr.findFirst(inputs.rules ?? [], (rule) => rule.id === id), + (rule) => rule.name, + ), + () => (inputs.rules === null ? "Rule" : "Deleted rule"), + ) + +const rulesField = (h: H, model: Model, inputs: ViewInputs): Html => + vstack(h, { gap: "1.5" }, [ + typography(h, { size: "xs", weight: "medium" }, ["Rules"]), + pickList(h, { + entries: Arr.map(model.ruleIds, (id) => ({ + key: id, + label: ruleName(inputs, id), + media: icon(h, "BellIcon", { className: "text-muted-foreground" }), + removeLabel: `Remove ${ruleName(inputs, id)}`, + onRemove: Message.ClickedRemoveRule({ ruleId: id }), + })), + emptyText: "Any rule the fields below match", + menu: model.ruleMenu, + toMenuMessage: (message) => Message.GotRuleMenuMessage({ message }), + addEntries: Arr.map( + Arr.filter(inputs.rules ?? [], (rule) => !Arr.some(model.ruleIds, (id) => id === rule.id)), + (rule) => ({ _tag: "Item" as const, value: rule.id, label: rule.name }), + ), + addLabel: "Add rule", + isDisabled: model.isSaving, + }), + ]) + +const coversSection = (h: H, model: Model, inputs: ViewInputs): Html => + vstack(h, { gap: "2" }, [ + eyebrow(h, { variant: "label", as: "div" }, ["Covers"]), + panel(h, { padded: true, className: "gap-3" }, [ + rulesField(h, model, inputs), + ...matcherFields(h, { + idPrefix: ID_PREFIX, + typed: model.typed, + onInput: (field, value) => Message.ChangedMatcher({ field, value }), + isDisabled: model.isSaving, + }), + ...groupError(h, `${ID_PREFIX}-matchers-error`, shown(model, matchersError(model))), + ]), + ]) + +const weeklyFields = (h: H, model: Model): ReadonlyArray => [ + vstack(h, { gap: "1.5" }, [ + typography(h, { size: "xs", weight: "medium" }, ["Days"]), + multiSegmentedSelect(h, { + ariaLabel: "Days", + options: Arr.map(WEEKDAYS, (day) => ({ + value: day.value, + label: day.label, + ariaLabel: day.label, + })), + value: model.days, + onToggle: (day) => Message.ToggledDay({ day }), + size: "sm", + isDisabled: model.isSaving, + className: "max-w-full flex-wrap", + }), + ...groupError(h, `${ID_PREFIX}-days-error`, shown(model, daysError(model.days))), + ]), + hstack(h, { gap: "3", align: "start", className: "*:flex-1" }, [ + textField(h, model, { + name: "start-time", + label: "Starts at", + value: model.startTime, + placeholder: "02:00", + type: "time", + onInput: (value) => Message.ChangedStartTime({ value }), + error: shown(model, startTimeError(model.startTime)), + }), + textField(h, model, { + name: "duration", + label: "Duration (minutes)", + value: model.durationMinutes, + placeholder: "120", + onInput: (value) => Message.ChangedDuration({ value }), + description: Option.match( + Option.filter(durationOf(model.durationMinutes), (n) => n > 0), + { + onNone: () => "5 minutes to 7 days", + onSome: (minutes) => `${formatDurationMinutes(minutes)} each time`, + }, + ), + error: shown(model, durationError(model.durationMinutes)), + }), + ]), +] + +const onceFields = (h: H, model: Model): ReadonlyArray => [ + hstack(h, { gap: "3", align: "start", wrap: true, className: "*:min-w-48 *:flex-1" }, [ + textField(h, model, { + name: "starts-at", + label: "Starts", + value: model.startsAt, + placeholder: "", + type: "datetime-local", + onInput: (value) => Message.ChangedStartsAt({ value }), + error: shown(model, startsAtError(model)), + }), + textField(h, model, { + name: "ends-at", + label: "Ends", + value: model.endsAt, + placeholder: "", + type: "datetime-local", + onInput: (value) => Message.ChangedEndsAt({ value }), + // A wrong order shows as soon as both are typed; a missing one waits for a save. + error: model.isShowingErrors + ? endsAtError(model, isScheduleChanged(model)) + : Option.filter( + endsAtError(model, isScheduleChanged(model)), + () => model.startsAt !== "" && model.endsAt !== "", + ), + }), + ]), +] + +const zoneField = (h: H, model: Model): Html => + vstack(h, { gap: "1.5" }, [ + label(h, { className: "text-xs", attributes: [h.For(`${model.zonePicker.id}-input`)] }, [ + "Time zone", + ]), + h.submodel({ + slotId: model.zonePicker.id, + model: model.zonePicker, + view: ComboboxView.view, + toParentMessage: (message) => Message.GotZoneMessage({ message }), + viewInputs: { + items: zoneItems(model), + ariaLabel: "Time zone", + placeholder: "Search time zones…", + emptyText: "No time zone matches that", + startAddon: icon(h, "GlobeIcon"), + className: "w-full", + highlightsSelected: true, + toView: ({ field, rest }) => h.div([h.Class("contents")], [field, ...rest]), + }, + }), + ...groupError(h, `${ID_PREFIX}-zone-error`, shown(model, zoneError(model.timeZone))), + ]) + +const scheduleSection = (h: H, model: Model): Html => + vstack(h, { gap: "2" }, [ + eyebrow(h, { variant: "label", as: "div" }, ["Schedule"]), + panel(h, { padded: true, className: "gap-3" }, [ + segmentedSelect(h, { + ariaLabel: "Repeats", + options: KINDS, + value: model.kind, + onChange: (kind) => Message.ChangedKind({ kind }), + size: "sm", + }), + ...(model.kind === "weekly" ? weeklyFields(h, model) : onceFields(h, model)), + zoneField(h, model), + ]), + ]) + +/** A checkbox with its label, and a muted line under it. */ +const checkboxOption = ( + h: H, + model: Model, + options: Readonly<{ name: string; label: string; hint: string; isChecked: boolean; onToggle: Message }>, +): Html => { + const inputId = `${ID_PREFIX}-${options.name}` + return vstack(h, { gap: "1" }, [ + label(h, { className: "text-sm font-normal", attributes: [h.Id(Checkbox.labelId(inputId))] }, [ + ...Checkbox.view(h, { + model: { isChecked: options.isChecked }, + toParentMessage: () => options.onToggle, + id: `${inputId}-root`, + inputId, + isDisabled: model.isSaving, + }), + options.label, + ]), + typography(h, { size: "xs", tone: "muted", as: "p", className: "pl-6" }, [options.hint]), + ]) +} + +const optionsSection = (h: H, model: Model): Html => + vstack(h, { gap: "3" }, [ + checkboxOption(h, model, { + name: "exclude-baselines", + label: "Exclude from anomaly baselines", + hint: "Only applies when the window names a service or an environment.", + isChecked: model.excludeFromBaselines, + onToggle: Message.ToggledExcludeFromBaselines(), + }), + checkboxOption(h, model, { + name: "enabled", + label: "Enabled", + hint: "A disabled window silences nothing until you turn it back on.", + isChecked: model.enabled, + onToggle: Message.ToggledEnabled(), + }), + ]) + +/** The maintenance window dialog: name, what it covers, when it runs, and its options. */ +export const view = Submodel.defineView((model, inputs, h) => { + const isEditing = model.editing !== null + return formDialog(h, { + slotId: `${inputs.id}-dialog`, + model: model.dialog, + toParentMessage: (message) => Message.GotDialogMessage({ message }), + className: "sm:max-w-xl", + title: isEditing ? "Edit maintenance window" : "New maintenance window", + description: + "Each occurrence silences the rules it covers. Rules keep checking and incidents still open, but no one is paged.", + panelClassName: "space-y-5", + fields: [ + textField(h, model, { + name: "name", + label: "Name", + value: model.name, + placeholder: "Weekend database maintenance", + onInput: (value) => Message.ChangedName({ value }), + error: shown(model, nameError(model.name)), + }), + coversSection(h, model, inputs), + scheduleSection(h, model), + textField(h, model, { + name: "reason", + label: "Reason (optional)", + value: model.reason, + placeholder: "Vacuum and index rebuilds", + onInput: (value) => Message.ChangedReason({ value }), + error: shown(model, reasonError(model.reason)), + }), + optionsSection(h, model), + ...(model.saveError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [isEditing ? "Couldn't save window" : "Couldn't create window"]), + alertDescription(h, [model.saveError]), + ]), + ]), + ], + onSubmit: Message.ClickedSave(), + submitLabel: isEditing ? "Save window" : "Create window", + isPending: model.isSaving, + }) +}) diff --git a/apps/web/src/page/alerts/shared/maintenance-test-fixtures.ts b/apps/web/src/page/alerts/shared/maintenance-test-fixtures.ts new file mode 100644 index 0000000000..c2fca4af6b --- /dev/null +++ b/apps/web/src/page/alerts/shared/maintenance-test-fixtures.ts @@ -0,0 +1,142 @@ +import { AlertMaintenanceWindowId, AlertRuleId, AlertSilenceId } from "@maple/domain/http" +import type { V2AlertMaintenanceWindow, V2AlertSilence } from "@maple/domain/http/v2" +import { Schema } from "effect" +import { msIso } from "./time" +import { NOW, ruleId } from "./test-rows" + +// Maintenance windows and their silences for the scene tests and `/lab/maintenance`. +// `NOW` is Thursday 2026-10-01 12:00 UTC. + +const MIN = 60_000 + +export const windowUuid = (n: number): string => `0199a00f-0000-7000-8000-00000000000${n}` +export const windowId = (n: number) => Schema.decodeSync(AlertMaintenanceWindowId)(windowUuid(n)) + +const base = (n: number): V2AlertMaintenanceWindow => ({ + id: windowId(n), + object: "alert_maintenance_window", + name: `Window ${n}`, + reason: null, + matchers: { services: ["checkout"] }, + schedule: { + type: "weekly", + time_zone: "Europe/Berlin", + days: ["mon", "thu"], + start_time: "02:00", + duration_minutes: 120, + }, + exclude_from_baselines: true, + enabled: true, + next_occurrence: null, + created_by: "user_1", + created_at: msIso(NOW - 7 * 24 * 60 * MIN), + updated_at: msIso(NOW - 7 * 24 * 60 * MIN), +}) + +/** Weekly, Mon and Thu at 02:00 Berlin for 2h; the next one is Monday's. */ +export const weeklyWindow: V2AlertMaintenanceWindow = { + ...base(1), + name: "Nightly database backup", + reason: "Snapshot and vacuum", + matchers: { services: ["checkout", "payments"], environments: ["production"] }, + next_occurrence: { + // Monday 2026-10-05 02:00 in Berlin (CEST) is 00:00 UTC. + starts_at: msIso(NOW + 84 * 60 * MIN), + ends_at: msIso(NOW + 86 * 60 * MIN), + }, +} + +/** A one-off window running now, for 90 more minutes. */ +export const runningWindow: V2AlertMaintenanceWindow = { + ...base(2), + name: "Checkout v3 migration", + matchers: { rule_ids: [Schema.decodeSync(AlertRuleId)(ruleId(1))], environments: ["production"] }, + schedule: { type: "once", starts_at: msIso(NOW - 30 * MIN), ends_at: msIso(NOW + 90 * MIN) }, + exclude_from_baselines: true, + next_occurrence: { starts_at: msIso(NOW - 30 * MIN), ends_at: msIso(NOW + 90 * MIN) }, +} + +/** A one-off window that ran last week. */ +export const endedWindow: V2AlertMaintenanceWindow = { + ...base(3), + name: "Datacenter move", + matchers: { tags: ["infra"] }, + schedule: { + type: "once", + starts_at: msIso(NOW - 6 * 24 * 60 * MIN), + ends_at: msIso(NOW - 6 * 24 * 60 * MIN + 4 * 60 * MIN), + }, + exclude_from_baselines: false, + next_occurrence: null, +} + +/** A weekend window someone turned off. */ +export const disabledWindow: V2AlertMaintenanceWindow = { + ...base(4), + name: "Weekend reindex", + matchers: { tags: ["db"] }, + schedule: { + type: "weekly", + time_zone: "UTC", + days: ["sat", "sun"], + start_time: "23:30", + duration_minutes: 90, + }, + exclude_from_baselines: false, + enabled: false, +} + +export const populatedWindows: ReadonlyArray = [ + runningWindow, + weeklyWindow, + disabledWindow, + endedWindow, +] + +const silenceUuid = (n: number) => `0199a010-0000-7000-8000-00000000000${n}` + +/** A silence; `window` makes it one of that window's occurrences. */ +export const silenceOf = ( + n: number, + fields: Readonly<{ + reason: string + startMin: number + endMin: number + window?: V2AlertMaintenanceWindow + matchers?: V2AlertSilence["matchers"] + }>, +): V2AlertSilence => ({ + id: Schema.decodeSync(AlertSilenceId)(silenceUuid(n)), + object: "alert_silence", + reason: fields.reason, + matchers: fields.matchers ?? fields.window?.matchers ?? { services: ["checkout"] }, + status: fields.endMin <= 0 ? "ended" : fields.startMin > 0 ? "scheduled" : "active", + starts_at: msIso(NOW + fields.startMin * MIN), + ends_at: msIso(NOW + fields.endMin * MIN), + maintenance_window_id: fields.window?.id ?? null, + created_by: "user_1", + created_at: msIso(NOW), + updated_at: msIso(NOW), +}) + +/** A manual silence, the running window's occurrence, and this morning's weekly one. */ +export const silencesWithWindows: ReadonlyArray = [ + silenceOf(1, { + reason: "Deploying payments v2.3", + startMin: -10, + endMin: 50, + matchers: { services: ["payments"] }, + }), + silenceOf(2, { + reason: "Maintenance: Checkout v3 migration", + startMin: -30, + endMin: 90, + window: runningWindow, + }), + silenceOf(4, { + reason: "Maintenance: Nightly database backup (Snapshot and vacuum)", + startMin: -(12 * 60), + endMin: -(10 * 60), + window: weeklyWindow, + }), +] diff --git a/apps/web/src/page/alerts/shared/maintenance-windows.test.ts b/apps/web/src/page/alerts/shared/maintenance-windows.test.ts new file mode 100644 index 0000000000..4f47bc0442 --- /dev/null +++ b/apps/web/src/page/alerts/shared/maintenance-windows.test.ts @@ -0,0 +1,85 @@ +import { describe, it } from "@effect/vitest" +import { MapleApiV2, V2AlertMaintenanceWindow, V2AlertSilence } from "@maple/domain/http/v2" +import { Effect, Layer, Option, Ref, Schema, Array as Arr } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse, UrlParams } from "effect/http" +import { HttpApiClient } from "effect/http-api" +import { expect } from "vitest" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { listMaintenanceWindows, listWindowSilences, windowPublicId } from "./maintenance-windows" +import { endedWindow, silencesWithWindows, weeklyWindow } from "./maintenance-test-fixtures" + +type Seen = Readonly<{ cursor: string | null; window: string | null }> + +/** A client whose pages come from `pages` in order, recording each request's cursor and filter. */ +const pagedClient = (seen: Ref.Ref>, pages: ReadonlyArray>) => { + const http = HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Ref.modify(seen, (previous) => [ + previous.length, + [ + ...previous, + { + cursor: Option.getOrNull(UrlParams.getFirst(request.urlParams, "cursor")), + window: Option.getOrNull(UrlParams.getFirst(request.urlParams, "maintenance_window_id")), + }, + ], + ]).pipe( + Effect.map((index) => + HttpClientResponse.fromWeb( + request, + Response.json({ object: "list", ...pages[Math.min(index, pages.length - 1)] }), + ), + ), + ), + ) + return Layer.effect( + MapleApiV2Client, + HttpApiClient.make(MapleApiV2, { baseUrl: "https://api.test" }), + ).pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, http))) +} + +const encodeWindow = Schema.encodeSync(V2AlertMaintenanceWindow) +const encodeSilence = Schema.encodeSync(V2AlertSilence) + +describe("listMaintenanceWindows", () => { + it.effect("follows next_cursor until has_more is false", () => + Effect.gen(function* () { + const seen = yield* Ref.make>([]) + const client = pagedClient(seen, [ + { data: [encodeWindow(weeklyWindow)], has_more: true, next_cursor: "page-2" }, + { data: [encodeWindow(endedWindow)], has_more: false, next_cursor: null }, + ]) + const windows = yield* listMaintenanceWindows.pipe(Effect.provide(client)) + expect(Arr.map(windows, (window) => window.id)).toEqual([weeklyWindow.id, endedWindow.id]) + expect(Arr.map(yield* Ref.get(seen), (request) => request.cursor)).toEqual([null, "page-2"]) + }), + ) +}) + +describe("listWindowSilences", () => { + it.effect("asks the API for one window's silences, every page of them", () => + Effect.gen(function* () { + const seen = yield* Ref.make>([]) + const [first, second] = silencesWithWindows + const client = pagedClient(seen, [ + { + data: first === undefined ? [] : [encodeSilence(first)], + has_more: true, + next_cursor: "p2", + }, + { + data: second === undefined ? [] : [encodeSilence(second)], + has_more: false, + next_cursor: null, + }, + ]) + const silences = yield* listWindowSilences(weeklyWindow.id).pipe(Effect.provide(client)) + expect(silences).toHaveLength(2) + const requests = yield* Ref.get(seen) + expect(Arr.map(requests, (request) => request.cursor)).toEqual([null, "p2"]) + expect(Arr.map(requests, (request) => request.window)).toEqual([ + windowPublicId(weeklyWindow.id), + windowPublicId(weeklyWindow.id), + ]) + }), + ) +}) diff --git a/apps/web/src/page/alerts/shared/maintenance-windows.ts b/apps/web/src/page/alerts/shared/maintenance-windows.ts new file mode 100644 index 0000000000..494d5758c5 --- /dev/null +++ b/apps/web/src/page/alerts/shared/maintenance-windows.ts @@ -0,0 +1,200 @@ +import { AlertMaintenanceWindowId, type MaintenanceWeekday } from "@maple/domain/http" +import { + AlertMaintenanceWindowPublicId, + encodePublicId, + PublicIdPrefixes, + type V2AlertMaintenanceWindow, + V2AlertMaintenanceWindowCreateParams, + V2AlertMaintenanceWindowUpdateParams, + type V2MaintenanceSchedule, +} from "@maple/domain/http/v2" +import { Effect, Option, Ref, Schema, Array as Arr, String as Str } from "effect" +import { formatTimeInTimezone } from "@maple/web-data/lib/timezone-format" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { alertsHref } from "./links" +import { formatSilenceTime } from "./silences" +import { isoMs } from "./time" + +// Maintenance windows: the hub's reads and writes, and how a window's schedule reads in a row. + +export const WINDOW_LIST_LIMIT = 100 + +/** Pages read at most per list, far past what an org holds. */ +const MAX_LIST_PAGES = 20 + +type Page = Readonly<{ data: ReadonlyArray; has_more: boolean; next_cursor: string | null }> + +/** Every row of a cursor-paginated list, following `next_cursor` until `has_more` is false. */ +const readAllPages = (readPage: (cursor: string | null) => Effect.Effect, E, R>) => + Effect.gen(function* () { + const progress = yield* Ref.make<{ rows: ReadonlyArray; cursor: string | null; pages: number }>({ + rows: [], + cursor: null, + pages: 0, + }) + const step = Effect.gen(function* () { + const { rows, cursor, pages } = yield* Ref.get(progress) + const page = yield* readPage(cursor) + const next = page.has_more ? page.next_cursor : null + yield* Ref.set(progress, { rows: [...rows, ...page.data], cursor: next, pages: pages + 1 }) + return next !== null && pages + 1 < MAX_LIST_PAGES + }) + yield* step.pipe(Effect.repeat({ while: (hasMore) => hasMore })) + return (yield* Ref.get(progress)).rows + }) + +const cursorQuery = (cursor: string | null) => (cursor === null ? undefined : { cursor }) + +export const listMaintenanceWindows = Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* readAllPages((cursor) => + client.alertMaintenanceWindows.list({ query: { limit: WINDOW_LIST_LIMIT, ...cursorQuery(cursor) } }), + ) +}).pipe(Effect.mapError(toLoadFailure)) + +/** Every silence one window wrote, filtered by the API rather than in the first page. */ +export const listWindowSilences = (id: AlertMaintenanceWindowId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* readAllPages((cursor) => + client.alertSilences.list({ + query: { limit: WINDOW_LIST_LIMIT, maintenance_window_id: id, ...cursorQuery(cursor) }, + }), + ) + }).pipe(Effect.mapError(toLoadFailure)) + +/** A new window in full, or only the fields an edit changed. */ +export const SaveRequest = Schema.Union([ + Schema.TaggedStruct("Create", { payload: V2AlertMaintenanceWindowCreateParams }), + Schema.TaggedStruct("Update", { + id: AlertMaintenanceWindowId, + payload: V2AlertMaintenanceWindowUpdateParams, + }), +]) +export type SaveRequest = typeof SaveRequest.Type + +export const saveMaintenanceWindow = (request: SaveRequest) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return request._tag === "Create" + ? yield* client.alertMaintenanceWindows.create({ payload: request.payload }) + : yield* client.alertMaintenanceWindows.update({ + params: { id: request.id }, + payload: request.payload, + }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const updateMaintenanceWindow = ( + id: AlertMaintenanceWindowId, + payload: V2AlertMaintenanceWindowUpdateParams, +) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.alertMaintenanceWindows.update({ params: { id }, payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deleteMaintenanceWindow = (id: AlertMaintenanceWindowId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.alertMaintenanceWindows.delete({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** The `mw_…` id a link carries. */ +export const windowPublicId = (id: AlertMaintenanceWindowId): string => + encodePublicId(PublicIdPrefixes.alertMaintenanceWindow, id) + +/** A `window` search param read back as the window's id; none when it is not one. */ +export const windowIdOfParam = (raw: string | undefined): Option.Option => + raw === undefined ? Option.none() : Schema.decodeOption(AlertMaintenanceWindowPublicId)(raw) + +/** The Silences tab showing only this window's occurrences. */ +export const windowSilencesHref = (id: AlertMaintenanceWindowId): string => + alertsHref({ tab: "silences", window: windowPublicId(id) }) + +export const WEEKDAYS: ReadonlyArray<{ value: MaintenanceWeekday; label: string }> = [ + { value: "mon", label: "Mon" }, + { value: "tue", label: "Tue" }, + { value: "wed", label: "Wed" }, + { value: "thu", label: "Thu" }, + { value: "fri", label: "Fri" }, + { value: "sat", label: "Sat" }, + { value: "sun", label: "Sun" }, +] + +/** "Mon and Thu", "Mon, Wed and Fri", "every day", in week order. */ +export const formatDays = (days: ReadonlyArray): string => { + const labels = Arr.map( + Arr.filter(WEEKDAYS, (day) => days.includes(day.value)), + (day) => day.label, + ) + if (labels.length === WEEKDAYS.length) return "every day" + const last = labels[labels.length - 1] + return labels.length <= 1 || last === undefined + ? Arr.join(labels, "") + : `${Arr.join(labels.slice(0, -1), ", ")} and ${last}` +} + +/** "2h", "1h 30m", "45m", "7d". */ +export const formatDurationMinutes = (minutes: number): string => { + const days = Math.floor(minutes / 1440) + const hours = Math.floor((minutes % 1440) / 60) + const rest = minutes % 60 + const parts = [ + ...(days > 0 ? [`${days}d`] : []), + ...(hours > 0 ? [`${hours}h`] : []), + ...(rest > 0 ? [`${rest}m`] : []), + ] + return parts.length === 0 ? "0m" : Arr.join(parts, " ") +} + +/** A weekly `HH:MM` start drawn by the same formatter as every other time on the page. */ +export const formatWallClock = (startTime: string): string => { + const parts = Arr.map(Str.split(":")(startTime), Number) + const at = (index: number) => Option.getOrElse(Arr.get(parts, index), () => 0) + return formatTimeInTimezone(Date.UTC(2000, 0, 1, at(0), at(1)), { timeZone: "UTC", withSeconds: false }) +} + +/** "Weekly, Mon and Thu at 02:00 for 2h (Europe/Berlin)" or "Once, Nov 7, 22:00 to Nov 8, 01:00". */ +export const scheduleSummary = (schedule: V2MaintenanceSchedule, nowMs: number, timeZone: string): string => + schedule.type === "weekly" + ? `Weekly, ${formatDays(schedule.days)} at ${formatWallClock(schedule.start_time)} for ${formatDurationMinutes(schedule.duration_minutes)} (${schedule.time_zone})` + : `Once, ${formatSilenceTime(isoMs(schedule.starts_at), nowMs, timeZone)} to ${formatSilenceTime(isoMs(schedule.ends_at), nowMs, timeZone)}` + +/** The window's occurrence as the list reads it now. */ +export type OccurrenceState = + | Readonly<{ _tag: "Running"; endsAtMs: number }> + | Readonly<{ _tag: "Next"; startsAtMs: number; endsAtMs: number }> + | Readonly<{ _tag: "None" }> + +export const occurrenceStateAt = (window: V2AlertMaintenanceWindow, nowMs: number): OccurrenceState => { + const next = window.next_occurrence + if (next === null) return { _tag: "None" } + const startsAtMs = isoMs(next.starts_at) + const endsAtMs = isoMs(next.ends_at) + if (endsAtMs <= nowMs) return { _tag: "None" } + return startsAtMs <= nowMs ? { _tag: "Running", endsAtMs } : { _tag: "Next", startsAtMs, endsAtMs } +} + +/** "Running until 15:00", "Next Nov 8, 02:00", "No more occurrences", or "Disabled". */ +export const occurrenceText = (window: V2AlertMaintenanceWindow, nowMs: number, timeZone: string): string => { + if (!window.enabled) return "Disabled" + const state = occurrenceStateAt(window, nowMs) + switch (state._tag) { + case "Running": + return `Running until ${formatSilenceTime(state.endsAtMs, nowMs, timeZone)}` + case "Next": + return `Next ${formatSilenceTime(state.startsAtMs, nowMs, timeZone)}` + case "None": + return "No more occurrences" + } +} + +/** A one-off window whose end has passed: it can never run again. */ +export const isEndedOnce = (window: V2AlertMaintenanceWindow, nowMs: number): boolean => + window.schedule.type === "once" && isoMs(window.schedule.ends_at) <= nowMs + +/** Baseline exclusion only applies when the window names a service or an environment. */ +export const excludesBaselines = (window: V2AlertMaintenanceWindow): boolean => + window.exclude_from_baselines && + (window.matchers.services ?? []).length + (window.matchers.environments ?? []).length > 0 diff --git a/apps/web/src/page/alerts/shared/matcher-fields.ts b/apps/web/src/page/alerts/shared/matcher-fields.ts new file mode 100644 index 0000000000..44e0a356f2 --- /dev/null +++ b/apps/web/src/page/alerts/shared/matcher-fields.ts @@ -0,0 +1,142 @@ +import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" +import { Option, Schema, Array as Arr, String as Str } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { fieldDescription, fieldError, fieldLabel, type FieldIds, input } from "@maple/views/ui/field" +import type { FieldState } from "@maple/views/ui/input" +import { vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" + +// The typed matcher fields (services, environments, tags) the silence and maintenance window +// dialogs share, and the text field both dialogs draw them with. + +export const MatcherField = Schema.Literals(["services", "tags", "environments"]) +export type MatcherField = typeof MatcherField.Type + +/** The matchers as typed: comma-separated lists. */ +export const TypedMatchers = Schema.Struct({ + services: Schema.String, + tags: Schema.String, + environments: Schema.String, +}) +export type TypedMatchers = typeof TypedMatchers.Type + +export const emptyTypedMatchers: TypedMatchers = { services: "", tags: "", environments: "" } + +/** Saved matchers as the fields show them. */ +export const typedFromMatchers = (matchers: V2AlertSilenceMatchers): TypedMatchers => ({ + services: Arr.join(matchers.services ?? [], ", "), + tags: Arr.join(matchers.tags ?? [], ", "), + environments: Arr.join(matchers.environments ?? [], ", "), +}) + +const splitList = (value: string): ReadonlyArray => + Arr.dedupe(Arr.filter(Arr.map(Str.split(value, ","), Str.trim), Str.isNonEmpty)) + +/** The matchers the fields describe; an empty field is left out. */ +export const matchersFromTyped = (typed: TypedMatchers): V2AlertSilenceMatchers => { + const services = splitList(typed.services) + const tags = splitList(typed.tags) + const environments = splitList(typed.environments) + return { + ...(services.length === 0 ? undefined : { services }), + ...(tags.length === 0 ? undefined : { tags }), + ...(environments.length === 0 ? undefined : { environments }), + } +} + +export const hasMatchers = (matchers: V2AlertSilenceMatchers): boolean => + (matchers.rule_ids?.length ?? 0) + + (matchers.services?.length ?? 0) + + (matchers.tags?.length ?? 0) + + (matchers.environments?.length ?? 0) > + 0 + +const stateOf = (value: string, isInvalid = false): FieldState => ({ + isInvalid, + isDirty: false, + isFilled: value !== "", + isFocused: false, + isTouched: false, +}) + +export type DialogTextFieldOptions = Readonly<{ + idPrefix: string + name: string + label: string + value: string + placeholder: string + onInput: (value: string) => M + type?: string + description?: string + error?: Option.Option + isDisabled?: boolean +}> + +/** A labelled dialog text field with an optional description and error; `idPrefix` keeps ids unique. */ +export const dialogTextField = (h: HtmlBuilder, options: DialogTextFieldOptions): Html => { + const fieldIds: FieldIds = { + control: `${options.idPrefix}-${options.name}`, + label: `${options.idPrefix}-${options.name}-label`, + error: `${options.idPrefix}-${options.name}-error`, + ...(options.description === undefined + ? undefined + : { description: `${options.idPrefix}-${options.name}-description` }), + } + 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.isDisabled === true ? [h.Disabled(true)] : []), + ], + }), + ...(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 MATCHER_FIELDS: ReadonlyArray<{ field: MatcherField; label: string; placeholder: string }> = [ + { field: "services", label: "Services", placeholder: "checkout, payments" }, + { field: "environments", label: "Environments", placeholder: "production" }, + { field: "tags", label: "Tags", placeholder: "database" }, +] + +/** The services, environments and tags fields, then how they combine. */ +export const matcherFields = ( + h: HtmlBuilder, + options: Readonly<{ + idPrefix: string + typed: TypedMatchers + onInput: (field: MatcherField, value: string) => M + isDisabled?: boolean + }>, +): ReadonlyArray => [ + ...Arr.map(MATCHER_FIELDS, ({ field, label, placeholder }) => + dialogTextField(h, { + idPrefix: options.idPrefix, + name: field, + label, + value: options.typed[field], + placeholder, + onInput: (value) => options.onInput(field, value), + isDisabled: options.isDisabled, + }), + ), + 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.", + ]), +] diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index 3589f53f5c..c2c034cdc7 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -6,6 +6,7 @@ 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 { hasMatchers, MatcherField, matchersFromTyped, TypedMatchers } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" // "Silence notifications": the dialog the hub, a rule and an incident open, as one Submodel. @@ -13,17 +14,13 @@ import { createSilence } from "../shared/silences" 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 +export const ScopeField = MatcherField +export type ScopeField = MatcherField /** 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, - }), + Schema.TaggedStruct("Typed", TypedMatchers.fields), ]) export type Scope = typeof Scope.Type @@ -125,28 +122,9 @@ export const silenceEndMs = ( } } -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 +export const matchersOf = (scope: Scope): V2AlertSilenceMatchers => + scope._tag === "Fixed" ? scope.matchers : matchersFromTyped(scope) /** 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 => { diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index bb301807e5..6e1b31bfc3 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -2,23 +2,14 @@ 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 { isFormReady, Message, type Model, type SilenceDuration, silenceEndMs, untilError } from "./dialog" +import { dialogTextField, type DialogTextFieldOptions, matcherFields } from "../shared/matcher-fields" import { formatSilenceTime } from "../shared/silences" type H = HtmlBuilder @@ -42,66 +33,8 @@ const submitLabel = (model: Model): string => `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" }, -] +const textField = (h: H, options: Omit, "idPrefix">): Html => + dialogTextField(h, { ...options, idPrefix: "silence" }) /** What a scope fixed by a rule or an incident covers, in a sentence. */ const fixedScopeLine = (matchers: V2AlertSilenceMatchers): string => { @@ -119,20 +52,15 @@ const scopeSection = (h: H, model: Model): Html => 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.", - ]), - ]), + : panel( + h, + { padded: true, className: "gap-3" }, + matcherFields(h, { + idPrefix: "silence", + typed: model.scope, + onInput: (field, value) => Message.ChangedScopeField({ field, value }), + }), + ), ]) const durationSection = (h: H, model: Model): Html => diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 6b4e5500a8..7253354e1f 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -521,6 +521,8 @@ export const AlertsSearch = Schema.Struct({ signal: Schema.optional(Schema.String), /** The Anomalies view's incidents: open (the default), "resolved" or "all". */ anomalyStatus: Schema.optional(Schema.String), + /** The Silences tab narrowed to one maintenance window's occurrences (`mw_…`). */ + window: Schema.optional(Schema.String), }) // sessionTimeRangeSearchMiddleware() diff --git a/packages/backend/src/services/alerts/AlertMaintenanceWindowsService.test.ts b/packages/backend/src/services/alerts/AlertMaintenanceWindowsService.test.ts index 2d5a86ea4e..bfc5cf2e38 100644 --- a/packages/backend/src/services/alerts/AlertMaintenanceWindowsService.test.ts +++ b/packages/backend/src/services/alerts/AlertMaintenanceWindowsService.test.ts @@ -289,4 +289,22 @@ describe("AlertMaintenanceWindowsService", () => { assert.lengthOf((yield* service.list(orgId)).windows, 0) }).pipe(Effect.provide(layer())), ) + + it.effect("lists one window's silences, within the org only", () => + Effect.gen(function* () { + yield* TestClock.setTime(DEFAULT_CLOCK_EPOCH_MS) + const first = yield* createWindow() + const second = yield* createWindow({ matchers: { services: ["cart"] } }) + const silences = yield* AlertSilencesService + const ofFirst = yield* silences.list(orgId, { maintenanceWindowId: first.id }) + assert.lengthOf(ofFirst.silences, 2) + assert.isTrue(Arr.every(ofFirst.silences, (silence) => silence.maintenanceWindowId === first.id)) + const ofSecond = yield* silences.list(orgId, { + maintenanceWindowId: second.id, + status: "scheduled", + }) + assert.lengthOf(ofSecond.silences, 2) + assert.lengthOf((yield* silences.list(otherOrgId, { maintenanceWindowId: first.id })).silences, 0) + }).pipe(Effect.provide(layer())), + ) }) diff --git a/packages/backend/src/services/alerts/AlertSilencesService.ts b/packages/backend/src/services/alerts/AlertSilencesService.ts index 356607845e..4ff1ef9b69 100644 --- a/packages/backend/src/services/alerts/AlertSilencesService.ts +++ b/packages/backend/src/services/alerts/AlertSilencesService.ts @@ -6,6 +6,7 @@ import { AlertSilencesListResponse, AlertValidationError, MAX_ALERT_SILENCE_DURATION_MS, + type AlertMaintenanceWindowId, type AlertSilenceCreateRequest, type AlertSilenceDocument, type AlertSilenceId, @@ -36,6 +37,8 @@ const decodeSilenceIdSync = Schema.decodeUnknownSync(AlertSilenceIdSchema) export interface ListAlertSilencesOptions { readonly status?: AlertSilenceStatus + /** Only the occurrences one maintenance window wrote. */ + readonly maintenanceWindowId?: AlertMaintenanceWindowId readonly limit?: number readonly offset?: number } @@ -202,6 +205,9 @@ export class AlertSilencesService extends Context.Service`: an icon-only Button that always carries a name and, by default, a tooltip. -- `packages/views/src/ui/icon.ts` `icon`, `iconNode`, `iconColor`: A generated icon with its size, class, style and attributes. +- `packages/views/src/ui/icon.ts` `icon`, `iconNode`, `rowIcon`, `iconColor`: A generated icon with its size, class, style and attributes. - `packages/views/src/ui/identity-label.ts` `identityLabel`: `IdentityLabel`: the name-over-detail text beside an avatar in an account or org trigger, a grid of truncating lines that fills the rest of the row. - `packages/views/src/ui/inline-code.ts` `inlineCodeClassName`, `inlineCode`: `InlineCode`: inline monospace for identifiers and commands inside prose. - `packages/views/src/ui/inline-rename-input.ts` `inlineRenameInput`: `InlineRenameInput`: the text input that stands in for a title while it is renamed, an underline with no box. diff --git a/packages/views/src/ui/icon.ts b/packages/views/src/ui/icon.ts index 4e6ac684a1..bb133aa245 100644 --- a/packages/views/src/ui/icon.ts +++ b/packages/views/src/ui/icon.ts @@ -68,6 +68,10 @@ export const iconNode = ( ): Html => renderNode(h, tree, options, String(options.size ?? ICON_DEFAULT_SIZES[name] ?? 24), options.id ?? name) +/** `RowIcon`: a list row's leading icon, muted and nudged down onto its first line of text. */ +export const rowIcon = (h: HtmlBuilder, name: IconName): Html => + icon(h, name, { className: "mt-0.5 shrink-0 text-muted-foreground" }) + /** `style={{ color }}` when the call site has a colour. */ export const iconColor = (color: string | undefined): Pick, "style"> => color === undefined ? {} : { style: { color } }