From a9d7623ca59fc13854474b832cd1f503d569dc57 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 13:18:40 +0100 Subject: [PATCH 1/8] feat(web): maintenance windows in the alerts hub A Maintenance tab lists each window's schedule, scope and next run, with an enabled switch, edit and delete. The window dialog shares the silence dialog's matcher fields. The Silences tab filters to one window's occurrences and names the window that manages a silence. --- apps/web/src/lab/entries/maintenance.ts | 16 + apps/web/src/lab/maintenance-lab.ts | 163 ++++++ apps/web/src/lab/registry.ts | 8 + apps/web/src/page/alerts/hub/command.ts | 36 ++ apps/web/src/page/alerts/hub/derive.ts | 12 +- .../alerts/hub/hub-maintenance.scene.test.ts | 246 +++++++++ .../alerts/hub/hub-silences.scene.test.ts | 2 +- apps/web/src/page/alerts/hub/message.ts | 19 + apps/web/src/page/alerts/hub/model.ts | 11 + apps/web/src/page/alerts/hub/update.ts | 200 ++++++- apps/web/src/page/alerts/hub/view-chains.ts | 4 +- .../src/page/alerts/hub/view-maintenance.ts | 228 ++++++++ apps/web/src/page/alerts/hub/view-oncall.ts | 4 +- apps/web/src/page/alerts/hub/view-silences.ts | 77 ++- apps/web/src/page/alerts/hub/view.ts | 46 +- .../alerts/maintenance-dialog/dialog.test.ts | 190 +++++++ .../page/alerts/maintenance-dialog/dialog.ts | 503 ++++++++++++++++++ .../page/alerts/maintenance-dialog/view.ts | 312 +++++++++++ .../shared/maintenance-test-fixtures.ts | 142 +++++ .../page/alerts/shared/maintenance-windows.ts | 141 +++++ .../src/page/alerts/shared/matcher-fields.ts | 142 +++++ .../src/page/alerts/silence-dialog/dialog.ts | 34 +- .../src/page/alerts/silence-dialog/view.ts | 98 +--- apps/web/src/route/search.ts | 2 + packages/views/src/ui/INDEX.md | 2 +- packages/views/src/ui/icon.ts | 4 + 26 files changed, 2508 insertions(+), 134 deletions(-) create mode 100644 apps/web/src/lab/entries/maintenance.ts create mode 100644 apps/web/src/lab/maintenance-lab.ts create mode 100644 apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts create mode 100644 apps/web/src/page/alerts/hub/view-maintenance.ts create mode 100644 apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts create mode 100644 apps/web/src/page/alerts/maintenance-dialog/dialog.ts create mode 100644 apps/web/src/page/alerts/maintenance-dialog/view.ts create mode 100644 apps/web/src/page/alerts/shared/maintenance-test-fixtures.ts create mode 100644 apps/web/src/page/alerts/shared/maintenance-windows.ts create mode 100644 apps/web/src/page/alerts/shared/matcher-fields.ts 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..8612ab9656 --- /dev/null +++ b/apps/web/src/lab/maintenance-lab.ts @@ -0,0 +1,163 @@ +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: "UTC" } +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({ browserZone: "Europe/Berlin", 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({ browserZone: "Europe/Berlin", 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 3e6933419a..0fa7751037 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -77,6 +77,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..fe9c6e4189 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,11 @@ 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, + updateMaintenanceWindow, +} from "../shared/maintenance-windows" import { Message } from "./message" import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" @@ -261,3 +267,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 4f25a883d2..4f83978633 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -115,7 +115,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 => { @@ -128,6 +136,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..4843e9d475 --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts @@ -0,0 +1,246 @@ +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, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { + disabledWindow, + endedWindow, + populatedWindows, + runningWindow, + 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, 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"]) +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 02:00 for 2h (Europe/Berlin)")).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("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", () => { + const step = pageUpdate(onMaintenance([weeklyWindow]), Message.ClickedNewWindow()) + expect(step.model.windowDialog.dialog.isOpen).toBe(true) + expect(names(step.commands)).toContain("ReadMaintenanceWindowZones") + }) +}) + +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" })), + ) + const cleared = pageUpdate(model, Message.ClickedClearWindowFilter()) + expect(cleared.outMessage).toMatchObject({ _tag: "RequestedNavigation" }) + expect(cleared.outMessage).not.toMatchObject({ url: expect.stringContaining("window=") }) + }) + + 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 })), + expectView(text("Managed by Checkout v3 migration")).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 yet")).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 856e643737..06c51609f8 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 4bc9ba85eb..653d631638 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 { endsAtMs, startsAtMs } 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 } 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 { resolveStatus, resolveTab } from "./derive" @@ -75,6 +81,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 => ({ @@ -137,6 +145,29 @@ 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, so both lists are read again. */ +const refreshWindows = (model: Model, context: PageContext): Return => { + const windows = requestWindows(model, context, "Supersede") + const silences = requestSilences(windows.model, context, "Supersede") + return { model: silences.model, commands: [...windows.commands, ...silences.commands] } +} + const monitorsKey = (context: PageContext) => requestKey(context.orgId, "uptimeMonitors.list", { query: { limit: UPTIME_LIST_LIMIT } }) @@ -302,12 +333,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) @@ -315,6 +353,7 @@ export const init = (route: HubRoute, context: PageContext): Return => { model: members.model, commands: [ ReadHubClock(), + ...windows.commands, FetchHubSession({ orgId: context.orgId }), ...members.commands, ...schedules.commands, @@ -332,7 +371,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) @@ -340,6 +380,7 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext model: members.model, commands: [ ...routing.commands, + ...windows.commands, ...monitors.commands, ...silences.commands, ...slos.commands, @@ -716,9 +757,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:`. */ @@ -727,6 +876,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), () => []) @@ -820,6 +970,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.map( @@ -1145,6 +1296,49 @@ 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: () => + 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 1d161f42f6..50f401d8df 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" @@ -86,7 +86,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..4631cf2cb0 --- /dev/null +++ b/apps/web/src/page/alerts/hub/view-maintenance.ts @@ -0,0 +1,228 @@ +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 { 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, + 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" + 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" }, [ + window.name, + ]), + ...(isRunning ? [badge(h, { variant: "live" }, ["Running"])] : []), + ...(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: !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", 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" }, [ + h.ul( + [h.Class("divide-y")], + 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 a27509f16b..ac6af84a59 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" @@ -83,7 +83,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 8f22345a79..ff5f066aed 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,19 @@ 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"), + `Managed by ${options.windowName(silence.maintenance_window_id)}`, + ], + ), + ] : status !== "ended" && canManage(model) ? [ button( @@ -130,6 +146,7 @@ const section = ( timeZone: string ruleName: (id: string) => string rulesLoaded: boolean + windowName: (id: AlertMaintenanceWindowId) => string }>, ): ReadonlyArray => silences.length === 0 @@ -145,6 +162,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 yet"]), + 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, @@ -161,14 +206,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 784c835acf..7611225e43 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" @@ -61,6 +63,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" }, ] @@ -102,7 +105,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, @@ -232,6 +241,19 @@ const headerActions = (h: H, model: Model, tab: AlertsTab): Html => ), ] : []), + ...(canManage(model) && tab === "maintenance" && hasWindows(model) + ? [ + button( + h, + { + size: "sm", + variant: "outline", + attributes: [h.OnClick(Message.ClickedNewWindow())], + }, + [icon(h, "CalendarIcon"), "New window"], + ), + ] + : []), ...(canManage(model) && tab === "slos" && hasSlos(model) ? [ linkButton( @@ -401,15 +423,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, @@ -437,6 +462,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..038e392106 --- /dev/null +++ b/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts @@ -0,0 +1,190 @@ +import type { LoadFailure } from "@maple/views/data/load-failure" +import { Array as Arr, Option, Result } from "effect" +import { describe, expect, it } from "vitest" +import { 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 ahead of now", () => { + const at = (startsAt: string, endsAt: string) => + endsAtError({ startsAt, endsAt, timeZone: BERLIN, nowMs: NOW, editingId: null }) + 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-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({ browserZone: BERLIN, zones: [BERLIN, "UTC"] }), + Message.ChangedName({ value: " Nightly backup " }), + Message.ChangedMatcher({ field: "services", value: "checkout, payments" }), + Message.ToggledDay({ day: "thu" }), + Message.ToggledDay({ day: "mon" }), + ]) + const { commands } = saveArgs(model) + expect(commands).toEqual([ + expect.objectContaining({ + name: "SaveMaintenanceWindow", + args: { + openCount: 1, + editingId: null, + 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 picked 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: { + 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("edits a window in place, keeping its rules, zone and options", () => { + const editing = openEdit(init("test-window"), weeklyWindow, NOW, "UTC").model + // The browser's zone does not replace the window's own. + const model = run(editing, [Message.ReadZones({ browserZone: "UTC", zones: ["UTC"] })]) + expect(saveArgs(model).commands[0]).toMatchObject({ + args: { + editingId: weeklyWindow.id, + payload: { + name: weeklyWindow.name, + reason: weeklyWindow.reason, + matchers: weeklyWindow.matchers, + schedule: weeklyWindow.schedule, + exclude_from_baselines: true, + enabled: true, + }, + }, + }) + const withRule = openEdit(init("test-window"), runningWindow, NOW, "UTC").model + expect(saveArgs(withRule).commands[0]).toMatchObject({ + args: { payload: { matchers: runningWindow.matchers, schedule: runningWindow.schedule } }, + }) + }) + + 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..73f3b35dd6 --- /dev/null +++ b/apps/web/src/page/alerts/maintenance-dialog/dialog.ts @@ -0,0 +1,503 @@ +import { + AlertMaintenanceWindowId, + AlertMaintenanceWindowName, + AlertMaintenanceWindowReason, + AlertRuleId, + MAX_ALERT_SILENCE_DURATION_MS, + MaintenanceDurationMinutes, + MaintenanceStartTime, + MaintenanceTimeZone, + MaintenanceWeekday, +} from "@maple/domain/http" +import { + V2AlertMaintenanceWindow, + V2AlertMaintenanceWindowCreateParams, + 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 { browserZone, readSupportedTimeZones } from "../../../platform/time-zone" +import { + emptyTypedMatchers, + hasMatchers, + MatcherField, + matchersFromTyped, + TypedMatchers, + typedFromMatchers, +} from "../shared/matcher-fields" +import { 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, + editingId: Schema.NullOr(AlertMaintenanceWindowId), + 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"), + editingId: 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 window must end ahead of now; an edit may rename one that already ran. */ +export const endsAtError = ( + model: Pick, +): 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") + : model.editingId === null && 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)") + +/** 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)] + : [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, + })) + +/** What the form saves, or none while any field is invalid. */ +export const payloadOf = (model: Model): Option.Option => + formErrors(model).length > 0 + ? Option.none() + : 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, + })) + +// MESSAGES + +export const Message = defineMessageUnion({ + GotDialogMessage: { message: Dialog.Message }, + ReadZones: { browserZone: Schema.String, 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 + +/** The browser's zone (a new window's default) and every zone to pick from. */ +export const ReadWindowZones = Command.define("ReadMaintenanceWindowZones", { + messages: [Message.ReadZones], + execute: Effect.map(Effect.all([browserZone, readSupportedTimeZones]), ([zone, zones]) => + Message.ReadZones({ browserZone: zone, zones }), + ), +}) + +export const SaveWindow = Command.define("SaveMaintenanceWindow", { + args: { + openCount: Schema.Number, + editingId: Schema.NullOr(AlertMaintenanceWindowId), + payload: V2AlertMaintenanceWindowCreateParams, + }, + messages: [Message.CompletedSave], + execute: ({ openCount, editingId, payload }) => + saveMaintenanceWindow(editingId, payload).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, + { + editingId: 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, + { + editingId: window.id, + 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 + return { + model: { + ...model, + zonePicker: step.model, + timeZone: zone === null || model.isSaving ? model.timeZone : zone, + }, + commands, + } +} + +const save = (model: Model): Return => + model.isSaving + ? { model } + : Option.match(payloadOf(model), { + onNone: () => ({ model: { ...model, isShowingErrors: true } }), + onSome: (payload) => ({ + model: { ...model, isSaving: true, isShowingErrors: true, saveError: null }, + commands: [ + SaveWindow({ openCount: model.openCount, editingId: model.editingId, payload }), + ], + }), + }) + +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: ({ browserZone: zone, zones }) => { + // A new window starts on the browser's zone; an edit keeps its own. + const timeZone = model.editingId === null && !model.isSaving ? zone : model.timeZone + return { + model: { + ...model, + zones, + timeZone, + zonePicker: + timeZone === model.timeZone + ? model.zonePicker + : Combobox.init(model.zonePicker.id, "single", { selected: [timeZone] }), + }, + } + }, + 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.editingId === 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..d7ec9296cc --- /dev/null +++ b/apps/web/src/page/alerts/maintenance-dialog/view.ts @@ -0,0 +1,312 @@ +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, + 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) + : Option.filter(endsAtError(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.editingId !== 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: 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.ts b/apps/web/src/page/alerts/shared/maintenance-windows.ts new file mode 100644 index 0000000000..6131cded05 --- /dev/null +++ b/apps/web/src/page/alerts/shared/maintenance-windows.ts @@ -0,0 +1,141 @@ +import type { AlertMaintenanceWindowId, MaintenanceWeekday } from "@maple/domain/http" +import { + AlertMaintenanceWindowPublicId, + encodePublicId, + PublicIdPrefixes, + type V2AlertMaintenanceWindow, + type V2AlertMaintenanceWindowCreateParams, + type V2AlertMaintenanceWindowUpdateParams, + type V2MaintenanceSchedule, +} from "@maple/domain/http/v2" +import { Effect, Option, Schema, Array as Arr } from "effect" +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 + +export const listMaintenanceWindows = Effect.gen(function* () { + const client = yield* MapleApiV2Client + const page = yield* client.alertMaintenanceWindows.list({ query: { limit: WINDOW_LIST_LIMIT } }) + return page.data +}).pipe(Effect.mapError(toLoadFailure)) + +/** Creates a window, or replaces every field of the one being edited. */ +export const saveMaintenanceWindow = ( + editingId: AlertMaintenanceWindowId | null, + payload: V2AlertMaintenanceWindowCreateParams, +) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return editingId === null + ? yield* client.alertMaintenanceWindows.create({ payload }) + : yield* client.alertMaintenanceWindows.update({ params: { id: editingId }, 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, " ") +} + +/** "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 ${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" + } +} + +/** 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 9e748ea70e..2e54eb6abd 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/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 490ce2e509..f51cbfadd6 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -74,7 +74,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/hover-card.ts` `view` (stateful: Model, init, update): Base UI `PreviewCard` (`@maple/ui/hover-card`): opens after the pointer rests on the trigger (600 ms, or the trigger's `delay`), closes after it leaves trigger and card (300 ms)... - `packages/views/src/ui/http-span-label.ts` `httpSpanLabel`: Port of `HttpSpanLabel` (packages/ui/src/components/traces/http-span-label.tsx). - `packages/views/src/ui/icon-button.ts` `iconButton`: ``: 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 } } From e9c4cb7150420595975a73352c902a9c7daad90e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 13:28:23 +0100 Subject: [PATCH 2/8] feat(api): filter the v2 silences list by maintenance window GET /v2/alerts/silences takes maintenance_window_id and filters in SQL within the org, so a window's occurrences are found past the first page. --- apps/api/src/routes/v2/alert-silences.http.ts | 3 +++ apps/api/src/routes/v2/alerts.http.test.ts | 22 +++++++++++++++++++ .../AlertMaintenanceWindowsService.test.ts | 18 +++++++++++++++ .../services/alerts/AlertSilencesService.ts | 6 +++++ packages/domain/src/http/v2/alert-silences.ts | 7 +++++- 5 files changed, 55 insertions(+), 1 deletion(-) 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 a3fe57110b..43aafeb052 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -2186,6 +2186,28 @@ 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. + await harness.request("POST", "/v2/alerts/silences", key.secret, { + reason: "Deploy", + matchers: { services: ["cart"] }, + ends_at: new Date(Date.now() + 60 * 60_000).toISOString(), + }) + 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), + ) + 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/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 Date: Sat, 7 Nov 2026 13:35:53 +0100 Subject: [PATCH 3/8] fix(web): maintenance window edits, zones and fresh silences An edit sends only the fields it changed, so a one-off window that already ran, or one naming a deleted rule, can still be renamed. The dialog reads every time in the page's zone. A window change off the Silences tab drops the cached silences, and the window filter asks the API for that window's silences, following the cursor like the window list. New window waits for the clock, an ended one-off window shows "Ended" with its switch off, and weekly starts use the page's time format. --- apps/web/src/lab/maintenance-lab.ts | 10 +- apps/web/src/page/alerts/hub/command.ts | 8 +- .../alerts/hub/hub-maintenance.scene.test.ts | 109 +++++++++++- apps/web/src/page/alerts/hub/update.ts | 40 +++-- .../src/page/alerts/hub/view-maintenance.ts | 20 ++- apps/web/src/page/alerts/hub/view.ts | 1 + .../alerts/maintenance-dialog/dialog.test.ts | 151 ++++++++++++----- .../page/alerts/maintenance-dialog/dialog.ts | 159 ++++++++++++------ .../page/alerts/maintenance-dialog/view.ts | 10 +- .../alerts/shared/maintenance-windows.test.ts | 85 ++++++++++ .../page/alerts/shared/maintenance-windows.ts | 89 ++++++++-- 11 files changed, 535 insertions(+), 147 deletions(-) create mode 100644 apps/web/src/page/alerts/shared/maintenance-windows.test.ts diff --git a/apps/web/src/lab/maintenance-lab.ts b/apps/web/src/lab/maintenance-lab.ts index 8612ab9656..6c83ac933f 100644 --- a/apps/web/src/lab/maintenance-lab.ts +++ b/apps/web/src/lab/maintenance-lab.ts @@ -55,7 +55,11 @@ type Step = Readonly<{ commands?: ReadonlyArray> }> -const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +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"] @@ -85,7 +89,7 @@ const extraFor = (state: LabState): ReadonlyArray => state === "dialog-weekly" ? [ HubMessage.Message.ClickedNewWindow(), - dialog(WindowDialog.Message.ReadZones({ browserZone: "Europe/Berlin", zones: ZONES })), + dialog(WindowDialog.Message.ReadZones({ zones: ZONES })), dialog(WindowDialog.Message.ChangedName({ value: "Nightly database backup" })), dialog( WindowDialog.Message.ChangedMatcher({ field: "services", value: "checkout, payments" }), @@ -98,7 +102,7 @@ const extraFor = (state: LabState): ReadonlyArray => : state === "dialog-once-invalid" ? [ HubMessage.Message.ClickedNewWindow(), - dialog(WindowDialog.Message.ReadZones({ browserZone: "Europe/Berlin", zones: ZONES })), + 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" })), diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index fe9c6e4189..785a833772 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -35,6 +35,7 @@ import { endSilence, listSilences } from "../shared/silences" import { deleteMaintenanceWindow, listMaintenanceWindows, + listWindowSilences, updateMaintenanceWindow, } from "../shared/maintenance-windows" import { Message } from "./message" @@ -248,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 })), ), 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 index 4843e9d475..9c77b97778 100644 --- a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts @@ -3,14 +3,25 @@ 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, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { + click, + expect as expectView, + expectOutMessage, + given, + role, + scene, + text, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" -import type { PageContext } from "../../contract" +import { PageOutMessage, type PageContext } from "../../contract" +import { alertsHref } from "../shared/links" import { disabledWindow, endedWindow, populatedWindows, runningWindow, + silenceOf, silencesWithWindows, weeklyWindow, } from "../shared/maintenance-test-fixtures" @@ -18,7 +29,7 @@ import { windowPublicId, windowSilencesHref } from "../shared/maintenance-window import { NOW, ORG, ruleRow } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" -import { init, update, windowMenuId } from "./update" +import { init, routeChanged, update, windowMenuId } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) @@ -26,6 +37,13 @@ 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) @@ -79,7 +97,7 @@ describe("alerts hub, Maintenance tab", () => { { update: pageUpdate, view }, given(onMaintenance(populatedWindows)), expectView(text("Nightly database backup")).toExist(), - expectView(text("Weekly, Mon and Thu at 02:00 for 2h (Europe/Berlin)")).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(), @@ -93,6 +111,49 @@ describe("alerts hub, Maintenance tab", () => { ) }) + 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 }, @@ -182,10 +243,12 @@ describe("alerts hub, Maintenance tab", () => { expect(windowSilencesHref(weeklyWindow.id)).toContain(`window=${windowPublicId(weeklyWindow.id)}`) }) - test("New window opens the dialog", () => { + 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) }) }) @@ -203,17 +266,45 @@ describe("alerts hub, Silences tab with maintenance windows", () => { 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(), ) - const cleared = pageUpdate(model, Message.ClickedClearWindowFilter()) - expect(cleared.outMessage).toMatchObject({ _tag: "RequestedNavigation" }) - expect(cleared.outMessage).not.toMatchObject({ url: expect.stringContaining("window=") }) }) 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 })), + 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(), ) diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 653d631638..e0b6138eb2 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -36,7 +36,7 @@ import { SCHEDULE_IN_USE_CODE, SCHEDULE_LIST_LIMIT, } from "../shared/oncall-schedules" -import { WINDOW_LIST_LIMIT } from "../shared/maintenance-windows" +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 { @@ -125,17 +125,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 })], }), }) } @@ -161,10 +166,16 @@ const requestWindows = (model: Model, context: PageContext, mode: Keyed.ReadMode }) } -/** A window changed: its occurrences are silences too, so both lists are read again. */ +/** + * 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 = requestSilences(windows.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] } } @@ -1305,11 +1316,14 @@ export const update = (model: Model, message: Message, context: PageContext): Re return { model: step.model, commands: step.commands } }, ClickedNewWindow: () => - liftWindowDialog( - model, - context, - WindowDialog.openNew(model.windowDialog, model.nowMs ?? 0, context.timeZone), - ), + // 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), diff --git a/apps/web/src/page/alerts/hub/view-maintenance.ts b/apps/web/src/page/alerts/hub/view-maintenance.ts index 4631cf2cb0..876ccaa982 100644 --- a/apps/web/src/page/alerts/hub/view-maintenance.ts +++ b/apps/web/src/page/alerts/hub/view-maintenance.ts @@ -20,6 +20,7 @@ import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { excludesBaselines, + isEndedOnce, occurrenceStateAt, occurrenceText, scheduleSummary, @@ -90,6 +91,8 @@ export const windowRow = ( 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")], @@ -102,6 +105,7 @@ export const windowRow = ( window.name, ]), ...(isRunning ? [badge(h, { variant: "live" }, ["Running"])] : []), + ...(isEnded ? [badge(h, { variant: "muted" }, ["Ended"])] : []), ...(excludesBaselines(window) ? [badge(h, { variant: "meta" }, ["Excluded from baselines"])] : []), @@ -127,7 +131,8 @@ export const windowRow = ( id: `window-switch-${window.id}`, inputId: `window-switch-input-${window.id}`, isChecked: window.enabled, - isDisabled: !canManage(model) || model.togglingWindowIds.includes(window.id), + isDisabled: + isEnded || !canManage(model) || model.togglingWindowIds.includes(window.id), ariaLabel: window.enabled ? `Disable ${window.name}` : `Enable ${window.name}`, onToggle: Message.ClickedToggleWindow({ windowId: window.id }), }), @@ -153,10 +158,15 @@ const windowsEmpty = (h: H, model: Model): Html => ...(canManage(model) ? [ emptyActions(h, [ - button(h, { size: "sm", attributes: [h.OnClick(Message.ClickedNewWindow())] }, [ - icon(h, "PlusIcon"), - "Create window", - ]), + button( + h, + { + size: "sm", + isDisabled: model.nowMs === null, + attributes: [h.OnClick(Message.ClickedNewWindow())], + }, + [icon(h, "PlusIcon"), "Create window"], + ), ]), ] : []), diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 7611225e43..bd1256eb0f 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -248,6 +248,7 @@ const headerActions = (h: H, model: Model, tab: AlertsTab): Html => { size: "sm", variant: "outline", + isDisabled: model.nowMs === null, attributes: [h.OnClick(Message.ClickedNewWindow())], }, [icon(h, "CalendarIcon"), "New window"], diff --git a/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts b/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts index 038e392106..4480d73e61 100644 --- a/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts @@ -58,9 +58,9 @@ describe("weekly validation", () => { }) describe("once validation", () => { - it("needs the end after the start, within 30 days and ahead of now", () => { - const at = (startsAt: string, endsAt: string) => - endsAtError({ startsAt, endsAt, timeZone: BERLIN, nowMs: NOW, editingId: null }) + 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"), ) @@ -68,6 +68,7 @@ describe("once validation", () => { 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()) }) @@ -86,39 +87,40 @@ describe("once validation", () => { describe("payload", () => { it("creates a weekly window with days in week order and the defaults on", () => { const model = run(opened(), [ - Message.ReadZones({ browserZone: BERLIN, zones: [BERLIN, "UTC"] }), + 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" }), ]) - const { commands } = saveArgs(model) - expect(commands).toEqual([ + expect(saveArgs(model).commands).toEqual([ expect.objectContaining({ name: "SaveMaintenanceWindow", args: { openCount: 1, - editingId: null, - 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, + 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, }, - exclude_from_baselines: true, - enabled: true, }, }, }), ]) }) - it("creates a one-off window with its times read in the picked zone", () => { + 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" }), @@ -130,39 +132,104 @@ describe("payload", () => { ]) expect(saveArgs(model).commands[0]).toMatchObject({ args: { - payload: { - reason: "Rack swap", - schedule: { - type: "once", - starts_at: "2026-11-07T21:00:00.000Z", - ends_at: "2026-11-08T00:00:00.000Z", + 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, }, - exclude_from_baselines: false, }, }, }) }) - it("edits a window in place, keeping its rules, zone and options", () => { - const editing = openEdit(init("test-window"), weeklyWindow, NOW, "UTC").model - // The browser's zone does not replace the window's own. - const model = run(editing, [Message.ReadZones({ browserZone: "UTC", zones: ["UTC"] })]) + 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: { - editingId: weeklyWindow.id, - payload: { - name: weeklyWindow.name, - reason: weeklyWindow.reason, - matchers: weeklyWindow.matchers, - schedule: weeklyWindow.schedule, - exclude_from_baselines: true, - enabled: true, + request: { + payload: { + schedule: { + type: "once", + starts_at: "2026-11-06T14:00:00.000Z", + ends_at: "2026-11-06T16:00:00.000Z", + }, + }, }, }, }) - const withRule = openEdit(init("test-window"), runningWindow, NOW, "UTC").model - expect(saveArgs(withRule).commands[0]).toMatchObject({ - args: { payload: { matchers: runningWindow.matchers, schedule: runningWindow.schedule } }, + }) + + 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("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", + }, + }, + }, + }, }) }) diff --git a/apps/web/src/page/alerts/maintenance-dialog/dialog.ts b/apps/web/src/page/alerts/maintenance-dialog/dialog.ts index 73f3b35dd6..85007fa62f 100644 --- a/apps/web/src/page/alerts/maintenance-dialog/dialog.ts +++ b/apps/web/src/page/alerts/maintenance-dialog/dialog.ts @@ -1,5 +1,4 @@ import { - AlertMaintenanceWindowId, AlertMaintenanceWindowName, AlertMaintenanceWindowReason, AlertRuleId, @@ -11,7 +10,9 @@ import { } from "@maple/domain/http" import { V2AlertMaintenanceWindow, - V2AlertMaintenanceWindowCreateParams, + 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" @@ -22,7 +23,7 @@ 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 { browserZone, readSupportedTimeZones } from "../../../platform/time-zone" +import { readSupportedTimeZones } from "../../../platform/time-zone" import { emptyTypedMatchers, hasMatchers, @@ -31,7 +32,7 @@ import { TypedMatchers, typedFromMatchers, } from "../shared/matcher-fields" -import { saveMaintenanceWindow, WEEKDAYS } from "../shared/maintenance-windows" +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" @@ -43,7 +44,8 @@ export type ScheduleKind = typeof ScheduleKind.Type export const Model = Schema.Struct({ dialog: Dialog.Model, - editingId: Schema.NullOr(AlertMaintenanceWindowId), + /** 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, @@ -83,7 +85,7 @@ export const zonePickerIdOf = (id: string): string => `${id}-zone` export const init = (id: string): Model => ({ dialog: Dialog.init(id, "dialog"), - editingId: null, + editing: null, name: "", reason: "", typed: { services: "", tags: "", environments: "" }, @@ -147,9 +149,10 @@ export const startsAtError = (model: Pick): Opti ? Option.none() : Option.some("Pick a start date and time") -/** A new window must end ahead of now; an edit may rename one that already ran. */ +/** A new or rescheduled window must end ahead of now; an edit that keeps the schedule need not. */ export const endsAtError = ( - model: Pick, + model: Pick, + requiresFuture = true, ): Option.Option => Option.match(parseLocalDateTime(model.endsAt, model.timeZone), { onNone: () => Option.some("Pick an end date and time"), @@ -161,7 +164,7 @@ export const endsAtError = ( ? 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") - : model.editingId === null && endMs <= model.nowMs + : requiresFuture && endMs <= model.nowMs ? Option.some("Pick an end in the future") : Option.none(), }), @@ -184,13 +187,72 @@ export const durationError = (raw: string): Option.Option => ? 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, + ) + +const scheduleKey = (schedule: V2MaintenanceSchedule): string => + schedule.type === "once" + ? `once|${isoMs(schedule.starts_at)}|${isoMs(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)] + ? [startsAtError(model), endsAtError(model, isScheduleChanged(model))] : [daysError(model.days), startTimeError(model.startTime), durationError(model.durationMinutes)]), zoneError(model.timeZone), reasonError(model.reason), @@ -218,24 +280,32 @@ const scheduleOf = (model: Model): Option.Option => duration_minutes: duration, })) -/** What the form saves, or none while any field is invalid. */ -export const payloadOf = (model: Model): Option.Option => - formErrors(model).length > 0 - ? Option.none() - : 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, - })) +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: { browserZone: Schema.String, zones: Schema.Array(Schema.String) }, + ReadZones: { zones: Schema.Array(Schema.String) }, ChangedName: { value: Schema.String }, ChangedReason: { value: Schema.String }, ChangedMatcher: { field: MatcherField, value: Schema.String }, @@ -267,23 +337,20 @@ type Return = Update.ReturnWithOutMessage // COMMANDS -/** The browser's zone (a new window's default) and every zone to pick from. */ +/** Every zone to pick from. */ export const ReadWindowZones = Command.define("ReadMaintenanceWindowZones", { messages: [Message.ReadZones], - execute: Effect.map(Effect.all([browserZone, readSupportedTimeZones]), ([zone, zones]) => - Message.ReadZones({ browserZone: zone, zones }), - ), + execute: Effect.map(readSupportedTimeZones, (zones) => Message.ReadZones({ zones })), }) export const SaveWindow = Command.define("SaveMaintenanceWindow", { args: { openCount: Schema.Number, - editingId: Schema.NullOr(AlertMaintenanceWindowId), - payload: V2AlertMaintenanceWindowCreateParams, + request: SaveRequest, }, messages: [Message.CompletedSave], - execute: ({ openCount, editingId, payload }) => - saveMaintenanceWindow(editingId, payload).pipe( + execute: ({ openCount, request }) => + saveMaintenanceWindow(request).pipe( Effect.result, Effect.map((result) => Message.CompletedSave({ openCount, result })), ), @@ -337,7 +404,7 @@ export const openNew = (model: Model, nowMs: number, timeZone: string): Return = return open( model, { - editingId: null, + editing: null, name: "", reason: "", typed: emptyTypedMatchers, @@ -368,7 +435,7 @@ export const openEdit = ( return open( model, { - editingId: window.id, + editing: window, name: window.name, reason: window.reason ?? "", typed: typedFromMatchers(window.matchers), @@ -434,13 +501,11 @@ const zoneStep = (model: Model, message: Combobox.Message): Return => { const save = (model: Model): Return => model.isSaving ? { model } - : Option.match(payloadOf(model), { + : Option.match(requestOf(model), { onNone: () => ({ model: { ...model, isShowingErrors: true } }), - onSome: (payload) => ({ + onSome: (request) => ({ model: { ...model, isSaving: true, isShowingErrors: true, saveError: null }, - commands: [ - SaveWindow({ openCount: model.openCount, editingId: model.editingId, payload }), - ], + commands: [SaveWindow({ openCount: model.openCount, request })], }), }) @@ -452,21 +517,7 @@ export const update = (model: Model, message: Message): Return => GotDialogMessage: ({ message: child }) => // The dialog stays open while a save is in flight. model.isSaving ? { model } : liftDialog(model, Dialog.update(model.dialog, child)), - ReadZones: ({ browserZone: zone, zones }) => { - // A new window starts on the browser's zone; an edit keeps its own. - const timeZone = model.editingId === null && !model.isSaving ? zone : model.timeZone - return { - model: { - ...model, - zones, - timeZone, - zonePicker: - timeZone === model.timeZone - ? model.zonePicker - : Combobox.init(model.zonePicker.id, "single", { selected: [timeZone] }), - }, - } - }, + ReadZones: ({ zones }) => ({ model: { ...model, zones } }), ChangedName: ({ value }) => unlessSaving(model, () => ({ ...model, name: value })), ChangedReason: ({ value }) => unlessSaving(model, () => ({ ...model, reason: value })), ChangedMatcher: ({ field, value }) => @@ -494,7 +545,7 @@ export const update = (model: Model, message: Message): Return => : Result.match(result, { onSuccess: (window) => ({ ...liftDialog({ ...model, isSaving: false }, Dialog.close(model.dialog)), - outMessage: OutMessage.SavedWindow({ window, isNew: model.editingId === null }), + 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 index d7ec9296cc..8d17f40bb9 100644 --- a/apps/web/src/page/alerts/maintenance-dialog/view.ts +++ b/apps/web/src/page/alerts/maintenance-dialog/view.ts @@ -22,6 +22,7 @@ import { durationError, durationOf, endsAtError, + isScheduleChanged, matchersError, Message, type Model, @@ -177,8 +178,11 @@ const onceFields = (h: H, model: Model): ReadonlyArray => [ 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) - : Option.filter(endsAtError(model), () => model.startsAt !== "" && model.endsAt !== ""), + ? endsAtError(model, isScheduleChanged(model)) + : Option.filter( + endsAtError(model, isScheduleChanged(model)), + () => model.startsAt !== "" && model.endsAt !== "", + ), }), ]), ] @@ -265,7 +269,7 @@ const optionsSection = (h: H, model: Model): Html => /** 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.editingId !== null + const isEditing = model.editing !== null return formDialog(h, { slotId: `${inputs.id}-dialog`, model: model.dialog, 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 index 6131cded05..494d5758c5 100644 --- a/apps/web/src/page/alerts/shared/maintenance-windows.ts +++ b/apps/web/src/page/alerts/shared/maintenance-windows.ts @@ -1,14 +1,15 @@ -import type { AlertMaintenanceWindowId, MaintenanceWeekday } from "@maple/domain/http" +import { AlertMaintenanceWindowId, type MaintenanceWeekday } from "@maple/domain/http" import { AlertMaintenanceWindowPublicId, encodePublicId, PublicIdPrefixes, type V2AlertMaintenanceWindow, - type V2AlertMaintenanceWindowCreateParams, - type V2AlertMaintenanceWindowUpdateParams, + V2AlertMaintenanceWindowCreateParams, + V2AlertMaintenanceWindowUpdateParams, type V2MaintenanceSchedule, } from "@maple/domain/http/v2" -import { Effect, Option, Schema, Array as Arr } from "effect" +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" @@ -19,22 +20,69 @@ import { isoMs } from "./time" 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 - const page = yield* client.alertMaintenanceWindows.list({ query: { limit: WINDOW_LIST_LIMIT } }) - return page.data + return yield* readAllPages((cursor) => + client.alertMaintenanceWindows.list({ query: { limit: WINDOW_LIST_LIMIT, ...cursorQuery(cursor) } }), + ) }).pipe(Effect.mapError(toLoadFailure)) -/** Creates a window, or replaces every field of the one being edited. */ -export const saveMaintenanceWindow = ( - editingId: AlertMaintenanceWindowId | null, - payload: V2AlertMaintenanceWindowCreateParams, -) => +/** 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 editingId === null - ? yield* client.alertMaintenanceWindows.create({ payload }) - : yield* client.alertMaintenanceWindows.update({ params: { id: editingId }, payload }) + 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 = ( @@ -100,10 +148,17 @@ export const formatDurationMinutes = (minutes: number): string => { 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 ${schedule.start_time} for ${formatDurationMinutes(schedule.duration_minutes)} (${schedule.time_zone})` + ? `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. */ @@ -135,6 +190,10 @@ export const occurrenceText = (window: V2AlertMaintenanceWindow, nowMs: number, } } +/** 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 && From d509ad1496114cc47b78232bf3057a8661d8b523 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 13:49:14 +0100 Subject: [PATCH 4/8] test(api): the window filter test checks the manual silence was created and excluded --- apps/api/src/routes/v2/alerts.http.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/api/src/routes/v2/alerts.http.test.ts b/apps/api/src/routes/v2/alerts.http.test.ts index 43aafeb052..65381494c6 100644 --- a/apps/api/src/routes/v2/alerts.http.test.ts +++ b/apps/api/src/routes/v2/alerts.http.test.ts @@ -2187,11 +2187,12 @@ describe("v2 alerts over HTTP", () => { expect(occurrences[0].reason).toBe("Maintenance: Nightly vacuum") // A manual silence is left out when the list is filtered to the window. - await harness.request("POST", "/v2/alerts/silences", key.secret, { + 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}`, @@ -2201,6 +2202,7 @@ describe("v2 alerts over HTTP", () => { 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", From 60edee19fde7db250ecc0f7be1e74b49452eb636 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 14:18:37 +0100 Subject: [PATCH 5/8] fix(web): one-off window edits compare times to the minute --- .../alerts/maintenance-dialog/dialog.test.ts | 24 ++++++++++++++++++- .../page/alerts/maintenance-dialog/dialog.ts | 5 +++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts b/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts index 4480d73e61..bd614ce2b4 100644 --- a/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts @@ -1,7 +1,8 @@ import type { LoadFailure } from "@maple/views/data/load-failure" import { Array as Arr, Option, Result } from "effect" import { describe, expect, it } from "vitest" -import { runningWindow, weeklyWindow } from "../shared/maintenance-test-fixtures" +import type { V2AlertMaintenanceWindow } from "@maple/domain/http/v2" +import { endedWindow, runningWindow, weeklyWindow } from "../shared/maintenance-test-fixtures" import { daysError, durationError, @@ -190,6 +191,27 @@ describe("payload", () => { ]) }) + 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("sends only the reason when only the reason changed", () => { const editing = openEdit(init("test-window"), weeklyWindow, NOW, "UTC").model const model = run(editing, [ diff --git a/apps/web/src/page/alerts/maintenance-dialog/dialog.ts b/apps/web/src/page/alerts/maintenance-dialog/dialog.ts index 85007fa62f..d337575329 100644 --- a/apps/web/src/page/alerts/maintenance-dialog/dialog.ts +++ b/apps/web/src/page/alerts/maintenance-dialog/dialog.ts @@ -196,9 +196,12 @@ const weekOrder = (days: ReadonlyArray): ReadonlyArray 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|${isoMs(schedule.starts_at)}|${isoMs(schedule.ends_at)}` + ? `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 => From 3102348210c2a314fd2d3b2f90b36fcd0e96604d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 17:58:10 +0100 Subject: [PATCH 6/8] fix(alerts): say occurrences are not listed, not that none ran Ended silences drop out of the panel after a week, so a window that ran earlier still shows an empty list. --- apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts | 2 +- apps/web/src/page/alerts/hub/view-silences.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 9c77b97778..085e40b704 100644 --- a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts @@ -331,7 +331,7 @@ describe("alerts hub, Silences tab with maintenance windows", () => { ), ), expectView(text("Window: Weekend reindex")).toExist(), - expectView(text("No occurrences yet")).toExist(), + expectView(text("No occurrences listed")).toExist(), ) }) }) diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index 5ebe4a0999..3c8f82d0ac 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -185,7 +185,7 @@ const windowOccurrencesEmpty = (h: H): Html => [ emptyHeader(h, [ emptyMedia(h, { variant: "icon" }, [icon(h, "CalendarIcon", { size: 18 })]), - emptyTitle(h, ["No occurrences yet"]), + 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.", ]), From 7bcfd686ed10a9ca6d34078a0e38828b7433bf30 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 18:06:25 +0100 Subject: [PATCH 7/8] fix(alerts): keep one-off instants across a zone change, truncate long window names - A new zone re-reads a one-off window's inputs at the same instants, so a rename no longer moves the window. - The window name can shrink, so truncate applies and the badges stay visible. - The reason's length error waits for a save, like the other fields. - The windows list uses the kit's dividedList. --- .../src/page/alerts/hub/view-maintenance.ts | 14 +++++++---- .../alerts/maintenance-dialog/dialog.test.ts | 24 +++++++++++++++++++ .../page/alerts/maintenance-dialog/dialog.ts | 14 ++++++++++- .../page/alerts/maintenance-dialog/view.ts | 2 +- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/apps/web/src/page/alerts/hub/view-maintenance.ts b/apps/web/src/page/alerts/hub/view-maintenance.ts index 876ccaa982..5e7e775f9e 100644 --- a/apps/web/src/page/alerts/hub/view-maintenance.ts +++ b/apps/web/src/page/alerts/hub/view-maintenance.ts @@ -3,6 +3,7 @@ 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" @@ -101,9 +102,11 @@ export const windowRow = ( 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" }, [ - window.name, - ]), + 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) @@ -183,8 +186,9 @@ const windowsList = ( windows.length === 0 ? windowsEmpty(h, model) : borderedBox(h, { overflow: "hidden" }, [ - h.ul( - [h.Class("divide-y")], + dividedList( + h, + { as: "ul", bordered: false }, Arr.map(windows, (window) => windowRow(h, model, window, options)), ), ]) diff --git a/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts b/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts index bd614ce2b4..4a52eb5b9d 100644 --- a/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/maintenance-dialog/dialog.test.ts @@ -1,6 +1,7 @@ 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 { @@ -212,6 +213,29 @@ describe("payload", () => { ]) }) + 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, [ diff --git a/apps/web/src/page/alerts/maintenance-dialog/dialog.ts b/apps/web/src/page/alerts/maintenance-dialog/dialog.ts index d337575329..0afff28018 100644 --- a/apps/web/src/page/alerts/maintenance-dialog/dialog.ts +++ b/apps/web/src/page/alerts/maintenance-dialog/dialog.ts @@ -491,11 +491,23 @@ const zoneStep = (model: Model, message: Combobox.Message): Return => { 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 === null || model.isSaving ? model.timeZone : zone, + timeZone: zone, + startsAt: inZone(model.startsAt), + endsAt: inZone(model.endsAt), }, commands, } diff --git a/apps/web/src/page/alerts/maintenance-dialog/view.ts b/apps/web/src/page/alerts/maintenance-dialog/view.ts index 8d17f40bb9..2819aa4179 100644 --- a/apps/web/src/page/alerts/maintenance-dialog/view.ts +++ b/apps/web/src/page/alerts/maintenance-dialog/view.ts @@ -296,7 +296,7 @@ export const view = Submodel.defineView((model, inpu value: model.reason, placeholder: "Vacuum and index rebuilds", onInput: (value) => Message.ChangedReason({ value }), - error: reasonError(model.reason), + error: shown(model, reasonError(model.reason)), }), optionsSection(h, model), ...(model.saveError === null From ea1b118f7cd529a65ab8249682c85fbc09fb8d1c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 18:16:55 +0100 Subject: [PATCH 8/8] fix(alerts): truncate the window name in a silence's Managed by badge --- apps/web/src/page/alerts/hub/view-silences.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index 3c8f82d0ac..5e0a0e2e0d 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -111,7 +111,9 @@ const silenceRow = ( }, [ icon(h, "CalendarIcon"), - `Managed by ${options.windowName(silence.maintenance_window_id)}`, + typography(h, { as: "span", truncate: true, className: "min-w-0 max-w-40" }, [ + `Managed by ${options.windowName(silence.maintenance_window_id)}`, + ]), ], ), ]