diff --git a/apps/web/e2e/tests/alert-rules.e2e.ts b/apps/web/e2e/tests/alert-rules.e2e.ts index feeef87386..0524ea4ea1 100644 --- a/apps/web/e2e/tests/alert-rules.e2e.ts +++ b/apps/web/e2e/tests/alert-rules.e2e.ts @@ -81,22 +81,26 @@ test.describe("alert rules", () => { try { await app.open(editUrl) + // A saved rule opens as closed cards: each field sits behind its card's summary. + await browser.locator("#sentence-card-condition-summary").tap() await expect(browser.locator("#rule-threshold")).toHaveValue("0.7") await expect(screen.getByLabel("Critical threshold")).toHaveValue("3") await expect(screen.getByLabel("Out of the last")).toHaveValue("4") + await browser.locator("#sentence-card-name-summary").tap() 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) expect(renamed.body.breaches_out_of).toBe(4) await app.open(editUrl) + await browser.locator("#sentence-card-condition-summary").tap() 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 { @@ -117,10 +121,12 @@ test.describe("alert rules", () => { // The template picker opens first; Cancel closes it on the default rule. await screen.getByRole("button", "Cancel").tap() await screen.getByLabel("Rule name").fill(`e2e range ${runTag()}`) - await screen.getByRole("combobox", "Alert when the value").tap() + await screen.getByRole("combobox", "Condition").tap() await screen.getByRole("option", "is between").tap() - await expect(screen.getByText(/^Fix before saving:/)).toContainText("Upper threshold") + // The action bar lists what blocks the save, each a link to its field. + await expect(screen.getByText("Fix before saving:")).toBeVisible() + await expect(screen.getByRole("button", "Upper threshold")).toBeVisible() }) test("previews a SQL rule", { session: "reviewer" }, async ({ app, screen }) => { diff --git a/apps/web/package.json b/apps/web/package.json index 5e40e73a9d..cbc8133733 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,7 @@ "@fontsource-variable/geist-mono": "^5.3.0", "@maple-dev/effect-sdk": "workspace:*", "@maple/agent-sessions": "workspace:*", + "@maple/alerting-core": "workspace:*", "@maple/chat-platform": "workspace:*", "@maple/domain": "workspace:*", "@maple/effect-db": "workspace:*", diff --git a/apps/web/src/components/settings/audit-log-resource.ts b/apps/web/src/components/settings/audit-log-resource.ts index 4432954756..72c9590f98 100644 --- a/apps/web/src/components/settings/audit-log-resource.ts +++ b/apps/web/src/components/settings/audit-log-resource.ts @@ -14,7 +14,7 @@ export type AuditResourceLink = | { readonly to: "/errors/issues/$issueId"; readonly params: { readonly issueId: string } } | { readonly to: "/investigations/$id"; readonly params: { readonly id: string } } | { readonly to: "/anomalies/$incidentId"; readonly params: { readonly incidentId: string } } - | { readonly to: "/alerts"; readonly search: { readonly tab: "routing" } } + | { readonly to: "/alerts/routing" } | { readonly to: "/integrations"; readonly search: { readonly integration?: string } } | { readonly to: "/settings"; readonly search: { readonly tab: string } } @@ -79,7 +79,7 @@ function resourceLink(entry: AuditEntryResource): AuditResourceLink | null { return incidentId === null ? null : { to: "/anomalies/$incidentId", params: { incidentId } } } case "alert_destination": - return { to: "/alerts", search: { tab: "routing" } } + return { to: "/alerts/routing" } case "scrape_target": return { to: "/integrations", search: { integration: "prometheus" } } case "gcp_connector": diff --git a/apps/web/src/lab/alerts-hub-lab.ts b/apps/web/src/lab/alerts-hub-lab.ts index 8a4e55067a..a1a60d4719 100644 --- a/apps/web/src/lab/alerts-hub-lab.ts +++ b/apps/web/src/lab/alerts-hub-lab.ts @@ -2,22 +2,28 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" -import { Effect, Option, Result, Schema, Array as Arr } from "effect" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" +import { zonedDateParts } from "@maple/query-engine/datetime" +import * as Combobox from "@maple/views/ui/combobox" import * as HubMessage from "../page/alerts/hub/message" import * as HubModel from "../page/alerts/hub/model" import * as HubUpdate from "../page/alerts/hub/update" import { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view" import * as SilenceDialog from "../page/alerts/silence-dialog/dialog" -import { destinationRow, NOW, ORG, ruleRow, stateRow } from "../page/alerts/shared/test-rows" +import { alertsHref } from "../page/alerts/shared/links" +import { destinationRow, NOW, ORG, ruleRow, stateRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" +import { readEffectiveTimeZone } from "../platform/time-zone" -// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences tabs and the silence dialog over +// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences sections and the silence side panel over // fixtures, including a rule skipped for too few samples and a cancelled silence. `?state=` picks -// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom. +// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom, +// silence-dialog-filled. The shell stands on the section's own path, `?sheet=` is played here +// rather than pushed, and times read on the viewer's own clock, as in the app. export const LabState = Schema.Literals([ "inbox", @@ -27,20 +33,22 @@ export const LabState = Schema.Literals([ "silences", "silence-dialog", "silence-dialog-custom", + "silence-dialog-filled", ]) export type LabState = typeof LabState.Type -export const Flags = Schema.Struct({ state: LabState }) +export const Flags = Schema.Struct({ state: LabState, timeZone: Schema.String }) export type Flags = typeof Flags.Type -export const flags: Effect.Effect = Effect.sync(() => ({ +export const flags: Effect.Effect = Effect.map(readEffectiveTimeZone, (timeZone) => ({ state: Option.getOrElse( Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), (): LabState => "inbox", ), + timeZone, })) -export const Model = Schema.Struct({ state: LabState, hub: HubModel.Model }) +export const Model = Schema.Struct({ state: LabState, timeZone: Schema.String, hub: HubModel.Model }) export type Model = typeof Model.Type export const Message = defineMessageUnion({ GotHubMessage: { message: HubMessage.Message } }) @@ -48,16 +56,19 @@ export type Message = typeof Message.Type type Return = Update.Return -const CONTEXT: PageContext = { - orgId: Schema.decodeSync(OrgId)(ORG), - pinnedNamespace: null, - timeZone: "UTC", -} +const LAB_ORG = Schema.decodeSync(OrgId)(ORG) +const contextOf = (timeZone: string): PageContext => ({ orgId: LAB_ORG, pinnedNamespace: null, timeZone }) const GRANTS = Policy.grantsOf(["org:engineer"]) -/** The hub's reads and writes are dropped: the fixture is all the data there is. */ +/** The hub's reads, writes and URL changes are dropped: the fixture is all the data there is. */ const keepLocal = (commands: ReadonlyArray> | undefined) => - Arr.filter(commands ?? [], (command) => !/^(Fetch|Toggle|Delete|Save|Read|End)/.test(command.name)) + Arr.filter( + commands ?? [], + (command) => + !/^(Fetch|Toggle|Delete|Save|Read|End|PushAlertsHubSheet|DismissAlertsHubSheet)/.test( + command.name, + ), + ) const MINUTE = 60_000 const iso = (ms: number) => new Date(ms).toISOString() @@ -123,7 +134,10 @@ const SILENCES = [ silence("11111111-1111-4111-8111-111111111111", "Deploying checkout v2.3", -20, 40, { services: ["checkout"], }), - silence("22222222-2222-4222-8222-222222222222", "Database migration", 180, 240, { tags: ["db"] }), + // Tomorrow: its row names the day. + silence("22222222-2222-4222-8222-222222222222", "Database migration", 26 * 60, 27 * 60, { + tags: ["db"], + }), // Cancelled before it started: the API collapses its window to one instant. silence("33333333-3333-4333-8333-333333333333", "Load test on staging", -45, -45, { environments: ["staging"], @@ -140,10 +154,24 @@ const tabOf = (state: LabState) => ? "inbox" : "silences" -const step = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT) +const routeTo = (model: HubModel.Model, sheet: string | undefined, context: PageContext) => + HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), context) + +/** The hub's update, with the `?sheet=` push and dismissal it asks for played back as route changes. */ +const step = (model: HubModel.Model, message: HubMessage.Message, context: PageContext) => { + const next = HubUpdate.update(model, message, context) + const asked = Arr.findFirst(next.commands ?? [], (command) => Str.endsWith("HubSheet")(command.name)) + if (asked._tag === "None") return next + const routed = routeTo( + next.model, + asked.value.name === "PushAlertsHubSheet" ? HubUpdate.SILENCE_SHEET : undefined, + context, + ) + return { ...routed, commands: [...(next.commands ?? []), ...(routed.commands ?? [])] } +} -const hubFor = (state: LabState): HubModel.Model => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: tabOf(state) } }, CONTEXT).model +const hubFor = (state: LabState, context: PageContext): HubModel.Model => { + const entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), context).model const base = [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), @@ -163,49 +191,85 @@ const hubFor = (state: LabState): HubModel.Model => { result: Result.succeed(SILENCES), }), ] - return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message).model) + return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message, context).model) +} + +const pad = (value: number): string => Str.padStart(2, "0")(String(value)) + +/** `ms` as a `datetime-local` value on the viewer's clock. */ +const localInput = (ms: number, timeZone: string): string => { + const parts = zonedDateParts(ms, timeZone) + return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}` } +const dialog = (message: SilenceDialog.Message) => HubMessage.Message.GotSilenceDialogMessage({ message }) +const chip = (field: SilenceDialog.ScopeField, value: string) => + dialog( + SilenceDialog.Message.GotScopeComboMessage({ + field, + message: Combobox.Message.ClickedItem({ value }), + }), + ) + +/** What each silence-panel state does once the panel is open. */ +const panelSteps = (state: LabState, timeZone: string): ReadonlyArray => + state === "silence-dialog-custom" + ? [dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" }))] + : state === "silence-dialog-filled" + ? [ + chip("services", "checkout"), + chip("services", "payment-svc"), + chip("environments", "production"), + dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" })), + dialog( + SilenceDialog.Message.ChangedUntil({ + value: localInput(NOW + 26 * 60 * MINUTE, timeZone), + }), + ), + dialog(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ] + : [] + const toLab = ( next: Readonly<{ commands?: ReadonlyArray> }>, ) => Command.mapMessages(keepLocal(next.commands), (message) => Message.GotHubMessage({ message })) export const init = (flags: Flags): Return => { - const hub = hubFor(flags.state) - if (flags.state !== "silence-dialog" && flags.state !== "silence-dialog-custom") - return { model: { state: flags.state, hub } } + const context = contextOf(flags.timeZone) + const hub = hubFor(flags.state, context) + const lab = { state: flags.state, timeZone: flags.timeZone } + if (!Str.startsWith("silence-dialog")(flags.state)) return { model: { ...lab, hub } } // The dialog opens through its own commands, so they run here rather than in the fold. - const opened = step(hub, HubMessage.Message.ClickedNewSilence()) - const picked = - flags.state === "silence-dialog-custom" - ? step( - opened.model, - HubMessage.Message.GotSilenceDialogMessage({ - message: SilenceDialog.Message.ChangedDuration({ duration: "custom" }), - }), - ) - : { model: opened.model, commands: [] } - return { - model: { state: flags.state, hub: picked.model }, - commands: [...toLab(opened), ...toLab(picked)], - } + const opened = step(hub, HubMessage.Message.ClickedNewSilence(), context) + const filled = Arr.reduce( + panelSteps(flags.state, flags.timeZone), + { model: opened.model, commands: toLab(opened) }, + (current, message) => { + const next = step(current.model, message, context) + return { model: next.model, commands: [...current.commands, ...toLab(next)] } + }, + ) + return { model: { ...lab, hub: filled.model }, commands: filled.commands } } export const update = (model: Model, message: Message): Return => Message.match(message, { GotHubMessage: ({ message: child }) => { - const next = step(model.hub, child) + const next = step(model.hub, child, contextOf(model.timeZone)) return { model: { ...model, hub: next.model }, commands: toLab(next) } }, }) export const breadcrumbs = (model: Model) => hubBreadcrumbs(model.hub) +/** The section's own path, so the sidebar opens Alerts on it. */ +export const currentPath = (model: Model): string => alertsHref({}, model.hub.tab) + export const view = (model: Model, h: HtmlBuilder): Html => h.submodel({ slotId: "lab-alerts-hub", model: model.hub, view: hubView, - viewInputs: { context: CONTEXT }, + viewInputs: { context: contextOf(model.timeZone) }, toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), }) diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index 6a9e917e01..805810cc42 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -46,6 +46,8 @@ import { ruleId, ruleRow, stateRow, + hubAt, + type HubAt, } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" @@ -156,7 +158,7 @@ const RESOLVED = [ ] const hubFor = (state: LabState) => { - const search: HubModel.Model["search"] = + const search: HubAt = state === "hub-rules" ? { tab: "rules" } : state === "hub-anomalies-resolved" @@ -167,7 +169,7 @@ const hubFor = (state: LabState) => { ? [incidentRow({ rule_name: "Payment p95 latency" })] : [incidentRow({ rule_name: "Payment p95 latency" }), ...OPEN, ...RESOLVED] return fold( - HubUpdate.init({ _tag: "Alerts", search }, CONTEXT).model, + HubUpdate.init(hubAt(search), CONTEXT).model, [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), diff --git a/apps/web/src/lab/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/condition-builder-lab.ts b/apps/web/src/lab/condition-builder-lab.ts new file mode 100644 index 0000000000..ae60de661d --- /dev/null +++ b/apps/web/src/lab/condition-builder-lab.ts @@ -0,0 +1,155 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { Option, 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 { typography } from "@maple/views/ui/typography" +import { vstack } from "@maple/views/ui/stack" +import type { WhereClauseAutocompleteValues } from "../lib/query-builder/where-clause-autocomplete" +import type { Resources } from "../platform/resources" +import { sectionHeading } from "../page/kit/section-heading" +import { applyQueryChange } from "../page/kit/query-builder/change" +import { QueryDraft } from "../page/kit/query-builder/draft" +import * as QueryPanel from "../page/kit/query-builder/panel" +import { queryPanelView } from "../page/kit/query-builder/view" + +// `/lab/condition-builder`: the query panel's where clause as condition rows, over fixture +// vocabulary: empty, populated, clauses the rows can't show, and the text editor. + +export const LAB_VALUES: WhereClauseAutocompleteValues = { + services: ["checkout", "api", "payments", "frontend"], + spanNames: ["GET /api/cart", "POST /api/checkout", "db.query"], + environments: ["production", "staging"], + severities: ["ERROR", "WARN", "INFO", "DEBUG"], + httpStatusCodes: ["200", "404", "500", "503"], + attributeKeys: [ + "http.route", + "http.request.method", + "http.response.status_code", + "db.system", + "messaging.destination.partition.id", + "service.version", + ], + // `service.version` is in both groups: its trigger names the group. + resourceAttributeKeys: ["host.name", "k8s.pod.name", "cloud.region", "service.version"], +} + +type Scenario = Readonly<{ title: string; hint: string; query: QueryDraft }> + +const draft = (id: string, name: string, dataSource: "traces" | "logs", whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id, name }, dataSource), + whereClause, +}) + +export const SCENARIOS: ReadonlyArray = [ + { title: "Empty", hint: "No conditions yet.", query: draft("empty", "A", "traces", "") }, + { + title: "Populated", + hint: "An attribute row with several values matches any of them; rows are AND-ed.", + query: draft( + "populated", + "B", + "traces", + 'service.name = "checkout" AND (attr.http.request.method = "GET" OR attr.http.request.method = "POST") AND resource.service.version exists AND attr.messaging.destination.partition.id = "12"', + ), + }, + { + title: "Can't show as rows", + hint: "An OR across different fields stays in the text editor.", + query: draft( + "unparseable", + "C", + "traces", + '(attr.http.route = "/cart" OR attr.rpc.method = "Checkout")', + ), + }, + { + title: "OR group the query drops", + hint: "A named field can't be OR-ed: the filter stays in Text, and the warning names the group the query drops.", + query: draft( + "dropped", + "E", + "traces", + '(service.name = "checkout" OR service.name = "worker") AND attr.http.route exists', + ), + }, + { + title: "Text mode", + hint: "Switched to Text by hand.", + query: draft("text", "D", "logs", 'service.name = "checkout" AND severity = "ERROR"'), + }, +] + +export const Model = Schema.Struct({ panel: QueryPanel.Model, queries: Schema.Array(QueryDraft) }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotPanelMessage: { message: QueryPanel.Message } }) +export type Message = typeof Message.Type + +export const init = (): Update.Return => { + const panel = QueryPanel.init("condition-lab") + return { + model: { + panel: { ...panel, whereModes: { text: "text" } }, + queries: Arr.map(SCENARIOS, (scenario) => scenario.query), + }, + } +} + +export const panelInputs = (queries: ReadonlyArray): QueryPanel.PanelInputs => ({ + queryOf: (queryId) => Option.getOrUndefined(Arr.findFirst(queries, (query) => query.id === queryId)), + baseValuesOf: () => LAB_VALUES, + orgId: "org_lab", + window: null, +}) + +/** The host's half: the panel's query edits land on the drafts. */ +export const update = (model: Model, message: Message): Update.Return => { + const next = QueryPanel.update(model.panel, message.message, panelInputs(model.queries)) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotPanelMessage({ message: inner }), + ) + const out = next.outMessage + const queries = + out?._tag === "ChangedQuery" + ? Arr.map(model.queries, (query) => + query.id === out.queryId ? applyQueryChange(query, out.change) : query, + ) + : model.queries + return { model: { panel: next.model, queries }, commands } +} + +export const view = (model: Model, h: HtmlBuilder): Html => + // As wide as the alert editor gives the panel, so rows lay out as they do there. + vstack(h, { gap: "8", className: "mx-auto w-full max-w-5xl p-4 sm:p-6" }, [ + vstack(h, { gap: "1" }, [ + sectionHeading(h, { title: "Condition builder" }), + typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "The where clause as rows. Every edit prints the same string the text editor writes.", + ]), + ]), + ...Arr.map(model.queries, (query, index) => { + const scenario = SCENARIOS[index] + return vstack(h, { gap: "2", as: "section", attributes: [h.Key(query.id)] }, [ + sectionHeading(h, { title: scenario?.title ?? query.name, hint: scenario?.hint, as: "h3" }), + h.submodel({ + slotId: `condition-lab-${query.id}`, + model: model.panel, + view: queryPanelView, + toParentMessage: (message) => Message.GotPanelMessage({ message }), + viewInputs: { + query, + index, + canRemove: false, + intervalPlaceholder: "Auto", + hasHeaderActions: false, + hasVisibilityToggle: false, + autocompleteValues: LAB_VALUES, + }, + }), + typography(h, { size: "2xs", tone: "subtle", mono: true, as: "p", className: "break-all" }, [ + query.whereClause === "" ? "(empty where clause)" : query.whereClause, + ]), + ]) + }), + ]) diff --git a/apps/web/src/lab/entries/alerts-hub.ts b/apps/web/src/lab/entries/alerts-hub.ts index de2a9f57ed..3a0c51ebf6 100644 --- a/apps/web/src/lab/entries/alerts-hub.ts +++ b/apps/web/src/lab/entries/alerts-hub.ts @@ -1,7 +1,8 @@ import { defineShellLab } from "../shell-lab" -import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" +import { breadcrumbs, currentPath, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" -// `/lab/alerts-hub`: the hub tabs and silence dialog over fixtures (`?state=`). +// `/lab/alerts-hub`: the hub sections and the silence side panel over fixtures (`?state=`), with +// two unacknowledged incidents counted on the sidebar's Inbox. export const lab = defineShellLab("Alerts hub", { Model, @@ -11,6 +12,7 @@ export const lab = defineShellLab("Alerts hub", { init, update, view, - currentPath: "/lab/alerts-hub", + currentPath, breadcrumbs, + seedShell: (shell) => ({ ...shell, alertsInboxCount: 2 }), }) diff --git a/apps/web/src/lab/entries/condition-builder.ts b/apps/web/src/lab/entries/condition-builder.ts new file mode 100644 index 0000000000..10d5534662 --- /dev/null +++ b/apps/web/src/lab/entries/condition-builder.ts @@ -0,0 +1,18 @@ +import { Effect, Schema } from "effect" +import { init, Message, Model, update, view } from "../condition-builder-lab" +import { defineShellLab } from "../shell-lab" + +// `/lab/condition-builder`: the query panel's condition rows over fixture vocabulary, inside the +// real shell so the kit tooltips show. + +export const lab = defineShellLab("Condition builder", { + Model, + Message, + Flags: Schema.Struct({}), + flags: Effect.succeed({}), + init, + update, + view, + currentPath: "/lab/condition-builder", + breadcrumbs: [{ label: "Lab" }, { label: "Condition builder" }], +}) 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/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/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/entries/rule-editor.ts b/apps/web/src/lab/entries/rule-editor.ts new file mode 100644 index 0000000000..2b2ed42a18 --- /dev/null +++ b/apps/web/src/lab/entries/rule-editor.ts @@ -0,0 +1,6 @@ +import { defineLabWithFlags } from "../define" +import { Flags, flags, init, Model, update, view } from "../rule-editor-lab" + +// `/lab/rule-editor`: the alert rule editor over fixture rows (`?variant=populated|new|anomaly`). + +export const lab = defineLabWithFlags("Rule editor", { Model, Flags, flags, init, update, view }) diff --git a/apps/web/src/lab/entries/shell.ts b/apps/web/src/lab/entries/shell.ts new file mode 100644 index 0000000000..ab51c1c6c3 --- /dev/null +++ b/apps/web/src/lab/entries/shell.ts @@ -0,0 +1,57 @@ +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, toTooltip, update, view } from "../shell-states-lab" + +// `/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 + +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 }), + // 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/escalation-editor-lab.ts b/apps/web/src/lab/escalation-editor-lab.ts new file mode 100644 index 0000000000..8a4a84110b --- /dev/null +++ b/apps/web/src/lab/escalation-editor-lab.ts @@ -0,0 +1,202 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { OnCallScheduleId } from "@maple/domain/http" +import { EscalationChainPublicId } 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 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" +import { chainTitle, view as chainView } from "../page/alerts/escalation-chain/view" +import { routingTabHref } from "../page/alerts/shared/escalation-chains" +import { + chainDestinations, + 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" +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", + "leave", + "readonly", + "long", +]) +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", enabled: false }), + ruleRow(3, { name: "Payments webhook failures" }), +] +const M = ChainMessage.Message + +/** The fixture destinations with the type their name says, so each draws its own mark. */ +const TYPES: 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 = [] + +/** 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, ...kept(next.commands)] } + }) + +const pageFor = (state: LabState) => { + const isNew = state === "new" || state === "invalid" + const chain = state === "long" ? (LONG ?? EVERYDAY) : EVERYDAY + const entered = chainInit( + isNew + ? { _tag: "AlertsEscalationChainNew", search: {} } + : { + _tag: "AlertsEscalationChain", + chainId: Schema.encodeSync(EscalationChainPublicId)(chain.id), + search: {}, + }, + CONTEXT, + ).model + const base = [ + 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 + ? [] + : [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 })] + : state === "delete" + ? [M.ClickedDelete()] + : state === "leave" + ? [M.ChangedWait({ key: "s0", value: "20" }), M.ClickedCancel()] + : [] + 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(kept(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/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts index 08d3416e53..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" @@ -28,21 +25,17 @@ 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" -// `/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,20 +92,8 @@ 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({ _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 }), @@ -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/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..d53e3cd1dd 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" @@ -54,6 +54,10 @@ export const LabState = Schema.Literals([ "schedules", "strip", "editor-new", + "editor-covered", + "editor-gaps", + "editor-early", + "editor-override", "editor-edit", "editor-invalid", "editor-error", @@ -128,8 +132,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( @@ -186,8 +190,14 @@ const hubFor = (state: LabState): Step => { const formStep = (model: FormModel.Model, message: FormMessage.Message) => FormUpdate.update(model, message, CONTEXT) +const addPerson = (index: number) => + FormMessage.Message.GotParticipantMenuMessage({ + key: "l0", + message: { _tag: "ClickedItem", index, item: { kind: "Item", value: memberId(index) } }, + }) + const formFor = (state: LabState): Step => { - const isEdit = state === "editor-edit" || state === "editor-error" + const isEdit = state === "editor-edit" || state === "editor-error" || state === "editor-override" const start = FormUpdate.init( { _tag: "AlertsScheduleNew", search: isEdit ? { scheduleId: schedulePublicId(1) } : {} }, CONTEXT, @@ -206,6 +216,10 @@ const formFor = (state: LabState): Step => key: start.model.existing.key ?? "", result: Result.succeed(checkoutSchedule(ONCALL_NOW)), }), + FormMessage.Message.CompletedFetchOverrides({ + key: start.model.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(ONCALL_NOW)), + }), ] : []), ] @@ -252,7 +266,38 @@ const formFor = (state: LabState): Step => ] : state === "editor-new" ? [FormMessage.Message.ChangedName({ value: "Checkout primary" })] - : [] + : state === "editor-covered" || state === "editor-gaps" || state === "editor-early" + ? [ + FormMessage.Message.ChangedName({ value: "Checkout primary" }), + addPerson(0), + addPerson(1), + // Weeknights only: the days and weekends show as gaps. + ...(state === "editor-gaps" + ? [FormMessage.Message.ClickedAddRestriction({ key: "l0" })] + : []), + // A start two days back: the rotation is already under way. + ...(state === "editor-early" + ? [ + FormMessage.Message.PickedRotation({ + key: "l0", + rotation: "daily", + }), + FormMessage.Message.ChangedLayerText({ + key: "l0", + field: "startsAt", + value: "2026-11-08T09:00", + }), + ] + : []), + ] + : state === "editor-override" + ? [ + // One weeknights layer, so the saved overrides sit over real gaps. + FormMessage.Message.ClickedRemoveLayer({ key: "l2" }), + FormMessage.Message.ClickedRemoveLayer({ key: "l1" }), + FormMessage.Message.ClickedAddRestriction({ key: "l0" }), + ] + : [] return fold(start, [...base, ...extra], formStep) } diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 278d123e6c..fc949d7608 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -65,7 +65,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/alerts-hub", title: "Alerts hub", description: - "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence dialog (`?state=`).", + "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence side panel empty, picking a time and filled in (`?state=`).", kind: "lab", session: "none", }, @@ -89,7 +89,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, asking to delete or to leave, read-only and with a long chain (`?state=`).", kind: "lab", session: "none", }, @@ -451,6 +459,22 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/condition-builder", + title: "Condition builder", + description: + "The query panel's where clause as field, operator and value rows: empty, populated, a clause the rows can't show, and text mode.", + kind: "lab", + session: "none", + }, + { + path: "/lab/rule-editor", + title: "Rule editor", + description: + "The alert rule editor over fixture rows: a saved error rate rule (?variant=populated), a blank new rule (new) and a scoped anomaly rule (anomaly). ?theme=light|dark.", + kind: "lab", + session: "none", + }, { path: "/lab/time-range", title: "Time range picker", @@ -507,6 +531,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/rule-editor-lab.ts b/apps/web/src/lab/rule-editor-lab.ts new file mode 100644 index 0000000000..5050a7f106 --- /dev/null +++ b/apps/web/src/lab/rule-editor-lab.ts @@ -0,0 +1,230 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { AlertRulePreviewResponse } from "@maple/domain/http" +import { Clock, 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 type { PageContext } from "../page/contract" +import { chainDestinations, chainUuid, populatedChains } from "../page/alerts/shared/escalation-test-fixtures" +import { scopedAnomalyRuleRow } from "../page/alerts/shared/anomaly-test-fixtures" +import { DESTINATION_ID, destinationRow, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import { windowMsOf } from "../page/alerts/editor/derive-rows" +import { RuleCard } from "../page/alerts/editor/form" +import { init as editorInit } from "../page/alerts/editor/init" +import { Message as EditorMessage } from "../page/alerts/editor/message" +import { Model as EditorModel } from "../page/alerts/editor/model" +import { update as editorUpdate } from "../page/alerts/editor/update" +import { view as editorView } from "../page/alerts/editor/view" +import type { Resources } from "../platform/resources" + +// `/lab/rule-editor`: the alert rule editor over fixture rows, no API needed. `?variant=` picks a +// saved rule (`populated`), a blank new rule (`new`) or a scoped anomaly rule (`anomaly`); +// `?open=condition,notify` opens cards. + +export const Variant = Schema.Literals(["populated", "new", "anomaly"]) +export type Variant = typeof Variant.Type + +export const Flags = Schema.Struct({ nowMs: Schema.Number, variant: Variant, open: Schema.Array(RuleCard) }) +export type Flags = typeof Flags.Type + +const decodeVariant = Schema.decodeUnknownOption(Variant) +const decodeCard = Schema.decodeUnknownOption(RuleCard) +const searchParams = Effect.sync(() => new URLSearchParams(window.location.search)) + +/** + * The boot clock, `?variant=` and `?open=` (the cards opened for editing, comma-separated; a saved + * rule opens on its summaries). `?theme=light|dark` sets the theme class as `index.html` does. + */ +export const flags: Effect.Effect = Effect.all({ + nowMs: Clock.currentTimeMillis, + variant: Effect.sync(() => { + const params = new URLSearchParams(window.location.search) + const theme = params.get("theme") + if (theme === "light" || theme === "dark") { + document.documentElement.classList.remove("light", "dark") + document.documentElement.classList.add(theme) + document.documentElement.style.colorScheme = theme + } + return Option.getOrElse(decodeVariant(params.get("variant")), (): Variant => "populated") + }), + open: Effect.map(searchParams, (params) => + Arr.flatMap(Str.split(params.get("open") ?? "", ","), (card) => Option.toArray(decodeCard(card))), + ), +}) + +export const Model = Schema.Struct({ variant: Variant, editor: EditorModel }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotEditorMessage: { message: EditorMessage } }) +export type Message = typeof Message.Type + +const LAB_CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)(ORG), + pinnedNamespace: null, + timeZone: "UTC", +} + +const run = (model: EditorModel, messages: ReadonlyArray): EditorModel => + Arr.reduce(messages, model, (current, message) => editorUpdate(current, message, LAB_CONTEXT).model) + +const DRAFT_ID = "6f1c2e3a-0000-4000-8000-000000000001" +const MIN = 60_000 + +const CHAT_DESTINATION = destinationRow({ + name: "Checkout on-call", + type: "chat", + config_json: { summary: "Slack · acme", channelLabel: "#checkout-oncall" }, +}) + +/** A saved error rate rule on checkout-api, two tiers, the critical one starting a chain. */ +const ERROR_RATE_RULE = ruleRow(2, { + name: "Checkout error rate", + notes: "Pages the checkout team. Runbook: wiki/checkout/errors", + signal_type: "error_rate", + service_names_json: ["checkout-api"], + environments_json: ["production"], + tags_json: ["checkout", "tier-1"], + severity: "warning", + threshold: 0.05, + thresholds_json: [ + { + severity: "warning", + value: 0.05, + upper: null, + recoveryValue: null, + destinationIds: [DESTINATION_ID], + }, + { + severity: "critical", + value: 0.1, + upper: null, + recoveryValue: null, + destinationIds: [], + escalationChainId: chainUuid(1), + }, + ], + consecutive_breaches_required: 3, +}) + +const decodePreview = Schema.decodeUnknownSync(AlertRulePreviewResponse) + +/** The preview window's error rate around 2%, with two bursts over the 5% line. */ +const previewOf = (window: Readonly<{ min: number; max: number }>): AlertRulePreviewResponse => { + const bucketMs = 30 * MIN + const count = Math.max(1, Math.floor((window.max - window.min) / bucketMs)) + const at = (share: number) => Math.round(count * share) + const bursts = [ + { from: at(0.36), to: at(0.38) }, + { from: at(0.75), to: at(0.76) }, + ] + const points = Arr.map(Arr.range(0, count - 1), (i) => { + const isBurst = Arr.some(bursts, (burst) => i >= burst.from && i < burst.to) + const value = isBurst ? 0.072 + Math.sin(i) * 0.008 : 0.02 + Math.sin(i * 0.7) * 0.006 + const point = { bucket: new Date(window.min + i * bucketMs).toISOString(), value, sampleCount: 300 } + return value > 0.05 + ? { ...point, status: "breached", level: "warning" } + : { ...point, status: "healthy" } + }) + return decodePreview({ + bucketSeconds: bucketMs / 1000, + windowMinutes: 5, + threshold: 0.05, + thresholdUpper: null, + thresholds: [ + { severity: "warning", value: 0.05, upper: null, recoveryValue: null, destinationIds: [] }, + ], + comparator: "gt", + truncatedToStart: null, + series: [{ groupKey: "all", points }], + wouldFire: Arr.map(bursts, (burst) => ({ + groupKey: "all", + start: new Date(window.min + burst.from * bucketMs).toISOString(), + end: new Date(window.min + burst.to * bucketMs).toISOString(), + severity: "warning", + })), + }) +} + +const opened = (search: EditorModel["search"], nowMs: number, rules: ReadonlyArray) => + run(editorInit({ _tag: "AlertsCreate", search }, LAB_CONTEXT).model, [ + EditorMessage.GeneratedDraftId({ id: DRAFT_ID }), + EditorMessage.CompletedReadClock({ nowMs }), + EditorMessage.CompletedReadGrants({ grants: Policy.allPermissions }), + EditorMessage.UpdatedDestinations({ orgId: ORG, rows: [CHAT_DESTINATION, ...chainDestinations] }), + EditorMessage.UpdatedRules({ orgId: ORG, rows: rules }), + EditorMessage.CompletedFetchEscalationChains({ chains: populatedChains([]) }), + ]) + +/** The preview the lab's "API" answers with: the fixture series, or no data for a new rule's org. */ +const previewAnswer = (variant: Variant, model: EditorModel): EditorMessage => + EditorMessage.CompletedFetchPreview({ + key: model.preview.key ?? "", + isSaved: false, + result: + variant === "new" ? Result.fail({ message: null }) : Result.succeed(previewOf(windowMsOf(model))), + }) + +type EditorCommands = ReadonlyArray> +type Step = Readonly<{ model: EditorModel; commands: EditorCommands }> + +/** A user action on the seeded page: unlike the setup reads, its commands run (a dialog's exit). */ +const act = (step: Step, message: EditorMessage): Step => { + const next = editorUpdate(step.model, message, LAB_CONTEXT) + return { model: next.model, commands: [...step.commands, ...(next.commands ?? [])] } +} + +const seed = (variant: Variant, open: ReadonlyArray, nowMs: number): Step => { + const loaded = + variant === "new" + ? opened({}, nowMs, []) + : variant === "anomaly" + ? opened({ ruleId: ruleId(8) }, nowMs, [scopedAnomalyRuleRow()]) + : opened({ ruleId: ruleId(2) }, nowMs, [ERROR_RATE_RULE]) + const answered: Step = { model: run(loaded, [previewAnswer(variant, loaded)]), commands: [] } + // "Start blank" closes the templates dialog: its exit transition has to run, or it stays mounted. + const started = variant === "new" ? act(answered, EditorMessage.ClickedStartBlank()) : answered + return Arr.reduce(open, started, (step, card) => act(step, EditorMessage.ClickedExpandCard({ card }))) +} + +// Reads and writes need a session; the lab keeps the editor's local commands (chart, focus, menus). +const BLOCKED = /^(FetchAlert|RunAlert|SaveAlert|TrackAlert|ReloadAlert|ReadAlertEditorGrants|GenerateAlert)/ +const isPreviewFetch = (command: Readonly<{ name: string }>) => command.name === "FetchAlertEditorPreview" + +const toLab = (commands: ReadonlyArray> | undefined) => + Command.mapMessages( + Arr.filter(commands ?? [], (command) => !BLOCKED.test(command.name)), + (message) => Message.GotEditorMessage({ message }), + ) + +export const init = (labFlags: Flags): Update.Return => { + const started = editorInit({ _tag: "AlertsCreate", search: {} }, LAB_CONTEXT) + const seeded = seed(labFlags.variant, labFlags.open, labFlags.nowMs) + return { + model: { variant: labFlags.variant, editor: seeded.model }, + commands: toLab([...(started.commands ?? []), ...seeded.commands]), + } +} + +export const update = (model: Model, message: Message): Update.Return => { + const next = editorUpdate(model.editor, message.message, LAB_CONTEXT) + // An edit that asks for a new preview gets the lab's answer at once, in place of the API's. + const editor = Arr.some(next.commands ?? [], isPreviewFetch) + ? run(next.model, [previewAnswer(model.variant, next.model)]) + : next.model + return { model: { ...model, editor }, commands: toLab(next.commands) } +} + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("flex h-dvh flex-col bg-background")], + [ + h.submodel({ + slotId: "rule-editor-lab", + model: model.editor, + view: editorView, + viewInputs: { context: LAB_CONTEXT }, + toParentMessage: (message) => Message.GotEditorMessage({ message }), + }), + ], + ) 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-lab.ts b/apps/web/src/lab/shell-lab.ts index e63276dbb6..921480308d 100644 --- a/apps/web/src/lab/shell-lab.ts +++ b/apps/web/src/lab/shell-lab.ts @@ -31,13 +31,16 @@ export type ShellLabPage Update.Return view: (model: PageModel, h: HtmlBuilder) => Html subscriptions?: Subscription.Subscriptions - currentPath: string + /** Fixed, or read from the page (a lab standing on the route it renders). */ + currentPath: string | ((model: PageModel) => string) /** Fixed, or read from the page (a lab switching between pages). */ breadcrumbs: | ReadonlyArray | ((model: PageModel) => ReadonlyArray) /** Rendered in `#app` before the shell: lab toolbars that sit beside the shell. */ before?: (model: PageModel, h: HtmlBuilder) => Html + /** Shell state the root's reads would bring (the Alerts › Inbox count), as a fixture. */ + seedShell?: (shell: Shell.Model) => Shell.Model }> export const defineShellLab = ( @@ -88,6 +91,8 @@ export const defineShellLab = (page.view) const before = page.before const beforeView = before === undefined ? null : Submodel.defineView(before) + const currentPathOf = (model: PageModel) => + typeof page.currentPath === "function" ? page.currentPath(model) : page.currentPath const breadcrumbsOf = (model: PageModel) => typeof page.breadcrumbs === "function" ? page.breadcrumbs(model) : page.breadcrumbs const own = Subscription.lift(page.subscriptions ?? {})({ @@ -104,7 +109,7 @@ export const defineShellLab = h.submodel({ diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts new file mode 100644 index 0000000000..7499d93bff --- /dev/null +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -0,0 +1,141 @@ +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 { Array as Arr } from "effect" +import { MeasureSidebarOverflow } from "../shell/mounts" +import { Message as ShellMessage } from "../shell/message" +import { + init, + Message, + type Model, + COMPACT_SHEET_ID, + DismissSheetUrl, + PushSheetUrl, + 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 dismisses it through the URL", () => { + 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(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), + ) + }) + + 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({ href: "/lab/shell", id: 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("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" })) + expectVitest(back.model.sheet.isOpen).toBe(false) + expectVitest(Arr.map(back.commands ?? [], (command) => command.name)).not.toContain( + DismissSheetUrl.name, + ) + }) + + 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), + ) + }) +}) 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..49d780bef8 --- /dev/null +++ b/apps/web/src/lab/shell-states-lab.ts @@ -0,0 +1,375 @@ +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 { 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, + type SheetStep, + sidePanelSheet, +} from "@maple/views/ui/sheet" +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" +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" +import { foldLabShell, labShellView } from "./shell-frame" + +// `/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", "onCallFar", "notOnCall", "none"]) +export type OnCallFixture = typeof OnCallFixture.Type + +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: SHIFTS[fixture], +}) + +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, + /** The panel the sheet shows; kept through its exit transition. */ + panel: Panel, + tooltip: TooltipHost.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 }, + GotTooltipMessage: { message: TooltipHost.Message }, + /** The URL changed (a push, a replace, Back or Forward). */ + ChangedUrl: { href: Schema.String }, + ClickedOpenPanel: { panel: Panel }, + ClickedSubmitPanel: {}, + ClickedOnCallFixture: { fixture: OnCallFixture }, + ClickedStandOn: { standOn: StandOn }, + CompletedNavigate: {}, +}) +export type Message = typeof Message.Type + +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: sheetArgs, + messages: [Message.CompletedNavigate], + execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedNavigate())), +}) + +/** 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: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedNavigate())), +}) + +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) + +/** 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: 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 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", + }, + 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)), + 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 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), + } + }, + ClickedOpenPanel: ({ panel }) => ({ + model, + commands: [PushSheetUrl({ href: model.href, id: PANEL_ID[panel] })], + }), + ClickedSubmitPanel: () => 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]) + +/** The `form` size: a full create form. */ +const rulePanel = (h: HtmlBuilder, model: Model): Html => + sidePanelSheet(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: (close) => [ + button(h, { variant: "outline", attributes: close }, ["Cancel"]), + 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"]), + ], + }) + +const page = (h: HtmlBuilder, model: Model): Html => + dashboardPage( + h, + { + title: "Shell states", + 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" }, [ + 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: "onCallFar", label: "On call, next week" }, + { 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)" }, + { value: "services", label: "Services (Monitor)" }, + { value: "logs", label: "Logs (Analyze)" }, + ], + }), + ), + ]), + model.panel === "snooze" ? snoozePanel(h, model) : rulePanel(h, model), + ], + ) + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Id("app")], + [ + 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/lab/slos-lab.ts b/apps/web/src/lab/slos-lab.ts index 58b043672d..c50337a9ba 100644 --- a/apps/web/src/lab/slos-lab.ts +++ b/apps/web/src/lab/slos-lab.ts @@ -20,7 +20,7 @@ import { sloUuid, type SloScenario, } from "../page/alerts/shared/slo-test-fixtures" -import { destinationRow, incidentRow, NOW, ORG } from "../page/alerts/shared/test-rows" +import { destinationRow, incidentRow, NOW, ORG, hubAt } from "../page/alerts/shared/test-rows" import * as SloMessage from "../page/alerts/slo/message" import * as SloModel from "../page/alerts/slo/model" import * as SloUpdate from "../page/alerts/slo/update" @@ -106,7 +106,7 @@ const INCIDENT = incidentRow({ }) const hubFor = (state: LabState) => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "slos" } }, CONTEXT).model + const entered = HubUpdate.init(hubAt({ tab: "slos" }), CONTEXT).model const rows = state === "tab-empty" ? [] diff --git a/apps/web/src/page/alerts/editor/actions.ts b/apps/web/src/page/alerts/editor/actions.ts index 0e6108183b..7bf019e3cd 100644 --- a/apps/web/src/page/alerts/editor/actions.ts +++ b/apps/web/src/page/alerts/editor/actions.ts @@ -3,15 +3,17 @@ import { buildRuleTestParamsV2, isRulePreviewReady, } from "@maple/web-data/alerts/form-utils" -import { Result } from "effect" +import { Array as Arr, Result } from "effect" import { PageOutMessage } from "../../contract" import { alertsHref } from "../shared/links" -import { RunRuleTest, SaveRule, TrackRuleCreated } from "./command" +import { sentenceCardHeadingId } from "../../kit/sentence-card" +import { FocusRuleCard, RunRuleTest, SaveRule, TrackRuleCreated } from "./command" import { isProtectedDefault, previewIdentityKey, saveIssues } from "./derive" import { draftTimingOf } from "./derive-rows" import type { Message } from "./message" import type { Model } from "./model" import type { Return } from "./steps" +import { cardOfIssue, issueFieldId } from "./summary" // The writes: Dry run and Send test (`runTest`), and Create / Save changes (`handleSave`). @@ -63,7 +65,20 @@ export const completeTest = (model: Model, message: Extract { const form = model.form if (form === null || model.isSaving) return { model } - if (saveIssues(form, draftTimingOf(model, form), isProtectedDefault(model)).length > 0) return { model } + const issues = saveIssues(form, draftTimingOf(model, form), isProtectedDefault(model)) + const first = issues[0] + // A blocked save shows every field's error and lands on the first thing to fix. + if (first !== undefined) { + const card = cardOfIssue(first) + return { + model: { + ...model, + hasAttemptedSave: true, + expandedCards: Arr.union(model.expandedCards, [card]), + }, + commands: [FocusRuleCard({ id: issueFieldId(first) ?? sentenceCardHeadingId(card) })], + } + } return { model: { ...model, isSaving: true }, commands: [SaveRule({ ruleId: model.editingRuleId, payload: buildRuleCreateParamsV2(form) })], @@ -79,7 +94,7 @@ export const completeSave = (model: Model, message: Extract + Effect.callback((resume) => { + const focus = () => { + const element = document.getElementById(id) + if (element instanceof HTMLElement) element.focus() + resume(Effect.succeed(Message.CompletedFocusCard())) + } + // Two frames: the first renders the opened card, the second finds its controls. + const handle = requestAnimationFrame(() => requestAnimationFrame(focus)) + return Effect.sync(() => cancelAnimationFrame(handle)) + }), +}) + /** `onRetry={() => window.location.reload()}`: a live query past its recovery has no other retry. */ export const ReloadPage = Command.define("ReloadAlertEditorPage", { messages: [Message.ReloadedPage], diff --git a/apps/web/src/page/alerts/editor/derive-rows.ts b/apps/web/src/page/alerts/editor/derive-rows.ts index 1d00b3fe31..e146dd5be3 100644 --- a/apps/web/src/page/alerts/editor/derive-rows.ts +++ b/apps/web/src/page/alerts/editor/derive-rows.ts @@ -12,7 +12,9 @@ import { AsyncData } from "foldkit" import { lookupField } from "../../kit/autocomplete-lookups" import { previewIssue, previewPayload, previewStateOf, type PreviewState } from "../shared/preview" import { isoMs } from "../shared/time" +import { isProtectedDefault, saveIssues } from "./derive" import type { RuleForm } from "./form" +import { revealedInvalidFields } from "./summary" import type { Model, TraceFacets } from "./model" import { Array as Arr } from "effect" @@ -89,8 +91,10 @@ export const windowMsOf = (model: Pick): { min: number; max: isoMs(normalizeTimestampInput(model.previewWindow.endTime)), } -const hasPayload = (model: Pick, form: RuleForm): boolean => +/** Whether the draft can be previewed at all: a window, and a query the preview can run. */ +export const hasPreviewPayload = (model: Pick, form: RuleForm): boolean => model.previewWindow !== null && previewPayload(form, model.previewWindow)._tag === "Some" +const hasPayload = hasPreviewPayload /** The timing of the draft's own preview: none while a new one loads, so nothing gates on an older query. */ export const draftTimingOf = ( @@ -113,3 +117,23 @@ export const draftPreviewOf = ( model: Pick, form: RuleForm, ): PreviewState => previewStateOf(model.preview, hasPayload(model, form), previewIssue(form)) + +/** + * The fields that show their error (by element id), as one string: a `createLazy` slot compares it + * by value. Empty on a pristine form. + */ +export const invalidFieldsOf = ( + model: Pick< + Model, + "preview" | "previewWindow" | "touchedFields" | "hasAttemptedSave" | "editingRuleId" | "savedForm" + >, + form: RuleForm, +): string => + Arr.join( + revealedInvalidFields( + saveIssues(form, draftTimingOf(model, form), isProtectedDefault(model)), + model.touchedFields, + model.hasAttemptedSave, + ), + ",", + ) diff --git a/apps/web/src/page/alerts/editor/derive.ts b/apps/web/src/page/alerts/editor/derive.ts index 2ffe94d7a4..a6e53edc29 100644 --- a/apps/web/src/page/alerts/editor/derive.ts +++ b/apps/web/src/page/alerts/editor/derive.ts @@ -49,6 +49,24 @@ export const conditionSubject = (form: RuleForm): string => { return Str.toLowerCase(signalLabels[form.signalType]) } +/** A threshold with its unit: "5%", "800 ms", "?" while empty. */ +export const thresholdValue = (form: RuleForm, raw: string): string => { + const unit = thresholdUnitLabel(form) ?? "" + return `${Str.trim(raw) || "?"}${unit === "%" ? "%" : unit ? ` ${unit}` : ""}` +} + +/** How many checks fire the rule: "in 3 checks in a row (5 min each)", "over 5 min". */ +export const checksPhrase = (form: RuleForm): string => { + const checks = Str.trim(form.consecutiveBreachesRequired) || "?" + const outOf = Str.trim(form.breachesOutOf) + const windowText = `${Str.trim(form.windowMinutes) || "?"} min` + return outOf.length > 0 + ? `in ${checks} of the last ${outOf} checks (${windowText} each)` + : checks === "1" + ? `over ${windowText}` + : `in ${checks} checks in a row (${windowText} each)` +} + /** The rule in one plain sentence, shown in the action bar. */ export const describeRuleCondition = (form: RuleForm, isDefault = false): string => { if (form.signalType === "anomaly") @@ -57,21 +75,12 @@ export const describeRuleCondition = (form: RuleForm, isDefault = false): string : isDefaultAnomalyRule(form) ? "Anomalies in a service, environment or signal" : `Pages on anomalies in ${anomalyScopeSummary(form)}` - const unit = thresholdUnitLabel(form) ?? "" - const value = (raw: string) => `${Str.trim(raw) || "?"}${unit === "%" ? "%" : unit ? ` ${unit}` : ""}` + const value = (raw: string) => thresholdValue(form, raw) const threshold = isRangeComparator(form.comparator) ? `${value(form.threshold)} and ${value(form.thresholdUpper)}` : value(form.threshold) const escalation = comparatorSupportsTiers(form.comparator) ? form.escalation : null - const checks = Str.trim(form.consecutiveBreachesRequired) || "?" - const outOf = Str.trim(form.breachesOutOf) - const windowText = `${Str.trim(form.windowMinutes) || "?"} min` - const when = - outOf.length > 0 - ? `in ${checks} of the last ${outOf} checks (${windowText} each)` - : checks === "1" - ? `over ${windowText}` - : `in ${checks} checks in a row (${windowText} each)` + const when = checksPhrase(form) const severity = escalation === null && form.severity === "critical" ? "Critical" : "Warning" const critical = escalation === null diff --git a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts index 603a765e60..3e36aa836e 100644 --- a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts @@ -3,6 +3,7 @@ import { AlertDestinationId } from "@maple/domain/http" import { Result, Schema, Array as Arr } from "effect" import { click, + Command, expect as expectView, given, Mount, @@ -20,6 +21,7 @@ import { menuIdOf } from "./dest-steps" import type { PageContext } from "../../contract" import { defaultAnomalyRuleRow, scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" import { DESTINATION_ID, destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { FocusRuleCard } from "./command" import { init } from "./init" import { Message } from "./message" import type { Model } from "./model" @@ -54,6 +56,9 @@ const pickExisting = (tier: "warning" | "critical", id: string) => message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value: `existing:${id}` } }), }) +const savesOf = (model: Model) => + Arr.map(pageUpdate(model, Message.ClickedSave()).commands ?? [], ({ name }) => name) + const DESCRIPTION = /^Pages when Maple detects unusual error rate, latency, throughput/ const DEFAULT_NOTE = /^This is the default anomaly rule/ @@ -64,10 +69,10 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(blank), themeResolved(), - expectView(text("When to alert")).toExist(), + expectView(text("Alert when")).toExist(), click(role("button", { name: "Anomaly" })), expectView(text(DESCRIPTION)).toExist(), - expectView(text("When to alert")).not.toExist(), + expectView(text("Alert when")).not.toExist(), expectView(role("button", { name: "Dry run" })).not.toExist(), expectView(role("group", { name: "Anomaly signals" })).toExist(), expectView(text("None picked: every signal pages.")).toExist(), @@ -134,9 +139,9 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(unrouted), themeResolved(), - expectView(text("Who gets notified")).toExist(), - expectView(role("button", { name: "Create rule" })).toBeDisabled(), + expectView(role("button", { name: "Who gets notified" })).toExist(), ) + expect(savesOf(unrouted)).not.toContain("SaveAlertRule") }) test("editing a scoped anomaly rule opens on its scope and keeps the save unchanged", () => { @@ -148,6 +153,8 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(scoped), themeResolved(), + click(role("button", { name: /^When: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(text("No changes")).toExist(), expectView(text(DEFAULT_NOTE)).not.toExist(), // Each chip's remove button names its chip. @@ -162,6 +169,8 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(fallback), themeResolved(), + click(role("button", { name: /^When: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(text(DEFAULT_NOTE)).toExist(), expectView(role("button", { name: "Error rate" })).toBeDisabled(), expectView(role("button", { name: "Built-in" })).toBeDisabled(), @@ -264,12 +273,12 @@ describe("alert rule editor: anomaly rules", () => { given(unscoped), themeResolved(), expectView(text(/^Pick at least one service, environment or signal/)).toExist(), - expectView(role("button", { name: "Create rule" })).toBeDisabled(), + expectView(role("button", { name: "Scope" })).toExist(), click(role("button", { name: "Throughput" })), expectView(text(/^Pick at least one service, environment or signal/)).not.toExist(), - expectView(role("button", { name: "Create rule" })).not.toBeDisabled(), + expectView(text("Ready to save")).toExist(), ) - expect(pageUpdate(unscoped, Message.ClickedSave()).commands ?? []).toHaveLength(0) + expect(savesOf(unscoped)).not.toContain("SaveAlertRule") }) test("an anomaly rule's name suggests what it covers", () => { diff --git a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts new file mode 100644 index 0000000000..71c76d3a01 --- /dev/null +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -0,0 +1,475 @@ +import { OrgId } from "@maple/domain" +import { AlertRulePreviewResponse } from "@maple/domain/http" +import { Result, Schema, Array as Arr } from "effect" +import * as Animation from "@foldkit/ui/animation" +import { + all, + click, + Command, + expect as expectView, + expectAll, + given, + keydown, + Mount, + role, + scene, + selector, + text, + type, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import * as Chart from "@maple/views/charts/chart" +import { MountChart } from "@maple/views/charts/chart-mounts" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import * as Dialog from "@maple/views/ui/dialog" +import type { PageContext } from "../../contract" +import { scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" +import { destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import * as Select from "@maple/views/ui/select" +import { FetchEditorPreview, FocusRuleCard } from "./command" +import { BREACH_MODE_SELECT_ID } from "./form-steps" +import { init } from "./init" +import { Message } from "./message" +import type { Model } from "./model" +import { 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 themeResolved = () => + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) +const opened = (search: Model["search"], rules = [ruleRow(2)]) => + run(init({ _tag: "AlertsCreate", search }, context).model, [ + Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + Message.CompletedReadClock({ nowMs: NOW }), + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + Message.UpdatedRules({ orgId: ORG, rows: rules }), + ]) + +const SQL = + "SELECT toStartOfInterval(Timestamp, INTERVAL $__interval_s SECOND) AS bucket, count() AS value FROM traces WHERE $__orgFilter AND $__timeFilter(Timestamp) GROUP BY bucket ORDER BY bucket" + +/** A preview whose one check takes 7.8s: too slow for 1-minute checks. */ +const slowPreview = () => + Schema.decodeSync(AlertRulePreviewResponse)({ + bucketSeconds: 300, + windowMinutes: 5, + threshold: 800, + thresholdUpper: null, + thresholds: [ + { severity: "critical", value: 800, upper: null, recoveryValue: null, destinationIds: [] }, + ], + comparator: "gt", + truncatedToStart: null, + series: [], + wouldFire: [], + queryTiming: { + elapsedMs: 7_800, + failure: null, + probeLimitMs: 25_000, + intervalMinutes: 1, + limitMs: 5_000, + fitsIntervalMinutes: [5, 10, 15, 30, 60], + }, + }) + +/** A preview with one point over the threshold: a chart, its threshold line and a would-fire band. */ +const breachingPreview = () => + Schema.decodeSync(AlertRulePreviewResponse)({ + bucketSeconds: 300, + windowMinutes: 5, + threshold: 800, + thresholdUpper: null, + thresholds: [ + { severity: "critical", value: 800, upper: null, recoveryValue: null, destinationIds: [] }, + ], + comparator: "gt", + truncatedToStart: null, + series: [ + { + groupKey: "__ungrouped__", + points: [ + { + bucket: "2026-10-01T11:00:00.000Z", + value: 1800, + sampleCount: 40, + status: "breached", + }, + ], + }, + ], + wouldFire: [ + { + groupKey: "__ungrouped__", + start: "2026-10-01T11:00:00.000Z", + end: "2026-10-01T11:10:00.000Z", + severity: "critical", + }, + ], + }) + +const summary = (lead: string, rest: string) => role("button", { name: `${lead}: ${rest}. Edit` }) +const queryMode = role("button", { name: "Built-in" }) + +describe("alert rule editor, sentence cards", () => { + test("a saved rule opens on one-line summaries; clicking one opens its card, Done closes it", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(2) })), + themeResolved(), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + expectView( + summary( + "Alert when", + "Above 800 ms (critical), in 2 checks in a row (5 min each), resolves after 2 healthy checks", + ), + ).toExist(), + expectView(summary("Notify", "https://hooks.acme.test/maple")).toExist(), + expectView(summary("Name", "Rule 2")).toExist(), + expectView(queryMode).not.toExist(), + click(summary("When", "P95 latency on payment-svc in production")), + // Opening moves focus to the card's heading; closing returns it to the summary. + Command.expectHas(FocusRuleCard({ id: "sentence-card-watch" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + expectView(queryMode).toExist(), + expectView(role("heading", { name: "When" })).toExist(), + click(role("button", { name: "Done" })), + Command.expectHas(FocusRuleCard({ id: "sentence-card-watch-summary" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + expectView(queryMode).not.toExist(), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + ) + }) + + test("a card with something missing stays open, and stays open once it is filled in", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(2) }, [ruleRow(2, { name: "" })])), + themeResolved(), + expectView(text("Missing: Rule name")).toExist(), + expectView(role("textbox", { name: "Rule name" })).toExist(), + type(role("textbox", { name: "Rule name" }), "Payment latency"), + expectView(text("Missing: Rule name")).not.toExist(), + expectView(role("textbox", { name: "Rule name" })).toExist(), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + ) + }) + + test("a new rule opens every card", () => { + scene( + { update: pageUpdate, view }, + given(opened({ template: "slow_p95" })), + themeResolved(), + expectView(queryMode).toExist(), + expectView(role("heading", { name: "Alert when" })).toExist(), + expectView(role("heading", { name: "Notify" })).toExist(), + expectView(role("textbox", { name: "Rule name" })).toExist(), + ) + }) + + // Blank counts as missing: `Number("")` is 0, which once drew a threshold line at zero. + test.each([["-"], [""], [" "]])( + "with the threshold %j, the empty chart asks for a number, not for a signal", + (value) => { + scene( + { update: pageUpdate, view }, + given( + run(opened({ template: "slow_p95" }), [ + Message.ChangedField({ field: "threshold", value }), + ]), + ), + themeResolved(), + expectView( + text(/^Enter a number for the threshold to preview it against the last/u), + ).toExist(), + expectView(text(/^Pick a signal/u)).not.toExist(), + ) + }, + ) + + test("clearing the threshold drops the chart and its threshold line, and asks for no new preview", () => { + const loading = opened({ template: "slow_p95" }) + const draft = run(loading, [ + Message.CompletedFetchPreview({ + key: loading.preview.key ?? "", + isSaved: false, + result: Result.succeed(breachingPreview()), + }), + ]) + scene( + { update: pageUpdate, view }, + given(draft), + themeResolved(), + Mount.resolve(MountChart, Chart.Message.CompletedMountChart()), + expectView(text(/^Breach above/u)).toExist(), + expectView(text("Would have fired")).toExist(), + // A preview at threshold 0 would be a command left unanswered here. + type(selector("#rule-threshold"), ""), + Mount.expectEnded(MountChart), + expectView(text(/^Enter a number for the threshold to preview it against the last/u)).toExist(), + expectView(text(/^Breach above/u)).not.toExist(), + expectView(text("Would have fired")).not.toExist(), + ) + }) + + test("opening an empty add-on section on a saved builder rule is not an unsaved change", () => { + const builderRule = ruleRow(2, { + signal_type: "builder_query", + query_builder_draft_json: { + id: "q1", + name: "A", + dataSource: "traces", + aggregation: "count", + whereClause: 'service.name = "checkout"', + addOns: { groupBy: false, having: false, orderBy: false, limit: false, legend: false }, + groupBy: [], + }, + }) + const saved = opened({ ruleId: ruleId(2) }, [builderRule]) + const groupBy = role("button", { name: "Group by" }) + scene( + { update: pageUpdate, view }, + given(saved), + themeResolved(), + expectView(text("No changes")).toExist(), + click(role("button", { name: /^When: .* Edit$/u })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + click(groupBy), + // The draft's JSON did change, so its preview is asked for again; the answer is not needed here. + Command.resolve( + FetchEditorPreview, + Message.CompletedFetchPreview({ + key: "unanswered", + isSaved: false, + result: Result.succeed(slowPreview()), + }), + ), + expectView(groupBy).toHaveAttr("aria-pressed", "true"), + expectView(text("No changes")).toExist(), + expectView(text(/unsaved change/u)).not.toExist(), + ) + }) + + test("an anomaly rule reads as its scope and has no Alert when card", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(8) }, [scopedAnomalyRuleRow()])), + themeResolved(), + expectView( + summary("When", "Anomalies in checkout · production · Error rate, p95 latency"), + ).toExist(), + expectView(text("Alert when")).not.toExist(), + ) + }) + + test("the action bar says one thing: no changes, ready, or what to fix", () => { + const saved = opened({ ruleId: ruleId(2) }) + scene( + { update: pageUpdate, view }, + given(saved), + themeResolved(), + expectView(text("No changes")).toExist(), + expectView(text("Ready to save")).not.toExist(), + expectView(role("button", { name: "Save changes" })).toBeDisabled(), + ) + const edited = run(saved, [Message.ChangedField({ field: "threshold", value: "900" })]) + scene( + { update: pageUpdate, view }, + given(edited), + themeResolved(), + expectView(text("Ready to save")).toExist(), + expectView(text("No changes")).not.toExist(), + expectView(text("1 unsaved change")).toExist(), + expectView(role("button", { name: "Save changes" })).toBeEnabled(), + ) + scene( + { update: pageUpdate, view }, + given(run(edited, [Message.ChangedField({ field: "name", value: "" })])), + themeResolved(), + expectView(text("Fix before saving:")).toExist(), + expectView(text("Ready to save")).not.toExist(), + expectView(role("button", { name: "Rule name" })).toExist(), + ) + }) + + test("a thing to fix in the action bar opens its card and focuses its field", () => { + const blank = run(opened({ ruleId: ruleId(2) }), [ + Message.ChangedField({ field: "threshold", value: "" }), + Message.ClickedCollapseCard({ card: "name" }), + ]) + scene( + { update: pageUpdate, view }, + given(blank), + themeResolved(), + expectView(role("textbox", { name: "Rule name" })).not.toExist(), + click(role("button", { name: "Threshold" })), + Command.expectHas(FocusRuleCard({ id: "rule-threshold" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + // The field it lands on now shows its error. + expectView(selector("#rule-threshold")).toHaveAttr("aria-invalid", "true"), + ) + // An issue on a closed card opens the card first. + const next = pageUpdate(opened({ ruleId: ruleId(2) }), Message.ClickedIssue({ issue: "Rule name" })) + expect(next.model.expandedCards).toContain("name") + expect(Arr.map(next.commands ?? [], ({ name }) => name)).toEqual(["FocusAlertRuleCard"]) + }) + + test("a pristine form shows no red errors until a field is left or save is pressed", () => { + // A new rule in an org with no destination: Notify and Name are missing from the start. + const pristine = run( + init({ _tag: "AlertsCreate", search: { template: "slow_p95" } }, context).model, + [ + Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + Message.CompletedReadClock({ nowMs: NOW }), + Message.UpdatedDestinations({ orgId: ORG, rows: [] }), + Message.UpdatedRules({ orgId: ORG, rows: [] }), + Message.ChangedField({ field: "name", value: "" }), + ], + ) + const routeError = text("Pick a destination or an escalation chain") + scene( + { update: pageUpdate, view }, + given(pristine), + themeResolved(), + expectView(text("Missing: Who gets notified")).toExist(), + expectView(text("Missing: Rule name")).toExist(), + expectAll(all.selector('[aria-invalid="true"]')).toHaveCount(0), + expectAll(all.selector("[aria-describedby='rule-name-error']")).toHaveCount(0), + expectView(selector("#rule-name-error")).not.toExist(), + expectView(routeError).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + // Leaving the empty name shows its error, and only its own. + given(run(pristine, [Message.BlurredField({ id: "rule-name" })])), + themeResolved(), + expectView(role("textbox", { name: "Rule name" })).toHaveAttr("aria-invalid", "true"), + // The error the input is described by is on the page, under the field. + expectView(role("textbox", { name: "Rule name" })).toHaveAttr( + "aria-describedby", + "rule-name-error", + ), + expectView(selector("#rule-name-error")).toHaveText("Give the rule a name."), + expectView(routeError).not.toExist(), + // Pressing save shows the rest, and lands on the first thing to fix. + click(role("button", { name: "Create rule" })), + Command.expectHas(FocusRuleCard({ id: "rule-name" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + expectView(routeError).toExist(), + ) + }) + + test("Escape inside an open, complete card collapses it and returns focus to its summary", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(2) })), + themeResolved(), + click(summary("When", "P95 latency on payment-svc in production")), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + keydown(role("region", { name: "When" }), "Escape"), + Command.expectHas(FocusRuleCard({ id: "sentence-card-watch-summary" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + expectView(queryMode).not.toExist(), + ) + // A card with something missing has nothing to collapse to: Escape is not handled. + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(2) }, [ruleRow(2, { name: "" })])), + themeResolved(), + expectView(role("region", { name: "Name" })).not.toHaveHandler("keydown"), + expectView(role("button", { name: "Done" })).not.toExist(), + ) + }) + + test("Start blank closes the templates dialog and unmounts it, leaving nothing over the form", () => { + const fresh = run(init({ _tag: "AlertsCreate", search: {} }, context).model, [ + Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + Message.CompletedReadClock({ nowMs: NOW }), + ]) + expect(fresh.templates.isOpen).toBe(true) + scene( + { update: pageUpdate, view }, + given(fresh), + themeResolved(), + Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("dialog")).toExist(), + click(role("button", { name: /^Start blank/ })), + // The exit transition settles, then the dialog leaves the tree. + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 2 }), + ), + Command.resolve( + Dialog.WaitForDialogTransition, + Dialog.Message.GotAnimationMessage({ + message: Animation.Message.EndedAnimation({ generation: 2 }), + }), + ), + Mount.expectEnded(Dialog.MountDialog), + expectView(role("dialog")).not.toExist(), + expectView(text("Start with a template")).not.toExist(), + ) + }) + + test("breaches count in a row, or out of the last N once that is picked", () => { + const draft = opened({ template: "slow_p95" }) + const pick = (value: string) => + Message.GotSelectMessage({ + id: BREACH_MODE_SELECT_ID, + message: Select.Message.SelectedItem({ index: 0, value }), + }) + const mode = role("combobox", { name: "How breaches are counted" }) + scene( + { update: pageUpdate, view }, + given(draft), + themeResolved(), + expectView(mode).toContainText("in a row"), + expectView(selector("#rule-breaches-out-of")).not.toExist(), + ) + const windowed = run(draft, [pick("window")]) + scene( + { update: pageUpdate, view }, + given(windowed), + themeResolved(), + expectView(mode).toContainText("out of the last"), + expectView(selector("#rule-breaches-out-of")).toHaveValue(windowed.form?.breachesOutOf ?? ""), + ) + // Retyping N passes through empty: the field stays until "in a row" is picked. + const emptied = run(windowed, [Message.ChangedField({ field: "breachesOutOf", value: "" })]) + scene( + { update: pageUpdate, view }, + given(emptied), + themeResolved(), + expectView(selector("#rule-breaches-out-of")).toExist(), + ) + expect(run(emptied, [pick("row")]).isBreachWindowOpen).toBe(false) + }) + + test("a SQL rule whose timing lands too slow opens Alert when with the blocker", () => { + const sql = opened({ ruleId: ruleId(2) }, [ + ruleRow(2, { signal_type: "raw_query", raw_query_sql: SQL }), + ]) + expect(sql.expandedCards).toEqual([]) + const timed = pageUpdate( + sql, + Message.CompletedFetchPreview({ + key: sql.preview.key ?? "", + isSaved: false, + result: Result.succeed(slowPreview()), + }), + ).model + expect(timed.expandedCards).toContain("condition") + scene( + { update: pageUpdate, view }, + given(timed), + themeResolved(), + expectView(text("Query too slow for this check interval")).toExist(), + ) + }) +}) 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..a030ea4fae 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 @@ -1,7 +1,17 @@ import { OrgId } from "@maple/domain" import { AlertDestinationId } from "@maple/domain/http" import { Schema, Array as Arr } from "effect" -import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { + click, + Command, + expect as expectView, + given, + Mount, + role, + scene, + text, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import * as Select from "@maple/views/ui/select" @@ -9,6 +19,7 @@ import type { PageContext } from "../../contract" import { chainDestinations, chainUuid, populatedChains } from "../shared/escalation-test-fixtures" import { destinationRow, NOW, ORG, ruleRow } from "../shared/test-rows" import { chainSelectIdOf } from "./form-steps" +import { FocusRuleCard } from "./command" import { init } from "./init" import { Message, type Tier } from "./message" import type { Model } from "./model" @@ -62,7 +73,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?tab=routing"), + expectView(role("link", { name: "Create a chain" })).toHaveAttr( + "href", + "/alerts/escalation-chains/new", + ), ) }) @@ -80,7 +94,7 @@ describe("alert rule editor, escalation chains", () => { test("a tier with neither a destination nor a chain says so and cannot be saved", () => { const model = withoutDestination(ready()) expect(issuesOf(model)).toContain("Who gets notified") - expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual([]) + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).not.toContain("SaveAlertRule") scene( { update: pageUpdate, view }, given(model), @@ -142,6 +156,10 @@ describe("alert rule editor, escalation chains", () => { { update: pageUpdate, view }, given(tiered), themeResolved(), + // Untouched, the new tier only counts as missing; pressing save shows its error. + expectView(text("Pick a destination or an escalation chain")).not.toExist(), + click(role("button", { name: "Create rule" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(text("Pick a destination or an escalation chain")).toExist(), ) const routed = run(tiered, [pickChain("critical", chainUuid(2))]) @@ -164,8 +182,9 @@ describe("alert rule editor, escalation chains", () => { { update: pageUpdate, view }, given(anomaly), themeResolved(), - expectView(role("button", { name: "Create rule" })).toBeDisabled(), + expectView(text("Missing: Who gets notified")).toExist(), ) + expect(names(pageUpdate(anomaly, Message.ClickedSave()).commands)).not.toContain("SaveAlertRule") const routed = run(anomaly, [pickChain("critical", chainUuid(1))]) expect(routed.form?.destinationIds).toEqual([]) expect(routed.form?.escalation?.destinationIds).toEqual([]) diff --git a/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts b/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts index 1e2a948987..4423cbc4d0 100644 --- a/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-metrics.scene.test.ts @@ -6,9 +6,6 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" -import { CaretMessage } from "../../kit/where-autocomplete" -import { WhereFieldMessage } from "../../kit/where-field" -import { MountWhereFieldCaret } from "../../kit/where-field-command" import type { PageContext } from "../../contract" import * as QueryPanel from "../../kit/query-builder/panel" import { NOW, ORG } from "../shared/test-rows" @@ -26,17 +23,10 @@ const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } const view = withViewInputs(pageView, { context })() const pageUpdate = (model: Model, message: Message) => update(model, message, context) const DRAFT_ID = "6f1c2e3a-0000-4000-8000-000000000001" -/** The page's mounts: the theme, the closed templates dialog and the where editor's caret. */ +/** The page's mounts: the theme and the closed templates dialog (the where clause opens as rows, no caret). */ const mounted = () => [ Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), - // Resolved with the query panel's message: the page's submodel wraps it. - Mount.resolve( - MountWhereFieldCaret, - QueryPanel.Message.GotWhereField({ - message: WhereFieldMessage.GotCaret({ fieldId: DRAFT_ID, message: CaretMessage.BlurredEditor() }), - }), - ), ] const comboMounted = () => Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()) diff --git a/apps/web/src/page/alerts/editor/editor.scene.test.ts b/apps/web/src/page/alerts/editor/editor.scene.test.ts index 5a28d691c1..fdc6dfa0f0 100644 --- a/apps/web/src/page/alerts/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor.scene.test.ts @@ -27,16 +27,17 @@ import * as Chart from "@maple/views/charts/chart" import { MountChart } from "@maple/views/charts/chart-mounts" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import * as Menu from "@maple/views/ui/menu" -import * as RadioGroup from "@maple/views/ui/radio-group" +import * as Select from "@maple/views/ui/select" import * as Tooltip from "@maple/views/ui/tooltip" import type { PageContext } from "../../contract" import { destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" -import { FetchEditorPreview, ReloadPage } from "./command" +import { FetchEditorPreview, FocusRuleCard, ReloadPage } from "./command" import { init } from "./init" +import { INTERVAL_SELECT_ID } from "./interval" import { Message } from "./message" import type { Model } from "./model" import { update } from "./update" -import { view as pageView } from "./view" +import { breadcrumbsOf, view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -153,6 +154,8 @@ describe("alert rule editor", () => { { update: pageUpdate, view }, given(withWindow(editing, "60")), themeResolved(), + click(role("button", { name: /^Alert when: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(text(`${saved} min`)).toExist(), expectView(hint("5 min")).toExist(), ) @@ -161,10 +164,11 @@ describe("alert rule editor", () => { test("the check interval picker sets how often the rule runs, and refuses one longer than the window", () => { const hint = (every: string) => text(`Each check looks at this much data, checked every ${every}.`) const pick = (value: string) => - Message.GotRadioMessage({ - group: "interval", - message: RadioGroup.Message.SelectedRadio({ value }), + Message.GotSelectMessage({ + id: INTERVAL_SELECT_ID, + message: Select.Message.SelectedItem({ index: 0, value }), }) + const interval = role("combobox", { name: "Check every" }) const draft = run(withLists(entered({ template: "slow_p95" })), [ Message.ChangedField({ field: "windowMinutes", value: "15" }), ]) @@ -174,17 +178,26 @@ describe("alert rule editor", () => { { update: pageUpdate, view }, given(draft), themeResolved(), - expectView(role("radio", { name: /^Auto · 3 min/ })).toHaveAttr("aria-checked", "true"), - // The preview pins the interval, so picking one never re-queries. - click(role("radio", { name: /^10 min/ })), + expectView(interval).toContainText("Auto · 3 min"), + ) + scene( + { update: pageUpdate, view }, + given(every10), + themeResolved(), expectView(hint("10 min")).toExist(), - expectView(role("radio", { name: /^10 min/ })).toHaveAttr("aria-checked", "true"), - click(role("radio", { name: /^30 min/ })), + expectView(interval).toContainText("10 min"), + ) + scene( + { update: pageUpdate, view }, + given(every30), + themeResolved(), + expectView(interval).toContainText("30 min"), expectView(text("Longer than the window, so data between checks would go unread.")).toExist(), ) + // The preview pins the interval, so picking one never re-queries. expect(every10.preview.key).toBe(draft.preview.key) expect(every30.form?.evaluationIntervalMinutes).toBe("30") - expect(names(pageUpdate(every30, Message.ClickedSave()).commands)).toEqual([]) + expect(names(pageUpdate(every30, Message.ClickedSave()).commands)).not.toContain("SaveAlertRule") }) test("a raw SQL query too slow for its interval says so, blocks save, and switches in one click", () => { @@ -220,7 +233,7 @@ describe("alert rule editor", () => { Message.ClickedSignalKind({ kind: "raw_query" }), ]) const slow = run(sql, [answer(sql, 7_800)]) - expect(names(pageUpdate(slow, Message.ClickedSave()).commands)).toEqual([]) + expect(names(pageUpdate(slow, Message.ClickedSave()).commands)).not.toContain("SaveAlertRule") scene( { update: pageUpdate, view }, given(slow), @@ -233,7 +246,7 @@ describe("alert rule editor", () => { ), ).toExist(), click(role("button", { name: "Check every 5 min" })), - expectView(role("radio", { name: /^5 min/ })).toHaveAttr("aria-checked", "true"), + expectView(role("combobox", { name: "Check every" })).toContainText("5 min"), expectView(text("Fits checks every 5 min.")).toExist(), ) scene( @@ -366,7 +379,7 @@ describe("alert rule editor", () => { "It ran out of memory with the most any interval allows, so checking less often will not help.", ), ).toExist(), - expectView(role("radio", { name: /too slow/ })).not.toExist(), + expectView(text(/^Over or too close to/)).not.toExist(), ) }) @@ -382,10 +395,12 @@ describe("alert rule editor", () => { { update: pageUpdate, view }, given(slowed), themeResolved(), + click(role("button", { name: /^Alert when: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(notice).toExist(), click(role("button", { name: "Keep 5 min" })), expectView(notice).not.toExist(), - expectView(role("radio", { name: /^5 min/ })).toHaveAttr("aria-checked", "true"), + expectView(role("combobox", { name: "Check every" })).toContainText("5 min"), ) }) @@ -411,13 +426,18 @@ describe("alert rule editor", () => { expect(late.preview).toEqual(edited.preview) }) - test("save sends once when ready, and lands on /alerts with a toast", () => { + test("save sends once when ready, and lands on the rules with a toast; the crumbs go through Rules", () => { const ready = withLists(entered({ template: "slow_p95" })) const saving = pageUpdate(ready, Message.ClickedSave()) expect(names(saving.commands)).toEqual(["SaveAlertRule"]) expect(names(pageUpdate(saving.model, Message.ClickedSave()).commands)).toEqual([]) const saved = pageUpdate(saving.model, Message.CompletedSave({ result: Result.succeed(undefined) })) - expect(saved.outMessage?._tag).toBe("RequestedNavigation") + expect(saved.outMessage).toMatchObject({ _tag: "RequestedNavigation", url: "/alerts/rules" }) + expect(breadcrumbsOf(ready)).toEqual([ + { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, + { label: "New rule" }, + ]) }) test("a created rule records alert_rule_created with its signal; a failed save does not", () => { @@ -571,7 +591,10 @@ describe("alert rule editor", () => { test("an incomplete rule cannot be saved", () => { const unnamed = pageUpdate(withLists(entered()), Message.ChangedField({ field: "name", value: "" })) - expect(names(pageUpdate(unnamed.model, Message.ClickedSave()).commands)).toEqual([]) + // The press is an attempt: it lands on what to fix and sends nothing. + expect(names(pageUpdate(unnamed.model, Message.ClickedSave()).commands)).toEqual([ + "FocusAlertRuleCard", + ]) }) test("switching SQL to Built-in says what it dropped; a template lands in the query builder", () => { diff --git a/apps/web/src/page/alerts/editor/form-steps.ts b/apps/web/src/page/alerts/editor/form-steps.ts index 9f3e5d0a46..2d191ecb53 100644 --- a/apps/web/src/page/alerts/editor/form-steps.ts +++ b/apps/web/src/page/alerts/editor/form-steps.ts @@ -27,6 +27,23 @@ export const setEscalationField = ( ): RuleForm => form.escalation === null ? form : { ...form, escalation: { ...form.escalation, [field]: value } } +/** How breaches are counted, a select inside the "Fire after" sentence. */ +export const BREACH_MODE_SELECT_ID = "rule-breach-mode" + +/** Breaches counted in a row (no window), or out of the last N checks, starting two past the count. */ +export const setBreachMode = (form: RuleForm, mode: string): RuleForm => { + if (mode !== "window") return { ...form, breachesOutOf: "" } + if (Str.trim(form.breachesOutOf).length > 0) return form + const required = Number(Str.trim(form.consecutiveBreachesRequired)) + return { + ...form, + breachesOutOf: Number.isInteger(required) && required > 0 ? String(required + 2) : "5", + } +} + +/** The mode switch as a select, below `sm` where the four segments do not fit. */ +export const KIND_SELECT_ID = "rule-kind" + /** A tier's escalation chain select, and its "None" value. */ export const chainSelectIdOf = (tier: Tier): string => `rule-chain-${tier}` export const NO_CHAIN = "none" diff --git a/apps/web/src/page/alerts/editor/form.ts b/apps/web/src/page/alerts/editor/form.ts index aad6b6dcb7..12e1f389c0 100644 --- a/apps/web/src/page/alerts/editor/form.ts +++ b/apps/web/src/page/alerts/editor/form.ts @@ -64,6 +64,10 @@ export type RuleForm = typeof RuleForm.Type export const asFormState = (form: RuleForm): RuleFormState => form export const fromFormState = (form: RuleFormState): RuleForm => form +/** The editor's sentence cards: "When" (signal and scope), "Alert when", "Notify", "Name". */ +export const RuleCard = Schema.Literals(["watch", "condition", "notify", "name"]) +export type RuleCard = typeof RuleCard.Type + /** The warning tier's destinations, or the critical tier's. */ export const Tier = Schema.Literals(["warning", "critical"]) export type Tier = typeof Tier.Type diff --git a/apps/web/src/page/alerts/editor/init.ts b/apps/web/src/page/alerts/editor/init.ts index c1f6d82704..0c9228f013 100644 --- a/apps/web/src/page/alerts/editor/init.ts +++ b/apps/web/src/page/alerts/editor/init.ts @@ -67,6 +67,10 @@ export const init = (route: CreateRoute, _context: PageContext): PageReturn +/** The check interval select, inside the "How it is checked" sentence. */ +export const INTERVAL_SELECT_ID = "rule-interval" + +export const intervalSelectValue = (formValue: string): string => Str.trim(formValue).length === 0 ? AUTO_INTERVAL : formValue export interface IntervalOption { diff --git a/apps/web/src/page/alerts/editor/message.ts b/apps/web/src/page/alerts/editor/message.ts index ea1fc8fb30..bacfdd599a 100644 --- a/apps/web/src/page/alerts/editor/message.ts +++ b/apps/web/src/page/alerts/editor/message.ts @@ -31,7 +31,7 @@ import * as TimeRangePicker from "../../kit/time-range-picker" import * as DestinationDialog from "../destination/dialog" import * as ChatConnect from "../shared/chat-connect" import { PreviewFailure } from "../shared/preview" -import { Tier } from "./form" +import { RuleCard, Tier } from "./form" import { DryRun, TraceFacets } from "./model" /** The form's free-text fields, each one input. */ @@ -107,7 +107,7 @@ export const Message = defineMessageUnion({ GotTimePickerMessage: { message: TimeRangePicker.Message }, ClickedSignalKind: { kind: SignalKind }, GotRadioMessage: { - group: Schema.Literals(["signal", "severity", "interval"]), + group: Schema.Literals(["signal", "severity"]), message: RadioGroup.Message, }, ClickedOpenInBuilder: {}, @@ -144,6 +144,15 @@ export const Message = defineMessageUnion({ BlurredTagInput: {}, ClickedRemoveTag: { tag: Schema.String }, ClickedAddNotes: {}, + /** A closed sentence card's summary: opens the card for editing. */ + ClickedExpandCard: { card: RuleCard }, + /** An open card's Done, or Escape inside it: back to its summary. */ + ClickedCollapseCard: { card: RuleCard }, + CompletedFocusCard: {}, + /** A form control lost focus (by element id): its error may show from now on. */ + BlurredField: { id: Schema.String }, + /** One of the action bar's "Fix before saving" items: opens its card and focuses its field. */ + ClickedIssue: { issue: Schema.String }, ToggledSqlCollapsed: {}, ClickedSqlToken: { token: Schema.String }, CompletedInsertSqlToken: { sql: Schema.String, caret: Schema.Number }, diff --git a/apps/web/src/page/alerts/editor/model.ts b/apps/web/src/page/alerts/editor/model.ts index 78d46e0859..2145b93bda 100644 --- a/apps/web/src/page/alerts/editor/model.ts +++ b/apps/web/src/page/alerts/editor/model.ts @@ -28,7 +28,7 @@ import * as TimeRangePicker from "../../kit/time-range-picker" import * as DestinationDialog from "../destination/dialog" import * as ChatConnect from "../shared/chat-connect" import { PreviewSlot } from "../shared/preview" -import { RuleForm, Tier } from "./form" +import { RuleCard, RuleForm, Tier } from "./form" // `/alerts/create`: `AlertCreatePageContent` and the // `AlertCreateFormSurface` it mounts once the draft is known. @@ -122,6 +122,14 @@ export const Model = Schema.Struct({ /** The switch notice under the mode toggle (`notice`), cleared by the next switch. */ kindNotice: Schema.NullOr(Schema.String), isNotesOpen: Schema.Boolean, + /** The sentence cards opened for editing; a card with something missing is open regardless. */ + expandedCards: Schema.Array(RuleCard), + /** "Out of the last N" was picked for the breach count: its field stays while N is retyped. */ + isBreachWindowOpen: Schema.Boolean, + /** Save was pressed with something missing: every field then shows its error. */ + hasAttemptedSave: Schema.Boolean, + /** The controls the user has left (by element id): each shows its error from then on. */ + touchedFields: Schema.Array(Schema.String), /** Tags already in use across the org's rules, sorted; kept by reference while they stay the same. */ tagSuggestions: Schema.Array(Schema.String), isSqlCollapsed: Schema.Boolean, diff --git a/apps/web/src/page/alerts/editor/steps.ts b/apps/web/src/page/alerts/editor/steps.ts index a7ff3e147e..7cf25101dd 100644 --- a/apps/web/src/page/alerts/editor/steps.ts +++ b/apps/web/src/page/alerts/editor/steps.ts @@ -3,10 +3,14 @@ import * as Dialog from "@maple/views/ui/dialog" import type { PageContext, PageReturn } from "../../contract" import { editingRuleOf, initialization } from "./draft" import type { RuleForm } from "./form" -import { Message } from "./message" +import * as F from "./form-steps" +import { Message, type SignalKind } from "./message" import type { Model } from "./model" import { applyDefaultDestination, planMetrics, planPreviews } from "./plan" -import { Option, String as Str } from "effect" +import { isProtectedDefault } from "./derive" +import { draftTimingOf } from "./derive-rows" +import { missingCards, RULE_CARDS } from "./summary" +import { Option, String as Str, Array as Arr } from "effect" export type Return = PageReturn @@ -43,15 +47,39 @@ export const initialize = (model: Model, context: PageContext): Return => { isNotesOpen: Str.trim(form.notes).length > 0, isMessageTemplateOpen: form.notificationTitle.length > 0 || form.notificationBody.length > 0, } - const loaded = planned(applyDefaultDestination(ready), context) + // A new rule opens every card; a saved one opens on its summaries, with any gap left open. + const withCards: Model = { + ...ready, + expandedCards: + draft.editingRule === null ? RULE_CARDS : missingCards(form, null, isProtectedDefault(ready)), + } + const loaded = planned(applyDefaultDestination(withCards), context) if (!draft.isShowingTemplates) return loaded const opened = liftTemplates(loaded.model, Dialog.open(loaded.model.templates)) return { model: opened.model, commands: [...(loaded.commands ?? []), ...(opened.commands ?? [])] } } /** An edit to the draft; the preview follows it (`useDeferredValue` over the form). */ +/** Marks a control as left (by element id), so it shows its error from now on. */ +export const touched = (model: Model, id: string): Model => + model.touchedFields.includes(id) ? model : { ...model, touchedFields: [...model.touchedFields, id] } + +/** Opens every card with a save blocker; it stays open once fixed, so it never closes under the cursor. */ +export const withMissingCardsOpen = (model: Model): Model => + model.form === null + ? model + : { + ...model, + expandedCards: Arr.union( + model.expandedCards, + missingCards(model.form, draftTimingOf(model, model.form), isProtectedDefault(model)), + ), + } + export const editForm = (model: Model, context: PageContext, edit: (form: RuleForm) => RuleForm): Return => - model.form === null ? { model } : planned({ ...model, form: edit(model.form) }, context) + model.form === null + ? { model } + : planned(withMissingCardsOpen({ ...model, form: edit(model.form) }), context) /** A list answered: the draft may now be known, and the default destination picked. */ export const followRows = (model: Model, context: PageContext): Return => { @@ -65,3 +93,24 @@ export const followRows = (model: Model, context: PageContext): Return => { return { model: { ...model, form: null, status: "Unreadable" } } return { model: applyDefaultDestination(model) } } + +/** The mode switch: the filters carry over, and a destination the editor chose leaves an anomaly rule. */ +export const switchKindStep = (model: Model, context: PageContext, kind: SignalKind): Return => { + if (model.form === null || isProtectedDefault(model)) return { model } + const moved = F.switchKind(model.form, kind) + // Anomaly paging is opt-in: a destination the editor chose, not the user, does not carry over. + const auto = model.autoDestinationId + const form = + kind === "anomaly" && auto !== null + ? F.updateTierIds( + F.updateTierIds(moved.form, "warning", F.removeId(auto)), + "critical", + F.removeId(auto), + ) + : moved.form + return editForm( + { ...model, kindNotice: moved.notice, autoDestinationId: kind === "anomaly" ? null : auto }, + context, + () => form, + ) +} diff --git a/apps/web/src/page/alerts/editor/sub-steps.ts b/apps/web/src/page/alerts/editor/sub-steps.ts index 75eba25e79..409bb176d3 100644 --- a/apps/web/src/page/alerts/editor/sub-steps.ts +++ b/apps/web/src/page/alerts/editor/sub-steps.ts @@ -13,20 +13,23 @@ import * as QueryPanel from "../../kit/query-builder/panel" import * as TimeRangePicker from "../../kit/time-range-picker" import { isProtectedDefault } from "./derive" import { + BREACH_MODE_SELECT_ID, chainSelectIdOf, + KIND_SELECT_ID, NO_CHAIN, + setBreachMode, setBuiltinSignal, setChips, setSeverity, setTierChain, } from "./form-steps" -import { AUTO_INTERVAL, intervalRadioValue } from "./interval" -import { Message } from "./message" +import { AUTO_INTERVAL, INTERVAL_SELECT_ID } from "./interval" +import { Message, SignalKind } from "./message" import { panelInputsOf } from "./where-inputs" import type { Model } from "./model" import { ReadEditorClock } from "./command" import { planMetrics } from "./plan" -import { editForm, type Return } from "./steps" +import { editForm, type Return, switchKindStep } from "./steps" // The child Submodels the form is built from: selects, chip pickers, the query panel, the chart, // and the preview's own lookback picker. @@ -36,22 +39,17 @@ const decodeReducer = Schema.decodeUnknownOption(QueryEngineAlertReducer) const decodeSignal = Schema.decodeUnknownOption(AlertSignalType) const decodeSeverity = Schema.decodeUnknownOption(AlertSeverity) const decodeChainId = Schema.decodeUnknownOption(EscalationChainId) +const decodeKind = Schema.decodeUnknownOption(SignalKind) -/** The radio groups: built-in signal, severity and check interval; their value is the form's. */ +/** The radio groups: built-in signal and severity; their value is the form's. */ export const radioStep = ( model: Model, context: PageContext, - group: "signal" | "severity" | "interval", + group: "signal" | "severity", message: RadioGroup.Message, ): Return => { const current = - model.form === null - ? null - : group === "signal" - ? model.form.signalType - : group === "severity" - ? model.form.severity - : intervalRadioValue(model.form.evaluationIntervalMinutes) + model.form === null ? null : group === "signal" ? model.form.signalType : model.form.severity const next = RadioGroup.update(RadioGroup.init(current ?? undefined), message) const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotRadioMessage({ group, message: inner }), @@ -59,9 +57,6 @@ export const radioStep = ( const value = next.model.maybeValue if (value._tag === "None" || value.value === current) return { model, commands } const edited = editForm(model, context, (form) => { - if (group === "interval") { - return { ...form, evaluationIntervalMinutes: value.value === AUTO_INTERVAL ? "" : value.value } - } if (group === "signal") { const signal = decodeSignal(value.value) return signal._tag === "Some" ? setBuiltinSignal(form, signal.value) : form @@ -91,7 +86,24 @@ export const selectStep = ( ) if (next.outMessage === undefined) return { model: lifted, commands } const value = next.outMessage.value + if (id === KIND_SELECT_ID) { + const kind = decodeKind(value) + return kind._tag === "Some" + ? withCommands(switchKindStep(lifted, context, kind.value), commands) + : { model: lifted, commands } + } + if (id === BREACH_MODE_SELECT_ID) { + return withCommands( + editForm({ ...lifted, isBreachWindowOpen: value === "window" }, context, (form) => + setBreachMode(form, value), + ), + commands, + ) + } const edited = editForm(lifted, context, (form) => { + if (id === INTERVAL_SELECT_ID) { + return { ...form, evaluationIntervalMinutes: value === AUTO_INTERVAL ? "" : value } + } const tier = id === chainSelectIdOf("warning") ? "warning" diff --git a/apps/web/src/page/alerts/editor/summary.test.ts b/apps/web/src/page/alerts/editor/summary.test.ts new file mode 100644 index 0000000000..8c043972b2 --- /dev/null +++ b/apps/web/src/page/alerts/editor/summary.test.ts @@ -0,0 +1,300 @@ +import { AlertDestinationId, AlertQueryTiming, EscalationChainId } from "@maple/domain/http" +import { anomalyRuleForm, defaultRuleForm } from "@maple/web-data/alerts/form-utils" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" +import { fromFormState, type RuleForm } from "./form" +import { setBreachMode } from "./form-steps" +import { + cardIssues, + cardOfIssue, + cardsOf, + conditionSummary, + isProblemIssue, + issueFieldId, + missingCards, + nameSummary, + notifySummary, + type NameLookup, + type NotifyNames, + revealedInvalidFields, + watchSummary, +} from "./summary" + +const destinationId = Schema.decodeSync(AlertDestinationId) +const chainId = Schema.decodeSync(EscalationChainId) +const ONCALL = destinationId("0199a001-0000-7000-8000-000000000001") +const PAGER = destinationId("0199a001-0000-7000-8000-000000000002") +const GONE = destinationId("0199a001-0000-7000-8000-000000000009") +const CHAIN = chainId("0199a00e-0000-7000-8000-000000000001") + +const form = (fields: Partial = {}): RuleForm => ({ + ...fromFormState(defaultRuleForm()), + name: "Checkout errors", + destinationIds: [ONCALL], + ...fields, +}) + +const found = (name: string): NameLookup => ({ _tag: "Found", name }) +const GONE_LOOKUP: NameLookup = { _tag: "Gone" } +const NAMES: NotifyNames = { + destination: (id) => + id === ONCALL ? found("#oncall") : id === PAGER ? found("PagerDuty Checkout") : GONE_LOOKUP, + chain: (id) => (id === CHAIN ? found("Checkout primary") : GONE_LOOKUP), +} +const LOADING: NotifyNames = { destination: () => ({ _tag: "Loading" }), chain: () => ({ _tag: "Loading" }) } + +/** One check of the SQL takes 7.8s: too slow for 1-minute checks, fits 5-minute ones. */ +const SLOW = Schema.decodeSync(AlertQueryTiming)({ + elapsedMs: 7_800, + failure: null, + probeLimitMs: 25_000, + intervalMinutes: 1, + limitMs: 5_000, + fitsIntervalMinutes: [5, 10, 15, 30, 60], +}) + +const criticalTier = (fields: Partial> = {}) => ({ + threshold: "10", + recoveryValue: "", + destinationIds: [], + escalationChainId: null, + ...fields, +}) + +describe("watchSummary", () => { + it("names a built-in signal with its services and environments", () => { + expect(watchSummary(form({ serviceNames: ["checkout-api"], environments: ["production"] }))).toBe( + "Error rate on checkout-api in production", + ) + expect(watchSummary(form({ signalType: "p95_latency", excludeServiceNames: ["batch"] }))).toBe( + "P95 latency on all services except batch", + ) + }) + + it("shortens a long service list and says how the rule is split", () => { + expect(watchSummary(form({ signalType: "throughput", serviceNames: ["a", "b", "c", "d"] }))).toBe( + "Throughput on a, b and 2 more", + ) + expect(watchSummary(form({ signalType: "apdex", groupBy: ["http.route"] }))).toBe( + "Apdex on all services per http.route", + ) + }) + + it("reads a builder query as its aggregation, source and where clause", () => { + const base = form({ signalType: "builder_query" }) + const draft = { ...base.queryBuilderDraft, whereClause: 'service.name = "checkout"' } + expect(watchSummary({ ...base, queryBuilderDraft: draft })).toBe( + 'Error rate of traces, where service.name = "checkout", per service.name', + ) + }) + + it("reads a SQL rule by its reducer", () => { + expect(watchSummary(form({ signalType: "raw_query", rawQueryReducer: "max" }))).toBe( + "SQL query, maximum of value", + ) + }) + + it("reads an anomaly rule by its scope, and the default rule as the catch-all", () => { + const anomaly = fromFormState(anomalyRuleForm(form({ serviceNames: ["checkout"] }))) + expect(watchSummary(anomaly)).toBe("Anomalies in checkout · all environments · all signals") + expect(watchSummary(fromFormState(anomalyRuleForm(form())), true)).toBe( + "Every anomaly no narrower rule handles", + ) + }) +}) + +describe("conditionSummary", () => { + it("says the threshold with its unit and how many checks fire it", () => { + expect(conditionSummary(form({ consecutiveBreachesRequired: "3" }))).toBe( + "Above 5%, in 3 checks in a row (5 min each), resolves after 2 healthy checks", + ) + }) + + it("adds the critical tier, in the same direction", () => { + expect( + conditionSummary( + form({ comparator: "lt", threshold: "20", escalation: criticalTier({ threshold: "10" }) }), + ), + ).toBe( + "Below 20%, critical below 10%, in 2 checks in a row (5 min each), resolves after 2 healthy checks", + ) + }) + + it("reads a range on a latency signal, and a single critical tier", () => { + expect( + conditionSummary( + form({ + signalType: "p95_latency", + comparator: "between", + threshold: "200", + thresholdUpper: "800", + severity: "critical", + consecutiveBreachesRequired: "1", + consecutiveHealthyRequired: "1", + }), + ), + ).toBe("Between 200 ms and 800 ms (critical), over 5 min") + }) + + it("reads a breach window and alerting on no data", () => { + expect( + conditionSummary( + form({ consecutiveBreachesRequired: "3", breachesOutOf: "5", alertOnNoData: true }), + ), + ).toBe( + "Above 5%, in 3 of the last 5 checks (5 min each), resolves after 2 healthy checks, alerts on no data", + ) + }) + + it("says the recovery value when one is set", () => { + expect(conditionSummary(form({ recoveryValue: "4", consecutiveHealthyRequired: "1" }))).toBe( + "Above 5%, in 2 checks in a row (5 min each), recovers below 4%", + ) + }) + + it("marks a blank threshold", () => { + expect(conditionSummary(form({ threshold: "" }))).toMatch(/^Above \?%/) + }) +}) + +describe("notifySummary", () => { + it("lists the destinations, and the chain after them", () => { + expect(notifySummary(form(), NAMES)).toBe("#oncall") + expect( + notifySummary(form({ destinationIds: [ONCALL, PAGER], escalationChainId: CHAIN }), NAMES), + ).toBe("#oncall and PagerDuty Checkout, then escalate to Checkout primary") + }) + + it("says where the critical tier goes", () => { + expect(notifySummary(form({ escalation: criticalTier({ escalationChainId: CHAIN }) }), NAMES)).toBe( + "#oncall, critical escalates to Checkout primary", + ) + expect(notifySummary(form({ escalation: criticalTier({ destinationIds: [PAGER] }) }), NAMES)).toBe( + "#oncall, critical also PagerDuty Checkout", + ) + }) + + it("reads a rule routed only by a chain, or by nobody", () => { + expect(notifySummary(form({ destinationIds: [], escalationChainId: CHAIN }), NAMES)).toBe( + "Escalate to Checkout primary", + ) + expect(notifySummary(form({ destinationIds: [] }), NAMES)).toBe("No one is notified") + }) + + it("names a destination the org no longer has instead of dropping it", () => { + expect(notifySummary(form({ destinationIds: [GONE] }), NAMES)).toBe("A removed destination") + }) + + it("never calls a destination removed while the lists are still loading", () => { + expect(notifySummary(form({ destinationIds: [GONE], escalationChainId: CHAIN }), LOADING)).toBe( + "A destination, then escalate to a chain", + ) + }) + + it("names a destination on both tiers once", () => { + expect(notifySummary(form({ escalation: criticalTier({ destinationIds: [ONCALL] }) }), NAMES)).toBe( + "#oncall", + ) + }) + + it("reads a rule routed only on its critical tier as the critical route", () => { + const anomaly = fromFormState( + anomalyRuleForm(form({ serviceNames: ["checkout"], destinationIds: [] })), + ) + const criticalOnly = { + ...anomaly, + escalation: { ...criticalTier(), ...anomaly.escalation, destinationIds: [PAGER] }, + } + expect(notifySummary(criticalOnly, NAMES)).toBe("Critical: PagerDuty Checkout") + }) + + it("reads both tiers of an anomaly rule", () => { + const anomaly = fromFormState(anomalyRuleForm(form({ serviceNames: ["checkout"] }))) + const routed = { + ...anomaly, + escalation: { ...criticalTier(), ...anomaly.escalation, destinationIds: [PAGER] }, + } + expect(notifySummary(routed, NAMES)).toBe("#oncall, critical also PagerDuty Checkout") + }) +}) + +describe("nameSummary", () => { + it("is the name, then its tags", () => { + expect(nameSummary(form({ tags: ["checkout", "tier-1"] }))).toBe( + "Checkout errors · checkout and tier-1", + ) + expect(nameSummary(form({ name: " " }))).toBe("Unnamed rule") + }) +}) + +describe("field errors", () => { + it("tells a problem to fix from a field left empty, so a card says only empty fields are missing", () => { + expect(isProblemIssue("Threshold")).toBe(false) + expect(isProblemIssue("Rule name")).toBe(false) + expect(isProblemIssue("Critical threshold past warning")).toBe(true) + expect(isProblemIssue("Critical recovery past warning")).toBe(true) + expect(isProblemIssue("Check interval longer than the window")).toBe(true) + expect(isProblemIssue("Query too slow for this check interval")).toBe(true) + }) + + it("points each issue at the control that fixes it", () => { + expect(issueFieldId("Threshold")).toBe("rule-threshold") + expect(issueFieldId("Critical threshold past warning")).toBe("rule-critical-threshold") + expect(issueFieldId("Query too slow for this check interval")).toBe("rule-interval-trigger") + expect(issueFieldId("Who gets notified")).toBe("rule-chain-warning-trigger") + // A query that does not compile has no one field: its card's heading is the target. + expect(issueFieldId("Query: pick a metric")).toBeNull() + expect(cardOfIssue("Query: pick a metric")).toBe("watch") + }) + + it("shows no field error on a pristine form, one a left field has, and all after a save attempt", () => { + const issues = ["Rule name", "Threshold", "Who gets notified"] + expect(revealedInvalidFields(issues, [], false)).toEqual([]) + expect(revealedInvalidFields(issues, ["rule-threshold", "rule-window-minutes"], false)).toEqual([ + "rule-threshold", + ]) + expect(revealedInvalidFields(issues, [], true)).toEqual([ + "rule-name", + "rule-threshold", + "rule-chain-warning-trigger", + ]) + }) +}) + +describe("setBreachMode", () => { + it("starts the window two past the breaches to fire, keeps one already typed, and clears it in a row", () => { + expect(setBreachMode(form({ consecutiveBreachesRequired: "3" }), "window").breachesOutOf).toBe("5") + expect(setBreachMode(form({ consecutiveBreachesRequired: "" }), "window").breachesOutOf).toBe("5") + expect(setBreachMode(form({ breachesOutOf: "8" }), "window").breachesOutOf).toBe("8") + expect(setBreachMode(form({ breachesOutOf: "8" }), "row").breachesOutOf).toBe("") + }) +}) + +describe("card issues", () => { + it("puts each missing field on the card that edits it", () => { + expect(missingCards(form(), null)).toEqual([]) + expect(missingCards(form({ name: "" }), null)).toEqual(["name"]) + expect(missingCards(form({ threshold: "" }), null)).toEqual(["condition"]) + expect(missingCards(form({ destinationIds: [] }), null)).toEqual(["notify"]) + expect( + cardIssues(form({ signalType: "raw_query", rawQuerySql: "SELECT 1 AS value" }), "watch", null), + ).toEqual(["$__orgFilter in SQL"]) + expect(cardIssues(form({ escalation: criticalTier() }), "notify", null)).toEqual([ + "Critical destinations", + ]) + }) + + it("puts a query too slow for its interval on Alert when, as a save blocker", () => { + const sql = form({ signalType: "raw_query" }) + expect(missingCards(sql, null)).toEqual([]) + expect(cardIssues(sql, "condition", SLOW)).toEqual(["Query too slow for this check interval"]) + expect(missingCards(sql, SLOW)).toEqual(["condition"]) + }) + + it("drops the condition card for an anomaly rule, whose scope is required unless it is the default", () => { + const unscoped = fromFormState(anomalyRuleForm(form())) + expect(cardsOf(unscoped)).toEqual(["watch", "notify", "name"]) + expect(missingCards(unscoped, null)).toEqual(["watch"]) + expect(missingCards(unscoped, null, true)).toEqual([]) + }) +}) diff --git a/apps/web/src/page/alerts/editor/summary.ts b/apps/web/src/page/alerts/editor/summary.ts new file mode 100644 index 0000000000..f541ee943e --- /dev/null +++ b/apps/web/src/page/alerts/editor/summary.ts @@ -0,0 +1,264 @@ +import { Array as Arr, String as Str } from "effect" +import type { AlertDestinationId, AlertQueryTiming, EscalationChainId } from "@maple/domain/http" +import { + comparatorSupportsTiers, + isRangeComparator, + RAW_QUERY_REDUCER_LABELS, + signalLabels, +} from "@maple/web-data/alerts/form-utils" +import { anomalyScopeSummary } from "@maple/web-data/alerts/anomaly-rule" +import type { RuleCard, RuleForm } from "./form" +import { + checksPhrase, + comparatorWords, + conditionSubject, + groupBySummary, + recoveryLabel, + saveIssues, + thresholdValue, +} from "./derive" + +// Blockers that are a problem with the rule, not a field left empty: a card says them in full. +const PROBLEM_ISSUES: ReadonlyArray = [ + "Critical threshold past warning", + "Critical recovery past warning", + "Check interval longer than the window", + "Query too slow for this check interval", +] + +/** Whether a blocker is a problem to fix (said as it is), not a missing field (said after "Missing:"). */ +export const isProblemIssue = (issue: string): boolean => Arr.contains(PROBLEM_ISSUES, issue) + +// The rule editor's sentence cards: which card each field lives on, and each card's one-line summary. + +export const RULE_CARDS: ReadonlyArray = ["watch", "condition", "notify", "name"] + +/** The cards an anomaly rule shows: the detector decides when, so it has no condition card. */ +export const cardsOf = (form: RuleForm): ReadonlyArray => + form.signalType === "anomaly" ? Arr.filter(RULE_CARDS, (card) => card !== "condition") : RULE_CARDS + +const WATCH_ISSUES = new Set(["SQL query", "$__orgFilter in SQL", "SQL value column", "Scope"]) +const NOTIFY_ISSUES = new Set(["Who gets notified", "Critical destinations"]) + +/** The card an issue is fixed on. */ +export const cardOfIssue = (issue: string): RuleCard => + issue === "Rule name" + ? "name" + : NOTIFY_ISSUES.has(issue) + ? "notify" + : WATCH_ISSUES.has(issue) || Str.startsWith("Query:")(issue) + ? "watch" + : "condition" + +// Each issue's control, by element id. A query that does not compile has no one field. +const ISSUE_FIELDS = new Map([ + ["Rule name", "rule-name"], + ["Threshold", "rule-threshold"], + ["Upper threshold", "rule-threshold-upper"], + ["Recovery value", "rule-recovery"], + ["Critical threshold", "rule-critical-threshold"], + ["Critical threshold past warning", "rule-critical-threshold"], + ["Critical recovery past warning", "rule-critical-recovery"], + ["Critical recovery value", "rule-critical-recovery"], + ["Window", "rule-window-minutes"], + ["Checks to fire", "rule-consecutive-breaches"], + ["Breach window", "rule-breaches-out-of"], + ["Min samples", "rule-minimum-samples"], + ["Checks to resolve", "rule-consecutive-healthy"], + ["Remind interval", "rule-renotify"], + ["Check interval longer than the window", "rule-interval-trigger"], + ["Query too slow for this check interval", "rule-interval-trigger"], + ["SQL query", "alert-rule-sql"], + ["$__orgFilter in SQL", "alert-rule-sql"], + ["SQL value column", "alert-rule-sql"], + ["Scope", "rule-services-input"], + ["Who gets notified", "rule-chain-warning-trigger"], + ["Critical destinations", "rule-chain-critical-trigger"], +]) + +/** The control that fixes an issue, by element id; `null` when the card itself is the best target. */ +export const issueFieldId = (issue: string): string | null => ISSUE_FIELDS.get(issue) ?? null + +/** + * The fields that show their error: one with an issue, once the user left it or tried to save. + * A pristine form shows none, only the card's "Missing" note. + */ +export const revealedInvalidFields = ( + issues: ReadonlyArray, + touchedFields: ReadonlyArray, + hasAttemptedSave: boolean, +): ReadonlyArray => + Arr.dedupe( + Arr.filter( + Arr.flatMap(issues, (issue) => { + const id = issueFieldId(issue) + return id === null ? [] : [id] + }), + (id) => hasAttemptedSave || touchedFields.includes(id), + ), + ) + +/** + * What blocks saving on one card: the action bar's own list (`saveIssues`), so a closed card never + * hides a blocker. A query too slow for its check interval lands on "Alert when", where the interval is. + */ +export const cardIssues = ( + form: RuleForm, + card: RuleCard, + timing: AlertQueryTiming | null, + isDefault = false, +): ReadonlyArray => + Arr.filter(saveIssues(form, timing, isDefault), (issue) => cardOfIssue(issue) === card) + +/** The cards with something missing, which stay open until it is filled in. */ +export const missingCards = ( + form: RuleForm, + timing: AlertQueryTiming | null, + isDefault = false, +): ReadonlyArray => + Arr.filter(cardsOf(form), (card) => cardIssues(form, card, timing, isDefault).length > 0) + +const capitalize = (text: string): string => Str.toUpperCase(Str.slice(0, 1)(text)) + Str.slice(1)(text) + +/** "a, b and c", or "a, b and 3 more" past three names. */ +const listOf = (names: ReadonlyArray): string => { + if (names.length <= 1) return Arr.join(names, "") + const shown = names.length > 3 ? Arr.take(names, 2) : Arr.take(names, names.length - 1) + const rest = names.length > 3 ? `${names.length - 2} more` : (names[names.length - 1] ?? "") + return `${Arr.join(shown, ", ")} and ${rest}` +} + +const builtinScope = (form: RuleForm): ReadonlyArray => [ + form.serviceNames.length === 0 ? "on all services" : `on ${listOf(form.serviceNames)}`, + ...(form.serviceNames.length === 0 && form.excludeServiceNames.length > 0 + ? [`except ${listOf(form.excludeServiceNames)}`] + : []), + ...(form.environments.length > 0 ? [`in ${listOf(form.environments)}`] : []), +] + +/** The "When" card: the signal and what it watches. "Error rate on checkout-api in production". */ +export const watchSummary = (form: RuleForm, isDefault = false): string => { + if (form.signalType === "anomaly") + return isDefault + ? "Every anomaly no narrower rule handles" + : `Anomalies in ${anomalyScopeSummary(form)}` + const groupBy = groupBySummary(form) + const perGroup = groupBy === null ? [] : [`per ${groupBy}`] + if (form.signalType === "raw_query") + return `SQL query, ${Str.toLowerCase(RAW_QUERY_REDUCER_LABELS[form.rawQueryReducer])} of value` + if (form.signalType === "builder_query") { + const draft = form.queryBuilderDraft + const source = + draft.dataSource === "metrics" && draft.metricName.length > 0 + ? draft.metricName + : draft.dataSource + const where = Str.trim(draft.whereClause) + return Arr.join( + [ + `${capitalize(conditionSubject(form))} of ${source}`, + ...(where.length > 0 ? [`where ${where}`] : []), + ...perGroup, + ], + ", ", + ) + } + return Arr.join([signalLabels[form.signalType], ...builtinScope(form), ...perGroup], " ") +} + +/** The "Alert when" card: "Above 5%, critical above 10%, in 3 checks in a row (5 min each)". */ +export const conditionSummary = (form: RuleForm): string => { + const words = Str.replace(/^is /, "")(comparatorWords(form.comparator)) + const threshold = isRangeComparator(form.comparator) + ? `${thresholdValue(form, form.threshold)} and ${thresholdValue(form, form.thresholdUpper)}` + : thresholdValue(form, form.threshold) + const escalation = comparatorSupportsTiers(form.comparator) ? form.escalation : null + const healthy = Str.trim(form.consecutiveHealthyRequired) + const recovery = comparatorSupportsTiers(form.comparator) ? Str.trim(form.recoveryValue) : "" + return Arr.join( + [ + `${capitalize(words)} ${threshold}${escalation === null && form.severity === "critical" ? " (critical)" : ""}`, + ...(escalation === null + ? [] + : [`critical ${words} ${thresholdValue(form, escalation.threshold)}`]), + checksPhrase(form), + ...(recovery.length > 0 + ? [`${Str.toLowerCase(recoveryLabel(form.comparator))} ${thresholdValue(form, recovery)}`] + : []), + ...(healthy.length > 0 && healthy !== "1" ? [`resolves after ${healthy} healthy checks`] : []), + ...(form.alertOnNoData ? ["alerts on no data"] : []), + ], + ", ", + ) +} + +/** A destination or chain by id: its list still loading, its name, or gone from the loaded list. */ +export type NameLookup = + | Readonly<{ _tag: "Loading" }> + | Readonly<{ _tag: "Found"; name: string }> + | Readonly<{ _tag: "Gone" }> + +/** How the summary names destinations and chains. */ +export type NotifyNames = Readonly<{ + destination: (id: AlertDestinationId) => NameLookup + chain: (id: EscalationChainId) => NameLookup +}> + +const nameOf = (lookup: NameLookup, noun: string): string => + lookup._tag === "Found" ? lookup.name : lookup._tag === "Gone" ? `a removed ${noun}` : `a ${noun}` + +type Route = Readonly<{ + destinationIds: ReadonlyArray + escalationChainId: EscalationChainId | null +}> + +const routeText = (route: Route, names: NotifyNames): string | null => { + const destinations = Arr.map(route.destinationIds, (id) => nameOf(names.destination(id), "destination")) + const chain = + route.escalationChainId === null ? null : nameOf(names.chain(route.escalationChainId), "chain") + if (destinations.length === 0) return chain === null ? null : `escalate to ${chain}` + return chain === null ? listOf(destinations) : `${listOf(destinations)}, then escalate to ${chain}` +} + +/** The "Notify" card: "#oncall, critical escalates to Checkout on-call". */ +export const notifySummary = (form: RuleForm, names: NotifyNames): string => { + const primary = routeText(form, names) + const tiered = form.signalType === "anomaly" || comparatorSupportsTiers(form.comparator) + // Critical pages on top of warning, so a destination already on warning is not named twice. + const critical = + tiered && form.escalation !== null + ? routeText( + { + ...form.escalation, + destinationIds: Arr.filter( + form.escalation.destinationIds, + (id) => !form.destinationIds.includes(id), + ), + }, + names, + ) + : null + if (primary === null && critical === null) return "No one is notified" + // With no warning route, critical is the whole route: "Critical: #pager", not "Critical also". + const criticalText = + critical === null + ? null + : Str.startsWith("escalate to ")(critical) + ? `critical ${Str.replace(/^escalate/, "escalates")(critical)}` + : primary === null + ? `critical: ${critical}` + : `critical also ${critical}` + // Only our own words are capitalised: a destination keeps its spelling ("#oncall", an email). + const lead = (text: string) => (/^(escalate|critical|a |an )/.test(text) ? capitalize(text) : text) + if (primary === null) return lead(criticalText ?? "") + return Arr.join( + Arr.filter([lead(primary), criticalText], (part): part is string => part !== null), + ", ", + ) +} + +/** The "Name" card: the name, then its tags. */ +export const nameSummary = (form: RuleForm): string => { + const name = Str.trim(form.name) + const tags = form.tags.length === 0 ? [] : [listOf(form.tags)] + return Arr.join([name.length > 0 ? name : "Unnamed rule", ...tags], " · ") +} diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index 206340027a..e3fdec106d 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -7,16 +7,29 @@ import { Array as Arr, Result } from "effect" import type { PageContext } from "../../contract" import * as DestinationDialog from "../destination/dialog" import { completeSave, completeTest, runTest, save } from "./actions" -import { FetchEditorChains, FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" +import { FetchEditorChains, FocusRuleCard, FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" +import { sentenceCardHeadingId, sentenceCardSummaryId } from "../../kit/sentence-card" import { SQL_TEXTAREA_ID } from "./view-sql" import { chatConnectStep, connectorsAnswered, liftDialog, menuStep, removeDestination } from "./dest-steps" import { isProtectedDefault, suggestedName } from "./derive" import { tagSuggestionsOf } from "./derive-rows" +import { triggerId } from "@maple/views/ui/overlay/popup-state" +import { chainSelectIdOf } from "./form-steps" +import { cardOfIssue, issueFieldId } from "./summary" import * as F from "./form-steps" import { Message } from "./message" import type { Model } from "./model" import { planAutocomplete, planPreviews } from "./plan" -import { editForm, followRows, initialize, liftTemplates, type Return } from "./steps" +import { + editForm, + followRows, + initialize, + liftTemplates, + type Return, + switchKindStep, + touched, + withMissingCardsOpen, +} from "./steps" import { chartStep, comboStep, queryPanelStep, radioStep, selectStep, timePickerStep } from "./sub-steps" const AUTOCOMPLETE_PRESET = "24h" @@ -45,6 +58,11 @@ const readClock = (model: Model, context: PageContext, nowMs: number): Return => return { model: previewed.model, commands: [...looked.commands, ...previewed.commands] } } +const isChainSelect = (id: string): boolean => + id === chainSelectIdOf("warning") || id === chainSelectIdOf("critical") +// The select was left: a pick, Escape or a press outside. +const LEFT_SELECT = new Set(["SelectedItem", "PressedEscape", "PressedOutside"]) + const closeTemplates = (model: Model): Return => liftTemplates(model, Dialog.close(model.templates)) export const update = (model: Model, message: Message, context: PageContext): Return => @@ -126,14 +144,15 @@ export const update = (model: Model, message: Message, context: PageContext): Re }, // Both slots take an answer for their key: the replay can share the draft's first request. CompletedFetchPreview: ({ key, result }) => ({ - model: { + // A timing that lands as too slow opens "Alert when", where the interval is picked. + model: withMissingCardsOpen({ ...model, preview: model.preview.key === key ? Keyed.accept(model.preview, key, result) : model.preview, savedPreview: model.savedPreview.key === key ? Keyed.accept(model.savedPreview, key, result) : model.savedPreview, - }, + }), }), ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), CompletedLoadChartRuntime: () => ({ model: { ...model, chartRuntime: { _tag: "Ready" } } }), @@ -142,25 +161,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), GotChartMessage: ({ message: inner }) => chartStep(model, inner), GotTimePickerMessage: ({ message: inner }) => timePickerStep(model, inner), - ClickedSignalKind: ({ kind }) => { - if (model.form === null || isProtectedDefault(model)) return { model } - const moved = F.switchKind(model.form, kind) - // Anomaly paging is opt-in: a destination the editor chose, not the user, does not carry over. - const auto = model.autoDestinationId - const form = - kind === "anomaly" && auto !== null - ? F.updateTierIds( - F.updateTierIds(moved.form, "warning", F.removeId(auto)), - "critical", - F.removeId(auto), - ) - : moved.form - return editForm( - { ...model, kindNotice: moved.notice, autoDestinationId: kind === "anomaly" ? null : auto }, - context, - () => form, - ) - }, + ClickedSignalKind: ({ kind }) => switchKindStep(model, context, kind), GotRadioMessage: ({ group, message: inner }) => radioStep(model, context, group, inner), ClickedOpenInBuilder: () => { if (model.form === null) return { model } @@ -172,7 +173,14 @@ export const update = (model: Model, message: Message, context: PageContext): Re ? { model } : editForm(model, context, (form) => F.toggleAnomalySignal(form, signal)), GotQueryPanelMessage: ({ message: inner }) => queryPanelStep(model, context, inner), - GotSelectMessage: ({ id, message: inner }) => selectStep(model, context, id, inner), + GotSelectMessage: ({ id, message: inner }) => + selectStep( + // Leaving a tier's chain picker without a route shows its error from then on. + isChainSelect(id) && LEFT_SELECT.has(inner._tag) ? touched(model, triggerId(id)) : model, + context, + id, + inner, + ), GotComboMessage: ({ id, message: inner }) => comboStep(model, context, id, inner), ChangedField: ({ field, value }) => editForm(model, context, (form) => F.setText(form, field, value)), ChangedEscalationField: ({ field, value }) => @@ -214,6 +222,24 @@ export const update = (model: Model, message: Message, context: PageContext): Re editForm({ ...model, tagDraft: "" }, context, (form) => F.commitTag(form, model.tagDraft)), ClickedRemoveTag: ({ tag }) => editForm(model, context, (form) => F.removeTag(form, tag)), ClickedAddNotes: () => ({ model: { ...model, isNotesOpen: true } }), + ClickedExpandCard: ({ card }) => ({ + model: { ...model, expandedCards: Arr.union(model.expandedCards, [card]) }, + commands: [FocusRuleCard({ id: sentenceCardHeadingId(card) })], + }), + ClickedCollapseCard: ({ card }) => ({ + model: { ...model, expandedCards: Arr.filter(model.expandedCards, (open) => open !== card) }, + commands: [FocusRuleCard({ id: sentenceCardSummaryId(card) })], + }), + CompletedFocusCard: () => ({ model }), + BlurredField: ({ id }) => ({ model: touched(model, id) }), + ClickedIssue: ({ issue }) => { + const card = cardOfIssue(issue) + const id = issueFieldId(issue) ?? sentenceCardHeadingId(card) + return { + model: touched({ ...model, expandedCards: Arr.union(model.expandedCards, [card]) }, id), + commands: [FocusRuleCard({ id })], + } + }, ToggledSqlCollapsed: () => ({ model: { ...model, isSqlCollapsed: !model.isSqlCollapsed } }), ClickedSqlToken: ({ token }) => model.form === null @@ -237,7 +263,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), ClickedTemplateVariable: ({ key }) => editForm(model, context, (form) => F.appendVariable(form, key)), GotMenuMessage: ({ id, message: inner }) => menuStep(model, id, inner), - ClickedRemoveDestination: ({ tier, id }) => removeDestination(model, tier, id), + ClickedRemoveDestination: ({ tier, id }) => + removeDestination(touched(model, triggerId(chainSelectIdOf(tier))), tier, id), GotDestinationDialogMessage: ({ message: inner }) => liftDialog(model, DestinationDialog.update(model.destinationDialog, inner)), GotIssuesTooltipMessage: ({ message: inner }) => { 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..cda4d2233a 100644 --- a/apps/web/src/page/alerts/editor/view-action-bar.ts +++ b/apps/web/src/page/alerts/editor/view-action-bar.ts @@ -64,31 +64,64 @@ const issuesDetails = (h: H, model: BarView, issues: ReadonlyArray): Htm }, }) -/** `ValidationSummary`. */ -const validationSummary = (h: H, model: BarView, issues: ReadonlyArray): Html => { - if (issues.length === 0) - return hstack( - h, - { - gap: "1.5", - as: "span", - className: typographyClass({ size: "xs", className: "text-severity-info" }), - }, - [icon(h, "CheckIcon", { size: 14 }), "Ready to save"], - ) - const visible = issues.slice(0, MAX_VISIBLE_ISSUES) - const hiddenCount = issues.length - visible.length - const summary = Arr.join(visible, ", ") + (hiddenCount > 0 ? ` +${hiddenCount} more` : "") - return h.span( - [h.Class(cn("flex min-w-0 items-center gap-1.5 truncate text-xs text-muted-foreground"))], - [ +/** A blocker as a jump link: it opens the blocker's card and focuses its field. */ +const issueLink = (h: H, issue: string): Html => + button( + h, + { + variant: "link", + size: "xs", + className: "h-9 px-0 sm:h-7", + attributes: [h.Key(issue), h.OnClick(Message.ClickedIssue({ issue }))], + }, + [issue], + ) + +/** + * `ValidationSummary`: one state at a time. Nothing to save says so; a valid edit is ready; a + * blocked one lists what to fix, each item a jump to its field. + */ +const validationSummary = (h: H, model: BarView, issues: ReadonlyArray, isClean: boolean): Html => { + if (issues.length > 0) { + const visible = issues.slice(0, MAX_VISIBLE_ISSUES) + const hiddenCount = issues.length - visible.length + return hstack(h, { align: "center", wrap: true, shrink: true, className: "gap-x-1.5 gap-y-0.5" }, [ icon(h, "CircleWarningIcon", { size: 14, className: "shrink-0 text-severity-warn" }), - typography(h, { truncate: true }, [ - "Fix before saving: ", - typography(h, { tone: "foreground" }, [summary]), + typography(h, { size: "xs", tone: "muted" }, ["Fix before saving:"]), + ...Arr.flatMap(visible, (issue, index) => [ + issueLink(h, issue), + ...(index < visible.length - 1 + ? [ + typography( + h, + { + size: "xs", + tone: "muted", + className: "-ml-1.5", + attributes: [h.AriaHidden(true)], + }, + [","], + ), + ] + : []), ]), - ...(hiddenCount > 0 ? [issuesDetails(h, model, issues)] : []), - ], + ...(hiddenCount > 0 + ? [ + typography(h, { size: "xs", tone: "muted" }, [`+${hiddenCount} more`]), + issuesDetails(h, model, issues), + ] + : []), + ]) + } + if (isClean) return typography(h, { size: "xs", tone: "muted", as: "p" }, ["No changes"]) + return hstack( + h, + { + gap: "1.5", + as: "span", + className: typographyClass({ size: "xs", className: "text-severity-info" }), + }, + [icon(h, "CheckIcon", { size: 14 }), "Ready to save"], ) } @@ -118,28 +151,36 @@ const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | nu ), ]), vstack(h, { gap: "0.5", shrink: true }, [ + // A phone wraps the sentence onto a second line; from `sm` it is one line. typography( h, - { size: "sm", tone: "foreground", truncate: true, as: "p", attributes: [h.Title(summary)] }, + { + size: "sm", + tone: "foreground", + as: "p", + className: "line-clamp-2 sm:line-clamp-1", + attributes: [h.Title(summary)], + }, [summary], ), - validationSummary(h, model, issues), + validationSummary(h, model, issues, changeCount === 0), ]), ], actions: [ - ...(changeCount === null + ...(changeCount === null || changeCount === 0 ? [] : [ typography(h, { size: "xs", tone: "muted", className: "hidden sm:inline" }, [ - changeCount === 0 ? "No changes" : `${changeCount} unsaved ${pluralize(changeCount, "change")}`, + `${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, { size: "sm", - isDisabled: issues.length > 0 || changeCount === 0, + // With something missing the press is a save attempt: it shows what to fix. + isDisabled: changeCount === 0, isLoading: model.isSaving, attributes: [h.OnClick(Message.ClickedSave())], }, diff --git a/apps/web/src/page/alerts/editor/view-condition.ts b/apps/web/src/page/alerts/editor/view-condition.ts index ffe2103a03..ff3bd63da0 100644 --- a/apps/web/src/page/alerts/editor/view-condition.ts +++ b/apps/web/src/page/alerts/editor/view-condition.ts @@ -8,42 +8,45 @@ import { import { cn } from "@maple/ui/lib/utils" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" -import { card } from "@maple/views/ui/card" -import { eyebrow } from "@maple/views/ui/eyebrow" -import { field, fieldLabel } from "@maple/views/ui/field" import { icon } from "@maple/views/ui/icon" import * as RadioGroup from "@maple/views/ui/radio-group" import { statusDot } from "@maple/views/ui/status-dot" import type * as Select from "@maple/views/ui/select" +import { sentenceClause, sentencePhrase, sentenceRow, sentenceSection } from "../../kit/sentence-card" import { stableSlice } from "../../kit/stable-slice" import { COMPARATOR_WORDS, comparatorWords, recoveryLabel } from "./derive" -import { draftTimingOf } from "./derive-rows" +import { draftTimingOf, invalidFieldsOf } from "./derive-rows" import type { RuleForm } from "./form" +import { BREACH_MODE_SELECT_ID } from "./form-steps" import { AUTO_INTERVAL, autoIntervalMinutes, formIntervalMinutes, + INTERVAL_SELECT_ID, + type IntervalOption, intervalOptions, - intervalRadioValue, + intervalSelectValue, } from "./interval" import { Message, type TextField } from "./message" import type { Model } from "./model" -import { fieldStateOf, idsOf, numberField, wasNote } from "./view-fields" +import { inlineNumber, wasLine } from "./view-fields" import { formSelect } from "./view-select" import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { Array as Arr, String as Str, Option } from "effect" -// `RuleConditionFields`: the rule's condition row. +// `RuleConditionFields`: the thresholds and how they are checked, each as a sentence. type H = HtmlBuilder const COMPARATOR_SELECT_ID = "rule-comparator" -/** What the card reads off the Model: the dirty and saved values, and the comparator select's state. */ -type ConditionView = Pick & +/** What the card reads off the Model: the dirty and saved values, and its selects' state. */ +type ConditionView = Pick & Readonly<{ comparatorSelect: Select.Model | undefined + intervalSelect: Select.Model | undefined + breachModeSelect: Select.Model | undefined /** The edited rule's slowdown after query timeouts, which saving clears. */ slowedMinutes: number | null }> @@ -71,9 +74,11 @@ const SEVERITY_OPTIONS: ReadonlyArray<{ }, ] -const filledState = fieldStateOf("x", undefined) +// One chip for a tier's severity, picked or fixed: 32px, 36px on a phone. +const SEVERITY_CHIP = + "inline-flex h-9 shrink-0 select-none items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium sm:h-8" -/** `SeverityPicker`: the one tier's severity, as two pills. */ +/** `SeverityPicker`: the one tier's severity, as two chips. */ const severityPicker = (h: H, value: AlertSeverity): Html => { const config: RadioGroup.RadioGroupViewConfig = { model: RadioGroup.init(value), @@ -84,103 +89,55 @@ const severityPicker = (h: H, value: AlertSeverity): Html => { inputId: `rule-severity-${option.value}-input`, })), } - const ids = { control: "rule-severity-input", label: "rule-severity-label", error: "rule-severity-error" } - return field(h, { className: "items-stretch gap-1.5", state: filledState }, [ - fieldLabel(h, { ids, state: filledState }, "Severity"), - h.div( - [ - h.AriaLabelledBy("rule-severity-label"), - h.Class("grid grid-cols-2 gap-1 rounded-md bg-surface-sunken p-0.5"), - h.DataAttribute("filled", ""), - h.Role("radiogroup"), - ], - Arr.flatMap(SEVERITY_OPTIONS, (option) => { - const isSelected = value === option.value - return RadioGroup.radio( - h, - config, - option.value, - cn( - "inline-flex h-8 cursor-pointer select-none items-center justify-center gap-1.5 rounded-sm border border-transparent px-2.5 text-xs font-medium", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", - isSelected - ? option.selected - : "text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", - ), - { - children: [ - statusDot(h, { - tone: "custom", - className: isSelected ? option.dot : "bg-foreground-faint", - }), - option.label, - ], - }, - ) - }), - ), - ]) + return hstack( + h, + { + gap: "1", + attributes: [h.AriaLabel("Severity"), h.DataAttribute("filled", ""), h.Role("radiogroup")], + }, + Arr.flatMap(SEVERITY_OPTIONS, (option) => { + const isSelected = value === option.value + return RadioGroup.radio( + h, + config, + option.value, + cn( + SEVERITY_CHIP, + "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", + isSelected + ? option.selected + : "border-border text-muted-foreground hover:border-border-strong active:border-border-strong hover:text-foreground active:text-foreground", + ), + { + children: [ + statusDot(h, { + tone: "custom", + className: isSelected ? option.dot : "bg-foreground-faint", + }), + option.label, + ], + }, + ) + }), + ) } -/** `TierLabel`: the fixed severity of a row once a rule has two tiers. */ +/** `TierLabel`: the fixed severity of a row once a rule has two tiers, the same chip as the picker's. */ const tierLabel = (h: H, severity: AlertSeverity): Html => { const option = Option.getOrElse( Arr.findFirst(SEVERITY_OPTIONS, (candidate) => candidate.value === severity), () => SEVERITY_OPTIONS[0], ) - const ids = idsOf(`rule-tier-${severity}`, false) - return field(h, { className: "items-stretch gap-1.5", state: fieldStateOf("", undefined) }, [ - fieldLabel(h, { ids, state: fieldStateOf("", undefined) }, "Severity"), - h.span( - [ - h.Class( - cn( - "inline-flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium", - option?.selected, - ), - ), - ], - [statusDot(h, { tone: "custom", className: option?.dot ?? "" }), option?.label ?? ""], - ), - ]) + return h.span( + [h.Class(cn(SEVERITY_CHIP, option?.selected))], + [statusDot(h, { tone: "custom", className: option?.dot ?? "" }), option?.label ?? ""], + ) } /** The saved value of a field when the draft changed it (`was`). */ const wasOf = (saved: RuleForm | null, form: RuleForm, pick: (form: RuleForm) => string): string | null => saved !== null && Str.trim(pick(saved)) !== Str.trim(pick(form)) ? pick(saved) : null -const comparatorRow = (h: H, model: ConditionView, form: RuleForm, saved: RuleForm | null): Html => { - const comparatorWas = wasOf(saved, form, (f) => f.comparator) - const ids = idsOf("rule-comparator", false) - const state = fieldStateOf(form.comparator, undefined) - return h.div( - [h.Class("grid items-start gap-4 p-4 md:grid-cols-3")], - [ - formSelect(h, model.comparatorSelect, COMPARATOR_SELECT_ID, form.comparator, COMPARATOR_ITEMS, { - className: cn("h-9 w-full", comparatorWas !== null && "border-severity-warn/60"), - ariaLabel: "Condition", - labelledBy: ids.label, - toView: (parts) => - field(h, { className: "items-stretch gap-1.5", state }, [ - fieldLabel(h, { ids, state }, "Alert when the value"), - ...parts, - ...(comparatorWas === null - ? [] - : [wasNote(h, [comparatorWords(saved?.comparator ?? form.comparator)])]), - ]), - }), - ...(!comparatorSupportsTiers(form.comparator) && form.escalation !== null - ? [ - h.p( - [h.Class("text-xs text-muted-foreground md:col-span-2 md:pt-7")], - ["This condition has one tier, so the critical tier is dropped on save."], - ), - ] - : []), - ], - ) -} - /** A field's value, its mounted value (dirty) and its saved value while editing (was). */ type FieldValues = Readonly<{ value: string; mounted: string | undefined; previous: string | null }> @@ -194,50 +151,189 @@ const valuesOf = const changed = (field: TextField) => (value: string) => Message.ChangedField({ field, value }) -const criticalRow = ( +const help = (h: H, text: string): Html => typography(h, { size: "xs", tone: "muted", as: "p" }, [text]) + +/** The comparator without its "is": "above", "below", "between". */ +const bareComparator = (form: RuleForm): string => Str.replace(/^is /, "")(comparatorWords(form.comparator)) + +const thresholdsSection = ( h: H, - unit: string | null, - recovery: string, - escalationOf: (pick: (tier: NonNullable) => string) => FieldValues, -): Html => - h.div( - [h.Class("grid items-start gap-4 border-t pt-3 md:grid-cols-3")], - [ - hstack(h, { gap: "2", justify: "between" }, [ - tierLabel(h, "critical"), - button( + model: ConditionView, + form: RuleForm, + saved: RuleForm | null, + invalid: ReadonlySet, +): Html => { + const isRange = isRangeComparator(form.comparator) + const isTiered = comparatorSupportsTiers(form.comparator) + const escalation = isTiered ? form.escalation : null + const unit = thresholdUnitLabel(form) + const values = valuesOf(model, form, saved) + const recovery = Str.toLowerCase(recoveryLabel(form.comparator)) + const escalationOf = (pick: (tier: NonNullable) => string) => + values((f) => (f.escalation === null ? "" : pick(f.escalation))) + const comparatorWas = wasOf(saved, form, (f) => f.comparator) + const threshold = values((f) => f.threshold) + const upper = values((f) => f.thresholdUpper) + const recoveryValue = values((f) => f.recoveryValue) + const criticalThreshold = escalationOf((tier) => tier.threshold) + const criticalRecovery = escalationOf((tier) => tier.recoveryValue) + // An empty recovery clears at the threshold itself, so that is what the empty field shows. + const recoveryPlaceholder = (value: string) => (Str.trim(value).length > 0 ? Str.trim(value) : "same") + return sentenceSection(h, { title: "Thresholds" }, [ + sentenceClause( + h, + sentenceRow(h, {}, [ + escalation === null ? severityPicker(h, form.severity) : tierLabel(h, "warning"), + "when the value", + formSelect( h, + model.comparatorSelect, + COMPARATOR_SELECT_ID, + form.comparator, + COMPARATOR_ITEMS, { - variant: "ghost", - size: "sm", - className: "mt-6 px-2 text-xs text-muted-foreground", - attributes: [h.OnClick(Message.ClickedRemoveCriticalTier())], + className: cn("w-auto", comparatorWas !== null && "border-severity-warn/60"), + ariaLabel: "Condition", + toView: (parts) => h.span([h.Class("contents")], parts), }, - ["Remove"], ), + inlineNumber(h, { + id: "rule-threshold", + ariaLabel: isRange ? "From" : "Threshold", + unit, + isInvalid: invalid.has("rule-threshold"), + onInput: changed("threshold"), + ...threshold, + }), + ...(isRange + ? [ + sentencePhrase(h, [ + "and", + inlineNumber(h, { + id: "rule-threshold-upper", + ariaLabel: "To", + unit, + isInvalid: invalid.has("rule-threshold-upper"), + onInput: changed("thresholdUpper"), + ...upper, + }), + ]), + ] + : []), + ...(isTiered + ? [ + sentencePhrase(h, [ + `and ${recovery}`, + inlineNumber(h, { + id: "rule-recovery", + ariaLabel: recoveryLabel(form.comparator), + placeholder: recoveryPlaceholder(form.threshold), + unit, + isInvalid: invalid.has("rule-recovery"), + onInput: changed("recoveryValue"), + ...recoveryValue, + }), + ]), + ] + : []), ]), - numberField(h, { - id: "rule-critical-threshold", - label: "Threshold", - ariaLabel: "Critical threshold", - unit, - onInput: (value) => Message.ChangedEscalationField({ field: "threshold", value }), - ...escalationOf((tier) => tier.threshold), - }), - numberField(h, { - id: "rule-critical-recovery", - label: recovery, - ariaLabel: `Critical ${Str.toLowerCase(recovery)}`, - placeholder: "Threshold", - description: "A critical that recovers drops back to warning.", - unit, - onInput: (value) => Message.ChangedEscalationField({ field: "recoveryValue", value }), - ...escalationOf((tier) => tier.recoveryValue), - }), - ], - ) + [ + ...wasLine(h, [ + { + label: "Condition", + previous: + comparatorWas === null + ? null + : comparatorWords(saved?.comparator ?? form.comparator), + }, + { label: isRange ? "From" : "Threshold", previous: threshold.previous, unit }, + { label: "To", previous: isRange ? upper.previous : null, unit }, + { + label: recoveryLabel(form.comparator), + previous: isTiered ? recoveryValue.previous : null, + unit, + }, + ]), + ...(isTiered + ? [ + help( + h, + "Recovery is optional. Left empty, it clears once it's back past the threshold.", + ), + ] + : []), + ...(!isTiered && form.escalation !== null + ? [help(h, "This condition has one tier, so the critical tier is dropped on save.")] + : []), + ], + ), + ...(escalation === null + ? [] + : [ + sentenceClause( + h, + sentenceRow(h, {}, [ + tierLabel(h, "critical"), + bareComparator(form), + inlineNumber(h, { + id: "rule-critical-threshold", + ariaLabel: "Critical threshold", + unit, + isInvalid: invalid.has("rule-critical-threshold"), + onInput: (value) => + Message.ChangedEscalationField({ field: "threshold", value }), + ...criticalThreshold, + }), + sentencePhrase(h, [ + `and ${recovery}`, + inlineNumber(h, { + id: "rule-critical-recovery", + ariaLabel: `Critical ${recovery}`, + placeholder: recoveryPlaceholder(escalation.threshold), + unit, + isInvalid: invalid.has("rule-critical-recovery"), + onInput: (value) => + Message.ChangedEscalationField({ field: "recoveryValue", value }), + ...criticalRecovery, + }), + ]), + button( + h, + { + variant: "ghost", + attributes: [h.OnClick(Message.ClickedRemoveCriticalTier())], + }, + ["Remove"], + ), + ]), + [ + ...wasLine(h, [ + { label: "Critical threshold", previous: criticalThreshold.previous, unit }, + { label: `Critical ${recovery}`, previous: criticalRecovery.previous, unit }, + ]), + help(h, "A critical that recovers drops back to warning."), + ], + ), + ]), + ...(isTiered && escalation === null && form.severity === "warning" + ? [ + hstack(h, {}, [ + button( + h, + { + variant: "ghost", + className: "-ml-2", + attributes: [h.OnClick(Message.ClickedAddCriticalTier())], + }, + [icon(h, "PlusIcon", { size: 12 }), "Add critical tier"], + ), + ]), + ] + : []), + ]) +} -/** `windowDescription`: what a check reads and how often the scheduler runs one. */ +/** What a check reads and how often the scheduler runs one. */ const windowDescription = (form: RuleForm): string => { const minutes = Number(Str.trim(form.windowMinutes)) if (!Number.isFinite(minutes) || minutes <= 0) return "Each check looks at this much data." @@ -245,83 +341,51 @@ const windowDescription = (form: RuleForm): string => { return `Each check looks at this much data, checked every ${every === 1 ? "minute" : `${every} min`}.` } -/** "Check every": the interval, and for raw SQL the query time each one allows. */ -const intervalPicker = ( +/** An interval as the list shows it: why it will not work, and for raw SQL the time it allows. */ +const intervalItemLabel = (option: IntervalOption, isRaw: boolean): string => + Arr.join( + [ + option.label, + ...(isRaw ? [`${option.limitSeconds} s limit`] : []), + ...(option.isLongerThanWindow + ? ["longer than the window"] + : option.isTooSlow + ? ["too slow for this query"] + : []), + ], + " · ", + ) + +/** "every [interval]": a select inside the sentence, at its content's width. */ +const intervalSelect = ( h: H, model: ConditionView, form: RuleForm, - timing: AlertQueryTiming | null, + options: ReadonlyArray, ): Html => { const isRaw = form.signalType === "raw_query" - const options = intervalOptions(form, isRaw ? timing : null) - const selected = intervalRadioValue(form.evaluationIntervalMinutes) - const config: RadioGroup.RadioGroupViewConfig = { - model: RadioGroup.init(selected), - toParentMessage: (message) => Message.GotRadioMessage({ group: "interval", message }), - items: Arr.map(options, (option) => ({ - value: option.value, - id: `rule-interval-${option.value}`, - inputId: `rule-interval-${option.value}-input`, - })), - } + const selected = intervalSelectValue(form.evaluationIntervalMinutes) const current = Arr.findFirst(options, (option) => option.value === selected) - return field(h, { className: "items-stretch gap-1.5", state: filledState }, [ - typography(h, { size: "sm", weight: "medium", attributes: [h.Id("rule-interval-label")] }, [ - "Check every", - ]), - ...slowdownNotice(h, model, form), - h.div( - [ - h.AriaLabelledBy("rule-interval-label"), - h.Class( - "grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1 rounded-md bg-surface-sunken p-0.5", + const isBlocked = Option.exists(current, (option) => option.isLongerThanWindow || option.isTooSlow) + return formSelect( + h, + model.intervalSelect, + INTERVAL_SELECT_ID, + selected, + Arr.map(options, (option) => ({ value: option.value, label: intervalItemLabel(option, isRaw) })), + { + className: cn("w-auto", isBlocked && "border-severity-warn/60"), + ariaLabel: "Check every", + // The trigger keeps the short name; the list says each option's limit and why it is blocked. + renderValue: (value) => [ + Option.match( + Arr.findFirst(options, (option) => option.value === value), + { onNone: () => value, onSome: (option) => option.label }, ), - h.DataAttribute("filled", ""), - h.Role("radiogroup"), ], - Arr.flatMap(options, (option) => { - const isSelected = option.value === selected - const isBlocked = option.isLongerThanWindow || option.isTooSlow - return RadioGroup.radio( - h, - config, - option.value, - cn( - "flex min-h-8 cursor-pointer select-none flex-col items-center justify-center gap-0.5 rounded-sm border border-transparent px-2 py-1 text-xs font-medium", - "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", - isSelected - ? isBlocked - ? "border-severity-warn/60 bg-severity-warn-a2 text-severity-warn" - : "border-primary-a4 bg-primary-a2 text-foreground" - : "text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", - !isSelected && isBlocked && "text-foreground-subtle", - ), - { - children: [ - h.span( - [h.Class(cn("whitespace-nowrap", option.isTooSlow && "line-through"))], - [option.label], - ), - ...(option.isLongerThanWindow - ? [h.span([h.Class("sr-only")], [", longer than the window"])] - : option.isTooSlow - ? [h.span([h.Class("sr-only")], [", too slow for this query"])] - : []), - ...(isRaw - ? [ - h.span( - [h.Class("text-2xs font-normal tabular-nums opacity-80")], - [`${option.limitSeconds} s limit`], - ), - ] - : []), - ], - }, - ) - }), - ), - typography(h, { size: "xs", tone: "muted", as: "p" }, [intervalNote(form, current, timing)]), - ]) + toView: (parts) => h.span([h.Class("contents")], parts), + }, + ) } const slowdownOf = (model: Model): Pick => { @@ -375,7 +439,7 @@ const slowdownNotice = (h: H, model: ConditionView, form: RuleForm): ReadonlyArr const intervalNote = ( form: RuleForm, - found: Option.Option[number]>, + found: Option.Option, timing: AlertQueryTiming | null, ): string => { if (Option.isNone(found)) return "" @@ -397,7 +461,7 @@ const intervalNote = ( if (form.signalType !== "raw_query") { const auto = autoIntervalMinutes(form) return option.value === AUTO_INTERVAL || option.minutes === auto - ? "A fifth of the window, from every minute to every 5." + ? "Auto checks every fifth of the window, from every minute to every 5." : option.minutes > auto ? "Fewer checks than Auto over the same window." : "More checks than Auto over the same window." @@ -405,149 +469,132 @@ const intervalNote = ( return `Each check may run ${option.limitSeconds} s. Checking less often allows a slower query.` } +const BREACH_MODE_ITEMS = [ + { value: "row", label: "in a row" }, + { value: "window", label: "out of the last" }, +] + const checksSection = ( h: H, model: ConditionView, form: RuleForm, saved: RuleForm | null, timing: AlertQueryTiming | null, + invalid: ReadonlySet, ): Html => { const values = valuesOf(model, form, saved) - return h.div( - [h.Class("space-y-3 border-t p-4")], - [ - eyebrow(h, { as: "h3" }, ["How it is checked"]), - h.div( - [h.Class("grid items-start gap-4 md:grid-cols-3")], - [ - numberField(h, { - id: "rule-window-minutes", - label: "Window", - description: windowDescription(form), - unit: "min", - onInput: changed("windowMinutes"), - ...values((f) => f.windowMinutes), - }), - h.div( - [h.Class("grid grid-cols-2 items-start gap-3")], - [ - numberField(h, { - id: "rule-consecutive-breaches", - label: "Breaches to fire", - unit: "checks", - onInput: changed("consecutiveBreachesRequired"), - ...values((f) => f.consecutiveBreachesRequired), - }), - numberField(h, { + const isRaw = form.signalType === "raw_query" + const options = intervalOptions(form, isRaw ? timing : null) + const current = Arr.findFirst( + options, + (option) => option.value === intervalSelectValue(form.evaluationIntervalMinutes), + ) + const hasBreachWindow = model.isBreachWindowOpen || Str.trim(form.breachesOutOf).length > 0 + const windowMinutes = values((f) => f.windowMinutes) + const breaches = values((f) => f.consecutiveBreachesRequired) + const outOf = values((f) => f.breachesOutOf) + const samples = values((f) => f.minimumSampleCount) + return sentenceSection(h, { title: "How it is checked" }, [ + sentenceClause( + h, + sentenceRow(h, {}, [ + "Look at the last", + inlineNumber(h, { + id: "rule-window-minutes", + ariaLabel: "Window", + isInvalid: invalid.has("rule-window-minutes"), + onInput: changed("windowMinutes"), + ...windowMinutes, + }), + sentencePhrase(h, ["min, every", intervalSelect(h, model, form, options)]), + ]), + [ + ...slowdownNotice(h, model, form), + ...wasLine(h, [{ label: "Window", previous: windowMinutes.previous, unit: "min" }]), + help(h, windowDescription(form)), + help(h, intervalNote(form, current, timing)), + ], + ), + sentenceClause( + h, + sentenceRow(h, {}, [ + "Fire after", + inlineNumber(h, { + id: "rule-consecutive-breaches", + ariaLabel: "Breaches to fire", + isInvalid: invalid.has("rule-consecutive-breaches"), + onInput: changed("consecutiveBreachesRequired"), + ...breaches, + }), + "breaches", + formSelect( + h, + model.breachModeSelect, + BREACH_MODE_SELECT_ID, + hasBreachWindow ? "window" : "row", + BREACH_MODE_ITEMS, + { + className: "w-auto", + ariaLabel: "How breaches are counted", + toView: (parts) => h.span([h.Class("contents")], parts), + }, + ), + ...(hasBreachWindow + ? [ + inlineNumber(h, { id: "rule-breaches-out-of", - label: "Out of the last", - placeholder: "In a row", - unit: Str.trim(form.breachesOutOf).length > 0 ? "checks" : null, + ariaLabel: "Out of the last", + inputMode: "numeric", + isInvalid: invalid.has("rule-breaches-out-of"), onInput: changed("breachesOutOf"), - ...values((f) => f.breachesOutOf), + ...outOf, }), - h.p( - [h.Class("col-span-2 -mt-1.5 text-xs text-muted-foreground")], - ["Empty counts them in a row. 1 fires on the first breach."], - ), - ], - ), - numberField(h, { - id: "rule-minimum-samples", - label: "Minimum samples", - description: "Windows with fewer are skipped and can't fire.", - unit: "per window", - onInput: changed("minimumSampleCount"), - ...values((f) => f.minimumSampleCount), - }), - ], - ), - intervalPicker(h, model, form, timing), - ], - ) + "checks.", + ] + : []), + ]), + [ + ...wasLine(h, [ + { label: "Breaches to fire", previous: breaches.previous }, + { label: "Out of the last", previous: outOf.previous }, + ]), + help(h, "1 fires on the first breach."), + ], + ), + sentenceClause( + h, + sentenceRow(h, {}, [ + "Skip windows with fewer than", + inlineNumber(h, { + id: "rule-minimum-samples", + ariaLabel: "Minimum samples", + isInvalid: invalid.has("rule-minimum-samples"), + onInput: changed("minimumSampleCount"), + ...samples, + }), + "samples.", + ]), + [ + ...wasLine(h, [{ label: "Minimum samples", previous: samples.previous }]), + help(h, "A skipped window can't fire."), + ], + ), + ]) } -const condition = (h: H, model: ConditionView, form: RuleForm, timing: AlertQueryTiming | null): Html => - card( - h, - [ - comparatorRow(h, model, form, model.savedForm), - thresholdsSection(h, model, form, model.savedForm), - checksSection(h, model, form, model.savedForm, timing), - ], - "gap-0 p-0", - ) - -const thresholdsSection = (h: H, model: ConditionView, form: RuleForm, saved: RuleForm | null): Html => { - const isRange = isRangeComparator(form.comparator) - const isTiered = comparatorSupportsTiers(form.comparator) - const escalation = isTiered ? form.escalation : null - const unit = thresholdUnitLabel(form) - const values = valuesOf(model, form, saved) - const recovery = recoveryLabel(form.comparator) - const escalationOf = (pick: (tier: NonNullable) => string) => - values((f) => (f.escalation === null ? "" : pick(f.escalation))) +const condition = ( + h: H, + model: ConditionView, + form: RuleForm, + timing: AlertQueryTiming | null, + invalidIds: string, +): Html => { + const invalid = new Set(Str.split(invalidIds, ",")) return h.div( - [h.Class("space-y-3 border-t p-4")], + [h.Class("contents")], [ - eyebrow(h, { as: "h3" }, ["Thresholds"]), - h.div( - [h.Class("grid items-start gap-4 md:grid-cols-3")], - [ - escalation === null ? severityPicker(h, form.severity) : tierLabel(h, "warning"), - h.div( - [h.Class(cn("grid items-start gap-3", isRange && "grid-cols-2"))], - [ - numberField(h, { - id: "rule-threshold", - label: isRange ? "From" : "Threshold", - unit, - onInput: changed("threshold"), - ...values((f) => f.threshold), - }), - ...(isRange - ? [ - numberField(h, { - id: "rule-threshold-upper", - label: "To", - unit, - onInput: changed("thresholdUpper"), - ...values((f) => f.thresholdUpper), - }), - ] - : []), - ], - ), - ...(isTiered - ? [ - numberField(h, { - id: "rule-recovery", - label: recovery, - placeholder: "Threshold", - description: "Optional. Empty clears once it's back past the threshold.", - unit, - onInput: changed("recoveryValue"), - ...values((f) => f.recoveryValue), - }), - ] - : []), - ], - ), - ...(escalation === null ? [] : [criticalRow(h, unit, recovery, escalationOf)]), - ...(isTiered && escalation === null && form.severity === "warning" - ? [ - button( - h, - { - variant: "ghost", - size: "sm", - className: "-ml-2 px-2 text-xs text-muted-foreground", - attributes: [h.OnClick(Message.ClickedAddCriticalTier())], - }, - [icon(h, "PlusIcon", { size: 12 }), "Add critical tier"], - ), - ] - : []), + thresholdsSection(h, model, form, model.savedForm, invalid), + checksSection(h, model, form, model.savedForm, timing, invalid), ], ) } @@ -556,10 +603,13 @@ const slot = createLazy() const viewOf = stableSlice((model: Model): ConditionView => ({ mountedForm: model.mountedForm, savedForm: model.savedForm, + isBreachWindowOpen: model.isBreachWindowOpen, comparatorSelect: model.selects[COMPARATOR_SELECT_ID], + intervalSelect: model.selects[INTERVAL_SELECT_ID], + breachModeSelect: model.selects[BREACH_MODE_SELECT_ID], ...slowdownOf(model), })) -/** The condition card under the chart; `form` is the evaluated draft (`evaluatedFormOf`). */ +/** The thresholds and the checks; `form` is the evaluated draft (`evaluatedFormOf`). */ export const conditionFields = (h: H, model: Model, form: RuleForm): Html => - slot(condition, [h, viewOf(model), form, draftTimingOf(model, form)]) + slot(condition, [h, viewOf(model), form, draftTimingOf(model, form), invalidFieldsOf(model, form)]) diff --git a/apps/web/src/page/alerts/editor/view-details.ts b/apps/web/src/page/alerts/editor/view-details.ts index a3b30a6fb2..acb6883f18 100644 --- a/apps/web/src/page/alerts/editor/view-details.ts +++ b/apps/web/src/page/alerts/editor/view-details.ts @@ -4,8 +4,7 @@ import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { bareInput } from "@maple/views/ui/bare-input" import { badge } from "@maple/views/ui/display" import { button } from "@maple/views/ui/button" -import { card } from "@maple/views/ui/card" -import { field, fieldDescription, fieldLabel, input } from "@maple/views/ui/field" +import { field, fieldDescription, fieldError, fieldLabel, input } from "@maple/views/ui/field" import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import { textarea } from "@maple/views/ui/textarea" @@ -86,10 +85,10 @@ const tagInput = (h: H, model: DetailsView, form: RuleForm): Html => { ) } -const nameField = (h: H, model: DetailsView, form: RuleForm): Html => { +const nameField = (h: H, model: DetailsView, form: RuleForm, isNameInvalid: boolean): Html => { const suggestion = suggestedName(form) const isSuggesting = Str.trim(form.name).length === 0 && suggestion !== null && suggestion.length > 0 - const state = fieldStateOf(form.name, model.mountedForm?.name) + const state = { ...fieldStateOf(form.name, model.mountedForm?.name), isInvalid: isNameInvalid } const ids = idsOf("rule-name", false) return field(h, { className: "items-stretch gap-1.5", state }, [ hstack(h, { gap: "2", justify: "between", className: "min-w-0" }, [ @@ -122,8 +121,11 @@ const nameField = (h: H, model: DetailsView, form: RuleForm): Html => { ), h.Value(form.name), h.OnInput((value) => Message.ChangedField({ field: "name", value })), + h.OnBlur(Message.BlurredField({ id: ids.control })), ], }), + // The invalid input names this id in `aria-describedby`: the two render together. + ...(isNameInvalid ? [fieldError(h, { ids, state }, "Give the rule a name.")] : []), ]) } @@ -160,30 +162,24 @@ const notes = (h: H, model: DetailsView, form: RuleForm): Html => { ]) } -const details = (h: H, model: DetailsView, form: RuleForm): Html => { +const details = (h: H, model: DetailsView, form: RuleForm, isNameInvalid: boolean): Html => { const tagIds = idsOf("rule-tags", true) const tagState = fieldStateOf("", undefined) - return card( - h, + return h.div( + [h.Class("space-y-3")], [ - h.div( - [h.Class("space-y-3")], - [ - nameField(h, model, form), - field(h, { className: "items-stretch gap-1.5", state: tagState }, [ - fieldLabel(h, { ids: tagIds, state: tagState }, "Tags"), - tagInput(h, model, form), - fieldDescription( - h, - { ids: tagIds, state: tagState }, - "Group and filter rules in the alerts list. Press Enter to add.", - ), - ]), - notes(h, model, form), - ], - ), + nameField(h, model, form, isNameInvalid), + field(h, { className: "items-stretch gap-1.5", state: tagState }, [ + fieldLabel(h, { ids: tagIds, state: tagState }, "Tags"), + tagInput(h, model, form), + fieldDescription( + h, + { ids: tagIds, state: tagState }, + "Group and filter rules in the alerts list. Press Enter to add.", + ), + ]), + notes(h, model, form), ], - "p-4", ) } @@ -194,4 +190,10 @@ const viewOf = stableSlice((model: Model) => /** `DetailsSection`: the name with a one-click suggestion, tags, and notes behind a button. */ export const detailsSection = (h: H, model: Model, form: RuleForm): Html => - slot(details, [h, viewOf(model), form]) + slot(details, [ + h, + viewOf(model), + form, + Str.trim(form.name).length === 0 && + (model.hasAttemptedSave || model.touchedFields.includes("rule-name")), + ]) diff --git a/apps/web/src/page/alerts/editor/view-fields.ts b/apps/web/src/page/alerts/editor/view-fields.ts index 3cd96e6a2e..8d2a41352f 100644 --- a/apps/web/src/page/alerts/editor/view-fields.ts +++ b/apps/web/src/page/alerts/editor/view-fields.ts @@ -1,21 +1,18 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" -import { field, fieldDescription, fieldLabel, type FieldIds } from "@maple/views/ui/field" +import type { FieldIds } from "@maple/views/ui/field" import type { FieldState } from "@maple/views/ui/input" import { inputGroup, inputGroupAddon, inputGroupInput, inputGroupText } from "@maple/views/ui/input-group" -import type { Message } from "./message" +import { Message } from "./message" import { typography } from "@maple/views/ui/typography" import { Array as Arr } from "effect" -// `NumberField` and `NoiseRow`: a Base UI field -// holding an input group with the unit as its trailing addon. +// The editor's field helpers: Base UI's field state, and the number that sits inside a sentence row. type H = HtmlBuilder export const NUMERIC_CONTROL_CLASS = "font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" -export const NOISE_CONTROL_CLASS = - "font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" /** Base UI's field state from the value and the value the field mounted with. */ export const fieldStateOf = (value: string, mounted: string | undefined): FieldState => ({ @@ -31,72 +28,76 @@ export const idsOf = (id: string, hasDescription: boolean): FieldIds => { return hasDescription ? { ...ids, description: `${id}-description` } : ids } -export type NumberFieldOptions = Readonly<{ +export type InlineNumberOptions = Readonly<{ id: string - label: string + /** The field's name: a sentence row has no label element, the words around it are its label. */ + ariaLabel: string value: string mounted: string | undefined - unit: string | null + /** A unit read as part of the number ("%", "ms"); a word unit is written after the field. */ + unit?: string | null onInput: (value: string) => Message - description?: string placeholder?: string - ariaLabel?: string inputMode?: "decimal" | "numeric" - controlClassName?: string min?: string - /** The saved value when it differs (`previous`): the border warns and a note says what it was. */ + /** The saved value when it differs (`previous`): the border warns. */ previous?: string | null + /** The field has an error the user has seen a reason for (left it, or tried to save). */ + isInvalid?: boolean + className?: string }> -/** `WasNote`: what a changed field was before the edit. */ -export const wasNote = (h: H, children: ReadonlyArray): Html => - typography(h, { size: "xs", tone: "warning", as: "p" }, [ - "Was ", - typography( - h, - { mono: true }, - Arr.filter(children, (child) => child !== ""), - ), - ]) - -export const numberField = (h: H, options: NumberFieldOptions): Html => { - const previous = options.previous ?? null - const description = options.description - const state = fieldStateOf(options.value, options.mounted) - const ids = idsOf(options.id, description !== undefined) - const described = ids.description - const unit = options.unit - return field(h, { className: "items-stretch gap-1.5", state }, [ - fieldLabel(h, { ids, state }, options.label), - inputGroup(h, { className: cn("h-9", previous !== null && "border-severity-warn/60") }, [ +/** A number inside a sentence row: about 96px wide, 32px tall (36px on a phone). */ +export const inlineNumber = (h: H, options: InlineNumberOptions): Html => { + const unit = options.unit ?? null + const state = { ...fieldStateOf(options.value, options.mounted), isInvalid: options.isInvalid ?? false } + return inputGroup( + h, + { + className: cn( + "h-9 w-24 shrink-0 sm:h-8", + options.className, + (options.previous ?? null) !== null && "border-severity-warn/60", + ), + }, + [ inputGroupInput(h, { id: options.id, type: "number", - controlClassName: options.controlClassName ?? NUMERIC_CONTROL_CLASS, + controlClassName: NUMERIC_CONTROL_CLASS, fieldState: state, attributes: [ - ...(described === undefined ? [] : [h.AriaDescribedBy(described)]), - ...(options.ariaLabel === undefined ? [] : [h.AriaLabel(options.ariaLabel)]), - h.AriaLabelledBy(ids.label), + h.AriaLabel(options.ariaLabel), + ...(state.isInvalid ? [h.AriaInvalid(true)] : []), h.InputMode(options.inputMode ?? "decimal"), ...(options.min === undefined ? [] : [h.Min(options.min)]), ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), h.Value(options.value), h.OnInput(options.onInput), + h.OnBlur(Message.BlurredField({ id: options.id })), ], }), ...(unit === null ? [] : [inputGroupAddon(h, { align: "inline-end" }, [inputGroupText(h, {}, [unit])])]), - ]), - ...(previous === null - ? [] - : [ - wasNote(h, [ - previous.length > 0 ? previous : "none", - previous.length > 0 && unit !== null ? (unit === "%" ? "%" : ` ${unit}`) : "", - ]), - ]), - ...(description === undefined ? [] : [fieldDescription(h, { ids, state }, description)]), - ]) + ], + ) } + +/** Under a sentence row: what each changed field was before the edit ("Threshold was 5%"). */ +export const wasLine = ( + h: H, + changes: ReadonlyArray>, +): ReadonlyArray => + Arr.flatMap(changes, (change) => { + const previous = change.previous + if (previous === null) return [] + const unit = change.unit ?? null + const suffix = previous.length > 0 && unit !== null ? (unit === "%" ? "%" : ` ${unit}`) : "" + return [ + typography(h, { size: "xs", tone: "warning", as: "p" }, [ + `${change.label} was `, + typography(h, { mono: true }, [previous.length > 0 ? `${previous}${suffix}` : "none"]), + ]), + ] + }) diff --git a/apps/web/src/page/alerts/editor/view-hero.ts b/apps/web/src/page/alerts/editor/view-hero.ts index 9a5658d573..ebf98d9cce 100644 --- a/apps/web/src/page/alerts/editor/view-hero.ts +++ b/apps/web/src/page/alerts/editor/view-hero.ts @@ -3,14 +3,19 @@ import { type BreachStats, formatBreachDuration, } from "@maple/web-data/alerts/breach-stats" -import { displaySignalType, formatSignalValue } from "@maple/web-data/alerts/form-utils" +import { + displaySignalType, + formatSignalValue, + isFilledNumber, + isRangeComparator, +} from "@maple/web-data/alerts/form-utils" import { countSkippedWindows, draftThresholds, focusPreviewOnClosestGroups, } from "@maple/web-data/alerts/preview-insights" import { cn } from "@maple/ui/lib/utils" -import { Struct, Array as Arr } from "effect" +import { Struct, Array as Arr, String as Str } from "effect" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { isChartRuntimeReady } from "@maple/views/charts/chart-runtime" import { button } from "@maple/views/ui/button" @@ -24,7 +29,7 @@ import { alertStatusBadge } from "../shared/badges" import { previewStateOf } from "../shared/preview" import { ruleChart } from "../shared/rule-chart" import { conditionSubject, groupBySummary, previewIdentityKey } from "./derive" -import { draftPreviewOf, windowMsOf } from "./derive-rows" +import { draftPreviewOf, hasPreviewPayload, windowMsOf } from "./derive-rows" import type { RuleForm } from "./form" import { Message } from "./message" import type { DryRun, Model } from "./model" @@ -37,38 +42,40 @@ import { hstack } from "@maple/views/ui/stack" type H = HtmlBuilder +// The verdict is the point of the chart: a phone gives it its own line under the title, and from +// `sm` it sits beside the title as before. +const verdictClass = (tone: string): string => + typographyClass({ size: "xs", className: cn("basis-full sm:basis-auto", tone) }) + const breachPill = (h: H, stats: BreachStats): ReadonlyArray => { if (stats.bucketCount === 0) return [] if (stats.breachCount === 0) return [ - h.span( - [h.Class("hidden items-center gap-1 text-xs text-severity-info sm:inline-flex")], - [icon(h, "CheckIcon", { size: 12 }), "No breaches in this window"], - ), + hstack(h, { gap: "1", inline: true, as: "span", className: verdictClass("text-severity-info") }, [ + icon(h, "CheckIcon", { size: 12 }), + "No breaches in this window", + ]), ] return [ - h.span( - [h.Class("hidden items-center gap-1 text-xs text-severity-error sm:inline-flex")], - [ - icon(h, "FireIcon", { size: 12 }), - "Would have fired", - " ", - typography(h, { weight: "semibold", mono: true, numeric: true }, [ - String(stats.breachCount), - "×", - ]), - ...(stats.longestRunMs === null - ? [] - : [ - " ", - "· longest", - " ", - typography(h, { weight: "semibold", mono: true, numeric: true }, [ - formatBreachDuration(stats.longestRunMs), - ]), + hstack(h, { gap: "1", inline: true, as: "span", className: verdictClass("text-severity-error") }, [ + icon(h, "FireIcon", { size: 12 }), + "Would have fired", + " ", + typography(h, { weight: "semibold", mono: true, numeric: true }, [ + String(stats.breachCount), + "×", + ]), + ...(stats.longestRunMs === null + ? [] + : [ + " ", + "· longest", + " ", + typography(h, { weight: "semibold", mono: true, numeric: true }, [ + formatBreachDuration(stats.longestRunMs), ]), - ], - ), + ]), + ]), ] } @@ -110,7 +117,7 @@ const header = (h: H, model: HeaderView, form: RuleForm, timeZone: string): Html h, { gap: "2", align: "center", justify: "between", wrap: true, className: "px-4 pt-3 pb-2" }, [ - hstack(h, { gap: "2", shrink: true }, [ + hstack(h, { gap: "2", shrink: true, wrap: true }, [ badge(h, { variant: "outline", className: typographyClass({ size: "xs", mono: true }) }, [ conditionSubject(form), ]), @@ -155,14 +162,25 @@ const header = (h: H, model: HeaderView, form: RuleForm, timeZone: string): Html ) } +/** The threshold (both bounds of a range) is a number; blank is missing, where `Number("")` is 0. */ +const hasThreshold = (form: RuleForm): boolean => + isFilledNumber(form.threshold) && + (!isRangeComparator(form.comparator) || isFilledNumber(form.thresholdUpper)) + /** The chart with the threshold and would-fire bands, and the notes under it. */ -type ChartView = Pick +type ChartView = Pick< + Model, + "preview" | "previewWindow" | "previewPreset" | "chart" | "themeColors" | "chartRuntime" +> const chartBody = (h: H, model: ChartView, form: RuleForm, timeZone: string): Html => { const preview = draftPreviewOf(model, form) const thresholds = draftThresholds(form) const focused = focusPreviewOnClosestGroups(preview.preview) const isChartReady = model.themeColors !== null && isChartRuntimeReady(model.chartRuntime) + const range = Str.toLowerCase(presetLabel(model.previewPreset)) + // No request to wait for: the rule has nothing previewable yet (no query, or a blank threshold). + const isIdle = !hasPreviewPayload(model, form) return h.div( [h.Class("space-y-2 px-4 pt-3 pb-4")], [ @@ -180,7 +198,19 @@ const chartBody = (h: H, model: ChartView, form: RuleForm, timeZone: string): Ht comparator: form.comparator, signalType: displaySignalType(form), window: windowMsOf(model), - isLoading: preview.isLoading || !isChartReady, + // With nothing to draw the text states need no chart runtime, so they never flash "Loading". + isLoading: preview.isLoading || (!isChartReady && preview.preview !== null), + loadingText: `Loading the ${range}…`, + emptyText: isIdle + ? { + title: hasThreshold(form) + ? `Pick a signal to preview it against the ${range}` + : `Enter a number for the threshold to preview it against the ${range}`, + } + : { + title: `No data for this signal in the ${range}`, + hint: "Pick another signal or widen the range", + }, error: preview.error, }), ...(focused.hiddenGroupCount > 0 @@ -218,7 +248,7 @@ const impactOf = stableSlice((model: Model): ImpactView => ]), ) const chartOf = stableSlice((model: Model): ChartView => - Struct.pick(model, ["preview", "previewWindow", "chart", "themeColors", "chartRuntime"]), + Struct.pick(model, ["preview", "previewWindow", "previewPreset", "chart", "themeColors", "chartRuntime"]), ) /** Step 2's chart card; the comparison block joins it while editing with unsaved changes. */ diff --git a/apps/web/src/page/alerts/editor/view-noise.ts b/apps/web/src/page/alerts/editor/view-noise.ts index e9e03a4b0a..9cfba091b1 100644 --- a/apps/web/src/page/alerts/editor/view-noise.ts +++ b/apps/web/src/page/alerts/editor/view-noise.ts @@ -1,92 +1,118 @@ import { ruleFormIsGrouped } from "@maple/web-data/alerts/form-utils" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" -import { card } from "@maple/views/ui/card" -import { eyebrow } from "@maple/views/ui/eyebrow" -import { settingRow } from "@maple/views/ui/setting-row" +import { label } from "@maple/views/ui/label" import { switchControl } from "@maple/views/ui/switch" +import { typography } from "@maple/views/ui/typography" +import { sentenceClause, sentenceRow, sentenceSection } from "../../kit/sentence-card" import type { RuleForm } from "./form" import { Message } from "./message" import type { Model } from "./model" -import { NOISE_CONTROL_CLASS, numberField } from "./view-fields" +import { invalidFieldsOf } from "./derive-rows" +import { inlineNumber, wasLine } from "./view-fields" +import { String as Str } from "effect" -// `RuleNoiseSection`: how an open incident behaves. +// `RuleNoiseSection`: how an open incident behaves, as sentences. type H = HtmlBuilder -const NO_DATA_IDS = { control: "rule-no-data-input", label: "rule-no-data-label", description: "rule-no-data-description" } +const NO_DATA_IDS = { + control: "rule-no-data-input", + label: "rule-no-data-label", + description: "rule-no-data-description", +} -const noise = (h: H, mounted: Model["mountedForm"], form: RuleForm): Html => { +const noise = ( + h: H, + mounted: Model["mountedForm"], + saved: Model["savedForm"], + form: RuleForm, + invalidIds: string, +): Html => { const isGrouped = ruleFormIsGrouped(form) - const noiseRow = ( - id: string, - label: string, - unit: string, - pick: (form: RuleForm) => string, - field: "consecutiveHealthyRequired" | "renotifyIntervalMinutes", - ) => - numberField(h, { - id, - label, - unit, - value: pick(form), - mounted: mounted === null ? undefined : pick(mounted), - inputMode: "numeric", - min: "1", - controlClassName: NOISE_CONTROL_CLASS, - onInput: (value) => Message.ChangedField({ field, value }), - }) - return card( - h, - [ - h.div( - [h.Class("space-y-3 p-4")], - [ - eyebrow(h, { as: "h3" }, ["After it fires"]), - h.div( - [h.Class("grid items-start gap-4 md:grid-cols-3")], - [ - noiseRow( - "rule-consecutive-healthy", - "Resolve after", - "healthy checks", - (f) => f.consecutiveHealthyRequired, - "consecutiveHealthyRequired", - ), - noiseRow( - "rule-renotify", - "Remind while open, every", - "min", - (f) => f.renotifyIntervalMinutes, - "renotifyIntervalMinutes", - ), - settingRow(h, { - ids: NO_DATA_IDS, - label: "Alert when there is no data", - description: isGrouped - ? "Not available on split rules: a quiet group keeps its incident open until data returns." - : "Count an empty window as a breach. Off, those windows are skipped.", - disabled: isGrouped, - control: switchControl(h, { - id: "rule-no-data", - inputId: NO_DATA_IDS.control, - isChecked: form.alertOnNoData && !isGrouped, - isDisabled: isGrouped, - labelledBy: NO_DATA_IDS.label, - describedBy: NO_DATA_IDS.description, - onToggle: Message.ToggledAlertOnNoData(), - }), - }), - ], - ), - ], - ), - ], - "gap-0 p-0", - ) + const invalid = new Set(Str.split(invalidIds, ",")) + const values = (pick: (form: RuleForm) => string) => ({ + value: pick(form), + mounted: mounted === null ? undefined : pick(mounted), + previous: saved !== null && Str.trim(pick(saved)) !== Str.trim(pick(form)) ? pick(saved) : null, + }) + const healthy = values((f) => f.consecutiveHealthyRequired) + const renotify = values((f) => f.renotifyIntervalMinutes) + return sentenceSection(h, { title: "After it fires" }, [ + sentenceClause( + h, + sentenceRow(h, {}, [ + "Resolve after", + inlineNumber(h, { + id: "rule-consecutive-healthy", + ariaLabel: "Resolve after", + inputMode: "numeric", + min: "1", + isInvalid: invalid.has("rule-consecutive-healthy"), + onInput: (value) => Message.ChangedField({ field: "consecutiveHealthyRequired", value }), + ...healthy, + }), + "healthy checks.", + ]), + wasLine(h, [{ label: "Resolve after", previous: healthy.previous }]), + ), + sentenceClause( + h, + sentenceRow(h, {}, [ + "Remind every", + inlineNumber(h, { + id: "rule-renotify", + ariaLabel: "Remind while open, every", + inputMode: "numeric", + min: "1", + isInvalid: invalid.has("rule-renotify"), + onInput: (value) => Message.ChangedField({ field: "renotifyIntervalMinutes", value }), + ...renotify, + }), + "min while open.", + ]), + wasLine(h, [{ label: "Remind interval", previous: renotify.previous, unit: "min" }]), + ), + sentenceClause( + h, + sentenceRow(h, {}, [ + // The label wraps the switch: its whole line toggles, at the row's control height. + label( + h, + { + className: "inline-flex min-h-9 items-center gap-2 sm:min-h-8", + attributes: [h.Id(NO_DATA_IDS.label), h.For(NO_DATA_IDS.control)], + }, + [ + ...switchControl(h, { + id: "rule-no-data", + inputId: NO_DATA_IDS.control, + isChecked: form.alertOnNoData && !isGrouped, + isDisabled: isGrouped, + labelledBy: NO_DATA_IDS.label, + describedBy: NO_DATA_IDS.description, + onToggle: Message.ToggledAlertOnNoData(), + }), + "Alert when there is no data", + ], + ), + ]), + [ + typography( + h, + { size: "xs", tone: "muted", as: "p", attributes: [h.Id(NO_DATA_IDS.description)] }, + [ + isGrouped + ? "Not available on split rules: a quiet group keeps its incident open until data returns." + : "Count an empty window as a breach. Off, those windows are skipped.", + ], + ), + ], + ), + ]) } const slot = createLazy() /** "After it fires"; `form` is the evaluated draft (`evaluatedFormOf`). */ export const noiseSection = (h: H, model: Model, form: RuleForm): Html => - slot(noise, [h, model.mountedForm, form]) + slot(noise, [h, model.mountedForm, model.savedForm, form, invalidFieldsOf(model, form)]) diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index e46c68bfce..a6a8c6ee2f 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -1,9 +1,9 @@ import { ALERT_TEMPLATE_VARIABLES, type AlertDestinationId, type EscalationChainId } from "@maple/domain/http" import { comparatorSupportsTiers } from "@maple/web-data/alerts/form-utils" -import { Option, Struct, Array as Arr } from "effect" +import { Option, Struct, Array as Arr, String as Str } from "effect" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" -import { card } from "@maple/views/ui/card" +import { triggerId } from "@maple/views/ui/overlay/popup-state" import { badge } from "@maple/views/ui/display" import { field, fieldLabel, input } from "@maple/views/ui/field" import { icon } from "@maple/views/ui/icon" @@ -12,7 +12,7 @@ import { statusDot } from "@maple/views/ui/status-dot" import { pressable } from "@maple/views/ui/pressable" import { textarea } from "@maple/views/ui/textarea" import { stableSlice } from "../../kit/stable-slice" -import { destinationsOf } from "./derive-rows" +import { destinationsOf, invalidFieldsOf } from "./derive-rows" import type { RuleForm } from "./form" import { Message } from "./message" import type { Model } from "./model" @@ -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" @@ -103,11 +103,13 @@ const chainPicker = ( label: string, /** Each tier needs its own route; an anomaly rule only needs someone on either tier. */ isRouteRequired: boolean, + /** The picker shows its error: the user left it unrouted, or tried to save. */ + isRevealed: boolean, ): Html => { const id = chainSelectIdOf(tier) const ids = idsOf(id, false) const value = chainId ?? NO_CHAIN - const isUnrouted = isRouteRequired && chainId === null && destinationIds.length === 0 + const isUnrouted = isRevealed && isRouteRequired && chainId === null && destinationIds.length === 0 const state = { ...fieldStateOf(value, undefined), isInvalid: isUnrouted } const destinations = destinationsOf(model) const nameOf = (destinationId: AlertDestinationId) => @@ -147,17 +149,17 @@ const chainPicker = ( ], }) return formSelect(h, model.selects[id], id, value, chainOptions(model, chainId), { - className: "h-9 w-full", + className: "w-full sm:w-80", labelledBy: ids.label, ...(isUnrouted ? { errorId: ids.error } : undefined), toView: (parts) => field(h, { className: "items-stretch gap-1.5", state }, [ - hstack(h, { gap: "2", justify: "between" }, [ + hstack(h, { gap: "3", wrap: true }, [ fieldLabel(h, { ids, state, className: "text-xs" }, label), textLink( h, { - href: routingTabHref(), + href: newChainHref(), tone: "muted", className: "text-xs", attributes: [h.Target("_blank"), h.Rel("noopener")], @@ -180,9 +182,11 @@ const tierRouting = ( chainId: EscalationChainId | null, tiered: boolean, isRouteRequired: boolean, + isRevealed: boolean, + emptyText: string, ): Html => vstack(h, { gap: "3" }, [ - destinationList(h, model, tier, destinationIds, destinationsOf(model)), + destinationList(h, model, tier, destinationIds, destinationsOf(model), emptyText), chainPicker( h, model, @@ -192,6 +196,7 @@ const tierRouting = ( // Two tiers render two pickers, so each needs its own accessible name. tiered ? `${tier === "warning" ? "Warning" : "Critical"} escalation chain` : "Escalation chain", isRouteRequired, + isRevealed, ), ]) @@ -204,7 +209,7 @@ const messageTemplate = (h: H, model: NotificationsView, form: NotificationsForm const titleIds = idsOf("notification-title", false) const bodyIds = idsOf("notification-body", false) return h.div( - [h.Class("mt-3")], + [], [ pressable( h, @@ -279,7 +284,44 @@ const messageTemplate = (h: H, model: NotificationsView, form: NotificationsForm ) } -const notifications = (h: H, model: NotificationsView, form: NotificationsForm): Html => { +/** A tier's chain by name, for the empty destination list: "the Checkout on-call chain". */ +const chainPhrase = (model: NotificationsView, chainId: EscalationChainId | null): string | null => + chainId === null + ? null + : Option.match( + Arr.findFirst(model.escalationChains ?? [], (chain) => chain.id === chainId), + { onNone: () => "its escalation chain", onSome: (chain) => `the ${chain.name} chain` }, + ) + +/** What a tier with no destinations of its own still reaches, so the list never contradicts the chain. */ +const emptyTextOf = ( + model: NotificationsView, + form: NotificationsForm, + escalation: NotificationsForm["escalation"], + tier: Tier, +): string => { + if (tier === "warning" || escalation === null) { + const chain = chainPhrase(model, form.escalationChainId) + return chain === null + ? "No one is notified yet" + : `No destinations. Notifications go through ${chain}.` + } + const chain = chainPhrase(model, escalation.escalationChainId) + if (form.destinationIds.length > 0) + return `No extra destinations. Critical also goes to the warning destinations${chain === null ? "." : ` and ${chain}.`}` + return chain === null + ? "No one is notified yet" + : `No destinations of its own. Critical goes through ${chain}.` +} + +const notifications = ( + h: H, + model: NotificationsView, + form: NotificationsForm, + revealedIds: string, +): Html => { + const revealed = new Set(Str.split(revealedIds, ",")) + const isRevealed = (tier: Tier) => revealed.has(triggerId(chainSelectIdOf(tier))) const destinations = destinationsOf(model) const escalation = comparatorSupportsTiers(form.comparator) ? form.escalation : null const isRouteRequired = form.signalType !== "anomaly" @@ -287,91 +329,92 @@ const notifications = (h: H, model: NotificationsView, form: NotificationsForm): destinations, (d) => form.destinationIds.includes(d.id) || (escalation?.destinationIds.includes(d.id) ?? false), ) - return card( - h, - [ - ...(isAnySelected - ? [ - // TODO(alert-editor): the "Send a real test notification…" tooltip. - hstack(h, { align: "stretch", justify: "end", className: "-mt-1" }, [ - button( - h, - { - variant: "ghost", - size: "sm", - isLoading: model.isSendingTest, - className: "-my-1 px-2 text-xs", - attributes: [h.OnClick(Message.ClickedSendTest())], - }, - [icon(h, "PaperPlaneIcon", { size: 12 }), "Send test"], - ), - ]), - ] - : []), - ...(escalation === null - ? [ - tierRouting( + return vstack(h, { gap: "4" }, [ + ...(isAnySelected + ? [ + // TODO(alert-editor): the "Send a real test notification…" tooltip. + hstack(h, { align: "stretch", justify: "end" }, [ + button( h, - model, - "warning", - form.destinationIds, - form.escalationChainId, - false, - isRouteRequired, - ), - ] - : [ - h.div( - [h.Class("mt-1 space-y-4")], - [ - ...(form.signalType === "anomaly" - ? [ - typography(h, { size: "xs", tone: "muted", as: "p" }, [ - "Maple rates each anomaly warning or critical by how far it strays from its baseline.", - ]), - ] - : []), - h.div( - [h.Class("space-y-1.5")], - [ - tierHeading(h, "warning", "Warning"), - tierRouting( - h, - model, - "warning", - form.destinationIds, - form.escalationChainId, - true, - isRouteRequired, - ), - ], - ), - h.div( - [h.Class("space-y-1.5")], - [ - tierHeading( - h, - "critical", - "Critical, on top of the warning destinations", - ), - tierRouting( - h, - model, - "critical", - escalation.destinationIds, - escalation.escalationChainId, - true, - isRouteRequired, - ), - ], - ), - ], + { + variant: "outline", + size: "sm", + isLoading: model.isSendingTest, + attributes: [h.OnClick(Message.ClickedSendTest())], + }, + [icon(h, "PaperPlaneIcon", { size: 12 }), "Send test"], ), ]), - messageTemplate(h, model, form), - ], - "p-4", - ) + ] + : []), + ...(escalation === null + ? [ + tierRouting( + h, + model, + "warning", + form.destinationIds, + form.escalationChainId, + false, + isRouteRequired, + isRevealed("warning"), + emptyTextOf(model, form, escalation, "warning"), + ), + ] + : [ + h.div( + [h.Class("space-y-4")], + [ + ...(form.signalType === "anomaly" + ? [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + "Maple rates each anomaly warning or critical by how far it strays from its baseline.", + ]), + ] + : []), + h.div( + [h.Class("space-y-1.5")], + [ + tierHeading(h, "warning", "Warning"), + tierRouting( + h, + model, + "warning", + form.destinationIds, + form.escalationChainId, + true, + isRouteRequired, + isRevealed("warning"), + emptyTextOf(model, form, escalation, "warning"), + ), + ], + ), + h.div( + [h.Class("space-y-1.5")], + [ + tierHeading( + h, + "critical", + "Critical, on top of the warning destinations", + ), + tierRouting( + h, + model, + "critical", + escalation.destinationIds, + escalation.escalationChainId, + true, + isRouteRequired, + isRevealed("critical"), + emptyTextOf(model, form, escalation, "critical"), + ), + ], + ), + ], + ), + ]), + messageTemplate(h, model, form), + ]) } /** "Insert a variable": one button per template variable. TODO(alert-editor): their description tooltips. */ @@ -433,4 +476,4 @@ const formOf = stableSlice((form: RuleForm) => /** "Who gets notified": one list per tier, Send test while anything is selected, and the template. */ export const notificationsSection = (h: H, model: Model, form: RuleForm): Html => - slot(notifications, [h, viewOf(model), formOf(form)]) + slot(notifications, [h, viewOf(model), formOf(form), invalidFieldsOf(model, form)]) diff --git a/apps/web/src/page/alerts/editor/view-notify.ts b/apps/web/src/page/alerts/editor/view-notify.ts index 317c25a782..d32dc207f7 100644 --- a/apps/web/src/page/alerts/editor/view-notify.ts +++ b/apps/web/src/page/alerts/editor/view-notify.ts @@ -264,12 +264,14 @@ export const destinationList = ( tier: Tier, ids: ReadonlyArray, destinations: ReadonlyArray, + /** What the empty list says: who the tier still reaches (a chain, the warning destinations). */ + emptyText: string, ): Html => { const selected = Arr.filter(destinations, (d) => ids.includes(d.id)) const available = Arr.filter(destinations, (d) => !ids.includes(d.id)) const unresolved = Arr.filter(ids, (id) => !Arr.some(destinations, (d) => d.id === id)) const canAdd = available.length > 0 || canManage(model) - return panel(h, { className: "mt-3 divide-y divide-border-subtle border-border-subtle" }, [ + return panel(h, { className: "divide-y divide-border-subtle border-border-subtle" }, [ ...Arr.map(selected, (destination) => selectedRow(h, model, tier, destination)), ...Arr.map(unresolved, (id) => unresolvedRow(h, tier, id)), ...(selected.length === 0 && unresolved.length === 0 @@ -278,7 +280,7 @@ export const destinationList = ( [h.Class("px-3 py-2.5 text-sm text-muted-foreground")], [ canAdd - ? "No one is notified yet." + ? emptyText : "No destinations exist yet, and only an org admin can add one.", ], ), diff --git a/apps/web/src/page/alerts/editor/view-query.ts b/apps/web/src/page/alerts/editor/view-query.ts index aaa55da61a..18b0f65cdc 100644 --- a/apps/web/src/page/alerts/editor/view-query.ts +++ b/apps/web/src/page/alerts/editor/view-query.ts @@ -5,7 +5,7 @@ import { signalTypeToKind } from "@maple/web-data/alerts/rule-modes" import { cn } from "@maple/ui/lib/utils" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" -import { card } from "@maple/views/ui/card" +import { panel } from "@maple/views/ui/panel" import * as Combobox from "@maple/views/ui/combobox" import { chipsView } from "@maple/views/ui/combobox-view" import { icon, type IconName } from "@maple/views/ui/icon" @@ -24,6 +24,9 @@ import { Message, type SignalKind } from "./message" import type { Model } from "./model" import { inputGroup, inputGroupAddon, inputGroupInput, inputGroupText } from "@maple/views/ui/input-group" import { rawQueryEditor, REDUCER_SELECT_ID, type SqlView } from "./view-sql" +import type * as Select from "@maple/views/ui/select" +import { KIND_SELECT_ID } from "./form-steps" +import { formSelect } from "./view-select" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { Array as Arr } from "effect" @@ -48,6 +51,7 @@ type QueryView = SqlView & > & { /** Editing the org's default anomaly rule: its kind and scope are fixed. */ readonly isDefaultAnomaly: boolean + readonly kindSelect: Select.Model | undefined } const KIND_OPTIONS: ReadonlyArray<{ value: SignalKind; label: string }> = [ @@ -213,124 +217,119 @@ const inputOf = Combobox.inputId const builtinForm = (h: H, model: QueryView, form: RuleForm): Html => { const hasServices = form.serviceNames.length > 0 const facets = traceFacetsOf(model) - return card( - h, - [ - signalRadios(h, form), - h.div( - [h.Class("space-y-2 p-3")], - [ - builtinRow( - h, - "Services", - inputOf("rule-services"), - undefined, - chipsPicker(h, model, "rule-services", form.serviceNames, { - items: facets.services, - placeholder: hasServices ? "Add service…" : "All services", - emptyText: "No services match", - }), - ), - builtinRow( - h, - "Environment", - inputOf("rule-environments"), - undefined, - chipsPicker(h, model, "rule-environments", form.environments, { - items: facets.environments, - placeholder: - form.environments.length === 0 ? "All environments" : "Add environment…", - emptyText: "No environments match", - }), - ), - builtinRow( - h, - "Split by", - inputOf("rule-group-by"), - hasServices ? "Each selected service is already its own group." : undefined, - chipsPicker(h, model, "rule-group-by", form.groupBy, { - items: groupByItems(model), - placeholder: form.groupBy.length === 0 ? "service.name" : "", - emptyText: "No fields match", - className: typographyClass({ size: "xs", mono: true }), - wrapperClassName: "w-full", - isDisabled: hasServices, - }), - ), - builtinRow( - h, - "Exclude", - inputOf("rule-exclude"), - undefined, - chipsPicker(h, model, "rule-exclude", form.excludeServiceNames, { - items: facets.services, - placeholder: hasServices ? "Only applies to all services" : "Skip these services", - emptyText: "No services match", - isDisabled: hasServices, - }), - ), - ...(form.signalType === "apdex" - ? [ - builtinRow( - h, - "Apdex target", - "apdex-threshold", - undefined, - inputGroup(h, { className: "w-40" }, [ - inputGroupInput(h, { - id: "apdex-threshold", - type: "number", - controlClassName: typographyClass({ mono: true, numeric: true }), - attributes: [ - h.InputMode("numeric"), - h.Value(form.apdexThresholdMs), - h.OnInput((value) => - Message.ChangedField({ - field: "apdexThresholdMs", - value, - }), - ), - ], - }), - inputGroupAddon(h, { align: "inline-end" }, [ - inputGroupText(h, {}, ["ms"]), - ]), + return panel(h, {}, [ + signalRadios(h, form), + h.div( + [h.Class("space-y-2 p-3")], + [ + builtinRow( + h, + "Services", + inputOf("rule-services"), + undefined, + chipsPicker(h, model, "rule-services", form.serviceNames, { + items: facets.services, + placeholder: hasServices ? "Add service…" : "All services", + emptyText: "No services match", + }), + ), + builtinRow( + h, + "Environment", + inputOf("rule-environments"), + undefined, + chipsPicker(h, model, "rule-environments", form.environments, { + items: facets.environments, + placeholder: form.environments.length === 0 ? "All environments" : "Add environment…", + emptyText: "No environments match", + }), + ), + builtinRow( + h, + "Split by", + inputOf("rule-group-by"), + hasServices ? "Each selected service is already its own group." : undefined, + chipsPicker(h, model, "rule-group-by", form.groupBy, { + items: groupByItems(model), + placeholder: form.groupBy.length === 0 ? "service.name" : "", + emptyText: "No fields match", + className: typographyClass({ size: "xs", mono: true }), + wrapperClassName: "w-full", + isDisabled: hasServices, + }), + ), + builtinRow( + h, + "Exclude", + inputOf("rule-exclude"), + undefined, + chipsPicker(h, model, "rule-exclude", form.excludeServiceNames, { + items: facets.services, + placeholder: hasServices ? "Only applies to all services" : "Skip these services", + emptyText: "No services match", + isDisabled: hasServices, + }), + ), + ...(form.signalType === "apdex" + ? [ + builtinRow( + h, + "Apdex target", + "apdex-threshold", + undefined, + inputGroup(h, { className: "w-40" }, [ + inputGroupInput(h, { + id: "apdex-threshold", + type: "number", + controlClassName: typographyClass({ mono: true, numeric: true }), + attributes: [ + h.InputMode("numeric"), + h.Value(form.apdexThresholdMs), + h.OnInput((value) => + Message.ChangedField({ + field: "apdexThresholdMs", + value, + }), + ), + ], + }), + inputGroupAddon(h, { align: "inline-end" }, [ + inputGroupText(h, {}, ["ms"]), ]), - ), - ] - : []), - ], - ), - hstack( - h, - { - gap: "2", - align: "center", - justify: "between", - wrap: true, - className: "border-t border-dashed px-3 py-2 text-xs text-muted-foreground", - }, - [ - h.span( - [], - [ - "Measured on root spans. A service that records failures on child spans and returns success from its entry point stays healthy here.", - ], - ), - button( - h, - { - variant: "link", - size: "xs", - attributes: [h.OnClick(Message.ClickedOpenInBuilder())], - }, - ["Open in query builder"], - ), - ], - ), - ], - "gap-0 overflow-hidden p-0", - ) + ]), + ), + ] + : []), + ], + ), + hstack( + h, + { + gap: "2", + align: "center", + justify: "between", + wrap: true, + className: "border-t border-dashed px-3 py-2 text-xs text-muted-foreground", + }, + [ + h.span( + [], + [ + "Measured on root spans. A service that records failures on child spans and returns success from its entry point stays healthy here.", + ], + ), + button( + h, + { + variant: "link", + size: "xs", + attributes: [h.OnClick(Message.ClickedOpenInBuilder())], + }, + ["Open in query builder"], + ), + ], + ), + ]) } /** An anomaly rule's scope: services, environments and detector signals, each empty for all. */ @@ -339,92 +338,87 @@ const anomalyForm = (h: H, model: QueryView, form: RuleForm): Html => { const isLocked = model.isDefaultAnomaly const isUnscoped = form.serviceNames.length === 0 && form.environments.length === 0 && form.anomalySignals.length === 0 - return card( - h, - [ - h.div( - [h.Class("space-y-1 border-b px-3 py-2.5")], - [ - typography(h, { size: "sm", tone: "foreground", as: "p" }, [ANOMALY_DESCRIPTION]), - ...(isLocked - ? [ - hstack( - h, - { - gap: "1.5", - align: "start", - className: typographyClass({ size: "xs", tone: "muted" }), - }, - [ - icon(h, "LockIcon", { size: 12, className: "mt-0.5 shrink-0" }), - h.span([], [DEFAULT_ANOMALY_NOTE]), - ], - ), - ] - : isUnscoped - ? [typography(h, { size: "xs", tone: "warning", as: "p" }, [SCOPE_NEEDED_NOTE])] - : []), - ], - ), - h.div( - [h.Class("space-y-2 p-3")], - [ - builtinRow( - h, - "Services", - inputOf("rule-services"), - "Empty covers every service.", - chipsPicker(h, model, "rule-services", form.serviceNames, { - items: facets.services, - placeholder: form.serviceNames.length > 0 ? "Add service…" : "All services", - emptyText: "No services match", - isDisabled: isLocked, - }), - ), - builtinRow( - h, - "Environment", - inputOf("rule-environments"), - "Empty covers every environment.", - chipsPicker(h, model, "rule-environments", form.environments, { - items: facets.environments, - placeholder: - form.environments.length === 0 ? "All environments" : "Add environment…", - emptyText: "No environments match", - isDisabled: isLocked, - }), - ), - builtinRow( - h, - "Signals", - ANOMALY_SIGNALS_ID, - form.anomalySignals.length === 0 - ? "None picked: every signal pages." - : "Only the picked signals page.", - h.div( - [h.Id(ANOMALY_SIGNALS_ID)], - [ - multiSegmentedSelect(h, { - options: Arr.map(ANOMALY_SIGNALS, (signal) => ({ - value: signal, - label: ANOMALY_SIGNAL_LABELS[signal], - ariaLabel: ANOMALY_SIGNAL_LABELS[signal], - })), - value: form.anomalySignals, - onToggle: (signal) => Message.ToggledAnomalySignal({ signal }), - size: "sm", - ariaLabel: "Anomaly signals", - isDisabled: isLocked, - className: "flex-wrap", - }), - ], - ), + return panel(h, {}, [ + h.div( + [h.Class("space-y-1 border-b px-3 py-2.5")], + [ + typography(h, { size: "sm", tone: "foreground", as: "p" }, [ANOMALY_DESCRIPTION]), + ...(isLocked + ? [ + hstack( + h, + { + gap: "1.5", + align: "start", + className: typographyClass({ size: "xs", tone: "muted" }), + }, + [ + icon(h, "LockIcon", { size: 12, className: "mt-0.5 shrink-0" }), + h.span([], [DEFAULT_ANOMALY_NOTE]), + ], + ), + ] + : isUnscoped + ? [typography(h, { size: "xs", tone: "warning", as: "p" }, [SCOPE_NEEDED_NOTE])] + : []), + ], + ), + h.div( + [h.Class("space-y-2 p-3")], + [ + builtinRow( + h, + "Services", + inputOf("rule-services"), + "Empty covers every service.", + chipsPicker(h, model, "rule-services", form.serviceNames, { + items: facets.services, + placeholder: form.serviceNames.length > 0 ? "Add service…" : "All services", + emptyText: "No services match", + isDisabled: isLocked, + }), + ), + builtinRow( + h, + "Environment", + inputOf("rule-environments"), + "Empty covers every environment.", + chipsPicker(h, model, "rule-environments", form.environments, { + items: facets.environments, + placeholder: form.environments.length === 0 ? "All environments" : "Add environment…", + emptyText: "No environments match", + isDisabled: isLocked, + }), + ), + builtinRow( + h, + "Signals", + ANOMALY_SIGNALS_ID, + form.anomalySignals.length === 0 + ? "None picked: every signal pages." + : "Only the picked signals page.", + h.div( + [h.Id(ANOMALY_SIGNALS_ID)], + [ + multiSegmentedSelect(h, { + options: Arr.map(ANOMALY_SIGNALS, (signal) => ({ + value: signal, + label: ANOMALY_SIGNAL_LABELS[signal], + ariaLabel: ANOMALY_SIGNAL_LABELS[signal], + })), + value: form.anomalySignals, + onToggle: (signal) => Message.ToggledAnomalySignal({ signal }), + size: "sm", + ariaLabel: "Anomaly signals", + isDisabled: isLocked, + className: "flex-wrap", + }), + ], ), - ], - ), - ], - "gap-0 overflow-hidden p-0", - ) + ), + ], + ), + ]) } const ANOMALY_SIGNALS_ID = "rule-anomaly-signals" @@ -460,23 +454,25 @@ const builderPanel = (h: H, model: QueryView, form: RuleForm): Html => const query = (h: H, model: QueryView, form: RuleForm, timing: AlertQueryTiming | null): Html => { const kind = signalTypeToKind(form.signalType) + const kinds = model.isDefaultAnomaly + ? Arr.map(KIND_OPTIONS, (option) => ({ ...option, isDisabled: option.value !== "anomaly" })) + : KIND_OPTIONS return h.div( [h.Class("space-y-2")], [ hstack(h, { gap: "3", wrap: true }, [ + // Four modes outgrow a phone's width: below `sm` the switch is a select. + formSelect(h, model.kindSelect, KIND_SELECT_ID, kind, kinds, { + className: "w-full", + ariaLabel: "Query mode", + toView: (parts) => h.div([h.Class("w-full sm:hidden")], parts), + }), segmentedSelect(h, { - options: model.isDefaultAnomaly - ? Arr.map(KIND_OPTIONS, (option) => ({ - ...option, - isDisabled: option.value !== "anomaly", - })) - : KIND_OPTIONS, + options: kinds, value: kind, onChange: (next) => Message.ClickedSignalKind({ kind: next }), ariaLabel: "Query mode", - size: "sm", - // Four modes outgrow a phone's width: the track wraps instead of clipping the last one. - className: "max-w-full flex-wrap", + className: "max-sm:hidden", }), typography(h, { size: "xs", tone: "muted", as: "p", className: "min-w-0 flex-1" }, [ model.kindNotice ?? KIND_HINT.get(kind) ?? "", @@ -505,6 +501,7 @@ const viewOf = stableSlice((model: Model): QueryView => ({ metricKeys: model.metricKeys, isSqlCollapsed: model.isSqlCollapsed, reducerSelect: model.selects[REDUCER_SELECT_ID], + kindSelect: model.selects[KIND_SELECT_ID], isDefaultAnomaly: isProtectedDefault(model), })) diff --git a/apps/web/src/page/alerts/editor/view-select.ts b/apps/web/src/page/alerts/editor/view-select.ts index b102b291ca..7379715001 100644 --- a/apps/web/src/page/alerts/editor/view-select.ts +++ b/apps/web/src/page/alerts/editor/view-select.ts @@ -19,6 +19,8 @@ export const formSelect = ( labelledBy?: string /** The error text the trigger is described by, and marked invalid with. */ errorId?: string + /** The trigger's text for the value, when it is shorter than the list's label. */ + renderValue?: (value: string) => ReadonlyArray toView: (parts: ReadonlyArray) => Html }>, ): Html => { @@ -35,6 +37,7 @@ export const formSelect = ( ariaLabel: inputs.ariaLabel, labelledBy: inputs.labelledBy, ...(inputs.errorId === undefined ? undefined : { describedBy: inputs.errorId, isInvalid: true }), + ...(inputs.renderValue === undefined ? undefined : { renderValue: inputs.renderValue }), toView: inputs.toView, }, }) diff --git a/apps/web/src/page/alerts/editor/view-step.ts b/apps/web/src/page/alerts/editor/view-step.ts index e93647e872..723913f84e 100644 --- a/apps/web/src/page/alerts/editor/view-step.ts +++ b/apps/web/src/page/alerts/editor/view-step.ts @@ -1,61 +1,4 @@ -import { cn } from "@maple/ui/lib/utils" -import type { Html, HtmlBuilder } from "foldkit/html" -import { heading } from "@maple/views/ui/heading" -import { typography } from "@maple/views/ui/typography" -import { hstack } from "@maple/views/ui/stack" - -// `RuleStep` and the editor's shared width. +// The editor's shared width. /** `RULE_FORM_MAX_WIDTH`: the form body and the sticky action bar line up on it. */ export const RULE_FORM_MAX_WIDTH = "max-w-[1400px]" - -export type RuleStepStatus = "done" | "missing" | "optional" - -export type RuleStepOptions = Readonly<{ - number: number - title: string - hint?: string - status: RuleStepStatus - /** A rule above the step; off for the first step and for steps sharing a row. */ - hasDivider?: boolean -}> - -/** One numbered step of the rule editor; says "Required" until it is filled in. */ -export const ruleStep = ( - h: HtmlBuilder, - options: RuleStepOptions, - children: ReadonlyArray, -): Html => { - const headingId = `rule-step-${options.number}` - return h.section( - [ - h.AriaLabelledBy(headingId), - h.Class(cn("space-y-4", (options.hasDivider ?? true) && "border-t pt-8")), - ], - [ - hstack(h, { gap: "4", align: "start", justify: "between" }, [ - h.div( - [h.Class("space-y-1")], - [ - hstack(h, { gap: "3", align: "baseline" }, [ - heading(h, { level: 2, variant: "title", id: headingId }, [ - h.span( - [h.Class("mr-2.5 font-mono text-sm font-normal text-muted-foreground")], - [String(options.number)], - ), - options.title, - ]), - ...(options.status === "missing" - ? [typography(h, { size: "xs", tone: "warning" }, ["Required"])] - : []), - ]), - ...(options.hint === undefined - ? [] - : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.hint])]), - ], - ), - ]), - ...children, - ], - ) -} diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index ebd1bae99e..3e92bdfa58 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -1,4 +1,3 @@ -import { deriveTierIssues, isFilledNumber } from "@maple/web-data/alerts/form-utils" import { cn } from "@maple/ui/lib/utils" import { Submodel } from "foldkit" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" @@ -16,7 +15,21 @@ import { RULE_CHART_TOKENS } from "../shared/rule-chart" import type { RuleForm } from "./form" import { Message } from "./message" import type { Model, Notice } from "./model" -import { isAnomalyUnrouted, isProtectedDefault } from "./derive" +import { destinationTarget, isProtectedDefault } from "./derive" +import type { AlertQueryTiming } from "@maple/domain/http" +import { destinationsOf, draftTimingOf } from "./derive-rows" +import type { RuleCard } from "./form" +import { + cardIssues, + conditionSummary, + nameSummary, + notifySummary, + type NameLookup, + type NotifyNames, + watchSummary, + isProblemIssue, +} from "./summary" +import { sentenceCard, sentenceChain } from "../../kit/sentence-card" import { actionBar } from "./view-action-bar" import { conditionFields } from "./view-condition" import { detailsSection } from "./view-details" @@ -24,9 +37,9 @@ import { liveChartHero } from "./view-hero" import { noiseSection } from "./view-noise" import { notificationsSection } from "./view-notifications" import { querySection } from "./view-query" -import { RULE_FORM_MAX_WIDTH, ruleStep } from "./view-step" +import { RULE_FORM_MAX_WIDTH } from "./view-step" import { templatesOverlay } from "./view-templates" -import { Array as Arr, String as Str } from "effect" +import { Array as Arr, Option } from "effect" // `AlertCreatePageContent` and `AlertCreateFormSurface`: the rule editor page and its form. @@ -123,85 +136,128 @@ const evaluatedFormOf = stableSlice((form: RuleForm): RuleForm => ({ notificationBody: "", })) -/** Who gets paged is missing: no destination or escalation chain on a tier that needs one. */ -const isNotifyMissingOf = (form: RuleForm, isDefault: boolean): boolean => - form.signalType === "anomaly" - ? !isDefault && isAnomalyUnrouted(form) - : (form.destinationIds.length === 0 && form.escalationChainId === null) || - deriveTierIssues(form).includes("Critical destinations") +/** How the Notify summary names destinations and chains, from the lists the editor holds. */ +const notifyNamesOf = (model: Model): NotifyNames => { + const destinations = destinationsOf(model) + // A list that has not answered (or failed, for chains) is loading, never "removed". + const lookup = ( + list: ReadonlyArray | null, + isMatch: (item: A) => boolean, + nameOf: (item: A) => string, + ): NameLookup => + list === null + ? { _tag: "Loading" } + : Option.match(Arr.findFirst(list, isMatch), { + onNone: (): NameLookup => ({ _tag: "Gone" }), + onSome: (item): NameLookup => ({ _tag: "Found", name: nameOf(item) }), + }) + return { + destination: (id) => + lookup( + model.destinations === null ? null : destinations, + (destination) => destination.id === id, + (destination) => destinationTarget(destination).primary, + ), + chain: (id) => + lookup( + model.isEscalationChainsFailed ? null : model.escalationChains, + (chain) => chain.id === id, + (chain) => chain.name, + ), + } +} + +// A card of short fields stops near 768px, so nothing stretches across the page. +const SHORT_FIELDS = "max-w-3xl" + +type CardSpec = Readonly<{ + card: RuleCard + lead: string + hint?: string | undefined + summary: string + /** The open card's body: a width cap for a card of short fields. */ + bodyClassName?: string | undefined + body: () => ReadonlyArray +}> + +const ruleCard = ( + h: H, + model: Model, + form: RuleForm, + timing: AlertQueryTiming | null, + isDefault: boolean, + spec: CardSpec, +): Html => { + const issues = cardIssues(form, spec.card, timing, isDefault) + return sentenceCard( + h, + { + id: spec.card, + lead: spec.lead, + hint: spec.hint, + status: issues.length > 0 ? "missing" : "done", + issues: Arr.filter(issues, (issue) => !isProblemIssue(issue)), + problems: Arr.filter(issues, isProblemIssue), + summary: spec.summary, + isExpanded: model.expandedCards.includes(spec.card), + onExpand: Message.ClickedExpandCard({ card: spec.card }), + onCollapse: Message.ClickedCollapseCard({ card: spec.card }), + ...(spec.bodyClassName === undefined ? undefined : { bodyClassName: spec.bodyClassName }), + }, + spec.body, + ) +} const surface = (h: H, model: Model, form: RuleForm, timeZone: string): ReadonlyArray => { const evaluated = evaluatedFormOf(form) - // An anomaly rule has no condition to tune: the detector decides, so "When to alert" goes. + // An anomaly rule has no condition to tune: the detector decides, so "Alert when" and the chart go. const isAnomaly = form.signalType === "anomaly" - const stepAfterQuery = isAnomaly ? 2 : 3 + const isDefault = isProtectedDefault(model) + const timing = draftTimingOf(model, form) + const condition: CardSpec = { + card: "condition", + lead: "Alert when", + hint: "The dashed line on the chart is your threshold.", + summary: conditionSummary(form), + bodyClassName: SHORT_FIELDS, + body: () => [conditionFields(h, model, evaluated), noiseSection(h, model, evaluated)], + } + const cards: ReadonlyArray = [ + { + card: "watch", + lead: "When", + hint: isAnomaly + ? "Which anomalies this rule pages on." + : "A built-in signal, or a query built like a dashboard widget.", + summary: watchSummary(form, isDefault), + body: () => [querySection(h, model, evaluated)], + }, + ...(isAnomaly ? [] : [condition]), + { + card: "notify", + lead: "Notify", + summary: notifySummary(form, notifyNamesOf(model)), + bodyClassName: SHORT_FIELDS, + body: () => [notificationsSection(h, model, form)], + }, + { + card: "name", + lead: "Name", + summary: nameSummary(form), + bodyClassName: SHORT_FIELDS, + body: () => [detailsSection(h, model, form)], + }, + ] return [ h.div( - [h.Class(cn("mx-auto w-full space-y-10", RULE_FORM_MAX_WIDTH))], + [h.Class(cn("mx-auto w-full space-y-6", RULE_FORM_MAX_WIDTH))], [ ...noticeBanner(h, model.notices), - ruleStep( + ...(isAnomaly ? [] : [liveChartHero(h, model, form, evaluated, timeZone)]), + sentenceChain( h, - { - number: 1, - hasDivider: false, - title: "What to watch", - hint: isAnomaly - ? "Pick which anomalies this rule pages on." - : "Pick a built-in signal, or build the query like a dashboard widget.", - status: "done", - }, - [querySection(h, model, evaluated)], - ), - ...(isAnomaly - ? [] - : [ - ruleStep( - h, - { - number: 2, - title: "When to alert", - hint: "The dashed line is your threshold. Shaded bands show when the rule would have fired.", - status: isFilledNumber(form.threshold) ? "done" : "missing", - }, - [ - h.div( - [h.Class("space-y-4")], - [ - liveChartHero(h, model, form, evaluated, timeZone), - conditionFields(h, model, evaluated), - noiseSection(h, model, evaluated), - ], - ), - ], - ), - ]), - h.div( - [h.Class("grid grid-cols-1 gap-6 border-t pt-8 lg:grid-cols-2 lg:items-start")], - [ - ruleStep( - h, - { - number: stepAfterQuery, - hasDivider: false, - title: "Who gets notified", - status: isNotifyMissingOf(form, isProtectedDefault(model)) - ? "missing" - : "done", - }, - [notificationsSection(h, model, form)], - ), - ruleStep( - h, - { - number: stepAfterQuery + 1, - hasDivider: false, - title: "Name it", - status: Str.trim(form.name).length === 0 ? "missing" : "done", - }, - [detailsSection(h, model, form)], - ), - ], + { label: "Rule" }, + Arr.map(cards, (spec) => ruleCard(h, model, form, timing, isDefault, spec)), ), ], ), @@ -247,6 +303,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/escalation-chain/command.ts b/apps/web/src/page/alerts/escalation-chain/command.ts new file mode 100644 index 0000000000..fc0fa0b217 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/command.ts @@ -0,0 +1,74 @@ +import { EscalationChainId } from "@maple/domain/http" +import { V2EscalationChainCreateParams } from "@maple/domain/http/v2" +import { Effect, Schema } from "effect" +import { Command, Dom } 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 })), + ), +}) + +/** 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 new file mode 100644 index 0000000000..99fb456d4c --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -0,0 +1,479 @@ +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 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, 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, insertButtonId, isBlockingNavigation, menuIdOf, removeTargetButtonId, update } from "./update" +import { chainTitle, 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 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", + 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 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 = (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 + ? model + : run(model, [ + Message.CompletedFetchExisting({ + 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", () => { + 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(), + // 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("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 back to Routing", () => { + 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(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(keys(middle)).toEqual(["s0", "s2", "s1"]) + const reordered = run(middle, [act("s1", "up"), act("s1", "up"), act("s2", "remove")]) + 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] + 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/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("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 }, + 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(), + ) + 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"), + ) + // 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", () => { + 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(), + expectView(within(role("alertdialog"), role("button", { name: "Delete chain" }))).toBeDisabled(), + ) + }) + + 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(), + ) + }) + + test("an unused chain deletes and goes back to Routing", () => { + const unused = editing(3) + 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" }) + // 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 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(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(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 on step 1" })).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(), + ) + }) + + 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/form.test.ts b/apps/web/src/page/alerts/escalation-chain/form.test.ts new file mode 100644 index 0000000000..c30f3098dc --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/form.test.ts @@ -0,0 +1,135 @@ +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 } 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, + isSameChain, + 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("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 new file mode 100644 index 0000000000..d5143a08cd --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/form.ts @@ -0,0 +1,299 @@ +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 { 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. + +/** 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, +}) + +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) +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..6ae2fa11fc --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/index.ts @@ -0,0 +1,28 @@ +import { definePage } from "../../contract" +import { routingTabHref } from "../shared/escalation-chains" +import { Message } from "./message" +import { Model } from "./model" +import { subscriptions } from "./subscription" +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. */ +export const page = definePage( + "AlertsEscalationChain", + { Model, Message }, + { + routes: ["AlertsEscalationChainNew", "AlertsEscalationChain"], + key: (route) => (route._tag === "AlertsEscalationChain" ? route.chainId : "new"), + init, + update, + view, + subscriptions, + isBlockingNavigation, + blockedNavigation: (model, url) => askToLeave(model, url), + 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..61cc0d7c0a --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/message.ts @@ -0,0 +1,46 @@ +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" + +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 }, + /** 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) }, + 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 new file mode 100644 index 0000000000..b805a30ab6 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/model.ts @@ -0,0 +1,53 @@ +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 { 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({ + /** 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, + /** 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)), + /** `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, + /** 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, + /** 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), + /** "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, +}) +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..2f2ca2143a --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/update.ts @@ -0,0 +1,420 @@ +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, + CHAIN_NOT_FOUND_CODE, + chainIdFromUrl, + routingTabHref, +} from "../shared/escalation-chains" +import { + DeleteChain, + FetchChainEditorMembers, + FetchChainEditorSchedules, + FetchChainEditorSession, + FetchExistingChain, + FocusChainElement, + SaveChain, +} from "./command" +import { + addTarget, + duplicateStep, + editStep, + emptyForm, + type Form, + formFromChain, + insertStep, + isSameChain, + moveStep, + payloadOf, + removeStep, + removeTarget, + targetKey, + targetOfKey, +} from "./form" +import { Message, type MenuKind } from "./message" +import { canManage, 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 LEAVE_DIALOG_ID = "escalation-chain-leave" + +export const menuIdOf = (kind: MenuKind, key: string): string => `escalation-chain-${kind}-${key}` + +/** 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() + +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 = { + editingId, + isBadId: Option.isSome(rawId) && editingId === null, + existing: Keyed.idle(), + form: emptyForm, + baseline: emptyForm, + destinations: null, + rules: null, + members: null, + isMembersFailed: false, + schedules: null, + isSchedulesFailed: false, + brokenLogos: [], + menus: {}, + isSaving: false, + isShowingErrors: false, + saveFailure: null, + 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, + } + 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 })], + }), + }) +} + +/** 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 => + 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}`))), + } + +/** 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)), + ), + } +} + +/** 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": + case "down": + return { + model: { ...model, form: moveStep(model.form, key, value) }, + commands: focus(triggerId(menuIdOf("actions", key))), + } + case "duplicate": + return added(model, duplicateStep(model.form, key)) + case "remove": + return removed(model, key) + default: + return { model } + } +} + +/** 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(canEdit(model) ? picked : Option.none(), { + onNone: () => ({ model: next, 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 => + !canEdit(model) + ? { 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 }), + ), +}) + +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; 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) => + isFirst + ? { + ...model, + existing, + form: formFromChain(chain), + baseline: formFromChain(chain), + } + : { ...model, existing }, + onFailure: () => ({ ...model, existing }), + }), + } + }, + ClickedRetryExisting: () => refetch(model, context), + 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", and the picker says people could not be loaded. + CompletedFetchMembers: ({ result }) => + Result.match(result, { + onSuccess: (members) => ({ model: { ...model, members, isMembersFailed: false } }), + onFailure: () => ({ model: { ...model, isMembersFailed: true } }), + }), + CompletedFetchSchedules: ({ result }) => + Result.match(result, { + onSuccess: (schedules) => ({ model: { ...model, schedules, isSchedulesFailed: false } }), + onFailure: () => ({ model: { ...model, isSchedulesFailed: true } }), + }), + 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 }) => + canEdit(model) ? added(model, insertStep(model.form, index)) : { model }, + GotMenuMessage: ({ kind, key, message: child }) => stepMenu(model, kind, key, child), + ClickedRemoveTarget: ({ key, target }) => removedTarget(model, key, target), + ClickedSave: () => save(model), + CompletedSave: ({ result }) => + Result.match(result, { + // 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 }, + }), + }), + GotDeleteDialogMessage: ({ message: child }) => + model.isDeleting ? { model } : liftDelete(model, Dialog.update(model.deleteDialog, child)), + ClickedDelete: () => + model.editingId === null || !canManage(model) + ? { model } + : liftDelete({ ...model, deleteError: null }, Dialog.open(model.deleteDialog)), + ClickedConfirmDelete: () => + model.editingId === null || model.isDeleting || !canManage(model) + ? { model } + : { + model: { ...model, isDeleting: true, deleteError: null }, + commands: [DeleteChain({ chainId: model.editingId })], + }, + CompletedDelete: ({ result }) => + Result.match(result, { + 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 new file mode 100644 index 0000000000..f6f7365310 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -0,0 +1,759 @@ +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" +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 { errorState } from "@maple/views/ui/error-state" +import { fieldErrorText, type FieldIds, fieldLabel, input } from "@maple/views/ui/field" +import { icon } from "@maple/views/ui/icon" +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, 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 { 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" +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, + 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, + 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. + +type H = HtmlBuilder + +/** 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), + () => "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 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() + +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, +}) + +/** 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 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, + index: number, + 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) { + case "destination": { + const name = nameOr(model.destinations, target.destination_id, "Destination") + return { + key: targetKey(target), + label: name, + media: destinationMedia(h, model, target.destination_id), + removeLabel: `Stop notifying ${name} ${onStep}`, + removeId, + onRemove, + } + } + case "user": { + const name = personName(model, 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), + ), + }), + removeLabel: `Stop paging ${name} ${onStep}`, + removeId, + 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", { size: 14, className: "shrink-0 text-muted-foreground" }), + ...(detail === undefined ? undefined : { detail }), + removeLabel: `Stop paging ${name} ${onStep}`, + removeId, + onRemove, + } + } + } +} + +/** 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 = (h: H, 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 })), + }, + ] + } + const entries: ReadonlyArray = [ + ...(model.destinations === null + ? [loadingEntry("Loading destinations")] + : group( + "Destinations", + Arr.map(model.destinations, (row) => ({ + value: `destination:${row.id}`, + label: row.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 + ? [loadingEntry(model.isSchedulesFailed ? "Schedules could not be loaded" : "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 => { + 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 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 + const wait = waitError(step.waitMinutes) + const targetsMenuId = menuIdOf("targets", key) + const actionsMenuId = menuIdOf("actions", key) + const waitId = `escalation-chain-wait-${key}` + 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, index, 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 }), + }), + ] + : []), + ]), + ...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: isLocked(model), + 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" }, [waitTail(model, index)]), + ]), + ...errorText(h, shownWait), + ]) +} + +/** 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 }), + isPinned: index === count, + } + : null, + }) +} + +const startCard = (h: H): Html => + panel(h, { padded: "sm", tone: "muted" }, [ + hstack(h, { gap: "2" }, [ + icon(h, "BellIcon", { size: 16, className: "shrink-0 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 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" }, [ + 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, shownRepeat), + ]) +} + +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))), + ...(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 + +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) => personName(model, 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]), + ]) +} + +/** 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), { + 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]), + ]), + // A paused rule starts no chain until it is resumed. + ...(rule.enabled + ? [] + : [ + typography(h, { size: "xs", tone: "muted" }, [ + " · paused", + ]), + ]), + ], + ), + ), + ...(others > 0 + ? [ + h.li( + [], + [ + typography(h, { size: "sm", tone: "muted" }, [ + `and ${others} more`, + ]), + ], + ), + ] + : []), + ], + ), + ]), + ]), + ] + }, + }) + +/** 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, + { + 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"], + ), + ] + : []), + ] + 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 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: confirm.description, + children: [ + ...confirm.ruleList, + ...(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: confirm.isInUse, + onConfirm: Message.ClickedConfirmDelete(), + }) +} + +/** "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 => + 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 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 + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [ + model.editingId === null ? "Couldn't create chain" : "Couldn't save chain", + ]), + 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 + ? [ + typography(h, { size: "sm", tone: "destructive", as: "p", attributes: [h.Role("status")] }, [ + "Fix the highlighted fields to save the chain.", + ]), + ] + : []), +] + +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 = { + title: chainTitle(model), + description: + "Page step by step until someone acknowledges. Acknowledging pauses it, resolving ends it.", + } + 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 escalation chains", + back: { href: backHref(), label: "Back to routing" }, + }) + if (isEdit && AsyncData.isFailure(model.existing.data)) + return dashboardPage(h, header, [ + 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 && Option.isNone(AsyncData.getData(model.existing.data))) return loading + return dashboardPage(h, { ...header, actions: actions(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/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/command.ts b/apps/web/src/page/alerts/hub/command.ts index 785a833772..69b04f3dbc 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -17,6 +17,7 @@ import { readThrough } from "../../../data/read-cache" import { readGrants } from "../../../data/session-tenant" import { toLoadFailure } from "@maple/views/data/load-failure" import { Command } from "foldkit" +import { dismissSheetUrl, pushSheetUrl } from "../../kit/sheet-param" import { deleteDestination, listDeliveries, @@ -41,6 +42,29 @@ import { import { Message } from "./message" import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" +const sheetArgs = { href: Schema.String, id: Schema.String } + +/** A command must answer with a message: a sheet change answers with the clock, as a route change reads it. */ +const thenReadClock = (effect: Effect.Effect) => + effect.pipe( + Effect.andThen(Clock.currentTimeMillis), + Effect.map((nowMs) => Message.ReadClock({ nowMs })), + ) + +/** Opens a side panel: a history entry carrying `?sheet=`, marked as this tab's. */ +export const PushHubSheet = Command.define("PushAlertsHubSheet", { + args: sheetArgs, + messages: [Message.ReadClock], + execute: ({ href, id }) => thenReadClock(pushSheetUrl(href, id)), +}) + +/** Closes it: Back over the entry this tab pushed, else `?sheet=` replaced away. */ +export const DismissHubSheet = Command.define("DismissAlertsHubSheet", { + args: sheetArgs, + messages: [Message.ReadClock], + execute: ({ href, id }) => thenReadClock(dismissSheetUrl(href, id)), +}) + export const ReadHubClock = Command.define("ReadAlertsHubClock", { messages: [Message.ReadClock], execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), diff --git a/apps/web/src/page/alerts/hub/derive.test.ts b/apps/web/src/page/alerts/hub/derive.test.ts index 3ee3c8e8d2..b95e0d6e1d 100644 --- a/apps/web/src/page/alerts/hub/derive.test.ts +++ b/apps/web/src/page/alerts/hub/derive.test.ts @@ -2,7 +2,7 @@ import { OrgId } from "@maple/domain" import { Array as Arr, Option, Schema } from "effect" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { incidentRow, NOW, ORG, ruleRow } from "../shared/test-rows" +import { incidentRow, NOW, ORG, ruleRow, hubAt } from "../shared/test-rows" import { overviewOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" @@ -17,7 +17,7 @@ const loaded = (): Model => Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1), ruleRow(2)] }), Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), ], - init({ _tag: "Alerts", search: {} }, context).model, + init(hubAt(), context).model, (model, message) => update(model, message, context).model, ) diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 572950d044..669786b354 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -6,11 +6,12 @@ import { rowsToAlertIncidentDocuments, rowsToAlertRuleDocuments, } from "@maple/web-data/collections/alerts" -import { Option, Array as Arr, String as Str } from "effect" +import { Option, Order, Array as Arr, Record as Rec, String as Str } from "effect" import { AsyncData } from "foldkit" import { lastBy } from "../../kit/last-by" import type { InboxSignal, IncidentStatusFilter, RuleBucket } from "./message" import type { Model } from "./model" +import type { ScopeSuggestions } from "../silence-dialog/dialog" type OverviewKey = readonly [ Model["rules"], @@ -112,38 +113,32 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) -export type AlertsTab = - | "inbox" - | "rules" - | "uptime" - | "slos" - | "silences" - | "maintenance" - | "oncall" - | "routing" +const sortedNames = (names: ReadonlyArray): ReadonlyArray => + Arr.sort(Arr.dedupe(Arr.filter(names, Str.isNonEmpty)), Order.String) -/** Legacy `tab` values map onto the current tabs so old links keep resolving. */ -export const resolveTab = (tab: string | undefined): AlertsTab => { - switch (tab) { - case "rules": - return "rules" - case "uptime": - return "uptime" - case "slos": - return "slos" - case "silences": - return "silences" - case "maintenance": - return "maintenance" - case "oncall": - return "oncall" - case "routing": - case "settings": - return "routing" - default: - return "inbox" - } -} +const silenceSuggestionsFor = lastBy( + ([rules, namespaces]: readonly [ + Model["rules"], + Model["serviceNamespaces"]["data"], + ]): ScopeSuggestions => { + const documents = rowsToAlertRuleDocuments(rules ?? [], buildRuleStatesByRuleId([])) + return { + services: sortedNames([ + ...Arr.flatMap(documents, (rule) => rule.serviceNames), + ...Option.match(AsyncData.getData(namespaces), { onNone: () => [], onSome: Rec.keys }), + ]), + environments: sortedNames(Arr.flatMap(documents, (rule) => rule.environments)), + tags: sortedNames(Arr.flatMap(documents, (rule) => rule.tags)), + } + }, +) + +/** + * What the silence panel suggests, from what the hub already holds: the services, environments and + * tags its rules name, and the services the Uptime section listed if it was opened. + */ +export const silenceSuggestionsOf = (model: Model): ScopeSuggestions => + silenceSuggestionsFor([model.rules, model.serviceNamespaces.data]) /** `attention` is the legacy name of the blind bucket. */ export const resolveStatus = (status: string | undefined): RuleBucket | undefined => { diff --git a/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts b/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts index 9d6e02aedf..1329c7864b 100644 --- a/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts @@ -21,7 +21,17 @@ import { defaultAnomalyRuleRow, scopedAnomalyRuleRow, } from "../shared/anomaly-test-fixtures" -import { destinationRow, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from "../shared/test-rows" +import { + destinationRow, + incidentRow, + NOW, + ORG, + ruleId, + ruleRow, + stateRow, + hubAt, + type HubAt, +} from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, ruleMenuId, update } from "./update" @@ -57,7 +67,7 @@ const RESOLVED_CHECKOUT = anomalyIncidentRow({ }) const loaded = ( - search: Model["search"], + at: HubAt, incidents = [ incidentRow({ rule_name: "Payment p95 latency" }), OPEN_CHECKOUT, @@ -87,7 +97,7 @@ const loaded = ( Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), ], - init({ _tag: "Alerts", search }, context).model, + init(hubAt(at), context).model, (model, message) => pageUpdate(model, message).model, ) @@ -100,7 +110,7 @@ describe("alerts hub: anomalies", () => { click(role("button", { name: /^Anomalies/ })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: "/alerts?tab=inbox&signal=anomaly", + url: "/alerts?signal=anomaly", isReplace: false, }), ), @@ -245,19 +255,4 @@ describe("alerts hub: anomalies", () => { expectView(text("Search anomalies")).toExist(), ) }) - - test("leaving the Inbox drops the Anomalies view's filters from the tab links", () => { - scene( - { update: pageUpdate, view }, - given(loaded({ signal: "anomaly", anomalyStatus: "resolved", serviceName: "checkout" })), - expectView(role("link", { name: /^Rules/ })).toHaveAttr( - "href", - "/alerts?tab=rules&serviceName=checkout", - ), - expectView(role("link", { name: /^Inbox/ })).toHaveAttr( - "href", - "/alerts?tab=inbox&serviceName=checkout&signal=anomaly&anomalyStatus=resolved", - ), - ) - }) }) diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index 623064ac52..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 @@ -1,30 +1,17 @@ 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 * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" -import { - click, - Command, - expect as expectView, - Mount, - given, - role, - scene, - selector, - submit, - text, - withViewInputs, -} from "foldkit/scene" +import { 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" 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 +30,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( [ @@ -61,12 +48,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 +55,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 +75,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 +95,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,17 +105,55 @@ describe("alerts hub, escalation chains", () => { ) }) - test("saving a new chain without a name or destinations shows what is missing and sends nothing", () => { + 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("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(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(), + 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(), ) }) diff --git a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts index de1fbab3af..c02a9c6fb0 100644 --- a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts @@ -1,4 +1,5 @@ import * as Policy from "@maple/domain/policy" +import type { AlertsTab } from "../../../route/search" import { OrgId } from "@maple/domain" import type { V2AlertMaintenanceWindow, V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" @@ -26,7 +27,7 @@ import { weeklyWindow, } from "../shared/maintenance-test-fixtures" import { windowPublicId, windowSilencesHref } from "../shared/maintenance-windows" -import { NOW, ORG, ruleRow } from "../shared/test-rows" +import { NOW, ORG, ruleRow, hubAt, type HubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update, windowMenuId } from "./update" @@ -49,14 +50,14 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onTab = ( - search: Model["search"], + at: HubAt, options: Readonly<{ grants?: ReturnType windows: ReadonlyArray silences?: ReadonlyArray }>, ): Model => { - const entered = init({ _tag: "Alerts", search }, context) + const entered = init(hubAt(at), context) const loaded = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -86,7 +87,7 @@ const onMaintenance = (windows: ReadonlyArray, grants describe("alerts hub, Maintenance tab", () => { test("reads the windows only while the Maintenance or Silences tab is open", () => { - const fetched = (tab: string) => names(init({ _tag: "Alerts", search: { tab } }, context).commands) + const fetched = (tab: AlertsTab) => names(init(hubAt({ tab }), context).commands) expect(fetched("maintenance")).toContain("FetchAlertsHubMaintenanceWindows") expect(fetched("silences")).toContain("FetchAlertsHubMaintenanceWindows") expect(fetched("rules")).not.toContain("FetchAlertsHubMaintenanceWindows") @@ -129,7 +130,7 @@ describe("alerts hub, Maintenance tab", () => { test("a window change on the Maintenance tab makes the Silences tab read its silences again", () => { const model = onTab({ tab: "silences" }, { windows: [weeklyWindow], silences: silencesWithWindows }) const onMaint = update(model, Message.ReadClock({ nowMs: NOW }), context).model - const moved = routeChanged(onMaint, { _tag: "Alerts", search: { tab: "maintenance" } }, context).model + const moved = routeChanged(onMaint, hubAt({ tab: "maintenance" }), context).model const toggled = pageUpdate(moved, Message.ClickedToggleWindow({ windowId: weeklyWindow.id })).model const settled = pageUpdate( toggled, @@ -141,7 +142,7 @@ describe("alerts hub, Maintenance tab", () => { // Following the row's Occurrences link lands on the Silences tab filtered to the window. const back = routeChanged( settled, - { _tag: "Alerts", search: { tab: "silences", window: windowPublicId(weeklyWindow.id) } }, + hubAt({ tab: "silences", window: windowPublicId(weeklyWindow.id) }), context, ) expect(back.commands ?? []).toContainEqual( @@ -150,7 +151,7 @@ describe("alerts hub, Maintenance tab", () => { args: expect.objectContaining({ windowId: weeklyWindow.id }), }), ) - const unfiltered = routeChanged(settled, { _tag: "Alerts", search: { tab: "silences" } }, context) + const unfiltered = routeChanged(settled, hubAt({ tab: "silences" }), context) expect(names(unfiltered.commands)).toContain("FetchAlertsHubSilences") }) @@ -247,7 +248,7 @@ describe("alerts hub, Maintenance tab", () => { const step = pageUpdate(onMaintenance([weeklyWindow]), Message.ClickedNewWindow()) expect(step.model.windowDialog.dialog.isOpen).toBe(true) expect(names(step.commands)).toContain("ReadMaintenanceWindowZones") - const early = init({ _tag: "Alerts", search: { tab: "maintenance" } }, context).model + const early = init(hubAt({ tab: "maintenance" }), context).model expect(pageUpdate(early, Message.ClickedNewWindow()).model.windowDialog.dialog.isOpen).toBe(false) }) }) @@ -268,13 +269,13 @@ describe("alerts hub, Silences tab with maintenance windows", () => { click(role("button", { name: "Remove Window: Nightly database backup" })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: alertsHref({ tab: "silences" }), + url: alertsHref({}, "silences"), isReplace: false, }), ), ) // The route without the param reads every silence and drops the chip. - const cleared = routeChanged(model, { _tag: "Alerts", search: { tab: "silences" } }, context) + const cleared = routeChanged(model, hubAt({ tab: "silences" }), context) const fetch = Arr.findFirst(cleared.commands ?? [], ({ name }) => name === "FetchAlertsHubSilences") expect(fetch).toMatchObject({ value: { args: { windowId: null } } }) const shown = pageUpdate( diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts index 5376879745..685ec689e8 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,10 +1,13 @@ import * as Policy from "@maple/domain/policy" +import type { AlertsTab } from "../../../route/search" import { OrgId } from "@maple/domain" import { EscalationChainId, OnCallScheduleId } from "@maple/domain/http" import type { V2OnCallSchedule } from "@maple/domain/http/v2" -import { Result, Schema, Array as Arr } from "effect" +import { Result, Schema, Array as Arr, String as Str } from "effect" 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 { PageContext } from "../../contract" import { chainDestinations, chainUuid } from "../shared/escalation-test-fixtures" @@ -16,7 +19,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 +37,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 +75,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 +85,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 +100,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 +125,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 +172,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 +199,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", () => { @@ -219,8 +222,34 @@ describe("alerts hub, on-call schedules", () => { ) }) + test("the delete confirm is named for its schedule, a long unbroken name in full", () => { + const name = `checkout-primary-${Str.repeat(120)("x")}` + const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) + const asked = pageUpdate( + onTab( + "on-call", + ENGINEER, + Arr.map(SCHEDULES, (schedule) => + schedule.id === scheduleId ? { ...schedule, name } : schedule, + ), + ), + Message.GotRowMenuMessage({ + menuId: scheduleMenuId(scheduleId), + message: Menu.Message.ClickedItem({ index: 1, item: { kind: "Item", value: "delete" } }), + }), + ).model + expect(asked.confirmDeleteScheduleId).toBe(scheduleId) + scene( + { update: pageUpdate, view }, + given(asked), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("alertdialog", { name: `Delete ${name}?` })).toExist(), + expectView(within(role("alertdialog"), role("heading", { name: `Delete ${name}?` }))).toExist(), + ) + }) + 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 +292,11 @@ describe("alerts hub, on-call schedules", () => { }) test("members are read only by the tabs that name people, once", () => { - const rules = init({ _tag: "Alerts", search: { tab: "rules" } }, context) + const rules = init(hubAt({ tab: "rules" }), context) expect(names(rules.commands)).not.toContain("FetchAlertsHubMembers") - const oncall = routeChanged(rules.model, { _tag: "Alerts", search: { tab: "oncall" } }, context) + const oncall = routeChanged(rules.model, hubAt({ tab: "on-call" }), context) expect(names(oncall.commands)).toContain("FetchAlertsHubMembers") - const routing = routeChanged(oncall.model, { _tag: "Alerts", search: { tab: "routing" } }, context) + const routing = routeChanged(oncall.model, hubAt({ tab: "routing" }), context) expect(names(routing.commands)).not.toContain("FetchAlertsHubMembers") }) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 8e741f9fae..01243dfb4b 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -3,27 +3,29 @@ import { OrgId } from "@maple/domain" import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import { AlertMaintenanceWindowPublicId, type V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" -import * as Animation from "@foldkit/ui/animation" +import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" import { - click, - Command, expect as expectView, Mount, given, role, scene, + selector, + submit, text, withViewInputs, } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { NOW, ORG } from "../shared/test-rows" +import { NOW, ORG, hubAt, ruleRow } from "../shared/test-rows" +import { silenceSuggestionsOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" -import { init, update } from "./update" +import { init, routeChanged, update } from "./update" import { view as pageView } from "./view" +import * as SilenceDialog from "../silence-dialog/dialog" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -57,7 +59,7 @@ const silence = ( } const onSilences = (grants: ReturnType, silences: ReadonlyArray) => { - const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context) + const entered = init(hubAt({ tab: "silences" }), context) expect(Arr.map(entered.commands ?? [], ({ name }) => name)).toContain("FetchAlertsHubSilences") return Arr.reduce( [ @@ -165,7 +167,7 @@ describe("alerts hub, Silences tab", () => { }) test("reads the clock again when the list lands, so a new silence is placed by it", () => { - const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context).model + const entered = init(hubAt({ tab: "silences" }), context).model const step = pageUpdate( entered, Message.CompletedFetchSilences({ key: entered.silences.key ?? "", result: Result.succeed([]) }), @@ -195,24 +197,338 @@ describe("alerts hub, Silences tab", () => { expectView(text(/· 1 rule$/u)).toExist(), ) }) +}) + +const SILENCES_URL = "/alerts/silences" +const callsOf = (commands: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> | undefined) => + Arr.map(commands ?? [], ({ name, args }) => [name, args]) +const namesOf = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const silenceMessage = (message: SilenceDialog.Message) => Message.GotSilenceDialogMessage({ message }) +const scopeCombo = (field: SilenceDialog.ScopeField, message: Combobox.Message) => + silenceMessage(SilenceDialog.Message.GotScopeComboMessage({ field, message })) + +/** The Silences tab with `?sheet=silence` followed: the panel is open. */ +const withPanel = (grants = ENGINEER) => + routeChanged(onSilences(grants, []), hubAt({ tab: "silences", sheet: "silence" }), context) - test("New silence opens the dialog with typed scope fields", () => { +describe("alerts hub, silence side panel", () => { + test("New silence opens the panel through the URL, as a history entry on this section", () => { + const model = onSilences(ENGINEER, []) + const step = pageUpdate(model, Message.ClickedNewSilence()) + expect(callsOf(step.commands)).toEqual([ + ["PushAlertsHubSheet", { href: SILENCES_URL, id: "silence" }], + ]) + expect(step.model.silenceDialog.dialog.isOpen).toBe(false) + }) + + test("the URL opens it with a chips input per scope field, Cancel and a save", () => { + const opened = withPanel() + expect(opened.model.silenceDialog.dialog.isOpen).toBe(true) scene( { update: pageUpdate, view }, - given(onSilences(ENGINEER, [])), - click(role("button", { name: "New silence" })), - Command.resolve( - Animation.WaitForPaint, - Animation.Message.CompletedWaitForPaint({ generation: 1 }), - ), - Command.resolve( - Animation.WaitForAnimationSettled, - Animation.Message.EndedAnimation({ generation: 1 }), - ), + given(opened.model), Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), expectView(role("dialog", { name: "Silence notifications" })).toExist(), - expectView(role("textbox", { name: "Services" })).toExist(), - expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), + expectView(role("combobox", { name: "Services" })).toExist(), + expectView(role("combobox", { name: "Environments" })).toExist(), + expectView(role("combobox", { name: "Tags" })).toExist(), + expectView( + text("Fill in at least one. A rule is silenced only if it matches every field you fill in."), + ).toExist(), + expectView(role("button", { name: "Cancel" })).toExist(), + expectView(role("button", { name: "Silence for 1 hour" })).not.toBeDisabled(), + expectView(role("alert")).not.toExist(), + ) + }) + + test("a chips input with nothing to suggest opens no list: how to add a name is said in the form", () => { + const focused = pageUpdate( + withPanel().model, + scopeCombo("services", Combobox.Message.ClickedInput()), + ).model + scene( + { update: pageUpdate, view }, + given(focused), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Type a name and press Enter to add it. Commas separate several.")).toExist(), + expectView(role("listbox")).not.toExist(), + expectView(role("combobox", { name: "Services" })).toHaveAttr("aria-expanded", "false"), + ) + // What is typed is listed, so Enter has something to pick. + const typed = pageUpdate( + focused, + scopeCombo("services", Combobox.Message.ChangedInput({ value: "demo-api" })), + ).model + scene( + { update: pageUpdate, view }, + given(typed), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("option", { name: "demo-api" })).toExist(), + ) + }) + + test("saving an incomplete form sends nothing and says what is missing, field by field", () => { + scene( + { update: pageUpdate, view }, + given(withPanel().model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + submit(selector("form")), + expectView(text("Add at least one service, environment or tag")).toExist(), + expectView(text("Add a reason")).toExist(), + expectView(role("textbox", { name: "Reason" })).toHaveAttr("aria-invalid", "true"), + expectView(role("button", { name: "Silence for 1 hour" })).not.toBeDisabled(), + ) + const tried = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedSave())) + expect(namesOf(tried.commands)).toEqual([]) + // Each line goes as its field is filled in. + const reasoned = pageUpdate( + tried.model, + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ).model + scene( + { update: pageUpdate, view }, + given(reasoned), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Add a reason")).not.toExist(), + expectView(text("Add at least one service, environment or tag")).toExist(), + ) + }) + + test("the chips inputs suggest what the org's rules name, and a picked one becomes a chip", () => { + const withRules = pageUpdate( + withPanel().model, + Message.UpdatedRules({ + orgId: ORG, + rows: [ + ruleRow(1, { service_names_json: ["checkout"], tags_json: ["db"] }), + ruleRow(2, { + service_names_json: ["payments", "checkout"], + environments_json: ["staging"], + }), + ], + }), + ).model + expect(silenceSuggestionsOf(withRules)).toEqual({ + services: ["checkout", "payments"], + environments: ["production", "staging"], + tags: ["db"], + }) + const listed = pageUpdate(withRules, scopeCombo("services", Combobox.Message.ClickedInput())).model + scene( + { update: pageUpdate, view }, + given(listed), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("option", { name: "checkout" })).toExist(), + expectView(role("option", { name: "payments" })).toExist(), ) + const picked = pageUpdate( + listed, + scopeCombo("services", Combobox.Message.ClickedItem({ value: "payments" })), + ).model + scene( + { update: pageUpdate, view }, + given(picked), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("button", { name: "Remove payments" })).toExist(), + ) + }) + + test("a picked end is on the viewer's clock, and the field names the zone", () => { + const berlin: PageContext = { ...context, timeZone: "Europe/Berlin" } + const opened = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "silences", sheet: "silence" }), + berlin, + ).model + const custom = update( + opened, + silenceMessage(SilenceDialog.Message.ChangedDuration({ duration: "custom" })), + berlin, + ).model + scene( + { update: (model: Model, message: Message) => update(model, message, berlin), view }, + given(custom), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + // NOW is 1 October: Berlin is on summer time. + expectView(text("Ends, CEST")).toExist(), + expectView(text("Your local time. At most 30 days from now.")).toExist(), + ) + }) + + test("the save button names its end on the 24h clock the duration choice and the picker use", () => { + const until = (duration: SilenceDialog.SilenceDuration, value: string) => + Arr.reduce( + [ + silenceMessage(SilenceDialog.Message.ChangedDuration({ duration })), + silenceMessage(SilenceDialog.Message.ChangedUntil({ value })), + ], + withPanel().model, + (model, message) => pageUpdate(model, message).model, + ) + const saveButton = role("button", { name: /^Silence until / }) + scene( + { update: pageUpdate, view }, + given(until("morning", "")), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Until 09:00")).toExist(), + expectView(saveButton).toHaveText(/ 09:00$/u), + ) + // NOW is noon on 1 October, so 15:30 that day is still ahead. + scene( + { update: pageUpdate, view }, + given(until("custom", "2026-10-01T15:30")), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(saveButton).toHaveText(/ 15:30$/u), + ) + }) + + test("a link straight to the panel waits for the clock and the viewer's grants, and a member never gets it", () => { + const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model + expect(entered.silenceDialog.dialog.isOpen).toBe(false) + const clocked = pageUpdate(entered, Message.ReadClock({ nowMs: NOW })).model + expect(clocked.silenceDialog.dialog.isOpen).toBe(false) + const granted = pageUpdate( + clocked, + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + ).model + expect(granted.silenceDialog.dialog.isOpen).toBe(true) + expect(granted.silenceDialog.nowMs).toBe(NOW) + expect(withPanel(MEMBER).model.silenceDialog.dialog.isOpen).toBe(false) + }) + + test("a member's link loses its `?sheet=` once the grants say so, replaced away", () => { + const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model + const clocked = pageUpdate(entered, Message.ReadClock({ nowMs: NOW })).model + const granted = pageUpdate(clocked, Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER })) + expect(granted.model.silenceDialog.dialog.isOpen).toBe(false) + expect(granted.model.search.sheet).toBeUndefined() + expect(callsOf(granted.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=silence`, id: "silence" }], + ]) + }) + + test("the panel opens only on the Silences section", () => { + const onRules = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "rules", sheet: "silence" }), + context, + ) + expect(onRules.model.silenceDialog.dialog.isOpen).toBe(false) + expect( + pageUpdate(onRules.model, Message.ReadClock({ nowMs: NOW })).model.silenceDialog.dialog.isOpen, + ).toBe(false) + }) + + test("a `?sheet=` that opens nothing is replaced away: another section's panel, or a made-up one", () => { + const dismissals = (commands: Parameters[0]) => + Arr.filter(callsOf(commands), ([name]) => name === "DismissAlertsHubSheet") + const onRules = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "rules", sheet: "silence" }), + context, + ) + expect(onRules.model.search.sheet).toBeUndefined() + expect(dismissals(onRules.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: "/alerts/rules?sheet=silence", id: "silence" }], + ]) + const onInbox = routeChanged(onSilences(ENGINEER, []), hubAt({ sheet: "silence" }), context) + expect(dismissals(onInbox.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: "/alerts?sheet=silence", id: "silence" }], + ]) + const bogus = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "silences", sheet: "bogus" }), + context, + ) + expect(bogus.model.search.sheet).toBeUndefined() + expect(bogus.model.silenceDialog.dialog.isOpen).toBe(false) + expect(dismissals(bogus.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=bogus`, id: "bogus" }], + ]) + // Asked once: the clock tick that follows finds nothing left to drop. + expect(dismissals(pageUpdate(bogus.model, Message.ReadClock({ nowMs: NOW })).commands)).toEqual([]) + // The open panel leaving its section closes with the param. + const left = routeChanged(withPanel().model, hubAt({ tab: "rules", sheet: "silence" }), context) + expect(left.model.silenceDialog.dialog.isOpen).toBe(false) + expect(dismissals(left.commands)).toHaveLength(1) + }) + + test("a clock tick after Cancel, before the URL change lands, leaves it closed", () => { + const cancelled = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedCancel())) + expect(cancelled.model.search.sheet).toBeUndefined() + const ticked = pageUpdate(cancelled.model, Message.ReadClock({ nowMs: NOW + 30_000 })) + expect(ticked.model.silenceDialog.dialog.isOpen).toBe(false) + const landed = routeChanged(ticked.model, hubAt({ tab: "silences" }), context) + expect(landed.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(landed.commands)).not.toContain("DismissAlertsHubSheet") + }) + + test("Cancel and Escape close it and take `?sheet=` off the URL", () => { + const dismissed = [ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=silence`, id: "silence" }], + ] + const cancelled = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedCancel())) + expect(cancelled.model.silenceDialog.dialog.isOpen).toBe(false) + expect(Arr.filter(callsOf(cancelled.commands), ([name]) => name === "DismissAlertsHubSheet")).toEqual( + dismissed, + ) + const escaped = pageUpdate( + withPanel().model, + silenceMessage( + SilenceDialog.Message.GotDialogMessage({ message: Dialog.Message.PressedEscape() }), + ), + ) + expect(escaped.model.silenceDialog.dialog.isOpen).toBe(false) + expect(Arr.filter(callsOf(escaped.commands), ([name]) => name === "DismissAlertsHubSheet")).toEqual( + dismissed, + ) + }) + + test("Back closes it without touching the URL again, and the form starts fresh when it reopens", () => { + const typed = pageUpdate( + withPanel().model, + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "x" })), + ) + const back = routeChanged(typed.model, hubAt({ tab: "silences" }), context) + expect(back.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(back.commands)).not.toContain("DismissAlertsHubSheet") + const reopened = routeChanged(back.model, hubAt({ tab: "silences", sheet: "silence" }), context) + expect(reopened.model.silenceDialog.dialog.isOpen).toBe(true) + expect(reopened.model.silenceDialog.reason).toBe("") + }) + + test("saving creates the silence, closes the panel, clears the URL and reads the list again", () => { + const filled = Arr.reduce( + [ + scopeCombo("services", Combobox.Message.ClickedItem({ value: "checkout" })), + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ], + withPanel().model, + (model, message) => pageUpdate(model, message).model, + ) + const saving = pageUpdate(filled, silenceMessage(SilenceDialog.Message.ClickedSave())) + expect(namesOf(saving.commands)).toEqual(["SaveAlertSilence"]) + const saved = pageUpdate( + saving.model, + silenceMessage( + SilenceDialog.Message.CompletedSave({ + openCount: saving.model.silenceDialog.openCount, + result: Result.succeed(silence({ startMin: 0, endMin: 60 })), + }), + ), + ) + expect(saved.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(saved.commands)).toEqual( + expect.arrayContaining(["DismissAlertsHubSheet", "FetchAlertsHubSilences"]), + ) + expect(saved.outMessage).toMatchObject({ title: "Alerts silenced" }) + // The refreshed list reads the clock again; the panel stays shut. + expect( + pageUpdate(saved.model, Message.ReadClock({ nowMs: NOW })).model.silenceDialog.dialog.isOpen, + ).toBe(false) }) }) diff --git a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts index b6b9ff22e4..84d833a2dc 100644 --- a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts @@ -8,7 +8,7 @@ import * as Menu from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" import { sloHref } from "../shared/slo-commands" import { checkoutSlo, SLO_RULE_UUID, searchSlo, sloStatus, sloUuid } from "../shared/slo-test-fixtures" -import { incidentRow, NOW, ORG } from "../shared/test-rows" +import { incidentRow, NOW, ORG, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model, SloListRow } from "./model" import { init, update } from "./update" @@ -25,7 +25,7 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onSlos = (grants = ENGINEER): Model => { - const entered = init({ _tag: "Alerts", search: { tab: "slos" } }, context).model + const entered = init(hubAt({ tab: "slos" }), context).model return Arr.reduce( [Message.ReadClock({ nowMs: NOW }), Message.CompletedFetchSession({ orgId: ORG, grants })], entered, @@ -46,12 +46,8 @@ const HEALTHY_AND_BURNING: ReadonlyArray = [ describe("alerts hub, SLOs tab", () => { test("the SLOs and their statuses are read only while the tab is open", () => { - expect(names(init({ _tag: "Alerts", search: {} }, context).commands)).not.toContain( - "FetchAlertsHubSlos", - ) - expect(names(init({ _tag: "Alerts", search: { tab: "slos" } }, context).commands)).toContain( - "FetchAlertsHubSlos", - ) + expect(names(init(hubAt(), context).commands)).not.toContain("FetchAlertsHubSlos") + expect(names(init(hubAt({ tab: "slos" }), context).commands)).toContain("FetchAlertsHubSlos") }) test("an org without SLOs is told what one is and offered the first", () => { @@ -204,18 +200,19 @@ describe("alerts hub, SLOs tab", () => { expect(names(pageUpdate(failed, Message.ClickedRetrySlos()).commands)).toEqual(["FetchAlertsHubSlos"]) }) - test("the header's main create button makes an SLO here, for admins only, and the menu offers it too", () => { + test("the header's one primary action is New SLO here, for admins only, and the Inbox's menu offers it too", () => { scene( { update: pageUpdate, view }, given(withSlos(HEALTHY_AND_BURNING)), expectView(role("link", { name: "New SLO" })).toHaveAttr("href", "/alerts/slos/new"), expectView(role("link", { name: "New rule" })).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), ) scene( { update: pageUpdate, view }, given(withSlos(HEALTHY_AND_BURNING, onSlos(MEMBER))), expectView(role("link", { name: "New SLO" })).not.toExist(), - expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), + expectView(role("link", { name: "New rule" })).not.toExist(), ) const picked = pageUpdate( withSlos(HEALTHY_AND_BURNING), @@ -231,7 +228,7 @@ describe("alerts hub, SLOs tab", () => { describe("alerts hub inbox, SLO incidents", () => { test("an org whose only rules are SLOs' sees their open incidents, not the empty state", () => { - const entered = init({ _tag: "Alerts", search: {} }, context).model + const entered = init(hubAt(), context).model const model = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), diff --git a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts index a62ab85d2a..83ffcfbd3b 100644 --- a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts @@ -18,7 +18,7 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import * as Menu from "@maple/views/ui/menu" import { MountMenu } from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" -import { incidentRow, NOW, ORG } from "../shared/test-rows" +import { incidentRow, NOW, ORG, hubAt } from "../shared/test-rows" import { monitorHref } from "../shared/uptime-commands" import type { UptimeMonitor } from "../shared/uptime-derive" import { downMonitor, httpMonitor, monitorId, pausedMonitor } from "../shared/uptime-test-monitors" @@ -38,7 +38,7 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onUptime = (): Model => { - const entered = init({ _tag: "Alerts", search: { tab: "uptime" } }, context).model + const entered = init(hubAt({ tab: "uptime" }), context).model return Arr.reduce( [Message.ReadClock({ nowMs: NOW }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER })], entered, @@ -74,7 +74,7 @@ const NAMESPACES = { "checkout-api": "shop", "payments-api": "billing" } describe("alerts hub inbox, uptime incidents", () => { test("an uptime incident reads as its monitor being down, without the ungrouped key", () => { - const entered = init({ _tag: "Alerts", search: {} }, context).model + const entered = init(hubAt(), context).model const model = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -123,16 +123,12 @@ describe("alerts hub, Uptime tab", () => { }) test("the monitors are read only when the Uptime tab is open", () => { - expect(names(init({ _tag: "Alerts", search: {} }, context).commands)).not.toContain( - "FetchAlertsHubMonitors", - ) - expect(names(init({ _tag: "Alerts", search: { tab: "uptime" } }, context).commands)).toContain( - "FetchAlertsHubMonitors", - ) + expect(names(init(hubAt(), context).commands)).not.toContain("FetchAlertsHubMonitors") + expect(names(init(hubAt({ tab: "uptime" }), context).commands)).toContain("FetchAlertsHubMonitors") }) test("opening the tab also reads which namespace each service reports under", () => { - expect(names(init({ _tag: "Alerts", search: { tab: "uptime" } }, context).commands)).toContain( + expect(names(init(hubAt({ tab: "uptime" }), context).commands)).toContain( "FetchAlertsHubServiceNamespaces", ) }) @@ -324,21 +320,29 @@ describe("alerts hub, Uptime tab", () => { expect(done.model.deletingMonitorId).toBeNull() }) - test("the header's main create button makes an uptime check here, for admins only", () => { + test("the header's one primary action is New monitor here, for admins only", () => { scene( { update: pageUpdate, view }, given(withMonitors(THREE)), - expectView(role("link", { name: "New check" })).toHaveAttr("href", "/alerts/uptime/new"), + expectView(role("link", { name: "New monitor" })).toHaveAttr("href", "/alerts/uptime/new"), expectView(role("link", { name: "New rule" })).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), ) scene( { update: pageUpdate, view }, - given({ ...withMonitors(THREE), search: { tab: "uptime", serviceName: "checkout-api" } }), - expectView(role("link", { name: "New check" })).toHaveAttr( + given({ ...withMonitors(THREE), search: { serviceName: "checkout-api" } }), + expectView(role("link", { name: "New monitor" })).toHaveAttr( "href", "/alerts/uptime/new?serviceName=checkout-api", ), ) + // The empty state offers the first check itself, so the header stays empty. + scene( + { update: pageUpdate, view }, + given(withMonitors([])), + expectView(role("link", { name: "New monitor" })).not.toExist(), + expectView(role("link", { name: "New uptime check" })).toExist(), + ) const asMember = pageUpdate( withMonitors(THREE), Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER }), @@ -346,15 +350,16 @@ describe("alerts hub, Uptime tab", () => { scene( { update: pageUpdate, view }, given(asMember), - expectView(role("link", { name: "New check" })).not.toExist(), - expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), + expectView(role("link", { name: "New monitor" })).not.toExist(), + expectView(role("link", { name: "New rule" })).not.toExist(), ) }) - test("the New rule chevron opens both kinds, and Uptime check goes to the uptime form", () => { + test("on the Inbox the New rule chevron opens every kind, and Uptime check goes to the uptime form", () => { scene( { update: pageUpdate, view }, - given(withMonitors(THREE)), + given({ ...withMonitors(THREE), tab: "inbox" as const }), + expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), click(role("button", { name: "More rule types" })), Command.resolve( Animation.WaitForPaint, diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 1327330cb8..f810e7473f 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,39 +106,128 @@ 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( + expectView(role("link", { name: "P95 latency 1.84s" })).toHaveAttr( "href", "/alerts/incidents/0199a003-0000-7000-8000-000000000001", ), ) }) - test("the section rail marks the open section, which titles the page and its crumbs", () => { + test("each section's own path opens it, titled by it with its crumbs", () => { + const sections = [ + ["rules", "Rules"], + ["uptime", "Uptime"], + ["slos", "SLOs"], + ["silences", "Silences"], + ["maintenance", "Maintenance"], + ["on-call", "On-call"], + ["routing", "Routing"], + ] as const + for (const [tab, label] of sections) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(role("heading", { name: label })).toExist(), + ) + expect(breadcrumbs(loaded({ tab }))).toEqual([{ label: "Alerts", href: "/alerts" }, { label }]) + } scene( { update: pageUpdate, view }, - given(loaded({ tab: "rules" })), - expectView(role("link", { name: /^Rules\s*3$/ })).toHaveAttr("aria-current", "page"), - expectView(role("link", { name: /^Inbox\s*1$/ })).not.toHaveAttr("aria-current", "page"), - expectView(role("heading", { name: "Rules" })).toExist(), + given(loaded()), + expectView(role("heading", { name: "Inbox" })).toExist(), ) - // The anomaly filters belong to the Inbox: its row keeps them, every other row drops them. + }) + + test("each section's header has one primary action: its own, and New rule only on the Inbox and Rules", () => { + const newRule = role("link", { name: "New rule" }) + const moreKinds = role("button", { name: "More rule types" }) + for (const tab of ["inbox", "rules"] as const) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(newRule).toHaveAttr("href", "/alerts/create"), + expectView(moreKinds).toExist(), + ) + } + for (const tab of ["uptime", "slos", "silences", "maintenance", "on-call", "routing"] as const) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(newRule).not.toExist(), + expectView(moreKinds).not.toExist(), + ) + } scene( { update: pageUpdate, view }, - given(loaded({ tab: "inbox", signal: "anomaly", serviceName: "checkout" })), - expectView(role("link", { name: /^Inbox\s*1$/ })).toHaveAttr( - "href", - "/alerts?tab=inbox&serviceName=checkout&signal=anomaly", - ), - expectView(role("link", { name: "Silences" })).toHaveAttr( - "href", - "/alerts?tab=silences&serviceName=checkout", - ), + given(loaded({ tab: "silences" })), + expectView(role("button", { name: "New silence" })).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "routing" })), + expectView(role("button", { name: "Add destination" })).toExist(), + ) + // 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(), ) - expect(breadcrumbs(loaded({ tab: "silences" }))).toEqual([ - { label: "Alerts", href: "/alerts" }, - { label: "Silences" }, + }) + + test("an org without rules gets the create actions from the empty state alone, on the Inbox and on Rules", () => { + const newRule = role("link", { name: "New rule" }) + const firstRun = (at: HubAt, grants = ENGINEER): Model => + Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedRules({ orgId: ORG, rows: [] }), + Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + Message.UpdatedIncidents({ orgId: ORG, rows: [] }), + Message.UpdatedDestinations({ orgId: ORG, rows: [] }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + ], + entered(at).model, + (model, message) => pageUpdate(model, message).model, + ) + const tabs: ReadonlyArray<"inbox" | "rules"> = ["inbox", "rules"] + Arr.forEach(tabs, (tab) => { + scene( + { update: pageUpdate, view }, + given(firstRun({ tab })), + expectView(role("link", { name: "Start from scratch" })).toExist(), + expectView(role("button", { name: "Add destination" })).toExist(), + expectView(newRule).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), + ) + // A member's empty state offers nothing to create, so the header keeps its action. + scene( + { update: pageUpdate, view }, + given(firstRun({ tab }, Policy.grantsOf(["org:member"]))), + expectView(role("link", { name: "Start from scratch" })).not.toExist(), + expectView(newRule).toExist(), + ) + }) + // Still loading: nothing says the org is empty yet. + scene({ update: pageUpdate, view }, given(entered().model), expectView(newRule).toExist()) + }) + + test("moving between sections keeps what was read: the Inbox and Rules share it, a section reads its own once", () => { + const inbox = loaded() + expect(names(routeChanged(inbox, hubAt({ tab: "rules" }), context).commands)).toEqual([ + "ReadAlertsHubClock", ]) + const uptime = routeChanged(inbox, hubAt({ tab: "uptime" }), context) + expect(names(uptime.commands)).toContain("FetchAlertsHubMonitors") + const back = routeChanged(uptime.model, hubAt(), context) + expect(names(routeChanged(back.model, hubAt({ tab: "uptime" }), context).commands)).not.toContain( + "FetchAlertsHubMonitors", + ) }) const v2Incident = Schema.decodeSync(V2AlertIncident)({ @@ -183,11 +282,9 @@ 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(), + expectView(role("button", { name: "Unacknowledge P95 latency 1.84s" })).toExist(), ) const clicked = pageUpdate( loaded(), @@ -306,6 +403,90 @@ describe("alerts hub", () => { ) }) + test("a rule's whole row is one link to the rule, with its actions menu beside it", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "rules" })), + expectView(role("link", { name: "Payment p95 latency" })).toHaveAttr( + "href", + `/alerts/rules/${ruleId(2)}`, + ), + expectView(role("button", { name: "Actions for Payment p95 latency" })).toExist(), + ) + }) + + test("untagged rules alone get no group header; with a tagged rule each group is named", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "rules" })), + expectView(text("No tag")).not.toExist(), + ) + const tagged = pageUpdate( + loaded({ tab: "rules" }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1), ruleRow(2, { tags_json: ["api"] })] }), + ).model + scene( + { update: pageUpdate, view }, + given(tagged), + expectView(text("No tag")).toExist(), + expectView(text("api")).toExist(), + ) + }) + + test("a quiet Inbox stays calm: no Anomalies toggle without anomalies, MTTR n/a, an ungrouped reading has no group line", () => { + // Nothing open, and one rule routed nowhere: a blind spot, so the last 24 hours are drawn. + const quiet = Arr.reduce( + [ + Message.UpdatedIncidents({ orgId: ORG, rows: [] }), + Message.UpdatedRules({ + orgId: ORG, + rows: [ + ruleRow(1, { destination_ids_json: [] }), + ruleRow(2, { name: "Payment p95 latency" }), + ], + }), + ], + loaded(), + (model, message) => pageUpdate(model, message).model, + ) + scene( + { update: pageUpdate, view }, + given(quiet), + expectView(role("heading", { name: /^Nothing firing/ })).toExist(), + expectView(role("button", { name: /^Anomalies/ })).not.toExist(), + expectView(text("0 incidents · MTTR n/a · 0 rules fired")).toExist(), + expectView(role("link", { name: "Payment p95 latency" })).toExist(), + expectView(text("all")).not.toExist(), + expectView(text("__total__")).not.toExist(), + ) + }) + + test("acknowledged and unacknowledged incidents both offer a button to flip the state", () => { + const acknowledged = pageUpdate( + loaded(), + Message.UpdatedIncidents({ + orgId: ORG, + rows: [ + incidentRow({ rule_name: "Payment p95 latency" }), + incidentRow({ + id: "0199a003-0000-7000-8000-000000000002", + rule_name: "Payment p95 latency", + group_key: "checkout", + acknowledged_at: new Date(NOW - 60_000).toISOString(), + acknowledged_by_name: "Dana Kim", + acknowledged_via: "slack", + }), + ], + }), + ).model + scene( + { update: pageUpdate, view }, + given(acknowledged), + expectView(role("button", { name: /^Acknowledge / })).toExist(), + expectView(role("button", { name: /^Unacknowledge / })).toExist(), + ) + }) + test("a status chip writes its bucket to the URL, and pressing it again clears it", () => { scene( { update: pageUpdate, view }, @@ -313,7 +494,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 +504,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..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 4c448ddcad..43a02b27c9 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -28,11 +28,10 @@ import { import { Schema } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" -import { AlertsSearch } from "../../../route/search" +import { AlertsHubSearch, AlertsTab } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" -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" @@ -54,7 +53,8 @@ export type SloListRow = typeof SloListRow.Type export const TestOutcome = Schema.Struct({ message: Schema.String, success: Schema.Boolean }) export const Model = Schema.Struct({ - search: AlertsSearch, + tab: AlertsTab, + search: AlertsHubSearch, /** `Clock.currentTimeMillis` when the overview model was built; `null` until read. */ nowMs: Schema.NullOr(Schema.Number), rules: Rows(AlertRuleRowSchema), @@ -105,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 935b7a6cb4..6a1e43f1e3 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, @@ -62,25 +61,33 @@ 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" 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" @@ -115,7 +122,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 +142,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 +163,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 +181,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 +205,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 +221,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 +233,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 +278,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 +293,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, @@ -328,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, @@ -380,7 +386,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 +406,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 }) @@ -492,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, @@ -564,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), { @@ -580,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, + }), + }, }, ) @@ -734,15 +726,72 @@ const deletedSchedule = ( // SILENCES +/** `?sheet=silence`: the new-silence side panel. */ +export const SILENCE_SHEET = "silence" + +const hubHref = (model: Model): string => alertsHref(model.search, model.tab) + +const silenceCommands = (step: ReturnType): Commands => + FoldkitCommand.mapMessages(step.commands ?? [], (message) => Message.GotSilenceDialogMessage({ message })) + +/** + * Opens or closes the silence panel to match `?sheet=` (a link, Back, or "New silence"). It opens on + * the Silences section once the clock is read, as the form checks its end against it, and only for + * a viewer who may save. + */ +const followSilenceSheet = (model: Model, context: PageContext): Requested => { + const sheet = model.search.sheet + const isInUrl = sheet === SILENCE_SHEET && model.tab === "silences" + // A `?sheet=` nothing here opens (another section's panel, a made-up id) is replaced away. + if (sheet !== undefined && !isInUrl) { + const dropped = followSilenceSheet( + { ...model, search: { ...model.search, sheet: undefined } }, + context, + ) + return { + model: dropped.model, + commands: [...dropped.commands, DismissHubSheet({ href: hubHref(model), id: sheet })], + } + } + const isOpen = model.silenceDialog.dialog.isOpen + if (isInUrl && !isOpen && model.nowMs !== null && canManage(model)) { + const step = SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs) + return { model: { ...model, silenceDialog: step.model }, commands: silenceCommands(step) } + } + if (!isInUrl && isOpen) { + const step = SilenceDialog.close(model.silenceDialog) + return { model: { ...model, silenceDialog: step.model }, commands: silenceCommands(step) } + } + return { model, commands: [] } +} + +/** The grants are in: a viewer who can't silence loses a linked panel's `?sheet=`, replaced away. */ +const grantsLoaded = (model: Model, context: PageContext): Requested => + model.search.sheet === SILENCE_SHEET && !canManage(model) + ? { + model: { ...model, search: { ...model.search, sheet: undefined } }, + commands: [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })], + } + : followSilenceSheet(model, context) + const liftSilenceDialog = ( model: Model, context: PageContext, step: ReturnType, ): Return => { - const next = { ...model, silenceDialog: step.model } - const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => - Message.GotSilenceDialogMessage({ message }), - ) + // The panel closed itself (Cancel, Escape, a save): its `?sheet=` goes now, in the model too, so a + // clock tick before the URL change lands can't reopen it. + const hasClosed = + model.silenceDialog.dialog.isOpen && !step.model.dialog.isOpen && model.search.sheet === SILENCE_SHEET + const next = { + ...model, + silenceDialog: step.model, + search: hasClosed ? { ...model.search, sheet: undefined } : model.search, + } + const commands = [ + ...silenceCommands(step), + ...(hasClosed ? [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] : []), + ] if (step.outMessage === undefined) return { model: next, commands } return SilenceDialog.OutMessage.match(step.outMessage, { RequestedToast: ({ type, title, description }) => ({ @@ -980,7 +1029,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") { @@ -1044,10 +1093,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 +1126,39 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, routingDeliveries: Keyed.accept(model.routingDeliveries, key, result) }, }), CompletedFetchSession: ({ orgId, grants }) => - orgId === context.orgId ? { model: { ...model, grants } } : { model }, + orgId === context.orgId ? grantsLoaded({ ...model, grants }, context) : { model }, ChangedRulesQuery: ({ value }) => ({ model: { ...model, rulesQuery: value } }), ClickedStatusFilter: ({ bucket }) => statusToggled(model, bucket), ClickedClearRuleFilters: () => ({ model: { ...model, rulesQuery: "" }, - outMessage: navigate({ ...model.search, status: undefined }), + outMessage: navigate({ ...model.search, status: undefined }, model.tab), }), ClickedInboxSignal: ({ signal }) => ({ model, // The status and service belong to the signal view: plain Inbox drops them. - outMessage: navigate( + outMessage: signal === null - ? { ...model.search, signal: undefined, anomalyStatus: undefined, serviceName: undefined } - : { ...model.search, tab: "inbox", signal, anomalyStatus: undefined }, - ), + ? navigate( + { + ...model.search, + signal: undefined, + anomalyStatus: undefined, + serviceName: undefined, + }, + model.tab, + ) + : navigate({ ...model.search, signal, anomalyStatus: undefined }, "inbox"), }), ClickedIncidentStatus: ({ status }) => ({ model, - outMessage: navigate({ ...model.search, anomalyStatus: status === "open" ? undefined : status }), + outMessage: navigate( + { ...model.search, anomalyStatus: status === "open" ? undefined : status }, + model.tab, + ), }), ClickedClearInboxService: () => ({ model, - outMessage: navigate({ ...model.search, serviceName: undefined }), + outMessage: navigate({ ...model.search, serviceName: undefined }, model.tab), }), ClickedShowAllRules: () => ({ model: { ...model, isShowingAllRules: true } }), ClickedToggleRule: ({ ruleId, isEnabled }) => toggleRule(model, ruleId, isEnabled), @@ -1229,9 +1288,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 }) => @@ -1284,11 +1340,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), ClickedRetrySilences: () => refreshSilences(model, context), ClickedNewSilence: () => - liftSilenceDialog( - model, - context, - SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs ?? 0), - ), + model.search.sheet === SILENCE_SHEET + ? { model } + : { model, commands: [PushHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] }, GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), @@ -1353,7 +1407,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-anomalies.ts b/apps/web/src/page/alerts/hub/view-anomalies.ts index 57217e983c..ba4031a3de 100644 --- a/apps/web/src/page/alerts/hub/view-anomalies.ts +++ b/apps/web/src/page/alerts/hub/view-anomalies.ts @@ -38,8 +38,24 @@ const STATUS_OPTIONS: ReadonlyArray<{ value: IncidentStatusFilter; label: string { value: "all", label: "All" }, ] -/** The Inbox's filter row: the Anomalies view toggle and, inside it, the status and service. */ +/** + * The Inbox's filter row: the Anomalies view toggle and, inside it, the status and service. An + * org with no anomaly in the window has no row at all, and a zero count is left off the toggle. + */ export const inboxFilters = ( + h: H, + options: Readonly<{ + signal: InboxSignal + status: IncidentStatusFilter + serviceName: string | undefined + openAnomalies: number + /** Any anomaly incident on hand, open or resolved: the view has something to show. */ + hasAnomalies: boolean + }>, +): ReadonlyArray => + options.signal !== "anomaly" && !options.hasAnomalies ? [] : [inboxFilterRow(h, options)] + +const inboxFilterRow = ( h: H, options: Readonly<{ signal: InboxSignal @@ -58,7 +74,7 @@ export const inboxFilters = ( onClick: Message.ClickedInboxSignal({ signal: options.signal === "anomaly" ? null : "anomaly", }), - count: String(options.openAnomalies), + ...(options.openAnomalies > 0 ? { count: String(options.openAnomalies) } : undefined), }, ["Anomalies"], ), diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index b533835c0e..42838fa2b6 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,12 @@ 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 { chainDeleteConfirm } from "../shared/chain-delete-confirm" +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 +91,14 @@ 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" }, [ + // `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, { @@ -150,7 +159,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 +220,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" }, [ @@ -231,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), () => []), @@ -241,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/hub/view-empty.ts b/apps/web/src/page/alerts/hub/view-empty.ts index dff9c09e16..364cf405ad 100644 --- a/apps/web/src/page/alerts/hub/view-empty.ts +++ b/apps/web/src/page/alerts/hub/view-empty.ts @@ -122,7 +122,7 @@ const destinationStep = (h: HtmlBuilder, onAddDestination: M | undefined): ], ), onAddDestination === undefined - ? linkButton(h, { size: "sm", href: alertsHref({ tab: "routing" }) }, [ + ? linkButton(h, { size: "sm", href: alertsHref({}, "routing") }, [ icon(h, "PaperPlaneIcon"), "Add destination", ]) diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 735256f805..a7c7bd9a6e 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -1,6 +1,6 @@ import type { AlertIncidentDocument, AlertIncidentId, AlertRuleDocument } from "@maple/domain/http" import { acknowledgedLine } from "@maple/web-data/alerts/incident-timeline" -import { countLabel, EMPTY_VALUE } from "@maple/ui/lib/format" +import { countLabel } from "@maple/ui/lib/format" import { formatRelativeShortFrom } from "@maple/ui/lib/time-format" import { cn } from "@maple/ui/lib/utils" import { @@ -17,6 +17,7 @@ import { blindSpots as deriveBlindSpots, type FireLane, fireLanes, + headroomTone, incidentSubline, incidentTitle, incidentValues, @@ -30,6 +31,7 @@ import { eyebrow } from "@maple/views/ui/eyebrow" import { icon } from "@maple/views/ui/icon" import { ROW_LINK_LIFT, rowLink } from "@maple/views/ui/table" import { severityBadge } from "../shared/badges" +import { HEADROOM_FILL, HEADROOM_TEXT } from "../shared/headroom-tone" import { incidentHref, ruleHref } from "../shared/links" import { sortIncidents } from "../shared/sort" import { isoMs, msIso } from "../shared/time" @@ -45,7 +47,7 @@ import { Array as Arr } from "effect" // `InboxTab` and `HeadroomList`: the hub's Inbox tab. const HOUR_MS = 60 * 60 * 1000 -const INCIDENT_GRID = "grid grid-cols-[minmax(0,1fr)_150px_90px_120px] items-center gap-4" +const INCIDENT_GRID = "grid grid-cols-[minmax(0,1fr)_150px_90px_140px] items-center gap-4" const MAX_LANES = 8 const MAX_HEADROOM_ROWS = 8 // Below `sm` the bar has no room: the row keeps the name, the value and when it was checked. @@ -63,10 +65,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, @@ -264,8 +262,9 @@ const openIncidentRow = ( button( h, { - variant: acked === null ? "outline" : "ghost", - size: "xs", + // Both states are the same small outline button: one row never reads as bare text. + variant: "outline", + size: "sm", isLoading: acknowledge.pendingIds.includes(incident.id), attributes: [ h.OnClick(acknowledge.onAcknowledge(incident.id, acked === null)), @@ -293,7 +292,7 @@ const fireStrip = ( const resolved = Arr.filter(inWindow, (i) => i.resolvedAt !== null) const mttr = resolved.length === 0 - ? EMPTY_VALUE + ? "n/a" : formatResolutionDuration( Arr.reduce( resolved, @@ -324,13 +323,7 @@ const fireStrip = ( hstack(h, { gap: "4", align: "baseline", justify: "between" }, [ eyebrow(h, { as: "h3" }, ["Last 24 hours"]), typography(h, { size: "xs", tone: "muted", mono: true }, [ - countLabel(inWindow.length, "incident"), - " · MTTR ", - mttr, - " ·", - " ", - countLabel(lanes.length, "rule"), - " fired", + `${countLabel(inWindow.length, "incident")} · MTTR ${mttr} · ${countLabel(lanes.length, "rule")} fired`, ]), ]), shown.length === 0 @@ -528,7 +521,8 @@ const headroomList = (h: HtmlBuilder, rows: ReadonlyArray, n const headroomRow = (h: HtmlBuilder, row: RuleHeadroom, now: number): Html => { const { rule, closeness } = row - const { isNear } = row + const tone = headroomTone(row) + const valueTone = HEADROOM_TEXT[tone] const isFloor = rule.comparator === "lt" || rule.comparator === "lte" return h.li( [ @@ -560,33 +554,30 @@ const headroomRow = (h: HtmlBuilder, row: RuleHeadroom, now: number): Html ], ), h.div( - [h.Class("relative hidden h-2 rounded-xs bg-surface-strong sm:block"), h.AriaHidden(true)], + [ + h.Class("relative hidden h-1.5 rounded-full bg-surface-strong sm:block"), + h.AriaHidden(true), + ], [ ...(closeness !== null ? [ h.span([ - h.Class( - barFillClass(isNear ? "bg-severity-warn" : "bg-foreground-faint"), - ), + h.Class(barFillClass(HEADROOM_FILL[tone])), h.Style({ width: `${Math.max(closeness * 100, 1.5)}%` }), ]), ] : []), + // The threshold, at the bar's end: a neutral tick, as in the rules table. h.span([ h.AriaHidden(true), - h.Class( - cn( - "absolute -inset-y-1 right-0 w-0.5 rounded-full", - rule.severity === "critical" ? "bg-severity-error" : "bg-severity-warn", - ), - ), + h.Class("absolute -inset-y-0.5 right-0 w-px bg-foreground/70"), ]), ], ), h.span( [h.Class("truncate text-right font-mono text-xs tabular-nums")], [ - h.span(isNear ? [h.Class("text-severity-warn")] : [], [ruleValue(rule, row.value)]), + h.span(valueTone === undefined ? [] : [h.Class(valueTone)], [ruleValue(rule, row.value)]), typography(h, { tone: "muted" }, [ isFloor ? " / min " : " / ", ruleValue(rule, rule.threshold), diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts index 7aad64110e..fb9cc54903 100644 --- a/apps/web/src/page/alerts/hub/view-oncall.ts +++ b/apps/web/src/page/alerts/hub/view-oncall.ts @@ -267,7 +267,8 @@ export const deleteScheduleDialog = (h: H, model: Model): Html => { slotId: DELETE_SCHEDULE_DIALOG_ID, model: model.deleteScheduleDialog, toParentMessage: (message) => Message.GotDeleteScheduleDialogMessage({ message }), - title: `Delete ${name}?`, + // A long unbroken name wraps inside the dialog instead of running out of it. + title: [typography(h, { className: "wrap-anywhere" }, [`Delete ${name}?`])], description: "Its overrides go with it. This cannot be undone.", children: model.deleteScheduleError === null @@ -284,10 +285,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-rules.ts b/apps/web/src/page/alerts/hub/view-rules.ts index 755b174c5b..6df9b87c6e 100644 --- a/apps/web/src/page/alerts/hub/view-rules.ts +++ b/apps/web/src/page/alerts/hub/view-rules.ts @@ -7,6 +7,7 @@ import { formatSignalValue } from "@maple/web-data/alerts/form-utils" import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" import { type RibbonCell, + headroomTone, ruleBucket, ruleConditionSummary, ruleHeadroom, @@ -26,6 +27,7 @@ import { statusDot } from "@maple/views/ui/status-dot" import { ROW_LINK_LIFT, rowLink } from "@maple/views/ui/table" import { listToolbar, toolbarCount } from "../../kit/list-toolbar" import { filteredEmpty } from "@maple/views/ui/signal-empty-state" +import { HEADROOM_FILL } from "../shared/headroom-tone" import { ruleHref } from "../shared/links" import { isoMs } from "../shared/time" import { Message, type RuleBucket } from "./message" @@ -55,8 +57,9 @@ const BUCKETS: ReadonlyArray<{ value: RuleBucket; label: string; tone: Tone }> = ] const BUCKET_TONE = new Map(Arr.map(BUCKETS, (bucket) => [bucket.value, bucket.tone])) +// The ribbon is 28 cells of 4px; Routes takes the width that leaves, and Fired fits its heading on one line. const ROW_GRID = - "grid grid-cols-[12px_minmax(0,1fr)_170px_150px_minmax(0,160px)_48px_32px] items-center gap-4" + "grid grid-cols-[12px_minmax(0,1fr)_170px_112px_minmax(0,200px)_68px_32px] items-center gap-4" const capGroups = (groups: ReadonlyArray>, limit: number): Array> => Arr.reduce, { out: Array>; left: number }>( @@ -111,6 +114,8 @@ export const rulesTab = (h: H, model: Model, inputs: RulesTabInputs): Html => { ) }) const groups = groupByTag(filtered, (rule) => rule.tags) + // One group of untagged rules is the whole list: its "No tag" header says nothing. + const isLoneUntagged = groups.length === 1 && groups[0]?.key === UNTAGGED_KEY const totalRows = Arr.reduce(groups, 0, (sum, group) => sum + group.items.length) const isCapped = !model.isShowingAllRules && totalRows > RENDER_LIMIT const visibleGroups = isCapped ? capGroups(groups, RENDER_LIMIT) : groups @@ -210,7 +215,7 @@ export const rulesTab = (h: H, model: Model, inputs: RulesTabInputs): Html => { h.span([], ["Last ", String(RIBBON_DAYS), " days"]), h.span([], ["Routes to"]), h.span( - [h.Class("text-right")], + [h.Class("whitespace-nowrap text-right")], ["Fired ", String(FIRED_WINDOW_DAYS), "d"], ), h.span([], []), @@ -224,21 +229,31 @@ export const rulesTab = (h: H, model: Model, inputs: RulesTabInputs): Html => { return h.section( [h.Key(group.key), h.Class("border-b last:border-b-0")], [ - h.div( - [h.Class("border-b bg-surface-sunken px-4 py-2 text-xs")], - [ - typography(h, { weight: "medium" }, [ - group.key === UNTAGGED_KEY ? "No tag" : group.label, + ...(isLoneUntagged + ? [] + : [ + h.div( + [ + h.Class( + "border-b bg-surface-sunken px-4 py-2 text-xs", + ), + ], + [ + typography(h, { weight: "medium" }, [ + group.key === UNTAGGED_KEY + ? "No tag" + : group.label, + ]), + typography(h, { tone: "muted" }, [ + " ", + countLabel(group.count, "rule"), + ...(firingInGroup > 0 + ? [` · ${firingInGroup} firing`] + : []), + ]), + ], + ), ]), - typography(h, { tone: "muted" }, [ - " ", - countLabel(group.count, "rule"), - ...(firingInGroup > 0 - ? [` · ${firingInGroup} firing`] - : []), - ]), - ], - ), dividedList( h, { as: "ul", bordered: false }, @@ -274,7 +289,8 @@ const rulesSearch = (h: H, value: string): Html => searchInput(h, { id: "alerts-rules-search", size: "sm", - className: "w-full max-w-xs", + // A fixed width: a fluid one makes the row measure short, and the last filter chip wraps alone. + className: "w-64 max-w-full shrink-0", placeholder: "Search rules, services, tags…", value, onInput: (next) => Message.ChangedRulesQuery({ value: next }), @@ -395,7 +411,7 @@ const headroomMeter = ( return typography(h, { size: "xs", tone: "muted" }, ["Too few samples to check"]) if (value === null) return typography(h, { size: "xs", tone: "muted" }, ["Waiting on data"]) const meter = ruleHeadroom(rule, value) - const tone = meter.breached ? "bg-severity-error" : meter.isNear ? "bg-severity-warn" : "bg-severity-info" + const tone = headroomTone(meter) const label = meter.breached ? "over threshold" : meter.headroom !== null @@ -408,7 +424,7 @@ const headroomMeter = ( [h.Class("relative h-1.5 rounded-full bg-muted")], [ h.span([ - h.Class(cn("absolute inset-y-0 left-0 rounded-full", tone)), + h.Class(cn("absolute inset-y-0 left-0 rounded-full", HEADROOM_FILL[tone])), h.Style({ width: `${meter.fill * 100}%` }), ]), ...Arr.map(meter.ticks, (tick) => diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index 61ba57ff02..b4fae5ff48 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -18,6 +18,7 @@ import { sectionHeading } from "../../kit/section-heading" import { windowIdOfParam } from "../shared/maintenance-windows" import { endsAtMs, + formatSilenceRange, formatSilenceTime, isCancelledSilence, silenceScopeParts, @@ -51,12 +52,11 @@ const silencesEmptyState = (h: H): Html => const windowText = (silence: V2AlertSilence, nowMs: number, timeZone: string): string => { const status = silenceStatusAt(silence, nowMs) - const start = formatSilenceTime(startsAtMs(silence), nowMs, timeZone) const end = formatSilenceTime(endsAtMs(silence), nowMs, timeZone) return status === "active" ? `Until ${end}` : status === "scheduled" - ? `${start} to ${end}` + ? formatSilenceRange(startsAtMs(silence), endsAtMs(silence), nowMs, timeZone) : isCancelledSilence(silence) ? `Cancelled ${end}` : `Ended ${end}` diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index ea70cd025a..14b7659be9 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -15,13 +15,13 @@ import * as MenuView from "@maple/views/ui/menu-view" import { skeleton, skeletonList } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" -import { navShell, type NavShellCount, type NavShellSection } from "../../kit/nav-shell" import type { BreadcrumbEntry } from "../../../shell" +import type { AlertsTab } from "../../../route/search" import { alertsHref, createHref } from "../shared/links" +import { newScheduleHref } from "../shared/oncall-schedules" import { newSloHref } from "../shared/slo-commands" import { newMonitorHref } from "../shared/uptime-commands" import { - type AlertsTab, anomalyOverviewOf, destinationsOf, isAboutService, @@ -29,18 +29,17 @@ import { resolveIncidentStatus, resolveSignal, resolveStatus, - resolveTab, + silenceSuggestionsOf, } from "./derive" import { Message } from "./message" import { canManage, type Model } from "./model" import { alertsEmptyState } from "./view-empty" import { anomalyInbox, inboxFilters } from "./view-anomalies" -import { inboxTab, unacknowledgedCount } from "./view-inbox" +import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" -import * as ChainDialogView from "../escalation-dialog/view" 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" @@ -48,17 +47,11 @@ import * as WindowDialogView from "../maintenance-dialog/view" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" import { slosTab } from "./view-slos" -import { - deleteScheduleDialog, - hasSchedules, - newScheduleButton, - oncallTab, - whoIsOnCallStrip, -} from "./view-oncall" +import { deleteScheduleDialog, hasSchedules, oncallTab, whoIsOnCallStrip } from "./view-oncall" import { hstack, vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" -// `AlertsPage` and `AlertsHub`: the `/alerts` page and the rail of its sections. +// `AlertsPage` and `AlertsHub`: `/alerts` and `/alerts/
`. The sidebar lists the sections. type H = HtmlBuilder @@ -69,112 +62,18 @@ const TAB_LABEL = { slos: "SLOs", silences: "Silences", maintenance: "Maintenance", - oncall: "On-call", + "on-call": "On-call", routing: "Routing", } as const satisfies Record -type RailRow = Readonly<{ tab: AlertsTab; icon: IconName }> - -const RAIL: ReadonlyArray }>> = [ - { id: "triage", title: "Triage", rows: [{ tab: "inbox", icon: "BellIcon" }] }, - { - id: "monitors", - title: "Monitors", - rows: [ - { tab: "rules", icon: "ChartLineIcon" }, - { tab: "uptime", icon: "GlobeIcon" }, - { tab: "slos", icon: "GaugeIcon" }, - ], - }, - { - id: "quiet", - title: "Quiet", - rows: [ - { tab: "silences", icon: "BellSlashIcon" }, - { tab: "maintenance", icon: "CalendarIcon" }, - ], - }, - { - id: "response", - title: "Response", - rows: [ - { tab: "oncall", icon: "UsersIcon" }, - { tab: "routing", icon: "BranchForkIcon" }, - ], - }, -] - const HUB_SKELETON_ROWS = ["h-14 w-1/2", "h-40 w-full", "h-48 w-full"] -/** `Alerts ›
`: the rail's active row names the page. */ +/** `Alerts ›
`: the sidebar's current sub-item names the page. */ export const breadcrumbs = (model: Model): ReadonlyArray => [ { label: "Alerts", href: alertsHref({}) }, - { label: TAB_LABEL[resolveTab(model.search.tab)] }, + { label: TAB_LABEL[model.tab] }, ] -const countOf = ( - model: Model, - overview: Option.Option, - tab: AlertsTab, -): number | undefined => { - const lengthOf = (rows: Option.Option>) => - Option.getOrUndefined(Option.map(rows, Arr.length)) - switch (tab) { - case "uptime": - return lengthOf(AsyncData.getData(model.monitors.data)) - case "slos": - return lengthOf(AsyncData.getData(model.slos.data)) - case "oncall": - return lengthOf(AsyncData.getData(model.schedules.data)) - case "inbox": - return Option.getOrUndefined( - Option.map(overview, (data) => unacknowledgedCount(data.openIncidents)), - ) - case "rules": - return Option.getOrUndefined(Option.map(overview, (data) => data.rules.length)) - case "silences": - case "maintenance": - case "routing": - return undefined - } -} - -/** The section rail in `dashboardPage`'s side slot, which folds into a sheet below `lg`. */ -const rail = (h: H, model: Model, overview: Option.Option, active: AlertsTab): Html => - navShell( - h, - Arr.map(RAIL, (section): NavShellSection => ({ - id: section.id, - title: section.title, - items: Arr.map(section.rows, (row) => { - const count = countOf(model, overview, row.tab) - const tone: NavShellCount["tone"] = row.tab === "inbox" ? "error" : "muted" - return { - kind: "link", - id: row.tab, - label: TAB_LABEL[row.tab], - icon: row.icon, - count: count !== undefined && count > 0 ? { value: count, tone } : undefined, - // The Anomalies view belongs to the Inbox: other sections drop its filters. - href: alertsHref( - row.tab === "inbox" - ? { ...model.search, tab: row.tab } - : { - ...model.search, - tab: row.tab, - signal: undefined, - anomalyStatus: undefined, - window: undefined, - }, - ), - } - }), - })), - active, - (tab) => tab, - "Alerts sections", - ) - /** A "New rule" menu row: icon, label and one line of what it watches. */ const newRuleItem = ( h: H, @@ -195,26 +94,18 @@ const newRuleItem = ( ], }) -/** The split create button: its main part makes the section's own kind (an uptime check on Uptime, an SLO - * on SLOs, else a telemetry rule), the chevron offers every kind. */ -const newRuleButton = (h: H, model: Model, tab: AlertsTab): Html => { +/** The Inbox's and Rules' split create button: a telemetry rule, and behind the chevron every kind. */ +const newRuleButton = (h: H, model: Model): Html => { const serviceName = model.search.serviceName - // Uptime checks and SLOs are admin-only to create; members keep the telemetry rule. - const lead = - canManage(model) && tab === "uptime" - ? { href: newMonitorHref({ serviceName }), label: "New check" } - : canManage(model) && tab === "slos" - ? { href: newSloHref({ serviceName }), label: "New SLO" } - : { href: createHref({ serviceName }), label: "New rule" } return hstack(h, { align: "center" }, [ linkButton( h, { size: "sm", - href: lead.href, + href: createHref({ serviceName }), className: splitButtonClass("lead"), }, - [icon(h, "PlusIcon"), lead.label], + [icon(h, "PlusIcon"), "New rule"], ), h.submodel({ slotId: model.newRuleMenu.id, @@ -279,52 +170,69 @@ const newRuleButton = (h: H, model: Model, tab: AlertsTab): Html => { ]) } -// Destinations are a Routing concern, so "New destination" only shows on that tab. -const headerActions = (h: H, model: Model, tab: AlertsTab): Html => - hstack(h, { gap: "2" }, [ - ...(canManage(model) && tab === "silences" - ? [ - button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewSilence())], - }, - [icon(h, "BellSlashIcon"), "New silence"], - ), - ] - : []), - ...(canManage(model) && tab === "maintenance" && hasWindows(model) - ? [ - button( - h, - { - size: "sm", - variant: "outline", - isDisabled: model.nowMs === null, - attributes: [h.OnClick(Message.ClickedNewWindow())], - }, - [icon(h, "CalendarIcon"), "New window"], - ), - ] - : []), - ...(canManage(model) && tab === "oncall" && hasSchedules(model) ? [newScheduleButton(h)] : []), - ...(canManage(model) && tab === "routing" - ? [ - button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewDestination())], - }, - [icon(h, "PlusIcon"), "New destination"], - ), - ] - : []), - newRuleButton(h, model, tab), - ]) +const hasRows = (rows: Option.Option>): boolean => + Option.exists(rows, (list) => list.length > 0) + +const primaryLink = (h: H, href: string, label: string): Html => + linkButton(h, { size: "sm", href }, [icon(h, "PlusIcon"), label]) + +const primaryButton = (h: H, onClick: Message, label: string, isDisabled = false): Html => + button(h, { size: "sm", isDisabled, attributes: [h.OnClick(onClick)] }, [icon(h, "PlusIcon"), label]) + +/** + * The first-run empty state fills the section: no rules yet, and on the Inbox nothing else to list + * (an org with only monitors or SLOs has no visible rules but may have incidents to show). + */ +const isFirstRun = (model: Model, data: AlertsOverviewReady, tab: "inbox" | "rules"): boolean => { + if (data.rules.length > 0) return false + if (tab === "rules") return true + return ( + resolveSignal(model.search.signal) === null && + !Arr.some(data.openIncidents, (incident) => isManagedSignalType(incident.signalType)) && + !Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") + ) +} + +/** + * The header's one primary action: what the open section lists. A section whose empty state + * already offers it shows none until it has rows, and a viewer who can't create it sees none. + */ +const headerActions = ( + h: H, + model: Model, + tab: AlertsTab, + overview: Option.Option, +): Html | undefined => { + if (tab === "inbox" || tab === "rules") + // The first-run empty state offers the templates and "Start from scratch" to who can create. + return canManage(model) && Option.exists(overview, (data) => isFirstRun(model, data, tab)) + ? undefined + : newRuleButton(h, model) + if (!canManage(model)) return undefined + const serviceName = model.search.serviceName + switch (tab) { + case "uptime": + return hasRows(AsyncData.getData(model.monitors.data)) + ? primaryLink(h, newMonitorHref({ serviceName }), "New monitor") + : undefined + case "slos": + return hasRows(AsyncData.getData(model.slos.data)) + ? primaryLink(h, newSloHref({ serviceName }), "New SLO") + : undefined + case "silences": + return primaryButton(h, Message.ClickedNewSilence(), "New silence") + case "maintenance": + return hasWindows(model) + ? primaryButton(h, Message.ClickedNewWindow(), "New window", model.nowMs === null) + : undefined + case "on-call": + return hasSchedules(model) ? primaryLink(h, newScheduleHref(), "New schedule") : undefined + case "routing": + return destinationsOf(model).length > 0 + ? primaryButton(h, Message.ClickedNewDestination(), "Add destination") + : undefined + } +} /** `SyncUnavailable`: the rules or incidents stream stopped before its first answer. */ const SYNC_UNAVAILABLE = makeClientErrorBody({ @@ -367,10 +275,6 @@ const hubBody = ( serviceName: model.search.serviceName, onAddDestination: canManage(model) ? Message.ClickedNewDestination() : undefined, }) - // An org with only monitors or SLOs has no visible rules but may have open incidents to show. - const hasManagedIncidents = Arr.some(data.openIncidents, (incident) => - isManagedSignalType(incident.signalType), - ) const acknowledge = { onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => Message.ClickedAcknowledgeIncident({ incidentId, acknowledged }), @@ -378,14 +282,14 @@ 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, status: resolveIncidentStatus(model.search.anomalyStatus), serviceName: model.search.serviceName, + hasAnomalies: Arr.some(data.incidents, (incident) => incident.signalType === "anomaly"), // Counted under the same service filter the list applies. openAnomalies: Arr.filter( data.openIncidents, @@ -407,7 +311,7 @@ const hubBody = ( }), }) : inboxTab(h, data, acknowledge) - return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), filters, body]) + return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), ...filters, body]) } return rulesTab(h, model, { data, @@ -440,7 +344,7 @@ const deleteDialog = (h: H, model: Model): Html => { } export const view = Submodel.defineView((model, inputs, h) => { - const tab = resolveTab(model.search.tab) + const tab = model.tab const overview = overviewOf(model) return h.div( [h.Class("contents")], @@ -449,10 +353,7 @@ export const view = Submodel.defineView((model, h, { title: TAB_LABEL[tab], - actions: headerActions(h, model, tab), - filters: rail(h, model, overview, tab), - filtersLabel: "Alerts", - filtersWidth: "w-56", + actions: headerActions(h, model, tab, overview), }, [ tab === "routing" @@ -469,7 +370,7 @@ export const view = Submodel.defineView((model, ? silencesTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) : tab === "maintenance" ? maintenanceTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) - : tab === "oncall" + : tab === "on-call" ? oncallTab(h, model, model.nowMs ?? 0) : hubBody(h, model, overview, tab), ], @@ -479,19 +380,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, @@ -503,7 +391,7 @@ export const view = Submodel.defineView((model, slotId: "alerts-silence", model: model.silenceDialog, view: SilenceDialogView.view, - viewInputs: { id: "alerts-silence" }, + viewInputs: { id: "alerts-silence", suggestions: silenceSuggestionsOf(model) }, toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), }), h.submodel({ diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 4f5ac406e4..b020209ac3 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -40,7 +40,7 @@ import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { resourceNotFound } from "../../kit/resource-not-found" import { severityBadge } from "../shared/badges" -import { alertsHref } from "../shared/links" +import { alertsHref, ruleHref } from "../shared/links" import { sloHref, slosTabHref } from "../shared/slo-commands" import { monitorHref, uptimeTabHref } from "../shared/uptime-commands" import { incidentChecksOf, summaryChecksOf } from "../shared/check-summary" @@ -544,7 +544,7 @@ export const breadcrumbsOf = (model: Model) => ], onSome: ({ incident, rule }) => [ { label: "Alerts", href: "/alerts" }, - { label: rule?.name ?? incident.ruleName, href: `/alerts/${incident.ruleId}` }, + { label: rule?.name ?? incident.ruleName, href: ruleHref(incident.ruleId) }, { label: "Incident" }, ], }) diff --git a/apps/web/src/page/alerts/rule/index.ts b/apps/web/src/page/alerts/rule/index.ts index 2c10c34d2e..ddb939bb6e 100644 --- a/apps/web/src/page/alerts/rule/index.ts +++ b/apps/web/src/page/alerts/rule/index.ts @@ -46,7 +46,7 @@ const subscriptions = Subscription.make, Message, R ), })) -/** `/alerts/$ruleId`: one instance per rule; the tab and window are search. */ +/** `/alerts/rules/$ruleId`: one instance per rule; the tab and window are search. */ export const page = definePage( "AlertRule", { Model, Message }, diff --git a/apps/web/src/page/alerts/rule/model.ts b/apps/web/src/page/alerts/rule/model.ts index 5c571dc3f1..f955de7adb 100644 --- a/apps/web/src/page/alerts/rule/model.ts +++ b/apps/web/src/page/alerts/rule/model.ts @@ -21,7 +21,7 @@ import * as TimeRangePicker from "../../kit/time-range-picker" import { CheckSummary } from "../shared/check-summary" import { PreviewSlot } from "../shared/preview" -// `/alerts/$ruleId`. +// `/alerts/rules/$ruleId`. const Rows = (row: S) => Schema.NullOr(Schema.Array(row)) diff --git a/apps/web/src/page/alerts/rule/rule.scene.test.ts b/apps/web/src/page/alerts/rule/rule.scene.test.ts index a6cb93c90a..d576df271c 100644 --- a/apps/web/src/page/alerts/rule/rule.scene.test.ts +++ b/apps/web/src/page/alerts/rule/rule.scene.test.ts @@ -27,7 +27,7 @@ import { ToggleRuleEnabled } from "./command" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update } from "./update" -import { view as pageView } from "./view" +import { breadcrumbsOf, view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -138,7 +138,7 @@ describe("alert rule", () => { test("the Overview and History tab links keep the time range", () => { const search = { timePreset: "7d" } - const href = (tab: string) => `/alerts/${RULE}?tab=${tab}&timePreset=7d` + const href = (tab: string) => `/alerts/rules/${RULE}?tab=${tab}&timePreset=7d` scene( { update: pageUpdate, view }, given(loaded(search)), @@ -169,9 +169,18 @@ describe("alert rule", () => { given(model), themeResolved(), expectView(text("Rule not found")).toExist(), + expectView(role("link", { name: "Back to rules" })).toHaveAttr("href", "/alerts/rules"), ) }) + test("the crumbs go through Rules to the rule", () => { + expect(breadcrumbsOf(loaded())).toEqual([ + { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, + { label: "Payment p95 latency" }, + ]) + }) + test("a raw SQL rule slowed down by its timeouts shows its query time per check and why", () => { const check = ( minutesAgo: number, @@ -348,7 +357,7 @@ describe("alert rule", () => { }), ) expect(viewAll.outMessage).toEqual( - PageOutMessage.RequestedNavigation({ url: "/alerts?tab=overview", isReplace: false }), + PageOutMessage.RequestedNavigation({ url: "/alerts", isReplace: false }), ) }) }) diff --git a/apps/web/src/page/alerts/rule/update.ts b/apps/web/src/page/alerts/rule/update.ts index 06177371b6..6c56441c30 100644 --- a/apps/web/src/page/alerts/rule/update.ts +++ b/apps/web/src/page/alerts/rule/update.ts @@ -322,7 +322,7 @@ const updateMenu = (model: Model, menuId: string, message: Menu.Message): Return model: next, commands, outMessage: PageOutMessage.RequestedNavigation({ - url: alertsHref({ tab: "overview" }), + url: alertsHref({}), isReplace: false, }), } diff --git a/apps/web/src/page/alerts/rule/view-diagnosis.ts b/apps/web/src/page/alerts/rule/view-diagnosis.ts index 0de938ee0c..5a24e324e2 100644 --- a/apps/web/src/page/alerts/rule/view-diagnosis.ts +++ b/apps/web/src/page/alerts/rule/view-diagnosis.ts @@ -290,7 +290,7 @@ const stageAction = (h: H, action: NonNullable): Reado ] if (action.kind === "destinations") return [ - linkButton(h, { variant: "outline", size: "xs", href: alertsHref({ tab: "routing" }) }, [ + linkButton(h, { variant: "outline", size: "xs", href: alertsHref({}, "routing") }, [ action.label, ]), ] diff --git a/apps/web/src/page/alerts/rule/view.ts b/apps/web/src/page/alerts/rule/view.ts index 402a974f5f..f33fa6c523 100644 --- a/apps/web/src/page/alerts/rule/view.ts +++ b/apps/web/src/page/alerts/rule/view.ts @@ -200,7 +200,7 @@ export const view = Submodel.defineView((model, resourceNotFound(h, { title: "Rule not found", description: "This alert rule could not be found. It may have been deleted.", - back: { href: alertsHref({}), label: "Back to rules" }, + back: { href: alertsHref({}, "rules"), label: "Back to rules" }, }), ]) : dashboardPage(h, { ...header(h, model, rule, timeZone), tabs: tabStrip(h, model) }, [ @@ -229,6 +229,7 @@ export const breadcrumbsOf = (model: Model) => { const rule = model.rules === null ? null : ruleOf(model) return [ { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, { label: model.rules === null ? "Loading…" : rule === null ? "Not found" : rule.name }, ] } diff --git a/apps/web/src/page/alerts/schedule-form/command.ts b/apps/web/src/page/alerts/schedule-form/command.ts index ffa4c6ff33..e3672cba30 100644 --- a/apps/web/src/page/alerts/schedule-form/command.ts +++ b/apps/web/src/page/alerts/schedule-form/command.ts @@ -4,7 +4,7 @@ import { Clock, Effect, Schema } from "effect" import { Command } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { browserZone, readSupportedTimeZones } from "../../../platform/time-zone" -import { retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" +import { listOverrides, retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" import { listOrgMembers } from "../shared/org-members" import { Message } from "./message" @@ -27,6 +27,17 @@ export const FetchExistingSchedule = Command.define("FetchScheduleFormSchedule", ), }) +/** The edited schedule's overrides that have not ended, for the preview. */ +export const FetchScheduleFormOverrides = Command.define("FetchScheduleFormOverrides", { + args: { key: Schema.String, scheduleId: OnCallScheduleId }, + messages: [Message.CompletedFetchOverrides], + execute: ({ key, scheduleId }) => + listOverrides(scheduleId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchOverrides({ key, result })), + ), +}) + export const FetchScheduleFormMembers = Command.define("FetchScheduleFormMembers", { messages: [Message.CompletedFetchMembers], execute: listOrgMembers.pipe( diff --git a/apps/web/src/page/alerts/schedule-form/form.ts b/apps/web/src/page/alerts/schedule-form/form.ts index dbb603b9ee..2fad2e32ec 100644 --- a/apps/web/src/page/alerts/schedule-form/form.ts +++ b/apps/web/src/page/alerts/schedule-form/form.ts @@ -199,7 +199,7 @@ export type LayerErrors = Readonly<{ restrictions: ReadonlyArray> }> -const restrictionError = (restriction: RestrictionDraft): Option.Option => +export const restrictionError = (restriction: RestrictionDraft): Option.Option => restriction.weekdays.length === 0 ? Option.some("Pick at least one day") : !isLocalTime(restriction.startTime) || !isLocalTime(restriction.endTime) @@ -207,7 +207,7 @@ const restrictionError = (restriction: RestrictionDraft): Option.Option : Option.none() /** The layer's start: the saved instant while untouched, else its text on the zone's clock. */ -const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => +export const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => layer.startsAtMs === null ? localInputMs(layer.startsAt, timeZone) : Option.some(layer.startsAtMs) export const layerErrors = (layer: LayerDraft, timeZone: string): LayerErrors => { diff --git a/apps/web/src/page/alerts/schedule-form/message.ts b/apps/web/src/page/alerts/schedule-form/message.ts index 922636d3dc..53db9f5d13 100644 --- a/apps/web/src/page/alerts/schedule-form/message.ts +++ b/apps/web/src/page/alerts/schedule-form/message.ts @@ -1,12 +1,13 @@ import * as Policy from "@maple/domain/policy" import { OnCallWeekday, UserId } from "@maple/domain/http" -import { V2OnCallSchedule } from "@maple/domain/http/v2" +import { V2OnCallOverride, V2OnCallSchedule } from "@maple/domain/http/v2" import { Schema } from "effect" import { defineMessageUnion } from "foldkit/message" import { LoadFailure } from "@maple/views/data/load-failure" import * as Combobox from "@maple/views/ui/combobox" import * as Menu from "@maple/views/ui/menu" import { OrgMember } from "../shared/org-members" +import { RangeDays } from "../shared/coverage-preview" import { RotationKind } from "./form" const outcome = (success: S) => Schema.Result(success, LoadFailure) @@ -22,6 +23,8 @@ export const Message = defineMessageUnion({ ReadDefaults: { timeZone: Schema.String, nowMs: Schema.Number, zones: Schema.Array(Schema.String) }, CompletedFetchExisting: { key: Schema.String, result: outcome(V2OnCallSchedule) }, ClickedRetryExisting: {}, + CompletedFetchOverrides: { key: Schema.String, result: outcome(Schema.Array(V2OnCallOverride)) }, + ClickedRetryOverrides: {}, CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, ChangedName: { value: Schema.String }, @@ -46,6 +49,13 @@ export const Message = defineMessageUnion({ edge: Schema.Literals(["start", "end"]), value: Schema.String, }, + ClickedPreviewPrevious: {}, + ClickedPreviewNext: {}, + ClickedPreviewToday: {}, + PickedPreviewRange: { days: RangeDays }, + /** A gap in the summary: its bar on the timeline takes focus. */ + ClickedPreviewGap: { index: Schema.Number }, + CompletedFocusGap: {}, ClickedSave: {}, CompletedSave: { result: outcome(V2OnCallSchedule) }, }) diff --git a/apps/web/src/page/alerts/schedule-form/model.ts b/apps/web/src/page/alerts/schedule-form/model.ts index da4206ac16..9100139c76 100644 --- a/apps/web/src/page/alerts/schedule-form/model.ts +++ b/apps/web/src/page/alerts/schedule-form/model.ts @@ -1,6 +1,6 @@ import * as Policy from "@maple/domain/policy" import { OnCallScheduleId } from "@maple/domain/http" -import { V2OnCallSchedule } from "@maple/domain/http/v2" +import { V2OnCallOverride, V2OnCallSchedule } from "@maple/domain/http/v2" import { Array as Arr, Order, Schema, String as Str } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" @@ -8,6 +8,7 @@ import * as Combobox from "@maple/views/ui/combobox" import * as Menu from "@maple/views/ui/menu" import { AlertsScheduleNewSearch } from "../../../route/search" import { OrgMember } from "../shared/org-members" +import { RangeDays } from "../shared/coverage-preview" import { Form } from "./form" // `/alerts/schedules/new`: create an on-call schedule, or edit one (`?scheduleId=`). @@ -18,6 +19,8 @@ export const Model = Schema.Struct({ editingId: Schema.NullOr(OnCallScheduleId), /** The edited schedule's read; the form fills from it once. */ existing: KeyedSchema(V2OnCallSchedule, LoadFailure), + /** The edited schedule's overrides, which the preview layers over the draft; a new one has none. */ + overrides: KeyedSchema(Schema.Array(V2OnCallOverride), LoadFailure), form: Form, /** The browser's zone and clock arrived: a new form starts from them. */ isReady: Schema.Boolean, @@ -35,6 +38,9 @@ export const Model = Schema.Struct({ saveFailure: Schema.NullOr(LoadFailure), grants: Schema.Array(Policy.PermissionName), isSessionLoaded: Schema.Boolean, + /** The coverage preview: weeks from today, and how many days it shows. */ + previewWeekOffset: Schema.Number, + previewRange: RangeDays, }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts new file mode 100644 index 0000000000..e277485c83 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts @@ -0,0 +1,71 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { Option, Result, Schema, Array as Arr } from "effect" +import { + click, + expect as expectView, + find, + given, + role, + scene, + tap, + text, + type, + withViewInputs, +} from "foldkit/scene" +import { expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { memberId, ONCALL_NOW, oncallMembers } from "../shared/oncall-test-fixtures" +import { ORG } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// The preview renders the whole schedule, so it must not redo that work on edits it does not read. + +const context: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const staffed = (): Model => + Arr.reduce( + [ + Message.ReadDefaults({ + timeZone: "Europe/Berlin", + nowMs: ONCALL_NOW, + zones: ["UTC", "Europe/Berlin"], + }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), + Message.GotParticipantMenuMessage({ + key: "l0", + message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value: memberId(0) } }, + }), + ], + init({ _tag: "AlertsScheduleNew", search: {} }, context).model, + (model, message) => pageUpdate(model, message).model, + ) + +test("typing the name keeps the drawn preview; a new range draws it again", () => { + // The preview's rendered node: the same object while nothing it reads has changed. + const drawn: Array = [] + const keep = tap((simulation) => { + drawn.push(Option.getOrNull(find(simulation.html, '[data-slot="coverage-preview"]'))) + }) + scene( + { update: pageUpdate, view }, + given(staffed()), + expectView(text("Covered in this period")).toExist(), + keep, + type(role("textbox", { name: "Name" }), "Checkout primary"), + type(role("textbox", { name: "Description (optional)" }), "Who swaps with whom"), + keep, + click(role("button", { name: "4 weeks" })), + expectView(text("Covered in this period")).toExist(), + keep, + ) + expect(drawn[0]).not.toBeNull() + expect(drawn[1]).toBe(drawn[0]) + expect(drawn[2]).not.toBe(drawn[0]) +}) diff --git a/apps/web/src/page/alerts/schedule-form/preview.ts b/apps/web/src/page/alerts/schedule-form/preview.ts new file mode 100644 index 0000000000..e131afad45 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/preview.ts @@ -0,0 +1,194 @@ +import type { OnCallShift } from "@maple/alerting-core/oncall" +import { OnCallOverride, OnCallTimeZone } from "@maple/domain/http" +import type { V2OnCallOverride } from "@maple/domain/http/v2" +import { skeleton } from "@maple/views/ui/skeleton" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Array as Arr, Option, Schema, String as Str } from "effect" +import { AsyncData } from "foldkit" +import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" +import { + coveragePreview, + type PreviewNote, + type PreviewShift, + type RangeDays, +} from "../shared/coverage-preview" +import { memberName, type OrgMember } from "../shared/org-members" +import { draftCoverage, layerOnCallAt, previewLayerOf, previewRange } from "../shared/schedule-coverage" +import { isoMs } from "../shared/time" +import { layerStartMs, type LayerDraft } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { PREVIEW_SCOPE } from "./update" + +// The editor's live coverage preview: the draft rendered as it is edited, no save needed. + +const isZone = Schema.is(OnCallTimeZone) + +const layerName = (name: string, index: number) => + Str.trim(name) === "" ? `Layer ${index + 1}` : Str.trim(name) + +/** What the preview reads off the Model; it redraws only when one of these changes. */ +export type PreviewInputs = Readonly<{ + timeZone: string + layers: ReadonlyArray + members: ReadonlyArray | null + nowMs: number + /** A new schedule: its starts are the user's own, so an early one is worth explaining. */ + isNew: boolean +}> + +/** Plain-language notes on the draft's layers, one per thing the preview cannot say by itself. */ +export const previewNotes = (inputs: PreviewInputs): ReadonlyArray => { + const todayMs = isZone(inputs.timeZone) ? zonedDayStart(inputs.nowMs, inputs.timeZone) : inputs.nowMs + return Arr.flatMap(inputs.layers, (layer, index): ReadonlyArray => { + const name = layerName(layer.name, index) + if (layer.participants.length === 0) + return [{ tone: "warn", text: `${name} has no people, so it covers nothing.` }] + // A saved schedule's start is always behind it, and a new layer starts today by default. + const startedEarlier = + inputs.isNew && Option.exists(layerStartMs(layer, inputs.timeZone), (ms) => ms < todayMs) + if (!startedEarlier) return [] + const under = `${name} started before today, so the rotation is already under way` + return [ + { + tone: "info", + text: Option.match(layerOnCallAt(layer, inputs.timeZone, inputs.nowMs), { + onNone: () => `${under}.`, + onSome: (userId) => `${under}: ${memberName(inputs.members, userId)} is on call now.`, + }), + }, + ] + }) +} + +const decodeOverride = Schema.decodeUnknownResult(OnCallOverride) + +/** The API's overrides as the engine reads them; one it cannot read (a bad instant) is left out. */ +export const engineOverrides = (overrides: ReadonlyArray): ReadonlyArray => + Arr.filterMap(overrides, (override) => + decodeOverride({ + id: override.id, + userId: override.user_id, + startsAt: isoMs(override.starts_at), + endsAt: isoMs(override.ends_at), + createdAt: isoMs(override.created_at), + }), + ) + +/** + * Where the edited schedule's overrides stand: `ready` (a new schedule has none), `loading` + * (nothing can be said about gaps yet), or `failed` / `retrying` (the preview goes on without them). + */ +export type OverridesState = "ready" | "loading" | "failed" | "retrying" + +const NO_OVERRIDES: ReadonlyArray = [] + +export const overridesStateOf = (model: Pick): OverridesState => + model.editingId === null + ? "ready" + : AsyncData.isFailure(model.overrides.data) + ? model.overrides.retrying === true + ? "retrying" + : "failed" + : Option.isSome(AsyncData.getData(model.overrides.data)) + ? "ready" + : "loading" + +const asPreview = (shift: OnCallShift): PreviewShift => ({ + userId: shift.userId, + startMs: shift.startMs, + endMs: shift.endMs, + isOverride: shift.source.kind === "override", +}) + +const render = ( + h: HtmlBuilder, + zone: string, + layers: ReadonlyArray, + members: ReadonlyArray | null, + nowMs: number, + weekOffset: number, + rangeDays: RangeDays, + isNew: boolean, + overrides: ReadonlyArray, + overridesState: OverridesState, +): Html => { + const timeZone = isZone(zone) ? zone : "UTC" + const range = previewRange(nowMs, timeZone, weekOffset, Number(rangeDays)) + // Overrides beat every layer when paging, so the preview layers them over the draft too. + const coverage = draftCoverage( + { timeZone: zone, layers }, + engineOverrides(overrides), + range.startMs, + range.endMs, + nowMs, + ) + const overridesNote: ReadonlyArray> = + overridesState === "failed" || overridesState === "retrying" + ? [ + { + tone: "info", + text: "Overrides could not be loaded, so gaps may be filled.", + action: { + label: "Try again", + onClick: Message.ClickedRetryOverrides(), + isDisabled: overridesState === "retrying", + }, + }, + ] + : [] + return coveragePreview(h, { + scope: PREVIEW_SCOPE, + title: "Coverage preview", + timeZone, + fromMs: range.startMs, + toMs: range.endMs, + nowMs, + weekOffset, + rangeDays, + lanes: Arr.map(layers, (layer, index) => ({ + key: layer.key, + label: layerName(layer.name, index), + detail: `Layer ${index + 1}`, + shifts: Option.map(coverage.layers[index] ?? Option.none(), Arr.map(asPreview)), + emptyText: + layer.participants.length === 0 + ? "Add people to see shifts" + : Option.isNone(previewLayerOf(layer, timeZone)) + ? "Fix this layer to see shifts" + : "Not active in this period", + })), + final: Arr.map(coverage.final, asPreview), + gaps: coverage.gaps, + nameOf: (userId) => memberName(members, userId), + onPrevious: Message.ClickedPreviewPrevious(), + onNext: Message.ClickedPreviewNext(), + onToday: Message.ClickedPreviewToday(), + onRange: (days) => Message.PickedPreviewRange({ days }), + onFocusGap: (index) => Message.ClickedPreviewGap({ index }), + // A draft has no past to look back on. + isPreviousDisabled: weekOffset <= 0, + notes: [...overridesNote, ...previewNotes({ timeZone: zone, layers, members, nowMs, isNew })], + // Until the overrides land nothing can be said about gaps: one may cover them. + placeholder: overridesState === "loading" ? skeleton(h, "h-40 w-full") : undefined, + }) +} + +const slot = createLazy() + +const MINUTE_MS = 60_000 + +/** Redrawn only when the zone, layers, members, overrides, minute or range change, not on every key. */ +export const schedulePreview = (h: HtmlBuilder, model: Model): Html => + slot(render, [ + h, + model.form.timeZone, + model.form.layers, + model.members, + Math.floor(model.nowMs / MINUTE_MS) * MINUTE_MS, + model.previewWeekOffset, + model.previewRange, + model.editingId === null, + Option.getOrElse(AsyncData.getData(model.overrides.data), () => NO_OVERRIDES), + overridesStateOf(model), + ]) diff --git a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts index f290843e39..076c3e5de4 100644 --- a/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/schedule-form.scene.test.ts @@ -6,6 +6,7 @@ import { expect as expectView, given, role, scene, text, withViewInputs } from " import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import { + checkoutOverrides, checkoutSchedule, memberId, ONCALL_NOW, @@ -49,6 +50,26 @@ const ready = (search: { scheduleId?: string } = {}) => { ]) } +const memberNameOf = (index: number) => oncallMembers[index]?.name ?? "" + +/** The saved checkout schedule cut down to one weeknights layer, so its days and weekends are gaps. */ +const weeknightsOnly = (model: Model) => + run(model, [ + Message.ClickedRemoveLayer({ key: "l2" }), + Message.ClickedRemoveLayer({ key: "l1" }), + Message.ClickedAddRestriction({ key: "l0" }), + ]) + +const overridesFailure = { + _tag: "@maple/http/errors/AlertPersistenceError", + type: "api_error", + code: "persistence_error", + title: "Couldn't read", + message: "Couldn't read the overrides", + retryable: false, + recovery: "retry", +} as const + const addPerson = (key: string, index: number) => Message.GotParticipantMenuMessage({ key, @@ -306,4 +327,217 @@ describe("schedule form", () => { "FetchScheduleFormSchedule", ) }) + + describe("coverage preview", () => { + test("a layer with nobody in it leaves the period uncovered and says why", () => { + scene( + { update: pageUpdate, view }, + given(ready()), + expectView(text("Primary has no people, so it covers nothing.")).toExist(), + expectView(text("Nobody is on call in this period")).toExist(), + expectView(text("Add people to see shifts")).toExist(), + // One gap that is the whole range has no link of its own. + expectView(role("button", { name: /^From /u })).not.toExist(), + expectView(role("img", { name: "No one on call from Tue 10 Nov 11:30" })).toExist(), + ) + }) + + test("adding a person updates the preview to covered, and a restriction opens gaps", () => { + const staffed = run(ready(), [addPerson("l0", 0)]) + scene( + { update: pageUpdate, view }, + given(staffed), + expectView(text("Covered in this period")).toExist(), + // A new layer starts today at 09:00, which is not worth a note even after 09:00. + expectView(text(/started before today/u)).not.toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).toExist(), + ) + // Weeknights only (18:00 to 08:00): days and weekends are gaps. + const nights = run(staffed, [Message.ClickedAddRestriction({ key: "l0" })]) + scene( + { update: pageUpdate, view }, + given(nights), + expectView( + text("10 gaps in this period, longest 2d 10h (Sat 14 Nov 08:00 to Mon 16 Nov 18:00)"), + ).toExist(), + expectView(role("button", { name: "Sat 14 Nov 08:00 to Mon 16 Nov 18:00" })).toExist(), + ) + const focus = pageUpdate(nights, Message.ClickedPreviewGap({ index: 4 })) + expect(names(focus.commands)).toEqual(["FocusCoverageGap"]) + }) + + test("a window with no days stops the layer previewing instead of reading as always active", () => { + const nights = run(ready(), [addPerson("l0", 0), Message.ClickedAddRestriction({ key: "l0" })]) + const noDays = run( + nights, + Arr.map(["mon", "tue", "wed", "thu", "fri"] as const, (weekday) => + Message.ToggledRestrictionDay({ key: "l0", restrictionKey: "r1", weekday }), + ), + ) + scene( + { update: pageUpdate, view }, + given(noDays), + expectView(text("Fix this layer to see shifts")).toExist(), + expectView(text("Nobody is on call in this period")).toExist(), + expectView(text("Covered in this period")).not.toExist(), + ) + }) + + test("a new schedule started on an earlier day says the rotation is under way and who has it", () => { + // Two people, daily at 09:00 from Sun 8 Nov: by Tue 10 Nov 11:30 it is the first one's turn again. + const early = run(ready(), [ + addPerson("l0", 0), + addPerson("l0", 1), + Message.PickedRotation({ key: "l0", rotation: "daily" }), + Message.ChangedLayerText({ key: "l0", field: "startsAt", value: "2026-11-08T09:00" }), + ]) + scene( + { update: pageUpdate, view }, + given(early), + expectView( + text( + `Primary started before today, so the rotation is already under way: ${memberNameOf(0)} is on call now.`, + ), + ).toExist(), + ) + // A saved schedule always started in the past: no note for it. + scene( + { update: pageUpdate, view }, + given(ready({ scheduleId: schedulePublicId(1) })), + expectView(text(/started before today/u)).not.toExist(), + ) + }) + + test("a draft has no past: Previous is off from today", () => { + const model = ready() + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("button", { name: "Previous week" })).toBeDisabled(), + ) + expect(pageUpdate(model, Message.ClickedPreviewPrevious()).model.previewWeekOffset).toBe(0) + const later = run(model, [Message.ClickedPreviewNext()]) + scene( + { update: pageUpdate, view }, + given(later), + expectView(role("button", { name: "Previous week" })).not.toBeDisabled(), + ) + }) + + test("the range switch and week steps change what the preview covers", () => { + const staffed = run(ready(), [addPerson("l0", 0), Message.PickedPreviewRange({ days: "7" })]) + scene( + { update: pageUpdate, view }, + given(staffed), + expectView(text("Covered in this period")).toExist(), + expectView(text("10 Nov to 16 Nov")).toExist(), + ) + const later = run(staffed, [Message.ClickedPreviewNext()]) + scene( + { update: pageUpdate, view }, + given(later), + expectView(text("Covered in this period")).toExist(), + expectView(text("17 Nov to 23 Nov")).toExist(), + ) + expect(run(later, [Message.ClickedPreviewToday()]).previewWeekOffset).toBe(0) + }) + }) + + describe("overrides in the preview of a saved schedule", () => { + test("an edit reads the schedule's overrides; a new schedule asks for none", () => { + expect(names(init({ _tag: "AlertsScheduleNew", search: {} }, context).commands)).not.toContain( + "FetchScheduleFormOverrides", + ) + expect( + names( + init({ _tag: "AlertsScheduleNew", search: { scheduleId: schedulePublicId(1) } }, context) + .commands, + ), + ).toContain("FetchScheduleFormOverrides") + // A new schedule's preview does not wait on anything. + scene( + { update: pageUpdate, view }, + given(run(ready(), [addPerson("l0", 0)])), + expectView(text("Covered in this period")).toExist(), + ) + }) + + test("until the overrides land the timeline is a skeleton and nothing is said about gaps", () => { + scene( + { update: pageUpdate, view }, + given(weeknightsOnly(ready({ scheduleId: schedulePublicId(1) }))), + expectView(text("Coverage preview")).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), + expectView(text(/gaps? in this period/u)).not.toExist(), + expectView(text(/^Covered/u)).not.toExist(), + expectView(text(/^Nobody is on call/u)).not.toExist(), + ) + }) + + test("an override covering part of a gap fills it, as it does when paging", () => { + const editing = weeknightsOnly(ready({ scheduleId: schedulePublicId(1) })) + // Dana Kim covers Fri 13 Nov 12:00 to Sat 14 Nov 12:00, over the Friday and weekend gaps. + const loaded = run(editing, [ + Message.CompletedFetchOverrides({ + key: editing.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(ONCALL_NOW)), + }), + ]) + scene( + { update: pageUpdate, view }, + given(loaded), + expectView( + role("img", { name: "Override: Dana Kim, Fri 13 Nov, 12:00 to Sat 14 Nov, 12:00" }), + ).toExist(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 12:00" })).toExist(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 18:00" })).not.toExist(), + expectView(role("button", { name: "Sat 14 Nov 12:00 to Mon 16 Nov 18:00" })).toExist(), + // The first weekend is partly covered now, so the second is the longest gap. + expectView( + text("10 gaps in this period, longest 2d 10h (Sat 21 Nov 08:00 to Mon 23 Nov 18:00)"), + ).toExist(), + expectView(text(/Overrides are not shown/u)).not.toExist(), + expectView(text(/Overrides could not be loaded/u)).not.toExist(), + ) + }) + + test("a failed overrides read previews without them, says so and offers another try", () => { + const editing = weeknightsOnly(ready({ scheduleId: schedulePublicId(1) })) + const failed = run(editing, [ + Message.CompletedFetchOverrides({ + key: editing.overrides.key ?? "", + result: Result.fail(overridesFailure), + }), + ]) + scene( + { update: pageUpdate, view }, + given(failed), + expectView(text(/Overrides could not be loaded, so gaps may be filled\./u)).toExist(), + expectView(role("button", { name: "Try again" })).not.toBeDisabled(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 18:00" })).toExist(), + expectView(role("img", { name: /^Override:/u })).not.toExist(), + ) + const retried = pageUpdate(failed, Message.ClickedRetryOverrides()) + expect(names(retried.commands)).toEqual(["FetchScheduleFormOverrides"]) + // The note stays up while the retry is out, with its link off. + scene( + { update: pageUpdate, view }, + given(retried.model), + expectView(text(/Overrides could not be loaded/u)).toExist(), + expectView(role("button", { name: "Try again" })).toBeDisabled(), + ) + const recovered = run(retried.model, [ + Message.CompletedFetchOverrides({ + key: retried.model.overrides.key ?? "", + result: Result.succeed(checkoutOverrides(ONCALL_NOW)), + }), + ]) + scene( + { update: pageUpdate, view }, + given(recovered), + expectView(text(/Overrides could not be loaded/u)).not.toExist(), + expectView(role("button", { name: "Fri 13 Nov 08:00 to 12:00" })).toExist(), + ) + }) + }) }) diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts index f8ee3957de..01b868656f 100644 --- a/apps/web/src/page/alerts/schedule-form/update.ts +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -1,4 +1,4 @@ -import { UserId } from "@maple/domain/http" +import { type OnCallScheduleId, UserId } from "@maple/domain/http" import { Option, Result, Schema, Array as Arr } from "effect" import { AsyncData, Command as FoldkitCommand } from "foldkit" import * as Keyed from "@maple/views/data/keyed" @@ -7,10 +7,12 @@ import * as Combobox from "@maple/views/ui/combobox" import * as Menu from "@maple/views/ui/menu" import type { AppRoute } from "../../../route" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { DEFAULT_RANGE_DAYS, FocusGap } from "../shared/coverage-preview" import { scheduleHref, scheduleIdFromUrl } from "../shared/oncall-schedules" import { FetchExistingSchedule, FetchScheduleFormMembers, + FetchScheduleFormOverrides, FetchScheduleFormSession, ReadScheduleFormDefaults, SaveSchedule, @@ -35,8 +37,23 @@ type FormRoute = Extract export const ZONE_PICKER_ID = "schedule-form-zone" +/** Prefixes the preview's gap bar ids. */ +export const PREVIEW_SCOPE = "schedule-preview" + export const participantMenuId = (layerKey: string) => `schedule-form-people-${layerKey}` +/** Reads the edited schedule's overrides (again), for the preview. */ +const requestOverrides = (model: Model, context: PageContext, scheduleId: OnCallScheduleId) => { + const overrides = Keyed.supersede( + model.overrides, + requestKey(context.orgId, "oncallSchedules.listOverrides", { id: scheduleId }), + ) + return { + model: { ...model, overrides }, + commands: [FetchScheduleFormOverrides({ key: overrides.key, scheduleId })], + } +} + export const init = (route: FormRoute, context: PageContext): Return => { const editingId = Option.getOrNull( Option.flatMap(Option.fromNullishOr(route.search.scheduleId), scheduleIdFromUrl), @@ -45,6 +62,7 @@ export const init = (route: FormRoute, context: PageContext): Return => { search: route.search, editingId, existing: Keyed.idle(), + overrides: Keyed.idle(), form: emptyForm("UTC", 0), isReady: false, nowMs: 0, @@ -58,19 +76,27 @@ export const init = (route: FormRoute, context: PageContext): Return => { saveFailure: null, grants: [], isSessionLoaded: false, + previewWeekOffset: 0, + previewRange: DEFAULT_RANGE_DAYS, } const common = [ ReadScheduleFormDefaults(), FetchScheduleFormMembers(), FetchScheduleFormSession({ orgId: context.orgId }), ] + // A new schedule has no overrides, so nothing is read for it. if (editingId === null) return { model, commands: common } + const overrides = requestOverrides(model, context, editingId) const key = requestKey(context.orgId, "oncallSchedules.retrieve", { id: editingId }) return Option.match(Keyed.request(model.existing, key, "Load"), { - onNone: () => ({ model, commands: common }), + onNone: () => ({ model: overrides.model, commands: [...common, ...overrides.commands] }), onSome: (existing) => ({ - model: { ...model, existing }, - commands: [...common, FetchExistingSchedule({ key, scheduleId: editingId })], + model: { ...overrides.model, existing }, + commands: [ + ...common, + FetchExistingSchedule({ key, scheduleId: editingId }), + ...overrides.commands, + ], }), }) } @@ -263,6 +289,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re commands: [FetchExistingSchedule({ key: existing.key, scheduleId: editingId })], } }, + CompletedFetchOverrides: ({ key, result }) => ({ + model: { ...model, overrides: Keyed.accept(model.overrides, key, result) }, + }), + ClickedRetryOverrides: () => + model.editingId === null ? { model } : requestOverrides(model, context, model.editingId), ChangedLayerText: ({ key, field, value }) => field === "startsAt" ? editLayer(model, key, (layer) => ({ ...layer, startsAt: value, startsAtMs: null })) @@ -301,6 +332,22 @@ export const update = (model: Model, message: Message, context: PageContext): Re editRestriction(model, key, restrictionKey, (restriction) => edge === "start" ? { ...restriction, startTime: value } : { ...restriction, endTime: value }, ), + // A draft has no past: the preview never steps back from today. + ClickedPreviewPrevious: () => ({ + model: { ...model, previewWeekOffset: Math.max(0, model.previewWeekOffset - 1) }, + }), + ClickedPreviewNext: () => ({ model: { ...model, previewWeekOffset: model.previewWeekOffset + 1 } }), + ClickedPreviewToday: () => ({ model: { ...model, previewWeekOffset: 0 } }), + PickedPreviewRange: ({ days }) => ({ model: { ...model, previewRange: days } }), + ClickedPreviewGap: ({ index }) => ({ + model, + commands: [ + FoldkitCommand.mapMessage(FocusGap({ scope: PREVIEW_SCOPE, index }), () => + Message.CompletedFocusGap(), + ), + ], + }), + CompletedFocusGap: () => ({ model }), ClickedSave: () => save(model), CompletedSave: ({ result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 3dc01e80da..05e63085ab 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -49,6 +49,7 @@ import { } from "./form" import { type LayerField, Message } from "./message" import { canManage, type Model, zoneItems } from "./model" +import { schedulePreview } from "./preview" import { participantMenuId } from "./update" import { stripClass } from "@maple/views/ui/rule" @@ -557,9 +558,37 @@ export const view = Submodel.defineView((model, if (!model.isReady || (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, fill: true }, [ - formScrollArea(h, [ - formCard(h, [nameSection(h, model), zoneSection(h, model), layersSection(h, model)], FORM_WIDTH), - ]), + formScrollArea( + h, + [ + // From 46rem of room (a 1024px screen beside the sidebar) the form keeps a narrow column and + // the preview takes the rest, held in view while the form scrolls. Under that it follows the form. + h.div( + [ + h.Class( + "grid gap-4 @min-[46rem]/scroll:grid-cols-[26rem_minmax(0,1fr)] @min-[46rem]/scroll:items-start", + ), + ], + [ + formCard( + h, + [nameSection(h, model), zoneSection(h, model), layersSection(h, model)], + FORM_WIDTH, + ), + h.div( + [ + h.Class( + "min-w-0 rounded-md border bg-card p-4 @min-[46rem]/scroll:sticky @min-[46rem]/scroll:top-0 @min-[46rem]/scroll:max-h-[100cqh] @min-[46rem]/scroll:overflow-y-auto", + ), + ], + [schedulePreview(h, model)], + ), + ], + ), + ], + // A size container: the columns read its width and the preview's height limit its height. + "@container-[size]/scroll", + ), actionBar(h, model), ]) }) diff --git a/apps/web/src/page/alerts/schedule/message.ts b/apps/web/src/page/alerts/schedule/message.ts index cf1187acc6..7607bca282 100644 --- a/apps/web/src/page/alerts/schedule/message.ts +++ b/apps/web/src/page/alerts/schedule/message.ts @@ -5,6 +5,7 @@ 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 { RangeDays } from "../shared/coverage-preview" import { OrgMember } from "../shared/org-members" const outcome = (success: S) => Schema.Result(success, LoadFailure) @@ -26,6 +27,10 @@ export const Message = defineMessageUnion({ ClickedPreviousWeek: {}, ClickedNextWeek: {}, ClickedThisWeek: {}, + PickedRange: { days: RangeDays }, + /** A gap in the summary: its bar on the calendar takes focus. */ + ClickedGap: { index: Schema.Number }, + CompletedFocusGap: {}, /** A final shift's bar: an override over its whole range, for someone else to cover. */ ClickedShift: { startMs: Schema.Number, endMs: Schema.Number }, ClickedNewOverride: {}, diff --git a/apps/web/src/page/alerts/schedule/model.ts b/apps/web/src/page/alerts/schedule/model.ts index 545d5434ef..68afb4905e 100644 --- a/apps/web/src/page/alerts/schedule/model.ts +++ b/apps/web/src/page/alerts/schedule/model.ts @@ -5,6 +5,7 @@ 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 { RangeDays } from "../shared/coverage-preview" import { OrgMember } from "../shared/org-members" // `/alerts/schedules/$scheduleId`: who is on call, the calendar of shifts, and the overrides. @@ -30,6 +31,8 @@ export const Model = Schema.Struct({ scheduleAskedAtMs: Schema.NullOr(Schema.Number), /** Weeks from this one the calendar shows: 0 is the week starting today. */ weekOffset: Schema.Number, + /** How many days the calendar shows from its week's first day. */ + rangeDays: RangeDays, shifts: KeyedSchema(V2OnCallShifts, LoadFailure), overrides: KeyedSchema(Schema.Array(V2OnCallOverride), LoadFailure), /** `null` until Clerk answers, and after a failed read: names then read neutrally. */ diff --git a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts index 4866bbee22..575b910021 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -2,7 +2,7 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { OnCallOverrideId } from "@maple/domain/http" import { zonedDayStart } from "@maple/views/ui/shift-timeline" -import { Option, Result, Schema, Array as Arr } from "effect" +import { Option, Result, Schema, Array as Arr, String as Str } from "effect" import { AsyncData } from "foldkit" import { isoMs, msIso } from "../shared/time" import { memberName } from "../shared/org-members" @@ -92,9 +92,7 @@ describe("on-call schedule page", () => { given(loaded(ONCALL_NOW)), expectView(text("Checkout primary")).toExist(), expectView( - text( - "Times in Europe/Berlin. Yours is UTC. Pick a shift in the final row to cover it with an override.", - ), + text("Your time zone is UTC. Pick a shift in the final row to cover it with an override."), ).toExist(), expectView(role("rowheader", { name: /Final schedule/u })).toExist(), expectView(role("rowheader", { name: /Weeknights/u })).toExist(), @@ -104,13 +102,35 @@ describe("on-call schedule page", () => { ) }) + test("the calendar's heading carries the whole schedule name as its title, for a name too long to show", () => { + const entered = init({ _tag: "AlertsSchedule", scheduleId: schedulePublicId(1), search: {} }, context) + const name = `checkout-primary-${Str.repeat(120)("x")}` + const long = run(entered.model, [ + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.ReadClock({ nowMs: ONCALL_NOW }), + Message.CompletedFetchSchedule({ + key: entered.model.schedule.key ?? "", + readAtMs: ONCALL_NOW, + result: Result.succeed({ ...checkoutSchedule(ONCALL_NOW), name }), + }), + ]) + scene( + { update: pageUpdate, view }, + given(long), + expectView(role("heading", { name: `Shifts for ${name}` })).toHaveAttr( + "title", + `Shifts for ${name}`, + ), + ) + }) + test("the DST week runs Thursday to Wednesday on the Berlin clock, through the fall-back Sunday", () => { const model = loaded(DST_NOW) scene( { update: pageUpdate, view }, given(model), expectView(text("Sun 25")).toExist(), - expectView(text("22 Oct to 28 Oct")).toExist(), + expectView(text("22 Oct to 4 Nov")).toExist(), ) }) @@ -122,7 +142,7 @@ describe("on-call schedule page", () => { scene( { update: pageUpdate, view }, given(next.model), - expectView(text("17 Nov to 23 Nov")).toExist(), + expectView(text("17 Nov to 30 Nov")).toExist(), expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), ) scene( @@ -394,7 +414,7 @@ describe("on-call schedule page", () => { given(loaded(ONCALL_NOW, MEMBER)), expectView(role("button", { name: "Add override" })).not.toExist(), expectView(role("button", { name: /^Delete Dana Kim's override, /u })).not.toExist(), - expectView(text("Times in Europe/Berlin. Yours is UTC.")).toExist(), + expectView(text("Your time zone is UTC.")).toExist(), ) }) @@ -456,4 +476,56 @@ describe("on-call schedule page", () => { expectView(text("Couldn't load members, try reloading the page")).toExist(), ) }) + + test("the calendar summarises coverage like the editor, with a range switch that reads the new range", () => { + const model = loaded(ONCALL_NOW) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Covered in this period")).toExist(), + expectView(role("group", { name: "Range" })).toExist(), + ) + const month = pageUpdate(model, Message.PickedRange({ days: "28" })) + expect(names(month.commands)).toEqual(["FetchOnCallShifts"]) + expect(Option.map(rangeOf(month.model), (r) => Math.round((r.toMs - r.fromMs) / 86_400_000))).toEqual( + Option.some(28), + ) + scene( + { update: pageUpdate, view }, + given(withShifts(month.model, ONCALL_NOW)), + expectView(text("10 Nov to 7 Dec")).toExist(), + ) + }) + + test("a wider range draws a skeleton, not the old shifts as one long gap, until its shifts land", () => { + const model = loaded(ONCALL_NOW) + const month = pageUpdate(model, Message.PickedRange({ days: "28" })) + scene( + { update: pageUpdate, view }, + given(month.model), + expectView(text("10 Nov to 7 Dec")).toExist(), + expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), + expectView(text(/gap/u)).not.toExist(), + expectView(text(/^Covered/u)).not.toExist(), + ) + }) + + test("a range that has wholly passed says its gaps are not checked, not that it was covered", () => { + const before = pageUpdate( + pageUpdate(loaded(ONCALL_NOW), Message.ClickedPreviousWeek()).model, + Message.ClickedPreviousWeek(), + ) + scene( + { update: pageUpdate, view }, + given(withShifts(before.model, ONCALL_NOW)), + expectView(text("27 Oct to 9 Nov")).toExist(), + expectView(text("Past shifts. Gaps are only checked from now.")).toExist(), + expectView(text(/^Covered/u)).not.toExist(), + ) + }) + + test("a gap in the summary focuses its bar on the calendar", () => { + const focus = pageUpdate(loaded(ONCALL_NOW), Message.ClickedGap({ index: 0 })) + expect(names(focus.commands)).toEqual(["FocusCoverageGap"]) + }) }) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts index 47b0e8f77d..51d897df50 100644 --- a/apps/web/src/page/alerts/schedule/update.ts +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -8,6 +8,7 @@ import * as Dialog from "@maple/views/ui/dialog" import { zonedDayStart } from "@maple/views/ui/shift-timeline" import type { AppRoute } from "../../../route" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { DEFAULT_RANGE_DAYS, FocusGap } from "../shared/coverage-preview" import { scheduleIdFromUrl } from "../shared/oncall-schedules" import { localInputMs, localInputOf } from "../schedule-form/form" import { @@ -30,9 +31,12 @@ type ScheduleRoute = Extract export const OVERRIDE_DIALOG_ID = "schedule-override" export const DELETE_OVERRIDE_DIALOG_ID = "schedule-delete-override" -/** The calendar shows one week of the schedule zone's days, from today. */ +/** The calendar steps a week of the schedule zone's days at a time. */ export const CALENDAR_DAYS = 7 +/** Prefixes the calendar's gap bar ids. */ +export const CALENDAR_SCOPE = "schedule-calendar" + export const scheduleOf = (model: Model): Option.Option => AsyncData.getData(model.schedule.data) @@ -46,7 +50,7 @@ export const rangeOf = (model: Model): Option.Option { schedule: Keyed.idle(), scheduleAskedAtMs: null, weekOffset: 0, + rangeDays: DEFAULT_RANGE_DAYS, shifts: Keyed.idle(), overrides: Keyed.idle(), members: null, @@ -344,6 +349,16 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedPreviousWeek: () => moveWeek(model, context, model.weekOffset - 1), ClickedNextWeek: () => moveWeek(model, context, model.weekOffset + 1), ClickedThisWeek: () => moveWeek(model, context, 0), + PickedRange: ({ days }) => requestShifts({ ...model, rangeDays: days }, context, "Load"), + ClickedGap: ({ index }) => ({ + model, + commands: [ + FoldkitCommand.mapMessage(FocusGap({ scope: CALENDAR_SCOPE, index }), () => + Message.CompletedFocusGap(), + ), + ], + }), + CompletedFocusGap: () => ({ model }), // An ended shift has nothing left to cover; a running one is covered from now to its end. ClickedShift: ({ startMs, endMs }) => canManage(model) && endMs > (model.nowMs ?? 0) ? openOverride(model, startMs, endMs) : { model }, diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts index 90a45a91eb..b2bd2e7796 100644 --- a/apps/web/src/page/alerts/schedule/view.ts +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -15,7 +15,6 @@ import { input } from "@maple/views/ui/input" import { label } from "@maple/views/ui/label" import { nativeOption, nativeSelect } from "@maple/views/ui/native-select" import { personLabel } from "@maple/views/ui/person-avatar" -import { shiftTimeline, type TimelineBar, type TimelineRow } from "@maple/views/ui/shift-timeline" import { skeleton } from "@maple/views/ui/skeleton" import { hstack, vstack } from "@maple/views/ui/stack" import { typography, typographyClass } from "@maple/views/ui/typography" @@ -24,11 +23,14 @@ import { dashboardPage } from "../../kit/dashboard-page" import { detailActions, detailTitle, detailTitleSkeleton } from "../../kit/detail-header" import { resourceNotFound } from "../../kit/resource-not-found" import { sectionHeading } from "../../kit/section-heading" +import { coveragePreview, type PreviewShift } from "../shared/coverage-preview" import { editScheduleHref, formatShiftEdge, oncallTabHref } from "../shared/oncall-schedules" +import { gapsOf } from "../shared/schedule-coverage" import { memberName, memberOf, type OrgMember } from "../shared/org-members" import { Message } from "./message" import { canManage, type Model } from "./model" import { + CALENDAR_SCOPE, DELETE_OVERRIDE_DIALOG_ID, OVERRIDE_DIALOG_ID, overrideIssue, @@ -58,145 +60,100 @@ const onCallLine = (model: Model, schedule: V2OnCallSchedule): string => `${memberName(model.members, now.user_id)} is on call until ${formatShiftEdge(isoMs(now.ends_at), schedule.time_zone)}`, }) -/** A shift as a bar: the person's name, and who and when in its tooltip. */ -const barOf = ( - model: Model, - schedule: V2OnCallSchedule, - shift: V2OnCallShift, - key: string, - isFinal: boolean, -): TimelineBar => { - const startMs = isoMs(shift.starts_at) - const endMs = isoMs(shift.ends_at) - const name = memberName(model.members, shift.user_id) - const isOverride = shift.source.kind === "override" - const bar: TimelineBar = { - key, - startMs, - endMs, - label: name, - title: `${isOverride ? "Override: " : ""}${name}, ${rangeText(startMs, endMs, schedule.time_zone)}`, - tone: isOverride ? "info" : isFinal ? "primary" : "neutral", - } - // Only a final shift that has not ended opens an override, and only for someone who may add one. - return isFinal && canManage(model) && endMs > (model.nowMs ?? 0) - ? { ...bar, onClick: Message.ClickedShift({ startMs, endMs }) } - : bar -} - -/** The final row on top, then the layers from the top one down, as they stack. */ -const timelineRows = ( - model: Model, - schedule: V2OnCallSchedule, - shifts: V2OnCallShifts, -): ReadonlyArray> => [ - { - key: "final", - label: "Final schedule", - detail: "Who gets paged", - bars: Arr.map(shifts.final, (shift, index) => barOf(model, schedule, shift, `final-${index}`, true)), - emptyText: "Nobody on call in this period", - }, - ...Arr.reverse( - Arr.map(schedule.layers, (layer, index): TimelineRow => ({ - key: `layer-${index}`, - label: layer.name, - detail: `Layer ${index + 1}`, - bars: Arr.map(shifts.layers[index] ?? [], (shift, at) => - barOf(model, schedule, shift, `layer-${index}-${at}`, false), - ), - emptyText: "Not active in this period", - })), - ), -] - -/** "10 Nov to 16 Nov": the first and last day the calendar shows. */ -const weekLabel = (fromMs: number, toMs: number, timeZone: string): string => { - const day = new Intl.DateTimeFormat("en-GB", { timeZone, day: "numeric", month: "short" }) - return `${day.format(fromMs)} to ${day.format(toMs - 1)}` -} +const previewShiftOf = (shift: V2OnCallShift): PreviewShift => ({ + userId: shift.user_id, + startMs: isoMs(shift.starts_at), + endMs: isoMs(shift.ends_at), + isOverride: shift.source.kind === "override", +}) -/** The calendar's header: the week, prev/this week/next, and which zone the times read in. */ +/** The calendar: the same coverage timeline the editor previews, from the server's shifts. */ const calendarSection = (h: H, model: Model, schedule: V2OnCallSchedule, viewerZone: string): Html => { const range = rangeOf(model) const nowMs = model.nowMs ?? 0 - // Shifts kept from another week (a week's read still in flight) draw as the skeleton, not blank rows. - const timeline = (fromMs: number, toMs: number, shifts: V2OnCallShifts): Html => - !Option.exists(readRangeOf(model), (read) => isoMs(shifts.from) === read.fromMs) - ? skeleton(h, "h-40 w-full") - : shiftTimeline(h, { - fromMs, - toMs, - timeZone: schedule.time_zone, - nowMs, - rows: timelineRows(model, schedule, shifts), - ariaLabel: `Shifts for ${schedule.name}`, - nowLabel: `Now, ${formatShiftEdge(nowMs, schedule.time_zone)}`, - legend: [ - { tone: "primary", label: "On call" }, - { tone: "info", label: "Override" }, - { tone: "neutral", label: "Layer shift" }, - ], - }) + // The axis names the schedule's zone; the note only says when the viewer's differs. + const noteParts = [ + ...(viewerZone === schedule.time_zone ? [] : [`Your time zone is ${viewerZone}.`]), + ...(canManage(model) ? ["Pick a shift in the final row to cover it with an override."] : []), + ] + const note = noteParts.length === 0 ? undefined : Arr.join(noteParts, " ") + const preview = ( + fromMs: number, + toMs: number, + shifts: Option.Option, + placeholder?: Html, + ): Html => { + const final = Option.match(shifts, { + onNone: () => [], + onSome: (s) => Arr.map(s.final, previewShiftOf), + }) + return coveragePreview(h, { + scope: CALENDAR_SCOPE, + title: `Shifts for ${schedule.name}`, + timeZone: schedule.time_zone, + fromMs, + toMs, + nowMs, + weekOffset: model.weekOffset, + rangeDays: model.rangeDays, + lanes: Arr.map(schedule.layers, (layer, index) => ({ + key: `layer-${index}`, + label: layer.name, + detail: `Layer ${index + 1}`, + shifts: Option.map(shifts, (s) => Arr.map(s.layers[index] ?? [], previewShiftOf)), + emptyText: "Not active in this period", + })), + final, + gaps: gapsOf(final, Math.max(fromMs, nowMs), toMs), + nameOf: (userId) => memberName(model.members, userId), + onPrevious: Message.ClickedPreviousWeek(), + onNext: Message.ClickedNextWeek(), + onToday: Message.ClickedThisWeek(), + onRange: (days) => Message.PickedRange({ days }), + onFocusGap: (index) => Message.ClickedGap({ index }), + // Only a final shift that has not ended opens an override, and only for someone who may add one. + onFinalShift: (shift) => + canManage(model) && shift.endMs > nowMs + ? Option.some(Message.ClickedShift({ startMs: shift.startMs, endMs: shift.endMs })) + : Option.none(), + note, + placeholder, + }) + } + const loading = (fromMs: number, toMs: number) => + preview(fromMs, toMs, Option.none(), skeleton(h, "h-40 w-full")) + // Shifts kept from another range (a read in flight, either end moved) draw as the skeleton: a + // shorter read on a longer axis would show its missing days as one long gap. + const loaded = (fromMs: number, toMs: number, shifts: V2OnCallShifts): Html => + Option.exists( + readRangeOf(model), + (read) => isoMs(shifts.from) === read.fromMs && isoMs(shifts.to) === read.toMs, + ) + ? preview(fromMs, toMs, Option.some(shifts)) + : loading(fromMs, toMs) const body = Option.match(range, { onNone: () => skeleton(h, "h-40 w-full"), onSome: ({ fromMs, toMs }) => AsyncData.match(model.shifts.data, { - onIdle: () => skeleton(h, "h-40 w-full"), - onLoading: () => skeleton(h, "h-40 w-full"), + onIdle: () => loading(fromMs, toMs), + onLoading: () => loading(fromMs, toMs), onFailure: (failure) => - errorState(h, { - failure, - title: "Couldn't load the shifts", - onRetry: Message.ClickedRetryShifts(), - }), - onRefreshing: (shifts) => timeline(fromMs, toMs, shifts), - onStale: ({ data }) => timeline(fromMs, toMs, data), - onSuccess: (shifts) => timeline(fromMs, toMs, shifts), - }), - }) - const zoneNote = - viewerZone === schedule.time_zone - ? `Times in ${schedule.time_zone}.` - : `Times in ${schedule.time_zone}. Yours is ${viewerZone}.` - return h.section( - [h.Class("space-y-3")], - [ - sectionHeading(h, { - title: Option.match(range, { - onNone: () => "This week", - onSome: ({ fromMs, toMs }) => weekLabel(fromMs, toMs, schedule.time_zone), - }), - actions: hstack(h, { gap: "1" }, [ - iconButton( - h, - { label: "Previous week", attributes: [h.OnClick(Message.ClickedPreviousWeek())] }, - [icon(h, "ChevronLeftIcon")], + preview( + fromMs, + toMs, + Option.none(), + errorState(h, { + failure, + title: "Couldn't load the shifts", + onRetry: Message.ClickedRetryShifts(), + }), ), - button( - h, - { - size: "sm", - variant: "outline", - isDisabled: model.weekOffset === 0, - attributes: [h.OnClick(Message.ClickedThisWeek())], - }, - ["Today"], - ), - iconButton( - h, - { label: "Next week", attributes: [h.OnClick(Message.ClickedNextWeek())] }, - [icon(h, "ChevronRightIcon")], - ), - ]), + onRefreshing: (shifts) => loaded(fromMs, toMs, shifts), + onStale: ({ data }) => loaded(fromMs, toMs, data), + onSuccess: (shifts) => loaded(fromMs, toMs, shifts), }), - typography(h, { size: "xs", tone: "muted", as: "p" }, [ - zoneNote, - ...(canManage(model) ? [" Pick a shift in the final row to cover it with an override."] : []), - ]), - body, - ], - ) + }) + return h.section([h.Class("space-y-3")], [body]) } const overrideRow = (h: H, model: Model, schedule: V2OnCallSchedule, override: V2OnCallOverride): Html => { 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`, + ]), + ], + ), + ] + : []), + ], + ), + ], + } +} diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts new file mode 100644 index 0000000000..de9569b886 --- /dev/null +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -0,0 +1,354 @@ +import { initialsFrom } from "@maple/ui/lib/initials" +import { Array as Arr, Effect, Option, Schema, String as Str } from "effect" +import { Command, Dom } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { button } from "@maple/views/ui/button" +import { heading } from "@maple/views/ui/heading" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { shiftTimeline, type TimelineBar, type TimelineRow } from "@maple/views/ui/shift-timeline" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { typography } from "@maple/views/ui/typography" +import { formatShiftEdge } from "./oncall-schedules" +import { gapRangeText, gapSummary, isUncovered, type Interval } from "./schedule-coverage" + +// The coverage timeline both the schedule editor (from the draft) and the detail page (from the +// server's shifts) draw: a lane per layer, the final lane with its "No one" gaps, and the controls. + +export const RangeDays = Schema.Literals(["7", "14", "28"]) +export type RangeDays = typeof RangeDays.Type +export const DEFAULT_RANGE_DAYS: RangeDays = "14" + +const RANGE_OPTIONS: ReadonlyArray> = [ + { value: "7", label: "1 week" }, + { value: "14", label: "2 weeks" }, + { value: "28", label: "4 weeks" }, +] + +/** The summary links this many gaps in a narrow preview and in a wide one; the rest are on the timeline. */ +const NARROW_GAP_LINKS = 3 +const WIDE_GAP_LINKS = 6 + +/** The element id of a gap's bar, so its summary link can scroll to and focus it. */ +export const gapBarId = (scope: string, index: number): string => `${scope}-gap-${index}` + +export const GapFocusMessage = defineMessageUnion({ CompletedFocusGap: {} }) + +/** A gap's bar takes focus, which scrolls it into view; a page lifts the result into its own message. */ +export const FocusGap = Command.define("FocusCoverageGap", { + args: { scope: Schema.String, index: Schema.Number }, + messages: [GapFocusMessage.CompletedFocusGap], + execute: ({ scope, index }) => + Dom.focus(`#${gapBarId(scope, index)}`).pipe( + Effect.ignore, + Effect.as(GapFocusMessage.CompletedFocusGap()), + ), +}) + +export type PreviewShift = Readonly<{ userId: string; startMs: number; endMs: number; isOverride: boolean }> + +export type PreviewLane = Readonly<{ + key: string + label: string + detail: string + /** None while the layer cannot be drawn; `emptyText` says why. */ + shifts: Option.Option> + emptyText: string +}> + +/** + * A plain-language line over the timeline: `warn` changes what is covered, `info` only explains. + * `action` is a link after the text ("Try again"). + */ +export type PreviewNote = Readonly<{ + tone: "info" | "warn" + text: string + action?: Readonly<{ label: string; onClick: M; isDisabled?: boolean }> +}> + +export type CoveragePreviewOptions = Readonly<{ + /** Prefixes the gap bars' ids, unique on the page. */ + scope: string + title: string + timeZone: string + fromMs: number + toMs: number + nowMs: number + weekOffset: number + rangeDays: RangeDays + lanes: ReadonlyArray + final: ReadonlyArray + gaps: ReadonlyArray + nameOf: (userId: string) => string + onPrevious: M + onNext: M + onToday: M + onRange: (days: RangeDays) => M + onFocusGap: (index: number) => M + /** A draft has no past: its preview cannot step back from today. */ + isPreviousDisabled?: boolean + /** A final shift's bar action (the detail page's "cover with an override"). */ + onFinalShift?: (shift: PreviewShift) => Option.Option + notes?: ReadonlyArray> + /** A line under the header. The axis names the zone, so this does not. */ + note?: string | undefined + /** Drawn in place of the summary and timeline while their data loads; the header stays. */ + placeholder?: Html | undefined +}> + +const rangeText = (startMs: number, endMs: number, timeZone: string) => + `${formatShiftEdge(startMs, timeZone)} to ${formatShiftEdge(endMs, timeZone)}` + +/** The lanes as timeline rows: the final row on top, then the layers top first, as they stack. */ +export const previewRows = (options: CoveragePreviewOptions): ReadonlyArray> => { + const shiftBar = (shift: PreviewShift, key: string, isFinal: boolean): TimelineBar => { + const name = options.nameOf(shift.userId) + const bar: TimelineBar = { + key, + startMs: shift.startMs, + endMs: shift.endMs, + label: name, + shortLabel: initialsFrom(name), + title: `${shift.isOverride ? "Override: " : ""}${name}, ${rangeText(shift.startMs, shift.endMs, options.timeZone)}`, + tone: shift.isOverride ? "info" : isFinal ? "primary" : "neutral", + } + const click = + isFinal && options.onFinalShift !== undefined ? options.onFinalShift(shift) : Option.none() + return Option.match(click, { onNone: () => bar, onSome: (onClick) => ({ ...bar, onClick }) }) + } + const gapBars = Arr.map(options.gaps, (gap, index): TimelineBar => { + const range = gapRangeText(gap, options.timeZone, options.toMs) + return { + key: `gap-${gap.startMs}`, + id: gapBarId(options.scope, index), + startMs: gap.startMs, + endMs: gap.endMs, + label: "No one", + // An open gap reads "No one on call from Sat 21 Nov 08:00". + title: Str.startsWith("from ")(range) ? `No one on call ${range}` : `No one on call, ${range}`, + tone: "gap", + } + }) + return [ + { + key: "final", + label: "Final schedule", + detail: "Who gets paged", + bars: [ + ...Arr.map(options.final, (shift, index) => shiftBar(shift, `final-${index}`, true)), + ...gapBars, + ], + emptyText: "Nobody on call in this period", + }, + ...Arr.reverse( + Arr.map(options.lanes, (lane): TimelineRow => ({ + key: lane.key, + label: lane.label, + detail: lane.detail, + // The final row above already carries every shift that pages; these stay out of the tab order. + isTabStop: false, + bars: Option.match(lane.shifts, { + onNone: () => [], + onSome: (shifts) => + Arr.map(shifts, (shift, at) => shiftBar(shift, `${lane.key}-${at}`, false)), + }), + emptyText: lane.emptyText, + })), + ), + ] +} + +/** "10 Nov to 23 Nov": the first and last day shown. */ +const periodLabel = (fromMs: number, toMs: number, timeZone: string): string => { + const day = new Intl.DateTimeFormat("en-GB", { timeZone, day: "numeric", month: "short" }) + return `${day.format(fromMs)} to ${day.format(toMs - 1)}` +} + +const summaryLine = ( + h: HtmlBuilder, + iconName: "CircleCheckIcon" | "AlertWarningIcon" | "ClockIcon", + iconClass: string, + text: string, +): Html => + hstack(h, { gap: "1.5", align: "start" }, [ + h.span([h.AriaHidden(true), h.Class(`mt-0.5 shrink-0 ${iconClass}`)], [icon(h, iconName)]), + typography(h, { size: "sm", weight: "medium", as: "p", attributes: [h.AriaLive("polite")] }, [text]), + ]) + +const gapLinks = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { + const count = options.gaps.length + const more = (shown: number, className: string): ReadonlyArray => + count > shown + ? [ + typography( + h, + { size: "xs", tone: "muted", className, attributes: [h.Key(`more-${shown}`)] }, + [`and ${count - shown} more on the timeline`], + ), + ] + : [] + return hstack(h, { gap: "1", wrap: true }, [ + ...Arr.map(Arr.take(options.gaps, WIDE_GAP_LINKS), (gap, index) => + button( + h, + { + size: "xs", + variant: "outline", + // A narrow preview (a phone) links fewer gaps. + className: index < NARROW_GAP_LINKS ? undefined : "hidden @min-[30rem]:inline-flex", + attributes: [h.Key(`gap-link-${gap.startMs}`), h.OnClick(options.onFocusGap(index))], + }, + [Str.capitalize(gapRangeText(gap, options.timeZone, options.toMs))], + ), + ), + ...more(NARROW_GAP_LINKS, "@min-[30rem]:hidden"), + ...more(WIDE_GAP_LINKS, "hidden @min-[30rem]:inline"), + ]) +} + +const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { + const scope = { + timeZone: options.timeZone, + checkedFromMs: Math.max(options.fromMs, options.nowMs), + checkedToMs: options.toMs, + } + const summary = gapSummary(options.gaps, scope) + const alarm = summaryLine(h, "AlertWarningIcon", "text-severity-error", summary) + const body = + // A range that has wholly passed has nothing left to check. + options.toMs <= options.nowMs + ? [ + summaryLine( + h, + "ClockIcon", + "text-muted-foreground", + "Past shifts. Gaps are only checked from now.", + ), + ] + : options.gaps.length === 0 + ? [summaryLine(h, "CircleCheckIcon", "text-severity-info", summary)] + : // One gap that is the whole range needs no link to find it. + isUncovered(options.gaps, scope) + ? [alarm] + : [alarm, gapLinks(h, options)] + return vstack(h, { gap: "1.5", attributes: [h.DataAttribute("slot", "coverage-summary")] }, body) +} + +const noteView = (h: HtmlBuilder, note: PreviewNote): Html => + hstack(h, { gap: "1.5", align: "start", attributes: [h.Key(note.text)] }, [ + h.span( + [ + h.AriaHidden(true), + h.Class( + `mt-0.5 shrink-0 ${note.tone === "warn" ? "text-severity-warn" : "text-muted-foreground"}`, + ), + ], + [icon(h, note.tone === "warn" ? "CircleWarningIcon" : "CircleInfoIcon", { size: 14 })], + ), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + note.text, + ...(note.action === undefined + ? [] + : [ + " ", + textLink( + h, + { onClick: note.action.onClick, isDisabled: note.action.isDisabled ?? false }, + [note.action.label], + ), + ]), + ]), + ]) + +const controls = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + // The range switch takes its own row in a narrow preview; the week steps never split. + vstack(h, { gap: "2", className: "@min-[30rem]:flex-row @min-[30rem]:items-center" }, [ + segmentedSelect(h, { + options: RANGE_OPTIONS, + value: options.rangeDays, + onChange: options.onRange, + size: "xs", + ariaLabel: "Range", + className: "w-fit", + }), + hstack(h, { gap: "1", className: "shrink-0" }, [ + iconButton( + h, + { + label: "Previous week", + isDisabled: options.isPreviousDisabled ?? false, + attributes: [h.OnClick(options.onPrevious)], + }, + [icon(h, "ChevronLeftIcon")], + ), + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: options.weekOffset === 0, + attributes: [h.OnClick(options.onToday)], + }, + ["Today"], + ), + iconButton(h, { label: "Next week", attributes: [h.OnClick(options.onNext)] }, [ + icon(h, "ChevronRightIcon"), + ]), + ]), + ]) + +const timelineView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + shiftTimeline(h, { + fromMs: options.fromMs, + toMs: options.toMs, + timeZone: options.timeZone, + nowMs: options.nowMs, + zoneLabel: options.timeZone, + rows: previewRows(options), + ariaLabel: options.title, + nowLabel: `Now, ${formatShiftEdge(options.nowMs, options.timeZone)}`, + legend: [ + { tone: "primary", label: "On call" }, + { tone: "info", label: "Override" }, + { tone: "neutral", label: "Layer shift" }, + { tone: "gap", label: "No one" }, + ], + }) + +export const coveragePreview = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + h.section( + // A container: the controls and gap links lay out by the preview's width, not the screen's. + [h.Class("@container min-w-0 space-y-3"), h.DataAttribute("slot", "coverage-preview")], + [ + hstack(h, { gap: "2", justify: "between", align: "start", wrap: true }, [ + // A set basis, not the title's own width: a long name truncates beside the controls + // while there is room for a readable one, and takes its own row when there is not. + vstack(h, { gap: "0", shrink: true, className: "grow basis-48" }, [ + heading( + h, + { + level: 2, + variant: "section", + className: "truncate", + attributes: [h.Title(options.title)], + }, + [options.title], + ), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + periodLabel(options.fromMs, options.toMs, options.timeZone), + ]), + ]), + controls(h, options), + ]), + ...(options.note === undefined + ? [] + : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.note])]), + ...Arr.map(options.notes ?? [], (note) => noteView(h, note)), + ...(options.placeholder === undefined + ? [gapSummaryView(h, options), timelineView(h, options)] + : [options.placeholder]), + ], + ) diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 4b8cbb53d7..e9482b8cb2 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -5,13 +5,14 @@ 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 } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" @@ -31,6 +32,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, @@ -60,7 +67,21 @@ 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: {} })) + +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): string => + hrefOf(AppRoute.AlertsEscalationChain({ chainId: encodeChainId(id), search: {} })) + +export const newChainHref = (): string => hrefOf(AppRoute.AlertsEscalationChainNew({ search: {} })) + +/** 404 on a read or a write: the chain was deleted elsewhere. */ +export const CHAIN_NOT_FOUND_CODE = "escalation_chain_not_found" // SUMMARY @@ -97,8 +118,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/alerts/shared/headroom-tone.ts b/apps/web/src/page/alerts/shared/headroom-tone.ts new file mode 100644 index 0000000000..a90ca31ea1 --- /dev/null +++ b/apps/web/src/page/alerts/shared/headroom-tone.ts @@ -0,0 +1,18 @@ +import type { HeadroomTone } from "@maple/web-data/alerts/overview-derive" + +// How a reading against its threshold is coloured. The rules table and the Inbox both pick the +// tone with `headroomTone`, so the same rule never reads calm on one and alarming on the other. + +/** A headroom bar's fill. */ +export const HEADROOM_FILL = { + neutral: "bg-foreground-faint", + warning: "bg-severity-warn", + error: "bg-severity-error", +} as const satisfies Record + +/** The reading's own text colour; a neutral one keeps its row's. */ +export const HEADROOM_TEXT = { + neutral: undefined, + warning: "text-severity-warn", + error: "text-severity-error", +} as const satisfies Record diff --git a/apps/web/src/page/alerts/shared/links.ts b/apps/web/src/page/alerts/shared/links.ts index bf622381e2..9316199646 100644 --- a/apps/web/src/page/alerts/shared/links.ts +++ b/apps/web/src/page/alerts/shared/links.ts @@ -1,7 +1,7 @@ import { AlertRuleId } from "@maple/domain/http" import { Option, Schema } from "effect" -import { AppRoute, hrefOf } from "../../../route" -import type { AlertRuleSearch, AlertsCreateSearch, AlertsSearch } from "../../../route/search" +import { alertsSectionRoute, AppRoute, hrefOf } from "../../../route" +import type { AlertRuleSearch, AlertsCreateSearch, AlertsHubSearch, AlertsTab } from "../../../route/search" const decodeRuleId = Schema.decodeUnknownOption(AlertRuleId) @@ -15,7 +15,9 @@ export const ruleHref = (rawRuleId: string, search: typeof AlertRuleSearch.Type export const incidentHref = (incidentId: string): string => hrefOf(AppRoute.AlertIncident({ incidentId, search: {} })) -export const alertsHref = (search: typeof AlertsSearch.Type): string => hrefOf(AppRoute.Alerts({ search })) +/** A hub section's URL with the hub's filters; the Inbox by default. */ +export const alertsHref = (search: typeof AlertsHubSearch.Type, tab: AlertsTab = "inbox"): string => + hrefOf(alertsSectionRoute(tab, search)) export const createHref = (search: typeof AlertsCreateSearch.Type): string => hrefOf(AppRoute.AlertsCreate({ search })) diff --git a/apps/web/src/page/alerts/shared/maintenance-windows.ts b/apps/web/src/page/alerts/shared/maintenance-windows.ts index 2f4f7d785b..926b397430 100644 --- a/apps/web/src/page/alerts/shared/maintenance-windows.ts +++ b/apps/web/src/page/alerts/shared/maintenance-windows.ts @@ -85,7 +85,7 @@ export const windowIdOfParam = (raw: string | undefined): Option.Option - alertsHref({ tab: "silences", window: windowPublicId(id) }) + alertsHref({ window: windowPublicId(id) }, "silences") export const WEEKDAYS: ReadonlyArray<{ value: MaintenanceWeekday; label: string }> = [ { value: "mon", label: "Mon" }, diff --git a/apps/web/src/page/alerts/shared/matcher-fields.ts b/apps/web/src/page/alerts/shared/matcher-fields.ts index 44e0a356f2..722e5698e0 100644 --- a/apps/web/src/page/alerts/shared/matcher-fields.ts +++ b/apps/web/src/page/alerts/shared/matcher-fields.ts @@ -3,6 +3,7 @@ import { Option, Schema, Array as Arr, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { fieldDescription, fieldError, fieldLabel, type FieldIds, input } from "@maple/views/ui/field" import type { FieldState } from "@maple/views/ui/input" +import { label } from "@maple/views/ui/label" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" @@ -29,7 +30,8 @@ export const typedFromMatchers = (matchers: V2AlertSilenceMatchers): TypedMatche environments: Arr.join(matchers.environments ?? [], ", "), }) -const splitList = (value: string): ReadonlyArray => +/** A comma-separated list as its names: trimmed, the empty ones dropped, each once. */ +export const splitList = (value: string): ReadonlyArray => Arr.dedupe(Arr.filter(Arr.map(Str.split(value, ","), Str.trim), Str.isNonEmpty)) /** The matchers the fields describe; an empty field is left out. */ @@ -59,6 +61,30 @@ const stateOf = (value: string, isInvalid = false): FieldState => ({ isTouched: false, }) +/** A dialog field's label for a control the text field doesn't draw (chips, a segmented choice): the same look. */ +export const dialogLabel = ( + h: HtmlBuilder, + options: Readonly<{ id: string; htmlFor?: string }>, + text: string, +): Html => + label( + h, + { + className: "text-xs", + attributes: [ + h.Id(options.id), + ...(options.htmlFor === undefined ? [] : [h.For(options.htmlFor)]), + ], + }, + [text], + ) + +/** A line of help under a group of dialog fields, in the face a field's own description uses. */ +export const dialogHelp = (h: HtmlBuilder, id: string, text: string): Html => + fieldDescription(h, { ids: { control: id, label: id, error: id, description: id }, state: stateOf("") }, [ + text, + ]) + export type DialogTextFieldOptions = Readonly<{ idPrefix: string name: string diff --git a/apps/web/src/page/alerts/shared/oncall-schedules.ts b/apps/web/src/page/alerts/shared/oncall-schedules.ts index 0d2b0dd2fd..b35b9a8b57 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 @@ -123,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/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/alerts/shared/preview.ts b/apps/web/src/page/alerts/shared/preview.ts index 31726f85ae..dd6e50a1c1 100644 --- a/apps/web/src/page/alerts/shared/preview.ts +++ b/apps/web/src/page/alerts/shared/preview.ts @@ -28,8 +28,9 @@ export type PreviewSlot = typeof PreviewSlot.Type const isPreviewQueryReady = (form: RuleFormState): boolean => { // An anomaly rule has no query: the detector's own chart is its evidence. if (form.signalType === "anomaly") return false - if (!Number.isFinite(Number(form.threshold))) return false - if (isRangeComparator(form.comparator) && !Number.isFinite(Number(form.thresholdUpper))) return false + // A blank threshold is missing, not zero: nothing to draw a line at or to ask the server for. + if (!isFilledNumber(form.threshold)) return false + if (isRangeComparator(form.comparator) && !isFilledNumber(form.thresholdUpper)) return false if (form.signalType === "raw_query") { const sql = Str.trim(form.rawQuerySql) if (sql.length === 0 || !Str.includes("$__orgFilter")(sql)) return false diff --git a/apps/web/src/page/alerts/shared/rule-chart.ts b/apps/web/src/page/alerts/shared/rule-chart.ts index 1f83845eb0..b731be2bae 100644 --- a/apps/web/src/page/alerts/shared/rule-chart.ts +++ b/apps/web/src/page/alerts/shared/rule-chart.ts @@ -58,10 +58,11 @@ export const RULE_CHART_TOKENS: ReadonlyArray = [ "--background", ] -const SIGNAL_TOKEN = "--chart-1" +// Blue in both themes: `--chart-1` is the dark theme's orange, the warning threshold's colour. +const SIGNAL_TOKEN = "--chart-p50" const CHART_HEIGHT = 300 const Y_AXIS_WIDTH = 72 -const PLOT_RIGHT = 12 +const PLOT_RIGHT = 28 const RAIL_CELLS = 60 type RailStatus = "breached" | "error" | "skipped" | "healthy" | "empty" @@ -101,6 +102,10 @@ export type RuleChartInputs = Readonly<{ /** `onSelectBucket`: the rail cells are buttons when set. */ onSelectBucket?: (index: number | null) => M isLoading?: boolean + /** Said in place of the loading skeleton, whose drawn trend reads like data (the rule editor). */ + loadingText?: string + /** Said when there is nothing to chart and nothing failed, in place of "No data in this window". */ + emptyText?: Readonly<{ title: string; hint?: string | undefined }> error?: string | null className?: string }> @@ -133,7 +138,11 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm source === "preview" ? checksChart.rows : previewChart.rows, (domain.max - domain.min) / RAIL_CELLS, ) - const chartData = merged.rows + // A point outside the window would draw past the plot, over the axis labels. + const chartData = Arr.filter(merged.rows, (row) => { + const t = num(row.t) + return t >= domain.min && t <= domain.max + }) const hasSignal = chartData.length > 0 const bucketSeconds = inputs.preview != null @@ -242,6 +251,9 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm gradientId: `${inputs.chartId}-signal`, }) + const hint = inputs.emptyText === undefined ? "Try widening the range" : inputs.emptyText.hint + const emptyHint = hint === undefined ? {} : { hint } + const chartArea = hasSignal ? h.div( [h.Class("w-full")], @@ -274,7 +286,9 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm ], ) : inputs.isLoading === true - ? chartLoading(h, { variant: "area", height: CHART_HEIGHT }) + ? inputs.loadingText === undefined + ? chartLoading(h, { variant: "area", height: CHART_HEIGHT }) + : chartEmpty(h, { height: CHART_HEIGHT }, [inputs.loadingText]) : inputs.error != null ? chartError(h, { height: CHART_HEIGHT }, [ h.div( @@ -289,8 +303,8 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm ], ), ]) - : chartEmpty(h, { height: CHART_HEIGHT, hint: "Try widening the range" }, [ - "No data in this window", + : chartEmpty(h, { height: CHART_HEIGHT, ...emptyHint }, [ + inputs.emptyText?.title ?? "No data in this window", ]) const thresholdCaption = hstack( diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts new file mode 100644 index 0000000000..3a3821353e --- /dev/null +++ b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts @@ -0,0 +1,298 @@ +import { onCallAt } from "@maple/alerting-core/oncall" +import { OnCallOverride, UserId } from "@maple/domain/http" +import { Array as Arr, Option, Schema } from "effect" +import { describe, expect, it } from "vitest" +import { emptyLayer, type Form, type LayerDraft } from "../schedule-form/form" +import { + draftConfigOf, + draftCoverage, + durationText, + gapRangeText, + gapsOf, + gapSummary, + layerOnCallAt, + previewLayerOf, + previewRange, + type Interval, +} from "./schedule-coverage" + +const HOUR = 3_600_000 +const at = (iso: string) => Date.parse(iso) +const people = Schema.decodeUnknownSync(Schema.Array(UserId)) +const decodeOverrides = Schema.decodeUnknownSync(Schema.Array(OnCallOverride)) + +const layer = (patch: Partial): LayerDraft => ({ + ...emptyLayer(0, 0, at("2026-11-02T08:00:00Z"), "Europe/Berlin"), + ...patch, +}) +const formOf = (layers: ReadonlyArray, timeZone = "Europe/Berlin"): Form => ({ + name: "Ops", + description: "", + timeZone, + layers, + nextKey: layers.length, +}) + +/** Every `stepMs` across the range, checks the preview's final lane against the server's `onCallAt`. */ +const expectMatchesServer = ( + form: Form, + overrides: ReadonlyArray, + range: Interval, + stepMs: number, +) => { + const config = Option.getOrThrow(draftConfigOf(form)).config + const coverage = draftCoverage(form, overrides, range.startMs, range.endMs, range.startMs) + const count = Math.floor((range.endMs - range.startMs) / stepMs) + Arr.forEach(Arr.range(0, count - 1), (i) => { + const ms = range.startMs + i * stepMs + 1 + const server = onCallAt(config, overrides, ms) + const preview = Arr.findFirst(coverage.final, (s) => s.startMs <= ms && ms < s.endMs) + const inGap = Arr.some(coverage.gaps, (g) => g.startMs <= ms && ms < g.endMs) + expect(Option.getOrNull(Option.map(preview, (s) => s.userId))).toBe( + server === null ? null : server.userId, + ) + expect(inGap).toBe(server === null) + }) +} + +describe("gapsOf", () => { + it("returns the whole range when nothing covers it", () => { + expect(gapsOf([], 0, 10)).toEqual([{ startMs: 0, endMs: 10 }]) + }) + it("returns no gaps when shifts cover the range end to end", () => { + expect( + gapsOf( + [ + { startMs: 5, endMs: 12 }, + { startMs: -2, endMs: 5 }, + ], + 0, + 10, + ), + ).toEqual([]) + }) + it("finds holes between, before and after shifts", () => { + expect( + gapsOf( + [ + { startMs: 2, endMs: 4 }, + { startMs: 6, endMs: 8 }, + ], + 0, + 10, + ), + ).toEqual([ + { startMs: 0, endMs: 2 }, + { startMs: 4, endMs: 6 }, + { startMs: 8, endMs: 10 }, + ]) + }) +}) + +describe("draftCoverage", () => { + const range = { startMs: at("2026-11-09T00:00:00Z"), endMs: at("2026-11-23T00:00:00Z") } + + it("cycles members at the daily handoff time", () => { + const form = formOf([ + layer({ + participants: people(["ana", "ben", "chen"]), + rotation: "daily", + handoffTime: "09:00", + startsAt: "2026-11-09T09:00", + }), + ]) + const coverage = draftCoverage(form, [], at("2026-11-09T08:00:00Z"), at("2026-11-12T08:00:00Z"), 0) + expect(Arr.map(coverage.final, (s) => s.userId)).toEqual(["ana", "ben", "chen"]) + // 09:00 Berlin is 08:00 UTC in November. + expect(Arr.map(coverage.final, (s) => new Date(s.startMs).toISOString())).toEqual([ + "2026-11-09T08:00:00.000Z", + "2026-11-10T08:00:00.000Z", + "2026-11-11T08:00:00.000Z", + ]) + expect(coverage.gaps).toEqual([]) + }) + + it("layers a business-hours layer over a weekly one and applies overrides, matching onCallAt", () => { + const form = formOf([ + layer({ participants: people(["ana", "ben"]), rotation: "weekly", startsAt: "2026-11-02T09:00" }), + layer({ + key: "l1", + participants: people(["dev"]), + rotation: "daily", + startsAt: "2026-11-02T09:00", + restrictions: [ + { + key: "r0", + weekdays: ["mon", "tue", "wed", "thu", "fri"], + startTime: "09:00", + endTime: "17:00", + }, + ], + }), + ]) + const overrides = decodeOverrides([ + { + id: "0b7f6a3e-1c2d-4e5f-8a9b-1c2d3e4f5a60", + userId: "eve", + startsAt: at("2026-11-12T10:00:00Z"), + endsAt: at("2026-11-13T02:00:00Z"), + createdAt: 1, + }, + ]) + const coverage = draftCoverage(form, overrides, range.startMs, range.endMs, range.startMs) + expect(coverage.gaps).toEqual([]) + expect(Arr.some(coverage.final, (s) => s.userId === "eve")).toBe(true) + expectMatchesServer(form, overrides, range, HOUR / 2) + }) + + it("reports restriction holes as gaps that match onCallAt being null", () => { + const form = formOf([ + layer({ + participants: people(["ana"]), + rotation: "weekly", + startsAt: "2026-11-02T09:00", + restrictions: [ + { + key: "r0", + weekdays: ["mon", "tue", "wed", "thu", "fri"], + startTime: "08:00", + endTime: "20:00", + }, + ], + }), + ]) + const coverage = draftCoverage(form, [], range.startMs, range.endMs, range.startMs) + // Nights plus the two weekends. + expect(coverage.gaps.length).toBeGreaterThan(8) + expectMatchesServer(form, [], range, HOUR / 2) + }) + + it("hands off at local 09:00 across the October DST change", () => { + const form = formOf([ + layer({ + participants: people(["ana", "ben"]), + rotation: "daily", + handoffTime: "09:00", + startsAt: "2026-10-23T09:00", + }), + ]) + const dst = { startMs: at("2026-10-22T22:00:00Z"), endMs: at("2026-10-28T23:00:00Z") } + const coverage = draftCoverage(form, [], dst.startMs, dst.endMs, dst.startMs) + const sunday = Arr.findFirst(coverage.final, (s) => s.startMs === at("2026-10-25T07:00:00Z")) + // Sat 09:00 CEST (07:00Z) to Sun 09:00 CET (08:00Z) is 25 hours. + const saturday = Arr.findFirst(coverage.final, (s) => s.startMs === at("2026-10-24T07:00:00Z")) + expect(Option.map(saturday, (s) => s.endMs - s.startMs)).toEqual(Option.some(25 * HOUR)) + expect(Option.isNone(sunday)).toBe(true) + expectMatchesServer(form, [], dst, HOUR / 4) + }) + + it("treats a layer with no members as not previewable and the schedule as all gap", () => { + const form = formOf([layer({ participants: [] })]) + const coverage = draftCoverage(form, [], range.startMs, range.endMs, range.startMs) + expect(Option.isNone(coverage.layers[0] ?? Option.some([]))).toBe(true) + expect(coverage.gaps).toEqual([{ startMs: range.startMs, endMs: range.endMs }]) + }) + + it("keeps lanes aligned with the draft when a middle layer cannot preview", () => { + const form = formOf([ + layer({ participants: people(["ana"]), startsAt: "2026-11-02T09:00" }), + layer({ key: "l1", participants: [] }), + layer({ key: "l2", participants: people(["ben"]), rotation: "custom", lengthHours: "nope" }), + ]) + const coverage = draftCoverage(form, [], range.startMs, range.endMs, range.startMs) + expect(Arr.map(coverage.layers, Option.isSome)).toEqual([true, false, false]) + }) + + it("counts gaps only from now, and a future start leaves a gap before it", () => { + const form = formOf([layer({ participants: people(["ana"]), startsAt: "2026-11-12T09:00" })]) + const nowMs = at("2026-11-10T12:00:00Z") + const coverage = draftCoverage(form, [], range.startMs, range.endMs, nowMs) + expect(coverage.gaps).toEqual([{ startMs: nowMs, endMs: at("2026-11-12T08:00:00Z") }]) + }) +}) + +describe("previewRange", () => { + it("spans whole local days, so a DST week is an hour longer", () => { + const week = previewRange(at("2026-10-21T10:00:00Z"), "Europe/Berlin", 0, 7) + expect(week.startMs).toBe(at("2026-10-20T22:00:00Z")) + expect(week.endMs - week.startMs).toBe(7 * 24 * HOUR + HOUR) + }) +}) + +describe("gapSummary", () => { + const scope = { + timeZone: "Europe/Berlin", + checkedFromMs: at("2026-11-10T10:30:00Z"), + checkedToMs: at("2026-11-23T23:00:00Z"), + } + it("says covered when there are no gaps", () => { + expect(gapSummary([], scope)).toBe("Covered in this period") + }) + it("names the range drawn, not a count of days: from now, a 14-day range ends short of 14 days", () => { + const nowMs = at("2026-11-10T10:30:00Z") + const range = previewRange(nowMs, "Europe/Berlin", 0, 14) + const today = { timeZone: "Europe/Berlin", checkedFromMs: nowMs, checkedToMs: range.endMs } + // What is counted stops at the range's last midnight, 11.5 hours before 14 days from now. + expect(range.endMs - nowMs).toBe(14 * 24 * HOUR - 11.5 * HOUR) + const late = { startMs: range.endMs - 2 * HOUR, endMs: range.endMs - HOUR } + expect(gapSummary([late], today)).toBe("1 gap in this period, longest 1h (Mon 23 Nov 22:00 to 23:00)") + expect(gapSummary([], today)).toBe("Covered in this period") + }) + it("counts gaps and names the longest in the schedule's zone", () => { + const gaps = [ + { startMs: at("2026-11-19T23:00:00Z"), endMs: at("2026-11-20T01:00:00Z") }, + { startMs: at("2026-11-20T23:00:00Z"), endMs: at("2026-11-21T15:00:00Z") }, + ] + expect(gapSummary(gaps, scope)).toBe("2 gaps in this period, longest 16h (Sat 21 Nov 00:00 to 16:00)") + }) + it("says nobody is on call when the one gap is everything checked", () => { + const whole = [{ startMs: scope.checkedFromMs, endMs: scope.checkedToMs }] + expect(gapSummary(whole, scope)).toBe("Nobody is on call in this period") + }) + it("gives a gap cut off by the range's end no end time", () => { + const closed = { startMs: at("2026-11-12T08:00:00Z"), endMs: at("2026-11-12T10:00:00Z") } + const open = { startMs: at("2026-11-21T07:00:00Z"), endMs: scope.checkedToMs } + expect(gapSummary([closed, open], scope)).toBe( + "2 gaps in this period, longest 2d 16h or longer (from Sat 21 Nov 08:00)", + ) + expect(gapRangeText(closed, "Europe/Berlin", scope.checkedToMs)).toBe("Thu 12 Nov 09:00 to 11:00") + expect(gapRangeText(open, "Europe/Berlin", scope.checkedToMs)).toBe("from Sat 21 Nov 08:00") + }) + it("formats durations", () => { + expect(durationText(90 * 60_000)).toBe("1h 30m") + expect(durationText(76 * HOUR)).toBe("3d 4h") + // From a day up the minutes are left out, not rounded into the hours. + expect(durationText(58 * HOUR + 30 * 60_000)).toBe("2d 10h") + expect(durationText(23 * HOUR + 30 * 60_000)).toBe("23h 30m") + }) +}) + +describe("a layer being edited", () => { + const window = { key: "r0", weekdays: ["mon" as const], startTime: "18:00", endTime: "08:00" } + const staffed = layer({ + participants: people(["ana", "ben"]), + rotation: "daily", + startsAt: "2026-11-09T09:00", + }) + + it("does not preview while one of its windows is half typed, rather than reading as always active", () => { + const halfTyped = { ...staffed, restrictions: [window, { ...window, key: "r1", endTime: "" }] } + const noDays = { ...staffed, restrictions: [{ ...window, weekdays: [] }] } + expect(Option.isNone(previewLayerOf(halfTyped, "Europe/Berlin"))).toBe(true) + expect(Option.isNone(previewLayerOf(noDays, "Europe/Berlin"))).toBe(true) + const from = at("2026-11-09T00:00:00Z") + const to = at("2026-11-16T00:00:00Z") + const coverage = draftCoverage(formOf([halfTyped]), [], from, to, from) + expect(coverage.final).toEqual([]) + expect(coverage.gaps).toEqual([{ startMs: from, endMs: to }]) + }) + + it("names who the layer alone has on call at an instant, and nobody outside its windows", () => { + // Ana holds Mon 9 Nov 09:00, Ben Tue 10 Nov 09:00. + expect(layerOnCallAt(staffed, "Europe/Berlin", at("2026-11-10T10:30:00Z"))).toEqual( + Option.some("ben"), + ) + const nights = { ...staffed, restrictions: [window] } + expect(layerOnCallAt(nights, "Europe/Berlin", at("2026-11-10T10:30:00Z"))).toEqual(Option.none()) + }) +}) diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.ts b/apps/web/src/page/alerts/shared/schedule-coverage.ts new file mode 100644 index 0000000000..6788ffa3df --- /dev/null +++ b/apps/web/src/page/alerts/shared/schedule-coverage.ts @@ -0,0 +1,256 @@ +import { onCallAt, renderOnCall, type OnCallShift } from "@maple/alerting-core/oncall" +import { OnCallLayer, OnCallScheduleConfig, OnCallTimeZone } from "@maple/domain/http" +import type { OnCallOverride } from "@maple/domain/http" +import type { UserId } from "@maple/domain/primitives" +import { zonedDateParts } from "@maple/query-engine/datetime" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Array as Arr, Option, Order, Result, Schema, String as Str } from "effect" +import { layerStartMs, restrictionError, type Form, type LayerDraft } from "../schedule-form/form" + +// Coverage preview: the draft rendered with the server's own `renderOnCall`, so the preview +// and paging agree, plus the gaps where nobody is on call. + +export interface Interval { + readonly startMs: number + readonly endMs: number +} + +const byStart = Order.mapInput(Order.Number, (interval: Interval) => interval.startMs) + +/** The stretches of [fromMs, toMs) that no shift covers. */ +export const gapsOf = ( + shifts: ReadonlyArray, + fromMs: number, + toMs: number, +): ReadonlyArray => { + if (toMs <= fromMs) return [] + const sorted = Arr.sort(shifts, byStart) + const { gaps, cursor } = Arr.reduce( + sorted, + { gaps: Arr.empty(), cursor: fromMs }, + ({ gaps, cursor }, shift) => { + if (shift.endMs <= cursor || shift.startMs >= toMs) return { gaps, cursor } + const next = shift.startMs > cursor ? [...gaps, { startMs: cursor, endMs: shift.startMs }] : gaps + return { gaps: next, cursor: Math.max(cursor, shift.endMs) } + }, + ) + return cursor < toMs ? [...gaps, { startMs: cursor, endMs: toMs }] : gaps +} + +const isZone = Schema.is(OnCallTimeZone) +const decodeLayer = Schema.decodeUnknownOption(OnCallLayer) +const decodeConfig = Schema.decodeUnknownOption(OnCallScheduleConfig) + +/** + * A draft layer as the engine's layer, once it has people, a valid rotation and valid windows. + * A half-typed window is none, not "always active": the engine reads no windows as 24/7. + */ +export const previewLayerOf = (layer: LayerDraft, timeZone: string): Option.Option => + Arr.some(layer.restrictions, (restriction) => Option.isSome(restrictionError(restriction))) + ? Option.none() + : Option.flatMap(layerStartMs(layer, timeZone), (startsAt) => + decodeLayer(engineLayerOf(layer, startsAt)), + ) + +const engineLayerOf = (layer: LayerDraft, startsAt: number) => ({ + // The name never changes who is on call, so a blank one still previews. + name: Str.isEmpty(Str.trim(layer.name)) ? "Layer" : Str.slice(0, 100)(Str.trim(layer.name)), + participants: layer.participants, + rotation: + layer.rotation === "custom" + ? { kind: "custom", lengthHours: Number(Str.trim(layer.lengthHours)) } + : layer.rotation === "daily" + ? { kind: "daily", handoffTime: layer.handoffTime } + : { + kind: "weekly", + handoffWeekday: layer.handoffWeekday, + handoffTime: layer.handoffTime, + }, + startsAt, + restrictions: Arr.map(layer.restrictions, ({ weekdays, startTime, endTime }) => ({ + weekdays, + startTime, + endTime, + })), +}) + +/** Who the layer alone has on call at `atMs`; none in one of its gaps, or while it cannot preview. */ +export const layerOnCallAt = (layer: LayerDraft, timeZone: string, atMs: number): Option.Option => + previewLayerOf(layer, timeZone).pipe( + Option.flatMap((engineLayer) => decodeConfig({ timeZone, layers: [engineLayer] })), + Option.flatMap((config) => Option.fromNullishOr(onCallAt(config, [], atMs))), + Option.map((shift) => shift.userId), + ) + +export interface DraftConfig { + readonly config: OnCallScheduleConfig + /** The draft layer each config layer came from. */ + readonly draftIndexes: ReadonlyArray +} + +/** The draft as an engine config, keeping only the layers that can preview; none while no layer can. */ +export const draftConfigOf = (form: Pick): Option.Option => { + if (!isZone(form.timeZone)) return Option.none() + const usable = Arr.filterMap(form.layers, (layer, index) => + Option.match(previewLayerOf(layer, form.timeZone), { + onNone: () => Result.failVoid, + onSome: (engineLayer) => Result.succeed({ engineLayer, index }), + }), + ) + return Option.map( + decodeConfig({ timeZone: form.timeZone, layers: Arr.map(usable, (u) => u.engineLayer) }), + (config) => ({ config, draftIndexes: Arr.map(usable, (u) => u.index) }), + ) +} + +export interface Coverage { + /** Per draft layer: its shifts, or none while it cannot preview (no people, bad times). */ + readonly layers: ReadonlyArray>> + readonly final: ReadonlyArray + /** Gaps in the final schedule from `max(fromMs, nowMs)`: the past is not scheduled. */ + readonly gaps: ReadonlyArray +} + +/** The draft's layers, final schedule and gaps over [fromMs, toMs). */ +export const draftCoverage = ( + form: Pick, + overrides: ReadonlyArray, + fromMs: number, + toMs: number, + nowMs: number, +): Coverage => { + const gapFrom = Math.max(fromMs, nowMs) + return Option.match(draftConfigOf(form), { + onNone: () => ({ + layers: Arr.map(form.layers, () => Option.none()), + final: [], + gaps: gapsOf([], gapFrom, toMs), + }), + onSome: ({ config, draftIndexes }) => { + const render = renderOnCall(config, overrides, fromMs, toMs) + return { + layers: Arr.map(form.layers, (_, draftIndex) => + Option.flatMap( + Arr.findFirstIndex(draftIndexes, (i) => i === draftIndex), + (configIndex) => Option.fromNullishOr(render.layers[configIndex]), + ), + ), + final: render.final, + gaps: gapsOf(render.final, gapFrom, toMs), + } + }, + }) +} + +// RANGE: whole wall-clock days in the schedule's zone, so DST days are 23 or 25 hours. + +/** `days` wall-clock days from the start of today, `weekOffset` weeks on. */ +export const previewRange = ( + nowMs: number, + timeZone: string, + weekOffset: number, + days: number, +): Interval => ({ + startMs: zonedDayStart(nowMs, timeZone, weekOffset * 7), + endMs: zonedDayStart(nowMs, timeZone, weekOffset * 7 + days), +}) + +// COPY + +const formatters = new Map() +const formatterOf = (timeZone: string, kind: "day" | "time"): Intl.DateTimeFormat => { + const key = `${timeZone}|${kind}` + const cached = formatters.get(key) + if (cached !== undefined) return cached + const formatter = new Intl.DateTimeFormat( + "en-GB", + kind === "day" + ? { + timeZone, + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + } + : { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }, + ) + formatters.set(key, formatter) + return formatter +} + +const sameDay = (a: number, b: number, timeZone: string) => { + const pa = zonedDateParts(a, timeZone) + const pb = zonedDateParts(b, timeZone) + return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day +} + +const dropComma = Str.replace(",", "") + +/** A gap that runs to the end of what was checked: its real end is not known. */ +export const isOpenGap = (gap: Interval, checkedToMs: number): boolean => gap.endMs >= checkedToMs + +/** "Sat 21 Nov 00:00 to 16:00", both ends in full across days, or "from Sat 21 Nov 00:00" when open. */ +export const gapRangeText = (gap: Interval, timeZone: string, checkedToMs: number): string => { + const start = dropComma(formatterOf(timeZone, "day").format(gap.startMs)) + if (isOpenGap(gap, checkedToMs)) return `from ${start}` + const end = sameDay(gap.startMs, gap.endMs, timeZone) + ? formatterOf(timeZone, "time").format(gap.endMs) + : dropComma(formatterOf(timeZone, "day").format(gap.endMs)) + return `${start} to ${end}` +} + +/** "16h", "1h 30m", "3d 4h": a gap's length. Minutes show only under a day; a longer one reads in days and hours. */ +export const durationText = (ms: number): string => { + const minutes = Math.round(ms / 60_000) + const days = Math.floor(minutes / 1440) + const hours = Math.floor((minutes % 1440) / 60) + const mins = minutes % 60 + const parts = [ + ...(days > 0 ? [`${days}d`] : []), + ...(hours > 0 ? [`${hours}h`] : []), + ...(mins > 0 && days === 0 ? [`${mins}m`] : []), + ] + return parts.length === 0 ? "0m" : Arr.join(parts, " ") +} + +/** The longest gap; the earliest among equals. */ +export const longestGap = (gaps: ReadonlyArray): Option.Option => + Arr.reduce(gaps, Option.none(), (best, gap) => + Option.match(best, { + onNone: () => Option.some(gap), + onSome: (b) => (gap.endMs - gap.startMs > b.endMs - b.startMs ? Option.some(gap) : best), + }), + ) + +/** What the summary is about: the zone and the stretch checked for gaps. */ +export type GapScope = Readonly<{ + timeZone: string + /** Gaps are checked over [checkedFromMs, checkedToMs): from now to the range's end. */ + checkedFromMs: number + checkedToMs: number +}> + +/** One gap spanning everything checked: nobody is on call at all. */ +export const isUncovered = ( + gaps: ReadonlyArray, + scope: Pick, +): boolean => { + const only = gaps.length === 1 ? gaps[0] : undefined + return only !== undefined && only.startMs <= scope.checkedFromMs && only.endMs >= scope.checkedToMs +} + +/** + * "2 gaps in this period, longest 16h (Sat 21 Nov 00:00 to 16:00)", "Covered in this period", or + * "Nobody is on call in this period" when the one gap is everything checked. "This period" is the + * range drawn: it ends at its last midnight, so a range from today is never a full "next N days". + */ +export const gapSummary = (gaps: ReadonlyArray, scope: GapScope): string => { + if (isUncovered(gaps, scope)) return "Nobody is on call in this period" + return Option.match(longestGap(gaps), { + onNone: () => "Covered in this period", + onSome: (longest) => + `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} in this period, longest ${durationText(longest.endMs - longest.startMs)}${isOpenGap(longest, scope.checkedToMs) ? " or longer" : ""} (${gapRangeText(longest, scope.timeZone, scope.checkedToMs)})`, + }) +} diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts index 1d0e7e8f89..0c2566425f 100644 --- a/apps/web/src/page/alerts/shared/silences.test.ts +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { Option, Schema } from "effect" import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" -import { activeSilenceFor, matchersForRule } from "./silences" +import { activeSilenceFor, formatSilenceRange, formatSilenceTime, matchersForRule } from "./silences" const ruleId = Schema.decodeSync(AlertRuleId)("11111111-1111-4111-8111-111111111111") const asSilenceId = Schema.decodeSync(AlertSilenceId) @@ -39,6 +39,39 @@ const groupedRule = { queryBuilderDraft: null, } +describe("silence times", () => { + const BERLIN = "Europe/Berlin" + + it("reads on the viewer's clock, and names the day when it is not today there", () => { + // NOW is 13:00 in Berlin. + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 14, 0), NOW, BERLIN)).toBe("03:00 PM") + expect(formatSilenceTime(Date.UTC(2026, 10, 7, 8, 0), NOW, BERLIN)).toBe("Nov 7, 09:00 AM") + // 23:30 UTC is still today in UTC, and already tomorrow in Berlin. + const late = Date.UTC(2026, 10, 6, 23, 30) + expect(formatSilenceTime(late, NOW, "UTC")).toBe("11:30 PM") + expect(formatSilenceTime(late, NOW, BERLIN)).toBe("Nov 7, 12:30 AM") + }) + + it("reads on a 24h clock when asked, midnight as 00", () => { + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 14, 30), NOW, BERLIN, "h23")).toBe("15:30") + expect(formatSilenceTime(Date.UTC(2026, 10, 7, 8, 0), NOW, BERLIN, "h23")).toBe("Nov 7, 09:00") + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 23, 30), NOW, BERLIN, "h23")).toBe("Nov 7, 00:30") + }) + + it("names the day of a window once when both ends share it", () => { + const start = Date.UTC(2026, 10, 7, 14, 0) + expect(formatSilenceRange(start, start + 60 * 60_000, NOW, BERLIN)).toBe( + "Nov 7, 03:00 PM to 04:00 PM", + ) + expect(formatSilenceRange(start, start + 24 * 60 * 60_000, NOW, BERLIN)).toBe( + "Nov 7, 03:00 PM to Nov 8, 03:00 PM", + ) + expect(formatSilenceRange(NOW + 60 * 60_000, NOW + 2 * 60 * 60_000, NOW, BERLIN)).toBe( + "02:00 PM to 03:00 PM", + ) + }) +}) + describe("activeSilenceFor", () => { it("picks the latest-ending silence in force, from the window not the stored status", () => { const long = silence("22222222-2222-4222-8222-222222222222", { rule_ids: [ruleId] }, -10, 120) diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 8db968ee93..77db386276 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -70,14 +70,36 @@ export const endsAtMs = (silence: V2AlertSilence): number => isoMs(silence.ends_ export const isCancelledSilence = (silence: V2AlertSilence): boolean => startsAtMs(silence) === endsAtMs(silence) -/** "15:00" today, "Nov 7, 09:00" on another day, in the viewer's zone. */ -export const formatSilenceTime = (ms: number, nowMs: number, timeZone: string): string => { - const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false }) +/** + * "03:00 PM" today, "Nov 7, 09:00 AM" on another day, in the viewer's zone. `h23` reads "15:00", + * for a label beside the form's 24h time picker. + */ +export const formatSilenceTime = ( + ms: number, + nowMs: number, + timeZone: string, + hourCycle?: "h12" | "h23", +): string => { + const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false, hourCycle }) return formatDateInTimezone(ms, { timeZone }) === formatDateInTimezone(nowMs, { timeZone }) ? time : `${formatDateInTimezone(ms, { timeZone, withYear: false })}, ${time}` } +/** "15:00 to 16:00" today, "Nov 7, 15:00 to 16:00" on another day: the day is named once when both ends share it. */ +export const formatSilenceRange = ( + startMs: number, + endMs: number, + nowMs: number, + timeZone: string, +): string => { + const isOneDay = formatDateInTimezone(startMs, { timeZone }) === formatDateInTimezone(endMs, { timeZone }) + const end = isOneDay + ? formatTimeInTimezone(endMs, { timeZone, withSeconds: false }) + : formatSilenceTime(endMs, nowMs, timeZone) + return `${formatSilenceTime(startMs, nowMs, timeZone)} to ${end}` +} + /** Where a silence stands at `nowMs`, from its window rather than the status it was read with. */ export const silenceStatusAt = (silence: V2AlertSilence, nowMs: number): V2AlertSilence["status"] => nowMs < startsAtMs(silence) ? "scheduled" : nowMs < endsAtMs(silence) ? "active" : "ended" diff --git a/apps/web/src/page/alerts/shared/slo-commands.ts b/apps/web/src/page/alerts/shared/slo-commands.ts index ae9de12599..e2d66603af 100644 --- a/apps/web/src/page/alerts/shared/slo-commands.ts +++ b/apps/web/src/page/alerts/shared/slo-commands.ts @@ -114,4 +114,4 @@ export const newSloHref = (search: typeof AlertsSloNewSearch.Type = {}): string export const editSloHref = (id: SloId): string => newSloHref({ sloId: encodePublicId(id) }) -export const slosTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "slos" } })) +export const slosTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "slos", search: {} })) diff --git a/apps/web/src/page/alerts/shared/test-rows.ts b/apps/web/src/page/alerts/shared/test-rows.ts index b6b41b16a6..9f304a19f6 100644 --- a/apps/web/src/page/alerts/shared/test-rows.ts +++ b/apps/web/src/page/alerts/shared/test-rows.ts @@ -1,11 +1,20 @@ +import { UNGROUPED_GROUP_KEY } from "@maple/domain/http" import type { AlertDestinationRow, AlertIncidentRow, AlertRuleRow, AlertRuleStateRow, } from "@maple/web-data/collections/alerts" +import { alertsSectionRoute } from "../../../route" +import type { AlertsHubSearch, AlertsTab } from "../../../route/search" import { isoMs } from "./time" +/** A hub section and its filters, as the tests and labs spell where they stand. */ +export type HubAt = typeof AlertsHubSearch.Type & { readonly tab?: AlertsTab } + +/** The hub route for `at`: `/alerts` for the Inbox (the default), else `/alerts/`. */ +export const hubAt = ({ tab = "inbox", ...search }: HubAt = {}) => alertsSectionRoute(tab, search) + // Synced alert rows for the alerts pages' scene tests, the shape Electric delivers. export const ORG = "org_acme" @@ -58,7 +67,7 @@ export const ruleRow = (n: number, fields: Partial = {}): AlertRul export const stateRow = (n: number, fields: Partial = {}): AlertRuleStateRow => ({ org_id: ORG, rule_id: ruleId(n), - group_key: "all", + group_key: UNGROUPED_GROUP_KEY, consecutive_breaches: 0, consecutive_healthy: 40, last_status: "healthy", @@ -76,7 +85,7 @@ export const incidentRow = (fields: Partial = {}): AlertIncide rule_id: ruleId(2), incident_key: "rule-2:all", rule_name: "Rule 2", - group_key: "all", + group_key: UNGROUPED_GROUP_KEY, signal_type: "p95_latency", severity: "critical", status: "open", diff --git a/apps/web/src/page/alerts/shared/uptime-commands.ts b/apps/web/src/page/alerts/shared/uptime-commands.ts index 18d2b3d1f7..847410c969 100644 --- a/apps/web/src/page/alerts/shared/uptime-commands.ts +++ b/apps/web/src/page/alerts/shared/uptime-commands.ts @@ -104,7 +104,7 @@ export const newMonitorHref = (search: typeof AlertsUptimeNewSearch.Type = {}): export const editMonitorHref = (id: UptimeMonitorId): string => newMonitorHref({ monitorId: encodePublicId(id) }) -export const uptimeTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "uptime" } })) +export const uptimeTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "uptime", search: {} })) /** Every check is a span in the `maple-uptime` service, namespace `maple-services`. */ export const UPTIME_SERVICE = UPTIME_SERVICE_NAME diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 8e8c49ab56..9b3b282415 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -2,13 +2,21 @@ import { describe, expect, it } from "vitest" import { AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" import { Option, Result, Schema } from "effect" +import * as Combobox from "@maple/views/ui/combobox" import { + endZoneLabel, init, isFormReady, matchersOf, Message, + type Model, + openFor, openNew, OutMessage, + parseLocalDateTime, + reasonError, + scopeError, + type ScopeField, silenceEndMs, untilError, update, @@ -36,6 +44,41 @@ describe("silenceEndMs", () => { ) }) + it("reads a custom end on the viewer's clock on either side of a DST change", () => { + // Berlin springs forward on 2026-03-29: 09:00 the day before is CET, 09:00 that day is CEST. + const before = Date.UTC(2026, 2, 28, 12, 0) + expect(parseLocalDateTime("2026-03-28T09:00", BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 28, 8, 0)), + ) + expect(parseLocalDateTime("2026-03-29T09:00", BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 29, 7, 0)), + ) + expect(silenceEndMs("custom", "2026-03-29T09:00", before, BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 29, 7, 0)), + ) + // New York falls back on 2026-11-01: 12:00 that day is EST, five hours behind UTC. + expect(parseLocalDateTime("2026-10-31T12:00", "America/New_York")).toEqual( + Option.some(Date.UTC(2026, 9, 31, 16, 0)), + ) + expect(parseLocalDateTime("2026-11-01T12:00", "America/New_York")).toEqual( + Option.some(Date.UTC(2026, 10, 1, 17, 0)), + ) + // The same wall clock read as UTC is a different instant: the form never assumes UTC. + expect(parseLocalDateTime("2026-03-29T09:00", "UTC")).toEqual( + Option.some(Date.UTC(2026, 2, 29, 9, 0)), + ) + }) + + it("names the zone the custom end is in, as it is at that end", () => { + const model = { timeZone: BERLIN, nowMs: Date.UTC(2026, 2, 28, 12, 0) } + expect(endZoneLabel({ ...model, until: "" })).toBe("CET") + expect(endZoneLabel({ ...model, until: "2026-03-28T18:00" })).toBe("CET") + expect(endZoneLabel({ ...model, until: "2026-03-29T09:00" })).toBe("CEST") + expect(endZoneLabel({ ...model, timeZone: "UTC", until: "" })).toBe("UTC") + // A zone ICU has no abbreviation for reads as its offset. + expect(endZoneLabel({ ...model, timeZone: "Asia/Kolkata", until: "" })).toBe("UTC+5:30") + }) + it("reads a custom end in the viewer's zone and refuses one in the past", () => { expect(silenceEndMs("custom", "2026-11-06T18:30", NOW, BERLIN)).toEqual( Option.some(Date.UTC(2026, 10, 6, 17, 30)), @@ -46,20 +89,123 @@ describe("silenceEndMs", () => { }) describe("matchersOf", () => { - it("splits typed lists and drops empty fields", () => { + it("takes the chips of each field and drops the empty ones", () => { expect( matchersOf({ _tag: "Typed", - services: " checkout, payments ,checkout", - tags: "", - environments: "production", + services: ["checkout", "payments"], + tags: [], + environments: ["production"], }), ).toEqual({ services: ["checkout", "payments"], environments: ["production"] }) }) }) +describe("scope chips", () => { + const pick = (model: Model, field: ScopeField, value: string) => + update( + model, + Message.GotScopeComboMessage({ field, message: Combobox.Message.ClickedItem({ value }) }), + ).model + + it("picking a suggestion adds a chip, picking it again or removing it takes it off", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const picked = pick(pick(opened, "services", "checkout"), "environments", "production") + expect(matchersOf(picked.scope)).toEqual({ services: ["checkout"], environments: ["production"] }) + expect(matchersOf(pick(picked, "services", "checkout").scope)).toEqual({ + environments: ["production"], + }) + const removed = update( + picked, + Message.GotScopeComboMessage({ + field: "environments", + message: Combobox.Message.RemovedChip({ value: "production" }), + }), + ).model + expect(matchersOf(removed.scope)).toEqual({ services: ["checkout"] }) + }) + + it("a typed name is picked with Enter, and the next open starts without chips", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const combo = (message: Combobox.Message) => Message.GotScopeComboMessage({ field: "tags", message }) + const enter = (model: Model, value: string) => + update( + update(model, combo(Combobox.Message.ChangedInput({ value }))).model, + // The view lists the suggestions, then what is typed when it is none of them. + combo(Combobox.Message.PressedInputKey({ key: "Enter", items: ["database", value] })), + ).model + // The first suggestion that matches wins; a name nothing suggests is added as typed. + const entered = enter(enter(opened, "dat"), "queue") + expect(matchersOf(entered.scope)).toEqual({ tags: ["database", "queue"] }) + expect(entered.combos.tags.inputValue).toBe("") + expect(matchersOf(openNew(entered, BERLIN, NOW).model.scope)).toEqual({}) + }) + + it("commas in a typed entry make one chip per name, trimmed, without empties or repeats", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const services = (message: Combobox.Message) => + Message.GotScopeComboMessage({ field: "services", message }) + const enter = (model: Model, value: string) => + update( + update(model, services(Combobox.Message.ChangedInput({ value }))).model, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api", value] })), + ).model + const entered = enter(opened, "demo-worker, demo-api,,") + expect(matchersOf(entered.scope)).toEqual({ services: ["demo-worker", "demo-api"] }) + expect(entered.combos.services.inputValue).toBe("") + // A name that is already a chip stays where it is; only the new one is added. + expect(matchersOf(enter(entered, "demo-api,demo-web").scope)).toEqual({ + services: ["demo-worker", "demo-api", "demo-web"], + }) + // A list picked from the popup with the pointer splits the same way. + expect(matchersOf(pick(opened, "services", "a , b").scope)).toEqual({ services: ["a", "b"] }) + }) + + it("typing a name that is already a chip, then Enter, keeps the chip and clears the input", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const services = (message: Combobox.Message) => + Message.GotScopeComboMessage({ field: "services", message }) + const picked = pick(opened, "services", "demo-api") + const typed = update(picked, services(Combobox.Message.ChangedInput({ value: "demo-api" }))).model + const entered = update( + typed, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api"] })), + ).model + expect(matchersOf(entered.scope)).toEqual({ services: ["demo-api"] }) + expect(entered.combos.services.inputValue).toBe("") + // With nothing typed, Enter on a highlighted option still toggles it, as a pointer pick does. + const highlighted = update( + picked, + services(Combobox.Message.PressedInputKey({ key: "ArrowDown", items: ["demo-api"] })), + ).model + expect( + matchersOf( + update( + highlighted, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api"] })), + ).model.scope, + ), + ).toEqual({}) + }) + + it("a scope fixed by a rule ignores the chips", () => { + const fixed = openFor( + init("silence"), + { label: "Rule", matchers: { rule_ids: [] } }, + BERLIN, + NOW, + ).model + expect(pick(fixed, "services", "checkout").scope).toEqual(fixed.scope) + }) +}) + describe("isFormReady", () => { - const typed = (services: string) => ({ _tag: "Typed" as const, services, tags: "", environments: "" }) + const typed = (service: string) => ({ + _tag: "Typed" as const, + services: service === "" ? [] : [service], + tags: [], + environments: [], + }) it("needs a reason and something to match", () => { const model = init("silence") @@ -88,6 +234,23 @@ describe("isFormReady", () => { Option.some("A silence can last at most 30 days"), ) }) + + it("a tried save on an incomplete form sends nothing and turns the errors on, an empty custom end included", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const custom = update(opened, Message.ChangedDuration({ duration: "custom" })).model + expect(untilError(custom)).toEqual(Option.none()) + const tried = update(custom, Message.ClickedSave()) + expect(tried.commands ?? []).toEqual([]) + expect(tried.model.isSaving).toBe(false) + expect(tried.model.isShowingErrors).toBe(true) + expect(untilError(tried.model)).toEqual(Option.some("Pick a date and time")) + expect(reasonError(tried.model.reason)).toEqual(Option.some("Add a reason")) + expect(scopeError(tried.model.scope)).toEqual( + Option.some("Add at least one service, environment or tag"), + ) + // The next open starts quiet again. + expect(openNew(tried.model, BERLIN, NOW).model.isShowingErrors).toBe(false) + }) }) describe("saving", () => { @@ -106,7 +269,7 @@ describe("saving", () => { } const saving = () => { const opened = openNew(init("silence"), BERLIN, NOW).model - const typed = { ...opened, reason: "deploy", scope: { ...opened.scope, services: "checkout" } } + const typed = { ...opened, reason: "deploy", scope: { ...opened.scope, services: ["checkout"] } } return update(typed, Message.ClickedSave()).model } diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index c2c034cdc7..a29b1de01b 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -3,13 +3,15 @@ import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetim import { Clock, DateTime, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" +import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import type { Resources } from "../../../platform/resources" import { MAX_ALERT_SILENCE_DURATION_MS } from "@maple/domain/http" -import { hasMatchers, MatcherField, matchersFromTyped, TypedMatchers } from "../shared/matcher-fields" +import { shortZoneName } from "../../kit/timezone-zones" +import { hasMatchers, MatcherField, splitList } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" -// "Silence notifications": the dialog the hub, a rule and an incident open, as one Submodel. +// "Silence notifications": the side panel the hub, a rule and an incident open, as one Submodel. export const SilenceDuration = Schema.Literals(["1h", "4h", "morning", "custom"]) export type SilenceDuration = typeof SilenceDuration.Type @@ -17,16 +19,41 @@ export type SilenceDuration = typeof SilenceDuration.Type export const ScopeField = MatcherField export type ScopeField = MatcherField -/** What the silence covers: fixed by the page that opened it, or typed in (the hub's "New silence"). */ +const Chips = Schema.Array(Schema.String) + +/** What the silence covers: fixed by the page that opened it, or picked as chips (the hub's "New silence"). */ export const Scope = Schema.Union([ Schema.TaggedStruct("Fixed", { label: Schema.String, matchers: V2AlertSilenceMatchers }), - Schema.TaggedStruct("Typed", TypedMatchers.fields), + Schema.TaggedStruct("Typed", { services: Chips, tags: Chips, environments: Chips }), ]) export type Scope = typeof Scope.Type +/** What each chips input offers before anything is typed: the names the org's rules already use. */ +export type ScopeSuggestions = Readonly>> + +const NO_CHIPS: Extract = { + _tag: "Typed", + services: [], + tags: [], + environments: [], +} + +/** One chips input per scope field; each field's chips live on the scope. */ +const ScopeCombos = Schema.Struct({ + services: Combobox.Model, + tags: Combobox.Model, + environments: Combobox.Model, +}) + +const initCombos = (id: string): typeof ScopeCombos.Type => { + const combo = (field: ScopeField) => Combobox.init(`${id}-${field}`, "multiple", { autoHighlight: true }) + return { services: combo("services"), tags: combo("tags"), environments: combo("environments") } +} + export const Model = Schema.Struct({ dialog: Dialog.Model, scope: Scope, + combos: ScopeCombos, reason: Schema.String, duration: SilenceDuration, /** The custom end, as a `datetime-local` input holds it (`2026-11-06T18:30`), in `timeZone`. */ @@ -35,20 +62,24 @@ export const Model = Schema.Struct({ /** The wall clock when the dialog opened: a custom end is checked against it. */ nowMs: Schema.Number, isSaving: Schema.Boolean, + /** A save was tried on an incomplete form: each field left out says so. */ + isShowingErrors: Schema.Boolean, /** Bumped on every open: a save's result only settles the form it was sent from. */ openCount: Schema.Number, }) export type Model = typeof Model.Type export const init = (id: string): Model => ({ - dialog: Dialog.init(id, "dialog"), - scope: { _tag: "Typed", services: "", tags: "", environments: "" }, + dialog: Dialog.init(id, "sheet"), + scope: NO_CHIPS, + combos: initCombos(id), reason: "", duration: "1h", until: "", timeZone: "UTC", nowMs: 0, isSaving: false, + isShowingErrors: false, openCount: 0, }) @@ -57,7 +88,7 @@ export const Message = defineMessageUnion({ ChangedReason: { value: Schema.String }, ChangedDuration: { duration: SilenceDuration }, ChangedUntil: { value: Schema.String }, - ChangedScopeField: { field: ScopeField, value: Schema.String }, + GotScopeComboMessage: { field: ScopeField, message: Combobox.Message }, ClickedCancel: {}, ClickedSave: {}, CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2AlertSilence, Schema.String) }, @@ -122,13 +153,27 @@ export const silenceEndMs = ( } } -/** The matchers the form describes; empty matchers mean there is nothing to save yet. */ +/** The matchers the form describes; a field without chips is left out, and none mean nothing to save yet. */ export const matchersOf = (scope: Scope): V2AlertSilenceMatchers => - scope._tag === "Fixed" ? scope.matchers : matchersFromTyped(scope) + scope._tag === "Fixed" + ? scope.matchers + : { + ...(scope.services.length === 0 ? undefined : { services: scope.services }), + ...(scope.tags.length === 0 ? undefined : { tags: scope.tags }), + ...(scope.environments.length === 0 ? undefined : { environments: scope.environments }), + } -/** Why a typed custom end cannot be used, or none when it can (or nothing is typed yet). */ +/** The zone the custom end is read in, as the viewer knows it at that end ("CET", then "CEST" past the change). */ +export const endZoneLabel = (model: Pick): string => + shortZoneName( + model.timeZone, + Option.getOrElse(parseLocalDateTime(model.until, model.timeZone), () => model.nowMs), + ) + +/** Why a typed custom end cannot be used, or none when it can; an empty one waits for a tried save. */ export const untilError = (model: Model): Option.Option => { - if (model.duration !== "custom" || model.until === "") return Option.none() + if (model.duration !== "custom") return Option.none() + if (model.until === "") return model.isShowingErrors ? Option.some("Pick a date and time") : Option.none() return Option.match(parseLocalDateTime(model.until, model.timeZone), { onNone: () => Option.some("Pick a date and time"), onSome: (endMs) => @@ -140,6 +185,15 @@ export const untilError = (model: Model): Option.Option => { }) } +/** A silence always says why: the reason shows on its row and on the pages it covers. */ +export const reasonError = (reason: string): Option.Option => + Str.isEmpty(Str.trim(reason)) ? Option.some("Add a reason") : Option.none() + +export const scopeError = (scope: Scope): Option.Option => + hasMatchers(matchersOf(scope)) + ? Option.none() + : Option.some("Add at least one service, environment or tag") + /** Ready to save: a reason, something to match and an end that is ahead and within 30 days. */ export const isFormReady = (model: Model): boolean => Str.isNonEmpty(Str.trim(model.reason)) && @@ -187,12 +241,14 @@ const open = (model: Model, scope: Scope, timeZone: string, nowMs: number): Retu { ...model, scope, + combos: initCombos(model.dialog.id), timeZone, nowMs, reason: "", duration: "1h", until: "", isSaving: false, + isShowingErrors: false, openCount: model.openCount + 1, }, Dialog.open(model.dialog), @@ -208,24 +264,58 @@ export const openFor = ( /** A silence the user scopes by service, tag and environment. */ export const openNew = (model: Model, timeZone: string, nowMs: number): Return => - open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone, nowMs) + open(model, NO_CHIPS, timeZone, nowMs) -const save = (model: Model): Return => - model.isSaving || !isFormReady(model) - ? { model } - : { - model: { ...model, isSaving: true }, - commands: [ - SaveSilence({ - openCount: model.openCount, - reason: Str.trim(model.reason), - matchers: matchersOf(model.scope), - duration: model.duration, - until: model.until, - timeZone: model.timeZone, - }), - ], - } +/** The chips for what was picked or typed: "checkout, payments" is two names, each listed once. */ +const chipsOf = (names: ReadonlyArray): ReadonlyArray => + Arr.dedupe(Arr.flatMap(names, splitList)) + +/** A chips input's step: its popup state stays on the combo, a changed selection lands on the scope. */ +const scopeComboStep = (model: Model, field: ScopeField, message: Combobox.Message): Return => { + if (model.scope._tag === "Fixed") return { model } + const chips = model.scope[field] + const step = Combobox.update({ ...model.combos[field], selected: chips }, message) + // Enter on typed text only ever adds: the combobox would toggle a name that is already a chip off. + const isTypedEntry = + message._tag === "PressedInputKey" && Str.isNonEmpty(Str.trim(model.combos[field].inputValue)) + const scope = + step.outMessage?._tag === "ChangedSelection" + ? { + ...model.scope, + [field]: chipsOf( + isTypedEntry ? [...chips, ...step.outMessage.selected] : step.outMessage.selected, + ), + } + : model.scope + return { + model: { ...model, scope, combos: { ...model.combos, [field]: step.model } }, + commands: Command.mapMessages(step.commands ?? [], (inner) => + Message.GotScopeComboMessage({ field, message: inner }), + ), + } +} + +/** Closes the panel, as Cancel does; a page whose URL holds it closes it on Back. */ +export const close = (model: Model): Return => liftDialog(model, Dialog.close(model.dialog)) + +const save = (model: Model): Return => { + if (model.isSaving) return { model } + // An incomplete form stays open and names what is missing, as the other alert forms do. + if (!isFormReady(model)) return { model: { ...model, isShowingErrors: true } } + return { + model: { ...model, isSaving: true }, + commands: [ + SaveSilence({ + openCount: model.openCount, + reason: Str.trim(model.reason), + matchers: matchersOf(model.scope), + duration: model.duration, + until: model.until, + timeZone: model.timeZone, + }), + ], + } +} export const update = (model: Model, message: Message): Return => Message.match(message, { @@ -233,11 +323,8 @@ export const update = (model: Model, message: Message): Return => ChangedReason: ({ value }) => ({ model: { ...model, reason: value } }), ChangedDuration: ({ duration }) => ({ model: { ...model, duration } }), ChangedUntil: ({ value }) => ({ model: { ...model, until: value } }), - ChangedScopeField: ({ field, value }) => - model.scope._tag === "Fixed" - ? { model } - : { model: { ...model, scope: { ...model.scope, [field]: value } } }, - ClickedCancel: () => liftDialog(model, Dialog.close(model.dialog)), + GotScopeComboMessage: ({ field, message: child }) => scopeComboStep(model, field, child), + ClickedCancel: () => close(model), ClickedSave: () => save(model), CompletedSave: ({ openCount, result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 6e1b31bfc3..1c8b288f3e 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -1,15 +1,35 @@ -import { Option, Array as Arr } from "effect" +import { Option, Array as Arr, String as Str } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { eyebrow } from "@maple/views/ui/eyebrow" -import { formDialog } from "@maple/views/ui/form-dialog" +import { button } from "@maple/views/ui/button" +import * as Combobox from "@maple/views/ui/combobox" +import { chipsView } from "@maple/views/ui/combobox-view" import { panel } from "@maple/views/ui/panel" import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { sidePanelSheet } from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" +import { fieldErrorText } from "@maple/views/ui/field" import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" -import { isFormReady, Message, type Model, type SilenceDuration, silenceEndMs, untilError } from "./dialog" -import { dialogTextField, type DialogTextFieldOptions, matcherFields } from "../shared/matcher-fields" +import { + endZoneLabel, + Message, + type Model, + reasonError, + type Scope, + scopeError, + type ScopeField, + type ScopeSuggestions, + type SilenceDuration, + silenceEndMs, + untilError, +} from "./dialog" +import { + dialogHelp, + dialogLabel, + dialogTextField, + type DialogTextFieldOptions, +} from "../shared/matcher-fields" import { formatSilenceTime } from "../shared/silences" type H = HtmlBuilder @@ -18,7 +38,7 @@ const DURATIONS: ReadonlyArray<{ value: SilenceDuration; label: string }> = [ { value: "1h", label: "1 hour" }, { value: "4h", label: "4 hours" }, { value: "morning", label: "Until 09:00" }, - { value: "custom", label: "Pick time" }, + { value: "custom", label: "Custom" }, ] /** "Silence for 1 hour", or the end it will stop at: "Silence until Nov 7, 18:30". */ @@ -30,9 +50,14 @@ const submitLabel = (model: Model): string => : Option.match(silenceEndMs(model.duration, model.until, model.nowMs, model.timeZone), { onNone: () => "Silence", onSome: (endMs) => - `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone)}`, + // 24h, as the "Until 09:00" choice and the end's time picker read. + `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone, "h23")}`, }) +/** A field's error once a save was tried. */ +const shown = (model: Model, error: Option.Option): Option.Option => + model.isShowingErrors ? error : Option.none() + const textField = (h: H, options: Omit, "idPrefix">): Html => dialogTextField(h, { ...options, idPrefix: "silence" }) @@ -44,77 +69,166 @@ const fixedScopeLine = (matchers: V2AlertSilenceMatchers): string => { : "Every notification of this rule" } -const scopeSection = (h: H, model: Model): Html => - vstack(h, { gap: "2" }, [ - eyebrow(h, { variant: "label", as: "div" }, ["Covers"]), - model.scope._tag === "Fixed" - ? panel(h, { padded: true, className: "gap-1" }, [ - typography(h, { size: "sm", weight: "medium" }, [model.scope.label]), - typography(h, { size: "xs", tone: "muted" }, [fixedScopeLine(model.scope.matchers)]), - ]) - : panel( - h, - { padded: true, className: "gap-3" }, - matcherFields(h, { - idPrefix: "silence", - typed: model.scope, - onInput: (field, value) => Message.ChangedScopeField({ field, value }), - }), - ), +const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholder: string }> = [ + { field: "services", label: "Services", placeholder: "Add a service" }, + { field: "environments", label: "Environments", placeholder: "Add an environment" }, + { field: "tags", label: "Tags", placeholder: "Add a tag" }, +] + +const SCOPE_HELP_ID = "silence-scope-help" +const SCOPE_ENTRY_HELP_ID = "silence-scope-entry-help" +const SCOPE_ERROR_ID = "silence-scope-error" + +const NO_SUGGESTIONS: ScopeSuggestions = { services: [], environments: [], tags: [] } + +/** The suggestions, then what is typed when it is none of them: a name no rule lists can still be added. */ +const itemsOf = (suggested: ReadonlyArray, typed: string): ReadonlyArray => { + const draft = Str.trim(typed) + return draft === "" || suggested.includes(draft) ? suggested : [...suggested, draft] +} + +/** One scope field: its label and the chips input, suggesting what the org's rules already name. */ +const chipsField = ( + h: H, + model: Model, + scope: Extract, + suggestions: ScopeSuggestions, + { field, label, placeholder }: (typeof SCOPE_FIELDS)[number], +): Html => { + const combo = model.combos[field] + const labelId = `${combo.id}-label` + return vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: labelId, htmlFor: Combobox.inputId(combo.id) }, label), + h.submodel({ + slotId: `${combo.id}-chips`, + model: { ...combo, selected: scope[field] }, + view: chipsView, + toParentMessage: (message) => Message.GotScopeComboMessage({ field, message }), + viewInputs: { + items: itemsOf(suggestions[field], combo.inputValue), + ariaLabelledBy: labelId, + ariaDescribedBy: `${SCOPE_ENTRY_HELP_ID} ${SCOPE_HELP_ID}`, + placeholder, + isDisabled: model.isSaving, + toView: (parts) => h.div([], parts), + }, + }), ]) +} -const durationSection = (h: H, model: Model): Html => - vstack(h, { gap: "2" }, [ - eyebrow(h, { variant: "label", as: "div" }, ["For"]), +const scopeSection = (h: H, model: Model, suggestions: ScopeSuggestions): ReadonlyArray => { + const scope = model.scope + return scope._tag === "Fixed" + ? [ + vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: "silence-covers-label" }, "Covers"), + panel(h, { padded: true, className: "gap-1" }, [ + typography(h, { size: "sm", weight: "medium" }, [scope.label]), + typography(h, { size: "xs", tone: "muted" }, [fixedScopeLine(scope.matchers)]), + ]), + ]), + ] + : [ + ...Arr.map(SCOPE_FIELDS, (entry) => chipsField(h, model, scope, suggestions, entry)), + vstack(h, { gap: "1.5" }, [ + // In the flow, not in the list's popup: there it sat over the next field's label. + dialogHelp( + h, + SCOPE_ENTRY_HELP_ID, + "Type a name and press Enter to add it. Commas separate several.", + ), + dialogHelp( + h, + SCOPE_HELP_ID, + "Fill in at least one. A rule is silenced only if it matches every field you fill in.", + ), + ...Option.toArray( + Option.map(shown(model, scopeError(scope)), (message) => + fieldErrorText( + h, + { attributes: [h.Id(SCOPE_ERROR_ID), h.Role("alert")] }, + message, + ), + ), + ), + ]), + ] +} + +const durationSection = (h: H, model: Model): ReadonlyArray => [ + vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: "silence-duration-label" }, "Duration"), segmentedSelect(h, { ariaLabel: "Silence duration", options: DURATIONS, value: model.duration, onChange: (duration) => Message.ChangedDuration({ duration }), - size: "sm", + size: "lg", className: "w-fit flex-wrap", }), - ...(model.duration === "custom" - ? [ + ]), + ...(model.duration === "custom" + ? [ + textField(h, { + name: "until", + // The viewer's own clock, named: "Ends, CET". + label: `Ends, ${endZoneLabel(model)}`, + value: model.until, + placeholder: "", + type: "datetime-local", + onInput: (value) => Message.ChangedUntil({ value }), + description: "Your local time. At most 30 days from now.", + error: untilError(model), + }), + ] + : []), +] + +/** The "Silence notifications" side panel: what it covers, for how long, and why. */ +export const view = Submodel.defineView( + (model, inputs, h) => + sidePanelSheet(h, { + sheet: model.dialog, + size: "compact", + toParentMessage: (message) => Message.GotDialogMessage({ message }), + title: "Silence notifications", + description: + "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", + body: [ + vstack(h, { gap: "5" }, [ + ...scopeSection(h, model, inputs.suggestions ?? NO_SUGGESTIONS), + ...durationSection(h, model), textField(h, { - name: "until", - label: "Ends", - value: model.until, - placeholder: "", - type: "datetime-local", - onInput: (value) => Message.ChangedUntil({ value }), - description: `In ${model.timeZone}. At most 30 days from now.`, - error: untilError(model), + name: "reason", + label: "Reason", + value: model.reason, + placeholder: "Deploying checkout v2.3", + onInput: (value) => Message.ChangedReason({ value }), + error: shown(model, reasonError(model.reason)), }), - ] - : []), - ]) - -/** The "Silence notifications" dialog: what it covers, for how long, and why. */ -export const view = Submodel.defineView((model, inputs, h) => - formDialog(h, { - slotId: `${inputs.id}-dialog`, - model: model.dialog, - toParentMessage: (message) => Message.GotDialogMessage({ message }), - className: "sm:max-w-lg", - title: "Silence notifications", - description: - "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", - panelClassName: "space-y-5", - fields: [ - scopeSection(h, model), - durationSection(h, model), - textField(h, { - name: "reason", - label: "Reason", - value: model.reason, - placeholder: "Deploying checkout v2.3", - onInput: (value) => Message.ChangedReason({ value }), - }), - ], - onSubmit: Message.ClickedSave(), - submitLabel: submitLabel(model), - isPending: model.isSaving, - isSubmitDisabled: !isFormReady(model), - }), + ]), + ], + onSubmit: Message.ClickedSave(), + footer: (close) => [ + button( + h, + { + variant: "outline", + isDisabled: model.isSaving, + attributes: model.isSaving ? [] : close, + }, + ["Cancel"], + ), + button( + h, + { + type: "submit", + isLoading: model.isSaving, + // Never disabled for a missing field: pressing it says what is missing. + isDisabled: model.isSaving, + }, + [submitLabel(model)], + ), + ], + }), ) diff --git a/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts b/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts index 578dab66d2..8a3d104ce8 100644 --- a/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts +++ b/apps/web/src/page/dashboards/widget/widget-presets.scene.test.ts @@ -15,9 +15,6 @@ import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import * as QueryPanel from "../../kit/query-builder/panel" import * as Combobox from "@maple/views/ui/combobox" -import { CaretMessage } from "../../kit/where-autocomplete" -import { WhereFieldMessage } from "../../kit/where-field" -import { MountWhereFieldCaret } from "../../kit/where-field-command" import { Message } from "./message" import type { Model } from "./model" import { update } from "./page-update" @@ -222,17 +219,6 @@ describe("preset widgets in the editor", () => { rect: { left: 0, right: 225, top: 4, bottom: 5, width: 112, height: 32 }, }), ), - // Resolved with the query panel's message: the page's submodel wraps it. - Mount.resolve( - MountWhereFieldCaret, - QueryPanel.Message.GotWhereField({ - message: WhereFieldMessage.GotCaret({ - fieldId: - builder.state?.queries[0]?.id ?? expect.unreachable("the Builder has query A"), - message: CaretMessage.BlurredEditor(), - }), - }), - ), expectView(text("+ Formula")).toExist(), expectView(text("This preset's query can't be edited")).not.toExist(), ) @@ -277,15 +263,6 @@ describe("preset widgets in the editor", () => { scene( { update: (model: Model, message: Message) => update(model, message, context), view }, ...mounted(pie), - Mount.resolve( - MountWhereFieldCaret, - QueryPanel.Message.GotWhereField({ - message: WhereFieldMessage.GotCaret({ - fieldId: pie.state?.queries[0]?.id ?? expect.unreachable("the pie has query A"), - message: CaretMessage.BlurredEditor(), - }), - }), - ), expectView( text("This visualization groups data into categories. Add a group-by field"), ).toExist(), 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..3fd1a75319 --- /dev/null +++ b/apps/web/src/page/kit/chain-connector.ts @@ -0,0 +1,45 @@ +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" + +// `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 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; isPinned?: boolean }> | 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: 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")], + ), + ]), + ], + ) diff --git a/apps/web/src/page/kit/form-section.ts b/apps/web/src/page/kit/form-section.ts index 7405a6aac5..6a876a4117 100644 --- a/apps/web/src/page/kit/form-section.ts +++ b/apps/web/src/page/kit/form-section.ts @@ -12,7 +12,8 @@ import { dashboardPage } from "./dashboard-page" /** * `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) - * and its controls on the right; the two stack on phones. Rows sit in a `divide-y` card. + * and its controls on the right; the two stack in a card under 36rem wide (a phone, or a form + * beside a preview). Rows sit in a `divide-y` `formCard`, whose width they read. */ export const formSection = ( h: HtmlBuilder, @@ -29,12 +30,24 @@ export const formSection = ( [ h.AriaLabel(options.title), h.Class( - cn("grid gap-x-8 gap-y-2 px-4 py-4 sm:grid-cols-[136px_minmax(0,1fr)]", options.className), + cn( + "grid gap-x-8 gap-y-2 px-4 py-4 @min-[36rem]/form:grid-cols-[136px_minmax(0,1fr)]", + options.className, + ), ), ], [ h.div( - [h.Class(cn("flex flex-col gap-0.5", options.isCompact === true ? "sm:pt-1.5" : "sm:pt-2"))], + [ + h.Class( + cn( + "flex flex-col gap-0.5", + options.isCompact === true + ? "@min-[36rem]/form:pt-1.5" + : "@min-[36rem]/form:pt-2", + ), + ), + ], [ heading(h, { level: 3, variant: "subsection" }, [options.title]), ...(options.hint === undefined @@ -47,15 +60,22 @@ export const formSection = ( ) /** `FormScrollArea`: a form page's scroller above its action bar, the page's own scroll area. */ -export const formScrollArea = (h: HtmlBuilder, children: ReadonlyArray): Html => +export const formScrollArea = ( + h: HtmlBuilder, + children: ReadonlyArray, + className?: string, +): Html => h.div( - [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], + [ + h.DataAttribute("slot", "page-scroll-area"), + h.Class(cn("min-h-0 flex-1 overflow-auto p-4", className)), + ], children, ) /** `FormCard`: the bordered card a form's sections sit in, ruled between them. */ export const formCard = (h: HtmlBuilder, children: ReadonlyArray, className?: string): Html => - dividedList(h, { as: "div", className: cn("bg-card", className) }, children) + dividedList(h, { as: "div", className: cn("@container/form bg-card", className) }, children) /** * `FormActionBar`: pinned under a form's scroller, outside it, so it never floats over the form: 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..6bf72b6447 --- /dev/null +++ b/apps/web/src/page/kit/pick-chips.ts @@ -0,0 +1,134 @@ +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 + /** The remove button's id, for a form that moves focus to it when the chip before it goes. */ + removeId?: 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: [ + ...(entry.removeId === undefined ? [] : [h.Id(entry.removeId)]), + 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/page/kit/query-builder/condition-builder-view.ts b/apps/web/src/page/kit/query-builder/condition-builder-view.ts new file mode 100644 index 0000000000..20cb9cb4e9 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -0,0 +1,346 @@ +import type { WhereClauseAutocompleteValues } from "../../../lib/query-builder/where-clause-autocomplete" +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import * as Combobox from "@maple/views/ui/combobox" +import { comboboxPicker } from "@maple/views/ui/combobox-picker" +import { chipsView, view as comboboxView } from "@maple/views/ui/combobox-view" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import { hstack, vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { + type ConditionField, + type FieldPickerItem, + fieldOf, + fieldPickerItems, + fieldsOf, + groupLabelOf, + isOwnedElsewhere, + OPERATOR_LABEL, + operatorsOf, + sharesLabel, + valueSuggestionsOf, +} from "./condition-fields" +import { type ConditionRow, isUnspellable, isValueless } from "./condition-rows" +import { conditionPickerId, operatorSelectId, valuePickerOf } from "./conditions" +import type { QueryDraft } from "./draft" +import { Message, type Model } from "./panel-message" +import { panelSelect } from "./panel-select" +import { Array as Arr, Option, String as Str } from "effect" + +// `ConditionBuilder`: the where clause as `[field] [operator] [values]` rows, AND-ed, each +// picked from the query's own vocabulary. + +type H = HtmlBuilder + +const GROUP_SHORT = { common: "", resource: "resource", attribute: "attr" } + +/** The pick as the list shows it: no `attr.` / `resource.` prefix, the group named only to tell two fields apart. */ +const fieldTrigger = (h: H, field: ConditionField, isLabelShared: boolean): ReadonlyArray => + isLabelShared && field.group !== "common" + ? [ + field.label, + typography(h, { size: "2xs", tone: "subtle", as: "span", className: "ml-1.5" }, [ + GROUP_SHORT[field.group], + ]), + ] + : [field.label] + +const fieldItem = ( + h: HtmlBuilder, + item: FieldPickerItem, + dataSource: QueryDraft["dataSource"], + isSearching: boolean, +): ReadonlyArray => { + const row = (label: string, trailing: ReadonlyArray) => + hstack(h, { gap: "2", justify: "between", shrink: true, className: "w-full" }, [ + typography(h, { truncate: true, as: "span" }, [label]), + ...trailing, + ]) + const hint = (text: string) => + typography( + h, + { size: "2xs", tone: "subtle", mono: true, as: "span", truncate: true, className: "max-w-1/2" }, + [text], + ) + switch (item._tag) { + case "Field": + return [ + row(item.field.label, [ + ...(item.field.group === "common" + ? [hint(item.field.key)] + : isSearching + ? [hint(GROUP_SHORT[item.field.group])] + : []), + ]), + ] + case "Group": + return [ + row(groupLabelOf(item.group, dataSource), [ + hstack(h, { gap: "1", as: "span" }, [ + hint(String(item.count)), + icon(h, "ChevronRightIcon", { size: 12, className: "text-foreground-subtle" }), + ]), + ]), + ] + case "Back": + return [ + hstack(h, { gap: "1.5", as: "span" }, [ + icon(h, "ChevronLeftIcon", { size: 12, className: "text-foreground-subtle" }), + "All fields", + ]), + ] + case "Custom": + return [row(`Use "${item.key}"`, [])] + } +} + +const fieldPicker = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + index: number, + values: WhereClauseAutocompleteValues, + rowFields: ReadonlyArray, +): Html => { + const id = conditionPickerId(query.id, row.id, "field") + const picker = model.conditionPickers[id] ?? Combobox.init(id, "single") + const current = { ...picker, selected: row.key === "" ? [] : [row.key] } + const search = Combobox.queryOf(current) + const fields = fieldsOf(query.dataSource, values) + const field = fieldOf(row.key, query.dataSource) + const items = fieldPickerItems(fields, model.fieldDrills[id] ?? "all", search, query.dataSource) + const byValue = new Map(Arr.map(items, (item) => [item.value, item])) + const firstGroup = Option.getOrNull(Arr.findFirst(items, (item) => item._tag === "Group")) + return h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: current, + view: comboboxPicker, + toParentMessage: (message) => + Message.GotConditionField({ queryId: query.id, rowId: row.id, message }), + viewInputs: { + items: Arr.map(items, (item) => item.value), + // The label replaces the trigger's text for screen readers, so it carries the pick. + ariaLabel: + row.key === "" + ? `Field for condition ${index + 1}` + : `Field for condition ${index + 1}: ${row.key}`, + searchPlaceholder: "Search fields…", + emptyText: "No fields match", + trigger: + row.key === "" + ? ["Choose field"] + : fieldTrigger(h, field, sharesLabel(field, [...fields, ...rowFields])), + isPlaceholder: row.key === "", + ...(row.key === "" ? undefined : { tooltip: row.key }), + triggerClassName: "w-full max-w-80", + renderItem: (itemH, value) => { + const item = byValue.get(value) + return item === undefined + ? [value] + : fieldItem(itemH, item, query.dataSource, Str.trim(search) !== "") + }, + itemClassName: (value) => + firstGroup !== null && value === firstGroup.value + ? "mt-1 border-t border-border-subtle pt-1" + : undefined, + toView: (parts) => h.div([h.Class(FIELD_CELL)], parts), + }, + }) +} + +/** A row's field in words, for the labels of its controls. */ +const rowFieldLabel = (row: ConditionRow, dataSource: QueryDraft["dataSource"]): string => + row.key === "" ? "new condition" : fieldOf(row.key, dataSource).label + +/** "Service is checkout, api", or just "Service" before a value is picked: what a row's remove button removes. */ +const rowSummary = (row: ConditionRow, dataSource: QueryDraft["dataSource"]): string => + row.key === "" + ? "new condition" + : Arr.join( + [ + rowFieldLabel(row, dataSource), + ...(isValueless(row.operator) || row.values.length > 0 + ? [OPERATOR_LABEL[row.operator]] + : []), + ...(row.values.length === 0 ? [] : [Arr.join(row.values, ", ")]), + ], + " ", + ) + +const UNSPELLABLE_NOTE = `A value can't contain both ' and "` +const VALUE_HINT = "Type a value and press Enter" + +const valuePicker = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => { + const id = conditionPickerId(query.id, row.id, "values") + const picker = valuePickerOf(model, query, row) + const suggestions = valueSuggestionsOf(fieldOf(row.key, query.dataSource), query.dataSource, values) + const typed = Str.trim(Combobox.queryOf(picker)) + const isUnspellableTyped = isUnspellable(typed) + // What is typed is offered first when it is not already a suggestion, so a click adds it too. + const isTypedNew = + typed !== "" && !isUnspellableTyped && !suggestions.includes(typed) && !row.values.includes(typed) + // A value the clause can't spell offers nothing: the open list says why, where the note under + // the field is covered by it. + const items = isUnspellableTyped ? [] : isTypedNew ? [typed, ...suggestions] : suggestions + const emptyText = isUnspellableTyped ? UNSPELLABLE_NOTE : VALUE_HINT + const ariaLabel = `Value for ${rowFieldLabel(row, query.dataSource)}` + const toMessage = (message: Combobox.Message) => + Message.GotConditionValues({ queryId: query.id, rowId: row.id, message }) + const renderItem = (value: string): ReadonlyArray => + isTypedNew && value === typed ? [`Use "${value}"`] : [value] + const note = isUnspellableTyped + ? [typography(h, { size: "2xs", tone: "warning", as: "p" }, [UNSPELLABLE_NOTE])] + : [] + const field = + picker.mode === "multiple" + ? h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: picker, + view: chipsView, + toParentMessage: toMessage, + viewInputs: { + items, + ariaLabel, + placeholder: row.values.length === 0 ? "Value" : "", + emptyText, + size: "sm", + renderItem: (_, value) => renderItem(value), + toView: (parts) => h.div([h.Class("min-w-0")], parts), + }, + }) + : h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: picker, + view: comboboxView, + toParentMessage: toMessage, + viewInputs: { + items, + ariaLabel, + placeholder: "Value", + emptyText, + size: "sm", + highlightsSelected: true, + renderItem, + toView: ({ field: input, rest }) => h.div([h.Class("min-w-0")], [input, ...rest]), + }, + }) + return [field, ...note] +} + +// Wide (the where area, `@container/where`): one line of [lead] [field] [operator] [values] +// [remove], the field and operator as wide as their text. Narrow: the lead, field and remove on +// one line, then the operator and values under the field. `min-w` is what a row needs beside the +// editor's floated controls; with less it drops under them. +const ROW_GRID = + "grid min-w-52 grid-cols-[2.5rem_max-content_minmax(0,1fr)_auto] items-center gap-1.5 @xl/where:min-w-108 @xl/where:grid-cols-[2.5rem_minmax(8rem,max-content)_max-content_minmax(10rem,1fr)_auto]" +const FIELD_CELL = "col-span-2 col-start-2 row-start-1 min-w-32 @xl/where:col-span-1" +const OPERATOR_CELL = "col-start-2 row-start-2 @xl/where:col-start-3 @xl/where:row-start-1" +const VALUES_CELL = + "col-span-2 col-start-3 row-start-2 min-w-0 @xl/where:col-span-1 @xl/where:col-start-4 @xl/where:row-start-1" +const REMOVE_CELL = "col-start-4 row-start-1 @xl/where:col-start-5" + +const conditionRow = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + index: number, + values: WhereClauseAutocompleteValues, + rowFields: ReadonlyArray, +): Html => { + const connector = typography(h, { size: "xs", tone: "muted", className: "col-start-1 row-start-1" }, [ + index === 0 ? "Where" : "and", + ]) + const remove = iconButton( + h, + { + size: "icon-xs", + label: `Remove condition: ${rowSummary(row, query.dataSource)}`, + hasTooltip: false, + className: cn(REMOVE_CELL, "text-muted-foreground hover:text-foreground active:text-foreground"), + attributes: [h.OnClick(Message.ClickedRemoveCondition({ queryId: query.id, rowId: row.id }))], + }, + [icon(h, "XmarkIcon")], + ) + const field = fieldPicker(h, model, query, row, index, values, rowFields) + const layout = (parts: ReadonlyArray) => + h.div( + [h.Class(ROW_GRID), h.DataAttribute("slot", "condition-row")], + [connector, field, ...parts, remove], + ) + if (row.key === "") return layout([]) + const fieldLabel = rowFieldLabel(row, query.dataSource) + return panelSelect( + h, + model, + operatorSelectId(query.id, row.id), + row.operator, + Arr.map( + operatorsOf(fieldOf(row.key, query.dataSource), query.dataSource, row.operator), + (operator) => ({ + value: operator, + label: OPERATOR_LABEL[operator], + }), + ), + { + size: "sm", + // As wide as the operator's words, not the select's default 9rem. + className: cn(OPERATOR_CELL, "min-w-16"), + // The label replaces the trigger's text for screen readers, so it carries the pick. + ariaLabel: `Operator for ${fieldLabel}: ${OPERATOR_LABEL[row.operator]}`, + alignItemWithTrigger: false, + toView: (parts) => + layout([ + ...parts, + ...(isValueless(row.operator) + ? [] + : [ + vstack( + h, + { gap: "1", className: VALUES_CELL }, + valuePicker(h, model, query, row, values), + ), + ]), + ]), + }, + ) +} + +/** The rows a query shows, or nothing: the caller's "Add condition" starts the first. A row another + * control owns (traces `root_only`) stays in the clause but is not drawn. */ +export const conditionBuilder = ( + h: H, + model: Model, + query: QueryDraft, + rows: ReadonlyArray, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => { + const shown = Arr.filter(rows, (row) => !isOwnedElsewhere(row.key, query.dataSource)) + const rowFields = Arr.map( + Arr.filter(shown, (row) => row.key !== ""), + (row) => fieldOf(row.key, query.dataSource), + ) + // A plain block, not a stack: each row lays out against the editor's floated controls itself. + return shown.length === 0 + ? [] + : [ + h.div( + [ + h.AriaLabel(`Conditions for query ${query.name}`), + h.Class("space-y-1.5"), + h.Role("group"), + ], + Arr.map(shown, (row, index) => + conditionRow(h, model, query, row, index, values, rowFields), + ), + ), + ] +} diff --git a/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts new file mode 100644 index 0000000000..4fb03880bf --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts @@ -0,0 +1,292 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { + click, + Command, + expect as expectView, + given, + keydown, + label, + Mount, + role, + scene, + text, + type as typeInto, +} from "foldkit/scene" +import { describe, test } from "vitest" +import * as Animation from "@foldkit/ui/animation" +import * as Combobox from "@maple/views/ui/combobox" +import { CaretMessage } from "../where-autocomplete" +import { WhereFieldMessage } from "../where-field" +import { MountWhereFieldCaret } from "../where-field-command" +import { type Model, update, view } from "../../../lab/condition-builder-lab" +import type { QueryDraft } from "./draft" +import * as QueryPanel from "./panel" + +// The where clause as condition rows: every pick prints the clause the text editor would hold. + +const traces = (whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, "traces"), + whereClause, +}) + +const hosted = (whereClause: string): Model => ({ + panel: QueryPanel.init("test"), + queries: [traces(whereClause)], +}) + +const comboMounted = () => Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()) + +/** A picker's close animation, `generation` counting its open and close transitions. */ +const pickerClosed = (id: string, generation: number) => [ + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve( + Combobox.WaitForComboboxExit({ id, generation }), + Animation.Message.EndedAnimation({ generation }), + ), + Mount.expectEnded(Combobox.MountCombobox), +] + +/** A picker's open animation. */ +const pickerOpened = (generation: number) => [ + comboMounted(), + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation })), +] + +/** The text editor's caret, read when it mounts. */ +const caretMounted = () => + Mount.resolve( + MountWhereFieldCaret, + QueryPanel.Message.GotWhereField({ + message: WhereFieldMessage.GotCaret({ fieldId: "q", message: CaretMessage.BlurredEditor() }), + }), + ) + +describe("condition builder", () => { + test("a new row picks an attribute, then typed values, and prints an OR group", () => { + const value = role("combobox", { name: "Value for http.route" }) + scene( + { update, view }, + given(hosted("")), + expectView(text("(empty where clause)")).toExist(), + click(role("button", { name: "Add condition" })), + comboMounted(), + click(role("option", { name: /^Span attributes/ })), + click(role("option", { name: /^http\.route/ })), + ...pickerClosed("cond-q-c0-field", 1), + // Adding a row and picking its field leave the clause alone until a value completes it. + expectView(text("(empty where clause)")).toExist(), + typeInto(value, "/cart"), + ...pickerOpened(1), + keydown(value, "Enter"), + typeInto(value, "/checkout"), + keydown(value, "Enter"), + expectView(text('(attr.http.route = "/cart" OR attr.http.route = "/checkout")')).toExist(), + ) + }) + + test("the field picker drills into resource attributes", () => { + const value = role("combobox", { name: "Value for host.name" }) + scene( + { update, view }, + given(hosted('service.name = "checkout"')), + click(role("button", { name: "Add condition" })), + comboMounted(), + click(role("option", { name: /^Resource attributes/ })), + click(role("option", { name: /^host\.name/ })), + ...pickerClosed("cond-q-c1-field", 1), + typeInto(value, "web-1"), + ...pickerOpened(1), + keydown(value, "Enter"), + expectView(text('service.name = "checkout" AND resource.host.name = "web-1"')).toExist(), + ) + }) + + test("a named field holds one value: a pick replaces it and closes the list", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout"')), + click(role("combobox", { name: "Value for Service" })), + ...pickerOpened(1), + click(role("option", { name: "api" })), + ...pickerClosed("cond-q-c0-values", 2), + expectView(text('service.name = "api"')).toExist(), + ) + }) + + test("a value with both quote kinds is refused in the open list, and Enter keeps the clause", () => { + const value = role("combobox", { name: "Value for http.route" }) + scene( + { update, view }, + given(hosted('attr.http.route = "/cart"')), + typeInto(value, `it's "x"`), + ...pickerOpened(1), + expectView(role("status")).toHaveText(`A value can't contain both ' and "`), + expectView(role("option", { name: /^Use/ })).not.toExist(), + keydown(value, "Enter"), + expectView(role("status")).toHaveText(`A value can't contain both ' and "`), + expectView(text('attr.http.route = "/cart"')).toExist(), + ) + }) + + test("either quote alone is offered as a value", () => { + const value = role("combobox", { name: "Value for http.route" }) + scene( + { update, view }, + given(hosted('attr.http.route = "/cart"')), + typeInto(value, `it's`), + ...pickerOpened(1), + expectView(role("option", { name: `Use "it's"` })).toExist(), + keydown(value, "Enter"), + expectView(text(`(attr.http.route = "/cart" OR attr.http.route = "it's")`)).toExist(), + ) + }) + + test("removing a row keeps the other clauses as the user wrote them", () => { + scene( + { update, view }, + given(hosted("service = checkout AND attr.http.route exists")), + click(label("Remove condition: http.route is set")), + expectView(text("service = checkout")).toExist(), + ) + }) + + test("Text shows the editor over the same clause, and Builder brings the rows back", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout"')), + expectView(role("group", { name: "Conditions for query A" })).toExist(), + click(role("button", { name: "Text" })), + caretMounted(), + expectView(label("Where clause for query A")).toHaveValue('service.name = "checkout"'), + expectView(role("group", { name: "Conditions for query A" })).not.toExist(), + click(role("button", { name: "Builder" })), + Mount.expectEnded(MountWhereFieldCaret), + expectView(role("group", { name: "Conditions for query A" })).toExist(), + ) + }) + + test("typing in the field search and pressing Enter picks the highlighted field", () => { + const search = role("combobox", { name: "Search Field for condition 1" }) + scene( + { update, view }, + given(hosted("")), + click(role("button", { name: "Add condition" })), + comboMounted(), + typeInto(search, "http.ro"), + keydown(search, "Enter"), + ...pickerClosed("cond-q-c0-field", 1), + expectView(role("combobox", { name: "Field for condition 1: attr.http.route" })).toExist(), + ) + }) + + test("Enter on a freshly opened field list picks its first field", () => { + scene( + { update, view }, + given(hosted("")), + click(role("button", { name: "Add condition" })), + comboMounted(), + keydown(role("combobox", { name: "Search Field for condition 1" }), "Enter"), + ...pickerClosed("cond-q-c0-field", 1), + expectView(role("combobox", { name: "Field for condition 1: service.name" })).toExist(), + ) + }) + + test("a clause the rows can't show stays in the text editor and says why, once", () => { + scene( + { update, view }, + given(hosted('(attr.http.route = "/cart" OR attr.rpc.method = "Checkout")')), + caretMounted(), + expectView(text("This filter mixes fields in an OR, so it stays in Text.")).toExist(), + // The query applies an OR of attribute filters, so nothing warns. + expectView(text(/This OR group is not applied/)).not.toExist(), + expectView(label("Where clause for query A")).toExist(), + expectView(role("button", { name: "Builder" })).toBeDisabled(), + ) + }) + + test("a doubled operator stays in Text with its reason instead of a row whose value is =", () => { + scene( + { update, view }, + given(hosted("service.name ==")), + caretMounted(), + expectView( + text("This filter uses syntax the builder can't show, so it stays in Text."), + ).toExist(), + expectView(label("Where clause for query A")).toHaveValue("service.name =="), + expectView(role("group", { name: "Conditions for query A" })).not.toExist(), + expectView(role("button", { name: "Builder" })).toBeDisabled(), + ) + }) + + test("an OR group the query drops also says which group is not applied", () => { + scene( + { update, view }, + given(hosted('(service.name = "a" OR span.name = "b")')), + caretMounted(), + expectView(text("This filter mixes fields in an OR, so it stays in Text.")).toExist(), + expectView( + text( + "This OR group is not applied: (service.name = a OR span.name = b). Only attribute filters can be OR-ed.", + ), + ).toExist(), + ) + }) + + test("an OR group on a named field stays in Text, untouched, with its reason and the dropped group", () => { + const clause = '(service.name = "checkout" OR service.name = "worker")' + scene( + { update, view }, + given(hosted(clause)), + caretMounted(), + expectView(label("Where clause for query A")).toHaveValue(clause), + expectView(role("group", { name: "Conditions for query A" })).not.toExist(), + expectView(role("button", { name: "Builder" })).toBeDisabled(), + expectView( + text("This OR group is on a field that can't be OR-ed, so it stays in Text."), + ).toExist(), + // The note already says why, so the warning only names the group. + expectView( + text("This OR group is not applied: (service.name = checkout OR service.name = worker)."), + ).toExist(), + expectView(text(clause)).toExist(), + ) + }) + + test("two dropped OR groups give two lines, one per group", () => { + scene( + { update, view }, + given( + hosted('(service.name = "a" OR service.name = "b") AND (span.name = "x" OR span.name = "y")'), + ), + caretMounted(), + expectView( + text("This OR group is not applied: (service.name = a OR service.name = b)."), + ).toExist(), + expectView(text("This OR group is not applied: (span.name = x OR span.name = y).")).toExist(), + ) + }) + + test("the same OR group on an attribute key reads as one row with both values", () => { + scene( + { update, view }, + given(hosted('(attr.http.route = "/cart" OR attr.http.route = "/pay")')), + expectView(role("group", { name: "Conditions for query A" })).toExist(), + expectView(text(/stays in Text/)).not.toExist(), + expectView(text(/This OR group is not applied/)).not.toExist(), + ) + }) + + test("typing in the text editor keeps it, even once the text reads as rows", () => { + const editor = label("Where clause for query A") + scene( + { update, view }, + given(hosted('(service.name = "a" OR span.name = "b")')), + caretMounted(), + typeInto(editor, 'service.name = "a"'), + expectView(editor).toExist(), + expectView(role("group", { name: "Conditions for query A" })).not.toExist(), + expectView(role("button", { name: "Builder" })).not.toBeDisabled(), + ) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-fields.test.ts b/apps/web/src/page/kit/query-builder/condition-fields.test.ts new file mode 100644 index 0000000000..ac472897fc --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-fields.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "vitest" +import { + canMatchAnyOf, + fieldOf, + fieldPickerItems, + fieldsOf, + isPickable, + operatorsOf, + sharesLabel, +} from "./condition-fields" +import { Array as Arr } from "effect" + +// The builder offers what the source's clause handler applies (the engine's key rules). + +describe("operatorsOf", () => { + test.each([ + ["logs", "service.name", ["=", "!="]], + ["logs", "severity", ["=", "!="]], + ["logs", "body", ["=", "contains"]], + ["metrics", "service.name", ["="]], + ["metrics", "deployment.environment", ["="]], + ["metrics", "metric.type", ["="]], + ["traces", "has_error", ["="]], + ["traces", "min_duration_ms", ["="]], + ["traces", "service.name", ["=", "!=", "contains"]], + ["product_events", "event.name", ["=", "!="]], + ["product_events", "user.id", ["="]], + ] as const)("%s %s", (dataSource, key, expected) => { + expect(operatorsOf(fieldOf(key, dataSource), dataSource)).toEqual(expected) + }) + + test("a map attribute takes the operators of its kind", () => { + expect(operatorsOf(fieldOf("attr.http.route", "traces"), "traces")).toEqual([ + "=", + "!=", + "contains", + "!contains", + "exists", + "!exists", + ]) + expect(operatorsOf(fieldOf("attr.http.response.status_code", "logs"), "logs")).toContain(">=") + }) + + test("a row keeps its current operator on offer", () => { + expect(operatorsOf(fieldOf("severity", "logs"), "logs", "contains")).toEqual(["=", "!=", "contains"]) + }) +}) + +describe("canMatchAnyOf", () => { + test("only attribute filters on sources that lower OR groups", () => { + expect(canMatchAnyOf(fieldOf("attr.http.route", "logs"), "logs")).toBe(true) + expect(canMatchAnyOf(fieldOf("db.system", "traces"), "traces")).toBe(true) + expect(canMatchAnyOf(fieldOf("db.system", "logs"), "logs")).toBe(false) + expect(canMatchAnyOf(fieldOf("service.name", "traces"), "traces")).toBe(false) + expect(canMatchAnyOf(fieldOf("attr.plan", "product_events"), "product_events")).toBe(false) + }) +}) + +describe("fieldsOf", () => { + test("traces intrinsics are named fields, and root_only is left to the span select", () => { + const keys = Arr.map(fieldsOf("traces", {}), (field) => field.key) + expect(keys).toEqual( + expect.arrayContaining(["min_duration_ms", "max_duration_ms", "vcs.ref.head.revision"]), + ) + expect(keys).not.toContain("root_only") + expect(fieldOf("root_only", "traces")).toMatchObject({ group: "common", kind: "boolean" }) + }) +}) + +describe("sharesLabel", () => { + const fields = fieldsOf("traces", { + attributeKeys: ["service.version", "http.route"], + resourceAttributeKeys: ["service.version", "host.name"], + }) + + test("only a key that two groups hold needs its group named", () => { + expect(sharesLabel(fieldOf("resource.service.version", "traces"), fields)).toBe(true) + expect(sharesLabel(fieldOf("attr.service.version", "traces"), fields)).toBe(true) + expect(sharesLabel(fieldOf("attr.http.route", "traces"), fields)).toBe(false) + expect(sharesLabel(fieldOf("service.name", "traces"), fields)).toBe(false) + }) +}) + +// What the picker offers is what the source applies: a key it drops is not listed and cannot be typed in. +describe("fields the source drops", () => { + const vocabulary = { attributeKeys: ["plan", "http.route"], resourceAttributeKeys: ["host.name"] } + const offered = ( + dataSource: "traces" | "logs" | "metrics" | "product_events", + drill: "all" | "resource" | "attribute", + search: string, + ) => fieldPickerItems(fieldsOf(dataSource, vocabulary), drill, search, dataSource) + const keysOf = (items: ReturnType) => + Arr.flatMap(items, (item) => + item._tag === "Field" ? [item.field.key] : item._tag === "Custom" ? [item.key] : [], + ) + + test("product events list no resource attributes", () => { + expect( + Arr.filter(fieldsOf("product_events", vocabulary), (field) => field.group === "resource"), + ).toEqual([]) + expect(Arr.map(offered("product_events", "all", ""), (item) => item.value)).not.toContain( + "@group:resource", + ) + expect(Arr.map(offered("traces", "all", ""), (item) => item.value)).toContain("@group:resource") + }) + + test("product events take no typed resource.* key, in the list or inside a stale resource drill", () => { + expect(offered("product_events", "all", "resource.host.name")).toEqual([]) + expect(offered("product_events", "all", "host.name")).toEqual([ + { _tag: "Custom", value: "@custom:attr.host.name", key: "attr.host.name" }, + ]) + expect(keysOf(offered("product_events", "resource", "region"))).toEqual([]) + expect(keysOf(offered("traces", "all", "resource.cloud.region"))).toEqual(["resource.cloud.region"]) + }) + + test.each(["logs", "metrics"] as const)( + "%s never offer a bare unknown key: what is typed becomes an attribute", + (dataSource) => { + expect(isPickable("db.system", dataSource)).toBe(false) + expect(keysOf(offered(dataSource, "all", "db.system"))).toEqual(["attr.db.system"]) + expect(keysOf(offered(dataSource, "attribute", "db.system"))).toEqual(["attr.db.system"]) + expect(keysOf(offered(dataSource, "resource", "db.system"))).toEqual(["resource.db.system"]) + }, + ) + + test.each(["traces", "logs", "metrics", "product_events"] as const)( + "every key %s offers is one it applies", + (dataSource) => { + const keys = Arr.flatMap(["", "db.system", "resource.x", "attr.y", "service", "host"], (search) => + Arr.flatMap(["all", "resource", "attribute"] as const, (drill) => + keysOf(offered(dataSource, drill, search)), + ), + ) + expect(Arr.filter(keys, (key) => !isPickable(key, dataSource))).toEqual([]) + }, + ) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-fields.ts b/apps/web/src/page/kit/query-builder/condition-fields.ts new file mode 100644 index 0000000000..acab2a4056 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-fields.ts @@ -0,0 +1,263 @@ +import type { Operator } from "@maple/domain/where-clause" +import { whereKeyMatchesAnyOf, whereKeyRule, whereOperatorsOf } from "@maple/query-engine/query-builder" +import { + getWhereClauseAutocomplete, + type WhereClauseAutocompleteValues, +} from "../../../lib/query-builder/where-clause-autocomplete" +import { Array as Arr, Option, Schema, String as Str } from "effect" +import type { QueryDraft } from "./draft" + +// The condition builder's vocabulary: which fields a query offers (grouped like a path picker), +// the operators a field takes, and the values it suggests, all read from the where editor's +// autocomplete values. + +type DataSource = QueryDraft["dataSource"] + +export type FieldGroup = "common" | "resource" | "attribute" +export type FieldKind = "string" | "number" | "boolean" +export type ConditionField = Readonly<{ key: string; label: string; group: FieldGroup; kind: FieldKind }> + +const common = (key: string, label: string, kind: FieldKind = "string"): ConditionField => ({ + key, + label, + group: "common", + kind, +}) + +const COMMON_FIELDS = { + traces: [ + common("service.name", "Service"), + common("span.name", "Span name"), + common("deployment.environment", "Environment"), + common("attr.http.response.status_code", "HTTP status code", "number"), + common("has_error", "Has error", "boolean"), + common("min_duration_ms", "Min duration (ms)", "number"), + common("max_duration_ms", "Max duration (ms)", "number"), + common("vcs.ref.head.revision", "Commit"), + common("root_only", "Root spans only", "boolean"), + ], + logs: [common("service.name", "Service"), common("severity", "Severity"), common("body", "Message")], + metrics: [ + common("service.name", "Service"), + common("deployment.environment", "Environment"), + common("metric.type", "Metric type"), + ], + product_events: [ + common("event.name", "Event name"), + common("host", "Host"), + common("page.path", "Page path"), + common("service.name", "Service"), + common("user.id", "User"), + ], +} satisfies Record> + +const ATTRIBUTE_GROUP_LABEL = { + traces: "Span attributes", + logs: "Log attributes", + metrics: "Metric attributes", + product_events: "Event properties", +} satisfies Record + +export const groupLabelOf = (group: FieldGroup, dataSource: DataSource): string => + group === "common" + ? "Common" + : group === "resource" + ? "Resource attributes" + : ATTRIBUTE_GROUP_LABEL[dataSource] + +const NUMERIC_KEY = /(status_code|_ms|duration|\.port|\.count|\.size|_bytes)$/ + +const kindOfKey = (key: string): FieldKind => (NUMERIC_KEY.test(key) ? "number" : "string") + +const RESOURCE_PREFIX = "resource." +const ATTRIBUTE_PREFIX = "attr." + +/** A key as a field: a common one by name, else its attribute group with the prefix dropped. */ +export const fieldOf = (key: string, dataSource: DataSource): ConditionField => { + const known = Option.getOrNull(Arr.findFirst(COMMON_FIELDS[dataSource], (field) => field.key === key)) + if (known !== null) return known + if (Str.startsWith(RESOURCE_PREFIX)(key)) + return { key, label: Str.slice(RESOURCE_PREFIX.length)(key), group: "resource", kind: kindOfKey(key) } + const label = Str.startsWith(ATTRIBUTE_PREFIX)(key) ? Str.slice(ATTRIBUTE_PREFIX.length)(key) : key + return { key, label, group: "attribute", kind: kindOfKey(key) } +} + +/** Whether the query engine ORs this field's values (`whereKeyMatchesAnyOf`: attribute filters only). */ +export const canMatchAnyOf = (field: ConditionField, dataSource: DataSource): boolean => + field.kind !== "boolean" && whereKeyMatchesAnyOf(dataSource, field.key) + +/** A key another control owns: the traces span select writes `root_only`. */ +export const isOwnedElsewhere = (key: string, dataSource: DataSource): boolean => + dataSource === "traces" && key === "root_only" + +/** + * Whether the source applies a filter on this key at all (the engine's `whereOperatorsOf`). A key + * it drops (`resource.*` on product events) is never a new pick; a saved row on one stays as it is. + */ +export const isPickable = (key: string, dataSource: DataSource): boolean => + whereOperatorsOf(dataSource, key).length > 0 + +/** + * Every field the query offers: the common ones, then resource and own attributes as the editor + * writes them, less the keys the source drops. + */ +export const fieldsOf = ( + dataSource: DataSource, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => { + const known: ReadonlyArray = [ + ...Arr.filter(COMMON_FIELDS[dataSource], (field) => !isOwnedElsewhere(field.key, dataSource)), + ...Arr.map(values.resourceAttributeKeys ?? [], (key) => + fieldOf(`${RESOURCE_PREFIX}${key}`, dataSource), + ), + ...Arr.map(values.attributeKeys ?? [], (key) => fieldOf(`${ATTRIBUTE_PREFIX}${key}`, dataSource)), + ] + return Arr.filter( + Arr.dedupeWith(known, (a, b) => a.key === b.key), + (field) => isPickable(field.key, dataSource), + ) +} + +/** Whether another known field reads the same in a trigger, so this one needs its group named. */ +export const sharesLabel = (field: ConditionField, known: ReadonlyArray): boolean => + Arr.some(known, (other) => other.key !== field.key && other.label === field.label) + +// The field picker's list: a drill-down over the groups, flat while searching. + +export const FieldDrill = Schema.Literals(["all", "resource", "attribute"]) +export type FieldDrill = typeof FieldDrill.Type + +export type FieldPickerItem = + | Readonly<{ _tag: "Field"; value: string; field: ConditionField }> + | Readonly<{ _tag: "Group"; value: string; group: "resource" | "attribute"; count: number }> + | Readonly<{ _tag: "Back"; value: string }> + | Readonly<{ _tag: "Custom"; value: string; key: string }> + +const GROUP_VALUE = "@group:" +const BACK_VALUE = "@back" +const CUSTOM_VALUE = "@custom:" +const KEY_PATTERN = /^[a-zA-Z0-9_.-]+$/ + +const asField = (field: ConditionField): FieldPickerItem => ({ _tag: "Field", value: field.key, field }) + +const matchesQuery = (field: ConditionField, query: string): boolean => { + const needle = Str.toLowerCase(query) + return ( + Str.includes(needle)(Str.toLowerCase(field.key)) || Str.includes(needle)(Str.toLowerCase(field.label)) + ) +} + +/** + * What a typed key means inside a group: the group's prefix unless it already has one. A bare key + * is never free entry, so a source that drops unknown bare keys (logs, metrics) gets `attr.`. + */ +const customKeyOf = (typed: string, drill: FieldDrill, fields: ReadonlyArray): string => { + if (Str.startsWith(RESOURCE_PREFIX)(typed) || Str.startsWith(ATTRIBUTE_PREFIX)(typed)) return typed + if (drill === "resource") return `${RESOURCE_PREFIX}${typed}` + if (drill === "all" && Arr.some(fields, (field) => field.group === "common" && field.key === typed)) + return typed + return `${ATTRIBUTE_PREFIX}${typed}` +} + +/** The list for a search: the matching fields, then the typed key itself when the source takes it. */ +export const fieldPickerItems = ( + fields: ReadonlyArray, + drill: FieldDrill, + query: string, + dataSource: DataSource, +): ReadonlyArray => { + const typed = Str.trim(query) + const inScope = drill === "all" ? fields : Arr.filter(fields, (field) => field.group === drill) + const back: ReadonlyArray = drill === "all" ? [] : [{ _tag: "Back", value: BACK_VALUE }] + if (typed !== "") { + const key = customKeyOf(typed, drill, fields) + const custom: ReadonlyArray = + KEY_PATTERN.test(typed) && + isPickable(key, dataSource) && + !Arr.some(fields, (field) => field.key === key) + ? [{ _tag: "Custom", value: `${CUSTOM_VALUE}${key}`, key }] + : [] + return [ + ...Arr.map( + Arr.filter(inScope, (field) => matchesQuery(field, typed)), + asField, + ), + ...custom, + ] + } + if (drill !== "all") return [...back, ...Arr.map(inScope, asField)] + const groupNames: ReadonlyArray<"resource" | "attribute"> = ["resource", "attribute"] + const groups = Arr.flatMap(groupNames, (group): ReadonlyArray => { + const count = Arr.filter(fields, (field) => field.group === group).length + return count === 0 ? [] : [{ _tag: "Group", value: `${GROUP_VALUE}${group}`, group, count }] + }) + return [ + ...Arr.map( + Arr.filter(fields, (field) => field.group === "common"), + asField, + ), + ...groups, + ] +} + +// Operators and values. + +const KIND_OPERATORS = { + number: ["=", "!=", ">", ">=", "<", "<=", "exists", "!exists"], + boolean: ["=", "!=", "exists", "!exists"], + string: ["=", "!=", "contains", "!contains", "exists", "!exists"], +} satisfies Record> + +/** + * The operators a field takes on its source (the engine's `whereOperatorsOf`); a map attribute + * takes every operator, narrowed to its kind. A row's current operator always stays offered. + */ +export const operatorsOf = ( + field: ConditionField, + dataSource: DataSource, + current?: Operator, +): ReadonlyArray => { + const accepted = whereOperatorsOf(dataSource, field.key) + const own: ReadonlyArray = + whereKeyRule(dataSource, field.key)._tag === "Attribute" + ? Arr.filter(KIND_OPERATORS[field.kind], (operator) => accepted.includes(operator)) + : accepted + return current === undefined || own.includes(current) ? own : [...own, current] +} + +export const OPERATOR_LABEL = { + "=": "is", + "!=": "is not", + contains: "contains", + "!contains": "does not contain", + ">": "greater than", + ">=": "at least", + "<": "less than", + "<=": "at most", + exists: "is set", + "!exists": "is not set", +} satisfies Record + +/** Known values for a field: what the where editor would suggest after `key = `. */ +export const valueSuggestionsOf = ( + field: ConditionField, + dataSource: DataSource, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => { + if (field.kind === "boolean") return ["true", "false"] + if (Str.endsWith("status_code")(field.key)) return values.httpStatusCodes ?? [] + const expression = `${field.key} = ` + const { suggestions } = getWhereClauseAutocomplete({ + expression, + cursor: expression.length, + dataSource, + values, + maxSuggestions: 200, + }) + return Arr.dedupe( + Arr.map( + Arr.filter(suggestions, (suggestion) => suggestion.kind === "value"), + (suggestion) => suggestion.label, + ), + ) +} diff --git a/apps/web/src/page/kit/query-builder/condition-rows.test.ts b/apps/web/src/page/kit/query-builder/condition-rows.test.ts new file mode 100644 index 0000000000..4a6321292a --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-rows.test.ts @@ -0,0 +1,276 @@ +import { parseWhereClause } from "@maple/domain/where-clause" +import { describe, expect, test } from "vitest" +import { + type ConditionRow, + isUnspellable, + nextRowId, + parseConditionRows, + printConditionRows, + printConditionRowsOver, + sameConditions, + unshowableReasonOf, +} from "./condition-rows" +import { Array as Arr, Option } from "effect" + +type DataSource = Parameters[1] + +const rowsOf = (whereClause: string, dataSource: DataSource = "traces") => + Option.map(parseConditionRows(whereClause, dataSource), (rows) => + Arr.map(rows, ({ key, operator, values }) => ({ key, operator, values })), + ) + +const row = (key: string, operator: ConditionRow["operator"], values: ReadonlyArray, id = "c0") => ({ + id, + key, + operator, + values, +}) + +describe("parseConditionRows", () => { + test.each([ + ["", []], + ['service.name = "checkout"', [{ key: "service.name", operator: "=", values: ["checkout"] }]], + [ + '(attr.http.route = "/cart" OR attr.http.route = "/pay")', + [{ key: "attr.http.route", operator: "=", values: ["/cart", "/pay"] }], + ], + // A group of one is a plain clause, on any field. + ['(service.name = "checkout")', [{ key: "service.name", operator: "=", values: ["checkout"] }]], + // AND-ed excludes are one row on any field: every source applies them. + [ + 'service.name != "a" AND service.name != "b"', + [{ key: "service.name", operator: "!=", values: ["a", "b"] }], + ], + ["attr.http.route exists", [{ key: "attr.http.route", operator: "exists", values: [] }]], + ["resource.host.name !exists", [{ key: "resource.host.name", operator: "!exists", values: [] }]], + [ + "attr.http.response.status_code >= 500", + [{ key: "attr.http.response.status_code", operator: ">=", values: ["500"] }], + ], + // Aliases read as their canonical key. + [ + "service = checkout AND env = prod", + [ + { key: "service.name", operator: "=", values: ["checkout"] }, + { key: "deployment.environment", operator: "=", values: ["prod"] }, + ], + ], + // Negations of one key fold into one row. + [ + 'severity != "DEBUG" AND severity != "TRACE"', + [{ key: "severity", operator: "!=", values: ["DEBUG", "TRACE"] }], + ], + // Attribute keys keep their case. + ["attr.userId = 'u_1'", [{ key: "attr.userId", operator: "=", values: ["u_1"] }]], + ['body contains "timed out"', [{ key: "body", operator: "contains", values: ["timed out"] }]], + ])("%s", (whereClause, expected) => { + expect(rowsOf(whereClause)).toEqual(Option.some(expected)) + }) + + test.each([ + // Not the grammar at all. + "service.name ~ checkout", + 'service.name = "unclosed', + // OR groups a row cannot show: mixed keys, mixed or negated operators, a named field, nesting. + '(service.name = "a" OR span.name = "b")', + '(service.name = "a" OR service.name = "b")', + '(service.name = "a" OR service.name contains "b")', + '(service.name != "a" OR service.name != "b")', + '(service.name = "a" OR (span.name = "b"))', + // A group without orGroups support in a row: AND inside parentheses. + '(service.name = "a" AND span.name = "b")', + ])("%s cannot be shown", (whereClause) => { + expect(Option.isNone(parseConditionRows(whereClause, "traces"))).toBe(true) + }) +}) + +describe("printConditionRows", () => { + test.each([ + [[row("service.name", "=", ["checkout"])], 'service.name = "checkout"'], + [ + [row("attr.http.route", "=", ["/cart", "/pay"])], + '(attr.http.route = "/cart" OR attr.http.route = "/pay")', + ], + [[row("severity", "!=", ["DEBUG", "TRACE"])], 'severity != "DEBUG" AND severity != "TRACE"'], + [[row("attr.http.route", "exists", [])], "attr.http.route exists"], + [[row("attr.http.response.status_code", ">", ["499"])], "attr.http.response.status_code > 499"], + [[row("root_only", "=", ["true"])], "root_only = true"], + // A value holding a double quote takes single quotes. + [[row("span.name", "contains", ['say "hi"'])], `span.name contains 'say "hi"'`], + [ + [row("service.name", "=", ["checkout"]), row("deployment.environment", "!exists", [], "c1")], + 'service.name = "checkout" AND deployment.environment !exists', + ], + ])("%j", (rows, expected) => { + expect(printConditionRows(rows)).toBe(expected) + }) + + test("unfinished rows print nothing", () => { + expect( + printConditionRows([ + row("", "=", []), + row("service.name", "=", [], "c1"), + row("span.name", "=", ["GET /"], "c2"), + ]), + ).toBe('span.name = "GET /"') + }) +}) + +describe("round trip", () => { + // What the grammar reads, order-free: the builder may reorder AND-ed clauses. + const meaning = (whereClause: string) => { + const parsed = parseWhereClause(whereClause, { orGroups: true }) + const keyed = (clause: { key: string; operator: string; value: string }) => + `${clause.key} ${clause.operator} ${clause.value}` + return { + clauses: Arr.sort(Arr.map(parsed.clauses, keyed), (a: string, b: string) => + a < b ? -1 : a > b ? 1 : 0, + ), + groups: Arr.map(parsed.groups, (group) => Arr.map(group, keyed)), + warnings: parsed.warnings.length, + } + } + + test.each([ + 'service.name = "checkout"', + 'service.name = checkout AND span.name contains "GET /api"', + '(attr.http.route = "/a" OR attr.http.route = "/b") AND has_error = true', + 'severity != "DEBUG" AND service.name = "x" AND severity != "TRACE"', + "attr.http.response.status_code >= 500 AND attr.http.response.status_code < 600", + "resource.k8s.pod.name exists AND attr.db.system !exists", + `attr.message contains 'it''s' AND span.name = 'say "hi"'`, + '(attr.error.message contains "timeout" OR attr.error.message contains "refused")', + ])("%s", (whereClause) => { + const rows = Option.getOrThrow(parseConditionRows(whereClause, "traces")) + const printed = printConditionRows(rows) + expect(meaning(printed)).toEqual(meaning(whereClause)) + expect( + Option.map(parseConditionRows(printed, "traces"), (again) => Arr.map(again, (r) => r.values)), + ).toEqual(Option.some(Arr.map(rows, (r) => r.values))) + }) +}) + +describe("nextRowId", () => { + test("is past every id in use", () => { + expect(nextRowId([])).toBe("c0") + expect(nextRowId([row("a", "=", [], "c0"), row("b", "=", [], "c4")])).toBe("c5") + }) +}) + +describe("printConditionRowsOver", () => { + const original = + "service = checkout AND attr.http.route exists AND severity != DEBUG AND severity != TRACE" + const rows = Option.getOrThrow(parseConditionRows(original, "logs")) + + test("untouched rows keep their text, a changed row is printed afresh", () => { + const edited = Arr.map(rows, (row) => + row.key === "attr.http.route" ? { ...row, operator: "!exists" as const } : row, + ) + expect(printConditionRowsOver(edited, original, "logs")).toBe( + "service = checkout AND attr.http.route !exists AND severity != DEBUG AND severity != TRACE", + ) + }) + + test("a removed row drops only its text", () => { + expect( + printConditionRowsOver( + Arr.filter(rows, (row) => row.key !== "attr.http.route"), + original, + "logs", + ), + ).toBe("service = checkout AND severity != DEBUG AND severity != TRACE") + }) +}) + +describe("sameConditions", () => { + test("ignores ids and unfinished rows, not order or values", () => { + const a = [row("service.name", "=", ["a"], "c0"), row("", "=", [], "c1")] + expect(sameConditions(a, [row("service.name", "=", ["a"], "c9")])).toBe(true) + expect(sameConditions(a, [row("service.name", "=", ["b"])])).toBe(false) + expect( + sameConditions( + [row("a", "exists", []), row("b", "exists", [], "c1")], + [row("b", "exists", []), row("a", "exists", [], "c1")], + ), + ).toBe(false) + }) +}) + +describe("isUnspellable", () => { + test("only a value with both quote kinds", () => { + expect(Arr.map([`it's`, `say "hi"`, `it's "on"`], isUnspellable)).toEqual([false, false, true]) + }) +}) + +describe("unshowableReasonOf", () => { + test.each([ + ['service.name = "a" AND attr.b exists', Option.none()], + ['(attr.a = "1" OR attr.a = "2")', Option.none()], + ['(service.name = "a" OR span.name = "b")', Option.some("mixedFields")], + ['(service.name = "a" OR service.name contains "b")', Option.some("orOperators")], + ['(service.name != "a" OR service.name != "b")', Option.some("orOperators")], + ['(service.name = "checkout" OR service.name = "worker")', Option.some("orNotApplied")], + ["attr.b exists AND (has_error = true OR has_error = false)", Option.some("orNotApplied")], + ["service.name ~ checkout", Option.some("syntax")], + // A missing value or a doubled operator: the grammar reads the stray `=` as the value. + ["service.name =", Option.some("syntax")], + ["service.name ==", Option.some("syntax")], + ["service.name >=", Option.some("syntax")], + ["service.name ==checkout", Option.some("syntax")], + ['service.name = "a" AND attr.b !==', Option.some("syntax")], + ['(attr.a = "1" OR attr.a ==)', Option.some("syntax")], + // Quoted, the same characters are a value the user meant. + ['attr.a = "="', Option.none()], + ["attr.a = '=x'", Option.none()], + ["attr.duration >= 5", Option.none()], + ['service.name = "a" AND (service.name = "b" AND span.name = "c")', Option.some("syntax")], + ])("%s", (whereClause, expected) => { + expect(unshowableReasonOf(whereClause, "traces")).toEqual(expected) + }) +}) + +// An OR group reads as a multi-value row only where the source applies it (the engine's +// `whereKeyMatchesAnyOf`); anywhere else a row would hold one value and a pick would drop the rest. +describe("OR groups by source", () => { + const twoValues = (key: string) => `(${key} = "a" OR ${key} = "b")` + + test.each([ + ["traces", "attr.http.route"], + ["traces", "resource.host.name"], + // Traces read a bare unknown key as a span attribute. + ["traces", "db.system"], + ["logs", "attr.http.route"], + ["logs", "resource.host.name"], + ["metrics", "attr.state"], + ] as const)("%s ORs %s: one row, two values", (dataSource, key) => { + expect(rowsOf(twoValues(key), dataSource)).toEqual( + Option.some([{ key, operator: "=", values: ["a", "b"] }]), + ) + expect(unshowableReasonOf(twoValues(key), dataSource)).toEqual(Option.none()) + }) + + test.each([ + ["traces", "service.name"], + ["traces", "span.name"], + ["traces", "deployment.environment"], + ["logs", "service.name"], + ["logs", "severity"], + // Logs and metrics drop a bare unknown key. + ["logs", "db.system"], + ["metrics", "service.name"], + ["metrics", "db.system"], + // Product events lower no OR group at all. + ["product_events", "event.name"], + ["product_events", "attr.plan"], + ["product_events", "plan"], + ] as const)("%s does not OR %s: it stays in Text", (dataSource, key) => { + expect(rowsOf(twoValues(key), dataSource)).toEqual(Option.none()) + expect(unshowableReasonOf(twoValues(key), dataSource)).toEqual(Option.some("orNotApplied")) + }) + + test("a product-events OR across fields still reads as mixed fields", () => { + expect(unshowableReasonOf('(event.name = "a" OR host = "b")', "product_events")).toEqual( + Option.some("mixedFields"), + ) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-rows.ts b/apps/web/src/page/kit/query-builder/condition-rows.ts new file mode 100644 index 0000000000..e6e8dfe533 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-rows.ts @@ -0,0 +1,225 @@ +import { + normalizeKey, + Operator, + type ParsedClause, + parseWhereClause, + quoteWhereValue, + splitWhereClause, +} from "@maple/domain/where-clause" +import { type QueryBuilderDataSource, whereKeyMatchesAnyOf } from "@maple/query-engine/query-builder" +import { Array as Arr, Option, Result, Schema, String as Str } from "effect" + +// The condition builder's rows and their two-way mapping to the where-clause string +// (`@maple/domain/where-clause`): rows are AND-ed; a row's values are OR-ed for `=`/`contains` +// and AND-ed for `!=`/`!contains`. Reading is per data source: what one source ORs another drops. + +/** One `[field] [operator] [values]` row; `key` is empty until a field is picked. */ +export const ConditionRow = Schema.Struct({ + id: Schema.String, + key: Schema.String, + operator: Operator, + values: Schema.Array(Schema.String), +}) +export type ConditionRow = typeof ConditionRow.Type + +/** Several values mean "any of them": printed as one `(k = a OR k = b)` group. */ +export const isAnyOf = (operator: Operator): boolean => operator === "=" || operator === "contains" + +/** Several values mean "none of them": printed as AND-ed clauses. */ +export const isNoneOf = (operator: Operator): boolean => operator === "!=" || operator === "!contains" + +export const isValueless = (operator: Operator): boolean => operator === "exists" || operator === "!exists" + +/** An alias (`service`) as its canonical key; any other key as typed, since attribute keys keep case. */ +const keyOf = (clause: ParsedClause): string => { + const canonical = normalizeKey(clause.key) + return canonical !== clause.key ? canonical : (clause.rawKey ?? clause.key) +} + +type Draft = Omit + +const draftOf = (clause: ParsedClause): Draft => ({ + key: keyOf(clause), + operator: clause.operator, + values: isValueless(clause.operator) ? [] : [clause.value], +}) + +/** + * Why a clause has no rows: an OR across fields, an OR a row's operators can't hold, an OR on a + * key the source does not OR, or other syntax. + */ +export type UnshowableReason = "mixedFields" | "orOperators" | "orNotApplied" | "syntax" + +type Part = Result.Result + +/** + * A bare value that is the rest of a mistyped operator: `key ==` reads as `key = "="` and + * `key >=` as `key > "="`. Quoted (`key = "="`), the same value is one the user meant. + */ +const isStrayOperator = (clause: ParsedClause, part: string): boolean => + /^=|^[=!<>]+$/.test(clause.value) && + !Str.includes(`"${clause.value}"`)(part) && + !Str.includes(`'${clause.value}'`)(part) + +/** + * One AND-ed part: a single clause, or an OR group whose members share key and an any-of + * operator, on a key the source ORs (the engine's `whereKeyMatchesAnyOf`). + */ +const readPart = (part: string, dataSource: QueryBuilderDataSource): Part => { + const parsed = parseWhereClause(part, { orGroups: true }) + if (parsed.warnings.length > 0) return Result.fail("syntax") + const clauses = [...parsed.clauses, ...Arr.flatten(parsed.groups)] + if (Arr.some(clauses, (each) => isStrayOperator(each, part))) return Result.fail("syntax") + const [clause] = parsed.clauses + if (parsed.groups.length === 0 && parsed.clauses.length === 1 && clause !== undefined) + return Result.succeed(draftOf(clause)) + const [group] = parsed.groups + if (parsed.clauses.length > 0 || parsed.groups.length !== 1 || group === undefined) + return Result.fail("syntax") + const drafts = Arr.map(group, draftOf) + const [first] = drafts + if (first === undefined) return Result.fail("syntax") + if (!Arr.every(drafts, (draft) => draft.key === first.key)) return Result.fail("mixedFields") + if (!isAnyOf(first.operator) || !Arr.every(drafts, (draft) => draft.operator === first.operator)) + return Result.fail("orOperators") + // Such a row holds one value: shown as a row, the next pick would drop the rest of a saved group. + if (drafts.length > 1 && !whereKeyMatchesAnyOf(dataSource, first.key)) return Result.fail("orNotApplied") + return Result.succeed({ ...first, values: Arr.flatMap(drafts, (draft) => draft.values) }) +} + +/** Why the builder can't show a clause on this source, or none when it reads as rows. */ +export const unshowableReasonOf = ( + whereClause: string, + dataSource: QueryBuilderDataSource, +): Option.Option => + Arr.findFirst(splitWhereClause(Str.trim(whereClause)), (part) => + Result.getFailure(readPart(part, dataSource)), + ) + +/** A row with the clause text it was read from, so an untouched row keeps the user's spelling. */ +type Spelled = Readonly<{ draft: Draft; text: string }> + +/** `k != a AND k != b` reads as one "is not a, b" row. */ +const mergeNoneOf = (parts: ReadonlyArray): ReadonlyArray => + Arr.reduce(parts, Arr.empty(), (rows: ReadonlyArray, part) => { + if (!isNoneOf(part.draft.operator)) return [...rows, part] + const index = Arr.findFirstIndex( + rows, + (row) => row.draft.key === part.draft.key && row.draft.operator === part.draft.operator, + ) + return Option.match(index, { + onNone: () => [...rows, part], + onSome: (at) => + Arr.map(rows, (row, i) => + i === at + ? { + draft: { + ...row.draft, + values: Arr.dedupe([...row.draft.values, ...part.draft.values]), + }, + text: `${row.text} AND ${part.text}`, + } + : row, + ), + }) + }) + +const spelledRows = ( + whereClause: string, + dataSource: QueryBuilderDataSource, +): Option.Option> => + Option.map( + Option.all( + Arr.map(splitWhereClause(Str.trim(whereClause)), (text) => + Option.map(Result.getSuccess(readPart(text, dataSource)), (draft): Spelled => ({ + draft, + text, + })), + ), + ), + mergeNoneOf, + ) + +/** The rows a where clause reads as on this source, or none when a row cannot show it. */ +export const parseConditionRows = ( + whereClause: string, + dataSource: QueryBuilderDataSource, +): Option.Option> => + Option.map(spelledRows(whereClause, dataSource), (parts) => + Arr.map(parts, ({ draft }, index) => ({ id: `c${index}`, ...draft })), + ) + +const sameDraft = (a: Draft, b: Draft): boolean => + a.key === b.key && + a.operator === b.operator && + a.values.length === b.values.length && + Arr.every(a.values, (value, index) => b.values[index] === value) + +/** The same complete conditions in the same order, ids aside. */ +export const sameConditions = (a: ReadonlyArray, b: ReadonlyArray): boolean => { + const left = Arr.filter(a, isComplete) + const right = Arr.filter(b, isComplete) + return ( + left.length === right.length && + Arr.every(left, (row, index) => { + const other = right[index] + return other !== undefined && sameDraft(row, other) + }) + ) +} + +/** A value the grammar cannot spell: it has no escape, so a value may not hold both quotes. */ +export const isUnspellable = (value: string): boolean => Str.includes('"')(value) && Str.includes("'")(value) + +/** Numbers and booleans stay bare (`root_only = true` is what the span select reads); the rest is quoted. */ +const literalOf = (value: string): string => + /^-?\d+(\.\d+)?$/.test(value) || value === "true" || value === "false" ? value : quoteWhereValue(value) + +const clauseOf = (key: string, operator: Operator, value: string): string => + isValueless(operator) ? `${key} ${operator}` : `${key} ${operator} ${literalOf(value)}` + +/** A row the where clause can carry: a field, and a value unless the operator takes none. */ +export const isComplete = (row: ConditionRow): boolean => + row.key !== "" && (isValueless(row.operator) || row.values.length > 0) + +const printRow = (row: ConditionRow): string => { + if (isValueless(row.operator)) return clauseOf(row.key, row.operator, "") + const clauses = Arr.map(row.values, (value) => clauseOf(row.key, row.operator, value)) + if (clauses.length === 1) return Arr.join(clauses, "") + return isAnyOf(row.operator) ? `(${Arr.join(clauses, " OR ")})` : Arr.join(clauses, " AND ") +} + +/** The where clause for the complete rows; an unfinished row prints nothing. */ +export const printConditionRows = (rows: ReadonlyArray): string => + Arr.join(Arr.map(Arr.filter(rows, isComplete), printRow), " AND ") + +/** + * `printConditionRows`, but a row `original` already holds keeps its text there, so an edit + * rewrites only the clauses it changed. + */ +export const printConditionRowsOver = ( + rows: ReadonlyArray, + original: string, + dataSource: QueryBuilderDataSource, +): string => { + const spelled = Option.getOrElse(spelledRows(original, dataSource), () => Arr.empty()) + const [, texts] = Arr.mapAccum( + Arr.filter(rows, isComplete), + spelled, + (unused: ReadonlyArray, row): [ReadonlyArray, string] => + Option.match( + Arr.findFirstIndex(unused, (part) => sameDraft(part.draft, row)), + { + onNone: () => [unused, printRow(row)], + onSome: (at) => [Arr.remove(unused, at), unused[at]?.text ?? printRow(row)], + }, + ), + ) + return Arr.join(texts, " AND ") +} + +/** A new row's id, past every id the rows use. */ +export const nextRowId = (rows: ReadonlyArray): string => { + const used = Arr.map(rows, (row) => Number(Str.slice(1)(row.id))) + return `c${Arr.reduce(used, -1, (max, id) => (Number.isFinite(id) && id > max ? id : max)) + 1}` +} diff --git a/apps/web/src/page/kit/query-builder/conditions.test.ts b/apps/web/src/page/kit/query-builder/conditions.test.ts new file mode 100644 index 0000000000..98e483b89d --- /dev/null +++ b/apps/web/src/page/kit/query-builder/conditions.test.ts @@ -0,0 +1,356 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { describe, expect, test } from "vitest" +import type { QueryDraft } from "./draft" +import { fieldPickerItems, fieldsOf } from "./condition-fields" +import { conditionRowsOf } from "./conditions" +import { init, Message, type Model, operatorSelectId, type PanelInputs, update } from "./panel" +import { Array as Arr, Option } from "effect" + +// The builder's operator and value steps, as the where-clause edit they emit. + +const draftOf = (dataSource: QueryDraft["dataSource"], whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, dataSource), + whereClause, +}) + +const traces = (whereClause: string): QueryDraft => draftOf("traces", whereClause) + +const inputsFor = (query: QueryDraft): PanelInputs => ({ + queryOf: (queryId) => (queryId === query.id ? query : undefined), + baseValuesOf: () => ({ services: ["checkout", "api"] }), + orgId: "org_test", + window: null, +}) + +/** The where clause a message leaves as, or null when it leaves none. */ +const emittedOn = (query: QueryDraft, message: Message, model: Model = init("test")): string | null => { + const next = update(model, message, inputsFor(query)) + const change = next.outMessage?._tag === "ChangedQuery" ? next.outMessage.change : null + return change?._tag === "SetText" ? change.value : null +} + +const emitted = (whereClause: string, message: Message, model?: Model): string | null => + emittedOn(traces(whereClause), message, model) + +const values = (rowId: string, message: Parameters[0]["message"]) => + Message.GotConditionValues({ queryId: "q", rowId, message }) + +const pickOperator = (rowId: string, value: string) => + Message.GotSelectMessage({ + id: operatorSelectId("q", rowId), + message: { _tag: "SelectedItem", index: 0, value }, + }) + +describe("operator", () => { + test("is set drops the values", () => { + expect( + emitted('(attr.http.route = "/cart" OR attr.http.route = "/pay")', pickOperator("c0", "exists")), + ).toBe("attr.http.route exists") + }) + + test("is not keeps every value, as AND-ed negations", () => { + expect( + emitted('(attr.http.route = "/cart" OR attr.http.route = "/pay")', pickOperator("c0", "!=")), + ).toBe('attr.http.route != "/cart" AND attr.http.route != "/pay"') + }) + + test("a comparison keeps one value", () => { + expect( + emitted( + "(attr.http.response.status_code = 500 OR attr.http.response.status_code = 503)", + pickOperator("c0", ">="), + ), + ).toBe("attr.http.response.status_code >= 500") + }) +}) + +describe("values", () => { + // The query engine ORs attribute filters only; a named field's "is" holds one value. + test("a named field's new value replaces the old one", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "api" }, + }) + expect(emitted('service.name = "checkout"', message)).toBe('service.name = "api"') + }) + + test("an attribute's new value joins the OR group", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "POST" }, + }) + expect(emitted('attr.http.request.method = "GET"', message)).toBe( + '(attr.http.request.method = "GET" OR attr.http.request.method = "POST")', + ) + }) + + test("a named field's is not keeps every value", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "api" }, + }) + expect(emitted('service.name != "checkout"', message)).toBe( + 'service.name != "checkout" AND service.name != "api"', + ) + }) + + test("a comparison's new value replaces the old one", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "ClickedItem", value: "404" }, + }) + expect(emitted("attr.http.response.status_code > 499", message)).toBe( + "attr.http.response.status_code > 404", + ) + }) + + test("removing a chip removes the value", () => { + const message = Message.GotConditionValues({ + queryId: "q", + rowId: "c0", + message: { _tag: "RemovedChip", value: "/pay" }, + }) + expect(emitted('(attr.http.route = "/cart" OR attr.http.route = "/pay")', message)).toBe( + 'attr.http.route = "/cart"', + ) + }) +}) + +describe("mode", () => { + test("switching editors is draft state, not an edit", () => { + const next = update( + init("test"), + Message.ChangedWhereMode({ queryId: "q", mode: "text" }), + inputsFor(traces("")), + ) + expect(next.outMessage).toBeUndefined() + expect(next.model.whereModes).toEqual({ q: "text" }) + }) + + test("a row in progress is still there after a look at Text and back", () => { + const inputs = inputsFor(traces('service.name = "checkout"')) + // A new row has no field yet, so the clause does not carry it: only the panel holds it. + const added = update(init("test"), Message.ClickedAddCondition({ queryId: "q" }), inputs).model + const rowCount = added.conditions["q"]?.length + const back = Arr.reduce( + [ + Message.ChangedWhereMode({ queryId: "q", mode: "text" }), + Message.ChangedWhereMode({ queryId: "q", mode: "builder" }), + ], + added, + (model, message) => update(model, message, inputs).model, + ) + expect(rowCount).toBe(2) + expect(back.conditions["q"]?.length).toBe(2) + }) +}) + +describe("rewrites", () => { + test("adding a row leaves the clause alone until the row is complete", () => { + expect(emitted('service.name = "checkout"', Message.ClickedAddCondition({ queryId: "q" }))).toBeNull() + }) + + test("an edit rewrites only its own clause", () => { + expect(emitted('service = checkout AND attr.http.route = "/a"', pickOperator("c1", "exists"))).toBe( + "service = checkout AND attr.http.route exists", + ) + }) + + test("the span select's root_only clause survives a builder edit", () => { + expect(emitted('root_only = true AND service.name = "a"', pickOperator("c1", "!="))).toBe( + 'root_only = true AND service.name != "a"', + ) + }) + + test("clicking a single value's current value changes nothing", () => { + expect( + emitted('service.name = "checkout"', values("c0", { _tag: "ClickedItem", value: "checkout" })), + ).toBeNull() + }) +}) + +describe("refusals", () => { + test("a typed value holding both quotes is not added", () => { + const query = traces('attr.note = "x"') + const typed = update( + init("test"), + values("c0", { _tag: "ChangedInput", value: `it's "on"` }), + inputsFor(query), + ) + expect(typed.outMessage).toBeUndefined() + const entered = update( + typed.model, + values("c0", { _tag: "PressedInputKey", key: "Enter", items: [] }), + inputsFor(query), + ) + expect(entered.outMessage).toBeUndefined() + }) + + test("a product-events exclude holds one value, since a second would replace the first", () => { + expect( + emittedOn( + draftOf("product_events", 'event.name != "signup"'), + values("c0", { _tag: "ClickedItem", value: "login" }), + ), + ).toBe('event.name != "login"') + }) +}) + +describe("field picker highlight", () => { + const vocabulary = { attributeKeys: ["http.route", "db.system"], resourceAttributeKeys: ["host.name"] } + const query = traces('attr.db.system = "postgres"') + const inputs: PanelInputs = { ...inputsFor(query), baseValuesOf: () => vocabulary } + const fields = fieldsOf("traces", vocabulary) + const valuesOf = (drill: "all" | "resource" | "attribute", search = "") => + Arr.map(fieldPickerItems(fields, drill, search, "traces"), (item) => item.value) + const field = (message: Parameters[0]["message"]) => + Message.GotConditionField({ queryId: "q", rowId: "c0", message }) + /** The model after the messages, and the value its field list highlights. */ + const after = (messages: ReadonlyArray) => { + const model = Arr.reduce( + messages, + init("test"), + (current, message) => update(current, message, inputs).model, + ) + return { model, highlighted: model.conditionPickers["cond-q-c0-field"]?.highlighted ?? null } + } + const opened = field({ _tag: "ClickedTriggerOf", items: valuesOf("all") }) + const drilled = (group: "resource" | "attribute") => + field({ _tag: "ClickedItem", value: `@group:${group}` }) + + test("opening highlights the first field when the picked one is inside a group", () => { + expect(after([opened]).highlighted).toBe(0) + }) + + test("drilling into the picked field's group highlights it, not the back row", () => { + const { highlighted } = after([opened, drilled("attribute")]) + expect(valuesOf("attribute")[highlighted ?? -1]).toBe("attr.db.system") + }) + + test("drilling into another group highlights its first field", () => { + const { highlighted } = after([opened, drilled("resource")]) + expect(valuesOf("resource")[highlighted ?? -1]).toBe("resource.host.name") + }) + + test("going back highlights the group that was open", () => { + const { highlighted } = after([ + opened, + drilled("resource"), + field({ _tag: "ClickedItem", value: "@back" }), + ]) + expect(valuesOf("all")[highlighted ?? -1]).toBe("@group:resource") + }) + + test("Enter after a search picks the first match", () => { + const { model } = after([ + opened, + field({ _tag: "ChangedInput", value: "http.ro" }), + field({ + _tag: "PressedInputKey", + key: "Enter", + items: valuesOf("all", "http.ro"), + isUnfiltered: true, + }), + ]) + expect(model.conditions["q"]?.[0]?.key).toBe("attr.http.route") + }) +}) + +// A saved OR group on a field the source does not OR has no rows: no builder message can reach it. +describe("an OR group the source does not apply", () => { + const clause = '(service.name = "checkout" OR service.name = "worker") AND attr.http.route exists' + + test("it has no rows", () => { + expect(conditionRowsOf(init("test"), traces(clause))).toEqual(Option.none()) + }) + + test.each([ + ["adding a condition", Message.ClickedAddCondition({ queryId: "q" })], + ["picking a value", values("c0", { _tag: "ClickedItem", value: "api" })], + ["picking an operator", pickOperator("c0", "!=")], + ["removing a condition", Message.ClickedRemoveCondition({ queryId: "q", rowId: "c0" })], + ])("%s leaves the clause as written", (_, message) => { + const next = update(init("test"), message, inputsFor(traces(clause))) + expect(next.outMessage).toBeUndefined() + expect(next.model.conditions).toEqual({}) + }) + + test("the same group on an attribute key is a row that takes a third value", () => { + expect( + emitted( + '(attr.http.route = "/cart" OR attr.http.route = "/pay")', + values("c0", { _tag: "ClickedItem", value: "/ship" }), + ), + ).toBe('(attr.http.route = "/cart" OR attr.http.route = "/pay" OR attr.http.route = "/ship")') + }) +}) + +// A key the source drops (the engine's `whereOperatorsOf` is empty) is never a new pick. +describe("field picks the source drops", () => { + const field = (message: Parameters[0]["message"]) => + Message.GotConditionField({ queryId: "q", rowId: "c0", message }) + /** A new row, the search typed into its field picker, then a click on a list value. */ + const afterPick = (dataSource: QueryDraft["dataSource"], typed: string, value: string) => { + const inputs: PanelInputs = { + ...inputsFor(draftOf(dataSource, "")), + // The host hands resource keys to every source; product events must not list them. + baseValuesOf: () => ({ attributeKeys: ["plan"], resourceAttributeKeys: ["host.name"] }), + } + const model = Arr.reduce( + [ + Message.ClickedAddCondition({ queryId: "q" }), + ...(typed === "" ? [] : [field({ _tag: "ChangedInput", value: typed })]), + ], + init("test"), + (current, message) => update(current, message, inputs).model, + ) + const picked = update(model, field({ _tag: "ClickedItem", value }), inputs) + return { + outMessage: picked.outMessage, + keys: Arr.map(picked.model.conditions["q"] ?? [], (row) => row.key), + } + } + + test.each([ + // The known resource key, and the same key typed in. + ["product_events", "host", "resource.host.name"], + ["product_events", "resource.host.name", "resource.host.name"], + ["product_events", "resource.host.name", "@custom:resource.host.name"], + ["product_events", "resource.cloud.region", "@custom:resource.cloud.region"], + // A bare unknown key, which logs and metrics drop. + ["logs", "db.system", "db.system"], + ["logs", "db.system", "@custom:db.system"], + ["metrics", "db.system", "db.system"], + ["metrics", "db.system", "@custom:db.system"], + ] as const)("%s, search %j: a pick of %s is ignored", (dataSource, typed, value) => { + expect(afterPick(dataSource, typed, value)).toEqual({ outMessage: undefined, keys: [""] }) + }) + + test.each([ + ["product_events", "", "event.name", "event.name"], + ["product_events", "plan", "attr.plan", "attr.plan"], + ["logs", "", "severity", "severity"], + ["logs", "host", "resource.host.name", "resource.host.name"], + // What logs and metrics offer for a typed bare key: the attribute of that name. + ["logs", "db.system", "@custom:attr.db.system", "attr.db.system"], + ["metrics", "db.system", "@custom:attr.db.system", "attr.db.system"], + ["traces", "resource.cloud.region", "@custom:resource.cloud.region", "resource.cloud.region"], + ] as const)("%s, search %j: a pick of %s lands as %s", (dataSource, typed, value, key) => { + expect(afterPick(dataSource, typed, value).keys).toEqual([key]) + }) + + test("a saved product-events row on resource.* stays, and an edit beside it leaves its text alone", () => { + const query = draftOf("product_events", 'resource.host.name contains web AND event.name = "signup"') + expect(Option.map(conditionRowsOf(init("test"), query), (rows) => rows[0])).toEqual( + Option.some({ id: "c0", key: "resource.host.name", operator: "contains", values: ["web"] }), + ) + expect(emittedOn(query, Message.ClickedAddCondition({ queryId: "q" }))).toBeNull() + expect(emittedOn(query, pickOperator("c1", "!="))).toBe( + 'resource.host.name contains web AND event.name != "signup"', + ) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/conditions.ts b/apps/web/src/page/kit/query-builder/conditions.ts new file mode 100644 index 0000000000..a9278d6cfa --- /dev/null +++ b/apps/web/src/page/kit/query-builder/conditions.ts @@ -0,0 +1,409 @@ +import { Operator } from "@maple/domain/where-clause" +import { Command, type Update } from "foldkit" +import * as Combobox from "@maple/views/ui/combobox" +import * as Select from "@maple/views/ui/select" +import type { Resources } from "../../../platform/resources" +import { QueryChange } from "./change" +import { + canMatchAnyOf, + type FieldDrill, + fieldOf, + fieldPickerItems, + type FieldPickerItem, + fieldsOf, + operatorsOf, +} from "./condition-fields" +import { + type ConditionRow, + isAnyOf, + isNoneOf, + isUnspellable, + isValueless, + nextRowId, + parseConditionRows, + printConditionRowsOver, + sameConditions, +} from "./condition-rows" +import type { QueryDraft } from "./draft" +import { Message, type Model, OutMessage, type WhereMode } from "./panel-message" +import type { PanelInputs } from "./where" +import { Array as Arr, Option, Record as Rec, Schema, String as Str } from "effect" + +// The condition builder's steps: an edit that changes the conditions leaves as the query's +// where-clause edit, rewriting only the clauses it touched, so previews and saving see only the string. + +type Return = Update.ReturnWithOutMessage + +export type PickerPart = "field" | "values" + +export const conditionPickerId = (queryId: string, rowId: string, part: PickerPart): string => + `cond-${queryId}-${rowId}-${part}` + +const OPERATOR_SELECT = "whereOp" + +export const operatorSelectId = (queryId: string, rowId: string): string => + `${OPERATOR_SELECT}:${queryId}:${rowId}` + +export const isOperatorSelect = (id: string): boolean => Str.startsWith(`${OPERATOR_SELECT}:`)(id) + +/** The kept rows while they still mean the query's where clause, else none. */ +const keptRowsOf = (model: Model, query: QueryDraft): Option.Option> => + Option.flatMap(Option.fromNullishOr(model.conditions[query.id]), (kept) => + Option.filter(parseConditionRows(query.whereClause, query.dataSource), (parsed) => + sameConditions(parsed, kept), + ).pipe(Option.map(() => kept)), + ) + +/** The rows a query shows: the kept ones while they mean its where clause, else a fresh parse. */ +export const conditionRowsOf = ( + model: Model, + query: QueryDraft, +): Option.Option> => + Option.orElse(keptRowsOf(model, query), () => parseConditionRows(query.whereClause, query.dataSource)) + +/** A fresh parse renumbers the rows: drop the query's rows, pickers, drills and operator selects. */ +export const withoutBuilderState = (model: Model, queryId: string): Model => { + const pickerPrefix = `cond-${queryId}-` + return { + ...model, + conditions: Rec.remove(model.conditions, queryId), + conditionPickers: Rec.filter(model.conditionPickers, (_, id) => !Str.startsWith(pickerPrefix)(id)), + fieldDrills: Rec.filter(model.fieldDrills, (_, id) => !Str.startsWith(pickerPrefix)(id)), + selects: Rec.filter(model.selects, (_, id) => !Str.startsWith(`${OPERATOR_SELECT}:${queryId}:`)(id)), + } +} + +/** The editor a query shows; only a Builder click leaves Text once the text editor was used. */ +export const whereModeOf = (model: Model, query: QueryDraft): WhereMode => + model.whereModes[query.id] ?? "builder" + +/** The text editor was typed in: it stays the editor, and the rows read afresh later. */ +export const typedInTextEditor = (model: Model, queryId: string): Model => ({ + ...withoutBuilderState(model, queryId), + whereModes: { ...model.whereModes, [queryId]: "text" }, +}) + +/** Keeps the rows; the where clause changes only when the complete conditions do. */ +const emitted = ( + model: Model, + query: QueryDraft, + before: ReadonlyArray, + rows: ReadonlyArray, + commands?: Return["commands"], +): Return => { + const kept: Model = { ...model, conditions: { ...model.conditions, [query.id]: rows } } + const withCommands = commands === undefined ? undefined : { commands } + if (sameConditions(before, rows)) return { model: kept, ...withCommands } + return { + model: kept, + ...withCommands, + outMessage: OutMessage.ChangedQuery({ + queryId: query.id, + change: QueryChange.SetText({ + field: "whereClause", + value: printConditionRowsOver(rows, query.whereClause, query.dataSource), + }), + }), + } +} + +type RowContext = Readonly<{ model: Model; query: QueryDraft; rows: ReadonlyArray }> + +const withRows = ( + model: Model, + queryId: string, + inputs: PanelInputs | undefined, + step: (context: RowContext) => Return, +): Return => { + const query = inputs?.queryOf(queryId) + if (query === undefined) return { model } + return Option.match(keptRowsOf(model, query), { + onSome: (rows) => step({ model, query, rows }), + // Stale kept rows mean the ids renumber: their pickers go. The fresh rows are kept either way. + onNone: () => + Option.match(parseConditionRows(query.whereClause, query.dataSource), { + onNone: () => ({ model }), + onSome: (rows) => { + const fresh = + model.conditions[queryId] === undefined ? model : withoutBuilderState(model, queryId) + return step({ + model: { ...fresh, conditions: { ...fresh.conditions, [queryId]: rows } }, + query, + rows, + }) + }, + }), + }) +} + +const withRow = ( + model: Model, + queryId: string, + rowId: string, + inputs: PanelInputs | undefined, + step: (context: RowContext & Readonly<{ row: ConditionRow }>) => Return, +): Return => + withRows(model, queryId, inputs, (context) => + Option.match( + Arr.findFirst(context.rows, (row) => row.id === rowId), + { onNone: () => ({ model: context.model }), onSome: (row) => step({ ...context, row }) }, + ), + ) + +const replaced = (rows: ReadonlyArray, next: ConditionRow): ReadonlyArray => + Arr.map(rows, (row) => (row.id === next.id ? next : row)) + +/** Switching editors keeps the rows in progress: a half-filled row is still there on the way back. */ +export const changedWhereMode = (model: Model, queryId: string, mode: WhereMode): Return => ({ + model: { ...model, whereModes: { ...model.whereModes, [queryId]: mode } }, +}) + +/** A new row opens its field picker, as the first thing to choose; the clause is unchanged. */ +export const addedCondition = (model: Model, queryId: string, inputs: PanelInputs | undefined): Return => + withRows(model, queryId, inputs, ({ model: current, query, rows }) => { + const id = nextRowId(rows) + const pickerId = conditionPickerId(queryId, id, "field") + const opened: Model = { + ...current, + conditionPickers: { + ...current.conditionPickers, + [pickerId]: { ...Combobox.init(pickerId, "single", { isOpen: true }), highlighted: 0 }, + }, + } + return emitted(opened, query, rows, [...rows, { id, key: "", operator: "=", values: [] }]) + }) + +export const removedCondition = ( + model: Model, + queryId: string, + rowId: string, + inputs: PanelInputs | undefined, +): Return => + withRows(model, queryId, inputs, ({ model: current, query, rows }) => { + const pickerIds = [ + conditionPickerId(queryId, rowId, "field"), + conditionPickerId(queryId, rowId, "values"), + ] + return emitted( + { + ...current, + conditionPickers: Rec.filter(current.conditionPickers, (_, id) => !pickerIds.includes(id)), + selects: Rec.remove(current.selects, operatorSelectId(queryId, rowId)), + }, + query, + rows, + Arr.filter(rows, (row) => row.id !== rowId), + ) + }) + +/** The value an Enter or a click picks from the list the view drew. */ +const pickedValue = (current: Combobox.Model, message: Combobox.Message): string | null => { + if (message._tag === "ClickedItem") return message.value + if (message._tag === "PressedInputKey" && message.key === "Enter" && current.highlighted !== null) + return message.items[current.highlighted] ?? null + return null +} + +/** The option an open field list highlights: the picked field when listed, else the first that is not "All fields". */ +const fieldHighlightOf = ( + items: ReadonlyArray, + key: string, + from?: FieldDrill, +): number | null => + Option.getOrNull( + Option.orElse( + Arr.findFirstIndex(items, (item) => item.value === key), + () => + Option.orElse( + // Back out of a group: its own row, where the highlight was before drilling in. + Arr.findFirstIndex(items, (item) => item._tag === "Group" && item.group === from), + () => + Option.orElse( + Arr.findFirstIndex(items, (item) => item._tag !== "Back"), + () => (items.length === 0 ? Option.none() : Option.some(0)), + ), + ), + ), + ) + +/** + * The field picker: a group or "All fields" drills without closing; a field sets the row's key. + * An open list always has a highlight, so Enter picks without an arrow key first. + */ +export const fieldStep = ( + model: Model, + queryId: string, + rowId: string, + message: Combobox.Message, + inputs: PanelInputs | undefined, +): Return => + withRow(model, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { + const id = conditionPickerId(queryId, rowId, "field") + const stored = base.conditionPickers[id] ?? Combobox.init(id, "single") + const current = { ...stored, selected: row.key === "" ? [] : [row.key] } + const drill = base.fieldDrills[id] ?? "all" + const fields = fieldsOf(query.dataSource, inputs?.baseValuesOf(query) ?? {}) + const items = fieldPickerItems(fields, drill, Combobox.queryOf(current), query.dataSource) + // A pick is one of the offered items, so a key the source drops (never offered) cannot be picked. + const picked = Option.flatMap(Option.fromNullishOr(pickedValue(current, message)), (value) => + Arr.findFirst(items, (item) => item.value === value), + ) + const navigated = Option.flatMap(picked, (item) => + item._tag === "Group" + ? Option.some(item.group) + : item._tag === "Back" + ? Option.some("all" as const) + : Option.none(), + ) + if (Option.isSome(navigated)) + return { + model: { + ...base, + conditionPickers: { + ...base.conditionPickers, + [id]: { + ...current, + inputValue: "", + highlighted: fieldHighlightOf( + fieldPickerItems(fields, navigated.value, "", query.dataSource), + row.key, + drill, + ), + }, + }, + fieldDrills: { ...base.fieldDrills, [id]: navigated.value }, + }, + } + const next = Combobox.update(current, message) + // Opening, and typing a search, start from the default highlight. + const needsHighlight = + next.model.isOpen && (next.model.highlighted === null || message._tag === "ChangedInput") + const picker = needsHighlight + ? { + ...next.model, + highlighted: fieldHighlightOf( + fieldPickerItems(fields, drill, Combobox.queryOf(next.model), query.dataSource), + row.key, + ), + } + : next.model + const lifted: Model = { + ...base, + conditionPickers: { ...base.conditionPickers, [id]: picker }, + fieldDrills: picker.isOpen ? base.fieldDrills : Rec.remove(base.fieldDrills, id), + } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotConditionField({ queryId, rowId, message: inner }), + ) + const key = Option.getOrNull( + Option.flatMap(picked, (item) => + item._tag === "Field" + ? Option.some(item.field.key) + : item._tag === "Custom" + ? Option.some(item.key) + : Option.none(), + ), + ) + if (next.outMessage?._tag !== "ChangedValue" || key === null || key === row.key) + return { model: lifted, commands } + const operators = operatorsOf(fieldOf(key, query.dataSource), query.dataSource) + const operator = operators.includes(row.operator) ? row.operator : (operators[0] ?? "=") + return emitted(lifted, query, rows, replaced(rows, { ...row, key, operator, values: [] }), commands) + }) + +/** + * Whether a row holds several values: "is" only where the engine ORs (attribute filters), + * "is not" as AND-ed excludes except on product events, whose excludes replace each other. + */ +export const holdsManyValues = (row: Pick, query: QueryDraft): boolean => + isAnyOf(row.operator) + ? canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource) + : isNoneOf(row.operator) && query.dataSource !== "product_events" + +/** The value picker's Model for the row: a multiple picker with chips, or a single one holding the value. */ +export const valuePickerOf = (model: Model, query: QueryDraft, row: ConditionRow): Combobox.Model => { + const id = conditionPickerId(query.id, row.id, "values") + const mode = holdsManyValues(row, query) ? "multiple" : "single" + const stored = model.conditionPickers[id] + const picker = stored !== undefined && stored.mode === mode ? stored : Combobox.init(id, mode) + return { + ...picker, + selected: row.values, + inputValue: mode === "single" && !picker.isOpen ? (row.values[0] ?? "") : picker.inputValue, + } +} + +/** The value picker: a suggestion or anything typed (Enter adds it); a single picker closes on a pick. */ +export const valuesStep = ( + model: Model, + queryId: string, + rowId: string, + message: Combobox.Message, + inputs: PanelInputs | undefined, +): Return => + withRow(model, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { + const id = conditionPickerId(queryId, rowId, "values") + const current = valuePickerOf(base, query, row) + const isMany = current.mode === "multiple" + const lift = (next: Combobox.Model): Model => ({ + ...base, + conditionPickers: { ...base.conditionPickers, [id]: next }, + }) + const typed = Str.trim(current.inputValue) + const isTypedEnter = + message._tag === "PressedInputKey" && + message.key === "Enter" && + current.highlighted === null && + typed !== "" + // The grammar cannot spell it; the view says so beside the input. + if (isTypedEnter && isUnspellable(typed)) return { model: lift(current) } + if (isTypedEnter && isMany) + return emitted( + lift({ ...current, inputValue: "" }), + query, + rows, + replaced(rows, { ...row, values: Arr.dedupe([...row.values, typed]) }), + ) + const pick = isTypedEnter ? Combobox.Message.PressedEscape() : message + const next = Combobox.update(isTypedEnter ? { ...current, selected: [typed] } : current, pick) + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotConditionValues({ queryId, rowId, message: inner }), + ) + const values = isTypedEnter + ? [typed] + : next.outMessage?._tag === "ChangedSelection" + ? next.outMessage.selected + : next.outMessage?._tag === "ChangedValue" + ? [next.outMessage.value] + : null + if (values === null || Arr.some(values, isUnspellable)) return { model: lift(next.model), commands } + return emitted(lift(next.model), query, rows, replaced(rows, { ...row, values }), commands) + }) + +const isOperator = Schema.is(Operator) + +/** The operator select (`whereOp::`); a valueless operator drops the values. */ +export const operatorStep = ( + model: Model, + id: string, + message: Select.Message, + inputs: PanelInputs | undefined, +): Return => { + const current = model.selects[id] ?? Select.init(id, { value: "", selectedIndex: null }) + const next = Select.update(current, message) + const lifted: Model = { ...model, selects: { ...model.selects, [id]: next.model } } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotSelectMessage({ id, message: inner }), + ) + const [, queryId = "", rowId = ""] = Str.split(":")(id) + const operator = next.outMessage?.value + if (operator === undefined || !isOperator(operator)) return { model: lifted, commands } + return withRow(lifted, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { + const values = isValueless(operator) + ? [] + : holdsManyValues({ key: row.key, operator }, query) + ? row.values + : Arr.take(row.values, 1) + return emitted(base, query, rows, replaced(rows, { ...row, operator, values }), commands) + }) +} diff --git a/apps/web/src/page/kit/query-builder/metrics-body.ts b/apps/web/src/page/kit/query-builder/metrics-body.ts index 5d57b60350..8bf41ce7b0 100644 --- a/apps/web/src/page/kit/query-builder/metrics-body.ts +++ b/apps/web/src/page/kit/query-builder/metrics-body.ts @@ -14,7 +14,8 @@ import { QUERY_BUILDER_SPACE_AGGREGATIONS } from "@maple/query-model" import { groupBySelectionOf, type QueryDraft } from "./draft" import { Message, metricPickerIdOf, type Model } from "./panel" import { type MetricOption, metricValueOf } from "./metric-options" -import { panelSelect, textInput, whereClauseEditor } from "./parts" +import { panelSelect } from "./panel-select" +import { textInput, whereClauseEditor } from "./parts" import { Array as Arr, Schema, String as Str, Option } from "effect" // `MetricsBody`: the metric picker, the where clause, and the two aggregation stages: diff --git a/apps/web/src/page/kit/query-builder/panel-message.ts b/apps/web/src/page/kit/query-builder/panel-message.ts index 521b1a73fe..5dbd66695d 100644 --- a/apps/web/src/page/kit/query-builder/panel-message.ts +++ b/apps/web/src/page/kit/query-builder/panel-message.ts @@ -4,6 +4,12 @@ import * as Combobox from "@maple/views/ui/combobox" import * as Select from "@maple/views/ui/select" import { initWhereFields, WhereFieldMessage, WhereFields } from "../where-field" import { AddOnKey, QueryChange, QueryTextField } from "./change" +import { FieldDrill } from "./condition-fields" +import { ConditionRow } from "./condition-rows" + +/** How a query's where clause is edited: condition rows, or the text editor. */ +export const WhereMode = Schema.Literals(["builder", "text"]) +export type WhereMode = typeof WhereMode.Type // `QueryPanel`'s own state: the // selects and group-by chips of every panel, and which panels are collapsed. The drafts stay with @@ -19,6 +25,14 @@ export const Model = Schema.Struct({ collapsed: Schema.Array(Schema.String), /** Each where editor's caret, list and active key values, by query id. */ whereFields: WhereFields, + /** Draft UI state, never saved: the editor each query shows (builder when absent). */ + whereModes: Schema.Record(Schema.String, WhereMode), + /** The builder's rows by query id, kept while they still print to the draft's where clause. */ + conditions: Schema.Record(Schema.String, Schema.Array(ConditionRow)), + /** Each row's field and value pickers, by picker id (`conditionPickerId`). */ + conditionPickers: Schema.Record(Schema.String, Combobox.Model), + /** Which group a field picker has drilled into, by picker id. */ + fieldDrills: Schema.Record(Schema.String, FieldDrill), }) export type Model = typeof Model.Type @@ -29,6 +43,10 @@ export const init = (slotPrefix: string): Model => ({ metricPickers: {}, collapsed: [], whereFields: initWhereFields(), + whereModes: {}, + conditions: {}, + conditionPickers: {}, + fieldDrills: {}, }) export const Message = defineMessageUnion({ @@ -44,6 +62,11 @@ export const Message = defineMessageUnion({ ClickedRemove: { queryId: Schema.String }, /** The where editors' suggestions; a field id is its query's id. */ GotWhereField: { message: WhereFieldMessage }, + ChangedWhereMode: { queryId: Schema.String, mode: WhereMode }, + ClickedAddCondition: { queryId: Schema.String }, + ClickedRemoveCondition: { queryId: Schema.String, rowId: Schema.String }, + GotConditionField: { queryId: Schema.String, rowId: Schema.String, message: Combobox.Message }, + GotConditionValues: { queryId: Schema.String, rowId: Schema.String, message: Combobox.Message }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/kit/query-builder/panel-select.ts b/apps/web/src/page/kit/query-builder/panel-select.ts new file mode 100644 index 0000000000..fcca5ee2f8 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/panel-select.ts @@ -0,0 +1,42 @@ +import type { Html, HtmlBuilder } from "foldkit/html" +import * as Select from "@maple/views/ui/select" +import { view as selectView, type SelectOption } from "@maple/views/ui/select-view" +import { Message, type Model } from "./panel-message" +import { Array as Arr, Option } from "effect" + +type H = HtmlBuilder + +/** One of the panel's selects, kept by id; its value is always the draft's. */ +export const panelSelect = ( + h: H, + model: Model, + id: string, + value: string, + options: ReadonlyArray, + inputs: Readonly<{ + size?: "sm" | "default" + className?: string + ariaLabel?: string + /** `false`: the list opens below the trigger instead of over it. */ + alignItemWithTrigger?: boolean + /** Places the trigger and its siblings (hidden input, portal) in the caller's layout. */ + toView: (parts: ReadonlyArray) => Html + }>, +): Html => { + const index = Option.getOrNull(Arr.findFirstIndex(options, (option) => option.value === value)) + const current = model.selects[id] ?? Select.init(id, { value, selectedIndex: index }) + return h.submodel({ + slotId: `${model.slotPrefix}-select-${id}`, + model: { ...current, value }, + view: selectView, + toParentMessage: (message) => Message.GotSelectMessage({ id, message }), + viewInputs: { + entries: Arr.map(options, (item) => ({ _tag: "Item" as const, item })), + size: inputs.size, + className: inputs.className, + ariaLabel: inputs.ariaLabel, + alignItemWithTrigger: inputs.alignItemWithTrigger, + toView: inputs.toView, + }, + }) +} diff --git a/apps/web/src/page/kit/query-builder/panel.ts b/apps/web/src/page/kit/query-builder/panel.ts index 7074687bf4..af231d5039 100644 --- a/apps/web/src/page/kit/query-builder/panel.ts +++ b/apps/web/src/page/kit/query-builder/panel.ts @@ -8,6 +8,17 @@ import type { Resources } from "../../../platform/resources" import * as Combobox from "@maple/views/ui/combobox" import * as Select from "@maple/views/ui/select" import { QueryChange } from "./change" +import { + addedCondition, + changedWhereMode, + fieldStep, + isOperatorSelect, + operatorSelectId, + operatorStep, + removedCondition, + typedInTextEditor, + valuesStep, +} from "./conditions" import { groupBySelectionOf } from "./draft" import { Message, type Model, OutMessage } from "./panel-message" import { type PanelInputs, whereStep } from "./where" @@ -15,6 +26,7 @@ import { Array as Arr, Option, String as Str } from "effect" export * from "./panel-message" export type { PanelInputs } from "./where" +export { operatorSelectId } type Return = Update.ReturnWithOutMessage @@ -123,21 +135,39 @@ const metricStep = (model: Model, queryId: string, value: string, message: Combo : { model: lifted, commands } } -/** Where-editor Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. */ +/** Where-editor and condition-builder Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. */ export const update = (model: Model, message: Message, inputs?: PanelInputs): Return => Message.match(message, { - GotSelectMessage: ({ id, message: inner }) => selectStep(model, id, inner), + GotSelectMessage: ({ id, message: inner }) => + isOperatorSelect(id) ? operatorStep(model, id, inner, inputs) : selectStep(model, id, inner), GotGroupByMessage: ({ queryId, message: inner }) => groupByStep(model, queryId, inner, inputs), GotMetricMessage: ({ queryId, value, message: inner }) => metricStep(model, queryId, value, inner), ToggledCollapsed: ({ queryId }) => ({ model: { ...model, collapsed: toggled(model.collapsed, queryId) }, }), ToggledVisible: ({ queryId }) => changed(model, queryId, QueryChange.ToggledHidden()), + // Typing in the text editor keeps it the editor, even once the text reads as rows. ChangedText: ({ queryId, field, value }) => - changed(model, queryId, QueryChange.SetText({ field, value })), + changed( + field === "whereClause" ? typedInTextEditor(model, queryId) : model, + queryId, + QueryChange.SetText({ field, value }), + ), ToggledAddOn: ({ queryId, key }) => changed(model, queryId, QueryChange.ToggledAddOn({ key })), ClickedClone: ({ queryId }) => ({ model, outMessage: OutMessage.ClickedClone({ queryId }) }), ClickedRemove: ({ queryId }) => ({ model, outMessage: OutMessage.ClickedRemove({ queryId }) }), - GotWhereField: ({ message: inner }) => - inputs === undefined ? { model } : whereStep(model, inner, inputs), + GotWhereField: ({ message: inner }) => { + if (inputs === undefined) return { model } + const next = whereStep(model, inner, inputs) + return next.outMessage === undefined + ? next + : { ...next, model: typedInTextEditor(next.model, next.outMessage.queryId) } + }, + ChangedWhereMode: ({ queryId, mode }) => changedWhereMode(model, queryId, mode), + ClickedAddCondition: ({ queryId }) => addedCondition(model, queryId, inputs), + ClickedRemoveCondition: ({ queryId, rowId }) => removedCondition(model, queryId, rowId, inputs), + GotConditionField: ({ queryId, rowId, message: inner }) => + fieldStep(model, queryId, rowId, inner, inputs), + GotConditionValues: ({ queryId, rowId, message: inner }) => + valuesStep(model, queryId, rowId, inner, inputs), }) diff --git a/apps/web/src/page/kit/query-builder/parts.ts b/apps/web/src/page/kit/query-builder/parts.ts index e12dc31548..655b486b14 100644 --- a/apps/web/src/page/kit/query-builder/parts.ts +++ b/apps/web/src/page/kit/query-builder/parts.ts @@ -5,13 +5,22 @@ import * as Combobox from "@maple/views/ui/combobox" import { chipsView } from "@maple/views/ui/combobox-view" import { filterChip } from "@maple/views/ui/filter-chip" import { input } from "@maple/views/ui/input" -import * as Select from "@maple/views/ui/select" -import { view as selectView, type SelectOption } from "@maple/views/ui/select-view" import type { AddOnKey } from "./change" import { groupBySelectionOf, type QueryDraft } from "./draft" import { Message, type Model } from "./panel" import { whereEditor } from "./where-editor" +import { button } from "@maple/views/ui/button" +import { icon } from "@maple/views/ui/icon" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { cn } from "@maple/ui/lib/utils" +import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { conditionBuilder } from "./condition-builder-view" +import { type UnshowableReason, unshowableReasonOf } from "./condition-rows" +import { conditionRowsOf, whereModeOf } from "./conditions" +import type { WhereMode } from "./panel-message" import { whereSpecOf, whereTextareaId } from "./where" +import { panelSelect } from "./panel-select" import { Array as Arr, Record as Rec, Option } from "effect" // The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. @@ -26,38 +35,6 @@ export const ADD_ON_KEYS: ReadonlyArray<{ key: AddOnKey; label: string }> = [ { key: "legend", label: "Legend" }, ] -/** One of the panel's selects, kept by id; its value is always the draft's. */ -export const panelSelect = ( - h: H, - model: Model, - id: string, - value: string, - options: ReadonlyArray, - inputs: Readonly<{ - size?: "sm" | "default" - className?: string - ariaLabel?: string - /** Places the trigger and its siblings (hidden input, portal) in the caller's layout. */ - toView: (parts: ReadonlyArray) => Html - }>, -): Html => { - const index = Option.getOrNull(Arr.findFirstIndex(options, (option) => option.value === value)) - const current = model.selects[id] ?? Select.init(id, { value, selectedIndex: index }) - return h.submodel({ - slotId: `${model.slotPrefix}-select-${id}`, - model: { ...current, value }, - view: selectView, - toParentMessage: (message) => Message.GotSelectMessage({ id, message }), - viewInputs: { - entries: Arr.map(options, (item) => ({ _tag: "Item" as const, item })), - size: inputs.size, - className: inputs.className, - ariaLabel: inputs.ariaLabel, - toView: inputs.toView, - }, - }) -} - const WHERE_PLACEHOLDER = { traces: 'service.name = "checkout" AND status.code = "Error"', logs: 'service.name = "checkout" AND severity = "ERROR"', @@ -67,21 +44,22 @@ const WHERE_PLACEHOLDER = { const WHERE_TEXTAREA_CLASS = "min-h-[32px] resize-y text-xs" -/** `WhereClauseEditor`: the textarea under its highlight overlay, and its suggestion list. */ -export const whereClauseEditor = ( +/** The text editor: the textarea under its highlight overlay, and its suggestion list. */ +const whereTextEditor = ( h: H, model: Model, query: QueryDraft, values: WhereClauseAutocompleteValues, - options: Readonly<{ placeholder?: string; className?: string }> = {}, + placeholder: string | undefined, + className: string, ): Html => whereEditor(h, { id: whereTextareaId(query.id), value: query.whereClause, ariaLabel: `Where clause for query ${query.name}`, - placeholder: options.placeholder ?? WHERE_PLACEHOLDER[query.dataSource], + placeholder: placeholder ?? WHERE_PLACEHOLDER[query.dataSource], rows: 1, - className: options.className ?? "flex-1", + className, textareaClassName: WHERE_TEXTAREA_CLASS, onInput: (value) => Message.ChangedText({ queryId: query.id, field: "whereClause", value }), autocomplete: { @@ -93,6 +71,104 @@ export const whereClauseEditor = ( }, }) +/** Why the clause stays in the text editor, in one line. */ +const UNSHOWABLE_NOTE = { + mixedFields: "This filter mixes fields in an OR, so it stays in Text.", + orOperators: "This filter uses an OR the builder can't show, so it stays in Text.", + orNotApplied: "This OR group is on a field that can't be OR-ed, so it stays in Text.", + syntax: "This filter uses syntax the builder can't show, so it stays in Text.", +} satisfies Record + +// What sits beside the floated controls takes this much room, or drops under them. +const BESIDE_CONTROLS = "flow-root min-w-52" + +/** + * `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text + * editor. The Builder/Text switch and `trailing` (the span select) float top right, level with + * the first row; a narrow editor puts them on their own line. Both edit the same string. + */ +export const whereClauseEditor = ( + h: H, + model: Model, + query: QueryDraft, + values: WhereClauseAutocompleteValues, + options: Readonly<{ placeholder?: string; className?: string; trailing?: ReadonlyArray }> = {}, +): Html => { + const rows = conditionRowsOf(model, query) + const isBuilder = whereModeOf(model, query) === "builder" && Option.isSome(rows) + const toggle = segmentedSelect(h, { + options: [ + { value: "builder", label: "Builder", isDisabled: Option.isNone(rows) }, + { value: "text", label: "Text" }, + ], + value: isBuilder ? "builder" : "text", + onChange: (mode: WhereMode) => Message.ChangedWhereMode({ queryId: query.id, mode }), + size: "sm", + ariaLabel: `Where clause editor for query ${query.name}`, + className: "shrink-0", + }) + // Capped at the editor's width and wrapping, so a narrow panel stacks the two instead of overflowing. + const controls = hstack( + h, + { gap: "2", wrap: true, justify: "end", className: "float-right mb-1 ml-2 max-w-full" }, + [toggle, ...(options.trailing ?? [])], + ) + const note = Option.match(unshowableReasonOf(query.whereClause, query.dataSource), { + onNone: () => [], + onSome: (reason) => [ + // One line beside the controls where the editor is wide, else its own lines under them. + hstack(h, { className: cn(BESIDE_CONTROLS, "min-h-7 @xl/where:min-w-100") }, [ + typography(h, { size: "xs", tone: "muted" }, [UNSHOWABLE_NOTE[reason]]), + ]), + ], + }) + const conditions = isBuilder + ? conditionBuilder( + h, + model, + query, + Option.getOrElse(rows, () => []), + values, + ) + : [] + const addCondition = hstack( + h, + { className: cn(BESIDE_CONTROLS, "min-h-7", conditions.length > 0 && "mt-1") }, + [ + button( + h, + { + variant: "ghost", + size: "xs", + className: "text-muted-foreground", + attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], + }, + [icon(h, "PlusIcon"), "Add condition"], + ), + ], + ) + return h.div( + [h.Class(cn("@container/where flow-root", options.className ?? "min-w-0 flex-1"))], + [ + controls, + ...note, + ...(isBuilder + ? [...conditions, addCondition] + : [ + whereTextEditor( + h, + model, + query, + values, + options.placeholder, + // Under the note when there is one, else beside the controls. + cn(BESIDE_CONTROLS, note.length > 0 && "clear-both"), + ), + ]), + ], + ) +} + /** A controlled `` that reports each keystroke. */ export const textInput = ( h: HtmlBuilder, diff --git a/apps/web/src/page/kit/query-builder/view.ts b/apps/web/src/page/kit/query-builder/view.ts index cd6a5e0753..21067cef25 100644 --- a/apps/web/src/page/kit/query-builder/view.ts +++ b/apps/web/src/page/kit/query-builder/view.ts @@ -21,8 +21,10 @@ import type { QueryDraft } from "./draft" import { Message, type Model } from "./panel" import type { MetricOption } from "./metric-options" import { type MetricKeys, metricsBody } from "./metrics-body" -import { addOnSections, ADD_ON_KEYS, addOnToggles, panelSelect, textInput, whereClauseEditor } from "./parts" -import { Array as Arr, String as Str } from "effect" +import { unshowableReasonOf } from "./condition-rows" +import { panelSelect } from "./panel-select" +import { addOnSections, ADD_ON_KEYS, addOnToggles, textInput, whereClauseEditor } from "./parts" +import { Array as Arr, Option, String as Str } from "effect" // `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. @@ -148,11 +150,12 @@ const body = (h: H, model: Model, inputs: QueryPanelInputs): ReadonlyArray intervalPlaceholder, autocompleteValues: inputs.autocompleteValues ?? {}, }) + // The span select rides with the editor's Builder/Text switch, top right of the where area. const whereRow = (parts: ReadonlyArray) => - h.div( - [h.Class("flex items-start gap-2")], - [whereClauseEditor(h, model, query, inputs.autocompleteValues ?? {}), ...parts], - ) + whereClauseEditor(h, model, query, inputs.autocompleteValues ?? {}, { + className: "min-w-0", + trailing: parts, + }) return [ query.dataSource === "traces" ? panelSelect( @@ -189,6 +192,35 @@ const body = (h: H, model: Model, inputs: QueryPanelInputs): ReadonlyArray ] } +// The engine's warning for a dropped OR group ends in the group's text: which filter is missing. +const OR_GROUP_DROPPED = /OR group ignored: only attribute filters can be OR-ed: (.+)$/s +const OR_RULE = "Only attribute filters can be OR-ed." + +/** + * The engine's warnings in the panel's words: a dropped OR group names its group, one line per + * distinct group. `isRuleStated`: the editor's note already says the field can't be OR-ed. + */ +export const panelWarningsOf = ( + warnings: ReadonlyArray, + options: Readonly<{ isRuleStated?: boolean }> = {}, +): ReadonlyArray => + Arr.dedupe( + Arr.map(warnings, (warning) => + Option.match( + Option.flatMap(Str.match(OR_GROUP_DROPPED)(warning), (match) => + Option.fromNullishOr(match[1]), + ), + { + onNone: () => warning, + onSome: (group) => + options.isRuleStated === true + ? `This OR group is not applied: ${group}.` + : `This OR group is not applied: ${group}. ${OR_RULE}`, + }, + ), + ), + ) + /** `WarningList`: clauses the builder could not apply as written. */ const warningList = (h: H, warnings: ReadonlyArray): ReadonlyArray => warnings.length === 0 @@ -215,7 +247,15 @@ export const queryPanelView = Submodel.defineView [ ...body(h, model, inputs), // TODO(query-builder): the product-events setup hint (`SignalEmptyState`). - ...warningList(h, buildTimeseriesQuerySpec(query).warnings), + ...warningList( + h, + panelWarningsOf(buildTimeseriesQuerySpec(query).warnings, { + isRuleStated: Option.contains( + unshowableReasonOf(query.whereClause, query.dataSource), + "orNotApplied", + ), + }), + ), // A metrics query groups from its "across" row; a Group by toggle would only hide that choice. addOnToggles( h, diff --git a/apps/web/src/page/kit/sentence-card.ts b/apps/web/src/page/kit/sentence-card.ts new file mode 100644 index 0000000000..6b982c0cdf --- /dev/null +++ b/apps/web/src/page/kit/sentence-card.ts @@ -0,0 +1,303 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { card } from "@maple/views/ui/card" +import { cardButton } from "@maple/views/ui/card-button" +import { separator } from "@maple/views/ui/display" +import { heading } from "@maple/views/ui/heading" +import { icon } from "@maple/views/ui/icon" +import { hstack, vstack } from "@maple/views/ui/stack" +import { statusDot } from "@maple/views/ui/status-dot" +import { typography } from "@maple/views/ui/typography" +import { Array as Arr, Option } from "effect" + +// Sentence cards: a form that reads top to bottom as one sentence ("When ... Alert when ... +// Notify ... Name"), each card a clause. No numbers: a status dot on a rail joins them. + +export type SentenceCardStatus = "done" | "missing" + +export type SentenceCardOptions = Readonly<{ + /** Unique on the page; the card's element ids derive from it. */ + id: string + /** The clause's lead word, the card's heading: "When", "Alert when", "Notify". */ + lead: string + /** A line beside the lead while the card is open. */ + hint?: string | undefined + status: SentenceCardStatus + /** What is missing, said while `status` is `missing`: "Threshold", "Rule name". */ + issues?: ReadonlyArray + /** Blockers that are not a missing field, said as they are: "Query too slow for this check interval". */ + problems?: ReadonlyArray + /** The clause in plain words, shown while the card is closed. */ + summary: string + isExpanded: boolean + onExpand: M + /** Closes an open card back to its summary (Done, or Escape); a missing card stays open. */ + onCollapse?: M + /** Layout of the open card's body: a width cap for a card of short fields. */ + bodyClassName?: string +}> + +/** The open card's heading: focus lands here when the card opens. */ +export const sentenceCardHeadingId = (id: string): string => `sentence-card-${id}` +/** The closed card's summary button: focus returns here when the card closes. */ +export const sentenceCardSummaryId = (id: string): string => `sentence-card-${id}-summary` +const panelIdOf = (id: string): string => `sentence-card-${id}-panel` + +const DOT_TONE = { done: "done", missing: "warn" } as const + +const LEAD_CLASS = "shrink-0 sm:w-24" + +/** The rail beside a card: its status dot, then the line down to the next card (none after the last). */ +const rail = (h: HtmlBuilder, status: SentenceCardStatus): Html => + vstack(h, { align: "center", className: "h-full" }, [ + h.span( + [h.Class("mt-[calc(--spacing(5)+1px)] grid size-4 shrink-0 place-items-center")], + [statusDot(h, { tone: DOT_TONE[status], size: "lg" })], + ), + // In a row the separator stretches to the row's height: the rest of the step, gap included. + hstack(h, { justify: "center", grow: true, className: "mt-1.5 group-last/sentence:hidden" }, [ + separator(h, "vertical", undefined), + ]), + ]) + +/** The lead as the heading, its whole card a disclosure button that opens the card. */ +const closedCard = (h: HtmlBuilder, options: SentenceCardOptions): Html => + heading(h, { level: 2, variant: "section" }, [ + cardButton( + h, + { + onClick: options.onExpand, + label: `${options.lead}: ${options.summary}. Edit`, + className: "block w-full px-4 py-3", + attributes: [ + h.Id(sentenceCardSummaryId(options.id)), + h.AriaExpanded(false), + h.AriaControls(panelIdOf(options.id)), + ], + }, + [ + // A phone stacks the lead over the summary; from `sm` they share one line. + h.span( + [ + h.Class( + "grid min-h-8 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-0.5 py-1.5 sm:grid-cols-[--spacing(24)_minmax(0,1fr)_auto]", + ), + ], + [ + typography( + h, + { size: "sm", weight: "semibold", className: "col-start-1 row-start-1" }, + [options.lead], + ), + typography( + h, + { + size: "sm", + weight: "normal", + className: + "col-span-2 row-start-2 line-clamp-3 min-w-0 sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:line-clamp-1", + }, + [options.summary], + ), + hstack( + h, + { + gap: "1", + align: "center", + as: "span", + className: "col-start-2 row-start-1 h-5 sm:col-start-3", + }, + [ + icon(h, "PencilIcon", { size: 12, className: "text-muted-foreground" }), + typography(h, { size: "xs", weight: "normal", tone: "muted" }, ["Edit"]), + ], + ), + ], + ), + ], + ), + ]) + +// A popup open over the card (a select, a menu, a chip picker) takes Escape for itself: it is read +// off the page at the keypress, the one moment the popup is certain to still be mounted. +const isPopupOpen = (): boolean => + typeof document !== "undefined" && + document.querySelector('[data-base-ui-portal]:not([data-slot^="toast"])') !== null + +const openCard = ( + h: HtmlBuilder, + options: SentenceCardOptions, + body: () => ReadonlyArray, +): Html => { + const headingId = sentenceCardHeadingId(options.id) + const issues = options.issues ?? [] + // "Missing: Threshold. Query too slow for this check interval": fields by name, problems in full. + const issueLine = Arr.join( + [...(issues.length === 0 ? [] : [`Missing: ${Arr.join(issues, ", ")}`]), ...(options.problems ?? [])], + ". ", + ) + const onCollapse = options.status === "missing" ? undefined : options.onCollapse + // One frame around the header and the body, the same box the closed card draws. + return card( + h, + [ + h.section( + [ + h.AriaLabelledBy(headingId), + h.Class("flex min-w-0 flex-col gap-4 px-4 pt-3 pb-4"), + ...(onCollapse === undefined + ? [] + : [ + h.OnKeyDownPreventDefault((key) => + key === "Escape" && !isPopupOpen() + ? Option.some(onCollapse) + : Option.none(), + ), + ]), + ], + [ + hstack( + h, + { gap: "3", align: "baseline", wrap: true, className: "min-h-8 gap-y-1 pt-1.5" }, + [ + heading( + h, + { + level: 2, + variant: "section", + id: headingId, + className: cn(LEAD_CLASS, "outline-none"), + attributes: [h.Tabindex(-1)], + }, + [options.lead], + ), + ...(options.hint === undefined + ? [] + : [ + typography( + h, + { size: "xs", tone: "muted", as: "p", className: "min-w-0" }, + [options.hint], + ), + ]), + ...(options.status === "missing" + ? [ + typography(h, { size: "xs", tone: "warning", as: "p" }, [ + issueLine === "" ? "Required" : issueLine, + ]), + ] + : []), + ], + ), + h.div( + [ + h.Id(panelIdOf(options.id)), + h.Class(cn("flex min-w-0 flex-col gap-4", options.bodyClassName)), + ], + [ + ...body(), + // Last in the tab order, after the fields it closes. + ...(onCollapse === undefined + ? [] + : [ + hstack(h, { justify: "end" }, [ + button( + h, + { + variant: "outline", + attributes: [ + h.AriaExpanded(true), + h.AriaControls(panelIdOf(options.id)), + h.OnClick(onCollapse), + ], + }, + ["Done"], + ), + ]), + ]), + ], + ), + ], + ), + ], + "gap-0 p-0", + ) +} + +/** + * `SentenceCard`: one clause of a sentence-card form, on its rail. Open, it shows its lead and + * controls (`body`, called only then); closed (complete and not being edited), a one-line summary + * that opens it on click. A missing card is always open; Done or Escape closes a complete one. + */ +export const sentenceCard = ( + h: HtmlBuilder, + options: SentenceCardOptions, + body: () => ReadonlyArray, +): Html => { + const isOpen = options.isExpanded || options.status === "missing" + return h.li( + [ + h.Key(options.id), + h.Class("group/sentence grid grid-cols-[--spacing(4)_minmax(0,1fr)] gap-x-3"), + h.DataAttribute("state", isOpen ? "open" : "closed"), + ], + [ + rail(h, options.status), + h.div( + [h.Class("min-w-0 pb-4 group-last/sentence:pb-0")], + [isOpen ? openCard(h, options, body) : closedCard(h, options)], + ), + ], + ) +} + +/** `SentenceChain`: the sentence cards top to bottom, their rails joined into one line. */ +export const sentenceChain = ( + h: HtmlBuilder, + options: Readonly<{ label: string; className?: string }>, + cards: ReadonlyArray, +): Html => h.ol([h.AriaLabel(options.label), h.Class(cn("flex flex-col", options.className))], cards) + +/** + * `SentenceRow`: one line of a sentence form, its words and inline controls wrapping together + * ("Fire after [3] breaches [in a row]."). Controls sit at 32px, 36px on a phone. + */ +export const sentenceRow = ( + h: HtmlBuilder, + options: Readonly<{ className?: string }>, + children: ReadonlyArray, +): Html => + h.div( + [ + h.Class( + cn("flex flex-wrap items-center gap-x-2 gap-y-2 text-sm text-foreground", options.className), + ), + ], + children, + ) + +/** `SentencePhrase`: words and their control kept together, so a wrap never strands the control. */ +export const sentencePhrase = (h: HtmlBuilder, children: ReadonlyArray): Html => + h.span([h.Class("inline-flex min-w-0 items-center gap-2")], children) + +/** `SentenceClause`: a sentence row with the notes that belong to it (a hint, what it was) under it. */ +export const sentenceClause = (h: HtmlBuilder, row: Html, notes: ReadonlyArray = []): Html => + notes.length === 0 ? row : h.div([h.Class("flex min-w-0 flex-col gap-1.5")], [row, ...notes]) + +/** `SentenceSection`: a titled group of sentence rows inside a card, ruled off from the one above. */ +export const sentenceSection = ( + h: HtmlBuilder, + options: Readonly<{ title: string; className?: string }>, + children: ReadonlyArray, +): Html => + h.section( + [ + h.AriaLabel(options.title), + h.Class(cn("flex flex-col gap-4 border-t pt-4 first:border-t-0 first:pt-0", options.className)), + ], + [ + typography(h, { size: "xs", weight: "medium", tone: "muted", as: "p" }, [options.title]), + ...children, + ], + ) 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 new file mode 100644 index 0000000000..09cfb5f800 --- /dev/null +++ b/apps/web/src/page/kit/sheet-param.ts @@ -0,0 +1,54 @@ +import { Effect, Option, Schema } from "effect" +import { back, replaceUrl } from "foldkit/navigation" + +/** + * 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" + +/** 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 !== "") + +/** The history entry state `pushSheetUrl` writes. */ +export const SheetHistoryState = Schema.Struct({ mapleSheet: Schema.String }) +const decodeSheetState = Schema.decodeUnknownOption(SheetHistoryState) + +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/page/kit/timezone-zones.ts b/apps/web/src/page/kit/timezone-zones.ts index 6cf9a54649..091b719af5 100644 --- a/apps/web/src/page/kit/timezone-zones.ts +++ b/apps/web/src/page/kit/timezone-zones.ts @@ -26,6 +26,37 @@ const clockFormatter = Option.liftThrowable( new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hour12: false }), ) +const abbreviationFormatter = (locale: string) => + Option.liftThrowable( + (timeZone: string) => new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: "short" }), + ) +const ABBREVIATION_FORMATTERS = [abbreviationFormatter("en-GB"), abbreviationFormatter("en-US")] + +/** + * A zone's short name at `atMs`: its abbreviation where ICU has one (`CET`, `CEST`, `EST`), else + * its offset (`UTC+5:30`). An unknown zone reads `UTC`. + */ +export const shortZoneName = (timeZone: string, atMs: number): string => + Option.getOrElse( + Arr.findFirst( + Arr.flatMap(ABBREVIATION_FORMATTERS, (formatterFor) => + Option.toArray( + Option.flatMap(formatterFor(timeZone), (formatter) => + Option.map( + Arr.findFirst( + formatter.formatToParts(atMs), + (entry) => entry.type === "timeZoneName", + ), + (entry) => entry.value, + ), + ), + ), + ), + (name) => /^[A-Z]{2,5}$/.test(name), + ), + () => formatUtcOffset(timeZone, atMs), + ) + /** `formatUtcOffset`: `UTC+2`, `UTC-5:30`, `UTC`, the offset a zone is on at `atMs`. */ export const formatUtcOffset = (timeZone: string, atMs: number): string => { const part = Option.match(offsetFormatter(timeZone), { diff --git a/apps/web/src/page/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/page/services/detail/uptime-card.scene.test.ts b/apps/web/src/page/services/detail/uptime-card.scene.test.ts index 0e683b9dc5..60558c0397 100644 --- a/apps/web/src/page/services/detail/uptime-card.scene.test.ts +++ b/apps/web/src/page/services/detail/uptime-card.scene.test.ts @@ -73,7 +73,7 @@ describe("service detail, Uptime card", () => { "href", "/alerts/uptime/new?serviceName=checkout-api", ), - expectView(role("link", { name: "Open in Alerts" })).toHaveAttr("href", "/alerts?tab=uptime"), + expectView(role("link", { name: "Open in Alerts" })).toHaveAttr("href", "/alerts/uptime"), ) }) }) diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index e57a9395f9..c230a9dd05 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -34,7 +34,7 @@ type H = HtmlBuilder export const resourceHref = (link: AuditResourceLink): string => { switch (link.to) { case "/alerts/$ruleId": - return `/alerts/${encodeURIComponent(link.params.ruleId)}` + return `/alerts/rules/${encodeURIComponent(link.params.ruleId)}` case "/dashboards/$dashboardId": return `/dashboards/${encodeURIComponent(link.params.dashboardId)}` case "/errors/issues/$issueId": @@ -43,8 +43,8 @@ export const resourceHref = (link: AuditResourceLink): string => { return `/investigations/${encodeURIComponent(link.params.id)}` case "/anomalies/$incidentId": return `/anomalies/${encodeURIComponent(link.params.incidentId)}` - case "/alerts": - return hrefOf(AppRoute.Alerts({ search: link.search })) + case "/alerts/routing": + return hrefOf(AppRoute.AlertsSection({ section: "routing", search: {} })) case "/integrations": return hrefOf(AppRoute.Integrations({ search: link.search })) case "/settings": diff --git a/apps/web/src/page/settings/automation/view.ts b/apps/web/src/page/settings/automation/view.ts index e7072574c8..f00504f671 100644 --- a/apps/web/src/page/settings/automation/view.ts +++ b/apps/web/src/page/settings/automation/view.ts @@ -24,6 +24,7 @@ import { canManage, Message, type Model, type PolicyDraft } from "./model" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { Array as Arr, String as Str, Option } from "effect" +import { routingTabHref } from "../../alerts/shared/escalation-chains" type H = HtmlBuilder type Inputs = Readonly<{ nowMs: number; timeZone: string; isKnownMember: boolean }> @@ -124,7 +125,7 @@ const destinationsBlock = (h: H, model: Model, draft: PolicyDraft): Html => { "Escalation sends triaged issues to a destination. Add a Slack, email or webhook destination first.", ]), hstack(h, { wrap: true, className: "gap-x-4 gap-y-2" }, [ - linkButton(h, { size: "sm", variant: "outline", href: "/alerts?tab=routing" }, [ + linkButton(h, { size: "sm", variant: "outline", href: routingTabHref() }, [ "Add a destination", ]), docsLink(h, "destinations"), diff --git a/apps/web/src/page/settings/setup-health/derive.ts b/apps/web/src/page/settings/setup-health/derive.ts index dcf4e4321f..5df280ec43 100644 --- a/apps/web/src/page/settings/setup-health/derive.ts +++ b/apps/web/src/page/settings/setup-health/derive.ts @@ -53,7 +53,7 @@ const INTEGRATION_FOR_CHECK = new Map([ const link = (label: string, href: string): SetupHealthAction => ({ kind: "link", label, href }) const settingsTab = (tab: "ingestion" | "data-platform") => hrefOf(AppRoute.Settings({ search: { tab } })) -const alertsTab = (tab: "rules" | "routing") => hrefOf(AppRoute.Alerts({ search: { tab } })) +const alertsTab = (section: "rules" | "routing") => hrefOf(AppRoute.AlertsSection({ section, search: {} })) const integrations = (integration?: string) => hrefOf(AppRoute.Integrations({ search: integration === undefined ? {} : { integration } })) diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 6cc1190873..47969e2b1a 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 }, @@ -36,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 }, @@ -78,6 +81,7 @@ export const AppRoute = defineRouteUnion({ Trace: { traceId: Schema.String, search: Search.TraceSearch }, AgentSessions: { search: Search.AgentSessionsSearch }, Alerts: { search: Search.AlertsSearch }, + AlertsSection: { section: Search.AlertsSection, search: Search.AlertsHubSearch }, Analytics: { search: Search.AnalyticsSearch }, Anomalies: { search: Search.AnomaliesSearch }, CodeReview: { search: Search.CodeReviewSearch }, diff --git a/apps/web/src/route/index.ts b/apps/web/src/route/index.ts index 952e2e784a..71e42e5619 100644 --- a/apps/web/src/route/index.ts +++ b/apps/web/src/route/index.ts @@ -1,3 +1,3 @@ export { AppRoute, isPublicRoute, type RouteTag } from "./app-route" -export { flaggedRouteRedirect, Redirect, routeRedirect } from "./redirect" +export { alertsSectionRoute, flaggedRouteRedirect, Redirect, routeRedirect } from "./redirect" export { hrefOf, urlToAppRoute } from "./routers" diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index e21bd3f792..db4ae480eb 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -3,7 +3,14 @@ import { Option, Predicate, Schema } from "effect" import { AppRoute } from "./app-route" import { hrefOf } from "./routers" import { DATA_INTEGRATIONS } from "./data-integrations" -import { type AlertsSearch, type AnomaliesSearch, ErrorsSearch } from "./search" +import { + type AlertsHubSearch, + type AlertsSearch, + AlertsSection, + type AlertsTab, + type AnomaliesSearch, + ErrorsSearch, +} from "./search" /** Where a route sends the browser instead of rendering, and whether it replaces history. */ export const Redirect = Schema.Struct({ url: Schema.String, isReplace: Schema.Boolean }) @@ -60,6 +67,23 @@ const anomaliesViewSearch = (search: typeof AnomaliesSearch.Type): typeof Alerts } } +const decodeAlertsSection = Schema.decodeUnknownOption(AlertsSection) + +/** The section an old `?tab=` named: "oncall" and "settings" were renamed, the rest is the Inbox. */ +const legacyAlertsSection = (tab: string): AlertsTab => + tab === "oncall" + ? "on-call" + : tab === "settings" + ? "routing" + : Option.getOrElse(decodeAlertsSection(tab), (): "inbox" => "inbox") + +/** `/alerts` for the Inbox, else `/alerts/
`, with the hub's filters. */ +export const alertsSectionRoute = ( + section: AlertsTab, + search: typeof AlertsHubSearch.Type, +): Extract => + section === "inbox" ? AppRoute.Alerts({ search }) : AppRoute.AlertsSection({ section, search }) + const DATA_INTEGRATION_IDS: ReadonlySet = new Set(DATA_INTEGRATIONS) const redirect = (to: AppRoute, isReplace: boolean) => @@ -97,12 +121,18 @@ export const routeRedirect = (route: AppRoute): Option.Option => InfraDiscover: () => redirect(AppRoute.Infra({ search: {} }), true), InfraLegacyHost: ({ hostName }) => redirect(AppRoute.InfraHost({ hostName, search: {} }), true), AlertIncidentsIndex: () => redirect(AppRoute.Alerts({ search: {} }), true), + /** A rule moved under Rules; old links (notifications, the apps) keep their tab and window. */ + AlertRuleLegacy: ({ ruleId, search }) => redirect(AppRoute.AlertRule({ ruleId, search }), true), /** The anomalies list is the Alerts Inbox's Anomalies view; one service and the status carry over. */ Anomalies: ({ search }) => redirect(AppRoute.Alerts({ search: anomaliesViewSearch(search) }), true), - /** Status pages left the Alerts hub for their own sidebar entry. */ - Alerts: ({ search }) => - search.tab === "status-pages" ? redirect(AppRoute.StatusPages(), true) : Option.none(), + /** A legacy `?tab=` opens its section's own path; status pages left the hub for the sidebar. */ + Alerts: ({ search: { tab, ...search } }) => + tab === undefined + ? Option.none() + : tab === "status-pages" + ? redirect(AppRoute.StatusPages(), true) + : redirect(alertsSectionRoute(legacyAlertsSection(tab), search), true), AlertsStatusPage: ({ pageId, search }) => redirect( AppRoute.StatusPage({ diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 2c35a58573..eda04755d6 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -110,11 +110,60 @@ 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/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") + expect(parse("/alerts/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRuleLegacy") + expect(parse("/alerts/nope")._tag).toBe("NotFound") + }) + + test("an old `?tab=` link replaces itself with its section's path and keeps its other params", () => { + const redirectOf = (url: string) => Option.map(routeRedirect(parse(url)), (to) => to.url) + expect(redirectOf("/alerts?tab=rules&status=firing")).toEqual( + Option.some("/alerts/rules?status=firing"), + ) + expect(redirectOf("/alerts?tab=%22silences%22&window=mw_1")).toEqual( + Option.some("/alerts/silences?window=mw_1"), + ) + expect(redirectOf("/alerts?tab=oncall")).toEqual(Option.some("/alerts/on-call")) + expect(redirectOf("/alerts?tab=settings")).toEqual(Option.some("/alerts/routing")) + expect(redirectOf("/alerts?tab=inbox&signal=anomaly")).toEqual(Option.some("/alerts?signal=anomaly")) + expect(redirectOf("/alerts?tab=overview")).toEqual(Option.some("/alerts")) + expect(routeRedirect(parse("/alerts?tab=uptime"))).toEqual( + Option.some({ url: "/alerts/uptime", isReplace: true }), + ) + // A rule's old address (notifications, the apps) opens it under Rules, keeping its tab and window. + expect(redirectOf("/alerts/0199a001-0000-7000-8000-000000000001?tab=history&timePreset=7d")).toEqual( + Option.some("/alerts/rules/0199a001-0000-7000-8000-000000000001?tab=history&timePreset=7d"), + ) + expect(routeRedirect(parse("/alerts/rules/0199a001-0000-7000-8000-000000000001"))).toEqual( + Option.none(), + ) + expect(routeRedirect(parse("/alerts?signal=anomaly"))).toEqual(Option.none()) + expect(routeRedirect(parse("/alerts/rules"))).toEqual(Option.none()) + }) + test("the old status page links under Alerts redirect to their own section", () => { expect(routeRedirect(parse("/alerts?tab=%22status-pages%22"))).toEqual( Option.some({ url: "/status-pages", isReplace: true }), ) - expect(routeRedirect(parse("/alerts?tab=%22uptime%22"))).toEqual(Option.none()) expect(routeRedirect(parse("/alerts/status-pages/stp_abc"))).toEqual( Option.some({ url: "/status-pages/stp_abc", isReplace: true }), ) diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 08e3c2da40..5b8d1b5536 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), @@ -114,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), @@ -284,6 +302,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 +545,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, @@ -528,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 d44acc4a11..3e0a98c389 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -389,6 +389,9 @@ export const AlertsScheduleNewSearch = Schema.Struct({ export const AlertsScheduleSearch = Schema.Struct({}) +/** `/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({ template: StringParam, @@ -520,8 +523,23 @@ export const AgentSessionsSearch = Schema.Struct({ sortDir: Schema.optional(AiSessionSortDir), }) -export const AlertsSearch = Schema.Struct({ - tab: StringParam, +/** The Alerts hub's sections past the Inbox, each at `/alerts/
`. */ +export const AlertsSection = Schema.Literals([ + "rules", + "uptime", + "slos", + "silences", + "maintenance", + "on-call", + "routing", +]) +export type AlertsSection = typeof AlertsSection.Type + +/** A hub section: the Inbox at `/alerts`, the rest at `/alerts/
`. */ +export const AlertsTab = Schema.Union([Schema.Literal("inbox"), AlertsSection]) +export type AlertsTab = typeof AlertsTab.Type + +const alertsHubSearchFields = { serviceName: StringParam, status: StringParam, /** The Inbox narrowed to one kind of incident: "anomaly" lists anomalies alone. */ @@ -530,7 +548,15 @@ export const AlertsSearch = Schema.Struct({ anomalyStatus: StringParam, /** The Silences tab narrowed to one maintenance window's occurrences (`mw_…`). */ window: StringParam, -}) + /** The open side panel (`SHEET_PARAM`): "silence" is the new-silence form. */ + sheet: StringParam, +} + +/** `/alerts` and `/alerts/
`: the filters every section shares. */ +export const AlertsHubSearch = Schema.Struct(alertsHubSearchFields) + +/** `/alerts`: a legacy `tab` redirects to its section's own path. */ +export const AlertsSearch = Schema.Struct({ tab: StringParam, ...alertsHubSearchFields }) export const AnalyticsSearch = Schema.Struct({ ...analyticsFilterSearchFields, diff --git a/apps/web/src/shell/alerts-inbox.ts b/apps/web/src/shell/alerts-inbox.ts new file mode 100644 index 0000000000..0860e5bd8f --- /dev/null +++ b/apps/web/src/shell/alerts-inbox.ts @@ -0,0 +1,23 @@ +import { Array as Arr, Stream } from "effect" +import { type OrgCollections, orgLive } from "../data/collections/org-collections" +import { liveQueryStream } from "../data/live-query" + +/** + * The Alerts › Inbox count: open incidents nobody has acknowledged, over the org's live + * `alert_incidents`. The alerts pages already read that collection, so reading it while one is open + * requests no other shape. + */ +export const alertsInboxCounts = ( + orgId: string, + toMessage: (count: number) => Message, +): Stream.Stream => + orgLive(orgId, (set) => + liveQueryStream( + (q) => q.from({ i: set.alertIncidents }), + (rows) => + toMessage( + Arr.filter(rows, (row) => row.status === "open" && (row.acknowledged_at ?? null) === null) + .length, + ), + ), + ) diff --git a/apps/web/src/shell/command.ts b/apps/web/src/shell/command.ts index 538bb36529..3e49bbf34e 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,48 @@ 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() + }), +}) + +/** `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), which `readShellFlags` reads at boot. */ 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..f9dbf7960e 100644 --- a/apps/web/src/shell/data.ts +++ b/apps/web/src/shell/data.ts @@ -7,6 +7,11 @@ 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" +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 @@ -78,3 +83,48 @@ 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), 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: { orgId: Schema.String, noSchedulesCheckedAtMs: Schema.NullOr(Schema.Number) }, + messages: [Message.SucceededLoadOnCall, Message.FailedLoadOnCall], + 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, + userId: schedule.on_call_now?.user_id ?? null, + endsAtMs: schedule.on_call_now === null ? null : isoMs(schedule.on_call_now.ends_at), + })), + }, + }) + }).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 61875459bf..ec32a46300 100644 --- a/apps/web/src/shell/flags.ts +++ b/apps/web/src/shell/flags.ts @@ -3,7 +3,9 @@ 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, + NO_SCHEDULES_STORAGE_PREFIX, NAMESPACE_STORAGE_PREFIX, ShellStorageError, THEME_STORAGE_KEY, @@ -61,6 +63,18 @@ 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), () => []) + +/** 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), +) + /** `SaveSidebarState`'s cookie: open unless the last toggle collapsed it (as Maple Local reads it). */ export const isSidebarOpenIn = (cookie: string): boolean => !Arr.contains(Str.split("; ")(cookie), "sidebar_state=false") @@ -76,6 +90,8 @@ export const readShellFlags: Effect.Effect = Effect.gen(function* () theme: readTheme(), 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 364fd9e910..bffa528e4e 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -9,12 +9,17 @@ export { Platform } from "./palette/shortcuts" export { favoritesChanges, filterViewportChanges, + onCallChanges, + onCallReadAt, + onCallTimer, + onCallWakes, sidebarShortcut, smallViewportChanges, viewportChanges, } 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 4a2c263971..9b8d333801 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,26 @@ 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, + 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: {}, 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 f501b6a745..1fd15184b5 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,20 @@ 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. */ + 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, @@ -135,6 +151,8 @@ export type ShellFlags = Readonly<{ theme: Theme pinnedNamespaces: Readonly> dashboardFavorites: ReadonlyArray + collapsedNavGroups?: ReadonlyArray + noScheduleOrgs?: Readonly> rememberedSurfaces?: Readonly>> isQuotaApproachingDismissed?: boolean platform?: Platform @@ -159,6 +177,13 @@ 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, + onCallNextReadAtMs: null, + onCallBackoffMs: 0, + noScheduleOrgs: flags.noScheduleOrgs ?? {}, namespaces: NamespacesData.Idle(), sources: SourcesData.Idle(), namespaceQuery: "", 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-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts new file mode 100644 index 0000000000..558131f7d7 --- /dev/null +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -0,0 +1,149 @@ +import { Option } from "effect" +import type { HtmlBuilder } from "foldkit/html" +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" +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("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()), + measured, + 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("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("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("/") }, + given(signedIn({ isSidebarOpen: false, collapsedNavGroups: ["analyze"] })), + measured, + expect(dashboards).toExist(), + expect(analyze).toBeAbsent(), + ) + }) + + 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/nav.ts b/apps/web/src/shell/nav.ts index 22ded041ce..11fbc2f667 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -34,6 +34,12 @@ export interface NavSubItem { }> /** The command palette's title, when the row's own reads ambiguously there. */ readonly paletteTitle?: string + /** Pages under another path that this row stands in for (schedules under On-call). */ + readonly alsoActive?: ReadonlyArray + /** A live count beside the title, read by the shell (the Inbox's unacknowledged incidents). */ + readonly count?: "alertsInbox" + /** Starts a group of rows: a small gap above it, no heading. */ + readonly startsGroup?: boolean } export interface NavItem { @@ -129,6 +135,54 @@ const exploreItem: NavItem = { ], } +const alertsItem: NavItem = { + title: "Alerts", + href: "/alerts", + icon: "BellIcon", + alsoActive: ["/anomalies"], + subItems: [ + { + title: "Inbox", + href: "/alerts", + icon: "BellIcon", + paletteTitle: "Alerts", + alsoActive: ["/anomalies"], + count: "alertsInbox", + }, + { + title: "Rules", + href: "/alerts/rules", + icon: "ChartLineIcon", + paletteTitle: "Alert rules", + alsoActive: ["/alerts/create"], + startsGroup: true, + }, + { title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" }, + { title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" }, + { title: "Silences", href: "/alerts/silences", icon: "BellSlashIcon", startsGroup: true }, + { + title: "Maintenance", + href: "/alerts/maintenance", + icon: "CalendarIcon", + paletteTitle: "Maintenance windows", + }, + { + title: "On-call", + href: "/alerts/on-call", + icon: "UsersIcon", + alsoActive: ["/alerts/schedules"], + startsGroup: true, + }, + { + title: "Routing", + href: "/alerts/routing", + icon: "BranchForkIcon", + paletteTitle: "Alert routing", + alsoActive: ["/alerts/escalation-chains"], + }, + ], +} + /** The org rollout flags that gate a nav row each. */ export type NavFlags = Readonly<{ releases: boolean @@ -174,7 +228,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 +251,9 @@ export const isPathActive = (currentPath: string, href: string): boolean => export const matchSubItem = (currentPath: string, sub: NavSubItem): string | undefined => Arr.reduce( - Arr.filter([sub.href, ...Arr.map(sub.views ?? [], (view) => view.href)], (href) => - isPathActive(currentPath, href), + Arr.filter( + [sub.href, ...Arr.map(sub.views ?? [], (view) => view.href), ...(sub.alsoActive ?? [])], + (href) => isPathActive(currentPath, href), ), undefined, (best, href) => (best === undefined || href.length > best.length ? href : best), @@ -209,6 +264,9 @@ export const isNavItemActive = (currentPath: string, item: NavItem): boolean => Arr.some(item.alsoActive ?? [], (href) => isPathActive(currentPath, href)) || Arr.some(item.subItems ?? [], (sub) => matchSubItem(currentPath, sub) !== undefined) +/** The Alerts row is open here, so its Inbox count is shown (and worth reading). */ +export const isAlertsOpenAt = (currentPath: string): boolean => isNavItemActive(currentPath, alertsItem) + export interface NavPreview { readonly glyphs: ReadonlyArray /** Marks past the cap, drawn as `+N`. */ diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts new file mode 100644 index 0000000000..c6e557e9e1 --- /dev/null +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -0,0 +1,327 @@ +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 { Message } from "./message" +import { init, type Model, OnCallData } from "./model" +import { MeasureSidebarOverflow } from "./mounts" +import { + isNoSchedulesFresh, + nextHandoffMs, + NO_SCHEDULES_TTL_MS, + 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: 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 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", + userId: "user_me", + endsAtMs: HANDOFF, + ...overrides, +}) + +const snapshotOf = (shifts: ReadonlyArray): OnCallSnapshot => ({ + userId: "user_me", + viewerTimeZone: "Europe/Berlin", + shifts, +}) + +const signedIn = (overrides: Partial = {}): Model => ({ + ...init(flags), + maybeOrgId: Option.some(ORG), + hasRequestedSidebarData: true, + ...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 viewAt = (currentPath: string) => { + const frame = withViewInputs(frameView, { + 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()) + +describe("on-call pill", () => { + 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(pill).toHaveText("On call until Mon 09:00"), + expect(pill).toHaveAttr("href", "/alerts/on-call"), + expect(pill).toHaveAttr("data-tooltip", "On call until Mon 09:00 CET, 16 Nov · Checkout primary"), + ) + }) + + 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(pill).toHaveAttr("href", "/alerts/on-call"), + 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("Not on call")).toBeAbsent(), + expect(text("On call until Mon 09:00")).toBeAbsent(), + ] + scene({ update, view }, given(withOnCall([])), measured, ...absent) + scene( + { update, view }, + given(signedIn({ onCall: OnCallData.Failure({ error: "Failed" }) })), + measured, + ...absent, + ) + scene( + { update, view }, + given(withOnCall([shift()], { onCallNowMs: HANDOFF + 1 })), + measured, + ...absent, + ) + }) + + 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( + { update, view }, + given(withOnCall([shift()], { isSidebarOpen: false })), + measured, + 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()), + 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( + "On call until Mon 09:00 CET, 16 Nov · Checkout primary", + ), + ) + }) +}) + +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") + }) + + 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 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 } }, + ]) + }) + + 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([]) + }) +}) + +describe("onCallStatusOf", () => { + 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: { short: "Mon 09:00", rest: "CET, 16 Nov" }, + }) + }) + + 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(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 new file mode 100644 index 0000000000..bdb87b1a88 --- /dev/null +++ b/apps/web/src/shell/oncall.ts @@ -0,0 +1,193 @@ +import { Array as Arr, Option, Order, Schema } from "effect" + +// 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, + userId: Schema.NullOr(Schema.String), + endsAtMs: Schema.NullOr(Schema.Number), +}) +export type OnCallShift = typeof OnCallShift.Type + +/** 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" + scheduleName: string + /** Other schedules the user also holds now. */ + otherSchedules: number + /** 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() + +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 ?? + 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 }, + ) + 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) + +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 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(live), { + onNone: (): OnCallStatus => (held.length > 0 ? { _tag: "Unknown" } : { _tag: "NotOnCall" }), + onSome: (shift) => ({ + _tag: "OnCall", + scheduleName: shift.scheduleName, + otherSchedules: live.length - 1, + until: + shift.endsAtMs === null ? null : formatUntil(shift.endsAtMs, snapshot.viewerTimeZone, nowMs), + }), + }) +} + +/** 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) +} + +// 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. 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 }> + +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 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} more` + +/** 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 status.until === null ? "On call" : `On call until ${status.until.short}` + case "NotOnCall": + 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/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index bc9e7aade1..c7444e9066 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", failedAtMs: 0 })), Command.resolve(LoadInfraSurfaces, surfacesAnswer("org_parity", [])), Command.expectExact(SaveInfraSurfaces({ orgId: "org_parity", surfaces: [] })), Command.resolve(SaveInfraSurfaces, Message.CompletedSaveInfraSurfaces()), @@ -424,6 +425,83 @@ describe("app update banner", () => { }) }) +describe("alerts section", () => { + const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) + + test("on an alerts page, Alerts opens its sections with the open one current", () => { + scene( + { update, view: shellAt("/alerts/rules") }, + given(signedIn()), + measured, + expect(role("link", { name: "Rules" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Rules" })).toHaveAttr("href", "/alerts/rules"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Silences" })).toHaveAttr("href", "/alerts/silences"), + expect(role("link", { name: "On-call" })).toHaveAttr("href", "/alerts/on-call"), + ) + scene( + { update, view: shellAt("/alerts") }, + given(signedIn()), + measured, + expect(role("link", { name: "Inbox" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Rules" })).not.toHaveAttr("aria-current", "page"), + ) + }) + + test("a detail page marks the section it belongs to", () => { + scene( + { update, view: shellAt("/alerts/schedules/sch_1") }, + given(signedIn()), + measured, + expect(role("link", { name: "On-call" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + ) + scene( + { update, view: shellAt("/alerts/uptime/mon_1") }, + given(signedIn()), + measured, + expect(role("link", { name: "Uptime" })).toHaveAttr("aria-current", "page"), + ) + scene( + { update, view: shellAt("/alerts/rules/0199a001-0000-7000-8000-000000000001") }, + given(signedIn()), + measured, + expect(role("link", { name: "Rules" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + ) + }) + + test("the Inbox counts unacknowledged incidents for this org", () => { + const counted = update(signedIn(), Message.UpdatedAlertsInboxCount({ orgId: "org_parity", count: 3 })) + scene( + { update, view: shellAt("/alerts/rules") }, + given(counted.model), + measured, + expect(text("3")).toExist(), + ) + // Nothing to act on shows no badge; another org's count is dropped, and an org change clears it. + scene( + { update, view: shellAt("/alerts/rules") }, + given(signedIn({ alertsInboxCount: 0 })), + measured, + expect(text("0")).toBeAbsent(), + ) + const stray = update(signedIn(), Message.UpdatedAlertsInboxCount({ orgId: "org_other", count: 4 })) + expectVitest(stray.model.alertsInboxCount).toBeNull() + expectVitest(enter("org_other")(counted.model).model.alertsInboxCount).toBeNull() + }) + + test("elsewhere the sections stay hidden", () => { + scene( + { update, view: shellAt("/logs") }, + given(signedIn()), + measured, + expect(role("link", { name: "Alerts" })).toExist(), + expect(role("link", { name: "Silences" })).toBeAbsent(), + ) + }) +}) + describe("services section", () => { test("on the service map, Services opens with the map row current", () => { scene( diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index 480f26a23c..210f70f782 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -1,8 +1,10 @@ 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 { Dom } from "foldkit" import { DASHBOARD_FAVORITES_EVENT, readDashboardFavorites } from "./favorites" import { Message } from "./message" +import type { Model } from "./model" +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)" @@ -82,3 +84,44 @@ 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; `null` while one is in flight or none is due. */ +export const onCallReadAt = (model: Model): number | null => model.onCallNextReadAtMs + +/** 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))) + return Message.ElapsedOnCallTimer({ nowMs: yield* Clock.currentTimeMillis }) + }), + ) + +/** The tab came back into view or focus (a handoff may have passed while it slept). */ +// 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 })), +) + +/** A page wrote a schedule or an override. */ +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(), + }) +}) diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 66acbbe8eb..c436e5cc0b 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -11,12 +11,14 @@ import { FocusSiblingMenuItem, FocusShortcutSearch, LogOut, + SaveCollapsedNavGroups, + SaveNoSchedules, 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" @@ -26,6 +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 { + isNoSchedulesFresh, + ONCALL_WAKE_MIN_MS, + type OnCallSnapshot, + scheduleAfterAnswer, + scheduleAfterFailure, +} from "./oncall" import { DashboardsData, InfraPresenceData, @@ -34,6 +43,7 @@ import { initRightSheet, type Model, NamespacesData, + OnCallData, shellProbe, SourcesData, } from "./model" @@ -61,6 +71,10 @@ export const enter = infraPresence: () => InfraPresenceData.Idle(), integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), + alertsInboxCount: () => null, + onCall: () => OnCallData.Idle(), + onCallNextReadAtMs: () => null, + onCallBackoffMs: () => 0, namespaces: () => NamespacesData.Idle(), sources: () => SourcesData.Idle(), }) @@ -84,12 +98,120 @@ 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 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, 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. + * 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 => + 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) + ? Array.filter(model.collapsedNavGroups, (id) => id !== groupId) + : [...model.collapsedNavGroups, groupId] + return { + model: modifyFields(model, { collapsedNavGroups: () => groups }), + commands: [SaveCollapsedNavGroups({ groups })], } } @@ -442,6 +564,10 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => modifyFields(current, { sources: () => SourcesData.Failure({ error: "Failed" }) }), ), // A result for an org the shell has left is dropped. + UpdatedAlertsInboxCount: ({ orgId, count }) => + Option.contains(model.maybeOrgId, orgId) + ? { model: modifyFields(model, { alertsInboxCount: () => count }) } + : { model }, SucceededLoadDashboards: ({ orgId, dashboards }) => Option.contains(model.maybeOrgId, orgId) ? { @@ -461,6 +587,14 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => }), } : { 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 }) => 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..faf6de6fef 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" @@ -8,7 +9,9 @@ import { sidebarGroup, sidebarGroupContent, sidebarGroupLabel, + sidebarGroupToggle, sidebarMenu, + sidebarMenuBadge, sidebarMenuButton, sidebarMenuItem, sidebarMenuSub, @@ -95,17 +98,32 @@ const collapsedSectionRow = (h: HtmlBuilder, model: Model, item: NavIte ), ]) -const subList = (h: HtmlBuilder, subItems: ReadonlyArray, currentPath: string): Html => { +/** The live count a sub-item shows, when it has one and it is above zero (an org change clears it). */ +const subCountOf = (model: Model, sub: NavSubItem): number | undefined => + sub.count === "alertsInbox" && model.alertsInboxCount !== null && model.alertsInboxCount > 0 + ? model.alertsInboxCount + : undefined + +const subList = ( + h: HtmlBuilder, + model: Model, + subItems: ReadonlyArray, + currentPath: string, +): Html => { const activeSubHref = activeSubHrefOf(subItems, currentPath) return sidebarMenuSub( h, "mx-0 ms-4 translate-x-0 gap-0 border-l-0 px-0 py-0", - Arr.map(subItems, (sub) => - sidebarMenuSubItem( + Arr.map(subItems, (sub, index) => { + const count = subCountOf(model, sub) + return sidebarMenuSubItem( h, - sub.href === activeSubHref - ? "border-sidebar-primary border-l-2 ps-2.5" - : "border-sidebar-border border-l-2 ps-2.5", + cn( + "border-l-2 ps-2.5", + sub.href === activeSubHref ? "border-sidebar-primary" : "border-sidebar-border", + // A 4px gap, not a rule, sets a group of rows apart. + sub.startsGroup === true && index > 0 && "mt-1", + ), [ sidebarMenuSubButton( h, @@ -127,19 +145,38 @@ const subList = (h: HtmlBuilder, subItems: ReadonlyArray, c h.span([], [sub.title]), ], ), + ...(count === undefined + ? [] + : [ + sidebarMenuBadge( + h, + [ + typography( + h, + { size: "xs", mono: true, numeric: true, tone: "error" }, + [String(count)], + ), + ], + "top-1/2 -translate-y-1/2", + ), + ]), ], - ), - ), + ) + }), ) } -/** `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 @@ -149,7 +186,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( @@ -157,6 +196,7 @@ const navRow = ( { rail: isOpen ? "lane" : "active", isActive, + hasActiveChild, link: { href: item.href, currentPath }, hasTooltip: true, attributes: tip(item.title), @@ -198,7 +238,9 @@ const navRow = ( ], ), ...(item.badge === undefined ? [] : [navBadge(h, item.badge)]), - ...(subItems !== undefined && isActive ? [subList(h, subItems, currentPath)] : []), + ...(subItems !== undefined && isActive && !isGroupFolded + ? [subList(h, model, subItems, currentPath)] + : []), ]) } @@ -276,6 +318,20 @@ export const pinnedGroup = ( ] } +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 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" + if (!model.isSidebarOpen && !model.isMobile) return "rail" + 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 +339,40 @@ 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) + const rows = + fold === "collapsed" + ? Arr.filter(group.items, (item) => isNavItemActive(currentPath, item)) + : group.items + return sidebarGroup(h, undefined, [ + ...(group.label === undefined + ? [] + : 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, + rows.length === 0 + ? [] + : [ + sidebarMenu( + h, + undefined, + 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 1bf4a398ec..12f1b99b71 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -25,6 +25,10 @@ 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 { tooltipTrigger } from "@maple/views/ui/tooltip-host" +import { oncallTabHref } from "../page/alerts/shared/oncall-schedules" +import { isPillShown, onCallDetail, onCallLabel, type OnCallStatus, onCallStatusOf } from "./oncall" export const GROUP_LABEL = "h-6 text-muted-foreground" @@ -244,6 +248,67 @@ 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: (snapshot) => onCallStatusOf(snapshot, model.onCallNowMs), + }) + +/** + * 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, + status: OnCallStatus, + tip: RailTip, + isRail: boolean, +): 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", + // 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. + ...(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 = ( h: HtmlBuilder, @@ -339,6 +404,8 @@ export const shellSidebarFooter = ( [], ), sidebarMenu(h, undefined, [ + // A row of the footer's list, so it keeps the list's 4px rhythm. + ...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/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index af4ad7e522..74ecf10a8c 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 { 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" @@ -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)), + ...(isPillShown(onCall) ? [onCallDetail(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..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) }, @@ -215,6 +250,35 @@ const root = Subscription.make()((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.onCallReadAt(model.shell) : null, + }), + dependenciesToStream: ({ atMs }) => + atMs === null + ? Stream.empty + : 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`). */ shellDashboardFavorites: entry( {}, diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 754840a01d..27a543acf9 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -39,13 +39,15 @@ 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", "/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", diff --git a/bun.lock b/bun.lock index 5006b9d3e7..9f2f5b2ab0 100644 --- a/bun.lock +++ b/bun.lock @@ -322,6 +322,7 @@ "@fontsource-variable/geist-mono": "^5.3.0", "@maple-dev/effect-sdk": "workspace:*", "@maple/agent-sessions": "workspace:*", + "@maple/alerting-core": "workspace:*", "@maple/chat-platform": "workspace:*", "@maple/domain": "workspace:*", "@maple/effect-db": "workspace:*", diff --git a/docs/design-rules.md b/docs/design-rules.md index 0ae8ecd8ea..2ae063d8ae 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`). @@ -97,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/alerting-core/package.json b/packages/alerting-core/package.json index 2069779090..a14c20056e 100644 --- a/packages/alerting-core/package.json +++ b/packages/alerting-core/package.json @@ -5,7 +5,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./hysteresis": "./src/hysteresis.ts" + "./hysteresis": "./src/hysteresis.ts", + "./oncall": "./src/oncall.ts" }, "scripts": { "test": "vitest run", diff --git a/packages/query-engine/src/query-builder/model.ts b/packages/query-engine/src/query-builder/model.ts index 79c7bea8de..a8d24c93f3 100644 --- a/packages/query-engine/src/query-builder/model.ts +++ b/packages/query-engine/src/query-builder/model.ts @@ -10,6 +10,8 @@ import { } from "@maple/domain/where-clause" import { Match, Array as Arr, Record as Rec, String as Str, Option, Result } from "effect" +export * from "./where-key-rules" + export type { QueryBuilderDataSource, QueryBuilderMetricType, diff --git a/packages/query-engine/src/query-builder/where-key-rules.test.ts b/packages/query-engine/src/query-builder/where-key-rules.test.ts new file mode 100644 index 0000000000..62a8458652 --- /dev/null +++ b/packages/query-engine/src/query-builder/where-key-rules.test.ts @@ -0,0 +1,112 @@ +import type { QueryBuilderQueryDraftPayload } from "@maple/domain/http" +import type { QueryBuilderDataSource } from "@maple/query-model" +import { describe, expect, it } from "vitest" +import { buildTimeseriesQuerySpec, WHERE_DIMENSIONS, WHERE_OPERATORS, whereOperatorsOf } from "./model" +import { Array as Arr, Option, Record as Rec } from "effect" + +// The key rules against the clause handlers: every allowlisted operator is applied without a +// warning, and every other operator on a dimension warns, so the table cannot drift. + +const draft = (dataSource: QueryBuilderDataSource, whereClause: string): QueryBuilderQueryDraftPayload => ({ + id: "q1", + name: "A", + enabled: true, + hidden: false, + dataSource, + aggregation: dataSource === "metrics" ? "avg" : "count", + whereClause, + stepInterval: "", + orderByDirection: "desc", + addOns: { groupBy: false, having: false, orderBy: false, limit: false, legend: false }, + groupBy: [], + having: "", + orderBy: "", + limit: "", + legend: "", + ...(dataSource === "metrics" + ? { + metricName: "system.cpu.utilization", + metricType: "gauge", + isMonotonic: false, + signalSource: "default", + } + : undefined), +}) + +/** A value each key's handler reads as valid, so only the operator decides. */ +const VALID_VALUES: ReadonlyArray = [ + ["root_only", "true"], + ["has_error", "true"], + ["min_duration_ms", "5"], + ["max_duration_ms", "5"], + ["metric.type", "gauge"], + ["visitor.type", "new"], + ["visitor_type", "new"], +] + +const validValueOf = (key: string): string => + Option.match( + Arr.findFirst(VALID_VALUES, ([candidate]) => candidate === key), + { onNone: () => "x", onSome: ([, value]) => value }, + ) + +const clauseOf = (key: string, operator: string): string => + operator === "exists" || operator === "!exists" + ? `${key} ${operator}` + : `${key} ${operator} "${validValueOf(key)}"` + +const warningsOf = (dataSource: QueryBuilderDataSource, whereClause: string) => + buildTimeseriesQuerySpec(draft(dataSource, whereClause)).warnings + +const sources: ReadonlyArray = ["traces", "logs", "metrics", "product_events"] + +describe("where key rules", () => { + const dimensionCases = Arr.flatMap(sources, (dataSource) => + Arr.flatMap(Rec.keys(WHERE_DIMENSIONS[dataSource]), (key) => + Arr.map(WHERE_OPERATORS, (operator) => ({ + dataSource, + key, + operator, + isAllowed: whereOperatorsOf(dataSource, key).includes(operator), + })), + ), + ) + + it.each(dimensionCases)( + "$dataSource $key $operator: allowed=$isAllowed", + ({ dataSource, key, operator, isAllowed }) => { + expect(warningsOf(dataSource, clauseOf(key, operator)).length === 0).toBe(isAllowed) + }, + ) + + const attributeCases = Arr.flatMap(sources, (dataSource) => + Arr.map(WHERE_OPERATORS, (operator) => ({ dataSource, operator })), + ) + + it.each(attributeCases)("$dataSource attr.* takes $operator", ({ dataSource, operator }) => { + expect(whereOperatorsOf(dataSource, "attr.http.route")).toContain(operator) + expect(warningsOf(dataSource, clauseOf("attr.http.route", operator))).toEqual([]) + }) + + const resourceSources: ReadonlyArray = ["traces", "logs", "metrics"] + const resourceCases = Arr.flatMap(resourceSources, (dataSource) => + Arr.map(WHERE_OPERATORS, (operator) => ({ dataSource, operator })), + ) + + it.each(resourceCases)("$dataSource resource.* takes $operator", ({ dataSource, operator }) => { + expect(whereOperatorsOf(dataSource, "resource.host.name")).toContain(operator) + expect(warningsOf(dataSource, clauseOf("resource.host.name", operator))).toEqual([]) + }) + + it("product events offer no operator on resource.*, which they would read as a prop of that name", () => { + expect(whereOperatorsOf("product_events", "resource.host.name")).toEqual([]) + }) + + it("a bare unknown key is an attribute on traces and dropped on logs and metrics", () => { + expect(whereOperatorsOf("traces", "db.system")).toEqual(WHERE_OPERATORS) + expect(whereOperatorsOf("logs", "db.system")).toEqual([]) + expect(warningsOf("logs", 'db.system = "x"').length).toBeGreaterThan(0) + expect(whereOperatorsOf("metrics", "db.system")).toEqual([]) + expect(warningsOf("metrics", 'db.system = "x"').length).toBeGreaterThan(0) + }) +}) diff --git a/packages/query-engine/src/query-builder/where-key-rules.ts b/packages/query-engine/src/query-builder/where-key-rules.ts new file mode 100644 index 0000000000..bc8050daf8 --- /dev/null +++ b/packages/query-engine/src/query-builder/where-key-rules.ts @@ -0,0 +1,139 @@ +import { normalizeKey, type Operator } from "@maple/domain/where-clause" +import type { QueryBuilderDataSource } from "@maple/query-model" +import { Option, Record as Rec, String as Str } from "effect" + +// Which where-clause operators each source's clause handler (`apply*Clause` in `model.ts`) +// accepts per key. Editors read it so they never offer a clause the query drops with a +// warning; `where-key-rules.test.ts` holds it to the handlers in both directions. + +export const WHERE_OPERATORS: ReadonlyArray = [ + "=", + "!=", + ">", + ">=", + "<", + "<=", + "contains", + "!contains", + "exists", + "!exists", +] + +/** + * A key's rule: a column-backed `Dimension` takes its listed operators; an `Attribute` + * (a map key) takes every operator; an `Unsupported` key is dropped by the source. + */ +export type WhereKeyRule = + | Readonly<{ _tag: "Dimension"; operators: ReadonlyArray }> + | Readonly<{ _tag: "Attribute" }> + | Readonly<{ _tag: "Unsupported" }> + +const EQUALS: ReadonlyArray = ["="] +const EQUALS_OR_NOT: ReadonlyArray = ["=", "!="] +const TRACES_DIMENSION: ReadonlyArray = ["=", "!=", "contains"] +const LOGS_BODY: ReadonlyArray = ["=", "contains"] + +const PRODUCT_EVENT_LIST_KEYS = [ + "event.name", + "event", + "event_name", + "event.kind", + "kind", + "source", + "host", + "page.path", + "page_path", + "path", + "service.name", + "service", +] + +const PRODUCT_EVENT_EQUALS_KEYS = [ + "user.id", + "group.id", + "referrer.host", + "referrer_host", + "referrer", + "country", + "device.type", + "device_type", + "device", + "browser", + "browser.name", + "os", + "os.name", + "language", + "utm.source", + "utm_source", + "utm.medium", + "utm_medium", + "utm.campaign", + "utm_campaign", + "visitor.type", + "visitor_type", +] + +/** Column-backed keys by source, after `normalizeKey`. */ +export const WHERE_DIMENSIONS = { + traces: { + "service.name": TRACES_DIMENSION, + "span.name": TRACES_DIMENSION, + "deployment.environment": TRACES_DIMENSION, + "vcs.ref.head.revision": EQUALS_OR_NOT, + root_only: EQUALS, + has_error: EQUALS, + min_duration_ms: EQUALS, + max_duration_ms: EQUALS, + }, + logs: { + "service.name": EQUALS_OR_NOT, + severity: EQUALS_OR_NOT, + body: LOGS_BODY, + message: LOGS_BODY, + search: LOGS_BODY, + }, + metrics: { + "service.name": EQUALS, + "deployment.environment": EQUALS, + "metric.type": EQUALS, + }, + product_events: { + ...Rec.fromIterableWith(PRODUCT_EVENT_LIST_KEYS, (key) => [key, EQUALS_OR_NOT]), + ...Rec.fromIterableWith(PRODUCT_EVENT_EQUALS_KEYS, (key) => [key, EQUALS]), + }, +} satisfies Record>> + +/** One source's dimensions, read by any key. */ +const dimensionsOf = ( + dataSource: QueryBuilderDataSource, +): Readonly>> => WHERE_DIMENSIONS[dataSource] + +/** The rule for a key as written (aliases resolve), on one source. */ +export const whereKeyRule = (dataSource: QueryBuilderDataSource, key: string): WhereKeyRule => { + const canonical = normalizeKey(key) + if (Str.startsWith("attr.")(canonical)) return { _tag: "Attribute" } + // Product events have no resource attributes: their handler would read the key as a prop of that literal name. + if (Str.startsWith("resource.")(canonical)) + return dataSource === "product_events" ? { _tag: "Unsupported" } : { _tag: "Attribute" } + return Option.match(Rec.get(dimensionsOf(dataSource), canonical), { + onSome: (operators): WhereKeyRule => ({ _tag: "Dimension", operators }), + // Traces read a bare key as a span attribute and product events as a `track()` prop. + onNone: (): WhereKeyRule => + dataSource === "traces" || dataSource === "product_events" + ? { _tag: "Attribute" } + : { _tag: "Unsupported" }, + }) +} + +/** The operators a key takes on a source: none when the source drops it. */ +export const whereOperatorsOf = ( + dataSource: QueryBuilderDataSource, + key: string, +): ReadonlyArray => { + const rule = whereKeyRule(dataSource, key) + return rule._tag === "Dimension" ? rule.operators : rule._tag === "Attribute" ? WHERE_OPERATORS : [] +} + +/** Whether `(k = a OR k = b)` on this key is lowered: attribute filters only, on sources that lower groups. */ +export const whereKeyMatchesAnyOf = (dataSource: QueryBuilderDataSource, key: string): boolean => + dataSource !== "product_events" && whereKeyRule(dataSource, key)._tag === "Attribute" 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/ui/src/components/ui/sheet.styles.ts b/packages/ui/src/components/ui/sheet.styles.ts index 7bcec0b55c..c4c8bf9a47 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" @@ -27,12 +31,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/ui/src/components/ui/sidebar.styles.ts b/packages/ui/src/components/ui/sidebar.styles.ts index d8f10547f7..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-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-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,8 +14,10 @@ export const sidebarMenuButtonVariants = cva( sm: "h-7 text-xs", }, variant: { + // 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-sidebar-accent active:bg-sidebar-accent 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", }, @@ -121,7 +123,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-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 diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 580c9a01f6..d6d4db9b5f 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`. @@ -825,3 +830,12 @@ @utility loading-delay { animation: loading-delay 0s 300ms both; } + +/* A hatch for something missing (an on-call gap): the error tone's lines over no fill. */ +@utility hatch-error { + background-image: repeating-linear-gradient( + 135deg, + color-mix(in oklab, var(--severity-error) 32%, transparent) 0 1px, + transparent 1px 6px + ); +} diff --git a/packages/views/src/charts/definitions/alert-rule.ts b/packages/views/src/charts/definitions/alert-rule.ts index 623ee6599b..97aacc0225 100644 --- a/packages/views/src/charts/definitions/alert-rule.ts +++ b/packages/views/src/charts/definitions/alert-rule.ts @@ -23,7 +23,8 @@ type AlertRuleSpec = Extract type SignalPoint = Record & { t: number; at: Date } type Band = Readonly<{ x1: number; x2: number }> -const PLOT_RIGHT = 12 +// Room for half of the last tick's label, which sits on the plot's right edge. +const PLOT_RIGHT = 28 const Y_AXIS_WIDTH = 72 const isSignalType = Schema.is(AlertSignalType) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index f01bff1962..3b52f948dc 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -35,8 +35,9 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/collapsible.ts` `collapsible`, `collapsibleTrigger`: `Collapsible` whose open state lives in the owner's Model: the trigger button and, while open, its panel (closed, Base UI renders no panel). - `packages/views/src/ui/color-input.ts` `colorInput`: `ColorInput`: a native colour picker drawn as a small bordered swatch, reporting the picked hex colour. - `packages/views/src/ui/column.ts` `columnClass`, `column`: `Column`: a cell's width in a row of flex cells (a div table's head and rows): a flexible column that takes the free width over a floor (`min`), or a fixed lane (`width`). +- `packages/views/src/ui/combobox-picker.ts` `triggerIdOf`, `comboboxPicker`: `ComboboxPicker`: a button showing the current pick that opens a searchable list, the search input on top (a `` with ``). - `packages/views/src/ui/combobox-view.ts` `dismissButton`, `inputAttributes`, `chipsInput`, `hiddenInput`, `comboboxCheckIcon`, `listPopup`, `chipsView`, `inputField`, `view`: Base UI's visually hidden "Dismiss" button for screen readers, beside the input and the list. -- `packages/views/src/ui/combobox.ts` `inputId`, `listId`, `itemId`, `filterItems`, `queryOf`, `visibleItems` (stateful: Model, init, update): Base UI `Combobox` (multiple, with chips) and `Autocomplete` (`@maple/ui/combobox`, `@maple/ui/autocomplete`): an input that filters a listbox. +- `packages/views/src/ui/combobox.ts` `inputId`, `listId`, `itemId`, `filterItems`, `queryOf`, `visibleItems`, `focusLeaves` (stateful: Model, init, update): Base UI `Combobox` (multiple, with chips) and `Autocomplete` (`@maple/ui/combobox`, `@maple/ui/autocomplete`): an input that filters a listbox. - `packages/views/src/ui/command.ts` `commandTitles`, `commandInput`, `commandList`, `commandGroup`, `commandFooter`, `commandHiddenInput`, `commandOption`, `commandListbox`, `commandEmpty`: `@maple/ui/command`: the command palette's body, an inline Autocomplete (always open, first match highlighted) on the Combobox Model, inside a `command-dialog` Dialog. - `packages/views/src/ui/compact-search-input.ts` `compactSearchInput`: `CompactSearchInput`: a 24px mono filter field (magnifier, search input, clear button) for a panel toolbar. - `packages/views/src/ui/confirm-dialog.ts` `confirmDialog`: Port of `ConfirmDialog` (packages/ui/src/components/ui/confirm-dialog.tsx) over the alert-dialog Submodel. @@ -117,7 +118,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/overlay/anchored.ts` `outsidePresses`, `anchoredPopup`, `strayEscapes`: The Mount body every anchored popup shares (popover, hover card, menus, select, combobox): portal to ``, Base UI's outside marking, optional scroll lock and focus moves, t... - `packages/views/src/ui/overlay/auto-resize.ts` `fitPositionerToPopup`: Base UI's `usePopupAutoResize`: the popup is measured static inside a `max-content` positioner (a long label near the viewport edge must not wrap), the positioner takes that fra... - `packages/views/src/ui/overlay/popup-state.ts` `popupId`, `triggerId`, `portalId`, `initPopup`, `makeTransition`: Open state shared by every anchored popup's Model: open flag, Base UI transition status and the side and alignment the positioner rendered on (after any flip). -- `packages/views/src/ui/overlay/portal.ts` `portalToBody`, `markOutside`, `lockBodyScroll`, `containEscape`, `tabbables`, `trapFocus`, `manageFocus`: DOM side of Base UI's floating layer, used by overlay Mounts: the portal to ``, marking what lies outside a popup, the scroll lock, and moving focus in and back out. +- `packages/views/src/ui/overlay/portal.ts` `portalToBody`, `markOutside`, `lockBodyScroll`, `containEscape`, `keepFocusOnPress`, `tabbables`, `trapFocus`, `manageFocus`: DOM side of Base UI's floating layer, used by overlay Mounts: the portal to ``, marking what lies outside a popup, the scroll lock, and moving focus in and back out. - `packages/views/src/ui/overlay/position.ts` `positionDefaults`, `onScreenOr`, `anchorPosition`: Base UI's `useAnchorPositioning` (internals/useAnchorPositioning.mjs, @base-ui/react 1.8) on `@floating-ui/dom`: the same middleware order, collision padding bias, CSS variables... - `packages/views/src/ui/overlay/roving.ts` `isRovingKey`, `nextIndex`: Base UI's list navigation for menus and listboxes: arrows move the highlight and wrap (`loop`), Home/End jump, disabled entries are skipped. - `packages/views/src/ui/overlay/select-align.ts` `alignItemWithTrigger`: Base UI Select's `alignItemWithTrigger` (select/popup/SelectPopup.mjs, @base-ui/react 1.8): the popup opens over the trigger so the selected item's text sits on the trigger's va... @@ -142,10 +143,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`, `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`, `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. @@ -181,6 +182,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). @@ -191,7 +193,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/day-label.ts` `dayKey`, `dayLabel`: Calendar days in a time zone, for lists grouped by day ("Today", "Yesterday", "Mon 2 Nov"). - `apps/web/src/page/kit/destination-picker.ts` `destinationPicker`, `pickedDestination`: `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add destination" menu of the rest; the owner keeps the menu's Model and the chosen ids. - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: `DetailHeader`'s title side and `DetailHeaderSkeleton`. -- `apps/web/src/page/kit/form-section.ts` `formSection`, `formScrollArea`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. +- `apps/web/src/page/kit/form-section.ts` `formSection`, `formScrollArea`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack in a card under 36rem wide (a phone,... - `apps/web/src/page/kit/grid-list-row.ts` `gridListRow`: `GridListRow`: one `
  • ` of a bordered grid list (the alerts inbox, rules and resolved lists), padded, tinted on hover and press, positioned so a stretched row link covers it. - `apps/web/src/page/kit/guided-setup.ts` `keyFor`, `setupBlocks`, `serverKey`, `promptKey`, `frameworkIconStrip`, `frameworkPicker`, `setupAgentPrompt`, `agentPromptButton`, `connectInstructions`: `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. - `apps/web/src/page/kit/infra-correlation/command.ts` `operationOf`: The four `*InfraTimeseries` reads the panel's charts make, through the read cache under the atom key (`requestKey(org, "", { data })`), so a reopened panel reuses them. @@ -207,15 +209,21 @@ 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. +- `apps/web/src/page/kit/query-builder/condition-builder-view.ts` `conditionBuilder`: `ConditionBuilder`: the where clause as `[field] [operator] [values]` rows, AND-ed, each picked from the query's own vocabulary. +- `apps/web/src/page/kit/query-builder/condition-fields.ts` `groupLabelOf`, `fieldOf`, `canMatchAnyOf`, `isOwnedElsewhere`, `isPickable`, `fieldsOf`, `sharesLabel`, `fieldPickerItems`, `operatorsOf`, `valueSuggestionsOf`: The condition builder's vocabulary: which fields a query offers (grouped like a path picker), the operators a field takes, and the values it suggests, all read from the where ed... +- `apps/web/src/page/kit/query-builder/condition-rows.ts` `isAnyOf`, `isNoneOf`, `isValueless`, `unshowableReasonOf`, `parseConditionRows`, `sameConditions`, `isUnspellable`, `isComplete`, `printConditionRows`, `printConditionRowsOver`, `nextRowId`: The condition builder's rows and their two-way mapping to the where-clause string (`@maple/domain/where-clause`): rows are AND-ed; a row's values are OR-ed for `=`/`contains` an... +- `apps/web/src/page/kit/query-builder/conditions.ts` `conditionPickerId`, `operatorSelectId`, `isOperatorSelect`, `conditionRowsOf`, `withoutBuilderState`, `whereModeOf`, `typedInTextEditor`, `changedWhereMode`, `addedCondition`, `removedCondition`, `fieldStep`, `holdsManyValues`, `valuePickerOf`, `valuesStep`, `operatorStep`: The condition builder's steps: an edit that changes the conditions leaves as the query's where-clause edit, rewriting only the clauses it touched, so previews and saving see onl... - `apps/web/src/page/kit/query-builder/draft.ts` `groupBySelectionOf`: `QueryBuilderQueryDraft` (@maple/query-engine/query-builder) as a Schema: the query one `QueryPanel` edits, shared by the widget editor and the alert rule editor. - `apps/web/src/page/kit/query-builder/metric-options.ts` `metricValueOf`, `listMetricOptions`, `readMetricKeys`, `unaskedMetrics`, `pickedMetric`: The metric picker's reads, shared by every host of a metrics `QueryPanel`: one page of the org's metrics for what was searched, and each picked metric's own keys. - `apps/web/src/page/kit/query-builder/metrics-body.ts` `groupByChoicesOf`, `metricsBody`: `MetricsBody`: the metric picker, the where clause, and the two aggregation stages: each series reduced within the interval, then the series combined by any number of fields. -- `apps/web/src/page/kit/query-builder/panel.ts` `metricPickerIdOf` (stateful: Model, init, update): Where-editor Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. -- `apps/web/src/page/kit/query-builder/parts.ts` `panelSelect`, `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. -- `apps/web/src/page/kit/query-builder/view.ts` `queryPanelView`: `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. +- `apps/web/src/page/kit/query-builder/panel-select.ts` `panelSelect`: One of the panel's selects, kept by id; its value is always the draft's. +- `apps/web/src/page/kit/query-builder/panel.ts` `metricPickerIdOf` (stateful: Model, init, update): Where-editor and condition-builder Messages need `inputs` (the drafts and autocomplete values); without them they are ignored. +- `apps/web/src/page/kit/query-builder/parts.ts` `whereClauseEditor`, `textInput`, `addOnToggles`, `addOnSections`: The pieces of a `QueryPanel` body: the where clause, the add-on toggles and their sections. +- `apps/web/src/page/kit/query-builder/view.ts` `panelWarningsOf`, `queryPanelView`: `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. - `apps/web/src/page/kit/query-builder/where-editor.ts` `whereEditor`, `highlightOverlay`: The suggestions of a field kept in the owner's `WhereFields` (`page/kit/where-field.ts`). - `apps/web/src/page/kit/query-builder/where.ts` `whereTextareaId`, `whereSpecOf`, `whereStep`: `WhereClauseEditor` inside a `QueryPanel`: one `WhereFields` field per query, keyed by query id. - `apps/web/src/page/kit/query-builder-legend.ts` `queryBuilderLegend`: `QueryBuilderLegend` (packages/ui/src/components/charts/_shared/query-builder-legend.tsx), bottom layout, for hosts outside a chart. @@ -225,9 +233,11 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/resource-not-found.ts` `resourceNotFound` - `apps/web/src/page/kit/route-window.ts` `isWindowChange`, `snapModeOf`, `routeWindowStep`: `useEffectiveTimeRange` + `useRefreshableAtomValue` for a page's `routeChanged`: the window is re-resolved only when its own fields moved, and only Reload revalidates unchanged... - `apps/web/src/page/kit/section-heading.ts` `sectionHeading`: `SectionHeading`: the `title` row and the `eyebrow` overline. +- `apps/web/src/page/kit/sentence-card.ts` `sentenceCardHeadingId`, `sentenceCardSummaryId`, `sentenceCard`, `sentenceChain`, `sentenceRow`, `sentencePhrase`, `sentenceClause`, `sentenceSection`: Sentence cards: a form that reads top to bottom as one sentence ("When ... - `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`, `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. @@ -236,7 +246,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/card-button.ts b/packages/views/src/ui/card-button.ts index f31e4ccbf9..0aa2d9a479 100644 --- a/packages/views/src/ui/card-button.ts +++ b/packages/views/src/ui/card-button.ts @@ -8,12 +8,15 @@ import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" const BASE = "group rounded-md border bg-card text-left hover:border-border-strong active:border-border-strong hover:bg-surface-sunken active:bg-surface-sunken focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-64 disabled:hover:border-border disabled:active:border-border disabled:hover:bg-card disabled:active:bg-card" -const SELECTED = "border-primary-a4 bg-primary-a1 hover:border-primary-a4 active:border-primary-a4 hover:bg-primary-a2 active:bg-primary-a2" +const SELECTED = + "border-primary-a4 bg-primary-a1 hover:border-primary-a4 active:border-primary-a4 hover:bg-primary-a2 active:bg-primary-a2" export type CardButtonOptions = Readonly<{ onClick: M isSelected?: boolean isDisabled?: boolean + /** `aria-label`, when the card's text alone does not name the action. */ + label?: string className?: string attributes?: ReadonlyArray | ChildAttribute> }> @@ -35,6 +38,7 @@ export const cardButton = ( ? [] : [h.AriaPressed(options.isSelected ? "true" : "false")]), ...((options.isDisabled ?? false) ? [h.Disabled(true)] : []), + ...(options.label === undefined ? [] : [h.AriaLabel(options.label)]), ...(options.attributes ?? []), ], children, diff --git a/packages/views/src/ui/combobox-focus.test.ts b/packages/views/src/ui/combobox-focus.test.ts new file mode 100644 index 0000000000..ff908ec88b --- /dev/null +++ b/packages/views/src/ui/combobox-focus.test.ts @@ -0,0 +1,60 @@ +import { Effect, Fiber, Stream } from "effect" +import { afterEach, beforeEach, describe, expect, test } from "vitest" +import { focusLeaves } from "./combobox" + +/** An element stand-in: `closest` answers whether it sits in the field (the anchor). */ +class FakeElement extends EventTarget { + constructor(private readonly isInField: boolean) { + super() + } + closest(): FakeElement | null { + return this.isInField ? this : null + } +} + +const original = globalThis.document +const originalElement = globalThis.Element +beforeEach(() => { + Object.defineProperty(globalThis, "document", { value: new EventTarget(), configurable: true }) + Object.defineProperty(globalThis, "Element", { value: FakeElement, configurable: true }) +}) +afterEach(() => { + Object.defineProperty(globalThis, "document", { value: original, configurable: true }) + Object.defineProperty(globalThis, "Element", { value: originalElement, configurable: true }) +}) + +/** How many times the list is told to close while focus lands on each of `targets` in turn. */ +const closesFor = (inList: FakeElement, targets: ReadonlyArray) => + Effect.runPromise( + Effect.gen(function* () { + const seen: Array = [] + const root = { contains: (node: unknown) => node === inList } + const fiber = yield* Effect.forkChild( + Stream.runForEach(focusLeaves(root, "[data-field]"), (message) => + Effect.sync(() => seen.push(message._tag)), + ), + ) + yield* Effect.sleep(10) + yield* Effect.forEach(targets, (target) => + Effect.sync(() => + globalThis.document.dispatchEvent( + Object.defineProperty(new Event("focusin"), "target", { value: target }), + ), + ), + ) + yield* Effect.sleep(10) + yield* Fiber.interrupt(fiber) + return seen + }), + ) + +describe("focusLeaves", () => { + test("focus moving within the field or its list keeps the list open", async () => { + const inList = new FakeElement(false) + expect(await closesFor(inList, [new FakeElement(true), inList])).toEqual([]) + }) + + test("Tab to a control outside the field and its list closes the list", async () => { + expect(await closesFor(new FakeElement(false), [new FakeElement(false)])).toEqual(["PressedOutside"]) + }) +}) diff --git a/packages/views/src/ui/combobox-picker.ts b/packages/views/src/ui/combobox-picker.ts new file mode 100644 index 0000000000..1b0d3d7b4f --- /dev/null +++ b/packages/views/src/ui/combobox-picker.ts @@ -0,0 +1,124 @@ +import { cn } from "@maple/ui/lib/utils" +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "./button" +import { Message, type Model, queryOf } from "./combobox" +import { chipsInput, inputAttributes, listPopup } from "./combobox-view" +import { icon } from "./icon" +import { isMounted } from "./overlay/transition" +import { tooltipTrigger } from "./tooltip-host" + +/** + * `ComboboxPicker`: a button showing the current pick that opens a searchable list, the search + * input on top (a `` with ``). The caller matches `items` to the + * search itself (`queryOf`), so a list can drill into groups or offer a typed value. + */ +export type ComboboxPickerInputs = Readonly<{ + /** The list for the current search, in order. */ + items: ReadonlyArray + ariaLabel: string + searchPlaceholder: string + emptyText: string + /** The trigger's label: the pick, or a prompt drawn muted with `isPlaceholder`. */ + trigger: ReadonlyArray + isPlaceholder?: boolean + /** The trigger's tooltip, for a pick the trigger shows shortened. */ + tooltip?: string + renderItem?: (h: HtmlBuilder, value: string) => ReadonlyArray + itemClassName?: (value: string) => string | undefined + triggerClassName?: string + /** Places the trigger and, while open, the list portal. */ + toView: (parts: ReadonlyArray) => Html +}> + +export const triggerIdOf = (id: string): string => `${id}-trigger` + +export const comboboxPicker = Submodel.defineView( + (model, inputs, h) => { + const triggerId = triggerIdOf(model.id) + const isShown = isMounted(model.isOpen, model.animation) + const trigger = button( + h, + { + variant: "outline", + size: "sm", + className: cn("min-w-0 max-w-full justify-between font-normal", inputs.triggerClassName), + slot: "combobox-trigger", + attributes: [ + h.Id(triggerId), + h.AriaLabel(inputs.ariaLabel), + h.AriaExpanded(model.isOpen), + h.AriaHasPopup("dialog"), + h.Role("combobox"), + ...(isShown ? [h.AriaControls(`${model.id}-popup`)] : []), + ...(model.isOpen ? [h.DataAttribute("popup-open", "")] : []), + h.OnClick(Message.ClickedTriggerOf({ items: inputs.items })), + // Hidden while its own list is open, which would cover it. + ...(inputs.tooltip === undefined || model.isOpen + ? [] + : tooltipTrigger(h, inputs.tooltip)), + ], + }, + [ + h.span( + [ + h.Class( + cn("min-w-0 truncate", inputs.isPlaceholder === true && "text-muted-foreground"), + ), + ], + inputs.trigger, + ), + icon(h, "ChevronDownIcon", { size: 12, className: "shrink-0 text-muted-foreground" }), + ], + ) + const header = h.div( + [h.Class("flex items-center gap-2 border-b border-border-subtle px-3")], + [ + icon(h, "MagnifierIcon", { className: "size-3.5 shrink-0 text-foreground-subtle" }), + chipsInput(h, { + size: "sm", + className: "h-9 w-full ps-0 text-xs", + attributes: [ + h.Attribute("autofocus", ""), + ...inputAttributes( + h, + { ...model, inputValue: queryOf(model) }, + { + slot: "combobox-chips-input", + ariaLabel: `Search ${inputs.ariaLabel}`, + placeholder: inputs.searchPlaceholder, + items: inputs.items, + isUnfiltered: true, + }, + (message) => message, + ), + ], + }), + ], + ) + return inputs.toView([ + trigger, + ...(isShown + ? [ + listPopup(h, model, { + slot: "combobox", + items: inputs.items, + isPrefiltered: true, + emptyText: inputs.emptyText, + anchorSelector: `#${triggerId}`, + className: "w-90 max-w-[calc(100vw-2rem)] overflow-hidden", + positionerClassName: "z-60", + listClassName: "max-h-72 overflow-y-auto p-1", + itemClassName: (value) => + cn( + "min-h-7 grid-cols-[1rem_minmax(0,1fr)] text-xs", + inputs.itemClassName?.(value), + ), + renderItem: (value) => inputs.renderItem?.(h, value) ?? [value], + trigger: { returnFocusId: triggerId, header }, + }), + ] + : []), + ]) + }, +) diff --git a/packages/views/src/ui/combobox-view.ts b/packages/views/src/ui/combobox-view.ts index 28819cdbea..cc5e799c53 100644 --- a/packages/views/src/ui/combobox-view.ts +++ b/packages/views/src/ui/combobox-view.ts @@ -390,8 +390,11 @@ 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 + /** ``: the field is as tall as a control of that size. */ + size?: "sm" | "default" /** * `MultiSelectCombobox disabled`: the field stays mounted, dimmed and inert, so cards holding * several conditionally disabled pickers never reflow. @@ -461,7 +464,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( [ @@ -485,12 +491,19 @@ export const chipsView = Submodel.defineView((m }), ...(isOpen ? [dismissButton(h)] : []), h.input([ - h.Class(cn(C.comboboxChipsInputStyles, C.comboboxChipsInputDefaultStyles)), - h.DataAttribute("size", "default"), + h.Class( + cn( + C.comboboxChipsInputStyles, + inputs.size === "sm" + ? C.comboboxChipsInputSmStyles + : C.comboboxChipsInputDefaultStyles, + ), + ), + h.DataAttribute("size", inputs.size ?? "default"), ...(isDisabled ? [h.Disabled(true)] : []), ...inputAttributes( h, - model, + hasList ? model : { ...model, isOpen: false }, { slot: "combobox-chips-input", ariaLabel: inputs.ariaLabel, @@ -513,7 +526,7 @@ export const chipsView = Submodel.defineView((m listPopup(h, model, { slot: "combobox", items: inputs.items, - emptyText: inputs.emptyText, + emptyText: inputs.emptyText ?? "", anchorSelector: chipsSelector, isUnfiltered: inputs.isUnfiltered, renderItem: (value: string) => inputs.renderItem?.(h, value) ?? [value], diff --git a/packages/views/src/ui/combobox.scene.test.ts b/packages/views/src/ui/combobox.scene.test.ts index 78e926812a..ce1e06b096 100644 --- a/packages/views/src/ui/combobox.scene.test.ts +++ b/packages/views/src/ui/combobox.scene.test.ts @@ -131,6 +131,38 @@ describe("combobox scene (chips)", () => { ) }) + test("without an Empty line the list opens only once it has an item to show", () => { + const quiet = { + update, + view: withViewInputs(chipsView, { + items: ["checkout-svc"], + ariaLabel: "Services", + placeholder: "Add a service", + toView: (parts) => ih.main([], parts), + })(), + } + scene( + quiet, + given(init("services", "multiple")), + type(input("Services"), "zzz"), + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 1 }), + ), + Command.resolve( + Animation.WaitForAnimationSettled, + Animation.Message.EndedAnimation({ generation: 1 }), + ), + expect(role("listbox")).toBeAbsent(), + expect(role("status")).toBeAbsent(), + expect(input("Services")).toHaveAttr("aria-expanded", "false"), + type(input("Services"), "che"), + Mount.resolve(MountCombobox, Message.CompletedMountCombobox()), + expect(option("checkout-svc")).toExist(), + expect(input("Services")).toHaveAttr("aria-expanded", "true"), + ) + }) + test("no match shows the empty status; Escape closes", () => { scene( chips, diff --git a/packages/views/src/ui/combobox.ts b/packages/views/src/ui/combobox.ts index a5a3f407ab..23f07e608a 100644 --- a/packages/views/src/ui/combobox.ts +++ b/packages/views/src/ui/combobox.ts @@ -1,9 +1,9 @@ import * as Animation from "@foldkit/ui/animation" -import { Array, Effect, Option, Schema, Stream, String as Str } from "effect" +import { Array, Effect, Option, Queue, Schema, Stream, String as Str } from "effect" import { Mount, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" import { anchoredPopup } from "./overlay/anchored" -import { containEscape } from "./overlay/portal" +import { containEscape, keepFocusOnPress } from "./overlay/portal" import { initPopup, makeTransition, PopupState } from "./overlay/popup-state" import { Align, positionDefaults, Side } from "./overlay/position" import { isRovingKey, nextIndex } from "./overlay/roving" @@ -243,9 +243,50 @@ export const MountCombobox = Mount.defineStream("MountCombobox", { } }), Stream.merge(escapeContained(anchorSelector)), + // A press on the list leaves focus in the input that takes the keys: the field's own, or + // the popup's search input. Left on the listbox, Escape and Enter reached nothing. + Stream.merge(pressesKeepFocus(element)), + // A popup with its own search input: Escape there is stopped at the portal, which the + // anchor does not enclose. A field's list instead closes once focus leaves the field. + Stream.merge( + returnFocusId === undefined + ? focusLeaves(element, anchorSelector) + : escapeContainedIn(element), + ), ), }) +type PressedOutside = Extract + +/** Base UI keeps focus in the input while the list is pressed, so typing, Enter and Escape go on working. */ +const pressesKeepFocus = (root: Element): Stream.Stream => + Stream.callback(() => keepFocusOnPress(root).pipe(Effect.andThen(Effect.never))) + +/** Focus moved (Tab) to something outside the field and its list: the list closes, as on a press outside. */ +export const focusLeaves = ( + root: Pick, + anchorSelector: string, +): Stream.Stream => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onFocusIn = (event: Event) => { + const target = event.target + if (!(target instanceof Element)) return + if (root.contains(target) || target.closest(anchorSelector) !== null) return + Queue.offerUnsafe(queue, Message.PressedOutside()) + } + document.addEventListener("focusin", onFocusIn) + return onFocusIn + }), + (onFocusIn) => Effect.sync(() => document.removeEventListener("focusin", onFocusIn)), + ).pipe(Effect.andThen(Effect.never)), + ) + +/** Escape typed in the popup's own input stops at the portal, short of an enclosing dialog or page. */ +const escapeContainedIn = (root: Element): Stream.Stream => + Stream.callback(() => containEscape(root).pipe(Effect.andThen(Effect.never))) + /** While the list is open, Escape in the input closes it and stops short of an enclosing dialog. */ const escapeContained = (anchorSelector: string): Stream.Stream => Stream.callback(() => 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/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/overlay/portal.test.ts b/packages/views/src/ui/overlay/portal.test.ts index 05a415fbdc..885d76e375 100644 --- a/packages/views/src/ui/overlay/portal.test.ts +++ b/packages/views/src/ui/overlay/portal.test.ts @@ -1,16 +1,29 @@ import { Effect, Exit, Scope } from "effect" import { afterEach, beforeEach, describe, expect, test } from "vitest" -import { containEscape, lockBodyScroll } from "./portal" +import { containEscape, keepFocusOnPress, lockBodyScroll } from "./portal" /** A body stand-in: the scroll lock only reads `style.overflow` and `dataset`. */ const fakeBody = () => ({ style: { overflow: "" }, dataset: {} }) +/** An element stand-in that is its own press target: `closest` finds a control only when it is one. */ +class FakeElement extends EventTarget { + constructor(private readonly isControl: boolean) { + super() + } + closest(): FakeElement | null { + return this.isControl ? this : null + } +} + const original = globalThis.document +const originalElement = globalThis.Element beforeEach(() => { Object.defineProperty(globalThis, "document", { value: { body: fakeBody() }, configurable: true }) + Object.defineProperty(globalThis, "Element", { value: FakeElement, configurable: true }) }) afterEach(() => { Object.defineProperty(globalThis, "document", { value: original, configurable: true }) + Object.defineProperty(globalThis, "Element", { value: originalElement, configurable: true }) }) const overflow = () => globalThis.document.body.style.overflow @@ -51,3 +64,25 @@ describe("containEscape", () => { ), )) }) + +describe("keepFocusOnPress", () => { + /** Whether a mouse press on `target` still moves focus (its default) with the popup holding it. */ + const pressMovesFocus = (target: FakeElement) => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* keepFocusOnPress(target) + const press = new Event("mousedown", { cancelable: true }) + target.dispatchEvent(press) + return !press.defaultPrevented + }), + ), + ) + + test("a press on a list option leaves focus in the input; a press on a control still takes it", async () => { + expect({ + option: await pressMovesFocus(new FakeElement(false)), + control: await pressMovesFocus(new FakeElement(true)), + }).toEqual({ option: false, control: true }) + }) +}) diff --git a/packages/views/src/ui/overlay/portal.ts b/packages/views/src/ui/overlay/portal.ts index 7a167e9c7b..e074ec0bc4 100644 --- a/packages/views/src/ui/overlay/portal.ts +++ b/packages/views/src/ui/overlay/portal.ts @@ -138,6 +138,26 @@ export const containEscape = (container: EventTarget): Effect.Effect Effect.sync(() => container.removeEventListener("keydown", onKeyDown)), ).pipe(Effect.asVoid) +const OWN_FOCUS = "input, textarea, select, button, a[href]" + +/** + * A press in `container` that is not on one of its controls leaves focus where it is: a list's + * options are not focus targets, so the input that handles the keys keeps them (Base UI's combobox). + */ +export const keepFocusOnPress = (container: EventTarget): Effect.Effect => + Effect.acquireRelease( + Effect.sync(() => { + const onMouseDown = (event: Event) => { + const target = event.target + if (typeof Element === "undefined" || !(target instanceof Element)) return + if (target.closest(OWN_FOCUS) === null) event.preventDefault() + } + container.addEventListener("mousedown", onMouseDown) + return onMouseDown + }), + (onMouseDown) => Effect.sync(() => container.removeEventListener("mousedown", onMouseDown)), + ).pipe(Effect.asVoid) + const TABBABLE = "a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]" diff --git a/packages/views/src/ui/segmented-select.ts b/packages/views/src/ui/segmented-select.ts index abc39de79f..a48365741f 100644 --- a/packages/views/src/ui/segmented-select.ts +++ b/packages/views/src/ui/segmented-select.ts @@ -122,7 +122,8 @@ export type SegmentedSelectConfig = Readonly<{ options: ReadonlyArray> value: T onChange: (value: T) => M - size?: "xs" | "sm" | "default" + /** `lg` keeps each segment at least as tall as a small button, for a choice in a form. */ + size?: "xs" | "sm" | "default" | "lg" ariaLabel?: string className?: string }> diff --git a/packages/views/src/ui/select-view.ts b/packages/views/src/ui/select-view.ts index 7dc9c6d2c0..f69fa64702 100644 --- a/packages/views/src/ui/select-view.ts +++ b/packages/views/src/ui/select-view.ts @@ -64,8 +64,14 @@ export type ViewInputs = Readonly<{ leading?: ReadonlyArray /** `