From 2bcbbf01a7a244149ef650a9861b4dbeadf1c764 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:08:03 +0100 Subject: [PATCH 01/35] feat(shell): URL-held right sheet, foldable sidebar groups, on-call pill - Kit: rightSheet over the Dialog Submodel (672px form size, scrolling body, sticky footer, slide dropped under reduced motion) with followUrl/isDismissed; page/kit/sheet-param holds the ?sheet= helpers (push to open, replace to close). - Sidebar group labels fold their rows (sidebarGroupToggle, aria-expanded), saved to localStorage; the group holding the current page and the collapsed rail always show every row. - Footer on-call pill from the schedules list's on_call_now and the session user: on call until the handoff, not on call, or hidden without schedules. Re-read at each handoff; a dot with a tooltip on the rail. - /lab/shell shows all three, with a demo form in the sheet. --- apps/web/src/lab/app-shell.ts | 52 +--- apps/web/src/lab/entries/shell.ts | 37 +++ apps/web/src/lab/registry.ts | 8 + apps/web/src/lab/shell-frame.ts | 56 ++++ .../src/lab/shell-states-lab.scene.test.ts | 144 ++++++++++ apps/web/src/lab/shell-states-lab.ts | 261 ++++++++++++++++++ apps/web/src/page/kit/sheet-param.ts | 33 +++ apps/web/src/shell/command.ts | 17 +- apps/web/src/shell/data.ts | 28 ++ apps/web/src/shell/flags.ts | 8 + apps/web/src/shell/index.ts | 2 + apps/web/src/shell/message.ts | 8 + apps/web/src/shell/model.ts | 9 + apps/web/src/shell/nav-groups.scene.test.ts | 95 +++++++ apps/web/src/shell/oncall.scene.test.ts | 167 +++++++++++ apps/web/src/shell/oncall.ts | 74 +++++ apps/web/src/shell/shell.scene.test.ts | 5 +- apps/web/src/shell/subscription.ts | 19 +- apps/web/src/shell/update.ts | 42 ++- apps/web/src/shell/view-nav.ts | 57 +++- apps/web/src/shell/view-sidebar.ts | 83 ++++++ apps/web/src/shell/view.ts | 29 +- apps/web/src/subscription.ts | 15 + packages/ui/src/components/ui/sheet.styles.ts | 5 +- packages/views/src/ui/INDEX.md | 5 +- packages/views/src/ui/identity-label.ts | 13 +- packages/views/src/ui/sheet.ts | 76 +++++ packages/views/src/ui/sidebar.ts | 51 +++- 28 files changed, 1315 insertions(+), 84 deletions(-) create mode 100644 apps/web/src/lab/entries/shell.ts create mode 100644 apps/web/src/lab/shell-frame.ts create mode 100644 apps/web/src/lab/shell-states-lab.scene.test.ts create mode 100644 apps/web/src/lab/shell-states-lab.ts create mode 100644 apps/web/src/page/kit/sheet-param.ts create mode 100644 apps/web/src/shell/nav-groups.scene.test.ts create mode 100644 apps/web/src/shell/oncall.scene.test.ts create mode 100644 apps/web/src/shell/oncall.ts diff --git a/apps/web/src/lab/app-shell.ts b/apps/web/src/lab/app-shell.ts index aa7895bbe5..a3d1d53d06 100644 --- a/apps/web/src/lab/app-shell.ts +++ b/apps/web/src/lab/app-shell.ts @@ -1,10 +1,11 @@ import { Effect, Option, Schema, Stream, String as Str } from "effect" -import { Subscription, Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" +import { Subscription, type Update } from "foldkit" import { readSessionToken } from "../platform/auth-api" import type { Resources } from "../platform/resources" import * as Shell from "../shell" +export { foldLabShell, labShellView } from "./shell-frame" + // Shell lab pages render inside the real shell: the same Shell Submodel the // app root keeps, booted for the session's org, with the frame around the page and its overlays. @@ -14,6 +15,7 @@ export const LabShellFlags = Schema.Struct({ theme: Shell.Theme, pinnedNamespaces: Schema.Record(Schema.String, Schema.String), dashboardFavorites: Schema.Array(Schema.String), + collapsedNavGroups: Schema.optionalKey(Schema.Array(Schema.String)), }), maybeOrgId: Schema.Option(Schema.String), }) @@ -55,52 +57,6 @@ export const initLabShell = ( }) } -/** Folds Shell Messages; a lab page has no queries to rebuild on a namespace pin. */ -export const foldLabShell = ( - toParentMessage: (message: Shell.Message) => Message, -) => - Update.foldChild({ - update: Shell.update, - read: (model: Model) => Option.some(model.shell), - write: (model: Model, shell: Shell.Model): Model => ({ ...model, shell }), - toParentMessage, - foldOutMessage: () => (model: Model) => ({ model }), - }) - -/** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */ -export const labShellView = ( - h: HtmlBuilder, - shell: Shell.Model, - toParentMessage: (message: Shell.Message) => Message, - frame: Readonly<{ - currentPath: string - breadcrumbs: ReadonlyArray - page: () => Html - }>, -): ReadonlyArray => [ - h.submodel({ - slotId: "shell-frame", - model: shell, - view: Shell.frameView, - toParentMessage, - viewInputs: { ...frame, topbarActions: () => null }, - }), - h.submodel({ - slotId: "shell-sheet", - model: shell, - view: Shell.sheetLayerView, - toParentMessage, - viewInputs: { currentPath: frame.currentPath }, - }), - h.submodel({ - slotId: "shell-menu", - model: shell, - view: Shell.menuLayerView, - toParentMessage, - viewInputs: { currentPath: frame.currentPath }, - }), -] - /** The root's shell Subscriptions: `useIsMobile`, the sidebar shortcut and the Pinned group's shape. */ export const labShellSubscriptions = ( toParentMessage: (message: Shell.Message) => Message, diff --git a/apps/web/src/lab/entries/shell.ts b/apps/web/src/lab/entries/shell.ts new file mode 100644 index 0000000000..8af4756284 --- /dev/null +++ b/apps/web/src/lab/entries/shell.ts @@ -0,0 +1,37 @@ +import { Effect, Stream } from "effect" +import { Dom, Subscription } from "foldkit" +import type { Resources } from "../../platform/resources" +import * as Shell from "../../shell" +import { labShellSubscriptions } from "../app-shell" +import { defineLabWithFlags } from "../define" +import { Flags, init, Message, Model, toShell, update, view } from "../shell-states-lab" + +// `/lab/shell`: sidebar group folding, the on-call pill's states and the URL-held right sheet. + +const urlTarget: Dom.TypedEventTarget<{ popstate: PopStateEvent; "foldkit:urlchange": Event }> = window + +const currentHref = () => Message.ChangedUrl({ href: `${window.location.pathname}${window.location.search}` }) + +/** Back, Forward and the lab's own pushes and replaces. */ +const urlChanges: Stream.Stream = Stream.merge( + Dom.streamFromEvent({ target: urlTarget, type: "popstate", mapEvent: currentHref }), + Dom.streamFromEvent({ target: urlTarget, type: "foldkit:urlchange", mapEvent: currentHref }), +) + +export const lab = defineLabWithFlags("Shell states", { + Model, + Flags, + flags: Effect.all({ + shell: Shell.readShellFlags, + href: Effect.sync(() => `${window.location.pathname}${window.location.search}`), + }), + init, + update, + view, + subscriptions: Subscription.aggregate()( + labShellSubscriptions(toShell), + Subscription.make()((entry) => ({ + url: entry({}, { modelToDependencies: () => ({}), dependenciesToStream: () => urlChanges }), + })), + ), +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index c338dbc73d..8ec40d1192 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -491,6 +491,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/shell", + title: "Shell states", + description: + "The real sidebar with foldable groups and the on-call pill's three states, and a right sheet held in ?sheet= with a demo form.", + kind: "lab", + session: "none", + }, { path: "/lab/query-builder", title: "Query builder", diff --git a/apps/web/src/lab/shell-frame.ts b/apps/web/src/lab/shell-frame.ts new file mode 100644 index 0000000000..dd99c21860 --- /dev/null +++ b/apps/web/src/lab/shell-frame.ts @@ -0,0 +1,56 @@ +import { Option } from "effect" +import { Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { Message as ShellMessage } from "../shell/message" +import type { Model as ShellModel } from "../shell/model" +import { update as shellUpdate } from "../shell/update" +import { type BreadcrumbEntry, frameView, menuLayerView, sheetLayerView } from "../shell/view" + +// The shell frame a lab page renders in, kept off the shell barrel (its streams read `window` on +// import) so a lab's own logic can run in a scene test. + +/** Folds Shell Messages; a lab page has no queries to rebuild on a namespace pin. */ +export const foldLabShell = ( + toParentMessage: (message: ShellMessage) => Message, +) => + Update.foldChild({ + update: shellUpdate, + read: (model: Model) => Option.some(model.shell), + write: (model: Model, shell: ShellModel): Model => ({ ...model, shell }), + toParentMessage, + foldOutMessage: () => (model: Model) => ({ model }), + }) + +/** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */ +export const labShellView = ( + h: HtmlBuilder, + shell: ShellModel, + toParentMessage: (message: ShellMessage) => Message, + frame: Readonly<{ + currentPath: string + breadcrumbs: ReadonlyArray + page: () => Html + }>, +): ReadonlyArray => [ + h.submodel({ + slotId: "shell-frame", + model: shell, + view: frameView, + toParentMessage, + viewInputs: { ...frame, topbarActions: () => null }, + }), + h.submodel({ + slotId: "shell-sheet", + model: shell, + view: sheetLayerView, + toParentMessage, + viewInputs: { currentPath: frame.currentPath }, + }), + h.submodel({ + slotId: "shell-menu", + model: shell, + view: menuLayerView, + toParentMessage, + viewInputs: { currentPath: frame.currentPath }, + }), +] diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts new file mode 100644 index 0000000000..56a7b6324c --- /dev/null +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -0,0 +1,144 @@ +import * as Animation from "@foldkit/ui/animation" +import { + Command, + click, + expect, + given, + keydown, + Mount, + role, + scene, + selector, + Subscription, +} from "foldkit/scene" +import { describe, expect as expectVitest, test } from "vitest" +import * as Dialog from "@maple/views/ui/dialog" +import { + closeSheetNavigation, + openSheetNavigation, + sheetParamOf, + withSheetParam, +} from "../page/kit/sheet-param" +import { Array as Arr, Option } from "effect" +import { MeasureSidebarOverflow } from "../shell/mounts" +import { Message as ShellMessage } from "../shell/message" +import { + init, + Message, + type Model, + PushSheetUrl, + ReplaceSheetUrl, + SHEET_ID, + update, + view, +} from "./shell-states-lab" + +// The right sheet held in `?sheet=`: the URL opens it, Escape and the backdrop clear it, Back closes it. + +const shellFlags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const + +const at = (href: string): Model => init({ shell: shellFlags, href }).model + +const dialog = role("dialog", { name: "New alert rule" }) +const portal = selector("[data-base-ui-portal]") + +const measured = Mount.resolve(MeasureSidebarOverflow, ShellMessage.MeasuredSidebarOverflow()) + +/** The portal Mount and the enter transition (or none, for a sheet that boots open). */ +const mounted = Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()) + +const enter = (generation: number) => [ + mounted, + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation })), +] + +const leave = (generation: number) => [ + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve( + Dialog.WaitForDialogTransition({ id: SHEET_ID, generation }), + Dialog.Message.GotAnimationMessage({ message: Animation.Message.EndedAnimation({ generation }) }), + ), + Mount.expectEnded(Dialog.MountDialog), + expect(dialog).toBeAbsent(), +] + +describe("URL-held right sheet", () => { + test("a link with ?sheet= opens it, and Escape clears the param in place", () => { + scene( + { update, view }, + given(at(`/lab/shell?tab=x&sheet=${SHEET_ID}`)), + measured, + mounted, + expect(dialog).toExist(), + expect(dialog).toContainText("Error rate above (%)"), + expect(role("button", { name: "Create rule" })).toExist(), + keydown(portal, "Escape"), + Command.expectHas(ReplaceSheetUrl({ url: "/lab/shell?tab=x" })), + Command.resolve(ReplaceSheetUrl, Message.CompletedNavigate()), + // Booting open counted the show as generation 1. + ...leave(2), + ) + }) + + test("the page's button pushes ?sheet=, the URL change opens it, and Back closes it", () => { + scene( + { update, view }, + given(at("/lab/shell")), + measured, + expect(dialog).toBeAbsent(), + click(role("button", { name: "New alert rule" })), + Command.expectExact(PushSheetUrl({ url: `/lab/shell?sheet=${SHEET_ID}` })), + Command.resolve(PushSheetUrl, Message.CompletedNavigate()), + expect(dialog).toBeAbsent(), + Subscription.emit(Message.ChangedUrl({ href: `/lab/shell?sheet=${SHEET_ID}` })), + ...enter(1), + expect(dialog).toHaveAttr("data-open", ""), + Subscription.emit(Message.ChangedUrl({ href: "/lab/shell" })), + ...leave(2), + Command.expectNone(), + ) + }) + + test("Back (the URL losing ?sheet=) closes it without touching the URL again", () => { + const open = at(`/lab/shell?sheet=${SHEET_ID}`) + const back = update(open, Message.ChangedUrl({ href: "/lab/shell" })) + expectVitest(back.model.sheet.isOpen).toBe(false) + expectVitest(Arr.map(back.commands ?? [], (command) => command.name)).not.toContain( + ReplaceSheetUrl.name, + ) + }) + + test("Cancel in the footer closes it and clears the param", () => { + const closed = update( + at(`/lab/shell?sheet=${SHEET_ID}`), + Message.GotSheetMessage({ message: Dialog.Message.ClickedClose() }), + ) + expectVitest(closed.model.sheet.isOpen).toBe(false) + expectVitest(Arr.map(closed.commands ?? [], (command) => command.name)).toContain( + ReplaceSheetUrl.name, + ) + }) +}) + +describe("sheet param", () => { + test("sets and clears ?sheet= while keeping the other params and the hash", () => { + expectVitest(withSheetParam("/alerts?tab=rules#top", "new-rule")).toBe( + "/alerts?tab=rules&sheet=new-rule#top", + ) + expectVitest(withSheetParam("/alerts?sheet=new-rule&tab=rules", undefined)).toBe("/alerts?tab=rules") + expectVitest(sheetParamOf("/alerts?sheet=new-rule")).toEqual(Option.some("new-rule")) + expectVitest(sheetParamOf("/alerts?sheet=")).toEqual(Option.none()) + }) + + test("opening pushes a history entry; dismissing replaces it", () => { + expectVitest(openSheetNavigation("/alerts", "new-rule")).toMatchObject({ + url: "/alerts?sheet=new-rule", + isReplace: false, + }) + expectVitest(closeSheetNavigation("/alerts?sheet=new-rule")).toMatchObject({ + url: "/alerts", + isReplace: true, + }) + }) +}) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts new file mode 100644 index 0000000000..478ea5404f --- /dev/null +++ b/apps/web/src/lab/shell-states-lab.ts @@ -0,0 +1,261 @@ +import { Effect, Option, Schema } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { pushUrl, replaceUrl } from "foldkit/navigation" +import { button } from "@maple/views/ui/button" +import * as Dialog from "@maple/views/ui/dialog" +import { input } from "@maple/views/ui/input" +import { label } from "@maple/views/ui/label" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { followUrl, isDismissed, mapSheetCommands, rightSheet, type SheetStep } from "@maple/views/ui/sheet" +import { vstack } from "@maple/views/ui/stack" +import { textarea } from "@maple/views/ui/textarea" +import { typography } from "@maple/views/ui/typography" +import type { Resources } from "../platform/resources" +import { dashboardPage } from "../page/kit/dashboard-page" +import { sheetParamOf, withSheetParam } from "../page/kit/sheet-param" +import { Message as ShellMessage, Theme } from "../shell/message" +import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model" +import type { OnCallSnapshot } from "../shell/oncall" +import { foldLabShell, labShellView } from "./shell-frame" + +// `/lab/shell`: the real sidebar with fixture on-call states and a page to stand on, and the +// right sheet held in `?sheet=` (Back closes it, the link reopens it). + +export const SHEET_ID = "new-alert-rule" +const LAB_PATH = "/lab/shell" + +export const OnCallFixture = Schema.Literals(["onCall", "notOnCall", "none"]) +export type OnCallFixture = typeof OnCallFixture.Type + +export const StandOn = Schema.Literals(["lab", "alerts"]) +export type StandOn = typeof StandOn.Type + +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 on the schedule's Berlin clock. */ +const HANDOFF_MS = Date.UTC(2026, 10, 16, 8) + +const LAB_USER = "user_lab" + +export const onCallSnapshotOf = (fixture: OnCallFixture): OnCallSnapshot => ({ + userId: LAB_USER, + shifts: + fixture === "none" + ? [] + : [ + { + scheduleName: "Checkout primary", + timeZone: "Europe/Berlin", + userId: fixture === "onCall" ? LAB_USER : "user_other", + endsAtMs: HANDOFF_MS, + }, + ], +}) + +export const Flags = Schema.Struct({ + shell: Schema.Struct({ + isMobile: Schema.Boolean, + theme: Theme, + pinnedNamespaces: Schema.Record(Schema.String, Schema.String), + dashboardFavorites: Schema.Array(Schema.String), + collapsedNavGroups: Schema.optionalKey(Schema.Array(Schema.String)), + }), + href: Schema.String, +}) +export type Flags = typeof Flags.Type + +export const Model = Schema.Struct({ + shell: ShellModel, + sheet: Dialog.Model, + href: Schema.String, + onCall: OnCallFixture, + standOn: StandOn, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotShellMessage: { message: ShellMessage }, + GotSheetMessage: { message: Dialog.Message }, + /** The URL changed (a push, a replace, Back or Forward). */ + ChangedUrl: { href: Schema.String }, + ClickedNewRule: {}, + ClickedCreateRule: {}, + ClickedOnCallFixture: { fixture: OnCallFixture }, + ClickedStandOn: { standOn: StandOn }, + CompletedNavigate: {}, +}) +export type Message = typeof Message.Type + +/** Opening the sheet: a new history entry carrying `?sheet=`. */ +export const PushSheetUrl = Command.define("PushSheetUrl", { + args: { url: Schema.String }, + messages: [Message.CompletedNavigate], + execute: ({ url }) => pushUrl(url).pipe(Effect.as(Message.CompletedNavigate())), +}) + +/** Dismissing it: the param leaves in place. */ +export const ReplaceSheetUrl = Command.define("ReplaceSheetUrl", { + args: { url: Schema.String }, + messages: [Message.CompletedNavigate], + execute: ({ url }) => replaceUrl(url).pipe(Effect.as(Message.CompletedNavigate())), +}) + +type Return = Update.Return + +export const toShell = (message: ShellMessage): Message => Message.GotShellMessage({ message }) +const toSheet = (message: Dialog.Message): Message => Message.GotSheetMessage({ message }) +const foldShell = foldLabShell(toShell) + +const isSheetInUrl = (href: string): boolean => Option.contains(sheetParamOf(href), SHEET_ID) + +const sheetStep = (model: Model, step: SheetStep): Return => ({ + model: { ...model, sheet: step.model }, + commands: [ + ...mapSheetCommands(step, toSheet), + ...(isDismissed(step) ? [ReplaceSheetUrl({ url: withSheetParam(model.href, undefined) })] : []), + ], +}) + +const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({ + ...shell, + onCall: OnCallData.Success({ data: onCallSnapshotOf(fixture) }), +}) + +/** No org: the shell sends nothing, so the fixtures are all it shows. */ +export const init = (flags: Flags): Return => { + const sheet = followUrl(Dialog.init(SHEET_ID, "sheet"), isSheetInUrl(flags.href)) + return { + model: { + shell: withOnCall(initShell(flags.shell), "onCall"), + sheet: sheet.model, + href: flags.href, + onCall: "onCall", + standOn: "lab", + }, + commands: mapSheetCommands(sheet, toSheet), + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotShellMessage: ({ message: inner }) => foldShell(model, inner), + GotSheetMessage: ({ message: inner }) => sheetStep(model, Dialog.update(model.sheet, inner)), + ChangedUrl: ({ href }) => { + const step = followUrl(model.sheet, isSheetInUrl(href)) + return { model: { ...model, href, sheet: step.model }, commands: mapSheetCommands(step, toSheet) } + }, + ClickedNewRule: () => ({ + model, + commands: [PushSheetUrl({ url: withSheetParam(model.href, SHEET_ID) })], + }), + ClickedCreateRule: () => sheetStep(model, Dialog.close(model.sheet)), + ClickedOnCallFixture: ({ fixture }) => ({ + model: { ...model, onCall: fixture, shell: withOnCall(model.shell, fixture) }, + }), + ClickedStandOn: ({ standOn }) => ({ model: { ...model, standOn } }), + CompletedNavigate: () => ({ model }), + }) + +// VIEW + +const control = (h: HtmlBuilder, title: string, body: Html): Html => + vstack(h, { gap: "2" }, [typography(h, { size: "sm", weight: "medium" }, [title]), body]) + +const field = (h: HtmlBuilder, id: string, text: string, control: Html): Html => + vstack(h, { gap: "2" }, [label(h, { attributes: [h.For(id)] }, [text]), control]) + +const sheet = (h: HtmlBuilder, model: Model): Html => + rightSheet(h, { + sheet: model.sheet, + title: "New alert rule", + description: "Page the on-call person when a service's error rate crosses a threshold.", + toParentMessage: toSheet, + body: [ + vstack(h, { gap: "5" }, [ + field( + h, + "rule-name", + "Name", + input(h, { id: "rule-name", attributes: [h.Placeholder("Checkout error rate")] }), + ), + field( + h, + "rule-service", + "Service", + input(h, { id: "rule-service", attributes: [h.Placeholder("checkout-svc")] }), + ), + field( + h, + "rule-threshold", + "Error rate above (%)", + input(h, { id: "rule-threshold", attributes: [h.Value("5")] }), + ), + field( + h, + "rule-notes", + "Notes for the responder", + textarea(h, { id: "rule-notes", attributes: [h.Placeholder("What to check first")] }), + ), + ]), + ], + footer: [ + button( + h, + { variant: "outline", attributes: [h.OnClick(toSheet(Dialog.Message.ClickedClose()))] }, + ["Cancel"], + ), + button(h, { attributes: [h.OnClick(Message.ClickedCreateRule())] }, ["Create rule"]), + ], + }) + +const page = (h: HtmlBuilder, model: Model): Html => + dashboardPage( + h, + { + title: "Shell states", + description: "Fold a sidebar group, switch the on-call fixture, or open the sheet.", + actions: button(h, { attributes: [h.OnClick(Message.ClickedNewRule())] }, ["New alert rule"]), + }, + [ + vstack(h, { gap: "6" }, [ + control( + h, + "On-call pill", + segmentedSelect(h, { + ariaLabel: "On-call fixture", + value: model.onCall, + onChange: (fixture) => Message.ClickedOnCallFixture({ fixture }), + options: [ + { value: "onCall", label: "On call" }, + { value: "notOnCall", label: "Not on call" }, + { value: "none", label: "No schedules" }, + ], + }), + ), + control( + h, + "Current page", + segmentedSelect(h, { + ariaLabel: "Current page", + value: model.standOn, + onChange: (standOn) => Message.ClickedStandOn({ standOn }), + options: [ + { value: "lab", label: "Lab (no group)" }, + { value: "alerts", label: "Alerts (Triage)" }, + ], + }), + ), + ]), + sheet(h, model), + ], + ) + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Id("app")], + labShellView(h, model.shell, toShell, { + currentPath: model.standOn === "alerts" ? "/alerts" : LAB_PATH, + breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], + page: () => page(h, model), + }), + ) diff --git a/apps/web/src/page/kit/sheet-param.ts b/apps/web/src/page/kit/sheet-param.ts new file mode 100644 index 0000000000..68d05b7c5d --- /dev/null +++ b/apps/web/src/page/kit/sheet-param.ts @@ -0,0 +1,33 @@ +import { Option, Schema } from "effect" +import { PageOutMessage } from "../contract" + +/** + * A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the + * link reopens it), and dismissing replaces it away. Pair with `followUrl` / `isDismissed`. + */ +export const SHEET_PARAM = "sheet" + +/** Spread into a route's search schema. */ +export const SheetSearchFields = { [SHEET_PARAM]: Schema.optional(Schema.String) } + +const BASE = "http://maple.local" + +/** `href` with `?sheet=` set to `id`, or removed when `id` is undefined; other params stay. */ +export const withSheetParam = (href: string, id: string | undefined): string => { + const url = new URL(href, BASE) + if (id === undefined) url.searchParams.delete(SHEET_PARAM) + else url.searchParams.set(SHEET_PARAM, id) + return `${url.pathname}${url.search}${url.hash}` +} + +/** The open sheet named in `href`, if any. */ +export const sheetParamOf = (href: string): Option.Option => + Option.filter(Option.fromNullOr(new URL(href, BASE).searchParams.get(SHEET_PARAM)), (id) => id !== "") + +/** A page opens the sheet: a new history entry, so Back closes it. */ +export const openSheetNavigation = (href: string, id: string): PageOutMessage => + PageOutMessage.RequestedNavigation({ url: withSheetParam(href, id), isReplace: false }) + +/** The sheet was dismissed: the param leaves without adding an entry. */ +export const closeSheetNavigation = (href: string): PageOutMessage => + PageOutMessage.RequestedNavigation({ url: withSheetParam(href, undefined), isReplace: true }) diff --git a/apps/web/src/shell/command.ts b/apps/web/src/shell/command.ts index ebadbbb555..ee7d602a64 100644 --- a/apps/web/src/shell/command.ts +++ b/apps/web/src/shell/command.ts @@ -9,6 +9,8 @@ import { Message, Theme } from "./message" export const NAMESPACE_STORAGE_PREFIX = "maple.global-namespace." /** `maple.infra-surfaces.`: the org's last settled infra surfaces, JSON. */ export const INFRA_SURFACES_STORAGE_PREFIX = "maple.infra-surfaces." +/** The nav group ids the user folded, JSON. */ +export const COLLAPSED_NAV_GROUPS_KEY = "maple.sidebar.collapsed-groups" /** The theme key the app and the pre-paint script in index.html share. */ export const THEME_STORAGE_KEY = "maple-theme" @@ -21,7 +23,7 @@ export class ShellStorageError extends Schema.TaggedError()( }, ) {} -const encodeSurfaces = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))) +const encodeStrings = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))) /** Base UI moves DOM focus to the open popup and to the hovered or arrowed item. */ export const FocusElement = Command.define("FocusElement", { @@ -96,11 +98,22 @@ export const SaveInfraSurfaces = Command.define("SaveInfraSurfaces", { messages: [Message.CompletedSaveInfraSurfaces], execute: ({ orgId, surfaces }) => Effect.sync(() => { - writeLocalStorage(`${INFRA_SURFACES_STORAGE_PREFIX}${orgId}`, encodeSurfaces(surfaces)) + writeLocalStorage(`${INFRA_SURFACES_STORAGE_PREFIX}${orgId}`, encodeStrings(surfaces)) return Message.CompletedSaveInfraSurfaces() }), }) +/** Remembers which nav groups are folded; storage that throws keeps them for this tab only. */ +export const SaveCollapsedNavGroups = Command.define("SaveCollapsedNavGroups", { + args: { groups: Schema.Array(Schema.String) }, + messages: [Message.CompletedSaveNavGroups], + execute: ({ groups }) => + Effect.sync(() => { + writeLocalStorage(COLLAPSED_NAV_GROUPS_KEY, encodeStrings(groups)) + return Message.CompletedSaveNavGroups() + }), +}) + /** `SidebarProvider.setOpen`: the `sidebar_state` cookie (7 days). Never read back. */ export const SaveSidebarState = Command.define("SaveSidebarState", { args: { isOpen: Schema.Boolean }, diff --git a/apps/web/src/shell/data.ts b/apps/web/src/shell/data.ts index a96756bed9..4e32d22ceb 100644 --- a/apps/web/src/shell/data.ts +++ b/apps/web/src/shell/data.ts @@ -7,6 +7,9 @@ import { MapleInternalApiClient } from "../platform/api-clients" import { loadServicesFacets } from "../data/services-facets-probe" import { loadSources, sourcesListKey } from "../page/sources/kit/data" import { Message } from "./message" +import { SessionTenant } from "../data/session-tenant" +import { listOnCallSchedules } from "../page/alerts/shared/oncall-schedules" +import { isoMs } from "../page/alerts/shared/time" /** * Everything the shell loads at boot, over the S1 API clients. The data layer (S6) is designed in @@ -78,3 +81,28 @@ export const LoadNamespaces = Command.define("LoadNamespaces", { return Message.SucceededLoadNamespaces({ orgId, namespaces }) }).pipe(failed(Message.FailedLoadNamespaces({ orgId }))), }) + +// ON CALL + +/** The org's schedules (who holds each now, as the API reads it) and who is looking. */ +export const LoadOnCall = Command.define("LoadOnCall", { + args: orgArgs, + messages: [Message.SucceededLoadOnCall, Message.FailedLoadOnCall], + execute: ({ orgId }) => + Effect.gen(function* () { + const tenant = yield* (yield* SessionTenant).get + const schedules = yield* listOnCallSchedules + return Message.SucceededLoadOnCall({ + orgId, + snapshot: { + userId: tenant.userId, + shifts: Arr.map(schedules, (schedule) => ({ + scheduleName: schedule.name, + timeZone: schedule.time_zone, + userId: schedule.on_call_now?.user_id ?? null, + endsAtMs: schedule.on_call_now === null ? null : isoMs(schedule.on_call_now.ends_at), + })), + }, + }) + }).pipe(failed(Message.FailedLoadOnCall({ orgId }))), +}) diff --git a/apps/web/src/shell/flags.ts b/apps/web/src/shell/flags.ts index c320341099..644f4a4fbd 100644 --- a/apps/web/src/shell/flags.ts +++ b/apps/web/src/shell/flags.ts @@ -3,6 +3,7 @@ import { Effect, Option, Schema, Array as Arr, Record as Rec, String as Str } fr import { readQuotaApproachingDismissed } from "./billing-banners" import { readDashboardFavorites } from "./favorites" import { + COLLAPSED_NAV_GROUPS_KEY, INFRA_SURFACES_STORAGE_PREFIX, NAMESPACE_STORAGE_PREFIX, ShellStorageError, @@ -61,6 +62,12 @@ const readRememberedSurfaces = readPrefixed( Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(NavSurface))), ) +const decodeGroups = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(Schema.String))) + +/** The folded nav groups; unreadable or blocked storage folds none. */ +export const readCollapsedNavGroups = (): ReadonlyArray => + Option.getOrElse(Option.flatMap(readLocalStorage(COLLAPSED_NAV_GROUPS_KEY), decodeGroups), () => []) + /** Persisted shell UI state, read once before the first render. */ export const readShellFlags: Effect.Effect = Effect.gen(function* () { const pinnedNamespaces = yield* readPinnedNamespaces @@ -71,6 +78,7 @@ export const readShellFlags: Effect.Effect = Effect.gen(function* () theme: readTheme(), pinnedNamespaces, dashboardFavorites: readDashboardFavorites(), + collapsedNavGroups: readCollapsedNavGroups(), rememberedSurfaces: yield* readRememberedSurfaces, isQuotaApproachingDismissed: yield* readQuotaApproachingDismissed, platform: detectPlatform(), diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index 9fc3eedb40..78b2bea6ee 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -8,6 +8,8 @@ export { init, Model, type ShellClerk } from "./model" export { favoritesChanges, filterViewportChanges, + onCallHandoff, + onCallHandoffAt, sidebarShortcut, smallViewportChanges, viewportChanges, diff --git a/apps/web/src/shell/message.ts b/apps/web/src/shell/message.ts index 4a2c263971..370ae54310 100644 --- a/apps/web/src/shell/message.ts +++ b/apps/web/src/shell/message.ts @@ -12,6 +12,7 @@ import * as ConnectPanel from "./connect-panel" import * as SupportChannel from "./support-channel" import { PinnedDashboardRow } from "./dashboards-shape" import * as Palette from "./palette/model" +import { OnCallSnapshot } from "./oncall" /** The shell's dropdown menus; a collapsed-rail section menu is keyed by its section title. */ export const Menu = defineTaggedUnion({ @@ -79,6 +80,13 @@ export const Message = defineMessageUnion({ FailedLoadSources: { orgId: Schema.String }, SucceededLoadDashboards: { orgId: Schema.String, dashboards: Schema.Array(PinnedDashboardRow) }, FailedLoadDashboards: { orgId: Schema.String }, + SucceededLoadOnCall: { orgId: Schema.String, snapshot: OnCallSnapshot }, + FailedLoadOnCall: { orgId: Schema.String }, + /** A schedule handed off: who is on call may have changed. */ + ElapsedOnCallHandoff: {}, + /** A nav group's label: fold or unfold its rows. */ + ClickedNavGroup: { groupId: Schema.String }, + CompletedSaveNavGroups: {}, ChangedDashboardFavorites: { favorites: Schema.Array(Schema.String) }, SucceededLoadNamespaces: { orgId: Schema.String, namespaces: Schema.Array(Schema.String) }, FailedLoadNamespaces: { orgId: Schema.String }, diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index 13f1c0df7b..01736502fc 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -16,6 +16,7 @@ import * as Palette from "./palette/model" import { Platform } from "./palette/shortcuts" import { Menu, Theme } from "./message" import { NavSurface } from "./nav" +import { OnCallSnapshot } from "./oncall" import { type InfraSurfaces, PENDING_INTEGRATIONS, @@ -31,6 +32,7 @@ export const InfraPresenceData = AsyncData.Schema(Schema.Array(InfraSurfaceLiter export const DashboardsData = AsyncData.Schema(Schema.Array(PinnedDashboardRow), LoadFailure) export const NamespacesData = AsyncData.Schema(Schema.Array(Schema.String), LoadFailure) export const SourcesData = AsyncData.Schema(Schema.Array(V2Source), LoadFailure) +export const OnCallData = AsyncData.Schema(OnCallSnapshot, LoadFailure) export const OpenMenu = Schema.Struct({ menu: Menu, maybePosition: Schema.Option(PopupPosition) }) export type OpenMenu = typeof OpenMenu.Type @@ -76,6 +78,10 @@ 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, + /** 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. */ + collapsedNavGroups: Schema.Array(Schema.String), namespaces: NamespacesData.schema, /** The org's sources, read once a page links to its source (`source-link.ts`). */ sources: SourcesData.schema, @@ -133,6 +139,7 @@ export type ShellFlags = Readonly<{ theme: Theme pinnedNamespaces: Readonly> dashboardFavorites: ReadonlyArray + collapsedNavGroups?: ReadonlyArray rememberedSurfaces?: Readonly>> isQuotaApproachingDismissed?: boolean platform?: Platform @@ -157,6 +164,8 @@ export const init = (flags: ShellFlags): Model => ({ integrations: PENDING_INTEGRATIONS, rememberedSurfaces: flags.rememberedSurfaces ?? {}, dashboards: DashboardsData.Idle(), + onCall: OnCallData.Idle(), + collapsedNavGroups: flags.collapsedNavGroups ?? [], namespaces: NamespacesData.Idle(), sources: SourcesData.Idle(), namespaceQuery: "", diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts new file mode 100644 index 0000000000..405111c4e6 --- /dev/null +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -0,0 +1,95 @@ +import { Option } from "effect" +import type { HtmlBuilder } from "foldkit/html" +import { Command, click, expect, given, Mount, role, scene, withViewInputs } from "foldkit/scene" +import { describe, expect as expectVitest, test } from "vitest" +import { SaveCollapsedNavGroups } from "./command" +import { Message } from "./message" +import { init, type Model } from "./model" +import { MeasureSidebarOverflow } from "./mounts" +import { update } from "./update" +import { frameView } from "./view" + +// The sidebar's group labels fold their rows, remembered across reloads. + +const flags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const + +const signedIn = (overrides: Partial = {}): Model => ({ + ...init(flags), + maybeOrgId: Option.some("org_parity"), + hasRequestedSidebarData: true, + ...overrides, +}) + +const shellAt = (currentPath: string) => { + const frame = withViewInputs(frameView, { + currentPath, + breadcrumbs: [{ label: "Overview" }], + page: () => null, + topbarActions: () => null, + })() + return (model: Model, h: HtmlBuilder) => frame(model, h) +} + +const analyze = role("button", { name: "Analyze" }) +const dashboards = role("link", { name: "Dashboards" }) +const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) + +describe("sidebar groups", () => { + test("a group label folds its rows, saves the folded set, and unfolds again", () => { + scene( + { update, view: shellAt("/") }, + given(signedIn()), + measured, + expect(analyze).toHaveAttr("aria-expanded", "true"), + expect(dashboards).toExist(), + click(analyze), + Command.expectExact(SaveCollapsedNavGroups({ groups: ["analyze"] })), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(analyze).toHaveAttr("aria-expanded", "false"), + expect(dashboards).toBeAbsent(), + expect(role("link", { name: "Errors" })).toExist(), + click(analyze), + Command.expectExact(SaveCollapsedNavGroups({ groups: [] })), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(dashboards).toExist(), + ) + }) + + test("a folded group read from storage starts folded", () => { + scene( + { update, view: shellAt("/") }, + given(signedIn({ collapsedNavGroups: ["triage"] })), + measured, + expect(role("button", { name: "Triage" })).toHaveAttr("aria-expanded", "false"), + expect(role("link", { name: "Alerts" })).toBeAbsent(), + ) + }) + + test("the group holding the current page stays open and cannot fold", () => { + scene( + { update, view: shellAt("/alerts") }, + given(signedIn({ collapsedNavGroups: ["triage"] })), + measured, + expect(role("button", { name: "Triage" })).toHaveAttr("aria-expanded", "true"), + expect(role("button", { name: "Triage" })).toBeDisabled(), + expect(role("link", { name: "Alerts" })).toExist(), + ) + }) + + test("the collapsed rail shows every row of a folded group", () => { + scene( + { update, view: shellAt("/") }, + given(signedIn({ isSidebarOpen: false, collapsedNavGroups: ["analyze"] })), + measured, + expect(dashboards).toExist(), + ) + }) + + test("folding persists only the groups the user folded", () => { + const folded = update( + update(signedIn(), Message.ClickedNavGroup({ groupId: "monitor" })).model, + Message.ClickedNavGroup({ groupId: "triage" }), + ) + expectVitest(folded.model.collapsedNavGroups).toEqual(["monitor", "triage"]) + }) +}) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts new file mode 100644 index 0000000000..0abc3f0fc8 --- /dev/null +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -0,0 +1,167 @@ +import * as Animation from "@foldkit/ui/animation" +import { Array as Arr, Option } from "effect" +import type { HtmlBuilder } from "foldkit/html" +import { + Command, + expect, + given, + hover, + Mount, + role, + scene, + selector, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect as expectVitest, test } from "vitest" +import * as Tooltip from "@maple/views/ui/tooltip" +import { LoadOnCall } from "./data" +import { Message } from "./message" +import { init, type Model, OnCallData } from "./model" +import { MeasureSidebarOverflow } from "./mounts" +import { nextHandoffMs, type OnCallShift, type OnCallSnapshot, onCallStatusOf } from "./oncall" +import { enter, update } from "./update" +import { frameView } from "./view" + +// The footer's on-call pill: on call until the handoff, not on call, or nothing without schedules. + +const flags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const + +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 in Berlin. */ +const HANDOFF = Date.UTC(2026, 10, 16, 8) + +const shift = (overrides: Partial = {}): OnCallShift => ({ + scheduleName: "Checkout primary", + timeZone: "Europe/Berlin", + userId: "user_me", + endsAtMs: HANDOFF, + ...overrides, +}) + +const withOnCall = (shifts: ReadonlyArray): Model => ({ + ...init(flags), + maybeOrgId: Option.some("org_parity"), + hasRequestedSidebarData: true, + onCall: OnCallData.Success({ data: { userId: "user_me", shifts } }), +}) + +const view = (() => { + const frame = withViewInputs(frameView, { + currentPath: "/", + breadcrumbs: [{ label: "Overview" }], + page: () => null, + topbarActions: () => null, + })() + return (model: Model, h: HtmlBuilder) => frame(model, h) +})() + +const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) +const footer = selector('[data-slot="sidebar-footer"]') + +describe("on-call pill", () => { + test("on call: until the handoff on the schedule's clock, with the schedule, linking to the schedules", () => { + scene( + { update, view }, + given(withOnCall([shift()])), + measured, + expect(footer).toContainText("You're on call"), + expect(footer).toContainText("until Mon 16 Nov, 09:00"), + expect(footer).toContainText("Checkout primary"), + expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + ) + }) + + test("not on call: says so with a way to the schedules", () => { + scene( + { update, view }, + given(withOnCall([shift({ userId: "user_someone" })])), + measured, + expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(footer).toContainText("My schedules"), + ) + }) + + test("an org without schedules shows no pill", () => { + scene( + { update, view }, + given(withOnCall([])), + measured, + expect(text("You're not on call")).toBeAbsent(), + expect(text("My schedules")).toBeAbsent(), + ) + }) + + test("a failed read shows no pill", () => { + scene( + { update, view }, + given({ ...withOnCall([]), onCall: OnCallData.Failure({ error: "Failed" }) }), + measured, + expect(text("My schedules")).toBeAbsent(), + ) + }) + + test("on the collapsed rail the pill is a dot whose tooltip says the sentence", () => { + const pill = role("link", { name: /You're on call/ }) + scene( + { update, view }, + given({ ...withOnCall([shift()]), isSidebarOpen: false }), + measured, + expect(selector('[data-slot="sidebar-footer"] [data-slot="status-dot"]')).toExist(), + hover(pill), + Command.resolve(Tooltip.WaitOpenDelay, Tooltip.Message.ElapsedOpenDelay({ generation: 1 })), + Mount.resolve(Tooltip.MountTooltip, Tooltip.Message.CompletedMountTooltip()), + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 1 }), + ), + Command.resolve( + Animation.WaitForAnimationSettled, + Animation.Message.EndedAnimation({ generation: 1 }), + ), + expect(selector('[data-slot="tooltip-popup"]')).toHaveText( + "You're on call until Mon 16 Nov, 09:00 (Checkout primary)", + ), + ) + }) + + test("the sidebar reads the schedules with its other data, for its org only", () => { + const entered = enter("org_parity")(init(flags)) + expectVitest(Arr.map(entered.commands ?? [], (command) => command.name)).toContain(LoadOnCall.name) + const snapshot: OnCallSnapshot = { userId: "user_me", shifts: [shift()] } + const stale = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_other", snapshot })) + expectVitest(stale.model.onCall._tag).toBe("Loading") + const fresh = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_parity", snapshot })) + expectVitest(fresh.model.onCall).toEqual(OnCallData.Success({ data: snapshot })) + }) + + test("a handoff reads the schedules again", () => { + const loaded = withOnCall([shift()]) + const next = update(loaded, Message.ElapsedOnCallHandoff()) + expectVitest(Arr.map(next.commands ?? [], (command) => command.name)).toEqual([LoadOnCall.name]) + }) +}) + +describe("onCallStatusOf", () => { + test("the user's soonest-ending shift names the pill, counting the others", () => { + const status = onCallStatusOf({ + userId: "user_me", + shifts: [ + shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 86_400_000 }), + shift({ scheduleName: "Checkout primary" }), + shift({ scheduleName: "Search", userId: "user_someone" }), + ], + }) + expectVitest(status).toEqual({ + _tag: "OnCall", + scheduleName: "Checkout primary", + otherSchedules: 1, + until: "Mon 16 Nov, 09:00", + }) + }) + + test("the next handoff is the soonest across every schedule; none in a gap", () => { + const shifts = [shift({ endsAtMs: HANDOFF + 1 }), shift({ userId: null, endsAtMs: null }), shift()] + expectVitest(nextHandoffMs({ userId: "user_me", shifts })).toBe(HANDOFF) + expectVitest(nextHandoffMs({ userId: "user_me", shifts: [shift({ endsAtMs: null })] })).toBeNull() + }) +}) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts new file mode 100644 index 0000000000..97099b2239 --- /dev/null +++ b/apps/web/src/shell/oncall.ts @@ -0,0 +1,74 @@ +import { Array as Arr, Option, Order, Schema } from "effect" +import { formatShiftEdge } from "../page/alerts/shared/oncall-schedules" + +// The sidebar's on-call pill: each schedule's current shift, as the schedules list reads it now. + +/** One schedule's current shift: who holds it until when, `null` in a gap. */ +export const OnCallShift = Schema.Struct({ + scheduleName: Schema.String, + timeZone: Schema.String, + userId: Schema.NullOr(Schema.String), + endsAtMs: Schema.NullOr(Schema.Number), +}) +export type OnCallShift = typeof OnCallShift.Type + +/** The signed-in user and every schedule of the org. */ +export const OnCallSnapshot = Schema.Struct({ + userId: Schema.String, + shifts: Schema.Array(OnCallShift), +}) +export type OnCallSnapshot = typeof OnCallSnapshot.Type + +export type OnCallStatus = + | Readonly<{ _tag: "NoSchedules" }> + | Readonly<{ _tag: "NotOnCall" }> + | Readonly<{ + _tag: "OnCall" + scheduleName: string + /** Other schedules the user also holds now. */ + otherSchedules: number + until: string + }> + +const byEnd = Order.mapInput(Order.Number, (shift: OnCallShift) => shift.endsAtMs ?? Number.POSITIVE_INFINITY) + +/** The user's soonest-ending shift names the pill; an org without schedules shows none. */ +export const onCallStatusOf = (snapshot: OnCallSnapshot): OnCallStatus => { + if (snapshot.shifts.length === 0) return { _tag: "NoSchedules" } + const mine = Arr.sort( + Arr.filter(snapshot.shifts, (shift) => shift.userId === snapshot.userId), + byEnd, + ) + return Option.match(Arr.head(mine), { + onNone: () => ({ _tag: "NotOnCall" }), + onSome: (shift) => ({ + _tag: "OnCall", + scheduleName: shift.scheduleName, + otherSchedules: mine.length - 1, + until: + shift.endsAtMs === null ? "further notice" : formatShiftEdge(shift.endsAtMs, shift.timeZone), + }), + }) +} + +/** The next handoff on any schedule, when who is on call may change. */ +export const nextHandoffMs = (snapshot: OnCallSnapshot): number | null => { + const ends = Arr.flatMap(snapshot.shifts, (shift) => (shift.endsAtMs === null ? [] : [shift.endsAtMs])) + return ends.length === 0 ? null : Math.min(...ends) +} + +/** The pill's schedule line: "Checkout primary", "Checkout primary +1". */ +export const scheduleLabel = (status: Extract): string => + status.otherSchedules === 0 ? status.scheduleName : `${status.scheduleName} +${status.otherSchedules}` + +/** The pill's full sentence: its title, and the collapsed rail's tooltip. */ +export const onCallSentence = (status: OnCallStatus): string => { + switch (status._tag) { + case "OnCall": + return `You're on call until ${status.until} (${scheduleLabel(status)})` + case "NotOnCall": + return "You're not on call" + case "NoSchedules": + return "" + } +} diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index bc9e7aade1..65ee2707bb 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -22,7 +22,7 @@ import { } from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import { FocusElement, SaveInfraSurfaces, SaveNamespace, SaveSidebarState, SaveTheme } from "./command" -import { LoadInfraSurfaces, LoadNamespaces } from "./data" +import { LoadInfraSurfaces, LoadNamespaces, LoadOnCall } from "./data" import { type Integration, Menu, Message, OutMessage } from "./message" import { DashboardsData, init, type Model, NamespacesData } from "./model" import { MeasureSidebarOverflow, MountNamespaceSearch, PositionMenu } from "./mounts" @@ -197,7 +197,8 @@ describe("sidebar collapse", () => { given(signedIn({ isMobile: true })), expect(sidebar).toBeAbsent(), ...open(), - Command.expectExact(LoadInfraSurfaces), + Command.expectExact(LoadInfraSurfaces, LoadOnCall), + Command.resolve(LoadOnCall, Message.FailedLoadOnCall({ orgId: "org_parity" })), Command.resolve(LoadInfraSurfaces, surfacesAnswer("org_parity", [])), Command.expectExact(SaveInfraSurfaces({ orgId: "org_parity", surfaces: [] })), Command.resolve(SaveInfraSurfaces, Message.CompletedSaveInfraSurfaces()), diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index 480f26a23c..076cfaba32 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -1,8 +1,11 @@ import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" -import { Option, Stream, String as Str } from "effect" +import { Clock, Duration, Effect, Option, Stream, String as Str } from "effect" +import { AsyncData } from "foldkit" import { Dom } from "foldkit" import { DASHBOARD_FAVORITES_EVENT, readDashboardFavorites } from "./favorites" import { Message } from "./message" +import type { Model } from "./model" +import { nextHandoffMs } from "./oncall" /** `useIsMobile` (`max-md`): below 768px the sidebar is a sheet. */ export const MOBILE_QUERY = "(max-width: 767px)" @@ -82,3 +85,17 @@ export const favoritesChanges: Stream.Stream = Dom.streamFromEvent({ type: DASHBOARD_FAVORITES_EVENT, mapEvent: () => Message.ChangedDashboardFavorites({ favorites: readDashboardFavorites() }), }) + +/** When the on-call pill next reads the schedules again: the soonest handoff, if any. */ +export const onCallHandoffAt = (model: Model): number | null => + Option.getOrNull(Option.flatMapNullishOr(AsyncData.getData(model.onCall), nextHandoffMs)) + +/** Waits out the handoff (and a few seconds for the API to agree), then asks again. */ +export const onCallHandoff = (atMs: number): Stream.Stream => + Stream.fromEffect( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + yield* Effect.sleep(Duration.millis(Math.max(0, atMs - now) + 5_000)) + return Message.ElapsedOnCallHandoff() + }), + ) diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 66acbbe8eb..b671d58c33 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -11,12 +11,13 @@ import { FocusSiblingMenuItem, FocusShortcutSearch, LogOut, + SaveCollapsedNavGroups, SaveInfraSurfaces, SaveNamespace, SaveSidebarState, SaveTheme, } from "./command" -import { LoadInfraSurfaces, LoadNamespaces, LoadSources } from "./data" +import { LoadInfraSurfaces, LoadNamespaces, LoadOnCall, LoadSources } from "./data" import { DismissQuotaBanner, OpenCustomerPortal } from "./billing-banners" import * as ClerkOrgSwitcher from "./clerk-org-switcher" import * as Checklist from "./onboarding-checklist" @@ -34,6 +35,7 @@ import { initRightSheet, type Model, NamespacesData, + OnCallData, shellProbe, SourcesData, } from "./model" @@ -61,6 +63,7 @@ export const enter = infraPresence: () => InfraPresenceData.Idle(), integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), + onCall: () => OnCallData.Idle(), namespaces: () => NamespacesData.Idle(), sources: () => SourcesData.Idle(), }) @@ -84,12 +87,34 @@ export const enter = const requestSidebarData = (model: Model): UpdateReturn => { if (model.hasRequestedSidebarData || Option.isNone(model.maybeOrgId)) return { model } const surfaces = requestInfraSurfaces(model) + const onCall = readOnCall(surfaces.model) return { - model: modifyFields(surfaces.model, { + model: modifyFields(onCall.model, { hasRequestedSidebarData: () => true, dashboards: () => DashboardsData.Loading(), }), - commands: surfaces.commands ?? [], + commands: [...(surfaces.commands ?? []), ...(onCall.commands ?? [])], + } +} + +/** The footer's on-call pill: read with the sidebar, again at each handoff with the last answer up. */ +const readOnCall = (model: Model): UpdateReturn => + Option.match(Option.all([model.maybeOrgId, AsyncData.revalidateOrLoad(model.onCall)]), { + onNone: () => ({ model }), + onSome: ([orgId, onCall]) => ({ + model: modifyFields(model, { onCall: () => onCall }), + commands: [LoadOnCall({ orgId })], + }), + }) + +/** Folds or unfolds a nav group and remembers it. */ +const toggleNavGroup = (model: Model, groupId: string): UpdateReturn => { + const groups = Array.contains(model.collapsedNavGroups, groupId) + ? Array.filter(model.collapsedNavGroups, (id) => id !== groupId) + : [...model.collapsedNavGroups, groupId] + return { + model: modifyFields(model, { collapsedNavGroups: () => groups }), + commands: [SaveCollapsedNavGroups({ groups })], } } @@ -461,6 +486,17 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => }), } : { model }, + SucceededLoadOnCall: ({ orgId, snapshot }) => + forOrg(model, orgId, (current) => + modifyFields(current, { onCall: () => OnCallData.Success({ data: snapshot }) }), + ), + FailedLoadOnCall: ({ orgId }) => + forOrg(model, orgId, (current) => + modifyFields(current, { onCall: () => OnCallData.Failure({ error: "Failed" }) }), + ), + ElapsedOnCallHandoff: () => readOnCall(model), + ClickedNavGroup: ({ groupId }) => toggleNavGroup(model, groupId), + CompletedSaveNavGroups: () => ({ model }), SucceededLoadNamespaces: ({ orgId, namespaces }) => forOrg(model, orgId, (current) => modifyFields(current, { namespaces: () => NamespacesData.Success({ data: namespaces }) }), diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 77296dc717..215e8d8a29 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -8,6 +8,7 @@ import { sidebarGroup, sidebarGroupContent, sidebarGroupLabel, + sidebarGroupToggle, sidebarMenu, sidebarMenuButton, sidebarMenuItem, @@ -276,6 +277,21 @@ export const pinnedGroup = ( ] } +export type NavGroupFold = "unlabelled" | "expanded" | "collapsed" | "pinned" + +/** + * How a group's rows show: a labelled group folds, except while it holds the current page + * (`pinned`) and on the collapsed rail, where its label is hidden and every row shows. + */ +export const navGroupFold = (model: Model, group: NavGroup, currentPath: string): NavGroupFold => { + if (group.label === undefined) return "unlabelled" + if (Arr.some(group.items, (item) => isNavItemActive(currentPath, item))) return "pinned" + if (!model.isSidebarOpen && !model.isMobile) return "expanded" + return model.collapsedNavGroups.includes(group.id) ? "collapsed" : "expanded" +} + +const navGroupContentId = (group: NavGroup) => `shell-nav-group-${group.id}` + export const navGroupSections = ( h: HtmlBuilder, model: Model, @@ -283,15 +299,32 @@ export const navGroupSections = ( tip: RailTip = NO_TIP, clerk: ShellClerk | null = null, ): ReadonlyArray => - Arr.map(shellNavGroups(clerk), (group) => - sidebarGroup(h, undefined, [ - ...(group.label === undefined ? [] : [sidebarGroupLabel(h, GROUP_LABEL, [group.label])]), - sidebarGroupContent(h, [ - sidebarMenu( - h, - undefined, - Arr.map(group.items, (item) => navRow(h, model, item, currentPath, tip)), - ), - ]), - ]), - ) + Arr.map(shellNavGroups(clerk), (group) => { + const fold = navGroupFold(model, group, currentPath) + return sidebarGroup(h, undefined, [ + ...(group.label === undefined + ? [] + : [ + sidebarGroupToggle(h, { + label: group.label, + isExpanded: fold !== "collapsed", + controlsId: navGroupContentId(group), + onToggle: + fold === "pinned" ? null : Message.ClickedNavGroup({ groupId: group.id }), + }), + ]), + sidebarGroupContent( + h, + fold === "collapsed" + ? [] + : [ + sidebarMenu( + h, + undefined, + Arr.map(group.items, (item) => navRow(h, model, item, currentPath, tip)), + ), + ], + [h.Id(navGroupContentId(group))], + ), + ]) + }) diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 1bf4a398ec..324e27aa37 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -25,6 +25,9 @@ import { orgAvatar } from "./clerk-org-switcher-view" import { typography } from "@maple/views/ui/typography" import { identityLabel } from "@maple/views/ui/identity-label" import { hstack } from "@maple/views/ui/stack" +import { statusDot } from "@maple/views/ui/status-dot" +import { oncallTabHref } from "../page/alerts/shared/oncall-schedules" +import { type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" export const GROUP_LABEL = "h-6 text-muted-foreground" @@ -244,6 +247,85 @@ const footerNavRow = ( ), ]) +/** The pill's state; nothing while the schedules read, after a failed read, or with none. */ +export const shellOnCallStatus = (model: Model): OnCallStatus => + Option.match(AsyncData.getData(model.onCall), { + onNone: (): OnCallStatus => ({ _tag: "NoSchedules" }), + onSome: onCallStatusOf, + }) + +/** + * "You're on call until …" with the schedule, or "You're not on call" with a way to the schedules; + * a dot with the sentence as its tooltip on the collapsed rail. + */ +const onCallPill = ( + h: HtmlBuilder, + currentPath: string, + status: OnCallStatus, + tip: RailTip, +): ReadonlyArray => + status._tag === "NoSchedules" + ? [] + : [ + sidebarMenu(h, undefined, [ + sidebarMenuItem(h, undefined, [ + sidebarMenuButton( + h, + { + size: "lg", + className: + "h-auto gap-2.5 border border-sidebar-border py-1.5 group-data-[collapsible=icon]:border-transparent", + link: { href: oncallTabHref(), currentPath }, + hasTooltip: true, + attributes: [...tip(onCallSentence(status)), h.Title(onCallSentence(status))], + }, + [ + hstack( + h, + { align: "center", justify: "center", className: "size-4 shrink-0" }, + [ + statusDot(h, { + tone: status._tag === "OnCall" ? "ok" : "neutral", + size: "lg", + }), + ], + ), + // Three short lines: the full sentence does not fit the rail's width. + identityLabel( + h, + status._tag === "OnCall" + ? [ + typography( + h, + { size: "xs", weight: "medium", truncate: true }, + ["You're on call"], + ), + typography(h, { size: "xs", truncate: true }, [ + `until ${status.until}`, + ]), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + scheduleLabel(status), + ]), + ] + : [ + typography( + h, + { size: "xs", weight: "medium", truncate: true }, + ["You're not on call"], + ), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + "My schedules", + ]), + ], + // The rail shows the dot; the tooltip says the sentence. + "group-data-[collapsible=icon]:hidden", + ), + ], + ), + ]), + ]), + ] + /** The user avatar: the Clerk image over the initials on a warm tile keyed by the name. */ export const userAvatar = ( h: HtmlBuilder, @@ -338,6 +420,7 @@ export const shellSidebarFooter = ( ], [], ), + ...onCallPill(h, currentPath, shellOnCallStatus(model), tip), sidebarMenu(h, undefined, [ footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index af4ad7e522..171d7dea3f 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -23,7 +23,14 @@ import { MeasureSidebarOverflow, PositionMenu } from "./mounts" import { isMenuKey } from "./update" import { menuContent } from "./view-menus" import { navGroupSections, pinnedGroup, shellNavGroups } from "./view-nav" -import { NO_TIP, type RailTip, shellSidebarFooter, shellSidebarHeader } from "./view-sidebar" +import { + NO_TIP, + type RailTip, + shellOnCallStatus, + shellSidebarFooter, + shellSidebarHeader, +} from "./view-sidebar" +import { onCallSentence } from "./oncall" import { type BreadcrumbEntry, hidesTopbarConnect, topbar } from "./view-topbar" import { splitPageRails } from "./page-rails" import { filterSheet, filterSheetTrigger, rightSheet, rightSheetTrigger } from "./view-page-sheets" @@ -54,13 +61,17 @@ const sidebarRegions = ( const RAIL_TIP_PREFIX = "shell-rail-tip-" -/** The rows that carry a rail tooltip: Search, the nav rows this org is shown, the footer links. */ -const railTipTitles = (clerk: ShellClerk | null): ReadonlyArray => [ - "Search", - ...Arr.flatMap(shellNavGroups(clerk), (group) => Arr.map(group.items, (item) => item.title)), - "Sources", - "Settings", -] +/** The rows that carry a rail tooltip: Search, the nav rows, the on-call pill, the footer links. */ +const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray => { + const onCall = shellOnCallStatus(model) + return [ + "Search", + ...Arr.flatMap(shellNavGroups(clerk), (group) => Arr.map(group.items, (item) => item.title)), + ...(onCall._tag === "NoSchedules" ? [] : [onCallSentence(onCall)]), + "Sources", + "Settings", + ] +} /** The desktop rail; collapsed, its rows share one Tooltip naming the row (`tooltip={title}`). */ const desktopSidebar = ( @@ -97,7 +108,7 @@ const desktopSidebar = ( isInstant, ), toContent: (triggerId) => [Str.slice(RAIL_TIP_PREFIX.length)(triggerId)], - triggerIds: Arr.map(railTipTitles(clerk), (title) => `${RAIL_TIP_PREFIX}${title}`), + triggerIds: Arr.map(railTipTitles(model, clerk), (title) => `${RAIL_TIP_PREFIX}${title}`), }, toParentMessage: (message) => Message.GotRailTooltipMessage({ message }), }) diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index 628d8a73d8..655b59a89b 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -215,6 +215,21 @@ const root = Subscription.make()((entry) => ({ ), }, ), + /** The on-call pill reads the schedules again once a shift hands off. */ + shellOnCallHandoff: entry( + { atMs: Schema.NullOr(Schema.Number) }, + { + modelToDependencies: (model) => ({ + atMs: model.session._tag === "SignedIn" ? Shell.onCallHandoffAt(model.shell) : null, + }), + dependenciesToStream: ({ atMs }) => + atMs === null + ? Stream.empty + : Stream.map(Shell.onCallHandoff(atMs), (message) => + Message.GotShellMessage({ message }), + ), + }, + ), /** The dashboards list starred or unstarred one (`dashboardFavoritesAtom`). */ shellDashboardFavorites: entry( {}, diff --git a/packages/ui/src/components/ui/sheet.styles.ts b/packages/ui/src/components/ui/sheet.styles.ts index aa1487cabd..c05a45b746 100644 --- a/packages/ui/src/components/ui/sheet.styles.ts +++ b/packages/ui/src/components/ui/sheet.styles.ts @@ -29,12 +29,13 @@ export const sheetPopupRightStyles = "col-start-2 w-[calc(100%-(--spacing(12)))] max-w-md border-s data-ending-style:translate-x-8 data-starting-style:translate-x-8" /** - * A side sheet's width, by what it holds. `default` a form or filter list, `detail` a peek at one - * item, `wide` reading content (a log, a review, a setup guide), `full` a trace waterfall. + * A side sheet's width, by what it holds. `default` a filter list, `detail` a peek at one item, + * `form` a create or edit form, `wide` reading content (a log, a review), `full` a trace waterfall. */ export const sheetSizeStyles = { default: "sm:max-w-md", detail: "sm:max-w-xl", + form: "sm:max-w-2xl", wide: "sm:max-w-3xl", full: "sm:max-w-[min(1100px,calc(100vw-2rem))]", } as const diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index efea20d0f7..f5dfdb194d 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -142,10 +142,10 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/select.ts` `listId`, `itemId` (stateful: Model, init, update): Base UI `Select` (`@maple/ui/select`): the popup opens over the trigger with the selected item on the trigger's value (`alignItemWithTrigger`), or below it when that does not fit. - `packages/views/src/ui/selectable-row.ts` `selectableRow`: `SelectableRow`: a bespoke row's frame (a `div`, or a link with `href`) with `listRow`'s selection lane and tint; a clickable one is a `pressable` with `selectedLane`. - `packages/views/src/ui/setting-row.ts` `settingRow`: Port of packages/ui/src/components/ui/setting-row.tsx. -- `packages/views/src/ui/sheet.ts` `leftSheet`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. +- `packages/views/src/ui/sheet.ts` `leftSheet`, `rightSheet`, `followUrl`, `isDismissed`, `mapSheetCommands`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. - `packages/views/src/ui/shift-timeline.ts` `zonedDayStart`, `placeBar`, `timelineDays`, `barRoomPct`, `shiftTimeline`: `ShiftTimeline`: rows of time bars over a day-ruled axis in one time zone, with a now line (an on-call schedule's layers and its final row). - `packages/views/src/ui/side-panel.ts` `sidePanelFrame`: A detail side panel's frame (span, service, database): a full-height clipped column on the page background. -- `packages/views/src/ui/sidebar.ts` `sidebarWrapper`, `sidebarDesktop`, `sidebarMobile`, `sidebarHeader`, `sidebarFooter`, `sidebarContent`, `sidebarGroup`, `sidebarGroupLabel`, `sidebarGroupContent`, `sidebarGroupAction`, `sidebarSeparator`, `sidebarMenu`, `sidebarMenuItem`, `sidebarMenuBadge`, `sidebarMenuSub`, `sidebarMenuSubItem`, `isLinkActive`, `sidebarMenuButton`, `sidebarMenuSubButton`, `sidebarRail`, `sidebarInset`, `sidebarTrigger`: The Foldkit side of packages/ui/src/components/ui/sidebar.tsx: same markup, same class composition. +- `packages/views/src/ui/sidebar.ts` `sidebarWrapper`, `sidebarDesktop`, `sidebarMobile`, `sidebarHeader`, `sidebarFooter`, `sidebarContent`, `sidebarGroup`, `sidebarGroupLabel`, `sidebarGroupContent`, `sidebarGroupToggle`, `sidebarGroupAction`, `sidebarSeparator`, `sidebarMenu`, `sidebarMenuItem`, `sidebarMenuBadge`, `sidebarMenuSub`, `sidebarMenuSubItem`, `isLinkActive`, `sidebarMenuButton`, `sidebarMenuSubButton`, `sidebarRail`, `sidebarInset`, `sidebarTrigger`: The Foldkit side of packages/ui/src/components/ui/sidebar.tsx: same markup, same class composition. - `packages/views/src/ui/signal-empty-state.ts` `emptyActions`, `filteredEmpty`, `presenceLoading`, `signalEmptyState`: `SignalEmptyState` and `FilteredEmpty`. - `packages/views/src/ui/skeleton.ts` `skeleton`, `skeletonSlowNote`, `skeletonList`, `skeletonBlock`, `skeletonMeterClass`, `skeletonMeter`, `skeletonSwitchClass`, `skeletonSwitch`, `skeletonLine`: Port of packages/ui/src/components/ui/skeleton.tsx. - `packages/views/src/ui/spaced.ts` `spacedClass`, `spaced`: `Spaced`: a block whose children stack with `space-y-*` margins (not a flex column), usually a row's text column that fills the free width and lets its lines truncate. @@ -227,6 +227,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/series-legend.ts` `seriesLegend` - `apps/web/src/page/kit/service-runtimes.ts` `runtimeLink`, `workloadLink`: Where a service runs, as links to the infra pages: its platform resources (`ServiceRuntimesResponse`) and its Kubernetes workloads. - `apps/web/src/page/kit/sheet-detail-header.ts` `sheetDetailHeader`: `SheetDetailHeader`: a detail sheet's eyebrow kind, title, screen-reader description and meta row. +- `apps/web/src/page/kit/sheet-param.ts` `withSheetParam`, `sheetParamOf`, `openSheetNavigation`, `closeSheetNavigation`: A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the link reopens it), and dismissing replaces it away. - `apps/web/src/page/kit/signal-empty-state.ts` `docsLink`, `signalEmptyState`: The dashboard's copy for `SignalEmptyState`: where each signal comes from, and the setup page and guides it links. - `apps/web/src/page/kit/signal-presence.ts` `loadSignalPresences`: Signal presence over `telemetrySignalsAtom`. - `apps/web/src/page/kit/stable-slice.ts` `stableSlice`: A view's inputs as one object, kept by reference while every field is: a `createLazy` slot keyed on it skips the renders that change nothing the view reads. diff --git a/packages/views/src/ui/identity-label.ts b/packages/views/src/ui/identity-label.ts index 30a99e1178..bc3d0a8b0c 100644 --- a/packages/views/src/ui/identity-label.ts +++ b/packages/views/src/ui/identity-label.ts @@ -1,3 +1,4 @@ +import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { typography } from "./typography" @@ -5,5 +6,13 @@ import { typography } from "./typography" // of truncating lines that fills the rest of the row. /** The label's lines (the name, then an email, a region or a namespace). */ -export const identityLabel = (h: HtmlBuilder, lines: ReadonlyArray): Html => - typography(h, { size: "sm", as: "div", className: "grid flex-1 text-left leading-tight" }, lines) +export const identityLabel = ( + h: HtmlBuilder, + lines: ReadonlyArray, + className?: string, +): Html => + typography( + h, + { size: "sm", as: "div", className: cn("grid flex-1 text-left leading-tight", className) }, + lines, + ) diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 860b076795..2ca2ec85fd 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -9,9 +9,13 @@ import { sheetTitleStyles, sheetViewportLeftStyles, sheetViewportStyles, + type SheetSize, } from "@maple/ui/styles/ui/sheet" +import { Command } from "foldkit" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "./button" +import * as Dialog from "./dialog" +import * as DialogView from "./dialog-view" import { icon } from "./icon" // `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. @@ -127,3 +131,75 @@ export const leftSheet = ( ), ], ) + +// RIGHT SHEET + +export type RightSheetOptions = Readonly<{ + sheet: Dialog.Model + title: string + description?: string + /** `form` (672px) by default; `wide` for reading content. */ + size?: SheetSize + /** The scrolling body. */ + body: ReadonlyArray + /** The sticky footer (Cancel, then the primary action); none renders no footer. */ + footer?: ReadonlyArray + toParentMessage: (message: Dialog.Message) => Message +}> + +// Reduced motion keeps the fade and drops the slide. +const REDUCED_MOTION = + "motion-reduce:data-ending-style:translate-x-0 motion-reduce:data-starting-style:translate-x-0" + +/** + * A full-height panel from the right over the Dialog Submodel: title, close, a scrolling body and a + * sticky footer. Escape, the backdrop and the close button close it (`isDismissed`). + */ +export const rightSheet = (h: HtmlBuilder, options: RightSheetOptions): Html => { + const { sheet } = options + return h.submodel({ + slotId: `${sheet.id}-sheet`, + model: sheet, + view: DialogView.view, + viewInputs: { + side: "right", + size: options.size ?? "form", + className: REDUCED_MOTION, + hasDescription: options.description !== undefined, + toView: ({ portal }) => portal, + toContent: () => [ + 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.dialogFooter(h, "sheet", options.footer)]), + ], + }, + toParentMessage: options.toParentMessage, + }) +} + +export type SheetStep = ReturnType + +/** + * A sheet whose open state lives in the URL: open or close it to match. The Opened/Closed it + * reports are dropped, as the URL already says so. + */ +export const followUrl = (sheet: Dialog.Model, isOpenInUrl: boolean): SheetStep => { + const step = isOpenInUrl ? Dialog.open(sheet) : Dialog.close(sheet) + return { model: step.model, commands: step.commands } +} + +/** The sheet closed itself (Escape, backdrop, close button): the caller clears its URL state. */ +export const isDismissed = (step: SheetStep): boolean => step.outMessage?._tag === "Closed" + +/** A sheet step's commands under the parent's Message. */ +export const mapSheetCommands = ( + step: SheetStep, + toParentMessage: (message: Dialog.Message) => Message, +) => Command.mapMessages(step.commands ?? [], toParentMessage) diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 0ee064f0f3..67c77e1c02 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -206,16 +206,65 @@ export const sidebarGroupLabel = ( children, ) -export const sidebarGroupContent = (h: HtmlBuilder, children: Children): Html => +export const sidebarGroupContent = ( + h: HtmlBuilder, + children: Children, + attributes: Attributes = [], +): Html => h.div( [ h.Class(cn(sidebarGroupContentStyles)), h.DataAttribute("sidebar", "group-content"), h.DataAttribute("slot", "sidebar-group-content"), + ...attributes, ], children, ) +export type SidebarGroupToggleOptions = Readonly<{ + label: string + isExpanded: boolean + /** The `sidebarGroupContent` id it shows and hides. */ + controlsId: string + /** None: the group cannot collapse right now (it holds the current page). */ + onToggle: Message | null +}> + +/** A group label that collapses its rows: a chevron button with `aria-expanded`. */ +export const sidebarGroupToggle = ( + h: HtmlBuilder, + options: SidebarGroupToggleOptions, +): Html => + h.button( + [ + h.Type("button"), + h.Class( + cn( + ...sidebarGroupLabelStyles, + "group/toggle h-6 w-full gap-1 text-left text-muted-foreground", + options.onToggle === null + ? "cursor-default" + : "cursor-pointer hover:text-sidebar-foreground active:text-sidebar-foreground", + ), + ), + h.DataAttribute("sidebar", "group-label"), + h.DataAttribute("slot", "sidebar-group-toggle"), + h.AriaExpanded(options.isExpanded), + h.AriaControls(options.controlsId), + ...(options.onToggle === null ? [h.AriaDisabled(true)] : [h.OnClick(options.onToggle)]), + ], + [ + h.span([h.Class("flex-1 truncate")], [options.label]), + icon(h, "ChevronDownIcon", { + size: 12, + className: cn( + "opacity-0 transition-[opacity,rotate] duration-150 group-hover/toggle:opacity-100 group-focus-visible/toggle:opacity-100 motion-reduce:transition-none", + options.isExpanded ? "" : "-rotate-90 opacity-100", + ), + }), + ], + ) + /** ``: the icon button in a group label's corner. */ export const sidebarGroupAction = ( h: HtmlBuilder, From 5535bc2dddaef3aba75ad20ce61df4b85d674609 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:26:21 +0100 Subject: [PATCH 02/35] fix(shell): sheet history, on-call freshness, foldable active group - Sheet: opening pushes a history entry marked as this tab's; dismissing goes back past it, and only a deep link replaces the param. Kit renamed to sidePanelSheet, footer receives close; sheet-param tests sit beside it. - On-call pill: a shift past its end reads as unknown; reads again at the handoff, hourly while off call, on focus and visibility, and with backoff (60s doubling, capped) after a stale or failed answer, keeping the last good data. Schedule and override writes announce a re-read. - An org with no schedules is cached per org for a day and skips the request; a schedule write clears it. - "until" uses the viewer's time zone with a short zone name. - Sidebar: the collapsed rail renders plain labels (no tab stops); the group holding the current page can fold and keeps only that row. The pill uses aria-label on the rail instead of a title. --- .../src/lab/shell-states-lab.scene.test.ts | 61 ++---- apps/web/src/lab/shell-states-lab.ts | 45 ++--- .../page/alerts/shared/oncall-schedules.ts | 28 ++- apps/web/src/page/kit/sheet-param.test.ts | 56 ++++++ apps/web/src/page/kit/sheet-param.ts | 41 +++- apps/web/src/shell/command.ts | 26 +++ apps/web/src/shell/data.ts | 32 +++- apps/web/src/shell/flags.ts | 8 + apps/web/src/shell/index.ts | 6 +- apps/web/src/shell/message.ts | 19 +- apps/web/src/shell/model.ts | 13 ++ apps/web/src/shell/nav-groups.scene.test.ts | 16 +- apps/web/src/shell/oncall.scene.test.ts | 177 +++++++++++++----- apps/web/src/shell/oncall.ts | 119 ++++++++++-- apps/web/src/shell/shell.scene.test.ts | 2 +- apps/web/src/shell/subscription.ts | 41 +++- apps/web/src/shell/update.ts | 106 +++++++++-- apps/web/src/shell/view-nav.ts | 41 ++-- apps/web/src/shell/view-sidebar.ts | 15 +- apps/web/src/shell/view.ts | 4 +- apps/web/src/subscription.ts | 26 ++- packages/views/src/ui/INDEX.md | 4 +- packages/views/src/ui/sheet.ts | 22 ++- packages/views/src/ui/sidebar.ts | 10 +- 24 files changed, 696 insertions(+), 222 deletions(-) create mode 100644 apps/web/src/page/kit/sheet-param.test.ts diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 56a7b6324c..445addbe8f 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -13,21 +13,15 @@ import { } from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import * as Dialog from "@maple/views/ui/dialog" -import { - closeSheetNavigation, - openSheetNavigation, - sheetParamOf, - withSheetParam, -} from "../page/kit/sheet-param" -import { Array as Arr, Option } from "effect" +import { Array as Arr } from "effect" import { MeasureSidebarOverflow } from "../shell/mounts" import { Message as ShellMessage } from "../shell/message" import { init, Message, type Model, + DismissSheetUrl, PushSheetUrl, - ReplaceSheetUrl, SHEET_ID, update, view, @@ -64,7 +58,7 @@ const leave = (generation: number) => [ ] describe("URL-held right sheet", () => { - test("a link with ?sheet= opens it, and Escape clears the param in place", () => { + test("a link with ?sheet= opens it, and Escape dismisses it through the URL", () => { scene( { update, view }, given(at(`/lab/shell?tab=x&sheet=${SHEET_ID}`)), @@ -74,8 +68,8 @@ describe("URL-held right sheet", () => { expect(dialog).toContainText("Error rate above (%)"), expect(role("button", { name: "Create rule" })).toExist(), keydown(portal, "Escape"), - Command.expectHas(ReplaceSheetUrl({ url: "/lab/shell?tab=x" })), - Command.resolve(ReplaceSheetUrl, Message.CompletedNavigate()), + Command.expectHas(DismissSheetUrl({ href: `/lab/shell?tab=x&sheet=${SHEET_ID}`, id: SHEET_ID })), + Command.resolve(DismissSheetUrl, Message.CompletedNavigate()), // Booting open counted the show as generation 1. ...leave(2), ) @@ -88,7 +82,7 @@ describe("URL-held right sheet", () => { measured, expect(dialog).toBeAbsent(), click(role("button", { name: "New alert rule" })), - Command.expectExact(PushSheetUrl({ url: `/lab/shell?sheet=${SHEET_ID}` })), + Command.expectExact(PushSheetUrl({ href: "/lab/shell", id: SHEET_ID })), Command.resolve(PushSheetUrl, Message.CompletedNavigate()), expect(dialog).toBeAbsent(), Subscription.emit(Message.ChangedUrl({ href: `/lab/shell?sheet=${SHEET_ID}` })), @@ -105,40 +99,21 @@ describe("URL-held right sheet", () => { const back = update(open, Message.ChangedUrl({ href: "/lab/shell" })) expectVitest(back.model.sheet.isOpen).toBe(false) expectVitest(Arr.map(back.commands ?? [], (command) => command.name)).not.toContain( - ReplaceSheetUrl.name, + DismissSheetUrl.name, ) }) - test("Cancel in the footer closes it and clears the param", () => { - const closed = update( - at(`/lab/shell?sheet=${SHEET_ID}`), - Message.GotSheetMessage({ message: Dialog.Message.ClickedClose() }), - ) - expectVitest(closed.model.sheet.isOpen).toBe(false) - expectVitest(Arr.map(closed.commands ?? [], (command) => command.name)).toContain( - ReplaceSheetUrl.name, - ) - }) -}) - -describe("sheet param", () => { - test("sets and clears ?sheet= while keeping the other params and the hash", () => { - expectVitest(withSheetParam("/alerts?tab=rules#top", "new-rule")).toBe( - "/alerts?tab=rules&sheet=new-rule#top", + test("Cancel in the footer dismisses it like Escape", () => { + scene( + { update, view }, + given(at(`/lab/shell?sheet=${SHEET_ID}`)), + measured, + mounted, + expect(dialog).toExist(), + click(role("button", { name: "Cancel" })), + Command.expectHas(DismissSheetUrl({ href: `/lab/shell?sheet=${SHEET_ID}`, id: SHEET_ID })), + Command.resolve(DismissSheetUrl, Message.CompletedNavigate()), + ...leave(2), ) - expectVitest(withSheetParam("/alerts?sheet=new-rule&tab=rules", undefined)).toBe("/alerts?tab=rules") - expectVitest(sheetParamOf("/alerts?sheet=new-rule")).toEqual(Option.some("new-rule")) - expectVitest(sheetParamOf("/alerts?sheet=")).toEqual(Option.none()) - }) - - test("opening pushes a history entry; dismissing replaces it", () => { - expectVitest(openSheetNavigation("/alerts", "new-rule")).toMatchObject({ - url: "/alerts?sheet=new-rule", - isReplace: false, - }) - expectVitest(closeSheetNavigation("/alerts?sheet=new-rule")).toMatchObject({ - url: "/alerts", - isReplace: true, - }) }) }) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 478ea5404f..6d017f71c7 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -2,19 +2,24 @@ import { Effect, Option, Schema } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" -import { pushUrl, replaceUrl } from "foldkit/navigation" import { button } from "@maple/views/ui/button" import * as Dialog from "@maple/views/ui/dialog" import { input } from "@maple/views/ui/input" import { label } from "@maple/views/ui/label" import { segmentedSelect } from "@maple/views/ui/segmented-select" -import { followUrl, isDismissed, mapSheetCommands, rightSheet, type SheetStep } from "@maple/views/ui/sheet" +import { + followUrl, + isDismissed, + mapSheetCommands, + type SheetStep, + sidePanelSheet, +} from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { textarea } from "@maple/views/ui/textarea" import { typography } from "@maple/views/ui/typography" import type { Resources } from "../platform/resources" import { dashboardPage } from "../page/kit/dashboard-page" -import { sheetParamOf, withSheetParam } from "../page/kit/sheet-param" +import { dismissSheetUrl, pushSheetUrl, sheetParamOf } from "../page/kit/sheet-param" import { Message as ShellMessage, Theme } from "../shell/message" import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model" import type { OnCallSnapshot } from "../shell/oncall" @@ -32,20 +37,20 @@ export type OnCallFixture = typeof OnCallFixture.Type export const StandOn = Schema.Literals(["lab", "alerts"]) export type StandOn = typeof StandOn.Type -/** Mon 16 Nov 2026, 08:00 UTC: 09:00 on the schedule's Berlin clock. */ +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 CET for a Berlin viewer. */ const HANDOFF_MS = Date.UTC(2026, 10, 16, 8) const LAB_USER = "user_lab" export const onCallSnapshotOf = (fixture: OnCallFixture): OnCallSnapshot => ({ userId: LAB_USER, + viewerTimeZone: "Europe/Berlin", shifts: fixture === "none" ? [] : [ { scheduleName: "Checkout primary", - timeZone: "Europe/Berlin", userId: fixture === "onCall" ? LAB_USER : "user_other", endsAtMs: HANDOFF_MS, }, @@ -86,18 +91,20 @@ export const Message = defineMessageUnion({ }) export type Message = typeof Message.Type -/** Opening the sheet: a new history entry carrying `?sheet=`. */ +const sheetArgs = { href: Schema.String, id: Schema.String } + +/** Opening the sheet: a history entry carrying `?sheet=`, marked as this tab's. */ export const PushSheetUrl = Command.define("PushSheetUrl", { - args: { url: Schema.String }, + args: sheetArgs, messages: [Message.CompletedNavigate], - execute: ({ url }) => pushUrl(url).pipe(Effect.as(Message.CompletedNavigate())), + execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedNavigate())), }) -/** Dismissing it: the param leaves in place. */ -export const ReplaceSheetUrl = Command.define("ReplaceSheetUrl", { - args: { url: Schema.String }, +/** Dismissing it: back past the entry this tab pushed, else the param is replaced away. */ +export const DismissSheetUrl = Command.define("DismissSheetUrl", { + args: sheetArgs, messages: [Message.CompletedNavigate], - execute: ({ url }) => replaceUrl(url).pipe(Effect.as(Message.CompletedNavigate())), + execute: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedNavigate())), }) type Return = Update.Return @@ -112,7 +119,7 @@ const sheetStep = (model: Model, step: SheetStep): Return => ({ model: { ...model, sheet: step.model }, commands: [ ...mapSheetCommands(step, toSheet), - ...(isDismissed(step) ? [ReplaceSheetUrl({ url: withSheetParam(model.href, undefined) })] : []), + ...(isDismissed(step) ? [DismissSheetUrl({ href: model.href, id: SHEET_ID })] : []), ], }) @@ -146,7 +153,7 @@ export const update = (model: Model, message: Message): Return => }, ClickedNewRule: () => ({ model, - commands: [PushSheetUrl({ url: withSheetParam(model.href, SHEET_ID) })], + commands: [PushSheetUrl({ href: model.href, id: SHEET_ID })], }), ClickedCreateRule: () => sheetStep(model, Dialog.close(model.sheet)), ClickedOnCallFixture: ({ fixture }) => ({ @@ -165,7 +172,7 @@ const field = (h: HtmlBuilder, id: string, text: string, control: Html): H vstack(h, { gap: "2" }, [label(h, { attributes: [h.For(id)] }, [text]), control]) const sheet = (h: HtmlBuilder, model: Model): Html => - rightSheet(h, { + sidePanelSheet(h, { sheet: model.sheet, title: "New alert rule", description: "Page the on-call person when a service's error rate crosses a threshold.", @@ -198,12 +205,8 @@ const sheet = (h: HtmlBuilder, model: Model): Html => ), ]), ], - footer: [ - button( - h, - { variant: "outline", attributes: [h.OnClick(toSheet(Dialog.Message.ClickedClose()))] }, - ["Cancel"], - ), + footer: (close) => [ + button(h, { variant: "outline", attributes: close }, ["Cancel"]), button(h, { attributes: [h.OnClick(Message.ClickedCreateRule())] }, ["Create rule"]), ], }) diff --git a/apps/web/src/page/alerts/shared/oncall-schedules.ts b/apps/web/src/page/alerts/shared/oncall-schedules.ts index 0d2b0dd2fd..fc9c70a19e 100644 --- a/apps/web/src/page/alerts/shared/oncall-schedules.ts +++ b/apps/web/src/page/alerts/shared/oncall-schedules.ts @@ -22,6 +22,14 @@ export const SCHEDULE_LIST_LIMIT = 50 /** 409 on delete while an escalation chain still pages the schedule. */ export const SCHEDULE_IN_USE_CODE = "oncall_schedule_in_use" +/** + * Fired on `window` after a schedule or override is written, so the sidebar's on-call pill reads + * again (a page's single out-message is already its toast or navigation). + */ +export const ONCALL_CHANGED_EVENT = "maple:oncall-schedules-changed" + +const announceOnCallChange = Effect.sync(() => window.dispatchEvent(new Event(ONCALL_CHANGED_EVENT))) + export const listOnCallSchedules = Effect.gen(function* () { const client = yield* MapleApiV2Client const page = yield* client.oncallSchedules.list({ query: { limit: SCHEDULE_LIST_LIMIT } }) @@ -47,13 +55,19 @@ export const saveOnCallSchedule = ( params: { id: editingId }, payload: { ...payload, description: payload.description ?? null }, }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) export const deleteOnCallSchedule = (id: OnCallScheduleId) => Effect.gen(function* () { const client = yield* MapleApiV2Client yield* client.oncallSchedules.delete({ params: { id } }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) /** Each layer's shifts and the final ones over `[fromMs, toMs)`. */ export const listShifts = (id: OnCallScheduleId, fromMs: number, toMs: number) => @@ -98,13 +112,19 @@ export const createOverride = (id: OnCallScheduleId, payload: V2OnCallOverrideCr Effect.gen(function* () { const client = yield* MapleApiV2Client return yield* client.oncallSchedules.createOverride({ params: { id }, payload }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) export const deleteOverride = (id: OnCallScheduleId, overrideId: OnCallOverrideId) => Effect.gen(function* () { const client = yield* MapleApiV2Client yield* client.oncallSchedules.deleteOverride({ params: { id, override_id: overrideId } }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) // LINKS diff --git a/apps/web/src/page/kit/sheet-param.test.ts b/apps/web/src/page/kit/sheet-param.test.ts new file mode 100644 index 0000000000..fb713b1c0b --- /dev/null +++ b/apps/web/src/page/kit/sheet-param.test.ts @@ -0,0 +1,56 @@ +import { Array as Arr, Option } from "effect" +import { describe, expect, test } from "vitest" +import { dismissalOf, SheetHistoryState, sheetParamOf, withSheetParam } from "./sheet-param" + +// `?sheet=` in the URL, and how a dismissal leaves the tab's history. + +type Entry = Readonly<{ url: string; state: unknown }> + +const topOf = (stack: ReadonlyArray): Entry => Option.getOrThrow(Arr.last(stack)) + +/** A tab's history as `pushSheetUrl` and `dismissSheetUrl` move it. */ +const open = (stack: ReadonlyArray, id: string): ReadonlyArray => { + const top = topOf(stack) + const state: typeof SheetHistoryState.Type = { mapleSheet: id } + return [...stack, { url: withSheetParam(top.url, id), state }] +} + +const dismiss = (stack: ReadonlyArray, id: string): ReadonlyArray => { + const top = topOf(stack) + const dismissal = dismissalOf(top.state, top.url, id) + return dismissal._tag === "Back" + ? stack.slice(0, -1) + : [...stack.slice(0, -1), { url: dismissal.url, state: null }] +} + +describe("sheet param", () => { + test("sets and clears ?sheet= while keeping the other params and the hash", () => { + expect(withSheetParam("/alerts?tab=rules#top", "new-rule")).toBe( + "/alerts?tab=rules&sheet=new-rule#top", + ) + expect(withSheetParam("/alerts?sheet=new-rule&tab=rules", undefined)).toBe("/alerts?tab=rules") + expect(sheetParamOf("/alerts?sheet=new-rule")).toEqual(Option.some("new-rule")) + expect(sheetParamOf("/alerts?sheet=")).toEqual(Option.none()) + }) + + test("open, Escape, then one Back leaves the page: the dismissal pops the entry it pushed", () => { + const before: ReadonlyArray = [ + { url: "/services", state: null }, + { url: "/alerts?tab=rules", state: null }, + ] + const closed = dismiss(open(before, "new-rule"), "new-rule") + expect(closed).toEqual(before) + expect(Option.map(Arr.last(closed.slice(0, -1)), (entry) => entry.url)).toEqual( + Option.some("/services"), + ) + }) + + test("a deep link (or a reload into another tab's entry) replaces the param away", () => { + const linked: ReadonlyArray = [{ url: "/alerts?tab=rules&sheet=new-rule", state: null }] + expect(dismiss(linked, "new-rule")).toEqual([{ url: "/alerts?tab=rules", state: null }]) + expect(dismissalOf({ mapleSheet: "other" }, "/alerts?sheet=new-rule", "new-rule")).toEqual({ + _tag: "Replace", + url: "/alerts", + }) + }) +}) diff --git a/apps/web/src/page/kit/sheet-param.ts b/apps/web/src/page/kit/sheet-param.ts index 68d05b7c5d..09cfb5f800 100644 --- a/apps/web/src/page/kit/sheet-param.ts +++ b/apps/web/src/page/kit/sheet-param.ts @@ -1,9 +1,11 @@ -import { Option, Schema } from "effect" -import { PageOutMessage } from "../contract" +import { Effect, Option, Schema } from "effect" +import { back, replaceUrl } from "foldkit/navigation" /** - * A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the - * link reopens it), and dismissing replaces it away. Pair with `followUrl` / `isDismissed`. + * A side sheet held in the URL as `?sheet=`. Opening pushes an entry this tab marks as its own, + * so dismissing it goes back (Back after a close never lands on the same page again); a deep link + * or a sheet opened by another tab's entry replaces the param away instead. Pages wrap + * `pushSheetUrl` / `dismissSheetUrl` in a Command and follow the URL with `followUrl`. */ export const SHEET_PARAM = "sheet" @@ -24,10 +26,29 @@ export const withSheetParam = (href: string, id: string | undefined): string => export const sheetParamOf = (href: string): Option.Option => Option.filter(Option.fromNullOr(new URL(href, BASE).searchParams.get(SHEET_PARAM)), (id) => id !== "") -/** A page opens the sheet: a new history entry, so Back closes it. */ -export const openSheetNavigation = (href: string, id: string): PageOutMessage => - PageOutMessage.RequestedNavigation({ url: withSheetParam(href, id), isReplace: false }) +/** The history entry state `pushSheetUrl` writes. */ +export const SheetHistoryState = Schema.Struct({ mapleSheet: Schema.String }) +const decodeSheetState = Schema.decodeUnknownOption(SheetHistoryState) -/** The sheet was dismissed: the param leaves without adding an entry. */ -export const closeSheetNavigation = (href: string): PageOutMessage => - PageOutMessage.RequestedNavigation({ url: withSheetParam(href, undefined), isReplace: true }) +export type SheetDismissal = Readonly<{ _tag: "Back" }> | Readonly<{ _tag: "Replace"; url: string }> + +/** Back when the current entry is the one this tab pushed for `id`, else replace the param away. */ +export const dismissalOf = (historyState: unknown, href: string, id: string): SheetDismissal => + Option.exists(decodeSheetState(historyState), (state) => state.mapleSheet === id) + ? { _tag: "Back" } + : { _tag: "Replace", url: withSheetParam(href, undefined) } + +/** Opens the sheet: a marked history entry, announced the way foldkit's `pushUrl` does. */ +export const pushSheetUrl = (href: string, id: string): Effect.Effect => + Effect.sync(() => { + const state: typeof SheetHistoryState.Type = { mapleSheet: id } + window.history.pushState(state, "", withSheetParam(href, id)) + window.dispatchEvent(new CustomEvent("foldkit:urlchange")) + }) + +/** Dismisses it (Escape, backdrop, close, Cancel): see `dismissalOf`. */ +export const dismissSheetUrl = (href: string, id: string): Effect.Effect => + Effect.suspend(() => { + const dismissal = dismissalOf(window.history.state, href, id) + return dismissal._tag === "Back" ? back() : replaceUrl(dismissal.url) + }) diff --git a/apps/web/src/shell/command.ts b/apps/web/src/shell/command.ts index ee7d602a64..3a69223ac3 100644 --- a/apps/web/src/shell/command.ts +++ b/apps/web/src/shell/command.ts @@ -114,6 +114,32 @@ export const SaveCollapsedNavGroups = Command.define("SaveCollapsedNavGroups", { }), }) +/** `maple.oncall.no-schedules.`: when the org last answered with no schedules, epoch ms. */ +export const NO_SCHEDULES_STORAGE_PREFIX = "maple.oncall.no-schedules." + +/** Remembers an org's "no schedules" answer, or forgets it (`null`) once it has one. */ +export const SaveNoSchedules = Command.define("SaveNoSchedules", { + args: { orgId: Schema.String, checkedAtMs: Schema.NullOr(Schema.Number) }, + messages: [Message.CompletedSaveNoSchedules], + execute: ({ orgId, checkedAtMs }) => { + const key = `${NO_SCHEDULES_STORAGE_PREFIX}${orgId}` + return ( + checkedAtMs === null + ? Effect.ignore( + Effect.try({ + try: () => localStorage.removeItem(key), + catch: (cause) => + new ShellStorageError({ + message: "Could not clear the on-call cache", + cause, + }), + }), + ) + : Effect.sync(() => writeLocalStorage(key, String(checkedAtMs))) + ).pipe(Effect.as(Message.CompletedSaveNoSchedules())) + }, +}) + /** `SidebarProvider.setOpen`: the `sidebar_state` cookie (7 days). Never read back. */ export const SaveSidebarState = Command.define("SaveSidebarState", { args: { isOpen: Schema.Boolean }, diff --git a/apps/web/src/shell/data.ts b/apps/web/src/shell/data.ts index 4e32d22ceb..f9dbf7960e 100644 --- a/apps/web/src/shell/data.ts +++ b/apps/web/src/shell/data.ts @@ -10,6 +10,8 @@ import { Message } from "./message" import { SessionTenant } from "../data/session-tenant" import { listOnCallSchedules } from "../page/alerts/shared/oncall-schedules" import { isoMs } from "../page/alerts/shared/time" +import { readEffectiveTimeZone } from "../platform/time-zone" +import { isNoSchedulesFresh } from "./oncall" /** * Everything the shell loads at boot, over the S1 API clients. The data layer (S6) is designed in @@ -84,25 +86,45 @@ export const LoadNamespaces = Command.define("LoadNamespaces", { // ON CALL -/** The org's schedules (who holds each now, as the API reads it) and who is looking. */ +/** + * The org's schedules (who holds each now, as the API reads it), who is looking and in which zone. + * An org whose "no schedules" answer is fresh is not asked at all. + */ export const LoadOnCall = Command.define("LoadOnCall", { - args: orgArgs, + args: { orgId: Schema.String, noSchedulesCheckedAtMs: Schema.NullOr(Schema.Number) }, messages: [Message.SucceededLoadOnCall, Message.FailedLoadOnCall], - execute: ({ orgId }) => + execute: ({ orgId, noSchedulesCheckedAtMs }) => Effect.gen(function* () { + const readAtMs = yield* Clock.currentTimeMillis + if (isNoSchedulesFresh(noSchedulesCheckedAtMs ?? undefined, readAtMs)) + return Message.SucceededLoadOnCall({ + orgId, + readAtMs, + isFromCache: true, + snapshot: { userId: "", viewerTimeZone: "UTC", shifts: [] }, + }) const tenant = yield* (yield* SessionTenant).get const schedules = yield* listOnCallSchedules + const viewerTimeZone = yield* readEffectiveTimeZone return Message.SucceededLoadOnCall({ orgId, + readAtMs, + isFromCache: false, snapshot: { userId: tenant.userId, + viewerTimeZone, shifts: Arr.map(schedules, (schedule) => ({ scheduleName: schedule.name, - timeZone: schedule.time_zone, userId: schedule.on_call_now?.user_id ?? null, endsAtMs: schedule.on_call_now === null ? null : isoMs(schedule.on_call_now.ends_at), })), }, }) - }).pipe(failed(Message.FailedLoadOnCall({ orgId }))), + }).pipe( + Effect.catch(() => + Effect.map(Clock.currentTimeMillis, (failedAtMs) => + Message.FailedLoadOnCall({ orgId, failedAtMs }), + ), + ), + ), }) diff --git a/apps/web/src/shell/flags.ts b/apps/web/src/shell/flags.ts index 644f4a4fbd..f9abf86a83 100644 --- a/apps/web/src/shell/flags.ts +++ b/apps/web/src/shell/flags.ts @@ -5,6 +5,7 @@ import { readDashboardFavorites } from "./favorites" import { COLLAPSED_NAV_GROUPS_KEY, INFRA_SURFACES_STORAGE_PREFIX, + NO_SCHEDULES_STORAGE_PREFIX, NAMESPACE_STORAGE_PREFIX, ShellStorageError, THEME_STORAGE_KEY, @@ -68,6 +69,12 @@ const decodeGroups = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Arr export const readCollapsedNavGroups = (): ReadonlyArray => Option.getOrElse(Option.flatMap(readLocalStorage(COLLAPSED_NAV_GROUPS_KEY), decodeGroups), () => []) +/** Every org's last "no schedules" answer, epoch ms; an entry that is not a number is skipped. */ +const readNoScheduleOrgs = readPrefixed( + NO_SCHEDULES_STORAGE_PREFIX, + Schema.decodeUnknownOption(Schema.NumberFromString), +) + /** Persisted shell UI state, read once before the first render. */ export const readShellFlags: Effect.Effect = Effect.gen(function* () { const pinnedNamespaces = yield* readPinnedNamespaces @@ -79,6 +86,7 @@ export const readShellFlags: Effect.Effect = Effect.gen(function* () pinnedNamespaces, dashboardFavorites: readDashboardFavorites(), collapsedNavGroups: readCollapsedNavGroups(), + noScheduleOrgs: yield* readNoScheduleOrgs, rememberedSurfaces: yield* readRememberedSurfaces, isQuotaApproachingDismissed: yield* readQuotaApproachingDismissed, platform: detectPlatform(), diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index 78b2bea6ee..6fa454d18f 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -8,8 +8,10 @@ export { init, Model, type ShellClerk } from "./model" export { favoritesChanges, filterViewportChanges, - onCallHandoff, - onCallHandoffAt, + onCallChanges, + onCallReadAt, + onCallTimer, + onCallWakes, sidebarShortcut, smallViewportChanges, viewportChanges, diff --git a/apps/web/src/shell/message.ts b/apps/web/src/shell/message.ts index 370ae54310..966f002435 100644 --- a/apps/web/src/shell/message.ts +++ b/apps/web/src/shell/message.ts @@ -80,10 +80,21 @@ export const Message = defineMessageUnion({ FailedLoadSources: { orgId: Schema.String }, SucceededLoadDashboards: { orgId: Schema.String, dashboards: Schema.Array(PinnedDashboardRow) }, FailedLoadDashboards: { orgId: Schema.String }, - SucceededLoadOnCall: { orgId: Schema.String, snapshot: OnCallSnapshot }, - FailedLoadOnCall: { orgId: Schema.String }, - /** A schedule handed off: who is on call may have changed. */ - ElapsedOnCallHandoff: {}, + /** `isFromCache`: the org's fresh "no schedules" answer stood in for the request. */ + SucceededLoadOnCall: { + orgId: Schema.String, + snapshot: OnCallSnapshot, + readAtMs: Schema.Number, + isFromCache: Schema.Boolean, + }, + FailedLoadOnCall: { orgId: Schema.String, failedAtMs: Schema.Number }, + /** The pill's next read is due: a handoff, the hourly check off call, or a retry. */ + ElapsedOnCallTimer: { nowMs: Schema.Number }, + /** The tab came back into view or focus. */ + WokeOnCall: { nowMs: Schema.Number }, + /** A page wrote a schedule or an override. */ + ChangedOnCallSchedules: {}, + CompletedSaveNoSchedules: {}, /** A nav group's label: fold or unfold its rows. */ ClickedNavGroup: { groupId: Schema.String }, CompletedSaveNavGroups: {}, diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index 01736502fc..e36edc6948 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -82,6 +82,14 @@ export const Model = Schema.Struct({ onCall: OnCallData.schema, /** `maple.sidebar.collapsed-groups`: nav group ids the user folded. */ collapsedNavGroups: Schema.Array(Schema.String), + /** The pill's clock: when an on-call event last happened, so ended shifts read as unknown. */ + onCallNowMs: Schema.Number, + /** When the pill reads again; `null` for an org without schedules. */ + onCallNextReadAtMs: Schema.NullOr(Schema.Number), + /** The current retry delay; 0 after a good answer. */ + onCallBackoffMs: Schema.Number, + /** `maple.oncall.no-schedules.`: when each org last answered with no schedules. */ + noScheduleOrgs: Schema.Record(Schema.String, Schema.Number), namespaces: NamespacesData.schema, /** The org's sources, read once a page links to its source (`source-link.ts`). */ sources: SourcesData.schema, @@ -140,6 +148,7 @@ export type ShellFlags = Readonly<{ pinnedNamespaces: Readonly> dashboardFavorites: ReadonlyArray collapsedNavGroups?: ReadonlyArray + noScheduleOrgs?: Readonly> rememberedSurfaces?: Readonly>> isQuotaApproachingDismissed?: boolean platform?: Platform @@ -166,6 +175,10 @@ export const init = (flags: ShellFlags): Model => ({ dashboards: DashboardsData.Idle(), onCall: OnCallData.Idle(), collapsedNavGroups: flags.collapsedNavGroups ?? [], + onCallNowMs: 0, + onCallNextReadAtMs: null, + onCallBackoffMs: 0, + noScheduleOrgs: flags.noScheduleOrgs ?? {}, namespaces: NamespacesData.Idle(), sources: SourcesData.Idle(), namespaceQuery: "", diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts index 405111c4e6..77503cb45c 100644 --- a/apps/web/src/shell/nav-groups.scene.test.ts +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -65,23 +65,29 @@ describe("sidebar groups", () => { ) }) - test("the group holding the current page stays open and cannot fold", () => { + test("folding the group holding the current page keeps only the current page's row", () => { + const triage = role("button", { name: "Triage" }) scene( { update, view: shellAt("/alerts") }, - given(signedIn({ collapsedNavGroups: ["triage"] })), + given(signedIn()), measured, - expect(role("button", { name: "Triage" })).toHaveAttr("aria-expanded", "true"), - expect(role("button", { name: "Triage" })).toBeDisabled(), + expect(role("link", { name: "Errors" })).toExist(), + click(triage), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(triage).toHaveAttr("aria-expanded", "false"), expect(role("link", { name: "Alerts" })).toExist(), + expect(role("link", { name: "Errors" })).toBeAbsent(), + expect(role("link", { name: "Code review" })).toBeAbsent(), ) }) - test("the collapsed rail shows every row of a folded group", () => { + test("the collapsed rail shows every row of a folded group and its labels are no tab stops", () => { scene( { update, view: shellAt("/") }, given(signedIn({ isSidebarOpen: false, collapsedNavGroups: ["analyze"] })), measured, expect(dashboards).toExist(), + expect(analyze).toBeAbsent(), ) }) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 0abc3f0fc8..5ce01cc3bf 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -15,36 +15,59 @@ import { } from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import * as Tooltip from "@maple/views/ui/tooltip" -import { LoadOnCall } from "./data" import { Message } from "./message" import { init, type Model, OnCallData } from "./model" import { MeasureSidebarOverflow } from "./mounts" -import { nextHandoffMs, type OnCallShift, type OnCallSnapshot, onCallStatusOf } from "./oncall" -import { enter, update } from "./update" +import { + isNoSchedulesFresh, + nextHandoffMs, + ONCALL_IDLE_READ_MS, + ONCALL_RETRY_MS, + type OnCallShift, + type OnCallSnapshot, + onCallStatusOf, +} from "./oncall" +import { enter, update, type UpdateReturn } from "./update" import { frameView } from "./view" -// The footer's on-call pill: on call until the handoff, not on call, or nothing without schedules. +// The footer's on-call pill: what it says, and when it reads the schedules again. const flags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const -/** Mon 16 Nov 2026, 08:00 UTC: 09:00 in Berlin. */ +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 CET for a Berlin viewer. */ const HANDOFF = Date.UTC(2026, 10, 16, 8) +const NOW = HANDOFF - 3 * 60 * 60_000 +const ORG = "org_parity" const shift = (overrides: Partial = {}): OnCallShift => ({ scheduleName: "Checkout primary", - timeZone: "Europe/Berlin", userId: "user_me", endsAtMs: HANDOFF, ...overrides, }) -const withOnCall = (shifts: ReadonlyArray): Model => ({ +const snapshotOf = (shifts: ReadonlyArray): OnCallSnapshot => ({ + userId: "user_me", + viewerTimeZone: "Europe/Berlin", + shifts, +}) + +const signedIn = (overrides: Partial = {}): Model => ({ ...init(flags), - maybeOrgId: Option.some("org_parity"), + maybeOrgId: Option.some(ORG), hasRequestedSidebarData: true, - onCall: OnCallData.Success({ data: { userId: "user_me", shifts } }), + ...overrides, }) +const withOnCall = (shifts: ReadonlyArray, overrides: Partial = {}): Model => + signedIn({ onCall: OnCallData.Success({ data: snapshotOf(shifts) }), onCallNowMs: NOW, ...overrides }) + +const answer = (shifts: ReadonlyArray, readAtMs = NOW, isFromCache = false) => + Message.SucceededLoadOnCall({ orgId: ORG, snapshot: snapshotOf(shifts), readAtMs, isFromCache }) + +const commandsOf = (step: UpdateReturn) => + Arr.map(step.commands ?? [], (command) => ({ name: command.name, args: command.args })) + const view = (() => { const frame = withViewInputs(frameView, { currentPath: "/", @@ -59,13 +82,13 @@ const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOv const footer = selector('[data-slot="sidebar-footer"]') describe("on-call pill", () => { - test("on call: until the handoff on the schedule's clock, with the schedule, linking to the schedules", () => { + test("on call: until the handoff on the viewer's clock with its zone, the schedule, a link to schedules", () => { scene( { update, view }, given(withOnCall([shift()])), measured, expect(footer).toContainText("You're on call"), - expect(footer).toContainText("until Mon 16 Nov, 09:00"), + expect(footer).toContainText("until Mon 16 Nov, 09:00 CET"), expect(footer).toContainText("Checkout primary"), expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), ) @@ -81,32 +104,33 @@ describe("on-call pill", () => { ) }) - test("an org without schedules shows no pill", () => { + test("an org without schedules, a first read that failed, or a shift that ended show no pill", () => { + const absent = [ + expect(text("My schedules")).toBeAbsent(), + expect(text("You're on call")).toBeAbsent(), + ] + scene({ update, view }, given(withOnCall([])), measured, ...absent) scene( { update, view }, - given(withOnCall([])), + given(signedIn({ onCall: OnCallData.Failure({ error: "Failed" }) })), measured, - expect(text("You're not on call")).toBeAbsent(), - expect(text("My schedules")).toBeAbsent(), + ...absent, ) - }) - - test("a failed read shows no pill", () => { scene( { update, view }, - given({ ...withOnCall([]), onCall: OnCallData.Failure({ error: "Failed" }) }), + given(withOnCall([shift()], { onCallNowMs: HANDOFF + 1 })), measured, - expect(text("My schedules")).toBeAbsent(), + ...absent, ) }) - test("on the collapsed rail the pill is a dot whose tooltip says the sentence", () => { - const pill = role("link", { name: /You're on call/ }) + test("on the collapsed rail the pill is a dot named by the sentence, which its tooltip shows", () => { + const pill = role("link", { name: "You're on call until Mon 16 Nov, 09:00 CET (Checkout primary)" }) scene( { update, view }, - given({ ...withOnCall([shift()]), isSidebarOpen: false }), + given(withOnCall([shift()], { isSidebarOpen: false })), measured, - expect(selector('[data-slot="sidebar-footer"] [data-slot="status-dot"]')).toExist(), + expect(pill).toExist(), hover(pill), Command.resolve(Tooltip.WaitOpenDelay, Tooltip.Message.ElapsedOpenDelay({ generation: 1 })), Mount.resolve(Tooltip.MountTooltip, Tooltip.Message.CompletedMountTooltip()), @@ -119,49 +143,114 @@ describe("on-call pill", () => { Animation.Message.EndedAnimation({ generation: 1 }), ), expect(selector('[data-slot="tooltip-popup"]')).toHaveText( - "You're on call until Mon 16 Nov, 09:00 (Checkout primary)", + "You're on call until Mon 16 Nov, 09:00 CET (Checkout primary)", ), ) }) +}) - test("the sidebar reads the schedules with its other data, for its org only", () => { - const entered = enter("org_parity")(init(flags)) - expectVitest(Arr.map(entered.commands ?? [], (command) => command.name)).toContain(LoadOnCall.name) - const snapshot: OnCallSnapshot = { userId: "user_me", shifts: [shift()] } - const stale = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_other", snapshot })) +describe("on-call reads", () => { + test("the sidebar reads the schedules with its other data; an answer for another org is dropped", () => { + const entered = enter(ORG)(init(flags)) + expectVitest(commandsOf(entered)).toContainEqual({ + name: "LoadOnCall", + args: { orgId: ORG, noSchedulesCheckedAtMs: null }, + }) + const stale = update(entered.model, { + ...answer([shift()]), + orgId: "org_other", + }) expectVitest(stale.model.onCall._tag).toBe("Loading") - const fresh = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_parity", snapshot })) - expectVitest(fresh.model.onCall).toEqual(OnCallData.Success({ data: snapshot })) }) - test("a handoff reads the schedules again", () => { + test("an answer reads again after the next handoff, or within the hour while the user is off call", () => { + const onCall = update(signedIn(), answer([shift()])).model + expectVitest(onCall.onCallNextReadAtMs).toBe(HANDOFF + 5_000) + const offCall = update(signedIn(), answer([shift({ userId: "user_someone", endsAtMs: null })])).model + expectVitest(offCall.onCallNextReadAtMs).toBe(NOW + ONCALL_IDLE_READ_MS) + }) + + test("an answer whose handoff has already passed retries with a growing delay", () => { + const late = HANDOFF + 1_000 + const first = update(signedIn(), answer([shift()], late)).model + expectVitest(first.onCallNextReadAtMs).toBe(late + ONCALL_RETRY_MS) + const second = update(first, answer([shift()], late)).model + expectVitest(second.onCallNextReadAtMs).toBe(late + 2 * ONCALL_RETRY_MS) + }) + + test("a failed re-read keeps the last answer up and retries from a minute, doubling", () => { const loaded = withOnCall([shift()]) - const next = update(loaded, Message.ElapsedOnCallHandoff()) - expectVitest(Arr.map(next.commands ?? [], (command) => command.name)).toEqual([LoadOnCall.name]) + const failed = update(loaded, Message.FailedLoadOnCall({ orgId: ORG, failedAtMs: NOW })).model + expectVitest(failed.onCall).toEqual(OnCallData.Success({ data: snapshotOf([shift()]) })) + expectVitest(failed.onCallNextReadAtMs).toBe(NOW + ONCALL_RETRY_MS) + const again = update(failed, Message.FailedLoadOnCall({ orgId: ORG, failedAtMs: NOW })).model + expectVitest(again.onCallNextReadAtMs).toBe(NOW + 2 * ONCALL_RETRY_MS) + }) + + test("the timer reads again; coming back into focus reads only once a minute has passed", () => { + const loaded = withOnCall([shift()]) + expectVitest( + Arr.map( + commandsOf(update(loaded, Message.ElapsedOnCallTimer({ nowMs: HANDOFF }))), + (c) => c.name, + ), + ).toEqual(["LoadOnCall"]) + expectVitest(commandsOf(update(loaded, Message.WokeOnCall({ nowMs: NOW + 10_000 })))).toEqual([]) + expectVitest( + Arr.map(commandsOf(update(loaded, Message.WokeOnCall({ nowMs: NOW + 61_000 }))), (c) => c.name), + ).toEqual(["LoadOnCall"]) + }) + + test("an org with no schedules is remembered, skipped on the next read, and forgotten on a write", () => { + const empty = update(signedIn(), answer([])) + expectVitest(commandsOf(empty)).toEqual([ + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: NOW } }, + ]) + expectVitest(empty.model.onCallNextReadAtMs).toBeNull() + const booted = enter(ORG)(init({ ...flags, noScheduleOrgs: { [ORG]: NOW } })) + expectVitest(commandsOf(booted)).toContainEqual({ + name: "LoadOnCall", + args: { orgId: ORG, noSchedulesCheckedAtMs: NOW }, + }) + const written = update(empty.model, Message.ChangedOnCallSchedules()) + expectVitest(commandsOf(written)).toEqual([ + { name: "LoadOnCall", args: { orgId: ORG, noSchedulesCheckedAtMs: null } }, + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: null } }, + ]) + expectVitest(isNoSchedulesFresh(NOW, NOW + 23 * 60 * 60_000)).toBe(true) + expectVitest(isNoSchedulesFresh(NOW, NOW + 25 * 60 * 60_000)).toBe(false) }) }) describe("onCallStatusOf", () => { - test("the user's soonest-ending shift names the pill, counting the others", () => { - const status = onCallStatusOf({ - userId: "user_me", - shifts: [ + test("the user's soonest-ending live shift names the pill, counting the others", () => { + const status = onCallStatusOf( + snapshotOf([ shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 86_400_000 }), shift({ scheduleName: "Checkout primary" }), shift({ scheduleName: "Search", userId: "user_someone" }), - ], - }) + ]), + NOW, + ) expectVitest(status).toEqual({ _tag: "OnCall", scheduleName: "Checkout primary", otherSchedules: 1, - until: "Mon 16 Nov, 09:00", + until: "Mon 16 Nov, 09:00 CET", + }) + }) + + test("a shift past its end is not trusted: the next live one names the pill, or none does", () => { + const shifts = [shift(), shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 86_400_000 })] + expectVitest(onCallStatusOf(snapshotOf(shifts), HANDOFF + 1)).toMatchObject({ + scheduleName: "Payments", }) + expectVitest(onCallStatusOf(snapshotOf([shift()]), HANDOFF + 1)).toEqual({ _tag: "Unknown" }) }) test("the next handoff is the soonest across every schedule; none in a gap", () => { const shifts = [shift({ endsAtMs: HANDOFF + 1 }), shift({ userId: null, endsAtMs: null }), shift()] - expectVitest(nextHandoffMs({ userId: "user_me", shifts })).toBe(HANDOFF) - expectVitest(nextHandoffMs({ userId: "user_me", shifts: [shift({ endsAtMs: null })] })).toBeNull() + expectVitest(nextHandoffMs(snapshotOf(shifts))).toBe(HANDOFF) + expectVitest(nextHandoffMs(snapshotOf([shift({ endsAtMs: null })]))).toBeNull() }) }) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index 97099b2239..72664980f1 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -1,26 +1,28 @@ import { Array as Arr, Option, Order, Schema } from "effect" -import { formatShiftEdge } from "../page/alerts/shared/oncall-schedules" -// The sidebar's on-call pill: each schedule's current shift, as the schedules list reads it now. +// The sidebar's on-call pill: each schedule's current shift as the schedules list reads it, what +// the pill says, and when it asks again. /** One schedule's current shift: who holds it until when, `null` in a gap. */ export const OnCallShift = Schema.Struct({ scheduleName: Schema.String, - timeZone: Schema.String, userId: Schema.NullOr(Schema.String), endsAtMs: Schema.NullOr(Schema.Number), }) export type OnCallShift = typeof OnCallShift.Type -/** The signed-in user and every schedule of the org. */ +/** The signed-in user, the zone they read times in, and every schedule of the org. */ export const OnCallSnapshot = Schema.Struct({ userId: Schema.String, + viewerTimeZone: Schema.String, shifts: Schema.Array(OnCallShift), }) export type OnCallSnapshot = typeof OnCallSnapshot.Type export type OnCallStatus = | Readonly<{ _tag: "NoSchedules" }> + /** Every shift the user held has ended since the last read: hidden until the next one. */ + | Readonly<{ _tag: "Unknown" }> | Readonly<{ _tag: "NotOnCall" }> | Readonly<{ _tag: "OnCall" @@ -30,23 +32,59 @@ export type OnCallStatus = until: string }> +const untilFormatters = new Map() + +/** "Mon 16 Nov, 09:00 CET" on the viewer's clock, naming the zone; built from parts, as ICU builds differ in the joiner. */ +export const formatUntil = (ms: number, timeZone: string): string => { + const cached = untilFormatters.get(timeZone) + const formatter = + cached ?? + new Intl.DateTimeFormat("en-GB", { + timeZone, + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }) + if (cached === undefined) untilFormatters.set(timeZone, formatter) + const parts = formatter.formatToParts(ms) + const part = (type: Intl.DateTimeFormatPartTypes) => + Option.match( + Arr.findFirst(parts, (candidate) => candidate.type === type), + { onNone: () => "", onSome: (found) => found.value }, + ) + return `${part("weekday")} ${part("day")} ${part("month")}, ${part("hour")}:${part("minute")} ${part("timeZoneName")}` +} + const byEnd = Order.mapInput(Order.Number, (shift: OnCallShift) => shift.endsAtMs ?? Number.POSITIVE_INFINITY) -/** The user's soonest-ending shift names the pill; an org without schedules shows none. */ -export const onCallStatusOf = (snapshot: OnCallSnapshot): OnCallStatus => { +const hasEnded = (shift: OnCallShift, nowMs: number): boolean => + shift.endsAtMs !== null && shift.endsAtMs <= nowMs + +/** + * The user's soonest-ending live shift names the pill. A shift past its end is unknown (the read + * is stale), so a user whose every shift has ended sees nothing until the next read. + */ +export const onCallStatusOf = (snapshot: OnCallSnapshot, nowMs: number): OnCallStatus => { if (snapshot.shifts.length === 0) return { _tag: "NoSchedules" } - const mine = Arr.sort( - Arr.filter(snapshot.shifts, (shift) => shift.userId === snapshot.userId), + const held = Arr.filter(snapshot.shifts, (shift) => shift.userId === snapshot.userId) + const live = Arr.sort( + Arr.filter(held, (shift) => !hasEnded(shift, nowMs)), byEnd, ) - return Option.match(Arr.head(mine), { - onNone: () => ({ _tag: "NotOnCall" }), + return Option.match(Arr.head(live), { + onNone: (): OnCallStatus => (held.length > 0 ? { _tag: "Unknown" } : { _tag: "NotOnCall" }), onSome: (shift) => ({ _tag: "OnCall", scheduleName: shift.scheduleName, - otherSchedules: mine.length - 1, + otherSchedules: live.length - 1, until: - shift.endsAtMs === null ? "further notice" : formatShiftEdge(shift.endsAtMs, shift.timeZone), + shift.endsAtMs === null + ? "further notice" + : formatUntil(shift.endsAtMs, snapshot.viewerTimeZone), }), }) } @@ -57,11 +95,61 @@ export const nextHandoffMs = (snapshot: OnCallSnapshot): number | null => { return ends.length === 0 ? null : Math.min(...ends) } +// WHEN TO ASK AGAIN + +/** The API may lag a handoff by a moment. */ +const HANDOFF_SETTLE_MS = 5_000 +export const ONCALL_RETRY_MS = 60_000 +const ONCALL_RETRY_CAP_MS = 15 * 60_000 +/** A shift starting out of a gap shows up in no handoff, so a user off call checks hourly. */ +export const ONCALL_IDLE_READ_MS = 60 * 60_000 +/** Focus re-reads at most this often. */ +export const ONCALL_WAKE_MIN_MS = 60_000 +/** An org's "no schedules" answer is trusted this long before asking again. */ +export const NO_SCHEDULES_TTL_MS = 24 * 60 * 60_000 + +export type OnCallSchedule = Readonly<{ nextReadAtMs: number | null; backoffMs: number }> + +const backedOff = (nowMs: number, backoffMs: number): OnCallSchedule => { + const next = backoffMs === 0 ? ONCALL_RETRY_MS : Math.min(backoffMs * 2, ONCALL_RETRY_CAP_MS) + return { nextReadAtMs: nowMs + next, backoffMs: next } +} + +/** + * After an answer: at the next handoff, or within the hour while the user is off call; a handoff + * the answer already places in the past (the API has not caught up) retries with backoff. + */ +export const scheduleAfterAnswer = ( + snapshot: OnCallSnapshot, + nowMs: number, + backoffMs: number, +): OnCallSchedule => { + if (snapshot.shifts.length === 0) return { nextReadAtMs: null, backoffMs: 0 } + const handoff = nextHandoffMs(snapshot) + if (handoff !== null && handoff <= nowMs) return backedOff(nowMs, backoffMs) + const isOnCall = onCallStatusOf(snapshot, nowMs)._tag === "OnCall" + const candidates = [ + ...(handoff === null ? [] : [handoff + HANDOFF_SETTLE_MS]), + ...(isOnCall ? [] : [nowMs + ONCALL_IDLE_READ_MS]), + ] + return { nextReadAtMs: candidates.length === 0 ? null : Math.min(...candidates), backoffMs: 0 } +} + +/** After a failed read: retry from a minute, doubling, capped. */ +export const scheduleAfterFailure = (nowMs: number, backoffMs: number): OnCallSchedule => + backedOff(nowMs, backoffMs) + +/** The cached "no schedules" answer for an org is still fresh. */ +export const isNoSchedulesFresh = (checkedAtMs: number | undefined, nowMs: number): boolean => + checkedAtMs !== undefined && nowMs - checkedAtMs < NO_SCHEDULES_TTL_MS + +// COPY + /** The pill's schedule line: "Checkout primary", "Checkout primary +1". */ export const scheduleLabel = (status: Extract): string => status.otherSchedules === 0 ? status.scheduleName : `${status.scheduleName} +${status.otherSchedules}` -/** The pill's full sentence: its title, and the collapsed rail's tooltip. */ +/** The pill's full sentence: the collapsed rail's tooltip and accessible name. */ export const onCallSentence = (status: OnCallStatus): string => { switch (status._tag) { case "OnCall": @@ -69,6 +157,11 @@ export const onCallSentence = (status: OnCallStatus): string => { case "NotOnCall": return "You're not on call" case "NoSchedules": + case "Unknown": return "" } } + +/** The pill shows for these. */ +export const isPillShown = (status: OnCallStatus): boolean => + status._tag === "OnCall" || status._tag === "NotOnCall" diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index 65ee2707bb..f395917878 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -198,7 +198,7 @@ describe("sidebar collapse", () => { expect(sidebar).toBeAbsent(), ...open(), Command.expectExact(LoadInfraSurfaces, LoadOnCall), - Command.resolve(LoadOnCall, Message.FailedLoadOnCall({ orgId: "org_parity" })), + Command.resolve(LoadOnCall, Message.FailedLoadOnCall({ orgId: "org_parity", failedAtMs: 0 })), Command.resolve(LoadInfraSurfaces, surfacesAnswer("org_parity", [])), Command.expectExact(SaveInfraSurfaces({ orgId: "org_parity", surfaces: [] })), Command.resolve(SaveInfraSurfaces, Message.CompletedSaveInfraSurfaces()), diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index 076cfaba32..ec188cab53 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -1,11 +1,10 @@ import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" import { Clock, Duration, Effect, Option, Stream, String as Str } from "effect" -import { AsyncData } from "foldkit" import { Dom } from "foldkit" import { DASHBOARD_FAVORITES_EVENT, readDashboardFavorites } from "./favorites" import { Message } from "./message" import type { Model } from "./model" -import { nextHandoffMs } from "./oncall" +import { ONCALL_CHANGED_EVENT } from "../page/alerts/shared/oncall-schedules" /** `useIsMobile` (`max-md`): below 768px the sidebar is a sheet. */ export const MOBILE_QUERY = "(max-width: 767px)" @@ -86,16 +85,40 @@ export const favoritesChanges: Stream.Stream = Dom.streamFromEvent({ mapEvent: () => Message.ChangedDashboardFavorites({ favorites: readDashboardFavorites() }), }) -/** When the on-call pill next reads the schedules again: the soonest handoff, if any. */ -export const onCallHandoffAt = (model: Model): number | null => - Option.getOrNull(Option.flatMapNullishOr(AsyncData.getData(model.onCall), nextHandoffMs)) +/** When the on-call pill next reads the schedules; `null` while one is in flight or none is due. */ +export const onCallReadAt = (model: Model): number | null => model.onCallNextReadAtMs -/** Waits out the handoff (and a few seconds for the API to agree), then asks again. */ -export const onCallHandoff = (atMs: number): Stream.Stream => +/** Waits until the pill's next read is due. */ +export const onCallTimer = (atMs: number): Stream.Stream => Stream.fromEffect( Effect.gen(function* () { const now = yield* Clock.currentTimeMillis - yield* Effect.sleep(Duration.millis(Math.max(0, atMs - now) + 5_000)) - return Message.ElapsedOnCallHandoff() + yield* Effect.sleep(Duration.millis(Math.max(0, atMs - now))) + return Message.ElapsedOnCallTimer({ nowMs: yield* Clock.currentTimeMillis }) }), ) + +const wakeTargets: Dom.TypedEventTarget<{ focus: FocusEvent; visibilitychange: Event }> = window + +/** The tab came back into view or focus (a handoff may have passed while it slept). */ +export const onCallWakes: Stream.Stream = Stream.merge( + Dom.streamFromEvent({ target: wakeTargets, type: "focus", mapEvent: () => true }), + Dom.streamFromEvent({ + target: document, + type: "visibilitychange", + mapEvent: () => document.visibilityState === "visible", + }), +).pipe( + Stream.filter((isAwake) => isAwake), + Stream.mapEffect(() => Clock.currentTimeMillis), + Stream.map((nowMs) => Message.WokeOnCall({ nowMs })), +) + +const onCallChangeTarget: Dom.TypedEventTarget<{ [ONCALL_CHANGED_EVENT]: Event }> = window + +/** A page wrote a schedule or an override. */ +export const onCallChanges: Stream.Stream = Dom.streamFromEvent({ + target: onCallChangeTarget, + type: ONCALL_CHANGED_EVENT, + mapEvent: () => Message.ChangedOnCallSchedules(), +}) diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index b671d58c33..0bdca57292 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -12,6 +12,7 @@ import { FocusShortcutSearch, LogOut, SaveCollapsedNavGroups, + SaveNoSchedules, SaveInfraSurfaces, SaveNamespace, SaveSidebarState, @@ -27,6 +28,7 @@ import { isSameMenu, popupIdOf, triggerIdOf } from "./menus" import { BUILT_COMMIT, CheckDeployedCommit, ReloadPage } from "./version" import { openPalette, openShortcuts, paletteStep, shortcutsDialogStep, toggleTheme } from "./update-palette" import { Menu, Message, OutMessage } from "./message" +import { ONCALL_WAKE_MIN_MS, type OnCallSnapshot, scheduleAfterAnswer, scheduleAfterFailure } from "./oncall" import { DashboardsData, InfraPresenceData, @@ -64,6 +66,8 @@ export const enter = integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), onCall: () => OnCallData.Idle(), + onCallNextReadAtMs: () => null, + onCallBackoffMs: () => 0, namespaces: () => NamespacesData.Idle(), sources: () => SourcesData.Idle(), }) @@ -97,16 +101,95 @@ const requestSidebarData = (model: Model): UpdateReturn => { } } -/** The footer's on-call pill: read with the sidebar, again at each handoff with the last answer up. */ -const readOnCall = (model: Model): UpdateReturn => +/** + * The footer's on-call pill: read with the sidebar and again when due, the last answer staying up. + * A forced read skips the org's cached "no schedules" answer (a schedule was just written). + */ +const readOnCall = (model: Model, isForced = false): UpdateReturn => Option.match(Option.all([model.maybeOrgId, AsyncData.revalidateOrLoad(model.onCall)]), { onNone: () => ({ model }), onSome: ([orgId, onCall]) => ({ - model: modifyFields(model, { onCall: () => onCall }), - commands: [LoadOnCall({ orgId })], + model: modifyFields(model, { onCall: () => onCall, onCallNextReadAtMs: () => null }), + commands: [ + LoadOnCall({ + orgId, + noSchedulesCheckedAtMs: isForced ? null : (model.noScheduleOrgs[orgId] ?? null), + }), + ], }), }) +/** An answer: schedule the next read, and remember (or forget) that the org has no schedules. */ +const onCallAnswered = ( + model: Model, + answer: Readonly<{ orgId: string; snapshot: OnCallSnapshot; readAtMs: number; isFromCache: boolean }>, +): UpdateReturn => { + const { orgId, snapshot, readAtMs, isFromCache } = answer + if (!Option.contains(model.maybeOrgId, orgId)) return { model } + const next = scheduleAfterAnswer(snapshot, readAtMs, model.onCallBackoffMs) + const isEmpty = snapshot.shifts.length === 0 + const wasEmpty = model.noScheduleOrgs[orgId] !== undefined + return { + model: modifyFields(model, { + onCall: () => OnCallData.Success({ data: snapshot }), + onCallNowMs: () => readAtMs, + onCallNextReadAtMs: () => next.nextReadAtMs, + onCallBackoffMs: () => next.backoffMs, + noScheduleOrgs: (all) => + isFromCache ? all : isEmpty ? Record.set(all, orgId, readAtMs) : Record.remove(all, orgId), + }), + commands: isFromCache + ? [] + : isEmpty + ? [SaveNoSchedules({ orgId, checkedAtMs: readAtMs })] + : wasEmpty + ? [SaveNoSchedules({ orgId, checkedAtMs: null })] + : [], + } +} + +/** A failed read keeps the last good answer up and retries with backoff. */ +const onCallFailed = (model: Model, orgId: string, failedAtMs: number): UpdateReturn => { + if (!Option.contains(model.maybeOrgId, orgId)) return { model } + const next = scheduleAfterFailure(failedAtMs, model.onCallBackoffMs) + return { + model: modifyFields(model, { + onCall: (current) => + Option.match(AsyncData.getData(current), { + onNone: () => OnCallData.Failure({ error: "Failed" }), + onSome: (data) => OnCallData.Success({ data }), + }), + onCallNowMs: () => failedAtMs, + onCallNextReadAtMs: () => next.nextReadAtMs, + onCallBackoffMs: () => next.backoffMs, + }), + } +} + +/** Back in view or focus: move the pill's clock, and read again if the last event is a minute old. */ +const onCallWoke = (model: Model, nowMs: number): UpdateReturn => { + const hasSchedules = Option.exists(AsyncData.getData(model.onCall), (data) => data.shifts.length > 0) + if (!hasSchedules) return { model } + const moved = modifyFields(model, { onCallNowMs: () => nowMs }) + return nowMs - model.onCallNowMs >= ONCALL_WAKE_MIN_MS ? readOnCall(moved) : { model: moved } +} + +/** A page wrote a schedule or override: the org may have its first schedule now. */ +const onCallChanged = (model: Model): UpdateReturn => + Option.match(model.maybeOrgId, { + onNone: () => ({ model }), + onSome: (orgId) => { + const read = readOnCall( + modifyFields(model, { noScheduleOrgs: (all) => Record.remove(all, orgId) }), + true, + ) + return { + model: read.model, + commands: [...(read.commands ?? []), SaveNoSchedules({ orgId, checkedAtMs: null })], + } + }, + }) + /** Folds or unfolds a nav group and remembers it. */ const toggleNavGroup = (model: Model, groupId: string): UpdateReturn => { const groups = Array.contains(model.collapsedNavGroups, groupId) @@ -486,15 +569,12 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => }), } : { model }, - SucceededLoadOnCall: ({ orgId, snapshot }) => - forOrg(model, orgId, (current) => - modifyFields(current, { onCall: () => OnCallData.Success({ data: snapshot }) }), - ), - FailedLoadOnCall: ({ orgId }) => - forOrg(model, orgId, (current) => - modifyFields(current, { onCall: () => OnCallData.Failure({ error: "Failed" }) }), - ), - ElapsedOnCallHandoff: () => readOnCall(model), + SucceededLoadOnCall: (answer) => onCallAnswered(model, answer), + FailedLoadOnCall: ({ orgId, failedAtMs }) => onCallFailed(model, orgId, failedAtMs), + ElapsedOnCallTimer: ({ nowMs }) => readOnCall(modifyFields(model, { onCallNowMs: () => nowMs })), + WokeOnCall: ({ nowMs }) => onCallWoke(model, nowMs), + ChangedOnCallSchedules: () => onCallChanged(model), + CompletedSaveNoSchedules: () => ({ model }), ClickedNavGroup: ({ groupId }) => toggleNavGroup(model, groupId), CompletedSaveNavGroups: () => ({ model }), SucceededLoadNamespaces: ({ orgId, namespaces }) => diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 215e8d8a29..749a57a46f 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -277,16 +277,15 @@ export const pinnedGroup = ( ] } -export type NavGroupFold = "unlabelled" | "expanded" | "collapsed" | "pinned" +export type NavGroupFold = "unlabelled" | "rail" | "expanded" | "collapsed" /** - * How a group's rows show: a labelled group folds, except while it holds the current page - * (`pinned`) and on the collapsed rail, where its label is hidden and every row shows. + * How a group's rows show. The collapsed rail hides labels, so every row shows there; elsewhere a + * labelled group folds to the rows of the current page, if it holds it. */ -export const navGroupFold = (model: Model, group: NavGroup, currentPath: string): NavGroupFold => { +export const navGroupFold = (model: Model, group: NavGroup): NavGroupFold => { if (group.label === undefined) return "unlabelled" - if (Arr.some(group.items, (item) => isNavItemActive(currentPath, item))) return "pinned" - if (!model.isSidebarOpen && !model.isMobile) return "expanded" + if (!model.isSidebarOpen && !model.isMobile) return "rail" return model.collapsedNavGroups.includes(group.id) ? "collapsed" : "expanded" } @@ -300,28 +299,34 @@ export const navGroupSections = ( clerk: ShellClerk | null = null, ): ReadonlyArray => Arr.map(shellNavGroups(clerk), (group) => { - const fold = navGroupFold(model, group, currentPath) + const fold = navGroupFold(model, group) + const rows = + fold === "collapsed" + ? Arr.filter(group.items, (item) => isNavItemActive(currentPath, item)) + : group.items return sidebarGroup(h, undefined, [ ...(group.label === undefined ? [] - : [ - sidebarGroupToggle(h, { - label: group.label, - isExpanded: fold !== "collapsed", - controlsId: navGroupContentId(group), - onToggle: - fold === "pinned" ? null : Message.ClickedNavGroup({ groupId: group.id }), - }), - ]), + : fold === "rail" + ? // Hidden on the rail: a plain label keeps the spacing and is no tab stop. + [sidebarGroupLabel(h, GROUP_LABEL, [group.label])] + : [ + sidebarGroupToggle(h, { + label: group.label, + isExpanded: fold !== "collapsed", + controlsId: navGroupContentId(group), + onToggle: Message.ClickedNavGroup({ groupId: group.id }), + }), + ]), sidebarGroupContent( h, - fold === "collapsed" + rows.length === 0 ? [] : [ sidebarMenu( h, undefined, - Arr.map(group.items, (item) => navRow(h, model, item, currentPath, tip)), + Arr.map(rows, (item) => navRow(h, model, item, currentPath, tip)), ), ], [h.Id(navGroupContentId(group))], diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 324e27aa37..403da11b4e 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -27,7 +27,7 @@ import { identityLabel } from "@maple/views/ui/identity-label" import { hstack } from "@maple/views/ui/stack" import { statusDot } from "@maple/views/ui/status-dot" import { oncallTabHref } from "../page/alerts/shared/oncall-schedules" -import { type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" +import { isPillShown, type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" export const GROUP_LABEL = "h-6 text-muted-foreground" @@ -251,7 +251,7 @@ const footerNavRow = ( export const shellOnCallStatus = (model: Model): OnCallStatus => Option.match(AsyncData.getData(model.onCall), { onNone: (): OnCallStatus => ({ _tag: "NoSchedules" }), - onSome: onCallStatusOf, + onSome: (snapshot) => onCallStatusOf(snapshot, model.onCallNowMs), }) /** @@ -263,8 +263,9 @@ const onCallPill = ( currentPath: string, status: OnCallStatus, tip: RailTip, + isRail: boolean, ): ReadonlyArray => - status._tag === "NoSchedules" + !isPillShown(status) ? [] : [ sidebarMenu(h, undefined, [ @@ -277,7 +278,11 @@ const onCallPill = ( "h-auto gap-2.5 border border-sidebar-border py-1.5 group-data-[collapsible=icon]:border-transparent", link: { href: oncallTabHref(), currentPath }, hasTooltip: true, - attributes: [...tip(onCallSentence(status)), h.Title(onCallSentence(status))], + // The rail hides the lines, so the link names itself there. + attributes: [ + ...tip(onCallSentence(status)), + ...(isRail ? [h.AriaLabel(onCallSentence(status))] : []), + ], }, [ hstack( @@ -420,7 +425,7 @@ export const shellSidebarFooter = ( ], [], ), - ...onCallPill(h, currentPath, shellOnCallStatus(model), tip), + ...onCallPill(h, currentPath, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), sidebarMenu(h, undefined, [ footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index 171d7dea3f..ae898eb3eb 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -30,7 +30,7 @@ import { shellSidebarFooter, shellSidebarHeader, } from "./view-sidebar" -import { onCallSentence } from "./oncall" +import { isPillShown, onCallSentence } from "./oncall" import { type BreadcrumbEntry, hidesTopbarConnect, topbar } from "./view-topbar" import { splitPageRails } from "./page-rails" import { filterSheet, filterSheetTrigger, rightSheet, rightSheetTrigger } from "./view-page-sheets" @@ -67,7 +67,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), - ...(onCall._tag === "NoSchedules" ? [] : [onCallSentence(onCall)]), + ...(isPillShown(onCall) ? [onCallSentence(onCall)] : []), "Sources", "Settings", ] diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index 655b59a89b..da920252d9 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -215,19 +215,33 @@ const root = Subscription.make()((entry) => ({ ), }, ), - /** The on-call pill reads the schedules again once a shift hands off. */ - shellOnCallHandoff: entry( + /** The on-call pill's next read: a handoff, the hourly check off call, or a retry. */ + shellOnCallTimer: entry( { atMs: Schema.NullOr(Schema.Number) }, { modelToDependencies: (model) => ({ - atMs: model.session._tag === "SignedIn" ? Shell.onCallHandoffAt(model.shell) : null, + atMs: model.session._tag === "SignedIn" ? Shell.onCallReadAt(model.shell) : null, }), dependenciesToStream: ({ atMs }) => atMs === null ? Stream.empty - : Stream.map(Shell.onCallHandoff(atMs), (message) => - Message.GotShellMessage({ message }), - ), + : Stream.map(Shell.onCallTimer(atMs), (message) => Message.GotShellMessage({ message })), + }, + ), + /** Focus and visibility, and a page's schedule writes, refresh the on-call pill. */ + shellOnCallEvents: entry( + { isShellOpen: Schema.Boolean }, + { + modelToDependencies: (model) => ({ + isShellOpen: model.session._tag === "SignedIn" && rendersShell(model.route), + }), + dependenciesToStream: ({ isShellOpen }) => + Stream.when( + Stream.map(Stream.merge(Shell.onCallWakes, Shell.onCallChanges), (message) => + Message.GotShellMessage({ message }), + ), + Effect.sync(() => isShellOpen), + ), }, ), /** The dashboards list starred or unstarred one (`dashboardFavoritesAtom`). */ diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index f5dfdb194d..3ce934a3bd 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -142,7 +142,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/select.ts` `listId`, `itemId` (stateful: Model, init, update): Base UI `Select` (`@maple/ui/select`): the popup opens over the trigger with the selected item on the trigger's value (`alignItemWithTrigger`), or below it when that does not fit. - `packages/views/src/ui/selectable-row.ts` `selectableRow`: `SelectableRow`: a bespoke row's frame (a `div`, or a link with `href`) with `listRow`'s selection lane and tint; a clickable one is a `pressable` with `selectedLane`. - `packages/views/src/ui/setting-row.ts` `settingRow`: Port of packages/ui/src/components/ui/setting-row.tsx. -- `packages/views/src/ui/sheet.ts` `leftSheet`, `rightSheet`, `followUrl`, `isDismissed`, `mapSheetCommands`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. +- `packages/views/src/ui/sheet.ts` `leftSheet`, `sidePanelSheet`, `followUrl`, `isDismissed`, `mapSheetCommands`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. - `packages/views/src/ui/shift-timeline.ts` `zonedDayStart`, `placeBar`, `timelineDays`, `barRoomPct`, `shiftTimeline`: `ShiftTimeline`: rows of time bars over a day-ruled axis in one time zone, with a now line (an on-call schedule's layers and its final row). - `packages/views/src/ui/side-panel.ts` `sidePanelFrame`: A detail side panel's frame (span, service, database): a full-height clipped column on the page background. - `packages/views/src/ui/sidebar.ts` `sidebarWrapper`, `sidebarDesktop`, `sidebarMobile`, `sidebarHeader`, `sidebarFooter`, `sidebarContent`, `sidebarGroup`, `sidebarGroupLabel`, `sidebarGroupContent`, `sidebarGroupToggle`, `sidebarGroupAction`, `sidebarSeparator`, `sidebarMenu`, `sidebarMenuItem`, `sidebarMenuBadge`, `sidebarMenuSub`, `sidebarMenuSubItem`, `isLinkActive`, `sidebarMenuButton`, `sidebarMenuSubButton`, `sidebarRail`, `sidebarInset`, `sidebarTrigger`: The Foldkit side of packages/ui/src/components/ui/sidebar.tsx: same markup, same class composition. @@ -227,7 +227,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/series-legend.ts` `seriesLegend` - `apps/web/src/page/kit/service-runtimes.ts` `runtimeLink`, `workloadLink`: Where a service runs, as links to the infra pages: its platform resources (`ServiceRuntimesResponse`) and its Kubernetes workloads. - `apps/web/src/page/kit/sheet-detail-header.ts` `sheetDetailHeader`: `SheetDetailHeader`: a detail sheet's eyebrow kind, title, screen-reader description and meta row. -- `apps/web/src/page/kit/sheet-param.ts` `withSheetParam`, `sheetParamOf`, `openSheetNavigation`, `closeSheetNavigation`: A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the link reopens it), and dismissing replaces it away. +- `apps/web/src/page/kit/sheet-param.ts` `withSheetParam`, `sheetParamOf`, `dismissalOf`, `pushSheetUrl`, `dismissSheetUrl`: A side sheet held in the URL as `?sheet=`. - `apps/web/src/page/kit/signal-empty-state.ts` `docsLink`, `signalEmptyState`: The dashboard's copy for `SignalEmptyState`: where each signal comes from, and the setup page and guides it links. - `apps/web/src/page/kit/signal-presence.ts` `loadSignalPresences`: Signal presence over `telemetrySignalsAtom`. - `apps/web/src/page/kit/stable-slice.ts` `stableSlice`: A view's inputs as one object, kept by reference while every field is: a `createLazy` slot keyed on it skips the renders that change nothing the view reads. diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 2ca2ec85fd..966070641b 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -12,7 +12,7 @@ import { type SheetSize, } from "@maple/ui/styles/ui/sheet" import { Command } from "foldkit" -import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "./button" import * as Dialog from "./dialog" import * as DialogView from "./dialog-view" @@ -132,9 +132,9 @@ export const leftSheet = ( ], ) -// RIGHT SHEET +// SIDE PANEL SHEET -export type RightSheetOptions = Readonly<{ +export type SidePanelSheetOptions = Readonly<{ sheet: Dialog.Model title: string description?: string @@ -142,8 +142,11 @@ export type RightSheetOptions = Readonly<{ size?: SheetSize /** The scrolling body. */ body: ReadonlyArray - /** The sticky footer (Cancel, then the primary action); none renders no footer. */ - footer?: ReadonlyArray + /** + * The sticky footer (Cancel, then the primary action), handed the close button's attributes so + * Cancel dismisses like Escape does; none renders no footer. + */ + footer?: (close: ReadonlyArray) => ReadonlyArray toParentMessage: (message: Dialog.Message) => Message }> @@ -155,7 +158,10 @@ const REDUCED_MOTION = * A full-height panel from the right over the Dialog Submodel: title, close, a scrolling body and a * sticky footer. Escape, the backdrop and the close button close it (`isDismissed`). */ -export const rightSheet = (h: HtmlBuilder, options: RightSheetOptions): Html => { +export const sidePanelSheet = ( + h: HtmlBuilder, + options: SidePanelSheetOptions, +): Html => { const { sheet } = options return h.submodel({ slotId: `${sheet.id}-sheet`, @@ -167,7 +173,7 @@ export const rightSheet = (h: HtmlBuilder, options: RightSheet className: REDUCED_MOTION, hasDescription: options.description !== undefined, toView: ({ portal }) => portal, - toContent: () => [ + toContent: (close) => [ DialogView.dialogHeader(h, "sheet", [ DialogView.dialogTitle(h, "sheet", sheet.id, options.title), ...(options.description === undefined @@ -177,7 +183,7 @@ export const rightSheet = (h: HtmlBuilder, options: RightSheet DialogView.dialogPanel(h, "sheet", sheet.id, options.body), ...(options.footer === undefined ? [] - : [DialogView.dialogFooter(h, "sheet", options.footer)]), + : [DialogView.dialogFooter(h, "sheet", options.footer(close))]), ], }, toParentMessage: options.toParentMessage, diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 67c77e1c02..528a4fd6cd 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -226,8 +226,7 @@ export type SidebarGroupToggleOptions = Readonly<{ isExpanded: boolean /** The `sidebarGroupContent` id it shows and hides. */ controlsId: string - /** None: the group cannot collapse right now (it holds the current page). */ - onToggle: Message | null + onToggle: Message }> /** A group label that collapses its rows: a chevron button with `aria-expanded`. */ @@ -241,17 +240,14 @@ export const sidebarGroupToggle = ( h.Class( cn( ...sidebarGroupLabelStyles, - "group/toggle h-6 w-full gap-1 text-left text-muted-foreground", - options.onToggle === null - ? "cursor-default" - : "cursor-pointer hover:text-sidebar-foreground active:text-sidebar-foreground", + "group/toggle h-6 w-full cursor-pointer gap-1 text-left text-muted-foreground hover:text-sidebar-foreground active:text-sidebar-foreground", ), ), h.DataAttribute("sidebar", "group-label"), h.DataAttribute("slot", "sidebar-group-toggle"), h.AriaExpanded(options.isExpanded), h.AriaControls(options.controlsId), - ...(options.onToggle === null ? [h.AriaDisabled(true)] : [h.OnClick(options.onToggle)]), + h.OnClick(options.onToggle), ], [ h.span([h.Class("flex-1 truncate")], [options.label]), From 18c83a33daccd2249eb142049d4c926f36e53b63 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:00:58 +0100 Subject: [PATCH 03/35] feat(alerts): sections at their own paths, sidebar sub-items, silence side panel - Each hub section has its own path (/alerts/rules, /alerts/uptime, /alerts/slos, /alerts/silences, /alerts/maintenance, /alerts/on-call, /alerts/routing); /alerts stays the Inbox. One page instance serves them all, so a section change reads nothing it already has. - Old /alerts?tab= links replace themselves with the section's path and keep their other params ("oncall" and "settings" map to on-call and routing). - The sidebar's Alerts row lists the sections when active and replaces the hub's own section rail. Sub-item aria-current now follows the most specific match. - New silence opens in a URL-held side panel (?sheet=silence) with a sticky Cancel / Save footer; Back, Cancel, Escape and a save close it. sidePanelSheet gains onSubmit so the panel is a form. --- .../components/settings/audit-log-resource.ts | 4 +- apps/web/src/lab/alerts-hub-lab.ts | 40 ++++- apps/web/src/lab/anomaly-alerts-lab.ts | 6 +- apps/web/src/lab/entries/alerts-hub.ts | 4 +- apps/web/src/lab/escalation-lab.ts | 4 +- apps/web/src/lab/maintenance-lab.ts | 6 +- apps/web/src/lab/oncall-lab.ts | 6 +- apps/web/src/lab/registry.ts | 2 +- apps/web/src/lab/shell-lab.ts | 7 +- apps/web/src/lab/slos-lab.ts | 4 +- .../alerts/editor/editor-chains.scene.test.ts | 2 +- apps/web/src/page/alerts/hub/command.ts | 17 +++ apps/web/src/page/alerts/hub/derive.test.ts | 4 +- apps/web/src/page/alerts/hub/derive.ts | 33 ----- .../alerts/hub/hub-anomalies.scene.test.ts | 33 ++--- .../page/alerts/hub/hub-chains.scene.test.ts | 4 +- .../alerts/hub/hub-maintenance.scene.test.ts | 21 +-- .../page/alerts/hub/hub-oncall.scene.test.ts | 33 +++-- .../alerts/hub/hub-silences.scene.test.ts | 137 ++++++++++++++---- .../page/alerts/hub/hub-slos.scene.test.ts | 14 +- .../page/alerts/hub/hub-uptime.scene.test.ts | 16 +- .../web/src/page/alerts/hub/hub.scene.test.ts | 80 ++++++---- apps/web/src/page/alerts/hub/index.ts | 8 +- apps/web/src/page/alerts/hub/message.ts | 2 + apps/web/src/page/alerts/hub/model.ts | 8 +- apps/web/src/page/alerts/hub/update.ts | 135 +++++++++++------ apps/web/src/page/alerts/hub/view-empty.ts | 2 +- apps/web/src/page/alerts/hub/view-inbox.ts | 4 - apps/web/src/page/alerts/hub/view.ts | 118 ++------------- .../src/page/alerts/rule/rule.scene.test.ts | 2 +- apps/web/src/page/alerts/rule/update.ts | 2 +- .../src/page/alerts/rule/view-diagnosis.ts | 2 +- .../page/alerts/shared/escalation-chains.ts | 2 +- apps/web/src/page/alerts/shared/links.ts | 16 +- .../page/alerts/shared/maintenance-windows.ts | 2 +- .../page/alerts/shared/oncall-schedules.ts | 2 +- .../src/page/alerts/shared/slo-commands.ts | 2 +- apps/web/src/page/alerts/shared/test-rows.ts | 9 ++ .../src/page/alerts/shared/uptime-commands.ts | 2 +- .../src/page/alerts/silence-dialog/dialog.ts | 9 +- .../src/page/alerts/silence-dialog/view.ts | 55 ++++--- .../services/detail/uptime-card.scene.test.ts | 2 +- .../page/settings/audit-log/view-detail.ts | 10 +- apps/web/src/route/app-route.ts | 1 + apps/web/src/route/index.ts | 2 +- apps/web/src/route/redirect.ts | 35 ++++- apps/web/src/route/route.test.ts | 38 ++++- apps/web/src/route/routers.ts | 7 + apps/web/src/route/search.ts | 25 +++- apps/web/src/shell/nav.ts | 43 +++++- apps/web/src/shell/oncall.scene.test.ts | 4 +- apps/web/src/shell/shell.scene.test.ts | 50 +++++++ packages/views/src/ui/sheet.ts | 30 ++-- packages/views/src/ui/sidebar.ts | 3 +- 54 files changed, 693 insertions(+), 416 deletions(-) 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..e87756b96b 100644 --- a/apps/web/src/lab/alerts-hub-lab.ts +++ b/apps/web/src/lab/alerts-hub-lab.ts @@ -2,7 +2,7 @@ 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" @@ -11,13 +11,15 @@ 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" -// `/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. The shell +// stands on the section's own path, and `?sheet=` is played here rather than pushed. export const LabState = Schema.Literals([ "inbox", @@ -55,9 +57,15 @@ const CONTEXT: PageContext = { } 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() @@ -140,10 +148,23 @@ 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) => + 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) => { + 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, + ) + 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 entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), CONTEXT).model const base = [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), @@ -201,6 +222,9 @@ export const update = (model: Model, message: Message): Return => 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", 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..9e69ef8a00 100644 --- a/apps/web/src/lab/entries/alerts-hub.ts +++ b/apps/web/src/lab/entries/alerts-hub.ts @@ -1,5 +1,5 @@ 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=`). @@ -11,6 +11,6 @@ export const lab = defineShellLab("Alerts hub", { init, update, view, - currentPath: "/lab/alerts-hub", + currentPath, breadcrumbs, }) 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 8ec40d1192..843e1bdbb2 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -57,7 +57,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 (`?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..2003012daa 100644 --- a/apps/web/src/lab/shell-lab.ts +++ b/apps/web/src/lab/shell-lab.ts @@ -31,7 +31,8 @@ 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 @@ -88,6 +89,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 ?? {})({ @@ -154,7 +157,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/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index fbbb61e587..496064507e 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -62,7 +62,7 @@ describe("alert rule editor, escalation chains", () => { 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/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index 785a833772..abc46e5491 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,22 @@ import { import { Message } from "./message" import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" +const sheetArgs = { href: Schema.String, id: Schema.String } + +/** Opens a side panel: a history entry carrying `?sheet=`, marked as this tab's. */ +export const PushHubSheet = Command.define("PushAlertsHubSheet", { + args: sheetArgs, + messages: [Message.CompletedSheetUrl], + execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), +}) + +/** Closes it: Back over the entry this tab pushed, else `?sheet=` replaced away. */ +export const DismissHubSheet = Command.define("DismissAlertsHubSheet", { + args: sheetArgs, + messages: [Message.CompletedSheetUrl], + execute: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), +}) + 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..e9f48fefbb 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -112,39 +112,6 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) -export type AlertsTab = - | "inbox" - | "rules" - | "uptime" - | "slos" - | "silences" - | "maintenance" - | "oncall" - | "routing" - -/** Legacy `tab` values map onto the current tabs so old links keep resolving. */ -export const resolveTab = (tab: string | undefined): AlertsTab => { - 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" - } -} - /** `attention` is the legacy name of the blind bucket. */ export const resolveStatus = (status: string | undefined): RuleBucket | undefined => { switch (status) { 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..570e9b8986 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 "../shared/links" 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..2102c64f20 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 "../shared/links" 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..2d7d322363 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,17 @@ 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 Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" -import { - click, - Command, - expect as expectView, - Mount, - given, - role, - scene, - text, - withViewInputs, -} from "foldkit/scene" +import { expect as expectView, Mount, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { NOW, ORG } from "../shared/test-rows" +import { NOW, ORG, hubAt } from "../shared/test-rows" 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 +47,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 +155,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 +185,117 @@ 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 }) - test("New silence opens the dialog with typed scope fields", () => { +/** The Silences tab with `?sheet=silence` followed: the panel is open. */ +const withPanel = (grants = ENGINEER) => + routeChanged(onSilences(grants, []), hubAt({ tab: "silences", sheet: "silence" }), context) + +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 typed scope fields, Cancel and a save that waits for a valid form", () => { + 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: "Cancel" })).toExist(), expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), ) }) + + 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("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( + [ + silenceMessage( + SilenceDialog.Message.ChangedScopeField({ field: "services", 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" }) + }) }) 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..6526d14694 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", () => { @@ -231,7 +227,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..5694c615e2 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", ) }) 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..9e44f27e3d 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,42 @@ 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(), - ) - // The anomaly filters belong to the Inbox: its row keeps them, every other row drops them. - 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()), + expectView(role("heading", { name: "Inbox" })).toExist(), ) - expect(breadcrumbs(loaded({ tab: "silences" }))).toEqual([ - { label: "Alerts", href: "/alerts" }, - { label: "Silences" }, + }) + + 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 +203,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 +331,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 +341,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/message.ts b/apps/web/src/page/alerts/hub/message.ts index 75a499ae12..5f0c12fffb 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -52,6 +52,8 @@ const rows = (row: S) => ({ orgId: Schema.String, rows: Sc const outcome = (success: S) => Schema.Result(success, LoadFailure) export const Message = defineMessageUnion({ + /** A `?sheet=` push or dismissal ran; the route change that follows does the work. */ + CompletedSheetUrl: {}, ReadClock: { nowMs: Schema.Number }, UpdatedRules: rows(AlertRuleRowSchema), UpdatedRuleStates: rows(AlertRuleStateRowSchema), diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 4c448ddcad..c23d373893 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, AlertsSection } 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" @@ -53,8 +53,12 @@ export type SloListRow = typeof SloListRow.Type export const TestOutcome = Schema.Struct({ message: Schema.String, success: Schema.Boolean }) +/** The section the route names: the Inbox at `/alerts`, the rest at `/alerts/
`. */ +export const AlertsTab = Schema.Union([Schema.Literal("inbox"), AlertsSection]) + 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..f47c40d86d 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,46 @@ 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 + * 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 isInUrl = model.search.sheet === SILENCE_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: [] } +} + 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 too. + const hasClosed = model.silenceDialog.dialog.isOpen && !step.model.dialog.isOpen + const commands = [ + ...silenceCommands(step), + ...(hasClosed && model.search.sheet === SILENCE_SHEET + ? [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 +1086,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 +1119,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 ? followSilenceSheet({ ...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 +1336,10 @@ 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 })] }, + CompletedSheetUrl: () => ({ model }), GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), @@ -1353,7 +1404,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 5332b2de89..44384ef70f 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.ts b/apps/web/src/page/alerts/hub/view.ts index ea70cd025a..94d722f8fa 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -15,13 +15,11 @@ 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 { alertsHref, createHref } from "../shared/links" +import { type AlertsTab, alertsHref, createHref } from "../shared/links" import { newSloHref } from "../shared/slo-commands" import { newMonitorHref } from "../shared/uptime-commands" import { - type AlertsTab, anomalyOverviewOf, destinationsOf, isAboutService, @@ -29,13 +27,12 @@ import { resolveIncidentStatus, resolveSignal, resolveStatus, - resolveTab, } 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" @@ -58,7 +55,7 @@ import { 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 +66,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, @@ -309,7 +212,7 @@ const headerActions = (h: H, model: Model, tab: AlertsTab): Html => ), ] : []), - ...(canManage(model) && tab === "oncall" && hasSchedules(model) ? [newScheduleButton(h)] : []), + ...(canManage(model) && tab === "on-call" && hasSchedules(model) ? [newScheduleButton(h)] : []), ...(canManage(model) && tab === "routing" ? [ button( @@ -440,7 +343,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")], @@ -450,9 +353,6 @@ export const view = Submodel.defineView((model, { title: TAB_LABEL[tab], actions: headerActions(h, model, tab), - filters: rail(h, model, overview, tab), - filtersLabel: "Alerts", - filtersWidth: "w-56", }, [ tab === "routing" @@ -469,7 +369,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), ], 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..f9f7c1bc38 100644 --- a/apps/web/src/page/alerts/rule/rule.scene.test.ts +++ b/apps/web/src/page/alerts/rule/rule.scene.test.ts @@ -348,7 +348,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/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..4d86fc8328 100644 --- a/apps/web/src/page/alerts/shared/links.ts +++ b/apps/web/src/page/alerts/shared/links.ts @@ -1,7 +1,12 @@ 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, + AlertsSection, +} from "../../../route/search" const decodeRuleId = Schema.decodeUnknownOption(AlertRuleId) @@ -15,7 +20,12 @@ 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 })) +/** The Alerts hub's sections: the Inbox at `/alerts`, the rest at `/alerts/
`. */ +export type AlertsTab = "inbox" | AlertsSection + +/** 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/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/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..2259f78b67 100644 --- a/apps/web/src/page/alerts/shared/test-rows.ts +++ b/apps/web/src/page/alerts/shared/test-rows.ts @@ -4,8 +4,17 @@ import type { AlertRuleRow, AlertRuleStateRow, } from "@maple/web-data/collections/alerts" +import { alertsSectionRoute } from "../../../route" +import type { AlertsHubSearch } from "../../../route/search" +import type { AlertsTab } from "./links" 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.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index c2c034cdc7..2eba7bba2e 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -9,7 +9,7 @@ import { MAX_ALERT_SILENCE_DURATION_MS } from "@maple/domain/http" import { hasMatchers, MatcherField, matchersFromTyped, TypedMatchers } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" -// "Silence notifications": the dialog the hub, a rule and an incident open, as one Submodel. +// "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 @@ -41,7 +41,7 @@ export const Model = Schema.Struct({ export type Model = typeof Model.Type export const init = (id: string): Model => ({ - dialog: Dialog.init(id, "dialog"), + dialog: Dialog.init(id, "sheet"), scope: { _tag: "Typed", services: "", tags: "", environments: "" }, reason: "", duration: "1h", @@ -210,6 +210,9 @@ export const openFor = ( export const openNew = (model: Model, timeZone: string, nowMs: number): Return => open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone, nowMs) +/** 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 => model.isSaving || !isFormReady(model) ? { model } @@ -237,7 +240,7 @@ export const update = (model: Model, message: Message): Return => model.scope._tag === "Fixed" ? { model } : { model: { ...model, scope: { ...model.scope, [field]: value } } }, - ClickedCancel: () => liftDialog(model, Dialog.close(model.dialog)), + 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..d2758eb2df 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -2,9 +2,10 @@ import { Option, Array as Arr } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { eyebrow } from "@maple/views/ui/eyebrow" -import { formDialog } from "@maple/views/ui/form-dialog" +import { button } from "@maple/views/ui/button" 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 type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" @@ -90,31 +91,43 @@ const durationSection = (h: H, model: Model): Html => : []), ]) -/** 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, +/** 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, 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 }), - }), + body: [ + vstack(h, { gap: "5" }, [ + 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), + footer: (close) => [ + button( + h, + { variant: "outline", isDisabled: model.isSaving, attributes: model.isSaving ? [] : close }, + ["Cancel"], + ), + button( + h, + { + type: "submit", + isLoading: model.isSaving, + isDisabled: model.isSaving || !isFormReady(model), + }, + [submitLabel(model)], + ), + ], }), ) 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 506af359a4..8e1120611f 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -39,8 +39,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": @@ -99,11 +99,7 @@ const identifier = (h: H, model: Model, value: string, label: string): Html => { const changesTable = (h: H, changes: V2AuditChanges): Html => table(h, { size: "sm", className: "font-mono" }, [ tableHeader(h, [ - tableRow(h, [ - tableHead(h, ["Field"], "w-40"), - tableHead(h, ["Before"]), - tableHead(h, ["After"]), - ]), + tableRow(h, [tableHead(h, ["Field"], "w-40"), tableHead(h, ["Before"]), tableHead(h, ["After"])]), ]), tableBody( h, diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 6cc1190873..69069410ea 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -78,6 +78,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..a897eff99f 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -3,7 +3,13 @@ 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 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 +66,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): AlertsSection | "inbox" => + 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: AlertsSection | "inbox", + 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) => @@ -100,9 +123,13 @@ export const routeRedirect = (route: AppRoute): Option.Option => /** 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 e9d860e27a..91812635e3 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -69,11 +69,47 @@ 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/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") + 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 }), + ) + 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..e1710c59c2 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -284,6 +284,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 +527,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 84b89168c0..c0999c525f 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -514,8 +514,19 @@ export const AgentSessionsSearch = Schema.Struct({ sortDir: Schema.optional(AiSessionSortDir), }) -export const AlertsSearch = Schema.Struct({ - tab: Schema.optional(Schema.String), +/** 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 + +const alertsHubSearchFields = { serviceName: Schema.optional(Schema.String), status: Schema.optional(Schema.String), /** The Inbox narrowed to one kind of incident: "anomaly" lists anomalies alone. */ @@ -524,7 +535,15 @@ export const AlertsSearch = Schema.Struct({ anomalyStatus: Schema.optional(Schema.String), /** The Silences tab narrowed to one maintenance window's occurrences (`mw_…`). */ window: Schema.optional(Schema.String), -}) + /** The open side panel (`SHEET_PARAM`): "silence" is the new-silence form. */ + sheet: Schema.optional(Schema.String), +} + +/** `/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: Schema.optional(Schema.String), ...alertsHubSearchFields }) // sessionTimeRangeSearchMiddleware() export const AnalyticsSearch = Schema.Struct({ diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 22ded041ce..71f756559c 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -34,6 +34,8 @@ 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 } export interface NavItem { @@ -129,6 +131,40 @@ 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"], + }, + { + title: "Rules", + href: "/alerts/rules", + icon: "ChartLineIcon", + paletteTitle: "Alert rules", + alsoActive: ["/alerts/create"], + }, + { title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" }, + { title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" }, + { title: "Silences", href: "/alerts/silences", icon: "BellSlashIcon" }, + { + title: "Maintenance", + href: "/alerts/maintenance", + icon: "CalendarIcon", + paletteTitle: "Maintenance windows", + }, + { title: "On-call", href: "/alerts/on-call", icon: "UsersIcon", alsoActive: ["/alerts/schedules"] }, + { 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 @@ -174,7 +210,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 +233,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), diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 5ce01cc3bf..825096ee87 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -90,7 +90,7 @@ describe("on-call pill", () => { expect(footer).toContainText("You're on call"), expect(footer).toContainText("until Mon 16 Nov, 09:00 CET"), expect(footer).toContainText("Checkout primary"), - expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts/on-call"), ) }) @@ -99,7 +99,7 @@ describe("on-call pill", () => { { update, view }, given(withOnCall([shift({ userId: "user_someone" })])), measured, - expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts/on-call"), expect(footer).toContainText("My schedules"), ) }) diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index f395917878..d1f5daa5a0 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -425,6 +425,56 @@ 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"), + ) + }) + + 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/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 966070641b..5c5709fbfc 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. @@ -147,6 +148,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 }> @@ -173,18 +176,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 528a4fd6cd..b632d9b440 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -427,7 +427,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, From 58883959f0d6f11e220a44eb44de2b1cf69e9648 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:03:18 +0100 Subject: [PATCH 04/35] fix(shell): read window and document on subscribe, not on import --- apps/web/src/shell/subscription.ts | 35 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index ec188cab53..210f70f782 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -98,27 +98,30 @@ export const onCallTimer = (atMs: number): Stream.Stream => }), ) -const wakeTargets: Dom.TypedEventTarget<{ focus: FocusEvent; visibilitychange: Event }> = window - /** The tab came back into view or focus (a handoff may have passed while it slept). */ -export const onCallWakes: Stream.Stream = Stream.merge( - Dom.streamFromEvent({ target: wakeTargets, type: "focus", mapEvent: () => true }), - Dom.streamFromEvent({ - target: document, - type: "visibilitychange", - mapEvent: () => document.visibilityState === "visible", - }), -).pipe( +// Suspended: `window` and `document` are read on subscribe, so importing the shell needs no DOM. +export const onCallWakes: Stream.Stream = Stream.suspend(() => { + const wakeTarget: Dom.TypedEventTarget<{ focus: FocusEvent }> = window + return Stream.merge( + Dom.streamFromEvent({ target: wakeTarget, type: "focus", mapEvent: () => true }), + Dom.streamFromEvent({ + target: document, + type: "visibilitychange", + mapEvent: () => document.visibilityState === "visible", + }), + ) +}).pipe( Stream.filter((isAwake) => isAwake), Stream.mapEffect(() => Clock.currentTimeMillis), Stream.map((nowMs) => Message.WokeOnCall({ nowMs })), ) -const onCallChangeTarget: Dom.TypedEventTarget<{ [ONCALL_CHANGED_EVENT]: Event }> = window - /** A page wrote a schedule or an override. */ -export const onCallChanges: Stream.Stream = Dom.streamFromEvent({ - target: onCallChangeTarget, - type: ONCALL_CHANGED_EVENT, - mapEvent: () => Message.ChangedOnCallSchedules(), +export const onCallChanges: Stream.Stream = Stream.suspend(() => { + const target: Dom.TypedEventTarget<{ [ONCALL_CHANGED_EVENT]: Event }> = window + return Dom.streamFromEvent({ + target, + type: ONCALL_CHANGED_EVENT, + mapEvent: () => Message.ChangedOnCallSchedules(), + }) }) From 280ea477269f044edefd27267ef094b0f2ab7938 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:31:47 +0100 Subject: [PATCH 05/35] fix(alerts): review follow-ups for section routes and the silence panel - The silence panel clears ?sheet= in the model as soon as it closes itself, so a clock tick before the URL change can't reopen it. It opens only on the Silences section, and a viewer who can't manage loses a linked ?sheet= once the grants load (replaced away). - A rule lives at /alerts/rules/; /alerts/ (notifications, the apps) replaces itself with it, keeping its tab and window. The rule page and editor go back to Rules and their crumbs go through it. - Settings automation and setup health link to the new section paths. - The sidebar reveals the open section, every sub-item past the edge fades, on each route change; sub-rows are tighter, thin rules split the former groups, and the Inbox shows its unacknowledged count from the alert_incidents collection, read only while an alerts page is open. - One AlertsTab schema next to AlertsSection; the sheet commands answer with a clock read instead of a no-op message. --- apps/web/src/lab/entries/alerts-hub.ts | 4 +- apps/web/src/lab/shell-lab.ts | 4 +- apps/web/src/page/alerts/editor/actions.ts | 2 +- .../page/alerts/editor/editor.scene.test.ts | 11 +- .../src/page/alerts/editor/view-action-bar.ts | 14 ++- apps/web/src/page/alerts/editor/view.ts | 1 + apps/web/src/page/alerts/hub/command.ts | 15 ++- .../alerts/hub/hub-maintenance.scene.test.ts | 2 +- .../page/alerts/hub/hub-oncall.scene.test.ts | 2 +- .../alerts/hub/hub-silences.scene.test.ts | 37 +++++++ apps/web/src/page/alerts/hub/message.ts | 2 - apps/web/src/page/alerts/hub/model.ts | 5 +- apps/web/src/page/alerts/hub/update.ts | 35 ++++-- apps/web/src/page/alerts/hub/view.ts | 3 +- apps/web/src/page/alerts/incident/view.ts | 4 +- apps/web/src/page/alerts/rule/index.ts | 2 +- apps/web/src/page/alerts/rule/model.ts | 2 +- .../src/page/alerts/rule/rule.scene.test.ts | 13 ++- apps/web/src/page/alerts/rule/view.ts | 3 +- apps/web/src/page/alerts/shared/links.ts | 10 +- apps/web/src/page/alerts/shared/test-rows.ts | 3 +- .../page/settings/audit-log/view-detail.ts | 2 +- apps/web/src/page/settings/automation/view.ts | 3 +- .../src/page/settings/setup-health/derive.ts | 2 +- apps/web/src/route/app-route.ts | 1 + apps/web/src/route/redirect.ts | 7 +- apps/web/src/route/route.test.ts | 10 +- apps/web/src/route/routers.ts | 9 +- apps/web/src/route/search.ts | 4 + apps/web/src/shell/alerts-inbox.ts | 23 ++++ apps/web/src/shell/index.ts | 1 + apps/web/src/shell/message.ts | 2 + apps/web/src/shell/model.ts | 3 + apps/web/src/shell/mounts.ts | 47 ++++++-- apps/web/src/shell/nav.ts | 19 +++- apps/web/src/shell/shell.scene.test.ts | 28 +++++ apps/web/src/shell/update.ts | 5 + apps/web/src/shell/view-nav.ts | 100 ++++++++++++------ apps/web/src/subscription.ts | 37 ++++++- apps/web/src/view.lazy.test.ts | 2 +- packages/views/src/ui/sidebar.ts | 9 +- 41 files changed, 385 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/shell/alerts-inbox.ts diff --git a/apps/web/src/lab/entries/alerts-hub.ts b/apps/web/src/lab/entries/alerts-hub.ts index 9e69ef8a00..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, 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, @@ -13,4 +14,5 @@ export const lab = defineShellLab("Alerts hub", { view, currentPath, breadcrumbs, + seedShell: (shell) => ({ ...shell, alertsInboxCount: 2 }), }) diff --git a/apps/web/src/lab/shell-lab.ts b/apps/web/src/lab/shell-lab.ts index 2003012daa..921480308d 100644 --- a/apps/web/src/lab/shell-lab.ts +++ b/apps/web/src/lab/shell-lab.ts @@ -39,6 +39,8 @@ export type ShellLabPage 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 = ( @@ -107,7 +109,7 @@ export const defineShellLab = { 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 abc46e5491..69b04f3dbc 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -44,18 +44,25 @@ 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.CompletedSheetUrl], - execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), + 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.CompletedSheetUrl], - execute: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), + messages: [Message.ReadClock], + execute: ({ href, id }) => thenReadClock(dismissSheetUrl(href, id)), }) export const ReadHubClock = Command.define("ReadAlertsHubClock", { 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 570e9b8986..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,5 +1,5 @@ import * as Policy from "@maple/domain/policy" -import type { AlertsTab } from "../shared/links" +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" 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 2102c64f20..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,5 +1,5 @@ import * as Policy from "@maple/domain/policy" -import type { AlertsTab } from "../shared/links" +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" 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 2d7d322363..395a2fcd02 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 @@ -236,6 +236,39 @@ describe("alerts hub, silence side panel", () => { 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 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" }], @@ -297,5 +330,9 @@ describe("alerts hub, silence side panel", () => { 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/message.ts b/apps/web/src/page/alerts/hub/message.ts index 5f0c12fffb..75a499ae12 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -52,8 +52,6 @@ const rows = (row: S) => ({ orgId: Schema.String, rows: Sc const outcome = (success: S) => Schema.Result(success, LoadFailure) export const Message = defineMessageUnion({ - /** A `?sheet=` push or dismissal ran; the route change that follows does the work. */ - CompletedSheetUrl: {}, ReadClock: { nowMs: Schema.Number }, UpdatedRules: rows(AlertRuleRowSchema), UpdatedRuleStates: rows(AlertRuleStateRowSchema), diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index c23d373893..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 { AlertsHubSearch, AlertsSection } 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" @@ -53,9 +53,6 @@ export type SloListRow = typeof SloListRow.Type export const TestOutcome = Schema.Struct({ message: Schema.String, success: Schema.Boolean }) -/** The section the route names: the Inbox at `/alerts`, the rest at `/alerts/
`. */ -export const AlertsTab = Schema.Union([Schema.Literal("inbox"), AlertsSection]) - export const Model = Schema.Struct({ tab: AlertsTab, search: AlertsHubSearch, diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index f47c40d86d..89fb77bfeb 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -754,11 +754,12 @@ const silenceCommands = (step: ReturnType): Command 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 - * once the clock is read, as the form checks its end against it, and only for a viewer who may save. + * 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 isInUrl = model.search.sheet === SILENCE_SHEET + const isInUrl = model.search.sheet === SILENCE_SHEET && model.tab === "silences" const isOpen = model.silenceDialog.dialog.isOpen if (isInUrl && !isOpen && model.nowMs !== null && canManage(model)) { const step = SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs) @@ -771,19 +772,32 @@ const followSilenceSheet = (model: Model, context: PageContext): Requested => { 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 } - // The panel closed itself (Cancel, Escape, a save): its `?sheet=` goes too. - const hasClosed = model.silenceDialog.dialog.isOpen && !step.model.dialog.isOpen + // 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 && model.search.sheet === SILENCE_SHEET - ? [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] - : []), + ...(hasClosed ? [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] : []), ] if (step.outMessage === undefined) return { model: next, commands } return SilenceDialog.OutMessage.match(step.outMessage, { @@ -1119,7 +1133,7 @@ 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 ? followSilenceSheet({ ...model, grants }, context) : { model }, + orgId === context.orgId ? grantsLoaded({ ...model, grants }, context) : { model }, ChangedRulesQuery: ({ value }) => ({ model: { ...model, rulesQuery: value } }), ClickedStatusFilter: ({ bucket }) => statusToggled(model, bucket), ClickedClearRuleFilters: () => ({ @@ -1339,7 +1353,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re model.search.sheet === SILENCE_SHEET ? { model } : { model, commands: [PushHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] }, - CompletedSheetUrl: () => ({ model }), GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 94d722f8fa..e9e8dd6843 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -16,7 +16,8 @@ import { skeleton, skeletonList } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import type { BreadcrumbEntry } from "../../../shell" -import { type AlertsTab, alertsHref, createHref } from "../shared/links" +import type { AlertsTab } from "../../../route/search" +import { alertsHref, createHref } from "../shared/links" import { newSloHref } from "../shared/slo-commands" import { newMonitorHref } from "../shared/uptime-commands" import { 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 f9f7c1bc38..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, 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/links.ts b/apps/web/src/page/alerts/shared/links.ts index 4d86fc8328..9316199646 100644 --- a/apps/web/src/page/alerts/shared/links.ts +++ b/apps/web/src/page/alerts/shared/links.ts @@ -1,12 +1,7 @@ import { AlertRuleId } from "@maple/domain/http" import { Option, Schema } from "effect" import { alertsSectionRoute, AppRoute, hrefOf } from "../../../route" -import type { - AlertRuleSearch, - AlertsCreateSearch, - AlertsHubSearch, - AlertsSection, -} from "../../../route/search" +import type { AlertRuleSearch, AlertsCreateSearch, AlertsHubSearch, AlertsTab } from "../../../route/search" const decodeRuleId = Schema.decodeUnknownOption(AlertRuleId) @@ -20,9 +15,6 @@ export const ruleHref = (rawRuleId: string, search: typeof AlertRuleSearch.Type export const incidentHref = (incidentId: string): string => hrefOf(AppRoute.AlertIncident({ incidentId, search: {} })) -/** The Alerts hub's sections: the Inbox at `/alerts`, the rest at `/alerts/
`. */ -export type AlertsTab = "inbox" | AlertsSection - /** 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)) diff --git a/apps/web/src/page/alerts/shared/test-rows.ts b/apps/web/src/page/alerts/shared/test-rows.ts index 2259f78b67..74df831c09 100644 --- a/apps/web/src/page/alerts/shared/test-rows.ts +++ b/apps/web/src/page/alerts/shared/test-rows.ts @@ -5,8 +5,7 @@ import type { AlertRuleStateRow, } from "@maple/web-data/collections/alerts" import { alertsSectionRoute } from "../../../route" -import type { AlertsHubSearch } from "../../../route/search" -import type { AlertsTab } from "./links" +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. */ 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 8e1120611f..8a070ddf1c 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -30,7 +30,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": 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 c9e66ab507..01f5b37f9d 100644 --- a/apps/web/src/page/settings/setup-health/derive.ts +++ b/apps/web/src/page/settings/setup-health/derive.ts @@ -50,7 +50,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 69069410ea..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 }, diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index a897eff99f..db4ae480eb 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -7,6 +7,7 @@ import { type AlertsHubSearch, type AlertsSearch, AlertsSection, + type AlertsTab, type AnomaliesSearch, ErrorsSearch, } from "./search" @@ -69,7 +70,7 @@ 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): AlertsSection | "inbox" => +const legacyAlertsSection = (tab: string): AlertsTab => tab === "oncall" ? "on-call" : tab === "settings" @@ -78,7 +79,7 @@ const legacyAlertsSection = (tab: string): AlertsSection | "inbox" => /** `/alerts` for the Inbox, else `/alerts/
`, with the hub's filters. */ export const alertsSectionRoute = ( - section: AlertsSection | "inbox", + section: AlertsTab, search: typeof AlertsHubSearch.Type, ): Extract => section === "inbox" ? AppRoute.Alerts({ search }) : AppRoute.AlertsSection({ section, search }) @@ -120,6 +121,8 @@ 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), diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 91812635e3..e1e46c1249 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -83,7 +83,8 @@ describe("routes", () => { 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/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") + 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") }) @@ -102,6 +103,13 @@ describe("routes", () => { 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()) }) diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index e1710c59c2..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), diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index c0999c525f..80ab3c0dd5 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -526,6 +526,10 @@ export const AlertsSection = Schema.Literals([ ]) 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: Schema.optional(Schema.String), status: Schema.optional(Schema.String), 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 6fa454d18f..aa21e01c2a 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -18,6 +18,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 e36edc6948..61bb54465e 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. */ @@ -173,6 +175,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 71f756559c..d41ccc49d9 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -36,6 +36,10 @@ export interface NavSubItem { 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 thin rule above it, no heading. */ + readonly startsGroup?: boolean } export interface NavItem { @@ -143,6 +147,7 @@ const alertsItem: NavItem = { icon: "BellIcon", paletteTitle: "Alerts", alsoActive: ["/anomalies"], + count: "alertsInbox", }, { title: "Rules", @@ -150,17 +155,24 @@ const alertsItem: NavItem = { 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" }, + { 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"] }, + { + title: "On-call", + href: "/alerts/on-call", + icon: "UsersIcon", + alsoActive: ["/alerts/schedules"], + startsGroup: true, + }, { title: "Routing", href: "/alerts/routing", icon: "BranchForkIcon", paletteTitle: "Alert routing" }, ], } @@ -246,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/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index d1f5daa5a0..fd4ae2e8c8 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -462,6 +462,34 @@ describe("alerts section", () => { 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, and thin rules split the groups", () => { + 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(), + expect(role("separator")).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", () => { diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 0bdca57292..96d0d55382 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -65,6 +65,7 @@ export const enter = infraPresence: () => InfraPresenceData.Idle(), integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), + alertsInboxCount: () => null, onCall: () => OnCallData.Idle(), onCallNextReadAtMs: () => null, onCallBackoffMs: () => 0, @@ -550,6 +551,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 749a57a46f..4206e21fe9 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -10,11 +10,13 @@ import { sidebarGroupLabel, sidebarGroupToggle, sidebarMenu, + sidebarMenuBadge, sidebarMenuButton, sidebarMenuItem, sidebarMenuSub, sidebarMenuSubButton, sidebarMenuSubItem, + sidebarSeparator, } from "@maple/views/ui/sidebar" import type { PinnedDashboardRow } from "./dashboards-shape" import { Menu, Message } from "./message" @@ -96,41 +98,75 @@ 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 + +// A rule between groups keeps the sub-list's lane line unbroken. +const groupRule = (h: HtmlBuilder): Html => + sidebarMenuSubItem(h, "border-sidebar-border border-l-2 py-1 ps-2.5", [sidebarSeparator(h)]) + +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( - h, - sub.href === activeSubHref - ? "border-sidebar-primary border-l-2 ps-2.5" - : "border-sidebar-border border-l-2 ps-2.5", - [ - sidebarMenuSubButton( - h, - { - className: - "translate-x-0 data-[active=true]:text-sidebar-primary [&>svg]:text-current", - isActive: sub.href === activeSubHref, - link: { href: sub.href, currentPath }, - }, - [ - ...(sub.icon === undefined - ? [] - : [ - icon(h, sub.icon, { - className: "size-3.5", - ...iconColor(sub.iconColor), - }), - ]), - h.span([], [sub.title]), - ], - ), - ], - ), - ), + Arr.flatMap(subItems, (sub, index) => { + const count = subCountOf(model, sub) + return [ + ...(sub.startsGroup === true && index > 0 ? [groupRule(h)] : []), + sidebarMenuSubItem( + h, + sub.href === activeSubHref + ? "border-sidebar-primary border-l-2 ps-2.5" + : "border-sidebar-border border-l-2 ps-2.5", + [ + sidebarMenuSubButton( + h, + { + className: + "h-7 translate-x-0 data-[active=true]:text-sidebar-primary sm:h-6 [&>svg]:text-current", + isActive: sub.href === activeSubHref, + link: { href: sub.href, currentPath }, + }, + [ + ...(sub.icon === undefined + ? [] + : [ + icon(h, sub.icon, { + className: "size-3.5", + ...iconColor(sub.iconColor), + }), + ]), + h.span([], [sub.title]), + ], + ), + ...(count === undefined + ? [] + : [ + sidebarMenuBadge( + h, + [ + typography( + h, + { size: "xs", mono: true, numeric: true, tone: "error" }, + [String(count)], + ), + ], + "top-1 sm:top-0.5", + ), + ]), + ], + ), + ] + }), ) } @@ -199,7 +235,7 @@ const navRow = ( ], ), ...(item.badge === undefined ? [] : [navBadge(h, item.badge)]), - ...(subItems !== undefined && isActive ? [subList(h, subItems, currentPath)] : []), + ...(subItems !== undefined && isActive ? [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/sidebar.ts b/packages/views/src/ui/sidebar.ts index b632d9b440..fae9c9bff9 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -313,10 +313,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"), ], From 1cb4cef916ba54c045b017d473d092f1cdcef5f8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:48:02 +0100 Subject: [PATCH 06/35] feat(alerts): full-page escalation chain editor Escalation chains move out of the Routing dialog into /alerts/escalation-chains/new and /alerts/escalation-chains/:chainId. The chain reads as a column of sentence cards (start, steps, repeat) with a + between cards to insert a step and a per-step menu to move, duplicate or remove it. The aside holds the name, notes, a one-line preview, the rules that use the chain, and delete, whose confirm lists those rules. Validation moved from the dialog into a shared form module; the data model and API are unchanged. Routing and the rule editor link to the new pages and the dialog is deleted. --- apps/web/src/lab/entries/escalation.ts | 2 +- apps/web/src/lab/escalation-lab.ts | 64 +- .../alerts/editor/editor-chains.scene.test.ts | 5 +- .../page/alerts/editor/view-notifications.ts | 4 +- .../page/alerts/escalation-chain/command.ts | 66 ++ .../escalation-chain.scene.test.ts | 231 +++++++ .../page/alerts/escalation-chain/form.test.ts | 133 ++++ .../src/page/alerts/escalation-chain/form.ts | 289 ++++++++ .../src/page/alerts/escalation-chain/index.ts | 26 + .../page/alerts/escalation-chain/message.ts | 45 ++ .../src/page/alerts/escalation-chain/model.ts | 44 ++ .../alerts/escalation-chain/subscription.ts | 25 + .../page/alerts/escalation-chain/update.ts | 274 ++++++++ .../src/page/alerts/escalation-chain/view.ts | 636 ++++++++++++++++++ .../escalation-dialog/dialog.scene.test.ts | 76 --- .../alerts/escalation-dialog/dialog.test.ts | 176 ----- .../page/alerts/escalation-dialog/dialog.ts | 404 ----------- .../src/page/alerts/escalation-dialog/view.ts | 454 ------------- .../page/alerts/hub/hub-chains.scene.test.ts | 59 +- apps/web/src/page/alerts/hub/message.ts | 3 - apps/web/src/page/alerts/hub/model.ts | 2 - apps/web/src/page/alerts/hub/update.ts | 42 +- apps/web/src/page/alerts/hub/view-chains.ts | 24 +- apps/web/src/page/alerts/hub/view.ts | 18 +- .../page/alerts/shared/escalation-chains.ts | 51 +- apps/web/src/page/registry.ts | 2 + apps/web/src/route/app-route.ts | 2 + apps/web/src/route/route.test.ts | 5 + apps/web/src/route/routers.ts | 13 + apps/web/src/route/search.ts | 5 + apps/web/src/shell/nav.ts | 8 +- apps/web/src/view.lazy.test.ts | 2 + 32 files changed, 1906 insertions(+), 1284 deletions(-) create mode 100644 apps/web/src/page/alerts/escalation-chain/command.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/form.test.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/form.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/index.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/message.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/model.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/subscription.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/update.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/view.ts delete mode 100644 apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts delete mode 100644 apps/web/src/page/alerts/escalation-dialog/dialog.test.ts delete mode 100644 apps/web/src/page/alerts/escalation-dialog/dialog.ts delete mode 100644 apps/web/src/page/alerts/escalation-dialog/view.ts diff --git a/apps/web/src/lab/entries/escalation.ts b/apps/web/src/lab/entries/escalation.ts index 462078241e..0c1e5e2524 100644 --- a/apps/web/src/lab/entries/escalation.ts +++ b/apps/web/src/lab/entries/escalation.ts @@ -1,7 +1,7 @@ import { defineShellLab } from "../shell-lab" import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../escalation-lab" -// `/lab/escalation`: escalation chains, their dialog, the rule tier picker and the incident rail (`?state=`). +// `/lab/escalation`: escalation chains, the rule tier picker and the incident rail (`?state=`). export const lab = defineShellLab("Escalation chains", { Model, diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts index f710bea7d1..35a01cfe6b 100644 --- a/apps/web/src/lab/escalation-lab.ts +++ b/apps/web/src/lab/escalation-lab.ts @@ -1,13 +1,10 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" -import type { LoadFailure } from "@maple/views/data/load-failure" 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 * as Menu from "@maple/views/ui/menu" import * as Select from "@maple/views/ui/select" -import * as ChainDialog from "../page/alerts/escalation-dialog/dialog" import * as EditorMessage from "../page/alerts/editor/message" import * as EditorModel from "../page/alerts/editor/model" import { init as editorInit } from "../page/alerts/editor/init" @@ -33,16 +30,12 @@ import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { keepLocal } from "./local-commands" -// `/lab/escalation`: escalation chains in the Routing tab, their dialog, a rule tier's chain picker +// `/lab/escalation`: escalation chains in the Routing tab, a rule tier's chain picker // and the incident rail, over fixtures. `?state=` picks one. export const LabState = Schema.Literals([ "chains-empty", "chains", - "dialog-new", - "dialog-edit", - "dialog-invalid", - "dialog-error", "rule-picker", "incident-escalating", "incident-paused", @@ -99,18 +92,6 @@ const fold = ( const hubStep = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT) -const dialog = (message: ChainDialog.Message) => HubMessage.Message.GotChainDialogMessage({ message }) - -const SAVE_FAILURE: LoadFailure = { - _tag: "@maple/http/errors/AlertValidationError", - type: "invalid_request_error", - code: "invalid_request", - title: "Invalid request", - message: "Step 2 names a destination that no longer exists.", - retryable: false, - recovery: "fix_request", -} - const hubFor = (state: LabState): Step => { const entered = HubUpdate.init(hubAt({ tab: "routing" }), CONTEXT).model const chains = state === "chains-empty" ? [] : populatedChains([ruleId(1), ruleId(2), ruleId(3)]) @@ -128,45 +109,7 @@ const hubFor = (state: LabState): Step => { result: Result.succeed(chains), }), ] - const firstStepKey = "s0" - const extra: ReadonlyArray = - state === "dialog-new" - ? [HubMessage.Message.ClickedNewChain()] - : state === "dialog-invalid" - ? [ - HubMessage.Message.ClickedNewChain(), - dialog(ChainDialog.Message.ChangedWait({ key: firstStepKey, value: "2000" })), - dialog(ChainDialog.Message.ClickedSave()), - ] - : state === "dialog-error" - ? [ - HubMessage.Message.GotRowMenuMessage({ - menuId: `chain:${chainUuid(1)}`, - message: Menu.Message.ClickedItem({ - index: 0, - item: { kind: "Item", value: "edit" }, - }), - }), - dialog(ChainDialog.Message.ClickedSave()), - dialog( - ChainDialog.Message.CompletedSave({ - openCount: 1, - result: Result.fail(SAVE_FAILURE), - }), - ), - ] - : state === "dialog-edit" - ? [ - HubMessage.Message.GotRowMenuMessage({ - menuId: `chain:${chainUuid(3)}`, - message: Menu.Message.ClickedItem({ - index: 0, - item: { kind: "Item", value: "edit" }, - }), - }), - ] - : [] - return fold({ model: entered }, [...base, ...extra], hubStep) + return fold({ model: entered }, base, hubStep) } const editorStep = (model: EditorModel.Model, message: EditorMessage.Message) => @@ -250,8 +193,7 @@ const incidentFor = (state: LabState): Step - state === "chains" || state === "chains-empty" || Str.startsWith("dialog-")(state) +const isHub = (state: LabState) => state === "chains" || state === "chains-empty" const isIncident = (state: LabState) => Str.startsWith("incident-")(state) export const init = (flags: Flags): Return => { diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index 496064507e..829ebdadb1 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -62,7 +62,10 @@ describe("alert rule editor, escalation chains", () => { given(ready()), themeResolved(), expectView(role("combobox", { name: "Escalation chain" })).toExist(), - expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts/routing"), + expectView(role("link", { name: "Create a chain" })).toHaveAttr( + "href", + "/alerts/escalation-chains/new", + ), ) }) diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index e46c68bfce..f03dd78d10 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -21,7 +21,7 @@ import { destinationList, type NotifyView } from "./view-notify" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" import { textLink } from "@maple/views/ui/text-link" -import { chainSummary, routingTabHref } from "../shared/escalation-chains" +import { chainSummary, newChainHref } from "../shared/escalation-chains" import { chainSelectIdOf, NO_CHAIN } from "./form-steps" import type { Tier } from "./message" import { formSelect } from "./view-select" @@ -157,7 +157,7 @@ const chainPicker = ( textLink( h, { - href: routingTabHref(), + href: newChainHref(), tone: "muted", className: "text-xs", attributes: [h.Target("_blank"), h.Rel("noopener")], diff --git a/apps/web/src/page/alerts/escalation-chain/command.ts b/apps/web/src/page/alerts/escalation-chain/command.ts new file mode 100644 index 0000000000..4eb548a801 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/command.ts @@ -0,0 +1,66 @@ +import { EscalationChainId } from "@maple/domain/http" +import { V2EscalationChainCreateParams } from "@maple/domain/http/v2" +import { Effect, Schema } from "effect" +import { Command } from "foldkit" +import { readGrants } from "../../../data/session-tenant" +import { + deleteEscalationChain, + retrieveEscalationChain, + saveEscalationChain, +} from "../shared/escalation-chains" +import { listOnCallSchedules } from "../shared/oncall-schedules" +import { listOrgMembers } from "../shared/org-members" +import { Message } from "./message" + +export const FetchExistingChain = Command.define("FetchEscalationChainEditorChain", { + args: { key: Schema.String, chainId: EscalationChainId }, + messages: [Message.CompletedFetchExisting], + execute: ({ key, chainId }) => + retrieveEscalationChain(chainId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchExisting({ key, result })), + ), +}) + +export const FetchChainEditorMembers = Command.define("FetchEscalationChainEditorMembers", { + messages: [Message.CompletedFetchMembers], + execute: listOrgMembers.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMembers({ result })), + ), +}) + +export const FetchChainEditorSchedules = Command.define("FetchEscalationChainEditorSchedules", { + messages: [Message.CompletedFetchSchedules], + execute: listOnCallSchedules.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSchedules({ result })), + ), +}) + +export const FetchChainEditorSession = Command.define("FetchEscalationChainEditorSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), +}) + +export const SaveChain = Command.define("SaveEscalationChainFromEditor", { + args: { editingId: Schema.NullOr(EscalationChainId), payload: V2EscalationChainCreateParams }, + messages: [Message.CompletedSave], + execute: ({ editingId, payload }) => + saveEscalationChain(editingId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSave({ result })), + ), +}) + +export const DeleteChain = Command.define("DeleteEscalationChainFromEditor", { + args: { chainId: EscalationChainId }, + messages: [Message.CompletedDelete], + execute: ({ chainId }) => + deleteEscalationChain(chainId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDelete({ result })), + ), +}) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts new file mode 100644 index 0000000000..755b6f57e8 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -0,0 +1,231 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { EscalationChainId } from "@maple/domain/http" +import { EscalationChainPublicId } from "@maple/domain/http/v2" +import { Option, Result, Schema, Array as Arr } from "effect" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { click, expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { CHAIN_IN_USE_CODE } from "../shared/escalation-chains" +import { + chainDestinations, + chainDestinationUuid, + chainUuid, + populatedChains, +} from "../shared/escalation-test-fixtures" +import { ORG, ruleId, ruleRow } from "../shared/test-rows" +import { payloadOf } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const RULES = [ruleRow(1, { name: "Checkout errors" }), ruleRow(2, { name: "Checkout p95" }), ruleRow(3)] +const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) +const chainPublicId = (n: number) => + Schema.encodeSync(EscalationChainPublicId)(Schema.decodeSync(EscalationChainId)(chainUuid(n))) + +const pick = (key: string, value: string) => + Message.GotMenuMessage({ + kind: "targets", + key, + message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value } }, + }) +const act = (key: string, value: string) => + Message.GotMenuMessage({ + kind: "actions", + key, + message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value } }, + }) + +const ready = (route: Parameters[0]) => { + const entered = init(route, context) + return run(entered.model, [ + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.CompletedFetchMembers({ result: Result.succeed([]) }), + Message.CompletedFetchSchedules({ result: Result.succeed([]) }), + Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + Message.UpdatedRules({ orgId: ORG, rows: RULES }), + ]) +} +const newChain = (returnTo?: string) => + ready({ _tag: "AlertsEscalationChainNew", search: returnTo === undefined ? {} : { returnTo } }) + +const editing = (n: number) => { + const base = ready({ _tag: "AlertsEscalationChain", chainId: chainPublicId(n), search: {} }) + const chain = CHAINS[n - 1] + return chain === undefined + ? base + : run(base, [ + Message.CompletedFetchExisting({ + key: base.existing.key ?? "", + result: Result.succeed(chain), + }), + ]) +} + +describe("escalation chain editor", () => { + test("a new chain reads as a sentence from the start card to the end card", () => { + scene( + { update: pageUpdate, view }, + given(newChain()), + expectView(text("New escalation chain")).toExist(), + expectView(text("When an alert escalates to this chain")).toExist(), + expectView(text("Notify")).toExist(), + expectView(text("minutes for an acknowledgement")).toExist(), + expectView(text("If nobody acknowledges, repeat the chain")).toExist(), + expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/routing"), + expectView(role("button", { name: "Create chain" })).toExist(), + ) + }) + + test("builds a chain: add steps, insert between, reorder, remove, then save", () => { + const first = run(newChain(), [ + Message.ChangedName({ value: "Checkout on-call" }), + pick("s0", `destination:${chainDestinationUuid(1)}`), + Message.ClickedInsertStep({ index: 1 }), + pick("s1", `destination:${chainDestinationUuid(2)}`), + Message.ChangedWait({ key: "s0", value: "5" }), + ]) + expect(Arr.map(first.form.steps, (step) => step.key)).toEqual(["s0", "s1"]) + // The + between the two cards puts a new step in the middle. + scene( + { update: pageUpdate, view }, + given(first), + click(role("button", { name: "Add a step after step 1" })), + expectView(role("button", { name: "Actions for step 3" })).toExist(), + ) + const middle = pageUpdate(first, Message.ClickedInsertStep({ index: 1 })).model + expect(Arr.map(middle.form.steps, (step) => step.key)).toEqual(["s0", "s2", "s1"]) + const reordered = run(middle, [act("s1", "up"), act("s1", "up"), act("s2", "remove")]) + expect(Arr.map(reordered.form.steps, (step) => step.key)).toEqual(["s1", "s0"]) + const saved = pageUpdate(reordered, Message.ClickedSave()) + expect(names(saved.commands)).toEqual(["SaveEscalationChainFromEditor"]) + expect( + Option.map(payloadOf(reordered.form), (payload) => Arr.map(payload.steps, (s) => s.wait_minutes)), + ).toEqual(Option.some([10, 5])) + const chain = CHAINS[0] + if (chain === undefined) throw new Error("fixture chain missing") + const done = pageUpdate(saved.model, Message.CompletedSave({ result: Result.succeed(chain) })) + expect(done.outMessage).toMatchObject({ + _tag: "RequestedNavigation", + url: `/alerts/escalation-chains/${chainPublicId(1)}`, + toast: { title: "Escalation chain created" }, + }) + }) + + test("saving with missing fields shows each problem and sends nothing", () => { + scene( + { update: pageUpdate, view }, + given(newChain()), + click(role("button", { name: "Create chain" })), + expectView(text("Name the chain")).toExist(), + expectView(text("Pick at least one destination, person or schedule")).toExist(), + ) + expect(names(pageUpdate(newChain(), Message.ClickedSave()).commands)).toEqual([]) + }) + + test("an existing chain fills the form, previews itself and lists the rules that use it", () => { + const model = editing(1) + expect(model.form.name).toBe("Checkout on-call") + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Used by 2 rules")).toExist(), + expectView(role("link", { name: "Checkout errors" })).toExist(), + expectView( + text( + "Slack #alerts-api, then PagerDuty Checkout after 10m, then Email on-call after 15m; repeats twice", + ), + ).toExist(), + expectView(role("button", { name: "Save chain" })).toExist(), + ) + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual([ + "SaveEscalationChainFromEditor", + ]) + }) + + test("delete lists the rules that would stop escalating and holds the delete while they use it", () => { + const asked = pageUpdate(editing(1), Message.ClickedDelete()).model + expect(asked.deleteDialog.isOpen).toBe(true) + scene( + { update: pageUpdate, view }, + given(asked), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/These rules would stop escalating/u)).toExist(), + expectView(text("Checkout p95")).toExist(), + ) + // A stale page still gets the API's refusal as a message in the dialog. + const refused = pageUpdate( + { ...asked, isDeleting: true }, + Message.CompletedDelete({ + result: Result.fail({ + _tag: "@maple/http/errors/EscalationChainInUseError", + type: "conflict_error", + code: CHAIN_IN_USE_CODE, + title: "Escalation chain is in use", + message: "The escalation chain is used by one or more alert rules.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + expect(refused.model.deleteError).toMatch(/Remove it from the rule's severity tiers first/u) + }) + + test("an unused chain deletes and goes back to Routing", () => { + const unused = editing(2) + const confirmed = pageUpdate( + pageUpdate(unused, Message.ClickedDelete()).model, + Message.ClickedConfirmDelete(), + ) + expect(names(confirmed.commands)).toEqual(["DeleteEscalationChainFromEditor"]) + const done = pageUpdate(confirmed.model, Message.CompletedDelete({ result: Result.void })) + expect(done.outMessage).toMatchObject({ _tag: "RequestedNavigation", url: "/alerts/routing" }) + }) + + test("Cancel and Save go back to an in-app returnTo, never another origin", () => { + scene( + { update: pageUpdate, view }, + given(newChain("/alerts/create")), + expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/create"), + ) + scene( + { update: pageUpdate, view }, + given(newChain("https://evil.example")), + expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/routing"), + ) + const filled = run(newChain("/alerts/create"), [ + Message.ChangedName({ value: "On-call" }), + pick("s0", `destination:${chainDestinationUuid(1)}`), + ]) + const saving = pageUpdate(filled, Message.ClickedSave()).model + const chain = CHAINS[0] + if (chain === undefined) throw new Error("fixture chain missing") + expect( + pageUpdate(saving, Message.CompletedSave({ result: Result.succeed(chain) })).outMessage, + ).toMatchObject({ + url: "/alerts/create", + }) + }) + + test("a link with a malformed chain id says the chain was not found", () => { + scene( + { update: pageUpdate, view }, + given(ready({ _tag: "AlertsEscalationChain", chainId: "nope", search: {} })), + expectView(text("Escalation chain not found")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/escalation-chain/form.test.ts b/apps/web/src/page/alerts/escalation-chain/form.test.ts new file mode 100644 index 0000000000..45acc7ce4e --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/form.test.ts @@ -0,0 +1,133 @@ +import { Option, Array as Arr, Schema } from "effect" +import { AlertDestinationId, MAX_ESCALATION_STEPS } from "@maple/domain/http" +import { describe, expect, it } from "vitest" +import { chainSummary, safeReturnTo } from "../shared/escalation-chains" +import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" +import { mixedTargetChain } from "../shared/oncall-test-fixtures" +import { + addTarget, + duplicateStep, + emptyForm, + type Form, + formFromChain, + formIssues, + insertStep, + moveStep, + payloadOf, + previewOf, + removeStep, + repeatError, + waitError, +} from "./form" + +const asDestination = Schema.decodeSync(AlertDestinationId) +const destination = (n: number) => ({ + type: "destination" as const, + destination_id: asDestination(chainDestinationUuid(n)), +}) +const keys = (form: Form) => Arr.map(form.steps, (step) => step.key) + +const loaded = (): Form => + formFromChain( + chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + ], + repeat: 1, + }), + ) + +describe("escalation chain form", () => { + it("an edited chain round-trips to the payload it was read from", () => { + expect(payloadOf(loaded())).toEqual( + Option.some({ + name: "Checkout on-call", + description: null, + steps: [ + { targets: [destination(1)], wait_minutes: 10 }, + { targets: [destination(2)], wait_minutes: 15 }, + ], + repeat_count: 1, + }), + ) + }) + + it("keeps a chain's people and schedules through a load", () => { + const chain = mixedTargetChain(chainDestinationUuid(1)) + expect(Option.map(payloadOf(formFromChain(chain)), (payload) => payload.steps)).toEqual( + Option.some(chain.steps), + ) + }) + + it("a new form has no payload until it is named and its step pages someone", () => { + expect(payloadOf(emptyForm)).toEqual(Option.none()) + expect(formIssues(emptyForm)).toEqual([ + "Name the chain", + "Pick at least one destination, person or schedule", + ]) + const ready = addTarget({ ...emptyForm, name: "On-call" }, "s0", destination(1)) + expect(Option.isSome(payloadOf(ready))).toBe(true) + }) + + it("rejects waits and repeats outside the API's limits", () => { + expect(Option.isSome(waitError("0"))).toBe(true) + expect(Option.isSome(waitError("2000"))).toBe(true) + expect(Option.isSome(waitError(""))).toBe(true) + expect(waitError("10")).toEqual(Option.none()) + expect(Option.isSome(repeatError("-1"))).toBe(true) + expect(repeatError("0")).toEqual(Option.none()) + }) + + it("inserts a step where the + was pressed, each with its own key", () => { + const form = loaded() + const between = insertStep(form, 1) + expect(keys(between)).toEqual(["s0", "s2", "s1"]) + expect(keys(insertStep(between, 0))).toEqual(["s3", "s0", "s2", "s1"]) + expect(keys(insertStep(form, 2))).toEqual(["s0", "s1", "s2"]) + }) + + it("stops adding steps at the API's limit", () => { + const full = Arr.reduce(Arr.range(1, MAX_ESCALATION_STEPS + 2), emptyForm, (form) => + insertStep(form, form.steps.length), + ) + expect(full.steps.length).toBe(MAX_ESCALATION_STEPS) + expect(duplicateStep(full, "s0").steps.length).toBe(MAX_ESCALATION_STEPS) + }) + + it("moves, duplicates and removes steps, keeping at least one", () => { + const form = loaded() + expect(keys(moveStep(form, "s1", "up"))).toEqual(["s1", "s0"]) + expect(keys(moveStep(form, "s0", "up"))).toEqual(["s0", "s1"]) + const copied = duplicateStep(form, "s0") + expect(keys(copied)).toEqual(["s0", "s2", "s1"]) + expect(copied.steps[1]?.targets).toEqual(form.steps[0]?.targets) + expect(keys(removeStep(form, "s0"))).toEqual(["s1"]) + expect(keys(removeStep(removeStep(form, "s0"), "s1"))).toEqual(["s1"]) + }) + + it("adds a target to a step once", () => { + const twice = addTarget(addTarget(emptyForm, "s0", destination(1)), "s0", destination(1)) + expect(twice.steps[0]?.targets).toEqual([destination(1)]) + }) + + it("previews the chain as it reads elsewhere, skipping steps that page no one yet", () => { + const names = (id: string) => + id === chainDestinationUuid(1) ? "Slack #alerts-api" : "PagerDuty Checkout" + const form = insertStep({ ...loaded(), repeatCount: "2" }, 1) + expect(chainSummary(previewOf(form), names)).toBe( + "Slack #alerts-api, then PagerDuty Checkout after 10m; repeats twice", + ) + expect(previewOf(emptyForm).steps).toEqual([]) + }) + + it("follows only an in-app returnTo", () => { + expect(safeReturnTo("/alerts/rules/abc")).toEqual(Option.some("/alerts/rules/abc")) + expect(safeReturnTo("https://evil.example")).toEqual(Option.none()) + expect(safeReturnTo("//evil.example")).toEqual(Option.none()) + expect(safeReturnTo("/\\evil.example")).toEqual(Option.none()) + expect(safeReturnTo(undefined)).toEqual(Option.none()) + }) +}) diff --git a/apps/web/src/page/alerts/escalation-chain/form.ts b/apps/web/src/page/alerts/escalation-chain/form.ts new file mode 100644 index 0000000000..61faa3649f --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/form.ts @@ -0,0 +1,289 @@ +import { + AlertDestinationId, + EscalationChainDescription, + EscalationChainName, + EscalationRepeatCount, + EscalationWaitMinutes, + MAX_ESCALATION_REPEATS, + MAX_ESCALATION_STEPS, + MAX_ESCALATION_STEP_TARGETS, + MAX_ESCALATION_WAIT_MINUTES, + OnCallScheduleId, + UserId, +} from "@maple/domain/http" +import { + type V2EscalationChain, + type V2EscalationChainCreateParams, + type V2EscalationStep, + V2EscalationTarget, +} from "@maple/domain/http/v2" +import { Option, Schema, Array as Arr, String as Str } from "effect" +import type { ChainOutline } from "../shared/escalation-chains" + +// The escalation chain editor's form: its drafts, the API's limits as field errors, and step edits. + +/** One step as the form holds it; `key` keeps a step's card (and its menus) across reorders. */ +export const StepDraft = Schema.Struct({ + key: Schema.String, + /** Destinations, people and schedules, in the order they were added; all kept on load. */ + targets: Schema.Array(V2EscalationTarget), + waitMinutes: Schema.String, +}) +export type StepDraft = typeof StepDraft.Type + +export const Form = Schema.Struct({ + name: Schema.String, + description: Schema.String, + steps: Schema.Array(StepDraft), + repeatCount: Schema.String, + nextKey: Schema.Number, +}) +export type Form = typeof Form.Type + +export const DEFAULT_WAIT_MINUTES = "10" + +export const emptyStep = (key: number): StepDraft => ({ + key: `s${key}`, + targets: [], + waitMinutes: DEFAULT_WAIT_MINUTES, +}) + +export const emptyForm: Form = { + name: "", + description: "", + steps: [emptyStep(0)], + repeatCount: "0", + nextKey: 1, +} + +export const formFromChain = (chain: V2EscalationChain): Form => ({ + name: chain.name, + description: chain.description ?? "", + steps: Arr.map(chain.steps, (step, index) => ({ + key: `s${index}`, + targets: step.targets, + waitMinutes: String(step.wait_minutes), + })), + repeatCount: String(chain.repeat_count), + nextKey: chain.steps.length, +}) + +// VALIDATION: the same limits the API's schemas hold. + +const isName = Schema.is(EscalationChainName) +const isDescription = Schema.is(EscalationChainDescription) +const isWait = Schema.is(EscalationWaitMinutes) +const isRepeat = Schema.is(EscalationRepeatCount) + +export const numberOf = (raw: string): number | null => + Str.isEmpty(Str.trim(raw)) ? null : Number(Str.trim(raw)) + +export const nameError = (name: string): Option.Option => + Str.isEmpty(Str.trim(name)) + ? Option.some("Name the chain") + : isName(Str.trim(name)) + ? Option.none() + : Option.some("Keep the name to 120 characters") + +export const descriptionError = (description: string): Option.Option => + isDescription(Str.trim(description)) + ? Option.none() + : Option.some("Keep the description to 500 characters") + +export const waitError = (raw: string): Option.Option => + isWait(numberOf(raw)) ? Option.none() : Option.some(`Wait 1 to ${MAX_ESCALATION_WAIT_MINUTES} minutes`) + +export const repeatError = (raw: string): Option.Option => + isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) + +export const targetsError = (targets: ReadonlyArray): Option.Option => + targets.length === 0 + ? Option.some("Pick at least one destination, person or schedule") + : targets.length > MAX_ESCALATION_STEP_TARGETS + ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets`) + : Option.none() + +export const stepsError = (steps: ReadonlyArray): Option.Option => + steps.length === 0 + ? Option.some("Add at least one step") + : steps.length > MAX_ESCALATION_STEPS + ? Option.some(`A chain has at most ${MAX_ESCALATION_STEPS} steps`) + : Option.none() + +/** Every field's problem, in form order, for the action bar. */ +export const formIssues = (form: Form): ReadonlyArray => + Arr.getSomes([ + nameError(form.name), + descriptionError(form.description), + stepsError(form.steps), + ...Arr.flatMap(form.steps, (step) => [targetsError(step.targets), waitError(step.waitMinutes)]), + repeatError(form.repeatCount), + ]) + +/** A target as a menu value and a row key: `destination:`, `user:`, `schedule:`. */ +export const targetKey = (target: V2EscalationTarget): string => { + switch (target.type) { + case "destination": + return `destination:${target.destination_id}` + case "user": + return `user:${target.user_id}` + case "schedule": + return `schedule:${target.schedule_id}` + } +} + +const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) +const decodeUserId = Schema.decodeUnknownOption(UserId) +const decodeScheduleId = Schema.decodeUnknownOption(OnCallScheduleId) + +/** The target a menu value names, if it is well formed. */ +export const targetOfKey = (key: string): Option.Option => { + const [kind, ...rest] = Str.split(":")(key) + const id = Arr.join(rest, ":") + switch (kind) { + case "destination": + return Option.map(decodeDestinationId(id), (destination_id): V2EscalationTarget => ({ + type: "destination", + destination_id, + })) + case "user": + return Option.map(decodeUserId(id), (user_id): V2EscalationTarget => ({ type: "user", user_id })) + case "schedule": + return Option.map(decodeScheduleId(id), (schedule_id): V2EscalationTarget => ({ + type: "schedule", + schedule_id, + })) + default: + return Option.none() + } +} + +const stepOf = (step: StepDraft): Option.Option => { + const targets = step.targets + const minutes = numberOf(step.waitMinutes) + return Arr.isReadonlyArrayNonEmpty(targets) && + Option.isNone(targetsError(targets)) && + minutes !== null && + isWait(minutes) + ? Option.some({ targets, wait_minutes: minutes }) + : Option.none() +} + +/** The create payload the form describes, or none while any field is wrong. */ +export const payloadOf = (form: Form): Option.Option => { + const steps = Option.all(Arr.map(form.steps, stepOf)) + const repeat = numberOf(form.repeatCount) + if ( + Option.isSome(nameError(form.name)) || + Option.isSome(descriptionError(form.description)) || + Option.isSome(stepsError(form.steps)) || + repeat === null || + !isRepeat(repeat) + ) + return Option.none() + return Option.flatMap(steps, (all) => + Arr.isReadonlyArrayNonEmpty(all) + ? Option.some({ + name: Str.trim(form.name), + description: Str.isEmpty(Str.trim(form.description)) ? null : Str.trim(form.description), + steps: all, + repeat_count: repeat, + }) + : Option.none(), + ) +} + +/** The chain the form describes so far, for the live preview: only steps with targets count. */ +export const previewOf = (form: Form): ChainOutline => ({ + steps: Arr.map( + Arr.filter(form.steps, (step) => step.targets.length > 0), + (step) => ({ + targets: step.targets, + wait_minutes: Option.isNone(waitError(step.waitMinutes)) ? Number(step.waitMinutes) : 0, + }), + ), + repeat_count: Option.isNone(repeatError(form.repeatCount)) ? Number(form.repeatCount) : 0, +}) + +// STEP EDITS + +export const Direction = Schema.Literals(["up", "down"]) +export type Direction = typeof Direction.Type + +export const canAddStep = (form: Form): boolean => form.steps.length < MAX_ESCALATION_STEPS + +/** A new empty step at `index` (0 puts it first, `steps.length` last). */ +export const insertStep = (form: Form, index: number): Form => + canAddStep(form) + ? { + ...form, + steps: Arr.insertAt( + form.steps, + Math.max(0, Math.min(index, form.steps.length)), + emptyStep(form.nextKey), + ).pipe(Option.getOrElse(() => form.steps)), + nextKey: form.nextKey + 1, + } + : form + +/** A copy of the step right after it, with its own key. */ +export const duplicateStep = (form: Form, key: string): Form => + Option.match( + Arr.findFirstIndex(form.steps, (step) => step.key === key), + { + onNone: () => form, + onSome: (index) => { + const source = form.steps[index] + if (source === undefined || !canAddStep(form)) return form + const copy: StepDraft = { ...source, key: `s${form.nextKey}` } + return { + ...form, + steps: Option.getOrElse(Arr.insertAt(form.steps, index + 1, copy), () => form.steps), + nextKey: form.nextKey + 1, + } + }, + }, + ) + +/** Removes a step; the last one stays, since a chain needs at least one. */ +export const removeStep = (form: Form, key: string): Form => + form.steps.length <= 1 ? form : { ...form, steps: Arr.filter(form.steps, (step) => step.key !== key) } + +/** Swaps a step with its neighbour; at either end the steps stay as they are. */ +export const moveStep = (form: Form, key: string, direction: Direction): Form => + Option.match( + Arr.findFirstIndex(form.steps, (step) => step.key === key), + { + onNone: () => form, + onSome: (from) => { + const to = direction === "up" ? from - 1 : from + 1 + const moving = form.steps[from] + const other = form.steps[to] + if (moving === undefined || other === undefined) return form + return { + ...form, + steps: Arr.map(form.steps, (step, index) => + index === from ? other : index === to ? moving : step, + ), + } + }, + }, + ) + +export const editStep = (form: Form, key: string, edit: (step: StepDraft) => StepDraft): Form => ({ + ...form, + steps: Arr.map(form.steps, (step) => (step.key === key ? edit(step) : step)), +}) + +export const addTarget = (form: Form, key: string, target: V2EscalationTarget): Form => + editStep(form, key, (step) => + Arr.some(step.targets, (existing) => targetKey(existing) === targetKey(target)) + ? step + : { ...step, targets: [...step.targets, target] }, + ) + +export const removeTarget = (form: Form, key: string, target: string): Form => + editStep(form, key, (step) => ({ + ...step, + targets: Arr.filter(step.targets, (existing) => targetKey(existing) !== target), + })) diff --git a/apps/web/src/page/alerts/escalation-chain/index.ts b/apps/web/src/page/alerts/escalation-chain/index.ts new file mode 100644 index 0000000000..ec917920fd --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/index.ts @@ -0,0 +1,26 @@ +import { definePage } from "../../contract" +import { routingTabHref } from "../shared/escalation-chains" +import { Message } from "./message" +import { Model } from "./model" +import { subscriptions } from "./subscription" +import { init, update } from "./update" +import { chainTitle, view } from "./view" + +/** `/alerts/escalation-chains/new` and `/:chainId`: one instance per chain, or the new one. */ +export const page = definePage( + "AlertsEscalationChain", + { Model, Message }, + { + routes: ["AlertsEscalationChainNew", "AlertsEscalationChain"], + key: (route) => (route._tag === "AlertsEscalationChain" ? route.chainId : "new"), + init, + update, + view, + subscriptions, + breadcrumbs: (model) => [ + { label: "Alerts", href: "/alerts" }, + { label: "Routing", href: routingTabHref() }, + { label: chainTitle(model) }, + ], + }, +) diff --git a/apps/web/src/page/alerts/escalation-chain/message.ts b/apps/web/src/page/alerts/escalation-chain/message.ts new file mode 100644 index 0000000000..49415ab705 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/message.ts @@ -0,0 +1,45 @@ +import * as Policy from "@maple/domain/policy" +import { V2EscalationChain, V2OnCallSchedule } from "@maple/domain/http/v2" +import { AlertDestinationRowSchema, AlertRuleRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import { OrgMember } from "../shared/org-members" +import { Direction } from "./form" + +const rows = (row: S) => ({ orgId: Schema.String, rows: Schema.Array(row) }) +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const MenuKind = Schema.Literals(["targets", "actions"]) +export type MenuKind = typeof MenuKind.Type + +export const Message = defineMessageUnion({ + CompletedFetchExisting: { key: Schema.String, result: outcome(V2EscalationChain) }, + ClickedRetryExisting: {}, + UpdatedDestinations: rows(AlertDestinationRowSchema), + UpdatedRules: rows(AlertRuleRowSchema), + CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, + CompletedFetchSchedules: { result: outcome(Schema.Array(V2OnCallSchedule)) }, + CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, + ChangedName: { value: Schema.String }, + ChangedDescription: { value: Schema.String }, + ChangedRepeatCount: { value: Schema.String }, + ChangedWait: { key: Schema.String, value: Schema.String }, + /** The "+" between two cards: a new step at `index`. */ + ClickedInsertStep: { index: Schema.Number }, + ClickedRemoveStep: { key: Schema.String }, + ClickedDuplicateStep: { key: Schema.String }, + ClickedMoveStep: { key: Schema.String, direction: Direction }, + /** A step's target picker or its actions menu. */ + GotMenuMessage: { kind: MenuKind, key: Schema.String, message: Menu.Message }, + ClickedRemoveTarget: { key: Schema.String, target: Schema.String }, + ClickedSave: {}, + CompletedSave: { result: outcome(V2EscalationChain) }, + GotDeleteDialogMessage: { message: Dialog.Message }, + ClickedDelete: {}, + ClickedConfirmDelete: {}, + CompletedDelete: { result: outcome(Schema.Void) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/escalation-chain/model.ts b/apps/web/src/page/alerts/escalation-chain/model.ts new file mode 100644 index 0000000000..11cb862749 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/model.ts @@ -0,0 +1,44 @@ +import * as Policy from "@maple/domain/policy" +import { EscalationChainId } from "@maple/domain/http" +import { V2EscalationChain, V2OnCallSchedule } from "@maple/domain/http/v2" +import { AlertDestinationRowSchema, AlertRuleRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import { AlertsEscalationChainSearch } from "../../../route/search" +import { OrgMember } from "../shared/org-members" +import { Form } from "./form" + +// `/alerts/escalation-chains/new` and `/alerts/escalation-chains/:chainId`: the chain editor. + +export const Model = Schema.Struct({ + search: AlertsEscalationChainSearch, + /** The chain being edited; `null` creates. */ + editingId: Schema.NullOr(EscalationChainId), + /** The URL named a chain id that does not decode. */ + isBadId: Schema.Boolean, + /** The edited chain's read; the form fills from it once. */ + existing: KeyedSchema(V2EscalationChain, LoadFailure), + form: Form, + destinations: Schema.NullOr(Schema.Array(AlertDestinationRowSchema)), + rules: Schema.NullOr(Schema.Array(AlertRuleRowSchema)), + members: Schema.NullOr(Schema.Array(OrgMember)), + schedules: Schema.NullOr(Schema.Array(V2OnCallSchedule)), + /** Each step's target picker and actions menu, by menu id. */ + menus: Schema.Record(Schema.String, Menu.Model), + isSaving: Schema.Boolean, + /** A save was tried: each field shows what is wrong with it. */ + isShowingErrors: Schema.Boolean, + saveFailure: Schema.NullOr(LoadFailure), + deleteDialog: Dialog.Model, + isDeleting: Schema.Boolean, + deleteError: Schema.NullOr(Schema.String), + grants: Schema.Array(Policy.PermissionName), + isSessionLoaded: Schema.Boolean, +}) +export type Model = typeof Model.Type + +/** The viewer may change chains (`alerts:manage`); the API refuses anyone else. */ +export const canManage = (model: Pick): boolean => Policy.has(model.grants, "alerts:manage") diff --git a/apps/web/src/page/alerts/escalation-chain/subscription.ts b/apps/web/src/page/alerts/escalation-chain/subscription.ts new file mode 100644 index 0000000000..dab0329dc8 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/subscription.ts @@ -0,0 +1,25 @@ +import { Schema } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import type { PageSubscriptionInput } from "../../contract" +import { destinationRows, ruleRows } from "../shared/collections" +import { Message } from "./message" +import type { Model } from "./model" + +const byOrg = { orgId: Schema.String } + +/** The synced destinations steps pick from, and the rules that name the chain ("Used by"). */ +export const subscriptions = Subscription.make, Message, Resources>()( + (entry) => ({ + destinations: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), + }), + rules: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, rows })), + }), + }), +) diff --git a/apps/web/src/page/alerts/escalation-chain/update.ts b/apps/web/src/page/alerts/escalation-chain/update.ts new file mode 100644 index 0000000000..89b3bd1ad2 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/update.ts @@ -0,0 +1,274 @@ +import { Option, Result } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { + CHAIN_IN_USE_CODE, + chainHref, + chainIdFromUrl, + routingTabHref, + safeReturnTo, +} from "../shared/escalation-chains" +import { + DeleteChain, + FetchChainEditorMembers, + FetchChainEditorSchedules, + FetchChainEditorSession, + FetchExistingChain, + SaveChain, +} from "./command" +import { + addTarget, + duplicateStep, + editStep, + emptyForm, + type Form, + formFromChain, + insertStep, + moveStep, + payloadOf, + removeStep, + removeTarget, + targetOfKey, +} from "./form" +import { Message, type MenuKind } from "./message" +import type { Model } from "./model" + +type Return = PageReturn +export type ChainRoute = Extract< + AppRoute, + { readonly _tag: "AlertsEscalationChainNew" } | { readonly _tag: "AlertsEscalationChain" } +> + +export const DELETE_DIALOG_ID = "escalation-chain-delete" + +export const menuIdOf = (kind: MenuKind, key: string): string => `escalation-chain-${kind}-${key}` + +/** Where Cancel goes: the `returnTo` the link carried, else the Routing section. */ +export const backHref = (model: Pick): string => + Option.getOrElse(safeReturnTo(model.search.returnTo), routingTabHref) + +export const init = (route: ChainRoute, context: PageContext): Return => { + const rawId = route._tag === "AlertsEscalationChain" ? Option.some(route.chainId) : Option.none() + const editingId = Option.getOrNull(Option.flatMap(rawId, chainIdFromUrl)) + const model: Model = { + search: route.search, + editingId, + isBadId: Option.isSome(rawId) && editingId === null, + existing: Keyed.idle(), + form: emptyForm, + destinations: null, + rules: null, + members: null, + schedules: null, + menus: {}, + isSaving: false, + isShowingErrors: false, + saveFailure: null, + deleteDialog: Dialog.init(DELETE_DIALOG_ID, "alert-dialog"), + isDeleting: false, + deleteError: null, + grants: [], + isSessionLoaded: false, + } + const common = [ + FetchChainEditorMembers(), + FetchChainEditorSchedules(), + FetchChainEditorSession({ orgId: context.orgId }), + ] + if (editingId === null) return { model, commands: common } + const key = requestKey(context.orgId, "escalationChains.retrieve", { id: editingId }) + return Option.match(Keyed.request(model.existing, key, "Load"), { + onNone: () => ({ model, commands: common }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [...common, FetchExistingChain({ key, chainId: editingId })], + }), + }) +} + +/** A form edit is dropped while a save is in flight: the saved chain is what the page shows. */ +const withForm = (model: Model, edit: (form: Form) => Form): Return => + model.isSaving ? { model } : { model: { ...model, form: edit(model.form) } } + +const stepAction = (form: Form, key: string, value: string): Form => { + switch (value) { + case "up": + return moveStep(form, key, "up") + case "down": + return moveStep(form, key, "down") + case "duplicate": + return duplicateStep(form, key) + case "remove": + return removeStep(form, key) + default: + return form + } +} + +/** A step's menus: a target pick adds it to the step, an action edits the steps. */ +const stepMenu = (model: Model, kind: MenuKind, key: string, message: Menu.Message): Return => { + const id = menuIdOf(kind, key) + const result = Menu.update(model.menus[id] ?? Menu.init(id), message) + const next: Model = { ...model, menus: { ...model.menus, [id]: result.model } } + const commands = FoldkitCommand.mapMessages(result.commands ?? [], (child) => + Message.GotMenuMessage({ kind, key, message: child }), + ) + const picked = + result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none() + // The menus still open and close while saving, but a pick no longer edits the chain. + return Option.match(model.isSaving ? Option.none() : picked, { + onNone: () => ({ model: next, commands }), + onSome: (value) => + kind === "actions" + ? { model: { ...next, form: stepAction(next.form, key, value) }, commands } + : Option.match(targetOfKey(value), { + onNone: () => ({ model: next, commands }), + onSome: (target) => ({ + model: { ...next, form: addTarget(next.form, key, target) }, + commands, + }), + }), + }) +} + +const save = (model: Model): Return => + model.isSaving + ? { model } + : Option.match(payloadOf(model.form), { + onNone: () => ({ model: { ...model, isShowingErrors: true } }), + onSome: (payload) => ({ + model: { ...model, isSaving: true, isShowingErrors: true, saveFailure: null }, + commands: [SaveChain({ editingId: model.editingId, payload })], + }), + }) + +const liftDelete = (model: Model, step: ReturnType): Return => ({ + model: { + ...model, + deleteDialog: step.model, + deleteError: step.model.isOpen ? model.deleteError : null, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteDialogMessage({ message }), + ), +}) + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + CompletedFetchExisting: ({ key, result }) => { + const existing = Keyed.accept(model.existing, key, result) + // The form fills once, from the first read. + const isFirst = !AsyncData.isSuccess(model.existing.data) + return { + model: Result.match(result, { + onSuccess: (chain) => ({ + ...model, + existing, + form: isFirst ? formFromChain(chain) : model.form, + }), + onFailure: () => ({ ...model, existing }), + }), + } + }, + ClickedRetryExisting: () => { + const editingId = model.editingId + if (editingId === null) return { model } + const key = requestKey(context.orgId, "escalationChains.retrieve", { id: editingId }) + return Option.match(Keyed.request(model.existing, key, "Refresh"), { + onNone: () => ({ model }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [FetchExistingChain({ key, chainId: editingId })], + }), + }) + }, + UpdatedDestinations: ({ orgId, rows }) => + orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, + UpdatedRules: ({ orgId, rows }) => + orgId === context.orgId ? { model: { ...model, rules: rows } } : { model }, + // Without members a person reads "Member"; destinations and schedules still show. + CompletedFetchMembers: ({ result }) => + Result.match(result, { + onSuccess: (members) => ({ model: { ...model, members } }), + onFailure: () => ({ model: { ...model, members: [] } }), + }), + CompletedFetchSchedules: ({ result }) => + Result.match(result, { + onSuccess: (schedules) => ({ model: { ...model, schedules } }), + onFailure: () => ({ model: { ...model, schedules: [] } }), + }), + CompletedFetchSession: ({ orgId, grants }) => + orgId === context.orgId ? { model: { ...model, grants, isSessionLoaded: true } } : { model }, + ChangedName: ({ value }) => withForm(model, (form) => ({ ...form, name: value })), + ChangedDescription: ({ value }) => withForm(model, (form) => ({ ...form, description: value })), + ChangedRepeatCount: ({ value }) => withForm(model, (form) => ({ ...form, repeatCount: value })), + ChangedWait: ({ key, value }) => + withForm(model, (form) => editStep(form, key, (step) => ({ ...step, waitMinutes: value }))), + ClickedInsertStep: ({ index }) => withForm(model, (form) => insertStep(form, index)), + ClickedRemoveStep: ({ key }) => withForm(model, (form) => removeStep(form, key)), + ClickedDuplicateStep: ({ key }) => withForm(model, (form) => duplicateStep(form, key)), + ClickedMoveStep: ({ key, direction }) => withForm(model, (form) => moveStep(form, key, direction)), + GotMenuMessage: ({ kind, key, message: child }) => stepMenu(model, kind, key, child), + ClickedRemoveTarget: ({ key, target }) => withForm(model, (form) => removeTarget(form, key, target)), + ClickedSave: () => save(model), + CompletedSave: ({ result }) => + Result.match(result, { + onSuccess: (chain): Return => ({ + model: { ...model, isSaving: false }, + outMessage: PageOutMessage.RequestedNavigation({ + url: Option.getOrElse(safeReturnTo(model.search.returnTo), () => chainHref(chain.id)), + isReplace: true, + toast: { + type: "success", + title: + model.editingId === null + ? "Escalation chain created" + : "Escalation chain saved", + description: "", + }, + }), + }), + onFailure: (failure): Return => ({ + model: { ...model, isSaving: false, saveFailure: failure }, + }), + }), + GotDeleteDialogMessage: ({ message: child }) => + model.isDeleting ? { model } : liftDelete(model, Dialog.update(model.deleteDialog, child)), + ClickedDelete: () => + model.editingId === null + ? { model } + : liftDelete({ ...model, deleteError: null }, Dialog.open(model.deleteDialog)), + ClickedConfirmDelete: () => + model.editingId === null || model.isDeleting + ? { model } + : { + model: { ...model, isDeleting: true, deleteError: null }, + commands: [DeleteChain({ chainId: model.editingId })], + }, + CompletedDelete: ({ result }) => + Result.match(result, { + onSuccess: (): Return => ({ + model: { ...model, isDeleting: false }, + outMessage: PageOutMessage.RequestedNavigation({ + url: routingTabHref(), + isReplace: true, + toast: { type: "success", title: "Escalation chain deleted", description: "" }, + }), + }), + onFailure: (failure): Return => ({ + model: { + ...model, + isDeleting: false, + deleteError: + failure.code === CHAIN_IN_USE_CODE + ? "An alert rule still starts this chain. Remove it from the rule's severity tiers first." + : failure.message, + }, + }), + }), + }) diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts new file mode 100644 index 0000000000..f9099298be --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -0,0 +1,636 @@ +import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" +import type { AlertDestinationId } from "@maple/domain/http" +import type { V2EscalationTarget } from "@maple/domain/http/v2" +import { pluralize } from "@maple/ui/lib/format" +import { Option, Array as Arr, String as Str } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "@maple/views/data/load-failure" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { button, linkButton } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { separator } from "@maple/views/ui/display" +import { errorState } from "@maple/views/ui/error-state" +import { resourceNotFound } from "../../kit/resource-not-found" +import { fieldErrorText, type FieldIds, fieldLabel, input } from "@maple/views/ui/field" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import type { FieldState } from "@maple/views/ui/input" +import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" +import { panel } from "@maple/views/ui/panel" +import { personAvatar } from "@maple/views/ui/person-avatar" +import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { textarea } from "@maple/views/ui/textarea" +import { typography } from "@maple/views/ui/typography" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { managersOnlyPage } from "../../kit/form-section" +import { type PickedEntry, pickList } from "../../kit/pick-list" +import { chainSummary } from "../shared/escalation-chains" +import { ruleHref } from "../shared/links" +import { memberName, memberOf } from "../shared/org-members" +import { + canAddStep, + descriptionError, + formIssues, + nameError, + numberOf, + previewOf, + repeatError, + type StepDraft, + stepsError, + targetKey, + targetsError, + waitError, +} from "./form" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { backHref, DELETE_DIALOG_ID, menuIdOf } from "./update" + +// The escalation chain editor: the chain as a column of sentence cards, its name and preview beside it. + +type H = HtmlBuilder + +/** The page's name: the chain as last read, or "New escalation chain". */ +export const chainTitle = (model: Pick): string => + model.editingId === null + ? "New escalation chain" + : Option.getOrElse( + Option.map(AsyncData.getData(model.existing.data), (chain) => chain.name), + () => "Escalation chain", + ) + +const nameOr = ( + rows: ReadonlyArray | null, + id: string, + fallback: string, +): string => + Option.getOrElse( + Option.map( + Arr.findFirst(rows ?? [], (row) => row.id === id), + (row) => row.name, + ), + () => (rows === null ? fallback : `Unavailable ${Str.toLowerCase(fallback)}`), + ) + +/** A field's error once a save was tried, or at once for a number typed out of range. */ +const shown = (model: Model, error: Option.Option, isImmediate = false): Option.Option => + model.isShowingErrors || isImmediate ? error : Option.none() + +const errorText = (h: H, error: Option.Option): ReadonlyArray => + Option.match(error, { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], + }) + +const isOutOfRange = (raw: string, error: Option.Option) => raw !== "" && Option.isSome(error) + +const idsOf = (id: string): FieldIds => ({ control: id, label: `${id}-label`, error: `${id}-error` }) + +const stateOf = (value: string, isInvalid: boolean): FieldState => ({ + isInvalid, + isDirty: false, + isFilled: value !== "", + isFocused: false, + isTouched: false, +}) + +// STEP CARDS + +/** A picked target's row: a destination's bell, a member's avatar, a schedule's calendar. */ +const targetEntry = (h: H, model: Model, key: string, target: V2EscalationTarget): PickedEntry => { + const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) + switch (target.type) { + case "destination": { + const name = nameOr(model.destinations, target.destination_id, "Destination") + return { + key: targetKey(target), + label: name, + media: icon(h, "BellIcon", { className: "text-muted-foreground" }), + removeLabel: `Stop notifying ${name}`, + onRemove, + } + } + case "user": { + const name = memberName(model.members, target.user_id) + return { + key: targetKey(target), + label: name, + media: personAvatar(h, { + name, + size: "xs", + imageUrl: Option.getOrUndefined( + Option.map(memberOf(model.members, target.user_id), (member) => member.imageUrl), + ), + }), + detail: "email and chat DM", + removeLabel: `Stop paging ${name}`, + onRemove, + } + } + case "schedule": { + const name = nameOr(model.schedules, target.schedule_id, "Schedule") + return { + key: targetKey(target), + label: name, + media: icon(h, "CalendarIcon", { className: "text-muted-foreground" }), + detail: "whoever is on call", + removeLabel: `Stop paging ${name}`, + onRemove, + } + } + } +} + +/** The target picker: destinations, people and schedules not yet on the step, by kind. */ +const addEntries = (model: Model, step: StepDraft): ReadonlyArray => { + const picked = new Set(Arr.map(step.targets, targetKey)) + const group = ( + groupLabel: string, + items: ReadonlyArray>, + ): ReadonlyArray => { + const open = Arr.filter(items, (item) => !picked.has(item.value)) + return open.length === 0 + ? [] + : [ + { + _tag: "Group", + label: groupLabel, + entries: Arr.map(open, (item): MenuEntry => ({ _tag: "Item", ...item })), + }, + ] + } + return [ + ...group( + "Destinations", + Arr.map(model.destinations ?? [], (row) => ({ value: `destination:${row.id}`, label: row.name })), + ), + ...group( + "People", + Arr.map(model.members ?? [], (member) => ({ + value: `user:${member.userId}`, + label: member.name, + })), + ), + ...group( + "Whoever is on call on", + Arr.map(model.schedules ?? [], (schedule) => ({ + value: `schedule:${schedule.id}`, + label: schedule.name, + })), + ), + ] +} + +const stepActions = (model: Model, index: number): ReadonlyArray => { + const count = model.form.steps.length + return [ + { _tag: "Item", value: "up", label: "Move up", icon: "ArrowUpIcon", isDisabled: index === 0 }, + { + _tag: "Item", + value: "down", + label: "Move down", + icon: "ArrowDownIcon", + isDisabled: index === count - 1, + }, + { + _tag: "Item", + value: "duplicate", + label: "Duplicate", + icon: "CopyIcon", + isDisabled: !canAddStep(model.form), + }, + { _tag: "Separator" }, + { + _tag: "Item", + value: "remove", + label: "Remove", + icon: "TrashIcon", + variant: "destructive", + isDisabled: count === 1, + }, + ] +} + +/** What the wait after a step is for: the next step, a repeat, or nothing once the chain stops. */ +const waitTail = (model: Model, index: number): string => + index < model.form.steps.length - 1 + ? "for an acknowledgement" + : (numberOf(model.form.repeatCount) ?? 0) > 0 + ? "for an acknowledgement, then repeat" + : "for an acknowledgement" + +const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { + const key = step.key + const wait = waitError(step.waitMinutes) + const targetsMenuId = menuIdOf("targets", key) + const actionsMenuId = menuIdOf("actions", key) + const waitId = `escalation-chain-wait-${key}` + const waitState = stateOf( + step.waitMinutes, + Option.isSome(shown(model, wait, isOutOfRange(step.waitMinutes, wait))), + ) + return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ + hstack(h, { gap: "2", justify: "between" }, [ + typography(h, { size: "sm", weight: "medium" }, [index === 0 ? "Notify" : "Then notify"]), + rowActionsMenu(h, { + slotId: actionsMenuId, + model: model.menus[actionsMenuId] ?? Menu.init(actionsMenuId), + entries: stepActions(model, index), + label: `Actions for step ${index + 1}`, + toParentMessage: (message) => Message.GotMenuMessage({ kind: "actions", key, message }), + }), + ]), + pickList(h, { + entries: Arr.map(step.targets, (target) => targetEntry(h, model, key, target)), + menu: model.menus[targetsMenuId] ?? Menu.init(targetsMenuId), + toMenuMessage: (message) => Message.GotMenuMessage({ kind: "targets", key, message }), + addEntries: addEntries(model, step), + addLabel: "Add destination, person or schedule", + isDisabled: model.isSaving, + context: `step ${index + 1}`, + emptyText: "Pick who this step pages", + }), + ...errorText(h, shown(model, targetsError(step.targets))), + hstack(h, { gap: "2", wrap: true }, [ + fieldLabel(h, { ids: idsOf(waitId), state: waitState, className: "font-normal" }, "then wait"), + input(h, { + ids: idsOf(waitId), + state: waitState, + className: "w-20", + isDisabled: model.isSaving, + attributes: [ + h.Type("number"), + h.Min("1"), + h.Attribute("max", String(MAX_ESCALATION_WAIT_MINUTES)), + h.Attribute("step", "1"), + h.AriaLabel(`Step ${index + 1} wait, in minutes`), + h.Value(step.waitMinutes), + h.OnInput((value) => Message.ChangedWait({ key, value })), + ], + }), + typography(h, { size: "sm" }, [`minutes ${waitTail(model, index)}`]), + ]), + ...errorText(h, shown(model, wait, isOutOfRange(step.waitMinutes, wait))), + ]) +} + +/** The line between two cards, with a "+" that adds a step there. */ +const connector = (h: H, model: Model, index: number): Html => + vstack(h, { align: "center", attributes: [h.Key(`connector-${index}`)] }, [ + separator(h, "vertical", "h-3"), + iconButton( + h, + { + label: index === 0 ? "Add a step at the start" : `Add a step after step ${index}`, + variant: "outline", + size: "icon-xs", + isDisabled: model.isSaving || !canAddStep(model.form), + attributes: [h.OnClick(Message.ClickedInsertStep({ index }))], + }, + [icon(h, "PlusIcon", { size: 12 })], + ), + separator(h, "vertical", "h-3"), + ]) + +const startCard = (h: H): Html => + panel(h, { padded: "sm", tone: "muted" }, [ + hstack(h, { gap: "2" }, [ + icon(h, "BellIcon", { className: "text-muted-foreground" }), + typography(h, { size: "sm" }, ["When an alert escalates to this chain"]), + ]), + ]) + +const endCard = (h: H, model: Model): Html => { + const repeat = repeatError(model.form.repeatCount) + const count = numberOf(model.form.repeatCount) ?? 0 + const id = "escalation-chain-repeat" + const state = stateOf( + model.form.repeatCount, + Option.isSome(shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat))), + ) + return panel(h, { padded: "sm", tone: "muted", className: "gap-2" }, [ + hstack(h, { gap: "2", wrap: true }, [ + icon(h, "ArrowPathIcon", { className: "text-muted-foreground" }), + fieldLabel( + h, + { ids: idsOf(id), state, className: "font-normal" }, + "If nobody acknowledges, repeat the chain", + ), + input(h, { + ids: idsOf(id), + state, + className: "w-20", + isDisabled: model.isSaving, + attributes: [ + h.Type("number"), + h.Min("0"), + h.Attribute("max", String(MAX_ESCALATION_REPEATS)), + h.Attribute("step", "1"), + h.Value(model.form.repeatCount), + h.OnInput((value) => Message.ChangedRepeatCount({ value })), + ], + }), + typography(h, { size: "sm" }, [count === 0 ? "times, then stop" : pluralize(count, "time")]), + ]), + ...errorText(h, shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat))), + ]) +} + +const chainColumn = (h: H, model: Model): Html => + vstack(h, { className: "min-w-0" }, [ + startCard(h), + ...Arr.flatMap(model.form.steps, (step, index) => [ + connector(h, model, index), + stepCard(h, model, step, index), + ]), + connector(h, model, model.form.steps.length), + endCard(h, model), + ...errorText(h, shown(model, stepsError(model.form.steps))), + ]) + +// ASIDE + +const destinationName = + (model: Model) => + (id: AlertDestinationId): string => + nameOr(model.destinations, id, "Destination") + +/** The chain in one line, as it reads on the Routing section and in the rule editor. */ +const preview = (h: H, model: Model): Html => { + const chain = previewOf(model.form) + const summary = + chain.steps.length === 0 + ? "Pick who the first step pages to see the chain in one line." + : chainSummary(chain, destinationName(model), { + userName: (id) => memberName(model.members, id), + scheduleName: (id) => + model.schedules === null ? undefined : nameOr(model.schedules, id, "Schedule"), + }) + return vstack(h, { gap: "1.5" }, [ + typography(h, { size: "xs", tone: "muted", weight: "medium" }, ["Preview"]), + typography(h, { size: "sm", as: "p", className: "break-words" }, [summary]), + ]) +} + +/** The rules whose tiers start this chain, as links; ones not synced yet are counted. */ +const usedBy = (h: H, model: Model): ReadonlyArray => + Option.match(AsyncData.getData(model.existing.data), { + onNone: () => [], + onSome: (chain) => { + const ruleIds = chain.rule_ids + const rules = Arr.filter(model.rules ?? [], (rule) => Arr.some(ruleIds, (id) => id === rule.id)) + const others = ruleIds.length - rules.length + return [ + vstack(h, { gap: "1.5" }, [ + typography(h, { size: "xs", tone: "muted", weight: "medium" }, [ + ruleIds.length === 0 + ? "Not used by any rule" + : `Used by ${ruleIds.length} ${pluralize(ruleIds.length, "rule")}`, + ]), + ...(ruleIds.length === 0 + ? [] + : [ + h.ul( + [h.Class("space-y-1")], + [ + ...Arr.map(rules, (rule) => + h.li( + [h.Key(rule.id)], + [ + typography(h, { size: "sm" }, [ + textLink(h, { href: ruleHref(rule.id) }, [rule.name]), + ]), + ], + ), + ), + ...(others > 0 + ? [ + h.li( + [], + [ + typography(h, { size: "sm", tone: "muted" }, [ + `and ${others} more`, + ]), + ], + ), + ] + : []), + ], + ), + ]), + ]), + ] + }, + }) + +const aside = (h: H, model: Model): Html => { + const name = shown(model, nameError(model.form.name)) + const description = shown(model, descriptionError(model.form.description)) + const nameState = stateOf(model.form.name, Option.isSome(name)) + const notesIds = idsOf("escalation-chain-description") + return h.aside( + [h.Class("order-first min-w-0 lg:order-none")], + [ + panel(h, { padded: "sm", className: "gap-4 lg:sticky lg:top-4" }, [ + vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids: idsOf("escalation-chain-name"), state: nameState }, "Name"), + input(h, { + ids: idsOf("escalation-chain-name"), + state: nameState, + isDisabled: model.isSaving, + attributes: [ + h.Value(model.form.name), + h.Placeholder("Checkout on-call"), + h.Attribute("autocomplete", "off"), + h.OnInput((value) => Message.ChangedName({ value })), + ], + }), + ...errorText(h, name), + ]), + vstack(h, { gap: "1.5" }, [ + fieldLabel( + h, + { ids: notesIds, state: stateOf(model.form.description, Option.isSome(description)) }, + "Notes", + ), + textarea(h, { + id: notesIds.control, + defaultValue: model.form.description, + isDisabled: model.isSaving, + attributes: [ + // Uncontrolled: keyed on the chain, so a loaded chain rebuilds it with its text. + h.Key(`escalation-chain-description-${model.editingId ?? "new"}`), + h.AriaLabelledBy(notesIds.label), + h.Placeholder("Optional: who is on this chain and when to change it"), + h.Rows(3), + h.OnInput((value) => Message.ChangedDescription({ value })), + ], + }), + ...errorText(h, description), + ]), + preview(h, model), + ...usedBy(h, model), + ...(model.editingId === null + ? [] + : [ + button( + h, + { + variant: "outline", + size: "sm", + className: "self-start", + isDisabled: model.isSaving || model.isDeleting, + attributes: [h.OnClick(Message.ClickedDelete())], + }, + [icon(h, "TrashIcon"), "Delete chain"], + ), + ]), + ]), + ], + ) +} + +/** "Delete {chain}?": lists the rules that start it, which the API makes move off it first. */ +const deleteDialog = (h: H, model: Model): Html => { + const chain = AsyncData.getData(model.existing.data) + const ruleIds: ReadonlyArray = Option.match(chain, { + onNone: () => [], + onSome: (found) => found.rule_ids, + }) + const names = Arr.map( + Arr.filter(model.rules ?? [], (rule) => Arr.some(ruleIds, (id) => id === rule.id)), + (rule) => rule.name, + ) + const isInUse = ruleIds.length > 0 + return confirmDialog(h, { + slotId: DELETE_DIALOG_ID, + model: model.deleteDialog, + toParentMessage: (message) => Message.GotDeleteDialogMessage({ message }), + title: `Delete ${chainTitle(model)}?`, + description: isInUse + ? "These rules would stop escalating. Remove the chain from their severity tiers first." + : "Escalations already running finish on their own copy. This cannot be undone.", + children: [ + ...(isInUse + ? [ + h.ul( + [h.Class("list-disc space-y-1 pl-5")], + [ + ...Arr.map(names, (ruleName) => + h.li([h.Key(ruleName)], [typography(h, { size: "sm" }, [ruleName])]), + ), + ...(ruleIds.length > names.length + ? [ + h.li( + [], + [ + typography(h, { size: "sm", tone: "muted" }, [ + `and ${ruleIds.length - names.length} more`, + ]), + ], + ), + ] + : []), + ], + ), + ] + : []), + ...(model.deleteError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't delete chain"]), + alertDescription(h, [model.deleteError]), + ]), + ]), + ], + confirmLabel: "Delete chain", + isPending: model.isDeleting, + isConfirmDisabled: isInUse, + onConfirm: Message.ClickedConfirmDelete(), + }) +} + +// PAGE + +const actions = (h: H, model: Model): Html => + hstack(h, { gap: "2" }, [ + linkButton(h, { variant: "outline", href: backHref(model) }, ["Cancel"]), + button( + h, + { + isDisabled: !canManage(model), + isLoading: model.isSaving, + attributes: [h.OnClick(Message.ClickedSave())], + }, + [model.editingId === null ? "Create chain" : "Save chain"], + ), + ]) + +const saveNotice = (h: H, model: Model): ReadonlyArray => [ + ...(model.saveFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [ + model.editingId === null ? "Couldn't create chain" : "Couldn't save chain", + ]), + alertDescription(h, [failureMessage(model.saveFailure)]), + ]), + ]), + ...(model.isShowingErrors && formIssues(model.form).length > 0 + ? [ + typography(h, { size: "sm", tone: "destructive", as: "p", attributes: [h.Role("status")] }, [ + "Fix the highlighted fields to save the chain.", + ]), + ] + : []), +] + +export const view = Submodel.defineView((model, _inputs, h) => { + const isEdit = model.editingId !== null + const header = { + title: chainTitle(model), + description: + "Page step by step until someone acknowledges. Acknowledging pauses it, resolving ends it.", + } + if (model.isSessionLoaded && !canManage(model)) + return managersOnlyPage(h, { + header, + title: "Only org admins and engineers can create or change escalation chains", + back: { href: backHref(model), label: "Back to routing" }, + }) + if (model.isBadId) + return dashboardPage(h, header, [ + resourceNotFound(h, { + title: "Escalation chain not found", + description: "This link does not name an escalation chain.", + back: { href: backHref(model), label: "Back to routing" }, + }), + ]) + if (isEdit && AsyncData.isFailure(model.existing.data)) + return dashboardPage(h, header, [ + errorState(h, { + failure: model.existing.data.error, + title: "Couldn't load the chain", + onRetry: Message.ClickedRetryExisting(), + }), + ]) + if (isEdit && !AsyncData.isSuccess(model.existing.data)) + return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + return dashboardPage(h, { ...header, actions: actions(h, model) }, [ + ...saveNotice(h, model), + h.div( + [h.Class("grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start")], + [chainColumn(h, model), aside(h, model)], + ), + deleteDialog(h, model), + ]) +}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts deleted file mode 100644 index 47c7a2b37e..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" -import { expect as expectView, given, Mount, role, scene, withViewInputs } from "foldkit/scene" -import { describe, test } from "vitest" -import * as Dialog from "@maple/views/ui/dialog" -import { MountDialog } from "@maple/views/ui/dialog" -import { chainDestinations, chainOf } from "../shared/escalation-test-fixtures" -import { init, Message, openEdit, update } from "./dialog" -import { view as dialogView } from "./view" - -const view = withViewInputs(dialogView, { - id: "chain", - destinations: chainDestinations, - members: [], - schedules: [], -})() -const editing = () => - openEdit( - init("chain"), - chainOf({ - n: 1, - name: "Checkout on-call", - steps: [ - { destinations: [1], wait: 10 }, - { destinations: [2], wait: 15 }, - ], - repeat: 1, - }), - ).model - -describe("escalation chain dialog view", () => { - test("while a save is in flight the fields and step controls are disabled", () => { - scene( - { update, view }, - given(update(editing(), Message.ClickedSave()).model), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), - expectView(role("textbox", { name: "Name" })).toBeDisabled(), - expectView(role("textbox", { name: "Description" })).toBeDisabled(), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).toBeDisabled(), - expectView(role("button", { name: "Move step 1 down" })).toBeDisabled(), - expectView(role("button", { name: "Remove step 1" })).toBeDisabled(), - expectView(role("button", { name: "Add step" })).toBeDisabled(), - expectView(role("button", { name: /^Stop notifying/ })).toBeDisabled(), - ) - }) - - test("before a save the same controls are editable", () => { - scene( - { update, view }, - given(editing()), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), - expectView(role("textbox", { name: "Name" })).not.toBeDisabled(), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).not.toBeDisabled(), - expectView(role("button", { name: "Add step" })).not.toBeDisabled(), - expectView(role("button", { name: /^Stop notifying .+ in step 1$/u })).toExist(), - ) - }) - - test("the wait and repeat inputs take their range and copy from the API's limits", () => { - scene( - { update, view }, - given(editing()), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAttr( - "max", - String(MAX_ESCALATION_REPEATS), - ), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAccessibleDescription( - `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, - ), - expectView(role("spinbutton", { name: /^Step 1: then wait/ })).toHaveAttr( - "max", - String(MAX_ESCALATION_WAIT_MINUTES), - ), - ) - }) -}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts deleted file mode 100644 index 61ad9a0595..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Option, Array as Arr, Schema } from "effect" -import { AlertDestinationId } from "@maple/domain/http" -import * as Menu from "@maple/views/ui/menu" -import { describe, expect, it } from "vitest" -import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" -import { memberId, mixedTargetChain, scheduleUuid } from "../shared/oncall-test-fixtures" -import { init, Message, type Model, openEdit, payloadOf, update } from "./dialog" - -const asDestination = Schema.decodeSync(AlertDestinationId) -const run = (model: Model, messages: ReadonlyArray) => - Arr.reduce(messages, model, (current, message) => update(current, message).model) -const opened = (): Model => - openEdit( - init("chain"), - chainOf({ - n: 1, - name: "Checkout on-call", - steps: [ - { destinations: [1], wait: 10 }, - { destinations: [2], wait: 15 }, - ], - repeat: 1, - }), - ).model - -describe("escalation chain dialog", () => { - it("an edited chain round-trips to the payload it was read from", () => { - expect(payloadOf(opened())).toEqual( - Option.some({ - name: "Checkout on-call", - description: null, - steps: [ - { - targets: [{ type: "destination", destination_id: chainDestinationUuid(1) }], - wait_minutes: 10, - }, - { - targets: [{ type: "destination", destination_id: chainDestinationUuid(2) }], - wait_minutes: 15, - }, - ], - repeat_count: 1, - }), - ) - }) - - it("a chain built through the API keeps its people and schedules through an edit", () => { - const chain = mixedTargetChain(chainDestinationUuid(1)) - const model = openEdit(init("chain"), chain).model - expect(Option.map(payloadOf(model), (payload) => payload.steps)).toEqual(Option.some(chain.steps)) - const key = model.steps[1]?.key ?? "" - const removed = run(model, [ - Message.ClickedRemoveTarget({ key, target: `schedule:${scheduleUuid(1)}` }), - ]) - expect(Option.map(payloadOf(removed), (payload) => payload.steps[1]?.targets)).toEqual( - Option.some([{ type: "user", user_id: memberId(1) }]), - ) - }) - - it("adds a person or a schedule from the step's menu, once each", () => { - const model = opened() - const key = model.steps[0]?.key ?? "" - const pick = (value: string) => - Message.GotStepMenuMessage({ - key, - message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value } }), - }) - const added = run(model, [ - pick(`user:${memberId(3)}`), - pick(`schedule:${scheduleUuid(2)}`), - pick(`user:${memberId(3)}`), - ]) - expect(added.steps[0]?.targets).toEqual([ - { type: "destination", destination_id: chainDestinationUuid(1) }, - { type: "user", user_id: memberId(3) }, - { type: "schedule", schedule_id: scheduleUuid(2) }, - ]) - }) - - it("holds the API's limits: a name, a destination per step, a 1 to 1440 minute wait, 0 to 5 repeats", () => { - const model = opened() - const key = model.steps[0]?.key ?? "" - const broken = (messages: ReadonlyArray) => Option.isNone(payloadOf(run(model, messages))) - expect(broken([Message.ChangedName({ value: " " })])).toBe(true) - expect(broken([Message.ChangedWait({ key, value: "0" })])).toBe(true) - expect(broken([Message.ChangedWait({ key, value: "1441" })])).toBe(true) - expect(broken([Message.ChangedWait({ key, value: "2.5" })])).toBe(true) - expect(broken([Message.ChangedRepeatCount({ value: "6" })])).toBe(true) - expect( - broken([ - Message.ClickedRemoveTarget({ - key, - target: `destination:${asDestination(chainDestinationUuid(1))}`, - }), - ]), - ).toBe(true) - expect( - broken([Message.ChangedWait({ key, value: "1440" }), Message.ChangedRepeatCount({ value: "5" })]), - ).toBe(false) - }) - - it("a save with errors sends nothing and shows them; a valid one sends once", () => { - const model = run(opened(), [Message.ChangedName({ value: "" })]) - const refused = update(model, Message.ClickedSave()) - expect(refused.commands ?? []).toEqual([]) - expect(refused.model.isShowingErrors).toBe(true) - const saving = update(opened(), Message.ClickedSave()) - expect(Arr.map(saving.commands ?? [], ({ name }) => name)).toEqual(["SaveEscalationChain"]) - expect(update(saving.model, Message.ClickedSave()).commands ?? []).toEqual([]) - }) - - it("edits sent while a save is in flight leave the form as it was sent", () => { - const saving = update(opened(), Message.ClickedSave()).model - const key = saving.steps[0]?.key ?? "" - const edited = run(saving, [ - Message.ChangedName({ value: "Renamed mid-save" }), - Message.ChangedDescription({ value: "typed while saving" }), - Message.ChangedWait({ key, value: "30" }), - Message.ChangedRepeatCount({ value: "3" }), - Message.ClickedAddStep(), - Message.ClickedRemoveStep({ key }), - Message.ClickedMoveStep({ key, direction: "down" }), - Message.ClickedRemoveTarget({ - key, - target: `destination:${asDestination(chainDestinationUuid(1))}`, - }), - ]) - expect(edited).toEqual(saving) - }) - - it("steps reorder up and down, add up to ten, and the last one cannot move further", () => { - const model = opened() - const [first, second] = model.steps - const moved = run(model, [Message.ClickedMoveStep({ key: first?.key ?? "", direction: "down" })]) - expect(Arr.map(moved.steps, (step) => step.key)).toEqual([second?.key, first?.key]) - expect( - run(moved, [Message.ClickedMoveStep({ key: first?.key ?? "", direction: "down" })]).steps, - ).toEqual(moved.steps) - const full = run( - model, - Arr.makeBy(12, () => Message.ClickedAddStep()), - ) - expect(full.steps).toHaveLength(10) - }) - - it("an edit sends a step's member and schedule targets back, and a schedule alone is enough", () => { - const chain = chainOf({ - n: 2, - name: "Checkout on-call", - steps: [ - { destinations: [1], users: ["user_ben"], wait: 10 }, - { destinations: [], schedules: [1], wait: 15 }, - ], - }) - const renamed = run(openEdit(init("chain"), chain).model, [ - Message.ChangedName({ value: "Checkout" }), - ]) - const saving = update(renamed, Message.ClickedSave()) - expect(saving.commands?.[0]?.args).toMatchObject({ - payload: { - name: "Checkout", - steps: Arr.map(chain.steps, (step) => ({ targets: step.targets })), - }, - }) - const key = renamed.steps[0]?.key ?? "" - const withoutDestination = run(renamed, [ - Message.ClickedRemoveTarget({ - key, - target: `destination:${asDestination(chainDestinationUuid(1))}`, - }), - ]) - expect(Option.map(payloadOf(withoutDestination), ({ steps }) => steps[0].targets)).toEqual( - Option.some([{ type: "user", user_id: "user_ben" }]), - ) - }) -}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.ts deleted file mode 100644 index c89f0ce055..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { - AlertDestinationId, - EscalationChainDescription, - EscalationChainId, - EscalationChainName, - EscalationRepeatCount, - EscalationWaitMinutes, - MAX_ESCALATION_REPEATS, - MAX_ESCALATION_STEPS, - MAX_ESCALATION_STEP_TARGETS, - MAX_ESCALATION_WAIT_MINUTES, - OnCallScheduleId, - UserId, -} from "@maple/domain/http" -import { - V2EscalationChain, - V2EscalationChainCreateParams, - type V2EscalationStep, - V2EscalationTarget, -} from "@maple/domain/http/v2" -import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" -import { Command, type Update } from "foldkit" -import { defineMessageUnion } from "foldkit/message" -import { LoadFailure } from "@maple/views/data/load-failure" -import * as Dialog from "@maple/views/ui/dialog" -import * as Menu from "@maple/views/ui/menu" -import type { Resources } from "../../../platform/resources" -import { saveEscalationChain } from "../shared/escalation-chains" - -// "New escalation chain" / "Edit escalation chain": the Routing tab's dialog, as one Submodel. - -/** One step as the form holds it; `key` keeps a step's row (and its menu) across reorders. */ -export const StepDraft = Schema.Struct({ - key: Schema.String, - /** Destinations, people and schedules, in the order they were added; all kept on load. */ - targets: Schema.Array(V2EscalationTarget), - waitMinutes: Schema.String, -}) -export type StepDraft = typeof StepDraft.Type - -export const Model = Schema.Struct({ - dialog: Dialog.Model, - editingId: Schema.NullOr(EscalationChainId), - name: Schema.String, - description: Schema.String, - steps: Schema.Array(StepDraft), - repeatCount: Schema.String, - nextKey: Schema.Number, - /** Each step's "Add target" menu, by step key. */ - menus: Schema.Record(Schema.String, Menu.Model), - isSaving: Schema.Boolean, - /** A save was tried: every field shows what is wrong with it. */ - isShowingErrors: Schema.Boolean, - saveError: Schema.NullOr(Schema.String), - /** Bumped on every open: a save's result only settles the form it was sent from. */ - openCount: Schema.Number, -}) -export type Model = typeof Model.Type - -export const DEFAULT_WAIT_MINUTES = "10" - -const emptyStep = (key: number): StepDraft => ({ - key: `s${key}`, - targets: [], - waitMinutes: DEFAULT_WAIT_MINUTES, -}) - -export const init = (id: string): Model => ({ - dialog: Dialog.init(id, "dialog"), - editingId: null, - name: "", - description: "", - steps: [emptyStep(0)], - repeatCount: "0", - nextKey: 1, - menus: {}, - isSaving: false, - isShowingErrors: false, - saveError: null, - openCount: 0, -}) - -export const Direction = Schema.Literals(["up", "down"]) - -export const Message = defineMessageUnion({ - GotDialogMessage: { message: Dialog.Message }, - ChangedName: { value: Schema.String }, - ChangedDescription: { value: Schema.String }, - ChangedRepeatCount: { value: Schema.String }, - ChangedWait: { key: Schema.String, value: Schema.String }, - ClickedAddStep: {}, - ClickedRemoveStep: { key: Schema.String }, - ClickedMoveStep: { key: Schema.String, direction: Direction }, - GotStepMenuMessage: { key: Schema.String, message: Menu.Message }, - ClickedRemoveTarget: { key: Schema.String, target: Schema.String }, - ClickedSave: {}, - CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2EscalationChain, LoadFailure) }, -}) -export type Message = typeof Message.Type - -export const OutMessage = defineMessageUnion({ - SavedChain: { chain: V2EscalationChain, isNew: Schema.Boolean }, -}) -export type OutMessage = typeof OutMessage.Type - -type Return = Update.ReturnWithOutMessage - -export const menuIdOf = (model: Pick, key: string): string => - `${model.dialog.id}-step-${key}` - -// VALIDATION: the same limits the API's schemas hold. - -const isName = Schema.is(EscalationChainName) -const isDescription = Schema.is(EscalationChainDescription) -const isWait = Schema.is(EscalationWaitMinutes) -const isRepeat = Schema.is(EscalationRepeatCount) - -export const numberOf = (raw: string): number | null => - Str.isEmpty(Str.trim(raw)) ? null : Number(Str.trim(raw)) - -export const nameError = (name: string): Option.Option => - Str.isEmpty(Str.trim(name)) - ? Option.some("Name the chain") - : isName(Str.trim(name)) - ? Option.none() - : Option.some("Keep the name to 120 characters") - -export const descriptionError = (description: string): Option.Option => - isDescription(Str.trim(description)) - ? Option.none() - : Option.some("Keep the description to 500 characters") - -export const waitError = (raw: string): Option.Option => - isWait(numberOf(raw)) ? Option.none() : Option.some(`Wait 1 to ${MAX_ESCALATION_WAIT_MINUTES} minutes`) - -export const repeatError = (raw: string): Option.Option => - isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) - -export const targetsError = (targets: ReadonlyArray): Option.Option => - targets.length === 0 - ? Option.some("Pick at least one destination, person or schedule") - : targets.length > MAX_ESCALATION_STEP_TARGETS - ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets`) - : Option.none() - -/** A target as a menu value and a row key: `destination:`, `user:`, `schedule:`. */ -export const targetKey = (target: V2EscalationTarget): string => { - switch (target.type) { - case "destination": - return `destination:${target.destination_id}` - case "user": - return `user:${target.user_id}` - case "schedule": - return `schedule:${target.schedule_id}` - } -} - -const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) -const decodeUserId = Schema.decodeUnknownOption(UserId) -const decodeScheduleId = Schema.decodeUnknownOption(OnCallScheduleId) - -/** The target a menu value names, if it is well formed. */ -export const targetOfKey = (key: string): Option.Option => { - const [kind, ...rest] = Str.split(":")(key) - const id = Arr.join(rest, ":") - switch (kind) { - case "destination": - return Option.map(decodeDestinationId(id), (destination_id): V2EscalationTarget => ({ - type: "destination", - destination_id, - })) - case "user": - return Option.map(decodeUserId(id), (user_id): V2EscalationTarget => ({ type: "user", user_id })) - case "schedule": - return Option.map(decodeScheduleId(id), (schedule_id): V2EscalationTarget => ({ - type: "schedule", - schedule_id, - })) - default: - return Option.none() - } -} - -export const stepsError = (steps: ReadonlyArray): Option.Option => - steps.length === 0 - ? Option.some("Add at least one step") - : steps.length > MAX_ESCALATION_STEPS - ? Option.some(`A chain has at most ${MAX_ESCALATION_STEPS} steps`) - : Option.none() - -const stepOf = (step: StepDraft): Option.Option => { - const targets = step.targets - const minutes = numberOf(step.waitMinutes) - return Arr.isReadonlyArrayNonEmpty(targets) && - Option.isNone(targetsError(targets)) && - minutes !== null && - isWait(minutes) - ? Option.some({ targets, wait_minutes: minutes }) - : Option.none() -} - -/** The create payload the form describes, or none while any field is wrong. */ -export const payloadOf = (model: Model): Option.Option => { - const steps = Option.all(Arr.map(model.steps, stepOf)) - const repeat = numberOf(model.repeatCount) - if ( - Option.isSome(nameError(model.name)) || - Option.isSome(descriptionError(model.description)) || - Option.isSome(stepsError(model.steps)) || - repeat === null || - !isRepeat(repeat) - ) - return Option.none() - return Option.flatMap(steps, (all) => - Arr.isReadonlyArrayNonEmpty(all) - ? Option.some({ - name: Str.trim(model.name), - description: Str.isEmpty(Str.trim(model.description)) - ? null - : Str.trim(model.description), - steps: all, - repeat_count: repeat, - }) - : Option.none(), - ) -} - -// COMMANDS - -export const SaveEscalationChain = Command.define("SaveEscalationChain", { - args: { - openCount: Schema.Number, - editingId: Schema.NullOr(EscalationChainId), - payload: V2EscalationChainCreateParams, - }, - messages: [Message.CompletedSave], - execute: ({ openCount, editingId, payload }) => - saveEscalationChain(editingId, payload).pipe( - Effect.result, - Effect.map((result) => Message.CompletedSave({ openCount, result })), - ), -}) - -// UPDATE - -const liftDialog = (model: Model, result: ReturnType): Return => ({ - model: { ...model, dialog: result.model }, - commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotDialogMessage({ message })), -}) - -const open = ( - model: Model, - fields: Pick, -): Return => - liftDialog( - { - ...model, - ...fields, - menus: {}, - isSaving: false, - isShowingErrors: false, - saveError: null, - openCount: model.openCount + 1, - }, - Dialog.open(model.dialog), - ) - -export const openNew = (model: Model): Return => - open(model, { - editingId: null, - name: "", - description: "", - steps: [emptyStep(0)], - repeatCount: "0", - nextKey: 1, - }) - -export const openEdit = (model: Model, chain: V2EscalationChain): Return => - open(model, { - editingId: chain.id, - name: chain.name, - description: chain.description ?? "", - steps: Arr.map(chain.steps, (step, index) => ({ - key: `s${index}`, - targets: step.targets, - waitMinutes: String(step.wait_minutes), - })), - repeatCount: String(chain.repeat_count), - nextKey: chain.steps.length, - }) - -const editStep = (model: Model, key: string, edit: (step: StepDraft) => StepDraft): Return => ({ - model: { ...model, steps: Arr.map(model.steps, (step) => (step.key === key ? edit(step) : step)) }, -}) - -const moveStep = (steps: ReadonlyArray, key: string, direction: "up" | "down") => { - const from = Arr.findFirstIndex(steps, (step) => step.key === key) - if (Option.isNone(from)) return steps - const to = direction === "up" ? from.value - 1 : from.value + 1 - const moving = steps[from.value] - const other = steps[to] - if (moving === undefined || other === undefined) return steps - return Arr.map(steps, (step, index) => (index === from.value ? other : index === to ? moving : step)) -} - -const stepMenu = (model: Model, key: string, message: Menu.Message): Return => { - const id = menuIdOf(model, key) - const result = Menu.update(model.menus[key] ?? Menu.init(id), message) - const next: Model = { ...model, menus: { ...model.menus, [key]: result.model } } - const commands = Command.mapMessages(result.commands ?? [], (child) => - Message.GotStepMenuMessage({ key, message: child }), - ) - const picked = Option.flatMap( - result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), - targetOfKey, - ) - // The menu still opens and closes while saving, but a pick no longer edits the step. - return Option.match(model.isSaving ? Option.none() : picked, { - onNone: () => ({ model: next, commands }), - onSome: (target) => ({ - ...editStep(next, key, (step) => - Arr.some(step.targets, (existing) => targetKey(existing) === targetKey(target)) - ? step - : { ...step, targets: [...step.targets, target] }, - ), - commands, - }), - }) -} - -const save = (model: Model): Return => - model.isSaving - ? { model } - : Option.match(payloadOf(model), { - onNone: () => ({ model: { ...model, isShowingErrors: true } }), - onSome: (payload) => ({ - model: { ...model, isSaving: true, isShowingErrors: true, saveError: null }, - commands: [ - SaveEscalationChain({ - openCount: model.openCount, - editingId: model.editingId, - payload, - }), - ], - }), - }) - -/** A form edit is dropped while a save is in flight: the saved chain is what the dialog shows. */ -const unlessSaving = (model: Model, edit: () => Return): Return => (model.isSaving ? { model } : edit()) - -export const update = (model: Model, message: Message): Return => - Message.match(message, { - GotDialogMessage: ({ message: child }) => - // The dialog stays open while a save is in flight. - model.isSaving ? { model } : liftDialog(model, Dialog.update(model.dialog, child)), - ChangedName: ({ value }) => unlessSaving(model, () => ({ model: { ...model, name: value } })), - ChangedDescription: ({ value }) => - unlessSaving(model, () => ({ model: { ...model, description: value } })), - ChangedRepeatCount: ({ value }) => - unlessSaving(model, () => ({ model: { ...model, repeatCount: value } })), - ChangedWait: ({ key, value }) => - unlessSaving(model, () => editStep(model, key, (step) => ({ ...step, waitMinutes: value }))), - ClickedAddStep: () => - unlessSaving(model, () => - model.steps.length >= MAX_ESCALATION_STEPS - ? { model } - : { - model: { - ...model, - steps: [...model.steps, emptyStep(model.nextKey)], - nextKey: model.nextKey + 1, - }, - }, - ), - ClickedRemoveStep: ({ key }) => - unlessSaving(model, () => ({ - model: { ...model, steps: Arr.filter(model.steps, (step) => step.key !== key) }, - })), - ClickedMoveStep: ({ key, direction }) => - unlessSaving(model, () => ({ - model: { ...model, steps: moveStep(model.steps, key, direction) }, - })), - GotStepMenuMessage: ({ key, message: child }) => stepMenu(model, key, child), - ClickedRemoveTarget: ({ key, target }) => - unlessSaving(model, () => - editStep(model, key, (step) => ({ - ...step, - targets: Arr.filter(step.targets, (existing) => targetKey(existing) !== target), - })), - ), - ClickedSave: () => save(model), - CompletedSave: ({ openCount, result }) => - openCount !== model.openCount - ? { model } - : Result.match(result, { - onSuccess: (chain) => ({ - ...liftDialog({ ...model, isSaving: false }, Dialog.close(model.dialog)), - outMessage: OutMessage.SavedChain({ chain, isNew: model.editingId === null }), - }), - onFailure: (failure) => ({ - model: { ...model, isSaving: false, saveError: failure.message }, - }), - }), - }) diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts deleted file mode 100644 index b0bc3107ea..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_STEPS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" -import type { V2EscalationTarget, V2OnCallSchedule } from "@maple/domain/http/v2" -import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" -import { Option, Array as Arr, String as Str } from "effect" -import { Submodel } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" -import { button } from "@maple/views/ui/button" -import { - fieldDescription, - fieldError, - fieldErrorText, - type FieldIds, - fieldLabel, - input, -} from "@maple/views/ui/field" -import { formDialog } from "@maple/views/ui/form-dialog" -import { icon } from "@maple/views/ui/icon" -import { iconButton } from "@maple/views/ui/icon-button" -import type { FieldState } from "@maple/views/ui/input" -import * as Menu from "@maple/views/ui/menu" -import type { MenuEntry } from "@maple/views/ui/menu-view" -import { panel } from "@maple/views/ui/panel" -import { personAvatar } from "@maple/views/ui/person-avatar" -import { hstack, vstack } from "@maple/views/ui/stack" -import { textarea } from "@maple/views/ui/textarea" -import { typography } from "@maple/views/ui/typography" -import { type PickedEntry, pickList } from "../../kit/pick-list" -import { formatWait } from "../shared/escalation-chains" -import { memberName, memberOf, type OrgMember } from "../shared/org-members" -import { - descriptionError, - targetKey, - menuIdOf, - Message, - type Model, - nameError, - numberOf, - repeatError, - type StepDraft, - stepsError, - targetsError, - waitError, -} from "./dialog" - -type H = HtmlBuilder - -export type ViewInputs = Readonly<{ - id: string - /** The synced destinations steps pick from; `null` until they arrive. */ - destinations: ReadonlyArray | null - /** The org's members a step can page; `null` until they load. */ - members: ReadonlyArray | null - /** The on-call schedules a step can page; `null` until they load. */ - schedules: ReadonlyArray | null -}> - -const nameOr = ( - rows: ReadonlyArray | null, - id: string, - fallback: string, -): string => - Option.getOrElse( - Option.map( - Arr.findFirst(rows ?? [], (row) => row.id === id), - (row) => row.name, - ), - () => (rows === null ? fallback : `Unavailable ${Str.toLowerCase(fallback)}`), - ) - -/** A picked target's row: a destination's bell, a member's avatar, a schedule's calendar. */ -const targetEntry = ( - h: H, - inputs: ViewInputs, - key: string, - target: V2EscalationTarget, -): PickedEntry => { - const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) - switch (target.type) { - case "destination": { - const name = nameOr(inputs.destinations, target.destination_id, "Destination") - return { - key: targetKey(target), - label: name, - media: icon(h, "BellIcon", { className: "text-muted-foreground" }), - removeLabel: `Stop notifying ${name}`, - onRemove, - } - } - case "user": { - const name = memberName(inputs.members, target.user_id) - return { - key: targetKey(target), - label: name, - media: personAvatar(h, { - name, - size: "xs", - imageUrl: Option.getOrUndefined( - Option.map(memberOf(inputs.members, target.user_id), (member) => member.imageUrl), - ), - }), - detail: "email and chat DM", - removeLabel: `Stop paging ${name}`, - onRemove, - } - } - case "schedule": { - const name = nameOr(inputs.schedules, target.schedule_id, "Schedule") - return { - key: targetKey(target), - label: name, - media: icon(h, "CalendarIcon", { className: "text-muted-foreground" }), - detail: "whoever is on call", - removeLabel: `Stop paging ${name}`, - onRemove, - } - } - } -} - -/** The "Add target" menu: destinations, people and schedules not yet on the step, by kind. */ -const addEntries = (inputs: ViewInputs, step: StepDraft): ReadonlyArray => { - const picked = new Set(Arr.map(step.targets, targetKey)) - const group = ( - label: string, - items: ReadonlyArray>, - ): ReadonlyArray => { - const open = Arr.filter(items, (item) => !picked.has(item.value)) - return open.length === 0 - ? [] - : [ - { - _tag: "Group", - label, - entries: Arr.map(open, (item): MenuEntry => ({ _tag: "Item", ...item })), - }, - ] - } - return [ - ...group( - "Destinations", - Arr.map(inputs.destinations ?? [], (row) => ({ - value: `destination:${row.id}`, - label: row.name, - })), - ), - ...group( - "People", - Arr.map(inputs.members ?? [], (member) => ({ - value: `user:${member.userId}`, - label: member.name, - })), - ), - ...group( - "Whoever is on call on", - Arr.map(inputs.schedules ?? [], (schedule) => ({ - value: `schedule:${schedule.id}`, - label: schedule.name, - })), - ), - ] -} - -const stateOf = (value: string, isInvalid: boolean): FieldState => ({ - isInvalid, - isDirty: false, - isFilled: value !== "", - isFocused: false, - isTouched: false, -}) - -const idsOf = (id: string, name: string, hasDescription = false): FieldIds => ({ - control: `${id}-${name}`, - label: `${id}-${name}-label`, - error: `${id}-${name}-error`, - ...(hasDescription ? { description: `${id}-${name}-description` } : undefined), -}) - -/** A field's error once a save was tried, or at once for a number typed out of range. */ -const shownError = (model: Model, error: Option.Option, isImmediate = false): Option.Option => - model.isShowingErrors || isImmediate ? error : Option.none() - -const errorLine = ( - h: H, - ids: FieldIds, - state: FieldState, - error: Option.Option, -): ReadonlyArray => - Option.match(error, { - onNone: () => [], - onSome: (text) => [fieldError(h, { ids, state, attributes: [h.Role("alert")] }, [text])], - }) - -const textField = ( - h: H, - options: Readonly<{ - ids: FieldIds - label: string - value: string - placeholder: string - error: Option.Option - isDisabled: boolean - onInput: (value: string) => Message - }>, -): Html => { - const state = stateOf(options.value, Option.isSome(options.error)) - return vstack(h, { gap: "1.5" }, [ - fieldLabel(h, { ids: options.ids, state }, options.label), - input(h, { - ids: options.ids, - state, - isDisabled: options.isDisabled, - attributes: [ - h.Value(options.value), - h.Placeholder(options.placeholder), - h.OnInput(options.onInput), - h.Attribute("autocomplete", "off"), - ], - }), - ...errorLine(h, options.ids, state, options.error), - ]) -} - -/** A short number input on one line with its label. */ -const numberField = ( - h: H, - options: Readonly<{ - ids: FieldIds - label: string - value: string - min: number - max: number - description?: string - error: Option.Option - isDisabled: boolean - onInput: (value: string) => Message - }>, -): Html => { - const state = stateOf(options.value, Option.isSome(options.error)) - return vstack(h, { gap: "1.5" }, [ - hstack(h, { gap: "3", wrap: true }, [ - fieldLabel(h, { ids: options.ids, state, className: "font-normal" }, options.label), - input(h, { - ids: options.ids, - state, - className: "w-20", - isDisabled: options.isDisabled, - attributes: [ - h.Type("number"), - h.Min(String(options.min)), - h.Attribute("max", String(options.max)), - h.Attribute("step", "1"), - h.Value(options.value), - h.OnInput(options.onInput), - ], - }), - ]), - ...(options.description === undefined - ? [] - : [fieldDescription(h, { ids: options.ids, state }, [options.description])]), - ...errorLine(h, options.ids, state, options.error), - ]) -} - -const isOutOfRange = (raw: string, error: Option.Option) => raw !== "" && Option.isSome(error) - -// Numbered, so each step's wait input has its own accessible name. -const waitLabel = (model: Model, index: number): string => - `Step ${index + 1}: ${ - index < model.steps.length - 1 - ? "then wait, in minutes" - : (numberOf(model.repeatCount) ?? 0) > 0 - ? "wait before repeating, in minutes" - : // The run ends as soon as the last step pages, so this wait only matters once it repeats. - "wait before repeating, in minutes (unused unless the chain repeats)" - }` - -const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, index: number): Html => { - const count = model.steps.length - const previous = index === 0 ? undefined : model.steps[index - 1] - const wait = waitError(step.waitMinutes) - const targets = shownError(model, targetsError(step.targets)) - const key = step.key - return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ - hstack(h, { gap: "2", justify: "between" }, [ - hstack(h, { gap: "2", shrink: true }, [ - typography(h, { size: "sm", weight: "medium", className: "shrink-0 whitespace-nowrap" }, [ - `Step ${index + 1}`, - ]), - typography(h, { size: "xs", tone: "muted", truncate: true }, [ - previous === undefined - ? "pages at once" - : Option.isNone(waitError(previous.waitMinutes)) - ? `after ${formatWait(Number(previous.waitMinutes))} without an ack` - : "after the wait above", - ]), - ]), - hstack(h, { gap: "0.5" }, [ - iconButton( - h, - { - label: `Move step ${index + 1} up`, - isDisabled: model.isSaving || index === 0, - attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "up" }))], - }, - [icon(h, "ArrowUpIcon")], - ), - iconButton( - h, - { - label: `Move step ${index + 1} down`, - isDisabled: model.isSaving || index === count - 1, - attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "down" }))], - }, - [icon(h, "ArrowDownIcon")], - ), - iconButton( - h, - { - label: `Remove step ${index + 1}`, - isDisabled: model.isSaving || count === 1, - attributes: [h.OnClick(Message.ClickedRemoveStep({ key }))], - }, - [icon(h, "TrashIcon")], - ), - ]), - ]), - pickList(h, { - entries: Arr.map(step.targets, (target) => targetEntry(h, inputs, key, target)), - menu: model.menus[key] ?? Menu.init(menuIdOf(model, key)), - toMenuMessage: (message) => Message.GotStepMenuMessage({ key, message }), - addEntries: addEntries(inputs, step), - addLabel: "Add destination, person or schedule", - isDisabled: model.isSaving, - context: `step ${index + 1}`, - emptyText: "Pick who this step pages", - }), - ...Option.match(targets, { - onNone: () => [], - onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], - }), - numberField(h, { - ids: idsOf(model.dialog.id, `wait-${key}`), - label: waitLabel(model, index), - value: step.waitMinutes, - min: 1, - max: MAX_ESCALATION_WAIT_MINUTES, - error: shownError(model, wait, isOutOfRange(step.waitMinutes, wait)), - isDisabled: model.isSaving, - onInput: (value) => Message.ChangedWait({ key, value }), - }), - ]) -} - -const stepsSection = (h: H, model: Model, inputs: ViewInputs): Html => - vstack(h, { gap: "3" }, [ - hstack(h, { gap: "2", justify: "between" }, [ - typography(h, { size: "sm", weight: "medium" }, ["Steps"]), - typography(h, { size: "xs", tone: "muted", numeric: true }, [ - `${model.steps.length} of ${MAX_ESCALATION_STEPS}`, - ]), - ]), - ...Arr.map(model.steps, (step, index) => stepBlock(h, model, inputs, step, index)), - ...Option.match(shownError(model, stepsError(model.steps)), { - onNone: () => [], - onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], - }), - button( - h, - { - variant: "outline", - size: "sm", - className: "self-start", - isDisabled: model.isSaving || model.steps.length >= MAX_ESCALATION_STEPS, - attributes: [h.OnClick(Message.ClickedAddStep())], - }, - [icon(h, "PlusIcon"), "Add step"], - ), - ]) - -const descriptionField = (h: H, model: Model): Html => { - const ids = idsOf(model.dialog.id, "description") - const error = shownError(model, descriptionError(model.description)) - const state = stateOf(model.description, Option.isSome(error)) - return vstack(h, { gap: "1.5" }, [ - fieldLabel(h, { ids, state }, "Description"), - textarea(h, { - id: ids.control, - defaultValue: model.description, - isDisabled: model.isSaving, - attributes: [ - // Uncontrolled: keyed on the open, so a reopened dialog rebuilds it with that chain's text. - h.Key(`${ids.control}-${model.openCount}`), - h.AriaLabelledBy(ids.label), - h.Placeholder("Optional: who is on this chain and when to change it"), - h.Rows(2), - h.OnInput((value) => Message.ChangedDescription({ value })), - ], - }), - ...errorLine(h, ids, state, error), - ]) -} - -/** The escalation chain dialog: name, ordered steps of targets and waits, and repeats. */ -export const view = Submodel.defineView((model, inputs, h) => { - const repeat = repeatError(model.repeatCount) - const isEditing = model.editingId !== null - return formDialog(h, { - slotId: `${inputs.id}-dialog`, - model: model.dialog, - toParentMessage: (message) => Message.GotDialogMessage({ message }), - className: "sm:max-w-xl", - title: isEditing ? "Edit escalation chain" : "New escalation chain", - description: - "Each step pages its destinations, people and whoever is on call, then waits for someone to acknowledge before paging the next. Acknowledging pauses the chain and resolving ends it.", - panelClassName: "space-y-5", - fields: [ - textField(h, { - ids: idsOf(model.dialog.id, "name"), - label: "Name", - value: model.name, - placeholder: "Checkout on-call", - error: shownError(model, nameError(model.name)), - isDisabled: model.isSaving, - onInput: (value) => Message.ChangedName({ value }), - }), - descriptionField(h, model), - stepsSection(h, model, inputs), - numberField(h, { - ids: idsOf(model.dialog.id, "repeat", true), - label: "Repeat the whole chain", - value: model.repeatCount, - min: 0, - max: MAX_ESCALATION_REPEATS, - description: `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, - error: shownError(model, repeat, isOutOfRange(model.repeatCount, repeat)), - isDisabled: model.isSaving, - onInput: (value) => Message.ChangedRepeatCount({ value }), - }), - ...(model.saveError === null - ? [] - : [ - alert(h, { variant: "crit", size: "sm" }, [ - icon(h, "CircleXmarkIcon"), - alertTitle(h, [isEditing ? "Couldn't save chain" : "Couldn't create chain"]), - alertDescription(h, [model.saveError]), - ]), - ]), - ], - onSubmit: Message.ClickedSave(), - submitLabel: isEditing ? "Save chain" : "Create chain", - isPending: model.isSaving, - }) -}) 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 f7adc99b7f..529b1e8bc2 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 @@ -1,24 +1,9 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr } from "effect" -import * as Animation from "@foldkit/ui/animation" import { EscalationChainId } from "@maple/domain/http" -import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" -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 { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { V2EscalationChain } from "@maple/domain/http/v2" import type { PageContext } from "../../contract" @@ -61,12 +46,6 @@ const onRouting = (grants: ReturnType, chains: ReadonlyA ) } -const openDialog = () => [ - Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation: 1 })), - Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation: 1 })), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), -] - describe("alerts hub, escalation chains", () => { test("an org without chains learns what one is and can create the first", () => { scene( @@ -74,7 +53,10 @@ describe("alerts hub, escalation chains", () => { given(onRouting(ENGINEER, [])), expectView(text("No escalation chains yet")).toExist(), expectView(text(/waits for someone to acknowledge, then pages the next/u)).toExist(), - expectView(role("button", { name: "Create chain" })).toExist(), + expectView(role("link", { name: "Create chain" })).toHaveAttr( + "href", + "/alerts/escalation-chains/new", + ), ) }) @@ -91,7 +73,11 @@ describe("alerts hub, escalation chains", () => { expectView(text("Used by 2 rules")).toExist(), expectView(text("Not used by any rule")).toExist(), expectView(role("button", { name: "Actions for Checkout on-call" })).toExist(), - expectView(role("button", { name: "New chain" })).toExist(), + expectView(role("link", { name: "New chain" })).toHaveAttr( + "href", + "/alerts/escalation-chains/new", + ), + expectView(role("link", { name: "Checkout on-call" })).toExist(), ) }) @@ -107,7 +93,7 @@ describe("alerts hub, escalation chains", () => { scene( { update: pageUpdate, view }, given(onRouting(MEMBER, [])), - expectView(role("button", { name: "Create chain" })).not.toExist(), + expectView(role("link", { name: "Create chain" })).not.toExist(), expectView(text(/Ask an org admin to create one/u)).toExist(), ) scene( @@ -117,18 +103,19 @@ describe("alerts hub, escalation chains", () => { ) }) - test("saving a new chain without a name or destinations shows what is missing and sends nothing", () => { - scene( - { update: pageUpdate, view }, - given(onRouting(ENGINEER, CHAINS)), - click(role("button", { name: "New chain" })), - ...openDialog(), - expectView(role("dialog", { name: "New escalation chain" })).toExist(), - submit(selector("form")), - Command.expectNone(), - expectView(text("Name the chain")).toExist(), - expectView(text("Pick at least one destination, person or schedule")).toExist(), + test("Edit in a chain's menu opens its editor page", () => { + const chainId = Schema.decodeSync(EscalationChainId)(chainUuid(1)) + const picked = pageUpdate( + onRouting(ENGINEER, CHAINS), + Message.GotRowMenuMessage({ + menuId: chainMenuId(chainId), + message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value: "edit" } }), + }), ) + expect(picked.outMessage).toMatchObject({ + _tag: "RequestedNavigation", + url: expect.stringMatching(/^\/alerts\/escalation-chains\/esc_/u), + }) }) test("a refused delete stays open and names the rules that still start the chain", () => { diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 75a499ae12..12b9a4da52 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -30,7 +30,6 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" -import * as ChainDialog from "../escalation-dialog/dialog" import { OrgMember } from "../shared/org-members" import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" @@ -110,8 +109,6 @@ export const Message = defineMessageUnion({ ClickedRetrySlos: {}, CompletedFetchChains: { key: Schema.String, result: outcome(Schema.Array(V2EscalationChain)) }, ClickedRetryChains: {}, - ClickedNewChain: {}, - GotChainDialogMessage: { message: ChainDialog.Message }, ClickedConfirmDeleteChain: {}, CompletedDeleteChain: { chainId: EscalationChainId, result: outcome(Schema.Void) }, GotDeleteChainDialogMessage: { message: Dialog.Message }, diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index fb584a2b40..43a02b27c9 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -32,7 +32,6 @@ 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" -import * as ChainDialog from "../escalation-dialog/dialog" import { OrgMember } from "../shared/org-members" import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" @@ -106,7 +105,6 @@ export const Model = Schema.Struct({ slos: KeyedSchema(Schema.Array(SloListRow), LoadFailure), /** Routing tab: `escalationChains.list`, read only while the tab is open. */ chains: KeyedSchema(Schema.Array(V2EscalationChain), LoadFailure), - chainDialog: ChainDialog.Model, /** The chain awaiting delete confirmation, the delete in flight, and why the last one was refused. */ confirmDeleteChainId: Schema.NullOr(EscalationChainId), deletingChainId: Schema.NullOr(EscalationChainId), diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 89fb77bfeb..082f2296d0 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -23,13 +23,12 @@ import type { AppRoute } from "../../../route" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" -import * as ChainDialog from "../escalation-dialog/dialog" import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { isCancelledSilence } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" -import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT } from "../shared/escalation-chains" +import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT, chainHref } from "../shared/escalation-chains" import { editScheduleHref, handoffMs, @@ -89,7 +88,6 @@ const DELETE_DIALOG_ID = "alerts-delete-destination" const DELETE_MONITOR_DIALOG_ID = "alerts-delete-monitor" const DELETE_CHAIN_DIALOG_ID = "alerts-delete-chain" export const DELETE_SCHEDULE_DIALOG_ID = "alerts-delete-schedule" -export const CHAIN_DIALOG_ID = "alerts-escalation-chain" export const NEW_RULE_MENU_ID = "alerts-new-rule-menu" export const WINDOW_DIALOG_ID = "alerts-maintenance-window" const DELETE_WINDOW_DIALOG_ID = "alerts-delete-window" @@ -337,7 +335,6 @@ export const init = (route: HubRoute, context: PageContext): Return => { deleteMonitorDialog: Dialog.init(DELETE_MONITOR_DIALOG_ID, "alert-dialog"), slos: Keyed.idle(), chains: Keyed.idle(), - chainDialog: ChainDialog.init(CHAIN_DIALOG_ID), confirmDeleteChainId: null, deletingChainId: null, deleteChainError: null, @@ -503,28 +500,6 @@ const refreshChains = (model: Model, context: PageContext): Return => { return { model: schedules.model, commands: [...chains.commands, ...schedules.commands] } } -/** The chain dialog's outcome: a toast, and the list read again. */ -const liftChainDialog = ( - model: Model, - context: PageContext, - step: ReturnType, -): Return => { - const lifted: Model = { ...model, chainDialog: step.model } - const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => - Message.GotChainDialogMessage({ message }), - ) - if (step.outMessage === undefined) return { model: lifted, commands } - const refreshed = refreshChains(lifted, context) - return { - model: refreshed.model, - commands: [...commands, ...(refreshed.commands ?? [])], - outMessage: toast( - "success", - step.outMessage.isNew ? "Escalation chain created" : "Escalation chain saved", - ), - } -} - /** The names of the rules that start `chain` that this tab has loaded; the rest are counted, not named. */ export const chainRuleNames = ( model: Pick, @@ -575,7 +550,7 @@ const updateDeleteChainDialog = (model: Model, step: ReturnType +const pickedChainAction = (model: Model, rawId: string, value: string): Return => Option.match( Array.findFirst(chainsOf(model), (chain) => chain.id === rawId), { @@ -591,7 +566,13 @@ const pickedChainAction = (model: Model, context: PageContext, rawId: string, va }, Dialog.open(model.deleteChainDialog), ) - : liftChainDialog(model, context, ChainDialog.openEdit(model.chainDialog, chain)), + : { + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: chainHref(chain.id), + isReplace: false, + }), + }, }, ) @@ -1036,7 +1017,7 @@ const pickedFromMenu = (model: Model, context: PageContext, menuId: string, valu const [kind, ...rest] = Str.split(":")(menuId) const target = Array.join(rest, ":") if (kind === "monitor") return pickedMonitorAction(model, target, value) - if (kind === "chain") return pickedChainAction(model, context, target, value) + if (kind === "chain") return pickedChainAction(model, target, value) if (kind === "schedule") return pickedScheduleAction(model, target, value) if (kind === "window") return pickedWindowAction(model, context, target, value) if (kind === "rule") { @@ -1295,9 +1276,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re } }, ClickedRetryChains: () => refreshChains(model, context), - ClickedNewChain: () => liftChainDialog(model, context, ChainDialog.openNew(model.chainDialog)), - GotChainDialogMessage: ({ message: child }) => - liftChainDialog(model, context, ChainDialog.update(model.chainDialog, child)), ClickedConfirmDeleteChain: () => confirmDeleteChain(model), CompletedDeleteChain: ({ chainId, result }) => deletedChain(model, context, chainId, result), GotDeleteChainDialogMessage: ({ message: child }) => diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index b533835c0e..f98f0af2a7 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -6,7 +6,7 @@ import { Array as Arr, Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { borderedBox } from "@maple/views/ui/bordered-box" -import { button } from "@maple/views/ui/button" +import { linkButton } from "@maple/views/ui/button" import { confirmDialog } from "@maple/views/ui/confirm-dialog" import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" import { errorState } from "@maple/views/ui/error-state" @@ -18,10 +18,11 @@ import { emptyActions } from "@maple/views/ui/signal-empty-state" import { skeleton } from "@maple/views/ui/skeleton" import { hstack, vstack } from "@maple/views/ui/stack" import { tooltipTrigger } from "@maple/views/ui/tooltip-host" +import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { sectionHeading } from "../../kit/section-heading" -import { chainSummary } from "../shared/escalation-chains" +import { chainHref, chainSummary, newChainHref } from "../shared/escalation-chains" import { memberName } from "../shared/org-members" import { Message } from "./message" import { canManage, type Model } from "./model" @@ -89,7 +90,9 @@ export const chainRow = ( hstack(h, { gap: "3", align: "start" }, [ rowIcon(h, "BellIcon"), vstack(h, { gap: "0.5", shrink: true, grow: true }, [ - typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [chain.name]), + typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [ + textLink(h, { href: chainHref(chain.id), tone: "inherit" }, [chain.name]), + ]), typography( h, { @@ -150,7 +153,7 @@ const chainsEmpty = (h: H, model: Model): Html => ...(canManage(model) ? [ emptyActions(h, [ - button(h, { size: "sm", attributes: [h.OnClick(Message.ClickedNewChain())] }, [ + linkButton(h, { size: "sm", href: newChainHref() }, [ icon(h, "PlusIcon"), "Create chain", ]), @@ -211,15 +214,10 @@ export const chainsSection = (h: H, model: Model): Html => title: "Escalation chains", actions: canManage(model) && hasChains(model) - ? button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewChain())], - }, - [icon(h, "PlusIcon"), "New chain"], - ) + ? linkButton(h, { size: "sm", variant: "outline", href: newChainHref() }, [ + icon(h, "PlusIcon"), + "New chain", + ]) : undefined, }), typography(h, { size: "sm", tone: "muted", as: "p" }, [ diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index e9e8dd6843..75e3c07ccc 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -2,7 +2,7 @@ import { type AlertIncidentId, isManagedSignalType } from "@maple/domain/http" import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" import { makeClientErrorBody } from "@maple/web-data/lib/error-messages" import { Option, Array as Arr } from "effect" -import { AsyncData, Submodel } from "foldkit" +import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { button, linkButton } from "@maple/views/ui/button" import { splitButtonClass } from "@maple/views/ui/split-button" @@ -36,9 +36,8 @@ import { anomalyInbox, inboxFilters } from "./view-anomalies" import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" -import * as ChainDialogView from "../escalation-dialog/view" import { deleteChainDialog } from "./view-chains" -import { CHAIN_DIALOG_ID, WINDOW_DIALOG_ID } from "./update" +import { WINDOW_DIALOG_ID } from "./update" import * as SilenceDialogView from "../silence-dialog/view" import { silencesTab } from "./view-silences" import { deleteWindowDialog, hasWindows, maintenanceTab } from "./view-maintenance" @@ -380,19 +379,6 @@ export const view = Submodel.defineView((model, deleteChainDialog(h, model), deleteScheduleDialog(h, model), deleteWindowDialog(h, model), - h.submodel({ - slotId: CHAIN_DIALOG_ID, - model: model.chainDialog, - view: ChainDialogView.view, - viewInputs: { - id: CHAIN_DIALOG_ID, - destinations: model.destinations, - members: model.members, - // `null` until the schedules load (or after a failed read): names read neutrally. - schedules: Option.getOrNull(AsyncData.getData(model.schedules.data)), - }, - toParentMessage: (message) => Message.GotChainDialogMessage({ message }), - }), h.submodel({ slotId: "alerts-destination", model: model.destinationDialog, diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index c2c01db6b0..4926ab9812 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -5,16 +5,18 @@ import type { OnCallScheduleId, UserId, } from "@maple/domain/http" -import type { - V2AlertIncidentEscalation, - V2EscalationChain, - V2EscalationChainCreateParams, +import { + EscalationChainPublicId, + type V2AlertIncidentEscalation, + type V2EscalationChain, + type V2EscalationChainCreateParams, } from "@maple/domain/http/v2" import { formatAlertTimeNear } from "@maple/web-data/alerts/form-utils" -import { Array as Arr, Effect, Option } from "effect" +import { Array as Arr, Effect, Option, Schema, String as Str } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" +import type { AlertsEscalationChainSearch } from "../../../route/search" // Escalation chain reads and writes as Effects the pages' Commands wrap, and how a chain and an // incident's run read in one line. @@ -31,6 +33,12 @@ export const listEscalationChains = Effect.gen(function* () { return page.data }).pipe(Effect.mapError(toLoadFailure)) +export const retrieveEscalationChain = (id: EscalationChainId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.escalationChains.retrieve({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + /** Creates a chain, or replaces `editingId`'s fields with the form's. */ export const saveEscalationChain = ( editingId: EscalationChainId | null, @@ -62,6 +70,27 @@ export const retrieveIncidentEscalation = (id: AlertIncidentId) => export const routingTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "routing", search: {} })) +const encodeChainId = Schema.encodeSync(EscalationChainPublicId) +const decodeChainId = Schema.decodeUnknownOption(EscalationChainPublicId) + +/** The `esc_…` id in a URL as the internal id. */ +export const chainIdFromUrl = (raw: string): Option.Option => decodeChainId(raw) + +export const chainHref = ( + id: EscalationChainId, + search: typeof AlertsEscalationChainSearch.Type = {}, +): string => hrefOf(AppRoute.AlertsEscalationChain({ chainId: encodeChainId(id), search })) + +export const newChainHref = (search: typeof AlertsEscalationChainSearch.Type = {}): string => + hrefOf(AppRoute.AlertsEscalationChainNew({ search })) + +/** A `returnTo` the editor may follow: an in-app path only, never another origin. */ +export const safeReturnTo = (raw: string | undefined): Option.Option => + Option.filter( + Option.fromNullishOr(raw), + (path) => Str.startsWith("/")(path) && !Str.startsWith("//")(path) && !Str.includes("\\")(path), + ) + // SUMMARY /** `10m`, `1h`, `1h 30m`, `1d`. */ @@ -97,8 +126,18 @@ export type TargetNames = Readonly<{ * One line for a chain: "Slack #alerts-api, then PagerDuty after 10m, then email after 15m; * repeats twice". Each step waits for an ack before the next, so a step's delay is the one before it. */ +export type ChainOutline = Readonly<{ + steps: ReadonlyArray< + Readonly<{ + targets: ReadonlyArray + wait_minutes: number + }> + > + repeat_count: number +}> + export const chainSummary = ( - chain: Pick, + chain: ChainOutline, nameOf: (id: AlertDestinationId) => string, people: TargetNames = {}, ): string => { diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 4c826a97ad..1deb10a79a 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -31,6 +31,7 @@ import * as AlertsSlo from "./alerts/slo" import * as AlertsSloNew from "./alerts/slo-form" import * as AlertsSchedule from "./alerts/schedule" import * as AlertsScheduleNew from "./alerts/schedule-form" +import * as AlertsEscalationChain from "./alerts/escalation-chain" import * as IngestRuleEditor from "./settings/ingest-rule" import * as DashboardsList from "./dashboards/list" import * as DashboardTemplates from "./dashboards/templates" @@ -126,6 +127,7 @@ export const pages = [ AlertsSloNew.page, AlertsSchedule.page, AlertsScheduleNew.page, + AlertsEscalationChain.page, StatusPagesList.page, StatusPageBuilder.page, StatusPageEditor.page, diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 4d03b0a0b6..47969e2b1a 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -37,6 +37,8 @@ export const AppRoute = defineRouteUnion({ AlertsSlo: { sloId: Schema.String, search: Search.AlertsSloSearch }, AlertsScheduleNew: { search: Search.AlertsScheduleNewSearch }, AlertsSchedule: { scheduleId: Schema.String, search: Search.AlertsScheduleSearch }, + AlertsEscalationChainNew: { search: Search.AlertsEscalationChainSearch }, + AlertsEscalationChain: { chainId: Schema.String, search: Search.AlertsEscalationChainSearch }, IngestRuleNew: { search: Search.IngestRuleNewSearch }, IngestRule: { ruleId: Schema.String }, AlertsStatusPage: { pageId: Schema.String, search: Search.AlertsStatusPageSearch }, diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index e1e46c1249..f6dc38312a 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -81,6 +81,11 @@ describe("routes", () => { 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/escalation-chains/new")._tag).toBe("AlertsEscalationChainNew") + expect(parse("/alerts/escalation-chains/esc_1")).toMatchObject({ + _tag: "AlertsEscalationChain", + chainId: "esc_1", + }) 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") diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 02f882e357..5b8d1b5536 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -121,6 +121,17 @@ const routers = { search(Search.AlertsScheduleSearch), Route.mapTo(AppRoute.AlertsSchedule), ), + AlertsEscalationChainNew: pipe( + path("alerts", "escalation-chains", "new"), + search(Search.AlertsEscalationChainSearch), + Route.mapTo(AppRoute.AlertsEscalationChainNew), + ), + AlertsEscalationChain: pipe( + path("alerts", "escalation-chains"), + slash(segment("chainId")), + search(Search.AlertsEscalationChainSearch), + Route.mapTo(AppRoute.AlertsEscalationChain), + ), IngestRuleNew: pipe( path("settings", "ingestion", "rules", "new"), search(Search.IngestRuleNewSearch), @@ -542,6 +553,8 @@ export const urlToAppRoute = Route.parseUrlWithFallback( routers.AlertsSlo, routers.AlertsScheduleNew, routers.AlertsSchedule, + routers.AlertsEscalationChainNew, + routers.AlertsEscalationChain, routers.IngestRuleNew, routers.IngestRule, routers.AlertsStatusPage, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 80ab3c0dd5..eaddd00b69 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -380,6 +380,11 @@ export const AlertsScheduleNewSearch = Schema.Struct({ export const AlertsScheduleSearch = Schema.Struct({}) +/** `/alerts/escalation-chains/new` and `/:chainId`: `returnTo` is where Cancel and Save go back to. */ +export const AlertsEscalationChainSearch = Schema.Struct({ + returnTo: Schema.optional(Schema.String), +}) + /** `/settings/ingestion/rules/new`: `template` prefills the editor from an empty-state template. */ export const IngestRuleNewSearch = Schema.Struct({ template: Schema.optional(Schema.String), diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index d41ccc49d9..eb8c3693d1 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -173,7 +173,13 @@ const alertsItem: NavItem = { alsoActive: ["/alerts/schedules"], startsGroup: true, }, - { title: "Routing", href: "/alerts/routing", icon: "BranchForkIcon", paletteTitle: "Alert routing" }, + { + title: "Routing", + href: "/alerts/routing", + icon: "BranchForkIcon", + paletteTitle: "Alert routing", + alsoActive: ["/alerts/escalation-chains"], + }, ], } diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index ae98087bc1..27a543acf9 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -46,6 +46,8 @@ const SAMPLE_PATHS = [ "/alerts/slos/slo_YofPTrK9782DWwcnXhpcCw", "/alerts/schedules/new", "/alerts/schedules/onc_gQtGH2s65G6a7sJmuayXL6", + "/alerts/escalation-chains/new", + "/alerts/escalation-chains/esc_gQtGH2s65G6a7sJmuayXL6", "/alerts/uptime/upm_abc", "/status-pages", "/status-pages/new", From f96ab12dd862692b4ebfddb1c042f461606f3921 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:53:38 +0100 Subject: [PATCH 07/35] feat(alerts): escalation chain editor lab /lab/escalation-editor renders the editor page over fixtures: new, populated, a step inserted, invalid, and the delete confirm. --- apps/web/src/lab/entries/escalation-editor.ts | 16 ++ apps/web/src/lab/escalation-editor-lab.ts | 145 ++++++++++++++++++ apps/web/src/lab/registry.ts | 10 +- 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lab/entries/escalation-editor.ts create mode 100644 apps/web/src/lab/escalation-editor-lab.ts diff --git a/apps/web/src/lab/entries/escalation-editor.ts b/apps/web/src/lab/entries/escalation-editor.ts new file mode 100644 index 0000000000..e2f1ac07c1 --- /dev/null +++ b/apps/web/src/lab/entries/escalation-editor.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../escalation-editor-lab" + +// `/lab/escalation-editor`: the escalation chain editor page over fixtures (`?state=`). + +export const lab = defineShellLab("Escalation chain editor", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/escalation-editor", + breadcrumbs, +}) diff --git a/apps/web/src/lab/escalation-editor-lab.ts b/apps/web/src/lab/escalation-editor-lab.ts new file mode 100644 index 0000000000..307fd958cc --- /dev/null +++ b/apps/web/src/lab/escalation-editor-lab.ts @@ -0,0 +1,145 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { EscalationChainId } from "@maple/domain/http" +import { EscalationChainPublicId } from "@maple/domain/http/v2" +import { Effect, Option, Result, Schema, Array as Arr } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as ChainMessage from "../page/alerts/escalation-chain/message" +import * as ChainModel from "../page/alerts/escalation-chain/model" +import { init as chainInit, update as chainUpdate } from "../page/alerts/escalation-chain/update" +import { chainTitle, view as chainView } from "../page/alerts/escalation-chain/view" +import { routingTabHref } from "../page/alerts/shared/escalation-chains" +import { + chainDestinations, + chainDestinationUuid, + chainUuid, + populatedChains, +} from "../page/alerts/shared/escalation-test-fixtures" +import { ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import type { PageContext } from "../page/contract" +import type { Resources } from "../platform/resources" +import { keepLocal } from "./local-commands" + +// `/lab/escalation-editor`: the escalation chain editor page over fixtures. `?state=` picks one. + +export const LabState = Schema.Literals(["new", "populated", "inserted", "invalid", "delete"]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "populated", + ), +})) + +export const Model = Schema.Struct({ state: LabState, page: ChainModel.Model }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotPageMessage: { message: ChainMessage.Message } }) +export type Message = typeof Message.Type + +type Return = Update.Return +type PageCommands = ReadonlyArray> + +const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const RULES = [ + ruleRow(1, { name: "Checkout error rate" }), + ruleRow(2, { name: "Checkout p95 latency" }), + ruleRow(3, { name: "Payments webhook failures" }), +] +const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) +const M = ChainMessage.Message + +const NO_COMMANDS: PageCommands = [] + +const run = (model: ChainModel.Model, messages: ReadonlyArray) => + Arr.reduce(messages, { model, commands: NO_COMMANDS }, (current, message) => { + const next = chainUpdate(current.model, message, CONTEXT) + return { model: next.model, commands: [...current.commands, ...keepLocal(next.commands)] } + }) + +const pageFor = (state: LabState) => { + const isNew = state === "new" || state === "invalid" + const chainId = Schema.encodeSync(EscalationChainPublicId)( + Schema.decodeSync(EscalationChainId)(chainUuid(1)), + ) + const entered = chainInit( + isNew + ? { _tag: "AlertsEscalationChainNew", search: {} } + : { _tag: "AlertsEscalationChain", chainId, search: {} }, + CONTEXT, + ).model + const base = [ + M.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), + M.CompletedFetchMembers({ result: Result.succeed([]) }), + M.CompletedFetchSchedules({ result: Result.succeed([]) }), + M.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + M.UpdatedRules({ orgId: ORG, rows: RULES }), + ] + const loaded = isNew + ? [] + : Arr.map(Arr.take(CHAINS, 1), (chain) => + M.CompletedFetchExisting({ key: entered.existing.key ?? "", result: Result.succeed(chain) }), + ) + const extra: ReadonlyArray = + state === "invalid" + ? [M.ChangedWait({ key: "s0", value: "2000" }), M.ClickedSave()] + : state === "inserted" + ? [ + M.ClickedInsertStep({ index: 1 }), + M.GotMenuMessage({ + kind: "targets", + key: "s3", + message: { + _tag: "ClickedItem", + index: 0, + item: { kind: "Item", value: `destination:${chainDestinationUuid(3)}` }, + }, + }), + ] + : state === "delete" + ? [M.ClickedDelete()] + : [] + return run(entered, [...base, ...loaded, ...extra]) +} + +export const init = (flags: Flags): Return => { + const page = pageFor(flags.state) + return { + model: { state: flags.state, page: page.model }, + commands: Command.mapMessages(page.commands, (message) => Message.GotPageMessage({ message })), + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotPageMessage: ({ message: child }) => { + const next = chainUpdate(model.page, child, CONTEXT) + return { + model: { ...model, page: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotPageMessage({ message: inner }), + ), + } + }, + }) + +export const breadcrumbs = (model: Model) => [ + { label: "Alerts", href: "/alerts" }, + { label: "Routing", href: routingTabHref() }, + { label: chainTitle(model.page) }, +] + +export const view = (model: Model, h: HtmlBuilder): Html => + h.submodel({ + slotId: "lab-escalation-editor", + model: model.page, + view: chainView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: ChainMessage.Message) => Message.GotPageMessage({ message }), + }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 843e1bdbb2..fbd3e687f8 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -73,7 +73,15 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/escalation", title: "Escalation chains", description: - "The Routing tab's escalation chains empty and populated, the chain dialog new, editing, invalid and failing, a rule tier's chain picker, and the incident rail escalating, paused or finished (`?state=`).", + "The Routing tab's escalation chains empty and populated, a rule tier's chain picker, and the incident rail escalating, paused or finished (`?state=`).", + kind: "lab", + session: "none", + }, + { + path: "/lab/escalation-editor", + title: "Escalation chain editor", + description: + "The escalation chain editor page new, populated, with a step inserted, invalid and asking to delete (`?state=`).", kind: "lab", session: "none", }, From ef301707e6e647cadfdd939c6c15e5e91e4c2a1f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:55:35 +0100 Subject: [PATCH 08/35] test(shell): lab on-call fixture no longer expires on 16 Nov 2026 --- apps/web/src/lab/shell-states-lab.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 6d017f71c7..9e78acc414 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -37,8 +37,8 @@ export type OnCallFixture = typeof OnCallFixture.Type export const StandOn = Schema.Literals(["lab", "alerts"]) export type StandOn = typeof StandOn.Type -/** Mon 16 Nov 2026, 08:00 UTC: 09:00 CET for a Berlin viewer. */ -const HANDOFF_MS = Date.UTC(2026, 10, 16, 8) +/** Mon 16 Nov 2099, 08:00 UTC (09:00 CET): far enough out that the fixture shift never ends. */ +const HANDOFF_MS = Date.UTC(2099, 10, 16, 8) const LAB_USER = "user_lab" From 176023c8475af4e07d48bc5ef105629fb4d55d91 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 01:45:16 +0100 Subject: [PATCH 09/35] fix(alerts): escalation chain editor review follow-ups Safety and state: - Drop returnTo and its helper: no caller passed it, and it could be steered off-site. - Ask before leaving with unsaved edits (link, Back or Cancel). A clean form, or one put back to how it was, leaves at once. - Save and Create go back to Routing with the toast, so the list shows the saved name and rules. - A chain deleted elsewhere reads as not found with the way back, and a save that hits it says the chain no longer exists. - A delete the API refuses reads the chain again, so the dialog names the rule that took it and holds its Delete. - Viewers without alerts:manage read the chain and who uses it; nothing can be edited, saved or deleted. Nothing renders until the session answers. - Step edits move focus: a new or duplicated step to its Add button, a removed step to the one before it or the leading +. - Remove the unused step messages. Layout: - A step is two lines: "Notify" with its targets as inline chips and an Add chip, then the wait. A three-step chain fits in 800px. - Targets draw their own mark: the destination's logo, a person's avatar, a schedule's calendar with who is on call now. - The last step of a chain that does not repeat reads "then stop". - The picker says what is still loading and where to make a destination; someone removed from the org reads "Unavailable member". - The connector is a 24px line whose + shows on hover, focus and touch. - Delete is a quiet destructive button under the details; a paused rule says so in Used by. Kit: pickChips, chainConnector, and a destructive-ghost button variant. --- apps/web/src/lab/entries/kit/buttons.ts | 18 +- apps/web/src/lab/escalation-editor-lab.ts | 112 +++- apps/web/src/lab/registry.ts | 2 +- .../page/alerts/escalation-chain/command.ts | 10 +- .../escalation-chain.scene.test.ts | 321 ++++++++-- .../page/alerts/escalation-chain/form.test.ts | 16 +- .../src/page/alerts/escalation-chain/form.ts | 12 +- .../src/page/alerts/escalation-chain/index.ts | 4 +- .../page/alerts/escalation-chain/message.ts | 9 +- .../src/page/alerts/escalation-chain/model.ts | 11 +- .../page/alerts/escalation-chain/update.ts | 289 ++++++--- .../src/page/alerts/escalation-chain/view.ts | 556 +++++++++++------- .../page/alerts/shared/escalation-chains.ts | 20 +- .../web/src/page/alerts/shared/org-members.ts | 9 +- apps/web/src/page/kit/chain-connector.ts | 40 ++ apps/web/src/page/kit/pick-chips.ts | 128 ++++ apps/web/src/route/search.ts | 6 +- .../ui/src/components/ui/button.styles.ts | 3 + packages/views/src/ui/INDEX.md | 2 + 19 files changed, 1136 insertions(+), 432 deletions(-) create mode 100644 apps/web/src/page/kit/chain-connector.ts create mode 100644 apps/web/src/page/kit/pick-chips.ts diff --git a/apps/web/src/lab/entries/kit/buttons.ts b/apps/web/src/lab/entries/kit/buttons.ts index 8e112e463b..7654482579 100644 --- a/apps/web/src/lab/entries/kit/buttons.ts +++ b/apps/web/src/lab/entries/kit/buttons.ts @@ -31,6 +31,7 @@ const VARIANTS = [ "ghost", "destructive", "destructive-outline", + "destructive-ghost", "link", "dashed", ] as const @@ -417,16 +418,27 @@ const actionBars = (h: HtmlBuilder): ReadonlyArray => [ statusDot(h, { tone: "live" }), typography(h, { size: "sm", tone: "foreground" }, ["Unsaved changes"]), ], - actions: [button(h, { variant: "ghost", size: "sm" }, ["Discard"]), button(h, { size: "sm" }, ["Save"])], + actions: [ + button(h, { variant: "ghost", size: "sm" }, ["Discard"]), + button(h, { size: "sm" }, ["Save"]), + ], }), ]), kitRow(h, "Editor, with a notice", [ actionBar(h, { label: "Rule actions", className: "static w-full", - notice: [alert(h, { variant: "crit", size: "sm" }, [icon(h, "CircleXmarkIcon"), alertTitle(h, ["Couldn't save the rule"])])], + notice: [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't save the rule"]), + ]), + ], leading: [typography(h, { size: "sm", tone: "muted" }, ["Drop health checks from api-gateway"])], - actions: [button(h, { variant: "outline", size: "sm" }, ["Cancel"]), button(h, { size: "sm" }, ["Save changes"])], + actions: [ + button(h, { variant: "outline", size: "sm" }, ["Cancel"]), + button(h, { size: "sm" }, ["Save changes"]), + ], }), ]), kitRow(h, "Step flow", [ diff --git a/apps/web/src/lab/escalation-editor-lab.ts b/apps/web/src/lab/escalation-editor-lab.ts index 307fd958cc..2ab2679f7e 100644 --- a/apps/web/src/lab/escalation-editor-lab.ts +++ b/apps/web/src/lab/escalation-editor-lab.ts @@ -1,8 +1,8 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" -import { EscalationChainId } from "@maple/domain/http" +import { OnCallScheduleId } from "@maple/domain/http" import { EscalationChainPublicId } 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" @@ -13,10 +13,11 @@ import { chainTitle, view as chainView } from "../page/alerts/escalation-chain/v import { routingTabHref } from "../page/alerts/shared/escalation-chains" import { chainDestinations, - chainDestinationUuid, - chainUuid, + chainOf, + chainScheduleUuid, populatedChains, } from "../page/alerts/shared/escalation-test-fixtures" +import { memberId, oncallMembers, populatedSchedules } from "../page/alerts/shared/oncall-test-fixtures" import { ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" @@ -24,7 +25,16 @@ import { keepLocal } from "./local-commands" // `/lab/escalation-editor`: the escalation chain editor page over fixtures. `?state=` picks one. -export const LabState = Schema.Literals(["new", "populated", "inserted", "invalid", "delete"]) +export const LabState = Schema.Literals([ + "new", + "populated", + "inserted", + "invalid", + "delete", + "leave", + "readonly", + "long", +]) export type LabState = typeof LabState.Type export const Flags = Schema.Struct({ state: LabState }) @@ -49,12 +59,58 @@ type PageCommands = ReadonlyArray = [ + ["Slack", "chat", "slack"], + ["PagerDuty", "pagerduty"], + ["Email", "email"], + ["Discord", "discord"], + ["Microsoft Teams", "microsoft-teams"], +] +const DESTINATIONS = Arr.map(chainDestinations, (row) => + Option.match( + Arr.findFirst(TYPES, ([prefix]) => Str.startsWith(prefix)(row.name)), + { + onNone: () => row, + onSome: ([, type, chatConnector]) => ({ + ...row, + type, + config_json: { + summary: row.name, + channelLabel: null, + ...(chatConnector === undefined ? undefined : { chatConnector }), + }, + }), + }, + ), +) + +/** One schedule, under the id the fixture chains page. */ +const SCHEDULES = Arr.map(Arr.take(populatedSchedules(), 1), (schedule) => ({ + ...schedule, + id: Schema.decodeSync(OnCallScheduleId)(chainScheduleUuid(1)), +})) + +/** The everyday chain, paging a schedule and a person beside its destinations. */ +const EVERYDAY = chainOf({ + n: 1, + name: "Checkout on-call", + description: "Checkout team first, then the platform lead.", + steps: [ + { destinations: [1], schedules: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + { destinations: [3], users: [memberId(0)], wait: 30 }, + ], + repeat: 2, + ruleIds: [ruleId(1), ruleId(2)], +}) +const LONG = populatedChains([])[2] + const NO_COMMANDS: PageCommands = [] const run = (model: ChainModel.Model, messages: ReadonlyArray) => @@ -65,46 +121,40 @@ const run = (model: ChainModel.Model, messages: ReadonlyArray { const isNew = state === "new" || state === "invalid" - const chainId = Schema.encodeSync(EscalationChainPublicId)( - Schema.decodeSync(EscalationChainId)(chainUuid(1)), - ) + const chain = state === "long" ? (LONG ?? EVERYDAY) : EVERYDAY const entered = chainInit( isNew ? { _tag: "AlertsEscalationChainNew", search: {} } - : { _tag: "AlertsEscalationChain", chainId, search: {} }, + : { + _tag: "AlertsEscalationChain", + chainId: Schema.encodeSync(EscalationChainPublicId)(chain.id), + search: {}, + }, CONTEXT, ).model const base = [ - M.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), - M.CompletedFetchMembers({ result: Result.succeed([]) }), - M.CompletedFetchSchedules({ result: Result.succeed([]) }), - M.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + M.CompletedFetchSession({ + orgId: ORG, + grants: Policy.grantsOf([state === "readonly" ? "org:member" : "org:engineer"]), + }), + M.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + M.CompletedFetchSchedules({ result: Result.succeed(SCHEDULES) }), + M.UpdatedDestinations({ orgId: ORG, rows: DESTINATIONS }), M.UpdatedRules({ orgId: ORG, rows: RULES }), ] const loaded = isNew ? [] - : Arr.map(Arr.take(CHAINS, 1), (chain) => - M.CompletedFetchExisting({ key: entered.existing.key ?? "", result: Result.succeed(chain) }), - ) + : [M.CompletedFetchExisting({ key: entered.existing.key ?? "", result: Result.succeed(chain) })] const extra: ReadonlyArray = state === "invalid" ? [M.ChangedWait({ key: "s0", value: "2000" }), M.ClickedSave()] : state === "inserted" - ? [ - M.ClickedInsertStep({ index: 1 }), - M.GotMenuMessage({ - kind: "targets", - key: "s3", - message: { - _tag: "ClickedItem", - index: 0, - item: { kind: "Item", value: `destination:${chainDestinationUuid(3)}` }, - }, - }), - ] + ? [M.ClickedInsertStep({ index: 1 })] : state === "delete" ? [M.ClickedDelete()] - : [] + : state === "leave" + ? [M.ChangedWait({ key: "s0", value: "20" }), M.ClickedCancel()] + : [] return run(entered, [...base, ...loaded, ...extra]) } diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index fbd3e687f8..b328010c37 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -81,7 +81,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/escalation-editor", title: "Escalation chain editor", description: - "The escalation chain editor page new, populated, with a step inserted, invalid and asking to delete (`?state=`).", + "The escalation chain editor page new, populated, with a step inserted, invalid, asking to delete or to leave, read-only and with a long chain (`?state=`).", kind: "lab", session: "none", }, diff --git a/apps/web/src/page/alerts/escalation-chain/command.ts b/apps/web/src/page/alerts/escalation-chain/command.ts index 4eb548a801..fc0fa0b217 100644 --- a/apps/web/src/page/alerts/escalation-chain/command.ts +++ b/apps/web/src/page/alerts/escalation-chain/command.ts @@ -1,7 +1,7 @@ import { EscalationChainId } from "@maple/domain/http" import { V2EscalationChainCreateParams } from "@maple/domain/http/v2" import { Effect, Schema } from "effect" -import { Command } from "foldkit" +import { Command, Dom } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { deleteEscalationChain, @@ -64,3 +64,11 @@ export const DeleteChain = Command.define("DeleteEscalationChainFromEditor", { Effect.map((result) => Message.CompletedDelete({ result })), ), }) + +/** Moves focus after a step edit, to an element by id once it is on the page. */ +export const FocusChainElement = Command.define("FocusEscalationChainElement", { + args: { id: Schema.String }, + messages: [Message.CompletedFocus], + execute: ({ id }) => + Dom.focus(`#${CSS.escape(id)}`).pipe(Effect.ignore, Effect.as(Message.CompletedFocus())), +}) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts index 755b6f57e8..99b85cfdb8 100644 --- a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -5,21 +5,37 @@ import { EscalationChainPublicId } from "@maple/domain/http/v2" import { Option, Result, Schema, Array as Arr } from "effect" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" -import { click, expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import type { LoadFailure } from "@maple/views/data/load-failure" +import * as Menu from "@maple/views/ui/menu" +import { triggerId } from "@maple/views/ui/overlay/popup-state" +import { + click, + Command, + expect as expectView, + given, + Mount, + role, + scene, + text, + within, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { CHAIN_IN_USE_CODE } from "../shared/escalation-chains" +import { CHAIN_IN_USE_CODE, CHAIN_NOT_FOUND_CODE } from "../shared/escalation-chains" import { chainDestinations, chainDestinationUuid, chainUuid, populatedChains, } from "../shared/escalation-test-fixtures" +import { memberId, mixedTargetChain, oncallMembers, populatedSchedules } from "../shared/oncall-test-fixtures" import { ORG, ruleId, ruleRow } from "../shared/test-rows" +import { FocusChainElement } from "./command" import { payloadOf } from "./form" import { Message } from "./message" import type { Model } from "./model" -import { init, update } from "./update" +import { init, insertButtonId, isBlockingNavigation, menuIdOf, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) @@ -30,13 +46,27 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const run = (model: Model, messages: ReadonlyArray) => Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) +const keys = (model: Model) => Arr.map(model.form.steps, (step) => step.key) const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) const RULES = [ruleRow(1, { name: "Checkout errors" }), ruleRow(2, { name: "Checkout p95" }), ruleRow(3)] const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) const chainPublicId = (n: number) => Schema.encodeSync(EscalationChainPublicId)(Schema.decodeSync(EscalationChainId)(chainUuid(n))) +const failureOf = (code: string, message: string): LoadFailure => ({ + _tag: "@maple/http/errors/EscalationChainError", + type: code === CHAIN_IN_USE_CODE ? "conflict_error" : "not_found_error", + code, + title: "Escalation chain", + message, + retryable: false, + recovery: "fix_request", +}) +const IN_USE = failureOf(CHAIN_IN_USE_CODE, "The escalation chain is used by one or more alert rules.") +const GONE = failureOf(CHAIN_NOT_FOUND_CODE, "Escalation chain not found.") + const pick = (key: string, value: string) => Message.GotMenuMessage({ kind: "targets", @@ -50,31 +80,29 @@ const act = (key: string, value: string) => message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value } }, }) -const ready = (route: Parameters[0]) => { - const entered = init(route, context) - return run(entered.model, [ - Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), - Message.CompletedFetchMembers({ result: Result.succeed([]) }), - Message.CompletedFetchSchedules({ result: Result.succeed([]) }), +const entered = (route: Parameters[0], grants = ENGINEER) => + run(init(route, context).model, [ + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSchedules({ result: Result.succeed(populatedSchedules()) }), Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), Message.UpdatedRules({ orgId: ORG, rows: RULES }), ]) -} -const newChain = (returnTo?: string) => - ready({ _tag: "AlertsEscalationChainNew", search: returnTo === undefined ? {} : { returnTo } }) - -const editing = (n: number) => { - const base = ready({ _tag: "AlertsEscalationChain", chainId: chainPublicId(n), search: {} }) - const chain = CHAINS[n - 1] +const newChain = (grants = ENGINEER) => entered({ _tag: "AlertsEscalationChainNew", search: {} }, grants) +const opening = (n: number, grants = ENGINEER) => + entered({ _tag: "AlertsEscalationChain", chainId: chainPublicId(n), search: {} }, grants) +const loaded = (model: Model, chain = CHAINS[0]) => { + expect(chain).toBeDefined() return chain === undefined - ? base - : run(base, [ + ? model + : run(model, [ Message.CompletedFetchExisting({ - key: base.existing.key ?? "", + key: model.existing.key ?? "", result: Result.succeed(chain), }), ]) } +const editing = (n: number, grants = ENGINEER) => loaded(opening(n, grants), CHAINS[n - 1]) describe("escalation chain editor", () => { test("a new chain reads as a sentence from the start card to the end card", () => { @@ -84,14 +112,20 @@ describe("escalation chain editor", () => { expectView(text("New escalation chain")).toExist(), expectView(text("When an alert escalates to this chain")).toExist(), expectView(text("Notify")).toExist(), - expectView(text("minutes for an acknowledgement")).toExist(), + // Nothing follows the last step of a chain that does not repeat. + expectView(text("minutes, then stop")).toExist(), expectView(text("If nobody acknowledges, repeat the chain")).toExist(), - expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/routing"), + expectView(role("button", { name: "Cancel" })).toExist(), expectView(role("button", { name: "Create chain" })).toExist(), ) + scene( + { update: pageUpdate, view }, + given(run(newChain(), [Message.ChangedRepeatCount({ value: "2" })])), + expectView(text("minutes for an acknowledgement, then repeat")).toExist(), + ) }) - test("builds a chain: add steps, insert between, reorder, remove, then save", () => { + test("builds a chain: add steps, insert between, reorder, remove, then save back to Routing", () => { const first = run(newChain(), [ Message.ChangedName({ value: "Checkout on-call" }), pick("s0", `destination:${chainDestinationUuid(1)}`), @@ -99,31 +133,58 @@ describe("escalation chain editor", () => { pick("s1", `destination:${chainDestinationUuid(2)}`), Message.ChangedWait({ key: "s0", value: "5" }), ]) - expect(Arr.map(first.form.steps, (step) => step.key)).toEqual(["s0", "s1"]) + expect(keys(first)).toEqual(["s0", "s1"]) // The + between the two cards puts a new step in the middle. scene( { update: pageUpdate, view }, given(first), + expectView(text("Slack #alerts-api")).toExist(), + expectView(text("minutes for an acknowledgement")).toExist(), click(role("button", { name: "Add a step after step 1" })), + Command.resolve(FocusChainElement, Message.CompletedFocus()), expectView(role("button", { name: "Actions for step 3" })).toExist(), ) const middle = pageUpdate(first, Message.ClickedInsertStep({ index: 1 })).model - expect(Arr.map(middle.form.steps, (step) => step.key)).toEqual(["s0", "s2", "s1"]) + expect(keys(middle)).toEqual(["s0", "s2", "s1"]) const reordered = run(middle, [act("s1", "up"), act("s1", "up"), act("s2", "remove")]) - expect(Arr.map(reordered.form.steps, (step) => step.key)).toEqual(["s1", "s0"]) + expect(keys(reordered)).toEqual(["s1", "s0"]) const saved = pageUpdate(reordered, Message.ClickedSave()) expect(names(saved.commands)).toEqual(["SaveEscalationChainFromEditor"]) expect( Option.map(payloadOf(reordered.form), (payload) => Arr.map(payload.steps, (s) => s.wait_minutes)), ).toEqual(Option.some([10, 5])) const chain = CHAINS[0] - if (chain === undefined) throw new Error("fixture chain missing") + expect(chain).toBeDefined() + if (chain === undefined) return const done = pageUpdate(saved.model, Message.CompletedSave({ result: Result.succeed(chain) })) expect(done.outMessage).toMatchObject({ _tag: "RequestedNavigation", - url: `/alerts/escalation-chains/${chainPublicId(1)}`, + url: "/alerts/routing", toast: { title: "Escalation chain created" }, }) + // The saved form is not an unsaved edit: leaving does not ask. + expect(isBlockingNavigation(done.model)).toBe(false) + }) + + test("a step edit moves focus to where the work continues", () => { + const two = pageUpdate(newChain(), Message.ClickedInsertStep({ index: 1 })) + // A new step: its Add button, so its targets come next. + expect(two.commands).toMatchObject([ + { name: "FocusEscalationChainElement", args: { id: triggerId(menuIdOf("targets", "s1")) } }, + ]) + const copied = pageUpdate(two.model, act("s0", "duplicate")) + expect(Arr.last(copied.commands ?? [])).toMatchObject( + Option.some({ args: { id: triggerId(menuIdOf("targets", "s2")) } }), + ) + // A removed step: the step before it, or the + that now leads the chain. + const removedLast = pageUpdate(two.model, act("s1", "remove")) + expect(Arr.last(removedLast.commands ?? [])).toMatchObject( + Option.some({ args: { id: triggerId(menuIdOf("actions", "s0")) } }), + ) + const removedFirst = pageUpdate(two.model, act("s0", "remove")) + expect(Arr.last(removedFirst.commands ?? [])).toMatchObject( + Option.some({ args: { id: insertButtonId(0) } }), + ) }) test("saving with missing fields shows each problem and sends nothing", () => { @@ -152,9 +213,63 @@ describe("escalation chain editor", () => { ).toExist(), expectView(role("button", { name: "Save chain" })).toExist(), ) - expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual([ - "SaveEscalationChainFromEditor", - ]) + const saving = pageUpdate( + run(model, [Message.ChangedName({ value: "Checkout" })]), + Message.ClickedSave(), + ) + expect(names(saving.commands)).toEqual(["SaveEscalationChainFromEditor"]) + const chain = CHAINS[0] + expect(chain).toBeDefined() + if (chain === undefined) return + // Back to the list, which reads the saved name and rules fresh. + expect( + pageUpdate(saving.model, Message.CompletedSave({ result: Result.succeed(chain) })).outMessage, + ).toMatchObject({ url: "/alerts/routing", toast: { title: "Escalation chain saved" } }) + }) + + test("people and schedules read by name, with who is on call and who has left", () => { + const schedule = populatedSchedules()[0] + expect(schedule).toBeDefined() + const chain = mixedTargetChain(chainDestinationUuid(1)) + const model = loaded(opening(1), chain) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text(schedule?.name ?? "")).toExist(), + expectView(text(/ on call$/u)).toExist(), + expectView(text(oncallMembers[1]?.name ?? "")).toExist(), + ) + // Someone removed from the org since the chain was saved. + scene( + { update: pageUpdate, view }, + given({ + ...model, + members: Arr.filter(model.members ?? [], (member) => member.userId !== memberId(0)), + }), + expectView(text("Unavailable member")).toExist(), + ) + }) + + test("the target picker says what is still loading, and where to make a destination", () => { + const menuId = menuIdOf("targets", "s0") + const open = (model: Model): Model => ({ + ...model, + menus: { [menuId]: Menu.init(menuId, { isOpen: true }) }, + }) + scene( + { update: pageUpdate, view }, + given(open({ ...newChain(), destinations: null, schedules: null })), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(text("Loading destinations")).toExist(), + expectView(text("Loading schedules")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(open({ ...newChain(), destinations: [], members: [], schedules: [] })), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(text(/No destinations yet/u)).toExist(), + expectView(role("link", { name: "Create a destination" })).toHaveAttr("href", "/alerts/routing"), + ) }) test("delete lists the rules that would stop escalating and holds the delete while they use it", () => { @@ -166,27 +281,40 @@ describe("escalation chain editor", () => { Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), expectView(text(/These rules would stop escalating/u)).toExist(), expectView(text("Checkout p95")).toExist(), + expectView(within(role("alertdialog"), role("button", { name: "Delete chain" }))).toBeDisabled(), ) - // A stale page still gets the API's refusal as a message in the dialog. - const refused = pageUpdate( - { ...asked, isDeleting: true }, - Message.CompletedDelete({ - result: Result.fail({ - _tag: "@maple/http/errors/EscalationChainInUseError", - type: "conflict_error", - code: CHAIN_IN_USE_CODE, - title: "Escalation chain is in use", - message: "The escalation chain is used by one or more alert rules.", - retryable: false, - recovery: "fix_request", - }), + }) + + test("a delete the API refuses reads the chain again, so the dialog names the rule that took it", () => { + const asked = run(editing(2), [Message.ClickedDelete(), Message.ClickedConfirmDelete()]) + expect(asked.isDeleting).toBe(true) + const refused = pageUpdate(asked, Message.CompletedDelete({ result: Result.fail(IN_USE) })) + expect(refused.model.deleteError).toMatch(/Remove it from the rule's severity tiers first/u) + expect(names(refused.commands)).toEqual(["FetchEscalationChainEditorChain"]) + const chain = CHAINS[1] + expect(chain).toBeDefined() + if (chain === undefined) return + const edited = run(refused.model, [Message.ChangedName({ value: "Payments" })]) + const reread = pageUpdate( + edited, + Message.CompletedFetchExisting({ + key: edited.existing.key ?? "", + result: Result.succeed({ ...chain, rule_ids: CHAINS[0]?.rule_ids ?? [] }), }), + ).model + // The re-read brings the rules, never the form: an edit in progress stays. + expect(reread.form.name).toBe("Payments") + scene( + { update: pageUpdate, view }, + given(reread), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Used by 2 rules")).toExist(), + expectView(text(/These rules would stop escalating/u)).toExist(), ) - expect(refused.model.deleteError).toMatch(/Remove it from the rule's severity tiers first/u) }) test("an unused chain deletes and goes back to Routing", () => { - const unused = editing(2) + const unused = editing(3) const confirmed = pageUpdate( pageUpdate(unused, Message.ClickedDelete()).model, Message.ClickedConfirmDelete(), @@ -194,37 +322,104 @@ describe("escalation chain editor", () => { expect(names(confirmed.commands)).toEqual(["DeleteEscalationChainFromEditor"]) const done = pageUpdate(confirmed.model, Message.CompletedDelete({ result: Result.void })) expect(done.outMessage).toMatchObject({ _tag: "RequestedNavigation", url: "/alerts/routing" }) + // Deleted elsewhere first: the same outcome. + expect( + pageUpdate(confirmed.model, Message.CompletedDelete({ result: Result.fail(GONE) })).outMessage, + ).toMatchObject({ url: "/alerts/routing", toast: { title: "Escalation chain deleted" } }) }) - test("Cancel and Save go back to an in-app returnTo, never another origin", () => { + test("Cancel leaves at once with nothing to lose, and asks first over unsaved edits", () => { + const clean = editing(1) + expect(isBlockingNavigation(clean)).toBe(false) + expect(pageUpdate(clean, Message.ClickedCancel()).outMessage).toMatchObject({ + _tag: "RequestedNavigation", + url: "/alerts/routing", + }) + const dirty = run(clean, [Message.ChangedWait({ key: "s0", value: "20" })]) + expect(isBlockingNavigation(dirty)).toBe(true) + // An edit put back is no edit. + expect(isBlockingNavigation(run(dirty, [Message.ChangedWait({ key: "s0", value: "10" })]))).toBe( + false, + ) + const asked = pageUpdate(dirty, Message.ClickedCancel()) + expect(asked.outMessage).toBeUndefined() + expect(asked.model.leaveDialog.isOpen).toBe(true) scene( { update: pageUpdate, view }, - given(newChain("/alerts/create")), - expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/create"), + given(asked.model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Unsaved changes")).toExist(), + expectView(role("button", { name: "Stay" })).toExist(), + expectView(role("button", { name: "Discard changes" })).toExist(), ) + const discarded = pageUpdate(asked.model, Message.ClickedDiscard()) + expect(discarded.outMessage).toMatchObject({ url: "/alerts/routing" }) + expect(isBlockingNavigation(discarded.model)).toBe(false) + }) + + test("a chain deleted elsewhere reads as not found, with the way back and nothing to retry", () => { + const opened = opening(1) + const gone = pageUpdate( + opened, + Message.CompletedFetchExisting({ key: opened.existing.key ?? "", result: Result.fail(GONE) }), + ).model scene( { update: pageUpdate, view }, - given(newChain("https://evil.example")), - expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/routing"), + given(gone), + expectView(text("Escalation chain not found")).toExist(), + expectView(role("link", { name: "Back to routing" })).toHaveAttr("href", "/alerts/routing"), + expectView(role("button", { name: "Try again" })).not.toExist(), + ) + // Deleted while it was being edited: the save says so. + const saving = pageUpdate(editing(1), Message.ClickedSave()).model + scene( + { update: pageUpdate, view }, + given(pageUpdate(saving, Message.CompletedSave({ result: Result.fail(GONE) })).model), + expectView(text(/This chain no longer exists/u)).toExist(), + ) + }) + + test("a viewer who cannot manage alerts reads the chain and its rules, and can change nothing", () => { + const model = editing(1, MEMBER) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Only org admins and engineers can change escalation chains")).toExist(), + expectView(text("Slack #alerts-api")).toExist(), + expectView(text("Used by 2 rules")).toExist(), + expectView(role("textbox", { name: "Name" })).toBeDisabled(), + expectView(role("button", { name: "Save chain" })).not.toExist(), + expectView(role("button", { name: "Delete chain" })).not.toExist(), + expectView(role("button", { name: "Actions for step 1" })).not.toExist(), + expectView(role("button", { name: "Add a step after step 1" })).not.toExist(), + expectView(role("button", { name: "Stop notifying Slack #alerts-api" })).not.toExist(), + expectView(role("link", { name: "Back to routing" })).toExist(), + ) + // The update holds the same line as the view. + expect(run(model, [Message.ChangedName({ value: "Mine now" })]).form.name).toBe("Checkout on-call") + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual([]) + expect(pageUpdate(model, Message.ClickedDelete()).model.deleteDialog.isOpen).toBe(false) + // A new chain has nothing to read: the viewer learns who can make one. + scene( + { update: pageUpdate, view }, + given(newChain(MEMBER)), + expectView(text("Only org admins and engineers can create escalation chains")).toExist(), + ) + }) + + test("nothing can be saved or changed until the session says who the viewer is", () => { + const opened = init({ _tag: "AlertsEscalationChainNew", search: {} }, context).model + scene( + { update: pageUpdate, view }, + given(opened), + expectView(role("button", { name: "Create chain" })).not.toExist(), ) - const filled = run(newChain("/alerts/create"), [ - Message.ChangedName({ value: "On-call" }), - pick("s0", `destination:${chainDestinationUuid(1)}`), - ]) - const saving = pageUpdate(filled, Message.ClickedSave()).model - const chain = CHAINS[0] - if (chain === undefined) throw new Error("fixture chain missing") - expect( - pageUpdate(saving, Message.CompletedSave({ result: Result.succeed(chain) })).outMessage, - ).toMatchObject({ - url: "/alerts/create", - }) }) test("a link with a malformed chain id says the chain was not found", () => { scene( { update: pageUpdate, view }, - given(ready({ _tag: "AlertsEscalationChain", chainId: "nope", search: {} })), + given(entered({ _tag: "AlertsEscalationChain", chainId: "nope", search: {} })), expectView(text("Escalation chain not found")).toExist(), ) }) diff --git a/apps/web/src/page/alerts/escalation-chain/form.test.ts b/apps/web/src/page/alerts/escalation-chain/form.test.ts index 45acc7ce4e..c30f3098dc 100644 --- a/apps/web/src/page/alerts/escalation-chain/form.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/form.test.ts @@ -1,7 +1,7 @@ import { Option, Array as Arr, Schema } from "effect" import { AlertDestinationId, MAX_ESCALATION_STEPS } from "@maple/domain/http" import { describe, expect, it } from "vitest" -import { chainSummary, safeReturnTo } from "../shared/escalation-chains" +import { chainSummary } from "../shared/escalation-chains" import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" import { mixedTargetChain } from "../shared/oncall-test-fixtures" import { @@ -12,6 +12,7 @@ import { formFromChain, formIssues, insertStep, + isSameChain, moveStep, payloadOf, previewOf, @@ -123,11 +124,12 @@ describe("escalation chain form", () => { expect(previewOf(emptyForm).steps).toEqual([]) }) - it("follows only an in-app returnTo", () => { - expect(safeReturnTo("/alerts/rules/abc")).toEqual(Option.some("/alerts/rules/abc")) - expect(safeReturnTo("https://evil.example")).toEqual(Option.none()) - expect(safeReturnTo("//evil.example")).toEqual(Option.none()) - expect(safeReturnTo("/\\evil.example")).toEqual(Option.none()) - expect(safeReturnTo(undefined)).toEqual(Option.none()) + it("reads an edit that was undone as the chain it started from", () => { + const form = loaded() + expect(isSameChain(form, form)).toBe(true) + expect(isSameChain(removeStep(insertStep(form, 1), "s2"), form)).toBe(true) + expect(isSameChain({ ...form, name: "Checkout on-call (EU)" }, form)).toBe(false) + expect(isSameChain(moveStep(form, "s1", "up"), form)).toBe(false) + expect(isSameChain(addTarget(form, "s0", destination(3)), form)).toBe(false) }) }) diff --git a/apps/web/src/page/alerts/escalation-chain/form.ts b/apps/web/src/page/alerts/escalation-chain/form.ts index 61faa3649f..d5143a08cd 100644 --- a/apps/web/src/page/alerts/escalation-chain/form.ts +++ b/apps/web/src/page/alerts/escalation-chain/form.ts @@ -17,7 +17,7 @@ import { type V2EscalationStep, V2EscalationTarget, } from "@maple/domain/http/v2" -import { Option, Schema, Array as Arr, String as Str } from "effect" +import { Equal, Option, Schema, Array as Arr, String as Str } from "effect" import type { ChainOutline } from "../shared/escalation-chains" // The escalation chain editor's form: its drafts, the API's limits as field errors, and step edits. @@ -68,6 +68,16 @@ export const formFromChain = (chain: V2EscalationChain): Form => ({ nextKey: chain.steps.length, }) +const contentOf = (form: Form) => ({ + name: form.name, + description: form.description, + repeatCount: form.repeatCount, + steps: Arr.map(form.steps, (step) => ({ targets: step.targets, waitMinutes: step.waitMinutes })), +}) + +/** Two forms describe the same chain: step keys aside, nothing a save sends differs. */ +export const isSameChain = (a: Form, b: Form): boolean => Equal.equals(contentOf(a), contentOf(b)) + // VALIDATION: the same limits the API's schemas hold. const isName = Schema.is(EscalationChainName) diff --git a/apps/web/src/page/alerts/escalation-chain/index.ts b/apps/web/src/page/alerts/escalation-chain/index.ts index ec917920fd..6ae2fa11fc 100644 --- a/apps/web/src/page/alerts/escalation-chain/index.ts +++ b/apps/web/src/page/alerts/escalation-chain/index.ts @@ -3,7 +3,7 @@ import { routingTabHref } from "../shared/escalation-chains" import { Message } from "./message" import { Model } from "./model" import { subscriptions } from "./subscription" -import { init, update } from "./update" +import { askToLeave, init, isBlockingNavigation, update } from "./update" import { chainTitle, view } from "./view" /** `/alerts/escalation-chains/new` and `/:chainId`: one instance per chain, or the new one. */ @@ -17,6 +17,8 @@ export const page = definePage( update, view, subscriptions, + isBlockingNavigation, + blockedNavigation: (model, url) => askToLeave(model, url), breadcrumbs: (model) => [ { label: "Alerts", href: "/alerts" }, { label: "Routing", href: routingTabHref() }, diff --git a/apps/web/src/page/alerts/escalation-chain/message.ts b/apps/web/src/page/alerts/escalation-chain/message.ts index 49415ab705..61cc0d7c0a 100644 --- a/apps/web/src/page/alerts/escalation-chain/message.ts +++ b/apps/web/src/page/alerts/escalation-chain/message.ts @@ -7,7 +7,6 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import { OrgMember } from "../shared/org-members" -import { Direction } from "./form" const rows = (row: S) => ({ orgId: Schema.String, rows: Schema.Array(row) }) const outcome = (success: S) => Schema.Result(success, LoadFailure) @@ -29,9 +28,6 @@ export const Message = defineMessageUnion({ ChangedWait: { key: Schema.String, value: Schema.String }, /** The "+" between two cards: a new step at `index`. */ ClickedInsertStep: { index: Schema.Number }, - ClickedRemoveStep: { key: Schema.String }, - ClickedDuplicateStep: { key: Schema.String }, - ClickedMoveStep: { key: Schema.String, direction: Direction }, /** A step's target picker or its actions menu. */ GotMenuMessage: { kind: MenuKind, key: Schema.String, message: Menu.Message }, ClickedRemoveTarget: { key: Schema.String, target: Schema.String }, @@ -41,5 +37,10 @@ export const Message = defineMessageUnion({ ClickedDelete: {}, ClickedConfirmDelete: {}, CompletedDelete: { result: outcome(Schema.Void) }, + ClickedCancel: {}, + GotLeaveDialogMessage: { message: Dialog.Message }, + ClickedDiscard: {}, + FailedLoadProviderLogo: { type: Schema.String }, + CompletedFocus: {}, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/escalation-chain/model.ts b/apps/web/src/page/alerts/escalation-chain/model.ts index 11cb862749..ec014ffef8 100644 --- a/apps/web/src/page/alerts/escalation-chain/model.ts +++ b/apps/web/src/page/alerts/escalation-chain/model.ts @@ -7,14 +7,12 @@ import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" -import { AlertsEscalationChainSearch } from "../../../route/search" import { OrgMember } from "../shared/org-members" import { Form } from "./form" // `/alerts/escalation-chains/new` and `/alerts/escalation-chains/:chainId`: the chain editor. export const Model = Schema.Struct({ - search: AlertsEscalationChainSearch, /** The chain being edited; `null` creates. */ editingId: Schema.NullOr(EscalationChainId), /** The URL named a chain id that does not decode. */ @@ -22,10 +20,16 @@ export const Model = Schema.Struct({ /** The edited chain's read; the form fills from it once. */ existing: KeyedSchema(V2EscalationChain, LoadFailure), form: Form, + /** The form as it was opened or last saved: `form` differing from it is an unsaved edit. */ + baseline: Form, destinations: Schema.NullOr(Schema.Array(AlertDestinationRowSchema)), rules: Schema.NullOr(Schema.Array(AlertRuleRowSchema)), members: Schema.NullOr(Schema.Array(OrgMember)), + /** `null` until the schedules load, and after a failed read (`isSchedulesFailed`). */ schedules: Schema.NullOr(Schema.Array(V2OnCallSchedule)), + isSchedulesFailed: Schema.Boolean, + /** Destination types whose brand image failed to load: they draw the monogram. */ + brokenLogos: Schema.Array(Schema.String), /** Each step's target picker and actions menu, by menu id. */ menus: Schema.Record(Schema.String, Menu.Model), isSaving: Schema.Boolean, @@ -35,6 +39,9 @@ export const Model = Schema.Struct({ deleteDialog: Dialog.Model, isDeleting: Schema.Boolean, deleteError: Schema.NullOr(Schema.String), + /** "Unsaved changes": a navigation held while the form has edits, and where it was going. */ + leaveDialog: Dialog.Model, + pendingLeaveUrl: Schema.NullOr(Schema.String), grants: Schema.Array(Policy.PermissionName), isSessionLoaded: Schema.Boolean, }) diff --git a/apps/web/src/page/alerts/escalation-chain/update.ts b/apps/web/src/page/alerts/escalation-chain/update.ts index 89b3bd1ad2..7f5ee38ddb 100644 --- a/apps/web/src/page/alerts/escalation-chain/update.ts +++ b/apps/web/src/page/alerts/escalation-chain/update.ts @@ -1,17 +1,17 @@ -import { Option, Result } from "effect" +import { Option, Result, Array as Arr } from "effect" import { AsyncData, Command as FoldkitCommand } from "foldkit" import * as Keyed from "@maple/views/data/keyed" import { requestKey } from "@maple/views/data/request-key" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" +import { triggerId } from "@maple/views/ui/overlay/popup-state" import type { AppRoute } from "../../../route" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { CHAIN_IN_USE_CODE, - chainHref, + CHAIN_NOT_FOUND_CODE, chainIdFromUrl, routingTabHref, - safeReturnTo, } from "../shared/escalation-chains" import { DeleteChain, @@ -19,6 +19,7 @@ import { FetchChainEditorSchedules, FetchChainEditorSession, FetchExistingChain, + FocusChainElement, SaveChain, } from "./command" import { @@ -29,6 +30,7 @@ import { type Form, formFromChain, insertStep, + isSameChain, moveStep, payloadOf, removeStep, @@ -36,7 +38,7 @@ import { targetOfKey, } from "./form" import { Message, type MenuKind } from "./message" -import type { Model } from "./model" +import { canManage, type Model } from "./model" type Return = PageReturn export type ChainRoute = Extract< @@ -45,26 +47,31 @@ export type ChainRoute = Extract< > export const DELETE_DIALOG_ID = "escalation-chain-delete" +export const LEAVE_DIALOG_ID = "escalation-chain-leave" export const menuIdOf = (kind: MenuKind, key: string): string => `escalation-chain-${kind}-${key}` -/** Where Cancel goes: the `returnTo` the link carried, else the Routing section. */ -export const backHref = (model: Pick): string => - Option.getOrElse(safeReturnTo(model.search.returnTo), routingTabHref) +/** The + on the connector above the step at `index` (or below the last one). */ +export const insertButtonId = (index: number): string => `escalation-chain-insert-${index}` + +/** Where Cancel, Save and Delete go back to. */ +export const backHref = (): string => routingTabHref() export const init = (route: ChainRoute, context: PageContext): Return => { const rawId = route._tag === "AlertsEscalationChain" ? Option.some(route.chainId) : Option.none() const editingId = Option.getOrNull(Option.flatMap(rawId, chainIdFromUrl)) const model: Model = { - search: route.search, editingId, isBadId: Option.isSome(rawId) && editingId === null, existing: Keyed.idle(), form: emptyForm, + baseline: emptyForm, destinations: null, rules: null, members: null, schedules: null, + isSchedulesFailed: false, + brokenLogos: [], menus: {}, isSaving: false, isShowingErrors: false, @@ -72,6 +79,8 @@ export const init = (route: ChainRoute, context: PageContext): Return => { deleteDialog: Dialog.init(DELETE_DIALOG_ID, "alert-dialog"), isDeleting: false, deleteError: null, + leaveDialog: Dialog.init(LEAVE_DIALOG_ID, "alert-dialog"), + pendingLeaveUrl: null, grants: [], isSessionLoaded: false, } @@ -91,22 +100,110 @@ export const init = (route: ChainRoute, context: PageContext): Return => { }) } -/** A form edit is dropped while a save is in flight: the saved chain is what the page shows. */ +/** Reads the chain again (a retry, or a refused delete whose rule list may have moved). */ +const refetch = (model: Model, context: PageContext): Return => { + const editingId = model.editingId + if (editingId === null) return { model } + const key = requestKey(context.orgId, "escalationChains.retrieve", { id: editingId }) + return Option.match(Keyed.request(model.existing, key, "Refresh"), { + onNone: () => ({ model }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [FetchExistingChain({ key, chainId: editingId })], + }), + }) +} + +// LEAVING + +/** The form holds edits a save has not sent. */ +export const isDirty = (model: Pick): boolean => + !isSameChain(model.form, model.baseline) + +/** Leaving with unsaved edits asks first; a save or delete in flight is on its way out already. */ +export const isBlockingNavigation = (model: Model): boolean => + !model.isSaving && !model.isDeleting && canManage(model) && isDirty(model) + +const navigate = (model: Model, url: string): Return => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ url, isReplace: false }), +}) + +/** A save or delete went through: back to Routing in place of this page, with its toast. */ +const finished = (model: Model, title: string): Return => ({ + // The form is what was saved (or is gone): nothing left to ask about on the way out. + model: { ...model, isSaving: false, isDeleting: false, baseline: model.form }, + outMessage: PageOutMessage.RequestedNavigation({ + url: backHref(), + isReplace: true, + toast: { type: "success", title, description: "" }, + }), +}) + +/** The confirmation over a held navigation (a link, Back, or Cancel). */ +export const askToLeave = (model: Model, url: string): Return => { + const next = Dialog.open(model.leaveDialog) + return { + model: { ...model, leaveDialog: next.model, pendingLeaveUrl: url }, + commands: FoldkitCommand.mapMessages(next.commands ?? [], (message) => + Message.GotLeaveDialogMessage({ message }), + ), + } +} + +const leave = (model: Model, url: string): Return => + isBlockingNavigation(model) ? askToLeave(model, url) : navigate(model, url) + +// STEP EDITS + +/** A form edit is dropped while a save is in flight, and for a viewer who may not change chains. */ +const canEdit = (model: Model): boolean => !model.isSaving && canManage(model) + const withForm = (model: Model, edit: (form: Form) => Form): Return => - model.isSaving ? { model } : { model: { ...model, form: edit(model.form) } } + canEdit(model) ? { model: { ...model, form: edit(model.form) } } : { model } + +const focus = (id: string) => [FocusChainElement({ id })] + +/** A step that was just added: focus lands on its "Add" button, so its targets come next. */ +const added = (model: Model, form: Form): Return => + form.nextKey === model.form.nextKey + ? { model } + : { + model: { ...model, form }, + commands: focus(triggerId(menuIdOf("targets", `s${model.form.nextKey}`))), + } -const stepAction = (form: Form, key: string, value: string): Form => { +/** A removed step hands focus to the step before it, or to the + that now leads the chain. */ +const removed = (model: Model, key: string): Return => { + const form = removeStep(model.form, key) + if (form.steps.length === model.form.steps.length) return { model } + const index = Option.getOrElse( + Arr.findFirstIndex(model.form.steps, (step) => step.key === key), + () => 0, + ) + const previous = index === 0 ? undefined : model.form.steps[index - 1] + return { + model: { ...model, form }, + commands: focus( + previous === undefined ? insertButtonId(0) : triggerId(menuIdOf("actions", previous.key)), + ), + } +} + +const stepAction = (model: Model, key: string, value: string): Return => { switch (value) { case "up": - return moveStep(form, key, "up") case "down": - return moveStep(form, key, "down") + return { + model: { ...model, form: moveStep(model.form, key, value) }, + commands: focus(triggerId(menuIdOf("actions", key))), + } case "duplicate": - return duplicateStep(form, key) + return added(model, duplicateStep(model.form, key)) case "remove": - return removeStep(form, key) + return removed(model, key) default: - return form + return { model } } } @@ -121,23 +218,26 @@ const stepMenu = (model: Model, kind: MenuKind, key: string, message: Menu.Messa const picked = result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none() // The menus still open and close while saving, but a pick no longer edits the chain. - return Option.match(model.isSaving ? Option.none() : picked, { + return Option.match(canEdit(model) ? picked : Option.none(), { onNone: () => ({ model: next, commands }), - onSome: (value) => - kind === "actions" - ? { model: { ...next, form: stepAction(next.form, key, value) }, commands } - : Option.match(targetOfKey(value), { - onNone: () => ({ model: next, commands }), - onSome: (target) => ({ - model: { ...next, form: addTarget(next.form, key, target) }, - commands, - }), - }), + onSome: (value) => { + if (kind === "actions") { + const acted = stepAction(next, key, value) + return { model: acted.model, commands: [...commands, ...(acted.commands ?? [])] } + } + return Option.match(targetOfKey(value), { + onNone: () => ({ model: next, commands }), + onSome: (target) => ({ + model: { ...next, form: addTarget(next.form, key, target) }, + commands, + }), + }) + }, }) } const save = (model: Model): Return => - model.isSaving + !canEdit(model) ? { model } : Option.match(payloadOf(model.form), { onNone: () => ({ model: { ...model, isShowingErrors: true } }), @@ -158,35 +258,30 @@ const liftDelete = (model: Model, step: ReturnType): Retur ), }) +const deleted = (model: Model): Return => finished(model, "Escalation chain deleted") + export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { CompletedFetchExisting: ({ key, result }) => { const existing = Keyed.accept(model.existing, key, result) - // The form fills once, from the first read. - const isFirst = !AsyncData.isSuccess(model.existing.data) + // The form fills once, from the first read; a later read only refreshes who uses the chain. + const isFirst = Option.isNone(AsyncData.getData(model.existing.data)) return { model: Result.match(result, { - onSuccess: (chain) => ({ - ...model, - existing, - form: isFirst ? formFromChain(chain) : model.form, - }), + onSuccess: (chain) => + isFirst + ? { + ...model, + existing, + form: formFromChain(chain), + baseline: formFromChain(chain), + } + : { ...model, existing }, onFailure: () => ({ ...model, existing }), }), } }, - ClickedRetryExisting: () => { - const editingId = model.editingId - if (editingId === null) return { model } - const key = requestKey(context.orgId, "escalationChains.retrieve", { id: editingId }) - return Option.match(Keyed.request(model.existing, key, "Refresh"), { - onNone: () => ({ model }), - onSome: (existing) => ({ - model: { ...model, existing }, - commands: [FetchExistingChain({ key, chainId: editingId })], - }), - }) - }, + ClickedRetryExisting: () => refetch(model, context), UpdatedDestinations: ({ orgId, rows }) => orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, UpdatedRules: ({ orgId, rows }) => @@ -195,12 +290,12 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchMembers: ({ result }) => Result.match(result, { onSuccess: (members) => ({ model: { ...model, members } }), - onFailure: () => ({ model: { ...model, members: [] } }), + onFailure: () => ({ model }), }), CompletedFetchSchedules: ({ result }) => Result.match(result, { - onSuccess: (schedules) => ({ model: { ...model, schedules } }), - onFailure: () => ({ model: { ...model, schedules: [] } }), + onSuccess: (schedules) => ({ model: { ...model, schedules, isSchedulesFailed: false } }), + onFailure: () => ({ model: { ...model, isSchedulesFailed: true } }), }), CompletedFetchSession: ({ orgId, grants }) => orgId === context.orgId ? { model: { ...model, grants, isSessionLoaded: true } } : { model }, @@ -209,30 +304,19 @@ export const update = (model: Model, message: Message, context: PageContext): Re ChangedRepeatCount: ({ value }) => withForm(model, (form) => ({ ...form, repeatCount: value })), ChangedWait: ({ key, value }) => withForm(model, (form) => editStep(form, key, (step) => ({ ...step, waitMinutes: value }))), - ClickedInsertStep: ({ index }) => withForm(model, (form) => insertStep(form, index)), - ClickedRemoveStep: ({ key }) => withForm(model, (form) => removeStep(form, key)), - ClickedDuplicateStep: ({ key }) => withForm(model, (form) => duplicateStep(form, key)), - ClickedMoveStep: ({ key, direction }) => withForm(model, (form) => moveStep(form, key, direction)), + ClickedInsertStep: ({ index }) => + canEdit(model) ? added(model, insertStep(model.form, index)) : { model }, GotMenuMessage: ({ kind, key, message: child }) => stepMenu(model, kind, key, child), ClickedRemoveTarget: ({ key, target }) => withForm(model, (form) => removeTarget(form, key, target)), ClickedSave: () => save(model), CompletedSave: ({ result }) => Result.match(result, { - onSuccess: (chain): Return => ({ - model: { ...model, isSaving: false }, - outMessage: PageOutMessage.RequestedNavigation({ - url: Option.getOrElse(safeReturnTo(model.search.returnTo), () => chainHref(chain.id)), - isReplace: true, - toast: { - type: "success", - title: - model.editingId === null - ? "Escalation chain created" - : "Escalation chain saved", - description: "", - }, - }), - }), + // Back to Routing, as the rule editor goes back to Rules: the list shows the saved chain. + onSuccess: (): Return => + finished( + model, + model.editingId === null ? "Escalation chain created" : "Escalation chain saved", + ), onFailure: (failure): Return => ({ model: { ...model, isSaving: false, saveFailure: failure }, }), @@ -240,11 +324,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re GotDeleteDialogMessage: ({ message: child }) => model.isDeleting ? { model } : liftDelete(model, Dialog.update(model.deleteDialog, child)), ClickedDelete: () => - model.editingId === null + model.editingId === null || !canManage(model) ? { model } : liftDelete({ ...model, deleteError: null }, Dialog.open(model.deleteDialog)), ClickedConfirmDelete: () => - model.editingId === null || model.isDeleting + model.editingId === null || model.isDeleting || !canManage(model) ? { model } : { model: { ...model, isDeleting: true, deleteError: null }, @@ -252,23 +336,50 @@ export const update = (model: Model, message: Message, context: PageContext): Re }, CompletedDelete: ({ result }) => Result.match(result, { - onSuccess: (): Return => ({ - model: { ...model, isDeleting: false }, - outMessage: PageOutMessage.RequestedNavigation({ - url: routingTabHref(), - isReplace: true, - toast: { type: "success", title: "Escalation chain deleted", description: "" }, - }), - }), - onFailure: (failure): Return => ({ - model: { - ...model, - isDeleting: false, - deleteError: - failure.code === CHAIN_IN_USE_CODE - ? "An alert rule still starts this chain. Remove it from the rule's severity tiers first." - : failure.message, - }, - }), + onSuccess: (): Return => deleted(model), + onFailure: (failure): Return => { + // Someone else deleted it first: the outcome the viewer asked for. + if (failure.code === CHAIN_NOT_FOUND_CODE) return deleted(model) + const settled = { ...model, isDeleting: false } + // A rule took the chain since this page read it: read it again, so the dialog + // lists the rule and holds its Delete. + return failure.code === CHAIN_IN_USE_CODE + ? refetch( + { + ...settled, + deleteError: + "An alert rule still starts this chain. Remove it from the rule's severity tiers first.", + }, + context, + ) + : { model: { ...settled, deleteError: failure.message } } + }, }), + ClickedCancel: () => leave(model, backHref()), + GotLeaveDialogMessage: ({ message: child }) => { + const next = Dialog.update(model.leaveDialog, child) + return { + model: { ...model, leaveDialog: next.model }, + commands: FoldkitCommand.mapMessages(next.commands ?? [], (inner) => + Message.GotLeaveDialogMessage({ message: inner }), + ), + } + }, + ClickedDiscard: () => { + const closed = Dialog.close(model.leaveDialog) + return { + ...navigate( + { ...model, leaveDialog: closed.model, baseline: model.form }, + model.pendingLeaveUrl ?? backHref(), + ), + commands: FoldkitCommand.mapMessages(closed.commands ?? [], (inner) => + Message.GotLeaveDialogMessage({ message: inner }), + ), + } + }, + FailedLoadProviderLogo: ({ type }) => + model.brokenLogos.includes(type) + ? { model } + : { model: { ...model, brokenLogos: [...model.brokenLogos, type] } }, + CompletedFocus: () => ({ model }), }) diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index f9099298be..45b4c451ec 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -1,7 +1,8 @@ -import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" +import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_STEPS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" import type { AlertDestinationId } from "@maple/domain/http" import type { V2EscalationTarget } from "@maple/domain/http/v2" import { pluralize } from "@maple/ui/lib/format" +import { rowToAlertDestinationDocument } from "@maple/web-data/collections/alerts" import { Option, Array as Arr, String as Str } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -9,12 +10,9 @@ import { failureMessage } from "@maple/views/data/load-failure" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { button, linkButton } from "@maple/views/ui/button" import { confirmDialog } from "@maple/views/ui/confirm-dialog" -import { separator } from "@maple/views/ui/display" import { errorState } from "@maple/views/ui/error-state" -import { resourceNotFound } from "../../kit/resource-not-found" import { fieldErrorText, type FieldIds, fieldLabel, input } from "@maple/views/ui/field" import { icon } from "@maple/views/ui/icon" -import { iconButton } from "@maple/views/ui/icon-button" import type { FieldState } from "@maple/views/ui/input" import * as Menu from "@maple/views/ui/menu" import type { MenuEntry } from "@maple/views/ui/menu-view" @@ -22,17 +20,20 @@ import { panel } from "@maple/views/ui/panel" import { personAvatar } from "@maple/views/ui/person-avatar" import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" import { skeleton } from "@maple/views/ui/skeleton" -import { hstack, vstack } from "@maple/views/ui/stack" +import { hstack, hstackClass, vstack } from "@maple/views/ui/stack" import { textLink } from "@maple/views/ui/text-link" import { textarea } from "@maple/views/ui/textarea" import { typography } from "@maple/views/ui/typography" import type { PageViewInputs } from "../../contract" +import { chainConnector } from "../../kit/chain-connector" import { dashboardPage } from "../../kit/dashboard-page" import { managersOnlyPage } from "../../kit/form-section" -import { type PickedEntry, pickList } from "../../kit/pick-list" -import { chainSummary } from "../shared/escalation-chains" +import { type PickedChip, pickChips } from "../../kit/pick-chips" +import { resourceNotFound } from "../../kit/resource-not-found" +import { CHAIN_NOT_FOUND_CODE, chainSummary, routingTabHref } from "../shared/escalation-chains" import { ruleHref } from "../shared/links" import { memberName, memberOf } from "../shared/org-members" +import { providerLogo } from "../shared/providers" import { canAddStep, descriptionError, @@ -49,7 +50,7 @@ import { } from "./form" import { Message } from "./message" import { canManage, type Model } from "./model" -import { backHref, DELETE_DIALOG_ID, menuIdOf } from "./update" +import { backHref, DELETE_DIALOG_ID, insertButtonId, LEAVE_DIALOG_ID, menuIdOf } from "./update" // The escalation chain editor: the chain as a column of sentence cards, its name and preview beside it. @@ -77,6 +78,10 @@ const nameOr = ( () => (rows === null ? fallback : `Unavailable ${Str.toLowerCase(fallback)}`), ) +/** A person by id; someone removed from the org reads "Unavailable member", as a deleted destination does. */ +const personName = (model: Model, userId: string): string => + memberName(model.members, userId, "Unavailable member") + /** A field's error once a save was tried, or at once for a number typed out of range. */ const shown = (model: Model, error: Option.Option, isImmediate = false): Option.Option => model.isShowingErrors || isImmediate ? error : Option.none() @@ -99,10 +104,47 @@ const stateOf = (value: string, isInvalid: boolean): FieldState => ({ isTouched: false, }) +/** Nothing can be typed: a save is in flight, or the viewer may only read the chain. */ +const isLocked = (model: Model): boolean => model.isSaving || !canManage(model) + // STEP CARDS -/** A picked target's row: a destination's bell, a member's avatar, a schedule's calendar. */ -const targetEntry = (h: H, model: Model, key: string, target: V2EscalationTarget): PickedEntry => { +/** A destination's own mark (Slack, PagerDuty, email); a bell once the destination is gone. */ +const destinationMedia = (h: H, model: Model, id: AlertDestinationId): Html => + Option.match( + Option.flatMap( + Arr.findFirst(model.destinations ?? [], (row) => row.id === id), + rowToAlertDestinationDocument, + ), + { + onNone: () => icon(h, "BellIcon", { size: 14, className: "shrink-0 text-muted-foreground" }), + onSome: (destination) => + providerLogo(h, { + type: destination.type, + chatConnector: destination.chatConnector ?? undefined, + size: 30, + isBare: true, + className: hstackClass({ className: "shrink-0" }), + isLogoBroken: model.brokenLogos.includes(destination.type), + onLogoError: Message.FailedLoadProviderLogo({ type: destination.type }), + }), + }, + ) + +/** Who a schedule pages right now, once the schedules have loaded. */ +const onCallNote = (model: Model, scheduleId: string): string | undefined => + Option.getOrUndefined( + Option.map( + Arr.findFirst(model.schedules ?? [], (schedule) => schedule.id === scheduleId), + (schedule) => + schedule.on_call_now === null + ? "nobody on call" + : `${personName(model, schedule.on_call_now.user_id)} on call`, + ), + ) + +/** A picked target's chip: a destination's logo, a member's avatar, a schedule's calendar. */ +const targetChip = (h: H, model: Model, key: string, target: V2EscalationTarget): PickedChip => { const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) switch (target.type) { case "destination": { @@ -110,13 +152,13 @@ const targetEntry = (h: H, model: Model, key: string, target: V2EscalationTarget return { key: targetKey(target), label: name, - media: icon(h, "BellIcon", { className: "text-muted-foreground" }), + media: destinationMedia(h, model, target.destination_id), removeLabel: `Stop notifying ${name}`, onRemove, } } case "user": { - const name = memberName(model.members, target.user_id) + const name = personName(model, target.user_id) return { key: targetKey(target), label: name, @@ -127,18 +169,18 @@ const targetEntry = (h: H, model: Model, key: string, target: V2EscalationTarget Option.map(memberOf(model.members, target.user_id), (member) => member.imageUrl), ), }), - detail: "email and chat DM", removeLabel: `Stop paging ${name}`, onRemove, } } case "schedule": { const name = nameOr(model.schedules, target.schedule_id, "Schedule") + const detail = onCallNote(model, target.schedule_id) return { key: targetKey(target), label: name, - media: icon(h, "CalendarIcon", { className: "text-muted-foreground" }), - detail: "whoever is on call", + media: icon(h, "CalendarIcon", { size: 14, className: "shrink-0 text-muted-foreground" }), + ...(detail === undefined ? undefined : { detail }), removeLabel: `Stop paging ${name}`, onRemove, } @@ -146,8 +188,26 @@ const targetEntry = (h: H, model: Model, key: string, target: V2EscalationTarget } } +/** The picker with nothing to offer: where a destination is made (in its own tab, keeping the draft). */ +const nothingToAdd = (h: H, model: Model): Html => + h.div( + [h.Class("px-2 py-1.5")], + [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + (model.destinations ?? []).length === 0 + ? "No destinations yet. " + : "Everything is on this step already. ", + textLink(h, { href: routingTabHref(), attributes: [h.Target("_blank"), h.Rel("noopener")] }, [ + "Create a destination", + ]), + ]), + ], + ) + +const loadingEntry = (label: string): MenuEntry => ({ _tag: "Label", label }) + /** The target picker: destinations, people and schedules not yet on the step, by kind. */ -const addEntries = (model: Model, step: StepDraft): ReadonlyArray => { +const addEntries = (h: H, model: Model, step: StepDraft): ReadonlyArray => { const picked = new Set(Arr.map(step.targets, targetKey)) const group = ( groupLabel: string, @@ -164,11 +224,16 @@ const addEntries = (model: Model, step: StepDraft): ReadonlyArray => }, ] } - return [ - ...group( - "Destinations", - Arr.map(model.destinations ?? [], (row) => ({ value: `destination:${row.id}`, label: row.name })), - ), + const entries: ReadonlyArray = [ + ...(model.destinations === null + ? [loadingEntry("Loading destinations")] + : group( + "Destinations", + Arr.map(model.destinations, (row) => ({ + value: `destination:${row.id}`, + label: row.name, + })), + )), ...group( "People", Arr.map(model.members ?? [], (member) => ({ @@ -176,14 +241,19 @@ const addEntries = (model: Model, step: StepDraft): ReadonlyArray => label: member.name, })), ), - ...group( - "Whoever is on call on", - Arr.map(model.schedules ?? [], (schedule) => ({ - value: `schedule:${schedule.id}`, - label: schedule.name, - })), - ), + ...(model.schedules === null + ? model.isSchedulesFailed + ? [] + : [loadingEntry("Loading schedules")] + : group( + "Whoever is on call on", + Arr.map(model.schedules, (schedule) => ({ + value: `schedule:${schedule.id}`, + label: schedule.name, + })), + )), ] + return entries.length === 0 ? [{ _tag: "Content", content: nothingToAdd(h, model) }] : entries } const stepActions = (model: Model, index: number): ReadonlyArray => { @@ -216,13 +286,13 @@ const stepActions = (model: Model, index: number): ReadonlyArray => { ] } -/** What the wait after a step is for: the next step, a repeat, or nothing once the chain stops. */ -const waitTail = (model: Model, index: number): string => - index < model.form.steps.length - 1 - ? "for an acknowledgement" - : (numberOf(model.form.repeatCount) ?? 0) > 0 - ? "for an acknowledgement, then repeat" - : "for an acknowledgement" +/** What follows a step's wait: the next step, another pass, or nothing once the chain stops. */ +const waitTail = (model: Model, index: number): string => { + if (index < model.form.steps.length - 1) return "minutes for an acknowledgement" + return (numberOf(model.form.repeatCount) ?? 0) > 0 + ? "minutes for an acknowledgement, then repeat" + : "minutes, then stop" +} const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { const key = step.key @@ -230,31 +300,39 @@ const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { const targetsMenuId = menuIdOf("targets", key) const actionsMenuId = menuIdOf("actions", key) const waitId = `escalation-chain-wait-${key}` - const waitState = stateOf( - step.waitMinutes, - Option.isSome(shown(model, wait, isOutOfRange(step.waitMinutes, wait))), - ) - return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ - hstack(h, { gap: "2", justify: "between" }, [ - typography(h, { size: "sm", weight: "medium" }, [index === 0 ? "Notify" : "Then notify"]), - rowActionsMenu(h, { - slotId: actionsMenuId, - model: model.menus[actionsMenuId] ?? Menu.init(actionsMenuId), - entries: stepActions(model, index), - label: `Actions for step ${index + 1}`, - toParentMessage: (message) => Message.GotMenuMessage({ kind: "actions", key, message }), - }), + const shownWait = shown(model, wait, isOutOfRange(step.waitMinutes, wait)) + const waitState = stateOf(step.waitMinutes, Option.isSome(shownWait)) + return panel(h, { padded: "sm", className: "gap-2", attributes: [h.Key(key)] }, [ + hstack(h, { gap: "2", align: "start" }, [ + hstack(h, { gap: "1.5", wrap: true, grow: true, shrink: true, className: "min-h-7" }, [ + typography(h, { size: "sm", weight: "medium", className: "mr-0.5" }, [ + index === 0 ? "Notify" : "Then notify", + ]), + ...pickChips(h, { + entries: Arr.map(step.targets, (target) => targetChip(h, model, key, target)), + menu: model.menus[targetsMenuId] ?? Menu.init(targetsMenuId), + toMenuMessage: (message) => Message.GotMenuMessage({ kind: "targets", key, message }), + addEntries: addEntries(h, model, step), + addLabel: "Add destination, person or schedule", + addAriaLabel: `Add destination, person or schedule to step ${index + 1}`, + isDisabled: model.isSaving, + isReadOnly: !canManage(model), + emptyText: "nobody", + }), + ]), + ...(canManage(model) + ? [ + rowActionsMenu(h, { + slotId: actionsMenuId, + model: model.menus[actionsMenuId] ?? Menu.init(actionsMenuId), + entries: stepActions(model, index), + label: `Actions for step ${index + 1}`, + toParentMessage: (message) => + Message.GotMenuMessage({ kind: "actions", key, message }), + }), + ] + : []), ]), - pickList(h, { - entries: Arr.map(step.targets, (target) => targetEntry(h, model, key, target)), - menu: model.menus[targetsMenuId] ?? Menu.init(targetsMenuId), - toMenuMessage: (message) => Message.GotMenuMessage({ kind: "targets", key, message }), - addEntries: addEntries(model, step), - addLabel: "Add destination, person or schedule", - isDisabled: model.isSaving, - context: `step ${index + 1}`, - emptyText: "Pick who this step pages", - }), ...errorText(h, shown(model, targetsError(step.targets))), hstack(h, { gap: "2", wrap: true }, [ fieldLabel(h, { ids: idsOf(waitId), state: waitState, className: "font-normal" }, "then wait"), @@ -262,7 +340,7 @@ const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { ids: idsOf(waitId), state: waitState, className: "w-20", - isDisabled: model.isSaving, + isDisabled: isLocked(model), attributes: [ h.Type("number"), h.Min("1"), @@ -273,34 +351,37 @@ const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { h.OnInput((value) => Message.ChangedWait({ key, value })), ], }), - typography(h, { size: "sm" }, [`minutes ${waitTail(model, index)}`]), + typography(h, { size: "sm" }, [waitTail(model, index)]), ]), - ...errorText(h, shown(model, wait, isOutOfRange(step.waitMinutes, wait))), + ...errorText(h, shownWait), ]) } -/** The line between two cards, with a "+" that adds a step there. */ -const connector = (h: H, model: Model, index: number): Html => - vstack(h, { align: "center", attributes: [h.Key(`connector-${index}`)] }, [ - separator(h, "vertical", "h-3"), - iconButton( - h, - { - label: index === 0 ? "Add a step at the start" : `Add a step after step ${index}`, - variant: "outline", - size: "icon-xs", - isDisabled: model.isSaving || !canAddStep(model.form), - attributes: [h.OnClick(Message.ClickedInsertStep({ index }))], - }, - [icon(h, "PlusIcon", { size: 12 })], - ), - separator(h, "vertical", "h-3"), - ]) +/** The line between two cards; while a step can be added, an editor gets a + on it that adds one there. */ +const connector = (h: H, model: Model, index: number): Html => { + const count = model.form.steps.length + return chainConnector(h, { + attributes: [h.Key(`connector-${index}`)], + insert: + canManage(model) && !model.isSaving && canAddStep(model.form) + ? { + id: insertButtonId(index), + label: + index === 0 + ? "Add a step at the start" + : index === count + ? "Add a step at the end" + : `Add a step after step ${index}`, + onClick: Message.ClickedInsertStep({ index }), + } + : null, + }) +} const startCard = (h: H): Html => panel(h, { padded: "sm", tone: "muted" }, [ hstack(h, { gap: "2" }, [ - icon(h, "BellIcon", { className: "text-muted-foreground" }), + icon(h, "BellIcon", { size: 16, className: "shrink-0 text-muted-foreground" }), typography(h, { size: "sm" }, ["When an alert escalates to this chain"]), ]), ]) @@ -309,35 +390,35 @@ const endCard = (h: H, model: Model): Html => { const repeat = repeatError(model.form.repeatCount) const count = numberOf(model.form.repeatCount) ?? 0 const id = "escalation-chain-repeat" - const state = stateOf( - model.form.repeatCount, - Option.isSome(shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat))), - ) + const shownRepeat = shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat)) + const state = stateOf(model.form.repeatCount, Option.isSome(shownRepeat)) return panel(h, { padded: "sm", tone: "muted", className: "gap-2" }, [ - hstack(h, { gap: "2", wrap: true }, [ - icon(h, "ArrowPathIcon", { className: "text-muted-foreground" }), - fieldLabel( - h, - { ids: idsOf(id), state, className: "font-normal" }, - "If nobody acknowledges, repeat the chain", - ), - input(h, { - ids: idsOf(id), - state, - className: "w-20", - isDisabled: model.isSaving, - attributes: [ - h.Type("number"), - h.Min("0"), - h.Attribute("max", String(MAX_ESCALATION_REPEATS)), - h.Attribute("step", "1"), - h.Value(model.form.repeatCount), - h.OnInput((value) => Message.ChangedRepeatCount({ value })), - ], - }), - typography(h, { size: "sm" }, [count === 0 ? "times, then stop" : pluralize(count, "time")]), + hstack(h, { gap: "2" }, [ + icon(h, "ArrowPathIcon", { size: 16, className: "shrink-0 text-muted-foreground" }), + hstack(h, { gap: "2", wrap: true, shrink: true }, [ + fieldLabel( + h, + { ids: idsOf(id), state, className: "font-normal" }, + "If nobody acknowledges, repeat the chain", + ), + input(h, { + ids: idsOf(id), + state, + className: "w-16", + isDisabled: isLocked(model), + attributes: [ + h.Type("number"), + h.Min("0"), + h.Attribute("max", String(MAX_ESCALATION_REPEATS)), + h.Attribute("step", "1"), + h.Value(model.form.repeatCount), + h.OnInput((value) => Message.ChangedRepeatCount({ value })), + ], + }), + typography(h, { size: "sm" }, [count === 0 ? "times, then stop" : pluralize(count, "time")]), + ]), ]), - ...errorText(h, shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat))), + ...errorText(h, shownRepeat), ]) } @@ -351,6 +432,13 @@ const chainColumn = (h: H, model: Model): Html => connector(h, model, model.form.steps.length), endCard(h, model), ...errorText(h, shown(model, stepsError(model.form.steps))), + ...(canManage(model) && !canAddStep(model.form) + ? [ + typography(h, { size: "xs", tone: "muted", as: "p", className: "mt-2" }, [ + `A chain has at most ${MAX_ESCALATION_STEPS} steps`, + ]), + ] + : []), ]) // ASIDE @@ -367,7 +455,7 @@ const preview = (h: H, model: Model): Html => { chain.steps.length === 0 ? "Pick who the first step pages to see the chain in one line." : chainSummary(chain, destinationName(model), { - userName: (id) => memberName(model.members, id), + userName: (id) => personName(model, id), scheduleName: (id) => model.schedules === null ? undefined : nameOr(model.schedules, id, "Schedule"), }) @@ -377,6 +465,53 @@ const preview = (h: H, model: Model): Html => { ]) } +/** Name, notes and the one-line preview. */ +const details = (h: H, model: Model): Html => { + const name = shown(model, nameError(model.form.name)) + const description = shown(model, descriptionError(model.form.description)) + const nameState = stateOf(model.form.name, Option.isSome(name)) + const notesIds = idsOf("escalation-chain-description") + return panel(h, { padded: "sm", className: "order-first gap-4 lg:order-none" }, [ + vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids: idsOf("escalation-chain-name"), state: nameState }, "Name"), + input(h, { + ids: idsOf("escalation-chain-name"), + state: nameState, + isDisabled: isLocked(model), + attributes: [ + h.Value(model.form.name), + h.Placeholder("Checkout on-call"), + h.Attribute("autocomplete", "off"), + h.OnInput((value) => Message.ChangedName({ value })), + ], + }), + ...errorText(h, name), + ]), + vstack(h, { gap: "1.5" }, [ + fieldLabel( + h, + { ids: notesIds, state: stateOf(model.form.description, Option.isSome(description)) }, + "Notes", + ), + textarea(h, { + id: notesIds.control, + defaultValue: model.form.description, + isDisabled: isLocked(model), + attributes: [ + // Uncontrolled: keyed on the chain, so a loaded chain rebuilds it with its text. + h.Key(`escalation-chain-description-${model.editingId ?? "new"}`), + h.AriaLabelledBy(notesIds.label), + h.Placeholder("Optional: who is on this chain and when to change it"), + h.Rows(2), + h.OnInput((value) => Message.ChangedDescription({ value })), + ], + }), + ...errorText(h, description), + ]), + preview(h, model), + ]) +} + /** The rules whose tiers start this chain, as links; ones not synced yet are counted. */ const usedBy = (h: H, model: Model): ReadonlyArray => Option.match(AsyncData.getData(model.existing.data), { @@ -405,6 +540,14 @@ const usedBy = (h: H, model: Model): ReadonlyArray => typography(h, { size: "sm" }, [ textLink(h, { href: ruleHref(rule.id) }, [rule.name]), ]), + // A paused rule starts no chain until it is resumed. + ...(rule.enabled + ? [] + : [ + typography(h, { size: "xs", tone: "muted" }, [ + " · paused", + ]), + ]), ], ), ), @@ -428,73 +571,36 @@ const usedBy = (h: H, model: Model): ReadonlyArray => }, }) -const aside = (h: H, model: Model): Html => { - const name = shown(model, nameError(model.form.name)) - const description = shown(model, descriptionError(model.form.description)) - const nameState = stateOf(model.form.name, Option.isSome(name)) - const notesIds = idsOf("escalation-chain-description") - return h.aside( - [h.Class("order-first min-w-0 lg:order-none")], - [ - panel(h, { padded: "sm", className: "gap-4 lg:sticky lg:top-4" }, [ - vstack(h, { gap: "1.5" }, [ - fieldLabel(h, { ids: idsOf("escalation-chain-name"), state: nameState }, "Name"), - input(h, { - ids: idsOf("escalation-chain-name"), - state: nameState, - isDisabled: model.isSaving, - attributes: [ - h.Value(model.form.name), - h.Placeholder("Checkout on-call"), - h.Attribute("autocomplete", "off"), - h.OnInput((value) => Message.ChangedName({ value })), - ], - }), - ...errorText(h, name), - ]), - vstack(h, { gap: "1.5" }, [ - fieldLabel( +/** Under the details: who uses the chain, and its quiet Delete. */ +const meta = (h: H, model: Model): ReadonlyArray => { + const rows = [ + ...usedBy(h, model), + ...(model.editingId !== null && canManage(model) + ? [ + button( h, - { ids: notesIds, state: stateOf(model.form.description, Option.isSome(description)) }, - "Notes", + { + variant: "destructive-ghost", + size: "sm", + className: "-ml-2 self-start", + isDisabled: model.isSaving || model.isDeleting, + attributes: [h.OnClick(Message.ClickedDelete())], + }, + [icon(h, "TrashIcon"), "Delete chain"], ), - textarea(h, { - id: notesIds.control, - defaultValue: model.form.description, - isDisabled: model.isSaving, - attributes: [ - // Uncontrolled: keyed on the chain, so a loaded chain rebuilds it with its text. - h.Key(`escalation-chain-description-${model.editingId ?? "new"}`), - h.AriaLabelledBy(notesIds.label), - h.Placeholder("Optional: who is on this chain and when to change it"), - h.Rows(3), - h.OnInput((value) => Message.ChangedDescription({ value })), - ], - }), - ...errorText(h, description), - ]), - preview(h, model), - ...usedBy(h, model), - ...(model.editingId === null - ? [] - : [ - button( - h, - { - variant: "outline", - size: "sm", - className: "self-start", - isDisabled: model.isSaving || model.isDeleting, - attributes: [h.OnClick(Message.ClickedDelete())], - }, - [icon(h, "TrashIcon"), "Delete chain"], - ), - ]), - ]), - ], - ) + ] + : []), + ] + return rows.length === 0 ? [] : [vstack(h, { gap: "3", className: "px-1" }, rows)] } +// Below `lg` the aside dissolves into the grid: the details lead, the chain follows, then the rest. +const aside = (h: H, model: Model): Html => + h.aside( + [h.Class("contents lg:sticky lg:top-0 lg:block lg:space-y-4")], + [details(h, model), ...meta(h, model)], + ) + /** "Delete {chain}?": lists the rules that start it, which the API makes move off it first. */ const deleteDialog = (h: H, model: Model): Html => { const chain = AsyncData.getData(model.existing.data) @@ -530,7 +636,9 @@ const deleteDialog = (h: H, model: Model): Html => { [], [ typography(h, { size: "sm", tone: "muted" }, [ - `and ${ruleIds.length - names.length} more`, + names.length === 0 + ? `${ruleIds.length} ${pluralize(ruleIds.length, "rule")}` + : `and ${ruleIds.length - names.length} more`, ]), ], ), @@ -557,23 +665,42 @@ const deleteDialog = (h: H, model: Model): Html => { }) } +/** "Unsaved changes": a link, Back or Cancel held while the form has edits. */ +const leaveDialog = (h: H, model: Model): Html => + confirmDialog(h, { + slotId: LEAVE_DIALOG_ID, + model: model.leaveDialog, + toParentMessage: (message) => Message.GotLeaveDialogMessage({ message }), + title: "Unsaved changes", + description: "This chain has changes you have not saved. Leave without saving them?", + cancelLabel: "Stay", + confirmLabel: "Discard changes", + onConfirm: Message.ClickedDiscard(), + }) + // PAGE const actions = (h: H, model: Model): Html => - hstack(h, { gap: "2" }, [ - linkButton(h, { variant: "outline", href: backHref(model) }, ["Cancel"]), - button( - h, - { - isDisabled: !canManage(model), - isLoading: model.isSaving, - attributes: [h.OnClick(Message.ClickedSave())], - }, - [model.editingId === null ? "Create chain" : "Save chain"], - ), - ]) + canManage(model) + ? hstack(h, { gap: "2" }, [ + button(h, { variant: "outline", attributes: [h.OnClick(Message.ClickedCancel())] }, [ + "Cancel", + ]), + button(h, { isLoading: model.isSaving, attributes: [h.OnClick(Message.ClickedSave())] }, [ + model.editingId === null ? "Create chain" : "Save chain", + ]), + ]) + : linkButton(h, { variant: "outline", href: backHref() }, ["Back to routing"]) -const saveNotice = (h: H, model: Model): ReadonlyArray => [ +const notices = (h: H, model: Model): ReadonlyArray => [ + ...(canManage(model) + ? [] + : [ + alert(h, { variant: "info", size: "sm" }, [ + icon(h, "ShieldIcon"), + alertTitle(h, ["Only org admins and engineers can change escalation chains"]), + ]), + ]), ...(model.saveFailure === null ? [] : [ @@ -582,7 +709,11 @@ const saveNotice = (h: H, model: Model): ReadonlyArray => [ alertTitle(h, [ model.editingId === null ? "Couldn't create chain" : "Couldn't save chain", ]), - alertDescription(h, [failureMessage(model.saveFailure)]), + alertDescription(h, [ + model.saveFailure.code === CHAIN_NOT_FOUND_CODE + ? "This chain no longer exists. Someone deleted it while you were editing." + : failureMessage(model.saveFailure), + ]), ]), ]), ...(model.isShowingErrors && formIssues(model.form).length > 0 @@ -594,6 +725,13 @@ const saveNotice = (h: H, model: Model): ReadonlyArray => [ : []), ] +const notFound = (h: H): Html => + resourceNotFound(h, { + title: "Escalation chain not found", + description: "It may have been deleted, or the link is wrong.", + back: { href: backHref(), label: "Back to routing" }, + }) + export const view = Submodel.defineView((model, _inputs, h) => { const isEdit = model.editingId !== null const header = { @@ -601,36 +739,36 @@ export const view = Submodel.defineView((model, description: "Page step by step until someone acknowledges. Acknowledging pauses it, resolving ends it.", } - if (model.isSessionLoaded && !canManage(model)) + const loading = dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + if (model.isBadId) return dashboardPage(h, header, [notFound(h)]) + if (!model.isSessionLoaded) return loading + // Nothing to read on a new chain: a viewer who cannot create one is told who can. + if (!isEdit && !canManage(model)) return managersOnlyPage(h, { header, - title: "Only org admins and engineers can create or change escalation chains", - back: { href: backHref(model), label: "Back to routing" }, + title: "Only org admins and engineers can create escalation chains", + back: { href: backHref(), label: "Back to routing" }, }) - if (model.isBadId) - return dashboardPage(h, header, [ - resourceNotFound(h, { - title: "Escalation chain not found", - description: "This link does not name an escalation chain.", - back: { href: backHref(model), label: "Back to routing" }, - }), - ]) if (isEdit && AsyncData.isFailure(model.existing.data)) return dashboardPage(h, header, [ - errorState(h, { - failure: model.existing.data.error, - title: "Couldn't load the chain", - onRetry: Message.ClickedRetryExisting(), - }), + model.existing.data.error.code === CHAIN_NOT_FOUND_CODE + ? notFound(h) + : errorState(h, { + failure: model.existing.data.error, + title: "Couldn't load the chain", + onRetry: Message.ClickedRetryExisting(), + }), ]) - if (isEdit && !AsyncData.isSuccess(model.existing.data)) - return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + if (isEdit && Option.isNone(AsyncData.getData(model.existing.data))) return loading return dashboardPage(h, { ...header, actions: actions(h, model) }, [ - ...saveNotice(h, model), - h.div( - [h.Class("grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start")], - [chainColumn(h, model), aside(h, model)], - ), + vstack(h, { gap: "4" }, [ + ...notices(h, model), + h.div( + [h.Class("grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start")], + [chainColumn(h, model), aside(h, model)], + ), + ]), deleteDialog(h, model), + leaveDialog(h, model), ]) }) diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 4926ab9812..e9482b8cb2 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -12,11 +12,10 @@ import { type V2EscalationChainCreateParams, } from "@maple/domain/http/v2" import { formatAlertTimeNear } from "@maple/web-data/alerts/form-utils" -import { Array as Arr, Effect, Option, Schema, String as Str } from "effect" +import { Array as Arr, Effect, Option, Schema } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" -import type { AlertsEscalationChainSearch } from "../../../route/search" // Escalation chain reads and writes as Effects the pages' Commands wrap, and how a chain and an // incident's run read in one line. @@ -76,20 +75,13 @@ const decodeChainId = Schema.decodeUnknownOption(EscalationChainPublicId) /** The `esc_…` id in a URL as the internal id. */ export const chainIdFromUrl = (raw: string): Option.Option => decodeChainId(raw) -export const chainHref = ( - id: EscalationChainId, - search: typeof AlertsEscalationChainSearch.Type = {}, -): string => hrefOf(AppRoute.AlertsEscalationChain({ chainId: encodeChainId(id), search })) +export const chainHref = (id: EscalationChainId): string => + hrefOf(AppRoute.AlertsEscalationChain({ chainId: encodeChainId(id), search: {} })) -export const newChainHref = (search: typeof AlertsEscalationChainSearch.Type = {}): string => - hrefOf(AppRoute.AlertsEscalationChainNew({ search })) +export const newChainHref = (): string => hrefOf(AppRoute.AlertsEscalationChainNew({ search: {} })) -/** A `returnTo` the editor may follow: an in-app path only, never another origin. */ -export const safeReturnTo = (raw: string | undefined): Option.Option => - Option.filter( - Option.fromNullishOr(raw), - (path) => Str.startsWith("/")(path) && !Str.startsWith("//")(path) && !Str.includes("\\")(path), - ) +/** 404 on a read or a write: the chain was deleted elsewhere. */ +export const CHAIN_NOT_FOUND_CODE = "escalation_chain_not_found" // SUMMARY diff --git a/apps/web/src/page/alerts/shared/org-members.ts b/apps/web/src/page/alerts/shared/org-members.ts index 92651bae91..506c314207 100644 --- a/apps/web/src/page/alerts/shared/org-members.ts +++ b/apps/web/src/page/alerts/shared/org-members.ts @@ -72,7 +72,12 @@ export const listOrgMembers: Effect.Effect, string> = c * A member by id; someone who left the org reads as "A former member". Until the members load, after * a failed read, or in an org past `MAX_MEMBERS`, an unknown id reads neutrally as "Member". */ -export const memberName = (members: ReadonlyArray | null, userId: string): string => +export const memberName = ( + members: ReadonlyArray | null, + userId: string, + /** What someone who left the org reads as, where the default does not fit. */ + formerName = "A former member", +): string => members === null || members.length >= MAX_MEMBERS ? Option.getOrElse( Option.map( @@ -86,7 +91,7 @@ export const memberName = (members: ReadonlyArray | null, userId: str Arr.findFirst(members, (member) => member.userId === userId), (member) => member.name, ), - () => "A former member", + () => formerName, ) export const memberOf = ( diff --git a/apps/web/src/page/kit/chain-connector.ts b/apps/web/src/page/kit/chain-connector.ts new file mode 100644 index 0000000000..b73b59e06f --- /dev/null +++ b/apps/web/src/page/kit/chain-connector.ts @@ -0,0 +1,40 @@ +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" + +// `ChainConnector`: the short line that joins two cards of a vertical chain. + +/** + * `ChainConnector`: the line between two cards of a vertical chain, with a + on it that adds a card + * there. The + shows while the gap is hovered or holds focus, and always on a touch screen. + */ +export const chainConnector = ( + h: HtmlBuilder, + options: Readonly<{ + /** The + button's id (a focus target) and accessible name; `null` draws the line alone. */ + insert: Readonly<{ id: string; label: string; onClick: M }> | null + attributes?: ReadonlyArray> + }>, +): Html => + h.div( + [h.Class("group/connector relative flex h-6 justify-center"), ...(options.attributes ?? [])], + [ + h.div([h.Class("h-full w-px bg-border"), h.AriaHidden(true)], []), + ...(options.insert === null + ? [] + : [ + iconButton( + h, + { + label: options.insert.label, + variant: "outline", + size: "icon-xs", + className: + "absolute top-1/2 -translate-y-1/2 opacity-0 group-hover/connector:opacity-100 group-active/connector:opacity-100 group-focus-within/connector:opacity-100 pointer-coarse:opacity-100", + attributes: [h.Id(options.insert.id), h.OnClick(options.insert.onClick)], + }, + [icon(h, "PlusIcon")], + ), + ]), + ], + ) diff --git a/apps/web/src/page/kit/pick-chips.ts b/apps/web/src/page/kit/pick-chips.ts new file mode 100644 index 0000000000..6cd33e14b4 --- /dev/null +++ b/apps/web/src/page/kit/pick-chips.ts @@ -0,0 +1,128 @@ +import { Array as Arr } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { icon } from "@maple/views/ui/icon" +import type * as Menu from "@maple/views/ui/menu" +import * as MenuView from "@maple/views/ui/menu-view" +import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" + +// `PickChips`: `PickList` for a sentence, where the picks sit inline on the sentence's own line. + +/** One picked chip: its media (a logo, an avatar, an icon), its name and how to remove it. */ +export type PickedChip = Readonly<{ + key: string + label: string + media: Html + /** A muted note after the name ("Ben Brook on call"). */ + detail?: string + removeLabel: string + onRemove: M +}> + +const chip = (h: HtmlBuilder, entry: PickedChip, canEdit: boolean, isDisabled: boolean): Html => + hstack( + h, + { + gap: "1.5", + inline: true, + as: "span", + className: `h-7 max-w-full rounded-md border border-border bg-surface-sunken pl-1.5 ${canEdit ? "pr-1" : "pr-2"}`, + attributes: [h.Key(entry.key)], + }, + [ + entry.media, + typography(h, { size: "sm", truncate: true }, [entry.label]), + ...(entry.detail === undefined + ? [] + : [ + typography( + h, + { size: "xs", tone: "muted", className: "hidden whitespace-nowrap sm:inline" }, + [entry.detail], + ), + ]), + ...(canEdit + ? [ + button( + h, + { + variant: "ghost", + size: "icon-2xs", + isDisabled, + attributes: [h.AriaLabel(entry.removeLabel), h.OnClick(entry.onRemove)], + }, + [icon(h, "XmarkIcon")], + ), + ] + : []), + ], + ) + +/** + * `PickChips`: what a form has picked as removable chips, then an "Add" chip that opens a menu of + * the rest; the chips and the button go inline on the caller's own wrapping line. + */ +export const pickChips = ( + h: HtmlBuilder, + options: Readonly<{ + entries: ReadonlyArray> + menu: Menu.Model + toMenuMessage: (message: Menu.Message) => M + addEntries: ReadonlyArray + /** The add button's text while nothing is picked; after that it reads "Add". */ + addLabel: string + /** The add button's accessible name, when a form shows several pickers ("... to step 2"). */ + addAriaLabel?: string + /** A save in flight: the picks show, but nothing can be removed or added. */ + isDisabled?: boolean + /** A viewer who cannot edit: the picks alone, and `emptyText` when there are none. */ + isReadOnly?: boolean + emptyText: string + }>, +): ReadonlyArray => { + const isDisabled = options.isDisabled ?? false + const canEdit = !(options.isReadOnly ?? false) + return [ + ...Arr.map(options.entries, (entry) => chip(h, entry, canEdit, isDisabled)), + ...(canEdit + ? [ + h.submodel({ + slotId: options.menu.id, + model: options.menu, + view: MenuView.view, + viewInputs: { + entries: options.addEntries, + align: "start", + className: "w-72", + toView: ({ trigger, withTrigger }) => + h.span( + [h.Class("contents")], + withTrigger( + button( + h, + { + variant: "dashed", + size: "xs", + isDisabled, + attributes: [ + h.AriaLabel(options.addAriaLabel ?? options.addLabel), + ...trigger, + ], + }, + [ + icon(h, "PlusIcon"), + options.entries.length === 0 ? options.addLabel : "Add", + ], + ), + ), + ), + }, + toParentMessage: options.toMenuMessage, + }), + ] + : options.entries.length === 0 + ? [typography(h, { size: "sm", tone: "muted" }, [options.emptyText])] + : []), + ] +} diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index eaddd00b69..90015c4a0a 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -380,10 +380,8 @@ export const AlertsScheduleNewSearch = Schema.Struct({ export const AlertsScheduleSearch = Schema.Struct({}) -/** `/alerts/escalation-chains/new` and `/:chainId`: `returnTo` is where Cancel and Save go back to. */ -export const AlertsEscalationChainSearch = Schema.Struct({ - returnTo: Schema.optional(Schema.String), -}) +/** `/alerts/escalation-chains/new` and `/:chainId`. */ +export const AlertsEscalationChainSearch = Schema.Struct({}) /** `/settings/ingestion/rules/new`: `template` prefills the editor from an empty-state template. */ export const IngestRuleNewSearch = Schema.Struct({ diff --git a/packages/ui/src/components/ui/button.styles.ts b/packages/ui/src/components/ui/button.styles.ts index eef0df92c5..296ba2dbf2 100644 --- a/packages/ui/src/components/ui/button.styles.ts +++ b/packages/ui/src/components/ui/button.styles.ts @@ -30,6 +30,9 @@ export const buttonVariants = cva( "not-disabled:inset-shadow-highlight border-destructive bg-destructive text-on-fill shadow-raised hover:bg-destructive/90 active:bg-destructive/90 data-pressed:bg-destructive/90 dark:border-destructive/80 dark:bg-destructive/80 dark:hover:bg-destructive/72 dark:active:bg-destructive/72 dark:data-pressed:bg-destructive/72 *:data-[slot=button-loading-indicator]:text-on-fill [:active,[data-pressed]]:inset-shadow-pressed [:disabled,:active,[data-pressed]]:shadow-none", "destructive-outline": "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-raised not-disabled:not-active:not-data-pressed:before:shadow-control hover:border-destructive-a3 active:border-destructive-a3 hover:bg-destructive-a1 active:bg-destructive-a1 data-pressed:border-destructive-a3 data-pressed:bg-destructive-a1 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 [:disabled,:active,[data-pressed]]:shadow-none", + // A quiet delete beside other content ("Delete chain"): destructive ink, a wash only on hover. + "destructive-ghost": + "border-transparent text-destructive-foreground hover:bg-destructive-a1 active:bg-destructive-a1 data-pressed:bg-destructive-a1 *:data-[slot=button-loading-indicator]:text-foreground", // Add-a-row affordances ("Add threshold", "New dashboard"): an outline that reads as an empty slot. dashed: "active:scale-100! border-dashed border-input bg-transparent text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground data-pressed:bg-surface-sunken *:data-[slot=button-loading-indicator]:text-foreground", ghost: "border-transparent text-foreground hover:bg-accent active:bg-accent data-pressed:bg-accent *:data-[slot=button-loading-indicator]:text-foreground", diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 3ce934a3bd..68c79dc9d2 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -181,6 +181,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). ## apps/web/src/page/kit - `apps/web/src/page/kit/autocomplete-lookups.ts` `lookupField`, `autocompleteLookup`: The autocomplete lookups a query builder fetches once it is first focused, each reduced to the names it suggests. +- `apps/web/src/page/kit/chain-connector.ts` `chainConnector`: `ChainConnector`: the short line that joins two cards of a vertical chain. - `apps/web/src/page/kit/clipboard.ts` `writeClipboard`, `copyToast`: `useCopy`'s write: the clipboard API, then the `execCommand` fallback; `true` when it landed. - `apps/web/src/page/kit/commit-card.ts` `isResolvableSha`, `commitDetailKey`, `commitDetailsKey`, `readCommitDetail`, `readCommitDetails`, `loadCommitDetail`, `commitDetailBody`: The commit SHA hover card: the body `CommitDetailBody` renders once `commitQueryAtom(sha)` settles, and the lookup itself. - `apps/web/src/page/kit/compliance-line.ts` `compliance`: The compliance footnote under the plan pickers (DPA available, SOC 2 and ISO 27001 in progress). @@ -206,6 +207,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/nav-shell.ts` `navShell`: `NavShell`: a page's own nav rail of titled sections (settings, security, alerts). - `apps/web/src/page/kit/page-rail-sheet.ts` `pageRailSheet`: `PageLayout.FilterSidebar` / `RightSidebar` below `lg`: the page's rail in a side sheet (``) with an sr-only header. - `apps/web/src/page/kit/peek-keys.ts` `peekStepFor`: The list peeks' step keys: down/J forward, up/K back, except in a text field or with a modifier. +- `apps/web/src/page/kit/pick-chips.ts` `pickChips`: `PickChips`: `PickList` for a sentence, where the picks sit inline on the sentence's own line. - `apps/web/src/page/kit/pick-list.ts` `pickList`: `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an ordered list, movable, then an "Add" menu of the rest. - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. - `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `switchDataSource`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. From 19047dfa8b96486a4b537b71b3bf5dc4bfea7641 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 01:52:29 +0100 Subject: [PATCH 10/35] chore(lab): run the escalation editor's focus moves in its lab --- apps/web/src/lab/escalation-editor-lab.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lab/escalation-editor-lab.ts b/apps/web/src/lab/escalation-editor-lab.ts index 2ab2679f7e..8a4a84110b 100644 --- a/apps/web/src/lab/escalation-editor-lab.ts +++ b/apps/web/src/lab/escalation-editor-lab.ts @@ -6,6 +6,7 @@ import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "eff import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" +import { FocusChainElement } from "../page/alerts/escalation-chain/command" import * as ChainMessage from "../page/alerts/escalation-chain/message" import * as ChainModel from "../page/alerts/escalation-chain/model" import { init as chainInit, update as chainUpdate } from "../page/alerts/escalation-chain/update" @@ -113,10 +114,16 @@ const LONG = populatedChains([])[2] const NO_COMMANDS: PageCommands = [] +/** The page's local Commands: the kit's, and its own focus moves, which only touch the DOM. */ +const kept = (commands: PageCommands | undefined): PageCommands => [ + ...keepLocal(commands), + ...Arr.filter(commands ?? [], (command) => command.name === FocusChainElement.name), +] + const run = (model: ChainModel.Model, messages: ReadonlyArray) => Arr.reduce(messages, { model, commands: NO_COMMANDS }, (current, message) => { const next = chainUpdate(current.model, message, CONTEXT) - return { model: next.model, commands: [...current.commands, ...keepLocal(next.commands)] } + return { model: next.model, commands: [...current.commands, ...kept(next.commands)] } }) const pageFor = (state: LabState) => { @@ -172,7 +179,7 @@ export const update = (model: Model, message: Message): Return => const next = chainUpdate(model.page, child, CONTEXT) return { model: { ...model, page: next.model }, - commands: Command.mapMessages(keepLocal(next.commands), (inner) => + commands: Command.mapMessages(kept(next.commands), (inner) => Message.GotPageMessage({ message: inner }), ), } From d6373386c54879120367a36d2491c4de8f63b2c2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:04:58 +0100 Subject: [PATCH 11/35] fix(alerts): the last gap of an escalation chain always shows its add button --- apps/web/src/page/alerts/escalation-chain/view.ts | 1 + apps/web/src/page/kit/chain-connector.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index 45b4c451ec..2e768f1808 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -373,6 +373,7 @@ const connector = (h: H, model: Model, index: number): Html => { ? "Add a step at the end" : `Add a step after step ${index}`, onClick: Message.ClickedInsertStep({ index }), + isPinned: index === count, } : null, }) diff --git a/apps/web/src/page/kit/chain-connector.ts b/apps/web/src/page/kit/chain-connector.ts index b73b59e06f..3fd1a75319 100644 --- a/apps/web/src/page/kit/chain-connector.ts +++ b/apps/web/src/page/kit/chain-connector.ts @@ -1,3 +1,4 @@ +import { cn } from "@maple/ui/lib/utils" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" @@ -6,13 +7,14 @@ import { iconButton } from "@maple/views/ui/icon-button" /** * `ChainConnector`: the line between two cards of a vertical chain, with a + on it that adds a card - * there. The + shows while the gap is hovered or holds focus, and always on a touch screen. + * there. The + shows while the gap is hovered or holds focus, and always on a touch screen or + * when `isPinned` (the last gap, so a mouse user can see that the chain takes another card). */ export const chainConnector = ( h: HtmlBuilder, options: Readonly<{ /** The + button's id (a focus target) and accessible name; `null` draws the line alone. */ - insert: Readonly<{ id: string; label: string; onClick: M }> | null + insert: Readonly<{ id: string; label: string; onClick: M; isPinned?: boolean }> | null attributes?: ReadonlyArray> }>, ): Html => @@ -29,8 +31,11 @@ export const chainConnector = ( label: options.insert.label, variant: "outline", size: "icon-xs", - className: - "absolute top-1/2 -translate-y-1/2 opacity-0 group-hover/connector:opacity-100 group-active/connector:opacity-100 group-focus-within/connector:opacity-100 pointer-coarse:opacity-100", + className: cn( + "absolute top-1/2 -translate-y-1/2", + options.insert.isPinned !== true && + "opacity-0 group-hover/connector:opacity-100 group-active/connector:opacity-100 group-focus-within/connector:opacity-100 pointer-coarse:opacity-100", + ), attributes: [h.Id(options.insert.id), h.OnClick(options.insert.onClick)], }, [icon(h, "PlusIcon")], From f9d010fc46c278ef136e3e561fcb662ff917c012 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:22:57 +0100 Subject: [PATCH 12/35] fix(views): side panel dims the page lightly, and comes in a compact size - A `scrim-panel` token (12% black, 24% in dark) with no blur sits beside a side panel, so the page it edits stays readable. Dialogs and other sheets keep `scrim`. - `sidePanelSheet` takes `size`: `compact` (28rem) for a few short fields, `form` (42rem, the default) or `wide`. --- docs/design-rules.md | 3 ++- packages/ui/src/components/ui/sheet.styles.ts | 4 ++++ packages/ui/src/styles/tokens.css | 5 +++++ packages/views/src/ui/dialog-view.ts | 13 ++++++++++- packages/views/src/ui/sheet.ts | 22 +++++++++++++++---- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/design-rules.md b/docs/design-rules.md index 0ae8ecd8ea..7b109f0724 100644 --- a/docs/design-rules.md +++ b/docs/design-rules.md @@ -89,7 +89,8 @@ bordered box takes its parent's radius minus 1px: `before:rounded-lg-inset` insi `surface-strong`. - A see-through surface is a token, never `bg-background/NN` or `bg-card/NN` (`maple/surface-translucency`): `surface-well` is an inset well on a card, `surface-float` (with - `backdrop-blur-sm`) a label over a chart, map or media, `scrim` the layer behind a dialog. + `backdrop-blur-sm`) a label over a chart, map or media, `scrim` the layer behind a dialog, + `scrim-panel` the lighter, unblurred one beside a side panel (the page stays readable). - Layers come from the z scale, never `z-[…]` (`maple/no-arbitrary-z`): `z-1`..`z-10` inside a surface, `z-50` dialogs and sheets, `z-55` popovers and tooltips, `z-60` toasts and nested lists. A popup takes a popup radius (`maple/popup-radius`). diff --git a/packages/ui/src/components/ui/sheet.styles.ts b/packages/ui/src/components/ui/sheet.styles.ts index c05a45b746..e09ce45e39 100644 --- a/packages/ui/src/components/ui/sheet.styles.ts +++ b/packages/ui/src/components/ui/sheet.styles.ts @@ -1,6 +1,10 @@ export const sheetBackdropStyles = "fixed inset-0 z-50 bg-scrim backdrop-blur-sm transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0" +/** Behind a side panel: a lighter dim and no blur, so the page it edits stays readable. */ +export const sheetBackdropPanelStyles = + "fixed inset-0 z-50 bg-scrim-panel transition-opacity duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0" + export const sheetViewportStyles = "fixed inset-0 z-50 grid" export const sheetViewportBottomStyles = "grid grid-rows-[1fr_auto] pt-12" diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 580c9a01f6..e71375759d 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -222,6 +222,8 @@ --on-fill: oklch(1 0 0); /* The dim behind a dialog, sheet or command palette. */ --scrim: oklch(0 0 0 / 32%); + /* The dim beside a side panel: light enough to keep reading the page the panel belongs to. */ + --scrim-panel: oklch(0 0 0 / 12%); /* Media frames (the replay player): dark in both themes, the stage, fullscreen and its play scrim. */ --media: oklch(0.205 0 0); --media-backdrop: oklch(0 0 0); @@ -370,6 +372,8 @@ --warning-foreground: var(--color-amber-400); --control-highlight: 0 -1px oklch(1 0 0 / 6%); --surface-recessed: oklch(0 0 0 / 25%); + /* A dark page needs more black to read as set back; still far lighter than the dialog's. */ + --scrim-panel: oklch(0 0 0 / 24%); --border-raised: oklch(1 0 0 / 10%); } @@ -572,6 +576,7 @@ --color-media-scrim: var(--media-scrim); --color-on-fill: var(--on-fill); --color-scrim: var(--scrim); + --color-scrim-panel: var(--scrim-panel); --color-surface-recessed: var(--surface-recessed); --color-border-raised: var(--border-raised); /* Service-map namespace boxes: one hue per namespace, set on the box as `--namespace-hue`. diff --git a/packages/views/src/ui/dialog-view.ts b/packages/views/src/ui/dialog-view.ts index bfc6bbfadc..fc5610b4c1 100644 --- a/packages/views/src/ui/dialog-view.ts +++ b/packages/views/src/ui/dialog-view.ts @@ -30,6 +30,9 @@ import { focusGuard, isMounted, stateAttributes } from "./overlay/transition" export type SheetSide = "right" | "left" | "top" | "bottom" +/** A sheet's dim: `dialog` blurs and darkens the page, `panel` only dims it lightly. */ +export type SheetScrim = "dialog" | "panel" + type Parts = Readonly<{ backdrop: string viewport: string @@ -51,6 +54,7 @@ const partsOf = ( className: string | undefined, isBottomStick = true, size: SheetStyles.SheetSize = "default", + scrim: SheetScrim = "dialog", ): Parts => { switch (kind) { case "dialog": @@ -89,7 +93,11 @@ const partsOf = ( } case "sheet": return { - backdrop: cn(SheetStyles.sheetBackdropStyles), + backdrop: cn( + scrim === "panel" + ? SheetStyles.sheetBackdropPanelStyles + : SheetStyles.sheetBackdropStyles, + ), viewport: cn( SheetStyles.sheetViewportStyles, side === "bottom" && SheetStyles.sheetViewportBottomStyles, @@ -137,6 +145,8 @@ export type ViewInputs = Readonly<{ /** A left or right sheet's width (`SheetSize`): pick by content, never a `max-w-*` class. */ size?: SheetStyles.SheetSize hasTrigger?: boolean + /** A sheet's dim (`dialog` by default); see `SheetScrim`. */ + scrim?: SheetScrim /** `showCloseButton={false}`: the content renders its own close. */ showCloseButton?: boolean /** `closeProps={{ className }}`: classes merged onto the close button's own. */ @@ -166,6 +176,7 @@ export const view = Submodel.defineView((model, inpu inputs.className, inputs.isBottomStickOnMobile, inputs.size, + inputs.scrim, ) const slot = slotPrefix(model.kind) const isVisible = isMounted(model.isOpen, model.animation) diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 966070641b..a0b121df0a 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -134,12 +134,24 @@ export const leftSheet = ( // SIDE PANEL SHEET +/** + * How wide a side panel is, by what it holds: `compact` (28rem) a short form of a few fields, + * `form` (42rem) a full create or edit form, `wide` (48rem) reading content. + */ +export type SidePanelSize = "compact" | "form" | "wide" + +const SIDE_PANEL_SIZE = { + compact: "default", + form: "form", + wide: "wide", +} satisfies Record + export type SidePanelSheetOptions = Readonly<{ sheet: Dialog.Model title: string description?: string - /** `form` (672px) by default; `wide` for reading content. */ - size?: SheetSize + /** `form` by default; pick `compact` when the form is a handful of short fields. */ + size?: SidePanelSize /** The scrolling body. */ body: ReadonlyArray /** @@ -156,7 +168,8 @@ const REDUCED_MOTION = /** * A full-height panel from the right over the Dialog Submodel: title, close, a scrolling body and a - * sticky footer. Escape, the backdrop and the close button close it (`isDismissed`). + * sticky footer. Its dim is light and unblurred, so the page it belongs to stays readable. Escape, + * the backdrop and the close button close it (`isDismissed`). */ export const sidePanelSheet = ( h: HtmlBuilder, @@ -169,7 +182,8 @@ export const sidePanelSheet = ( view: DialogView.view, viewInputs: { side: "right", - size: options.size ?? "form", + size: SIDE_PANEL_SIZE[options.size ?? "form"], + scrim: "panel", className: REDUCED_MOTION, hasDescription: options.description !== undefined, toView: ({ portal }) => portal, From f1179ea918e3ea4b55e5bf1c436e87dce2f02d07 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:22:57 +0100 Subject: [PATCH 13/35] fix(shell): one-row on-call pill, calmer sidebar states - The on-call pill is one 32px row of the footer list: a status dot and "On call until Mon 09:00" in the viewer's zone (the date instead of the weekday once the handoff is over six days out), or a muted "Not on call". The zone, date and schedules are its tooltip and accessible name. On the collapsed rail it is a calendar glyph badged with the dot. - Group chevrons show faintly at rest; the toggle is 28px tall. - A folded group holding the current page shows that page's parent row alone, highlighted, without its sub-items. - Hover is a neutral wash. When a sub-item is the current page only it is tinted; its parent keeps the medium weight on a plain background. - Sub-item rows are 28px on desktop and 36px in the mobile sheet. - /lab/shell gains Services and Logs pages, a far handoff and both panel sizes. --- apps/web/src/lab/entries/shell.ts | 26 ++- .../src/lab/shell-states-lab.scene.test.ts | 22 +++ apps/web/src/lab/shell-states-lab.ts | 181 ++++++++++++++---- apps/web/src/shell/nav-groups.scene.test.ts | 19 ++ apps/web/src/shell/oncall.scene.test.ts | 49 +++-- apps/web/src/shell/oncall.ts | 49 +++-- apps/web/src/shell/view-nav.ts | 19 +- apps/web/src/shell/view-sidebar.ts | 125 ++++++------ apps/web/src/shell/view.ts | 4 +- .../ui/src/components/ui/sidebar.styles.ts | 8 +- packages/views/src/ui/sidebar.ts | 19 +- 11 files changed, 369 insertions(+), 152 deletions(-) diff --git a/apps/web/src/lab/entries/shell.ts b/apps/web/src/lab/entries/shell.ts index 8af4756284..ab51c1c6c3 100644 --- a/apps/web/src/lab/entries/shell.ts +++ b/apps/web/src/lab/entries/shell.ts @@ -1,12 +1,13 @@ -import { Effect, Stream } from "effect" +import { Effect, Schema, Stream } from "effect" import { Dom, Subscription } from "foldkit" +import * as TooltipHost from "@maple/views/ui/tooltip-host" import type { Resources } from "../../platform/resources" import * as Shell from "../../shell" import { labShellSubscriptions } from "../app-shell" import { defineLabWithFlags } from "../define" -import { Flags, init, Message, Model, toShell, update, view } from "../shell-states-lab" +import { Flags, init, Message, Model, toShell, toTooltip, update, view } from "../shell-states-lab" -// `/lab/shell`: sidebar group folding, the on-call pill's states and the URL-held right sheet. +// `/lab/shell`: sidebar group folding, the on-call pill's states and the URL-held side panel. const urlTarget: Dom.TypedEventTarget<{ popstate: PopStateEvent; "foldkit:urlchange": Event }> = window @@ -32,6 +33,25 @@ export const lab = defineLabWithFlags("Shell states", { labShellSubscriptions(toShell), Subscription.make()((entry) => ({ url: entry({}, { modelToDependencies: () => ({}), dependenciesToStream: () => urlChanges }), + // The app's tooltip, which the open sidebar's on-call row uses. + tooltipTriggers: entry( + {}, + { + modelToDependencies: () => ({}), + dependenciesToStream: () => Stream.map(TooltipHost.triggerEvents, toTooltip), + }, + ), + tooltipWhileEngaged: entry( + { isEngaged: Schema.Boolean }, + { + modelToDependencies: (model) => ({ isEngaged: TooltipHost.isEngaged(model.tooltip) }), + dependenciesToStream: ({ isEngaged }) => + Stream.when( + Stream.map(TooltipHost.whileEngaged, toTooltip), + Effect.sync(() => isEngaged), + ), + }, + ), })), ), }) diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 445addbe8f..7499d93bff 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -20,6 +20,7 @@ import { init, Message, type Model, + COMPACT_SHEET_ID, DismissSheetUrl, PushSheetUrl, SHEET_ID, @@ -94,6 +95,27 @@ describe("URL-held right sheet", () => { ) }) + test("the compact panel has its own ?sheet= id and content", () => { + const snooze = role("dialog", { name: "Snooze rule" }) + scene( + { update, view }, + given(at("/lab/shell")), + measured, + click(role("button", { name: "Snooze rule" })), + Command.expectExact(PushSheetUrl({ href: "/lab/shell", id: COMPACT_SHEET_ID })), + Command.resolve(PushSheetUrl, Message.CompletedNavigate()), + Subscription.emit(Message.ChangedUrl({ href: `/lab/shell?sheet=${COMPACT_SHEET_ID}` })), + ...enter(1), + expect(snooze).toContainText("Hours"), + keydown(portal, "Escape"), + Command.expectHas( + DismissSheetUrl({ href: `/lab/shell?sheet=${COMPACT_SHEET_ID}`, id: COMPACT_SHEET_ID }), + ), + Command.resolve(DismissSheetUrl, Message.CompletedNavigate()), + ...leave(2), + ) + }) + test("Back (the URL losing ?sheet=) closes it without touching the URL again", () => { const open = at(`/lab/shell?sheet=${SHEET_ID}`) const back = update(open, Message.ChangedUrl({ href: "/lab/shell" })) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 9e78acc414..49d780bef8 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -14,8 +14,9 @@ import { type SheetStep, sidePanelSheet, } from "@maple/views/ui/sheet" -import { vstack } from "@maple/views/ui/stack" +import { hstack, vstack } from "@maple/views/ui/stack" import { textarea } from "@maple/views/ui/textarea" +import * as TooltipHost from "@maple/views/ui/tooltip-host" import { typography } from "@maple/views/ui/typography" import type { Resources } from "../platform/resources" import { dashboardPage } from "../page/kit/dashboard-page" @@ -25,36 +26,63 @@ import { init as initShell, Model as ShellModel, OnCallData } from "../shell/mod import type { OnCallSnapshot } from "../shell/oncall" import { foldLabShell, labShellView } from "./shell-frame" -// `/lab/shell`: the real sidebar with fixture on-call states and a page to stand on, and the -// right sheet held in `?sheet=` (Back closes it, the link reopens it). +// `/lab/shell`: the real sidebar with fixture on-call states and a page to stand on, and the side +// panel held in `?sheet=` (Back closes it, the link reopens it) in both of its sizes. +/** The `form` panel: also the Dialog's id. */ export const SHEET_ID = "new-alert-rule" +/** The `compact` panel. */ +export const COMPACT_SHEET_ID = "snooze-rule" const LAB_PATH = "/lab/shell" -export const OnCallFixture = Schema.Literals(["onCall", "notOnCall", "none"]) +export const OnCallFixture = Schema.Literals(["onCall", "onCallFar", "notOnCall", "none"]) export type OnCallFixture = typeof OnCallFixture.Type -export const StandOn = Schema.Literals(["lab", "alerts"]) +export const StandOn = Schema.Literals(["lab", "alerts", "services", "logs"]) export type StandOn = typeof StandOn.Type +const STAND_ON_PATH = { + lab: LAB_PATH, + alerts: "/alerts", + services: "/services", + logs: "/logs", +} satisfies Record + +/** Which panel the URL names. */ +export const Panel = Schema.Literals(["rule", "snooze"]) +export type Panel = typeof Panel.Type + +const PANEL_ID = { rule: SHEET_ID, snooze: COMPACT_SHEET_ID } satisfies Record + /** Mon 16 Nov 2099, 08:00 UTC (09:00 CET): far enough out that the fixture shift never ends. */ const HANDOFF_MS = Date.UTC(2099, 10, 16, 8) +const DAY_MS = 24 * 60 * 60_000 +/** The lab's clock: three days before the handoff, so the near shift reads by its weekday. */ +const LAB_NOW_MS = HANDOFF_MS - 3 * DAY_MS const LAB_USER = "user_lab" +const shiftOf = (scheduleName: string, userId: string, endsAtMs: number) => ({ + scheduleName, + userId, + endsAtMs, +}) + +const SHIFTS = { + onCall: [shiftOf("Checkout primary", LAB_USER, HANDOFF_MS)], + // Over six days out, and a second schedule held at once. + onCallFar: [ + shiftOf("Checkout primary", LAB_USER, HANDOFF_MS + 7 * DAY_MS), + shiftOf("Payments", LAB_USER, HANDOFF_MS + 14 * DAY_MS), + ], + notOnCall: [shiftOf("Checkout primary", "user_other", HANDOFF_MS)], + none: [], +} satisfies Record + export const onCallSnapshotOf = (fixture: OnCallFixture): OnCallSnapshot => ({ userId: LAB_USER, viewerTimeZone: "Europe/Berlin", - shifts: - fixture === "none" - ? [] - : [ - { - scheduleName: "Checkout primary", - userId: fixture === "onCall" ? LAB_USER : "user_other", - endsAtMs: HANDOFF_MS, - }, - ], + shifts: SHIFTS[fixture], }) export const Flags = Schema.Struct({ @@ -72,6 +100,9 @@ export type Flags = typeof Flags.Type export const Model = Schema.Struct({ shell: ShellModel, sheet: Dialog.Model, + /** The panel the sheet shows; kept through its exit transition. */ + panel: Panel, + tooltip: TooltipHost.Model, href: Schema.String, onCall: OnCallFixture, standOn: StandOn, @@ -81,10 +112,11 @@ export type Model = typeof Model.Type export const Message = defineMessageUnion({ GotShellMessage: { message: ShellMessage }, GotSheetMessage: { message: Dialog.Message }, + GotTooltipMessage: { message: TooltipHost.Message }, /** The URL changed (a push, a replace, Back or Forward). */ ChangedUrl: { href: Schema.String }, - ClickedNewRule: {}, - ClickedCreateRule: {}, + ClickedOpenPanel: { panel: Panel }, + ClickedSubmitPanel: {}, ClickedOnCallFixture: { fixture: OnCallFixture }, ClickedStandOn: { standOn: StandOn }, CompletedNavigate: {}, @@ -110,31 +142,45 @@ export const DismissSheetUrl = Command.define("DismissSheetUrl", { type Return = Update.Return export const toShell = (message: ShellMessage): Message => Message.GotShellMessage({ message }) +export const toTooltip = (message: TooltipHost.Message): Message => Message.GotTooltipMessage({ message }) const toSheet = (message: Dialog.Message): Message => Message.GotSheetMessage({ message }) const foldShell = foldLabShell(toShell) -const isSheetInUrl = (href: string): boolean => Option.contains(sheetParamOf(href), SHEET_ID) +/** The panel `?sheet=` names, if it names one of the lab's. */ +const panelInUrl = (href: string): Option.Option => + Option.flatMap(sheetParamOf(href), (id) => + id === SHEET_ID + ? Option.some("rule") + : id === COMPACT_SHEET_ID + ? Option.some("snooze") + : Option.none(), + ) const sheetStep = (model: Model, step: SheetStep): Return => ({ model: { ...model, sheet: step.model }, commands: [ ...mapSheetCommands(step, toSheet), - ...(isDismissed(step) ? [DismissSheetUrl({ href: model.href, id: SHEET_ID })] : []), + ...(isDismissed(step) ? [DismissSheetUrl({ href: model.href, id: PANEL_ID[model.panel] })] : []), ], }) +/** The fixture's shifts, read on the lab's fixed clock. */ const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({ ...shell, onCall: OnCallData.Success({ data: onCallSnapshotOf(fixture) }), + onCallNowMs: LAB_NOW_MS, }) /** No org: the shell sends nothing, so the fixtures are all it shows. */ export const init = (flags: Flags): Return => { - const sheet = followUrl(Dialog.init(SHEET_ID, "sheet"), isSheetInUrl(flags.href)) + const open = panelInUrl(flags.href) + const sheet = followUrl(Dialog.init(SHEET_ID, "sheet"), Option.isSome(open)) return { model: { shell: withOnCall(initShell(flags.shell), "onCall"), sheet: sheet.model, + panel: Option.getOrElse(open, (): Panel => "rule"), + tooltip: TooltipHost.init("tooltip"), href: flags.href, onCall: "onCall", standOn: "lab", @@ -147,15 +193,31 @@ export const update = (model: Model, message: Message): Return => Message.match(message, { GotShellMessage: ({ message: inner }) => foldShell(model, inner), GotSheetMessage: ({ message: inner }) => sheetStep(model, Dialog.update(model.sheet, inner)), + GotTooltipMessage: ({ message: inner }) => { + const next = TooltipHost.update(model.tooltip, inner) + return { + model: { ...model, tooltip: next.model }, + commands: Command.mapMessages(next.commands ?? [], toTooltip), + } + }, ChangedUrl: ({ href }) => { - const step = followUrl(model.sheet, isSheetInUrl(href)) - return { model: { ...model, href, sheet: step.model }, commands: mapSheetCommands(step, toSheet) } + const open = panelInUrl(href) + const step = followUrl(model.sheet, Option.isSome(open)) + return { + model: { + ...model, + href, + sheet: step.model, + panel: Option.getOrElse(open, () => model.panel), + }, + commands: mapSheetCommands(step, toSheet), + } }, - ClickedNewRule: () => ({ + ClickedOpenPanel: ({ panel }) => ({ model, - commands: [PushSheetUrl({ href: model.href, id: SHEET_ID })], + commands: [PushSheetUrl({ href: model.href, id: PANEL_ID[panel] })], }), - ClickedCreateRule: () => sheetStep(model, Dialog.close(model.sheet)), + ClickedSubmitPanel: () => sheetStep(model, Dialog.close(model.sheet)), ClickedOnCallFixture: ({ fixture }) => ({ model: { ...model, onCall: fixture, shell: withOnCall(model.shell, fixture) }, }), @@ -171,7 +233,8 @@ const control = (h: HtmlBuilder, title: string, body: Html): Html => const field = (h: HtmlBuilder, id: string, text: string, control: Html): Html => vstack(h, { gap: "2" }, [label(h, { attributes: [h.For(id)] }, [text]), control]) -const sheet = (h: HtmlBuilder, model: Model): Html => +/** The `form` size: a full create form. */ +const rulePanel = (h: HtmlBuilder, model: Model): Html => sidePanelSheet(h, { sheet: model.sheet, title: "New alert rule", @@ -207,7 +270,37 @@ const sheet = (h: HtmlBuilder, model: Model): Html => ], footer: (close) => [ button(h, { variant: "outline", attributes: close }, ["Cancel"]), - button(h, { attributes: [h.OnClick(Message.ClickedCreateRule())] }, ["Create rule"]), + button(h, { attributes: [h.OnClick(Message.ClickedSubmitPanel())] }, ["Create rule"]), + ], + }) + +/** The `compact` size: two short fields. */ +const snoozePanel = (h: HtmlBuilder, model: Model): Html => + sidePanelSheet(h, { + sheet: model.sheet, + size: "compact", + title: "Snooze rule", + description: "The rule keeps checking but pages no one until the snooze ends.", + toParentMessage: toSheet, + body: [ + vstack(h, { gap: "5" }, [ + field( + h, + "snooze-hours", + "Hours", + input(h, { id: "snooze-hours", attributes: [h.Value("4")] }), + ), + field( + h, + "snooze-reason", + "Reason", + input(h, { id: "snooze-reason", attributes: [h.Placeholder("Deploying checkout")] }), + ), + ]), + ], + footer: (close) => [ + button(h, { variant: "outline", attributes: close }, ["Cancel"]), + button(h, { attributes: [h.OnClick(Message.ClickedSubmitPanel())] }, ["Snooze"]), ], }) @@ -216,8 +309,20 @@ const page = (h: HtmlBuilder, model: Model): Html => h, { title: "Shell states", - description: "Fold a sidebar group, switch the on-call fixture, or open the sheet.", - actions: button(h, { attributes: [h.OnClick(Message.ClickedNewRule())] }, ["New alert rule"]), + description: "Fold a sidebar group, switch the on-call fixture, or open a side panel.", + actions: hstack(h, { gap: "2" }, [ + button( + h, + { + variant: "outline", + attributes: [h.OnClick(Message.ClickedOpenPanel({ panel: "snooze" }))], + }, + ["Snooze rule"], + ), + button(h, { attributes: [h.OnClick(Message.ClickedOpenPanel({ panel: "rule" }))] }, [ + "New alert rule", + ]), + ]), }, [ vstack(h, { gap: "6" }, [ @@ -230,6 +335,7 @@ const page = (h: HtmlBuilder, model: Model): Html => onChange: (fixture) => Message.ClickedOnCallFixture({ fixture }), options: [ { value: "onCall", label: "On call" }, + { value: "onCallFar", label: "On call, next week" }, { value: "notOnCall", label: "Not on call" }, { value: "none", label: "No schedules" }, ], @@ -245,20 +351,25 @@ const page = (h: HtmlBuilder, model: Model): Html => options: [ { value: "lab", label: "Lab (no group)" }, { value: "alerts", label: "Alerts (Triage)" }, + { value: "services", label: "Services (Monitor)" }, + { value: "logs", label: "Logs (Analyze)" }, ], }), ), ]), - sheet(h, model), + model.panel === "snooze" ? snoozePanel(h, model) : rulePanel(h, model), ], ) export const view = (model: Model, h: HtmlBuilder): Html => h.div( [h.Id("app")], - labShellView(h, model.shell, toShell, { - currentPath: model.standOn === "alerts" ? "/alerts" : LAB_PATH, - breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], - page: () => page(h, model), - }), + [ + TooltipHost.view(h, model.tooltip, toTooltip), + ...labShellView(h, model.shell, toShell, { + currentPath: STAND_ON_PATH[model.standOn], + breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], + page: () => page(h, model), + }), + ], ) diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts index 77503cb45c..a85dab0187 100644 --- a/apps/web/src/shell/nav-groups.scene.test.ts +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -81,6 +81,25 @@ describe("sidebar groups", () => { ) }) + test("a folded group on a sub-item's page shows the parent row alone, highlighted", () => { + const explore = role("link", { name: "Explore" }) + const logs = role("link", { name: "Logs" }) + scene( + { update, view: shellAt("/logs") }, + given(signedIn()), + measured, + // Open: the tint is the sub-item's, the parent only keeps its weight. + expect(logs).toHaveAttr("data-active", "true"), + expect(explore).toHaveAttr("data-active", "child"), + click(analyze), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(explore).toHaveAttr("data-active", "true"), + expect(logs).toBeAbsent(), + expect(role("link", { name: "Traces" })).toBeAbsent(), + expect(dashboards).toBeAbsent(), + ) + }) + test("the collapsed rail shows every row of a folded group and its labels are no tab stops", () => { scene( { update, view: shellAt("/") }, diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 5ce01cc3bf..801ee39f55 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -79,35 +79,51 @@ const view = (() => { })() const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) -const footer = selector('[data-slot="sidebar-footer"]') describe("on-call pill", () => { - test("on call: until the handoff on the viewer's clock with its zone, the schedule, a link to schedules", () => { + test("on call: one row with the weekday and time, the rest in its tooltip and name", () => { + const pill = role("link", { name: "On call until Mon 09:00 CET, 16 Nov · Checkout primary" }) scene( { update, view }, given(withOnCall([shift()])), measured, - expect(footer).toContainText("You're on call"), - expect(footer).toContainText("until Mon 16 Nov, 09:00 CET"), - expect(footer).toContainText("Checkout primary"), - expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(pill).toHaveText("On call until Mon 09:00"), + expect(pill).toHaveAttr("href", "/alerts?tab=oncall"), + expect(pill).toHaveAttr("data-tooltip", "On call until Mon 09:00 CET, 16 Nov · Checkout primary"), ) }) - test("not on call: says so with a way to the schedules", () => { + test("a handoff more than six days out reads by its date, and other schedules are counted", () => { + const week = 7 * 24 * 60 * 60_000 + const pill = role("link", { name: "On call until 23 Nov, 09:00 CET · Checkout primary +1 more" }) + scene( + { update, view }, + given( + withOnCall([ + shift({ endsAtMs: HANDOFF + week }), + shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 2 * week }), + ]), + ), + measured, + expect(pill).toHaveText("On call until 23 Nov, 09:00"), + ) + }) + + test("not on call: says so, linking to the schedules", () => { + const pill = role("link", { name: "Not on call" }) scene( { update, view }, given(withOnCall([shift({ userId: "user_someone" })])), measured, - expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), - expect(footer).toContainText("My schedules"), + expect(pill).toHaveAttr("href", "/alerts?tab=oncall"), + expect(pill).not.toHaveAttr("data-tooltip"), ) }) test("an org without schedules, a first read that failed, or a shift that ended show no pill", () => { const absent = [ - expect(text("My schedules")).toBeAbsent(), - expect(text("You're on call")).toBeAbsent(), + expect(text("Not on call")).toBeAbsent(), + expect(text("On call until Mon 09:00")).toBeAbsent(), ] scene({ update, view }, given(withOnCall([])), measured, ...absent) scene( @@ -124,13 +140,14 @@ describe("on-call pill", () => { ) }) - test("on the collapsed rail the pill is a dot named by the sentence, which its tooltip shows", () => { - const pill = role("link", { name: "You're on call until Mon 16 Nov, 09:00 CET (Checkout primary)" }) + test("on the collapsed rail the pill is a badged glyph named by the detail, which its tooltip shows", () => { + const pill = role("link", { name: "On call until Mon 09:00 CET, 16 Nov · Checkout primary" }) scene( { update, view }, given(withOnCall([shift()], { isSidebarOpen: false })), measured, - expect(pill).toExist(), + expect(pill).toHaveText(""), + expect(selector('[data-slot="sidebar-footer"] [data-slot="status-dot"]')).toExist(), hover(pill), Command.resolve(Tooltip.WaitOpenDelay, Tooltip.Message.ElapsedOpenDelay({ generation: 1 })), Mount.resolve(Tooltip.MountTooltip, Tooltip.Message.CompletedMountTooltip()), @@ -143,7 +160,7 @@ describe("on-call pill", () => { Animation.Message.EndedAnimation({ generation: 1 }), ), expect(selector('[data-slot="tooltip-popup"]')).toHaveText( - "You're on call until Mon 16 Nov, 09:00 CET (Checkout primary)", + "On call until Mon 09:00 CET, 16 Nov · Checkout primary", ), ) }) @@ -236,7 +253,7 @@ describe("onCallStatusOf", () => { _tag: "OnCall", scheduleName: "Checkout primary", otherSchedules: 1, - until: "Mon 16 Nov, 09:00 CET", + until: { short: "Mon 09:00", rest: "CET, 16 Nov" }, }) }) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index 72664980f1..e29cee4d4d 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -29,13 +29,23 @@ export type OnCallStatus = scheduleName: string /** Other schedules the user also holds now. */ otherSchedules: number - until: string + /** The handoff as the pill's row says it, and what that leaves for the tooltip. */ + until: OnCallUntil | null }> +/** + * A handoff on the viewer's clock. `short` is "Mon 09:00", or "23 Nov, 09:00" once it is more than + * six days out (a weekday alone would be ambiguous); `rest` is what `short` leaves out: the zone, + * and the date when `short` has none. + */ +export type OnCallUntil = Readonly<{ short: string; rest: string }> + const untilFormatters = new Map() -/** "Mon 16 Nov, 09:00 CET" on the viewer's clock, naming the zone; built from parts, as ICU builds differ in the joiner. */ -export const formatUntil = (ms: number, timeZone: string): string => { +const SIX_DAYS_MS = 6 * 24 * 60 * 60_000 + +/** Built from parts, as ICU builds differ in how they join a date and a time. */ +export const formatUntil = (ms: number, timeZone: string, nowMs: number): OnCallUntil => { const cached = untilFormatters.get(timeZone) const formatter = cached ?? @@ -56,7 +66,11 @@ export const formatUntil = (ms: number, timeZone: string): string => { Arr.findFirst(parts, (candidate) => candidate.type === type), { onNone: () => "", onSome: (found) => found.value }, ) - return `${part("weekday")} ${part("day")} ${part("month")}, ${part("hour")}:${part("minute")} ${part("timeZoneName")}` + const time = `${part("hour")}:${part("minute")}` + const date = `${part("day")} ${part("month")}` + return ms - nowMs > SIX_DAYS_MS + ? { short: `${date}, ${time}`, rest: part("timeZoneName") } + : { short: `${part("weekday")} ${time}`, rest: `${part("timeZoneName")}, ${date}` } } const byEnd = Order.mapInput(Order.Number, (shift: OnCallShift) => shift.endsAtMs ?? Number.POSITIVE_INFINITY) @@ -82,9 +96,7 @@ export const onCallStatusOf = (snapshot: OnCallSnapshot, nowMs: number): OnCallS scheduleName: shift.scheduleName, otherSchedules: live.length - 1, until: - shift.endsAtMs === null - ? "further notice" - : formatUntil(shift.endsAtMs, snapshot.viewerTimeZone), + shift.endsAtMs === null ? null : formatUntil(shift.endsAtMs, snapshot.viewerTimeZone, nowMs), }), }) } @@ -145,23 +157,34 @@ export const isNoSchedulesFresh = (checkedAtMs: number | undefined, nowMs: numbe // COPY -/** The pill's schedule line: "Checkout primary", "Checkout primary +1". */ +/** The schedules the user holds: "Checkout primary", "Checkout primary +1 more". */ export const scheduleLabel = (status: Extract): string => - status.otherSchedules === 0 ? status.scheduleName : `${status.scheduleName} +${status.otherSchedules}` + status.otherSchedules === 0 + ? status.scheduleName + : `${status.scheduleName} +${status.otherSchedules} more` -/** The pill's full sentence: the collapsed rail's tooltip and accessible name. */ -export const onCallSentence = (status: OnCallStatus): string => { +/** The pill's one row: "On call until Mon 09:00", "Not on call". */ +export const onCallLabel = (status: OnCallStatus): string => { switch (status._tag) { case "OnCall": - return `You're on call until ${status.until} (${scheduleLabel(status)})` + return status.until === null ? "On call" : `On call until ${status.until.short}` case "NotOnCall": - return "You're not on call" + return "Not on call" case "NoSchedules": case "Unknown": return "" } } +/** + * The pill's tooltip and accessible name: the row's words first (a spoken "click On call until + * Mon 09:00" still finds it), then the zone, the date and the schedules. + */ +export const onCallDetail = (status: OnCallStatus): string => + status._tag === "OnCall" + ? `${onCallLabel(status)}${status.until === null ? "" : ` ${status.until.rest}`} · ${scheduleLabel(status)}` + : onCallLabel(status) + /** The pill shows for these. */ export const isPillShown = (status: OnCallStatus): boolean => status._tag === "OnCall" || status._tag === "NotOnCall" diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 749a57a46f..90d8ca7ff4 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -134,13 +134,17 @@ const subList = (h: HtmlBuilder, subItems: ReadonlyArray, c ) } -/** `NavRow`: a link, a section with its children while active, or on the collapsed rail a menu. */ +/** + * `NavRow`: a link, a section with its children while active, or on the collapsed rail a menu. In a + * folded group the current page's row stands alone, highlighted, without its children. + */ const navRow = ( h: HtmlBuilder, model: Model, item: NavItem, currentPath: string, tip: RailTip, + isGroupFolded = false, ): Html => { const isActive = isNavItemActive(currentPath, item) const isCollapsed = !model.isSidebarOpen && !model.isMobile @@ -150,7 +154,9 @@ const navRow = ( : partitionSubItems(item.subItems, surfacesOf(model), currentPath).shown if (isCollapsed && subItems !== undefined && subItems.length > 0) return collapsedSectionRow(h, model, item, isActive) - const isOpen = subItems !== undefined && subItems.length > 0 && isActive + const isOpen = subItems !== undefined && subItems.length > 0 && isActive && !isGroupFolded + // The tint belongs to the sub-item that is the current page, when one is. + const hasActiveChild = isOpen && activeSubHrefOf(subItems, currentPath) !== undefined const preview = isOpen || subItems === undefined ? undefined : navPreview(item, subItems) return sidebarMenuItem(h, undefined, [ sidebarMenuButton( @@ -158,6 +164,7 @@ const navRow = ( { rail: isOpen ? "lane" : "active", isActive, + hasActiveChild, link: { href: item.href, currentPath }, hasTooltip: true, attributes: tip(item.title), @@ -199,7 +206,7 @@ const navRow = ( ], ), ...(item.badge === undefined ? [] : [navBadge(h, item.badge)]), - ...(subItems !== undefined && isActive ? [subList(h, subItems, currentPath)] : []), + ...(subItems !== undefined && isActive && !isGroupFolded ? [subList(h, subItems, currentPath)] : []), ]) } @@ -281,7 +288,7 @@ export type NavGroupFold = "unlabelled" | "rail" | "expanded" | "collapsed" /** * How a group's rows show. The collapsed rail hides labels, so every row shows there; elsewhere a - * labelled group folds to the rows of the current page, if it holds it. + * labelled group folds to the current page's row (without its sub-items), if it holds it. */ export const navGroupFold = (model: Model, group: NavGroup): NavGroupFold => { if (group.label === undefined) return "unlabelled" @@ -326,7 +333,9 @@ export const navGroupSections = ( sidebarMenu( h, undefined, - Arr.map(rows, (item) => navRow(h, model, item, currentPath, tip)), + Arr.map(rows, (item) => + navRow(h, model, item, currentPath, tip, fold === "collapsed"), + ), ), ], [h.Id(navGroupContentId(group))], diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 403da11b4e..b2e4ef78f6 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -26,8 +26,9 @@ import { typography } from "@maple/views/ui/typography" import { identityLabel } from "@maple/views/ui/identity-label" import { hstack } from "@maple/views/ui/stack" import { statusDot } from "@maple/views/ui/status-dot" +import { tooltipTrigger } from "@maple/views/ui/tooltip-host" import { oncallTabHref } from "../page/alerts/shared/oncall-schedules" -import { isPillShown, type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" +import { isPillShown, onCallDetail, onCallLabel, type OnCallStatus, onCallStatusOf } from "./oncall" export const GROUP_LABEL = "h-6 text-muted-foreground" @@ -255,8 +256,9 @@ export const shellOnCallStatus = (model: Model): OnCallStatus => }) /** - * "You're on call until …" with the schedule, or "You're not on call" with a way to the schedules; - * a dot with the sentence as its tooltip on the collapsed rail. + * One 32px footer row: a status dot and "On call until Mon 09:00" (or a muted "Not on call"), + * linking to the schedules. The zone, the date and the schedule names are its tooltip and + * accessible name. On the collapsed rail it is a calendar glyph, badged with the dot. */ const onCallPill = ( h: HtmlBuilder, @@ -264,72 +266,48 @@ const onCallPill = ( status: OnCallStatus, tip: RailTip, isRail: boolean, -): ReadonlyArray => - !isPillShown(status) - ? [] - : [ - sidebarMenu(h, undefined, [ - sidebarMenuItem(h, undefined, [ - sidebarMenuButton( - h, - { - size: "lg", - className: - "h-auto gap-2.5 border border-sidebar-border py-1.5 group-data-[collapsible=icon]:border-transparent", - link: { href: oncallTabHref(), currentPath }, - hasTooltip: true, - // The rail hides the lines, so the link names itself there. - attributes: [ - ...tip(onCallSentence(status)), - ...(isRail ? [h.AriaLabel(onCallSentence(status))] : []), - ], - }, - [ - hstack( - h, - { align: "center", justify: "center", className: "size-4 shrink-0" }, - [ - statusDot(h, { - tone: status._tag === "OnCall" ? "ok" : "neutral", - size: "lg", - }), - ], - ), - // Three short lines: the full sentence does not fit the rail's width. - identityLabel( - h, - status._tag === "OnCall" - ? [ - typography( - h, - { size: "xs", weight: "medium", truncate: true }, - ["You're on call"], - ), - typography(h, { size: "xs", truncate: true }, [ - `until ${status.until}`, - ]), - typography(h, { size: "xs", tone: "muted", truncate: true }, [ - scheduleLabel(status), - ]), - ] - : [ - typography( - h, - { size: "xs", weight: "medium", truncate: true }, - ["You're not on call"], - ), - typography(h, { size: "xs", tone: "muted", truncate: true }, [ - "My schedules", - ]), - ], - // The rail shows the dot; the tooltip says the sentence. - "group-data-[collapsible=icon]:hidden", - ), - ], - ), - ]), - ]), - ] +): ReadonlyArray => { + if (!isPillShown(status)) return [] + const label = onCallLabel(status) + const detail = onCallDetail(status) + const isOnCall = status._tag === "OnCall" + const dot = (className?: string) => + statusDot(h, { tone: isOnCall ? "ok" : "neutral", size: "lg", className }) + const glyphBox = (className: string, children: ReadonlyArray) => + hstack(h, { align: "center", justify: "center", className }, children) + return [ + sidebarMenuItem(h, undefined, [ + sidebarMenuButton( + h, + { + className: isOnCall ? undefined : "text-muted-foreground", + link: { href: oncallTabHref(), currentPath }, + hasTooltip: true, + attributes: [ + // The rail's tooltip names the glyph; the open sidebar's adds what the row leaves out. + ...(isRail + ? tip(detail) + : detail === label + ? [] + : tooltipTrigger(h, detail, { side: "right" })), + ...(isRail || detail !== label ? [h.AriaLabel(detail)] : []), + ], + }, + isRail + ? [ + glyphBox("relative size-4 shrink-0", [ + icon(h, "CalendarIcon", { className: "size-4" }), + // On call only: a grey badge on a grey glyph says nothing. + ...(isOnCall + ? [dot("absolute -top-0.5 -right-0.5 ring-2 ring-sidebar")] + : []), + ]), + ] + : [glyphBox("size-4 shrink-0", [dot()]), h.span([], [label])], + ), + ]), + ] +} /** The user avatar: the Clerk image over the initials on a warm tile keyed by the name. */ export const userAvatar = ( @@ -425,8 +403,15 @@ export const shellSidebarFooter = ( ], [], ), - ...onCallPill(h, currentPath, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), sidebarMenu(h, undefined, [ + // A row of the footer's list, so it keeps the list's 4px rhythm. + ...onCallPill( + h, + currentPath, + shellOnCallStatus(model), + tip, + !model.isSidebarOpen && !model.isMobile, + ), footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. footerNavRow( diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index ae898eb3eb..74ecf10a8c 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -30,7 +30,7 @@ import { shellSidebarFooter, shellSidebarHeader, } from "./view-sidebar" -import { isPillShown, onCallSentence } from "./oncall" +import { isPillShown, onCallDetail } from "./oncall" import { type BreadcrumbEntry, hidesTopbarConnect, topbar } from "./view-topbar" import { splitPageRails } from "./page-rails" import { filterSheet, filterSheetTrigger, rightSheet, rightSheetTrigger } from "./view-page-sheets" @@ -67,7 +67,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), - ...(isPillShown(onCall) ? [onCallSentence(onCall)] : []), + ...(isPillShown(onCall) ? [onCallDetail(onCall)] : []), "Sources", "Settings", ] diff --git a/packages/ui/src/components/ui/sidebar.styles.ts b/packages/ui/src/components/ui/sidebar.styles.ts index 189d81c8c8..e7c9e4dc91 100644 --- a/packages/ui/src/components/ui/sidebar.styles.ts +++ b/packages/ui/src/components/ui/sidebar.styles.ts @@ -1,7 +1,7 @@ import { cva } from "class-variance-authority" export const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] duration-200 ease-move group-data-instant:transition-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:active:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[state=open]:active:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] duration-200 ease-move group-data-instant:transition-none hover:bg-surface-sunken hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-surface-sunken active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=child]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:active:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[state=open]:active:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", { defaultVariants: { size: "default", @@ -14,8 +14,9 @@ export const sidebarMenuButtonVariants = cva( sm: "h-7 text-xs", }, variant: { + // Hover is a neutral wash; the accent tint is the selected row's alone. default: - "hover:bg-sidebar-accent active:bg-sidebar-accent hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground", + "hover:bg-surface-sunken active:bg-surface-sunken hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground", outline: "bg-background ring-1 ring-sidebar-border hover:bg-sidebar-accent active:bg-sidebar-accent hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground hover:ring-sidebar-accent active:ring-sidebar-accent", }, @@ -154,7 +155,8 @@ export const sidebarMenuSubStyles = [ export const sidebarMenuSubItemStyles = "group/menu-sub-item relative" export const sidebarMenuSubButtonStyles = [ - "flex h-8 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 sm:h-7 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + // 36px in the mobile sheet (below `md`, a touch target), 28px on the desktop rail. + "flex h-9 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-surface-sunken hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-surface-sunken active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 md:h-7 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", ] as const diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 528a4fd6cd..42bdbeb995 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -229,7 +229,7 @@ export type SidebarGroupToggleOptions = Readonly<{ onToggle: Message }> -/** A group label that collapses its rows: a chevron button with `aria-expanded`. */ +/** A group label that collapses its rows: a 28px button with `aria-expanded` and a faint chevron. */ export const sidebarGroupToggle = ( h: HtmlBuilder, options: SidebarGroupToggleOptions, @@ -240,7 +240,7 @@ export const sidebarGroupToggle = ( h.Class( cn( ...sidebarGroupLabelStyles, - "group/toggle h-6 w-full cursor-pointer gap-1 text-left text-muted-foreground hover:text-sidebar-foreground active:text-sidebar-foreground", + "group/toggle h-7 w-full cursor-pointer gap-1 text-left text-muted-foreground hover:text-sidebar-foreground active:text-sidebar-foreground", ), ), h.DataAttribute("sidebar", "group-label"), @@ -253,9 +253,10 @@ export const sidebarGroupToggle = ( h.span([h.Class("flex-1 truncate")], [options.label]), icon(h, "ChevronDownIcon", { size: 12, + // Faint at rest so folding is discoverable, full tone on hover and focus. className: cn( - "opacity-0 transition-[opacity,rotate] duration-150 group-hover/toggle:opacity-100 group-focus-visible/toggle:opacity-100 motion-reduce:transition-none", - options.isExpanded ? "" : "-rotate-90 opacity-100", + "text-foreground-faint transition-[rotate] duration-150 group-hover/toggle:text-sidebar-foreground group-focus-visible/toggle:text-sidebar-foreground motion-reduce:transition-none", + options.isExpanded ? "" : "-rotate-90", ), }), ], @@ -364,6 +365,11 @@ export type SidebarMenuButtonOptions = Readonly<{ size?: "default" | "lg" | "sm" className?: string isActive?: boolean + /** + * A sub-item of this row is the current page: the row keeps its medium weight and leaves the + * tint to that sub-item (`data-active="child"`). + */ + hasActiveChild?: boolean /** Rendered as a router link instead of a button. */ link?: LinkTarget /** `render={}`: a plain anchor, no router active state (apps/local-ui's hash links). */ @@ -400,7 +406,10 @@ export const sidebarMenuButton = ( ) const attributes: Array | ChildAttribute> = [ h.Class(linkActive ? `${className} active` : className), - h.DataAttribute("active", String(options.isActive ?? false)), + h.DataAttribute( + "active", + options.hasActiveChild === true ? "child" : String(options.isActive ?? false), + ), ...(options.hasTooltip === true ? [h.DataAttribute("base-ui-tooltip-trigger", "")] : []), h.DataAttribute("sidebar", "menu-button"), h.DataAttribute("size", size), From 3c3e99600c1d707754fb64de22094e8feae31d8e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:23:06 +0100 Subject: [PATCH 14/35] fix(alerts): one primary action per section, chips and viewer time in the silence panel - Each section's header has one primary action: New rule (split) on the Inbox and Rules, New monitor, New SLO, New silence, New window, New schedule and Add destination on their own sections. A section whose empty state already offers it shows none until it has rows. - The Alerts sub-list sets its groups apart with a 4px gap instead of rules, and its rows take the kit's height again. - The silence panel's Services, Environments and Tags are chips inputs that suggest what the org's rules name (and the services Uptime listed); a typed name can still be added. Its labels share one style. - A picked end is on the viewer's clock and the field names the zone ("Ends, CET"); a scheduled row names its day once. The lab reads the viewer's zone instead of UTC. - The duration choice uses the larger segmented size. --- apps/web/src/lab/alerts-hub-lab.ts | 114 ++++++--- apps/web/src/lab/registry.ts | 2 +- apps/web/src/page/alerts/hub/derive.ts | 30 ++- .../alerts/hub/hub-silences.scene.test.ts | 82 ++++++- .../page/alerts/hub/hub-slos.scene.test.ts | 5 +- .../page/alerts/hub/hub-uptime.scene.test.ts | 25 +- .../web/src/page/alerts/hub/hub.scene.test.ts | 42 ++++ apps/web/src/page/alerts/hub/view-oncall.ts | 7 - apps/web/src/page/alerts/hub/view-silences.ts | 4 +- apps/web/src/page/alerts/hub/view.ts | 114 ++++----- .../src/page/alerts/shared/matcher-fields.ts | 25 ++ .../src/page/alerts/shared/silences.test.ts | 29 ++- apps/web/src/page/alerts/shared/silences.ts | 14 ++ .../page/alerts/silence-dialog/dialog.test.ts | 109 ++++++++- .../src/page/alerts/silence-dialog/dialog.ts | 79 +++++- .../src/page/alerts/silence-dialog/view.ts | 227 ++++++++++++------ apps/web/src/page/kit/timezone-zones.ts | 31 +++ apps/web/src/shell/nav.ts | 2 +- apps/web/src/shell/shell.scene.test.ts | 3 +- apps/web/src/shell/view-nav.ts | 98 ++++---- packages/views/src/ui/INDEX.md | 2 +- packages/views/src/ui/segmented-select.ts | 3 +- 22 files changed, 765 insertions(+), 282 deletions(-) diff --git a/apps/web/src/lab/alerts-hub-lab.ts b/apps/web/src/lab/alerts-hub-lab.ts index e87756b96b..a1a60d4719 100644 --- a/apps/web/src/lab/alerts-hub-lab.ts +++ b/apps/web/src/lab/alerts-hub-lab.ts @@ -6,6 +6,8 @@ import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "eff 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" @@ -15,11 +17,13 @@ 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 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. The shell -// stands on the section's own path, and `?sheet=` is played here rather than pushed. +// 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", @@ -29,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 } }) @@ -50,11 +56,8 @@ 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, writes and URL changes are dropped: the fixture is all the data there is. */ @@ -131,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"], @@ -148,23 +154,24 @@ const tabOf = (state: LabState) => ? "inbox" : "silences" -const routeTo = (model: HubModel.Model, sheet: string | undefined) => - HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), 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) => { - const next = HubUpdate.update(model, message, CONTEXT) +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(hubAt({ 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 }), @@ -184,38 +191,71 @@ 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) } }, }) @@ -230,6 +270,6 @@ export const view = (model: Model, h: HtmlBuilder): Html => 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/registry.ts b/apps/web/src/lab/registry.ts index 843e1bdbb2..5059ffb8f8 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -57,7 +57,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 side panel (`?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/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index e9f48fefbb..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,6 +113,33 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) +const sortedNames = (names: ReadonlyArray): ReadonlyArray => + Arr.sort(Arr.dedupe(Arr.filter(names, Str.isNonEmpty)), Order.String) + +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 => { switch (status) { 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 395a2fcd02..e0b5755d6f 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,12 +3,14 @@ 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 Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" import { expect as expectView, Mount, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { NOW, ORG, hubAt } 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, routeChanged, update } from "./update" @@ -193,6 +195,8 @@ const callsOf = (commands: ReadonlyArray<{ readonly name: string; readonly 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) => @@ -208,7 +212,7 @@ describe("alerts hub, silence side panel", () => { expect(step.model.silenceDialog.dialog.isOpen).toBe(false) }) - test("the URL opens it with typed scope fields, Cancel and a save that waits for a valid form", () => { + test("the URL opens it with a chips input per scope field, Cancel and a save that waits for a valid form", () => { const opened = withPanel() expect(opened.model.silenceDialog.dialog.isOpen).toBe(true) scene( @@ -216,12 +220,80 @@ describe("alerts hub, silence side panel", () => { given(opened.model), Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), expectView(role("dialog", { name: "Silence notifications" })).toExist(), - expectView(role("textbox", { name: "Services" })).toExist(), + 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" })).toBeDisabled(), ) }) + 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("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) @@ -306,9 +378,7 @@ describe("alerts hub, silence side panel", () => { test("saving creates the silence, closes the panel, clears the URL and reads the list again", () => { const filled = Arr.reduce( [ - silenceMessage( - SilenceDialog.Message.ChangedScopeField({ field: "services", value: "checkout" }), - ), + scopeCombo("services", Combobox.Message.ClickedItem({ value: "checkout" })), silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), ], withPanel().model, 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 6526d14694..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 @@ -200,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), 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 5694c615e2..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 @@ -320,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 }), @@ -342,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 9e44f27e3d..5d0c3508cb 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -138,6 +138,48 @@ describe("alerts hub", () => { ) }) + 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: "silences" })), + expectView(role("button", { name: "New silence" })).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "routing" })), + expectView(role("button", { name: "Add destination" })).toExist(), + ) + // 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("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([ 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 e9e8dd6843..45ca0d6631 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -18,6 +18,7 @@ import { dashboardPage } from "../../kit/dashboard-page" 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 { @@ -28,6 +29,7 @@ import { resolveIncidentStatus, resolveSignal, resolveStatus, + silenceSuggestionsOf, } from "./derive" import { Message } from "./message" import { canManage, type Model } from "./model" @@ -46,13 +48,7 @@ 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" @@ -99,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, @@ -183,52 +171,46 @@ 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 === "on-call" && 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 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): Html | undefined => { + if (tab === "inbox" || tab === "rules") return 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({ @@ -404,7 +386,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/shared/matcher-fields.ts b/apps/web/src/page/alerts/shared/matcher-fields.ts index 44e0a356f2..bcc96dc098 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" @@ -59,6 +60,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/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts index 1d0e7e8f89..c482cf734a 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,33 @@ 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("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..5a5be70bf7 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -78,6 +78,20 @@ export const formatSilenceTime = (ms: number, nowMs: number, timeZone: string): : `${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/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 8e8c49ab56..3b62de3bad 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,19 @@ 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, + type ScopeField, silenceEndMs, untilError, update, @@ -36,6 +42,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 +87,76 @@ 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("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") @@ -106,7 +203,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 2eba7bba2e..8b69c61740 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -3,10 +3,12 @@ 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 } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" // "Silence notifications": the side panel the hub, a rule and an incident open, as one Submodel. @@ -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`. */ @@ -42,7 +69,8 @@ export type Model = typeof Model.Type export const init = (id: string): Model => ({ dialog: Dialog.init(id, "sheet"), - scope: { _tag: "Typed", services: "", tags: "", environments: "" }, + scope: NO_CHIPS, + combos: initCombos(id), reason: "", duration: "1h", until: "", @@ -57,7 +85,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,9 +150,22 @@ 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 }), + } + +/** 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 (or nothing is typed yet). */ export const untilError = (model: Model): Option.Option => { @@ -187,6 +228,7 @@ const open = (model: Model, scope: Scope, timeZone: string, nowMs: number): Retu { ...model, scope, + combos: initCombos(model.dialog.id), timeZone, nowMs, reason: "", @@ -208,7 +250,23 @@ 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) + +/** 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 step = Combobox.update({ ...model.combos[field], selected: model.scope[field] }, message) + const scope = + step.outMessage?._tag === "ChangedSelection" + ? { ...model.scope, [field]: 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)) @@ -236,10 +294,7 @@ 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 } } }, + GotScopeComboMessage: ({ field, message: child }) => scopeComboStep(model, field, child), ClickedCancel: () => close(model), ClickedSave: () => save(model), CompletedSave: ({ openCount, result }) => diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index d2758eb2df..73a6d6e305 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -1,16 +1,33 @@ -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 { 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 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, + isFormReady, + Message, + type Model, + type Scope, + 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 @@ -45,89 +62,145 @@ 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( +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 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_HELP_ID, + placeholder, + emptyText: "Type a name, then press Enter", + isDisabled: model.isSaving, + toView: (parts) => h.div([], parts), + }, + }), + ]) +} + +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)), + dialogHelp( h, - { padded: true, className: "gap-3" }, - matcherFields(h, { - idPrefix: "silence", - typed: model.scope, - onInput: (field, value) => Message.ChangedScopeField({ field, value }), - }), + SCOPE_HELP_ID, + "Fill in at least one. A rule is silenced only if it matches every field you fill in.", ), - ]) + ] +} -const durationSection = (h: H, model: Model): Html => - vstack(h, { gap: "2" }, [ - eyebrow(h, { variant: "label", as: "div" }, ["For"]), +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" - ? [ - 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), - }), - ] - : []), - ]) - -/** 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, - 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), - durationSection(h, model), + ]), + ...(model.duration === "custom" + ? [ textField(h, { - name: "reason", - label: "Reason", - value: model.reason, - placeholder: "Deploying checkout v2.3", - onInput: (value) => Message.ChangedReason({ value }), + 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), }), - ]), - ], - onSubmit: Message.ClickedSave(), - footer: (close) => [ - button( - h, - { variant: "outline", isDisabled: model.isSaving, attributes: model.isSaving ? [] : close }, - ["Cancel"], - ), - button( - h, - { - type: "submit", - isLoading: model.isSaving, - isDisabled: model.isSaving || !isFormReady(model), - }, - [submitLabel(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, + 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: "reason", + label: "Reason", + value: model.reason, + placeholder: "Deploying checkout v2.3", + onInput: (value) => Message.ChangedReason({ value }), + }), + ]), + ], + onSubmit: Message.ClickedSave(), + footer: (close) => [ + button( + h, + { + variant: "outline", + isDisabled: model.isSaving, + attributes: model.isSaving ? [] : close, + }, + ["Cancel"], + ), + button( + h, + { + type: "submit", + isLoading: model.isSaving, + isDisabled: model.isSaving || !isFormReady(model), + }, + [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/shell/nav.ts b/apps/web/src/shell/nav.ts index d41ccc49d9..e38119c24d 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -38,7 +38,7 @@ export interface NavSubItem { 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 thin rule above it, no heading. */ + /** Starts a group of rows: a small gap above it, no heading. */ readonly startsGroup?: boolean } diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index fd4ae2e8c8..c7444e9066 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -471,14 +471,13 @@ describe("alerts section", () => { ) }) - test("the Inbox counts unacknowledged incidents for this org, and thin rules split the groups", () => { + 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(), - expect(role("separator")).toExist(), ) // Nothing to act on shows no badge; another org's count is dropped, and an org change clears it. scene( diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 4206e21fe9..6137a5ff5a 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" @@ -16,7 +17,6 @@ import { sidebarMenuSub, sidebarMenuSubButton, sidebarMenuSubItem, - sidebarSeparator, } from "@maple/views/ui/sidebar" import type { PinnedDashboardRow } from "./dashboards-shape" import { Menu, Message } from "./message" @@ -104,10 +104,6 @@ const subCountOf = (model: Model, sub: NavSubItem): number | undefined => ? model.alertsInboxCount : undefined -// A rule between groups keeps the sub-list's lane line unbroken. -const groupRule = (h: HtmlBuilder): Html => - sidebarMenuSubItem(h, "border-sidebar-border border-l-2 py-1 ps-2.5", [sidebarSeparator(h)]) - const subList = ( h: HtmlBuilder, model: Model, @@ -118,54 +114,54 @@ const subList = ( return sidebarMenuSub( h, "mx-0 ms-4 translate-x-0 gap-0 border-l-0 px-0 py-0", - Arr.flatMap(subItems, (sub, index) => { + Arr.map(subItems, (sub, index) => { const count = subCountOf(model, sub) - return [ - ...(sub.startsGroup === true && index > 0 ? [groupRule(h)] : []), - sidebarMenuSubItem( - h, - sub.href === activeSubHref - ? "border-sidebar-primary border-l-2 ps-2.5" - : "border-sidebar-border border-l-2 ps-2.5", - [ - sidebarMenuSubButton( - h, - { - className: - "h-7 translate-x-0 data-[active=true]:text-sidebar-primary sm:h-6 [&>svg]:text-current", - isActive: sub.href === activeSubHref, - link: { href: sub.href, currentPath }, - }, - [ - ...(sub.icon === undefined - ? [] - : [ - icon(h, sub.icon, { - className: "size-3.5", - ...iconColor(sub.iconColor), - }), - ]), - h.span([], [sub.title]), - ], - ), - ...(count === undefined - ? [] - : [ - sidebarMenuBadge( - h, - [ - typography( - h, - { size: "xs", mono: true, numeric: true, tone: "error" }, - [String(count)], - ), - ], - "top-1 sm:top-0.5", - ), - ]), - ], + return sidebarMenuSubItem( + h, + 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, + { + className: + "translate-x-0 data-[active=true]:text-sidebar-primary [&>svg]:text-current", + isActive: sub.href === activeSubHref, + link: { href: sub.href, currentPath }, + }, + [ + ...(sub.icon === undefined + ? [] + : [ + icon(h, sub.icon, { + className: "size-3.5", + ...iconColor(sub.iconColor), + }), + ]), + 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", + ), + ]), + ], + ) }), ) } diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 3ce934a3bd..585ba45007 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -236,7 +236,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/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 }> From 6a7cfa6bc0d1a31adb14873dcac98f637b0acdb7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 04:44:49 +0100 Subject: [PATCH 15/35] fix(alerts): restore the AsyncData import the hub header needs after the merge --- apps/web/src/page/alerts/hub/view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 6474a11260..0527ff86e2 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -2,7 +2,7 @@ import { type AlertIncidentId, isManagedSignalType } from "@maple/domain/http" import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" import { makeClientErrorBody } from "@maple/web-data/lib/error-messages" import { Option, Array as Arr } from "effect" -import { Submodel } from "foldkit" +import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { button, linkButton } from "@maple/views/ui/button" import { splitButtonClass } from "@maple/views/ui/split-button" From 503f89fd7ad617442cdbec41898b04da37ccdf1e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:08:46 +0100 Subject: [PATCH 16/35] fix(alerts): a long chain name in the routing list ends in an ellipsis --- apps/web/src/page/alerts/hub/view-chains.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index f98f0af2a7..fd2e617007 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -91,7 +91,12 @@ export const chainRow = ( rowIcon(h, "BellIcon"), vstack(h, { gap: "0.5", shrink: true, grow: true }, [ typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [ - textLink(h, { href: chainHref(chain.id), tone: "inherit" }, [chain.name]), + // `block truncate`: an inline-flex link would be clipped without an ellipsis. + textLink( + h, + { href: chainHref(chain.id), tone: "inherit", className: "block truncate" }, + [chain.name], + ), ]), typography( h, From 9708166dce82f3ece39f19b82fc8e3d6e140f33d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:13:09 +0100 Subject: [PATCH 17/35] fix(alerts): chain target remove buttons name their step; a failed people read is said --- .../escalation-chain.scene.test.ts | 9 ++++- .../src/page/alerts/escalation-chain/model.ts | 2 + .../page/alerts/escalation-chain/update.ts | 7 ++-- .../src/page/alerts/escalation-chain/view.ts | 38 +++++++++++-------- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts index 99b85cfdb8..71674951eb 100644 --- a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -270,6 +270,13 @@ describe("escalation chain editor", () => { expectView(text(/No destinations yet/u)).toExist(), expectView(role("link", { name: "Create a destination" })).toHaveAttr("href", "/alerts/routing"), ) + // A failed read is said, so an empty People group is not mistaken for an org of one. + scene( + { update: pageUpdate, view }, + given(open({ ...newChain(), members: null, isMembersFailed: true })), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(text("People could not be loaded")).toExist(), + ) }) test("delete lists the rules that would stop escalating and holds the delete while they use it", () => { @@ -392,7 +399,7 @@ describe("escalation chain editor", () => { expectView(role("button", { name: "Delete chain" })).not.toExist(), expectView(role("button", { name: "Actions for step 1" })).not.toExist(), expectView(role("button", { name: "Add a step after step 1" })).not.toExist(), - expectView(role("button", { name: "Stop notifying Slack #alerts-api" })).not.toExist(), + expectView(role("button", { name: "Stop notifying Slack #alerts-api on step 1" })).not.toExist(), expectView(role("link", { name: "Back to routing" })).toExist(), ) // The update holds the same line as the view. diff --git a/apps/web/src/page/alerts/escalation-chain/model.ts b/apps/web/src/page/alerts/escalation-chain/model.ts index ec014ffef8..b805a30ab6 100644 --- a/apps/web/src/page/alerts/escalation-chain/model.ts +++ b/apps/web/src/page/alerts/escalation-chain/model.ts @@ -24,7 +24,9 @@ export const Model = Schema.Struct({ baseline: Form, destinations: Schema.NullOr(Schema.Array(AlertDestinationRowSchema)), rules: Schema.NullOr(Schema.Array(AlertRuleRowSchema)), + /** `null` until the members load, and after a failed read (`isMembersFailed`). */ members: Schema.NullOr(Schema.Array(OrgMember)), + isMembersFailed: Schema.Boolean, /** `null` until the schedules load, and after a failed read (`isSchedulesFailed`). */ schedules: Schema.NullOr(Schema.Array(V2OnCallSchedule)), isSchedulesFailed: Schema.Boolean, diff --git a/apps/web/src/page/alerts/escalation-chain/update.ts b/apps/web/src/page/alerts/escalation-chain/update.ts index 7f5ee38ddb..4c92b77107 100644 --- a/apps/web/src/page/alerts/escalation-chain/update.ts +++ b/apps/web/src/page/alerts/escalation-chain/update.ts @@ -69,6 +69,7 @@ export const init = (route: ChainRoute, context: PageContext): Return => { destinations: null, rules: null, members: null, + isMembersFailed: false, schedules: null, isSchedulesFailed: false, brokenLogos: [], @@ -286,11 +287,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, UpdatedRules: ({ orgId, rows }) => orgId === context.orgId ? { model: { ...model, rules: rows } } : { model }, - // Without members a person reads "Member"; destinations and schedules still show. + // Without members a person reads "Member", and the picker says people could not be loaded. CompletedFetchMembers: ({ result }) => Result.match(result, { - onSuccess: (members) => ({ model: { ...model, members } }), - onFailure: () => ({ model }), + onSuccess: (members) => ({ model: { ...model, members, isMembersFailed: false } }), + onFailure: () => ({ model: { ...model, isMembersFailed: true } }), }), CompletedFetchSchedules: ({ result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index 2e768f1808..e924e1acf1 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -144,8 +144,16 @@ const onCallNote = (model: Model, scheduleId: string): string | undefined => ) /** A picked target's chip: a destination's logo, a member's avatar, a schedule's calendar. */ -const targetChip = (h: H, model: Model, key: string, target: V2EscalationTarget): PickedChip => { +const targetChip = ( + h: H, + model: Model, + key: string, + index: number, + target: V2EscalationTarget, +): PickedChip => { const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) + // The step is named: the same target may sit on several steps. + const onStep = `on step ${index + 1}` switch (target.type) { case "destination": { const name = nameOr(model.destinations, target.destination_id, "Destination") @@ -153,7 +161,7 @@ const targetChip = (h: H, model: Model, key: string, target: V2EscalationTarget) key: targetKey(target), label: name, media: destinationMedia(h, model, target.destination_id), - removeLabel: `Stop notifying ${name}`, + removeLabel: `Stop notifying ${name} ${onStep}`, onRemove, } } @@ -169,7 +177,7 @@ const targetChip = (h: H, model: Model, key: string, target: V2EscalationTarget) Option.map(memberOf(model.members, target.user_id), (member) => member.imageUrl), ), }), - removeLabel: `Stop paging ${name}`, + removeLabel: `Stop paging ${name} ${onStep}`, onRemove, } } @@ -181,7 +189,7 @@ const targetChip = (h: H, model: Model, key: string, target: V2EscalationTarget) label: name, media: icon(h, "CalendarIcon", { size: 14, className: "shrink-0 text-muted-foreground" }), ...(detail === undefined ? undefined : { detail }), - removeLabel: `Stop paging ${name}`, + removeLabel: `Stop paging ${name} ${onStep}`, onRemove, } } @@ -234,17 +242,17 @@ const addEntries = (h: H, model: Model, step: StepDraft): ReadonlyArray ({ - value: `user:${member.userId}`, - label: member.name, - })), - ), + ...(model.members === null + ? [loadingEntry(model.isMembersFailed ? "People could not be loaded" : "Loading people")] + : group( + "People", + Arr.map(model.members, (member) => ({ + value: `user:${member.userId}`, + label: member.name, + })), + )), ...(model.schedules === null - ? model.isSchedulesFailed - ? [] - : [loadingEntry("Loading schedules")] + ? [loadingEntry(model.isSchedulesFailed ? "Schedules could not be loaded" : "Loading schedules")] : group( "Whoever is on call on", Arr.map(model.schedules, (schedule) => ({ @@ -309,7 +317,7 @@ const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { index === 0 ? "Notify" : "Then notify", ]), ...pickChips(h, { - entries: Arr.map(step.targets, (target) => targetChip(h, model, key, target)), + entries: Arr.map(step.targets, (target) => targetChip(h, model, key, index, target)), menu: model.menus[targetsMenuId] ?? Menu.init(targetsMenuId), toMenuMessage: (message) => Message.GotMenuMessage({ kind: "targets", key, message }), addEntries: addEntries(h, model, step), From 78ebb4a21244a375422b28d0fea0605104374c7d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:01:09 +0100 Subject: [PATCH 18/35] fix(alerts): silence form says what is missing instead of a silent disabled save --- .../alerts/hub/hub-silences.scene.test.ts | 44 ++++++++++++++-- .../page/alerts/silence-dialog/dialog.test.ts | 19 +++++++ .../src/page/alerts/silence-dialog/dialog.ts | 52 ++++++++++++------- .../src/page/alerts/silence-dialog/view.ts | 34 +++++++++--- 4 files changed, 121 insertions(+), 28 deletions(-) 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 e0b5755d6f..db20e245b5 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 @@ -6,7 +6,17 @@ import { Result, Schema, Array as Arr } from "effect" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" -import { expect as expectView, Mount, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { + 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, hubAt, ruleRow } from "../shared/test-rows" @@ -212,7 +222,7 @@ describe("alerts hub, silence side panel", () => { expect(step.model.silenceDialog.dialog.isOpen).toBe(false) }) - test("the URL opens it with a chips input per scope field, Cancel and a save that waits for a valid form", () => { + 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( @@ -227,7 +237,35 @@ describe("alerts hub, silence side panel", () => { 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" })).toBeDisabled(), + expectView(role("button", { name: "Silence for 1 hour" })).not.toBeDisabled(), + expectView(role("alert")).not.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(), ) }) 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 3b62de3bad..0bdddb0dde 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -14,6 +14,8 @@ import { openNew, OutMessage, parseLocalDateTime, + reasonError, + scopeError, type ScopeField, silenceEndMs, untilError, @@ -185,6 +187,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", () => { diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index 8b69c61740..c1c3ed72af 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -62,6 +62,8 @@ 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, }) @@ -77,6 +79,7 @@ export const init = (id: string): Model => ({ timeZone: "UTC", nowMs: 0, isSaving: false, + isShowingErrors: false, openCount: 0, }) @@ -167,9 +170,10 @@ export const endZoneLabel = (model: Pick) Option.getOrElse(parseLocalDateTime(model.until, model.timeZone), () => model.nowMs), ) -/** Why a typed custom end cannot be used, or none when it can (or nothing is typed yet). */ +/** 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) => @@ -181,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)) && @@ -235,6 +248,7 @@ const open = (model: Model, scope: Scope, timeZone: string, nowMs: number): Retu duration: "1h", until: "", isSaving: false, + isShowingErrors: false, openCount: model.openCount + 1, }, Dialog.open(model.dialog), @@ -271,22 +285,24 @@ const scopeComboStep = (model: Model, field: ScopeField, message: Combobox.Messa /** 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 => - 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, - }), - ], - } +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, { diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 0c1db820aa..93e90ad049 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -9,13 +9,15 @@ 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 { endZoneLabel, - isFormReady, Message, type Model, + reasonError, type Scope, + scopeError, type ScopeField, type ScopeSuggestions, type SilenceDuration, @@ -51,6 +53,10 @@ const submitLabel = (model: Model): string => `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone)}`, }) +/** 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" }) @@ -69,6 +75,7 @@ const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholde ] const SCOPE_HELP_ID = "silence-scope-help" +const SCOPE_ERROR_ID = "silence-scope-error" const NO_SUGGESTIONS: ScopeSuggestions = { services: [], environments: [], tags: [] } @@ -122,11 +129,22 @@ const scopeSection = (h: H, model: Model, suggestions: ScopeSuggestions): Readon ] : [ ...Arr.map(SCOPE_FIELDS, (entry) => chipsField(h, model, scope, suggestions, entry)), - dialogHelp( - h, - SCOPE_HELP_ID, - "Fill in at least one. A rule is silenced only if it matches every field you fill in.", - ), + vstack(h, { gap: "1.5" }, [ + 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, + ), + ), + ), + ]), ] } @@ -179,6 +197,7 @@ export const view = Submodel.defineView Message.ChangedReason({ value }), + error: shown(model, reasonError(model.reason)), }), ]), ], @@ -198,7 +217,8 @@ export const view = Submodel.defineView Date: Tue, 10 Nov 2026 14:01:49 +0100 Subject: [PATCH 19/35] fix(alerts): silence chips split on commas and keep a chip typed twice --- .../src/page/alerts/shared/matcher-fields.ts | 3 +- .../page/alerts/silence-dialog/dialog.test.ts | 47 +++++++++++++++++++ .../src/page/alerts/silence-dialog/dialog.ts | 19 ++++++-- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/shared/matcher-fields.ts b/apps/web/src/page/alerts/shared/matcher-fields.ts index bcc96dc098..722e5698e0 100644 --- a/apps/web/src/page/alerts/shared/matcher-fields.ts +++ b/apps/web/src/page/alerts/shared/matcher-fields.ts @@ -30,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. */ 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 0bdddb0dde..9b3b282415 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -141,6 +141,53 @@ describe("scope chips", () => { 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"), diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index c1c3ed72af..a29b1de01b 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -8,7 +8,7 @@ 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 { shortZoneName } from "../../kit/timezone-zones" -import { hasMatchers, MatcherField } from "../shared/matcher-fields" +import { hasMatchers, MatcherField, splitList } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" // "Silence notifications": the side panel the hub, a rule and an incident open, as one Submodel. @@ -266,13 +266,26 @@ export const openFor = ( export const openNew = (model: Model, timeZone: string, nowMs: number): Return => open(model, NO_CHIPS, timeZone, nowMs) +/** 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 step = Combobox.update({ ...model.combos[field], selected: model.scope[field] }, message) + 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]: step.outMessage.selected } + ? { + ...model.scope, + [field]: chipsOf( + isTypedEntry ? [...chips, ...step.outMessage.selected] : step.outMessage.selected, + ), + } : model.scope return { model: { ...model, scope, combos: { ...model.combos, [field]: step.model } }, From a037e66d7038644300995d74f95c40ea1ad5b549 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:02:48 +0100 Subject: [PATCH 20/35] fix(alerts): silence chips hint sits in the form, not in a popup over the next label --- .../alerts/hub/hub-silences.scene.test.ts | 27 ++++++++++++++++ .../src/page/alerts/silence-dialog/view.ts | 10 ++++-- packages/views/src/ui/combobox-view.ts | 12 ++++--- packages/views/src/ui/combobox.scene.test.ts | 32 +++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) 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 db20e245b5..6dedbb673c 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 @@ -242,6 +242,33 @@ describe("alerts hub, silence side panel", () => { ) }) + 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 }, diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 93e90ad049..73e5106252 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -75,6 +75,7 @@ const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholde ] 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: [] } @@ -105,9 +106,8 @@ const chipsField = ( viewInputs: { items: itemsOf(suggestions[field], combo.inputValue), ariaLabelledBy: labelId, - ariaDescribedBy: SCOPE_HELP_ID, + ariaDescribedBy: `${SCOPE_ENTRY_HELP_ID} ${SCOPE_HELP_ID}`, placeholder, - emptyText: "Type a name, then press Enter", isDisabled: model.isSaving, toView: (parts) => h.div([], parts), }, @@ -130,6 +130,12 @@ const scopeSection = (h: H, model: Model, suggestions: ScopeSuggestions): Readon : [ ...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, 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, From 2b33c5e0b86c640ea5fc01253e111734ea90db8f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:31 +0100 Subject: [PATCH 21/35] fix(shell): re-check a stale 'no schedules' answer so a teammate's first schedule shows the on-call row The marker lived a day and only a schedule write in the same tab cleared it, so the row stayed hidden across reloads after someone else created the org's first schedule. It now lives five minutes, and coming back into focus asks again once it has aged. --- apps/web/src/shell/oncall.scene.test.ts | 42 +++++++++++++++++++++++-- apps/web/src/shell/oncall.ts | 7 +++-- apps/web/src/shell/update.ts | 29 ++++++++++++----- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 801ee39f55..029b0c5301 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -21,6 +21,7 @@ import { MeasureSidebarOverflow } from "./mounts" import { isNoSchedulesFresh, nextHandoffMs, + NO_SCHEDULES_TTL_MS, ONCALL_IDLE_READ_MS, ONCALL_RETRY_MS, type OnCallShift, @@ -234,8 +235,45 @@ describe("on-call reads", () => { { name: "LoadOnCall", args: { orgId: ORG, noSchedulesCheckedAtMs: null } }, { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: null } }, ]) - expectVitest(isNoSchedulesFresh(NOW, NOW + 23 * 60 * 60_000)).toBe(true) - expectVitest(isNoSchedulesFresh(NOW, NOW + 25 * 60 * 60_000)).toBe(false) + }) + + test("a teammate's first schedule: back in focus, an aged 'no schedules' answer is asked again", () => { + // The marker was read from storage at sign-in, so the first read never reached the API. + const booted = enter(ORG)(init({ ...flags, noScheduleOrgs: { [ORG]: NOW } })) + const cached = update(booted.model, answer([], NOW + 1_000, true)).model + expectVitest(commandsOf(update(cached, Message.WokeOnCall({ nowMs: NOW + 61_000 })))).toEqual([]) + + const agedAtMs = NOW + NO_SCHEDULES_TTL_MS + expectVitest(isNoSchedulesFresh(NOW, agedAtMs - 1)).toBe(true) + expectVitest(isNoSchedulesFresh(NOW, agedAtMs)).toBe(false) + const woke = update(cached, Message.WokeOnCall({ nowMs: agedAtMs })) + expectVitest(commandsOf(woke)).toEqual([ + { name: "LoadOnCall", args: { orgId: ORG, noSchedulesCheckedAtMs: NOW } }, + ]) + + const found = update(woke.model, answer([shift({ userId: "user_someone" })], agedAtMs)) + expectVitest(commandsOf(found)).toEqual([ + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: null } }, + ]) + scene( + { update, view }, + given(found.model), + measured, + expect(role("link", { name: "Not on call" })).toExist(), + ) + }) + + test("an org still without schedules is asked at most once per lifetime of its answer", () => { + const empty = update(signedIn(), answer([])).model + const agedAtMs = NOW + NO_SCHEDULES_TTL_MS + const woke = update(empty, Message.WokeOnCall({ nowMs: agedAtMs })) + const stillEmpty = update(woke.model, answer([], agedAtMs)) + expectVitest(commandsOf(stillEmpty)).toEqual([ + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: agedAtMs } }, + ]) + expectVitest( + commandsOf(update(stillEmpty.model, Message.WokeOnCall({ nowMs: agedAtMs + 61_000 }))), + ).toEqual([]) }) }) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index e29cee4d4d..bdb87b1a88 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -117,8 +117,11 @@ const ONCALL_RETRY_CAP_MS = 15 * 60_000 export const ONCALL_IDLE_READ_MS = 60 * 60_000 /** Focus re-reads at most this often. */ export const ONCALL_WAKE_MIN_MS = 60_000 -/** An org's "no schedules" answer is trusted this long before asking again. */ -export const NO_SCHEDULES_TTL_MS = 24 * 60 * 60_000 +/** + * An org's "no schedules" answer is trusted this long. Minutes, not a day: only a schedule written + * in this tab clears it, so a teammate's first schedule shows once it ages out. + */ +export const NO_SCHEDULES_TTL_MS = 5 * 60_000 export type OnCallSchedule = Readonly<{ nextReadAtMs: number | null; backoffMs: number }> diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 0bdca57292..269f59789c 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -28,7 +28,13 @@ import { isSameMenu, popupIdOf, triggerIdOf } from "./menus" import { BUILT_COMMIT, CheckDeployedCommit, ReloadPage } from "./version" import { openPalette, openShortcuts, paletteStep, shortcutsDialogStep, toggleTheme } from "./update-palette" import { Menu, Message, OutMessage } from "./message" -import { ONCALL_WAKE_MIN_MS, type OnCallSnapshot, scheduleAfterAnswer, scheduleAfterFailure } from "./oncall" +import { + isNoSchedulesFresh, + ONCALL_WAKE_MIN_MS, + type OnCallSnapshot, + scheduleAfterAnswer, + scheduleAfterFailure, +} from "./oncall" import { DashboardsData, InfraPresenceData, @@ -166,13 +172,20 @@ const onCallFailed = (model: Model, orgId: string, failedAtMs: number): UpdateRe } } -/** Back in view or focus: move the pill's clock, and read again if the last event is a minute old. */ -const onCallWoke = (model: Model, nowMs: number): UpdateReturn => { - const hasSchedules = Option.exists(AsyncData.getData(model.onCall), (data) => data.shifts.length > 0) - if (!hasSchedules) return { model } - const moved = modifyFields(model, { onCallNowMs: () => nowMs }) - return nowMs - model.onCallNowMs >= ONCALL_WAKE_MIN_MS ? readOnCall(moved) : { model: moved } -} +/** + * Back in view or focus: move the pill's clock, and read again if the last event is a minute old. + * An org without schedules is asked again once that answer has aged: a teammate may have made one. + */ +const onCallWoke = (model: Model, nowMs: number): UpdateReturn => + Option.match(Option.all([model.maybeOrgId, AsyncData.getData(model.onCall)]), { + onNone: () => ({ model }), + onSome: ([orgId, data]) => { + if (data.shifts.length === 0) + return isNoSchedulesFresh(model.noScheduleOrgs[orgId], nowMs) ? { model } : readOnCall(model) + const moved = modifyFields(model, { onCallNowMs: () => nowMs }) + return nowMs - model.onCallNowMs >= ONCALL_WAKE_MIN_MS ? readOnCall(moved) : { model: moved } + }, + }) /** A page wrote a schedule or override: the org may have its first schedule now. */ const onCallChanged = (model: Model): UpdateReturn => From 82145ab326135e4e7a11d21f1739a6636ee27fb7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:31 +0100 Subject: [PATCH 22/35] fix(shell): only the current page's link carries aria-current in the sidebar A section row whose sub-item is the current page keeps data-active="child" and leaves aria-current to the sub-item. The on-call footer row is a plain anchor, so it never claims the page its nav row already marks. --- apps/web/src/shell/nav-groups.scene.test.ts | 31 ++++++++++++++++++++- apps/web/src/shell/oncall.scene.test.ts | 22 +++++++++++++-- apps/web/src/shell/view-sidebar.ts | 12 ++------ packages/views/src/ui/sidebar.ts | 6 ++-- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts index a85dab0187..558131f7d7 100644 --- a/apps/web/src/shell/nav-groups.scene.test.ts +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -1,6 +1,17 @@ import { Option } from "effect" import type { HtmlBuilder } from "foldkit/html" -import { Command, click, expect, given, Mount, role, scene, withViewInputs } from "foldkit/scene" +import { + all, + Command, + click, + expect, + expectAll, + given, + Mount, + role, + scene, + withViewInputs, +} from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import { SaveCollapsedNavGroups } from "./command" import { Message } from "./message" @@ -100,6 +111,24 @@ describe("sidebar groups", () => { ) }) + test("one link is the current page: the sub-item while its section is open, the section once folded", () => { + const explore = role("link", { name: "Explore" }) + const current = all.role("link", { current: "page" }) + scene( + // Explore links to `/traces`, the page its Traces sub-item is. + { update, view: shellAt("/traces") }, + given(signedIn()), + measured, + expectAll(current).toHaveCount(1), + expect(role("link", { name: "Traces" })).toHaveAttr("aria-current", "page"), + expect(explore).toHaveAttr("data-active", "child"), + click(analyze), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expectAll(current).toHaveCount(1), + expect(explore).toHaveAttr("aria-current", "page"), + ) + }) + test("the collapsed rail shows every row of a folded group and its labels are no tab stops", () => { scene( { update, view: shellAt("/") }, diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 029b0c5301..4409b856db 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -69,15 +69,17 @@ const answer = (shifts: ReadonlyArray, readAtMs = NOW, isFromCache const commandsOf = (step: UpdateReturn) => Arr.map(step.commands ?? [], (command) => ({ name: command.name, args: command.args })) -const view = (() => { +const viewAt = (currentPath: string) => { const frame = withViewInputs(frameView, { - currentPath: "/", + currentPath, breadcrumbs: [{ label: "Overview" }], page: () => null, topbarActions: () => null, })() return (model: Model, h: HtmlBuilder) => frame(model, h) -})() +} + +const view = viewAt("/") const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) @@ -141,6 +143,20 @@ describe("on-call pill", () => { ) }) + test("the pill is a shortcut: on the page it links to, the nav's row is the current page, not the pill", () => { + const pill = role("link", { name: "Not on call" }) + // Where the pill links today, and where it links once on-call has a page of its own. + Arr.forEach(["/alerts?tab=oncall", "/alerts/on-call"], (currentPath) => + scene( + { update, view: viewAt(currentPath) }, + given(withOnCall([shift({ userId: "user_someone" })])), + measured, + expect(pill).toExist(), + expect(pill).not.toHaveAttr("aria-current"), + ), + ) + }) + test("on the collapsed rail the pill is a badged glyph named by the detail, which its tooltip shows", () => { const pill = role("link", { name: "On call until Mon 09:00 CET, 16 Nov · Checkout primary" }) scene( diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index b2e4ef78f6..12f1b99b71 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -262,7 +262,6 @@ export const shellOnCallStatus = (model: Model): OnCallStatus => */ const onCallPill = ( h: HtmlBuilder, - currentPath: string, status: OnCallStatus, tip: RailTip, isRail: boolean, @@ -281,7 +280,8 @@ const onCallPill = ( h, { className: isOnCall ? undefined : "text-muted-foreground", - link: { href: oncallTabHref(), currentPath }, + // A shortcut, not a nav row: a plain anchor, so it never claims `aria-current="page"`. + href: oncallTabHref(), hasTooltip: true, attributes: [ // The rail's tooltip names the glyph; the open sidebar's adds what the row leaves out. @@ -405,13 +405,7 @@ export const shellSidebarFooter = ( ), sidebarMenu(h, undefined, [ // A row of the footer's list, so it keeps the list's 4px rhythm. - ...onCallPill( - h, - currentPath, - shellOnCallStatus(model), - tip, - !model.isSidebarOpen && !model.isMobile, - ), + ...onCallPill(h, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. footerNavRow( diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 42bdbeb995..dfe450e12e 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -367,7 +367,7 @@ export type SidebarMenuButtonOptions = Readonly<{ isActive?: boolean /** * A sub-item of this row is the current page: the row keeps its medium weight and leaves the - * tint to that sub-item (`data-active="child"`). + * tint and `aria-current="page"` to that sub-item (`data-active="child"`). */ hasActiveChild?: boolean /** Rendered as a router link instead of a button. */ @@ -421,7 +421,9 @@ export const sidebarMenuButton = ( return h.a( [ h.Href(options.link.href), - ...(linkActive ? [h.AriaCurrent("page"), h.DataAttribute("status", "active")] : []), + // One link is the current page: a section row leaves that to its current sub-item. + ...(linkActive && options.hasActiveChild !== true ? [h.AriaCurrent("page")] : []), + ...(linkActive ? [h.DataAttribute("status", "active")] : []), ...attributes, ], children, From f6754063a0f3b1c36046e4ce2ae80ce2d7b1fa6c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:40 +0100 Subject: [PATCH 23/35] fix(alerts): silence save button names its end on the 24h clock the picker uses --- .../alerts/hub/hub-silences.scene.test.ts | 27 +++++++++++++++++++ .../src/page/alerts/shared/silences.test.ts | 6 +++++ apps/web/src/page/alerts/shared/silences.ts | 14 +++++++--- .../src/page/alerts/silence-dialog/view.ts | 3 ++- packages/web-data/src/lib/timezone-format.ts | 10 +++++-- 5 files changed, 54 insertions(+), 6 deletions(-) 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 6dedbb673c..cfbb3daa93 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 @@ -359,6 +359,33 @@ describe("alerts hub, silence side panel", () => { ) }) + 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) diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts index c482cf734a..0c2566425f 100644 --- a/apps/web/src/page/alerts/shared/silences.test.ts +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -52,6 +52,12 @@ describe("silence times", () => { 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( diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 5a5be70bf7..77db386276 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -70,9 +70,17 @@ 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}` diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 73e5106252..1c8b288f3e 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -50,7 +50,8 @@ 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. */ 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) } From d599b1d7bf5d8f4cb42900281daf39de7ed35dde Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:43 +0100 Subject: [PATCH 24/35] fix(ui): make the sidebar row hover visible in the light theme surface-sunken sits under 1% from the light sidebar's grey. Rows now hover at half the selected tint (bg-sidebar-accent/50): visible in light, unchanged in dark. --- docs/design-rules.md | 3 ++- packages/ui/src/components/ui/sidebar.styles.ts | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/design-rules.md b/docs/design-rules.md index 7b109f0724..2ae063d8ae 100644 --- a/docs/design-rules.md +++ b/docs/design-rules.md @@ -98,7 +98,8 @@ bordered box takes its parent's radius minus 1px: `before:rounded-lg-inset` insi - Hover fills: a row, list item or chip hovers `bg-surface-sunken`, and a chip already on `surface-sunken` hovers `surface-strong`. A control (ghost button, toggle, tab, menu trigger) hovers `bg-accent`, and only the kit in `packages/ui` draws one. `hover:bg-muted` is never right - (`maple/surface-washes`). + (`maple/surface-washes`). A sidebar row hovers `bg-sidebar-accent/50`, half its selected tint: + the sidebar is already grey in the light theme, where a neutral wash does not show. - Hover outlines: a pickable card or chip hovers `border-border-strong`, a toned one its tone's `-a4` edge. `ring` is focus only, and `foreground` or a `/NN` tone is never a hover edge (`maple/hover-edge`). diff --git a/packages/ui/src/components/ui/sidebar.styles.ts b/packages/ui/src/components/ui/sidebar.styles.ts index 26c394eebe..13183bebcf 100644 --- a/packages/ui/src/components/ui/sidebar.styles.ts +++ b/packages/ui/src/components/ui/sidebar.styles.ts @@ -1,7 +1,7 @@ import { cva } from "class-variance-authority" export const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] duration-200 ease-move group-data-instant:transition-none hover:bg-surface-sunken hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-surface-sunken active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=child]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:active:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[state=open]:active:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] duration-200 ease-move group-data-instant:transition-none hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent/50 active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=child]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:active:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[state=open]:active:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", { defaultVariants: { size: "default", @@ -14,9 +14,10 @@ export const sidebarMenuButtonVariants = cva( sm: "h-7 text-xs", }, variant: { - // Hover is a neutral wash; the accent tint is the selected row's alone. + // Hover is the selected tint at half strength, so the full tint stays the selected row's. + // A neutral wash (`surface-sunken`) is under 1% from the light sidebar's own grey. default: - "hover:bg-surface-sunken active:bg-surface-sunken hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground", + "hover:bg-sidebar-accent/50 active:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground", outline: "bg-background ring-1 ring-sidebar-border hover:bg-sidebar-accent active:bg-sidebar-accent hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground hover:ring-sidebar-accent active:ring-sidebar-accent", }, @@ -123,7 +124,7 @@ export const sidebarMenuSubItemStyles = "group/menu-sub-item relative" export const sidebarMenuSubButtonStyles = [ // 36px in the mobile sheet (below `md`, a touch target), 28px on the desktop rail. - "flex h-9 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-surface-sunken hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-surface-sunken active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 md:h-7 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "flex h-9 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent/50 active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 md:h-7 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", ] as const From 717a12fa65eee712248d91d54cba66e9fb1841f5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:04:29 +0100 Subject: [PATCH 25/35] fix(alerts): first-run Inbox and Rules leave the create actions to the empty state --- .../web/src/page/alerts/hub/hub.scene.test.ts | 37 +++++++++++++++++++ apps/web/src/page/alerts/hub/view.ts | 36 +++++++++++++----- 2 files changed, 64 insertions(+), 9 deletions(-) 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 5d0c3508cb..a821b640e2 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -180,6 +180,43 @@ describe("alerts hub", () => { ) }) + 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([ diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 45ca0d6631..7278c9c6c1 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -180,12 +180,35 @@ const primaryLink = (h: H, href: string, label: string): Html => 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): Html | undefined => { - if (tab === "inbox" || tab === "rules") return newRuleButton(h, model) +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) { @@ -253,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 }), @@ -264,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, @@ -335,7 +353,7 @@ export const view = Submodel.defineView((model, h, { title: TAB_LABEL[tab], - actions: headerActions(h, model, tab), + actions: headerActions(h, model, tab, overview), }, [ tab === "routing" From 994eb5af29e4cc31e154fab424f49649f34846f7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:05:14 +0100 Subject: [PATCH 26/35] fix(alerts): hub replaces away a ?sheet= that opens nothing --- .../alerts/hub/hub-silences.scene.test.ts | 34 +++++++++++++++++++ apps/web/src/page/alerts/hub/update.ts | 14 +++++++- 2 files changed, 47 insertions(+), 1 deletion(-) 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 cfbb3daa93..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 @@ -423,6 +423,40 @@ describe("alerts hub, silence side panel", () => { ).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() diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 89fb77bfeb..c559c3f07c 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -759,7 +759,19 @@ const silenceCommands = (step: ReturnType): Command * a viewer who may save. */ const followSilenceSheet = (model: Model, context: PageContext): Requested => { - const isInUrl = model.search.sheet === SILENCE_SHEET && model.tab === "silences" + 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) From 9ca9b4afc532a5df7fb224a6975f99ce200c4608 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:07:50 +0100 Subject: [PATCH 27/35] fix(alerts): routing row's chain delete lists the rules using it and holds the delete --- .../src/page/alerts/escalation-chain/view.ts | 45 ++----------- .../page/alerts/hub/hub-chains.scene.test.ts | 41 +++++++++++- apps/web/src/page/alerts/hub/view-chains.ts | 33 ++++++---- .../alerts/shared/chain-delete-confirm.ts | 66 +++++++++++++++++++ 4 files changed, 132 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/page/alerts/shared/chain-delete-confirm.ts diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index e924e1acf1..91a1b0da94 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -26,6 +26,7 @@ import { textarea } from "@maple/views/ui/textarea" import { typography } from "@maple/views/ui/typography" import type { PageViewInputs } from "../../contract" import { chainConnector } from "../../kit/chain-connector" +import { chainDeleteConfirm } from "../shared/chain-delete-confirm" import { dashboardPage } from "../../kit/dashboard-page" import { managersOnlyPage } from "../../kit/form-section" import { type PickedChip, pickChips } from "../../kit/pick-chips" @@ -612,51 +613,15 @@ const aside = (h: H, model: Model): Html => /** "Delete {chain}?": lists the rules that start it, which the API makes move off it first. */ const deleteDialog = (h: H, model: Model): Html => { - const chain = AsyncData.getData(model.existing.data) - const ruleIds: ReadonlyArray = Option.match(chain, { - onNone: () => [], - onSome: (found) => found.rule_ids, - }) - const names = Arr.map( - Arr.filter(model.rules ?? [], (rule) => Arr.some(ruleIds, (id) => id === rule.id)), - (rule) => rule.name, - ) - const isInUse = ruleIds.length > 0 + const confirm = chainDeleteConfirm(h, AsyncData.getData(model.existing.data), model.rules) return confirmDialog(h, { slotId: DELETE_DIALOG_ID, model: model.deleteDialog, toParentMessage: (message) => Message.GotDeleteDialogMessage({ message }), title: `Delete ${chainTitle(model)}?`, - description: isInUse - ? "These rules would stop escalating. Remove the chain from their severity tiers first." - : "Escalations already running finish on their own copy. This cannot be undone.", + description: confirm.description, children: [ - ...(isInUse - ? [ - h.ul( - [h.Class("list-disc space-y-1 pl-5")], - [ - ...Arr.map(names, (ruleName) => - h.li([h.Key(ruleName)], [typography(h, { size: "sm" }, [ruleName])]), - ), - ...(ruleIds.length > names.length - ? [ - h.li( - [], - [ - typography(h, { size: "sm", tone: "muted" }, [ - names.length === 0 - ? `${ruleIds.length} ${pluralize(ruleIds.length, "rule")}` - : `and ${ruleIds.length - names.length} more`, - ]), - ], - ), - ] - : []), - ], - ), - ] - : []), + ...confirm.ruleList, ...(model.deleteError === null ? [] : [ @@ -669,7 +634,7 @@ const deleteDialog = (h: H, model: Model): Html => { ], confirmLabel: "Delete chain", isPending: model.isDeleting, - isConfirmDisabled: isInUse, + isConfirmDisabled: confirm.isInUse, onConfirm: Message.ClickedConfirmDelete(), }) } 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 529b1e8bc2..b7756ceb63 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 @@ -3,7 +3,9 @@ import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr } from "effect" import { EscalationChainId } from "@maple/domain/http" import * as Menu from "@maple/views/ui/menu" -import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { expect as expectView, given, Mount, role, scene, text, within, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { V2EscalationChain } from "@maple/domain/http/v2" import type { PageContext } from "../../contract" @@ -118,6 +120,43 @@ describe("alerts hub, escalation chains", () => { }) }) + test("Delete on a chain that rules start lists them and holds the delete, as its own page does", () => { + const askToDelete = (n: number) => + pageUpdate( + onRouting(ENGINEER, CHAINS), + Message.GotRowMenuMessage({ + menuId: chainMenuId(Schema.decodeSync(EscalationChainId)(chainUuid(n))), + message: Menu.Message.ClickedItem({ index: 2, item: { kind: "Item", value: "delete" } }), + }), + ).model + const confirm = within(role("alertdialog"), role("button", { name: "Delete chain" })) + scene( + { update: pageUpdate, view }, + given(askToDelete(1)), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/These rules would stop escalating/u)).toExist(), + expectView(within(role("alertdialog"), text("Checkout errors"))).toExist(), + expectView(within(role("alertdialog"), text("Checkout p95"))).toExist(), + expectView(confirm).toBeDisabled(), + ) + // Before the rules list has loaded they are counted instead of named, and still hold it. + scene( + { update: pageUpdate, view }, + given({ ...askToDelete(1), rules: null }), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(within(role("alertdialog"), text("2 rules"))).toExist(), + expectView(confirm).toBeDisabled(), + ) + // A chain no rule starts deletes as before. + scene( + { update: pageUpdate, view }, + given(askToDelete(3)), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/Escalations already running finish on their own copy/u)).toExist(), + expectView(confirm).not.toBeDisabled(), + ) + }) + test("a refused delete stays open and names the rules that still start the chain", () => { const model = onRouting(ENGINEER, CHAINS) const chainId = Schema.decodeSync(EscalationChainId)(chainUuid(1)) diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index fd2e617007..42838fa2b6 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -22,6 +22,7 @@ import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { sectionHeading } from "../../kit/section-heading" +import { chainDeleteConfirm } from "../shared/chain-delete-confirm" import { chainHref, chainSummary, newChainHref } from "../shared/escalation-chains" import { memberName } from "../shared/org-members" import { Message } from "./message" @@ -234,7 +235,10 @@ export const chainsSection = (h: H, model: Model): Html => ], ) -/** "Delete {chain}?": the 409 that names the rules holding it stays in the dialog. */ +/** + * "Delete {chain}?": as on the chain's page, the rules that start it are listed and hold the + * delete. A 409 from a rule attached since the list was read stays in the dialog. + */ export const deleteChainDialog = (h: H, model: Model): Html => { const chain = Arr.findFirst( Option.getOrElse(AsyncData.getData(model.chains.data), () => []), @@ -244,24 +248,29 @@ export const deleteChainDialog = (h: H, model: Model): Html => { Option.map(chain, (found) => found.name), () => "escalation chain", ) + const confirm = chainDeleteConfirm(h, chain, model.rules) + const children = [ + ...confirm.ruleList, + ...(model.deleteChainError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't delete chain"]), + alertDescription(h, [model.deleteChainError]), + ]), + ]), + ] return confirmDialog(h, { slotId: "alerts-delete-chain", model: model.deleteChainDialog, toParentMessage: (message) => Message.GotDeleteChainDialogMessage({ message }), title: `Delete ${name}?`, - description: "Escalations already running finish on their own copy. This cannot be undone.", - children: - model.deleteChainError === null - ? undefined - : [ - alert(h, { variant: "crit", size: "sm" }, [ - icon(h, "CircleXmarkIcon"), - alertTitle(h, ["Couldn't delete chain"]), - alertDescription(h, [model.deleteChainError]), - ]), - ], + description: confirm.description, + children: children.length === 0 ? undefined : children, confirmLabel: "Delete chain", isPending: model.deletingChainId !== null, + isConfirmDisabled: confirm.isInUse, onConfirm: Message.ClickedConfirmDeleteChain(), }) } diff --git a/apps/web/src/page/alerts/shared/chain-delete-confirm.ts b/apps/web/src/page/alerts/shared/chain-delete-confirm.ts new file mode 100644 index 0000000000..0662646ad3 --- /dev/null +++ b/apps/web/src/page/alerts/shared/chain-delete-confirm.ts @@ -0,0 +1,66 @@ +import type { V2EscalationChain } from "@maple/domain/http/v2" +import { pluralize } from "@maple/ui/lib/format" +import { Option, Array as Arr } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { typography } from "@maple/views/ui/typography" + +export type ChainDeleteConfirm = Readonly<{ + /** A rule still starts the chain: the API refuses the delete, so the confirm holds it. */ + isInUse: boolean + description: string + /** The rules that start it by name; ones this tab has not loaded are counted. */ + ruleList: ReadonlyArray +}> + +/** + * What a chain's delete confirm says, on its own page and on its Routing row: the rules that + * start it, which the API makes move off it first, and Delete held while there are any. + */ +export const chainDeleteConfirm = ( + h: HtmlBuilder, + chain: Option.Option>, + rules: ReadonlyArray<{ readonly id: string; readonly name: string }> | null, +): ChainDeleteConfirm => { + const ruleIds: ReadonlyArray = Option.match(chain, { + onNone: () => [], + onSome: (found) => found.rule_ids, + }) + const names = Arr.map( + Arr.filter(rules ?? [], (rule) => Arr.some(ruleIds, (id) => id === rule.id)), + (rule) => rule.name, + ) + if (ruleIds.length === 0) + return { + isInUse: false, + description: "Escalations already running finish on their own copy. This cannot be undone.", + ruleList: [], + } + return { + isInUse: true, + description: "These rules would stop escalating. Remove the chain from their severity tiers first.", + ruleList: [ + h.ul( + [h.Class("list-disc space-y-1 pl-5")], + [ + ...Arr.map(names, (ruleName) => + h.li([h.Key(ruleName)], [typography(h, { size: "sm" }, [ruleName])]), + ), + ...(ruleIds.length > names.length + ? [ + h.li( + [], + [ + typography(h, { size: "sm", tone: "muted" }, [ + names.length === 0 + ? `${ruleIds.length} ${pluralize(ruleIds.length, "rule")}` + : `and ${ruleIds.length - names.length} more`, + ]), + ], + ), + ] + : []), + ], + ), + ], + } +} From d04fb31f41c94e4a3fa6183852264ff730435255 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:08:24 +0100 Subject: [PATCH 28/35] fix(alerts): a chain id that matches nothing is titled Escalation chain, not New escalation chain --- .../escalation-chain.scene.test.ts | 23 +++++++++++++------ .../src/page/alerts/escalation-chain/view.ts | 6 ++--- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts index 71674951eb..91441ef15a 100644 --- a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -36,7 +36,7 @@ import { payloadOf } from "./form" import { Message } from "./message" import type { Model } from "./model" import { init, insertButtonId, isBlockingNavigation, menuIdOf, update } from "./update" -import { view as pageView } from "./view" +import { chainTitle, view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -423,11 +423,20 @@ describe("escalation chain editor", () => { ) }) - test("a link with a malformed chain id says the chain was not found", () => { - scene( - { update: pageUpdate, view }, - given(entered({ _tag: "AlertsEscalationChain", chainId: "nope", search: {} })), - expectView(text("Escalation chain not found")).toExist(), - ) + test("a link with a malformed chain id says the chain was not found, under the heading a deleted one gets", () => { + Arr.forEach(["nope", "esc_notAChainId"], (chainId) => { + const bad = entered({ _tag: "AlertsEscalationChain", chainId, search: {} }) + scene( + { update: pageUpdate, view }, + given(bad), + expectView(text("Escalation chain not found")).toExist(), + expectView(role("heading", { name: "Escalation chain" })).toExist(), + expectView(role("heading", { name: "New escalation chain" })).not.toExist(), + ) + // The breadcrumb's last entry is the same title. + expect(chainTitle(bad)).toBe("Escalation chain") + }) + // The new-chain page keeps its own name. + expect(chainTitle(newChain())).toBe("New escalation chain") }) }) diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index 91a1b0da94..153f25a0cd 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -57,9 +57,9 @@ import { backHref, DELETE_DIALOG_ID, insertButtonId, LEAVE_DIALOG_ID, menuIdOf } type H = HtmlBuilder -/** The page's name: the chain as last read, or "New escalation chain". */ -export const chainTitle = (model: Pick): string => - model.editingId === null +/** The page's name: the chain as last read, or "New escalation chain". A URL id that is no chain's is not a new one. */ +export const chainTitle = (model: Pick): string => + model.editingId === null && !model.isBadId ? "New escalation chain" : Option.getOrElse( Option.map(AsyncData.getData(model.existing.data), (chain) => chain.name), From af1108a671674fcbe76ffd6145725544f1639a99 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:09:40 +0100 Subject: [PATCH 29/35] fix(alerts): removing a chain target keeps focus in the step --- .../escalation-chain.scene.test.ts | 39 ++++++++++++++++++- .../page/alerts/escalation-chain/update.ts | 36 ++++++++++++++++- .../src/page/alerts/escalation-chain/view.ts | 13 ++++++- apps/web/src/page/kit/pick-chips.ts | 8 +++- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts index 91441ef15a..99fb456d4c 100644 --- a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -35,7 +35,7 @@ import { FocusChainElement } from "./command" import { payloadOf } from "./form" import { Message } from "./message" import type { Model } from "./model" -import { init, insertButtonId, isBlockingNavigation, menuIdOf, update } from "./update" +import { init, insertButtonId, isBlockingNavigation, menuIdOf, removeTargetButtonId, update } from "./update" import { chainTitle, view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) @@ -187,6 +187,43 @@ describe("escalation chain editor", () => { ) }) + test("removing a target moves focus to the chip that takes its place, or to the step's Add button", () => { + const slack = `destination:${chainDestinationUuid(1)}` + const pagerDuty = `destination:${chainDestinationUuid(2)}` + const both = run(newChain(), [pick("s0", slack), pick("s0", pagerDuty)]) + const focused = (model: Model, target: string) => + pageUpdate(model, Message.ClickedRemoveTarget({ key: "s0", target })).commands + // The first of two: the one after it, which now leads the step. + expect(focused(both, slack)).toMatchObject([ + { name: "FocusEscalationChainElement", args: { id: removeTargetButtonId("s0", pagerDuty) } }, + ]) + // The last one, and the only one: the Add button that follows the chips. + const add = { + name: "FocusEscalationChainElement", + args: { id: triggerId(menuIdOf("targets", "s0")) }, + } + expect(focused(both, pagerDuty)).toMatchObject([add]) + const one = pageUpdate(both, Message.ClickedRemoveTarget({ key: "s0", target: slack })).model + expect(focused(one, pagerDuty)).toMatchObject([add]) + // The chip the focus is sent to is on the page under that id. + scene( + { update: pageUpdate, view }, + given(both), + click(role("button", { name: "Stop notifying Slack #alerts-api on step 1" })), + Command.resolve(FocusChainElement, Message.CompletedFocus()), + expectView(role("button", { name: "Stop notifying Slack #alerts-api on step 1" })).not.toExist(), + expectView(role("button", { name: /^Stop notifying .+ on step 1$/u })).toHaveAttr( + "id", + removeTargetButtonId("s0", pagerDuty), + ), + ) + // A target that is not on the step, and a viewer who cannot edit: nothing moves. + expect(focused(both, "destination:missing") ?? []).toEqual([]) + expect( + focused(run(both, [Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER })]), slack) ?? [], + ).toEqual([]) + }) + test("saving with missing fields shows each problem and sends nothing", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/escalation-chain/update.ts b/apps/web/src/page/alerts/escalation-chain/update.ts index 4c92b77107..2f2ca2143a 100644 --- a/apps/web/src/page/alerts/escalation-chain/update.ts +++ b/apps/web/src/page/alerts/escalation-chain/update.ts @@ -35,6 +35,7 @@ import { payloadOf, removeStep, removeTarget, + targetKey, targetOfKey, } from "./form" import { Message, type MenuKind } from "./message" @@ -54,6 +55,10 @@ export const menuIdOf = (kind: MenuKind, key: string): string => `escalation-cha /** The + on the connector above the step at `index` (or below the last one). */ export const insertButtonId = (index: number): string => `escalation-chain-insert-${index}` +/** The remove button of one target chip on a step. */ +export const removeTargetButtonId = (key: string, target: string): string => + `escalation-chain-remove-${key}-${target}` + /** Where Cancel, Save and Delete go back to. */ export const backHref = (): string => routingTabHref() @@ -191,6 +196,35 @@ const removed = (model: Model, key: string): Return => { } } +/** A removed target hands focus to the chip that takes its place, or to the step's Add button after the last. */ +const removedTarget = (model: Model, key: string, target: string): Return => { + if (!canEdit(model)) return { model } + const targets = Option.match( + Arr.findFirst(model.form.steps, (step) => step.key === key), + { + onNone: (): ReadonlyArray => [], + onSome: (step) => Arr.map(step.targets, targetKey), + }, + ) + return Option.match( + Arr.findFirstIndex(targets, (existing) => existing === target), + { + onNone: () => ({ model }), + onSome: (index) => { + const next = targets[index + 1] + return { + model: { ...model, form: removeTarget(model.form, key, target) }, + commands: focus( + next === undefined + ? triggerId(menuIdOf("targets", key)) + : removeTargetButtonId(key, next), + ), + } + }, + }, + ) +} + const stepAction = (model: Model, key: string, value: string): Return => { switch (value) { case "up": @@ -308,7 +342,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedInsertStep: ({ index }) => canEdit(model) ? added(model, insertStep(model.form, index)) : { model }, GotMenuMessage: ({ kind, key, message: child }) => stepMenu(model, kind, key, child), - ClickedRemoveTarget: ({ key, target }) => withForm(model, (form) => removeTarget(form, key, target)), + ClickedRemoveTarget: ({ key, target }) => removedTarget(model, key, target), ClickedSave: () => save(model), CompletedSave: ({ result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index 153f25a0cd..f6f7365310 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -51,7 +51,14 @@ import { } from "./form" import { Message } from "./message" import { canManage, type Model } from "./model" -import { backHref, DELETE_DIALOG_ID, insertButtonId, LEAVE_DIALOG_ID, menuIdOf } from "./update" +import { + backHref, + DELETE_DIALOG_ID, + insertButtonId, + LEAVE_DIALOG_ID, + menuIdOf, + removeTargetButtonId, +} from "./update" // The escalation chain editor: the chain as a column of sentence cards, its name and preview beside it. @@ -153,6 +160,7 @@ const targetChip = ( target: V2EscalationTarget, ): PickedChip => { const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) + const removeId = removeTargetButtonId(key, targetKey(target)) // The step is named: the same target may sit on several steps. const onStep = `on step ${index + 1}` switch (target.type) { @@ -163,6 +171,7 @@ const targetChip = ( label: name, media: destinationMedia(h, model, target.destination_id), removeLabel: `Stop notifying ${name} ${onStep}`, + removeId, onRemove, } } @@ -179,6 +188,7 @@ const targetChip = ( ), }), removeLabel: `Stop paging ${name} ${onStep}`, + removeId, onRemove, } } @@ -191,6 +201,7 @@ const targetChip = ( media: icon(h, "CalendarIcon", { size: 14, className: "shrink-0 text-muted-foreground" }), ...(detail === undefined ? undefined : { detail }), removeLabel: `Stop paging ${name} ${onStep}`, + removeId, onRemove, } } diff --git a/apps/web/src/page/kit/pick-chips.ts b/apps/web/src/page/kit/pick-chips.ts index 6cd33e14b4..6bf72b6447 100644 --- a/apps/web/src/page/kit/pick-chips.ts +++ b/apps/web/src/page/kit/pick-chips.ts @@ -17,6 +17,8 @@ export type PickedChip = Readonly<{ /** A muted note after the name ("Ben Brook on call"). */ detail?: string removeLabel: string + /** The remove button's id, for a form that moves focus to it when the chip before it goes. */ + removeId?: string onRemove: M }> @@ -50,7 +52,11 @@ const chip = (h: HtmlBuilder, entry: PickedChip, canEdit: boolean, isDi variant: "ghost", size: "icon-2xs", isDisabled, - attributes: [h.AriaLabel(entry.removeLabel), h.OnClick(entry.onRemove)], + attributes: [ + ...(entry.removeId === undefined ? [] : [h.Id(entry.removeId)]), + h.AriaLabel(entry.removeLabel), + h.OnClick(entry.onRemove), + ], }, [icon(h, "XmarkIcon")], ), From fc1ac22c51b7a210d4827f5b88e486db3d4365e6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:17:46 +0100 Subject: [PATCH 30/35] test(e2e): a saved rule returns to the Rules section --- apps/web/e2e/tests/alert-rules.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 { From c5adf7d3ee85daf6465291b58b127303ca53d180 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:29:06 +0100 Subject: [PATCH 31/35] fix(shell): an org without schedules is asked again hourly, so an open tab finds the first one --- apps/web/src/shell/oncall.scene.test.ts | 3 ++- apps/web/src/shell/oncall.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 4409b856db..77154754f3 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -240,7 +240,8 @@ describe("on-call reads", () => { expectVitest(commandsOf(empty)).toEqual([ { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: NOW } }, ]) - expectVitest(empty.model.onCallNextReadAtMs).toBeNull() + // Asked again within the hour, so a tab left open and focused still finds a first schedule. + expectVitest(empty.model.onCallNextReadAtMs).toBe(NOW + ONCALL_IDLE_READ_MS) const booted = enter(ORG)(init({ ...flags, noScheduleOrgs: { [ORG]: NOW } })) expectVitest(commandsOf(booted)).toContainEqual({ name: "LoadOnCall", diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index bdb87b1a88..3f00fcfc44 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -131,15 +131,16 @@ const backedOff = (nowMs: number, backoffMs: number): OnCallSchedule => { } /** - * After an answer: at the next handoff, or within the hour while the user is off call; a handoff - * the answer already places in the past (the API has not caught up) retries with backoff. + * After an answer: at the next handoff, or within the hour while the user is off call or the org + * has no schedules; a handoff the answer already places in the past retries with backoff. */ export const scheduleAfterAnswer = ( snapshot: OnCallSnapshot, nowMs: number, backoffMs: number, ): OnCallSchedule => { - if (snapshot.shifts.length === 0) return { nextReadAtMs: null, backoffMs: 0 } + // No schedules yet: a teammate may make the first one while this tab stays open and focused. + if (snapshot.shifts.length === 0) return { nextReadAtMs: nowMs + ONCALL_IDLE_READ_MS, backoffMs: 0 } const handoff = nextHandoffMs(snapshot) if (handoff !== null && handoff <= nowMs) return backedOff(nowMs, backoffMs) const isOnCall = onCallStatusOf(snapshot, nowMs)._tag === "OnCall" From 655534efd3e3c034e426fd95c00ef04adcf38666 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:30:36 +0100 Subject: [PATCH 32/35] docs(design-rules): list the form sheet size --- docs/design-rules.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/design-rules.md b/docs/design-rules.md index 2ae063d8ae..004863639b 100644 --- a/docs/design-rules.md +++ b/docs/design-rules.md @@ -35,9 +35,9 @@ only shrink (`bun run ui:patterns:check` in CI). The workflow is in `apps/web/CL of the scroll area, status or Back on the left, buttons on the right, primary last. The settings `saveBar` is an `actionBar` that shows while edits are unsaved. A hand-built sticky bottom bar is flagged (`maple/no-handrolled-kit-look`). -- A side sheet's width is its `size`, picked by what it holds: `default` a form or filters, - `detail` a peek at one item, `wide` a log, review or guide, `full` a trace waterfall. Never a - `max-w-*` in its className (`maple/sheet-size`). +- A side sheet's width is its `size`, picked by what it holds: `default` a filter list or a few + fields, `detail` a peek at one item, `form` a create or edit form, `wide` a log, review or guide, + `full` a trace waterfall. Never a `max-w-*` in its className (`maple/sheet-size`). ## Type and spacing From 479e46c6cb3d1e9b9877264d74530423bf3a7ca2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:44:08 +0100 Subject: [PATCH 33/35] fix(shell): a folded group keeps the current sub-item; on-call is re-read within the hour whoever holds it --- apps/web/src/shell/nav-groups.scene.test.ts | 12 +++++++----- apps/web/src/shell/oncall.scene.test.ts | 8 ++++++-- apps/web/src/shell/oncall.ts | 19 ++++++++----------- apps/web/src/shell/view-nav.ts | 17 ++++++++++++----- 4 files changed, 33 insertions(+), 23 deletions(-) diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts index 558131f7d7..2a959129a2 100644 --- a/apps/web/src/shell/nav-groups.scene.test.ts +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -92,7 +92,7 @@ describe("sidebar groups", () => { ) }) - test("a folded group on a sub-item's page shows the parent row alone, highlighted", () => { + test("a folded group on a sub-item's page keeps the section row and that sub-item alone", () => { const explore = role("link", { name: "Explore" }) const logs = role("link", { name: "Logs" }) scene( @@ -104,14 +104,15 @@ describe("sidebar groups", () => { expect(explore).toHaveAttr("data-active", "child"), click(analyze), Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), - expect(explore).toHaveAttr("data-active", "true"), - expect(logs).toBeAbsent(), + // Still Logs: the section row alone would link to Traces and name no current page. + expect(logs).toHaveAttr("data-active", "true"), + expect(explore).toHaveAttr("data-active", "child"), expect(role("link", { name: "Traces" })).toBeAbsent(), expect(dashboards).toBeAbsent(), ) }) - test("one link is the current page: the sub-item while its section is open, the section once folded", () => { + test("one link is the current page: the sub-item, with its group open or folded", () => { const explore = role("link", { name: "Explore" }) const current = all.role("link", { current: "page" }) scene( @@ -125,7 +126,8 @@ describe("sidebar groups", () => { click(analyze), Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), expectAll(current).toHaveCount(1), - expect(explore).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Traces" })).toHaveAttr("aria-current", "page"), + expect(explore).toHaveAttr("data-active", "child"), ) }) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 77154754f3..b3362e79f3 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -197,9 +197,13 @@ describe("on-call reads", () => { expectVitest(stale.model.onCall._tag).toBe("Loading") }) - test("an answer reads again after the next handoff, or within the hour while the user is off call", () => { + test("an answer reads again after the next handoff, and within the hour whoever is on call", () => { + const soon = NOW + 30 * 60_000 + const handingOff = update(signedIn(), answer([shift({ endsAtMs: soon })])).model + expectVitest(handingOff.onCallNextReadAtMs).toBe(soon + 5_000) + // A handoff hours away: a teammate's override can still change who is on call before it. const onCall = update(signedIn(), answer([shift()])).model - expectVitest(onCall.onCallNextReadAtMs).toBe(HANDOFF + 5_000) + expectVitest(onCall.onCallNextReadAtMs).toBe(NOW + ONCALL_IDLE_READ_MS) const offCall = update(signedIn(), answer([shift({ userId: "user_someone", endsAtMs: null })])).model expectVitest(offCall.onCallNextReadAtMs).toBe(NOW + ONCALL_IDLE_READ_MS) }) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index 3f00fcfc44..fd60b46d98 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -113,7 +113,7 @@ export const nextHandoffMs = (snapshot: OnCallSnapshot): number | null => { const HANDOFF_SETTLE_MS = 5_000 export const ONCALL_RETRY_MS = 60_000 const ONCALL_RETRY_CAP_MS = 15 * 60_000 -/** A shift starting out of a gap shows up in no handoff, so a user off call checks hourly. */ +/** A new shift, override or first schedule shows up in no handoff the tab knows, so it checks hourly. */ export const ONCALL_IDLE_READ_MS = 60 * 60_000 /** Focus re-reads at most this often. */ export const ONCALL_WAKE_MIN_MS = 60_000 @@ -131,24 +131,21 @@ const backedOff = (nowMs: number, backoffMs: number): OnCallSchedule => { } /** - * After an answer: at the next handoff, or within the hour while the user is off call or the org - * has no schedules; a handoff the answer already places in the past retries with backoff. + * After an answer: at the next handoff, and within the hour whatever it said (a teammate's override + * or first schedule sends this tab no event); a handoff already in the past retries with backoff. */ export const scheduleAfterAnswer = ( snapshot: OnCallSnapshot, nowMs: number, backoffMs: number, ): OnCallSchedule => { - // No schedules yet: a teammate may make the first one while this tab stays open and focused. - if (snapshot.shifts.length === 0) return { nextReadAtMs: nowMs + ONCALL_IDLE_READ_MS, backoffMs: 0 } const handoff = nextHandoffMs(snapshot) if (handoff !== null && handoff <= nowMs) return backedOff(nowMs, backoffMs) - const isOnCall = onCallStatusOf(snapshot, nowMs)._tag === "OnCall" - const candidates = [ - ...(handoff === null ? [] : [handoff + HANDOFF_SETTLE_MS]), - ...(isOnCall ? [] : [nowMs + ONCALL_IDLE_READ_MS]), - ] - return { nextReadAtMs: candidates.length === 0 ? null : Math.min(...candidates), backoffMs: 0 } + const idle = nowMs + ONCALL_IDLE_READ_MS + return { + nextReadAtMs: handoff === null ? idle : Math.min(handoff + HANDOFF_SETTLE_MS, idle), + backoffMs: 0, + } } /** After a failed read: retry from a minute, doubling, capped. */ diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 90d8ca7ff4..c7e3c94ecb 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -148,16 +148,21 @@ const navRow = ( ): Html => { const isActive = isNavItemActive(currentPath, item) const isCollapsed = !model.isSidebarOpen && !model.isMobile - const subItems = + const allSubItems = item.subItems === undefined ? undefined : partitionSubItems(item.subItems, surfacesOf(model), currentPath).shown - if (isCollapsed && subItems !== undefined && subItems.length > 0) + if (isCollapsed && allSubItems !== undefined && allSubItems.length > 0) return collapsedSectionRow(h, model, item, isActive) - const isOpen = subItems !== undefined && subItems.length > 0 && isActive && !isGroupFolded + // A folded group keeps where you are: the section row and, under it, the current sub-item alone. + const subItems = + isGroupFolded && allSubItems !== undefined + ? Arr.filter(allSubItems, (sub) => sub.href === activeSubHrefOf(allSubItems, currentPath)) + : allSubItems + const isOpen = subItems !== undefined && subItems.length > 0 && isActive // The tint belongs to the sub-item that is the current page, when one is. const hasActiveChild = isOpen && activeSubHrefOf(subItems, currentPath) !== undefined - const preview = isOpen || subItems === undefined ? undefined : navPreview(item, subItems) + const preview = isOpen || allSubItems === undefined ? undefined : navPreview(item, allSubItems) return sidebarMenuItem(h, undefined, [ sidebarMenuButton( h, @@ -206,7 +211,9 @@ const navRow = ( ], ), ...(item.badge === undefined ? [] : [navBadge(h, item.badge)]), - ...(subItems !== undefined && isActive && !isGroupFolded ? [subList(h, subItems, currentPath)] : []), + ...(subItems !== undefined && isActive && (subItems.length > 0 || !isGroupFolded) + ? [subList(h, subItems, currentPath)] + : []), ]) } From 7360717b9b52481c217b63e59733ea49ba70fe5c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:44:53 +0100 Subject: [PATCH 34/35] docs(shell): navRow's comment follows the folded group's current sub-item --- apps/web/src/shell/view-nav.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index c7e3c94ecb..aff7e6f38e 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -136,7 +136,7 @@ const subList = (h: HtmlBuilder, subItems: ReadonlyArray, c /** * `NavRow`: a link, a section with its children while active, or on the collapsed rail a menu. In a - * folded group the current page's row stands alone, highlighted, without its children. + * folded group the current section keeps its row and, of its children, the current one alone. */ const navRow = ( h: HtmlBuilder, From 0c18b56188e9cb6783c8e355aca72a88941da27d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 15:36:47 +0100 Subject: [PATCH 35/35] fix(alerts): chain delete confirm speaks of one rule in the singular; notes error names the notes --- .../escalation-chain.scene.test.ts | 33 ++++++++++++++++++- .../src/page/alerts/escalation-chain/form.ts | 4 +-- .../alerts/shared/chain-delete-confirm.ts | 5 ++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts index 99fb456d4c..b195df009c 100644 --- a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -2,7 +2,7 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { EscalationChainId } from "@maple/domain/http" import { EscalationChainPublicId } from "@maple/domain/http/v2" -import { Option, Result, Schema, Array as Arr } from "effect" +import { Option, Result, Schema, Array as Arr, String as Str } from "effect" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" import type { LoadFailure } from "@maple/views/data/load-failure" @@ -329,6 +329,37 @@ describe("escalation chain editor", () => { ) }) + test("a chain one rule starts is refused in the singular", () => { + const [first] = CHAINS + expect(first).toBeDefined() + if (first === undefined) return + const single = loaded(opening(1), { ...first, rule_ids: Arr.take(first.rule_ids, 1) }) + scene( + { update: pageUpdate, view }, + given(pageUpdate(single, Message.ClickedDelete()).model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/This rule would stop escalating/u)).toExist(), + expectView(within(role("alertdialog"), role("button", { name: "Delete chain" }))).toBeDisabled(), + ) + }) + + test("notes are saved with the chain, and refused past 500 characters", () => { + const noted = run(editing(1), [Message.ChangedDescription({ value: "Rollback notes" })]) + expect(Option.map(payloadOf(noted.form), (payload) => payload.description)).toEqual( + Option.some("Rollback notes"), + ) + const tooLong = run(editing(1), [ + Message.ChangedDescription({ value: Str.repeat(501)("x") }), + Message.ClickedSave(), + ]) + expect(payloadOf(tooLong.form)).toEqual(Option.none()) + scene( + { update: pageUpdate, view }, + given(tooLong), + expectView(text("Keep the notes to 500 characters")).toExist(), + ) + }) + test("a delete the API refuses reads the chain again, so the dialog names the rule that took it", () => { const asked = run(editing(2), [Message.ClickedDelete(), Message.ClickedConfirmDelete()]) expect(asked.isDeleting).toBe(true) diff --git a/apps/web/src/page/alerts/escalation-chain/form.ts b/apps/web/src/page/alerts/escalation-chain/form.ts index d5143a08cd..794f656ee0 100644 --- a/apps/web/src/page/alerts/escalation-chain/form.ts +++ b/apps/web/src/page/alerts/escalation-chain/form.ts @@ -96,9 +96,7 @@ export const nameError = (name: string): Option.Option => : Option.some("Keep the name to 120 characters") export const descriptionError = (description: string): Option.Option => - isDescription(Str.trim(description)) - ? Option.none() - : Option.some("Keep the description to 500 characters") + isDescription(Str.trim(description)) ? Option.none() : Option.some("Keep the notes to 500 characters") export const waitError = (raw: string): Option.Option => isWait(numberOf(raw)) ? Option.none() : Option.some(`Wait 1 to ${MAX_ESCALATION_WAIT_MINUTES} minutes`) diff --git a/apps/web/src/page/alerts/shared/chain-delete-confirm.ts b/apps/web/src/page/alerts/shared/chain-delete-confirm.ts index 0662646ad3..2f78c960ff 100644 --- a/apps/web/src/page/alerts/shared/chain-delete-confirm.ts +++ b/apps/web/src/page/alerts/shared/chain-delete-confirm.ts @@ -37,7 +37,10 @@ export const chainDeleteConfirm = ( } return { isInUse: true, - description: "These rules would stop escalating. Remove the chain from their severity tiers first.", + description: + ruleIds.length === 1 + ? "This rule would stop escalating. Remove the chain from its severity tiers first." + : "These rules would stop escalating. Remove the chain from their severity tiers first.", ruleList: [ h.ul( [h.Class("list-disc space-y-1 pl-5")],