diff --git a/apps/web/e2e/tests/alert-rules.e2e.ts b/apps/web/e2e/tests/alert-rules.e2e.ts index feeef87386..4c8a829eba 100644 --- a/apps/web/e2e/tests/alert-rules.e2e.ts +++ b/apps/web/e2e/tests/alert-rules.e2e.ts @@ -87,7 +87,7 @@ test.describe("alert rules", () => { await screen.getByLabel("Rule name").fill(`e2e tiers renamed ${tag}`) await screen.getByRole("button", "Save changes").tap() - await expect(browser).toHaveURL(/\/alerts$/) + await expect(browser).toHaveURL(/\/alerts\/rules$/) const renamed = await read() expect(renamed.body.name).toBe(`e2e tiers renamed ${tag}`) expect(renamed.body.thresholds).toEqual(tiers) @@ -96,7 +96,7 @@ test.describe("alert rules", () => { await app.open(editUrl) await screen.getByLabel("Critical threshold").fill("4") await screen.getByRole("button", "Save changes").tap() - await expect(browser).toHaveURL(/\/alerts$/) + await expect(browser).toHaveURL(/\/alerts\/rules$/) const retuned = await read() expect(Arr.map(retuned.body.thresholds, (tier) => tier.value)).toEqual([0.007, 0.04]) } finally { diff --git a/apps/web/src/components/settings/audit-log-resource.ts b/apps/web/src/components/settings/audit-log-resource.ts index 4432954756..72c9590f98 100644 --- a/apps/web/src/components/settings/audit-log-resource.ts +++ b/apps/web/src/components/settings/audit-log-resource.ts @@ -14,7 +14,7 @@ export type AuditResourceLink = | { readonly to: "/errors/issues/$issueId"; readonly params: { readonly issueId: string } } | { readonly to: "/investigations/$id"; readonly params: { readonly id: string } } | { readonly to: "/anomalies/$incidentId"; readonly params: { readonly incidentId: string } } - | { readonly to: "/alerts"; readonly search: { readonly tab: "routing" } } + | { readonly to: "/alerts/routing" } | { readonly to: "/integrations"; readonly search: { readonly integration?: string } } | { readonly to: "/settings"; readonly search: { readonly tab: string } } @@ -79,7 +79,7 @@ function resourceLink(entry: AuditEntryResource): AuditResourceLink | null { return incidentId === null ? null : { to: "/anomalies/$incidentId", params: { incidentId } } } case "alert_destination": - return { to: "/alerts", search: { tab: "routing" } } + return { to: "/alerts/routing" } case "scrape_target": return { to: "/integrations", search: { integration: "prometheus" } } case "gcp_connector": diff --git a/apps/web/src/lab/alerts-hub-lab.ts b/apps/web/src/lab/alerts-hub-lab.ts index 8a4e55067a..a1a60d4719 100644 --- a/apps/web/src/lab/alerts-hub-lab.ts +++ b/apps/web/src/lab/alerts-hub-lab.ts @@ -2,22 +2,28 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" -import { Effect, Option, Result, Schema, Array as Arr } from "effect" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" +import { zonedDateParts } from "@maple/query-engine/datetime" +import * as Combobox from "@maple/views/ui/combobox" 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 { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view" import * as SilenceDialog from "../page/alerts/silence-dialog/dialog" -import { destinationRow, NOW, ORG, ruleRow, stateRow } from "../page/alerts/shared/test-rows" +import { alertsHref } from "../page/alerts/shared/links" +import { destinationRow, NOW, ORG, ruleRow, stateRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" +import { readEffectiveTimeZone } from "../platform/time-zone" -// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences tabs and the silence dialog over +// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences sections and the silence side panel over // fixtures, including a rule skipped for too few samples and a cancelled silence. `?state=` picks -// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom. +// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom, +// silence-dialog-filled. The shell stands on the section's own path, `?sheet=` is played here +// rather than pushed, and times read on the viewer's own clock, as in the app. export const LabState = Schema.Literals([ "inbox", @@ -27,20 +33,22 @@ export const LabState = Schema.Literals([ "silences", "silence-dialog", "silence-dialog-custom", + "silence-dialog-filled", ]) export type LabState = typeof LabState.Type -export const Flags = Schema.Struct({ state: LabState }) +export const Flags = Schema.Struct({ state: LabState, timeZone: Schema.String }) export type Flags = typeof Flags.Type -export const flags: Effect.Effect = Effect.sync(() => ({ +export const flags: Effect.Effect = Effect.map(readEffectiveTimeZone, (timeZone) => ({ state: Option.getOrElse( Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), (): LabState => "inbox", ), + timeZone, })) -export const Model = Schema.Struct({ state: LabState, hub: HubModel.Model }) +export const Model = Schema.Struct({ state: LabState, timeZone: Schema.String, hub: HubModel.Model }) export type Model = typeof Model.Type export const Message = defineMessageUnion({ GotHubMessage: { message: HubMessage.Message } }) @@ -48,16 +56,19 @@ export type Message = typeof Message.Type type Return = Update.Return -const CONTEXT: PageContext = { - orgId: Schema.decodeSync(OrgId)(ORG), - pinnedNamespace: null, - timeZone: "UTC", -} +const LAB_ORG = Schema.decodeSync(OrgId)(ORG) +const contextOf = (timeZone: string): PageContext => ({ orgId: LAB_ORG, pinnedNamespace: null, timeZone }) const GRANTS = Policy.grantsOf(["org:engineer"]) -/** The hub's reads and writes are dropped: the fixture is all the data there is. */ +/** The hub's reads, writes and URL changes are dropped: the fixture is all the data there is. */ const keepLocal = (commands: ReadonlyArray> | undefined) => - Arr.filter(commands ?? [], (command) => !/^(Fetch|Toggle|Delete|Save|Read|End)/.test(command.name)) + Arr.filter( + commands ?? [], + (command) => + !/^(Fetch|Toggle|Delete|Save|Read|End|PushAlertsHubSheet|DismissAlertsHubSheet)/.test( + command.name, + ), + ) const MINUTE = 60_000 const iso = (ms: number) => new Date(ms).toISOString() @@ -123,7 +134,10 @@ const SILENCES = [ silence("11111111-1111-4111-8111-111111111111", "Deploying checkout v2.3", -20, 40, { services: ["checkout"], }), - silence("22222222-2222-4222-8222-222222222222", "Database migration", 180, 240, { tags: ["db"] }), + // Tomorrow: its row names the day. + silence("22222222-2222-4222-8222-222222222222", "Database migration", 26 * 60, 27 * 60, { + tags: ["db"], + }), // Cancelled before it started: the API collapses its window to one instant. silence("33333333-3333-4333-8333-333333333333", "Load test on staging", -45, -45, { environments: ["staging"], @@ -140,10 +154,24 @@ const tabOf = (state: LabState) => ? "inbox" : "silences" -const step = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT) +const routeTo = (model: HubModel.Model, sheet: string | undefined, context: PageContext) => + HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), context) + +/** The hub's update, with the `?sheet=` push and dismissal it asks for played back as route changes. */ +const step = (model: HubModel.Model, message: HubMessage.Message, context: PageContext) => { + const next = HubUpdate.update(model, message, context) + const asked = Arr.findFirst(next.commands ?? [], (command) => Str.endsWith("HubSheet")(command.name)) + if (asked._tag === "None") return next + const routed = routeTo( + next.model, + asked.value.name === "PushAlertsHubSheet" ? HubUpdate.SILENCE_SHEET : undefined, + context, + ) + return { ...routed, commands: [...(next.commands ?? []), ...(routed.commands ?? [])] } +} -const hubFor = (state: LabState): HubModel.Model => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: tabOf(state) } }, CONTEXT).model +const hubFor = (state: LabState, context: PageContext): HubModel.Model => { + const entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), context).model const base = [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), @@ -163,49 +191,85 @@ const hubFor = (state: LabState): HubModel.Model => { result: Result.succeed(SILENCES), }), ] - return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message).model) + return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message, context).model) +} + +const pad = (value: number): string => Str.padStart(2, "0")(String(value)) + +/** `ms` as a `datetime-local` value on the viewer's clock. */ +const localInput = (ms: number, timeZone: string): string => { + const parts = zonedDateParts(ms, timeZone) + return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}` } +const dialog = (message: SilenceDialog.Message) => HubMessage.Message.GotSilenceDialogMessage({ message }) +const chip = (field: SilenceDialog.ScopeField, value: string) => + dialog( + SilenceDialog.Message.GotScopeComboMessage({ + field, + message: Combobox.Message.ClickedItem({ value }), + }), + ) + +/** What each silence-panel state does once the panel is open. */ +const panelSteps = (state: LabState, timeZone: string): ReadonlyArray => + state === "silence-dialog-custom" + ? [dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" }))] + : state === "silence-dialog-filled" + ? [ + chip("services", "checkout"), + chip("services", "payment-svc"), + chip("environments", "production"), + dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" })), + dialog( + SilenceDialog.Message.ChangedUntil({ + value: localInput(NOW + 26 * 60 * MINUTE, timeZone), + }), + ), + dialog(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ] + : [] + const toLab = ( next: Readonly<{ commands?: ReadonlyArray> }>, ) => Command.mapMessages(keepLocal(next.commands), (message) => Message.GotHubMessage({ message })) export const init = (flags: Flags): Return => { - const hub = hubFor(flags.state) - if (flags.state !== "silence-dialog" && flags.state !== "silence-dialog-custom") - return { model: { state: flags.state, hub } } + const context = contextOf(flags.timeZone) + const hub = hubFor(flags.state, context) + const lab = { state: flags.state, timeZone: flags.timeZone } + if (!Str.startsWith("silence-dialog")(flags.state)) return { model: { ...lab, hub } } // The dialog opens through its own commands, so they run here rather than in the fold. - const opened = step(hub, HubMessage.Message.ClickedNewSilence()) - const picked = - flags.state === "silence-dialog-custom" - ? step( - opened.model, - HubMessage.Message.GotSilenceDialogMessage({ - message: SilenceDialog.Message.ChangedDuration({ duration: "custom" }), - }), - ) - : { model: opened.model, commands: [] } - return { - model: { state: flags.state, hub: picked.model }, - commands: [...toLab(opened), ...toLab(picked)], - } + const opened = step(hub, HubMessage.Message.ClickedNewSilence(), context) + const filled = Arr.reduce( + panelSteps(flags.state, flags.timeZone), + { model: opened.model, commands: toLab(opened) }, + (current, message) => { + const next = step(current.model, message, context) + return { model: next.model, commands: [...current.commands, ...toLab(next)] } + }, + ) + return { model: { ...lab, hub: filled.model }, commands: filled.commands } } export const update = (model: Model, message: Message): Return => Message.match(message, { GotHubMessage: ({ message: child }) => { - const next = step(model.hub, child) + const next = step(model.hub, child, contextOf(model.timeZone)) return { model: { ...model, hub: next.model }, commands: toLab(next) } }, }) export const breadcrumbs = (model: Model) => hubBreadcrumbs(model.hub) +/** The section's own path, so the sidebar opens Alerts on it. */ +export const currentPath = (model: Model): string => alertsHref({}, model.hub.tab) + export const view = (model: Model, h: HtmlBuilder): Html => h.submodel({ slotId: "lab-alerts-hub", model: model.hub, view: hubView, - viewInputs: { context: CONTEXT }, + viewInputs: { context: contextOf(model.timeZone) }, toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), }) diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index 6a9e917e01..805810cc42 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -46,6 +46,8 @@ import { ruleId, ruleRow, stateRow, + hubAt, + type HubAt, } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" @@ -156,7 +158,7 @@ const RESOLVED = [ ] const hubFor = (state: LabState) => { - const search: HubModel.Model["search"] = + const search: HubAt = state === "hub-rules" ? { tab: "rules" } : state === "hub-anomalies-resolved" @@ -167,7 +169,7 @@ const hubFor = (state: LabState) => { ? [incidentRow({ rule_name: "Payment p95 latency" })] : [incidentRow({ rule_name: "Payment p95 latency" }), ...OPEN, ...RESOLVED] return fold( - HubUpdate.init({ _tag: "Alerts", search }, CONTEXT).model, + HubUpdate.init(hubAt(search), CONTEXT).model, [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), diff --git a/apps/web/src/lab/entries/alerts-hub.ts b/apps/web/src/lab/entries/alerts-hub.ts index de2a9f57ed..3a0c51ebf6 100644 --- a/apps/web/src/lab/entries/alerts-hub.ts +++ b/apps/web/src/lab/entries/alerts-hub.ts @@ -1,7 +1,8 @@ import { defineShellLab } from "../shell-lab" -import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" +import { breadcrumbs, currentPath, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" -// `/lab/alerts-hub`: the hub tabs and silence dialog over fixtures (`?state=`). +// `/lab/alerts-hub`: the hub sections and the silence side panel over fixtures (`?state=`), with +// two unacknowledged incidents counted on the sidebar's Inbox. export const lab = defineShellLab("Alerts hub", { Model, @@ -11,6 +12,7 @@ export const lab = defineShellLab("Alerts hub", { init, update, view, - currentPath: "/lab/alerts-hub", + currentPath, breadcrumbs, + seedShell: (shell) => ({ ...shell, alertsInboxCount: 2 }), }) diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts index 08d3416e53..f710bea7d1 100644 --- a/apps/web/src/lab/escalation-lab.ts +++ b/apps/web/src/lab/escalation-lab.ts @@ -28,7 +28,7 @@ import { populatedChains, runOf, } from "../page/alerts/shared/escalation-test-fixtures" -import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { keepLocal } from "./local-commands" @@ -112,7 +112,7 @@ const SAVE_FAILURE: LoadFailure = { } const hubFor = (state: LabState): Step => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "routing" } }, CONTEXT).model + const entered = HubUpdate.init(hubAt({ tab: "routing" }), CONTEXT).model const chains = state === "chains-empty" ? [] : populatedChains([ruleId(1), ruleId(2), ruleId(3)]) const base = [ HubMessage.Message.ReadClock({ nowMs: NOW }), diff --git a/apps/web/src/lab/maintenance-lab.ts b/apps/web/src/lab/maintenance-lab.ts index b8a04cb397..72cf955c67 100644 --- a/apps/web/src/lab/maintenance-lab.ts +++ b/apps/web/src/lab/maintenance-lab.ts @@ -15,7 +15,7 @@ import { 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 { NOW, ORG, ruleRow, hubAt, type HubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { keepLocal } from "./local-commands" @@ -78,7 +78,7 @@ const fold = (start: HubModel.Model, messages: ReadonlyArray const dialog = (message: WindowDialog.Message) => HubMessage.Message.GotWindowDialogMessage({ message }) -const searchFor = (state: LabState): HubModel.Model["search"] => +const searchFor = (state: LabState): HubAt => state === "silences" ? { tab: "silences" } : state === "silences-filtered" @@ -112,7 +112,7 @@ const extraFor = (state: LabState): ReadonlyArray => : [] const hubFor = (state: LabState): Step => { - const entered = HubUpdate.init({ _tag: "Alerts", search: searchFor(state) }, CONTEXT).model + const entered = HubUpdate.init(hubAt(searchFor(state)), CONTEXT).model const windows = state === "empty" ? [] : populatedWindows return fold(entered, [ HubMessage.Message.ReadClock({ nowMs: NOW }), diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index e94302533f..5f7dd1883f 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -40,7 +40,7 @@ import { populatedSchedules, schedulePublicId, } from "../page/alerts/shared/oncall-test-fixtures" -import { incidentRow, INCIDENT_ID, ORG, ruleRow } from "../page/alerts/shared/test-rows" +import { incidentRow, INCIDENT_ID, ORG, ruleRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { isoMs } from "../page/alerts/shared/time" @@ -128,8 +128,8 @@ const hubStep = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT) const hubFor = (state: LabState): Step => { - const tab = state === "strip" ? "inbox" : state === "chain-dialog" ? "routing" : "oncall" - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab } }, CONTEXT).model + const tab = state === "strip" ? "inbox" : state === "chain-dialog" ? "routing" : "on-call" + const entered = HubUpdate.init(hubAt({ tab }), CONTEXT).model const schedules = state === "schedules-empty" ? [] : populatedSchedules(ONCALL_NOW) const chains = [mixedTargetChain(chainDestinations[0]?.id ?? "")] return fold( diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 1c123999df..527b3f6027 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -65,7 +65,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/alerts-hub", title: "Alerts hub", description: - "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence dialog (`?state=`).", + "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence side panel empty, picking a time and filled in (`?state=`).", kind: "lab", session: "none", }, diff --git a/apps/web/src/lab/shell-lab.ts b/apps/web/src/lab/shell-lab.ts index e63276dbb6..921480308d 100644 --- a/apps/web/src/lab/shell-lab.ts +++ b/apps/web/src/lab/shell-lab.ts @@ -31,13 +31,16 @@ export type ShellLabPage Update.Return view: (model: PageModel, h: HtmlBuilder) => Html subscriptions?: Subscription.Subscriptions - currentPath: string + /** Fixed, or read from the page (a lab standing on the route it renders). */ + currentPath: string | ((model: PageModel) => string) /** Fixed, or read from the page (a lab switching between pages). */ breadcrumbs: | ReadonlyArray | ((model: PageModel) => ReadonlyArray) /** Rendered in `#app` before the shell: lab toolbars that sit beside the shell. */ before?: (model: PageModel, h: HtmlBuilder) => Html + /** Shell state the root's reads would bring (the Alerts › Inbox count), as a fixture. */ + seedShell?: (shell: Shell.Model) => Shell.Model }> export const defineShellLab = ( @@ -88,6 +91,8 @@ export const defineShellLab = (page.view) const before = page.before const beforeView = before === undefined ? null : Submodel.defineView(before) + const currentPathOf = (model: PageModel) => + typeof page.currentPath === "function" ? page.currentPath(model) : page.currentPath const breadcrumbsOf = (model: PageModel) => typeof page.breadcrumbs === "function" ? page.breadcrumbs(model) : page.breadcrumbs const own = Subscription.lift(page.subscriptions ?? {})({ @@ -104,7 +109,7 @@ export const defineShellLab = h.submodel({ diff --git a/apps/web/src/lab/slos-lab.ts b/apps/web/src/lab/slos-lab.ts index 58b043672d..c50337a9ba 100644 --- a/apps/web/src/lab/slos-lab.ts +++ b/apps/web/src/lab/slos-lab.ts @@ -20,7 +20,7 @@ import { sloUuid, type SloScenario, } from "../page/alerts/shared/slo-test-fixtures" -import { destinationRow, incidentRow, NOW, ORG } from "../page/alerts/shared/test-rows" +import { destinationRow, incidentRow, NOW, ORG, hubAt } from "../page/alerts/shared/test-rows" import * as SloMessage from "../page/alerts/slo/message" import * as SloModel from "../page/alerts/slo/model" import * as SloUpdate from "../page/alerts/slo/update" @@ -106,7 +106,7 @@ const INCIDENT = incidentRow({ }) const hubFor = (state: LabState) => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "slos" } }, CONTEXT).model + const entered = HubUpdate.init(hubAt({ tab: "slos" }), CONTEXT).model const rows = state === "tab-empty" ? [] diff --git a/apps/web/src/page/alerts/editor/actions.ts b/apps/web/src/page/alerts/editor/actions.ts index 0e6108183b..d2bdb298ff 100644 --- a/apps/web/src/page/alerts/editor/actions.ts +++ b/apps/web/src/page/alerts/editor/actions.ts @@ -79,7 +79,7 @@ export const completeSave = (model: Model, message: Extract { given(ready()), themeResolved(), expectView(role("combobox", { name: "Escalation chain" })).toExist(), - expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts?tab=routing"), + expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts/routing"), ) }) diff --git a/apps/web/src/page/alerts/editor/editor.scene.test.ts b/apps/web/src/page/alerts/editor/editor.scene.test.ts index 5a28d691c1..f153e5b516 100644 --- a/apps/web/src/page/alerts/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor.scene.test.ts @@ -36,7 +36,7 @@ import { init } from "./init" import { Message } from "./message" import type { Model } from "./model" import { update } from "./update" -import { view as pageView } from "./view" +import { breadcrumbsOf, view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -411,13 +411,18 @@ describe("alert rule editor", () => { expect(late.preview).toEqual(edited.preview) }) - test("save sends once when ready, and lands on /alerts with a toast", () => { + test("save sends once when ready, and lands on the rules with a toast; the crumbs go through Rules", () => { const ready = withLists(entered({ template: "slow_p95" })) const saving = pageUpdate(ready, Message.ClickedSave()) expect(names(saving.commands)).toEqual(["SaveAlertRule"]) expect(names(pageUpdate(saving.model, Message.ClickedSave()).commands)).toEqual([]) const saved = pageUpdate(saving.model, Message.CompletedSave({ result: Result.succeed(undefined) })) - expect(saved.outMessage?._tag).toBe("RequestedNavigation") + expect(saved.outMessage).toMatchObject({ _tag: "RequestedNavigation", url: "/alerts/rules" }) + expect(breadcrumbsOf(ready)).toEqual([ + { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, + { label: "New rule" }, + ]) }) test("a created rule records alert_rule_created with its signal; a failed save does not", () => { diff --git a/apps/web/src/page/alerts/editor/view-action-bar.ts b/apps/web/src/page/alerts/editor/view-action-bar.ts index 1a451f62a2..dbf195f94e 100644 --- a/apps/web/src/page/alerts/editor/view-action-bar.ts +++ b/apps/web/src/page/alerts/editor/view-action-bar.ts @@ -120,7 +120,13 @@ const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | nu vstack(h, { gap: "0.5", shrink: true }, [ typography( h, - { size: "sm", tone: "foreground", truncate: true, as: "p", attributes: [h.Title(summary)] }, + { + size: "sm", + tone: "foreground", + truncate: true, + as: "p", + attributes: [h.Title(summary)], + }, [summary], ), validationSummary(h, model, issues), @@ -131,10 +137,12 @@ const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | nu ? [] : [ typography(h, { size: "xs", tone: "muted", className: "hidden sm:inline" }, [ - changeCount === 0 ? "No changes" : `${changeCount} unsaved ${pluralize(changeCount, "change")}`, + changeCount === 0 + ? "No changes" + : `${changeCount} unsaved ${pluralize(changeCount, "change")}`, ]), ]), - linkButton(h, { variant: "outline", size: "sm", href: alertsHref({}) }, ["Cancel"]), + linkButton(h, { variant: "outline", size: "sm", href: alertsHref({}, "rules") }, ["Cancel"]), button( h, { diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index ebd1bae99e..0632c3ec1f 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -247,6 +247,7 @@ export const view = Submodel.defineView((model, /** `AlertRuleFormShell`'s breadcrumb. */ export const breadcrumbsOf = (model: Model) => [ { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, { label: (model.form === null ? model.search.ruleId !== undefined : model.editingRuleId !== null) ? "Edit rule" diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index 785a833772..69b04f3dbc 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -17,6 +17,7 @@ import { readThrough } from "../../../data/read-cache" import { readGrants } from "../../../data/session-tenant" import { toLoadFailure } from "@maple/views/data/load-failure" import { Command } from "foldkit" +import { dismissSheetUrl, pushSheetUrl } from "../../kit/sheet-param" import { deleteDestination, listDeliveries, @@ -41,6 +42,29 @@ import { import { Message } from "./message" import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" +const sheetArgs = { href: Schema.String, id: Schema.String } + +/** A command must answer with a message: a sheet change answers with the clock, as a route change reads it. */ +const thenReadClock = (effect: Effect.Effect) => + effect.pipe( + Effect.andThen(Clock.currentTimeMillis), + Effect.map((nowMs) => Message.ReadClock({ nowMs })), + ) + +/** Opens a side panel: a history entry carrying `?sheet=`, marked as this tab's. */ +export const PushHubSheet = Command.define("PushAlertsHubSheet", { + args: sheetArgs, + messages: [Message.ReadClock], + execute: ({ href, id }) => thenReadClock(pushSheetUrl(href, id)), +}) + +/** Closes it: Back over the entry this tab pushed, else `?sheet=` replaced away. */ +export const DismissHubSheet = Command.define("DismissAlertsHubSheet", { + args: sheetArgs, + messages: [Message.ReadClock], + execute: ({ href, id }) => thenReadClock(dismissSheetUrl(href, id)), +}) + export const ReadHubClock = Command.define("ReadAlertsHubClock", { messages: [Message.ReadClock], execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), diff --git a/apps/web/src/page/alerts/hub/derive.test.ts b/apps/web/src/page/alerts/hub/derive.test.ts index 3ee3c8e8d2..b95e0d6e1d 100644 --- a/apps/web/src/page/alerts/hub/derive.test.ts +++ b/apps/web/src/page/alerts/hub/derive.test.ts @@ -2,7 +2,7 @@ import { OrgId } from "@maple/domain" import { Array as Arr, Option, Schema } from "effect" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { incidentRow, NOW, ORG, ruleRow } from "../shared/test-rows" +import { incidentRow, NOW, ORG, ruleRow, hubAt } from "../shared/test-rows" import { overviewOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" @@ -17,7 +17,7 @@ const loaded = (): Model => Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1), ruleRow(2)] }), Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), ], - init({ _tag: "Alerts", search: {} }, context).model, + init(hubAt(), context).model, (model, message) => update(model, message, context).model, ) diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 572950d044..669786b354 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -6,11 +6,12 @@ import { rowsToAlertIncidentDocuments, rowsToAlertRuleDocuments, } from "@maple/web-data/collections/alerts" -import { Option, Array as Arr, String as Str } from "effect" +import { Option, Order, Array as Arr, Record as Rec, String as Str } from "effect" import { AsyncData } from "foldkit" import { lastBy } from "../../kit/last-by" import type { InboxSignal, IncidentStatusFilter, RuleBucket } from "./message" import type { Model } from "./model" +import type { ScopeSuggestions } from "../silence-dialog/dialog" type OverviewKey = readonly [ Model["rules"], @@ -112,38 +113,32 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) -export type AlertsTab = - | "inbox" - | "rules" - | "uptime" - | "slos" - | "silences" - | "maintenance" - | "oncall" - | "routing" +const sortedNames = (names: ReadonlyArray): ReadonlyArray => + Arr.sort(Arr.dedupe(Arr.filter(names, Str.isNonEmpty)), Order.String) -/** Legacy `tab` values map onto the current tabs so old links keep resolving. */ -export const resolveTab = (tab: string | undefined): AlertsTab => { - switch (tab) { - case "rules": - return "rules" - case "uptime": - return "uptime" - case "slos": - return "slos" - case "silences": - return "silences" - case "maintenance": - return "maintenance" - case "oncall": - return "oncall" - case "routing": - case "settings": - return "routing" - default: - return "inbox" - } -} +const silenceSuggestionsFor = lastBy( + ([rules, namespaces]: readonly [ + Model["rules"], + Model["serviceNamespaces"]["data"], + ]): ScopeSuggestions => { + const documents = rowsToAlertRuleDocuments(rules ?? [], buildRuleStatesByRuleId([])) + return { + services: sortedNames([ + ...Arr.flatMap(documents, (rule) => rule.serviceNames), + ...Option.match(AsyncData.getData(namespaces), { onNone: () => [], onSome: Rec.keys }), + ]), + environments: sortedNames(Arr.flatMap(documents, (rule) => rule.environments)), + tags: sortedNames(Arr.flatMap(documents, (rule) => rule.tags)), + } + }, +) + +/** + * What the silence panel suggests, from what the hub already holds: the services, environments and + * tags its rules name, and the services the Uptime section listed if it was opened. + */ +export const silenceSuggestionsOf = (model: Model): ScopeSuggestions => + silenceSuggestionsFor([model.rules, model.serviceNamespaces.data]) /** `attention` is the legacy name of the blind bucket. */ export const resolveStatus = (status: string | undefined): RuleBucket | undefined => { diff --git a/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts b/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts index 9d6e02aedf..1329c7864b 100644 --- a/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts @@ -21,7 +21,17 @@ import { defaultAnomalyRuleRow, scopedAnomalyRuleRow, } from "../shared/anomaly-test-fixtures" -import { destinationRow, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from "../shared/test-rows" +import { + destinationRow, + incidentRow, + NOW, + ORG, + ruleId, + ruleRow, + stateRow, + hubAt, + type HubAt, +} from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, ruleMenuId, update } from "./update" @@ -57,7 +67,7 @@ const RESOLVED_CHECKOUT = anomalyIncidentRow({ }) const loaded = ( - search: Model["search"], + at: HubAt, incidents = [ incidentRow({ rule_name: "Payment p95 latency" }), OPEN_CHECKOUT, @@ -87,7 +97,7 @@ const loaded = ( Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), ], - init({ _tag: "Alerts", search }, context).model, + init(hubAt(at), context).model, (model, message) => pageUpdate(model, message).model, ) @@ -100,7 +110,7 @@ describe("alerts hub: anomalies", () => { click(role("button", { name: /^Anomalies/ })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: "/alerts?tab=inbox&signal=anomaly", + url: "/alerts?signal=anomaly", isReplace: false, }), ), @@ -245,19 +255,4 @@ describe("alerts hub: anomalies", () => { expectView(text("Search anomalies")).toExist(), ) }) - - test("leaving the Inbox drops the Anomalies view's filters from the tab links", () => { - scene( - { update: pageUpdate, view }, - given(loaded({ signal: "anomaly", anomalyStatus: "resolved", serviceName: "checkout" })), - expectView(role("link", { name: /^Rules/ })).toHaveAttr( - "href", - "/alerts?tab=rules&serviceName=checkout", - ), - expectView(role("link", { name: /^Inbox/ })).toHaveAttr( - "href", - "/alerts?tab=inbox&serviceName=checkout&signal=anomaly&anomalyStatus=resolved", - ), - ) - }) }) diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index 623064ac52..f7adc99b7f 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -24,7 +24,7 @@ import type { V2EscalationChain } from "@maple/domain/http/v2" import type { PageContext } from "../../contract" import { CHAIN_IN_USE_CODE } from "../shared/escalation-chains" import { chainDestinations, chainUuid, populatedChains } from "../shared/escalation-test-fixtures" -import { NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { NOW, ORG, ruleId, ruleRow, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { chainMenuId, init, update } from "./update" @@ -43,7 +43,7 @@ const RULES = [ruleRow(1, { name: "Checkout errors" }), ruleRow(2, { name: "Chec const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) const onRouting = (grants: ReturnType, chains: ReadonlyArray) => { - const entered = init({ _tag: "Alerts", search: { tab: "routing" } }, context) + const entered = init(hubAt({ tab: "routing" }), context) expect(names(entered.commands)).toContain("FetchAlertsHubChains") return Arr.reduce( [ 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 de1fbab3af..c02a9c6fb0 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 @@ -1,4 +1,5 @@ import * as Policy from "@maple/domain/policy" +import type { AlertsTab } from "../../../route/search" import { OrgId } from "@maple/domain" import type { V2AlertMaintenanceWindow, V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" @@ -26,7 +27,7 @@ import { weeklyWindow, } from "../shared/maintenance-test-fixtures" import { windowPublicId, windowSilencesHref } from "../shared/maintenance-windows" -import { NOW, ORG, ruleRow } from "../shared/test-rows" +import { NOW, ORG, ruleRow, hubAt, type HubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update, windowMenuId } from "./update" @@ -49,14 +50,14 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onTab = ( - search: Model["search"], + at: HubAt, options: Readonly<{ grants?: ReturnType windows: ReadonlyArray silences?: ReadonlyArray }>, ): Model => { - const entered = init({ _tag: "Alerts", search }, context) + const entered = init(hubAt(at), context) const loaded = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -86,7 +87,7 @@ const onMaintenance = (windows: ReadonlyArray, 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) + const fetched = (tab: AlertsTab) => names(init(hubAt({ tab }), context).commands) expect(fetched("maintenance")).toContain("FetchAlertsHubMaintenanceWindows") expect(fetched("silences")).toContain("FetchAlertsHubMaintenanceWindows") expect(fetched("rules")).not.toContain("FetchAlertsHubMaintenanceWindows") @@ -129,7 +130,7 @@ describe("alerts hub, Maintenance tab", () => { 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 moved = routeChanged(onMaint, hubAt({ tab: "maintenance" }), context).model const toggled = pageUpdate(moved, Message.ClickedToggleWindow({ windowId: weeklyWindow.id })).model const settled = pageUpdate( toggled, @@ -141,7 +142,7 @@ describe("alerts hub, Maintenance tab", () => { // 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) } }, + hubAt({ tab: "silences", window: windowPublicId(weeklyWindow.id) }), context, ) expect(back.commands ?? []).toContainEqual( @@ -150,7 +151,7 @@ describe("alerts hub, Maintenance tab", () => { args: expect.objectContaining({ windowId: weeklyWindow.id }), }), ) - const unfiltered = routeChanged(settled, { _tag: "Alerts", search: { tab: "silences" } }, context) + const unfiltered = routeChanged(settled, hubAt({ tab: "silences" }), context) expect(names(unfiltered.commands)).toContain("FetchAlertsHubSilences") }) @@ -247,7 +248,7 @@ describe("alerts hub, Maintenance tab", () => { 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 + const early = init(hubAt({ tab: "maintenance" }), context).model expect(pageUpdate(early, Message.ClickedNewWindow()).model.windowDialog.dialog.isOpen).toBe(false) }) }) @@ -268,13 +269,13 @@ describe("alerts hub, Silences tab with maintenance windows", () => { click(role("button", { name: "Remove Window: Nightly database backup" })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: alertsHref({ tab: "silences" }), + url: alertsHref({}, "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 cleared = routeChanged(model, hubAt({ tab: "silences" }), context) const fetch = Arr.findFirst(cleared.commands ?? [], ({ name }) => name === "FetchAlertsHubSilences") expect(fetch).toMatchObject({ value: { args: { windowId: null } } }) const shown = pageUpdate( diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts index 5376879745..37f8bc9d73 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -1,4 +1,5 @@ import * as Policy from "@maple/domain/policy" +import type { AlertsTab } from "../../../route/search" import { OrgId } from "@maple/domain" import { EscalationChainId, OnCallScheduleId } from "@maple/domain/http" import type { V2OnCallSchedule } from "@maple/domain/http/v2" @@ -16,7 +17,7 @@ import { populatedSchedules, scheduleUuid, } from "../shared/oncall-test-fixtures" -import { incidentRow, ORG, ruleRow } from "../shared/test-rows" +import { incidentRow, ORG, ruleRow, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, scheduleMenuId, update } from "./update" @@ -34,11 +35,11 @@ const MEMBER = Policy.grantsOf(["org:member"]) const SCHEDULES = populatedSchedules(ONCALL_NOW) const onTab = ( - tab: string, + tab: AlertsTab, grants: ReturnType, schedules: ReadonlyArray | null, ) => { - const entered = init({ _tag: "Alerts", search: { tab } }, context) + const entered = init(hubAt({ tab }), context) expect(names(entered.commands)).toContain("FetchAlertsHubSchedules") return Arr.reduce( [ @@ -72,7 +73,7 @@ describe("alerts hub, on-call schedules", () => { test("an org without schedules learns what one is and can create the first", () => { scene( { update: pageUpdate, view }, - given(onTab("oncall", ENGINEER, [])), + given(onTab("on-call", ENGINEER, [])), expectView(text("No on-call schedules yet")).toExist(), expectView(text(/people take turns in layers/u)).toExist(), expectView(role("link", { name: "Create schedule" })).toExist(), @@ -82,7 +83,7 @@ describe("alerts hub, on-call schedules", () => { test("each schedule names its zone, who is on call until when, and the next handoff", () => { scene( { update: pageUpdate, view }, - given(onTab("oncall", ENGINEER, SCHEDULES)), + given(onTab("on-call", ENGINEER, SCHEDULES)), expectView(text("Checkout primary")).toExist(), expectView(text("Europe/Berlin · 3 layers")).toExist(), expectView(text("Sofia Alvarez")).toExist(), @@ -97,7 +98,7 @@ describe("alerts hub, on-call schedules", () => { test("a member sees the schedules but cannot create, edit or delete one", () => { scene( { update: pageUpdate, view }, - given(onTab("oncall", MEMBER, SCHEDULES)), + given(onTab("on-call", MEMBER, SCHEDULES)), expectView(text("Checkout primary")).toExist(), expectView(role("link", { name: "New schedule" })).not.toExist(), expectView(role("button", { name: "Actions for Checkout primary" })).not.toExist(), @@ -122,7 +123,7 @@ describe("alerts hub, on-call schedules", () => { }) test("the clock reads the schedules again once a shown shift has handed off, not before", () => { - const model = onTab("oncall", ENGINEER, SCHEDULES) + const model = onTab("on-call", ENGINEER, SCHEDULES) expect( names(pageUpdate(model, Message.ReadClock({ nowMs: ONCALL_NOW + 60_000 })).commands), ).not.toContain("FetchAlertsHubSchedules") @@ -169,7 +170,7 @@ describe("alerts hub, on-call schedules", () => { }) test("an org with schedules but no rules sees who is on call above the empty inbox", () => { - const entered = init({ _tag: "Alerts", search: { tab: "inbox" } }, context) + const entered = init(hubAt({ tab: "inbox" }), context) const model = Arr.reduce( [ Message.ReadClock({ nowMs: ONCALL_NOW }), @@ -196,15 +197,15 @@ describe("alerts hub, on-call schedules", () => { }) test("a failed members read is asked again on the next route change", () => { - const entered = init({ _tag: "Alerts", search: { tab: "inbox" } }, context) + const entered = init(hubAt({ tab: "inbox" }), context) expect(names(entered.commands)).toContain("FetchAlertsHubMembers") const failed = pageUpdate( entered.model, Message.CompletedFetchMembers({ result: Result.fail("Clerk is down") }), ).model - expect( - names(routeChanged(failed, { _tag: "Alerts", search: { tab: "oncall" } }, context).commands), - ).toContain("FetchAlertsHubMembers") + expect(names(routeChanged(failed, hubAt({ tab: "on-call" }), context).commands)).toContain( + "FetchAlertsHubMembers", + ) }) test("a chain's summary names the people and schedules it pages", () => { @@ -220,7 +221,7 @@ describe("alerts hub, on-call schedules", () => { }) test("a refused delete names the chains that still page the schedule", () => { - const model = onTab("oncall", ENGINEER, SCHEDULES) + const model = onTab("on-call", ENGINEER, SCHEDULES) const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) const asked = pageUpdate( pageUpdate( @@ -263,11 +264,11 @@ describe("alerts hub, on-call schedules", () => { }) test("members are read only by the tabs that name people, once", () => { - const rules = init({ _tag: "Alerts", search: { tab: "rules" } }, context) + const rules = init(hubAt({ tab: "rules" }), context) expect(names(rules.commands)).not.toContain("FetchAlertsHubMembers") - const oncall = routeChanged(rules.model, { _tag: "Alerts", search: { tab: "oncall" } }, context) + const oncall = routeChanged(rules.model, hubAt({ tab: "on-call" }), context) expect(names(oncall.commands)).toContain("FetchAlertsHubMembers") - const routing = routeChanged(oncall.model, { _tag: "Alerts", search: { tab: "routing" } }, context) + const routing = routeChanged(oncall.model, hubAt({ tab: "routing" }), context) expect(names(routing.commands)).not.toContain("FetchAlertsHubMembers") }) 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 8e741f9fae..01243dfb4b 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 @@ -3,27 +3,29 @@ import { OrgId } from "@maple/domain" import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import { AlertMaintenanceWindowPublicId, type V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" -import * as Animation from "@foldkit/ui/animation" +import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" import { - click, - Command, expect as expectView, Mount, given, role, scene, + selector, + submit, text, withViewInputs, } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { NOW, ORG } from "../shared/test-rows" +import { NOW, ORG, hubAt, ruleRow } from "../shared/test-rows" +import { silenceSuggestionsOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" -import { init, update } from "./update" +import { init, routeChanged, update } from "./update" import { view as pageView } from "./view" +import * as SilenceDialog from "../silence-dialog/dialog" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -57,7 +59,7 @@ const silence = ( } const onSilences = (grants: ReturnType, silences: ReadonlyArray) => { - const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context) + const entered = init(hubAt({ tab: "silences" }), context) expect(Arr.map(entered.commands ?? [], ({ name }) => name)).toContain("FetchAlertsHubSilences") return Arr.reduce( [ @@ -165,7 +167,7 @@ describe("alerts hub, Silences tab", () => { }) test("reads the clock again when the list lands, so a new silence is placed by it", () => { - const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context).model + const entered = init(hubAt({ tab: "silences" }), context).model const step = pageUpdate( entered, Message.CompletedFetchSilences({ key: entered.silences.key ?? "", result: Result.succeed([]) }), @@ -195,24 +197,338 @@ describe("alerts hub, Silences tab", () => { expectView(text(/· 1 rule$/u)).toExist(), ) }) +}) + +const SILENCES_URL = "/alerts/silences" +const callsOf = (commands: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> | undefined) => + Arr.map(commands ?? [], ({ name, args }) => [name, args]) +const namesOf = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const silenceMessage = (message: SilenceDialog.Message) => Message.GotSilenceDialogMessage({ message }) +const scopeCombo = (field: SilenceDialog.ScopeField, message: Combobox.Message) => + silenceMessage(SilenceDialog.Message.GotScopeComboMessage({ field, message })) + +/** The Silences tab with `?sheet=silence` followed: the panel is open. */ +const withPanel = (grants = ENGINEER) => + routeChanged(onSilences(grants, []), hubAt({ tab: "silences", sheet: "silence" }), context) - test("New silence opens the dialog with typed scope fields", () => { +describe("alerts hub, silence side panel", () => { + test("New silence opens the panel through the URL, as a history entry on this section", () => { + const model = onSilences(ENGINEER, []) + const step = pageUpdate(model, Message.ClickedNewSilence()) + expect(callsOf(step.commands)).toEqual([ + ["PushAlertsHubSheet", { href: SILENCES_URL, id: "silence" }], + ]) + expect(step.model.silenceDialog.dialog.isOpen).toBe(false) + }) + + test("the URL opens it with a chips input per scope field, Cancel and a save", () => { + const opened = withPanel() + expect(opened.model.silenceDialog.dialog.isOpen).toBe(true) scene( { update: pageUpdate, view }, - given(onSilences(ENGINEER, [])), - click(role("button", { name: "New silence" })), - Command.resolve( - Animation.WaitForPaint, - Animation.Message.CompletedWaitForPaint({ generation: 1 }), - ), - Command.resolve( - Animation.WaitForAnimationSettled, - Animation.Message.EndedAnimation({ generation: 1 }), - ), + given(opened.model), Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), expectView(role("dialog", { name: "Silence notifications" })).toExist(), - expectView(role("textbox", { name: "Services" })).toExist(), - expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), + expectView(role("combobox", { name: "Services" })).toExist(), + expectView(role("combobox", { name: "Environments" })).toExist(), + expectView(role("combobox", { name: "Tags" })).toExist(), + expectView( + text("Fill in at least one. A rule is silenced only if it matches every field you fill in."), + ).toExist(), + expectView(role("button", { name: "Cancel" })).toExist(), + expectView(role("button", { name: "Silence for 1 hour" })).not.toBeDisabled(), + expectView(role("alert")).not.toExist(), + ) + }) + + test("a chips input with nothing to suggest opens no list: how to add a name is said in the form", () => { + const focused = pageUpdate( + withPanel().model, + scopeCombo("services", Combobox.Message.ClickedInput()), + ).model + scene( + { update: pageUpdate, view }, + given(focused), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Type a name and press Enter to add it. Commas separate several.")).toExist(), + expectView(role("listbox")).not.toExist(), + expectView(role("combobox", { name: "Services" })).toHaveAttr("aria-expanded", "false"), + ) + // What is typed is listed, so Enter has something to pick. + const typed = pageUpdate( + focused, + scopeCombo("services", Combobox.Message.ChangedInput({ value: "demo-api" })), + ).model + scene( + { update: pageUpdate, view }, + given(typed), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("option", { name: "demo-api" })).toExist(), + ) + }) + + test("saving an incomplete form sends nothing and says what is missing, field by field", () => { + scene( + { update: pageUpdate, view }, + given(withPanel().model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + submit(selector("form")), + expectView(text("Add at least one service, environment or tag")).toExist(), + expectView(text("Add a reason")).toExist(), + expectView(role("textbox", { name: "Reason" })).toHaveAttr("aria-invalid", "true"), + expectView(role("button", { name: "Silence for 1 hour" })).not.toBeDisabled(), + ) + const tried = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedSave())) + expect(namesOf(tried.commands)).toEqual([]) + // Each line goes as its field is filled in. + const reasoned = pageUpdate( + tried.model, + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ).model + scene( + { update: pageUpdate, view }, + given(reasoned), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Add a reason")).not.toExist(), + expectView(text("Add at least one service, environment or tag")).toExist(), + ) + }) + + test("the chips inputs suggest what the org's rules name, and a picked one becomes a chip", () => { + const withRules = pageUpdate( + withPanel().model, + Message.UpdatedRules({ + orgId: ORG, + rows: [ + ruleRow(1, { service_names_json: ["checkout"], tags_json: ["db"] }), + ruleRow(2, { + service_names_json: ["payments", "checkout"], + environments_json: ["staging"], + }), + ], + }), + ).model + expect(silenceSuggestionsOf(withRules)).toEqual({ + services: ["checkout", "payments"], + environments: ["production", "staging"], + tags: ["db"], + }) + const listed = pageUpdate(withRules, scopeCombo("services", Combobox.Message.ClickedInput())).model + scene( + { update: pageUpdate, view }, + given(listed), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("option", { name: "checkout" })).toExist(), + expectView(role("option", { name: "payments" })).toExist(), ) + const picked = pageUpdate( + listed, + scopeCombo("services", Combobox.Message.ClickedItem({ value: "payments" })), + ).model + scene( + { update: pageUpdate, view }, + given(picked), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("button", { name: "Remove payments" })).toExist(), + ) + }) + + test("a picked end is on the viewer's clock, and the field names the zone", () => { + const berlin: PageContext = { ...context, timeZone: "Europe/Berlin" } + const opened = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "silences", sheet: "silence" }), + berlin, + ).model + const custom = update( + opened, + silenceMessage(SilenceDialog.Message.ChangedDuration({ duration: "custom" })), + berlin, + ).model + scene( + { update: (model: Model, message: Message) => update(model, message, berlin), view }, + given(custom), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + // NOW is 1 October: Berlin is on summer time. + expectView(text("Ends, CEST")).toExist(), + expectView(text("Your local time. At most 30 days from now.")).toExist(), + ) + }) + + test("the save button names its end on the 24h clock the duration choice and the picker use", () => { + const until = (duration: SilenceDialog.SilenceDuration, value: string) => + Arr.reduce( + [ + silenceMessage(SilenceDialog.Message.ChangedDuration({ duration })), + silenceMessage(SilenceDialog.Message.ChangedUntil({ value })), + ], + withPanel().model, + (model, message) => pageUpdate(model, message).model, + ) + const saveButton = role("button", { name: /^Silence until / }) + scene( + { update: pageUpdate, view }, + given(until("morning", "")), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Until 09:00")).toExist(), + expectView(saveButton).toHaveText(/ 09:00$/u), + ) + // NOW is noon on 1 October, so 15:30 that day is still ahead. + scene( + { update: pageUpdate, view }, + given(until("custom", "2026-10-01T15:30")), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(saveButton).toHaveText(/ 15:30$/u), + ) + }) + + test("a link straight to the panel waits for the clock and the viewer's grants, and a member never gets it", () => { + const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model + expect(entered.silenceDialog.dialog.isOpen).toBe(false) + const clocked = pageUpdate(entered, Message.ReadClock({ nowMs: NOW })).model + expect(clocked.silenceDialog.dialog.isOpen).toBe(false) + const granted = pageUpdate( + clocked, + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + ).model + expect(granted.silenceDialog.dialog.isOpen).toBe(true) + expect(granted.silenceDialog.nowMs).toBe(NOW) + expect(withPanel(MEMBER).model.silenceDialog.dialog.isOpen).toBe(false) + }) + + test("a member's link loses its `?sheet=` once the grants say so, replaced away", () => { + const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model + const clocked = pageUpdate(entered, Message.ReadClock({ nowMs: NOW })).model + const granted = pageUpdate(clocked, Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER })) + expect(granted.model.silenceDialog.dialog.isOpen).toBe(false) + expect(granted.model.search.sheet).toBeUndefined() + expect(callsOf(granted.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=silence`, id: "silence" }], + ]) + }) + + test("the panel opens only on the Silences section", () => { + const onRules = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "rules", sheet: "silence" }), + context, + ) + expect(onRules.model.silenceDialog.dialog.isOpen).toBe(false) + expect( + pageUpdate(onRules.model, Message.ReadClock({ nowMs: NOW })).model.silenceDialog.dialog.isOpen, + ).toBe(false) + }) + + test("a `?sheet=` that opens nothing is replaced away: another section's panel, or a made-up one", () => { + const dismissals = (commands: Parameters[0]) => + Arr.filter(callsOf(commands), ([name]) => name === "DismissAlertsHubSheet") + const onRules = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "rules", sheet: "silence" }), + context, + ) + expect(onRules.model.search.sheet).toBeUndefined() + expect(dismissals(onRules.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: "/alerts/rules?sheet=silence", id: "silence" }], + ]) + const onInbox = routeChanged(onSilences(ENGINEER, []), hubAt({ sheet: "silence" }), context) + expect(dismissals(onInbox.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: "/alerts?sheet=silence", id: "silence" }], + ]) + const bogus = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "silences", sheet: "bogus" }), + context, + ) + expect(bogus.model.search.sheet).toBeUndefined() + expect(bogus.model.silenceDialog.dialog.isOpen).toBe(false) + expect(dismissals(bogus.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=bogus`, id: "bogus" }], + ]) + // Asked once: the clock tick that follows finds nothing left to drop. + expect(dismissals(pageUpdate(bogus.model, Message.ReadClock({ nowMs: NOW })).commands)).toEqual([]) + // The open panel leaving its section closes with the param. + const left = routeChanged(withPanel().model, hubAt({ tab: "rules", sheet: "silence" }), context) + expect(left.model.silenceDialog.dialog.isOpen).toBe(false) + expect(dismissals(left.commands)).toHaveLength(1) + }) + + test("a clock tick after Cancel, before the URL change lands, leaves it closed", () => { + const cancelled = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedCancel())) + expect(cancelled.model.search.sheet).toBeUndefined() + const ticked = pageUpdate(cancelled.model, Message.ReadClock({ nowMs: NOW + 30_000 })) + expect(ticked.model.silenceDialog.dialog.isOpen).toBe(false) + const landed = routeChanged(ticked.model, hubAt({ tab: "silences" }), context) + expect(landed.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(landed.commands)).not.toContain("DismissAlertsHubSheet") + }) + + test("Cancel and Escape close it and take `?sheet=` off the URL", () => { + const dismissed = [ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=silence`, id: "silence" }], + ] + const cancelled = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedCancel())) + expect(cancelled.model.silenceDialog.dialog.isOpen).toBe(false) + expect(Arr.filter(callsOf(cancelled.commands), ([name]) => name === "DismissAlertsHubSheet")).toEqual( + dismissed, + ) + const escaped = pageUpdate( + withPanel().model, + silenceMessage( + SilenceDialog.Message.GotDialogMessage({ message: Dialog.Message.PressedEscape() }), + ), + ) + expect(escaped.model.silenceDialog.dialog.isOpen).toBe(false) + expect(Arr.filter(callsOf(escaped.commands), ([name]) => name === "DismissAlertsHubSheet")).toEqual( + dismissed, + ) + }) + + test("Back closes it without touching the URL again, and the form starts fresh when it reopens", () => { + const typed = pageUpdate( + withPanel().model, + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "x" })), + ) + const back = routeChanged(typed.model, hubAt({ tab: "silences" }), context) + expect(back.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(back.commands)).not.toContain("DismissAlertsHubSheet") + const reopened = routeChanged(back.model, hubAt({ tab: "silences", sheet: "silence" }), context) + expect(reopened.model.silenceDialog.dialog.isOpen).toBe(true) + expect(reopened.model.silenceDialog.reason).toBe("") + }) + + test("saving creates the silence, closes the panel, clears the URL and reads the list again", () => { + const filled = Arr.reduce( + [ + scopeCombo("services", Combobox.Message.ClickedItem({ value: "checkout" })), + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ], + withPanel().model, + (model, message) => pageUpdate(model, message).model, + ) + const saving = pageUpdate(filled, silenceMessage(SilenceDialog.Message.ClickedSave())) + expect(namesOf(saving.commands)).toEqual(["SaveAlertSilence"]) + const saved = pageUpdate( + saving.model, + silenceMessage( + SilenceDialog.Message.CompletedSave({ + openCount: saving.model.silenceDialog.openCount, + result: Result.succeed(silence({ startMin: 0, endMin: 60 })), + }), + ), + ) + expect(saved.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(saved.commands)).toEqual( + expect.arrayContaining(["DismissAlertsHubSheet", "FetchAlertsHubSilences"]), + ) + expect(saved.outMessage).toMatchObject({ title: "Alerts silenced" }) + // The refreshed list reads the clock again; the panel stays shut. + expect( + pageUpdate(saved.model, Message.ReadClock({ nowMs: NOW })).model.silenceDialog.dialog.isOpen, + ).toBe(false) }) }) diff --git a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts index b6b9ff22e4..84d833a2dc 100644 --- a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts @@ -8,7 +8,7 @@ import * as Menu from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" import { sloHref } from "../shared/slo-commands" import { checkoutSlo, SLO_RULE_UUID, searchSlo, sloStatus, sloUuid } from "../shared/slo-test-fixtures" -import { incidentRow, NOW, ORG } from "../shared/test-rows" +import { incidentRow, NOW, ORG, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model, SloListRow } from "./model" import { init, update } from "./update" @@ -25,7 +25,7 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onSlos = (grants = ENGINEER): Model => { - const entered = init({ _tag: "Alerts", search: { tab: "slos" } }, context).model + const entered = init(hubAt({ tab: "slos" }), context).model return Arr.reduce( [Message.ReadClock({ nowMs: NOW }), Message.CompletedFetchSession({ orgId: ORG, grants })], entered, @@ -46,12 +46,8 @@ const HEALTHY_AND_BURNING: ReadonlyArray = [ describe("alerts hub, SLOs tab", () => { test("the SLOs and their statuses are read only while the tab is open", () => { - expect(names(init({ _tag: "Alerts", search: {} }, context).commands)).not.toContain( - "FetchAlertsHubSlos", - ) - expect(names(init({ _tag: "Alerts", search: { tab: "slos" } }, context).commands)).toContain( - "FetchAlertsHubSlos", - ) + expect(names(init(hubAt(), context).commands)).not.toContain("FetchAlertsHubSlos") + expect(names(init(hubAt({ tab: "slos" }), context).commands)).toContain("FetchAlertsHubSlos") }) test("an org without SLOs is told what one is and offered the first", () => { @@ -204,18 +200,19 @@ describe("alerts hub, SLOs tab", () => { expect(names(pageUpdate(failed, Message.ClickedRetrySlos()).commands)).toEqual(["FetchAlertsHubSlos"]) }) - test("the header's main create button makes an SLO here, for admins only, and the menu offers it too", () => { + test("the header's one primary action is New SLO here, for admins only, and the Inbox's menu offers it too", () => { scene( { update: pageUpdate, view }, given(withSlos(HEALTHY_AND_BURNING)), expectView(role("link", { name: "New SLO" })).toHaveAttr("href", "/alerts/slos/new"), expectView(role("link", { name: "New rule" })).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), ) scene( { update: pageUpdate, view }, given(withSlos(HEALTHY_AND_BURNING, onSlos(MEMBER))), expectView(role("link", { name: "New SLO" })).not.toExist(), - expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), + expectView(role("link", { name: "New rule" })).not.toExist(), ) const picked = pageUpdate( withSlos(HEALTHY_AND_BURNING), @@ -231,7 +228,7 @@ describe("alerts hub, SLOs tab", () => { describe("alerts hub inbox, SLO incidents", () => { test("an org whose only rules are SLOs' sees their open incidents, not the empty state", () => { - const entered = init({ _tag: "Alerts", search: {} }, context).model + const entered = init(hubAt(), context).model const model = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), diff --git a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts index a62ab85d2a..83ffcfbd3b 100644 --- a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts @@ -18,7 +18,7 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import * as Menu from "@maple/views/ui/menu" import { MountMenu } from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" -import { incidentRow, NOW, ORG } from "../shared/test-rows" +import { incidentRow, NOW, ORG, hubAt } from "../shared/test-rows" import { monitorHref } from "../shared/uptime-commands" import type { UptimeMonitor } from "../shared/uptime-derive" import { downMonitor, httpMonitor, monitorId, pausedMonitor } from "../shared/uptime-test-monitors" @@ -38,7 +38,7 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onUptime = (): Model => { - const entered = init({ _tag: "Alerts", search: { tab: "uptime" } }, context).model + const entered = init(hubAt({ tab: "uptime" }), context).model return Arr.reduce( [Message.ReadClock({ nowMs: NOW }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER })], entered, @@ -74,7 +74,7 @@ const NAMESPACES = { "checkout-api": "shop", "payments-api": "billing" } describe("alerts hub inbox, uptime incidents", () => { test("an uptime incident reads as its monitor being down, without the ungrouped key", () => { - const entered = init({ _tag: "Alerts", search: {} }, context).model + const entered = init(hubAt(), context).model const model = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -123,16 +123,12 @@ describe("alerts hub, Uptime tab", () => { }) test("the monitors are read only when the Uptime tab is open", () => { - expect(names(init({ _tag: "Alerts", search: {} }, context).commands)).not.toContain( - "FetchAlertsHubMonitors", - ) - expect(names(init({ _tag: "Alerts", search: { tab: "uptime" } }, context).commands)).toContain( - "FetchAlertsHubMonitors", - ) + expect(names(init(hubAt(), context).commands)).not.toContain("FetchAlertsHubMonitors") + expect(names(init(hubAt({ tab: "uptime" }), context).commands)).toContain("FetchAlertsHubMonitors") }) test("opening the tab also reads which namespace each service reports under", () => { - expect(names(init({ _tag: "Alerts", search: { tab: "uptime" } }, context).commands)).toContain( + expect(names(init(hubAt({ tab: "uptime" }), context).commands)).toContain( "FetchAlertsHubServiceNamespaces", ) }) @@ -324,21 +320,29 @@ describe("alerts hub, Uptime tab", () => { expect(done.model.deletingMonitorId).toBeNull() }) - test("the header's main create button makes an uptime check here, for admins only", () => { + test("the header's one primary action is New monitor here, for admins only", () => { scene( { update: pageUpdate, view }, given(withMonitors(THREE)), - expectView(role("link", { name: "New check" })).toHaveAttr("href", "/alerts/uptime/new"), + expectView(role("link", { name: "New monitor" })).toHaveAttr("href", "/alerts/uptime/new"), expectView(role("link", { name: "New rule" })).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), ) scene( { update: pageUpdate, view }, - given({ ...withMonitors(THREE), search: { tab: "uptime", serviceName: "checkout-api" } }), - expectView(role("link", { name: "New check" })).toHaveAttr( + given({ ...withMonitors(THREE), search: { serviceName: "checkout-api" } }), + expectView(role("link", { name: "New monitor" })).toHaveAttr( "href", "/alerts/uptime/new?serviceName=checkout-api", ), ) + // The empty state offers the first check itself, so the header stays empty. + scene( + { update: pageUpdate, view }, + given(withMonitors([])), + expectView(role("link", { name: "New monitor" })).not.toExist(), + expectView(role("link", { name: "New uptime check" })).toExist(), + ) const asMember = pageUpdate( withMonitors(THREE), Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER }), @@ -346,15 +350,16 @@ describe("alerts hub, Uptime tab", () => { scene( { update: pageUpdate, view }, given(asMember), - expectView(role("link", { name: "New check" })).not.toExist(), - expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), + expectView(role("link", { name: "New monitor" })).not.toExist(), + expectView(role("link", { name: "New rule" })).not.toExist(), ) }) - test("the New rule chevron opens both kinds, and Uptime check goes to the uptime form", () => { + test("on the Inbox the New rule chevron opens every kind, and Uptime check goes to the uptime form", () => { scene( { update: pageUpdate, view }, - given(withMonitors(THREE)), + given({ ...withMonitors(THREE), tab: "inbox" as const }), + expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), click(role("button", { name: "More rule types" })), Command.resolve( Animation.WaitForPaint, diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 1327330cb8..a821b640e2 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -25,10 +25,20 @@ import { encodePublicId, PublicIdPrefixes, publicError, V2AlertIncident } from " import { toLoadFailure } from "@maple/views/data/load-failure" import * as Menu from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" -import { destinationRow, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from "../shared/test-rows" +import { + destinationRow, + incidentRow, + NOW, + ORG, + ruleId, + ruleRow, + stateRow, + hubAt, + type HubAt, +} from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" -import { init, ruleMenuId, update } from "./update" +import { init, routeChanged, ruleMenuId, update } from "./update" import { breadcrumbs, view as pageView } from "./view" const ENGINEER = Policy.grantsOf(["org:engineer"]) @@ -42,10 +52,10 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = const RULE_2 = Schema.decodeSync(AlertRuleId)(ruleId(2)) const DESTINATION = Schema.decodeSync(AlertDestinationId)(destinationRow().id) -const entered = (search: Model["search"] = {}) => init({ _tag: "Alerts", search }, context) +const entered = (at: HubAt = {}) => init(hubAt(at), context) /** The four shapes and the clock have answered: rule 2 is firing, rule 4 is off. */ -const loaded = (search: Model["search"] = {}): Model => +const loaded = (at: HubAt = {}): Model => Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -68,7 +78,7 @@ const loaded = (search: Model["search"] = {}): Model => Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), ], - entered(search).model, + entered(at).model, (model, message) => pageUpdate(model, message).model, ) @@ -96,7 +106,6 @@ describe("alerts hub", () => { { update: pageUpdate, view }, given(loaded()), expectView(text("1 firing")).toExist(), - expectView(role("link", { name: /^Inbox\s*1$/ })).toExist(), expectView(role("link", { name: "P95 latency 1.84s on all" })).toHaveAttr( "href", "/alerts/incidents/0199a003-0000-7000-8000-000000000001", @@ -104,31 +113,121 @@ describe("alerts hub", () => { ) }) - test("the section rail marks the open section, which titles the page and its crumbs", () => { + test("each section's own path opens it, titled by it with its crumbs", () => { + const sections = [ + ["rules", "Rules"], + ["uptime", "Uptime"], + ["slos", "SLOs"], + ["silences", "Silences"], + ["maintenance", "Maintenance"], + ["on-call", "On-call"], + ["routing", "Routing"], + ] as const + for (const [tab, label] of sections) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(role("heading", { name: label })).toExist(), + ) + expect(breadcrumbs(loaded({ tab }))).toEqual([{ label: "Alerts", href: "/alerts" }, { label }]) + } scene( { update: pageUpdate, view }, - given(loaded({ tab: "rules" })), - expectView(role("link", { name: /^Rules\s*3$/ })).toHaveAttr("aria-current", "page"), - expectView(role("link", { name: /^Inbox\s*1$/ })).not.toHaveAttr("aria-current", "page"), - expectView(role("heading", { name: "Rules" })).toExist(), + given(loaded()), + expectView(role("heading", { name: "Inbox" })).toExist(), ) - // The anomaly filters belong to the Inbox: its row keeps them, every other row drops them. + }) + + test("each section's header has one primary action: its own, and New rule only on the Inbox and Rules", () => { + const newRule = role("link", { name: "New rule" }) + const moreKinds = role("button", { name: "More rule types" }) + for (const tab of ["inbox", "rules"] as const) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(newRule).toHaveAttr("href", "/alerts/create"), + expectView(moreKinds).toExist(), + ) + } + for (const tab of ["uptime", "slos", "silences", "maintenance", "on-call", "routing"] as const) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(newRule).not.toExist(), + expectView(moreKinds).not.toExist(), + ) + } scene( { update: pageUpdate, view }, - given(loaded({ tab: "inbox", signal: "anomaly", serviceName: "checkout" })), - expectView(role("link", { name: /^Inbox\s*1$/ })).toHaveAttr( - "href", - "/alerts?tab=inbox&serviceName=checkout&signal=anomaly", - ), - expectView(role("link", { name: "Silences" })).toHaveAttr( - "href", - "/alerts?tab=silences&serviceName=checkout", - ), + given(loaded({ tab: "silences" })), + expectView(role("button", { name: "New silence" })).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "routing" })), + expectView(role("button", { name: "Add destination" })).toExist(), ) - expect(breadcrumbs(loaded({ tab: "silences" }))).toEqual([ - { label: "Alerts", href: "/alerts" }, - { label: "Silences" }, + // A viewer who can't create a section's thing gets no header action there. + const asMember = pageUpdate( + loaded({ tab: "silences" }), + Message.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:member"]) }), + ).model + scene( + { update: pageUpdate, view }, + given(asMember), + expectView(role("button", { name: "New silence" })).not.toExist(), + expectView(newRule).not.toExist(), + ) + }) + + test("an org without rules gets the create actions from the empty state alone, on the Inbox and on Rules", () => { + const newRule = role("link", { name: "New rule" }) + const firstRun = (at: HubAt, grants = ENGINEER): Model => + Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedRules({ orgId: ORG, rows: [] }), + Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + Message.UpdatedIncidents({ orgId: ORG, rows: [] }), + Message.UpdatedDestinations({ orgId: ORG, rows: [] }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + ], + entered(at).model, + (model, message) => pageUpdate(model, message).model, + ) + const tabs: ReadonlyArray<"inbox" | "rules"> = ["inbox", "rules"] + Arr.forEach(tabs, (tab) => { + scene( + { update: pageUpdate, view }, + given(firstRun({ tab })), + expectView(role("link", { name: "Start from scratch" })).toExist(), + expectView(role("button", { name: "Add destination" })).toExist(), + expectView(newRule).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), + ) + // A member's empty state offers nothing to create, so the header keeps its action. + scene( + { update: pageUpdate, view }, + given(firstRun({ tab }, Policy.grantsOf(["org:member"]))), + expectView(role("link", { name: "Start from scratch" })).not.toExist(), + expectView(newRule).toExist(), + ) + }) + // Still loading: nothing says the org is empty yet. + scene({ update: pageUpdate, view }, given(entered().model), expectView(newRule).toExist()) + }) + + test("moving between sections keeps what was read: the Inbox and Rules share it, a section reads its own once", () => { + const inbox = loaded() + expect(names(routeChanged(inbox, hubAt({ tab: "rules" }), context).commands)).toEqual([ + "ReadAlertsHubClock", ]) + const uptime = routeChanged(inbox, hubAt({ tab: "uptime" }), context) + expect(names(uptime.commands)).toContain("FetchAlertsHubMonitors") + const back = routeChanged(uptime.model, hubAt(), context) + expect(names(routeChanged(back.model, hubAt({ tab: "uptime" }), context).commands)).not.toContain( + "FetchAlertsHubMonitors", + ) }) const v2Incident = Schema.decodeSync(V2AlertIncident)({ @@ -183,8 +282,6 @@ describe("alerts hub", () => { { update: pageUpdate, view }, given(acknowledged), expectView(text("0 firing")).toExist(), - // The tab's red badge counts only what nobody is on yet. - expectView(role("link", { name: "Inbox" })).toExist(), expectView(text(/1 acknowledged/)).toExist(), expectView(text(/^Acknowledged by Dana Kim ·/)).toExist(), expectView(role("button", { name: "Unacknowledge P95 latency 1.84s on all" })).toExist(), @@ -313,7 +410,7 @@ describe("alerts hub", () => { click(role("button", { name: /^Firing/ })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: "/alerts?tab=rules&status=firing", + url: "/alerts/rules?status=firing", isReplace: false, }), ), @@ -323,7 +420,7 @@ describe("alerts hub", () => { Message.ClickedStatusFilter({ bucket: "firing" }), ) expect(cleared.outMessage).toEqual( - PageOutMessage.RequestedNavigation({ url: "/alerts?tab=rules", isReplace: false }), + PageOutMessage.RequestedNavigation({ url: "/alerts/rules", isReplace: false }), ) }) diff --git a/apps/web/src/page/alerts/hub/index.ts b/apps/web/src/page/alerts/hub/index.ts index d004453ea2..b2ad39c61d 100644 --- a/apps/web/src/page/alerts/hub/index.ts +++ b/apps/web/src/page/alerts/hub/index.ts @@ -5,12 +5,16 @@ import { subscriptions } from "./subscription" import { init, routeChanged, update } from "./update" import { breadcrumbs, view } from "./view" -/** `/alerts`: the incident-first hub and its sections. One instance per org; the section is search. */ +/** + * `/alerts` (the Inbox) and `/alerts/
`: the incident-first hub as one instance per org, so a + * section change keeps what the last one read. + */ export const page = definePage( "AlertsHub", { Model, Message }, { - routes: ["Alerts"], + routes: ["Alerts", "AlertsSection"], + key: () => "alerts", init, update, routeChanged, diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 4c448ddcad..fb584a2b40 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -28,7 +28,7 @@ import { import { Schema } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" -import { AlertsSearch } from "../../../route/search" +import { AlertsHubSearch, AlertsTab } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" @@ -54,7 +54,8 @@ export type SloListRow = typeof SloListRow.Type export const TestOutcome = Schema.Struct({ message: Schema.String, success: Schema.Boolean }) export const Model = Schema.Struct({ - search: AlertsSearch, + tab: AlertsTab, + search: AlertsHubSearch, /** `Clock.currentTimeMillis` when the overview model was built; `null` until read. */ nowMs: Schema.NullOr(Schema.Number), rules: Rows(AlertRuleRowSchema), diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 935b7a6cb4..c559c3f07c 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -62,19 +62,28 @@ import { EndHubSilence, FetchHubSilences, DeleteWindow, + DismissHubSheet, FetchHubWindows, + PushHubSheet, ToggleWindow, } from "./command" import { withAcknowledgement } from "../shared/commands" import { testSendFailureMessage } from "../shared/delivery-failure" -import { resolveStatus, resolveTab } from "./derive" +import { resolveStatus } from "./derive" import { Message, type RuleBucket } from "./message" -import type { Model } from "./model" +import { canManage, type Model } from "./model" type Return = PageReturn type Commands = ReadonlyArray> type Requested = Readonly<{ model: Model; commands: Commands }> -type HubRoute = Extract +export type HubRoute = Extract + +/** The section and filters a hub route names; a legacy `tab` on `/alerts` is left out. */ +const fromRoute = (route: HubRoute): Pick => { + if (route._tag === "AlertsSection") return { tab: route.section, search: route.search } + const { tab: _legacyTab, ...search } = route.search + return { tab: "inbox", search } +} const DELETE_DIALOG_ID = "alerts-delete-destination" const DELETE_MONITOR_DIALOG_ID = "alerts-delete-monitor" @@ -115,7 +124,7 @@ const requestDeliveries = (model: Model, context: PageContext, mode: Keyed.Reque /** The Routing tab mounts its own deliveries read; the overview's stays mounted with the tab counts. */ const requestRoutingDeliveries = (model: Model, context: PageContext, mode: Keyed.RequestMode): Requested => { const key = deliveriesKey(context) - return resolveTab(model.search.tab) !== "routing" + return model.tab !== "routing" ? { model, commands: [] } : Option.match(Keyed.request(model.routingDeliveries, key, mode), { onNone: () => ({ model, commands: [] }), @@ -135,7 +144,7 @@ const silencesKey = (context: PageContext, windowId: AlertMaintenanceWindowId | /** 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: [] } + if (model.tab !== "silences") return { model, commands: [] } const windowId = Option.getOrNull(windowIdOfParam(model.search.window)) return Option.match(Keyed.requestFor(model.silences, silencesKey(context, windowId), mode), { onNone: () => ({ model, commands: [] }), @@ -156,7 +165,7 @@ const windowsKey = (context: PageContext) => /** 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) + const tab = model.tab if (tab !== "maintenance" && tab !== "silences") return { model, commands: [] } return Option.match(Keyed.requestFor(model.windows, windowsKey(context), mode), { onNone: () => ({ model, commands: [] }), @@ -174,7 +183,7 @@ const requestWindows = (model: Model, context: PageContext, mode: Keyed.ReadMode const refreshWindows = (model: Model, context: PageContext): Return => { const windows = requestWindows(model, context, "Supersede") const silences: Requested = - resolveTab(windows.model.search.tab) === "silences" + windows.model.tab === "silences" ? requestSilences(windows.model, context, "Supersede") : { model: { ...windows.model, silences: Keyed.idle() }, commands: [] } return { model: silences.model, commands: [...windows.commands, ...silences.commands] } @@ -198,7 +207,7 @@ const requestServiceNamespaces = (model: Model, context: PageContext): Requested /** The Uptime tab reads the monitors, and the namespaces they group by, only while it is open. */ const requestMonitors = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - if (resolveTab(model.search.tab) !== "uptime") return { model, commands: [] } + if (model.tab !== "uptime") return { model, commands: [] } const namespaces = requestServiceNamespaces(model, context) return Option.match(Keyed.requestFor(namespaces.model.monitors, monitorsKey(context), mode), { onNone: () => namespaces, @@ -214,7 +223,7 @@ const slosKey = (context: PageContext) => /** The SLOs tab reads the SLOs and their statuses only while it is open. */ const requestSlos = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - if (resolveTab(model.search.tab) !== "slos") return { model, commands: [] } + if (model.tab !== "slos") return { model, commands: [] } return Option.match(Keyed.requestFor(model.slos, slosKey(context), mode), { onNone: () => ({ model, commands: [] }), onSome: (slos) => ({ model: { ...model, slos }, commands: [FetchHubSlos({ key: slos.key })] }), @@ -226,8 +235,8 @@ const chainsKey = (context: PageContext) => /** The Routing tab reads the escalation chains, and the On-call tab to name the ones paging a schedule. */ const requestChains = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - const tab = resolveTab(model.search.tab) - if (tab !== "routing" && tab !== "oncall") return { model, commands: [] } + const tab = model.tab + if (tab !== "routing" && tab !== "on-call") return { model, commands: [] } return Option.match(Keyed.requestFor(model.chains, chainsKey(context), mode), { onNone: () => ({ model, commands: [] }), onSome: (chains) => ({ @@ -271,10 +280,10 @@ const isSchedulesStale = (model: Model, nowMs: number): boolean => { /** The clock ticked: the overview's derivation moves, and stale schedules are read again. */ const clockRead = (model: Model, context: PageContext, nowMs: number): Return => { - const next = { ...model, nowMs } - if (!isSchedulesStale(next, nowMs)) return { model: next } - const step = requestSchedules({ ...next, schedulesAskedAtMs: nowMs }, context, "Refresh") - return { model: step.model, commands: step.commands } + const sheet = followSilenceSheet({ ...model, nowMs }, context) + if (!isSchedulesStale(sheet.model, nowMs)) return sheet + const step = requestSchedules({ ...sheet.model, schedulesAskedAtMs: nowMs }, context, "Refresh") + return { model: step.model, commands: [...sheet.commands, ...step.commands] } } /** `reactivityKeys: ["alertDeliveryEvents"]`: both reads revalidate. */ @@ -286,15 +295,15 @@ const refreshDeliveries = (model: Model, context: PageContext) => { /** Members are read once, on the first tab that names people: the inbox strip, On-call, Routing. */ const requestMembers = (model: Model): Requested => { - const tab = resolveTab(model.search.tab) - return model.isMembersRequested || (tab !== "inbox" && tab !== "oncall" && tab !== "routing") + const tab = model.tab + return model.isMembersRequested || (tab !== "inbox" && tab !== "on-call" && tab !== "routing") ? { model, commands: [] } : { model: { ...model, isMembersRequested: true }, commands: [FetchHubMembers()] } } export const init = (route: HubRoute, context: PageContext): Return => { const model: Model = { - search: route.search, + ...fromRoute(route), nowMs: null, rules: null, states: null, @@ -380,7 +389,8 @@ export const init = (route: HubRoute, context: PageContext): Return => { } export const routeChanged = (model: Model, route: HubRoute, context: PageContext): Return => { - const routing = requestRoutingDeliveries({ ...model, search: route.search }, context, "Load") + const sheet = followSilenceSheet({ ...model, ...fromRoute(route) }, context) + const routing = requestRoutingDeliveries(sheet.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") const silences = requestSilences(monitors.model, context, "Load") const windows = requestWindows(silences.model, context, "Load") @@ -399,13 +409,14 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext ...chains.commands, ...schedules.commands, ...members.commands, + ...sheet.commands, ReadHubClock(), ], } } -const navigate = (search: Model["search"]): PageOutMessage => - PageOutMessage.RequestedNavigation({ url: alertsHref(search), isReplace: false }) +const navigate = (search: Model["search"], tab: Model["tab"]): PageOutMessage => + PageOutMessage.RequestedNavigation({ url: alertsHref(search, tab), isReplace: false }) const toast = (type: "success" | "error", title: string, description = ""): PageOutMessage => PageOutMessage.RequestedToast({ type, title, description }) @@ -734,15 +745,72 @@ const deletedSchedule = ( // SILENCES +/** `?sheet=silence`: the new-silence side panel. */ +export const SILENCE_SHEET = "silence" + +const hubHref = (model: Model): string => alertsHref(model.search, model.tab) + +const silenceCommands = (step: ReturnType): Commands => + FoldkitCommand.mapMessages(step.commands ?? [], (message) => Message.GotSilenceDialogMessage({ message })) + +/** + * Opens or closes the silence panel to match `?sheet=` (a link, Back, or "New silence"). It opens on + * the Silences section once the clock is read, as the form checks its end against it, and only for + * a viewer who may save. + */ +const followSilenceSheet = (model: Model, context: PageContext): Requested => { + const sheet = model.search.sheet + const isInUrl = sheet === SILENCE_SHEET && model.tab === "silences" + // A `?sheet=` nothing here opens (another section's panel, a made-up id) is replaced away. + if (sheet !== undefined && !isInUrl) { + const dropped = followSilenceSheet( + { ...model, search: { ...model.search, sheet: undefined } }, + context, + ) + return { + model: dropped.model, + commands: [...dropped.commands, DismissHubSheet({ href: hubHref(model), id: sheet })], + } + } + const isOpen = model.silenceDialog.dialog.isOpen + if (isInUrl && !isOpen && model.nowMs !== null && canManage(model)) { + const step = SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs) + return { model: { ...model, silenceDialog: step.model }, commands: silenceCommands(step) } + } + if (!isInUrl && isOpen) { + const step = SilenceDialog.close(model.silenceDialog) + return { model: { ...model, silenceDialog: step.model }, commands: silenceCommands(step) } + } + return { model, commands: [] } +} + +/** The grants are in: a viewer who can't silence loses a linked panel's `?sheet=`, replaced away. */ +const grantsLoaded = (model: Model, context: PageContext): Requested => + model.search.sheet === SILENCE_SHEET && !canManage(model) + ? { + model: { ...model, search: { ...model.search, sheet: undefined } }, + commands: [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })], + } + : followSilenceSheet(model, context) + const liftSilenceDialog = ( model: Model, context: PageContext, step: ReturnType, ): Return => { - const next = { ...model, silenceDialog: step.model } - const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => - Message.GotSilenceDialogMessage({ message }), - ) + // The panel closed itself (Cancel, Escape, a save): its `?sheet=` goes now, in the model too, so a + // clock tick before the URL change lands can't reopen it. + const hasClosed = + model.silenceDialog.dialog.isOpen && !step.model.dialog.isOpen && model.search.sheet === SILENCE_SHEET + const next = { + ...model, + silenceDialog: step.model, + search: hasClosed ? { ...model.search, sheet: undefined } : model.search, + } + const commands = [ + ...silenceCommands(step), + ...(hasClosed ? [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] : []), + ] if (step.outMessage === undefined) return { model: next, commands } return SilenceDialog.OutMessage.match(step.outMessage, { RequestedToast: ({ type, title, description }) => ({ @@ -1044,10 +1112,10 @@ const updateMenu = (model: Model, context: PageContext, menuId: string, message: const statusToggled = (model: Model, bucket: RuleBucket): Return => ({ model, - outMessage: navigate({ - ...model.search, - status: resolveStatus(model.search.status) === bucket ? undefined : bucket, - }), + outMessage: navigate( + { ...model.search, status: resolveStatus(model.search.status) === bucket ? undefined : bucket }, + model.tab, + ), }) export const update = (model: Model, message: Message, context: PageContext): Return => @@ -1077,29 +1145,39 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, routingDeliveries: Keyed.accept(model.routingDeliveries, key, result) }, }), CompletedFetchSession: ({ orgId, grants }) => - orgId === context.orgId ? { model: { ...model, grants } } : { model }, + orgId === context.orgId ? grantsLoaded({ ...model, grants }, context) : { model }, ChangedRulesQuery: ({ value }) => ({ model: { ...model, rulesQuery: value } }), ClickedStatusFilter: ({ bucket }) => statusToggled(model, bucket), ClickedClearRuleFilters: () => ({ model: { ...model, rulesQuery: "" }, - outMessage: navigate({ ...model.search, status: undefined }), + outMessage: navigate({ ...model.search, status: undefined }, model.tab), }), ClickedInboxSignal: ({ signal }) => ({ model, // The status and service belong to the signal view: plain Inbox drops them. - outMessage: navigate( + outMessage: signal === null - ? { ...model.search, signal: undefined, anomalyStatus: undefined, serviceName: undefined } - : { ...model.search, tab: "inbox", signal, anomalyStatus: undefined }, - ), + ? navigate( + { + ...model.search, + signal: undefined, + anomalyStatus: undefined, + serviceName: undefined, + }, + model.tab, + ) + : navigate({ ...model.search, signal, anomalyStatus: undefined }, "inbox"), }), ClickedIncidentStatus: ({ status }) => ({ model, - outMessage: navigate({ ...model.search, anomalyStatus: status === "open" ? undefined : status }), + outMessage: navigate( + { ...model.search, anomalyStatus: status === "open" ? undefined : status }, + model.tab, + ), }), ClickedClearInboxService: () => ({ model, - outMessage: navigate({ ...model.search, serviceName: undefined }), + outMessage: navigate({ ...model.search, serviceName: undefined }, model.tab), }), ClickedShowAllRules: () => ({ model: { ...model, isShowingAllRules: true } }), ClickedToggleRule: ({ ruleId, isEnabled }) => toggleRule(model, ruleId, isEnabled), @@ -1284,11 +1362,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), ClickedRetrySilences: () => refreshSilences(model, context), ClickedNewSilence: () => - liftSilenceDialog( - model, - context, - SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs ?? 0), - ), + model.search.sheet === SILENCE_SHEET + ? { model } + : { model, commands: [PushHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] }, GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), @@ -1353,7 +1429,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re : updateDeleteWindowDialog(model, Dialog.update(model.deleteWindowDialog, child)), ClickedClearWindowFilter: () => ({ model, - outMessage: navigate({ ...model.search, window: undefined }), + outMessage: navigate({ ...model.search, window: undefined }, model.tab), }), ClickedConfirmDeleteMonitor: () => confirmDeleteMonitor(model), GotDeleteMonitorDialogMessage: ({ message: child }) => diff --git a/apps/web/src/page/alerts/hub/view-empty.ts b/apps/web/src/page/alerts/hub/view-empty.ts index dff9c09e16..364cf405ad 100644 --- a/apps/web/src/page/alerts/hub/view-empty.ts +++ b/apps/web/src/page/alerts/hub/view-empty.ts @@ -122,7 +122,7 @@ const destinationStep = (h: HtmlBuilder, onAddDestination: M | undefined): ], ), onAddDestination === undefined - ? linkButton(h, { size: "sm", href: alertsHref({ tab: "routing" }) }, [ + ? linkButton(h, { size: "sm", href: alertsHref({}, "routing") }, [ icon(h, "PaperPlaneIcon"), "Add destination", ]) diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 735256f805..cc2e0ec9f8 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -63,10 +63,6 @@ export type InboxAcknowledge = Readonly<{ const isAcknowledged = (incident: AlertIncidentDocument) => incident.acknowledgedAt !== null -/** The inbox tab's badge: open incidents nobody has acknowledged yet. */ -export const unacknowledgedCount = (incidents: ReadonlyArray): number => - Arr.filter(incidents, (incident) => !isAcknowledged(incident)).length - export const inboxTab = ( h: HtmlBuilder, data: AlertsOverviewReady, diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts index 7aad64110e..13c16cc1b9 100644 --- a/apps/web/src/page/alerts/hub/view-oncall.ts +++ b/apps/web/src/page/alerts/hub/view-oncall.ts @@ -284,10 +284,3 @@ export const deleteScheduleDialog = (h: H, model: Model): Html => { onConfirm: Message.ClickedConfirmDeleteSchedule(), }) } - -/** The tab header's "New schedule", once one exists. */ -export const newScheduleButton = (h: H): Html => - linkButton(h, { size: "sm", variant: "outline", href: newScheduleHref() }, [ - icon(h, "PlusIcon"), - "New schedule", - ]) diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index 61ba57ff02..b4fae5ff48 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -18,6 +18,7 @@ import { sectionHeading } from "../../kit/section-heading" import { windowIdOfParam } from "../shared/maintenance-windows" import { endsAtMs, + formatSilenceRange, formatSilenceTime, isCancelledSilence, silenceScopeParts, @@ -51,12 +52,11 @@ const silencesEmptyState = (h: H): Html => const windowText = (silence: V2AlertSilence, nowMs: number, timeZone: string): string => { const status = silenceStatusAt(silence, nowMs) - const start = formatSilenceTime(startsAtMs(silence), nowMs, timeZone) const end = formatSilenceTime(endsAtMs(silence), nowMs, timeZone) return status === "active" ? `Until ${end}` : status === "scheduled" - ? `${start} to ${end}` + ? formatSilenceRange(startsAtMs(silence), endsAtMs(silence), nowMs, timeZone) : isCancelledSilence(silence) ? `Cancelled ${end}` : `Ended ${end}` diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index ea70cd025a..7278c9c6c1 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -15,13 +15,13 @@ import * as MenuView from "@maple/views/ui/menu-view" import { skeleton, skeletonList } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" -import { navShell, type NavShellCount, type NavShellSection } from "../../kit/nav-shell" import type { BreadcrumbEntry } from "../../../shell" +import type { AlertsTab } from "../../../route/search" import { alertsHref, createHref } from "../shared/links" +import { newScheduleHref } from "../shared/oncall-schedules" import { newSloHref } from "../shared/slo-commands" import { newMonitorHref } from "../shared/uptime-commands" import { - type AlertsTab, anomalyOverviewOf, destinationsOf, isAboutService, @@ -29,13 +29,13 @@ import { resolveIncidentStatus, resolveSignal, resolveStatus, - resolveTab, + silenceSuggestionsOf, } from "./derive" import { Message } from "./message" import { canManage, type Model } from "./model" import { alertsEmptyState } from "./view-empty" import { anomalyInbox, inboxFilters } from "./view-anomalies" -import { inboxTab, unacknowledgedCount } from "./view-inbox" +import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" import * as ChainDialogView from "../escalation-dialog/view" @@ -48,17 +48,11 @@ import * as WindowDialogView from "../maintenance-dialog/view" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" import { slosTab } from "./view-slos" -import { - deleteScheduleDialog, - hasSchedules, - newScheduleButton, - oncallTab, - whoIsOnCallStrip, -} from "./view-oncall" +import { deleteScheduleDialog, hasSchedules, oncallTab, whoIsOnCallStrip } from "./view-oncall" import { hstack, vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" -// `AlertsPage` and `AlertsHub`: the `/alerts` page and the rail of its sections. +// `AlertsPage` and `AlertsHub`: `/alerts` and `/alerts/
`. The sidebar lists the sections. type H = HtmlBuilder @@ -69,112 +63,18 @@ const TAB_LABEL = { slos: "SLOs", silences: "Silences", maintenance: "Maintenance", - oncall: "On-call", + "on-call": "On-call", routing: "Routing", } as const satisfies Record -type RailRow = Readonly<{ tab: AlertsTab; icon: IconName }> - -const RAIL: ReadonlyArray }>> = [ - { id: "triage", title: "Triage", rows: [{ tab: "inbox", icon: "BellIcon" }] }, - { - id: "monitors", - title: "Monitors", - rows: [ - { tab: "rules", icon: "ChartLineIcon" }, - { tab: "uptime", icon: "GlobeIcon" }, - { tab: "slos", icon: "GaugeIcon" }, - ], - }, - { - id: "quiet", - title: "Quiet", - rows: [ - { tab: "silences", icon: "BellSlashIcon" }, - { tab: "maintenance", icon: "CalendarIcon" }, - ], - }, - { - id: "response", - title: "Response", - rows: [ - { tab: "oncall", icon: "UsersIcon" }, - { tab: "routing", icon: "BranchForkIcon" }, - ], - }, -] - const HUB_SKELETON_ROWS = ["h-14 w-1/2", "h-40 w-full", "h-48 w-full"] -/** `Alerts ›
`: the rail's active row names the page. */ +/** `Alerts ›
`: the sidebar's current sub-item names the page. */ export const breadcrumbs = (model: Model): ReadonlyArray => [ { label: "Alerts", href: alertsHref({}) }, - { label: TAB_LABEL[resolveTab(model.search.tab)] }, + { label: TAB_LABEL[model.tab] }, ] -const countOf = ( - model: Model, - overview: Option.Option, - tab: AlertsTab, -): number | undefined => { - const lengthOf = (rows: Option.Option>) => - Option.getOrUndefined(Option.map(rows, Arr.length)) - switch (tab) { - case "uptime": - return lengthOf(AsyncData.getData(model.monitors.data)) - case "slos": - return lengthOf(AsyncData.getData(model.slos.data)) - case "oncall": - return lengthOf(AsyncData.getData(model.schedules.data)) - case "inbox": - return Option.getOrUndefined( - Option.map(overview, (data) => unacknowledgedCount(data.openIncidents)), - ) - case "rules": - return Option.getOrUndefined(Option.map(overview, (data) => data.rules.length)) - case "silences": - case "maintenance": - case "routing": - return undefined - } -} - -/** The section rail in `dashboardPage`'s side slot, which folds into a sheet below `lg`. */ -const rail = (h: H, model: Model, overview: Option.Option, active: AlertsTab): Html => - navShell( - h, - Arr.map(RAIL, (section): NavShellSection => ({ - id: section.id, - title: section.title, - items: Arr.map(section.rows, (row) => { - const count = countOf(model, overview, row.tab) - const tone: NavShellCount["tone"] = row.tab === "inbox" ? "error" : "muted" - return { - kind: "link", - id: row.tab, - label: TAB_LABEL[row.tab], - icon: row.icon, - count: count !== undefined && count > 0 ? { value: count, tone } : undefined, - // The Anomalies view belongs to the Inbox: other sections drop its filters. - href: alertsHref( - row.tab === "inbox" - ? { ...model.search, tab: row.tab } - : { - ...model.search, - tab: row.tab, - signal: undefined, - anomalyStatus: undefined, - window: undefined, - }, - ), - } - }), - })), - active, - (tab) => tab, - "Alerts sections", - ) - /** A "New rule" menu row: icon, label and one line of what it watches. */ const newRuleItem = ( h: H, @@ -195,26 +95,18 @@ const newRuleItem = ( ], }) -/** The split create button: its main part makes the section's own kind (an uptime check on Uptime, an SLO - * on SLOs, else a telemetry rule), the chevron offers every kind. */ -const newRuleButton = (h: H, model: Model, tab: AlertsTab): Html => { +/** The Inbox's and Rules' split create button: a telemetry rule, and behind the chevron every kind. */ +const newRuleButton = (h: H, model: Model): Html => { const serviceName = model.search.serviceName - // Uptime checks and SLOs are admin-only to create; members keep the telemetry rule. - const lead = - canManage(model) && tab === "uptime" - ? { href: newMonitorHref({ serviceName }), label: "New check" } - : canManage(model) && tab === "slos" - ? { href: newSloHref({ serviceName }), label: "New SLO" } - : { href: createHref({ serviceName }), label: "New rule" } return hstack(h, { align: "center" }, [ linkButton( h, { size: "sm", - href: lead.href, + href: createHref({ serviceName }), className: splitButtonClass("lead"), }, - [icon(h, "PlusIcon"), lead.label], + [icon(h, "PlusIcon"), "New rule"], ), h.submodel({ slotId: model.newRuleMenu.id, @@ -279,52 +171,69 @@ const newRuleButton = (h: H, model: Model, tab: AlertsTab): Html => { ]) } -// Destinations are a Routing concern, so "New destination" only shows on that tab. -const headerActions = (h: H, model: Model, tab: AlertsTab): Html => - hstack(h, { gap: "2" }, [ - ...(canManage(model) && tab === "silences" - ? [ - button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewSilence())], - }, - [icon(h, "BellSlashIcon"), "New silence"], - ), - ] - : []), - ...(canManage(model) && tab === "maintenance" && hasWindows(model) - ? [ - button( - h, - { - size: "sm", - variant: "outline", - isDisabled: model.nowMs === null, - attributes: [h.OnClick(Message.ClickedNewWindow())], - }, - [icon(h, "CalendarIcon"), "New window"], - ), - ] - : []), - ...(canManage(model) && tab === "oncall" && hasSchedules(model) ? [newScheduleButton(h)] : []), - ...(canManage(model) && tab === "routing" - ? [ - button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewDestination())], - }, - [icon(h, "PlusIcon"), "New destination"], - ), - ] - : []), - newRuleButton(h, model, tab), - ]) +const hasRows = (rows: Option.Option>): boolean => + Option.exists(rows, (list) => list.length > 0) + +const primaryLink = (h: H, href: string, label: string): Html => + linkButton(h, { size: "sm", href }, [icon(h, "PlusIcon"), label]) + +const primaryButton = (h: H, onClick: Message, label: string, isDisabled = false): Html => + button(h, { size: "sm", isDisabled, attributes: [h.OnClick(onClick)] }, [icon(h, "PlusIcon"), label]) + +/** + * The first-run empty state fills the section: no rules yet, and on the Inbox nothing else to list + * (an org with only monitors or SLOs has no visible rules but may have incidents to show). + */ +const isFirstRun = (model: Model, data: AlertsOverviewReady, tab: "inbox" | "rules"): boolean => { + if (data.rules.length > 0) return false + if (tab === "rules") return true + return ( + resolveSignal(model.search.signal) === null && + !Arr.some(data.openIncidents, (incident) => isManagedSignalType(incident.signalType)) && + !Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") + ) +} + +/** + * The header's one primary action: what the open section lists. A section whose empty state + * already offers it shows none until it has rows, and a viewer who can't create it sees none. + */ +const headerActions = ( + h: H, + model: Model, + tab: AlertsTab, + overview: Option.Option, +): Html | undefined => { + if (tab === "inbox" || tab === "rules") + // The first-run empty state offers the templates and "Start from scratch" to who can create. + return canManage(model) && Option.exists(overview, (data) => isFirstRun(model, data, tab)) + ? undefined + : newRuleButton(h, model) + if (!canManage(model)) return undefined + const serviceName = model.search.serviceName + switch (tab) { + case "uptime": + return hasRows(AsyncData.getData(model.monitors.data)) + ? primaryLink(h, newMonitorHref({ serviceName }), "New monitor") + : undefined + case "slos": + return hasRows(AsyncData.getData(model.slos.data)) + ? primaryLink(h, newSloHref({ serviceName }), "New SLO") + : undefined + case "silences": + return primaryButton(h, Message.ClickedNewSilence(), "New silence") + case "maintenance": + return hasWindows(model) + ? primaryButton(h, Message.ClickedNewWindow(), "New window", model.nowMs === null) + : undefined + case "on-call": + return hasSchedules(model) ? primaryLink(h, newScheduleHref(), "New schedule") : undefined + case "routing": + return destinationsOf(model).length > 0 + ? primaryButton(h, Message.ClickedNewDestination(), "Add destination") + : undefined + } +} /** `SyncUnavailable`: the rules or incidents stream stopped before its first answer. */ const SYNC_UNAVAILABLE = makeClientErrorBody({ @@ -367,10 +276,6 @@ const hubBody = ( serviceName: model.search.serviceName, onAddDestination: canManage(model) ? Message.ClickedNewDestination() : undefined, }) - // An org with only monitors or SLOs has no visible rules but may have open incidents to show. - const hasManagedIncidents = Arr.some(data.openIncidents, (incident) => - isManagedSignalType(incident.signalType), - ) const acknowledge = { onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => Message.ClickedAcknowledgeIncident({ incidentId, acknowledged }), @@ -378,9 +283,8 @@ const hubBody = ( } if (tab === "inbox") { const signal = resolveSignal(model.search.signal) - const hasAnomalies = Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") // A team can set up on-call before its first rule, so the strip shows on the empty inbox too. - if (data.rules.length === 0 && !hasManagedIncidents && !hasAnomalies && signal === null) + if (isFirstRun(model, data, "inbox")) return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), emptyState]) const filters = inboxFilters(h, { signal, @@ -440,7 +344,7 @@ const deleteDialog = (h: H, model: Model): Html => { } export const view = Submodel.defineView((model, inputs, h) => { - const tab = resolveTab(model.search.tab) + const tab = model.tab const overview = overviewOf(model) return h.div( [h.Class("contents")], @@ -449,10 +353,7 @@ export const view = Submodel.defineView((model, h, { title: TAB_LABEL[tab], - actions: headerActions(h, model, tab), - filters: rail(h, model, overview, tab), - filtersLabel: "Alerts", - filtersWidth: "w-56", + actions: headerActions(h, model, tab, overview), }, [ tab === "routing" @@ -469,7 +370,7 @@ export const view = Submodel.defineView((model, ? silencesTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) : tab === "maintenance" ? maintenanceTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) - : tab === "oncall" + : tab === "on-call" ? oncallTab(h, model, model.nowMs ?? 0) : hubBody(h, model, overview, tab), ], @@ -503,7 +404,7 @@ export const view = Submodel.defineView((model, slotId: "alerts-silence", model: model.silenceDialog, view: SilenceDialogView.view, - viewInputs: { id: "alerts-silence" }, + viewInputs: { id: "alerts-silence", suggestions: silenceSuggestionsOf(model) }, toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), }), h.submodel({ diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 4f5ac406e4..b020209ac3 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -40,7 +40,7 @@ import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { resourceNotFound } from "../../kit/resource-not-found" import { severityBadge } from "../shared/badges" -import { alertsHref } from "../shared/links" +import { alertsHref, ruleHref } from "../shared/links" import { sloHref, slosTabHref } from "../shared/slo-commands" import { monitorHref, uptimeTabHref } from "../shared/uptime-commands" import { incidentChecksOf, summaryChecksOf } from "../shared/check-summary" @@ -544,7 +544,7 @@ export const breadcrumbsOf = (model: Model) => ], onSome: ({ incident, rule }) => [ { label: "Alerts", href: "/alerts" }, - { label: rule?.name ?? incident.ruleName, href: `/alerts/${incident.ruleId}` }, + { label: rule?.name ?? incident.ruleName, href: ruleHref(incident.ruleId) }, { label: "Incident" }, ], }) diff --git a/apps/web/src/page/alerts/rule/index.ts b/apps/web/src/page/alerts/rule/index.ts index 2c10c34d2e..ddb939bb6e 100644 --- a/apps/web/src/page/alerts/rule/index.ts +++ b/apps/web/src/page/alerts/rule/index.ts @@ -46,7 +46,7 @@ const subscriptions = Subscription.make, Message, R ), })) -/** `/alerts/$ruleId`: one instance per rule; the tab and window are search. */ +/** `/alerts/rules/$ruleId`: one instance per rule; the tab and window are search. */ export const page = definePage( "AlertRule", { Model, Message }, diff --git a/apps/web/src/page/alerts/rule/model.ts b/apps/web/src/page/alerts/rule/model.ts index 5c571dc3f1..f955de7adb 100644 --- a/apps/web/src/page/alerts/rule/model.ts +++ b/apps/web/src/page/alerts/rule/model.ts @@ -21,7 +21,7 @@ import * as TimeRangePicker from "../../kit/time-range-picker" import { CheckSummary } from "../shared/check-summary" import { PreviewSlot } from "../shared/preview" -// `/alerts/$ruleId`. +// `/alerts/rules/$ruleId`. const Rows = (row: S) => Schema.NullOr(Schema.Array(row)) diff --git a/apps/web/src/page/alerts/rule/rule.scene.test.ts b/apps/web/src/page/alerts/rule/rule.scene.test.ts index a6cb93c90a..d576df271c 100644 --- a/apps/web/src/page/alerts/rule/rule.scene.test.ts +++ b/apps/web/src/page/alerts/rule/rule.scene.test.ts @@ -27,7 +27,7 @@ import { ToggleRuleEnabled } from "./command" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update } from "./update" -import { view as pageView } from "./view" +import { breadcrumbsOf, view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -138,7 +138,7 @@ describe("alert rule", () => { test("the Overview and History tab links keep the time range", () => { const search = { timePreset: "7d" } - const href = (tab: string) => `/alerts/${RULE}?tab=${tab}&timePreset=7d` + const href = (tab: string) => `/alerts/rules/${RULE}?tab=${tab}&timePreset=7d` scene( { update: pageUpdate, view }, given(loaded(search)), @@ -169,9 +169,18 @@ describe("alert rule", () => { given(model), themeResolved(), expectView(text("Rule not found")).toExist(), + expectView(role("link", { name: "Back to rules" })).toHaveAttr("href", "/alerts/rules"), ) }) + test("the crumbs go through Rules to the rule", () => { + expect(breadcrumbsOf(loaded())).toEqual([ + { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, + { label: "Payment p95 latency" }, + ]) + }) + test("a raw SQL rule slowed down by its timeouts shows its query time per check and why", () => { const check = ( minutesAgo: number, @@ -348,7 +357,7 @@ describe("alert rule", () => { }), ) expect(viewAll.outMessage).toEqual( - PageOutMessage.RequestedNavigation({ url: "/alerts?tab=overview", isReplace: false }), + PageOutMessage.RequestedNavigation({ url: "/alerts", isReplace: false }), ) }) }) diff --git a/apps/web/src/page/alerts/rule/update.ts b/apps/web/src/page/alerts/rule/update.ts index 06177371b6..6c56441c30 100644 --- a/apps/web/src/page/alerts/rule/update.ts +++ b/apps/web/src/page/alerts/rule/update.ts @@ -322,7 +322,7 @@ const updateMenu = (model: Model, menuId: string, message: Menu.Message): Return model: next, commands, outMessage: PageOutMessage.RequestedNavigation({ - url: alertsHref({ tab: "overview" }), + url: alertsHref({}), isReplace: false, }), } diff --git a/apps/web/src/page/alerts/rule/view-diagnosis.ts b/apps/web/src/page/alerts/rule/view-diagnosis.ts index 0de938ee0c..5a24e324e2 100644 --- a/apps/web/src/page/alerts/rule/view-diagnosis.ts +++ b/apps/web/src/page/alerts/rule/view-diagnosis.ts @@ -290,7 +290,7 @@ const stageAction = (h: H, action: NonNullable): Reado ] if (action.kind === "destinations") return [ - linkButton(h, { variant: "outline", size: "xs", href: alertsHref({ tab: "routing" }) }, [ + linkButton(h, { variant: "outline", size: "xs", href: alertsHref({}, "routing") }, [ action.label, ]), ] diff --git a/apps/web/src/page/alerts/rule/view.ts b/apps/web/src/page/alerts/rule/view.ts index 402a974f5f..f33fa6c523 100644 --- a/apps/web/src/page/alerts/rule/view.ts +++ b/apps/web/src/page/alerts/rule/view.ts @@ -200,7 +200,7 @@ export const view = Submodel.defineView((model, resourceNotFound(h, { title: "Rule not found", description: "This alert rule could not be found. It may have been deleted.", - back: { href: alertsHref({}), label: "Back to rules" }, + back: { href: alertsHref({}, "rules"), label: "Back to rules" }, }), ]) : dashboardPage(h, { ...header(h, model, rule, timeZone), tabs: tabStrip(h, model) }, [ @@ -229,6 +229,7 @@ export const breadcrumbsOf = (model: Model) => { const rule = model.rules === null ? null : ruleOf(model) return [ { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, { label: model.rules === null ? "Loading…" : rule === null ? "Not found" : rule.name }, ] } diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 4b8cbb53d7..c2c01db6b0 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -60,7 +60,7 @@ export const retrieveIncidentEscalation = (id: AlertIncidentId) => return incident.escalation }).pipe(Effect.mapError(toLoadFailure)) -export const routingTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "routing" } })) +export const routingTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "routing", search: {} })) // SUMMARY diff --git a/apps/web/src/page/alerts/shared/links.ts b/apps/web/src/page/alerts/shared/links.ts index bf622381e2..9316199646 100644 --- a/apps/web/src/page/alerts/shared/links.ts +++ b/apps/web/src/page/alerts/shared/links.ts @@ -1,7 +1,7 @@ import { AlertRuleId } from "@maple/domain/http" import { Option, Schema } from "effect" -import { AppRoute, hrefOf } from "../../../route" -import type { AlertRuleSearch, AlertsCreateSearch, AlertsSearch } from "../../../route/search" +import { alertsSectionRoute, AppRoute, hrefOf } from "../../../route" +import type { AlertRuleSearch, AlertsCreateSearch, AlertsHubSearch, AlertsTab } from "../../../route/search" const decodeRuleId = Schema.decodeUnknownOption(AlertRuleId) @@ -15,7 +15,9 @@ export const ruleHref = (rawRuleId: string, search: typeof AlertRuleSearch.Type export const incidentHref = (incidentId: string): string => hrefOf(AppRoute.AlertIncident({ incidentId, search: {} })) -export const alertsHref = (search: typeof AlertsSearch.Type): string => hrefOf(AppRoute.Alerts({ search })) +/** A hub section's URL with the hub's filters; the Inbox by default. */ +export const alertsHref = (search: typeof AlertsHubSearch.Type, tab: AlertsTab = "inbox"): string => + hrefOf(alertsSectionRoute(tab, search)) export const createHref = (search: typeof AlertsCreateSearch.Type): string => hrefOf(AppRoute.AlertsCreate({ search })) diff --git a/apps/web/src/page/alerts/shared/maintenance-windows.ts b/apps/web/src/page/alerts/shared/maintenance-windows.ts index 2f4f7d785b..926b397430 100644 --- a/apps/web/src/page/alerts/shared/maintenance-windows.ts +++ b/apps/web/src/page/alerts/shared/maintenance-windows.ts @@ -85,7 +85,7 @@ export const windowIdOfParam = (raw: string | undefined): Option.Option - alertsHref({ tab: "silences", window: windowPublicId(id) }) + alertsHref({ window: windowPublicId(id) }, "silences") export const WEEKDAYS: ReadonlyArray<{ value: MaintenanceWeekday; label: string }> = [ { value: "mon", label: "Mon" }, diff --git a/apps/web/src/page/alerts/shared/matcher-fields.ts b/apps/web/src/page/alerts/shared/matcher-fields.ts index 44e0a356f2..722e5698e0 100644 --- a/apps/web/src/page/alerts/shared/matcher-fields.ts +++ b/apps/web/src/page/alerts/shared/matcher-fields.ts @@ -3,6 +3,7 @@ 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 { label } from "@maple/views/ui/label" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" @@ -29,7 +30,8 @@ export const typedFromMatchers = (matchers: V2AlertSilenceMatchers): TypedMatche environments: Arr.join(matchers.environments ?? [], ", "), }) -const splitList = (value: string): ReadonlyArray => +/** A comma-separated list as its names: trimmed, the empty ones dropped, each once. */ +export 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. */ @@ -59,6 +61,30 @@ const stateOf = (value: string, isInvalid = false): FieldState => ({ isTouched: false, }) +/** A dialog field's label for a control the text field doesn't draw (chips, a segmented choice): the same look. */ +export const dialogLabel = ( + h: HtmlBuilder, + options: Readonly<{ id: string; htmlFor?: string }>, + text: string, +): Html => + label( + h, + { + className: "text-xs", + attributes: [ + h.Id(options.id), + ...(options.htmlFor === undefined ? [] : [h.For(options.htmlFor)]), + ], + }, + [text], + ) + +/** A line of help under a group of dialog fields, in the face a field's own description uses. */ +export const dialogHelp = (h: HtmlBuilder, id: string, text: string): Html => + fieldDescription(h, { ids: { control: id, label: id, error: id, description: id }, state: stateOf("") }, [ + text, + ]) + export type DialogTextFieldOptions = Readonly<{ idPrefix: string name: string diff --git a/apps/web/src/page/alerts/shared/oncall-schedules.ts b/apps/web/src/page/alerts/shared/oncall-schedules.ts index fc9c70a19e..b35b9a8b57 100644 --- a/apps/web/src/page/alerts/shared/oncall-schedules.ts +++ b/apps/web/src/page/alerts/shared/oncall-schedules.ts @@ -143,7 +143,7 @@ export const newScheduleHref = (search: typeof AlertsScheduleNewSearch.Type = {} export const editScheduleHref = (id: OnCallScheduleId): string => newScheduleHref({ scheduleId: encodePublicId(id) }) -export const oncallTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "oncall" } })) +export const oncallTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "on-call", search: {} })) // FORMAT diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts index 1d0e7e8f89..0c2566425f 100644 --- a/apps/web/src/page/alerts/shared/silences.test.ts +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { Option, Schema } from "effect" import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" -import { activeSilenceFor, matchersForRule } from "./silences" +import { activeSilenceFor, formatSilenceRange, formatSilenceTime, matchersForRule } from "./silences" const ruleId = Schema.decodeSync(AlertRuleId)("11111111-1111-4111-8111-111111111111") const asSilenceId = Schema.decodeSync(AlertSilenceId) @@ -39,6 +39,39 @@ const groupedRule = { queryBuilderDraft: null, } +describe("silence times", () => { + const BERLIN = "Europe/Berlin" + + it("reads on the viewer's clock, and names the day when it is not today there", () => { + // NOW is 13:00 in Berlin. + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 14, 0), NOW, BERLIN)).toBe("03:00 PM") + expect(formatSilenceTime(Date.UTC(2026, 10, 7, 8, 0), NOW, BERLIN)).toBe("Nov 7, 09:00 AM") + // 23:30 UTC is still today in UTC, and already tomorrow in Berlin. + const late = Date.UTC(2026, 10, 6, 23, 30) + expect(formatSilenceTime(late, NOW, "UTC")).toBe("11:30 PM") + expect(formatSilenceTime(late, NOW, BERLIN)).toBe("Nov 7, 12:30 AM") + }) + + it("reads on a 24h clock when asked, midnight as 00", () => { + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 14, 30), NOW, BERLIN, "h23")).toBe("15:30") + expect(formatSilenceTime(Date.UTC(2026, 10, 7, 8, 0), NOW, BERLIN, "h23")).toBe("Nov 7, 09:00") + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 23, 30), NOW, BERLIN, "h23")).toBe("Nov 7, 00:30") + }) + + it("names the day of a window once when both ends share it", () => { + const start = Date.UTC(2026, 10, 7, 14, 0) + expect(formatSilenceRange(start, start + 60 * 60_000, NOW, BERLIN)).toBe( + "Nov 7, 03:00 PM to 04:00 PM", + ) + expect(formatSilenceRange(start, start + 24 * 60 * 60_000, NOW, BERLIN)).toBe( + "Nov 7, 03:00 PM to Nov 8, 03:00 PM", + ) + expect(formatSilenceRange(NOW + 60 * 60_000, NOW + 2 * 60 * 60_000, NOW, BERLIN)).toBe( + "02:00 PM to 03:00 PM", + ) + }) +}) + describe("activeSilenceFor", () => { it("picks the latest-ending silence in force, from the window not the stored status", () => { const long = silence("22222222-2222-4222-8222-222222222222", { rule_ids: [ruleId] }, -10, 120) diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 8db968ee93..77db386276 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -70,14 +70,36 @@ export const endsAtMs = (silence: V2AlertSilence): number => isoMs(silence.ends_ export const isCancelledSilence = (silence: V2AlertSilence): boolean => startsAtMs(silence) === endsAtMs(silence) -/** "15:00" today, "Nov 7, 09:00" on another day, in the viewer's zone. */ -export const formatSilenceTime = (ms: number, nowMs: number, timeZone: string): string => { - const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false }) +/** + * "03:00 PM" today, "Nov 7, 09:00 AM" on another day, in the viewer's zone. `h23` reads "15:00", + * for a label beside the form's 24h time picker. + */ +export const formatSilenceTime = ( + ms: number, + nowMs: number, + timeZone: string, + hourCycle?: "h12" | "h23", +): string => { + const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false, hourCycle }) return formatDateInTimezone(ms, { timeZone }) === formatDateInTimezone(nowMs, { timeZone }) ? time : `${formatDateInTimezone(ms, { timeZone, withYear: false })}, ${time}` } +/** "15:00 to 16:00" today, "Nov 7, 15:00 to 16:00" on another day: the day is named once when both ends share it. */ +export const formatSilenceRange = ( + startMs: number, + endMs: number, + nowMs: number, + timeZone: string, +): string => { + const isOneDay = formatDateInTimezone(startMs, { timeZone }) === formatDateInTimezone(endMs, { timeZone }) + const end = isOneDay + ? formatTimeInTimezone(endMs, { timeZone, withSeconds: false }) + : formatSilenceTime(endMs, nowMs, timeZone) + return `${formatSilenceTime(startMs, nowMs, timeZone)} to ${end}` +} + /** Where a silence stands at `nowMs`, from its window rather than the status it was read with. */ export const silenceStatusAt = (silence: V2AlertSilence, nowMs: number): V2AlertSilence["status"] => nowMs < startsAtMs(silence) ? "scheduled" : nowMs < endsAtMs(silence) ? "active" : "ended" diff --git a/apps/web/src/page/alerts/shared/slo-commands.ts b/apps/web/src/page/alerts/shared/slo-commands.ts index ae9de12599..e2d66603af 100644 --- a/apps/web/src/page/alerts/shared/slo-commands.ts +++ b/apps/web/src/page/alerts/shared/slo-commands.ts @@ -114,4 +114,4 @@ export const newSloHref = (search: typeof AlertsSloNewSearch.Type = {}): string export const editSloHref = (id: SloId): string => newSloHref({ sloId: encodePublicId(id) }) -export const slosTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "slos" } })) +export const slosTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "slos", search: {} })) diff --git a/apps/web/src/page/alerts/shared/test-rows.ts b/apps/web/src/page/alerts/shared/test-rows.ts index b6b41b16a6..74df831c09 100644 --- a/apps/web/src/page/alerts/shared/test-rows.ts +++ b/apps/web/src/page/alerts/shared/test-rows.ts @@ -4,8 +4,16 @@ import type { AlertRuleRow, AlertRuleStateRow, } from "@maple/web-data/collections/alerts" +import { alertsSectionRoute } from "../../../route" +import type { AlertsHubSearch, AlertsTab } from "../../../route/search" import { isoMs } from "./time" +/** A hub section and its filters, as the tests and labs spell where they stand. */ +export type HubAt = typeof AlertsHubSearch.Type & { readonly tab?: AlertsTab } + +/** The hub route for `at`: `/alerts` for the Inbox (the default), else `/alerts/`. */ +export const hubAt = ({ tab = "inbox", ...search }: HubAt = {}) => alertsSectionRoute(tab, search) + // Synced alert rows for the alerts pages' scene tests, the shape Electric delivers. export const ORG = "org_acme" diff --git a/apps/web/src/page/alerts/shared/uptime-commands.ts b/apps/web/src/page/alerts/shared/uptime-commands.ts index 18d2b3d1f7..847410c969 100644 --- a/apps/web/src/page/alerts/shared/uptime-commands.ts +++ b/apps/web/src/page/alerts/shared/uptime-commands.ts @@ -104,7 +104,7 @@ export const newMonitorHref = (search: typeof AlertsUptimeNewSearch.Type = {}): export const editMonitorHref = (id: UptimeMonitorId): string => newMonitorHref({ monitorId: encodePublicId(id) }) -export const uptimeTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "uptime" } })) +export const uptimeTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "uptime", search: {} })) /** Every check is a span in the `maple-uptime` service, namespace `maple-services`. */ export const UPTIME_SERVICE = UPTIME_SERVICE_NAME diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 8e8c49ab56..9b3b282415 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -2,13 +2,21 @@ import { describe, expect, it } from "vitest" import { AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" import { Option, Result, Schema } from "effect" +import * as Combobox from "@maple/views/ui/combobox" import { + endZoneLabel, init, isFormReady, matchersOf, Message, + type Model, + openFor, openNew, OutMessage, + parseLocalDateTime, + reasonError, + scopeError, + type ScopeField, silenceEndMs, untilError, update, @@ -36,6 +44,41 @@ describe("silenceEndMs", () => { ) }) + it("reads a custom end on the viewer's clock on either side of a DST change", () => { + // Berlin springs forward on 2026-03-29: 09:00 the day before is CET, 09:00 that day is CEST. + const before = Date.UTC(2026, 2, 28, 12, 0) + expect(parseLocalDateTime("2026-03-28T09:00", BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 28, 8, 0)), + ) + expect(parseLocalDateTime("2026-03-29T09:00", BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 29, 7, 0)), + ) + expect(silenceEndMs("custom", "2026-03-29T09:00", before, BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 29, 7, 0)), + ) + // New York falls back on 2026-11-01: 12:00 that day is EST, five hours behind UTC. + expect(parseLocalDateTime("2026-10-31T12:00", "America/New_York")).toEqual( + Option.some(Date.UTC(2026, 9, 31, 16, 0)), + ) + expect(parseLocalDateTime("2026-11-01T12:00", "America/New_York")).toEqual( + Option.some(Date.UTC(2026, 10, 1, 17, 0)), + ) + // The same wall clock read as UTC is a different instant: the form never assumes UTC. + expect(parseLocalDateTime("2026-03-29T09:00", "UTC")).toEqual( + Option.some(Date.UTC(2026, 2, 29, 9, 0)), + ) + }) + + it("names the zone the custom end is in, as it is at that end", () => { + const model = { timeZone: BERLIN, nowMs: Date.UTC(2026, 2, 28, 12, 0) } + expect(endZoneLabel({ ...model, until: "" })).toBe("CET") + expect(endZoneLabel({ ...model, until: "2026-03-28T18:00" })).toBe("CET") + expect(endZoneLabel({ ...model, until: "2026-03-29T09:00" })).toBe("CEST") + expect(endZoneLabel({ ...model, timeZone: "UTC", until: "" })).toBe("UTC") + // A zone ICU has no abbreviation for reads as its offset. + expect(endZoneLabel({ ...model, timeZone: "Asia/Kolkata", until: "" })).toBe("UTC+5:30") + }) + it("reads a custom end in the viewer's zone and refuses one in the past", () => { expect(silenceEndMs("custom", "2026-11-06T18:30", NOW, BERLIN)).toEqual( Option.some(Date.UTC(2026, 10, 6, 17, 30)), @@ -46,20 +89,123 @@ describe("silenceEndMs", () => { }) describe("matchersOf", () => { - it("splits typed lists and drops empty fields", () => { + it("takes the chips of each field and drops the empty ones", () => { expect( matchersOf({ _tag: "Typed", - services: " checkout, payments ,checkout", - tags: "", - environments: "production", + services: ["checkout", "payments"], + tags: [], + environments: ["production"], }), ).toEqual({ services: ["checkout", "payments"], environments: ["production"] }) }) }) +describe("scope chips", () => { + const pick = (model: Model, field: ScopeField, value: string) => + update( + model, + Message.GotScopeComboMessage({ field, message: Combobox.Message.ClickedItem({ value }) }), + ).model + + it("picking a suggestion adds a chip, picking it again or removing it takes it off", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const picked = pick(pick(opened, "services", "checkout"), "environments", "production") + expect(matchersOf(picked.scope)).toEqual({ services: ["checkout"], environments: ["production"] }) + expect(matchersOf(pick(picked, "services", "checkout").scope)).toEqual({ + environments: ["production"], + }) + const removed = update( + picked, + Message.GotScopeComboMessage({ + field: "environments", + message: Combobox.Message.RemovedChip({ value: "production" }), + }), + ).model + expect(matchersOf(removed.scope)).toEqual({ services: ["checkout"] }) + }) + + it("a typed name is picked with Enter, and the next open starts without chips", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const combo = (message: Combobox.Message) => Message.GotScopeComboMessage({ field: "tags", message }) + const enter = (model: Model, value: string) => + update( + update(model, combo(Combobox.Message.ChangedInput({ value }))).model, + // The view lists the suggestions, then what is typed when it is none of them. + combo(Combobox.Message.PressedInputKey({ key: "Enter", items: ["database", value] })), + ).model + // The first suggestion that matches wins; a name nothing suggests is added as typed. + const entered = enter(enter(opened, "dat"), "queue") + expect(matchersOf(entered.scope)).toEqual({ tags: ["database", "queue"] }) + expect(entered.combos.tags.inputValue).toBe("") + expect(matchersOf(openNew(entered, BERLIN, NOW).model.scope)).toEqual({}) + }) + + it("commas in a typed entry make one chip per name, trimmed, without empties or repeats", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const services = (message: Combobox.Message) => + Message.GotScopeComboMessage({ field: "services", message }) + const enter = (model: Model, value: string) => + update( + update(model, services(Combobox.Message.ChangedInput({ value }))).model, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api", value] })), + ).model + const entered = enter(opened, "demo-worker, demo-api,,") + expect(matchersOf(entered.scope)).toEqual({ services: ["demo-worker", "demo-api"] }) + expect(entered.combos.services.inputValue).toBe("") + // A name that is already a chip stays where it is; only the new one is added. + expect(matchersOf(enter(entered, "demo-api,demo-web").scope)).toEqual({ + services: ["demo-worker", "demo-api", "demo-web"], + }) + // A list picked from the popup with the pointer splits the same way. + expect(matchersOf(pick(opened, "services", "a , b").scope)).toEqual({ services: ["a", "b"] }) + }) + + it("typing a name that is already a chip, then Enter, keeps the chip and clears the input", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const services = (message: Combobox.Message) => + Message.GotScopeComboMessage({ field: "services", message }) + const picked = pick(opened, "services", "demo-api") + const typed = update(picked, services(Combobox.Message.ChangedInput({ value: "demo-api" }))).model + const entered = update( + typed, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api"] })), + ).model + expect(matchersOf(entered.scope)).toEqual({ services: ["demo-api"] }) + expect(entered.combos.services.inputValue).toBe("") + // With nothing typed, Enter on a highlighted option still toggles it, as a pointer pick does. + const highlighted = update( + picked, + services(Combobox.Message.PressedInputKey({ key: "ArrowDown", items: ["demo-api"] })), + ).model + expect( + matchersOf( + update( + highlighted, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api"] })), + ).model.scope, + ), + ).toEqual({}) + }) + + it("a scope fixed by a rule ignores the chips", () => { + const fixed = openFor( + init("silence"), + { label: "Rule", matchers: { rule_ids: [] } }, + BERLIN, + NOW, + ).model + expect(pick(fixed, "services", "checkout").scope).toEqual(fixed.scope) + }) +}) + describe("isFormReady", () => { - const typed = (services: string) => ({ _tag: "Typed" as const, services, tags: "", environments: "" }) + const typed = (service: string) => ({ + _tag: "Typed" as const, + services: service === "" ? [] : [service], + tags: [], + environments: [], + }) it("needs a reason and something to match", () => { const model = init("silence") @@ -88,6 +234,23 @@ describe("isFormReady", () => { Option.some("A silence can last at most 30 days"), ) }) + + it("a tried save on an incomplete form sends nothing and turns the errors on, an empty custom end included", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const custom = update(opened, Message.ChangedDuration({ duration: "custom" })).model + expect(untilError(custom)).toEqual(Option.none()) + const tried = update(custom, Message.ClickedSave()) + expect(tried.commands ?? []).toEqual([]) + expect(tried.model.isSaving).toBe(false) + expect(tried.model.isShowingErrors).toBe(true) + expect(untilError(tried.model)).toEqual(Option.some("Pick a date and time")) + expect(reasonError(tried.model.reason)).toEqual(Option.some("Add a reason")) + expect(scopeError(tried.model.scope)).toEqual( + Option.some("Add at least one service, environment or tag"), + ) + // The next open starts quiet again. + expect(openNew(tried.model, BERLIN, NOW).model.isShowingErrors).toBe(false) + }) }) describe("saving", () => { @@ -106,7 +269,7 @@ describe("saving", () => { } const saving = () => { const opened = openNew(init("silence"), BERLIN, NOW).model - const typed = { ...opened, reason: "deploy", scope: { ...opened.scope, services: "checkout" } } + const typed = { ...opened, reason: "deploy", scope: { ...opened.scope, services: ["checkout"] } } return update(typed, Message.ClickedSave()).model } diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index c2c034cdc7..a29b1de01b 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -3,13 +3,15 @@ import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetim import { Clock, DateTime, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" +import * as Combobox from "@maple/views/ui/combobox" 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 { shortZoneName } from "../../kit/timezone-zones" +import { hasMatchers, MatcherField, splitList } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" -// "Silence notifications": the dialog the hub, a rule and an incident open, as one Submodel. +// "Silence notifications": the side panel the hub, a rule and an incident open, as one Submodel. export const SilenceDuration = Schema.Literals(["1h", "4h", "morning", "custom"]) export type SilenceDuration = typeof SilenceDuration.Type @@ -17,16 +19,41 @@ export type SilenceDuration = typeof SilenceDuration.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"). */ +const Chips = Schema.Array(Schema.String) + +/** What the silence covers: fixed by the page that opened it, or picked as chips (the hub's "New silence"). */ export const Scope = Schema.Union([ Schema.TaggedStruct("Fixed", { label: Schema.String, matchers: V2AlertSilenceMatchers }), - Schema.TaggedStruct("Typed", TypedMatchers.fields), + Schema.TaggedStruct("Typed", { services: Chips, tags: Chips, environments: Chips }), ]) export type Scope = typeof Scope.Type +/** What each chips input offers before anything is typed: the names the org's rules already use. */ +export type ScopeSuggestions = Readonly>> + +const NO_CHIPS: Extract = { + _tag: "Typed", + services: [], + tags: [], + environments: [], +} + +/** One chips input per scope field; each field's chips live on the scope. */ +const ScopeCombos = Schema.Struct({ + services: Combobox.Model, + tags: Combobox.Model, + environments: Combobox.Model, +}) + +const initCombos = (id: string): typeof ScopeCombos.Type => { + const combo = (field: ScopeField) => Combobox.init(`${id}-${field}`, "multiple", { autoHighlight: true }) + return { services: combo("services"), tags: combo("tags"), environments: combo("environments") } +} + export const Model = Schema.Struct({ dialog: Dialog.Model, scope: Scope, + combos: ScopeCombos, reason: Schema.String, duration: SilenceDuration, /** The custom end, as a `datetime-local` input holds it (`2026-11-06T18:30`), in `timeZone`. */ @@ -35,20 +62,24 @@ export const Model = Schema.Struct({ /** The wall clock when the dialog opened: a custom end is checked against it. */ nowMs: Schema.Number, isSaving: Schema.Boolean, + /** A save was tried on an incomplete form: each field left out says so. */ + isShowingErrors: Schema.Boolean, /** Bumped on every open: a save's result only settles the form it was sent from. */ openCount: Schema.Number, }) export type Model = typeof Model.Type export const init = (id: string): Model => ({ - dialog: Dialog.init(id, "dialog"), - scope: { _tag: "Typed", services: "", tags: "", environments: "" }, + dialog: Dialog.init(id, "sheet"), + scope: NO_CHIPS, + combos: initCombos(id), reason: "", duration: "1h", until: "", timeZone: "UTC", nowMs: 0, isSaving: false, + isShowingErrors: false, openCount: 0, }) @@ -57,7 +88,7 @@ export const Message = defineMessageUnion({ ChangedReason: { value: Schema.String }, ChangedDuration: { duration: SilenceDuration }, ChangedUntil: { value: Schema.String }, - ChangedScopeField: { field: ScopeField, value: Schema.String }, + GotScopeComboMessage: { field: ScopeField, message: Combobox.Message }, ClickedCancel: {}, ClickedSave: {}, CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2AlertSilence, Schema.String) }, @@ -122,13 +153,27 @@ export const silenceEndMs = ( } } -/** The matchers the form describes; empty matchers mean there is nothing to save yet. */ +/** The matchers the form describes; a field without chips is left out, and none mean nothing to save yet. */ export const matchersOf = (scope: Scope): V2AlertSilenceMatchers => - scope._tag === "Fixed" ? scope.matchers : matchersFromTyped(scope) + scope._tag === "Fixed" + ? scope.matchers + : { + ...(scope.services.length === 0 ? undefined : { services: scope.services }), + ...(scope.tags.length === 0 ? undefined : { tags: scope.tags }), + ...(scope.environments.length === 0 ? undefined : { environments: scope.environments }), + } -/** Why a typed custom end cannot be used, or none when it can (or nothing is typed yet). */ +/** The zone the custom end is read in, as the viewer knows it at that end ("CET", then "CEST" past the change). */ +export const endZoneLabel = (model: Pick): string => + shortZoneName( + model.timeZone, + Option.getOrElse(parseLocalDateTime(model.until, model.timeZone), () => model.nowMs), + ) + +/** Why a typed custom end cannot be used, or none when it can; an empty one waits for a tried save. */ export const untilError = (model: Model): Option.Option => { - if (model.duration !== "custom" || model.until === "") return Option.none() + if (model.duration !== "custom") return Option.none() + if (model.until === "") return model.isShowingErrors ? Option.some("Pick a date and time") : Option.none() return Option.match(parseLocalDateTime(model.until, model.timeZone), { onNone: () => Option.some("Pick a date and time"), onSome: (endMs) => @@ -140,6 +185,15 @@ export const untilError = (model: Model): Option.Option => { }) } +/** A silence always says why: the reason shows on its row and on the pages it covers. */ +export const reasonError = (reason: string): Option.Option => + Str.isEmpty(Str.trim(reason)) ? Option.some("Add a reason") : Option.none() + +export const scopeError = (scope: Scope): Option.Option => + hasMatchers(matchersOf(scope)) + ? Option.none() + : Option.some("Add at least one service, environment or tag") + /** Ready to save: a reason, something to match and an end that is ahead and within 30 days. */ export const isFormReady = (model: Model): boolean => Str.isNonEmpty(Str.trim(model.reason)) && @@ -187,12 +241,14 @@ const open = (model: Model, scope: Scope, timeZone: string, nowMs: number): Retu { ...model, scope, + combos: initCombos(model.dialog.id), timeZone, nowMs, reason: "", duration: "1h", until: "", isSaving: false, + isShowingErrors: false, openCount: model.openCount + 1, }, Dialog.open(model.dialog), @@ -208,24 +264,58 @@ export const openFor = ( /** A silence the user scopes by service, tag and environment. */ export const openNew = (model: Model, timeZone: string, nowMs: number): Return => - open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone, nowMs) + open(model, NO_CHIPS, timeZone, nowMs) -const save = (model: Model): Return => - model.isSaving || !isFormReady(model) - ? { model } - : { - model: { ...model, isSaving: true }, - commands: [ - SaveSilence({ - openCount: model.openCount, - reason: Str.trim(model.reason), - matchers: matchersOf(model.scope), - duration: model.duration, - until: model.until, - timeZone: model.timeZone, - }), - ], - } +/** The chips for what was picked or typed: "checkout, payments" is two names, each listed once. */ +const chipsOf = (names: ReadonlyArray): ReadonlyArray => + Arr.dedupe(Arr.flatMap(names, splitList)) + +/** A chips input's step: its popup state stays on the combo, a changed selection lands on the scope. */ +const scopeComboStep = (model: Model, field: ScopeField, message: Combobox.Message): Return => { + if (model.scope._tag === "Fixed") return { model } + const chips = model.scope[field] + const step = Combobox.update({ ...model.combos[field], selected: chips }, message) + // Enter on typed text only ever adds: the combobox would toggle a name that is already a chip off. + const isTypedEntry = + message._tag === "PressedInputKey" && Str.isNonEmpty(Str.trim(model.combos[field].inputValue)) + const scope = + step.outMessage?._tag === "ChangedSelection" + ? { + ...model.scope, + [field]: chipsOf( + isTypedEntry ? [...chips, ...step.outMessage.selected] : step.outMessage.selected, + ), + } + : model.scope + return { + model: { ...model, scope, combos: { ...model.combos, [field]: step.model } }, + commands: Command.mapMessages(step.commands ?? [], (inner) => + Message.GotScopeComboMessage({ field, message: inner }), + ), + } +} + +/** Closes the panel, as Cancel does; a page whose URL holds it closes it on Back. */ +export const close = (model: Model): Return => liftDialog(model, Dialog.close(model.dialog)) + +const save = (model: Model): Return => { + if (model.isSaving) return { model } + // An incomplete form stays open and names what is missing, as the other alert forms do. + if (!isFormReady(model)) return { model: { ...model, isShowingErrors: true } } + return { + model: { ...model, isSaving: true }, + commands: [ + SaveSilence({ + openCount: model.openCount, + reason: Str.trim(model.reason), + matchers: matchersOf(model.scope), + duration: model.duration, + until: model.until, + timeZone: model.timeZone, + }), + ], + } +} export const update = (model: Model, message: Message): Return => Message.match(message, { @@ -233,11 +323,8 @@ export const update = (model: Model, message: Message): Return => ChangedReason: ({ value }) => ({ model: { ...model, reason: value } }), ChangedDuration: ({ duration }) => ({ model: { ...model, duration } }), ChangedUntil: ({ value }) => ({ model: { ...model, until: value } }), - ChangedScopeField: ({ field, value }) => - model.scope._tag === "Fixed" - ? { model } - : { model: { ...model, scope: { ...model.scope, [field]: value } } }, - ClickedCancel: () => liftDialog(model, Dialog.close(model.dialog)), + GotScopeComboMessage: ({ field, message: child }) => scopeComboStep(model, field, child), + ClickedCancel: () => close(model), ClickedSave: () => save(model), CompletedSave: ({ openCount, result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 6e1b31bfc3..1c8b288f3e 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -1,15 +1,35 @@ -import { Option, Array as Arr } from "effect" +import { Option, Array as Arr, String as Str } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { eyebrow } from "@maple/views/ui/eyebrow" -import { formDialog } from "@maple/views/ui/form-dialog" +import { button } from "@maple/views/ui/button" +import * as Combobox from "@maple/views/ui/combobox" +import { chipsView } from "@maple/views/ui/combobox-view" import { panel } from "@maple/views/ui/panel" import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { sidePanelSheet } from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" +import { fieldErrorText } from "@maple/views/ui/field" import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" -import { isFormReady, Message, type Model, type SilenceDuration, silenceEndMs, untilError } from "./dialog" -import { dialogTextField, type DialogTextFieldOptions, matcherFields } from "../shared/matcher-fields" +import { + endZoneLabel, + Message, + type Model, + reasonError, + type Scope, + scopeError, + type ScopeField, + type ScopeSuggestions, + type SilenceDuration, + silenceEndMs, + untilError, +} from "./dialog" +import { + dialogHelp, + dialogLabel, + dialogTextField, + type DialogTextFieldOptions, +} from "../shared/matcher-fields" import { formatSilenceTime } from "../shared/silences" type H = HtmlBuilder @@ -18,7 +38,7 @@ const DURATIONS: ReadonlyArray<{ value: SilenceDuration; label: string }> = [ { value: "1h", label: "1 hour" }, { value: "4h", label: "4 hours" }, { value: "morning", label: "Until 09:00" }, - { value: "custom", label: "Pick time" }, + { value: "custom", label: "Custom" }, ] /** "Silence for 1 hour", or the end it will stop at: "Silence until Nov 7, 18:30". */ @@ -30,9 +50,14 @@ const submitLabel = (model: Model): string => : Option.match(silenceEndMs(model.duration, model.until, model.nowMs, model.timeZone), { onNone: () => "Silence", onSome: (endMs) => - `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone)}`, + // 24h, as the "Until 09:00" choice and the end's time picker read. + `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone, "h23")}`, }) +/** A field's error once a save was tried. */ +const shown = (model: Model, error: Option.Option): Option.Option => + model.isShowingErrors ? error : Option.none() + const textField = (h: H, options: Omit, "idPrefix">): Html => dialogTextField(h, { ...options, idPrefix: "silence" }) @@ -44,77 +69,166 @@ const fixedScopeLine = (matchers: V2AlertSilenceMatchers): string => { : "Every notification of this rule" } -const scopeSection = (h: H, model: Model): Html => - vstack(h, { gap: "2" }, [ - eyebrow(h, { variant: "label", as: "div" }, ["Covers"]), - model.scope._tag === "Fixed" - ? panel(h, { padded: true, className: "gap-1" }, [ - typography(h, { size: "sm", weight: "medium" }, [model.scope.label]), - typography(h, { size: "xs", tone: "muted" }, [fixedScopeLine(model.scope.matchers)]), - ]) - : panel( - h, - { padded: true, className: "gap-3" }, - matcherFields(h, { - idPrefix: "silence", - typed: model.scope, - onInput: (field, value) => Message.ChangedScopeField({ field, value }), - }), - ), +const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholder: string }> = [ + { field: "services", label: "Services", placeholder: "Add a service" }, + { field: "environments", label: "Environments", placeholder: "Add an environment" }, + { field: "tags", label: "Tags", placeholder: "Add a tag" }, +] + +const SCOPE_HELP_ID = "silence-scope-help" +const SCOPE_ENTRY_HELP_ID = "silence-scope-entry-help" +const SCOPE_ERROR_ID = "silence-scope-error" + +const NO_SUGGESTIONS: ScopeSuggestions = { services: [], environments: [], tags: [] } + +/** The suggestions, then what is typed when it is none of them: a name no rule lists can still be added. */ +const itemsOf = (suggested: ReadonlyArray, typed: string): ReadonlyArray => { + const draft = Str.trim(typed) + return draft === "" || suggested.includes(draft) ? suggested : [...suggested, draft] +} + +/** One scope field: its label and the chips input, suggesting what the org's rules already name. */ +const chipsField = ( + h: H, + model: Model, + scope: Extract, + suggestions: ScopeSuggestions, + { field, label, placeholder }: (typeof SCOPE_FIELDS)[number], +): Html => { + const combo = model.combos[field] + const labelId = `${combo.id}-label` + return vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: labelId, htmlFor: Combobox.inputId(combo.id) }, label), + h.submodel({ + slotId: `${combo.id}-chips`, + model: { ...combo, selected: scope[field] }, + view: chipsView, + toParentMessage: (message) => Message.GotScopeComboMessage({ field, message }), + viewInputs: { + items: itemsOf(suggestions[field], combo.inputValue), + ariaLabelledBy: labelId, + ariaDescribedBy: `${SCOPE_ENTRY_HELP_ID} ${SCOPE_HELP_ID}`, + placeholder, + isDisabled: model.isSaving, + toView: (parts) => h.div([], parts), + }, + }), ]) +} -const durationSection = (h: H, model: Model): Html => - vstack(h, { gap: "2" }, [ - eyebrow(h, { variant: "label", as: "div" }, ["For"]), +const scopeSection = (h: H, model: Model, suggestions: ScopeSuggestions): ReadonlyArray => { + const scope = model.scope + return scope._tag === "Fixed" + ? [ + vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: "silence-covers-label" }, "Covers"), + panel(h, { padded: true, className: "gap-1" }, [ + typography(h, { size: "sm", weight: "medium" }, [scope.label]), + typography(h, { size: "xs", tone: "muted" }, [fixedScopeLine(scope.matchers)]), + ]), + ]), + ] + : [ + ...Arr.map(SCOPE_FIELDS, (entry) => chipsField(h, model, scope, suggestions, entry)), + vstack(h, { gap: "1.5" }, [ + // In the flow, not in the list's popup: there it sat over the next field's label. + dialogHelp( + h, + SCOPE_ENTRY_HELP_ID, + "Type a name and press Enter to add it. Commas separate several.", + ), + dialogHelp( + h, + SCOPE_HELP_ID, + "Fill in at least one. A rule is silenced only if it matches every field you fill in.", + ), + ...Option.toArray( + Option.map(shown(model, scopeError(scope)), (message) => + fieldErrorText( + h, + { attributes: [h.Id(SCOPE_ERROR_ID), h.Role("alert")] }, + message, + ), + ), + ), + ]), + ] +} + +const durationSection = (h: H, model: Model): ReadonlyArray => [ + vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: "silence-duration-label" }, "Duration"), segmentedSelect(h, { ariaLabel: "Silence duration", options: DURATIONS, value: model.duration, onChange: (duration) => Message.ChangedDuration({ duration }), - size: "sm", + size: "lg", className: "w-fit flex-wrap", }), - ...(model.duration === "custom" - ? [ + ]), + ...(model.duration === "custom" + ? [ + textField(h, { + name: "until", + // The viewer's own clock, named: "Ends, CET". + label: `Ends, ${endZoneLabel(model)}`, + value: model.until, + placeholder: "", + type: "datetime-local", + onInput: (value) => Message.ChangedUntil({ value }), + description: "Your local time. At most 30 days from now.", + error: untilError(model), + }), + ] + : []), +] + +/** The "Silence notifications" side panel: what it covers, for how long, and why. */ +export const view = Submodel.defineView( + (model, inputs, h) => + sidePanelSheet(h, { + sheet: model.dialog, + size: "compact", + toParentMessage: (message) => Message.GotDialogMessage({ message }), + title: "Silence notifications", + description: + "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", + body: [ + vstack(h, { gap: "5" }, [ + ...scopeSection(h, model, inputs.suggestions ?? NO_SUGGESTIONS), + ...durationSection(h, model), textField(h, { - name: "until", - label: "Ends", - value: model.until, - placeholder: "", - type: "datetime-local", - onInput: (value) => Message.ChangedUntil({ value }), - description: `In ${model.timeZone}. At most 30 days from now.`, - error: untilError(model), + name: "reason", + label: "Reason", + value: model.reason, + placeholder: "Deploying checkout v2.3", + onInput: (value) => Message.ChangedReason({ value }), + error: shown(model, reasonError(model.reason)), }), - ] - : []), - ]) - -/** The "Silence notifications" dialog: what it covers, for how long, and why. */ -export const view = Submodel.defineView((model, inputs, h) => - formDialog(h, { - slotId: `${inputs.id}-dialog`, - model: model.dialog, - toParentMessage: (message) => Message.GotDialogMessage({ message }), - className: "sm:max-w-lg", - title: "Silence notifications", - description: - "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", - panelClassName: "space-y-5", - fields: [ - scopeSection(h, model), - durationSection(h, model), - textField(h, { - name: "reason", - label: "Reason", - value: model.reason, - placeholder: "Deploying checkout v2.3", - onInput: (value) => Message.ChangedReason({ value }), - }), - ], - onSubmit: Message.ClickedSave(), - submitLabel: submitLabel(model), - isPending: model.isSaving, - isSubmitDisabled: !isFormReady(model), - }), + ]), + ], + onSubmit: Message.ClickedSave(), + footer: (close) => [ + button( + h, + { + variant: "outline", + isDisabled: model.isSaving, + attributes: model.isSaving ? [] : close, + }, + ["Cancel"], + ), + button( + h, + { + type: "submit", + isLoading: model.isSaving, + // Never disabled for a missing field: pressing it says what is missing. + isDisabled: model.isSaving, + }, + [submitLabel(model)], + ), + ], + }), ) diff --git a/apps/web/src/page/kit/timezone-zones.ts b/apps/web/src/page/kit/timezone-zones.ts index 6cf9a54649..091b719af5 100644 --- a/apps/web/src/page/kit/timezone-zones.ts +++ b/apps/web/src/page/kit/timezone-zones.ts @@ -26,6 +26,37 @@ const clockFormatter = Option.liftThrowable( new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hour12: false }), ) +const abbreviationFormatter = (locale: string) => + Option.liftThrowable( + (timeZone: string) => new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: "short" }), + ) +const ABBREVIATION_FORMATTERS = [abbreviationFormatter("en-GB"), abbreviationFormatter("en-US")] + +/** + * A zone's short name at `atMs`: its abbreviation where ICU has one (`CET`, `CEST`, `EST`), else + * its offset (`UTC+5:30`). An unknown zone reads `UTC`. + */ +export const shortZoneName = (timeZone: string, atMs: number): string => + Option.getOrElse( + Arr.findFirst( + Arr.flatMap(ABBREVIATION_FORMATTERS, (formatterFor) => + Option.toArray( + Option.flatMap(formatterFor(timeZone), (formatter) => + Option.map( + Arr.findFirst( + formatter.formatToParts(atMs), + (entry) => entry.type === "timeZoneName", + ), + (entry) => entry.value, + ), + ), + ), + ), + (name) => /^[A-Z]{2,5}$/.test(name), + ), + () => formatUtcOffset(timeZone, atMs), + ) + /** `formatUtcOffset`: `UTC+2`, `UTC-5:30`, `UTC`, the offset a zone is on at `atMs`. */ export const formatUtcOffset = (timeZone: string, atMs: number): string => { const part = Option.match(offsetFormatter(timeZone), { diff --git a/apps/web/src/page/services/detail/uptime-card.scene.test.ts b/apps/web/src/page/services/detail/uptime-card.scene.test.ts index 0e683b9dc5..60558c0397 100644 --- a/apps/web/src/page/services/detail/uptime-card.scene.test.ts +++ b/apps/web/src/page/services/detail/uptime-card.scene.test.ts @@ -73,7 +73,7 @@ describe("service detail, Uptime card", () => { "href", "/alerts/uptime/new?serviceName=checkout-api", ), - expectView(role("link", { name: "Open in Alerts" })).toHaveAttr("href", "/alerts?tab=uptime"), + expectView(role("link", { name: "Open in Alerts" })).toHaveAttr("href", "/alerts/uptime"), ) }) }) diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index e57a9395f9..c230a9dd05 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -34,7 +34,7 @@ type H = HtmlBuilder export const resourceHref = (link: AuditResourceLink): string => { switch (link.to) { case "/alerts/$ruleId": - return `/alerts/${encodeURIComponent(link.params.ruleId)}` + return `/alerts/rules/${encodeURIComponent(link.params.ruleId)}` case "/dashboards/$dashboardId": return `/dashboards/${encodeURIComponent(link.params.dashboardId)}` case "/errors/issues/$issueId": @@ -43,8 +43,8 @@ export const resourceHref = (link: AuditResourceLink): string => { return `/investigations/${encodeURIComponent(link.params.id)}` case "/anomalies/$incidentId": return `/anomalies/${encodeURIComponent(link.params.incidentId)}` - case "/alerts": - return hrefOf(AppRoute.Alerts({ search: link.search })) + case "/alerts/routing": + return hrefOf(AppRoute.AlertsSection({ section: "routing", search: {} })) case "/integrations": return hrefOf(AppRoute.Integrations({ search: link.search })) case "/settings": diff --git a/apps/web/src/page/settings/automation/view.ts b/apps/web/src/page/settings/automation/view.ts index e7072574c8..f00504f671 100644 --- a/apps/web/src/page/settings/automation/view.ts +++ b/apps/web/src/page/settings/automation/view.ts @@ -24,6 +24,7 @@ import { canManage, Message, type Model, type PolicyDraft } from "./model" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { Array as Arr, String as Str, Option } from "effect" +import { routingTabHref } from "../../alerts/shared/escalation-chains" type H = HtmlBuilder type Inputs = Readonly<{ nowMs: number; timeZone: string; isKnownMember: boolean }> @@ -124,7 +125,7 @@ const destinationsBlock = (h: H, model: Model, draft: PolicyDraft): Html => { "Escalation sends triaged issues to a destination. Add a Slack, email or webhook destination first.", ]), hstack(h, { wrap: true, className: "gap-x-4 gap-y-2" }, [ - linkButton(h, { size: "sm", variant: "outline", href: "/alerts?tab=routing" }, [ + linkButton(h, { size: "sm", variant: "outline", href: routingTabHref() }, [ "Add a destination", ]), docsLink(h, "destinations"), diff --git a/apps/web/src/page/settings/setup-health/derive.ts b/apps/web/src/page/settings/setup-health/derive.ts index dcf4e4321f..5df280ec43 100644 --- a/apps/web/src/page/settings/setup-health/derive.ts +++ b/apps/web/src/page/settings/setup-health/derive.ts @@ -53,7 +53,7 @@ const INTEGRATION_FOR_CHECK = new Map([ const link = (label: string, href: string): SetupHealthAction => ({ kind: "link", label, href }) const settingsTab = (tab: "ingestion" | "data-platform") => hrefOf(AppRoute.Settings({ search: { tab } })) -const alertsTab = (tab: "rules" | "routing") => hrefOf(AppRoute.Alerts({ search: { tab } })) +const alertsTab = (section: "rules" | "routing") => hrefOf(AppRoute.AlertsSection({ section, search: {} })) const integrations = (integration?: string) => hrefOf(AppRoute.Integrations({ search: integration === undefined ? {} : { integration } })) diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 6cc1190873..4d03b0a0b6 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -29,6 +29,7 @@ export const AppRoute = defineRouteUnion({ Unsubscribe: { search: Search.UnsubscribeSearch }, AgentSession: { sessionId: Schema.String, search: Search.AgentSessionSearch }, AlertRule: { ruleId: AlertRuleId, search: Search.AlertRuleSearch }, + AlertRuleLegacy: { ruleId: AlertRuleId, search: Search.AlertRuleSearch }, AlertsCreate: { search: Search.AlertsCreateSearch }, AlertsUptimeNew: { search: Search.AlertsUptimeNewSearch }, AlertsUptimeMonitor: { monitorId: Schema.String, search: Search.AlertsUptimeMonitorSearch }, @@ -78,6 +79,7 @@ export const AppRoute = defineRouteUnion({ Trace: { traceId: Schema.String, search: Search.TraceSearch }, AgentSessions: { search: Search.AgentSessionsSearch }, Alerts: { search: Search.AlertsSearch }, + AlertsSection: { section: Search.AlertsSection, search: Search.AlertsHubSearch }, Analytics: { search: Search.AnalyticsSearch }, Anomalies: { search: Search.AnomaliesSearch }, CodeReview: { search: Search.CodeReviewSearch }, diff --git a/apps/web/src/route/index.ts b/apps/web/src/route/index.ts index 952e2e784a..71e42e5619 100644 --- a/apps/web/src/route/index.ts +++ b/apps/web/src/route/index.ts @@ -1,3 +1,3 @@ export { AppRoute, isPublicRoute, type RouteTag } from "./app-route" -export { flaggedRouteRedirect, Redirect, routeRedirect } from "./redirect" +export { alertsSectionRoute, flaggedRouteRedirect, Redirect, routeRedirect } from "./redirect" export { hrefOf, urlToAppRoute } from "./routers" diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index e21bd3f792..db4ae480eb 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -3,7 +3,14 @@ import { Option, Predicate, Schema } from "effect" import { AppRoute } from "./app-route" import { hrefOf } from "./routers" import { DATA_INTEGRATIONS } from "./data-integrations" -import { type AlertsSearch, type AnomaliesSearch, ErrorsSearch } from "./search" +import { + type AlertsHubSearch, + type AlertsSearch, + AlertsSection, + type AlertsTab, + type AnomaliesSearch, + ErrorsSearch, +} from "./search" /** Where a route sends the browser instead of rendering, and whether it replaces history. */ export const Redirect = Schema.Struct({ url: Schema.String, isReplace: Schema.Boolean }) @@ -60,6 +67,23 @@ const anomaliesViewSearch = (search: typeof AnomaliesSearch.Type): typeof Alerts } } +const decodeAlertsSection = Schema.decodeUnknownOption(AlertsSection) + +/** The section an old `?tab=` named: "oncall" and "settings" were renamed, the rest is the Inbox. */ +const legacyAlertsSection = (tab: string): AlertsTab => + tab === "oncall" + ? "on-call" + : tab === "settings" + ? "routing" + : Option.getOrElse(decodeAlertsSection(tab), (): "inbox" => "inbox") + +/** `/alerts` for the Inbox, else `/alerts/
`, with the hub's filters. */ +export const alertsSectionRoute = ( + section: AlertsTab, + search: typeof AlertsHubSearch.Type, +): Extract => + section === "inbox" ? AppRoute.Alerts({ search }) : AppRoute.AlertsSection({ section, search }) + const DATA_INTEGRATION_IDS: ReadonlySet = new Set(DATA_INTEGRATIONS) const redirect = (to: AppRoute, isReplace: boolean) => @@ -97,12 +121,18 @@ export const routeRedirect = (route: AppRoute): Option.Option => InfraDiscover: () => redirect(AppRoute.Infra({ search: {} }), true), InfraLegacyHost: ({ hostName }) => redirect(AppRoute.InfraHost({ hostName, search: {} }), true), AlertIncidentsIndex: () => redirect(AppRoute.Alerts({ search: {} }), true), + /** A rule moved under Rules; old links (notifications, the apps) keep their tab and window. */ + AlertRuleLegacy: ({ ruleId, search }) => redirect(AppRoute.AlertRule({ ruleId, search }), true), /** The anomalies list is the Alerts Inbox's Anomalies view; one service and the status carry over. */ Anomalies: ({ search }) => redirect(AppRoute.Alerts({ search: anomaliesViewSearch(search) }), true), - /** Status pages left the Alerts hub for their own sidebar entry. */ - Alerts: ({ search }) => - search.tab === "status-pages" ? redirect(AppRoute.StatusPages(), true) : Option.none(), + /** A legacy `?tab=` opens its section's own path; status pages left the hub for the sidebar. */ + Alerts: ({ search: { tab, ...search } }) => + tab === undefined + ? Option.none() + : tab === "status-pages" + ? redirect(AppRoute.StatusPages(), true) + : redirect(alertsSectionRoute(legacyAlertsSection(tab), search), true), AlertsStatusPage: ({ pageId, search }) => redirect( AppRoute.StatusPage({ diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 2c35a58573..04c7c0d058 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -110,11 +110,55 @@ describe("routes", () => { expect(hrefOf(route)).toBe("/status-pages/stp_abc?view=settings") }) + test("each Alerts section has its own path, beside the detail pages under it", () => { + for (const section of ["rules", "uptime", "slos", "silences", "maintenance", "on-call", "routing"]) { + const route = parse(`/alerts/${section}`) + expect(route).toMatchObject({ _tag: "AlertsSection", section }) + expect(hrefOf(route)).toBe(`/alerts/${section}`) + } + expect(parse("/alerts")._tag).toBe("Alerts") + expect(hrefOf(parse("/alerts/rules?status=firing"))).toBe("/alerts/rules?status=firing") + expect(parse("/alerts/uptime/new")._tag).toBe("AlertsUptimeNew") + expect(parse("/alerts/uptime/mon_1")._tag).toBe("AlertsUptimeMonitor") + expect(parse("/alerts/slos/slo_1")._tag).toBe("AlertsSlo") + expect(parse("/alerts/schedules/sch_1")._tag).toBe("AlertsSchedule") + expect(parse("/alerts/incidents/inc_1")._tag).toBe("AlertIncident") + expect(parse("/alerts/create")._tag).toBe("AlertsCreate") + expect(parse("/alerts/rules/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") + expect(parse("/alerts/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRuleLegacy") + expect(parse("/alerts/nope")._tag).toBe("NotFound") + }) + + test("an old `?tab=` link replaces itself with its section's path and keeps its other params", () => { + const redirectOf = (url: string) => Option.map(routeRedirect(parse(url)), (to) => to.url) + expect(redirectOf("/alerts?tab=rules&status=firing")).toEqual( + Option.some("/alerts/rules?status=firing"), + ) + expect(redirectOf("/alerts?tab=%22silences%22&window=mw_1")).toEqual( + Option.some("/alerts/silences?window=mw_1"), + ) + expect(redirectOf("/alerts?tab=oncall")).toEqual(Option.some("/alerts/on-call")) + expect(redirectOf("/alerts?tab=settings")).toEqual(Option.some("/alerts/routing")) + expect(redirectOf("/alerts?tab=inbox&signal=anomaly")).toEqual(Option.some("/alerts?signal=anomaly")) + expect(redirectOf("/alerts?tab=overview")).toEqual(Option.some("/alerts")) + expect(routeRedirect(parse("/alerts?tab=uptime"))).toEqual( + Option.some({ url: "/alerts/uptime", isReplace: true }), + ) + // A rule's old address (notifications, the apps) opens it under Rules, keeping its tab and window. + expect(redirectOf("/alerts/0199a001-0000-7000-8000-000000000001?tab=history&timePreset=7d")).toEqual( + Option.some("/alerts/rules/0199a001-0000-7000-8000-000000000001?tab=history&timePreset=7d"), + ) + expect(routeRedirect(parse("/alerts/rules/0199a001-0000-7000-8000-000000000001"))).toEqual( + Option.none(), + ) + expect(routeRedirect(parse("/alerts?signal=anomaly"))).toEqual(Option.none()) + expect(routeRedirect(parse("/alerts/rules"))).toEqual(Option.none()) + }) + test("the old status page links under Alerts redirect to their own section", () => { expect(routeRedirect(parse("/alerts?tab=%22status-pages%22"))).toEqual( Option.some({ url: "/status-pages", isReplace: true }), ) - expect(routeRedirect(parse("/alerts?tab=%22uptime%22"))).toEqual(Option.none()) expect(routeRedirect(parse("/alerts/status-pages/stp_abc"))).toEqual( Option.some({ url: "/status-pages/stp_abc", isReplace: true }), ) diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 08e3c2da40..02f882e357 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -71,11 +71,18 @@ const routers = { Route.mapTo(AppRoute.AgentSession), ), AlertRule: pipe( - path("alerts"), + path("alerts", "rules"), slash(schemaSegment("ruleId", AlertRuleId)), search(Search.AlertRuleSearch), Route.mapTo(AppRoute.AlertRule), ), + // A rule's page before it moved under Rules; notifications still link here, `routeRedirect` sends them on. + AlertRuleLegacy: pipe( + path("alerts"), + slash(schemaSegment("ruleId", AlertRuleId)), + search(Search.AlertRuleSearch), + Route.mapTo(AppRoute.AlertRuleLegacy), + ), AlertsCreate: pipe( path("alerts", "create"), search(Search.AlertsCreateSearch), @@ -284,6 +291,12 @@ const routers = { Route.mapTo(AppRoute.AgentSessions), ), Alerts: pipe(path("alerts"), search(Search.AlertsSearch), Route.mapTo(AppRoute.Alerts)), + AlertsSection: pipe( + path("alerts"), + slash(schemaSegment("section", Search.AlertsSection)), + search(Search.AlertsHubSearch), + Route.mapTo(AppRoute.AlertsSection), + ), Analytics: pipe(path("analytics"), search(Search.AnalyticsSearch), Route.mapTo(AppRoute.Analytics)), Anomalies: pipe(path("anomalies"), search(Search.AnomaliesSearch), Route.mapTo(AppRoute.Anomalies)), CodeReview: pipe(path("code-review"), search(Search.CodeReviewSearch), Route.mapTo(AppRoute.CodeReview)), @@ -521,6 +534,7 @@ const routers = { // Literal routes come before the param routes that would also match them (`/infra/hosts`). export const urlToAppRoute = Route.parseUrlWithFallback( Route.oneOf( + routers.AlertsSection, routers.AlertsCreate, routers.AlertsUptimeNew, routers.AlertsUptimeMonitor, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index d44acc4a11..6c0ca40cfb 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -520,8 +520,23 @@ export const AgentSessionsSearch = Schema.Struct({ sortDir: Schema.optional(AiSessionSortDir), }) -export const AlertsSearch = Schema.Struct({ - tab: StringParam, +/** The Alerts hub's sections past the Inbox, each at `/alerts/
`. */ +export const AlertsSection = Schema.Literals([ + "rules", + "uptime", + "slos", + "silences", + "maintenance", + "on-call", + "routing", +]) +export type AlertsSection = typeof AlertsSection.Type + +/** A hub section: the Inbox at `/alerts`, the rest at `/alerts/
`. */ +export const AlertsTab = Schema.Union([Schema.Literal("inbox"), AlertsSection]) +export type AlertsTab = typeof AlertsTab.Type + +const alertsHubSearchFields = { serviceName: StringParam, status: StringParam, /** The Inbox narrowed to one kind of incident: "anomaly" lists anomalies alone. */ @@ -530,7 +545,15 @@ export const AlertsSearch = Schema.Struct({ anomalyStatus: StringParam, /** The Silences tab narrowed to one maintenance window's occurrences (`mw_…`). */ window: StringParam, -}) + /** The open side panel (`SHEET_PARAM`): "silence" is the new-silence form. */ + sheet: StringParam, +} + +/** `/alerts` and `/alerts/
`: the filters every section shares. */ +export const AlertsHubSearch = Schema.Struct(alertsHubSearchFields) + +/** `/alerts`: a legacy `tab` redirects to its section's own path. */ +export const AlertsSearch = Schema.Struct({ tab: StringParam, ...alertsHubSearchFields }) export const AnalyticsSearch = Schema.Struct({ ...analyticsFilterSearchFields, diff --git a/apps/web/src/shell/alerts-inbox.ts b/apps/web/src/shell/alerts-inbox.ts new file mode 100644 index 0000000000..0860e5bd8f --- /dev/null +++ b/apps/web/src/shell/alerts-inbox.ts @@ -0,0 +1,23 @@ +import { Array as Arr, Stream } from "effect" +import { type OrgCollections, orgLive } from "../data/collections/org-collections" +import { liveQueryStream } from "../data/live-query" + +/** + * The Alerts › Inbox count: open incidents nobody has acknowledged, over the org's live + * `alert_incidents`. The alerts pages already read that collection, so reading it while one is open + * requests no other shape. + */ +export const alertsInboxCounts = ( + orgId: string, + toMessage: (count: number) => Message, +): Stream.Stream => + orgLive(orgId, (set) => + liveQueryStream( + (q) => q.from({ i: set.alertIncidents }), + (rows) => + toMessage( + Arr.filter(rows, (row) => row.status === "open" && (row.acknowledged_at ?? null) === null) + .length, + ), + ), + ) diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index 18bb2b2627..bffa528e4e 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -19,6 +19,7 @@ export { } from "./subscription" export { pinnedDashboardRows } from "./dashboards-shape" +export { alertsInboxCounts } from "./alerts-inbox" export { BUILT_COMMIT, versionChecks } from "./version" export { checklistTicked, diff --git a/apps/web/src/shell/message.ts b/apps/web/src/shell/message.ts index 966f002435..9b8d333801 100644 --- a/apps/web/src/shell/message.ts +++ b/apps/web/src/shell/message.ts @@ -80,6 +80,8 @@ export const Message = defineMessageUnion({ FailedLoadSources: { orgId: Schema.String }, SucceededLoadDashboards: { orgId: Schema.String, dashboards: Schema.Array(PinnedDashboardRow) }, FailedLoadDashboards: { orgId: Schema.String }, + /** The Alerts › Inbox count: open incidents nobody has acknowledged. */ + UpdatedAlertsInboxCount: { orgId: Schema.String, count: Schema.Number }, /** `isFromCache`: the org's fresh "no schedules" answer stood in for the request. */ SucceededLoadOnCall: { orgId: Schema.String, diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index 8f620568fb..1fd15184b5 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -78,6 +78,8 @@ export const Model = Schema.Struct({ /** `maple.infra-surfaces.`: each org's last settled surfaces, so the sidebar lays out once. */ rememberedSurfaces: Schema.Record(Schema.String, Schema.Array(NavSurface)), dashboards: DashboardsData.schema, + /** Read only while an alerts page is open; `null` until then. */ + alertsInboxCount: Schema.NullOr(Schema.Number), /** The org's schedules and who holds each now, for the footer's on-call pill. */ onCall: OnCallData.schema, /** `maple.sidebar.collapsed-groups`: nav group ids the user folded. */ @@ -175,6 +177,7 @@ export const init = (flags: ShellFlags): Model => ({ integrations: PENDING_INTEGRATIONS, rememberedSurfaces: flags.rememberedSurfaces ?? {}, dashboards: DashboardsData.Idle(), + alertsInboxCount: null, onCall: OnCallData.Idle(), collapsedNavGroups: flags.collapsedNavGroups ?? [], onCallNowMs: 0, diff --git a/apps/web/src/shell/mounts.ts b/apps/web/src/shell/mounts.ts index 6c583acff7..4465f37236 100644 --- a/apps/web/src/shell/mounts.ts +++ b/apps/web/src/shell/mounts.ts @@ -1,4 +1,4 @@ -import { Effect, Queue, Schema, Stream } from "effect" +import { Array as Arr, Effect, Queue, Schema, Stream } from "effect" import { Dom, Mount } from "foldkit" import { Align, measurePosition, Side } from "@maple/views/ui/anchor" import { applyScrollOverflow } from "@maple/views/ui/scroll-area" @@ -57,17 +57,35 @@ export const MountNamespaceSearch = Mount.defineStream("MountNamespaceSearch", { }) /** The sidebar nav's `ScrollArea` overflow (its bottom fade), kept current on scroll and resize. */ -/** Scrolls the nav so the active row clears the pinned footer on a deep-linked load. */ +/** + * Scrolls the nav so the open section (its row and every sub-item) clears the pinned footer, or + * the active row alone when the section is taller than the nav. + */ const revealActiveRow = (viewport: HTMLElement): void => { const rows = viewport.querySelectorAll('[data-active="true"]') const row = rows.item(rows.length - 1) if (row === null) return - const top = row.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop - const bottom = top + row.offsetHeight - if (bottom > viewport.scrollTop + viewport.clientHeight) - viewport.scrollTop = bottom - viewport.clientHeight + 8 + const section = row.closest('[data-sidebar="menu-item"]') ?? row + // Past the scroll area's 1.5rem edge fades, so the last sub-item reads at full strength. + const margin = 28 + const target = section.offsetHeight + 2 * margin <= viewport.clientHeight ? section : row + const top = target.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop + const bottom = top + target.offsetHeight + if (top < viewport.scrollTop + margin) viewport.scrollTop = Math.max(0, top - margin) + else if (bottom > viewport.scrollTop + viewport.clientHeight - margin) + viewport.scrollTop = bottom - viewport.clientHeight + margin } +/** Which rows are active: a re-render that leaves them alone doesn't move the nav. */ +const activeKeyOf = (viewport: HTMLElement): string => + Arr.join( + Arr.map( + Arr.fromIterable(viewport.querySelectorAll('[data-active="true"]')), + (row) => row.getAttribute("href") ?? "", + ), + " ", + ) + export const MeasureSidebarOverflow = Mount.defineStream("MeasureSidebarOverflow", { messages: [Message.MeasuredSidebarOverflow], execute: ({ element }) => @@ -81,12 +99,27 @@ export const MeasureSidebarOverflow = Mount.defineStream("MeasureSidebarOverflow if (element.firstElementChild !== null) observer.observe(element.firstElementChild) element.addEventListener("scroll", measure, { passive: true }) revealActiveRow(element) + // A route change moves the active row (or opens a section): bring it into view too. + const seen = { key: activeKeyOf(element) } + const routed = new MutationObserver(() => { + const key = activeKeyOf(element) + if (key === seen.key) return + seen.key = key + revealActiveRow(element) + }) + routed.observe(element, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["data-active"], + }) measure() Queue.offerUnsafe(queue, Message.MeasuredSidebarOverflow()) - return { observer, measure } + return { observer, measure, routed } }), (handles) => Effect.sync(() => { + handles?.routed.disconnect() handles?.observer.disconnect() if (handles !== undefined) element.removeEventListener("scroll", handles.measure) }), diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 755254d9cc..067da40142 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -34,6 +34,12 @@ export interface NavSubItem { }> /** The command palette's title, when the row's own reads ambiguously there. */ readonly paletteTitle?: string + /** Pages under another path that this row stands in for (schedules under On-call). */ + readonly alsoActive?: ReadonlyArray + /** A live count beside the title, read by the shell (the Inbox's unacknowledged incidents). */ + readonly count?: "alertsInbox" + /** Starts a group of rows: a small gap above it, no heading. */ + readonly startsGroup?: boolean } export interface NavItem { @@ -129,6 +135,48 @@ const exploreItem: NavItem = { ], } +const alertsItem: NavItem = { + title: "Alerts", + href: "/alerts", + icon: "BellIcon", + alsoActive: ["/anomalies"], + subItems: [ + { + title: "Inbox", + href: "/alerts", + icon: "BellIcon", + paletteTitle: "Alerts", + alsoActive: ["/anomalies"], + count: "alertsInbox", + }, + { + title: "Rules", + href: "/alerts/rules", + icon: "ChartLineIcon", + paletteTitle: "Alert rules", + alsoActive: ["/alerts/create"], + startsGroup: true, + }, + { title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" }, + { title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" }, + { title: "Silences", href: "/alerts/silences", icon: "BellSlashIcon", startsGroup: true }, + { + title: "Maintenance", + href: "/alerts/maintenance", + icon: "CalendarIcon", + paletteTitle: "Maintenance windows", + }, + { + title: "On-call", + href: "/alerts/on-call", + icon: "UsersIcon", + alsoActive: ["/alerts/schedules"], + startsGroup: true, + }, + { title: "Routing", href: "/alerts/routing", icon: "BranchForkIcon", paletteTitle: "Alert routing" }, + ], +} + /** The org rollout flags that gate a nav row each. */ export type NavFlags = Readonly<{ releases: boolean @@ -171,7 +219,7 @@ export const navGroups = (flags: NavFlags): ReadonlyArray => [ label: "Triage", items: [ { title: "Errors", href: "/errors", icon: "CircleWarningIcon" }, - { title: "Alerts", href: "/alerts", icon: "BellIcon", alsoActive: ["/anomalies"] }, + alertsItem, { title: "Code review", href: "/code-review", @@ -197,8 +245,9 @@ export const isPathActive = (currentPath: string, href: string): boolean => export const matchSubItem = (currentPath: string, sub: NavSubItem): string | undefined => Arr.reduce( - Arr.filter([sub.href, ...Arr.map(sub.views ?? [], (view) => view.href)], (href) => - isPathActive(currentPath, href), + Arr.filter( + [sub.href, ...Arr.map(sub.views ?? [], (view) => view.href), ...(sub.alsoActive ?? [])], + (href) => isPathActive(currentPath, href), ), undefined, (best, href) => (best === undefined || href.length > best.length ? href : best), @@ -209,6 +258,9 @@ export const isNavItemActive = (currentPath: string, item: NavItem): boolean => Arr.some(item.alsoActive ?? [], (href) => isPathActive(currentPath, href)) || Arr.some(item.subItems ?? [], (sub) => matchSubItem(currentPath, sub) !== undefined) +/** The Alerts row is open here, so its Inbox count is shown (and worth reading). */ +export const isAlertsOpenAt = (currentPath: string): boolean => isNavItemActive(currentPath, alertsItem) + export interface NavPreview { readonly glyphs: ReadonlyArray /** Marks past the cap, drawn as `+N`. */ diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index b3362e79f3..3040d844a7 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -91,7 +91,7 @@ describe("on-call pill", () => { given(withOnCall([shift()])), measured, expect(pill).toHaveText("On call until Mon 09:00"), - expect(pill).toHaveAttr("href", "/alerts?tab=oncall"), + expect(pill).toHaveAttr("href", "/alerts/on-call"), expect(pill).toHaveAttr("data-tooltip", "On call until Mon 09:00 CET, 16 Nov · Checkout primary"), ) }) @@ -118,7 +118,7 @@ describe("on-call pill", () => { { update, view }, given(withOnCall([shift({ userId: "user_someone" })])), measured, - expect(pill).toHaveAttr("href", "/alerts?tab=oncall"), + expect(pill).toHaveAttr("href", "/alerts/on-call"), expect(pill).not.toHaveAttr("data-tooltip"), ) }) diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index f395917878..c7444e9066 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -425,6 +425,83 @@ describe("app update banner", () => { }) }) +describe("alerts section", () => { + const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) + + test("on an alerts page, Alerts opens its sections with the open one current", () => { + scene( + { update, view: shellAt("/alerts/rules") }, + given(signedIn()), + measured, + expect(role("link", { name: "Rules" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Rules" })).toHaveAttr("href", "/alerts/rules"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Silences" })).toHaveAttr("href", "/alerts/silences"), + expect(role("link", { name: "On-call" })).toHaveAttr("href", "/alerts/on-call"), + ) + scene( + { update, view: shellAt("/alerts") }, + given(signedIn()), + measured, + expect(role("link", { name: "Inbox" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Rules" })).not.toHaveAttr("aria-current", "page"), + ) + }) + + test("a detail page marks the section it belongs to", () => { + scene( + { update, view: shellAt("/alerts/schedules/sch_1") }, + given(signedIn()), + measured, + expect(role("link", { name: "On-call" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + ) + scene( + { update, view: shellAt("/alerts/uptime/mon_1") }, + given(signedIn()), + measured, + expect(role("link", { name: "Uptime" })).toHaveAttr("aria-current", "page"), + ) + scene( + { update, view: shellAt("/alerts/rules/0199a001-0000-7000-8000-000000000001") }, + given(signedIn()), + measured, + expect(role("link", { name: "Rules" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + ) + }) + + test("the Inbox counts unacknowledged incidents for this org", () => { + const counted = update(signedIn(), Message.UpdatedAlertsInboxCount({ orgId: "org_parity", count: 3 })) + scene( + { update, view: shellAt("/alerts/rules") }, + given(counted.model), + measured, + expect(text("3")).toExist(), + ) + // Nothing to act on shows no badge; another org's count is dropped, and an org change clears it. + scene( + { update, view: shellAt("/alerts/rules") }, + given(signedIn({ alertsInboxCount: 0 })), + measured, + expect(text("0")).toBeAbsent(), + ) + const stray = update(signedIn(), Message.UpdatedAlertsInboxCount({ orgId: "org_other", count: 4 })) + expectVitest(stray.model.alertsInboxCount).toBeNull() + expectVitest(enter("org_other")(counted.model).model.alertsInboxCount).toBeNull() + }) + + test("elsewhere the sections stay hidden", () => { + scene( + { update, view: shellAt("/logs") }, + given(signedIn()), + measured, + expect(role("link", { name: "Alerts" })).toExist(), + expect(role("link", { name: "Silences" })).toBeAbsent(), + ) + }) +}) + describe("services section", () => { test("on the service map, Services opens with the map row current", () => { scene( diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 269f59789c..c436e5cc0b 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -71,6 +71,7 @@ export const enter = infraPresence: () => InfraPresenceData.Idle(), integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), + alertsInboxCount: () => null, onCall: () => OnCallData.Idle(), onCallNextReadAtMs: () => null, onCallBackoffMs: () => 0, @@ -563,6 +564,10 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => modifyFields(current, { sources: () => SourcesData.Failure({ error: "Failed" }) }), ), // A result for an org the shell has left is dropped. + UpdatedAlertsInboxCount: ({ orgId, count }) => + Option.contains(model.maybeOrgId, orgId) + ? { model: modifyFields(model, { alertsInboxCount: () => count }) } + : { model }, SucceededLoadDashboards: ({ orgId, dashboards }) => Option.contains(model.maybeOrgId, orgId) ? { diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index aff7e6f38e..092bc56299 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -1,4 +1,5 @@ import { isClerkAuthEnabled } from "../lib/services/common/auth-mode" +import { cn } from "@maple/ui/lib/utils" import { Option, Array as Arr, String as Str } from "effect" import type { InfraSurfaceLiteral } from "@maple/domain/http" import { AsyncData } from "foldkit" @@ -10,6 +11,7 @@ import { sidebarGroupLabel, sidebarGroupToggle, sidebarMenu, + sidebarMenuBadge, sidebarMenuButton, sidebarMenuItem, sidebarMenuSub, @@ -96,17 +98,32 @@ const collapsedSectionRow = (h: HtmlBuilder, model: Model, item: NavIte ), ]) -const subList = (h: HtmlBuilder, subItems: ReadonlyArray, currentPath: string): Html => { +/** The live count a sub-item shows, when it has one and it is above zero (an org change clears it). */ +const subCountOf = (model: Model, sub: NavSubItem): number | undefined => + sub.count === "alertsInbox" && model.alertsInboxCount !== null && model.alertsInboxCount > 0 + ? model.alertsInboxCount + : undefined + +const subList = ( + h: HtmlBuilder, + model: Model, + subItems: ReadonlyArray, + currentPath: string, +): Html => { const activeSubHref = activeSubHrefOf(subItems, currentPath) return sidebarMenuSub( h, "mx-0 ms-4 translate-x-0 gap-0 border-l-0 px-0 py-0", - Arr.map(subItems, (sub) => - sidebarMenuSubItem( + Arr.map(subItems, (sub, index) => { + const count = subCountOf(model, sub) + return sidebarMenuSubItem( h, - sub.href === activeSubHref - ? "border-sidebar-primary border-l-2 ps-2.5" - : "border-sidebar-border border-l-2 ps-2.5", + cn( + "border-l-2 ps-2.5", + sub.href === activeSubHref ? "border-sidebar-primary" : "border-sidebar-border", + // A 4px gap, not a rule, sets a group of rows apart. + sub.startsGroup === true && index > 0 && "mt-1", + ), [ sidebarMenuSubButton( h, @@ -128,9 +145,24 @@ const subList = (h: HtmlBuilder, subItems: ReadonlyArray, c h.span([], [sub.title]), ], ), + ...(count === undefined + ? [] + : [ + sidebarMenuBadge( + h, + [ + typography( + h, + { size: "xs", mono: true, numeric: true, tone: "error" }, + [String(count)], + ), + ], + "top-1/2 -translate-y-1/2", + ), + ]), ], - ), - ), + ) + }), ) } @@ -212,7 +244,7 @@ const navRow = ( ), ...(item.badge === undefined ? [] : [navBadge(h, item.badge)]), ...(subItems !== undefined && isActive && (subItems.length > 0 || !isGroupFolded) - ? [subList(h, subItems, currentPath)] + ? [subList(h, model, subItems, currentPath)] : []), ]) } diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index da920252d9..134b8cdb59 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -1,5 +1,15 @@ import { isClerkAuthEnabled } from "./lib/services/common/auth-mode" -import { Clock, Duration, Effect, Option, Schedule, Schema, Stream } from "effect" +import { + Array as Arr, + Clock, + Duration, + Effect, + Option, + Schedule, + Schema, + Stream, + String as Str, +} from "effect" import { Dom, Subscription } from "foldkit" import { Message } from "./message" import type { Resources } from "./platform/resources" @@ -17,6 +27,8 @@ import { connectSubscriptions } from "./page/onboarding/quick-start-connect" import * as SourcesFlow from "./page/sources/new/embed" import { clerkSnapshots } from "./platform/clerk" import * as Shell from "./shell" +import { isAlertsOpenAt } from "./shell/nav" +import { hrefOf } from "./route" import { staleBuildRecovery } from "./shell/version" import { pollTicks } from "./data/poll" import { collectionLoadFailed } from "./page/alerts/shared/collection-load" @@ -78,6 +90,14 @@ const shellDashboardsOrgId = (model: Model): string | null => ? Option.getOrNull(model.shell.maybeOrgId) : null +/** The org whose Alerts › Inbox count the sidebar shows: while an alerts page has the row open. */ +const shellAlertsInboxOrgId = (model: Model): string | null => + model.session._tag === "SignedIn" && + rendersShell(model.route) && + isAlertsOpenAt(Arr.headNonEmpty(Str.split(hrefOf(model.route), "?"))) + ? Option.getOrNull(model.shell.maybeOrgId) + : null + const root = Subscription.make()((entry) => ({ /** `subscribeSelfHostedAuthChanges`: a renewal elsewhere ended or replaced the session. */ selfHostedSession: entry( @@ -180,6 +200,21 @@ const root = Subscription.make()((entry) => ({ ), }, ), + /** The Alerts › Inbox count, over the `alert_incidents` collection the alerts pages already sync. */ + shellAlertsInbox: entry( + { orgId: Schema.NullOr(Schema.String) }, + { + modelToDependencies: (model) => ({ orgId: shellAlertsInboxOrgId(model) }), + dependenciesToStream: ({ orgId }) => + orgId === null + ? Stream.empty + : Shell.alertsInboxCounts(orgId, (count) => + Message.GotShellMessage({ + message: Shell.Message.UpdatedAlertsInboxCount({ orgId, count }), + }), + ), + }, + ), /** `useDashboardsRead` for the Pinned group: the org's live `dashboards` collection. */ shellDashboards: entry( { orgId: Schema.NullOr(Schema.String) }, diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 754840a01d..ae98087bc1 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -39,7 +39,7 @@ const SAMPLE_PATHS = [ "/services/checkout", "/alerts", "/alerts/incidents/inc_1", - "/alerts/00000000-0000-4000-8000-000000000002", + "/alerts/rules/00000000-0000-4000-8000-000000000002", "/alerts/create", "/alerts/uptime/new", "/alerts/slos/new", diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 1bca428dc4..6413d39cd3 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -237,7 +237,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/time-controls.ts` `isPresetValue`, `presetLabel`, `presetSpanLabel`, `searchPreset`, `timeControls`, `timeRangeTriggerContent`: The time range header controls: the picker trigger, the reset for a custom range, and Reload. - `apps/web/src/page/kit/time-range-picker.ts` `searchPatchOf`, `dashboardTimeRangeOf`, `rejectedToast`, `unpicked`, `isActivePreset`, `isOpenKey`, `openOnHotkey`, `view` (stateful: Model, init, update): `TimeRangePicker`: the header popover with the preset rail and quick select. - `apps/web/src/page/kit/timezone-select-view.ts` `zoneListView`: The timezone select's list: the search input, "No timezone matches that." and the grouped zone list with clock and offset. -- `apps/web/src/page/kit/timezone-zones.ts` `formatUtcOffset`, `zoneLabel`, `matchesZoneQuery`, `zoneRows`, `readZoneRows`: The zone list logic of the timezone select. +- `apps/web/src/page/kit/timezone-zones.ts` `shortZoneName`, `formatUtcOffset`, `zoneLabel`, `matchesZoneQuery`, `zoneRows`, `readZoneRows`: The zone list logic of the timezone select. - `apps/web/src/page/kit/underline-tabs.ts` `underlineTabStrip`, `underlineTabClass`, `linkTabStrip`: `UnderlineTabStrip` and `underlineTabClass`: a row of tabs with an underlined active tab. - `apps/web/src/page/kit/where-autocomplete-view.ts` `suggestionListbox`, `whereKeyHandler`: `WhereClauseEditor`'s suggestion list and key handling, generic over the owner's Message. - `apps/web/src/page/kit/where-autocomplete.ts` `caretEvents`, `keepFocusEvents`, `listboxEvents`, `placeCaret`, `initWhereCaret`, `isOpenOf`, `activeKeysAt`, `syncedCaret`, `applied`, `listboxStyle`: The where clause autocomplete and its suggestion list, shared by every where editor (see lib/query-builder/where-clause-autocomplete.ts). diff --git a/packages/views/src/ui/combobox-view.ts b/packages/views/src/ui/combobox-view.ts index 28819cdbea..568beb04cc 100644 --- a/packages/views/src/ui/combobox-view.ts +++ b/packages/views/src/ui/combobox-view.ts @@ -390,7 +390,8 @@ export type ChipsViewInputs = Readonly<{ ariaLabelledBy?: string ariaDescribedBy?: string placeholder: string - emptyText: string + /** The Empty line. Left out, a list with no item to show stays closed instead of opening on nothing. */ + emptyText?: string className?: string /** * `MultiSelectCombobox disabled`: the field stays mounted, dimmed and inert, so cards holding @@ -461,7 +462,10 @@ const chip = ( /** `` with `ComboboxChips`: chips for the selection, then the chips input. */ export const chipsView = Submodel.defineView((model, inputs, h) => { const chipsSelector = `[data-slot=combobox-chips]:has(> #${inputId(model.id)})` - const isOpen = isMounted(model.isOpen, model.animation) + const listed = inputs.isUnfiltered === true ? inputs.items : visibleItems(model, inputs.items) + // No Empty line and no item: the field reads closed, whatever opened it. + const hasList = inputs.emptyText !== undefined || listed.length > 0 + const isOpen = hasList && isMounted(model.isOpen, model.animation) const isDisabled = inputs.isDisabled ?? false const chips = h.div( [ @@ -490,7 +494,7 @@ export const chipsView = Submodel.defineView((m ...(isDisabled ? [h.Disabled(true)] : []), ...inputAttributes( h, - model, + hasList ? model : { ...model, isOpen: false }, { slot: "combobox-chips-input", ariaLabel: inputs.ariaLabel, @@ -513,7 +517,7 @@ export const chipsView = Submodel.defineView((m listPopup(h, model, { slot: "combobox", items: inputs.items, - emptyText: inputs.emptyText, + emptyText: inputs.emptyText ?? "", anchorSelector: chipsSelector, isUnfiltered: inputs.isUnfiltered, renderItem: (value: string) => inputs.renderItem?.(h, value) ?? [value], diff --git a/packages/views/src/ui/combobox.scene.test.ts b/packages/views/src/ui/combobox.scene.test.ts index 78e926812a..ce1e06b096 100644 --- a/packages/views/src/ui/combobox.scene.test.ts +++ b/packages/views/src/ui/combobox.scene.test.ts @@ -131,6 +131,38 @@ describe("combobox scene (chips)", () => { ) }) + test("without an Empty line the list opens only once it has an item to show", () => { + const quiet = { + update, + view: withViewInputs(chipsView, { + items: ["checkout-svc"], + ariaLabel: "Services", + placeholder: "Add a service", + toView: (parts) => ih.main([], parts), + })(), + } + scene( + quiet, + given(init("services", "multiple")), + type(input("Services"), "zzz"), + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 1 }), + ), + Command.resolve( + Animation.WaitForAnimationSettled, + Animation.Message.EndedAnimation({ generation: 1 }), + ), + expect(role("listbox")).toBeAbsent(), + expect(role("status")).toBeAbsent(), + expect(input("Services")).toHaveAttr("aria-expanded", "false"), + type(input("Services"), "che"), + Mount.resolve(MountCombobox, Message.CompletedMountCombobox()), + expect(option("checkout-svc")).toExist(), + expect(input("Services")).toHaveAttr("aria-expanded", "true"), + ) + }) + test("no match shows the empty status; Escape closes", () => { scene( chips, diff --git a/packages/views/src/ui/segmented-select.ts b/packages/views/src/ui/segmented-select.ts index abc39de79f..a48365741f 100644 --- a/packages/views/src/ui/segmented-select.ts +++ b/packages/views/src/ui/segmented-select.ts @@ -122,7 +122,8 @@ export type SegmentedSelectConfig = Readonly<{ options: ReadonlyArray> value: T onChange: (value: T) => M - size?: "xs" | "sm" | "default" + /** `lg` keeps each segment at least as tall as a small button, for a choice in a form. */ + size?: "xs" | "sm" | "default" | "lg" ariaLabel?: string className?: string }> diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index a0b121df0a..457378e5db 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -16,6 +16,7 @@ import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "./button" import * as Dialog from "./dialog" import * as DialogView from "./dialog-view" +import { formDialogForm } from "./form-dialog" import { icon } from "./icon" // `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. @@ -159,6 +160,8 @@ export type SidePanelSheetOptions = Readonly<{ * Cancel dismisses like Escape does; none renders no footer. */ footer?: (close: ReadonlyArray) => ReadonlyArray + /** Makes the panel a `
`: Enter in a field or a `type: "submit"` footer button sends this. */ + onSubmit?: Message toParentMessage: (message: Dialog.Message) => Message }> @@ -187,18 +190,23 @@ export const sidePanelSheet = ( className: REDUCED_MOTION, hasDescription: options.description !== undefined, toView: ({ portal }) => portal, - toContent: (close) => [ - DialogView.dialogHeader(h, "sheet", [ - DialogView.dialogTitle(h, "sheet", sheet.id, options.title), - ...(options.description === undefined + toContent: (close) => { + const content = [ + DialogView.dialogHeader(h, "sheet", [ + DialogView.dialogTitle(h, "sheet", sheet.id, options.title), + ...(options.description === undefined + ? [] + : [DialogView.dialogDescription(h, "sheet", sheet.id, options.description)]), + ]), + DialogView.dialogPanel(h, "sheet", sheet.id, options.body), + ...(options.footer === undefined ? [] - : [DialogView.dialogDescription(h, "sheet", sheet.id, options.description)]), - ]), - DialogView.dialogPanel(h, "sheet", sheet.id, options.body), - ...(options.footer === undefined - ? [] - : [DialogView.dialogFooter(h, "sheet", options.footer(close))]), - ], + : [DialogView.dialogFooter(h, "sheet", options.footer(close))]), + ] + return options.onSubmit === undefined + ? content + : [formDialogForm(h, options.onSubmit, content)] + }, }, toParentMessage: options.toParentMessage, }) diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index dfe450e12e..725cd6c111 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -314,10 +314,15 @@ export const sidebarMenuItem = ( children, ) -export const sidebarMenuBadge = (h: HtmlBuilder, children: Children): Html => +/** ``; `className` places it beside a sub-item, which the peer offsets don't cover. */ +export const sidebarMenuBadge = ( + h: HtmlBuilder, + children: Children, + className?: string, +): Html => h.div( [ - h.Class(cn(...sidebarMenuBadgeStyles)), + h.Class(cn(...sidebarMenuBadgeStyles, className)), h.DataAttribute("sidebar", "menu-badge"), h.DataAttribute("slot", "sidebar-menu-badge"), ], @@ -438,7 +443,8 @@ export const sidebarMenuSubButton = ( options: Readonly<{ className?: string; isActive: boolean; link?: LinkTarget; href?: string }>, children: Children, ): Html => { - const linkActive = options.link !== undefined && isLinkActive(options.link.currentPath, options.link.href) + // The caller picks the most specific sibling, so an index row (`/alerts`) doesn't claim its siblings' pages. + const linkActive = options.link !== undefined && options.isActive const className = cn( ...sidebarMenuSubButtonStyles, sidebarMenuSubButtonMdStyles, diff --git a/packages/web-data/src/lib/timezone-format.ts b/packages/web-data/src/lib/timezone-format.ts index 5a3405a46d..e43e139dd7 100644 --- a/packages/web-data/src/lib/timezone-format.ts +++ b/packages/web-data/src/lib/timezone-format.ts @@ -80,13 +80,18 @@ export function formatTimestampInTimezone( export function formatTimeInTimezone( input: TimezoneFormatInput, - options: { timeZone: string; withSeconds?: boolean }, + options: { + timeZone: string + withSeconds?: boolean + /** A 24h clock (`h23`) where the time sits beside a 24h picker. */ + hourCycle?: "h12" | "h23" + }, ): string { const date = toValidDate(input) if (!date) return EMPTY_VALUE const tz = resolveTimeZone(options.timeZone) - const key = `${tz}|${options.withSeconds ? "s" : ""}` + const key = `${tz}|${options.withSeconds ? "s" : ""}|${options.hourCycle ?? ""}` let formatter = timeFormatters.get(key) if (!formatter) { formatter = new Intl.DateTimeFormat("en-US", { @@ -94,6 +99,7 @@ export function formatTimeInTimezone( hour: "2-digit", minute: "2-digit", second: options.withSeconds ? "2-digit" : undefined, + hourCycle: options.hourCycle, }) timeFormatters.set(key, formatter) }