From 3ec81ceba2f00f3255abcd51860ea3b0c8eebc78 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:05:58 +0100 Subject: [PATCH 01/69] feat(query-builder): condition builder for the where clause The query panel's where clause now edits as [field] [operator] [values] rows by default, with a Builder/Text switch back to the text editor. Rows map both ways to the same where-clause string (parse/print over the existing grammar), and every edit leaves as the existing whereClause text change, so previews and saving are unchanged. A clause the rows can't show stays in the text editor with a note. Fields come from the editor's autocomplete vocabulary: common fields, then resource and source attributes in a drill-down picker (new kit ComboboxPicker). Several values OR only on attribute fields, the only filters the query engine ORs; 'is not' AND-s any number. Adds /lab/condition-builder. --- apps/web/src/lab/condition-builder-lab.ts | 136 +++++++++ apps/web/src/lab/entries/condition-builder.ts | 6 + apps/web/src/lab/registry.ts | 8 + .../editor/editor-metrics.scene.test.ts | 12 +- .../widget/widget-presets.scene.test.ts | 23 -- .../query-builder/condition-builder-view.ts | 239 +++++++++++++++ .../condition-builder.scene.test.ts | 140 +++++++++ .../kit/query-builder/condition-fields.ts | 220 +++++++++++++ .../kit/query-builder/condition-rows.test.ts | 139 +++++++++ .../page/kit/query-builder/condition-rows.ts | 116 +++++++ .../page/kit/query-builder/conditions.test.ts | 122 ++++++++ .../src/page/kit/query-builder/conditions.ts | 289 ++++++++++++++++++ .../page/kit/query-builder/panel-message.ts | 23 ++ .../page/kit/query-builder/panel-select.ts | 39 +++ apps/web/src/page/kit/query-builder/panel.ts | 23 +- apps/web/src/page/kit/query-builder/parts.ts | 110 ++++--- packages/views/src/ui/INDEX.md | 10 +- packages/views/src/ui/combobox-picker.ts | 117 +++++++ 18 files changed, 1695 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/lab/condition-builder-lab.ts create mode 100644 apps/web/src/lab/entries/condition-builder.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-builder-view.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-fields.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-rows.test.ts create mode 100644 apps/web/src/page/kit/query-builder/condition-rows.ts create mode 100644 apps/web/src/page/kit/query-builder/conditions.test.ts create mode 100644 apps/web/src/page/kit/query-builder/conditions.ts create mode 100644 apps/web/src/page/kit/query-builder/panel-select.ts create mode 100644 packages/views/src/ui/combobox-picker.ts 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..1df10709d5 --- /dev/null +++ b/apps/web/src/lab/condition-builder-lab.ts @@ -0,0 +1,136 @@ +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, a clause 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"], + resourceAttributeKeys: ["host.name", "k8s.pod.name", "cloud.region"], +} + +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.k8s.pod.name exists', + ), + }, + { + title: "Can't show as rows", + hint: "An OR across different fields stays in the text editor.", + query: draft( + "unparseable", + "C", + "traces", + '(service.name = "checkout" OR span.name = "GET /health")', + ), + }, + { + 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 => + vstack(h, { gap: "8", className: "mx-auto w-full max-w-3xl 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/condition-builder.ts b/apps/web/src/lab/entries/condition-builder.ts new file mode 100644 index 0000000000..9c64b93b1c --- /dev/null +++ b/apps/web/src/lab/entries/condition-builder.ts @@ -0,0 +1,6 @@ +import { defineLab } from "../define" +import { init, Model, update, view } from "../condition-builder-lab" + +// `/lab/condition-builder`: the query panel's condition rows over fixture vocabulary. + +export const lab = defineLab("Condition builder", { Model, init, update, view }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index c338dbc73d..02bc171ec3 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -435,6 +435,14 @@ 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/time-range", title: "Time range picker", 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/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/query-builder/condition-builder-view.ts b/apps/web/src/page/kit/query-builder/condition-builder-view.ts new file mode 100644 index 0000000000..cd375741d0 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -0,0 +1,239 @@ +import type { WhereClauseAutocompleteValues } from "../../../lib/query-builder/where-clause-autocomplete" +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 } 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, + OPERATOR_LABEL, + operatorsOf, + valueSuggestionsOf, +} from "./condition-fields" +import { type ConditionRow, isValueless } from "./condition-rows" +import { conditionPickerId, operatorSelectId } 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" } + +const fieldTrigger = (h: H, field: ConditionField): ReadonlyArray => + field.group === "common" + ? [field.label] + : [ + typography(h, { tone: "subtle", as: "span", mono: true }, [`${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, + values: WhereClauseAutocompleteValues, +): 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 items = fieldPickerItems(fieldsOf(query.dataSource, values), model.fieldDrills[id] ?? "all", search) + 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), + ariaLabel: "Field", + searchPlaceholder: "Search fields…", + emptyText: "No fields match", + trigger: row.key === "" ? ["Choose field"] : fieldTrigger(h, fieldOf(row.key, query.dataSource)), + isPlaceholder: row.key === "", + triggerClassName: "max-w-56", + 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("contents")], parts), + }, + }) +} + +const valuePicker = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + values: WhereClauseAutocompleteValues, +): Html => { + const id = conditionPickerId(query.id, row.id, "values") + const picker = model.conditionPickers[id] ?? Combobox.init(id, "multiple") + const suggestions = valueSuggestionsOf(fieldOf(row.key, query.dataSource), query.dataSource, values) + const typed = Str.trim(picker.inputValue) + // What is typed is offered first when it is not already a suggestion, so a click adds it too. + const isTypedNew = typed !== "" && !suggestions.includes(typed) && !row.values.includes(typed) + return h.submodel({ + slotId: `${model.slotPrefix}-${id}`, + model: { ...picker, selected: row.values }, + view: chipsView, + toParentMessage: (message) => + Message.GotConditionValues({ queryId: query.id, rowId: row.id, message }), + viewInputs: { + items: isTypedNew ? [typed, ...suggestions] : suggestions, + ariaLabel: "Value", + placeholder: row.values.length === 0 ? "Value" : "", + emptyText: "Type a value and press Enter", + className: "text-xs", + renderItem: (_, value) => (isTypedNew && value === typed ? [`Use "${value}"`] : [value]), + toView: (parts) => h.div([h.Class("min-w-40 flex-1")], parts), + }, + }) +} + +const conditionRow = ( + h: H, + model: Model, + query: QueryDraft, + row: ConditionRow, + index: number, + values: WhereClauseAutocompleteValues, +): Html => { + const connector = typography(h, { size: "xs", tone: "muted", className: "w-10 shrink-0" }, [ + index === 0 ? "Where" : "and", + ]) + const remove = iconButton( + h, + { + size: "icon-xs", + label: "Remove condition", + hasTooltip: false, + className: "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, values) + const layout = (parts: ReadonlyArray) => + hstack(h, { gap: "1.5", wrap: true, attributes: [h.DataAttribute("slot", "condition-row")] }, [ + connector, + field, + ...parts, + remove, + ]) + if (row.key === "") return layout([]) + const kind = fieldOf(row.key, query.dataSource).kind + return panelSelect( + h, + model, + operatorSelectId(query.id, row.id), + row.operator, + Arr.map(operatorsOf(kind, row.operator), (operator) => ({ + value: operator, + label: OPERATOR_LABEL[operator], + })), + { + size: "sm", + className: "w-36", + ariaLabel: "Operator", + toView: (parts) => + layout([ + ...parts, + ...(isValueless(row.operator) ? [] : [valuePicker(h, model, query, row, values)]), + ]), + }, + ) +} + +/** The rows, or nothing when there are none: the caller's "Add condition" starts the first. */ +export const conditionBuilder = ( + h: H, + model: Model, + query: QueryDraft, + rows: ReadonlyArray, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => + rows.length === 0 + ? [] + : [ + vstack( + h, + { + gap: "1.5", + attributes: [h.AriaLabel(`Conditions for query ${query.name}`), h.Role("group")], + }, + Arr.map(rows, (row, index) => conditionRow(h, model, query, row, index, values)), + ), + ] 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..6d9e2665fd --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-builder.scene.test.ts @@ -0,0 +1,140 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { + all, + click, + Command, + expect as expectView, + given, + keydown, + label, + last, + 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() }), + }), + ) + +/** The last row's value picker: the row just added. */ +const newValue = last(all.role("combobox", { name: "Value" })) + +describe("condition builder", () => { + test("a new row picks an attribute, then typed values, and prints an OR group", () => { + 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), + typeInto(role("combobox", { name: "Value" }), "/cart"), + ...pickerOpened(1), + keydown(role("combobox", { name: "Value" }), "Enter"), + typeInto(role("combobox", { name: "Value" }), "/checkout"), + keydown(role("combobox", { name: "Value" }), "Enter"), + expectView(text('(attr.http.route = "/cart" OR attr.http.route = "/checkout")')).toExist(), + ) + }) + + test("the field picker drills into resource attributes", () => { + 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(newValue, "web-1"), + ...pickerOpened(1), + keydown(newValue, "Enter"), + expectView(text('service.name = "checkout" AND resource.host.name = "web-1"')).toExist(), + ) + }) + + test("removing a row prints the rest", () => { + scene( + { update, view }, + given(hosted('service.name = "checkout" AND attr.http.route exists')), + click(label("Remove condition")), + expectView(text("attr.http.route exists")).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("a clause the rows can't show stays in the text editor and says so", () => { + scene( + { update, view }, + given(hosted('(service.name = "a" OR span.name = "b")')), + caretMounted(), + expectView(text("This filter uses syntax the builder can't show, edit as text")).toExist(), + expectView(label("Where clause for query A")).toExist(), + expectView(role("button", { name: "Builder" })).toBeDisabled(), + ) + }) +}) 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..192ebecf18 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-fields.ts @@ -0,0 +1,220 @@ +import type { Operator } from "@maple/domain/where-clause" +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"), + ], + logs: [common("service.name", "Service"), common("severity", "Severity"), common("body", "Message")], + metrics: [common("service.name", "Service"), 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 can OR this field's values: only attribute filters lower an + * `(a OR b)` group; a named dimension (`service.name`) or a product-events query drops it. + */ +export const canMatchAnyOf = (field: ConditionField, dataSource: DataSource): boolean => + dataSource !== "product_events" && + field.kind !== "boolean" && + (field.group !== "common" || Str.startsWith(ATTRIBUTE_PREFIX)(field.key)) + +/** Every field the query offers: the common ones, then resource and own attributes as the editor writes them. */ +export const fieldsOf = ( + dataSource: DataSource, + values: WhereClauseAutocompleteValues, +): ReadonlyArray => + Arr.dedupeWith( + [ + ...COMMON_FIELDS[dataSource], + ...Arr.map(values.resourceAttributeKeys ?? [], (key) => + fieldOf(`${RESOURCE_PREFIX}${key}`, dataSource), + ), + ...Arr.map(values.attributeKeys ?? [], (key) => fieldOf(`${ATTRIBUTE_PREFIX}${key}`, dataSource)), + ], + (a, b) => a.key === b.key, + ) + +// 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. */ +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}` +} + +export const fieldPickerItems = ( + fields: ReadonlyArray, + drill: FieldDrill, + query: string, +): 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) && !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. + +/** The operators a field takes, in menu order; a row's current operator always stays offered. */ +export const operatorsOf = (kind: FieldKind, current?: Operator): ReadonlyArray => { + const own: ReadonlyArray = + kind === "number" + ? ["=", "!=", ">", ">=", "<", "<=", "exists", "!exists"] + : kind === "boolean" + ? ["=", "!=", "exists", "!exists"] + : ["=", "!=", "contains", "!contains", "exists", "!exists"] + 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..9a15ec4df8 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-rows.test.ts @@ -0,0 +1,139 @@ +import { parseWhereClause } from "@maple/domain/where-clause" +import { describe, expect, test } from "vitest" +import { type ConditionRow, nextRowId, parseConditionRows, printConditionRows } from "./condition-rows" +import { Array as Arr, Option } from "effect" + +const rowsOf = (whereClause: string) => + Option.map(parseConditionRows(whereClause), (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"] }]], + [ + '(service.name = "checkout" OR service.name = "api")', + [{ key: "service.name", operator: "=", values: ["checkout", "api"] }], + ], + ["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, nesting. + '(service.name = "a" OR span.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))).toBe(true) + }) +}) + +describe("printConditionRows", () => { + test.each([ + [[row("service.name", "=", ["checkout"])], 'service.name = "checkout"'], + [ + [row("service.name", "=", ["checkout", "api"])], + '(service.name = "checkout" OR service.name = "api")', + ], + [[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"', + '(service.name = "a" OR service.name = "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"'`, + '(body contains "timeout" OR body contains "refused")', + ])("%s", (whereClause) => { + const rows = Option.getOrThrow(parseConditionRows(whereClause)) + const printed = printConditionRows(rows) + expect(meaning(printed)).toEqual(meaning(whereClause)) + expect(Option.map(parseConditionRows(printed), (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") + }) +}) 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..c254f11f47 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-rows.ts @@ -0,0 +1,116 @@ +import { + normalizeKey, + Operator, + type ParsedClause, + parseWhereClause, + quoteWhereValue, + splitWhereClause, +} from "@maple/domain/where-clause" +import { Array as Arr, Option, 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`. + +/** 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], +}) + +/** One AND-ed part: a single clause, or an OR group whose members share key and an any-of operator. */ +const partOf = (part: string): Option.Option => { + const parsed = parseWhereClause(part, { orGroups: true }) + if (parsed.warnings.length > 0) return Option.none() + const [clause] = parsed.clauses + if (parsed.groups.length === 0 && parsed.clauses.length === 1 && clause !== undefined) + return Option.some(draftOf(clause)) + const [group] = parsed.groups + if (parsed.clauses.length > 0 || parsed.groups.length !== 1 || group === undefined) return Option.none() + const drafts = Arr.map(group, draftOf) + const [first] = drafts + if (first === undefined || !isAnyOf(first.operator)) return Option.none() + const isShared = Arr.every( + drafts, + (draft) => draft.key === first.key && draft.operator === first.operator, + ) + return isShared + ? Option.some({ ...first, values: Arr.flatMap(drafts, (draft) => draft.values) }) + : Option.none() +} + +/** `k != a AND k != b` reads as one "is not a, b" row. */ +const mergeNoneOf = (drafts: ReadonlyArray): ReadonlyArray => + Arr.reduce(drafts, Arr.empty(), (rows: ReadonlyArray, draft) => { + if (!isNoneOf(draft.operator)) return [...rows, draft] + const index = Arr.findFirstIndex( + rows, + (row) => row.key === draft.key && row.operator === draft.operator, + ) + return Option.match(index, { + onNone: () => [...rows, draft], + onSome: (at) => + Arr.map(rows, (row, i) => + i === at ? { ...row, values: Arr.dedupe([...row.values, ...draft.values]) } : row, + ), + }) + }) + +/** The rows a where clause reads as, or none when it uses syntax a row cannot show. */ +export const parseConditionRows = (whereClause: string): Option.Option> => + Option.map(Option.all(Arr.map(splitWhereClause(Str.trim(whereClause)), partOf)), (drafts) => + Arr.map(mergeNoneOf(drafts), (draft, index) => ({ id: `c${index}`, ...draft })), + ) + +/** 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 ") + +/** 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..537a799573 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/conditions.test.ts @@ -0,0 +1,122 @@ +import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" +import { describe, expect, test } from "vitest" +import type { QueryDraft } from "./draft" +import { init, Message, type Model, operatorSelectId, type PanelInputs, update } from "./panel" + +// The builder's operator and value steps, as the where-clause edit they emit. + +const traces = (whereClause: string): QueryDraft => ({ + ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, "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 emitted = (whereClause: string, message: Message, model: Model = init("test")): string | null => { + const next = update(model, message, inputsFor(traces(whereClause))) + const change = next.outMessage?._tag === "ChangedQuery" ? next.outMessage.change : null + return change?._tag === "SetText" ? change.value : null +} + +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('(service.name = "checkout" OR service.name = "api")', pickOperator("c0", "exists")), + ).toBe("service.name exists") + }) + + test("is not keeps every value, as AND-ed negations", () => { + expect(emitted('(service.name = "checkout" OR service.name = "api")', pickOperator("c0", "!="))).toBe( + 'service.name != "checkout" AND service.name != "api"', + ) + }) + + 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: "api" }, + }) + expect(emitted('(service.name = "checkout" OR service.name = "api")', message)).toBe( + 'service.name = "checkout"', + ) + }) +}) + +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" }) + }) +}) 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..252a1c1012 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/conditions.ts @@ -0,0 +1,289 @@ +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, fieldOf, fieldPickerItems, fieldsOf, operatorsOf } from "./condition-fields" +import { + type ConditionRow, + isValueless, + nextRowId, + parseConditionRows, + printConditionRows, + isAnyOf, + isNoneOf, +} 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: every edit prints the rows and leaves as the query's +// where-clause edit, 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 rows a query shows: the kept ones while they still print its where clause, else a fresh parse. */ +export const conditionRowsOf = ( + model: Model, + query: QueryDraft, +): Option.Option> => { + const kept = model.conditions[query.id] + return kept !== undefined && printConditionRows(kept) === query.whereClause + ? Option.some(kept) + : parseConditionRows(query.whereClause) +} + +/** Whether a query edits as rows: chosen (the default) and representable. */ +export const whereModeOf = (model: Model, query: QueryDraft): WhereMode => + model.whereModes[query.id] ?? "builder" + +const emitted = ( + model: Model, + query: QueryDraft, + rows: ReadonlyArray, + commands?: Return["commands"], +): Return => ({ + model: { ...model, conditions: { ...model.conditions, [query.id]: rows } }, + ...(commands === undefined ? undefined : { commands }), + outMessage: OutMessage.ChangedQuery({ + queryId: query.id, + change: QueryChange.SetText({ field: "whereClause", value: printConditionRows(rows) }), + }), +}) + +type RowContext = Readonly<{ 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(conditionRowsOf(model, query), { + onNone: () => ({ model }), + onSome: (rows) => step({ 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 }), onSome: (row) => step({ ...context, row }) }, + ), + ) + +const replaced = (rows: ReadonlyArray, next: ConditionRow): ReadonlyArray => + Arr.map(rows, (row) => (row.id === next.id ? next : row)) + +export const changedWhereMode = (model: Model, queryId: string, mode: WhereMode): Return => ({ + model: { + ...model, + whereModes: { ...model.whereModes, [queryId]: mode }, + conditions: Rec.remove(model.conditions, queryId), + }, +}) + +/** A new row opens its field picker, as the first thing to choose. */ +export const addedCondition = (model: Model, queryId: string, inputs: PanelInputs | undefined): Return => + withRows(model, queryId, inputs, ({ query, rows }) => { + const id = nextRowId(rows) + const pickerId = conditionPickerId(queryId, id, "field") + const opened: Model = { + ...model, + conditionPickers: { + ...model.conditionPickers, + [pickerId]: Combobox.init(pickerId, "single", { isOpen: true }), + }, + } + return emitted(opened, query, [...rows, { id, key: "", operator: "=", values: [] }]) + }) + +export const removedCondition = ( + model: Model, + queryId: string, + rowId: string, + inputs: PanelInputs | undefined, +): Return => + withRows(model, queryId, inputs, ({ query, rows }) => { + const pickerIds = [ + conditionPickerId(queryId, rowId, "field"), + conditionPickerId(queryId, rowId, "values"), + ] + const pickers = Rec.filter(model.conditionPickers, (_, id) => !pickerIds.includes(id)) + return emitted( + { + ...model, + conditionPickers: pickers, + selects: Rec.remove(model.selects, operatorSelectId(queryId, rowId)), + }, + query, + 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 +} + +const pickerOf = (model: Model, id: string, mode: Combobox.Mode): Combobox.Model => + model.conditionPickers[id] ?? Combobox.init(id, mode) + +/** The field picker: a group or "All fields" drills without closing; a field sets the row's key. */ +export const fieldStep = ( + model: Model, + queryId: string, + rowId: string, + message: Combobox.Message, + inputs: PanelInputs | undefined, +): Return => + withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + const id = conditionPickerId(queryId, rowId, "field") + const current = { ...pickerOf(model, id, "single"), selected: row.key === "" ? [] : [row.key] } + const drill = model.fieldDrills[id] ?? "all" + const fields = fieldsOf(query.dataSource, inputs?.baseValuesOf(query) ?? {}) + const items = fieldPickerItems(fields, drill, Combobox.queryOf(current)) + 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: { + ...model, + conditionPickers: { + ...model.conditionPickers, + [id]: { ...current, inputValue: "", highlighted: 0 }, + }, + fieldDrills: { ...model.fieldDrills, [id]: navigated.value }, + }, + } + const next = Combobox.update(current, message) + const lifted: Model = { + ...model, + conditionPickers: { ...model.conditionPickers, [id]: next.model }, + fieldDrills: next.model.isOpen ? model.fieldDrills : Rec.remove(model.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).kind) + const operator = operators.includes(row.operator) ? row.operator : "=" + return emitted(lifted, query, replaced(rows, { ...row, key, operator, values: [] }), commands) + }) + +/** Whether a row holds several values: "is not" always (AND-ed excludes), "is" only where the engine ORs. */ +export const holdsManyValues = (row: Pick, query: QueryDraft): boolean => + isNoneOf(row.operator) || + (isAnyOf(row.operator) && canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource)) + +const withValue = (row: ConditionRow, query: QueryDraft, value: string): ReadonlyArray => + holdsManyValues(row, query) ? Arr.dedupe([...row.values, value]) : [value] + +/** The value picker: suggestions or anything typed (Enter adds it); one value unless the row holds many. */ +export const valuesStep = ( + model: Model, + queryId: string, + rowId: string, + message: Combobox.Message, + inputs: PanelInputs | undefined, +): Return => + withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + const id = conditionPickerId(queryId, rowId, "values") + const current = { ...pickerOf(model, id, "multiple"), selected: row.values } + const typed = Str.trim(current.inputValue) + if ( + message._tag === "PressedInputKey" && + message.key === "Enter" && + current.highlighted === null && + typed !== "" + ) + return emitted( + { + ...model, + conditionPickers: { ...model.conditionPickers, [id]: { ...current, inputValue: "" } }, + }, + query, + replaced(rows, { ...row, values: withValue(row, query, typed) }), + ) + const next = Combobox.update(current, message) + const lifted: Model = { ...model, conditionPickers: { ...model.conditionPickers, [id]: next.model } } + const commands = Command.mapMessages(next.commands ?? [], (inner) => + Message.GotConditionValues({ queryId, rowId, message: inner }), + ) + if (next.outMessage?._tag !== "ChangedSelection") return { model: lifted, commands } + const selected = next.outMessage.selected + const values = holdsManyValues(row, query) ? selected : Arr.takeRight(selected, 1) + return emitted(lifted, query, 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, ({ query, rows, row }) => { + const values = isValueless(operator) + ? [] + : holdsManyValues({ key: row.key, operator }, query) + ? row.values + : Arr.take(row.values, 1) + return emitted(lifted, query, replaced(rows, { ...row, operator, values }), commands) + }) +} 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..058f46eb4d --- /dev/null +++ b/apps/web/src/page/kit/query-builder/panel-select.ts @@ -0,0 +1,39 @@ +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 + /** 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, + }, + }) +} diff --git a/apps/web/src/page/kit/query-builder/panel.ts b/apps/web/src/page/kit/query-builder/panel.ts index 7074687bf4..7329cab460 100644 --- a/apps/web/src/page/kit/query-builder/panel.ts +++ b/apps/web/src/page/kit/query-builder/panel.ts @@ -8,6 +8,16 @@ 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, + valuesStep, +} from "./conditions" import { groupBySelectionOf } from "./draft" import { Message, type Model, OutMessage } from "./panel-message" import { type PanelInputs, whereStep } from "./where" @@ -15,6 +25,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,10 +134,11 @@ 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 }) => ({ @@ -140,4 +152,11 @@ export const update = (model: Model, message: Message, inputs?: PanelInputs): Re ClickedRemove: ({ queryId }) => ({ model, outMessage: OutMessage.ClickedRemove({ queryId }) }), GotWhereField: ({ message: inner }) => inputs === undefined ? { model } : whereStep(model, inner, inputs), + 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..a29d43baba 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 { hstack, vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { conditionBuilder } from "./condition-builder-view" +import { conditionRowsOf, whereModeOf } from "./conditions" +import type { WhereMode } from "./panel-message" import { whereSpecOf, whereTextareaId } from "./where" +import { panelSelect } from "./panel-select" + +export { panelSelect } 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,21 @@ 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, ): 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: "w-full", textareaClassName: WHERE_TEXTAREA_CLASS, onInput: (value) => Message.ChangedText({ queryId: query.id, field: "whereClause", value }), autocomplete: { @@ -93,6 +70,61 @@ export const whereClauseEditor = ( }, }) +const UNPARSEABLE_NOTE = "This filter uses syntax the builder can't show, edit as text" + +/** + * `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text + * editor; a Builder/Text switch under it. Both edit the same where-clause string. + */ +export const whereClauseEditor = ( + h: H, + model: Model, + query: QueryDraft, + values: WhereClauseAutocompleteValues, + options: Readonly<{ placeholder?: string; className?: string }> = {}, +): Html => { + const rows = conditionRowsOf(model, query) + const preferred = whereModeOf(model, query) + const isBuilder = preferred === "builder" && Option.isSome(rows) + const leading = isBuilder + ? button( + h, + { + variant: "ghost", + size: "xs", + className: "text-muted-foreground", + attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], + }, + [icon(h, "PlusIcon"), "Add condition"], + ) + : preferred === "builder" + ? typography(h, { size: "xs", tone: "muted" }, [UNPARSEABLE_NOTE]) + : h.empty + 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: "xs", + ariaLabel: `Where clause editor for query ${query.name}`, + className: "ml-auto shrink-0", + }) + return vstack(h, { gap: "1.5", className: options.className ?? "flex-1 min-w-0" }, [ + ...(isBuilder + ? conditionBuilder( + h, + model, + query, + Option.getOrElse(rows, () => []), + values, + ) + : [whereTextEditor(h, model, query, values, options.placeholder)]), + hstack(h, { gap: "2", wrap: true }, [leading, toggle]), + ]) +} + /** A controlled `` that reports each keystroke. */ export const textInput = ( h: HtmlBuilder, diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index efea20d0f7..7b1311900d 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -35,6 +35,7 @@ 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/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. @@ -209,11 +210,16 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `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`, `fieldsOf`, `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`, `parseConditionRows`, `isComplete`, `printConditionRows`, `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`, `whereModeOf`, `changedWhereMode`, `addedCondition`, `removedCondition`, `fieldStep`, `holdsManyValues`, `valuesStep`, `operatorStep`: The condition builder's steps: every edit prints the rows and leaves as the query's where-clause edit, so previews and saving see only the string. - `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/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`: `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text editor; a Builder/Text switch under it. - `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/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. diff --git a/packages/views/src/ui/combobox-picker.ts b/packages/views/src/ui/combobox-picker.ts new file mode 100644 index 0000000000..9988a4d954 --- /dev/null +++ b/packages/views/src/ui/combobox-picker.ts @@ -0,0 +1,117 @@ +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" + +/** + * `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 + 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 })), + ], + }, + [ + 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-72 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 }, + }), + ] + : []), + ]) + }, +) From 2bcbbf01a7a244149ef650a9861b4dbeadf1c764 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:08:03 +0100 Subject: [PATCH 02/69] feat(shell): URL-held right sheet, foldable sidebar groups, on-call pill - Kit: rightSheet over the Dialog Submodel (672px form size, scrolling body, sticky footer, slide dropped under reduced motion) with followUrl/isDismissed; page/kit/sheet-param holds the ?sheet= helpers (push to open, replace to close). - Sidebar group labels fold their rows (sidebarGroupToggle, aria-expanded), saved to localStorage; the group holding the current page and the collapsed rail always show every row. - Footer on-call pill from the schedules list's on_call_now and the session user: on call until the handoff, not on call, or hidden without schedules. Re-read at each handoff; a dot with a tooltip on the rail. - /lab/shell shows all three, with a demo form in the sheet. --- apps/web/src/lab/app-shell.ts | 52 +--- apps/web/src/lab/entries/shell.ts | 37 +++ apps/web/src/lab/registry.ts | 8 + apps/web/src/lab/shell-frame.ts | 56 ++++ .../src/lab/shell-states-lab.scene.test.ts | 144 ++++++++++ apps/web/src/lab/shell-states-lab.ts | 261 ++++++++++++++++++ apps/web/src/page/kit/sheet-param.ts | 33 +++ apps/web/src/shell/command.ts | 17 +- apps/web/src/shell/data.ts | 28 ++ apps/web/src/shell/flags.ts | 8 + apps/web/src/shell/index.ts | 2 + apps/web/src/shell/message.ts | 8 + apps/web/src/shell/model.ts | 9 + apps/web/src/shell/nav-groups.scene.test.ts | 95 +++++++ apps/web/src/shell/oncall.scene.test.ts | 167 +++++++++++ apps/web/src/shell/oncall.ts | 74 +++++ apps/web/src/shell/shell.scene.test.ts | 5 +- apps/web/src/shell/subscription.ts | 19 +- apps/web/src/shell/update.ts | 42 ++- apps/web/src/shell/view-nav.ts | 57 +++- apps/web/src/shell/view-sidebar.ts | 83 ++++++ apps/web/src/shell/view.ts | 29 +- apps/web/src/subscription.ts | 15 + packages/ui/src/components/ui/sheet.styles.ts | 5 +- packages/views/src/ui/INDEX.md | 5 +- packages/views/src/ui/identity-label.ts | 13 +- packages/views/src/ui/sheet.ts | 76 +++++ packages/views/src/ui/sidebar.ts | 51 +++- 28 files changed, 1315 insertions(+), 84 deletions(-) create mode 100644 apps/web/src/lab/entries/shell.ts create mode 100644 apps/web/src/lab/shell-frame.ts create mode 100644 apps/web/src/lab/shell-states-lab.scene.test.ts create mode 100644 apps/web/src/lab/shell-states-lab.ts create mode 100644 apps/web/src/page/kit/sheet-param.ts create mode 100644 apps/web/src/shell/nav-groups.scene.test.ts create mode 100644 apps/web/src/shell/oncall.scene.test.ts create mode 100644 apps/web/src/shell/oncall.ts diff --git a/apps/web/src/lab/app-shell.ts b/apps/web/src/lab/app-shell.ts index aa7895bbe5..a3d1d53d06 100644 --- a/apps/web/src/lab/app-shell.ts +++ b/apps/web/src/lab/app-shell.ts @@ -1,10 +1,11 @@ import { Effect, Option, Schema, Stream, String as Str } from "effect" -import { Subscription, Update } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" +import { Subscription, type Update } from "foldkit" import { readSessionToken } from "../platform/auth-api" import type { Resources } from "../platform/resources" import * as Shell from "../shell" +export { foldLabShell, labShellView } from "./shell-frame" + // Shell lab pages render inside the real shell: the same Shell Submodel the // app root keeps, booted for the session's org, with the frame around the page and its overlays. @@ -14,6 +15,7 @@ export const LabShellFlags = Schema.Struct({ theme: Shell.Theme, pinnedNamespaces: Schema.Record(Schema.String, Schema.String), dashboardFavorites: Schema.Array(Schema.String), + collapsedNavGroups: Schema.optionalKey(Schema.Array(Schema.String)), }), maybeOrgId: Schema.Option(Schema.String), }) @@ -55,52 +57,6 @@ export const initLabShell = ( }) } -/** Folds Shell Messages; a lab page has no queries to rebuild on a namespace pin. */ -export const foldLabShell = ( - toParentMessage: (message: Shell.Message) => Message, -) => - Update.foldChild({ - update: Shell.update, - read: (model: Model) => Option.some(model.shell), - write: (model: Model, shell: Shell.Model): Model => ({ ...model, shell }), - toParentMessage, - foldOutMessage: () => (model: Model) => ({ model }), - }) - -/** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */ -export const labShellView = ( - h: HtmlBuilder, - shell: Shell.Model, - toParentMessage: (message: Shell.Message) => Message, - frame: Readonly<{ - currentPath: string - breadcrumbs: ReadonlyArray - page: () => Html - }>, -): ReadonlyArray => [ - h.submodel({ - slotId: "shell-frame", - model: shell, - view: Shell.frameView, - toParentMessage, - viewInputs: { ...frame, topbarActions: () => null }, - }), - h.submodel({ - slotId: "shell-sheet", - model: shell, - view: Shell.sheetLayerView, - toParentMessage, - viewInputs: { currentPath: frame.currentPath }, - }), - h.submodel({ - slotId: "shell-menu", - model: shell, - view: Shell.menuLayerView, - toParentMessage, - viewInputs: { currentPath: frame.currentPath }, - }), -] - /** The root's shell Subscriptions: `useIsMobile`, the sidebar shortcut and the Pinned group's shape. */ export const labShellSubscriptions = ( toParentMessage: (message: Shell.Message) => Message, diff --git a/apps/web/src/lab/entries/shell.ts b/apps/web/src/lab/entries/shell.ts new file mode 100644 index 0000000000..8af4756284 --- /dev/null +++ b/apps/web/src/lab/entries/shell.ts @@ -0,0 +1,37 @@ +import { Effect, Stream } from "effect" +import { Dom, Subscription } from "foldkit" +import type { Resources } from "../../platform/resources" +import * as Shell from "../../shell" +import { labShellSubscriptions } from "../app-shell" +import { defineLabWithFlags } from "../define" +import { Flags, init, Message, Model, toShell, update, view } from "../shell-states-lab" + +// `/lab/shell`: sidebar group folding, the on-call pill's states and the URL-held right sheet. + +const urlTarget: Dom.TypedEventTarget<{ popstate: PopStateEvent; "foldkit:urlchange": Event }> = window + +const currentHref = () => Message.ChangedUrl({ href: `${window.location.pathname}${window.location.search}` }) + +/** Back, Forward and the lab's own pushes and replaces. */ +const urlChanges: Stream.Stream = Stream.merge( + Dom.streamFromEvent({ target: urlTarget, type: "popstate", mapEvent: currentHref }), + Dom.streamFromEvent({ target: urlTarget, type: "foldkit:urlchange", mapEvent: currentHref }), +) + +export const lab = defineLabWithFlags("Shell states", { + Model, + Flags, + flags: Effect.all({ + shell: Shell.readShellFlags, + href: Effect.sync(() => `${window.location.pathname}${window.location.search}`), + }), + init, + update, + view, + subscriptions: Subscription.aggregate()( + labShellSubscriptions(toShell), + Subscription.make()((entry) => ({ + url: entry({}, { modelToDependencies: () => ({}), dependenciesToStream: () => urlChanges }), + })), + ), +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index c338dbc73d..8ec40d1192 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -491,6 +491,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/shell", + title: "Shell states", + description: + "The real sidebar with foldable groups and the on-call pill's three states, and a right sheet held in ?sheet= with a demo form.", + kind: "lab", + session: "none", + }, { path: "/lab/query-builder", title: "Query builder", diff --git a/apps/web/src/lab/shell-frame.ts b/apps/web/src/lab/shell-frame.ts new file mode 100644 index 0000000000..dd99c21860 --- /dev/null +++ b/apps/web/src/lab/shell-frame.ts @@ -0,0 +1,56 @@ +import { Option } from "effect" +import { Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { Message as ShellMessage } from "../shell/message" +import type { Model as ShellModel } from "../shell/model" +import { update as shellUpdate } from "../shell/update" +import { type BreadcrumbEntry, frameView, menuLayerView, sheetLayerView } from "../shell/view" + +// The shell frame a lab page renders in, kept off the shell barrel (its streams read `window` on +// import) so a lab's own logic can run in a scene test. + +/** Folds Shell Messages; a lab page has no queries to rebuild on a namespace pin. */ +export const foldLabShell = ( + toParentMessage: (message: ShellMessage) => Message, +) => + Update.foldChild({ + update: shellUpdate, + read: (model: Model) => Option.some(model.shell), + write: (model: Model, shell: ShellModel): Model => ({ ...model, shell }), + toParentMessage, + foldOutMessage: () => (model: Model) => ({ model }), + }) + +/** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */ +export const labShellView = ( + h: HtmlBuilder, + shell: ShellModel, + toParentMessage: (message: ShellMessage) => Message, + frame: Readonly<{ + currentPath: string + breadcrumbs: ReadonlyArray + page: () => Html + }>, +): ReadonlyArray => [ + h.submodel({ + slotId: "shell-frame", + model: shell, + view: frameView, + toParentMessage, + viewInputs: { ...frame, topbarActions: () => null }, + }), + h.submodel({ + slotId: "shell-sheet", + model: shell, + view: sheetLayerView, + toParentMessage, + viewInputs: { currentPath: frame.currentPath }, + }), + h.submodel({ + slotId: "shell-menu", + model: shell, + view: menuLayerView, + toParentMessage, + viewInputs: { currentPath: frame.currentPath }, + }), +] diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts new file mode 100644 index 0000000000..56a7b6324c --- /dev/null +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -0,0 +1,144 @@ +import * as Animation from "@foldkit/ui/animation" +import { + Command, + click, + expect, + given, + keydown, + Mount, + role, + scene, + selector, + Subscription, +} from "foldkit/scene" +import { describe, expect as expectVitest, test } from "vitest" +import * as Dialog from "@maple/views/ui/dialog" +import { + closeSheetNavigation, + openSheetNavigation, + sheetParamOf, + withSheetParam, +} from "../page/kit/sheet-param" +import { Array as Arr, Option } from "effect" +import { MeasureSidebarOverflow } from "../shell/mounts" +import { Message as ShellMessage } from "../shell/message" +import { + init, + Message, + type Model, + PushSheetUrl, + ReplaceSheetUrl, + SHEET_ID, + update, + view, +} from "./shell-states-lab" + +// The right sheet held in `?sheet=`: the URL opens it, Escape and the backdrop clear it, Back closes it. + +const shellFlags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const + +const at = (href: string): Model => init({ shell: shellFlags, href }).model + +const dialog = role("dialog", { name: "New alert rule" }) +const portal = selector("[data-base-ui-portal]") + +const measured = Mount.resolve(MeasureSidebarOverflow, ShellMessage.MeasuredSidebarOverflow()) + +/** The portal Mount and the enter transition (or none, for a sheet that boots open). */ +const mounted = Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()) + +const enter = (generation: number) => [ + mounted, + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation })), +] + +const leave = (generation: number) => [ + Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation })), + Command.resolve( + Dialog.WaitForDialogTransition({ id: SHEET_ID, generation }), + Dialog.Message.GotAnimationMessage({ message: Animation.Message.EndedAnimation({ generation }) }), + ), + Mount.expectEnded(Dialog.MountDialog), + expect(dialog).toBeAbsent(), +] + +describe("URL-held right sheet", () => { + test("a link with ?sheet= opens it, and Escape clears the param in place", () => { + scene( + { update, view }, + given(at(`/lab/shell?tab=x&sheet=${SHEET_ID}`)), + measured, + mounted, + expect(dialog).toExist(), + expect(dialog).toContainText("Error rate above (%)"), + expect(role("button", { name: "Create rule" })).toExist(), + keydown(portal, "Escape"), + Command.expectHas(ReplaceSheetUrl({ url: "/lab/shell?tab=x" })), + Command.resolve(ReplaceSheetUrl, Message.CompletedNavigate()), + // Booting open counted the show as generation 1. + ...leave(2), + ) + }) + + test("the page's button pushes ?sheet=, the URL change opens it, and Back closes it", () => { + scene( + { update, view }, + given(at("/lab/shell")), + measured, + expect(dialog).toBeAbsent(), + click(role("button", { name: "New alert rule" })), + Command.expectExact(PushSheetUrl({ url: `/lab/shell?sheet=${SHEET_ID}` })), + Command.resolve(PushSheetUrl, Message.CompletedNavigate()), + expect(dialog).toBeAbsent(), + Subscription.emit(Message.ChangedUrl({ href: `/lab/shell?sheet=${SHEET_ID}` })), + ...enter(1), + expect(dialog).toHaveAttr("data-open", ""), + Subscription.emit(Message.ChangedUrl({ href: "/lab/shell" })), + ...leave(2), + Command.expectNone(), + ) + }) + + test("Back (the URL losing ?sheet=) closes it without touching the URL again", () => { + const open = at(`/lab/shell?sheet=${SHEET_ID}`) + const back = update(open, Message.ChangedUrl({ href: "/lab/shell" })) + expectVitest(back.model.sheet.isOpen).toBe(false) + expectVitest(Arr.map(back.commands ?? [], (command) => command.name)).not.toContain( + ReplaceSheetUrl.name, + ) + }) + + test("Cancel in the footer closes it and clears the param", () => { + const closed = update( + at(`/lab/shell?sheet=${SHEET_ID}`), + Message.GotSheetMessage({ message: Dialog.Message.ClickedClose() }), + ) + expectVitest(closed.model.sheet.isOpen).toBe(false) + expectVitest(Arr.map(closed.commands ?? [], (command) => command.name)).toContain( + ReplaceSheetUrl.name, + ) + }) +}) + +describe("sheet param", () => { + test("sets and clears ?sheet= while keeping the other params and the hash", () => { + expectVitest(withSheetParam("/alerts?tab=rules#top", "new-rule")).toBe( + "/alerts?tab=rules&sheet=new-rule#top", + ) + expectVitest(withSheetParam("/alerts?sheet=new-rule&tab=rules", undefined)).toBe("/alerts?tab=rules") + expectVitest(sheetParamOf("/alerts?sheet=new-rule")).toEqual(Option.some("new-rule")) + expectVitest(sheetParamOf("/alerts?sheet=")).toEqual(Option.none()) + }) + + test("opening pushes a history entry; dismissing replaces it", () => { + expectVitest(openSheetNavigation("/alerts", "new-rule")).toMatchObject({ + url: "/alerts?sheet=new-rule", + isReplace: false, + }) + expectVitest(closeSheetNavigation("/alerts?sheet=new-rule")).toMatchObject({ + url: "/alerts", + isReplace: true, + }) + }) +}) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts new file mode 100644 index 0000000000..478ea5404f --- /dev/null +++ b/apps/web/src/lab/shell-states-lab.ts @@ -0,0 +1,261 @@ +import { Effect, Option, Schema } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { pushUrl, replaceUrl } from "foldkit/navigation" +import { button } from "@maple/views/ui/button" +import * as Dialog from "@maple/views/ui/dialog" +import { input } from "@maple/views/ui/input" +import { label } from "@maple/views/ui/label" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { followUrl, isDismissed, mapSheetCommands, rightSheet, type SheetStep } from "@maple/views/ui/sheet" +import { vstack } from "@maple/views/ui/stack" +import { textarea } from "@maple/views/ui/textarea" +import { typography } from "@maple/views/ui/typography" +import type { Resources } from "../platform/resources" +import { dashboardPage } from "../page/kit/dashboard-page" +import { sheetParamOf, withSheetParam } from "../page/kit/sheet-param" +import { Message as ShellMessage, Theme } from "../shell/message" +import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model" +import type { OnCallSnapshot } from "../shell/oncall" +import { foldLabShell, labShellView } from "./shell-frame" + +// `/lab/shell`: the real sidebar with fixture on-call states and a page to stand on, and the +// right sheet held in `?sheet=` (Back closes it, the link reopens it). + +export const SHEET_ID = "new-alert-rule" +const LAB_PATH = "/lab/shell" + +export const OnCallFixture = Schema.Literals(["onCall", "notOnCall", "none"]) +export type OnCallFixture = typeof OnCallFixture.Type + +export const StandOn = Schema.Literals(["lab", "alerts"]) +export type StandOn = typeof StandOn.Type + +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 on the schedule's Berlin clock. */ +const HANDOFF_MS = Date.UTC(2026, 10, 16, 8) + +const LAB_USER = "user_lab" + +export const onCallSnapshotOf = (fixture: OnCallFixture): OnCallSnapshot => ({ + userId: LAB_USER, + shifts: + fixture === "none" + ? [] + : [ + { + scheduleName: "Checkout primary", + timeZone: "Europe/Berlin", + userId: fixture === "onCall" ? LAB_USER : "user_other", + endsAtMs: HANDOFF_MS, + }, + ], +}) + +export const Flags = Schema.Struct({ + shell: Schema.Struct({ + isMobile: Schema.Boolean, + theme: Theme, + pinnedNamespaces: Schema.Record(Schema.String, Schema.String), + dashboardFavorites: Schema.Array(Schema.String), + collapsedNavGroups: Schema.optionalKey(Schema.Array(Schema.String)), + }), + href: Schema.String, +}) +export type Flags = typeof Flags.Type + +export const Model = Schema.Struct({ + shell: ShellModel, + sheet: Dialog.Model, + href: Schema.String, + onCall: OnCallFixture, + standOn: StandOn, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotShellMessage: { message: ShellMessage }, + GotSheetMessage: { message: Dialog.Message }, + /** The URL changed (a push, a replace, Back or Forward). */ + ChangedUrl: { href: Schema.String }, + ClickedNewRule: {}, + ClickedCreateRule: {}, + ClickedOnCallFixture: { fixture: OnCallFixture }, + ClickedStandOn: { standOn: StandOn }, + CompletedNavigate: {}, +}) +export type Message = typeof Message.Type + +/** Opening the sheet: a new history entry carrying `?sheet=`. */ +export const PushSheetUrl = Command.define("PushSheetUrl", { + args: { url: Schema.String }, + messages: [Message.CompletedNavigate], + execute: ({ url }) => pushUrl(url).pipe(Effect.as(Message.CompletedNavigate())), +}) + +/** Dismissing it: the param leaves in place. */ +export const ReplaceSheetUrl = Command.define("ReplaceSheetUrl", { + args: { url: Schema.String }, + messages: [Message.CompletedNavigate], + execute: ({ url }) => replaceUrl(url).pipe(Effect.as(Message.CompletedNavigate())), +}) + +type Return = Update.Return + +export const toShell = (message: ShellMessage): Message => Message.GotShellMessage({ message }) +const toSheet = (message: Dialog.Message): Message => Message.GotSheetMessage({ message }) +const foldShell = foldLabShell(toShell) + +const isSheetInUrl = (href: string): boolean => Option.contains(sheetParamOf(href), SHEET_ID) + +const sheetStep = (model: Model, step: SheetStep): Return => ({ + model: { ...model, sheet: step.model }, + commands: [ + ...mapSheetCommands(step, toSheet), + ...(isDismissed(step) ? [ReplaceSheetUrl({ url: withSheetParam(model.href, undefined) })] : []), + ], +}) + +const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({ + ...shell, + onCall: OnCallData.Success({ data: onCallSnapshotOf(fixture) }), +}) + +/** No org: the shell sends nothing, so the fixtures are all it shows. */ +export const init = (flags: Flags): Return => { + const sheet = followUrl(Dialog.init(SHEET_ID, "sheet"), isSheetInUrl(flags.href)) + return { + model: { + shell: withOnCall(initShell(flags.shell), "onCall"), + sheet: sheet.model, + href: flags.href, + onCall: "onCall", + standOn: "lab", + }, + commands: mapSheetCommands(sheet, toSheet), + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotShellMessage: ({ message: inner }) => foldShell(model, inner), + GotSheetMessage: ({ message: inner }) => sheetStep(model, Dialog.update(model.sheet, inner)), + ChangedUrl: ({ href }) => { + const step = followUrl(model.sheet, isSheetInUrl(href)) + return { model: { ...model, href, sheet: step.model }, commands: mapSheetCommands(step, toSheet) } + }, + ClickedNewRule: () => ({ + model, + commands: [PushSheetUrl({ url: withSheetParam(model.href, SHEET_ID) })], + }), + ClickedCreateRule: () => sheetStep(model, Dialog.close(model.sheet)), + ClickedOnCallFixture: ({ fixture }) => ({ + model: { ...model, onCall: fixture, shell: withOnCall(model.shell, fixture) }, + }), + ClickedStandOn: ({ standOn }) => ({ model: { ...model, standOn } }), + CompletedNavigate: () => ({ model }), + }) + +// VIEW + +const control = (h: HtmlBuilder, title: string, body: Html): Html => + vstack(h, { gap: "2" }, [typography(h, { size: "sm", weight: "medium" }, [title]), body]) + +const field = (h: HtmlBuilder, id: string, text: string, control: Html): Html => + vstack(h, { gap: "2" }, [label(h, { attributes: [h.For(id)] }, [text]), control]) + +const sheet = (h: HtmlBuilder, model: Model): Html => + rightSheet(h, { + sheet: model.sheet, + title: "New alert rule", + description: "Page the on-call person when a service's error rate crosses a threshold.", + toParentMessage: toSheet, + body: [ + vstack(h, { gap: "5" }, [ + field( + h, + "rule-name", + "Name", + input(h, { id: "rule-name", attributes: [h.Placeholder("Checkout error rate")] }), + ), + field( + h, + "rule-service", + "Service", + input(h, { id: "rule-service", attributes: [h.Placeholder("checkout-svc")] }), + ), + field( + h, + "rule-threshold", + "Error rate above (%)", + input(h, { id: "rule-threshold", attributes: [h.Value("5")] }), + ), + field( + h, + "rule-notes", + "Notes for the responder", + textarea(h, { id: "rule-notes", attributes: [h.Placeholder("What to check first")] }), + ), + ]), + ], + footer: [ + button( + h, + { variant: "outline", attributes: [h.OnClick(toSheet(Dialog.Message.ClickedClose()))] }, + ["Cancel"], + ), + button(h, { attributes: [h.OnClick(Message.ClickedCreateRule())] }, ["Create rule"]), + ], + }) + +const page = (h: HtmlBuilder, model: Model): Html => + dashboardPage( + h, + { + title: "Shell states", + description: "Fold a sidebar group, switch the on-call fixture, or open the sheet.", + actions: button(h, { attributes: [h.OnClick(Message.ClickedNewRule())] }, ["New alert rule"]), + }, + [ + vstack(h, { gap: "6" }, [ + control( + h, + "On-call pill", + segmentedSelect(h, { + ariaLabel: "On-call fixture", + value: model.onCall, + onChange: (fixture) => Message.ClickedOnCallFixture({ fixture }), + options: [ + { value: "onCall", label: "On call" }, + { value: "notOnCall", label: "Not on call" }, + { value: "none", label: "No schedules" }, + ], + }), + ), + control( + h, + "Current page", + segmentedSelect(h, { + ariaLabel: "Current page", + value: model.standOn, + onChange: (standOn) => Message.ClickedStandOn({ standOn }), + options: [ + { value: "lab", label: "Lab (no group)" }, + { value: "alerts", label: "Alerts (Triage)" }, + ], + }), + ), + ]), + sheet(h, model), + ], + ) + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Id("app")], + labShellView(h, model.shell, toShell, { + currentPath: model.standOn === "alerts" ? "/alerts" : LAB_PATH, + breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], + page: () => page(h, model), + }), + ) diff --git a/apps/web/src/page/kit/sheet-param.ts b/apps/web/src/page/kit/sheet-param.ts new file mode 100644 index 0000000000..68d05b7c5d --- /dev/null +++ b/apps/web/src/page/kit/sheet-param.ts @@ -0,0 +1,33 @@ +import { Option, Schema } from "effect" +import { PageOutMessage } from "../contract" + +/** + * A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the + * link reopens it), and dismissing replaces it away. Pair with `followUrl` / `isDismissed`. + */ +export const SHEET_PARAM = "sheet" + +/** Spread into a route's search schema. */ +export const SheetSearchFields = { [SHEET_PARAM]: Schema.optional(Schema.String) } + +const BASE = "http://maple.local" + +/** `href` with `?sheet=` set to `id`, or removed when `id` is undefined; other params stay. */ +export const withSheetParam = (href: string, id: string | undefined): string => { + const url = new URL(href, BASE) + if (id === undefined) url.searchParams.delete(SHEET_PARAM) + else url.searchParams.set(SHEET_PARAM, id) + return `${url.pathname}${url.search}${url.hash}` +} + +/** The open sheet named in `href`, if any. */ +export const sheetParamOf = (href: string): Option.Option => + Option.filter(Option.fromNullOr(new URL(href, BASE).searchParams.get(SHEET_PARAM)), (id) => id !== "") + +/** A page opens the sheet: a new history entry, so Back closes it. */ +export const openSheetNavigation = (href: string, id: string): PageOutMessage => + PageOutMessage.RequestedNavigation({ url: withSheetParam(href, id), isReplace: false }) + +/** The sheet was dismissed: the param leaves without adding an entry. */ +export const closeSheetNavigation = (href: string): PageOutMessage => + PageOutMessage.RequestedNavigation({ url: withSheetParam(href, undefined), isReplace: true }) diff --git a/apps/web/src/shell/command.ts b/apps/web/src/shell/command.ts index ebadbbb555..ee7d602a64 100644 --- a/apps/web/src/shell/command.ts +++ b/apps/web/src/shell/command.ts @@ -9,6 +9,8 @@ import { Message, Theme } from "./message" export const NAMESPACE_STORAGE_PREFIX = "maple.global-namespace." /** `maple.infra-surfaces.`: the org's last settled infra surfaces, JSON. */ export const INFRA_SURFACES_STORAGE_PREFIX = "maple.infra-surfaces." +/** The nav group ids the user folded, JSON. */ +export const COLLAPSED_NAV_GROUPS_KEY = "maple.sidebar.collapsed-groups" /** The theme key the app and the pre-paint script in index.html share. */ export const THEME_STORAGE_KEY = "maple-theme" @@ -21,7 +23,7 @@ export class ShellStorageError extends Schema.TaggedError()( }, ) {} -const encodeSurfaces = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))) +const encodeStrings = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))) /** Base UI moves DOM focus to the open popup and to the hovered or arrowed item. */ export const FocusElement = Command.define("FocusElement", { @@ -96,11 +98,22 @@ export const SaveInfraSurfaces = Command.define("SaveInfraSurfaces", { messages: [Message.CompletedSaveInfraSurfaces], execute: ({ orgId, surfaces }) => Effect.sync(() => { - writeLocalStorage(`${INFRA_SURFACES_STORAGE_PREFIX}${orgId}`, encodeSurfaces(surfaces)) + writeLocalStorage(`${INFRA_SURFACES_STORAGE_PREFIX}${orgId}`, encodeStrings(surfaces)) return Message.CompletedSaveInfraSurfaces() }), }) +/** Remembers which nav groups are folded; storage that throws keeps them for this tab only. */ +export const SaveCollapsedNavGroups = Command.define("SaveCollapsedNavGroups", { + args: { groups: Schema.Array(Schema.String) }, + messages: [Message.CompletedSaveNavGroups], + execute: ({ groups }) => + Effect.sync(() => { + writeLocalStorage(COLLAPSED_NAV_GROUPS_KEY, encodeStrings(groups)) + return Message.CompletedSaveNavGroups() + }), +}) + /** `SidebarProvider.setOpen`: the `sidebar_state` cookie (7 days). Never read back. */ export const SaveSidebarState = Command.define("SaveSidebarState", { args: { isOpen: Schema.Boolean }, diff --git a/apps/web/src/shell/data.ts b/apps/web/src/shell/data.ts index a96756bed9..4e32d22ceb 100644 --- a/apps/web/src/shell/data.ts +++ b/apps/web/src/shell/data.ts @@ -7,6 +7,9 @@ import { MapleInternalApiClient } from "../platform/api-clients" import { loadServicesFacets } from "../data/services-facets-probe" import { loadSources, sourcesListKey } from "../page/sources/kit/data" import { Message } from "./message" +import { SessionTenant } from "../data/session-tenant" +import { listOnCallSchedules } from "../page/alerts/shared/oncall-schedules" +import { isoMs } from "../page/alerts/shared/time" /** * Everything the shell loads at boot, over the S1 API clients. The data layer (S6) is designed in @@ -78,3 +81,28 @@ export const LoadNamespaces = Command.define("LoadNamespaces", { return Message.SucceededLoadNamespaces({ orgId, namespaces }) }).pipe(failed(Message.FailedLoadNamespaces({ orgId }))), }) + +// ON CALL + +/** The org's schedules (who holds each now, as the API reads it) and who is looking. */ +export const LoadOnCall = Command.define("LoadOnCall", { + args: orgArgs, + messages: [Message.SucceededLoadOnCall, Message.FailedLoadOnCall], + execute: ({ orgId }) => + Effect.gen(function* () { + const tenant = yield* (yield* SessionTenant).get + const schedules = yield* listOnCallSchedules + return Message.SucceededLoadOnCall({ + orgId, + snapshot: { + userId: tenant.userId, + shifts: Arr.map(schedules, (schedule) => ({ + scheduleName: schedule.name, + timeZone: schedule.time_zone, + userId: schedule.on_call_now?.user_id ?? null, + endsAtMs: schedule.on_call_now === null ? null : isoMs(schedule.on_call_now.ends_at), + })), + }, + }) + }).pipe(failed(Message.FailedLoadOnCall({ orgId }))), +}) diff --git a/apps/web/src/shell/flags.ts b/apps/web/src/shell/flags.ts index c320341099..644f4a4fbd 100644 --- a/apps/web/src/shell/flags.ts +++ b/apps/web/src/shell/flags.ts @@ -3,6 +3,7 @@ import { Effect, Option, Schema, Array as Arr, Record as Rec, String as Str } fr import { readQuotaApproachingDismissed } from "./billing-banners" import { readDashboardFavorites } from "./favorites" import { + COLLAPSED_NAV_GROUPS_KEY, INFRA_SURFACES_STORAGE_PREFIX, NAMESPACE_STORAGE_PREFIX, ShellStorageError, @@ -61,6 +62,12 @@ const readRememberedSurfaces = readPrefixed( Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(NavSurface))), ) +const decodeGroups = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Array(Schema.String))) + +/** The folded nav groups; unreadable or blocked storage folds none. */ +export const readCollapsedNavGroups = (): ReadonlyArray => + Option.getOrElse(Option.flatMap(readLocalStorage(COLLAPSED_NAV_GROUPS_KEY), decodeGroups), () => []) + /** Persisted shell UI state, read once before the first render. */ export const readShellFlags: Effect.Effect = Effect.gen(function* () { const pinnedNamespaces = yield* readPinnedNamespaces @@ -71,6 +78,7 @@ export const readShellFlags: Effect.Effect = Effect.gen(function* () theme: readTheme(), pinnedNamespaces, dashboardFavorites: readDashboardFavorites(), + collapsedNavGroups: readCollapsedNavGroups(), rememberedSurfaces: yield* readRememberedSurfaces, isQuotaApproachingDismissed: yield* readQuotaApproachingDismissed, platform: detectPlatform(), diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index 9fc3eedb40..78b2bea6ee 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -8,6 +8,8 @@ export { init, Model, type ShellClerk } from "./model" export { favoritesChanges, filterViewportChanges, + onCallHandoff, + onCallHandoffAt, sidebarShortcut, smallViewportChanges, viewportChanges, diff --git a/apps/web/src/shell/message.ts b/apps/web/src/shell/message.ts index 4a2c263971..370ae54310 100644 --- a/apps/web/src/shell/message.ts +++ b/apps/web/src/shell/message.ts @@ -12,6 +12,7 @@ import * as ConnectPanel from "./connect-panel" import * as SupportChannel from "./support-channel" import { PinnedDashboardRow } from "./dashboards-shape" import * as Palette from "./palette/model" +import { OnCallSnapshot } from "./oncall" /** The shell's dropdown menus; a collapsed-rail section menu is keyed by its section title. */ export const Menu = defineTaggedUnion({ @@ -79,6 +80,13 @@ export const Message = defineMessageUnion({ FailedLoadSources: { orgId: Schema.String }, SucceededLoadDashboards: { orgId: Schema.String, dashboards: Schema.Array(PinnedDashboardRow) }, FailedLoadDashboards: { orgId: Schema.String }, + SucceededLoadOnCall: { orgId: Schema.String, snapshot: OnCallSnapshot }, + FailedLoadOnCall: { orgId: Schema.String }, + /** A schedule handed off: who is on call may have changed. */ + ElapsedOnCallHandoff: {}, + /** A nav group's label: fold or unfold its rows. */ + ClickedNavGroup: { groupId: Schema.String }, + CompletedSaveNavGroups: {}, ChangedDashboardFavorites: { favorites: Schema.Array(Schema.String) }, SucceededLoadNamespaces: { orgId: Schema.String, namespaces: Schema.Array(Schema.String) }, FailedLoadNamespaces: { orgId: Schema.String }, diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index 13f1c0df7b..01736502fc 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -16,6 +16,7 @@ import * as Palette from "./palette/model" import { Platform } from "./palette/shortcuts" import { Menu, Theme } from "./message" import { NavSurface } from "./nav" +import { OnCallSnapshot } from "./oncall" import { type InfraSurfaces, PENDING_INTEGRATIONS, @@ -31,6 +32,7 @@ export const InfraPresenceData = AsyncData.Schema(Schema.Array(InfraSurfaceLiter export const DashboardsData = AsyncData.Schema(Schema.Array(PinnedDashboardRow), LoadFailure) export const NamespacesData = AsyncData.Schema(Schema.Array(Schema.String), LoadFailure) export const SourcesData = AsyncData.Schema(Schema.Array(V2Source), LoadFailure) +export const OnCallData = AsyncData.Schema(OnCallSnapshot, LoadFailure) export const OpenMenu = Schema.Struct({ menu: Menu, maybePosition: Schema.Option(PopupPosition) }) export type OpenMenu = typeof OpenMenu.Type @@ -76,6 +78,10 @@ export const Model = Schema.Struct({ /** `maple.infra-surfaces.`: each org's last settled surfaces, so the sidebar lays out once. */ rememberedSurfaces: Schema.Record(Schema.String, Schema.Array(NavSurface)), dashboards: DashboardsData.schema, + /** The org's schedules and who holds each now, for the footer's on-call pill. */ + onCall: OnCallData.schema, + /** `maple.sidebar.collapsed-groups`: nav group ids the user folded. */ + collapsedNavGroups: Schema.Array(Schema.String), namespaces: NamespacesData.schema, /** The org's sources, read once a page links to its source (`source-link.ts`). */ sources: SourcesData.schema, @@ -133,6 +139,7 @@ export type ShellFlags = Readonly<{ theme: Theme pinnedNamespaces: Readonly> dashboardFavorites: ReadonlyArray + collapsedNavGroups?: ReadonlyArray rememberedSurfaces?: Readonly>> isQuotaApproachingDismissed?: boolean platform?: Platform @@ -157,6 +164,8 @@ export const init = (flags: ShellFlags): Model => ({ integrations: PENDING_INTEGRATIONS, rememberedSurfaces: flags.rememberedSurfaces ?? {}, dashboards: DashboardsData.Idle(), + onCall: OnCallData.Idle(), + collapsedNavGroups: flags.collapsedNavGroups ?? [], namespaces: NamespacesData.Idle(), sources: SourcesData.Idle(), namespaceQuery: "", diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts new file mode 100644 index 0000000000..405111c4e6 --- /dev/null +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -0,0 +1,95 @@ +import { Option } from "effect" +import type { HtmlBuilder } from "foldkit/html" +import { Command, click, expect, given, Mount, role, scene, withViewInputs } from "foldkit/scene" +import { describe, expect as expectVitest, test } from "vitest" +import { SaveCollapsedNavGroups } from "./command" +import { Message } from "./message" +import { init, type Model } from "./model" +import { MeasureSidebarOverflow } from "./mounts" +import { update } from "./update" +import { frameView } from "./view" + +// The sidebar's group labels fold their rows, remembered across reloads. + +const flags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const + +const signedIn = (overrides: Partial = {}): Model => ({ + ...init(flags), + maybeOrgId: Option.some("org_parity"), + hasRequestedSidebarData: true, + ...overrides, +}) + +const shellAt = (currentPath: string) => { + const frame = withViewInputs(frameView, { + currentPath, + breadcrumbs: [{ label: "Overview" }], + page: () => null, + topbarActions: () => null, + })() + return (model: Model, h: HtmlBuilder) => frame(model, h) +} + +const analyze = role("button", { name: "Analyze" }) +const dashboards = role("link", { name: "Dashboards" }) +const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) + +describe("sidebar groups", () => { + test("a group label folds its rows, saves the folded set, and unfolds again", () => { + scene( + { update, view: shellAt("/") }, + given(signedIn()), + measured, + expect(analyze).toHaveAttr("aria-expanded", "true"), + expect(dashboards).toExist(), + click(analyze), + Command.expectExact(SaveCollapsedNavGroups({ groups: ["analyze"] })), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(analyze).toHaveAttr("aria-expanded", "false"), + expect(dashboards).toBeAbsent(), + expect(role("link", { name: "Errors" })).toExist(), + click(analyze), + Command.expectExact(SaveCollapsedNavGroups({ groups: [] })), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(dashboards).toExist(), + ) + }) + + test("a folded group read from storage starts folded", () => { + scene( + { update, view: shellAt("/") }, + given(signedIn({ collapsedNavGroups: ["triage"] })), + measured, + expect(role("button", { name: "Triage" })).toHaveAttr("aria-expanded", "false"), + expect(role("link", { name: "Alerts" })).toBeAbsent(), + ) + }) + + test("the group holding the current page stays open and cannot fold", () => { + scene( + { update, view: shellAt("/alerts") }, + given(signedIn({ collapsedNavGroups: ["triage"] })), + measured, + expect(role("button", { name: "Triage" })).toHaveAttr("aria-expanded", "true"), + expect(role("button", { name: "Triage" })).toBeDisabled(), + expect(role("link", { name: "Alerts" })).toExist(), + ) + }) + + test("the collapsed rail shows every row of a folded group", () => { + scene( + { update, view: shellAt("/") }, + given(signedIn({ isSidebarOpen: false, collapsedNavGroups: ["analyze"] })), + measured, + expect(dashboards).toExist(), + ) + }) + + test("folding persists only the groups the user folded", () => { + const folded = update( + update(signedIn(), Message.ClickedNavGroup({ groupId: "monitor" })).model, + Message.ClickedNavGroup({ groupId: "triage" }), + ) + expectVitest(folded.model.collapsedNavGroups).toEqual(["monitor", "triage"]) + }) +}) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts new file mode 100644 index 0000000000..0abc3f0fc8 --- /dev/null +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -0,0 +1,167 @@ +import * as Animation from "@foldkit/ui/animation" +import { Array as Arr, Option } from "effect" +import type { HtmlBuilder } from "foldkit/html" +import { + Command, + expect, + given, + hover, + Mount, + role, + scene, + selector, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect as expectVitest, test } from "vitest" +import * as Tooltip from "@maple/views/ui/tooltip" +import { LoadOnCall } from "./data" +import { Message } from "./message" +import { init, type Model, OnCallData } from "./model" +import { MeasureSidebarOverflow } from "./mounts" +import { nextHandoffMs, type OnCallShift, type OnCallSnapshot, onCallStatusOf } from "./oncall" +import { enter, update } from "./update" +import { frameView } from "./view" + +// The footer's on-call pill: on call until the handoff, not on call, or nothing without schedules. + +const flags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const + +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 in Berlin. */ +const HANDOFF = Date.UTC(2026, 10, 16, 8) + +const shift = (overrides: Partial = {}): OnCallShift => ({ + scheduleName: "Checkout primary", + timeZone: "Europe/Berlin", + userId: "user_me", + endsAtMs: HANDOFF, + ...overrides, +}) + +const withOnCall = (shifts: ReadonlyArray): Model => ({ + ...init(flags), + maybeOrgId: Option.some("org_parity"), + hasRequestedSidebarData: true, + onCall: OnCallData.Success({ data: { userId: "user_me", shifts } }), +}) + +const view = (() => { + const frame = withViewInputs(frameView, { + currentPath: "/", + breadcrumbs: [{ label: "Overview" }], + page: () => null, + topbarActions: () => null, + })() + return (model: Model, h: HtmlBuilder) => frame(model, h) +})() + +const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) +const footer = selector('[data-slot="sidebar-footer"]') + +describe("on-call pill", () => { + test("on call: until the handoff on the schedule's clock, with the schedule, linking to the schedules", () => { + scene( + { update, view }, + given(withOnCall([shift()])), + measured, + expect(footer).toContainText("You're on call"), + expect(footer).toContainText("until Mon 16 Nov, 09:00"), + expect(footer).toContainText("Checkout primary"), + expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + ) + }) + + test("not on call: says so with a way to the schedules", () => { + scene( + { update, view }, + given(withOnCall([shift({ userId: "user_someone" })])), + measured, + expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(footer).toContainText("My schedules"), + ) + }) + + test("an org without schedules shows no pill", () => { + scene( + { update, view }, + given(withOnCall([])), + measured, + expect(text("You're not on call")).toBeAbsent(), + expect(text("My schedules")).toBeAbsent(), + ) + }) + + test("a failed read shows no pill", () => { + scene( + { update, view }, + given({ ...withOnCall([]), onCall: OnCallData.Failure({ error: "Failed" }) }), + measured, + expect(text("My schedules")).toBeAbsent(), + ) + }) + + test("on the collapsed rail the pill is a dot whose tooltip says the sentence", () => { + const pill = role("link", { name: /You're on call/ }) + scene( + { update, view }, + given({ ...withOnCall([shift()]), isSidebarOpen: false }), + measured, + expect(selector('[data-slot="sidebar-footer"] [data-slot="status-dot"]')).toExist(), + hover(pill), + Command.resolve(Tooltip.WaitOpenDelay, Tooltip.Message.ElapsedOpenDelay({ generation: 1 })), + Mount.resolve(Tooltip.MountTooltip, Tooltip.Message.CompletedMountTooltip()), + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 1 }), + ), + Command.resolve( + Animation.WaitForAnimationSettled, + Animation.Message.EndedAnimation({ generation: 1 }), + ), + expect(selector('[data-slot="tooltip-popup"]')).toHaveText( + "You're on call until Mon 16 Nov, 09:00 (Checkout primary)", + ), + ) + }) + + test("the sidebar reads the schedules with its other data, for its org only", () => { + const entered = enter("org_parity")(init(flags)) + expectVitest(Arr.map(entered.commands ?? [], (command) => command.name)).toContain(LoadOnCall.name) + const snapshot: OnCallSnapshot = { userId: "user_me", shifts: [shift()] } + const stale = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_other", snapshot })) + expectVitest(stale.model.onCall._tag).toBe("Loading") + const fresh = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_parity", snapshot })) + expectVitest(fresh.model.onCall).toEqual(OnCallData.Success({ data: snapshot })) + }) + + test("a handoff reads the schedules again", () => { + const loaded = withOnCall([shift()]) + const next = update(loaded, Message.ElapsedOnCallHandoff()) + expectVitest(Arr.map(next.commands ?? [], (command) => command.name)).toEqual([LoadOnCall.name]) + }) +}) + +describe("onCallStatusOf", () => { + test("the user's soonest-ending shift names the pill, counting the others", () => { + const status = onCallStatusOf({ + userId: "user_me", + shifts: [ + shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 86_400_000 }), + shift({ scheduleName: "Checkout primary" }), + shift({ scheduleName: "Search", userId: "user_someone" }), + ], + }) + expectVitest(status).toEqual({ + _tag: "OnCall", + scheduleName: "Checkout primary", + otherSchedules: 1, + until: "Mon 16 Nov, 09:00", + }) + }) + + test("the next handoff is the soonest across every schedule; none in a gap", () => { + const shifts = [shift({ endsAtMs: HANDOFF + 1 }), shift({ userId: null, endsAtMs: null }), shift()] + expectVitest(nextHandoffMs({ userId: "user_me", shifts })).toBe(HANDOFF) + expectVitest(nextHandoffMs({ userId: "user_me", shifts: [shift({ endsAtMs: null })] })).toBeNull() + }) +}) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts new file mode 100644 index 0000000000..97099b2239 --- /dev/null +++ b/apps/web/src/shell/oncall.ts @@ -0,0 +1,74 @@ +import { Array as Arr, Option, Order, Schema } from "effect" +import { formatShiftEdge } from "../page/alerts/shared/oncall-schedules" + +// The sidebar's on-call pill: each schedule's current shift, as the schedules list reads it now. + +/** One schedule's current shift: who holds it until when, `null` in a gap. */ +export const OnCallShift = Schema.Struct({ + scheduleName: Schema.String, + timeZone: Schema.String, + userId: Schema.NullOr(Schema.String), + endsAtMs: Schema.NullOr(Schema.Number), +}) +export type OnCallShift = typeof OnCallShift.Type + +/** The signed-in user and every schedule of the org. */ +export const OnCallSnapshot = Schema.Struct({ + userId: Schema.String, + shifts: Schema.Array(OnCallShift), +}) +export type OnCallSnapshot = typeof OnCallSnapshot.Type + +export type OnCallStatus = + | Readonly<{ _tag: "NoSchedules" }> + | Readonly<{ _tag: "NotOnCall" }> + | Readonly<{ + _tag: "OnCall" + scheduleName: string + /** Other schedules the user also holds now. */ + otherSchedules: number + until: string + }> + +const byEnd = Order.mapInput(Order.Number, (shift: OnCallShift) => shift.endsAtMs ?? Number.POSITIVE_INFINITY) + +/** The user's soonest-ending shift names the pill; an org without schedules shows none. */ +export const onCallStatusOf = (snapshot: OnCallSnapshot): OnCallStatus => { + if (snapshot.shifts.length === 0) return { _tag: "NoSchedules" } + const mine = Arr.sort( + Arr.filter(snapshot.shifts, (shift) => shift.userId === snapshot.userId), + byEnd, + ) + return Option.match(Arr.head(mine), { + onNone: () => ({ _tag: "NotOnCall" }), + onSome: (shift) => ({ + _tag: "OnCall", + scheduleName: shift.scheduleName, + otherSchedules: mine.length - 1, + until: + shift.endsAtMs === null ? "further notice" : formatShiftEdge(shift.endsAtMs, shift.timeZone), + }), + }) +} + +/** The next handoff on any schedule, when who is on call may change. */ +export const nextHandoffMs = (snapshot: OnCallSnapshot): number | null => { + const ends = Arr.flatMap(snapshot.shifts, (shift) => (shift.endsAtMs === null ? [] : [shift.endsAtMs])) + return ends.length === 0 ? null : Math.min(...ends) +} + +/** The pill's schedule line: "Checkout primary", "Checkout primary +1". */ +export const scheduleLabel = (status: Extract): string => + status.otherSchedules === 0 ? status.scheduleName : `${status.scheduleName} +${status.otherSchedules}` + +/** The pill's full sentence: its title, and the collapsed rail's tooltip. */ +export const onCallSentence = (status: OnCallStatus): string => { + switch (status._tag) { + case "OnCall": + return `You're on call until ${status.until} (${scheduleLabel(status)})` + case "NotOnCall": + return "You're not on call" + case "NoSchedules": + return "" + } +} diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index bc9e7aade1..65ee2707bb 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -22,7 +22,7 @@ import { } from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import { FocusElement, SaveInfraSurfaces, SaveNamespace, SaveSidebarState, SaveTheme } from "./command" -import { LoadInfraSurfaces, LoadNamespaces } from "./data" +import { LoadInfraSurfaces, LoadNamespaces, LoadOnCall } from "./data" import { type Integration, Menu, Message, OutMessage } from "./message" import { DashboardsData, init, type Model, NamespacesData } from "./model" import { MeasureSidebarOverflow, MountNamespaceSearch, PositionMenu } from "./mounts" @@ -197,7 +197,8 @@ describe("sidebar collapse", () => { given(signedIn({ isMobile: true })), expect(sidebar).toBeAbsent(), ...open(), - Command.expectExact(LoadInfraSurfaces), + Command.expectExact(LoadInfraSurfaces, LoadOnCall), + Command.resolve(LoadOnCall, Message.FailedLoadOnCall({ orgId: "org_parity" })), Command.resolve(LoadInfraSurfaces, surfacesAnswer("org_parity", [])), Command.expectExact(SaveInfraSurfaces({ orgId: "org_parity", surfaces: [] })), Command.resolve(SaveInfraSurfaces, Message.CompletedSaveInfraSurfaces()), diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index 480f26a23c..076cfaba32 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -1,8 +1,11 @@ import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" -import { Option, Stream, String as Str } from "effect" +import { Clock, Duration, Effect, Option, Stream, String as Str } from "effect" +import { AsyncData } from "foldkit" import { Dom } from "foldkit" import { DASHBOARD_FAVORITES_EVENT, readDashboardFavorites } from "./favorites" import { Message } from "./message" +import type { Model } from "./model" +import { nextHandoffMs } from "./oncall" /** `useIsMobile` (`max-md`): below 768px the sidebar is a sheet. */ export const MOBILE_QUERY = "(max-width: 767px)" @@ -82,3 +85,17 @@ export const favoritesChanges: Stream.Stream = Dom.streamFromEvent({ type: DASHBOARD_FAVORITES_EVENT, mapEvent: () => Message.ChangedDashboardFavorites({ favorites: readDashboardFavorites() }), }) + +/** When the on-call pill next reads the schedules again: the soonest handoff, if any. */ +export const onCallHandoffAt = (model: Model): number | null => + Option.getOrNull(Option.flatMapNullishOr(AsyncData.getData(model.onCall), nextHandoffMs)) + +/** Waits out the handoff (and a few seconds for the API to agree), then asks again. */ +export const onCallHandoff = (atMs: number): Stream.Stream => + Stream.fromEffect( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + yield* Effect.sleep(Duration.millis(Math.max(0, atMs - now) + 5_000)) + return Message.ElapsedOnCallHandoff() + }), + ) diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 66acbbe8eb..b671d58c33 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -11,12 +11,13 @@ import { FocusSiblingMenuItem, FocusShortcutSearch, LogOut, + SaveCollapsedNavGroups, SaveInfraSurfaces, SaveNamespace, SaveSidebarState, SaveTheme, } from "./command" -import { LoadInfraSurfaces, LoadNamespaces, LoadSources } from "./data" +import { LoadInfraSurfaces, LoadNamespaces, LoadOnCall, LoadSources } from "./data" import { DismissQuotaBanner, OpenCustomerPortal } from "./billing-banners" import * as ClerkOrgSwitcher from "./clerk-org-switcher" import * as Checklist from "./onboarding-checklist" @@ -34,6 +35,7 @@ import { initRightSheet, type Model, NamespacesData, + OnCallData, shellProbe, SourcesData, } from "./model" @@ -61,6 +63,7 @@ export const enter = infraPresence: () => InfraPresenceData.Idle(), integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), + onCall: () => OnCallData.Idle(), namespaces: () => NamespacesData.Idle(), sources: () => SourcesData.Idle(), }) @@ -84,12 +87,34 @@ export const enter = const requestSidebarData = (model: Model): UpdateReturn => { if (model.hasRequestedSidebarData || Option.isNone(model.maybeOrgId)) return { model } const surfaces = requestInfraSurfaces(model) + const onCall = readOnCall(surfaces.model) return { - model: modifyFields(surfaces.model, { + model: modifyFields(onCall.model, { hasRequestedSidebarData: () => true, dashboards: () => DashboardsData.Loading(), }), - commands: surfaces.commands ?? [], + commands: [...(surfaces.commands ?? []), ...(onCall.commands ?? [])], + } +} + +/** The footer's on-call pill: read with the sidebar, again at each handoff with the last answer up. */ +const readOnCall = (model: Model): UpdateReturn => + Option.match(Option.all([model.maybeOrgId, AsyncData.revalidateOrLoad(model.onCall)]), { + onNone: () => ({ model }), + onSome: ([orgId, onCall]) => ({ + model: modifyFields(model, { onCall: () => onCall }), + commands: [LoadOnCall({ orgId })], + }), + }) + +/** Folds or unfolds a nav group and remembers it. */ +const toggleNavGroup = (model: Model, groupId: string): UpdateReturn => { + const groups = Array.contains(model.collapsedNavGroups, groupId) + ? Array.filter(model.collapsedNavGroups, (id) => id !== groupId) + : [...model.collapsedNavGroups, groupId] + return { + model: modifyFields(model, { collapsedNavGroups: () => groups }), + commands: [SaveCollapsedNavGroups({ groups })], } } @@ -461,6 +486,17 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => }), } : { model }, + SucceededLoadOnCall: ({ orgId, snapshot }) => + forOrg(model, orgId, (current) => + modifyFields(current, { onCall: () => OnCallData.Success({ data: snapshot }) }), + ), + FailedLoadOnCall: ({ orgId }) => + forOrg(model, orgId, (current) => + modifyFields(current, { onCall: () => OnCallData.Failure({ error: "Failed" }) }), + ), + ElapsedOnCallHandoff: () => readOnCall(model), + ClickedNavGroup: ({ groupId }) => toggleNavGroup(model, groupId), + CompletedSaveNavGroups: () => ({ model }), SucceededLoadNamespaces: ({ orgId, namespaces }) => forOrg(model, orgId, (current) => modifyFields(current, { namespaces: () => NamespacesData.Success({ data: namespaces }) }), diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 77296dc717..215e8d8a29 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -8,6 +8,7 @@ import { sidebarGroup, sidebarGroupContent, sidebarGroupLabel, + sidebarGroupToggle, sidebarMenu, sidebarMenuButton, sidebarMenuItem, @@ -276,6 +277,21 @@ export const pinnedGroup = ( ] } +export type NavGroupFold = "unlabelled" | "expanded" | "collapsed" | "pinned" + +/** + * How a group's rows show: a labelled group folds, except while it holds the current page + * (`pinned`) and on the collapsed rail, where its label is hidden and every row shows. + */ +export const navGroupFold = (model: Model, group: NavGroup, currentPath: string): NavGroupFold => { + if (group.label === undefined) return "unlabelled" + if (Arr.some(group.items, (item) => isNavItemActive(currentPath, item))) return "pinned" + if (!model.isSidebarOpen && !model.isMobile) return "expanded" + return model.collapsedNavGroups.includes(group.id) ? "collapsed" : "expanded" +} + +const navGroupContentId = (group: NavGroup) => `shell-nav-group-${group.id}` + export const navGroupSections = ( h: HtmlBuilder, model: Model, @@ -283,15 +299,32 @@ export const navGroupSections = ( tip: RailTip = NO_TIP, clerk: ShellClerk | null = null, ): ReadonlyArray => - Arr.map(shellNavGroups(clerk), (group) => - sidebarGroup(h, undefined, [ - ...(group.label === undefined ? [] : [sidebarGroupLabel(h, GROUP_LABEL, [group.label])]), - sidebarGroupContent(h, [ - sidebarMenu( - h, - undefined, - Arr.map(group.items, (item) => navRow(h, model, item, currentPath, tip)), - ), - ]), - ]), - ) + Arr.map(shellNavGroups(clerk), (group) => { + const fold = navGroupFold(model, group, currentPath) + return sidebarGroup(h, undefined, [ + ...(group.label === undefined + ? [] + : [ + sidebarGroupToggle(h, { + label: group.label, + isExpanded: fold !== "collapsed", + controlsId: navGroupContentId(group), + onToggle: + fold === "pinned" ? null : Message.ClickedNavGroup({ groupId: group.id }), + }), + ]), + sidebarGroupContent( + h, + fold === "collapsed" + ? [] + : [ + sidebarMenu( + h, + undefined, + Arr.map(group.items, (item) => navRow(h, model, item, currentPath, tip)), + ), + ], + [h.Id(navGroupContentId(group))], + ), + ]) + }) diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 1bf4a398ec..324e27aa37 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -25,6 +25,9 @@ import { orgAvatar } from "./clerk-org-switcher-view" import { typography } from "@maple/views/ui/typography" import { identityLabel } from "@maple/views/ui/identity-label" import { hstack } from "@maple/views/ui/stack" +import { statusDot } from "@maple/views/ui/status-dot" +import { oncallTabHref } from "../page/alerts/shared/oncall-schedules" +import { type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" export const GROUP_LABEL = "h-6 text-muted-foreground" @@ -244,6 +247,85 @@ const footerNavRow = ( ), ]) +/** The pill's state; nothing while the schedules read, after a failed read, or with none. */ +export const shellOnCallStatus = (model: Model): OnCallStatus => + Option.match(AsyncData.getData(model.onCall), { + onNone: (): OnCallStatus => ({ _tag: "NoSchedules" }), + onSome: onCallStatusOf, + }) + +/** + * "You're on call until …" with the schedule, or "You're not on call" with a way to the schedules; + * a dot with the sentence as its tooltip on the collapsed rail. + */ +const onCallPill = ( + h: HtmlBuilder, + currentPath: string, + status: OnCallStatus, + tip: RailTip, +): ReadonlyArray => + status._tag === "NoSchedules" + ? [] + : [ + sidebarMenu(h, undefined, [ + sidebarMenuItem(h, undefined, [ + sidebarMenuButton( + h, + { + size: "lg", + className: + "h-auto gap-2.5 border border-sidebar-border py-1.5 group-data-[collapsible=icon]:border-transparent", + link: { href: oncallTabHref(), currentPath }, + hasTooltip: true, + attributes: [...tip(onCallSentence(status)), h.Title(onCallSentence(status))], + }, + [ + hstack( + h, + { align: "center", justify: "center", className: "size-4 shrink-0" }, + [ + statusDot(h, { + tone: status._tag === "OnCall" ? "ok" : "neutral", + size: "lg", + }), + ], + ), + // Three short lines: the full sentence does not fit the rail's width. + identityLabel( + h, + status._tag === "OnCall" + ? [ + typography( + h, + { size: "xs", weight: "medium", truncate: true }, + ["You're on call"], + ), + typography(h, { size: "xs", truncate: true }, [ + `until ${status.until}`, + ]), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + scheduleLabel(status), + ]), + ] + : [ + typography( + h, + { size: "xs", weight: "medium", truncate: true }, + ["You're not on call"], + ), + typography(h, { size: "xs", tone: "muted", truncate: true }, [ + "My schedules", + ]), + ], + // The rail shows the dot; the tooltip says the sentence. + "group-data-[collapsible=icon]:hidden", + ), + ], + ), + ]), + ]), + ] + /** The user avatar: the Clerk image over the initials on a warm tile keyed by the name. */ export const userAvatar = ( h: HtmlBuilder, @@ -338,6 +420,7 @@ export const shellSidebarFooter = ( ], [], ), + ...onCallPill(h, currentPath, shellOnCallStatus(model), tip), sidebarMenu(h, undefined, [ footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index af4ad7e522..171d7dea3f 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -23,7 +23,14 @@ import { MeasureSidebarOverflow, PositionMenu } from "./mounts" import { isMenuKey } from "./update" import { menuContent } from "./view-menus" import { navGroupSections, pinnedGroup, shellNavGroups } from "./view-nav" -import { NO_TIP, type RailTip, shellSidebarFooter, shellSidebarHeader } from "./view-sidebar" +import { + NO_TIP, + type RailTip, + shellOnCallStatus, + shellSidebarFooter, + shellSidebarHeader, +} from "./view-sidebar" +import { onCallSentence } from "./oncall" import { type BreadcrumbEntry, hidesTopbarConnect, topbar } from "./view-topbar" import { splitPageRails } from "./page-rails" import { filterSheet, filterSheetTrigger, rightSheet, rightSheetTrigger } from "./view-page-sheets" @@ -54,13 +61,17 @@ const sidebarRegions = ( const RAIL_TIP_PREFIX = "shell-rail-tip-" -/** The rows that carry a rail tooltip: Search, the nav rows this org is shown, the footer links. */ -const railTipTitles = (clerk: ShellClerk | null): ReadonlyArray => [ - "Search", - ...Arr.flatMap(shellNavGroups(clerk), (group) => Arr.map(group.items, (item) => item.title)), - "Sources", - "Settings", -] +/** The rows that carry a rail tooltip: Search, the nav rows, the on-call pill, the footer links. */ +const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray => { + const onCall = shellOnCallStatus(model) + return [ + "Search", + ...Arr.flatMap(shellNavGroups(clerk), (group) => Arr.map(group.items, (item) => item.title)), + ...(onCall._tag === "NoSchedules" ? [] : [onCallSentence(onCall)]), + "Sources", + "Settings", + ] +} /** The desktop rail; collapsed, its rows share one Tooltip naming the row (`tooltip={title}`). */ const desktopSidebar = ( @@ -97,7 +108,7 @@ const desktopSidebar = ( isInstant, ), toContent: (triggerId) => [Str.slice(RAIL_TIP_PREFIX.length)(triggerId)], - triggerIds: Arr.map(railTipTitles(clerk), (title) => `${RAIL_TIP_PREFIX}${title}`), + triggerIds: Arr.map(railTipTitles(model, clerk), (title) => `${RAIL_TIP_PREFIX}${title}`), }, toParentMessage: (message) => Message.GotRailTooltipMessage({ message }), }) diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index 628d8a73d8..655b59a89b 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -215,6 +215,21 @@ const root = Subscription.make()((entry) => ({ ), }, ), + /** The on-call pill reads the schedules again once a shift hands off. */ + shellOnCallHandoff: entry( + { atMs: Schema.NullOr(Schema.Number) }, + { + modelToDependencies: (model) => ({ + atMs: model.session._tag === "SignedIn" ? Shell.onCallHandoffAt(model.shell) : null, + }), + dependenciesToStream: ({ atMs }) => + atMs === null + ? Stream.empty + : Stream.map(Shell.onCallHandoff(atMs), (message) => + Message.GotShellMessage({ message }), + ), + }, + ), /** The dashboards list starred or unstarred one (`dashboardFavoritesAtom`). */ shellDashboardFavorites: entry( {}, diff --git a/packages/ui/src/components/ui/sheet.styles.ts b/packages/ui/src/components/ui/sheet.styles.ts index aa1487cabd..c05a45b746 100644 --- a/packages/ui/src/components/ui/sheet.styles.ts +++ b/packages/ui/src/components/ui/sheet.styles.ts @@ -29,12 +29,13 @@ export const sheetPopupRightStyles = "col-start-2 w-[calc(100%-(--spacing(12)))] max-w-md border-s data-ending-style:translate-x-8 data-starting-style:translate-x-8" /** - * A side sheet's width, by what it holds. `default` a form or filter list, `detail` a peek at one - * item, `wide` reading content (a log, a review, a setup guide), `full` a trace waterfall. + * A side sheet's width, by what it holds. `default` a filter list, `detail` a peek at one item, + * `form` a create or edit form, `wide` reading content (a log, a review), `full` a trace waterfall. */ export const sheetSizeStyles = { default: "sm:max-w-md", detail: "sm:max-w-xl", + form: "sm:max-w-2xl", wide: "sm:max-w-3xl", full: "sm:max-w-[min(1100px,calc(100vw-2rem))]", } as const diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index efea20d0f7..f5dfdb194d 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -142,10 +142,10 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/select.ts` `listId`, `itemId` (stateful: Model, init, update): Base UI `Select` (`@maple/ui/select`): the popup opens over the trigger with the selected item on the trigger's value (`alignItemWithTrigger`), or below it when that does not fit. - `packages/views/src/ui/selectable-row.ts` `selectableRow`: `SelectableRow`: a bespoke row's frame (a `div`, or a link with `href`) with `listRow`'s selection lane and tint; a clickable one is a `pressable` with `selectedLane`. - `packages/views/src/ui/setting-row.ts` `settingRow`: Port of packages/ui/src/components/ui/setting-row.tsx. -- `packages/views/src/ui/sheet.ts` `leftSheet`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. +- `packages/views/src/ui/sheet.ts` `leftSheet`, `rightSheet`, `followUrl`, `isDismissed`, `mapSheetCommands`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. - `packages/views/src/ui/shift-timeline.ts` `zonedDayStart`, `placeBar`, `timelineDays`, `barRoomPct`, `shiftTimeline`: `ShiftTimeline`: rows of time bars over a day-ruled axis in one time zone, with a now line (an on-call schedule's layers and its final row). - `packages/views/src/ui/side-panel.ts` `sidePanelFrame`: A detail side panel's frame (span, service, database): a full-height clipped column on the page background. -- `packages/views/src/ui/sidebar.ts` `sidebarWrapper`, `sidebarDesktop`, `sidebarMobile`, `sidebarHeader`, `sidebarFooter`, `sidebarContent`, `sidebarGroup`, `sidebarGroupLabel`, `sidebarGroupContent`, `sidebarGroupAction`, `sidebarSeparator`, `sidebarMenu`, `sidebarMenuItem`, `sidebarMenuBadge`, `sidebarMenuSub`, `sidebarMenuSubItem`, `isLinkActive`, `sidebarMenuButton`, `sidebarMenuSubButton`, `sidebarRail`, `sidebarInset`, `sidebarTrigger`: The Foldkit side of packages/ui/src/components/ui/sidebar.tsx: same markup, same class composition. +- `packages/views/src/ui/sidebar.ts` `sidebarWrapper`, `sidebarDesktop`, `sidebarMobile`, `sidebarHeader`, `sidebarFooter`, `sidebarContent`, `sidebarGroup`, `sidebarGroupLabel`, `sidebarGroupContent`, `sidebarGroupToggle`, `sidebarGroupAction`, `sidebarSeparator`, `sidebarMenu`, `sidebarMenuItem`, `sidebarMenuBadge`, `sidebarMenuSub`, `sidebarMenuSubItem`, `isLinkActive`, `sidebarMenuButton`, `sidebarMenuSubButton`, `sidebarRail`, `sidebarInset`, `sidebarTrigger`: The Foldkit side of packages/ui/src/components/ui/sidebar.tsx: same markup, same class composition. - `packages/views/src/ui/signal-empty-state.ts` `emptyActions`, `filteredEmpty`, `presenceLoading`, `signalEmptyState`: `SignalEmptyState` and `FilteredEmpty`. - `packages/views/src/ui/skeleton.ts` `skeleton`, `skeletonSlowNote`, `skeletonList`, `skeletonBlock`, `skeletonMeterClass`, `skeletonMeter`, `skeletonSwitchClass`, `skeletonSwitch`, `skeletonLine`: Port of packages/ui/src/components/ui/skeleton.tsx. - `packages/views/src/ui/spaced.ts` `spacedClass`, `spaced`: `Spaced`: a block whose children stack with `space-y-*` margins (not a flex column), usually a row's text column that fills the free width and lets its lines truncate. @@ -227,6 +227,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/series-legend.ts` `seriesLegend` - `apps/web/src/page/kit/service-runtimes.ts` `runtimeLink`, `workloadLink`: Where a service runs, as links to the infra pages: its platform resources (`ServiceRuntimesResponse`) and its Kubernetes workloads. - `apps/web/src/page/kit/sheet-detail-header.ts` `sheetDetailHeader`: `SheetDetailHeader`: a detail sheet's eyebrow kind, title, screen-reader description and meta row. +- `apps/web/src/page/kit/sheet-param.ts` `withSheetParam`, `sheetParamOf`, `openSheetNavigation`, `closeSheetNavigation`: A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the link reopens it), and dismissing replaces it away. - `apps/web/src/page/kit/signal-empty-state.ts` `docsLink`, `signalEmptyState`: The dashboard's copy for `SignalEmptyState`: where each signal comes from, and the setup page and guides it links. - `apps/web/src/page/kit/signal-presence.ts` `loadSignalPresences`: Signal presence over `telemetrySignalsAtom`. - `apps/web/src/page/kit/stable-slice.ts` `stableSlice`: A view's inputs as one object, kept by reference while every field is: a `createLazy` slot keyed on it skips the renders that change nothing the view reads. diff --git a/packages/views/src/ui/identity-label.ts b/packages/views/src/ui/identity-label.ts index 30a99e1178..bc3d0a8b0c 100644 --- a/packages/views/src/ui/identity-label.ts +++ b/packages/views/src/ui/identity-label.ts @@ -1,3 +1,4 @@ +import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { typography } from "./typography" @@ -5,5 +6,13 @@ import { typography } from "./typography" // of truncating lines that fills the rest of the row. /** The label's lines (the name, then an email, a region or a namespace). */ -export const identityLabel = (h: HtmlBuilder, lines: ReadonlyArray): Html => - typography(h, { size: "sm", as: "div", className: "grid flex-1 text-left leading-tight" }, lines) +export const identityLabel = ( + h: HtmlBuilder, + lines: ReadonlyArray, + className?: string, +): Html => + typography( + h, + { size: "sm", as: "div", className: cn("grid flex-1 text-left leading-tight", className) }, + lines, + ) diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 860b076795..2ca2ec85fd 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -9,9 +9,13 @@ import { sheetTitleStyles, sheetViewportLeftStyles, sheetViewportStyles, + type SheetSize, } from "@maple/ui/styles/ui/sheet" +import { Command } from "foldkit" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "./button" +import * as Dialog from "./dialog" +import * as DialogView from "./dialog-view" import { icon } from "./icon" // `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. @@ -127,3 +131,75 @@ export const leftSheet = ( ), ], ) + +// RIGHT SHEET + +export type RightSheetOptions = Readonly<{ + sheet: Dialog.Model + title: string + description?: string + /** `form` (672px) by default; `wide` for reading content. */ + size?: SheetSize + /** The scrolling body. */ + body: ReadonlyArray + /** The sticky footer (Cancel, then the primary action); none renders no footer. */ + footer?: ReadonlyArray + toParentMessage: (message: Dialog.Message) => Message +}> + +// Reduced motion keeps the fade and drops the slide. +const REDUCED_MOTION = + "motion-reduce:data-ending-style:translate-x-0 motion-reduce:data-starting-style:translate-x-0" + +/** + * A full-height panel from the right over the Dialog Submodel: title, close, a scrolling body and a + * sticky footer. Escape, the backdrop and the close button close it (`isDismissed`). + */ +export const rightSheet = (h: HtmlBuilder, options: RightSheetOptions): Html => { + const { sheet } = options + return h.submodel({ + slotId: `${sheet.id}-sheet`, + model: sheet, + view: DialogView.view, + viewInputs: { + side: "right", + size: options.size ?? "form", + className: REDUCED_MOTION, + hasDescription: options.description !== undefined, + toView: ({ portal }) => portal, + toContent: () => [ + DialogView.dialogHeader(h, "sheet", [ + DialogView.dialogTitle(h, "sheet", sheet.id, options.title), + ...(options.description === undefined + ? [] + : [DialogView.dialogDescription(h, "sheet", sheet.id, options.description)]), + ]), + DialogView.dialogPanel(h, "sheet", sheet.id, options.body), + ...(options.footer === undefined + ? [] + : [DialogView.dialogFooter(h, "sheet", options.footer)]), + ], + }, + toParentMessage: options.toParentMessage, + }) +} + +export type SheetStep = ReturnType + +/** + * A sheet whose open state lives in the URL: open or close it to match. The Opened/Closed it + * reports are dropped, as the URL already says so. + */ +export const followUrl = (sheet: Dialog.Model, isOpenInUrl: boolean): SheetStep => { + const step = isOpenInUrl ? Dialog.open(sheet) : Dialog.close(sheet) + return { model: step.model, commands: step.commands } +} + +/** The sheet closed itself (Escape, backdrop, close button): the caller clears its URL state. */ +export const isDismissed = (step: SheetStep): boolean => step.outMessage?._tag === "Closed" + +/** A sheet step's commands under the parent's Message. */ +export const mapSheetCommands = ( + step: SheetStep, + toParentMessage: (message: Dialog.Message) => Message, +) => Command.mapMessages(step.commands ?? [], toParentMessage) diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 0ee064f0f3..67c77e1c02 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -206,16 +206,65 @@ export const sidebarGroupLabel = ( children, ) -export const sidebarGroupContent = (h: HtmlBuilder, children: Children): Html => +export const sidebarGroupContent = ( + h: HtmlBuilder, + children: Children, + attributes: Attributes = [], +): Html => h.div( [ h.Class(cn(sidebarGroupContentStyles)), h.DataAttribute("sidebar", "group-content"), h.DataAttribute("slot", "sidebar-group-content"), + ...attributes, ], children, ) +export type SidebarGroupToggleOptions = Readonly<{ + label: string + isExpanded: boolean + /** The `sidebarGroupContent` id it shows and hides. */ + controlsId: string + /** None: the group cannot collapse right now (it holds the current page). */ + onToggle: Message | null +}> + +/** A group label that collapses its rows: a chevron button with `aria-expanded`. */ +export const sidebarGroupToggle = ( + h: HtmlBuilder, + options: SidebarGroupToggleOptions, +): Html => + h.button( + [ + h.Type("button"), + h.Class( + cn( + ...sidebarGroupLabelStyles, + "group/toggle h-6 w-full gap-1 text-left text-muted-foreground", + options.onToggle === null + ? "cursor-default" + : "cursor-pointer hover:text-sidebar-foreground active:text-sidebar-foreground", + ), + ), + h.DataAttribute("sidebar", "group-label"), + h.DataAttribute("slot", "sidebar-group-toggle"), + h.AriaExpanded(options.isExpanded), + h.AriaControls(options.controlsId), + ...(options.onToggle === null ? [h.AriaDisabled(true)] : [h.OnClick(options.onToggle)]), + ], + [ + h.span([h.Class("flex-1 truncate")], [options.label]), + icon(h, "ChevronDownIcon", { + size: 12, + className: cn( + "opacity-0 transition-[opacity,rotate] duration-150 group-hover/toggle:opacity-100 group-focus-visible/toggle:opacity-100 motion-reduce:transition-none", + options.isExpanded ? "" : "-rotate-90 opacity-100", + ), + }), + ], + ) + /** ``: the icon button in a group label's corner. */ export const sidebarGroupAction = ( h: HtmlBuilder, From 5535bc2dddaef3aba75ad20ce61df4b85d674609 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:26:21 +0100 Subject: [PATCH 03/69] fix(shell): sheet history, on-call freshness, foldable active group - Sheet: opening pushes a history entry marked as this tab's; dismissing goes back past it, and only a deep link replaces the param. Kit renamed to sidePanelSheet, footer receives close; sheet-param tests sit beside it. - On-call pill: a shift past its end reads as unknown; reads again at the handoff, hourly while off call, on focus and visibility, and with backoff (60s doubling, capped) after a stale or failed answer, keeping the last good data. Schedule and override writes announce a re-read. - An org with no schedules is cached per org for a day and skips the request; a schedule write clears it. - "until" uses the viewer's time zone with a short zone name. - Sidebar: the collapsed rail renders plain labels (no tab stops); the group holding the current page can fold and keeps only that row. The pill uses aria-label on the rail instead of a title. --- .../src/lab/shell-states-lab.scene.test.ts | 61 ++---- apps/web/src/lab/shell-states-lab.ts | 45 ++--- .../page/alerts/shared/oncall-schedules.ts | 28 ++- apps/web/src/page/kit/sheet-param.test.ts | 56 ++++++ apps/web/src/page/kit/sheet-param.ts | 41 +++- apps/web/src/shell/command.ts | 26 +++ apps/web/src/shell/data.ts | 32 +++- apps/web/src/shell/flags.ts | 8 + apps/web/src/shell/index.ts | 6 +- apps/web/src/shell/message.ts | 19 +- apps/web/src/shell/model.ts | 13 ++ apps/web/src/shell/nav-groups.scene.test.ts | 16 +- apps/web/src/shell/oncall.scene.test.ts | 177 +++++++++++++----- apps/web/src/shell/oncall.ts | 119 ++++++++++-- apps/web/src/shell/shell.scene.test.ts | 2 +- apps/web/src/shell/subscription.ts | 41 +++- apps/web/src/shell/update.ts | 106 +++++++++-- apps/web/src/shell/view-nav.ts | 41 ++-- apps/web/src/shell/view-sidebar.ts | 15 +- apps/web/src/shell/view.ts | 4 +- apps/web/src/subscription.ts | 26 ++- packages/views/src/ui/INDEX.md | 4 +- packages/views/src/ui/sheet.ts | 22 ++- packages/views/src/ui/sidebar.ts | 10 +- 24 files changed, 696 insertions(+), 222 deletions(-) create mode 100644 apps/web/src/page/kit/sheet-param.test.ts diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 56a7b6324c..445addbe8f 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -13,21 +13,15 @@ import { } from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import * as Dialog from "@maple/views/ui/dialog" -import { - closeSheetNavigation, - openSheetNavigation, - sheetParamOf, - withSheetParam, -} from "../page/kit/sheet-param" -import { Array as Arr, Option } from "effect" +import { Array as Arr } from "effect" import { MeasureSidebarOverflow } from "../shell/mounts" import { Message as ShellMessage } from "../shell/message" import { init, Message, type Model, + DismissSheetUrl, PushSheetUrl, - ReplaceSheetUrl, SHEET_ID, update, view, @@ -64,7 +58,7 @@ const leave = (generation: number) => [ ] describe("URL-held right sheet", () => { - test("a link with ?sheet= opens it, and Escape clears the param in place", () => { + test("a link with ?sheet= opens it, and Escape dismisses it through the URL", () => { scene( { update, view }, given(at(`/lab/shell?tab=x&sheet=${SHEET_ID}`)), @@ -74,8 +68,8 @@ describe("URL-held right sheet", () => { expect(dialog).toContainText("Error rate above (%)"), expect(role("button", { name: "Create rule" })).toExist(), keydown(portal, "Escape"), - Command.expectHas(ReplaceSheetUrl({ url: "/lab/shell?tab=x" })), - Command.resolve(ReplaceSheetUrl, Message.CompletedNavigate()), + Command.expectHas(DismissSheetUrl({ href: `/lab/shell?tab=x&sheet=${SHEET_ID}`, id: SHEET_ID })), + Command.resolve(DismissSheetUrl, Message.CompletedNavigate()), // Booting open counted the show as generation 1. ...leave(2), ) @@ -88,7 +82,7 @@ describe("URL-held right sheet", () => { measured, expect(dialog).toBeAbsent(), click(role("button", { name: "New alert rule" })), - Command.expectExact(PushSheetUrl({ url: `/lab/shell?sheet=${SHEET_ID}` })), + Command.expectExact(PushSheetUrl({ href: "/lab/shell", id: SHEET_ID })), Command.resolve(PushSheetUrl, Message.CompletedNavigate()), expect(dialog).toBeAbsent(), Subscription.emit(Message.ChangedUrl({ href: `/lab/shell?sheet=${SHEET_ID}` })), @@ -105,40 +99,21 @@ describe("URL-held right sheet", () => { const back = update(open, Message.ChangedUrl({ href: "/lab/shell" })) expectVitest(back.model.sheet.isOpen).toBe(false) expectVitest(Arr.map(back.commands ?? [], (command) => command.name)).not.toContain( - ReplaceSheetUrl.name, + DismissSheetUrl.name, ) }) - test("Cancel in the footer closes it and clears the param", () => { - const closed = update( - at(`/lab/shell?sheet=${SHEET_ID}`), - Message.GotSheetMessage({ message: Dialog.Message.ClickedClose() }), - ) - expectVitest(closed.model.sheet.isOpen).toBe(false) - expectVitest(Arr.map(closed.commands ?? [], (command) => command.name)).toContain( - ReplaceSheetUrl.name, - ) - }) -}) - -describe("sheet param", () => { - test("sets and clears ?sheet= while keeping the other params and the hash", () => { - expectVitest(withSheetParam("/alerts?tab=rules#top", "new-rule")).toBe( - "/alerts?tab=rules&sheet=new-rule#top", + test("Cancel in the footer dismisses it like Escape", () => { + scene( + { update, view }, + given(at(`/lab/shell?sheet=${SHEET_ID}`)), + measured, + mounted, + expect(dialog).toExist(), + click(role("button", { name: "Cancel" })), + Command.expectHas(DismissSheetUrl({ href: `/lab/shell?sheet=${SHEET_ID}`, id: SHEET_ID })), + Command.resolve(DismissSheetUrl, Message.CompletedNavigate()), + ...leave(2), ) - expectVitest(withSheetParam("/alerts?sheet=new-rule&tab=rules", undefined)).toBe("/alerts?tab=rules") - expectVitest(sheetParamOf("/alerts?sheet=new-rule")).toEqual(Option.some("new-rule")) - expectVitest(sheetParamOf("/alerts?sheet=")).toEqual(Option.none()) - }) - - test("opening pushes a history entry; dismissing replaces it", () => { - expectVitest(openSheetNavigation("/alerts", "new-rule")).toMatchObject({ - url: "/alerts?sheet=new-rule", - isReplace: false, - }) - expectVitest(closeSheetNavigation("/alerts?sheet=new-rule")).toMatchObject({ - url: "/alerts", - isReplace: true, - }) }) }) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 478ea5404f..6d017f71c7 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -2,19 +2,24 @@ import { Effect, Option, Schema } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" -import { pushUrl, replaceUrl } from "foldkit/navigation" import { button } from "@maple/views/ui/button" import * as Dialog from "@maple/views/ui/dialog" import { input } from "@maple/views/ui/input" import { label } from "@maple/views/ui/label" import { segmentedSelect } from "@maple/views/ui/segmented-select" -import { followUrl, isDismissed, mapSheetCommands, rightSheet, type SheetStep } from "@maple/views/ui/sheet" +import { + followUrl, + isDismissed, + mapSheetCommands, + type SheetStep, + sidePanelSheet, +} from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { textarea } from "@maple/views/ui/textarea" import { typography } from "@maple/views/ui/typography" import type { Resources } from "../platform/resources" import { dashboardPage } from "../page/kit/dashboard-page" -import { sheetParamOf, withSheetParam } from "../page/kit/sheet-param" +import { dismissSheetUrl, pushSheetUrl, sheetParamOf } from "../page/kit/sheet-param" import { Message as ShellMessage, Theme } from "../shell/message" import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model" import type { OnCallSnapshot } from "../shell/oncall" @@ -32,20 +37,20 @@ export type OnCallFixture = typeof OnCallFixture.Type export const StandOn = Schema.Literals(["lab", "alerts"]) export type StandOn = typeof StandOn.Type -/** Mon 16 Nov 2026, 08:00 UTC: 09:00 on the schedule's Berlin clock. */ +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 CET for a Berlin viewer. */ const HANDOFF_MS = Date.UTC(2026, 10, 16, 8) const LAB_USER = "user_lab" export const onCallSnapshotOf = (fixture: OnCallFixture): OnCallSnapshot => ({ userId: LAB_USER, + viewerTimeZone: "Europe/Berlin", shifts: fixture === "none" ? [] : [ { scheduleName: "Checkout primary", - timeZone: "Europe/Berlin", userId: fixture === "onCall" ? LAB_USER : "user_other", endsAtMs: HANDOFF_MS, }, @@ -86,18 +91,20 @@ export const Message = defineMessageUnion({ }) export type Message = typeof Message.Type -/** Opening the sheet: a new history entry carrying `?sheet=`. */ +const sheetArgs = { href: Schema.String, id: Schema.String } + +/** Opening the sheet: a history entry carrying `?sheet=`, marked as this tab's. */ export const PushSheetUrl = Command.define("PushSheetUrl", { - args: { url: Schema.String }, + args: sheetArgs, messages: [Message.CompletedNavigate], - execute: ({ url }) => pushUrl(url).pipe(Effect.as(Message.CompletedNavigate())), + execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedNavigate())), }) -/** Dismissing it: the param leaves in place. */ -export const ReplaceSheetUrl = Command.define("ReplaceSheetUrl", { - args: { url: Schema.String }, +/** Dismissing it: back past the entry this tab pushed, else the param is replaced away. */ +export const DismissSheetUrl = Command.define("DismissSheetUrl", { + args: sheetArgs, messages: [Message.CompletedNavigate], - execute: ({ url }) => replaceUrl(url).pipe(Effect.as(Message.CompletedNavigate())), + execute: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedNavigate())), }) type Return = Update.Return @@ -112,7 +119,7 @@ const sheetStep = (model: Model, step: SheetStep): Return => ({ model: { ...model, sheet: step.model }, commands: [ ...mapSheetCommands(step, toSheet), - ...(isDismissed(step) ? [ReplaceSheetUrl({ url: withSheetParam(model.href, undefined) })] : []), + ...(isDismissed(step) ? [DismissSheetUrl({ href: model.href, id: SHEET_ID })] : []), ], }) @@ -146,7 +153,7 @@ export const update = (model: Model, message: Message): Return => }, ClickedNewRule: () => ({ model, - commands: [PushSheetUrl({ url: withSheetParam(model.href, SHEET_ID) })], + commands: [PushSheetUrl({ href: model.href, id: SHEET_ID })], }), ClickedCreateRule: () => sheetStep(model, Dialog.close(model.sheet)), ClickedOnCallFixture: ({ fixture }) => ({ @@ -165,7 +172,7 @@ const field = (h: HtmlBuilder, id: string, text: string, control: Html): H vstack(h, { gap: "2" }, [label(h, { attributes: [h.For(id)] }, [text]), control]) const sheet = (h: HtmlBuilder, model: Model): Html => - rightSheet(h, { + sidePanelSheet(h, { sheet: model.sheet, title: "New alert rule", description: "Page the on-call person when a service's error rate crosses a threshold.", @@ -198,12 +205,8 @@ const sheet = (h: HtmlBuilder, model: Model): Html => ), ]), ], - footer: [ - button( - h, - { variant: "outline", attributes: [h.OnClick(toSheet(Dialog.Message.ClickedClose()))] }, - ["Cancel"], - ), + footer: (close) => [ + button(h, { variant: "outline", attributes: close }, ["Cancel"]), button(h, { attributes: [h.OnClick(Message.ClickedCreateRule())] }, ["Create rule"]), ], }) diff --git a/apps/web/src/page/alerts/shared/oncall-schedules.ts b/apps/web/src/page/alerts/shared/oncall-schedules.ts index 0d2b0dd2fd..fc9c70a19e 100644 --- a/apps/web/src/page/alerts/shared/oncall-schedules.ts +++ b/apps/web/src/page/alerts/shared/oncall-schedules.ts @@ -22,6 +22,14 @@ export const SCHEDULE_LIST_LIMIT = 50 /** 409 on delete while an escalation chain still pages the schedule. */ export const SCHEDULE_IN_USE_CODE = "oncall_schedule_in_use" +/** + * Fired on `window` after a schedule or override is written, so the sidebar's on-call pill reads + * again (a page's single out-message is already its toast or navigation). + */ +export const ONCALL_CHANGED_EVENT = "maple:oncall-schedules-changed" + +const announceOnCallChange = Effect.sync(() => window.dispatchEvent(new Event(ONCALL_CHANGED_EVENT))) + export const listOnCallSchedules = Effect.gen(function* () { const client = yield* MapleApiV2Client const page = yield* client.oncallSchedules.list({ query: { limit: SCHEDULE_LIST_LIMIT } }) @@ -47,13 +55,19 @@ export const saveOnCallSchedule = ( params: { id: editingId }, payload: { ...payload, description: payload.description ?? null }, }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) export const deleteOnCallSchedule = (id: OnCallScheduleId) => Effect.gen(function* () { const client = yield* MapleApiV2Client yield* client.oncallSchedules.delete({ params: { id } }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) /** Each layer's shifts and the final ones over `[fromMs, toMs)`. */ export const listShifts = (id: OnCallScheduleId, fromMs: number, toMs: number) => @@ -98,13 +112,19 @@ export const createOverride = (id: OnCallScheduleId, payload: V2OnCallOverrideCr Effect.gen(function* () { const client = yield* MapleApiV2Client return yield* client.oncallSchedules.createOverride({ params: { id }, payload }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) export const deleteOverride = (id: OnCallScheduleId, overrideId: OnCallOverrideId) => Effect.gen(function* () { const client = yield* MapleApiV2Client yield* client.oncallSchedules.deleteOverride({ params: { id, override_id: overrideId } }) - }).pipe(Effect.mapError(toLoadFailure)) + }).pipe( + Effect.mapError(toLoadFailure), + Effect.tap(() => announceOnCallChange), + ) // LINKS diff --git a/apps/web/src/page/kit/sheet-param.test.ts b/apps/web/src/page/kit/sheet-param.test.ts new file mode 100644 index 0000000000..fb713b1c0b --- /dev/null +++ b/apps/web/src/page/kit/sheet-param.test.ts @@ -0,0 +1,56 @@ +import { Array as Arr, Option } from "effect" +import { describe, expect, test } from "vitest" +import { dismissalOf, SheetHistoryState, sheetParamOf, withSheetParam } from "./sheet-param" + +// `?sheet=` in the URL, and how a dismissal leaves the tab's history. + +type Entry = Readonly<{ url: string; state: unknown }> + +const topOf = (stack: ReadonlyArray): Entry => Option.getOrThrow(Arr.last(stack)) + +/** A tab's history as `pushSheetUrl` and `dismissSheetUrl` move it. */ +const open = (stack: ReadonlyArray, id: string): ReadonlyArray => { + const top = topOf(stack) + const state: typeof SheetHistoryState.Type = { mapleSheet: id } + return [...stack, { url: withSheetParam(top.url, id), state }] +} + +const dismiss = (stack: ReadonlyArray, id: string): ReadonlyArray => { + const top = topOf(stack) + const dismissal = dismissalOf(top.state, top.url, id) + return dismissal._tag === "Back" + ? stack.slice(0, -1) + : [...stack.slice(0, -1), { url: dismissal.url, state: null }] +} + +describe("sheet param", () => { + test("sets and clears ?sheet= while keeping the other params and the hash", () => { + expect(withSheetParam("/alerts?tab=rules#top", "new-rule")).toBe( + "/alerts?tab=rules&sheet=new-rule#top", + ) + expect(withSheetParam("/alerts?sheet=new-rule&tab=rules", undefined)).toBe("/alerts?tab=rules") + expect(sheetParamOf("/alerts?sheet=new-rule")).toEqual(Option.some("new-rule")) + expect(sheetParamOf("/alerts?sheet=")).toEqual(Option.none()) + }) + + test("open, Escape, then one Back leaves the page: the dismissal pops the entry it pushed", () => { + const before: ReadonlyArray = [ + { url: "/services", state: null }, + { url: "/alerts?tab=rules", state: null }, + ] + const closed = dismiss(open(before, "new-rule"), "new-rule") + expect(closed).toEqual(before) + expect(Option.map(Arr.last(closed.slice(0, -1)), (entry) => entry.url)).toEqual( + Option.some("/services"), + ) + }) + + test("a deep link (or a reload into another tab's entry) replaces the param away", () => { + const linked: ReadonlyArray = [{ url: "/alerts?tab=rules&sheet=new-rule", state: null }] + expect(dismiss(linked, "new-rule")).toEqual([{ url: "/alerts?tab=rules", state: null }]) + expect(dismissalOf({ mapleSheet: "other" }, "/alerts?sheet=new-rule", "new-rule")).toEqual({ + _tag: "Replace", + url: "/alerts", + }) + }) +}) diff --git a/apps/web/src/page/kit/sheet-param.ts b/apps/web/src/page/kit/sheet-param.ts index 68d05b7c5d..09cfb5f800 100644 --- a/apps/web/src/page/kit/sheet-param.ts +++ b/apps/web/src/page/kit/sheet-param.ts @@ -1,9 +1,11 @@ -import { Option, Schema } from "effect" -import { PageOutMessage } from "../contract" +import { Effect, Option, Schema } from "effect" +import { back, replaceUrl } from "foldkit/navigation" /** - * A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the - * link reopens it), and dismissing replaces it away. Pair with `followUrl` / `isDismissed`. + * A side sheet held in the URL as `?sheet=`. Opening pushes an entry this tab marks as its own, + * so dismissing it goes back (Back after a close never lands on the same page again); a deep link + * or a sheet opened by another tab's entry replaces the param away instead. Pages wrap + * `pushSheetUrl` / `dismissSheetUrl` in a Command and follow the URL with `followUrl`. */ export const SHEET_PARAM = "sheet" @@ -24,10 +26,29 @@ export const withSheetParam = (href: string, id: string | undefined): string => export const sheetParamOf = (href: string): Option.Option => Option.filter(Option.fromNullOr(new URL(href, BASE).searchParams.get(SHEET_PARAM)), (id) => id !== "") -/** A page opens the sheet: a new history entry, so Back closes it. */ -export const openSheetNavigation = (href: string, id: string): PageOutMessage => - PageOutMessage.RequestedNavigation({ url: withSheetParam(href, id), isReplace: false }) +/** The history entry state `pushSheetUrl` writes. */ +export const SheetHistoryState = Schema.Struct({ mapleSheet: Schema.String }) +const decodeSheetState = Schema.decodeUnknownOption(SheetHistoryState) -/** The sheet was dismissed: the param leaves without adding an entry. */ -export const closeSheetNavigation = (href: string): PageOutMessage => - PageOutMessage.RequestedNavigation({ url: withSheetParam(href, undefined), isReplace: true }) +export type SheetDismissal = Readonly<{ _tag: "Back" }> | Readonly<{ _tag: "Replace"; url: string }> + +/** Back when the current entry is the one this tab pushed for `id`, else replace the param away. */ +export const dismissalOf = (historyState: unknown, href: string, id: string): SheetDismissal => + Option.exists(decodeSheetState(historyState), (state) => state.mapleSheet === id) + ? { _tag: "Back" } + : { _tag: "Replace", url: withSheetParam(href, undefined) } + +/** Opens the sheet: a marked history entry, announced the way foldkit's `pushUrl` does. */ +export const pushSheetUrl = (href: string, id: string): Effect.Effect => + Effect.sync(() => { + const state: typeof SheetHistoryState.Type = { mapleSheet: id } + window.history.pushState(state, "", withSheetParam(href, id)) + window.dispatchEvent(new CustomEvent("foldkit:urlchange")) + }) + +/** Dismisses it (Escape, backdrop, close, Cancel): see `dismissalOf`. */ +export const dismissSheetUrl = (href: string, id: string): Effect.Effect => + Effect.suspend(() => { + const dismissal = dismissalOf(window.history.state, href, id) + return dismissal._tag === "Back" ? back() : replaceUrl(dismissal.url) + }) diff --git a/apps/web/src/shell/command.ts b/apps/web/src/shell/command.ts index ee7d602a64..3a69223ac3 100644 --- a/apps/web/src/shell/command.ts +++ b/apps/web/src/shell/command.ts @@ -114,6 +114,32 @@ export const SaveCollapsedNavGroups = Command.define("SaveCollapsedNavGroups", { }), }) +/** `maple.oncall.no-schedules.`: when the org last answered with no schedules, epoch ms. */ +export const NO_SCHEDULES_STORAGE_PREFIX = "maple.oncall.no-schedules." + +/** Remembers an org's "no schedules" answer, or forgets it (`null`) once it has one. */ +export const SaveNoSchedules = Command.define("SaveNoSchedules", { + args: { orgId: Schema.String, checkedAtMs: Schema.NullOr(Schema.Number) }, + messages: [Message.CompletedSaveNoSchedules], + execute: ({ orgId, checkedAtMs }) => { + const key = `${NO_SCHEDULES_STORAGE_PREFIX}${orgId}` + return ( + checkedAtMs === null + ? Effect.ignore( + Effect.try({ + try: () => localStorage.removeItem(key), + catch: (cause) => + new ShellStorageError({ + message: "Could not clear the on-call cache", + cause, + }), + }), + ) + : Effect.sync(() => writeLocalStorage(key, String(checkedAtMs))) + ).pipe(Effect.as(Message.CompletedSaveNoSchedules())) + }, +}) + /** `SidebarProvider.setOpen`: the `sidebar_state` cookie (7 days). Never read back. */ export const SaveSidebarState = Command.define("SaveSidebarState", { args: { isOpen: Schema.Boolean }, diff --git a/apps/web/src/shell/data.ts b/apps/web/src/shell/data.ts index 4e32d22ceb..f9dbf7960e 100644 --- a/apps/web/src/shell/data.ts +++ b/apps/web/src/shell/data.ts @@ -10,6 +10,8 @@ import { Message } from "./message" import { SessionTenant } from "../data/session-tenant" import { listOnCallSchedules } from "../page/alerts/shared/oncall-schedules" import { isoMs } from "../page/alerts/shared/time" +import { readEffectiveTimeZone } from "../platform/time-zone" +import { isNoSchedulesFresh } from "./oncall" /** * Everything the shell loads at boot, over the S1 API clients. The data layer (S6) is designed in @@ -84,25 +86,45 @@ export const LoadNamespaces = Command.define("LoadNamespaces", { // ON CALL -/** The org's schedules (who holds each now, as the API reads it) and who is looking. */ +/** + * The org's schedules (who holds each now, as the API reads it), who is looking and in which zone. + * An org whose "no schedules" answer is fresh is not asked at all. + */ export const LoadOnCall = Command.define("LoadOnCall", { - args: orgArgs, + args: { orgId: Schema.String, noSchedulesCheckedAtMs: Schema.NullOr(Schema.Number) }, messages: [Message.SucceededLoadOnCall, Message.FailedLoadOnCall], - execute: ({ orgId }) => + execute: ({ orgId, noSchedulesCheckedAtMs }) => Effect.gen(function* () { + const readAtMs = yield* Clock.currentTimeMillis + if (isNoSchedulesFresh(noSchedulesCheckedAtMs ?? undefined, readAtMs)) + return Message.SucceededLoadOnCall({ + orgId, + readAtMs, + isFromCache: true, + snapshot: { userId: "", viewerTimeZone: "UTC", shifts: [] }, + }) const tenant = yield* (yield* SessionTenant).get const schedules = yield* listOnCallSchedules + const viewerTimeZone = yield* readEffectiveTimeZone return Message.SucceededLoadOnCall({ orgId, + readAtMs, + isFromCache: false, snapshot: { userId: tenant.userId, + viewerTimeZone, shifts: Arr.map(schedules, (schedule) => ({ scheduleName: schedule.name, - timeZone: schedule.time_zone, userId: schedule.on_call_now?.user_id ?? null, endsAtMs: schedule.on_call_now === null ? null : isoMs(schedule.on_call_now.ends_at), })), }, }) - }).pipe(failed(Message.FailedLoadOnCall({ orgId }))), + }).pipe( + Effect.catch(() => + Effect.map(Clock.currentTimeMillis, (failedAtMs) => + Message.FailedLoadOnCall({ orgId, failedAtMs }), + ), + ), + ), }) diff --git a/apps/web/src/shell/flags.ts b/apps/web/src/shell/flags.ts index 644f4a4fbd..f9abf86a83 100644 --- a/apps/web/src/shell/flags.ts +++ b/apps/web/src/shell/flags.ts @@ -5,6 +5,7 @@ import { readDashboardFavorites } from "./favorites" import { COLLAPSED_NAV_GROUPS_KEY, INFRA_SURFACES_STORAGE_PREFIX, + NO_SCHEDULES_STORAGE_PREFIX, NAMESPACE_STORAGE_PREFIX, ShellStorageError, THEME_STORAGE_KEY, @@ -68,6 +69,12 @@ const decodeGroups = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Arr export const readCollapsedNavGroups = (): ReadonlyArray => Option.getOrElse(Option.flatMap(readLocalStorage(COLLAPSED_NAV_GROUPS_KEY), decodeGroups), () => []) +/** Every org's last "no schedules" answer, epoch ms; an entry that is not a number is skipped. */ +const readNoScheduleOrgs = readPrefixed( + NO_SCHEDULES_STORAGE_PREFIX, + Schema.decodeUnknownOption(Schema.NumberFromString), +) + /** Persisted shell UI state, read once before the first render. */ export const readShellFlags: Effect.Effect = Effect.gen(function* () { const pinnedNamespaces = yield* readPinnedNamespaces @@ -79,6 +86,7 @@ export const readShellFlags: Effect.Effect = Effect.gen(function* () pinnedNamespaces, dashboardFavorites: readDashboardFavorites(), collapsedNavGroups: readCollapsedNavGroups(), + noScheduleOrgs: yield* readNoScheduleOrgs, rememberedSurfaces: yield* readRememberedSurfaces, isQuotaApproachingDismissed: yield* readQuotaApproachingDismissed, platform: detectPlatform(), diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index 78b2bea6ee..6fa454d18f 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -8,8 +8,10 @@ export { init, Model, type ShellClerk } from "./model" export { favoritesChanges, filterViewportChanges, - onCallHandoff, - onCallHandoffAt, + onCallChanges, + onCallReadAt, + onCallTimer, + onCallWakes, sidebarShortcut, smallViewportChanges, viewportChanges, diff --git a/apps/web/src/shell/message.ts b/apps/web/src/shell/message.ts index 370ae54310..966f002435 100644 --- a/apps/web/src/shell/message.ts +++ b/apps/web/src/shell/message.ts @@ -80,10 +80,21 @@ export const Message = defineMessageUnion({ FailedLoadSources: { orgId: Schema.String }, SucceededLoadDashboards: { orgId: Schema.String, dashboards: Schema.Array(PinnedDashboardRow) }, FailedLoadDashboards: { orgId: Schema.String }, - SucceededLoadOnCall: { orgId: Schema.String, snapshot: OnCallSnapshot }, - FailedLoadOnCall: { orgId: Schema.String }, - /** A schedule handed off: who is on call may have changed. */ - ElapsedOnCallHandoff: {}, + /** `isFromCache`: the org's fresh "no schedules" answer stood in for the request. */ + SucceededLoadOnCall: { + orgId: Schema.String, + snapshot: OnCallSnapshot, + readAtMs: Schema.Number, + isFromCache: Schema.Boolean, + }, + FailedLoadOnCall: { orgId: Schema.String, failedAtMs: Schema.Number }, + /** The pill's next read is due: a handoff, the hourly check off call, or a retry. */ + ElapsedOnCallTimer: { nowMs: Schema.Number }, + /** The tab came back into view or focus. */ + WokeOnCall: { nowMs: Schema.Number }, + /** A page wrote a schedule or an override. */ + ChangedOnCallSchedules: {}, + CompletedSaveNoSchedules: {}, /** A nav group's label: fold or unfold its rows. */ ClickedNavGroup: { groupId: Schema.String }, CompletedSaveNavGroups: {}, diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index 01736502fc..e36edc6948 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -82,6 +82,14 @@ export const Model = Schema.Struct({ onCall: OnCallData.schema, /** `maple.sidebar.collapsed-groups`: nav group ids the user folded. */ collapsedNavGroups: Schema.Array(Schema.String), + /** The pill's clock: when an on-call event last happened, so ended shifts read as unknown. */ + onCallNowMs: Schema.Number, + /** When the pill reads again; `null` for an org without schedules. */ + onCallNextReadAtMs: Schema.NullOr(Schema.Number), + /** The current retry delay; 0 after a good answer. */ + onCallBackoffMs: Schema.Number, + /** `maple.oncall.no-schedules.`: when each org last answered with no schedules. */ + noScheduleOrgs: Schema.Record(Schema.String, Schema.Number), namespaces: NamespacesData.schema, /** The org's sources, read once a page links to its source (`source-link.ts`). */ sources: SourcesData.schema, @@ -140,6 +148,7 @@ export type ShellFlags = Readonly<{ pinnedNamespaces: Readonly> dashboardFavorites: ReadonlyArray collapsedNavGroups?: ReadonlyArray + noScheduleOrgs?: Readonly> rememberedSurfaces?: Readonly>> isQuotaApproachingDismissed?: boolean platform?: Platform @@ -166,6 +175,10 @@ export const init = (flags: ShellFlags): Model => ({ dashboards: DashboardsData.Idle(), onCall: OnCallData.Idle(), collapsedNavGroups: flags.collapsedNavGroups ?? [], + onCallNowMs: 0, + onCallNextReadAtMs: null, + onCallBackoffMs: 0, + noScheduleOrgs: flags.noScheduleOrgs ?? {}, namespaces: NamespacesData.Idle(), sources: SourcesData.Idle(), namespaceQuery: "", diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts index 405111c4e6..77503cb45c 100644 --- a/apps/web/src/shell/nav-groups.scene.test.ts +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -65,23 +65,29 @@ describe("sidebar groups", () => { ) }) - test("the group holding the current page stays open and cannot fold", () => { + test("folding the group holding the current page keeps only the current page's row", () => { + const triage = role("button", { name: "Triage" }) scene( { update, view: shellAt("/alerts") }, - given(signedIn({ collapsedNavGroups: ["triage"] })), + given(signedIn()), measured, - expect(role("button", { name: "Triage" })).toHaveAttr("aria-expanded", "true"), - expect(role("button", { name: "Triage" })).toBeDisabled(), + expect(role("link", { name: "Errors" })).toExist(), + click(triage), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expect(triage).toHaveAttr("aria-expanded", "false"), expect(role("link", { name: "Alerts" })).toExist(), + expect(role("link", { name: "Errors" })).toBeAbsent(), + expect(role("link", { name: "Code review" })).toBeAbsent(), ) }) - test("the collapsed rail shows every row of a folded group", () => { + test("the collapsed rail shows every row of a folded group and its labels are no tab stops", () => { scene( { update, view: shellAt("/") }, given(signedIn({ isSidebarOpen: false, collapsedNavGroups: ["analyze"] })), measured, expect(dashboards).toExist(), + expect(analyze).toBeAbsent(), ) }) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 0abc3f0fc8..5ce01cc3bf 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -15,36 +15,59 @@ import { } from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import * as Tooltip from "@maple/views/ui/tooltip" -import { LoadOnCall } from "./data" import { Message } from "./message" import { init, type Model, OnCallData } from "./model" import { MeasureSidebarOverflow } from "./mounts" -import { nextHandoffMs, type OnCallShift, type OnCallSnapshot, onCallStatusOf } from "./oncall" -import { enter, update } from "./update" +import { + isNoSchedulesFresh, + nextHandoffMs, + ONCALL_IDLE_READ_MS, + ONCALL_RETRY_MS, + type OnCallShift, + type OnCallSnapshot, + onCallStatusOf, +} from "./oncall" +import { enter, update, type UpdateReturn } from "./update" import { frameView } from "./view" -// The footer's on-call pill: on call until the handoff, not on call, or nothing without schedules. +// The footer's on-call pill: what it says, and when it reads the schedules again. const flags = { isMobile: false, theme: "dark", pinnedNamespaces: {}, dashboardFavorites: [] } as const -/** Mon 16 Nov 2026, 08:00 UTC: 09:00 in Berlin. */ +/** Mon 16 Nov 2026, 08:00 UTC: 09:00 CET for a Berlin viewer. */ const HANDOFF = Date.UTC(2026, 10, 16, 8) +const NOW = HANDOFF - 3 * 60 * 60_000 +const ORG = "org_parity" const shift = (overrides: Partial = {}): OnCallShift => ({ scheduleName: "Checkout primary", - timeZone: "Europe/Berlin", userId: "user_me", endsAtMs: HANDOFF, ...overrides, }) -const withOnCall = (shifts: ReadonlyArray): Model => ({ +const snapshotOf = (shifts: ReadonlyArray): OnCallSnapshot => ({ + userId: "user_me", + viewerTimeZone: "Europe/Berlin", + shifts, +}) + +const signedIn = (overrides: Partial = {}): Model => ({ ...init(flags), - maybeOrgId: Option.some("org_parity"), + maybeOrgId: Option.some(ORG), hasRequestedSidebarData: true, - onCall: OnCallData.Success({ data: { userId: "user_me", shifts } }), + ...overrides, }) +const withOnCall = (shifts: ReadonlyArray, overrides: Partial = {}): Model => + signedIn({ onCall: OnCallData.Success({ data: snapshotOf(shifts) }), onCallNowMs: NOW, ...overrides }) + +const answer = (shifts: ReadonlyArray, readAtMs = NOW, isFromCache = false) => + Message.SucceededLoadOnCall({ orgId: ORG, snapshot: snapshotOf(shifts), readAtMs, isFromCache }) + +const commandsOf = (step: UpdateReturn) => + Arr.map(step.commands ?? [], (command) => ({ name: command.name, args: command.args })) + const view = (() => { const frame = withViewInputs(frameView, { currentPath: "/", @@ -59,13 +82,13 @@ const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOv const footer = selector('[data-slot="sidebar-footer"]') describe("on-call pill", () => { - test("on call: until the handoff on the schedule's clock, with the schedule, linking to the schedules", () => { + test("on call: until the handoff on the viewer's clock with its zone, the schedule, a link to schedules", () => { scene( { update, view }, given(withOnCall([shift()])), measured, expect(footer).toContainText("You're on call"), - expect(footer).toContainText("until Mon 16 Nov, 09:00"), + expect(footer).toContainText("until Mon 16 Nov, 09:00 CET"), expect(footer).toContainText("Checkout primary"), expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), ) @@ -81,32 +104,33 @@ describe("on-call pill", () => { ) }) - test("an org without schedules shows no pill", () => { + test("an org without schedules, a first read that failed, or a shift that ended show no pill", () => { + const absent = [ + expect(text("My schedules")).toBeAbsent(), + expect(text("You're on call")).toBeAbsent(), + ] + scene({ update, view }, given(withOnCall([])), measured, ...absent) scene( { update, view }, - given(withOnCall([])), + given(signedIn({ onCall: OnCallData.Failure({ error: "Failed" }) })), measured, - expect(text("You're not on call")).toBeAbsent(), - expect(text("My schedules")).toBeAbsent(), + ...absent, ) - }) - - test("a failed read shows no pill", () => { scene( { update, view }, - given({ ...withOnCall([]), onCall: OnCallData.Failure({ error: "Failed" }) }), + given(withOnCall([shift()], { onCallNowMs: HANDOFF + 1 })), measured, - expect(text("My schedules")).toBeAbsent(), + ...absent, ) }) - test("on the collapsed rail the pill is a dot whose tooltip says the sentence", () => { - const pill = role("link", { name: /You're on call/ }) + test("on the collapsed rail the pill is a dot named by the sentence, which its tooltip shows", () => { + const pill = role("link", { name: "You're on call until Mon 16 Nov, 09:00 CET (Checkout primary)" }) scene( { update, view }, - given({ ...withOnCall([shift()]), isSidebarOpen: false }), + given(withOnCall([shift()], { isSidebarOpen: false })), measured, - expect(selector('[data-slot="sidebar-footer"] [data-slot="status-dot"]')).toExist(), + expect(pill).toExist(), hover(pill), Command.resolve(Tooltip.WaitOpenDelay, Tooltip.Message.ElapsedOpenDelay({ generation: 1 })), Mount.resolve(Tooltip.MountTooltip, Tooltip.Message.CompletedMountTooltip()), @@ -119,49 +143,114 @@ describe("on-call pill", () => { Animation.Message.EndedAnimation({ generation: 1 }), ), expect(selector('[data-slot="tooltip-popup"]')).toHaveText( - "You're on call until Mon 16 Nov, 09:00 (Checkout primary)", + "You're on call until Mon 16 Nov, 09:00 CET (Checkout primary)", ), ) }) +}) - test("the sidebar reads the schedules with its other data, for its org only", () => { - const entered = enter("org_parity")(init(flags)) - expectVitest(Arr.map(entered.commands ?? [], (command) => command.name)).toContain(LoadOnCall.name) - const snapshot: OnCallSnapshot = { userId: "user_me", shifts: [shift()] } - const stale = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_other", snapshot })) +describe("on-call reads", () => { + test("the sidebar reads the schedules with its other data; an answer for another org is dropped", () => { + const entered = enter(ORG)(init(flags)) + expectVitest(commandsOf(entered)).toContainEqual({ + name: "LoadOnCall", + args: { orgId: ORG, noSchedulesCheckedAtMs: null }, + }) + const stale = update(entered.model, { + ...answer([shift()]), + orgId: "org_other", + }) expectVitest(stale.model.onCall._tag).toBe("Loading") - const fresh = update(entered.model, Message.SucceededLoadOnCall({ orgId: "org_parity", snapshot })) - expectVitest(fresh.model.onCall).toEqual(OnCallData.Success({ data: snapshot })) }) - test("a handoff reads the schedules again", () => { + test("an answer reads again after the next handoff, or within the hour while the user is off call", () => { + const onCall = update(signedIn(), answer([shift()])).model + expectVitest(onCall.onCallNextReadAtMs).toBe(HANDOFF + 5_000) + const offCall = update(signedIn(), answer([shift({ userId: "user_someone", endsAtMs: null })])).model + expectVitest(offCall.onCallNextReadAtMs).toBe(NOW + ONCALL_IDLE_READ_MS) + }) + + test("an answer whose handoff has already passed retries with a growing delay", () => { + const late = HANDOFF + 1_000 + const first = update(signedIn(), answer([shift()], late)).model + expectVitest(first.onCallNextReadAtMs).toBe(late + ONCALL_RETRY_MS) + const second = update(first, answer([shift()], late)).model + expectVitest(second.onCallNextReadAtMs).toBe(late + 2 * ONCALL_RETRY_MS) + }) + + test("a failed re-read keeps the last answer up and retries from a minute, doubling", () => { const loaded = withOnCall([shift()]) - const next = update(loaded, Message.ElapsedOnCallHandoff()) - expectVitest(Arr.map(next.commands ?? [], (command) => command.name)).toEqual([LoadOnCall.name]) + const failed = update(loaded, Message.FailedLoadOnCall({ orgId: ORG, failedAtMs: NOW })).model + expectVitest(failed.onCall).toEqual(OnCallData.Success({ data: snapshotOf([shift()]) })) + expectVitest(failed.onCallNextReadAtMs).toBe(NOW + ONCALL_RETRY_MS) + const again = update(failed, Message.FailedLoadOnCall({ orgId: ORG, failedAtMs: NOW })).model + expectVitest(again.onCallNextReadAtMs).toBe(NOW + 2 * ONCALL_RETRY_MS) + }) + + test("the timer reads again; coming back into focus reads only once a minute has passed", () => { + const loaded = withOnCall([shift()]) + expectVitest( + Arr.map( + commandsOf(update(loaded, Message.ElapsedOnCallTimer({ nowMs: HANDOFF }))), + (c) => c.name, + ), + ).toEqual(["LoadOnCall"]) + expectVitest(commandsOf(update(loaded, Message.WokeOnCall({ nowMs: NOW + 10_000 })))).toEqual([]) + expectVitest( + Arr.map(commandsOf(update(loaded, Message.WokeOnCall({ nowMs: NOW + 61_000 }))), (c) => c.name), + ).toEqual(["LoadOnCall"]) + }) + + test("an org with no schedules is remembered, skipped on the next read, and forgotten on a write", () => { + const empty = update(signedIn(), answer([])) + expectVitest(commandsOf(empty)).toEqual([ + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: NOW } }, + ]) + expectVitest(empty.model.onCallNextReadAtMs).toBeNull() + const booted = enter(ORG)(init({ ...flags, noScheduleOrgs: { [ORG]: NOW } })) + expectVitest(commandsOf(booted)).toContainEqual({ + name: "LoadOnCall", + args: { orgId: ORG, noSchedulesCheckedAtMs: NOW }, + }) + const written = update(empty.model, Message.ChangedOnCallSchedules()) + expectVitest(commandsOf(written)).toEqual([ + { name: "LoadOnCall", args: { orgId: ORG, noSchedulesCheckedAtMs: null } }, + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: null } }, + ]) + expectVitest(isNoSchedulesFresh(NOW, NOW + 23 * 60 * 60_000)).toBe(true) + expectVitest(isNoSchedulesFresh(NOW, NOW + 25 * 60 * 60_000)).toBe(false) }) }) describe("onCallStatusOf", () => { - test("the user's soonest-ending shift names the pill, counting the others", () => { - const status = onCallStatusOf({ - userId: "user_me", - shifts: [ + test("the user's soonest-ending live shift names the pill, counting the others", () => { + const status = onCallStatusOf( + snapshotOf([ shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 86_400_000 }), shift({ scheduleName: "Checkout primary" }), shift({ scheduleName: "Search", userId: "user_someone" }), - ], - }) + ]), + NOW, + ) expectVitest(status).toEqual({ _tag: "OnCall", scheduleName: "Checkout primary", otherSchedules: 1, - until: "Mon 16 Nov, 09:00", + until: "Mon 16 Nov, 09:00 CET", + }) + }) + + test("a shift past its end is not trusted: the next live one names the pill, or none does", () => { + const shifts = [shift(), shift({ scheduleName: "Payments", endsAtMs: HANDOFF + 86_400_000 })] + expectVitest(onCallStatusOf(snapshotOf(shifts), HANDOFF + 1)).toMatchObject({ + scheduleName: "Payments", }) + expectVitest(onCallStatusOf(snapshotOf([shift()]), HANDOFF + 1)).toEqual({ _tag: "Unknown" }) }) test("the next handoff is the soonest across every schedule; none in a gap", () => { const shifts = [shift({ endsAtMs: HANDOFF + 1 }), shift({ userId: null, endsAtMs: null }), shift()] - expectVitest(nextHandoffMs({ userId: "user_me", shifts })).toBe(HANDOFF) - expectVitest(nextHandoffMs({ userId: "user_me", shifts: [shift({ endsAtMs: null })] })).toBeNull() + expectVitest(nextHandoffMs(snapshotOf(shifts))).toBe(HANDOFF) + expectVitest(nextHandoffMs(snapshotOf([shift({ endsAtMs: null })]))).toBeNull() }) }) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index 97099b2239..72664980f1 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -1,26 +1,28 @@ import { Array as Arr, Option, Order, Schema } from "effect" -import { formatShiftEdge } from "../page/alerts/shared/oncall-schedules" -// The sidebar's on-call pill: each schedule's current shift, as the schedules list reads it now. +// The sidebar's on-call pill: each schedule's current shift as the schedules list reads it, what +// the pill says, and when it asks again. /** One schedule's current shift: who holds it until when, `null` in a gap. */ export const OnCallShift = Schema.Struct({ scheduleName: Schema.String, - timeZone: Schema.String, userId: Schema.NullOr(Schema.String), endsAtMs: Schema.NullOr(Schema.Number), }) export type OnCallShift = typeof OnCallShift.Type -/** The signed-in user and every schedule of the org. */ +/** The signed-in user, the zone they read times in, and every schedule of the org. */ export const OnCallSnapshot = Schema.Struct({ userId: Schema.String, + viewerTimeZone: Schema.String, shifts: Schema.Array(OnCallShift), }) export type OnCallSnapshot = typeof OnCallSnapshot.Type export type OnCallStatus = | Readonly<{ _tag: "NoSchedules" }> + /** Every shift the user held has ended since the last read: hidden until the next one. */ + | Readonly<{ _tag: "Unknown" }> | Readonly<{ _tag: "NotOnCall" }> | Readonly<{ _tag: "OnCall" @@ -30,23 +32,59 @@ export type OnCallStatus = until: string }> +const untilFormatters = new Map() + +/** "Mon 16 Nov, 09:00 CET" on the viewer's clock, naming the zone; built from parts, as ICU builds differ in the joiner. */ +export const formatUntil = (ms: number, timeZone: string): string => { + const cached = untilFormatters.get(timeZone) + const formatter = + cached ?? + new Intl.DateTimeFormat("en-GB", { + timeZone, + weekday: "short", + day: "numeric", + month: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }) + if (cached === undefined) untilFormatters.set(timeZone, formatter) + const parts = formatter.formatToParts(ms) + const part = (type: Intl.DateTimeFormatPartTypes) => + Option.match( + Arr.findFirst(parts, (candidate) => candidate.type === type), + { onNone: () => "", onSome: (found) => found.value }, + ) + return `${part("weekday")} ${part("day")} ${part("month")}, ${part("hour")}:${part("minute")} ${part("timeZoneName")}` +} + const byEnd = Order.mapInput(Order.Number, (shift: OnCallShift) => shift.endsAtMs ?? Number.POSITIVE_INFINITY) -/** The user's soonest-ending shift names the pill; an org without schedules shows none. */ -export const onCallStatusOf = (snapshot: OnCallSnapshot): OnCallStatus => { +const hasEnded = (shift: OnCallShift, nowMs: number): boolean => + shift.endsAtMs !== null && shift.endsAtMs <= nowMs + +/** + * The user's soonest-ending live shift names the pill. A shift past its end is unknown (the read + * is stale), so a user whose every shift has ended sees nothing until the next read. + */ +export const onCallStatusOf = (snapshot: OnCallSnapshot, nowMs: number): OnCallStatus => { if (snapshot.shifts.length === 0) return { _tag: "NoSchedules" } - const mine = Arr.sort( - Arr.filter(snapshot.shifts, (shift) => shift.userId === snapshot.userId), + const held = Arr.filter(snapshot.shifts, (shift) => shift.userId === snapshot.userId) + const live = Arr.sort( + Arr.filter(held, (shift) => !hasEnded(shift, nowMs)), byEnd, ) - return Option.match(Arr.head(mine), { - onNone: () => ({ _tag: "NotOnCall" }), + return Option.match(Arr.head(live), { + onNone: (): OnCallStatus => (held.length > 0 ? { _tag: "Unknown" } : { _tag: "NotOnCall" }), onSome: (shift) => ({ _tag: "OnCall", scheduleName: shift.scheduleName, - otherSchedules: mine.length - 1, + otherSchedules: live.length - 1, until: - shift.endsAtMs === null ? "further notice" : formatShiftEdge(shift.endsAtMs, shift.timeZone), + shift.endsAtMs === null + ? "further notice" + : formatUntil(shift.endsAtMs, snapshot.viewerTimeZone), }), }) } @@ -57,11 +95,61 @@ export const nextHandoffMs = (snapshot: OnCallSnapshot): number | null => { return ends.length === 0 ? null : Math.min(...ends) } +// WHEN TO ASK AGAIN + +/** The API may lag a handoff by a moment. */ +const HANDOFF_SETTLE_MS = 5_000 +export const ONCALL_RETRY_MS = 60_000 +const ONCALL_RETRY_CAP_MS = 15 * 60_000 +/** A shift starting out of a gap shows up in no handoff, so a user off call checks hourly. */ +export const ONCALL_IDLE_READ_MS = 60 * 60_000 +/** Focus re-reads at most this often. */ +export const ONCALL_WAKE_MIN_MS = 60_000 +/** An org's "no schedules" answer is trusted this long before asking again. */ +export const NO_SCHEDULES_TTL_MS = 24 * 60 * 60_000 + +export type OnCallSchedule = Readonly<{ nextReadAtMs: number | null; backoffMs: number }> + +const backedOff = (nowMs: number, backoffMs: number): OnCallSchedule => { + const next = backoffMs === 0 ? ONCALL_RETRY_MS : Math.min(backoffMs * 2, ONCALL_RETRY_CAP_MS) + return { nextReadAtMs: nowMs + next, backoffMs: next } +} + +/** + * After an answer: at the next handoff, or within the hour while the user is off call; a handoff + * the answer already places in the past (the API has not caught up) retries with backoff. + */ +export const scheduleAfterAnswer = ( + snapshot: OnCallSnapshot, + nowMs: number, + backoffMs: number, +): OnCallSchedule => { + if (snapshot.shifts.length === 0) return { nextReadAtMs: null, backoffMs: 0 } + const handoff = nextHandoffMs(snapshot) + if (handoff !== null && handoff <= nowMs) return backedOff(nowMs, backoffMs) + const isOnCall = onCallStatusOf(snapshot, nowMs)._tag === "OnCall" + const candidates = [ + ...(handoff === null ? [] : [handoff + HANDOFF_SETTLE_MS]), + ...(isOnCall ? [] : [nowMs + ONCALL_IDLE_READ_MS]), + ] + return { nextReadAtMs: candidates.length === 0 ? null : Math.min(...candidates), backoffMs: 0 } +} + +/** After a failed read: retry from a minute, doubling, capped. */ +export const scheduleAfterFailure = (nowMs: number, backoffMs: number): OnCallSchedule => + backedOff(nowMs, backoffMs) + +/** The cached "no schedules" answer for an org is still fresh. */ +export const isNoSchedulesFresh = (checkedAtMs: number | undefined, nowMs: number): boolean => + checkedAtMs !== undefined && nowMs - checkedAtMs < NO_SCHEDULES_TTL_MS + +// COPY + /** The pill's schedule line: "Checkout primary", "Checkout primary +1". */ export const scheduleLabel = (status: Extract): string => status.otherSchedules === 0 ? status.scheduleName : `${status.scheduleName} +${status.otherSchedules}` -/** The pill's full sentence: its title, and the collapsed rail's tooltip. */ +/** The pill's full sentence: the collapsed rail's tooltip and accessible name. */ export const onCallSentence = (status: OnCallStatus): string => { switch (status._tag) { case "OnCall": @@ -69,6 +157,11 @@ export const onCallSentence = (status: OnCallStatus): string => { case "NotOnCall": return "You're not on call" case "NoSchedules": + case "Unknown": return "" } } + +/** The pill shows for these. */ +export const isPillShown = (status: OnCallStatus): boolean => + status._tag === "OnCall" || status._tag === "NotOnCall" diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index 65ee2707bb..f395917878 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -198,7 +198,7 @@ describe("sidebar collapse", () => { expect(sidebar).toBeAbsent(), ...open(), Command.expectExact(LoadInfraSurfaces, LoadOnCall), - Command.resolve(LoadOnCall, Message.FailedLoadOnCall({ orgId: "org_parity" })), + Command.resolve(LoadOnCall, Message.FailedLoadOnCall({ orgId: "org_parity", failedAtMs: 0 })), Command.resolve(LoadInfraSurfaces, surfacesAnswer("org_parity", [])), Command.expectExact(SaveInfraSurfaces({ orgId: "org_parity", surfaces: [] })), Command.resolve(SaveInfraSurfaces, Message.CompletedSaveInfraSurfaces()), diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index 076cfaba32..ec188cab53 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -1,11 +1,10 @@ import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" import { Clock, Duration, Effect, Option, Stream, String as Str } from "effect" -import { AsyncData } from "foldkit" import { Dom } from "foldkit" import { DASHBOARD_FAVORITES_EVENT, readDashboardFavorites } from "./favorites" import { Message } from "./message" import type { Model } from "./model" -import { nextHandoffMs } from "./oncall" +import { ONCALL_CHANGED_EVENT } from "../page/alerts/shared/oncall-schedules" /** `useIsMobile` (`max-md`): below 768px the sidebar is a sheet. */ export const MOBILE_QUERY = "(max-width: 767px)" @@ -86,16 +85,40 @@ export const favoritesChanges: Stream.Stream = Dom.streamFromEvent({ mapEvent: () => Message.ChangedDashboardFavorites({ favorites: readDashboardFavorites() }), }) -/** When the on-call pill next reads the schedules again: the soonest handoff, if any. */ -export const onCallHandoffAt = (model: Model): number | null => - Option.getOrNull(Option.flatMapNullishOr(AsyncData.getData(model.onCall), nextHandoffMs)) +/** When the on-call pill next reads the schedules; `null` while one is in flight or none is due. */ +export const onCallReadAt = (model: Model): number | null => model.onCallNextReadAtMs -/** Waits out the handoff (and a few seconds for the API to agree), then asks again. */ -export const onCallHandoff = (atMs: number): Stream.Stream => +/** Waits until the pill's next read is due. */ +export const onCallTimer = (atMs: number): Stream.Stream => Stream.fromEffect( Effect.gen(function* () { const now = yield* Clock.currentTimeMillis - yield* Effect.sleep(Duration.millis(Math.max(0, atMs - now) + 5_000)) - return Message.ElapsedOnCallHandoff() + yield* Effect.sleep(Duration.millis(Math.max(0, atMs - now))) + return Message.ElapsedOnCallTimer({ nowMs: yield* Clock.currentTimeMillis }) }), ) + +const wakeTargets: Dom.TypedEventTarget<{ focus: FocusEvent; visibilitychange: Event }> = window + +/** The tab came back into view or focus (a handoff may have passed while it slept). */ +export const onCallWakes: Stream.Stream = Stream.merge( + Dom.streamFromEvent({ target: wakeTargets, type: "focus", mapEvent: () => true }), + Dom.streamFromEvent({ + target: document, + type: "visibilitychange", + mapEvent: () => document.visibilityState === "visible", + }), +).pipe( + Stream.filter((isAwake) => isAwake), + Stream.mapEffect(() => Clock.currentTimeMillis), + Stream.map((nowMs) => Message.WokeOnCall({ nowMs })), +) + +const onCallChangeTarget: Dom.TypedEventTarget<{ [ONCALL_CHANGED_EVENT]: Event }> = window + +/** A page wrote a schedule or an override. */ +export const onCallChanges: Stream.Stream = Dom.streamFromEvent({ + target: onCallChangeTarget, + type: ONCALL_CHANGED_EVENT, + mapEvent: () => Message.ChangedOnCallSchedules(), +}) diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index b671d58c33..0bdca57292 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -12,6 +12,7 @@ import { FocusShortcutSearch, LogOut, SaveCollapsedNavGroups, + SaveNoSchedules, SaveInfraSurfaces, SaveNamespace, SaveSidebarState, @@ -27,6 +28,7 @@ import { isSameMenu, popupIdOf, triggerIdOf } from "./menus" import { BUILT_COMMIT, CheckDeployedCommit, ReloadPage } from "./version" import { openPalette, openShortcuts, paletteStep, shortcutsDialogStep, toggleTheme } from "./update-palette" import { Menu, Message, OutMessage } from "./message" +import { ONCALL_WAKE_MIN_MS, type OnCallSnapshot, scheduleAfterAnswer, scheduleAfterFailure } from "./oncall" import { DashboardsData, InfraPresenceData, @@ -64,6 +66,8 @@ export const enter = integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), onCall: () => OnCallData.Idle(), + onCallNextReadAtMs: () => null, + onCallBackoffMs: () => 0, namespaces: () => NamespacesData.Idle(), sources: () => SourcesData.Idle(), }) @@ -97,16 +101,95 @@ const requestSidebarData = (model: Model): UpdateReturn => { } } -/** The footer's on-call pill: read with the sidebar, again at each handoff with the last answer up. */ -const readOnCall = (model: Model): UpdateReturn => +/** + * The footer's on-call pill: read with the sidebar and again when due, the last answer staying up. + * A forced read skips the org's cached "no schedules" answer (a schedule was just written). + */ +const readOnCall = (model: Model, isForced = false): UpdateReturn => Option.match(Option.all([model.maybeOrgId, AsyncData.revalidateOrLoad(model.onCall)]), { onNone: () => ({ model }), onSome: ([orgId, onCall]) => ({ - model: modifyFields(model, { onCall: () => onCall }), - commands: [LoadOnCall({ orgId })], + model: modifyFields(model, { onCall: () => onCall, onCallNextReadAtMs: () => null }), + commands: [ + LoadOnCall({ + orgId, + noSchedulesCheckedAtMs: isForced ? null : (model.noScheduleOrgs[orgId] ?? null), + }), + ], }), }) +/** An answer: schedule the next read, and remember (or forget) that the org has no schedules. */ +const onCallAnswered = ( + model: Model, + answer: Readonly<{ orgId: string; snapshot: OnCallSnapshot; readAtMs: number; isFromCache: boolean }>, +): UpdateReturn => { + const { orgId, snapshot, readAtMs, isFromCache } = answer + if (!Option.contains(model.maybeOrgId, orgId)) return { model } + const next = scheduleAfterAnswer(snapshot, readAtMs, model.onCallBackoffMs) + const isEmpty = snapshot.shifts.length === 0 + const wasEmpty = model.noScheduleOrgs[orgId] !== undefined + return { + model: modifyFields(model, { + onCall: () => OnCallData.Success({ data: snapshot }), + onCallNowMs: () => readAtMs, + onCallNextReadAtMs: () => next.nextReadAtMs, + onCallBackoffMs: () => next.backoffMs, + noScheduleOrgs: (all) => + isFromCache ? all : isEmpty ? Record.set(all, orgId, readAtMs) : Record.remove(all, orgId), + }), + commands: isFromCache + ? [] + : isEmpty + ? [SaveNoSchedules({ orgId, checkedAtMs: readAtMs })] + : wasEmpty + ? [SaveNoSchedules({ orgId, checkedAtMs: null })] + : [], + } +} + +/** A failed read keeps the last good answer up and retries with backoff. */ +const onCallFailed = (model: Model, orgId: string, failedAtMs: number): UpdateReturn => { + if (!Option.contains(model.maybeOrgId, orgId)) return { model } + const next = scheduleAfterFailure(failedAtMs, model.onCallBackoffMs) + return { + model: modifyFields(model, { + onCall: (current) => + Option.match(AsyncData.getData(current), { + onNone: () => OnCallData.Failure({ error: "Failed" }), + onSome: (data) => OnCallData.Success({ data }), + }), + onCallNowMs: () => failedAtMs, + onCallNextReadAtMs: () => next.nextReadAtMs, + onCallBackoffMs: () => next.backoffMs, + }), + } +} + +/** Back in view or focus: move the pill's clock, and read again if the last event is a minute old. */ +const onCallWoke = (model: Model, nowMs: number): UpdateReturn => { + const hasSchedules = Option.exists(AsyncData.getData(model.onCall), (data) => data.shifts.length > 0) + if (!hasSchedules) return { model } + const moved = modifyFields(model, { onCallNowMs: () => nowMs }) + return nowMs - model.onCallNowMs >= ONCALL_WAKE_MIN_MS ? readOnCall(moved) : { model: moved } +} + +/** A page wrote a schedule or override: the org may have its first schedule now. */ +const onCallChanged = (model: Model): UpdateReturn => + Option.match(model.maybeOrgId, { + onNone: () => ({ model }), + onSome: (orgId) => { + const read = readOnCall( + modifyFields(model, { noScheduleOrgs: (all) => Record.remove(all, orgId) }), + true, + ) + return { + model: read.model, + commands: [...(read.commands ?? []), SaveNoSchedules({ orgId, checkedAtMs: null })], + } + }, + }) + /** Folds or unfolds a nav group and remembers it. */ const toggleNavGroup = (model: Model, groupId: string): UpdateReturn => { const groups = Array.contains(model.collapsedNavGroups, groupId) @@ -486,15 +569,12 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => }), } : { model }, - SucceededLoadOnCall: ({ orgId, snapshot }) => - forOrg(model, orgId, (current) => - modifyFields(current, { onCall: () => OnCallData.Success({ data: snapshot }) }), - ), - FailedLoadOnCall: ({ orgId }) => - forOrg(model, orgId, (current) => - modifyFields(current, { onCall: () => OnCallData.Failure({ error: "Failed" }) }), - ), - ElapsedOnCallHandoff: () => readOnCall(model), + SucceededLoadOnCall: (answer) => onCallAnswered(model, answer), + FailedLoadOnCall: ({ orgId, failedAtMs }) => onCallFailed(model, orgId, failedAtMs), + ElapsedOnCallTimer: ({ nowMs }) => readOnCall(modifyFields(model, { onCallNowMs: () => nowMs })), + WokeOnCall: ({ nowMs }) => onCallWoke(model, nowMs), + ChangedOnCallSchedules: () => onCallChanged(model), + CompletedSaveNoSchedules: () => ({ model }), ClickedNavGroup: ({ groupId }) => toggleNavGroup(model, groupId), CompletedSaveNavGroups: () => ({ model }), SucceededLoadNamespaces: ({ orgId, namespaces }) => diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 215e8d8a29..749a57a46f 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -277,16 +277,15 @@ export const pinnedGroup = ( ] } -export type NavGroupFold = "unlabelled" | "expanded" | "collapsed" | "pinned" +export type NavGroupFold = "unlabelled" | "rail" | "expanded" | "collapsed" /** - * How a group's rows show: a labelled group folds, except while it holds the current page - * (`pinned`) and on the collapsed rail, where its label is hidden and every row shows. + * How a group's rows show. The collapsed rail hides labels, so every row shows there; elsewhere a + * labelled group folds to the rows of the current page, if it holds it. */ -export const navGroupFold = (model: Model, group: NavGroup, currentPath: string): NavGroupFold => { +export const navGroupFold = (model: Model, group: NavGroup): NavGroupFold => { if (group.label === undefined) return "unlabelled" - if (Arr.some(group.items, (item) => isNavItemActive(currentPath, item))) return "pinned" - if (!model.isSidebarOpen && !model.isMobile) return "expanded" + if (!model.isSidebarOpen && !model.isMobile) return "rail" return model.collapsedNavGroups.includes(group.id) ? "collapsed" : "expanded" } @@ -300,28 +299,34 @@ export const navGroupSections = ( clerk: ShellClerk | null = null, ): ReadonlyArray => Arr.map(shellNavGroups(clerk), (group) => { - const fold = navGroupFold(model, group, currentPath) + const fold = navGroupFold(model, group) + const rows = + fold === "collapsed" + ? Arr.filter(group.items, (item) => isNavItemActive(currentPath, item)) + : group.items return sidebarGroup(h, undefined, [ ...(group.label === undefined ? [] - : [ - sidebarGroupToggle(h, { - label: group.label, - isExpanded: fold !== "collapsed", - controlsId: navGroupContentId(group), - onToggle: - fold === "pinned" ? null : Message.ClickedNavGroup({ groupId: group.id }), - }), - ]), + : fold === "rail" + ? // Hidden on the rail: a plain label keeps the spacing and is no tab stop. + [sidebarGroupLabel(h, GROUP_LABEL, [group.label])] + : [ + sidebarGroupToggle(h, { + label: group.label, + isExpanded: fold !== "collapsed", + controlsId: navGroupContentId(group), + onToggle: Message.ClickedNavGroup({ groupId: group.id }), + }), + ]), sidebarGroupContent( h, - fold === "collapsed" + rows.length === 0 ? [] : [ sidebarMenu( h, undefined, - Arr.map(group.items, (item) => navRow(h, model, item, currentPath, tip)), + Arr.map(rows, (item) => navRow(h, model, item, currentPath, tip)), ), ], [h.Id(navGroupContentId(group))], diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 324e27aa37..403da11b4e 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -27,7 +27,7 @@ import { identityLabel } from "@maple/views/ui/identity-label" import { hstack } from "@maple/views/ui/stack" import { statusDot } from "@maple/views/ui/status-dot" import { oncallTabHref } from "../page/alerts/shared/oncall-schedules" -import { type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" +import { isPillShown, type OnCallStatus, onCallSentence, onCallStatusOf, scheduleLabel } from "./oncall" export const GROUP_LABEL = "h-6 text-muted-foreground" @@ -251,7 +251,7 @@ const footerNavRow = ( export const shellOnCallStatus = (model: Model): OnCallStatus => Option.match(AsyncData.getData(model.onCall), { onNone: (): OnCallStatus => ({ _tag: "NoSchedules" }), - onSome: onCallStatusOf, + onSome: (snapshot) => onCallStatusOf(snapshot, model.onCallNowMs), }) /** @@ -263,8 +263,9 @@ const onCallPill = ( currentPath: string, status: OnCallStatus, tip: RailTip, + isRail: boolean, ): ReadonlyArray => - status._tag === "NoSchedules" + !isPillShown(status) ? [] : [ sidebarMenu(h, undefined, [ @@ -277,7 +278,11 @@ const onCallPill = ( "h-auto gap-2.5 border border-sidebar-border py-1.5 group-data-[collapsible=icon]:border-transparent", link: { href: oncallTabHref(), currentPath }, hasTooltip: true, - attributes: [...tip(onCallSentence(status)), h.Title(onCallSentence(status))], + // The rail hides the lines, so the link names itself there. + attributes: [ + ...tip(onCallSentence(status)), + ...(isRail ? [h.AriaLabel(onCallSentence(status))] : []), + ], }, [ hstack( @@ -420,7 +425,7 @@ export const shellSidebarFooter = ( ], [], ), - ...onCallPill(h, currentPath, shellOnCallStatus(model), tip), + ...onCallPill(h, currentPath, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), sidebarMenu(h, undefined, [ footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index 171d7dea3f..ae898eb3eb 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -30,7 +30,7 @@ import { shellSidebarFooter, shellSidebarHeader, } from "./view-sidebar" -import { onCallSentence } from "./oncall" +import { isPillShown, onCallSentence } from "./oncall" import { type BreadcrumbEntry, hidesTopbarConnect, topbar } from "./view-topbar" import { splitPageRails } from "./page-rails" import { filterSheet, filterSheetTrigger, rightSheet, rightSheetTrigger } from "./view-page-sheets" @@ -67,7 +67,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), - ...(onCall._tag === "NoSchedules" ? [] : [onCallSentence(onCall)]), + ...(isPillShown(onCall) ? [onCallSentence(onCall)] : []), "Sources", "Settings", ] diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index 655b59a89b..da920252d9 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -215,19 +215,33 @@ const root = Subscription.make()((entry) => ({ ), }, ), - /** The on-call pill reads the schedules again once a shift hands off. */ - shellOnCallHandoff: entry( + /** The on-call pill's next read: a handoff, the hourly check off call, or a retry. */ + shellOnCallTimer: entry( { atMs: Schema.NullOr(Schema.Number) }, { modelToDependencies: (model) => ({ - atMs: model.session._tag === "SignedIn" ? Shell.onCallHandoffAt(model.shell) : null, + atMs: model.session._tag === "SignedIn" ? Shell.onCallReadAt(model.shell) : null, }), dependenciesToStream: ({ atMs }) => atMs === null ? Stream.empty - : Stream.map(Shell.onCallHandoff(atMs), (message) => - Message.GotShellMessage({ message }), - ), + : Stream.map(Shell.onCallTimer(atMs), (message) => Message.GotShellMessage({ message })), + }, + ), + /** Focus and visibility, and a page's schedule writes, refresh the on-call pill. */ + shellOnCallEvents: entry( + { isShellOpen: Schema.Boolean }, + { + modelToDependencies: (model) => ({ + isShellOpen: model.session._tag === "SignedIn" && rendersShell(model.route), + }), + dependenciesToStream: ({ isShellOpen }) => + Stream.when( + Stream.map(Stream.merge(Shell.onCallWakes, Shell.onCallChanges), (message) => + Message.GotShellMessage({ message }), + ), + Effect.sync(() => isShellOpen), + ), }, ), /** The dashboards list starred or unstarred one (`dashboardFavoritesAtom`). */ diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index f5dfdb194d..3ce934a3bd 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -142,7 +142,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `packages/views/src/ui/select.ts` `listId`, `itemId` (stateful: Model, init, update): Base UI `Select` (`@maple/ui/select`): the popup opens over the trigger with the selected item on the trigger's value (`alignItemWithTrigger`), or below it when that does not fit. - `packages/views/src/ui/selectable-row.ts` `selectableRow`: `SelectableRow`: a bespoke row's frame (a `div`, or a link with `href`) with `listRow`'s selection lane and tint; a clickable one is a `pressable` with `selectedLane`. - `packages/views/src/ui/setting-row.ts` `settingRow`: Port of packages/ui/src/components/ui/setting-row.tsx. -- `packages/views/src/ui/sheet.ts` `leftSheet`, `rightSheet`, `followUrl`, `isDismissed`, `mapSheetCommands`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. +- `packages/views/src/ui/sheet.ts` `leftSheet`, `sidePanelSheet`, `followUrl`, `isDismissed`, `mapSheetCommands`: `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. - `packages/views/src/ui/shift-timeline.ts` `zonedDayStart`, `placeBar`, `timelineDays`, `barRoomPct`, `shiftTimeline`: `ShiftTimeline`: rows of time bars over a day-ruled axis in one time zone, with a now line (an on-call schedule's layers and its final row). - `packages/views/src/ui/side-panel.ts` `sidePanelFrame`: A detail side panel's frame (span, service, database): a full-height clipped column on the page background. - `packages/views/src/ui/sidebar.ts` `sidebarWrapper`, `sidebarDesktop`, `sidebarMobile`, `sidebarHeader`, `sidebarFooter`, `sidebarContent`, `sidebarGroup`, `sidebarGroupLabel`, `sidebarGroupContent`, `sidebarGroupToggle`, `sidebarGroupAction`, `sidebarSeparator`, `sidebarMenu`, `sidebarMenuItem`, `sidebarMenuBadge`, `sidebarMenuSub`, `sidebarMenuSubItem`, `isLinkActive`, `sidebarMenuButton`, `sidebarMenuSubButton`, `sidebarRail`, `sidebarInset`, `sidebarTrigger`: The Foldkit side of packages/ui/src/components/ui/sidebar.tsx: same markup, same class composition. @@ -227,7 +227,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/series-legend.ts` `seriesLegend` - `apps/web/src/page/kit/service-runtimes.ts` `runtimeLink`, `workloadLink`: Where a service runs, as links to the infra pages: its platform resources (`ServiceRuntimesResponse`) and its Kubernetes workloads. - `apps/web/src/page/kit/sheet-detail-header.ts` `sheetDetailHeader`: `SheetDetailHeader`: a detail sheet's eyebrow kind, title, screen-reader description and meta row. -- `apps/web/src/page/kit/sheet-param.ts` `withSheetParam`, `sheetParamOf`, `openSheetNavigation`, `closeSheetNavigation`: A right sheet held in the URL as `?sheet=`: opening pushes it (Back closes the sheet, the link reopens it), and dismissing replaces it away. +- `apps/web/src/page/kit/sheet-param.ts` `withSheetParam`, `sheetParamOf`, `dismissalOf`, `pushSheetUrl`, `dismissSheetUrl`: A side sheet held in the URL as `?sheet=`. - `apps/web/src/page/kit/signal-empty-state.ts` `docsLink`, `signalEmptyState`: The dashboard's copy for `SignalEmptyState`: where each signal comes from, and the setup page and guides it links. - `apps/web/src/page/kit/signal-presence.ts` `loadSignalPresences`: Signal presence over `telemetrySignalsAtom`. - `apps/web/src/page/kit/stable-slice.ts` `stableSlice`: A view's inputs as one object, kept by reference while every field is: a `createLazy` slot keyed on it skips the renders that change nothing the view reads. diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 2ca2ec85fd..966070641b 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -12,7 +12,7 @@ import { type SheetSize, } from "@maple/ui/styles/ui/sheet" import { Command } from "foldkit" -import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "./button" import * as Dialog from "./dialog" import * as DialogView from "./dialog-view" @@ -132,9 +132,9 @@ export const leftSheet = ( ], ) -// RIGHT SHEET +// SIDE PANEL SHEET -export type RightSheetOptions = Readonly<{ +export type SidePanelSheetOptions = Readonly<{ sheet: Dialog.Model title: string description?: string @@ -142,8 +142,11 @@ export type RightSheetOptions = Readonly<{ size?: SheetSize /** The scrolling body. */ body: ReadonlyArray - /** The sticky footer (Cancel, then the primary action); none renders no footer. */ - footer?: ReadonlyArray + /** + * The sticky footer (Cancel, then the primary action), handed the close button's attributes so + * Cancel dismisses like Escape does; none renders no footer. + */ + footer?: (close: ReadonlyArray) => ReadonlyArray toParentMessage: (message: Dialog.Message) => Message }> @@ -155,7 +158,10 @@ const REDUCED_MOTION = * A full-height panel from the right over the Dialog Submodel: title, close, a scrolling body and a * sticky footer. Escape, the backdrop and the close button close it (`isDismissed`). */ -export const rightSheet = (h: HtmlBuilder, options: RightSheetOptions): Html => { +export const sidePanelSheet = ( + h: HtmlBuilder, + options: SidePanelSheetOptions, +): Html => { const { sheet } = options return h.submodel({ slotId: `${sheet.id}-sheet`, @@ -167,7 +173,7 @@ export const rightSheet = (h: HtmlBuilder, options: RightSheet className: REDUCED_MOTION, hasDescription: options.description !== undefined, toView: ({ portal }) => portal, - toContent: () => [ + toContent: (close) => [ DialogView.dialogHeader(h, "sheet", [ DialogView.dialogTitle(h, "sheet", sheet.id, options.title), ...(options.description === undefined @@ -177,7 +183,7 @@ export const rightSheet = (h: HtmlBuilder, options: RightSheet DialogView.dialogPanel(h, "sheet", sheet.id, options.body), ...(options.footer === undefined ? [] - : [DialogView.dialogFooter(h, "sheet", options.footer)]), + : [DialogView.dialogFooter(h, "sheet", options.footer(close))]), ], }, toParentMessage: options.toParentMessage, diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 67c77e1c02..528a4fd6cd 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -226,8 +226,7 @@ export type SidebarGroupToggleOptions = Readonly<{ isExpanded: boolean /** The `sidebarGroupContent` id it shows and hides. */ controlsId: string - /** None: the group cannot collapse right now (it holds the current page). */ - onToggle: Message | null + onToggle: Message }> /** A group label that collapses its rows: a chevron button with `aria-expanded`. */ @@ -241,17 +240,14 @@ export const sidebarGroupToggle = ( h.Class( cn( ...sidebarGroupLabelStyles, - "group/toggle h-6 w-full gap-1 text-left text-muted-foreground", - options.onToggle === null - ? "cursor-default" - : "cursor-pointer hover:text-sidebar-foreground active:text-sidebar-foreground", + "group/toggle h-6 w-full cursor-pointer gap-1 text-left text-muted-foreground hover:text-sidebar-foreground active:text-sidebar-foreground", ), ), h.DataAttribute("sidebar", "group-label"), h.DataAttribute("slot", "sidebar-group-toggle"), h.AriaExpanded(options.isExpanded), h.AriaControls(options.controlsId), - ...(options.onToggle === null ? [h.AriaDisabled(true)] : [h.OnClick(options.onToggle)]), + h.OnClick(options.onToggle), ], [ h.span([h.Class("flex-1 truncate")], [options.label]), From 2b0962623c029d7d5c2ca4cf800babb2c20aff70 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:29:31 +0100 Subject: [PATCH 04/69] fix(query-builder): condition builder review fixes Operators come from one engine table (where-key-rules, held to the clause handlers by test), so the builder offers only clauses the query applies. Text mode is explicit state once typed in. Edits rewrite only their own clause and Add condition emits nothing. Single-value rows use a single picker; product-events excludes and user/group ids take one value; values with both quote kinds are refused. Traces intrinsics are named fields and root_only is left to the span select. Rows lay out on a grid where only the values cell wraps, controls carry row-specific labels, and builder state clears when rows re-parse. --- apps/web/src/lab/condition-builder-lab.ts | 10 +- .../query-builder/condition-builder-view.ts | 170 ++++++++++---- .../condition-builder.scene.test.ts | 54 +++-- .../query-builder/condition-fields.test.ts | 60 +++++ .../kit/query-builder/condition-fields.ts | 52 +++-- .../kit/query-builder/condition-rows.test.ts | 54 ++++- .../page/kit/query-builder/condition-rows.ts | 80 ++++++- .../page/kit/query-builder/conditions.test.ts | 67 +++++- .../src/page/kit/query-builder/conditions.ts | 221 +++++++++++------- .../page/kit/query-builder/metrics-body.ts | 3 +- apps/web/src/page/kit/query-builder/panel.ts | 17 +- apps/web/src/page/kit/query-builder/parts.ts | 51 ++-- apps/web/src/page/kit/query-builder/view.ts | 3 +- .../query-engine/src/query-builder/model.ts | 2 + .../src/query-builder/where-key-rules.test.ts | 98 ++++++++ .../src/query-builder/where-key-rules.ts | 137 +++++++++++ packages/views/src/ui/INDEX.md | 8 +- 17 files changed, 879 insertions(+), 208 deletions(-) create mode 100644 apps/web/src/page/kit/query-builder/condition-fields.test.ts create mode 100644 packages/query-engine/src/query-builder/where-key-rules.test.ts create mode 100644 packages/query-engine/src/query-builder/where-key-rules.ts diff --git a/apps/web/src/lab/condition-builder-lab.ts b/apps/web/src/lab/condition-builder-lab.ts index 1df10709d5..77d689dd65 100644 --- a/apps/web/src/lab/condition-builder-lab.ts +++ b/apps/web/src/lab/condition-builder-lab.ts @@ -22,7 +22,13 @@ export const LAB_VALUES: WhereClauseAutocompleteValues = { 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"], + attributeKeys: [ + "http.route", + "http.request.method", + "http.response.status_code", + "db.system", + "messaging.destination.partition.id", + ], resourceAttributeKeys: ["host.name", "k8s.pod.name", "cloud.region"], } @@ -42,7 +48,7 @@ export const SCENARIOS: ReadonlyArray = [ "populated", "B", "traces", - 'service.name = "checkout" AND (attr.http.request.method = "GET" OR attr.http.request.method = "POST") AND resource.k8s.pod.name exists', + 'service.name = "checkout" AND (attr.http.request.method = "GET" OR attr.http.request.method = "POST") AND resource.k8s.pod.name exists AND attr.messaging.destination.partition.id = "12"', ), }, { 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 index cd375741d0..0c9b61487e 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder-view.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -1,8 +1,9 @@ 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 } from "@maple/views/ui/combobox-view" +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" @@ -14,12 +15,13 @@ import { fieldPickerItems, fieldsOf, groupLabelOf, + isOwnedElsewhere, OPERATOR_LABEL, operatorsOf, valueSuggestionsOf, } from "./condition-fields" -import { type ConditionRow, isValueless } from "./condition-rows" -import { conditionPickerId, operatorSelectId } from "./conditions" +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" @@ -94,6 +96,7 @@ const fieldPicker = ( model: Model, query: QueryDraft, row: ConditionRow, + index: number, values: WhereClauseAutocompleteValues, ): Html => { const id = conditionPickerId(query.id, row.id, "field") @@ -111,12 +114,12 @@ const fieldPicker = ( Message.GotConditionField({ queryId: query.id, rowId: row.id, message }), viewInputs: { items: Arr.map(items, (item) => item.value), - ariaLabel: "Field", + ariaLabel: `Field for condition ${index + 1}`, searchPlaceholder: "Search fields…", emptyText: "No fields match", trigger: row.key === "" ? ["Choose field"] : fieldTrigger(h, fieldOf(row.key, query.dataSource)), isPlaceholder: row.key === "", - triggerClassName: "max-w-56", + triggerClassName: "w-full", renderItem: (itemH, value) => { const item = byValue.get(value) return item === undefined @@ -127,42 +130,100 @@ const fieldPicker = ( firstGroup !== null && value === firstGroup.value ? "mt-1 border-t border-border-subtle pt-1" : undefined, - toView: (parts) => h.div([h.Class("contents")], parts), + toView: (parts) => h.div([h.Class("col-start-2 row-start-1 min-w-0")], 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": what a row's remove button removes. */ +const rowSummary = (row: ConditionRow, dataSource: QueryDraft["dataSource"]): string => + row.key === "" + ? "new condition" + : Arr.join( + [ + rowFieldLabel(row, dataSource), + OPERATOR_LABEL[row.operator], + ...(row.values.length === 0 ? [] : [Arr.join(row.values, ", ")]), + ], + " ", + ) + +const UNSPELLABLE_NOTE = `A value can't hold both ' and "` + const valuePicker = ( h: H, model: Model, query: QueryDraft, row: ConditionRow, values: WhereClauseAutocompleteValues, -): Html => { +): ReadonlyArray => { const id = conditionPickerId(query.id, row.id, "values") - const picker = model.conditionPickers[id] ?? Combobox.init(id, "multiple") + const picker = valuePickerOf(model, query, row) const suggestions = valueSuggestionsOf(fieldOf(row.key, query.dataSource), query.dataSource, values) - const typed = Str.trim(picker.inputValue) + 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 !== "" && !suggestions.includes(typed) && !row.values.includes(typed) - return h.submodel({ - slotId: `${model.slotPrefix}-${id}`, - model: { ...picker, selected: row.values }, - view: chipsView, - toParentMessage: (message) => - Message.GotConditionValues({ queryId: query.id, rowId: row.id, message }), - viewInputs: { - items: isTypedNew ? [typed, ...suggestions] : suggestions, - ariaLabel: "Value", - placeholder: row.values.length === 0 ? "Value" : "", - emptyText: "Type a value and press Enter", - className: "text-xs", - renderItem: (_, value) => (isTypedNew && value === typed ? [`Use "${value}"`] : [value]), - toView: (parts) => h.div([h.Class("min-w-40 flex-1")], parts), - }, - }) + const isTypedNew = + typed !== "" && !isUnspellableTyped && !suggestions.includes(typed) && !row.values.includes(typed) + const items = isTypedNew ? [typed, ...suggestions] : suggestions + 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: "Type a value and press Enter", + className: "text-xs", + 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: "Type a value and press Enter", + size: "sm", + showTrigger: false, + highlightsSelected: true, + className: "text-xs", + renderItem, + toView: ({ field: input, rest }) => h.div([h.Class("min-w-0")], [input, ...rest]), + }, + }) + return [field, ...note] } +// Wide: one line of [connector] [field] [operator] [values] [remove]. Narrow: the values cell +// wraps under the field and operator, the rest stays on the first line. +const ROW_GRID = + "grid grid-cols-[2.5rem_minmax(0,1fr)_7rem_auto] items-center gap-1.5 @lg:grid-cols-[2.5rem_minmax(0,14rem)_9rem_minmax(10rem,1fr)_auto]" +const VALUES_CELL = + "col-start-2 col-span-2 row-start-2 min-w-0 @lg:col-start-4 @lg:col-span-1 @lg:row-start-1" +const REMOVE_CELL = "col-start-4 row-start-1 @lg:col-start-5" + const conditionRow = ( h: H, model: Model, @@ -171,69 +232,82 @@ const conditionRow = ( index: number, values: WhereClauseAutocompleteValues, ): Html => { - const connector = typography(h, { size: "xs", tone: "muted", className: "w-10 shrink-0" }, [ + 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", + label: `Remove condition: ${rowSummary(row, query.dataSource)}`, hasTooltip: false, - className: "text-muted-foreground hover:text-foreground active:text-foreground", + 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, values) + const field = fieldPicker(h, model, query, row, index, values) const layout = (parts: ReadonlyArray) => - hstack(h, { gap: "1.5", wrap: true, attributes: [h.DataAttribute("slot", "condition-row")] }, [ - connector, - field, - ...parts, - remove, - ]) + h.div( + [h.Class(ROW_GRID), h.DataAttribute("slot", "condition-row")], + [connector, field, ...parts, remove], + ) if (row.key === "") return layout([]) - const kind = fieldOf(row.key, query.dataSource).kind + const fieldLabel = rowFieldLabel(row, query.dataSource) return panelSelect( h, model, operatorSelectId(query.id, row.id), row.operator, - Arr.map(operatorsOf(kind, row.operator), (operator) => ({ - value: operator, - label: OPERATOR_LABEL[operator], - })), + Arr.map( + operatorsOf(fieldOf(row.key, query.dataSource), query.dataSource, row.operator), + (operator) => ({ + value: operator, + label: OPERATOR_LABEL[operator], + }), + ), { size: "sm", - className: "w-36", - ariaLabel: "Operator", + className: "col-start-3 row-start-1 w-full", + ariaLabel: `Operator for ${fieldLabel}`, toView: (parts) => layout([ ...parts, - ...(isValueless(row.operator) ? [] : [valuePicker(h, model, query, row, values)]), + ...(isValueless(row.operator) + ? [] + : [ + vstack( + h, + { gap: "1", className: VALUES_CELL }, + valuePicker(h, model, query, row, values), + ), + ]), ]), }, ) } -/** The rows, or nothing when there are none: the caller's "Add condition" starts the first. */ +/** 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 => - rows.length === 0 +): ReadonlyArray => { + const shown = Arr.filter(rows, (row) => !isOwnedElsewhere(row.key, query.dataSource)) + return shown.length === 0 ? [] : [ vstack( h, { gap: "1.5", + className: "@container", attributes: [h.AriaLabel(`Conditions for query ${query.name}`), h.Role("group")], }, - Arr.map(rows, (row, index) => conditionRow(h, model, query, row, index, values)), + Arr.map(shown, (row, index) => conditionRow(h, model, query, row, index, values)), ), ] +} 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 index 6d9e2665fd..22ca447025 100644 --- 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 @@ -1,13 +1,11 @@ import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/query-builder" import { - all, click, Command, expect as expectView, given, keydown, label, - last, Mount, role, scene, @@ -64,11 +62,9 @@ const caretMounted = () => }), ) -/** The last row's value picker: the row just added. */ -const newValue = last(all.role("combobox", { name: "Value" })) - 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("")), @@ -78,16 +74,19 @@ describe("condition builder", () => { click(role("option", { name: /^Span attributes/ })), click(role("option", { name: /^http\.route/ })), ...pickerClosed("cond-q-c0-field", 1), - typeInto(role("combobox", { name: "Value" }), "/cart"), + // 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(role("combobox", { name: "Value" }), "Enter"), - typeInto(role("combobox", { name: "Value" }), "/checkout"), - keydown(role("combobox", { name: "Value" }), "Enter"), + 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"')), @@ -96,19 +95,31 @@ describe("condition builder", () => { click(role("option", { name: /^Resource attributes/ })), click(role("option", { name: /^host\.name/ })), ...pickerClosed("cond-q-c1-field", 1), - typeInto(newValue, "web-1"), + typeInto(value, "web-1"), ...pickerOpened(1), - keydown(newValue, "Enter"), + keydown(value, "Enter"), expectView(text('service.name = "checkout" AND resource.host.name = "web-1"')).toExist(), ) }) - test("removing a row prints the rest", () => { + 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("removing a row keeps the other clauses as the user wrote them", () => { scene( { update, view }, - given(hosted('service.name = "checkout" AND attr.http.route exists')), - click(label("Remove condition")), - expectView(text("attr.http.route exists")).toExist(), + given(hosted("service = checkout AND attr.http.route exists")), + click(label("Remove condition: http.route is set")), + expectView(text("service = checkout")).toExist(), ) }) @@ -137,4 +148,17 @@ describe("condition builder", () => { expectView(role("button", { name: "Builder" })).toBeDisabled(), ) }) + + 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..74b4ae8cc3 --- /dev/null +++ b/apps/web/src/page/kit/query-builder/condition-fields.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vitest" +import { canMatchAnyOf, fieldOf, fieldsOf, operatorsOf } 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" }) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-fields.ts b/apps/web/src/page/kit/query-builder/condition-fields.ts index 192ebecf18..88f5161be0 100644 --- a/apps/web/src/page/kit/query-builder/condition-fields.ts +++ b/apps/web/src/page/kit/query-builder/condition-fields.ts @@ -1,4 +1,5 @@ import type { Operator } from "@maple/domain/where-clause" +import { whereKeyMatchesAnyOf, whereKeyRule, whereOperatorsOf } from "@maple/query-engine/query-builder" import { getWhereClauseAutocomplete, type WhereClauseAutocompleteValues, @@ -30,9 +31,17 @@ const COMMON_FIELDS = { 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("metric.type", "Metric type")], + metrics: [ + common("service.name", "Service"), + common("deployment.environment", "Environment"), + common("metric.type", "Metric type"), + ], product_events: [ common("event.name", "Event name"), common("host", "Host"), @@ -73,14 +82,13 @@ export const fieldOf = (key: string, dataSource: DataSource): ConditionField => return { key, label, group: "attribute", kind: kindOfKey(key) } } -/** - * Whether the query engine can OR this field's values: only attribute filters lower an - * `(a OR b)` group; a named dimension (`service.name`) or a product-events query drops it. - */ +/** Whether the query engine ORs this field's values (`whereKeyMatchesAnyOf`: attribute filters only). */ export const canMatchAnyOf = (field: ConditionField, dataSource: DataSource): boolean => - dataSource !== "product_events" && - field.kind !== "boolean" && - (field.group !== "common" || Str.startsWith(ATTRIBUTE_PREFIX)(field.key)) + 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" /** Every field the query offers: the common ones, then resource and own attributes as the editor writes them. */ export const fieldsOf = ( @@ -89,7 +97,7 @@ export const fieldsOf = ( ): ReadonlyArray => Arr.dedupeWith( [ - ...COMMON_FIELDS[dataSource], + ...Arr.filter(COMMON_FIELDS[dataSource], (field) => !isOwnedElsewhere(field.key, dataSource)), ...Arr.map(values.resourceAttributeKeys ?? [], (key) => fieldOf(`${RESOURCE_PREFIX}${key}`, dataSource), ), @@ -171,14 +179,26 @@ export const fieldPickerItems = ( // Operators and values. -/** The operators a field takes, in menu order; a row's current operator always stays offered. */ -export const operatorsOf = (kind: FieldKind, current?: Operator): ReadonlyArray => { +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 = - kind === "number" - ? ["=", "!=", ">", ">=", "<", "<=", "exists", "!exists"] - : kind === "boolean" - ? ["=", "!=", "exists", "!exists"] - : ["=", "!=", "contains", "!contains", "exists", "!exists"] + 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] } 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 index 9a15ec4df8..ce7e69025f 100644 --- a/apps/web/src/page/kit/query-builder/condition-rows.test.ts +++ b/apps/web/src/page/kit/query-builder/condition-rows.test.ts @@ -1,6 +1,14 @@ import { parseWhereClause } from "@maple/domain/where-clause" import { describe, expect, test } from "vitest" -import { type ConditionRow, nextRowId, parseConditionRows, printConditionRows } from "./condition-rows" +import { + type ConditionRow, + isUnspellable, + nextRowId, + parseConditionRows, + printConditionRows, + printConditionRowsOver, + sameConditions, +} from "./condition-rows" import { Array as Arr, Option } from "effect" const rowsOf = (whereClause: string) => @@ -137,3 +145,47 @@ describe("nextRowId", () => { 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)) + + 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)).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, + ), + ).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]) + }) +}) diff --git a/apps/web/src/page/kit/query-builder/condition-rows.ts b/apps/web/src/page/kit/query-builder/condition-rows.ts index c254f11f47..9ec40a1c9e 100644 --- a/apps/web/src/page/kit/query-builder/condition-rows.ts +++ b/apps/web/src/page/kit/query-builder/condition-rows.ts @@ -64,29 +64,72 @@ const partOf = (part: string): Option.Option => { : Option.none() } +/** 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 = (drafts: ReadonlyArray): ReadonlyArray => - Arr.reduce(drafts, Arr.empty(), (rows: ReadonlyArray, draft) => { - if (!isNoneOf(draft.operator)) return [...rows, draft] +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.key === draft.key && row.operator === draft.operator, + (row) => row.draft.key === part.draft.key && row.draft.operator === part.draft.operator, ) return Option.match(index, { - onNone: () => [...rows, draft], + onNone: () => [...rows, part], onSome: (at) => Arr.map(rows, (row, i) => - i === at ? { ...row, values: Arr.dedupe([...row.values, ...draft.values]) } : row, + 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): Option.Option> => + Option.map( + Option.all( + Arr.map(splitWhereClause(Str.trim(whereClause)), (text) => + Option.map(partOf(text), (draft): Spelled => ({ draft, text })), + ), + ), + mergeNoneOf, + ) + /** The rows a where clause reads as, or none when it uses syntax a row cannot show. */ export const parseConditionRows = (whereClause: string): Option.Option> => - Option.map(Option.all(Arr.map(splitWhereClause(Str.trim(whereClause)), partOf)), (drafts) => - Arr.map(mergeNoneOf(drafts), (draft, index) => ({ id: `c${index}`, ...draft })), + Option.map(spelledRows(whereClause), (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) @@ -109,6 +152,27 @@ const printRow = (row: ConditionRow): string => { 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): string => { + const spelled = Option.getOrElse(spelledRows(original), () => 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))) diff --git a/apps/web/src/page/kit/query-builder/conditions.test.ts b/apps/web/src/page/kit/query-builder/conditions.test.ts index 537a799573..5321b475f5 100644 --- a/apps/web/src/page/kit/query-builder/conditions.test.ts +++ b/apps/web/src/page/kit/query-builder/conditions.test.ts @@ -5,11 +5,13 @@ import { init, Message, type Model, operatorSelectId, type PanelInputs, update } // The builder's operator and value steps, as the where-clause edit they emit. -const traces = (whereClause: string): QueryDraft => ({ - ...resetQueryForDataSource({ ...createQueryDraft(0), id: "q", name: "A" }, "traces"), +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"] }), @@ -18,12 +20,18 @@ const inputsFor = (query: QueryDraft): PanelInputs => ({ }) /** The where clause a message leaves as, or null when it leaves none. */ -const emitted = (whereClause: string, message: Message, model: Model = init("test")): string | null => { - const next = update(model, message, inputsFor(traces(whereClause))) +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), @@ -120,3 +128,54 @@ describe("mode", () => { expect(next.model.whereModes).toEqual({ q: "text" }) }) }) + +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"') + }) +}) diff --git a/apps/web/src/page/kit/query-builder/conditions.ts b/apps/web/src/page/kit/query-builder/conditions.ts index 252a1c1012..dc069b6bfa 100644 --- a/apps/web/src/page/kit/query-builder/conditions.ts +++ b/apps/web/src/page/kit/query-builder/conditions.ts @@ -7,20 +7,22 @@ import { QueryChange } from "./change" import { canMatchAnyOf, fieldOf, fieldPickerItems, fieldsOf, operatorsOf } from "./condition-fields" import { type ConditionRow, + isAnyOf, + isNoneOf, + isUnspellable, isValueless, nextRowId, parseConditionRows, - printConditionRows, - isAnyOf, - isNoneOf, + 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: every edit prints the rows and leaves as the query's -// where-clause edit, so previews and saving see only the string. +// 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 @@ -36,36 +38,68 @@ export const operatorSelectId = (queryId: string, rowId: string): string => export const isOperatorSelect = (id: string): boolean => Str.startsWith(`${OPERATOR_SELECT}:`)(id) -/** The rows a query shows: the kept ones while they still print its where clause, else a fresh parse. */ +/** 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), (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> => { - const kept = model.conditions[query.id] - return kept !== undefined && printConditionRows(kept) === query.whereClause - ? Option.some(kept) - : parseConditionRows(query.whereClause) +): Option.Option> => + Option.orElse(keptRowsOf(model, query), () => parseConditionRows(query.whereClause)) + +/** 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)), + } } -/** Whether a query edits as rows: chosen (the default) and representable. */ +/** 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 => ({ - model: { ...model, conditions: { ...model.conditions, [query.id]: rows } }, - ...(commands === undefined ? undefined : { commands }), - outMessage: OutMessage.ChangedQuery({ - queryId: query.id, - change: QueryChange.SetText({ field: "whereClause", value: printConditionRows(rows) }), - }), -}) +): 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), + }), + }), + } +} -type RowContext = Readonly<{ query: QueryDraft; rows: ReadonlyArray }> +type RowContext = Readonly<{ model: Model; query: QueryDraft; rows: ReadonlyArray }> const withRows = ( model: Model, @@ -75,9 +109,22 @@ const withRows = ( ): Return => { const query = inputs?.queryOf(queryId) if (query === undefined) return { model } - return Option.match(conditionRowsOf(model, query), { - onNone: () => ({ model }), - onSome: (rows) => step({ query, rows }), + 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), { + 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, + }) + }, + }), }) } @@ -91,7 +138,7 @@ const withRow = ( withRows(model, queryId, inputs, (context) => Option.match( Arr.findFirst(context.rows, (row) => row.id === rowId), - { onNone: () => ({ model }), onSome: (row) => step({ ...context, row }) }, + { onNone: () => ({ model: context.model }), onSome: (row) => step({ ...context, row }) }, ), ) @@ -99,26 +146,22 @@ const replaced = (rows: ReadonlyArray, next: ConditionRow): Readon Arr.map(rows, (row) => (row.id === next.id ? next : row)) export const changedWhereMode = (model: Model, queryId: string, mode: WhereMode): Return => ({ - model: { - ...model, - whereModes: { ...model.whereModes, [queryId]: mode }, - conditions: Rec.remove(model.conditions, queryId), - }, + model: { ...withoutBuilderState(model, queryId), whereModes: { ...model.whereModes, [queryId]: mode } }, }) -/** A new row opens its field picker, as the first thing to choose. */ +/** 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, ({ query, rows }) => { + withRows(model, queryId, inputs, ({ model: current, query, rows }) => { const id = nextRowId(rows) const pickerId = conditionPickerId(queryId, id, "field") const opened: Model = { - ...model, + ...current, conditionPickers: { - ...model.conditionPickers, + ...current.conditionPickers, [pickerId]: Combobox.init(pickerId, "single", { isOpen: true }), }, } - return emitted(opened, query, [...rows, { id, key: "", operator: "=", values: [] }]) + return emitted(opened, query, rows, [...rows, { id, key: "", operator: "=", values: [] }]) }) export const removedCondition = ( @@ -127,19 +170,19 @@ export const removedCondition = ( rowId: string, inputs: PanelInputs | undefined, ): Return => - withRows(model, queryId, inputs, ({ query, rows }) => { + withRows(model, queryId, inputs, ({ model: current, query, rows }) => { const pickerIds = [ conditionPickerId(queryId, rowId, "field"), conditionPickerId(queryId, rowId, "values"), ] - const pickers = Rec.filter(model.conditionPickers, (_, id) => !pickerIds.includes(id)) return emitted( { - ...model, - conditionPickers: pickers, - selects: Rec.remove(model.selects, operatorSelectId(queryId, rowId)), + ...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), ) }) @@ -152,9 +195,6 @@ const pickedValue = (current: Combobox.Model, message: Combobox.Message): string return null } -const pickerOf = (model: Model, id: string, mode: Combobox.Mode): Combobox.Model => - model.conditionPickers[id] ?? Combobox.init(id, mode) - /** The field picker: a group or "All fields" drills without closing; a field sets the row's key. */ export const fieldStep = ( model: Model, @@ -163,10 +203,11 @@ export const fieldStep = ( message: Combobox.Message, inputs: PanelInputs | undefined, ): Return => - withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + withRow(model, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { const id = conditionPickerId(queryId, rowId, "field") - const current = { ...pickerOf(model, id, "single"), selected: row.key === "" ? [] : [row.key] } - const drill = model.fieldDrills[id] ?? "all" + 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)) const picked = Option.flatMap(Option.fromNullishOr(pickedValue(current, message)), (value) => @@ -182,19 +223,19 @@ export const fieldStep = ( if (Option.isSome(navigated)) return { model: { - ...model, + ...base, conditionPickers: { - ...model.conditionPickers, + ...base.conditionPickers, [id]: { ...current, inputValue: "", highlighted: 0 }, }, - fieldDrills: { ...model.fieldDrills, [id]: navigated.value }, + fieldDrills: { ...base.fieldDrills, [id]: navigated.value }, }, } const next = Combobox.update(current, message) const lifted: Model = { - ...model, - conditionPickers: { ...model.conditionPickers, [id]: next.model }, - fieldDrills: next.model.isOpen ? model.fieldDrills : Rec.remove(model.fieldDrills, id), + ...base, + conditionPickers: { ...base.conditionPickers, [id]: next.model }, + fieldDrills: next.model.isOpen ? base.fieldDrills : Rec.remove(base.fieldDrills, id), } const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotConditionField({ queryId, rowId, message: inner }), @@ -210,20 +251,34 @@ export const fieldStep = ( ) if (next.outMessage?._tag !== "ChangedValue" || key === null || key === row.key) return { model: lifted, commands } - const operators = operatorsOf(fieldOf(key, query.dataSource).kind) - const operator = operators.includes(row.operator) ? row.operator : "=" - return emitted(lifted, query, replaced(rows, { ...row, key, operator, values: [] }), 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 not" always (AND-ed excludes), "is" only where the engine ORs. */ +/** + * 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 => - isNoneOf(row.operator) || - (isAnyOf(row.operator) && canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource)) + isAnyOf(row.operator) + ? canMatchAnyOf(fieldOf(row.key, query.dataSource), query.dataSource) + : isNoneOf(row.operator) && query.dataSource !== "product_events" -const withValue = (row: ConditionRow, query: QueryDraft, value: string): ReadonlyArray => - holdsManyValues(row, query) ? Arr.dedupe([...row.values, value]) : [value] +/** 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: suggestions or anything typed (Enter adds it); one value unless the row holds many. */ +/** 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, @@ -231,33 +286,43 @@ export const valuesStep = ( message: Combobox.Message, inputs: PanelInputs | undefined, ): Return => - withRow(model, queryId, rowId, inputs, ({ query, rows, row }) => { + withRow(model, queryId, rowId, inputs, ({ model: base, query, rows, row }) => { const id = conditionPickerId(queryId, rowId, "values") - const current = { ...pickerOf(model, id, "multiple"), selected: row.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) - if ( + 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( - { - ...model, - conditionPickers: { ...model.conditionPickers, [id]: { ...current, inputValue: "" } }, - }, + lift({ ...current, inputValue: "" }), query, - replaced(rows, { ...row, values: withValue(row, query, typed) }), + rows, + replaced(rows, { ...row, values: Arr.dedupe([...row.values, typed]) }), ) - const next = Combobox.update(current, message) - const lifted: Model = { ...model, conditionPickers: { ...model.conditionPickers, [id]: next.model } } + 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 }), ) - if (next.outMessage?._tag !== "ChangedSelection") return { model: lifted, commands } - const selected = next.outMessage.selected - const values = holdsManyValues(row, query) ? selected : Arr.takeRight(selected, 1) - return emitted(lifted, query, replaced(rows, { ...row, values }), commands) + 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) @@ -278,12 +343,12 @@ export const operatorStep = ( 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, ({ query, rows, row }) => { + 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(lifted, query, replaced(rows, { ...row, operator, values }), commands) + 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.ts b/apps/web/src/page/kit/query-builder/panel.ts index 7329cab460..af231d5039 100644 --- a/apps/web/src/page/kit/query-builder/panel.ts +++ b/apps/web/src/page/kit/query-builder/panel.ts @@ -16,6 +16,7 @@ import { operatorSelectId, operatorStep, removedCondition, + typedInTextEditor, valuesStep, } from "./conditions" import { groupBySelectionOf } from "./draft" @@ -145,13 +146,23 @@ export const update = (model: Model, message: Message, inputs?: PanelInputs): Re 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), diff --git a/apps/web/src/page/kit/query-builder/parts.ts b/apps/web/src/page/kit/query-builder/parts.ts index a29d43baba..62ed3c3119 100644 --- a/apps/web/src/page/kit/query-builder/parts.ts +++ b/apps/web/src/page/kit/query-builder/parts.ts @@ -19,8 +19,6 @@ import { conditionRowsOf, whereModeOf } from "./conditions" import type { WhereMode } from "./panel-message" import { whereSpecOf, whereTextareaId } from "./where" import { panelSelect } from "./panel-select" - -export { panelSelect } 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. @@ -74,7 +72,7 @@ const UNPARSEABLE_NOTE = "This filter uses syntax the builder can't show, edit a /** * `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text - * editor; a Builder/Text switch under it. Both edit the same where-clause string. + * editor, under a Builder/Text switch. Both edit the same where-clause string. */ export const whereClauseEditor = ( h: H, @@ -84,22 +82,7 @@ export const whereClauseEditor = ( options: Readonly<{ placeholder?: string; className?: string }> = {}, ): Html => { const rows = conditionRowsOf(model, query) - const preferred = whereModeOf(model, query) - const isBuilder = preferred === "builder" && Option.isSome(rows) - const leading = isBuilder - ? button( - h, - { - variant: "ghost", - size: "xs", - className: "text-muted-foreground", - attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], - }, - [icon(h, "PlusIcon"), "Add condition"], - ) - : preferred === "builder" - ? typography(h, { size: "xs", tone: "muted" }, [UNPARSEABLE_NOTE]) - : h.empty + const isBuilder = whereModeOf(model, query) === "builder" && Option.isSome(rows) const toggle = segmentedSelect(h, { options: [ { value: "builder", label: "Builder", isDisabled: Option.isNone(rows) }, @@ -111,17 +94,31 @@ export const whereClauseEditor = ( ariaLabel: `Where clause editor for query ${query.name}`, className: "ml-auto shrink-0", }) + const note = Option.isNone(rows) ? [typography(h, { size: "xs", tone: "muted" }, [UNPARSEABLE_NOTE])] : [] + const addCondition = button( + h, + { + variant: "ghost", + size: "xs", + className: "self-start text-muted-foreground", + attributes: [h.OnClick(Message.ClickedAddCondition({ queryId: query.id }))], + }, + [icon(h, "PlusIcon"), "Add condition"], + ) return vstack(h, { gap: "1.5", className: options.className ?? "flex-1 min-w-0" }, [ + hstack(h, { gap: "2" }, [...note, toggle]), ...(isBuilder - ? conditionBuilder( - h, - model, - query, - Option.getOrElse(rows, () => []), - values, - ) + ? [ + ...conditionBuilder( + h, + model, + query, + Option.getOrElse(rows, () => []), + values, + ), + addCondition, + ] : [whereTextEditor(h, model, query, values, options.placeholder)]), - hstack(h, { gap: "2", wrap: true }, [leading, toggle]), ]) } diff --git a/apps/web/src/page/kit/query-builder/view.ts b/apps/web/src/page/kit/query-builder/view.ts index cd6a5e0753..749461fab9 100644 --- a/apps/web/src/page/kit/query-builder/view.ts +++ b/apps/web/src/page/kit/query-builder/view.ts @@ -21,7 +21,8 @@ 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 { panelSelect } from "./panel-select" +import { addOnSections, ADD_ON_KEYS, addOnToggles, textInput, whereClauseEditor } from "./parts" import { Array as Arr, String as Str } from "effect" // `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. 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..aec3551a62 --- /dev/null +++ b/packages/query-engine/src/query-builder/where-key-rules.test.ts @@ -0,0 +1,98 @@ +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([]) + }) + + 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..148cfa506a --- /dev/null +++ b/packages/query-engine/src/query-builder/where-key-rules.ts @@ -0,0 +1,137 @@ +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) || Str.startsWith("resource.")(canonical)) + return { _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/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 7b1311900d..aaeb175bff 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -211,15 +211,15 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `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`, `fieldsOf`, `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`, `parseConditionRows`, `isComplete`, `printConditionRows`, `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`, `whereModeOf`, `changedWhereMode`, `addedCondition`, `removedCondition`, `fieldStep`, `holdsManyValues`, `valuesStep`, `operatorStep`: The condition builder's steps: every edit prints the rows and leaves as the query's where-clause edit, so previews and saving see only the string. +- `apps/web/src/page/kit/query-builder/condition-fields.ts` `groupLabelOf`, `fieldOf`, `canMatchAnyOf`, `isOwnedElsewhere`, `fieldsOf`, `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`, `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-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`: `WhereClauseEditor`: condition rows when the clause reads as rows (the default), else the text editor; a Builder/Text switch under it. +- `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` `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. From be018faf89646d11a8f0e6f4f8496d758a46a776 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:40:33 +0100 Subject: [PATCH 05/69] feat(alerts): rule editor lab over fixture rows --- apps/web/src/lab/entries/rule-editor.ts | 6 + apps/web/src/lab/registry.ts | 8 + apps/web/src/lab/rule-editor-lab.ts | 194 ++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 apps/web/src/lab/entries/rule-editor.ts create mode 100644 apps/web/src/lab/rule-editor-lab.ts 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/registry.ts b/apps/web/src/lab/registry.ts index 02bc171ec3..26d882cc9d 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -443,6 +443,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ 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", 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..84cb7bbeca --- /dev/null +++ b/apps/web/src/lab/rule-editor-lab.ts @@ -0,0 +1,194 @@ +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 } 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 { 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`). + +export const Variant = Schema.Literals(["populated", "new", "anomaly"]) +export type Variant = typeof Variant.Type + +export const Flags = Schema.Struct({ nowMs: Schema.Number, variant: Variant }) +export type Flags = typeof Flags.Type + +const decodeVariant = Schema.decodeUnknownOption(Variant) + +/** The boot clock and `?variant=`; `?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") + }), +}) + +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) + +/** A week of error rate around 2%, with two bursts over the 5% line. */ +const previewOf = (nowMs: number): AlertRulePreviewResponse => { + const bucketMs = 30 * MIN + const start = nowMs - 7 * 24 * 60 * MIN + const count = Math.floor((nowMs - start) / bucketMs) + const burst = (i: number) => (i > 120 && i < 128) || (i > 250 && i < 255) + const points = Arr.map(Arr.range(0, count - 1), (i) => { + const value = burst(i) ? 0.072 + Math.sin(i) * 0.008 : 0.02 + Math.sin(i * 0.7) * 0.006 + const point = { bucket: new Date(start + i * bucketMs).toISOString(), value, sampleCount: 300 } + return value > 0.05 + ? { ...point, status: "breached", level: "warning" } + : { ...point, status: "healthy" } + }) + const span = (from: number, to: number) => ({ + groupKey: "all", + start: new Date(start + from * bucketMs).toISOString(), + end: new Date(start + to * bucketMs).toISOString(), + severity: "warning", + }) + 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: [span(123, 128), span(252, 255)], + }) +} + +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([]) }), + ]) + +const withPreview = (model: EditorModel, nowMs: number): EditorModel => + run(model, [ + EditorMessage.CompletedFetchPreview({ + key: model.preview.key ?? "", + isSaved: false, + result: Result.succeed(previewOf(nowMs)), + }), + ]) + +const seed = (variant: Variant, nowMs: number): EditorModel => { + if (variant === "new") return run(opened({}, nowMs, []), [EditorMessage.ClickedStartBlank()]) + if (variant === "anomaly") return opened({ ruleId: ruleId(8) }, nowMs, [scopedAnomalyRuleRow()]) + return withPreview(opened({ ruleId: ruleId(2) }, nowMs, [ERROR_RATE_RULE]), nowMs) +} + +// 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 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) + return { + model: { variant: labFlags.variant, editor: seed(labFlags.variant, labFlags.nowMs) }, + commands: toLab(started.commands), + } +} + +export const update = (model: Model, message: Message): Update.Return => { + const next = editorUpdate(model.editor, message.message, LAB_CONTEXT) + return { model: { ...model, editor: next.model }, 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 }), + }), + ], + ) From bb3897c395907de61caec0a319c8ddbf0e25fd76 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 20:49:57 +0100 Subject: [PATCH 06/69] feat(alerts): rule editor as sentence cards with plain-language summaries Replaces the numbered stepper with When / Alert when / Notify / Name cards on a connected rail. A complete card closes to a one-line summary derived from the form; a card with something missing stays open. The live chart sits above the cards as the preview. --- apps/web/src/lab/rule-editor-lab.ts | 5 +- apps/web/src/page/alerts/editor/derive.ts | 9 +- .../editor/editor-anomaly.scene.test.ts | 6 +- .../alerts/editor/editor-cards.scene.test.ts | 106 +++++++++ .../page/alerts/editor/editor.scene.test.ts | 2 + apps/web/src/page/alerts/editor/form.ts | 4 + apps/web/src/page/alerts/editor/init.ts | 1 + apps/web/src/page/alerts/editor/message.ts | 6 +- apps/web/src/page/alerts/editor/model.ts | 4 +- apps/web/src/page/alerts/editor/steps.ts | 21 +- .../src/page/alerts/editor/summary.test.ts | 205 ++++++++++++++++++ apps/web/src/page/alerts/editor/summary.ts | 182 ++++++++++++++++ apps/web/src/page/alerts/editor/update.ts | 6 + apps/web/src/page/alerts/editor/view-step.ts | 59 +---- apps/web/src/page/alerts/editor/view.ts | 174 ++++++++------- apps/web/src/page/kit/sentence-card.ts | 182 ++++++++++++++++ packages/views/src/ui/INDEX.md | 1 + 17 files changed, 830 insertions(+), 143 deletions(-) create mode 100644 apps/web/src/page/alerts/editor/editor-cards.scene.test.ts create mode 100644 apps/web/src/page/alerts/editor/summary.test.ts create mode 100644 apps/web/src/page/alerts/editor/summary.ts create mode 100644 apps/web/src/page/kit/sentence-card.ts diff --git a/apps/web/src/lab/rule-editor-lab.ts b/apps/web/src/lab/rule-editor-lab.ts index 84cb7bbeca..8fe52998b9 100644 --- a/apps/web/src/lab/rule-editor-lab.ts +++ b/apps/web/src/lab/rule-editor-lab.ts @@ -154,7 +154,10 @@ const withPreview = (model: EditorModel, nowMs: number): EditorModel => const seed = (variant: Variant, nowMs: number): EditorModel => { if (variant === "new") return run(opened({}, nowMs, []), [EditorMessage.ClickedStartBlank()]) if (variant === "anomaly") return opened({ ruleId: ruleId(8) }, nowMs, [scopedAnomalyRuleRow()]) - return withPreview(opened({ ruleId: ruleId(2) }, nowMs, [ERROR_RATE_RULE]), nowMs) + // Every card on its summary except "Alert when", open for editing. + return run(withPreview(opened({ ruleId: ruleId(2) }, nowMs, [ERROR_RATE_RULE]), nowMs), [ + EditorMessage.ClickedExpandCard({ card: "condition" }), + ]) } // Reads and writes need a session; the lab keeps the editor's local commands (chart, focus, menus). diff --git a/apps/web/src/page/alerts/editor/derive.ts b/apps/web/src/page/alerts/editor/derive.ts index 2ffe94d7a4..e693fe9657 100644 --- a/apps/web/src/page/alerts/editor/derive.ts +++ b/apps/web/src/page/alerts/editor/derive.ts @@ -49,6 +49,12 @@ 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}` : ""}` +} + /** The rule in one plain sentence, shown in the action bar. */ export const describeRuleCondition = (form: RuleForm, isDefault = false): string => { if (form.signalType === "anomaly") @@ -57,8 +63,7 @@ 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) 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..10ace1eb2f 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 @@ -64,10 +64,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(), @@ -148,6 +148,7 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(scoped), themeResolved(), + click(role("button", { name: /^When: / })), expectView(text("No changes")).toExist(), expectView(text(DEFAULT_NOTE)).not.toExist(), // Each chip's remove button names its chip. @@ -162,6 +163,7 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(fallback), themeResolved(), + click(role("button", { name: /^When: / })), expectView(text(DEFAULT_NOTE)).toExist(), expectView(role("button", { name: "Error rate" })).toBeDisabled(), expectView(role("button", { name: "Built-in" })).toBeDisabled(), 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..2e2966f07f --- /dev/null +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -0,0 +1,106 @@ +import { OrgId } from "@maple/domain" +import { Schema, Array as Arr } from "effect" +import { + click, + expect as expectView, + given, + Mount, + role, + scene, + text, + type, + withViewInputs, +} from "foldkit/scene" +import { describe, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import type { PageContext } from "../../contract" +import { scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" +import { destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +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 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), over 5 min, 2 checks in a row, 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")), + expectView(queryMode).toExist(), + expectView(role("heading", { name: "When" })).toExist(), + click(role("button", { name: "Done" })), + 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(), + ) + }) + + 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(), + ) + }) +}) 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..4fc9c60cb7 100644 --- a/apps/web/src/page/alerts/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor.scene.test.ts @@ -153,6 +153,7 @@ describe("alert rule editor", () => { { update: pageUpdate, view }, given(withWindow(editing, "60")), themeResolved(), + click(role("button", { name: /^Alert when: / })), expectView(text(`${saved} min`)).toExist(), expectView(hint("5 min")).toExist(), ) @@ -382,6 +383,7 @@ describe("alert rule editor", () => { { update: pageUpdate, view }, given(slowed), themeResolved(), + click(role("button", { name: /^Alert when: / })), expectView(notice).toExist(), click(role("button", { name: "Keep 5 min" })), expectView(notice).not.toExist(), 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..09ca171c50 100644 --- a/apps/web/src/page/alerts/editor/init.ts +++ b/apps/web/src/page/alerts/editor/init.ts @@ -67,6 +67,7 @@ export const init = (route: CreateRoute, _context: PageContext): PageReturn @@ -43,15 +45,26 @@ 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, 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). */ -export const editForm = (model: Model, context: PageContext, edit: (form: RuleForm) => RuleForm): Return => - model.form === null ? { model } : planned({ ...model, form: edit(model.form) }, context) +export const editForm = (model: Model, context: PageContext, edit: (form: RuleForm) => RuleForm): Return => { + if (model.form === null) return { model } + const form = edit(model.form) + // A card that goes missing stays open once filled in again, so it never closes under the cursor. + const expandedCards = Arr.union(model.expandedCards, missingCards(form, isProtectedDefault(model))) + return planned({ ...model, form, expandedCards }, context) +} /** A list answered: the draft may now be known, and the default destination picked. */ export const followRows = (model: Model, context: PageContext): Return => { 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..c24f5e3ce1 --- /dev/null +++ b/apps/web/src/page/alerts/editor/summary.test.ts @@ -0,0 +1,205 @@ +import { AlertDestinationId, 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 { + cardIssues, + cardsOf, + conditionSummary, + missingCards, + nameSummary, + notifySummary, + type NotifyNames, + 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 NAMES: NotifyNames = { + destination: (id) => (id === ONCALL ? "#oncall" : id === PAGER ? "PagerDuty Checkout" : null), + chain: (id) => (id === CHAIN ? "Checkout primary" : null), +} + +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%, over 5 min, 3 checks in a row, 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%, over 5 min, 2 checks in a row, 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%, over 5 min, 3 of the last 5 checks, resolves after 2 healthy checks, no data counts", + ) + }) + + 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("names a destination on both tiers once", () => { + expect(notifySummary(form({ escalation: criticalTier({ destinationIds: [ONCALL] }) }), NAMES)).toBe( + "#oncall", + ) + }) + + 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("card issues", () => { + it("puts each missing field on the card that edits it", () => { + expect(missingCards(form())).toEqual([]) + expect(missingCards(form({ name: "" }))).toEqual(["name"]) + expect(missingCards(form({ threshold: "" }))).toEqual(["condition"]) + expect(missingCards(form({ destinationIds: [] }))).toEqual(["notify"]) + expect( + cardIssues(form({ signalType: "raw_query", rawQuerySql: "SELECT 1 AS value" }), "watch"), + ).toEqual(["$__orgFilter in SQL"]) + expect(cardIssues(form({ escalation: criticalTier() }), "notify")).toEqual(["Critical destinations"]) + }) + + 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)).toEqual(["watch"]) + expect(missingCards(unscoped, 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..6bf5d49333 --- /dev/null +++ b/apps/web/src/page/alerts/editor/summary.ts @@ -0,0 +1,182 @@ +import { Array as Arr, String as Str } from "effect" +import type { AlertDestinationId, 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 { comparatorWords, conditionSubject, groupBySummary, thresholdValue, validationIssues } from "./derive" + +// 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"]) + +const cardOfIssue = (issue: string): RuleCard => + issue === "Rule name" + ? "name" + : NOTIFY_ISSUES.has(issue) + ? "notify" + : WATCH_ISSUES.has(issue) || Str.startsWith("Query:")(issue) + ? "watch" + : "condition" + +/** What blocks saving on one card, from the same list the action bar shows. */ +export const cardIssues = (form: RuleForm, card: RuleCard, isDefault = false): ReadonlyArray => + Arr.filter(validationIssues(form, isDefault), (issue) => cardOfIssue(issue) === card) + +/** The cards with something missing, which stay open until it is filled in. */ +export const missingCards = (form: RuleForm, isDefault = false): ReadonlyArray => + Arr.filter(cardsOf(form), (card) => cardIssues(form, card, 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], " ") +} + +const checksText = (form: RuleForm): string => { + const checks = Str.trim(form.consecutiveBreachesRequired) || "?" + const outOf = Str.trim(form.breachesOutOf) + const windowText = `over ${Str.trim(form.windowMinutes) || "?"} min` + if (outOf.length > 0) return `${windowText}, ${checks} of the last ${outOf} checks` + return checks === "1" ? windowText : `${windowText}, ${checks} checks in a row` +} + +/** The "Alert when" card: "Above 5%, critical above 10%, over 5 min, 3 checks in a row". */ +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) + return Arr.join( + [ + `${capitalize(words)} ${threshold}${escalation === null && form.severity === "critical" ? " (critical)" : ""}`, + ...(escalation === null + ? [] + : [`critical ${words} ${thresholdValue(form, escalation.threshold)}`]), + checksText(form), + ...(healthy.length > 0 && healthy !== "1" ? [`resolves after ${healthy} healthy checks`] : []), + ...(form.alertOnNoData ? ["no data counts"] : []), + ], + ", ", + ) +} + +/** How the summary names a destination and a chain; `null` for one the lists no longer hold. */ +export type NotifyNames = Readonly<{ + destination: (id: AlertDestinationId) => string | null + chain: (id: EscalationChainId) => string | null +}> + +type Route = Readonly<{ + destinationIds: ReadonlyArray + escalationChainId: EscalationChainId | null +}> + +const routeText = (route: Route, names: NotifyNames): string | null => { + const destinations = Arr.map( + route.destinationIds, + (id) => names.destination(id) ?? "a removed destination", + ) + const chain = + route.escalationChainId === null + ? null + : (names.chain(route.escalationChainId) ?? "an escalation 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" + const criticalText = + critical === null + ? null + : Str.startsWith("escalate to ")(critical) + ? `critical ${Str.replace(/^escalate/, "escalates")(critical)}` + : `critical also ${critical}` + // Only the leading verb is capitalised: a destination keeps its own spelling ("#oncall", an email). + const lead = (text: string) => + Str.startsWith("escalate")(text) || Str.startsWith("critical")(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..d3e9ffaaa9 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -214,6 +214,12 @@ 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]) }, + }), + ClickedCollapseCard: ({ card }) => ({ + model: { ...model, expandedCards: Arr.filter(model.expandedCards, (open) => open !== card) }, + }), ToggledSqlCollapsed: () => ({ model: { ...model, isSqlCollapsed: !model.isSqlCollapsed } }), ClickedSqlToken: ({ token }) => model.form === null 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..b3f6b0b42c 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,18 @@ 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 { destinationsOf } from "./derive-rows" +import type { RuleCard } from "./form" +import { + cardIssues, + conditionSummary, + nameSummary, + notifySummary, + type NotifyNames, + watchSummary, +} 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 +34,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 +133,101 @@ 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) + return { + destination: (id) => + Option.match( + Arr.findFirst(destinations, (destination) => destination.id === id), + { onNone: () => null, onSome: (destination) => destinationTarget(destination).primary }, + ), + chain: (id) => + Option.match( + Arr.findFirst(model.escalationChains ?? [], (chain) => chain.id === id), + { onNone: () => null, onSome: (chain) => chain.name }, + ), + } +} + +type CardSpec = Readonly<{ + card: RuleCard + lead: string + hint?: string | undefined + summary: string + body: () => Html +}> + +const ruleCard = (h: H, model: Model, form: RuleForm, isDefault: boolean, spec: CardSpec): Html => { + const issues = cardIssues(form, spec.card, isDefault) + return sentenceCard( + h, + { + id: spec.card, + lead: spec.lead, + hint: spec.hint, + status: issues.length > 0 ? "missing" : "done", + issues, + summary: spec.summary, + isExpanded: model.expandedCards.includes(spec.card), + onExpand: Message.ClickedExpandCard({ card: spec.card }), + onCollapse: Message.ClickedCollapseCard({ card: spec.card }), + }, + // Closed cards skip their section: its lazy slot renders again once the card opens. + model.expandedCards.includes(spec.card) || issues.length > 0 ? [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 condition: CardSpec = { + card: "condition", + lead: "Alert when", + hint: "The dashed line on the chart is your threshold.", + summary: conditionSummary(form), + body: () => + h.div( + [h.Class("space-y-4")], + [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)), + body: () => notificationsSection(h, model, form), + }, + { + card: "name", + lead: "Name", + summary: nameSummary(form), + 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, isDefault, spec)), ), ], ), 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..031c19068b --- /dev/null +++ b/apps/web/src/page/kit/sentence-card.ts @@ -0,0 +1,182 @@ +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 { separator } from "@maple/views/ui/display" +import { heading } from "@maple/views/ui/heading" +import { icon } from "@maple/views/ui/icon" +import { pressable } from "@maple/views/ui/pressable" +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 } 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" | "optional" + +export type SentenceCardOptions = Readonly<{ + /** Unique on the page: the heading's id. */ + id: string + /** The clause's lead word, the card's label: "When", "Alert when", "Notify". */ + lead: string + /** A line under 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 + /** 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; a missing card stays open, so it has none. */ + onCollapse?: M +}> + +const DOT_TONE = { done: "done", missing: "warn", optional: "neutral" } as const + +const LEAD_CLASS = "w-24 shrink-0" + +/** 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), + ]), + ]) + +const closedCard = (h: HtmlBuilder, options: SentenceCardOptions): Html => + card( + h, + [ + pressable( + h, + { + onClick: options.onExpand, + className: + "w-full rounded-md-inset px-4 py-3 text-left hover:bg-surface-sunken active:bg-surface-sunken", + attributes: [ + h.AriaExpanded(false), + h.AriaLabel(`${options.lead}: ${options.summary}. Edit`), + ], + }, + [ + hstack(h, { gap: "3", align: "center", className: "min-h-8" }, [ + typography(h, { size: "sm", weight: "medium", className: LEAD_CLASS }, [ + options.lead, + ]), + typography(h, { size: "sm", truncate: true, className: "min-w-0 grow" }, [ + options.summary, + ]), + hstack(h, { gap: "1", align: "center", as: "span", className: "shrink-0" }, [ + icon(h, "PencilIcon", { size: 12, className: "text-muted-foreground" }), + typography(h, { size: "xs", tone: "muted" }, ["Edit"]), + ]), + ]), + ], + ), + ], + "gap-0 p-0", + ) + +const openCard = ( + h: HtmlBuilder, + options: SentenceCardOptions, + children: ReadonlyArray, +): Html => { + const headingId = `sentence-card-${options.id}` + const issues = options.issues ?? [] + // No frame of its own: the sections inside are cards. The header lines up with a closed card's row. + return h.section( + [h.AriaLabelledBy(headingId), h.Class("flex min-w-0 flex-col gap-3 pt-[calc(--spacing(3)+1px)]")], + [ + hstack( + h, + { + gap: "3", + align: "start", + justify: "between", + className: "min-h-8 pl-[calc(--spacing(4)+1px)]", + }, + [ + hstack(h, { gap: "3", align: "baseline", wrap: true, className: "min-w-0 pt-1.5" }, [ + heading(h, { level: 2, variant: "section", id: headingId, className: LEAD_CLASS }, [ + 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" }, [ + issues.length === 0 + ? "Required" + : `Missing: ${Arr.join(issues, ", ")}`, + ]), + ] + : []), + ]), + ...(options.onCollapse === undefined || options.status === "missing" + ? [] + : [ + button( + h, + { + variant: "ghost", + size: "xs", + attributes: [h.AriaExpanded(true), h.OnClick(options.onCollapse)], + }, + ["Done"], + ), + ]), + ], + ), + ...children, + ], + ) +} + +/** + * `SentenceCard`: one clause of a sentence-card form, on its rail. Open, it shows its lead word and + * controls; closed (complete and not being edited), a one-line summary that opens it on click. + */ +export const sentenceCard = ( + h: HtmlBuilder, + options: SentenceCardOptions, + children: 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, children) : 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) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index aaeb175bff..87205a600a 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -230,6 +230,7 @@ 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` `sentenceCard`, `sentenceChain`: 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. From 18c83a33daccd2249eb142049d4c926f36e53b63 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:00:58 +0100 Subject: [PATCH 07/69] feat(alerts): sections at their own paths, sidebar sub-items, silence side panel - Each hub section has its own path (/alerts/rules, /alerts/uptime, /alerts/slos, /alerts/silences, /alerts/maintenance, /alerts/on-call, /alerts/routing); /alerts stays the Inbox. One page instance serves them all, so a section change reads nothing it already has. - Old /alerts?tab= links replace themselves with the section's path and keep their other params ("oncall" and "settings" map to on-call and routing). - The sidebar's Alerts row lists the sections when active and replaces the hub's own section rail. Sub-item aria-current now follows the most specific match. - New silence opens in a URL-held side panel (?sheet=silence) with a sticky Cancel / Save footer; Back, Cancel, Escape and a save close it. sidePanelSheet gains onSubmit so the panel is a form. --- .../components/settings/audit-log-resource.ts | 4 +- apps/web/src/lab/alerts-hub-lab.ts | 40 ++++- apps/web/src/lab/anomaly-alerts-lab.ts | 6 +- apps/web/src/lab/entries/alerts-hub.ts | 4 +- apps/web/src/lab/escalation-lab.ts | 4 +- apps/web/src/lab/maintenance-lab.ts | 6 +- apps/web/src/lab/oncall-lab.ts | 6 +- apps/web/src/lab/registry.ts | 2 +- apps/web/src/lab/shell-lab.ts | 7 +- apps/web/src/lab/slos-lab.ts | 4 +- .../alerts/editor/editor-chains.scene.test.ts | 2 +- apps/web/src/page/alerts/hub/command.ts | 17 +++ apps/web/src/page/alerts/hub/derive.test.ts | 4 +- apps/web/src/page/alerts/hub/derive.ts | 33 ----- .../alerts/hub/hub-anomalies.scene.test.ts | 33 ++--- .../page/alerts/hub/hub-chains.scene.test.ts | 4 +- .../alerts/hub/hub-maintenance.scene.test.ts | 21 +-- .../page/alerts/hub/hub-oncall.scene.test.ts | 33 +++-- .../alerts/hub/hub-silences.scene.test.ts | 137 ++++++++++++++---- .../page/alerts/hub/hub-slos.scene.test.ts | 14 +- .../page/alerts/hub/hub-uptime.scene.test.ts | 16 +- .../web/src/page/alerts/hub/hub.scene.test.ts | 80 ++++++---- apps/web/src/page/alerts/hub/index.ts | 8 +- apps/web/src/page/alerts/hub/message.ts | 2 + apps/web/src/page/alerts/hub/model.ts | 8 +- apps/web/src/page/alerts/hub/update.ts | 135 +++++++++++------ apps/web/src/page/alerts/hub/view-empty.ts | 2 +- apps/web/src/page/alerts/hub/view-inbox.ts | 4 - apps/web/src/page/alerts/hub/view.ts | 118 ++------------- .../src/page/alerts/rule/rule.scene.test.ts | 2 +- apps/web/src/page/alerts/rule/update.ts | 2 +- .../src/page/alerts/rule/view-diagnosis.ts | 2 +- .../page/alerts/shared/escalation-chains.ts | 2 +- apps/web/src/page/alerts/shared/links.ts | 16 +- .../page/alerts/shared/maintenance-windows.ts | 2 +- .../page/alerts/shared/oncall-schedules.ts | 2 +- .../src/page/alerts/shared/slo-commands.ts | 2 +- apps/web/src/page/alerts/shared/test-rows.ts | 9 ++ .../src/page/alerts/shared/uptime-commands.ts | 2 +- .../src/page/alerts/silence-dialog/dialog.ts | 9 +- .../src/page/alerts/silence-dialog/view.ts | 55 ++++--- .../services/detail/uptime-card.scene.test.ts | 2 +- .../page/settings/audit-log/view-detail.ts | 10 +- apps/web/src/route/app-route.ts | 1 + apps/web/src/route/index.ts | 2 +- apps/web/src/route/redirect.ts | 35 ++++- apps/web/src/route/route.test.ts | 38 ++++- apps/web/src/route/routers.ts | 7 + apps/web/src/route/search.ts | 25 +++- apps/web/src/shell/nav.ts | 43 +++++- apps/web/src/shell/oncall.scene.test.ts | 4 +- apps/web/src/shell/shell.scene.test.ts | 50 +++++++ packages/views/src/ui/sheet.ts | 30 ++-- packages/views/src/ui/sidebar.ts | 3 +- 54 files changed, 693 insertions(+), 416 deletions(-) diff --git a/apps/web/src/components/settings/audit-log-resource.ts b/apps/web/src/components/settings/audit-log-resource.ts index 4432954756..72c9590f98 100644 --- a/apps/web/src/components/settings/audit-log-resource.ts +++ b/apps/web/src/components/settings/audit-log-resource.ts @@ -14,7 +14,7 @@ export type AuditResourceLink = | { readonly to: "/errors/issues/$issueId"; readonly params: { readonly issueId: string } } | { readonly to: "/investigations/$id"; readonly params: { readonly id: string } } | { readonly to: "/anomalies/$incidentId"; readonly params: { readonly incidentId: string } } - | { readonly to: "/alerts"; readonly search: { readonly tab: "routing" } } + | { readonly to: "/alerts/routing" } | { readonly to: "/integrations"; readonly search: { readonly integration?: string } } | { readonly to: "/settings"; readonly search: { readonly tab: string } } @@ -79,7 +79,7 @@ function resourceLink(entry: AuditEntryResource): AuditResourceLink | null { return incidentId === null ? null : { to: "/anomalies/$incidentId", params: { incidentId } } } case "alert_destination": - return { to: "/alerts", search: { tab: "routing" } } + return { to: "/alerts/routing" } case "scrape_target": return { to: "/integrations", search: { integration: "prometheus" } } case "gcp_connector": diff --git a/apps/web/src/lab/alerts-hub-lab.ts b/apps/web/src/lab/alerts-hub-lab.ts index 8a4e55067a..e87756b96b 100644 --- a/apps/web/src/lab/alerts-hub-lab.ts +++ b/apps/web/src/lab/alerts-hub-lab.ts @@ -2,7 +2,7 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" -import { Effect, Option, Result, Schema, Array as Arr } from "effect" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" @@ -11,13 +11,15 @@ import * as HubModel from "../page/alerts/hub/model" import * as HubUpdate from "../page/alerts/hub/update" import { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view" import * as SilenceDialog from "../page/alerts/silence-dialog/dialog" -import { destinationRow, NOW, ORG, ruleRow, stateRow } from "../page/alerts/shared/test-rows" +import { alertsHref } from "../page/alerts/shared/links" +import { destinationRow, NOW, ORG, ruleRow, stateRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" -// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences tabs and the silence dialog over +// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences sections and the silence side panel over // fixtures, including a rule skipped for too few samples and a cancelled silence. `?state=` picks -// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom. +// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom. The shell +// stands on the section's own path, and `?sheet=` is played here rather than pushed. export const LabState = Schema.Literals([ "inbox", @@ -55,9 +57,15 @@ const CONTEXT: PageContext = { } const GRANTS = Policy.grantsOf(["org:engineer"]) -/** The hub's reads and writes are dropped: the fixture is all the data there is. */ +/** The hub's reads, writes and URL changes are dropped: the fixture is all the data there is. */ const keepLocal = (commands: ReadonlyArray> | undefined) => - Arr.filter(commands ?? [], (command) => !/^(Fetch|Toggle|Delete|Save|Read|End)/.test(command.name)) + Arr.filter( + commands ?? [], + (command) => + !/^(Fetch|Toggle|Delete|Save|Read|End|PushAlertsHubSheet|DismissAlertsHubSheet)/.test( + command.name, + ), + ) const MINUTE = 60_000 const iso = (ms: number) => new Date(ms).toISOString() @@ -140,10 +148,23 @@ const tabOf = (state: LabState) => ? "inbox" : "silences" -const step = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT) +const routeTo = (model: HubModel.Model, sheet: string | undefined) => + HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), CONTEXT) + +/** The hub's update, with the `?sheet=` push and dismissal it asks for played back as route changes. */ +const step = (model: HubModel.Model, message: HubMessage.Message) => { + const next = HubUpdate.update(model, message, CONTEXT) + const asked = Arr.findFirst(next.commands ?? [], (command) => Str.endsWith("HubSheet")(command.name)) + if (asked._tag === "None") return next + const routed = routeTo( + next.model, + asked.value.name === "PushAlertsHubSheet" ? HubUpdate.SILENCE_SHEET : undefined, + ) + return { ...routed, commands: [...(next.commands ?? []), ...(routed.commands ?? [])] } +} const hubFor = (state: LabState): HubModel.Model => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: tabOf(state) } }, CONTEXT).model + const entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), CONTEXT).model const base = [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), @@ -201,6 +222,9 @@ export const update = (model: Model, message: Message): Return => export const breadcrumbs = (model: Model) => hubBreadcrumbs(model.hub) +/** The section's own path, so the sidebar opens Alerts on it. */ +export const currentPath = (model: Model): string => alertsHref({}, model.hub.tab) + export const view = (model: Model, h: HtmlBuilder): Html => h.submodel({ slotId: "lab-alerts-hub", diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index 6a9e917e01..805810cc42 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -46,6 +46,8 @@ import { ruleId, ruleRow, stateRow, + hubAt, + type HubAt, } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" @@ -156,7 +158,7 @@ const RESOLVED = [ ] const hubFor = (state: LabState) => { - const search: HubModel.Model["search"] = + const search: HubAt = state === "hub-rules" ? { tab: "rules" } : state === "hub-anomalies-resolved" @@ -167,7 +169,7 @@ const hubFor = (state: LabState) => { ? [incidentRow({ rule_name: "Payment p95 latency" })] : [incidentRow({ rule_name: "Payment p95 latency" }), ...OPEN, ...RESOLVED] return fold( - HubUpdate.init({ _tag: "Alerts", search }, CONTEXT).model, + HubUpdate.init(hubAt(search), CONTEXT).model, [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }), diff --git a/apps/web/src/lab/entries/alerts-hub.ts b/apps/web/src/lab/entries/alerts-hub.ts index de2a9f57ed..9e69ef8a00 100644 --- a/apps/web/src/lab/entries/alerts-hub.ts +++ b/apps/web/src/lab/entries/alerts-hub.ts @@ -1,5 +1,5 @@ import { defineShellLab } from "../shell-lab" -import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" +import { breadcrumbs, currentPath, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" // `/lab/alerts-hub`: the hub tabs and silence dialog over fixtures (`?state=`). @@ -11,6 +11,6 @@ export const lab = defineShellLab("Alerts hub", { init, update, view, - currentPath: "/lab/alerts-hub", + currentPath, breadcrumbs, }) diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts index 08d3416e53..f710bea7d1 100644 --- a/apps/web/src/lab/escalation-lab.ts +++ b/apps/web/src/lab/escalation-lab.ts @@ -28,7 +28,7 @@ import { populatedChains, runOf, } from "../page/alerts/shared/escalation-test-fixtures" -import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { keepLocal } from "./local-commands" @@ -112,7 +112,7 @@ const SAVE_FAILURE: LoadFailure = { } const hubFor = (state: LabState): Step => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "routing" } }, CONTEXT).model + const entered = HubUpdate.init(hubAt({ tab: "routing" }), CONTEXT).model const chains = state === "chains-empty" ? [] : populatedChains([ruleId(1), ruleId(2), ruleId(3)]) const base = [ HubMessage.Message.ReadClock({ nowMs: NOW }), diff --git a/apps/web/src/lab/maintenance-lab.ts b/apps/web/src/lab/maintenance-lab.ts index b8a04cb397..72cf955c67 100644 --- a/apps/web/src/lab/maintenance-lab.ts +++ b/apps/web/src/lab/maintenance-lab.ts @@ -15,7 +15,7 @@ import { weeklyWindow, } from "../page/alerts/shared/maintenance-test-fixtures" import { windowPublicId } from "../page/alerts/shared/maintenance-windows" -import { NOW, ORG, ruleRow } from "../page/alerts/shared/test-rows" +import { NOW, ORG, ruleRow, hubAt, type HubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { keepLocal } from "./local-commands" @@ -78,7 +78,7 @@ const fold = (start: HubModel.Model, messages: ReadonlyArray const dialog = (message: WindowDialog.Message) => HubMessage.Message.GotWindowDialogMessage({ message }) -const searchFor = (state: LabState): HubModel.Model["search"] => +const searchFor = (state: LabState): HubAt => state === "silences" ? { tab: "silences" } : state === "silences-filtered" @@ -112,7 +112,7 @@ const extraFor = (state: LabState): ReadonlyArray => : [] const hubFor = (state: LabState): Step => { - const entered = HubUpdate.init({ _tag: "Alerts", search: searchFor(state) }, CONTEXT).model + const entered = HubUpdate.init(hubAt(searchFor(state)), CONTEXT).model const windows = state === "empty" ? [] : populatedWindows return fold(entered, [ HubMessage.Message.ReadClock({ nowMs: NOW }), diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index e94302533f..5f7dd1883f 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -40,7 +40,7 @@ import { populatedSchedules, schedulePublicId, } from "../page/alerts/shared/oncall-test-fixtures" -import { incidentRow, INCIDENT_ID, ORG, ruleRow } from "../page/alerts/shared/test-rows" +import { incidentRow, INCIDENT_ID, ORG, ruleRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { isoMs } from "../page/alerts/shared/time" @@ -128,8 +128,8 @@ const hubStep = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT) const hubFor = (state: LabState): Step => { - const tab = state === "strip" ? "inbox" : state === "chain-dialog" ? "routing" : "oncall" - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab } }, CONTEXT).model + const tab = state === "strip" ? "inbox" : state === "chain-dialog" ? "routing" : "on-call" + const entered = HubUpdate.init(hubAt({ tab }), CONTEXT).model const schedules = state === "schedules-empty" ? [] : populatedSchedules(ONCALL_NOW) const chains = [mixedTargetChain(chainDestinations[0]?.id ?? "")] return fold( diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 8ec40d1192..843e1bdbb2 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -57,7 +57,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/alerts-hub", title: "Alerts hub", description: - "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence dialog (`?state=`).", + "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence side panel (`?state=`).", kind: "lab", session: "none", }, diff --git a/apps/web/src/lab/shell-lab.ts b/apps/web/src/lab/shell-lab.ts index e63276dbb6..2003012daa 100644 --- a/apps/web/src/lab/shell-lab.ts +++ b/apps/web/src/lab/shell-lab.ts @@ -31,7 +31,8 @@ export type ShellLabPage Update.Return view: (model: PageModel, h: HtmlBuilder) => Html subscriptions?: Subscription.Subscriptions - currentPath: string + /** Fixed, or read from the page (a lab standing on the route it renders). */ + currentPath: string | ((model: PageModel) => string) /** Fixed, or read from the page (a lab switching between pages). */ breadcrumbs: | ReadonlyArray @@ -88,6 +89,8 @@ export const defineShellLab = (page.view) const before = page.before const beforeView = before === undefined ? null : Submodel.defineView(before) + const currentPathOf = (model: PageModel) => + typeof page.currentPath === "function" ? page.currentPath(model) : page.currentPath const breadcrumbsOf = (model: PageModel) => typeof page.breadcrumbs === "function" ? page.breadcrumbs(model) : page.breadcrumbs const own = Subscription.lift(page.subscriptions ?? {})({ @@ -154,7 +157,7 @@ export const defineShellLab = h.submodel({ diff --git a/apps/web/src/lab/slos-lab.ts b/apps/web/src/lab/slos-lab.ts index 58b043672d..c50337a9ba 100644 --- a/apps/web/src/lab/slos-lab.ts +++ b/apps/web/src/lab/slos-lab.ts @@ -20,7 +20,7 @@ import { sloUuid, type SloScenario, } from "../page/alerts/shared/slo-test-fixtures" -import { destinationRow, incidentRow, NOW, ORG } from "../page/alerts/shared/test-rows" +import { destinationRow, incidentRow, NOW, ORG, hubAt } from "../page/alerts/shared/test-rows" import * as SloMessage from "../page/alerts/slo/message" import * as SloModel from "../page/alerts/slo/model" import * as SloUpdate from "../page/alerts/slo/update" @@ -106,7 +106,7 @@ const INCIDENT = incidentRow({ }) const hubFor = (state: LabState) => { - const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "slos" } }, CONTEXT).model + const entered = HubUpdate.init(hubAt({ tab: "slos" }), CONTEXT).model const rows = state === "tab-empty" ? [] diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index fbbb61e587..496064507e 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -62,7 +62,7 @@ describe("alert rule editor, escalation chains", () => { given(ready()), themeResolved(), expectView(role("combobox", { name: "Escalation chain" })).toExist(), - expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts?tab=routing"), + expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts/routing"), ) }) diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index 785a833772..abc46e5491 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -17,6 +17,7 @@ import { readThrough } from "../../../data/read-cache" import { readGrants } from "../../../data/session-tenant" import { toLoadFailure } from "@maple/views/data/load-failure" import { Command } from "foldkit" +import { dismissSheetUrl, pushSheetUrl } from "../../kit/sheet-param" import { deleteDestination, listDeliveries, @@ -41,6 +42,22 @@ import { import { Message } from "./message" import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" +const sheetArgs = { href: Schema.String, id: Schema.String } + +/** Opens a side panel: a history entry carrying `?sheet=`, marked as this tab's. */ +export const PushHubSheet = Command.define("PushAlertsHubSheet", { + args: sheetArgs, + messages: [Message.CompletedSheetUrl], + execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), +}) + +/** Closes it: Back over the entry this tab pushed, else `?sheet=` replaced away. */ +export const DismissHubSheet = Command.define("DismissAlertsHubSheet", { + args: sheetArgs, + messages: [Message.CompletedSheetUrl], + execute: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), +}) + export const ReadHubClock = Command.define("ReadAlertsHubClock", { messages: [Message.ReadClock], execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), diff --git a/apps/web/src/page/alerts/hub/derive.test.ts b/apps/web/src/page/alerts/hub/derive.test.ts index 3ee3c8e8d2..b95e0d6e1d 100644 --- a/apps/web/src/page/alerts/hub/derive.test.ts +++ b/apps/web/src/page/alerts/hub/derive.test.ts @@ -2,7 +2,7 @@ import { OrgId } from "@maple/domain" import { Array as Arr, Option, Schema } from "effect" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { incidentRow, NOW, ORG, ruleRow } from "../shared/test-rows" +import { incidentRow, NOW, ORG, ruleRow, hubAt } from "../shared/test-rows" import { overviewOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" @@ -17,7 +17,7 @@ const loaded = (): Model => Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1), ruleRow(2)] }), Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), ], - init({ _tag: "Alerts", search: {} }, context).model, + init(hubAt(), context).model, (model, message) => update(model, message, context).model, ) diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 572950d044..e9f48fefbb 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -112,39 +112,6 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) -export type AlertsTab = - | "inbox" - | "rules" - | "uptime" - | "slos" - | "silences" - | "maintenance" - | "oncall" - | "routing" - -/** Legacy `tab` values map onto the current tabs so old links keep resolving. */ -export const resolveTab = (tab: string | undefined): AlertsTab => { - switch (tab) { - case "rules": - return "rules" - case "uptime": - return "uptime" - case "slos": - return "slos" - case "silences": - return "silences" - case "maintenance": - return "maintenance" - case "oncall": - return "oncall" - case "routing": - case "settings": - return "routing" - default: - return "inbox" - } -} - /** `attention` is the legacy name of the blind bucket. */ export const resolveStatus = (status: string | undefined): RuleBucket | undefined => { switch (status) { diff --git a/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts b/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts index 9d6e02aedf..1329c7864b 100644 --- a/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-anomalies.scene.test.ts @@ -21,7 +21,17 @@ import { defaultAnomalyRuleRow, scopedAnomalyRuleRow, } from "../shared/anomaly-test-fixtures" -import { destinationRow, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from "../shared/test-rows" +import { + destinationRow, + incidentRow, + NOW, + ORG, + ruleId, + ruleRow, + stateRow, + hubAt, + type HubAt, +} from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, ruleMenuId, update } from "./update" @@ -57,7 +67,7 @@ const RESOLVED_CHECKOUT = anomalyIncidentRow({ }) const loaded = ( - search: Model["search"], + at: HubAt, incidents = [ incidentRow({ rule_name: "Payment p95 latency" }), OPEN_CHECKOUT, @@ -87,7 +97,7 @@ const loaded = ( Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), ], - init({ _tag: "Alerts", search }, context).model, + init(hubAt(at), context).model, (model, message) => pageUpdate(model, message).model, ) @@ -100,7 +110,7 @@ describe("alerts hub: anomalies", () => { click(role("button", { name: /^Anomalies/ })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: "/alerts?tab=inbox&signal=anomaly", + url: "/alerts?signal=anomaly", isReplace: false, }), ), @@ -245,19 +255,4 @@ describe("alerts hub: anomalies", () => { expectView(text("Search anomalies")).toExist(), ) }) - - test("leaving the Inbox drops the Anomalies view's filters from the tab links", () => { - scene( - { update: pageUpdate, view }, - given(loaded({ signal: "anomaly", anomalyStatus: "resolved", serviceName: "checkout" })), - expectView(role("link", { name: /^Rules/ })).toHaveAttr( - "href", - "/alerts?tab=rules&serviceName=checkout", - ), - expectView(role("link", { name: /^Inbox/ })).toHaveAttr( - "href", - "/alerts?tab=inbox&serviceName=checkout&signal=anomaly&anomalyStatus=resolved", - ), - ) - }) }) diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index 623064ac52..f7adc99b7f 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -24,7 +24,7 @@ import type { V2EscalationChain } from "@maple/domain/http/v2" import type { PageContext } from "../../contract" import { CHAIN_IN_USE_CODE } from "../shared/escalation-chains" import { chainDestinations, chainUuid, populatedChains } from "../shared/escalation-test-fixtures" -import { NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { NOW, ORG, ruleId, ruleRow, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { chainMenuId, init, update } from "./update" @@ -43,7 +43,7 @@ const RULES = [ruleRow(1, { name: "Checkout errors" }), ruleRow(2, { name: "Chec const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) const onRouting = (grants: ReturnType, chains: ReadonlyArray) => { - const entered = init({ _tag: "Alerts", search: { tab: "routing" } }, context) + const entered = init(hubAt({ tab: "routing" }), context) expect(names(entered.commands)).toContain("FetchAlertsHubChains") return Arr.reduce( [ diff --git a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts index de1fbab3af..570e9b8986 100644 --- a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts @@ -1,4 +1,5 @@ import * as Policy from "@maple/domain/policy" +import type { AlertsTab } from "../shared/links" import { OrgId } from "@maple/domain" import type { V2AlertMaintenanceWindow, V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" @@ -26,7 +27,7 @@ import { weeklyWindow, } from "../shared/maintenance-test-fixtures" import { windowPublicId, windowSilencesHref } from "../shared/maintenance-windows" -import { NOW, ORG, ruleRow } from "../shared/test-rows" +import { NOW, ORG, ruleRow, hubAt, type HubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update, windowMenuId } from "./update" @@ -49,14 +50,14 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onTab = ( - search: Model["search"], + at: HubAt, options: Readonly<{ grants?: ReturnType windows: ReadonlyArray silences?: ReadonlyArray }>, ): Model => { - const entered = init({ _tag: "Alerts", search }, context) + const entered = init(hubAt(at), context) const loaded = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -86,7 +87,7 @@ const onMaintenance = (windows: ReadonlyArray, grants describe("alerts hub, Maintenance tab", () => { test("reads the windows only while the Maintenance or Silences tab is open", () => { - const fetched = (tab: string) => names(init({ _tag: "Alerts", search: { tab } }, context).commands) + const fetched = (tab: AlertsTab) => names(init(hubAt({ tab }), context).commands) expect(fetched("maintenance")).toContain("FetchAlertsHubMaintenanceWindows") expect(fetched("silences")).toContain("FetchAlertsHubMaintenanceWindows") expect(fetched("rules")).not.toContain("FetchAlertsHubMaintenanceWindows") @@ -129,7 +130,7 @@ describe("alerts hub, Maintenance tab", () => { test("a window change on the Maintenance tab makes the Silences tab read its silences again", () => { const model = onTab({ tab: "silences" }, { windows: [weeklyWindow], silences: silencesWithWindows }) const onMaint = update(model, Message.ReadClock({ nowMs: NOW }), context).model - const moved = routeChanged(onMaint, { _tag: "Alerts", search: { tab: "maintenance" } }, context).model + const moved = routeChanged(onMaint, hubAt({ tab: "maintenance" }), context).model const toggled = pageUpdate(moved, Message.ClickedToggleWindow({ windowId: weeklyWindow.id })).model const settled = pageUpdate( toggled, @@ -141,7 +142,7 @@ describe("alerts hub, Maintenance tab", () => { // Following the row's Occurrences link lands on the Silences tab filtered to the window. const back = routeChanged( settled, - { _tag: "Alerts", search: { tab: "silences", window: windowPublicId(weeklyWindow.id) } }, + hubAt({ tab: "silences", window: windowPublicId(weeklyWindow.id) }), context, ) expect(back.commands ?? []).toContainEqual( @@ -150,7 +151,7 @@ describe("alerts hub, Maintenance tab", () => { args: expect.objectContaining({ windowId: weeklyWindow.id }), }), ) - const unfiltered = routeChanged(settled, { _tag: "Alerts", search: { tab: "silences" } }, context) + const unfiltered = routeChanged(settled, hubAt({ tab: "silences" }), context) expect(names(unfiltered.commands)).toContain("FetchAlertsHubSilences") }) @@ -247,7 +248,7 @@ describe("alerts hub, Maintenance tab", () => { const step = pageUpdate(onMaintenance([weeklyWindow]), Message.ClickedNewWindow()) expect(step.model.windowDialog.dialog.isOpen).toBe(true) expect(names(step.commands)).toContain("ReadMaintenanceWindowZones") - const early = init({ _tag: "Alerts", search: { tab: "maintenance" } }, context).model + const early = init(hubAt({ tab: "maintenance" }), context).model expect(pageUpdate(early, Message.ClickedNewWindow()).model.windowDialog.dialog.isOpen).toBe(false) }) }) @@ -268,13 +269,13 @@ describe("alerts hub, Silences tab with maintenance windows", () => { click(role("button", { name: "Remove Window: Nightly database backup" })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: alertsHref({ tab: "silences" }), + url: alertsHref({}, "silences"), isReplace: false, }), ), ) // The route without the param reads every silence and drops the chip. - const cleared = routeChanged(model, { _tag: "Alerts", search: { tab: "silences" } }, context) + const cleared = routeChanged(model, hubAt({ tab: "silences" }), context) const fetch = Arr.findFirst(cleared.commands ?? [], ({ name }) => name === "FetchAlertsHubSilences") expect(fetch).toMatchObject({ value: { args: { windowId: null } } }) const shown = pageUpdate( diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts index 5376879745..2102c64f20 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -1,4 +1,5 @@ import * as Policy from "@maple/domain/policy" +import type { AlertsTab } from "../shared/links" import { OrgId } from "@maple/domain" import { EscalationChainId, OnCallScheduleId } from "@maple/domain/http" import type { V2OnCallSchedule } from "@maple/domain/http/v2" @@ -16,7 +17,7 @@ import { populatedSchedules, scheduleUuid, } from "../shared/oncall-test-fixtures" -import { incidentRow, ORG, ruleRow } from "../shared/test-rows" +import { incidentRow, ORG, ruleRow, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, scheduleMenuId, update } from "./update" @@ -34,11 +35,11 @@ const MEMBER = Policy.grantsOf(["org:member"]) const SCHEDULES = populatedSchedules(ONCALL_NOW) const onTab = ( - tab: string, + tab: AlertsTab, grants: ReturnType, schedules: ReadonlyArray | null, ) => { - const entered = init({ _tag: "Alerts", search: { tab } }, context) + const entered = init(hubAt({ tab }), context) expect(names(entered.commands)).toContain("FetchAlertsHubSchedules") return Arr.reduce( [ @@ -72,7 +73,7 @@ describe("alerts hub, on-call schedules", () => { test("an org without schedules learns what one is and can create the first", () => { scene( { update: pageUpdate, view }, - given(onTab("oncall", ENGINEER, [])), + given(onTab("on-call", ENGINEER, [])), expectView(text("No on-call schedules yet")).toExist(), expectView(text(/people take turns in layers/u)).toExist(), expectView(role("link", { name: "Create schedule" })).toExist(), @@ -82,7 +83,7 @@ describe("alerts hub, on-call schedules", () => { test("each schedule names its zone, who is on call until when, and the next handoff", () => { scene( { update: pageUpdate, view }, - given(onTab("oncall", ENGINEER, SCHEDULES)), + given(onTab("on-call", ENGINEER, SCHEDULES)), expectView(text("Checkout primary")).toExist(), expectView(text("Europe/Berlin · 3 layers")).toExist(), expectView(text("Sofia Alvarez")).toExist(), @@ -97,7 +98,7 @@ describe("alerts hub, on-call schedules", () => { test("a member sees the schedules but cannot create, edit or delete one", () => { scene( { update: pageUpdate, view }, - given(onTab("oncall", MEMBER, SCHEDULES)), + given(onTab("on-call", MEMBER, SCHEDULES)), expectView(text("Checkout primary")).toExist(), expectView(role("link", { name: "New schedule" })).not.toExist(), expectView(role("button", { name: "Actions for Checkout primary" })).not.toExist(), @@ -122,7 +123,7 @@ describe("alerts hub, on-call schedules", () => { }) test("the clock reads the schedules again once a shown shift has handed off, not before", () => { - const model = onTab("oncall", ENGINEER, SCHEDULES) + const model = onTab("on-call", ENGINEER, SCHEDULES) expect( names(pageUpdate(model, Message.ReadClock({ nowMs: ONCALL_NOW + 60_000 })).commands), ).not.toContain("FetchAlertsHubSchedules") @@ -169,7 +170,7 @@ describe("alerts hub, on-call schedules", () => { }) test("an org with schedules but no rules sees who is on call above the empty inbox", () => { - const entered = init({ _tag: "Alerts", search: { tab: "inbox" } }, context) + const entered = init(hubAt({ tab: "inbox" }), context) const model = Arr.reduce( [ Message.ReadClock({ nowMs: ONCALL_NOW }), @@ -196,15 +197,15 @@ describe("alerts hub, on-call schedules", () => { }) test("a failed members read is asked again on the next route change", () => { - const entered = init({ _tag: "Alerts", search: { tab: "inbox" } }, context) + const entered = init(hubAt({ tab: "inbox" }), context) expect(names(entered.commands)).toContain("FetchAlertsHubMembers") const failed = pageUpdate( entered.model, Message.CompletedFetchMembers({ result: Result.fail("Clerk is down") }), ).model - expect( - names(routeChanged(failed, { _tag: "Alerts", search: { tab: "oncall" } }, context).commands), - ).toContain("FetchAlertsHubMembers") + expect(names(routeChanged(failed, hubAt({ tab: "on-call" }), context).commands)).toContain( + "FetchAlertsHubMembers", + ) }) test("a chain's summary names the people and schedules it pages", () => { @@ -220,7 +221,7 @@ describe("alerts hub, on-call schedules", () => { }) test("a refused delete names the chains that still page the schedule", () => { - const model = onTab("oncall", ENGINEER, SCHEDULES) + const model = onTab("on-call", ENGINEER, SCHEDULES) const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) const asked = pageUpdate( pageUpdate( @@ -263,11 +264,11 @@ describe("alerts hub, on-call schedules", () => { }) test("members are read only by the tabs that name people, once", () => { - const rules = init({ _tag: "Alerts", search: { tab: "rules" } }, context) + const rules = init(hubAt({ tab: "rules" }), context) expect(names(rules.commands)).not.toContain("FetchAlertsHubMembers") - const oncall = routeChanged(rules.model, { _tag: "Alerts", search: { tab: "oncall" } }, context) + const oncall = routeChanged(rules.model, hubAt({ tab: "on-call" }), context) expect(names(oncall.commands)).toContain("FetchAlertsHubMembers") - const routing = routeChanged(oncall.model, { _tag: "Alerts", search: { tab: "routing" } }, context) + const routing = routeChanged(oncall.model, hubAt({ tab: "routing" }), context) expect(names(routing.commands)).not.toContain("FetchAlertsHubMembers") }) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 8e741f9fae..2d7d322363 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -3,27 +3,17 @@ import { OrgId } from "@maple/domain" import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import { AlertMaintenanceWindowPublicId, type V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" -import * as Animation from "@foldkit/ui/animation" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" -import { - click, - Command, - expect as expectView, - Mount, - given, - role, - scene, - text, - withViewInputs, -} from "foldkit/scene" +import { expect as expectView, Mount, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { NOW, ORG } from "../shared/test-rows" +import { NOW, ORG, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" -import { init, update } from "./update" +import { init, routeChanged, update } from "./update" import { view as pageView } from "./view" +import * as SilenceDialog from "../silence-dialog/dialog" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -57,7 +47,7 @@ const silence = ( } const onSilences = (grants: ReturnType, silences: ReadonlyArray) => { - const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context) + const entered = init(hubAt({ tab: "silences" }), context) expect(Arr.map(entered.commands ?? [], ({ name }) => name)).toContain("FetchAlertsHubSilences") return Arr.reduce( [ @@ -165,7 +155,7 @@ describe("alerts hub, Silences tab", () => { }) test("reads the clock again when the list lands, so a new silence is placed by it", () => { - const entered = init({ _tag: "Alerts", search: { tab: "silences" } }, context).model + const entered = init(hubAt({ tab: "silences" }), context).model const step = pageUpdate( entered, Message.CompletedFetchSilences({ key: entered.silences.key ?? "", result: Result.succeed([]) }), @@ -195,24 +185,117 @@ describe("alerts hub, Silences tab", () => { expectView(text(/· 1 rule$/u)).toExist(), ) }) +}) + +const SILENCES_URL = "/alerts/silences" +const callsOf = (commands: ReadonlyArray<{ readonly name: string; readonly args?: unknown }> | undefined) => + Arr.map(commands ?? [], ({ name, args }) => [name, args]) +const namesOf = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const silenceMessage = (message: SilenceDialog.Message) => Message.GotSilenceDialogMessage({ message }) - test("New silence opens the dialog with typed scope fields", () => { +/** The Silences tab with `?sheet=silence` followed: the panel is open. */ +const withPanel = (grants = ENGINEER) => + routeChanged(onSilences(grants, []), hubAt({ tab: "silences", sheet: "silence" }), context) + +describe("alerts hub, silence side panel", () => { + test("New silence opens the panel through the URL, as a history entry on this section", () => { + const model = onSilences(ENGINEER, []) + const step = pageUpdate(model, Message.ClickedNewSilence()) + expect(callsOf(step.commands)).toEqual([ + ["PushAlertsHubSheet", { href: SILENCES_URL, id: "silence" }], + ]) + expect(step.model.silenceDialog.dialog.isOpen).toBe(false) + }) + + test("the URL opens it with typed scope fields, Cancel and a save that waits for a valid form", () => { + const opened = withPanel() + expect(opened.model.silenceDialog.dialog.isOpen).toBe(true) scene( { update: pageUpdate, view }, - given(onSilences(ENGINEER, [])), - click(role("button", { name: "New silence" })), - Command.resolve( - Animation.WaitForPaint, - Animation.Message.CompletedWaitForPaint({ generation: 1 }), - ), - Command.resolve( - Animation.WaitForAnimationSettled, - Animation.Message.EndedAnimation({ generation: 1 }), - ), + given(opened.model), Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), expectView(role("dialog", { name: "Silence notifications" })).toExist(), expectView(role("textbox", { name: "Services" })).toExist(), + expectView(role("button", { name: "Cancel" })).toExist(), expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), ) }) + + test("a link straight to the panel waits for the clock and the viewer's grants, and a member never gets it", () => { + const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model + expect(entered.silenceDialog.dialog.isOpen).toBe(false) + const clocked = pageUpdate(entered, Message.ReadClock({ nowMs: NOW })).model + expect(clocked.silenceDialog.dialog.isOpen).toBe(false) + const granted = pageUpdate( + clocked, + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + ).model + expect(granted.silenceDialog.dialog.isOpen).toBe(true) + expect(granted.silenceDialog.nowMs).toBe(NOW) + expect(withPanel(MEMBER).model.silenceDialog.dialog.isOpen).toBe(false) + }) + + test("Cancel and Escape close it and take `?sheet=` off the URL", () => { + const dismissed = [ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=silence`, id: "silence" }], + ] + const cancelled = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedCancel())) + expect(cancelled.model.silenceDialog.dialog.isOpen).toBe(false) + expect(Arr.filter(callsOf(cancelled.commands), ([name]) => name === "DismissAlertsHubSheet")).toEqual( + dismissed, + ) + const escaped = pageUpdate( + withPanel().model, + silenceMessage( + SilenceDialog.Message.GotDialogMessage({ message: Dialog.Message.PressedEscape() }), + ), + ) + expect(escaped.model.silenceDialog.dialog.isOpen).toBe(false) + expect(Arr.filter(callsOf(escaped.commands), ([name]) => name === "DismissAlertsHubSheet")).toEqual( + dismissed, + ) + }) + + test("Back closes it without touching the URL again, and the form starts fresh when it reopens", () => { + const typed = pageUpdate( + withPanel().model, + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "x" })), + ) + const back = routeChanged(typed.model, hubAt({ tab: "silences" }), context) + expect(back.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(back.commands)).not.toContain("DismissAlertsHubSheet") + const reopened = routeChanged(back.model, hubAt({ tab: "silences", sheet: "silence" }), context) + expect(reopened.model.silenceDialog.dialog.isOpen).toBe(true) + expect(reopened.model.silenceDialog.reason).toBe("") + }) + + test("saving creates the silence, closes the panel, clears the URL and reads the list again", () => { + const filled = Arr.reduce( + [ + silenceMessage( + SilenceDialog.Message.ChangedScopeField({ field: "services", value: "checkout" }), + ), + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ], + withPanel().model, + (model, message) => pageUpdate(model, message).model, + ) + const saving = pageUpdate(filled, silenceMessage(SilenceDialog.Message.ClickedSave())) + expect(namesOf(saving.commands)).toEqual(["SaveAlertSilence"]) + const saved = pageUpdate( + saving.model, + silenceMessage( + SilenceDialog.Message.CompletedSave({ + openCount: saving.model.silenceDialog.openCount, + result: Result.succeed(silence({ startMin: 0, endMin: 60 })), + }), + ), + ) + expect(saved.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(saved.commands)).toEqual( + expect.arrayContaining(["DismissAlertsHubSheet", "FetchAlertsHubSilences"]), + ) + expect(saved.outMessage).toMatchObject({ title: "Alerts silenced" }) + }) }) diff --git a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts index b6b9ff22e4..6526d14694 100644 --- a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts @@ -8,7 +8,7 @@ import * as Menu from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" import { sloHref } from "../shared/slo-commands" import { checkoutSlo, SLO_RULE_UUID, searchSlo, sloStatus, sloUuid } from "../shared/slo-test-fixtures" -import { incidentRow, NOW, ORG } from "../shared/test-rows" +import { incidentRow, NOW, ORG, hubAt } from "../shared/test-rows" import { Message } from "./message" import type { Model, SloListRow } from "./model" import { init, update } from "./update" @@ -25,7 +25,7 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onSlos = (grants = ENGINEER): Model => { - const entered = init({ _tag: "Alerts", search: { tab: "slos" } }, context).model + const entered = init(hubAt({ tab: "slos" }), context).model return Arr.reduce( [Message.ReadClock({ nowMs: NOW }), Message.CompletedFetchSession({ orgId: ORG, grants })], entered, @@ -46,12 +46,8 @@ const HEALTHY_AND_BURNING: ReadonlyArray = [ describe("alerts hub, SLOs tab", () => { test("the SLOs and their statuses are read only while the tab is open", () => { - expect(names(init({ _tag: "Alerts", search: {} }, context).commands)).not.toContain( - "FetchAlertsHubSlos", - ) - expect(names(init({ _tag: "Alerts", search: { tab: "slos" } }, context).commands)).toContain( - "FetchAlertsHubSlos", - ) + expect(names(init(hubAt(), context).commands)).not.toContain("FetchAlertsHubSlos") + expect(names(init(hubAt({ tab: "slos" }), context).commands)).toContain("FetchAlertsHubSlos") }) test("an org without SLOs is told what one is and offered the first", () => { @@ -231,7 +227,7 @@ describe("alerts hub, SLOs tab", () => { describe("alerts hub inbox, SLO incidents", () => { test("an org whose only rules are SLOs' sees their open incidents, not the empty state", () => { - const entered = init({ _tag: "Alerts", search: {} }, context).model + const entered = init(hubAt(), context).model const model = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), diff --git a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts index a62ab85d2a..5694c615e2 100644 --- a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts @@ -18,7 +18,7 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import * as Menu from "@maple/views/ui/menu" import { MountMenu } from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" -import { incidentRow, NOW, ORG } from "../shared/test-rows" +import { incidentRow, NOW, ORG, hubAt } from "../shared/test-rows" import { monitorHref } from "../shared/uptime-commands" import type { UptimeMonitor } from "../shared/uptime-derive" import { downMonitor, httpMonitor, monitorId, pausedMonitor } from "../shared/uptime-test-monitors" @@ -38,7 +38,7 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const onUptime = (): Model => { - const entered = init({ _tag: "Alerts", search: { tab: "uptime" } }, context).model + const entered = init(hubAt({ tab: "uptime" }), context).model return Arr.reduce( [Message.ReadClock({ nowMs: NOW }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER })], entered, @@ -74,7 +74,7 @@ const NAMESPACES = { "checkout-api": "shop", "payments-api": "billing" } describe("alerts hub inbox, uptime incidents", () => { test("an uptime incident reads as its monitor being down, without the ungrouped key", () => { - const entered = init({ _tag: "Alerts", search: {} }, context).model + const entered = init(hubAt(), context).model const model = Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -123,16 +123,12 @@ describe("alerts hub, Uptime tab", () => { }) test("the monitors are read only when the Uptime tab is open", () => { - expect(names(init({ _tag: "Alerts", search: {} }, context).commands)).not.toContain( - "FetchAlertsHubMonitors", - ) - expect(names(init({ _tag: "Alerts", search: { tab: "uptime" } }, context).commands)).toContain( - "FetchAlertsHubMonitors", - ) + expect(names(init(hubAt(), context).commands)).not.toContain("FetchAlertsHubMonitors") + expect(names(init(hubAt({ tab: "uptime" }), context).commands)).toContain("FetchAlertsHubMonitors") }) test("opening the tab also reads which namespace each service reports under", () => { - expect(names(init({ _tag: "Alerts", search: { tab: "uptime" } }, context).commands)).toContain( + expect(names(init(hubAt({ tab: "uptime" }), context).commands)).toContain( "FetchAlertsHubServiceNamespaces", ) }) diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 1327330cb8..9e44f27e3d 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -25,10 +25,20 @@ import { encodePublicId, PublicIdPrefixes, publicError, V2AlertIncident } from " import { toLoadFailure } from "@maple/views/data/load-failure" import * as Menu from "@maple/views/ui/menu" import { PageOutMessage, type PageContext } from "../../contract" -import { destinationRow, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from "../shared/test-rows" +import { + destinationRow, + incidentRow, + NOW, + ORG, + ruleId, + ruleRow, + stateRow, + hubAt, + type HubAt, +} from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" -import { init, ruleMenuId, update } from "./update" +import { init, routeChanged, ruleMenuId, update } from "./update" import { breadcrumbs, view as pageView } from "./view" const ENGINEER = Policy.grantsOf(["org:engineer"]) @@ -42,10 +52,10 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = const RULE_2 = Schema.decodeSync(AlertRuleId)(ruleId(2)) const DESTINATION = Schema.decodeSync(AlertDestinationId)(destinationRow().id) -const entered = (search: Model["search"] = {}) => init({ _tag: "Alerts", search }, context) +const entered = (at: HubAt = {}) => init(hubAt(at), context) /** The four shapes and the clock have answered: rule 2 is firing, rule 4 is off. */ -const loaded = (search: Model["search"] = {}): Model => +const loaded = (at: HubAt = {}): Model => Arr.reduce( [ Message.ReadClock({ nowMs: NOW }), @@ -68,7 +78,7 @@ const loaded = (search: Model["search"] = {}): Model => Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), ], - entered(search).model, + entered(at).model, (model, message) => pageUpdate(model, message).model, ) @@ -96,7 +106,6 @@ describe("alerts hub", () => { { update: pageUpdate, view }, given(loaded()), expectView(text("1 firing")).toExist(), - expectView(role("link", { name: /^Inbox\s*1$/ })).toExist(), expectView(role("link", { name: "P95 latency 1.84s on all" })).toHaveAttr( "href", "/alerts/incidents/0199a003-0000-7000-8000-000000000001", @@ -104,31 +113,42 @@ describe("alerts hub", () => { ) }) - test("the section rail marks the open section, which titles the page and its crumbs", () => { + test("each section's own path opens it, titled by it with its crumbs", () => { + const sections = [ + ["rules", "Rules"], + ["uptime", "Uptime"], + ["slos", "SLOs"], + ["silences", "Silences"], + ["maintenance", "Maintenance"], + ["on-call", "On-call"], + ["routing", "Routing"], + ] as const + for (const [tab, label] of sections) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(role("heading", { name: label })).toExist(), + ) + expect(breadcrumbs(loaded({ tab }))).toEqual([{ label: "Alerts", href: "/alerts" }, { label }]) + } scene( { update: pageUpdate, view }, - given(loaded({ tab: "rules" })), - expectView(role("link", { name: /^Rules\s*3$/ })).toHaveAttr("aria-current", "page"), - expectView(role("link", { name: /^Inbox\s*1$/ })).not.toHaveAttr("aria-current", "page"), - expectView(role("heading", { name: "Rules" })).toExist(), - ) - // The anomaly filters belong to the Inbox: its row keeps them, every other row drops them. - scene( - { update: pageUpdate, view }, - given(loaded({ tab: "inbox", signal: "anomaly", serviceName: "checkout" })), - expectView(role("link", { name: /^Inbox\s*1$/ })).toHaveAttr( - "href", - "/alerts?tab=inbox&serviceName=checkout&signal=anomaly", - ), - expectView(role("link", { name: "Silences" })).toHaveAttr( - "href", - "/alerts?tab=silences&serviceName=checkout", - ), + given(loaded()), + expectView(role("heading", { name: "Inbox" })).toExist(), ) - expect(breadcrumbs(loaded({ tab: "silences" }))).toEqual([ - { label: "Alerts", href: "/alerts" }, - { label: "Silences" }, + }) + + test("moving between sections keeps what was read: the Inbox and Rules share it, a section reads its own once", () => { + const inbox = loaded() + expect(names(routeChanged(inbox, hubAt({ tab: "rules" }), context).commands)).toEqual([ + "ReadAlertsHubClock", ]) + const uptime = routeChanged(inbox, hubAt({ tab: "uptime" }), context) + expect(names(uptime.commands)).toContain("FetchAlertsHubMonitors") + const back = routeChanged(uptime.model, hubAt(), context) + expect(names(routeChanged(back.model, hubAt({ tab: "uptime" }), context).commands)).not.toContain( + "FetchAlertsHubMonitors", + ) }) const v2Incident = Schema.decodeSync(V2AlertIncident)({ @@ -183,8 +203,6 @@ describe("alerts hub", () => { { update: pageUpdate, view }, given(acknowledged), expectView(text("0 firing")).toExist(), - // The tab's red badge counts only what nobody is on yet. - expectView(role("link", { name: "Inbox" })).toExist(), expectView(text(/1 acknowledged/)).toExist(), expectView(text(/^Acknowledged by Dana Kim ·/)).toExist(), expectView(role("button", { name: "Unacknowledge P95 latency 1.84s on all" })).toExist(), @@ -313,7 +331,7 @@ describe("alerts hub", () => { click(role("button", { name: /^Firing/ })), expectOutMessage( PageOutMessage.RequestedNavigation({ - url: "/alerts?tab=rules&status=firing", + url: "/alerts/rules?status=firing", isReplace: false, }), ), @@ -323,7 +341,7 @@ describe("alerts hub", () => { Message.ClickedStatusFilter({ bucket: "firing" }), ) expect(cleared.outMessage).toEqual( - PageOutMessage.RequestedNavigation({ url: "/alerts?tab=rules", isReplace: false }), + PageOutMessage.RequestedNavigation({ url: "/alerts/rules", isReplace: false }), ) }) diff --git a/apps/web/src/page/alerts/hub/index.ts b/apps/web/src/page/alerts/hub/index.ts index d004453ea2..b2ad39c61d 100644 --- a/apps/web/src/page/alerts/hub/index.ts +++ b/apps/web/src/page/alerts/hub/index.ts @@ -5,12 +5,16 @@ import { subscriptions } from "./subscription" import { init, routeChanged, update } from "./update" import { breadcrumbs, view } from "./view" -/** `/alerts`: the incident-first hub and its sections. One instance per org; the section is search. */ +/** + * `/alerts` (the Inbox) and `/alerts/
`: the incident-first hub as one instance per org, so a + * section change keeps what the last one read. + */ export const page = definePage( "AlertsHub", { Model, Message }, { - routes: ["Alerts"], + routes: ["Alerts", "AlertsSection"], + key: () => "alerts", init, update, routeChanged, diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 75a499ae12..5f0c12fffb 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -52,6 +52,8 @@ const rows = (row: S) => ({ orgId: Schema.String, rows: Sc const outcome = (success: S) => Schema.Result(success, LoadFailure) export const Message = defineMessageUnion({ + /** A `?sheet=` push or dismissal ran; the route change that follows does the work. */ + CompletedSheetUrl: {}, ReadClock: { nowMs: Schema.Number }, UpdatedRules: rows(AlertRuleRowSchema), UpdatedRuleStates: rows(AlertRuleStateRowSchema), diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 4c448ddcad..c23d373893 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -28,7 +28,7 @@ import { import { Schema } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" -import { AlertsSearch } from "../../../route/search" +import { AlertsHubSearch, AlertsSection } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" @@ -53,8 +53,12 @@ export type SloListRow = typeof SloListRow.Type export const TestOutcome = Schema.Struct({ message: Schema.String, success: Schema.Boolean }) +/** The section the route names: the Inbox at `/alerts`, the rest at `/alerts/
`. */ +export const AlertsTab = Schema.Union([Schema.Literal("inbox"), AlertsSection]) + export const Model = Schema.Struct({ - search: AlertsSearch, + tab: AlertsTab, + search: AlertsHubSearch, /** `Clock.currentTimeMillis` when the overview model was built; `null` until read. */ nowMs: Schema.NullOr(Schema.Number), rules: Rows(AlertRuleRowSchema), diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 935b7a6cb4..f47c40d86d 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -62,19 +62,28 @@ import { EndHubSilence, FetchHubSilences, DeleteWindow, + DismissHubSheet, FetchHubWindows, + PushHubSheet, ToggleWindow, } from "./command" import { withAcknowledgement } from "../shared/commands" import { testSendFailureMessage } from "../shared/delivery-failure" -import { resolveStatus, resolveTab } from "./derive" +import { resolveStatus } from "./derive" import { Message, type RuleBucket } from "./message" -import type { Model } from "./model" +import { canManage, type Model } from "./model" type Return = PageReturn type Commands = ReadonlyArray> type Requested = Readonly<{ model: Model; commands: Commands }> -type HubRoute = Extract +export type HubRoute = Extract + +/** The section and filters a hub route names; a legacy `tab` on `/alerts` is left out. */ +const fromRoute = (route: HubRoute): Pick => { + if (route._tag === "AlertsSection") return { tab: route.section, search: route.search } + const { tab: _legacyTab, ...search } = route.search + return { tab: "inbox", search } +} const DELETE_DIALOG_ID = "alerts-delete-destination" const DELETE_MONITOR_DIALOG_ID = "alerts-delete-monitor" @@ -115,7 +124,7 @@ const requestDeliveries = (model: Model, context: PageContext, mode: Keyed.Reque /** The Routing tab mounts its own deliveries read; the overview's stays mounted with the tab counts. */ const requestRoutingDeliveries = (model: Model, context: PageContext, mode: Keyed.RequestMode): Requested => { const key = deliveriesKey(context) - return resolveTab(model.search.tab) !== "routing" + return model.tab !== "routing" ? { model, commands: [] } : Option.match(Keyed.request(model.routingDeliveries, key, mode), { onNone: () => ({ model, commands: [] }), @@ -135,7 +144,7 @@ const silencesKey = (context: PageContext, windowId: AlertMaintenanceWindowId | /** The Silences tab reads the org's silences, or one window's, only while it is open. */ const requestSilences = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - if (resolveTab(model.search.tab) !== "silences") return { model, commands: [] } + if (model.tab !== "silences") return { model, commands: [] } const windowId = Option.getOrNull(windowIdOfParam(model.search.window)) return Option.match(Keyed.requestFor(model.silences, silencesKey(context, windowId), mode), { onNone: () => ({ model, commands: [] }), @@ -156,7 +165,7 @@ const windowsKey = (context: PageContext) => /** The Maintenance tab lists the windows; the Silences tab names the windows its silences belong to. */ const requestWindows = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - const tab = resolveTab(model.search.tab) + const tab = model.tab if (tab !== "maintenance" && tab !== "silences") return { model, commands: [] } return Option.match(Keyed.requestFor(model.windows, windowsKey(context), mode), { onNone: () => ({ model, commands: [] }), @@ -174,7 +183,7 @@ const requestWindows = (model: Model, context: PageContext, mode: Keyed.ReadMode const refreshWindows = (model: Model, context: PageContext): Return => { const windows = requestWindows(model, context, "Supersede") const silences: Requested = - resolveTab(windows.model.search.tab) === "silences" + windows.model.tab === "silences" ? requestSilences(windows.model, context, "Supersede") : { model: { ...windows.model, silences: Keyed.idle() }, commands: [] } return { model: silences.model, commands: [...windows.commands, ...silences.commands] } @@ -198,7 +207,7 @@ const requestServiceNamespaces = (model: Model, context: PageContext): Requested /** The Uptime tab reads the monitors, and the namespaces they group by, only while it is open. */ const requestMonitors = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - if (resolveTab(model.search.tab) !== "uptime") return { model, commands: [] } + if (model.tab !== "uptime") return { model, commands: [] } const namespaces = requestServiceNamespaces(model, context) return Option.match(Keyed.requestFor(namespaces.model.monitors, monitorsKey(context), mode), { onNone: () => namespaces, @@ -214,7 +223,7 @@ const slosKey = (context: PageContext) => /** The SLOs tab reads the SLOs and their statuses only while it is open. */ const requestSlos = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - if (resolveTab(model.search.tab) !== "slos") return { model, commands: [] } + if (model.tab !== "slos") return { model, commands: [] } return Option.match(Keyed.requestFor(model.slos, slosKey(context), mode), { onNone: () => ({ model, commands: [] }), onSome: (slos) => ({ model: { ...model, slos }, commands: [FetchHubSlos({ key: slos.key })] }), @@ -226,8 +235,8 @@ const chainsKey = (context: PageContext) => /** The Routing tab reads the escalation chains, and the On-call tab to name the ones paging a schedule. */ const requestChains = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { - const tab = resolveTab(model.search.tab) - if (tab !== "routing" && tab !== "oncall") return { model, commands: [] } + const tab = model.tab + if (tab !== "routing" && tab !== "on-call") return { model, commands: [] } return Option.match(Keyed.requestFor(model.chains, chainsKey(context), mode), { onNone: () => ({ model, commands: [] }), onSome: (chains) => ({ @@ -271,10 +280,10 @@ const isSchedulesStale = (model: Model, nowMs: number): boolean => { /** The clock ticked: the overview's derivation moves, and stale schedules are read again. */ const clockRead = (model: Model, context: PageContext, nowMs: number): Return => { - const next = { ...model, nowMs } - if (!isSchedulesStale(next, nowMs)) return { model: next } - const step = requestSchedules({ ...next, schedulesAskedAtMs: nowMs }, context, "Refresh") - return { model: step.model, commands: step.commands } + const sheet = followSilenceSheet({ ...model, nowMs }, context) + if (!isSchedulesStale(sheet.model, nowMs)) return sheet + const step = requestSchedules({ ...sheet.model, schedulesAskedAtMs: nowMs }, context, "Refresh") + return { model: step.model, commands: [...sheet.commands, ...step.commands] } } /** `reactivityKeys: ["alertDeliveryEvents"]`: both reads revalidate. */ @@ -286,15 +295,15 @@ const refreshDeliveries = (model: Model, context: PageContext) => { /** Members are read once, on the first tab that names people: the inbox strip, On-call, Routing. */ const requestMembers = (model: Model): Requested => { - const tab = resolveTab(model.search.tab) - return model.isMembersRequested || (tab !== "inbox" && tab !== "oncall" && tab !== "routing") + const tab = model.tab + return model.isMembersRequested || (tab !== "inbox" && tab !== "on-call" && tab !== "routing") ? { model, commands: [] } : { model: { ...model, isMembersRequested: true }, commands: [FetchHubMembers()] } } export const init = (route: HubRoute, context: PageContext): Return => { const model: Model = { - search: route.search, + ...fromRoute(route), nowMs: null, rules: null, states: null, @@ -380,7 +389,8 @@ export const init = (route: HubRoute, context: PageContext): Return => { } export const routeChanged = (model: Model, route: HubRoute, context: PageContext): Return => { - const routing = requestRoutingDeliveries({ ...model, search: route.search }, context, "Load") + const sheet = followSilenceSheet({ ...model, ...fromRoute(route) }, context) + const routing = requestRoutingDeliveries(sheet.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") const silences = requestSilences(monitors.model, context, "Load") const windows = requestWindows(silences.model, context, "Load") @@ -399,13 +409,14 @@ export const routeChanged = (model: Model, route: HubRoute, context: PageContext ...chains.commands, ...schedules.commands, ...members.commands, + ...sheet.commands, ReadHubClock(), ], } } -const navigate = (search: Model["search"]): PageOutMessage => - PageOutMessage.RequestedNavigation({ url: alertsHref(search), isReplace: false }) +const navigate = (search: Model["search"], tab: Model["tab"]): PageOutMessage => + PageOutMessage.RequestedNavigation({ url: alertsHref(search, tab), isReplace: false }) const toast = (type: "success" | "error", title: string, description = ""): PageOutMessage => PageOutMessage.RequestedToast({ type, title, description }) @@ -734,15 +745,46 @@ const deletedSchedule = ( // SILENCES +/** `?sheet=silence`: the new-silence side panel. */ +export const SILENCE_SHEET = "silence" + +const hubHref = (model: Model): string => alertsHref(model.search, model.tab) + +const silenceCommands = (step: ReturnType): Commands => + FoldkitCommand.mapMessages(step.commands ?? [], (message) => Message.GotSilenceDialogMessage({ message })) + +/** + * Opens or closes the silence panel to match `?sheet=` (a link, Back, or "New silence"). It opens + * once the clock is read, as the form checks its end against it, and only for a viewer who may save. + */ +const followSilenceSheet = (model: Model, context: PageContext): Requested => { + const isInUrl = model.search.sheet === SILENCE_SHEET + const isOpen = model.silenceDialog.dialog.isOpen + if (isInUrl && !isOpen && model.nowMs !== null && canManage(model)) { + const step = SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs) + return { model: { ...model, silenceDialog: step.model }, commands: silenceCommands(step) } + } + if (!isInUrl && isOpen) { + const step = SilenceDialog.close(model.silenceDialog) + return { model: { ...model, silenceDialog: step.model }, commands: silenceCommands(step) } + } + return { model, commands: [] } +} + const liftSilenceDialog = ( model: Model, context: PageContext, step: ReturnType, ): Return => { const next = { ...model, silenceDialog: step.model } - const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => - Message.GotSilenceDialogMessage({ message }), - ) + // The panel closed itself (Cancel, Escape, a save): its `?sheet=` goes too. + const hasClosed = model.silenceDialog.dialog.isOpen && !step.model.dialog.isOpen + const commands = [ + ...silenceCommands(step), + ...(hasClosed && model.search.sheet === SILENCE_SHEET + ? [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] + : []), + ] if (step.outMessage === undefined) return { model: next, commands } return SilenceDialog.OutMessage.match(step.outMessage, { RequestedToast: ({ type, title, description }) => ({ @@ -1044,10 +1086,10 @@ const updateMenu = (model: Model, context: PageContext, menuId: string, message: const statusToggled = (model: Model, bucket: RuleBucket): Return => ({ model, - outMessage: navigate({ - ...model.search, - status: resolveStatus(model.search.status) === bucket ? undefined : bucket, - }), + outMessage: navigate( + { ...model.search, status: resolveStatus(model.search.status) === bucket ? undefined : bucket }, + model.tab, + ), }) export const update = (model: Model, message: Message, context: PageContext): Return => @@ -1077,29 +1119,39 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, routingDeliveries: Keyed.accept(model.routingDeliveries, key, result) }, }), CompletedFetchSession: ({ orgId, grants }) => - orgId === context.orgId ? { model: { ...model, grants } } : { model }, + orgId === context.orgId ? followSilenceSheet({ ...model, grants }, context) : { model }, ChangedRulesQuery: ({ value }) => ({ model: { ...model, rulesQuery: value } }), ClickedStatusFilter: ({ bucket }) => statusToggled(model, bucket), ClickedClearRuleFilters: () => ({ model: { ...model, rulesQuery: "" }, - outMessage: navigate({ ...model.search, status: undefined }), + outMessage: navigate({ ...model.search, status: undefined }, model.tab), }), ClickedInboxSignal: ({ signal }) => ({ model, // The status and service belong to the signal view: plain Inbox drops them. - outMessage: navigate( + outMessage: signal === null - ? { ...model.search, signal: undefined, anomalyStatus: undefined, serviceName: undefined } - : { ...model.search, tab: "inbox", signal, anomalyStatus: undefined }, - ), + ? navigate( + { + ...model.search, + signal: undefined, + anomalyStatus: undefined, + serviceName: undefined, + }, + model.tab, + ) + : navigate({ ...model.search, signal, anomalyStatus: undefined }, "inbox"), }), ClickedIncidentStatus: ({ status }) => ({ model, - outMessage: navigate({ ...model.search, anomalyStatus: status === "open" ? undefined : status }), + outMessage: navigate( + { ...model.search, anomalyStatus: status === "open" ? undefined : status }, + model.tab, + ), }), ClickedClearInboxService: () => ({ model, - outMessage: navigate({ ...model.search, serviceName: undefined }), + outMessage: navigate({ ...model.search, serviceName: undefined }, model.tab), }), ClickedShowAllRules: () => ({ model: { ...model, isShowingAllRules: true } }), ClickedToggleRule: ({ ruleId, isEnabled }) => toggleRule(model, ruleId, isEnabled), @@ -1284,11 +1336,10 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), ClickedRetrySilences: () => refreshSilences(model, context), ClickedNewSilence: () => - liftSilenceDialog( - model, - context, - SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs ?? 0), - ), + model.search.sheet === SILENCE_SHEET + ? { model } + : { model, commands: [PushHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] }, + CompletedSheetUrl: () => ({ model }), GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), @@ -1353,7 +1404,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re : updateDeleteWindowDialog(model, Dialog.update(model.deleteWindowDialog, child)), ClickedClearWindowFilter: () => ({ model, - outMessage: navigate({ ...model.search, window: undefined }), + outMessage: navigate({ ...model.search, window: undefined }, model.tab), }), ClickedConfirmDeleteMonitor: () => confirmDeleteMonitor(model), GotDeleteMonitorDialogMessage: ({ message: child }) => diff --git a/apps/web/src/page/alerts/hub/view-empty.ts b/apps/web/src/page/alerts/hub/view-empty.ts index 5332b2de89..44384ef70f 100644 --- a/apps/web/src/page/alerts/hub/view-empty.ts +++ b/apps/web/src/page/alerts/hub/view-empty.ts @@ -122,7 +122,7 @@ const destinationStep = (h: HtmlBuilder, onAddDestination: M | undefined): ], ), onAddDestination === undefined - ? linkButton(h, { size: "sm", href: alertsHref({ tab: "routing" }) }, [ + ? linkButton(h, { size: "sm", href: alertsHref({}, "routing") }, [ icon(h, "PaperPlaneIcon"), "Add destination", ]) diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 735256f805..cc2e0ec9f8 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -63,10 +63,6 @@ export type InboxAcknowledge = Readonly<{ const isAcknowledged = (incident: AlertIncidentDocument) => incident.acknowledgedAt !== null -/** The inbox tab's badge: open incidents nobody has acknowledged yet. */ -export const unacknowledgedCount = (incidents: ReadonlyArray): number => - Arr.filter(incidents, (incident) => !isAcknowledged(incident)).length - export const inboxTab = ( h: HtmlBuilder, data: AlertsOverviewReady, diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index ea70cd025a..94d722f8fa 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -15,13 +15,11 @@ import * as MenuView from "@maple/views/ui/menu-view" import { skeleton, skeletonList } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" -import { navShell, type NavShellCount, type NavShellSection } from "../../kit/nav-shell" import type { BreadcrumbEntry } from "../../../shell" -import { alertsHref, createHref } from "../shared/links" +import { type AlertsTab, alertsHref, createHref } from "../shared/links" import { newSloHref } from "../shared/slo-commands" import { newMonitorHref } from "../shared/uptime-commands" import { - type AlertsTab, anomalyOverviewOf, destinationsOf, isAboutService, @@ -29,13 +27,12 @@ import { resolveIncidentStatus, resolveSignal, resolveStatus, - resolveTab, } from "./derive" import { Message } from "./message" import { canManage, type Model } from "./model" import { alertsEmptyState } from "./view-empty" import { anomalyInbox, inboxFilters } from "./view-anomalies" -import { inboxTab, unacknowledgedCount } from "./view-inbox" +import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" import * as ChainDialogView from "../escalation-dialog/view" @@ -58,7 +55,7 @@ import { import { hstack, vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" -// `AlertsPage` and `AlertsHub`: the `/alerts` page and the rail of its sections. +// `AlertsPage` and `AlertsHub`: `/alerts` and `/alerts/
`. The sidebar lists the sections. type H = HtmlBuilder @@ -69,112 +66,18 @@ const TAB_LABEL = { slos: "SLOs", silences: "Silences", maintenance: "Maintenance", - oncall: "On-call", + "on-call": "On-call", routing: "Routing", } as const satisfies Record -type RailRow = Readonly<{ tab: AlertsTab; icon: IconName }> - -const RAIL: ReadonlyArray }>> = [ - { id: "triage", title: "Triage", rows: [{ tab: "inbox", icon: "BellIcon" }] }, - { - id: "monitors", - title: "Monitors", - rows: [ - { tab: "rules", icon: "ChartLineIcon" }, - { tab: "uptime", icon: "GlobeIcon" }, - { tab: "slos", icon: "GaugeIcon" }, - ], - }, - { - id: "quiet", - title: "Quiet", - rows: [ - { tab: "silences", icon: "BellSlashIcon" }, - { tab: "maintenance", icon: "CalendarIcon" }, - ], - }, - { - id: "response", - title: "Response", - rows: [ - { tab: "oncall", icon: "UsersIcon" }, - { tab: "routing", icon: "BranchForkIcon" }, - ], - }, -] - const HUB_SKELETON_ROWS = ["h-14 w-1/2", "h-40 w-full", "h-48 w-full"] -/** `Alerts ›
`: the rail's active row names the page. */ +/** `Alerts ›
`: the sidebar's current sub-item names the page. */ export const breadcrumbs = (model: Model): ReadonlyArray => [ { label: "Alerts", href: alertsHref({}) }, - { label: TAB_LABEL[resolveTab(model.search.tab)] }, + { label: TAB_LABEL[model.tab] }, ] -const countOf = ( - model: Model, - overview: Option.Option, - tab: AlertsTab, -): number | undefined => { - const lengthOf = (rows: Option.Option>) => - Option.getOrUndefined(Option.map(rows, Arr.length)) - switch (tab) { - case "uptime": - return lengthOf(AsyncData.getData(model.monitors.data)) - case "slos": - return lengthOf(AsyncData.getData(model.slos.data)) - case "oncall": - return lengthOf(AsyncData.getData(model.schedules.data)) - case "inbox": - return Option.getOrUndefined( - Option.map(overview, (data) => unacknowledgedCount(data.openIncidents)), - ) - case "rules": - return Option.getOrUndefined(Option.map(overview, (data) => data.rules.length)) - case "silences": - case "maintenance": - case "routing": - return undefined - } -} - -/** The section rail in `dashboardPage`'s side slot, which folds into a sheet below `lg`. */ -const rail = (h: H, model: Model, overview: Option.Option, active: AlertsTab): Html => - navShell( - h, - Arr.map(RAIL, (section): NavShellSection => ({ - id: section.id, - title: section.title, - items: Arr.map(section.rows, (row) => { - const count = countOf(model, overview, row.tab) - const tone: NavShellCount["tone"] = row.tab === "inbox" ? "error" : "muted" - return { - kind: "link", - id: row.tab, - label: TAB_LABEL[row.tab], - icon: row.icon, - count: count !== undefined && count > 0 ? { value: count, tone } : undefined, - // The Anomalies view belongs to the Inbox: other sections drop its filters. - href: alertsHref( - row.tab === "inbox" - ? { ...model.search, tab: row.tab } - : { - ...model.search, - tab: row.tab, - signal: undefined, - anomalyStatus: undefined, - window: undefined, - }, - ), - } - }), - })), - active, - (tab) => tab, - "Alerts sections", - ) - /** A "New rule" menu row: icon, label and one line of what it watches. */ const newRuleItem = ( h: H, @@ -309,7 +212,7 @@ const headerActions = (h: H, model: Model, tab: AlertsTab): Html => ), ] : []), - ...(canManage(model) && tab === "oncall" && hasSchedules(model) ? [newScheduleButton(h)] : []), + ...(canManage(model) && tab === "on-call" && hasSchedules(model) ? [newScheduleButton(h)] : []), ...(canManage(model) && tab === "routing" ? [ button( @@ -440,7 +343,7 @@ const deleteDialog = (h: H, model: Model): Html => { } export const view = Submodel.defineView((model, inputs, h) => { - const tab = resolveTab(model.search.tab) + const tab = model.tab const overview = overviewOf(model) return h.div( [h.Class("contents")], @@ -450,9 +353,6 @@ export const view = Submodel.defineView((model, { title: TAB_LABEL[tab], actions: headerActions(h, model, tab), - filters: rail(h, model, overview, tab), - filtersLabel: "Alerts", - filtersWidth: "w-56", }, [ tab === "routing" @@ -469,7 +369,7 @@ export const view = Submodel.defineView((model, ? silencesTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) : tab === "maintenance" ? maintenanceTab(h, model, model.nowMs ?? 0, inputs.context.timeZone) - : tab === "oncall" + : tab === "on-call" ? oncallTab(h, model, model.nowMs ?? 0) : hubBody(h, model, overview, tab), ], diff --git a/apps/web/src/page/alerts/rule/rule.scene.test.ts b/apps/web/src/page/alerts/rule/rule.scene.test.ts index a6cb93c90a..f9f7c1bc38 100644 --- a/apps/web/src/page/alerts/rule/rule.scene.test.ts +++ b/apps/web/src/page/alerts/rule/rule.scene.test.ts @@ -348,7 +348,7 @@ describe("alert rule", () => { }), ) expect(viewAll.outMessage).toEqual( - PageOutMessage.RequestedNavigation({ url: "/alerts?tab=overview", isReplace: false }), + PageOutMessage.RequestedNavigation({ url: "/alerts", isReplace: false }), ) }) }) diff --git a/apps/web/src/page/alerts/rule/update.ts b/apps/web/src/page/alerts/rule/update.ts index 06177371b6..6c56441c30 100644 --- a/apps/web/src/page/alerts/rule/update.ts +++ b/apps/web/src/page/alerts/rule/update.ts @@ -322,7 +322,7 @@ const updateMenu = (model: Model, menuId: string, message: Menu.Message): Return model: next, commands, outMessage: PageOutMessage.RequestedNavigation({ - url: alertsHref({ tab: "overview" }), + url: alertsHref({}), isReplace: false, }), } diff --git a/apps/web/src/page/alerts/rule/view-diagnosis.ts b/apps/web/src/page/alerts/rule/view-diagnosis.ts index 0de938ee0c..5a24e324e2 100644 --- a/apps/web/src/page/alerts/rule/view-diagnosis.ts +++ b/apps/web/src/page/alerts/rule/view-diagnosis.ts @@ -290,7 +290,7 @@ const stageAction = (h: H, action: NonNullable): Reado ] if (action.kind === "destinations") return [ - linkButton(h, { variant: "outline", size: "xs", href: alertsHref({ tab: "routing" }) }, [ + linkButton(h, { variant: "outline", size: "xs", href: alertsHref({}, "routing") }, [ action.label, ]), ] diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 4b8cbb53d7..c2c01db6b0 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -60,7 +60,7 @@ export const retrieveIncidentEscalation = (id: AlertIncidentId) => return incident.escalation }).pipe(Effect.mapError(toLoadFailure)) -export const routingTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "routing" } })) +export const routingTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "routing", search: {} })) // SUMMARY diff --git a/apps/web/src/page/alerts/shared/links.ts b/apps/web/src/page/alerts/shared/links.ts index bf622381e2..4d86fc8328 100644 --- a/apps/web/src/page/alerts/shared/links.ts +++ b/apps/web/src/page/alerts/shared/links.ts @@ -1,7 +1,12 @@ import { AlertRuleId } from "@maple/domain/http" import { Option, Schema } from "effect" -import { AppRoute, hrefOf } from "../../../route" -import type { AlertRuleSearch, AlertsCreateSearch, AlertsSearch } from "../../../route/search" +import { alertsSectionRoute, AppRoute, hrefOf } from "../../../route" +import type { + AlertRuleSearch, + AlertsCreateSearch, + AlertsHubSearch, + AlertsSection, +} from "../../../route/search" const decodeRuleId = Schema.decodeUnknownOption(AlertRuleId) @@ -15,7 +20,12 @@ export const ruleHref = (rawRuleId: string, search: typeof AlertRuleSearch.Type export const incidentHref = (incidentId: string): string => hrefOf(AppRoute.AlertIncident({ incidentId, search: {} })) -export const alertsHref = (search: typeof AlertsSearch.Type): string => hrefOf(AppRoute.Alerts({ search })) +/** The Alerts hub's sections: the Inbox at `/alerts`, the rest at `/alerts/
`. */ +export type AlertsTab = "inbox" | AlertsSection + +/** A hub section's URL with the hub's filters; the Inbox by default. */ +export const alertsHref = (search: typeof AlertsHubSearch.Type, tab: AlertsTab = "inbox"): string => + hrefOf(alertsSectionRoute(tab, search)) export const createHref = (search: typeof AlertsCreateSearch.Type): string => hrefOf(AppRoute.AlertsCreate({ search })) diff --git a/apps/web/src/page/alerts/shared/maintenance-windows.ts b/apps/web/src/page/alerts/shared/maintenance-windows.ts index 2f4f7d785b..926b397430 100644 --- a/apps/web/src/page/alerts/shared/maintenance-windows.ts +++ b/apps/web/src/page/alerts/shared/maintenance-windows.ts @@ -85,7 +85,7 @@ export const windowIdOfParam = (raw: string | undefined): Option.Option - alertsHref({ tab: "silences", window: windowPublicId(id) }) + alertsHref({ window: windowPublicId(id) }, "silences") export const WEEKDAYS: ReadonlyArray<{ value: MaintenanceWeekday; label: string }> = [ { value: "mon", label: "Mon" }, diff --git a/apps/web/src/page/alerts/shared/oncall-schedules.ts b/apps/web/src/page/alerts/shared/oncall-schedules.ts index fc9c70a19e..b35b9a8b57 100644 --- a/apps/web/src/page/alerts/shared/oncall-schedules.ts +++ b/apps/web/src/page/alerts/shared/oncall-schedules.ts @@ -143,7 +143,7 @@ export const newScheduleHref = (search: typeof AlertsScheduleNewSearch.Type = {} export const editScheduleHref = (id: OnCallScheduleId): string => newScheduleHref({ scheduleId: encodePublicId(id) }) -export const oncallTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "oncall" } })) +export const oncallTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "on-call", search: {} })) // FORMAT diff --git a/apps/web/src/page/alerts/shared/slo-commands.ts b/apps/web/src/page/alerts/shared/slo-commands.ts index ae9de12599..e2d66603af 100644 --- a/apps/web/src/page/alerts/shared/slo-commands.ts +++ b/apps/web/src/page/alerts/shared/slo-commands.ts @@ -114,4 +114,4 @@ export const newSloHref = (search: typeof AlertsSloNewSearch.Type = {}): string export const editSloHref = (id: SloId): string => newSloHref({ sloId: encodePublicId(id) }) -export const slosTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "slos" } })) +export const slosTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "slos", search: {} })) diff --git a/apps/web/src/page/alerts/shared/test-rows.ts b/apps/web/src/page/alerts/shared/test-rows.ts index b6b41b16a6..2259f78b67 100644 --- a/apps/web/src/page/alerts/shared/test-rows.ts +++ b/apps/web/src/page/alerts/shared/test-rows.ts @@ -4,8 +4,17 @@ import type { AlertRuleRow, AlertRuleStateRow, } from "@maple/web-data/collections/alerts" +import { alertsSectionRoute } from "../../../route" +import type { AlertsHubSearch } from "../../../route/search" +import type { AlertsTab } from "./links" import { isoMs } from "./time" +/** A hub section and its filters, as the tests and labs spell where they stand. */ +export type HubAt = typeof AlertsHubSearch.Type & { readonly tab?: AlertsTab } + +/** The hub route for `at`: `/alerts` for the Inbox (the default), else `/alerts/`. */ +export const hubAt = ({ tab = "inbox", ...search }: HubAt = {}) => alertsSectionRoute(tab, search) + // Synced alert rows for the alerts pages' scene tests, the shape Electric delivers. export const ORG = "org_acme" diff --git a/apps/web/src/page/alerts/shared/uptime-commands.ts b/apps/web/src/page/alerts/shared/uptime-commands.ts index 18d2b3d1f7..847410c969 100644 --- a/apps/web/src/page/alerts/shared/uptime-commands.ts +++ b/apps/web/src/page/alerts/shared/uptime-commands.ts @@ -104,7 +104,7 @@ export const newMonitorHref = (search: typeof AlertsUptimeNewSearch.Type = {}): export const editMonitorHref = (id: UptimeMonitorId): string => newMonitorHref({ monitorId: encodePublicId(id) }) -export const uptimeTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "uptime" } })) +export const uptimeTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "uptime", search: {} })) /** Every check is a span in the `maple-uptime` service, namespace `maple-services`. */ export const UPTIME_SERVICE = UPTIME_SERVICE_NAME diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index c2c034cdc7..2eba7bba2e 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -9,7 +9,7 @@ import { MAX_ALERT_SILENCE_DURATION_MS } from "@maple/domain/http" import { hasMatchers, MatcherField, matchersFromTyped, TypedMatchers } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" -// "Silence notifications": the dialog the hub, a rule and an incident open, as one Submodel. +// "Silence notifications": the side panel the hub, a rule and an incident open, as one Submodel. export const SilenceDuration = Schema.Literals(["1h", "4h", "morning", "custom"]) export type SilenceDuration = typeof SilenceDuration.Type @@ -41,7 +41,7 @@ export const Model = Schema.Struct({ export type Model = typeof Model.Type export const init = (id: string): Model => ({ - dialog: Dialog.init(id, "dialog"), + dialog: Dialog.init(id, "sheet"), scope: { _tag: "Typed", services: "", tags: "", environments: "" }, reason: "", duration: "1h", @@ -210,6 +210,9 @@ export const openFor = ( export const openNew = (model: Model, timeZone: string, nowMs: number): Return => open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone, nowMs) +/** Closes the panel, as Cancel does; a page whose URL holds it closes it on Back. */ +export const close = (model: Model): Return => liftDialog(model, Dialog.close(model.dialog)) + const save = (model: Model): Return => model.isSaving || !isFormReady(model) ? { model } @@ -237,7 +240,7 @@ export const update = (model: Model, message: Message): Return => model.scope._tag === "Fixed" ? { model } : { model: { ...model, scope: { ...model.scope, [field]: value } } }, - ClickedCancel: () => liftDialog(model, Dialog.close(model.dialog)), + ClickedCancel: () => close(model), ClickedSave: () => save(model), CompletedSave: ({ openCount, result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 6e1b31bfc3..d2758eb2df 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -2,9 +2,10 @@ import { Option, Array as Arr } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { eyebrow } from "@maple/views/ui/eyebrow" -import { formDialog } from "@maple/views/ui/form-dialog" +import { button } from "@maple/views/ui/button" import { panel } from "@maple/views/ui/panel" import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { sidePanelSheet } from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" @@ -90,31 +91,43 @@ const durationSection = (h: H, model: Model): Html => : []), ]) -/** The "Silence notifications" dialog: what it covers, for how long, and why. */ -export const view = Submodel.defineView((model, inputs, h) => - formDialog(h, { - slotId: `${inputs.id}-dialog`, - model: model.dialog, +/** The "Silence notifications" side panel: what it covers, for how long, and why. */ +export const view = Submodel.defineView((model, _inputs, h) => + sidePanelSheet(h, { + sheet: model.dialog, toParentMessage: (message) => Message.GotDialogMessage({ message }), - className: "sm:max-w-lg", title: "Silence notifications", description: "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", - panelClassName: "space-y-5", - fields: [ - scopeSection(h, model), - durationSection(h, model), - textField(h, { - name: "reason", - label: "Reason", - value: model.reason, - placeholder: "Deploying checkout v2.3", - onInput: (value) => Message.ChangedReason({ value }), - }), + body: [ + vstack(h, { gap: "5" }, [ + scopeSection(h, model), + durationSection(h, model), + textField(h, { + name: "reason", + label: "Reason", + value: model.reason, + placeholder: "Deploying checkout v2.3", + onInput: (value) => Message.ChangedReason({ value }), + }), + ]), ], onSubmit: Message.ClickedSave(), - submitLabel: submitLabel(model), - isPending: model.isSaving, - isSubmitDisabled: !isFormReady(model), + footer: (close) => [ + button( + h, + { variant: "outline", isDisabled: model.isSaving, attributes: model.isSaving ? [] : close }, + ["Cancel"], + ), + button( + h, + { + type: "submit", + isLoading: model.isSaving, + isDisabled: model.isSaving || !isFormReady(model), + }, + [submitLabel(model)], + ), + ], }), ) diff --git a/apps/web/src/page/services/detail/uptime-card.scene.test.ts b/apps/web/src/page/services/detail/uptime-card.scene.test.ts index 0e683b9dc5..60558c0397 100644 --- a/apps/web/src/page/services/detail/uptime-card.scene.test.ts +++ b/apps/web/src/page/services/detail/uptime-card.scene.test.ts @@ -73,7 +73,7 @@ describe("service detail, Uptime card", () => { "href", "/alerts/uptime/new?serviceName=checkout-api", ), - expectView(role("link", { name: "Open in Alerts" })).toHaveAttr("href", "/alerts?tab=uptime"), + expectView(role("link", { name: "Open in Alerts" })).toHaveAttr("href", "/alerts/uptime"), ) }) }) diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index 506af359a4..8e1120611f 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -39,8 +39,8 @@ export const resourceHref = (link: AuditResourceLink): string => { return `/investigations/${encodeURIComponent(link.params.id)}` case "/anomalies/$incidentId": return `/anomalies/${encodeURIComponent(link.params.incidentId)}` - case "/alerts": - return hrefOf(AppRoute.Alerts({ search: link.search })) + case "/alerts/routing": + return hrefOf(AppRoute.AlertsSection({ section: "routing", search: {} })) case "/integrations": return hrefOf(AppRoute.Integrations({ search: link.search })) case "/settings": @@ -99,11 +99,7 @@ const identifier = (h: H, model: Model, value: string, label: string): Html => { const changesTable = (h: H, changes: V2AuditChanges): Html => table(h, { size: "sm", className: "font-mono" }, [ tableHeader(h, [ - tableRow(h, [ - tableHead(h, ["Field"], "w-40"), - tableHead(h, ["Before"]), - tableHead(h, ["After"]), - ]), + tableRow(h, [tableHead(h, ["Field"], "w-40"), tableHead(h, ["Before"]), tableHead(h, ["After"])]), ]), tableBody( h, diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 6cc1190873..69069410ea 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -78,6 +78,7 @@ export const AppRoute = defineRouteUnion({ Trace: { traceId: Schema.String, search: Search.TraceSearch }, AgentSessions: { search: Search.AgentSessionsSearch }, Alerts: { search: Search.AlertsSearch }, + AlertsSection: { section: Search.AlertsSection, search: Search.AlertsHubSearch }, Analytics: { search: Search.AnalyticsSearch }, Anomalies: { search: Search.AnomaliesSearch }, CodeReview: { search: Search.CodeReviewSearch }, diff --git a/apps/web/src/route/index.ts b/apps/web/src/route/index.ts index 952e2e784a..71e42e5619 100644 --- a/apps/web/src/route/index.ts +++ b/apps/web/src/route/index.ts @@ -1,3 +1,3 @@ export { AppRoute, isPublicRoute, type RouteTag } from "./app-route" -export { flaggedRouteRedirect, Redirect, routeRedirect } from "./redirect" +export { alertsSectionRoute, flaggedRouteRedirect, Redirect, routeRedirect } from "./redirect" export { hrefOf, urlToAppRoute } from "./routers" diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index e21bd3f792..a897eff99f 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -3,7 +3,13 @@ import { Option, Predicate, Schema } from "effect" import { AppRoute } from "./app-route" import { hrefOf } from "./routers" import { DATA_INTEGRATIONS } from "./data-integrations" -import { type AlertsSearch, type AnomaliesSearch, ErrorsSearch } from "./search" +import { + type AlertsHubSearch, + type AlertsSearch, + AlertsSection, + type AnomaliesSearch, + ErrorsSearch, +} from "./search" /** Where a route sends the browser instead of rendering, and whether it replaces history. */ export const Redirect = Schema.Struct({ url: Schema.String, isReplace: Schema.Boolean }) @@ -60,6 +66,23 @@ const anomaliesViewSearch = (search: typeof AnomaliesSearch.Type): typeof Alerts } } +const decodeAlertsSection = Schema.decodeUnknownOption(AlertsSection) + +/** The section an old `?tab=` named: "oncall" and "settings" were renamed, the rest is the Inbox. */ +const legacyAlertsSection = (tab: string): AlertsSection | "inbox" => + tab === "oncall" + ? "on-call" + : tab === "settings" + ? "routing" + : Option.getOrElse(decodeAlertsSection(tab), (): "inbox" => "inbox") + +/** `/alerts` for the Inbox, else `/alerts/
`, with the hub's filters. */ +export const alertsSectionRoute = ( + section: AlertsSection | "inbox", + search: typeof AlertsHubSearch.Type, +): Extract => + section === "inbox" ? AppRoute.Alerts({ search }) : AppRoute.AlertsSection({ section, search }) + const DATA_INTEGRATION_IDS: ReadonlySet = new Set(DATA_INTEGRATIONS) const redirect = (to: AppRoute, isReplace: boolean) => @@ -100,9 +123,13 @@ export const routeRedirect = (route: AppRoute): Option.Option => /** The anomalies list is the Alerts Inbox's Anomalies view; one service and the status carry over. */ Anomalies: ({ search }) => redirect(AppRoute.Alerts({ search: anomaliesViewSearch(search) }), true), - /** Status pages left the Alerts hub for their own sidebar entry. */ - Alerts: ({ search }) => - search.tab === "status-pages" ? redirect(AppRoute.StatusPages(), true) : Option.none(), + /** A legacy `?tab=` opens its section's own path; status pages left the hub for the sidebar. */ + Alerts: ({ search: { tab, ...search } }) => + tab === undefined + ? Option.none() + : tab === "status-pages" + ? redirect(AppRoute.StatusPages(), true) + : redirect(alertsSectionRoute(legacyAlertsSection(tab), search), true), AlertsStatusPage: ({ pageId, search }) => redirect( AppRoute.StatusPage({ diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index e9d860e27a..91812635e3 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -69,11 +69,47 @@ describe("routes", () => { expect(hrefOf(route)).toBe("/status-pages/stp_abc?view=settings") }) + test("each Alerts section has its own path, beside the detail pages under it", () => { + for (const section of ["rules", "uptime", "slos", "silences", "maintenance", "on-call", "routing"]) { + const route = parse(`/alerts/${section}`) + expect(route).toMatchObject({ _tag: "AlertsSection", section }) + expect(hrefOf(route)).toBe(`/alerts/${section}`) + } + expect(parse("/alerts")._tag).toBe("Alerts") + expect(hrefOf(parse("/alerts/rules?status=firing"))).toBe("/alerts/rules?status=firing") + expect(parse("/alerts/uptime/new")._tag).toBe("AlertsUptimeNew") + expect(parse("/alerts/uptime/mon_1")._tag).toBe("AlertsUptimeMonitor") + expect(parse("/alerts/slos/slo_1")._tag).toBe("AlertsSlo") + expect(parse("/alerts/schedules/sch_1")._tag).toBe("AlertsSchedule") + expect(parse("/alerts/incidents/inc_1")._tag).toBe("AlertIncident") + expect(parse("/alerts/create")._tag).toBe("AlertsCreate") + expect(parse("/alerts/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") + expect(parse("/alerts/nope")._tag).toBe("NotFound") + }) + + test("an old `?tab=` link replaces itself with its section's path and keeps its other params", () => { + const redirectOf = (url: string) => Option.map(routeRedirect(parse(url)), (to) => to.url) + expect(redirectOf("/alerts?tab=rules&status=firing")).toEqual( + Option.some("/alerts/rules?status=firing"), + ) + expect(redirectOf("/alerts?tab=%22silences%22&window=mw_1")).toEqual( + Option.some("/alerts/silences?window=mw_1"), + ) + expect(redirectOf("/alerts?tab=oncall")).toEqual(Option.some("/alerts/on-call")) + expect(redirectOf("/alerts?tab=settings")).toEqual(Option.some("/alerts/routing")) + expect(redirectOf("/alerts?tab=inbox&signal=anomaly")).toEqual(Option.some("/alerts?signal=anomaly")) + expect(redirectOf("/alerts?tab=overview")).toEqual(Option.some("/alerts")) + expect(routeRedirect(parse("/alerts?tab=uptime"))).toEqual( + Option.some({ url: "/alerts/uptime", isReplace: true }), + ) + expect(routeRedirect(parse("/alerts?signal=anomaly"))).toEqual(Option.none()) + expect(routeRedirect(parse("/alerts/rules"))).toEqual(Option.none()) + }) + test("the old status page links under Alerts redirect to their own section", () => { expect(routeRedirect(parse("/alerts?tab=%22status-pages%22"))).toEqual( Option.some({ url: "/status-pages", isReplace: true }), ) - expect(routeRedirect(parse("/alerts?tab=%22uptime%22"))).toEqual(Option.none()) expect(routeRedirect(parse("/alerts/status-pages/stp_abc"))).toEqual( Option.some({ url: "/status-pages/stp_abc", isReplace: true }), ) diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 08e3c2da40..e1710c59c2 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -284,6 +284,12 @@ const routers = { Route.mapTo(AppRoute.AgentSessions), ), Alerts: pipe(path("alerts"), search(Search.AlertsSearch), Route.mapTo(AppRoute.Alerts)), + AlertsSection: pipe( + path("alerts"), + slash(schemaSegment("section", Search.AlertsSection)), + search(Search.AlertsHubSearch), + Route.mapTo(AppRoute.AlertsSection), + ), Analytics: pipe(path("analytics"), search(Search.AnalyticsSearch), Route.mapTo(AppRoute.Analytics)), Anomalies: pipe(path("anomalies"), search(Search.AnomaliesSearch), Route.mapTo(AppRoute.Anomalies)), CodeReview: pipe(path("code-review"), search(Search.CodeReviewSearch), Route.mapTo(AppRoute.CodeReview)), @@ -521,6 +527,7 @@ const routers = { // Literal routes come before the param routes that would also match them (`/infra/hosts`). export const urlToAppRoute = Route.parseUrlWithFallback( Route.oneOf( + routers.AlertsSection, routers.AlertsCreate, routers.AlertsUptimeNew, routers.AlertsUptimeMonitor, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 84b89168c0..c0999c525f 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -514,8 +514,19 @@ export const AgentSessionsSearch = Schema.Struct({ sortDir: Schema.optional(AiSessionSortDir), }) -export const AlertsSearch = Schema.Struct({ - tab: Schema.optional(Schema.String), +/** The Alerts hub's sections past the Inbox, each at `/alerts/
`. */ +export const AlertsSection = Schema.Literals([ + "rules", + "uptime", + "slos", + "silences", + "maintenance", + "on-call", + "routing", +]) +export type AlertsSection = typeof AlertsSection.Type + +const alertsHubSearchFields = { serviceName: Schema.optional(Schema.String), status: Schema.optional(Schema.String), /** The Inbox narrowed to one kind of incident: "anomaly" lists anomalies alone. */ @@ -524,7 +535,15 @@ export const AlertsSearch = Schema.Struct({ anomalyStatus: Schema.optional(Schema.String), /** The Silences tab narrowed to one maintenance window's occurrences (`mw_…`). */ window: Schema.optional(Schema.String), -}) + /** The open side panel (`SHEET_PARAM`): "silence" is the new-silence form. */ + sheet: Schema.optional(Schema.String), +} + +/** `/alerts` and `/alerts/
`: the filters every section shares. */ +export const AlertsHubSearch = Schema.Struct(alertsHubSearchFields) + +/** `/alerts`: a legacy `tab` redirects to its section's own path. */ +export const AlertsSearch = Schema.Struct({ tab: Schema.optional(Schema.String), ...alertsHubSearchFields }) // sessionTimeRangeSearchMiddleware() export const AnalyticsSearch = Schema.Struct({ diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 22ded041ce..71f756559c 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -34,6 +34,8 @@ export interface NavSubItem { }> /** The command palette's title, when the row's own reads ambiguously there. */ readonly paletteTitle?: string + /** Pages under another path that this row stands in for (schedules under On-call). */ + readonly alsoActive?: ReadonlyArray } export interface NavItem { @@ -129,6 +131,40 @@ const exploreItem: NavItem = { ], } +const alertsItem: NavItem = { + title: "Alerts", + href: "/alerts", + icon: "BellIcon", + alsoActive: ["/anomalies"], + subItems: [ + { + title: "Inbox", + href: "/alerts", + icon: "BellIcon", + paletteTitle: "Alerts", + alsoActive: ["/anomalies"], + }, + { + title: "Rules", + href: "/alerts/rules", + icon: "ChartLineIcon", + paletteTitle: "Alert rules", + alsoActive: ["/alerts/create"], + }, + { title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" }, + { title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" }, + { title: "Silences", href: "/alerts/silences", icon: "BellSlashIcon" }, + { + title: "Maintenance", + href: "/alerts/maintenance", + icon: "CalendarIcon", + paletteTitle: "Maintenance windows", + }, + { title: "On-call", href: "/alerts/on-call", icon: "UsersIcon", alsoActive: ["/alerts/schedules"] }, + { title: "Routing", href: "/alerts/routing", icon: "BranchForkIcon", paletteTitle: "Alert routing" }, + ], +} + /** The org rollout flags that gate a nav row each. */ export type NavFlags = Readonly<{ releases: boolean @@ -174,7 +210,7 @@ export const navGroups = (flags: NavFlags): ReadonlyArray => [ label: "Triage", items: [ { title: "Errors", href: "/errors", icon: "CircleWarningIcon" }, - { title: "Alerts", href: "/alerts", icon: "BellIcon", alsoActive: ["/anomalies"] }, + alertsItem, { title: "Code review", href: "/code-review", @@ -197,8 +233,9 @@ export const isPathActive = (currentPath: string, href: string): boolean => export const matchSubItem = (currentPath: string, sub: NavSubItem): string | undefined => Arr.reduce( - Arr.filter([sub.href, ...Arr.map(sub.views ?? [], (view) => view.href)], (href) => - isPathActive(currentPath, href), + Arr.filter( + [sub.href, ...Arr.map(sub.views ?? [], (view) => view.href), ...(sub.alsoActive ?? [])], + (href) => isPathActive(currentPath, href), ), undefined, (best, href) => (best === undefined || href.length > best.length ? href : best), diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 5ce01cc3bf..825096ee87 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -90,7 +90,7 @@ describe("on-call pill", () => { expect(footer).toContainText("You're on call"), expect(footer).toContainText("until Mon 16 Nov, 09:00 CET"), expect(footer).toContainText("Checkout primary"), - expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(role("link", { name: /You're on call/ })).toHaveAttr("href", "/alerts/on-call"), ) }) @@ -99,7 +99,7 @@ describe("on-call pill", () => { { update, view }, given(withOnCall([shift({ userId: "user_someone" })])), measured, - expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts?tab=oncall"), + expect(role("link", { name: /You're not on call/ })).toHaveAttr("href", "/alerts/on-call"), expect(footer).toContainText("My schedules"), ) }) diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index f395917878..d1f5daa5a0 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -425,6 +425,56 @@ describe("app update banner", () => { }) }) +describe("alerts section", () => { + const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) + + test("on an alerts page, Alerts opens its sections with the open one current", () => { + scene( + { update, view: shellAt("/alerts/rules") }, + given(signedIn()), + measured, + expect(role("link", { name: "Rules" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Rules" })).toHaveAttr("href", "/alerts/rules"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Silences" })).toHaveAttr("href", "/alerts/silences"), + expect(role("link", { name: "On-call" })).toHaveAttr("href", "/alerts/on-call"), + ) + scene( + { update, view: shellAt("/alerts") }, + given(signedIn()), + measured, + expect(role("link", { name: "Inbox" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Rules" })).not.toHaveAttr("aria-current", "page"), + ) + }) + + test("a detail page marks the section it belongs to", () => { + scene( + { update, view: shellAt("/alerts/schedules/sch_1") }, + given(signedIn()), + measured, + expect(role("link", { name: "On-call" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + ) + scene( + { update, view: shellAt("/alerts/uptime/mon_1") }, + given(signedIn()), + measured, + expect(role("link", { name: "Uptime" })).toHaveAttr("aria-current", "page"), + ) + }) + + test("elsewhere the sections stay hidden", () => { + scene( + { update, view: shellAt("/logs") }, + given(signedIn()), + measured, + expect(role("link", { name: "Alerts" })).toExist(), + expect(role("link", { name: "Silences" })).toBeAbsent(), + ) + }) +}) + describe("services section", () => { test("on the service map, Services opens with the map row current", () => { scene( diff --git a/packages/views/src/ui/sheet.ts b/packages/views/src/ui/sheet.ts index 966070641b..5c5709fbfc 100644 --- a/packages/views/src/ui/sheet.ts +++ b/packages/views/src/ui/sheet.ts @@ -16,6 +16,7 @@ import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" import { button } from "./button" import * as Dialog from "./dialog" import * as DialogView from "./dialog-view" +import { formDialogForm } from "./form-dialog" import { icon } from "./icon" // `` (packages/ui/src/components/ui/sheet.tsx) over Base UI Dialog. @@ -147,6 +148,8 @@ export type SidePanelSheetOptions = Readonly<{ * Cancel dismisses like Escape does; none renders no footer. */ footer?: (close: ReadonlyArray) => ReadonlyArray + /** Makes the panel a `
`: Enter in a field or a `type: "submit"` footer button sends this. */ + onSubmit?: Message toParentMessage: (message: Dialog.Message) => Message }> @@ -173,18 +176,23 @@ export const sidePanelSheet = ( className: REDUCED_MOTION, hasDescription: options.description !== undefined, toView: ({ portal }) => portal, - toContent: (close) => [ - DialogView.dialogHeader(h, "sheet", [ - DialogView.dialogTitle(h, "sheet", sheet.id, options.title), - ...(options.description === undefined + toContent: (close) => { + const content = [ + DialogView.dialogHeader(h, "sheet", [ + DialogView.dialogTitle(h, "sheet", sheet.id, options.title), + ...(options.description === undefined + ? [] + : [DialogView.dialogDescription(h, "sheet", sheet.id, options.description)]), + ]), + DialogView.dialogPanel(h, "sheet", sheet.id, options.body), + ...(options.footer === undefined ? [] - : [DialogView.dialogDescription(h, "sheet", sheet.id, options.description)]), - ]), - DialogView.dialogPanel(h, "sheet", sheet.id, options.body), - ...(options.footer === undefined - ? [] - : [DialogView.dialogFooter(h, "sheet", options.footer(close))]), - ], + : [DialogView.dialogFooter(h, "sheet", options.footer(close))]), + ] + return options.onSubmit === undefined + ? content + : [formDialogForm(h, options.onSubmit, content)] + }, }, toParentMessage: options.toParentMessage, }) diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 528a4fd6cd..b632d9b440 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -427,7 +427,8 @@ export const sidebarMenuSubButton = ( options: Readonly<{ className?: string; isActive: boolean; link?: LinkTarget; href?: string }>, children: Children, ): Html => { - const linkActive = options.link !== undefined && isLinkActive(options.link.currentPath, options.link.href) + // The caller picks the most specific sibling, so an index row (`/alerts`) doesn't claim its siblings' pages. + const linkActive = options.link !== undefined && options.isActive const className = cn( ...sidebarMenuSubButtonStyles, sidebarMenuSubButtonMdStyles, From 58883959f0d6f11e220a44eb44de2b1cf69e9648 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:03:18 +0100 Subject: [PATCH 08/69] fix(shell): read window and document on subscribe, not on import --- apps/web/src/shell/subscription.ts | 35 ++++++++++++++++-------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index ec188cab53..210f70f782 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -98,27 +98,30 @@ export const onCallTimer = (atMs: number): Stream.Stream => }), ) -const wakeTargets: Dom.TypedEventTarget<{ focus: FocusEvent; visibilitychange: Event }> = window - /** The tab came back into view or focus (a handoff may have passed while it slept). */ -export const onCallWakes: Stream.Stream = Stream.merge( - Dom.streamFromEvent({ target: wakeTargets, type: "focus", mapEvent: () => true }), - Dom.streamFromEvent({ - target: document, - type: "visibilitychange", - mapEvent: () => document.visibilityState === "visible", - }), -).pipe( +// Suspended: `window` and `document` are read on subscribe, so importing the shell needs no DOM. +export const onCallWakes: Stream.Stream = Stream.suspend(() => { + const wakeTarget: Dom.TypedEventTarget<{ focus: FocusEvent }> = window + return Stream.merge( + Dom.streamFromEvent({ target: wakeTarget, type: "focus", mapEvent: () => true }), + Dom.streamFromEvent({ + target: document, + type: "visibilitychange", + mapEvent: () => document.visibilityState === "visible", + }), + ) +}).pipe( Stream.filter((isAwake) => isAwake), Stream.mapEffect(() => Clock.currentTimeMillis), Stream.map((nowMs) => Message.WokeOnCall({ nowMs })), ) -const onCallChangeTarget: Dom.TypedEventTarget<{ [ONCALL_CHANGED_EVENT]: Event }> = window - /** A page wrote a schedule or an override. */ -export const onCallChanges: Stream.Stream = Dom.streamFromEvent({ - target: onCallChangeTarget, - type: ONCALL_CHANGED_EVENT, - mapEvent: () => Message.ChangedOnCallSchedules(), +export const onCallChanges: Stream.Stream = Stream.suspend(() => { + const target: Dom.TypedEventTarget<{ [ONCALL_CHANGED_EVENT]: Event }> = window + return Dom.streamFromEvent({ + target, + type: ONCALL_CHANGED_EVENT, + mapEvent: () => Message.ChangedOnCallSchedules(), + }) }) From 2de1f25e4fa58a208a8a19ce48f1e4baf03041bd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:11:02 +0100 Subject: [PATCH 09/69] fix(query-builder): field picker's accessible name includes the picked field --- .../src/page/kit/query-builder/condition-builder-view.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 0c9b61487e..16402480b6 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder-view.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -114,7 +114,11 @@ const fieldPicker = ( Message.GotConditionField({ queryId: query.id, rowId: row.id, message }), viewInputs: { items: Arr.map(items, (item) => item.value), - ariaLabel: `Field for condition ${index + 1}`, + // 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, fieldOf(row.key, query.dataSource)), From b6ae4b0a4ec66f8c848b2dddeac4cadb92a589b2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:20:14 +0100 Subject: [PATCH 10/69] fix(alerts): sentence card review fixes Card issues come from saveIssues with the query timing, so a slow SQL check opens Alert when; destination and chain names are loading, found or removed; a critical-only route reads "Critical: ..."; the Alert when summary shares the action bar's checks phrase and says recovery and no data. Cards keep the lead as the heading with the summary as a disclosure (cardButton), move focus on open and close, render their body only when open, and wrap summaries on phones. --- apps/web/src/page/alerts/editor/command.ts | 15 +++ apps/web/src/page/alerts/editor/derive.ts | 22 ++-- .../editor/editor-anomaly.scene.test.ts | 4 + .../alerts/editor/editor-cards.scene.test.ts | 63 ++++++++- .../page/alerts/editor/editor.scene.test.ts | 4 +- apps/web/src/page/alerts/editor/message.ts | 1 + apps/web/src/page/alerts/editor/steps.ts | 26 ++-- .../src/page/alerts/editor/summary.test.ts | 77 ++++++++--- apps/web/src/page/alerts/editor/summary.ts | 86 ++++++++----- apps/web/src/page/alerts/editor/update.ts | 13 +- apps/web/src/page/alerts/editor/view.ts | 47 +++++-- apps/web/src/page/kit/sentence-card.ts | 121 ++++++++++-------- packages/views/src/ui/INDEX.md | 2 +- packages/views/src/ui/card-button.ts | 6 +- 14 files changed, 351 insertions(+), 136 deletions(-) diff --git a/apps/web/src/page/alerts/editor/command.ts b/apps/web/src/page/alerts/editor/command.ts index c5bc8415cb..c9233f85b6 100644 --- a/apps/web/src/page/alerts/editor/command.ts +++ b/apps/web/src/page/alerts/editor/command.ts @@ -163,6 +163,21 @@ export const FocusSqlCaret = Command.define("FocusAlertSqlCaret", { }), }) +/** After the card opens or closes: its heading or its summary button, once the next frame has it. */ +export const FocusRuleCard = Command.define("FocusAlertRuleCard", { + args: { id: Schema.String }, + messages: [Message.CompletedFocusCard], + execute: ({ id }) => + Effect.callback((resume) => { + const handle = requestAnimationFrame(() => { + const element = document.getElementById(id) + if (element instanceof HTMLElement) element.focus() + resume(Effect.succeed(Message.CompletedFocusCard())) + }) + 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.ts b/apps/web/src/page/alerts/editor/derive.ts index e693fe9657..a6e53edc29 100644 --- a/apps/web/src/page/alerts/editor/derive.ts +++ b/apps/web/src/page/alerts/editor/derive.ts @@ -55,6 +55,18 @@ export const thresholdValue = (form: RuleForm, raw: string): string => { 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") @@ -68,15 +80,7 @@ export const describeRuleCondition = (form: RuleForm, isDefault = false): string ? `${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 10ace1eb2f..05d0353672 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" @@ -149,6 +151,7 @@ describe("alert rule editor: anomaly rules", () => { 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. @@ -164,6 +167,7 @@ describe("alert rule editor: anomaly rules", () => { 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(), 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 index 2e2966f07f..f53545c25d 100644 --- a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -1,7 +1,9 @@ import { OrgId } from "@maple/domain" -import { Schema, Array as Arr } from "effect" +import { AlertRulePreviewResponse } from "@maple/domain/http" +import { Result, Schema, Array as Arr } from "effect" import { click, + Command, expect as expectView, given, Mount, @@ -11,11 +13,12 @@ import { type, withViewInputs, } from "foldkit/scene" -import { describe, test } from "vitest" +import { describe, expect, test } from "vitest" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import type { PageContext } from "../../contract" import { scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" import { 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" @@ -38,6 +41,33 @@ const opened = (search: Model["search"], rules = [ruleRow(2)]) => 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], + }, + }) + const summary = (lead: string, rest: string) => role("button", { name: `${lead}: ${rest}. Edit` }) const queryMode = role("button", { name: "Built-in" }) @@ -51,16 +81,21 @@ describe("alert rule editor, sentence cards", () => { expectView( summary( "Alert when", - "Above 800 ms (critical), over 5 min, 2 checks in a row, resolves after 2 healthy checks", + "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(), ) @@ -103,4 +138,26 @@ describe("alert rule editor, sentence cards", () => { expectView(text("Alert when")).not.toExist(), ) }) + + 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("Missing: Query too slow for this check interval")).toExist(), + ) + }) }) 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 4fc9c60cb7..5d509883f5 100644 --- a/apps/web/src/page/alerts/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor.scene.test.ts @@ -31,7 +31,7 @@ import * as RadioGroup from "@maple/views/ui/radio-group" 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 { Message } from "./message" import type { Model } from "./model" @@ -154,6 +154,7 @@ describe("alert rule editor", () => { 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(), ) @@ -384,6 +385,7 @@ describe("alert rule editor", () => { 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(), diff --git a/apps/web/src/page/alerts/editor/message.ts b/apps/web/src/page/alerts/editor/message.ts index 7b3a8eb3b1..2dab1c03a1 100644 --- a/apps/web/src/page/alerts/editor/message.ts +++ b/apps/web/src/page/alerts/editor/message.ts @@ -148,6 +148,7 @@ export const Message = defineMessageUnion({ ClickedExpandCard: { card: RuleCard }, /** An open card's Done: back to its summary. */ ClickedCollapseCard: { card: RuleCard }, + CompletedFocusCard: {}, ToggledSqlCollapsed: {}, ClickedSqlToken: { token: Schema.String }, CompletedInsertSqlToken: { sql: Schema.String, caret: Schema.Number }, diff --git a/apps/web/src/page/alerts/editor/steps.ts b/apps/web/src/page/alerts/editor/steps.ts index eef5932eaa..0215f2ea31 100644 --- a/apps/web/src/page/alerts/editor/steps.ts +++ b/apps/web/src/page/alerts/editor/steps.ts @@ -7,6 +7,7 @@ import { Message } from "./message" import type { Model } from "./model" import { applyDefaultDestination, planMetrics, planPreviews } from "./plan" 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" @@ -49,7 +50,7 @@ export const initialize = (model: Model, context: PageContext): Return => { const withCards: Model = { ...ready, expandedCards: - draft.editingRule === null ? RULE_CARDS : missingCards(form, isProtectedDefault(ready)), + draft.editingRule === null ? RULE_CARDS : missingCards(form, null, isProtectedDefault(ready)), } const loaded = planned(applyDefaultDestination(withCards), context) if (!draft.isShowingTemplates) return loaded @@ -58,13 +59,22 @@ export const initialize = (model: Model, context: PageContext): Return => { } /** An edit to the draft; the preview follows it (`useDeferredValue` over the form). */ -export const editForm = (model: Model, context: PageContext, edit: (form: RuleForm) => RuleForm): Return => { - if (model.form === null) return { model } - const form = edit(model.form) - // A card that goes missing stays open once filled in again, so it never closes under the cursor. - const expandedCards = Arr.union(model.expandedCards, missingCards(form, isProtectedDefault(model))) - return planned({ ...model, form, expandedCards }, context) -} +/** 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(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 => { diff --git a/apps/web/src/page/alerts/editor/summary.test.ts b/apps/web/src/page/alerts/editor/summary.test.ts index c24f5e3ce1..fb1f6d1af5 100644 --- a/apps/web/src/page/alerts/editor/summary.test.ts +++ b/apps/web/src/page/alerts/editor/summary.test.ts @@ -1,4 +1,4 @@ -import { AlertDestinationId, EscalationChainId } from "@maple/domain/http" +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" @@ -10,6 +10,7 @@ import { missingCards, nameSummary, notifySummary, + type NameLookup, type NotifyNames, watchSummary, } from "./summary" @@ -28,10 +29,24 @@ const form = (fields: Partial = {}): RuleForm => ({ ...fields, }) +const found = (name: string): NameLookup => ({ _tag: "Found", name }) +const GONE_LOOKUP: NameLookup = { _tag: "Gone" } const NAMES: NotifyNames = { - destination: (id) => (id === ONCALL ? "#oncall" : id === PAGER ? "PagerDuty Checkout" : null), - chain: (id) => (id === CHAIN ? "Checkout primary" : null), + 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", @@ -86,7 +101,7 @@ describe("watchSummary", () => { describe("conditionSummary", () => { it("says the threshold with its unit and how many checks fire it", () => { expect(conditionSummary(form({ consecutiveBreachesRequired: "3" }))).toBe( - "Above 5%, over 5 min, 3 checks in a row, resolves after 2 healthy checks", + "Above 5%, in 3 checks in a row (5 min each), resolves after 2 healthy checks", ) }) @@ -96,7 +111,7 @@ describe("conditionSummary", () => { form({ comparator: "lt", threshold: "20", escalation: criticalTier({ threshold: "10" }) }), ), ).toBe( - "Below 20%, critical below 10%, over 5 min, 2 checks in a row, resolves after 2 healthy checks", + "Below 20%, critical below 10%, in 2 checks in a row (5 min each), resolves after 2 healthy checks", ) }) @@ -122,7 +137,13 @@ describe("conditionSummary", () => { form({ consecutiveBreachesRequired: "3", breachesOutOf: "5", alertOnNoData: true }), ), ).toBe( - "Above 5%, over 5 min, 3 of the last 5 checks, resolves after 2 healthy checks, no data counts", + "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%", ) }) @@ -156,7 +177,13 @@ describe("notifySummary", () => { }) it("names a destination the org no longer has instead of dropping it", () => { - expect(notifySummary(form({ destinationIds: [GONE] }), NAMES)).toBe("a removed destination") + 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", () => { @@ -165,6 +192,17 @@ describe("notifySummary", () => { ) }) + 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 = { @@ -186,20 +224,29 @@ describe("nameSummary", () => { describe("card issues", () => { it("puts each missing field on the card that edits it", () => { - expect(missingCards(form())).toEqual([]) - expect(missingCards(form({ name: "" }))).toEqual(["name"]) - expect(missingCards(form({ threshold: "" }))).toEqual(["condition"]) - expect(missingCards(form({ destinationIds: [] }))).toEqual(["notify"]) + 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"), + cardIssues(form({ signalType: "raw_query", rawQuerySql: "SELECT 1 AS value" }), "watch", null), ).toEqual(["$__orgFilter in SQL"]) - expect(cardIssues(form({ escalation: criticalTier() }), "notify")).toEqual(["Critical destinations"]) + 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)).toEqual(["watch"]) - expect(missingCards(unscoped, true)).toEqual([]) + 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 index 6bf5d49333..d6e3fe8c57 100644 --- a/apps/web/src/page/alerts/editor/summary.ts +++ b/apps/web/src/page/alerts/editor/summary.ts @@ -1,5 +1,5 @@ import { Array as Arr, String as Str } from "effect" -import type { AlertDestinationId, EscalationChainId } from "@maple/domain/http" +import type { AlertDestinationId, AlertQueryTiming, EscalationChainId } from "@maple/domain/http" import { comparatorSupportsTiers, isRangeComparator, @@ -8,7 +8,15 @@ import { } from "@maple/web-data/alerts/form-utils" import { anomalyScopeSummary } from "@maple/web-data/alerts/anomaly-rule" import type { RuleCard, RuleForm } from "./form" -import { comparatorWords, conditionSubject, groupBySummary, thresholdValue, validationIssues } from "./derive" +import { + checksPhrase, + comparatorWords, + conditionSubject, + groupBySummary, + recoveryLabel, + saveIssues, + thresholdValue, +} from "./derive" // The rule editor's sentence cards: which card each field lives on, and each card's one-line summary. @@ -30,13 +38,25 @@ const cardOfIssue = (issue: string): RuleCard => ? "watch" : "condition" -/** What blocks saving on one card, from the same list the action bar shows. */ -export const cardIssues = (form: RuleForm, card: RuleCard, isDefault = false): ReadonlyArray => - Arr.filter(validationIssues(form, isDefault), (issue) => cardOfIssue(issue) === card) +/** + * 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, isDefault = false): ReadonlyArray => - Arr.filter(cardsOf(form), (card) => cardIssues(form, card, isDefault).length > 0) +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) @@ -85,15 +105,7 @@ export const watchSummary = (form: RuleForm, isDefault = false): string => { return Arr.join([signalLabels[form.signalType], ...builtinScope(form), ...perGroup], " ") } -const checksText = (form: RuleForm): string => { - const checks = Str.trim(form.consecutiveBreachesRequired) || "?" - const outOf = Str.trim(form.breachesOutOf) - const windowText = `over ${Str.trim(form.windowMinutes) || "?"} min` - if (outOf.length > 0) return `${windowText}, ${checks} of the last ${outOf} checks` - return checks === "1" ? windowText : `${windowText}, ${checks} checks in a row` -} - -/** The "Alert when" card: "Above 5%, critical above 10%, over 5 min, 3 checks in a row". */ +/** 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) @@ -101,40 +113,48 @@ export const conditionSummary = (form: RuleForm): string => { : 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)}`]), - checksText(form), + 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 ? ["no data counts"] : []), + ...(form.alertOnNoData ? ["alerts on no data"] : []), ], ", ", ) } -/** How the summary names a destination and a chain; `null` for one the lists no longer hold. */ +/** 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) => string | null - chain: (id: EscalationChainId) => string | null + 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) => names.destination(id) ?? "a removed destination", - ) + const destinations = Arr.map(route.destinationIds, (id) => nameOf(names.destination(id), "destination")) const chain = - route.escalationChainId === null - ? null - : (names.chain(route.escalationChainId) ?? "an escalation 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}` } @@ -158,15 +178,17 @@ export const notifySummary = (form: RuleForm, names: NotifyNames): string => { ) : 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)}` - : `critical also ${critical}` - // Only the leading verb is capitalised: a destination keeps its own spelling ("#oncall", an email). - const lead = (text: string) => - Str.startsWith("escalate")(text) || Str.startsWith("critical")(text) ? capitalize(text) : text + : 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), diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index d3e9ffaaa9..a8159553e9 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -7,7 +7,8 @@ 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" @@ -16,7 +17,7 @@ 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, withMissingCardsOpen } from "./steps" import { chartStep, comboStep, queryPanelStep, radioStep, selectStep, timePickerStep } from "./sub-steps" const AUTOCOMPLETE_PRESET = "24h" @@ -126,14 +127,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" } } }), @@ -216,10 +218,13 @@ export const update = (model: Model, message: Message, context: PageContext): Re 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 }), ToggledSqlCollapsed: () => ({ model: { ...model, isSqlCollapsed: !model.isSqlCollapsed } }), ClickedSqlToken: ({ token }) => model.form === null diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index b3f6b0b42c..ba5e9f4b9a 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -16,13 +16,15 @@ import type { RuleForm } from "./form" import { Message } from "./message" import type { Model, Notice } from "./model" import { destinationTarget, isProtectedDefault } from "./derive" -import { destinationsOf } from "./derive-rows" +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, } from "./summary" @@ -136,16 +138,30 @@ const evaluatedFormOf = stableSlice((form: RuleForm): RuleForm => ({ /** 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) => - Option.match( - Arr.findFirst(destinations, (destination) => destination.id === id), - { onNone: () => null, onSome: (destination) => destinationTarget(destination).primary }, + lookup( + model.destinations === null ? null : destinations, + (destination) => destination.id === id, + (destination) => destinationTarget(destination).primary, ), chain: (id) => - Option.match( - Arr.findFirst(model.escalationChains ?? [], (chain) => chain.id === id), - { onNone: () => null, onSome: (chain) => chain.name }, + lookup( + model.isEscalationChainsFailed ? null : model.escalationChains, + (chain) => chain.id === id, + (chain) => chain.name, ), } } @@ -158,8 +174,15 @@ type CardSpec = Readonly<{ body: () => Html }> -const ruleCard = (h: H, model: Model, form: RuleForm, isDefault: boolean, spec: CardSpec): Html => { - const issues = cardIssues(form, spec.card, isDefault) +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, { @@ -173,8 +196,7 @@ const ruleCard = (h: H, model: Model, form: RuleForm, isDefault: boolean, spec: onExpand: Message.ClickedExpandCard({ card: spec.card }), onCollapse: Message.ClickedCollapseCard({ card: spec.card }), }, - // Closed cards skip their section: its lazy slot renders again once the card opens. - model.expandedCards.includes(spec.card) || issues.length > 0 ? [spec.body()] : [], + () => [spec.body()], ) } @@ -183,6 +205,7 @@ const surface = (h: H, model: Model, form: RuleForm, timeZone: string): Readonly // An anomaly rule has no condition to tune: the detector decides, so "Alert when" and the chart go. const isAnomaly = form.signalType === "anomaly" const isDefault = isProtectedDefault(model) + const timing = draftTimingOf(model, form) const condition: CardSpec = { card: "condition", lead: "Alert when", @@ -227,7 +250,7 @@ const surface = (h: H, model: Model, form: RuleForm, timeZone: string): Readonly sentenceChain( h, { label: "Rule" }, - Arr.map(cards, (spec) => ruleCard(h, model, form, isDefault, spec)), + Arr.map(cards, (spec) => ruleCard(h, model, form, timing, isDefault, spec)), ), ], ), diff --git a/apps/web/src/page/kit/sentence-card.ts b/apps/web/src/page/kit/sentence-card.ts index 031c19068b..a09958829c 100644 --- a/apps/web/src/page/kit/sentence-card.ts +++ b/apps/web/src/page/kit/sentence-card.ts @@ -1,11 +1,10 @@ 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 { pressable } from "@maple/views/ui/pressable" import { hstack, vstack } from "@maple/views/ui/stack" import { statusDot } from "@maple/views/ui/status-dot" import { typography } from "@maple/views/ui/typography" @@ -14,14 +13,14 @@ import { Array as Arr } 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" | "optional" +export type SentenceCardStatus = "done" | "missing" export type SentenceCardOptions = Readonly<{ - /** Unique on the page: the heading's id. */ + /** Unique on the page; the card's element ids derive from it. */ id: string - /** The clause's lead word, the card's label: "When", "Alert when", "Notify". */ + /** The clause's lead word, the card's heading: "When", "Alert when", "Notify". */ lead: string - /** A line under the lead while the card is open. */ + /** 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". */ @@ -34,9 +33,15 @@ export type SentenceCardOptions = Readonly<{ onCollapse?: M }> -const DOT_TONE = { done: "done", missing: "warn", optional: "neutral" } as const +/** 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 LEAD_CLASS = "w-24 shrink-0" +const DOT_TONE = { done: "done", missing: "warn" } as const + +const LEAD_CLASS = "w-20 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 => @@ -51,46 +56,49 @@ const rail = (h: HtmlBuilder, status: SentenceCardStatus): Html => ]), ]) +/** The lead as the heading, its whole card a disclosure button that opens the card. */ const closedCard = (h: HtmlBuilder, options: SentenceCardOptions): Html => - card( - h, - [ - pressable( - h, - { - onClick: options.onExpand, - className: - "w-full rounded-md-inset px-4 py-3 text-left hover:bg-surface-sunken active:bg-surface-sunken", - attributes: [ - h.AriaExpanded(false), - h.AriaLabel(`${options.lead}: ${options.summary}. Edit`), - ], - }, - [ - hstack(h, { gap: "3", align: "center", className: "min-h-8" }, [ - typography(h, { size: "sm", weight: "medium", className: LEAD_CLASS }, [ - options.lead, - ]), - typography(h, { size: "sm", truncate: true, className: "min-w-0 grow" }, [ - options.summary, - ]), - hstack(h, { gap: "1", align: "center", as: "span", className: "shrink-0" }, [ - icon(h, "PencilIcon", { size: 12, className: "text-muted-foreground" }), - typography(h, { size: "xs", tone: "muted" }, ["Edit"]), - ]), - ]), + 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)), ], - ), - ], - "gap-0 p-0", - ) + }, + [ + // Top-aligned with the first line on the rail dot: a phone wraps the summary to two lines. + hstack(h, { gap: "3", align: "start", as: "span", className: "min-h-8 py-1.5" }, [ + typography(h, { size: "sm", weight: "semibold", className: LEAD_CLASS }, [options.lead]), + typography( + h, + { + size: "sm", + weight: "normal", + className: "line-clamp-2 min-w-0 grow sm:line-clamp-1", + }, + [options.summary], + ), + hstack(h, { gap: "1", align: "center", as: "span", className: "h-5 shrink-0" }, [ + icon(h, "PencilIcon", { size: 12, className: "text-muted-foreground" }), + typography(h, { size: "xs", weight: "normal", tone: "muted" }, ["Edit"]), + ]), + ]), + ], + ), + ]) const openCard = ( h: HtmlBuilder, options: SentenceCardOptions, - children: ReadonlyArray, + body: () => ReadonlyArray, ): Html => { - const headingId = `sentence-card-${options.id}` + const headingId = sentenceCardHeadingId(options.id) const issues = options.issues ?? [] // No frame of its own: the sections inside are cards. The header lines up with a closed card's row. return h.section( @@ -106,9 +114,17 @@ const openCard = ( }, [ hstack(h, { gap: "3", align: "baseline", wrap: true, className: "min-w-0 pt-1.5" }, [ - heading(h, { level: 2, variant: "section", id: headingId, className: LEAD_CLASS }, [ - options.lead, - ]), + heading( + h, + { + level: 2, + variant: "section", + id: headingId, + className: cn(LEAD_CLASS, "outline-none"), + attributes: [h.Tabindex(-1)], + }, + [options.lead], + ), ...(options.hint === undefined ? [] : [ @@ -136,26 +152,31 @@ const openCard = ( { variant: "ghost", size: "xs", - attributes: [h.AriaExpanded(true), h.OnClick(options.onCollapse)], + attributes: [ + h.AriaExpanded(true), + h.AriaControls(panelIdOf(options.id)), + h.OnClick(options.onCollapse), + ], }, ["Done"], ), ]), ], ), - ...children, + h.div([h.Id(panelIdOf(options.id)), h.Class("flex min-w-0 flex-col gap-3")], body()), ], ) } /** - * `SentenceCard`: one clause of a sentence-card form, on its rail. Open, it shows its lead word and - * controls; closed (complete and not being edited), a one-line summary that opens it on click. + * `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. */ export const sentenceCard = ( h: HtmlBuilder, options: SentenceCardOptions, - children: ReadonlyArray, + body: () => ReadonlyArray, ): Html => { const isOpen = options.isExpanded || options.status === "missing" return h.li( @@ -168,7 +189,7 @@ export const sentenceCard = ( rail(h, options.status), h.div( [h.Class("min-w-0 pb-4 group-last/sentence:pb-0")], - [isOpen ? openCard(h, options, children) : closedCard(h, options)], + [isOpen ? openCard(h, options, body) : closedCard(h, options)], ), ], ) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 87205a600a..44ded0baf8 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -230,7 +230,7 @@ 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` `sentenceCard`, `sentenceChain`: Sentence cards: a form that reads top to bottom as one sentence ("When ... +- `apps/web/src/page/kit/sentence-card.ts` `sentenceCardHeadingId`, `sentenceCardSummaryId`, `sentenceCard`, `sentenceChain`: 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. 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, From 280ea477269f044edefd27267ef094b0f2ab7938 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:31:47 +0100 Subject: [PATCH 11/69] fix(alerts): review follow-ups for section routes and the silence panel - The silence panel clears ?sheet= in the model as soon as it closes itself, so a clock tick before the URL change can't reopen it. It opens only on the Silences section, and a viewer who can't manage loses a linked ?sheet= once the grants load (replaced away). - A rule lives at /alerts/rules/; /alerts/ (notifications, the apps) replaces itself with it, keeping its tab and window. The rule page and editor go back to Rules and their crumbs go through it. - Settings automation and setup health link to the new section paths. - The sidebar reveals the open section, every sub-item past the edge fades, on each route change; sub-rows are tighter, thin rules split the former groups, and the Inbox shows its unacknowledged count from the alert_incidents collection, read only while an alerts page is open. - One AlertsTab schema next to AlertsSection; the sheet commands answer with a clock read instead of a no-op message. --- apps/web/src/lab/entries/alerts-hub.ts | 4 +- apps/web/src/lab/shell-lab.ts | 4 +- apps/web/src/page/alerts/editor/actions.ts | 2 +- .../page/alerts/editor/editor.scene.test.ts | 11 +- .../src/page/alerts/editor/view-action-bar.ts | 14 ++- apps/web/src/page/alerts/editor/view.ts | 1 + apps/web/src/page/alerts/hub/command.ts | 15 ++- .../alerts/hub/hub-maintenance.scene.test.ts | 2 +- .../page/alerts/hub/hub-oncall.scene.test.ts | 2 +- .../alerts/hub/hub-silences.scene.test.ts | 37 +++++++ apps/web/src/page/alerts/hub/message.ts | 2 - apps/web/src/page/alerts/hub/model.ts | 5 +- apps/web/src/page/alerts/hub/update.ts | 35 ++++-- apps/web/src/page/alerts/hub/view.ts | 3 +- apps/web/src/page/alerts/incident/view.ts | 4 +- apps/web/src/page/alerts/rule/index.ts | 2 +- apps/web/src/page/alerts/rule/model.ts | 2 +- .../src/page/alerts/rule/rule.scene.test.ts | 13 ++- apps/web/src/page/alerts/rule/view.ts | 3 +- apps/web/src/page/alerts/shared/links.ts | 10 +- apps/web/src/page/alerts/shared/test-rows.ts | 3 +- .../page/settings/audit-log/view-detail.ts | 2 +- apps/web/src/page/settings/automation/view.ts | 3 +- .../src/page/settings/setup-health/derive.ts | 2 +- apps/web/src/route/app-route.ts | 1 + apps/web/src/route/redirect.ts | 7 +- apps/web/src/route/route.test.ts | 10 +- apps/web/src/route/routers.ts | 9 +- apps/web/src/route/search.ts | 4 + apps/web/src/shell/alerts-inbox.ts | 23 ++++ apps/web/src/shell/index.ts | 1 + apps/web/src/shell/message.ts | 2 + apps/web/src/shell/model.ts | 3 + apps/web/src/shell/mounts.ts | 47 ++++++-- apps/web/src/shell/nav.ts | 19 +++- apps/web/src/shell/shell.scene.test.ts | 28 +++++ apps/web/src/shell/update.ts | 5 + apps/web/src/shell/view-nav.ts | 100 ++++++++++++------ apps/web/src/subscription.ts | 37 ++++++- apps/web/src/view.lazy.test.ts | 2 +- packages/views/src/ui/sidebar.ts | 9 +- 41 files changed, 385 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/shell/alerts-inbox.ts diff --git a/apps/web/src/lab/entries/alerts-hub.ts b/apps/web/src/lab/entries/alerts-hub.ts index 9e69ef8a00..3a0c51ebf6 100644 --- a/apps/web/src/lab/entries/alerts-hub.ts +++ b/apps/web/src/lab/entries/alerts-hub.ts @@ -1,7 +1,8 @@ import { defineShellLab } from "../shell-lab" import { breadcrumbs, currentPath, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab" -// `/lab/alerts-hub`: the hub tabs and silence dialog over fixtures (`?state=`). +// `/lab/alerts-hub`: the hub sections and the silence side panel over fixtures (`?state=`), with +// two unacknowledged incidents counted on the sidebar's Inbox. export const lab = defineShellLab("Alerts hub", { Model, @@ -13,4 +14,5 @@ export const lab = defineShellLab("Alerts hub", { view, currentPath, breadcrumbs, + seedShell: (shell) => ({ ...shell, alertsInboxCount: 2 }), }) diff --git a/apps/web/src/lab/shell-lab.ts b/apps/web/src/lab/shell-lab.ts index 2003012daa..921480308d 100644 --- a/apps/web/src/lab/shell-lab.ts +++ b/apps/web/src/lab/shell-lab.ts @@ -39,6 +39,8 @@ export type ShellLabPage ReadonlyArray) /** Rendered in `#app` before the shell: lab toolbars that sit beside the shell. */ before?: (model: PageModel, h: HtmlBuilder) => Html + /** Shell state the root's reads would bring (the Alerts › Inbox count), as a fixture. */ + seedShell?: (shell: Shell.Model) => Shell.Model }> export const defineShellLab = ( @@ -107,7 +109,7 @@ export const defineShellLab = { expect(late.preview).toEqual(edited.preview) }) - test("save sends once when ready, and lands on /alerts with a toast", () => { + test("save sends once when ready, and lands on the rules with a toast; the crumbs go through Rules", () => { const ready = withLists(entered({ template: "slow_p95" })) const saving = pageUpdate(ready, Message.ClickedSave()) expect(names(saving.commands)).toEqual(["SaveAlertRule"]) expect(names(pageUpdate(saving.model, Message.ClickedSave()).commands)).toEqual([]) const saved = pageUpdate(saving.model, Message.CompletedSave({ result: Result.succeed(undefined) })) - expect(saved.outMessage?._tag).toBe("RequestedNavigation") + expect(saved.outMessage).toMatchObject({ _tag: "RequestedNavigation", url: "/alerts/rules" }) + expect(breadcrumbsOf(ready)).toEqual([ + { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, + { label: "New rule" }, + ]) }) test("a created rule records alert_rule_created with its signal; a failed save does not", () => { diff --git a/apps/web/src/page/alerts/editor/view-action-bar.ts b/apps/web/src/page/alerts/editor/view-action-bar.ts index 1a451f62a2..dbf195f94e 100644 --- a/apps/web/src/page/alerts/editor/view-action-bar.ts +++ b/apps/web/src/page/alerts/editor/view-action-bar.ts @@ -120,7 +120,13 @@ const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | nu vstack(h, { gap: "0.5", shrink: true }, [ typography( h, - { size: "sm", tone: "foreground", truncate: true, as: "p", attributes: [h.Title(summary)] }, + { + size: "sm", + tone: "foreground", + truncate: true, + as: "p", + attributes: [h.Title(summary)], + }, [summary], ), validationSummary(h, model, issues), @@ -131,10 +137,12 @@ const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | nu ? [] : [ typography(h, { size: "xs", tone: "muted", className: "hidden sm:inline" }, [ - changeCount === 0 ? "No changes" : `${changeCount} unsaved ${pluralize(changeCount, "change")}`, + changeCount === 0 + ? "No changes" + : `${changeCount} unsaved ${pluralize(changeCount, "change")}`, ]), ]), - linkButton(h, { variant: "outline", size: "sm", href: alertsHref({}) }, ["Cancel"]), + linkButton(h, { variant: "outline", size: "sm", href: alertsHref({}, "rules") }, ["Cancel"]), button( h, { diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index ebd1bae99e..0632c3ec1f 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -247,6 +247,7 @@ export const view = Submodel.defineView((model, /** `AlertRuleFormShell`'s breadcrumb. */ export const breadcrumbsOf = (model: Model) => [ { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, { label: (model.form === null ? model.search.ruleId !== undefined : model.editingRuleId !== null) ? "Edit rule" diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index abc46e5491..69b04f3dbc 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -44,18 +44,25 @@ import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" const sheetArgs = { href: Schema.String, id: Schema.String } +/** A command must answer with a message: a sheet change answers with the clock, as a route change reads it. */ +const thenReadClock = (effect: Effect.Effect) => + effect.pipe( + Effect.andThen(Clock.currentTimeMillis), + Effect.map((nowMs) => Message.ReadClock({ nowMs })), + ) + /** Opens a side panel: a history entry carrying `?sheet=`, marked as this tab's. */ export const PushHubSheet = Command.define("PushAlertsHubSheet", { args: sheetArgs, - messages: [Message.CompletedSheetUrl], - execute: ({ href, id }) => pushSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), + messages: [Message.ReadClock], + execute: ({ href, id }) => thenReadClock(pushSheetUrl(href, id)), }) /** Closes it: Back over the entry this tab pushed, else `?sheet=` replaced away. */ export const DismissHubSheet = Command.define("DismissAlertsHubSheet", { args: sheetArgs, - messages: [Message.CompletedSheetUrl], - execute: ({ href, id }) => dismissSheetUrl(href, id).pipe(Effect.as(Message.CompletedSheetUrl())), + messages: [Message.ReadClock], + execute: ({ href, id }) => thenReadClock(dismissSheetUrl(href, id)), }) export const ReadHubClock = Command.define("ReadAlertsHubClock", { diff --git a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts index 570e9b8986..c02a9c6fb0 100644 --- a/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-maintenance.scene.test.ts @@ -1,5 +1,5 @@ import * as Policy from "@maple/domain/policy" -import type { AlertsTab } from "../shared/links" +import type { AlertsTab } from "../../../route/search" import { OrgId } from "@maple/domain" import type { V2AlertMaintenanceWindow, V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" diff --git a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts index 2102c64f20..37f8bc9d73 100644 --- a/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-oncall.scene.test.ts @@ -1,5 +1,5 @@ import * as Policy from "@maple/domain/policy" -import type { AlertsTab } from "../shared/links" +import type { AlertsTab } from "../../../route/search" import { OrgId } from "@maple/domain" import { EscalationChainId, OnCallScheduleId } from "@maple/domain/http" import type { V2OnCallSchedule } from "@maple/domain/http/v2" diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 2d7d322363..395a2fcd02 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -236,6 +236,39 @@ describe("alerts hub, silence side panel", () => { expect(withPanel(MEMBER).model.silenceDialog.dialog.isOpen).toBe(false) }) + test("a member's link loses its `?sheet=` once the grants say so, replaced away", () => { + const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model + const clocked = pageUpdate(entered, Message.ReadClock({ nowMs: NOW })).model + const granted = pageUpdate(clocked, Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER })) + expect(granted.model.silenceDialog.dialog.isOpen).toBe(false) + expect(granted.model.search.sheet).toBeUndefined() + expect(callsOf(granted.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=silence`, id: "silence" }], + ]) + }) + + test("the panel opens only on the Silences section", () => { + const onRules = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "rules", sheet: "silence" }), + context, + ) + expect(onRules.model.silenceDialog.dialog.isOpen).toBe(false) + expect( + pageUpdate(onRules.model, Message.ReadClock({ nowMs: NOW })).model.silenceDialog.dialog.isOpen, + ).toBe(false) + }) + + test("a clock tick after Cancel, before the URL change lands, leaves it closed", () => { + const cancelled = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedCancel())) + expect(cancelled.model.search.sheet).toBeUndefined() + const ticked = pageUpdate(cancelled.model, Message.ReadClock({ nowMs: NOW + 30_000 })) + expect(ticked.model.silenceDialog.dialog.isOpen).toBe(false) + const landed = routeChanged(ticked.model, hubAt({ tab: "silences" }), context) + expect(landed.model.silenceDialog.dialog.isOpen).toBe(false) + expect(namesOf(landed.commands)).not.toContain("DismissAlertsHubSheet") + }) + test("Cancel and Escape close it and take `?sheet=` off the URL", () => { const dismissed = [ ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=silence`, id: "silence" }], @@ -297,5 +330,9 @@ describe("alerts hub, silence side panel", () => { expect.arrayContaining(["DismissAlertsHubSheet", "FetchAlertsHubSilences"]), ) expect(saved.outMessage).toMatchObject({ title: "Alerts silenced" }) + // The refreshed list reads the clock again; the panel stays shut. + expect( + pageUpdate(saved.model, Message.ReadClock({ nowMs: NOW })).model.silenceDialog.dialog.isOpen, + ).toBe(false) }) }) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 5f0c12fffb..75a499ae12 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -52,8 +52,6 @@ const rows = (row: S) => ({ orgId: Schema.String, rows: Sc const outcome = (success: S) => Schema.Result(success, LoadFailure) export const Message = defineMessageUnion({ - /** A `?sheet=` push or dismissal ran; the route change that follows does the work. */ - CompletedSheetUrl: {}, ReadClock: { nowMs: Schema.Number }, UpdatedRules: rows(AlertRuleRowSchema), UpdatedRuleStates: rows(AlertRuleStateRowSchema), diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index c23d373893..fb584a2b40 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -28,7 +28,7 @@ import { import { Schema } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" -import { AlertsHubSearch, AlertsSection } from "../../../route/search" +import { AlertsHubSearch, AlertsTab } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" @@ -53,9 +53,6 @@ export type SloListRow = typeof SloListRow.Type export const TestOutcome = Schema.Struct({ message: Schema.String, success: Schema.Boolean }) -/** The section the route names: the Inbox at `/alerts`, the rest at `/alerts/
`. */ -export const AlertsTab = Schema.Union([Schema.Literal("inbox"), AlertsSection]) - export const Model = Schema.Struct({ tab: AlertsTab, search: AlertsHubSearch, diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index f47c40d86d..89fb77bfeb 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -754,11 +754,12 @@ const silenceCommands = (step: ReturnType): Command FoldkitCommand.mapMessages(step.commands ?? [], (message) => Message.GotSilenceDialogMessage({ message })) /** - * Opens or closes the silence panel to match `?sheet=` (a link, Back, or "New silence"). It opens - * once the clock is read, as the form checks its end against it, and only for a viewer who may save. + * Opens or closes the silence panel to match `?sheet=` (a link, Back, or "New silence"). It opens on + * the Silences section once the clock is read, as the form checks its end against it, and only for + * a viewer who may save. */ const followSilenceSheet = (model: Model, context: PageContext): Requested => { - const isInUrl = model.search.sheet === SILENCE_SHEET + const isInUrl = model.search.sheet === SILENCE_SHEET && model.tab === "silences" const isOpen = model.silenceDialog.dialog.isOpen if (isInUrl && !isOpen && model.nowMs !== null && canManage(model)) { const step = SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs) @@ -771,19 +772,32 @@ const followSilenceSheet = (model: Model, context: PageContext): Requested => { return { model, commands: [] } } +/** The grants are in: a viewer who can't silence loses a linked panel's `?sheet=`, replaced away. */ +const grantsLoaded = (model: Model, context: PageContext): Requested => + model.search.sheet === SILENCE_SHEET && !canManage(model) + ? { + model: { ...model, search: { ...model.search, sheet: undefined } }, + commands: [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })], + } + : followSilenceSheet(model, context) + const liftSilenceDialog = ( model: Model, context: PageContext, step: ReturnType, ): Return => { - const next = { ...model, silenceDialog: step.model } - // The panel closed itself (Cancel, Escape, a save): its `?sheet=` goes too. - const hasClosed = model.silenceDialog.dialog.isOpen && !step.model.dialog.isOpen + // The panel closed itself (Cancel, Escape, a save): its `?sheet=` goes now, in the model too, so a + // clock tick before the URL change lands can't reopen it. + const hasClosed = + model.silenceDialog.dialog.isOpen && !step.model.dialog.isOpen && model.search.sheet === SILENCE_SHEET + const next = { + ...model, + silenceDialog: step.model, + search: hasClosed ? { ...model.search, sheet: undefined } : model.search, + } const commands = [ ...silenceCommands(step), - ...(hasClosed && model.search.sheet === SILENCE_SHEET - ? [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] - : []), + ...(hasClosed ? [DismissHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] : []), ] if (step.outMessage === undefined) return { model: next, commands } return SilenceDialog.OutMessage.match(step.outMessage, { @@ -1119,7 +1133,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re model: { ...model, routingDeliveries: Keyed.accept(model.routingDeliveries, key, result) }, }), CompletedFetchSession: ({ orgId, grants }) => - orgId === context.orgId ? followSilenceSheet({ ...model, grants }, context) : { model }, + orgId === context.orgId ? grantsLoaded({ ...model, grants }, context) : { model }, ChangedRulesQuery: ({ value }) => ({ model: { ...model, rulesQuery: value } }), ClickedStatusFilter: ({ bucket }) => statusToggled(model, bucket), ClickedClearRuleFilters: () => ({ @@ -1339,7 +1353,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re model.search.sheet === SILENCE_SHEET ? { model } : { model, commands: [PushHubSheet({ href: hubHref(model), id: SILENCE_SHEET })] }, - CompletedSheetUrl: () => ({ model }), GotSilenceDialogMessage: ({ message: child }) => liftSilenceDialog(model, context, SilenceDialog.update(model.silenceDialog, child)), ClickedEndSilence: ({ silenceId }) => endSilence(model, silenceId), diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 94d722f8fa..e9e8dd6843 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -16,7 +16,8 @@ import { skeleton, skeletonList } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import type { BreadcrumbEntry } from "../../../shell" -import { type AlertsTab, alertsHref, createHref } from "../shared/links" +import type { AlertsTab } from "../../../route/search" +import { alertsHref, createHref } from "../shared/links" import { newSloHref } from "../shared/slo-commands" import { newMonitorHref } from "../shared/uptime-commands" import { diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 4f5ac406e4..b020209ac3 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -40,7 +40,7 @@ import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { resourceNotFound } from "../../kit/resource-not-found" import { severityBadge } from "../shared/badges" -import { alertsHref } from "../shared/links" +import { alertsHref, ruleHref } from "../shared/links" import { sloHref, slosTabHref } from "../shared/slo-commands" import { monitorHref, uptimeTabHref } from "../shared/uptime-commands" import { incidentChecksOf, summaryChecksOf } from "../shared/check-summary" @@ -544,7 +544,7 @@ export const breadcrumbsOf = (model: Model) => ], onSome: ({ incident, rule }) => [ { label: "Alerts", href: "/alerts" }, - { label: rule?.name ?? incident.ruleName, href: `/alerts/${incident.ruleId}` }, + { label: rule?.name ?? incident.ruleName, href: ruleHref(incident.ruleId) }, { label: "Incident" }, ], }) diff --git a/apps/web/src/page/alerts/rule/index.ts b/apps/web/src/page/alerts/rule/index.ts index 2c10c34d2e..ddb939bb6e 100644 --- a/apps/web/src/page/alerts/rule/index.ts +++ b/apps/web/src/page/alerts/rule/index.ts @@ -46,7 +46,7 @@ const subscriptions = Subscription.make, Message, R ), })) -/** `/alerts/$ruleId`: one instance per rule; the tab and window are search. */ +/** `/alerts/rules/$ruleId`: one instance per rule; the tab and window are search. */ export const page = definePage( "AlertRule", { Model, Message }, diff --git a/apps/web/src/page/alerts/rule/model.ts b/apps/web/src/page/alerts/rule/model.ts index 5c571dc3f1..f955de7adb 100644 --- a/apps/web/src/page/alerts/rule/model.ts +++ b/apps/web/src/page/alerts/rule/model.ts @@ -21,7 +21,7 @@ import * as TimeRangePicker from "../../kit/time-range-picker" import { CheckSummary } from "../shared/check-summary" import { PreviewSlot } from "../shared/preview" -// `/alerts/$ruleId`. +// `/alerts/rules/$ruleId`. const Rows = (row: S) => Schema.NullOr(Schema.Array(row)) diff --git a/apps/web/src/page/alerts/rule/rule.scene.test.ts b/apps/web/src/page/alerts/rule/rule.scene.test.ts index f9f7c1bc38..d576df271c 100644 --- a/apps/web/src/page/alerts/rule/rule.scene.test.ts +++ b/apps/web/src/page/alerts/rule/rule.scene.test.ts @@ -27,7 +27,7 @@ import { ToggleRuleEnabled } from "./command" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update } from "./update" -import { view as pageView } from "./view" +import { breadcrumbsOf, view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -138,7 +138,7 @@ describe("alert rule", () => { test("the Overview and History tab links keep the time range", () => { const search = { timePreset: "7d" } - const href = (tab: string) => `/alerts/${RULE}?tab=${tab}&timePreset=7d` + const href = (tab: string) => `/alerts/rules/${RULE}?tab=${tab}&timePreset=7d` scene( { update: pageUpdate, view }, given(loaded(search)), @@ -169,9 +169,18 @@ describe("alert rule", () => { given(model), themeResolved(), expectView(text("Rule not found")).toExist(), + expectView(role("link", { name: "Back to rules" })).toHaveAttr("href", "/alerts/rules"), ) }) + test("the crumbs go through Rules to the rule", () => { + expect(breadcrumbsOf(loaded())).toEqual([ + { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, + { label: "Payment p95 latency" }, + ]) + }) + test("a raw SQL rule slowed down by its timeouts shows its query time per check and why", () => { const check = ( minutesAgo: number, diff --git a/apps/web/src/page/alerts/rule/view.ts b/apps/web/src/page/alerts/rule/view.ts index 402a974f5f..f33fa6c523 100644 --- a/apps/web/src/page/alerts/rule/view.ts +++ b/apps/web/src/page/alerts/rule/view.ts @@ -200,7 +200,7 @@ export const view = Submodel.defineView((model, resourceNotFound(h, { title: "Rule not found", description: "This alert rule could not be found. It may have been deleted.", - back: { href: alertsHref({}), label: "Back to rules" }, + back: { href: alertsHref({}, "rules"), label: "Back to rules" }, }), ]) : dashboardPage(h, { ...header(h, model, rule, timeZone), tabs: tabStrip(h, model) }, [ @@ -229,6 +229,7 @@ export const breadcrumbsOf = (model: Model) => { const rule = model.rules === null ? null : ruleOf(model) return [ { label: "Alerts", href: "/alerts" }, + { label: "Rules", href: "/alerts/rules" }, { label: model.rules === null ? "Loading…" : rule === null ? "Not found" : rule.name }, ] } diff --git a/apps/web/src/page/alerts/shared/links.ts b/apps/web/src/page/alerts/shared/links.ts index 4d86fc8328..9316199646 100644 --- a/apps/web/src/page/alerts/shared/links.ts +++ b/apps/web/src/page/alerts/shared/links.ts @@ -1,12 +1,7 @@ import { AlertRuleId } from "@maple/domain/http" import { Option, Schema } from "effect" import { alertsSectionRoute, AppRoute, hrefOf } from "../../../route" -import type { - AlertRuleSearch, - AlertsCreateSearch, - AlertsHubSearch, - AlertsSection, -} from "../../../route/search" +import type { AlertRuleSearch, AlertsCreateSearch, AlertsHubSearch, AlertsTab } from "../../../route/search" const decodeRuleId = Schema.decodeUnknownOption(AlertRuleId) @@ -20,9 +15,6 @@ export const ruleHref = (rawRuleId: string, search: typeof AlertRuleSearch.Type export const incidentHref = (incidentId: string): string => hrefOf(AppRoute.AlertIncident({ incidentId, search: {} })) -/** The Alerts hub's sections: the Inbox at `/alerts`, the rest at `/alerts/
`. */ -export type AlertsTab = "inbox" | AlertsSection - /** A hub section's URL with the hub's filters; the Inbox by default. */ export const alertsHref = (search: typeof AlertsHubSearch.Type, tab: AlertsTab = "inbox"): string => hrefOf(alertsSectionRoute(tab, search)) diff --git a/apps/web/src/page/alerts/shared/test-rows.ts b/apps/web/src/page/alerts/shared/test-rows.ts index 2259f78b67..74df831c09 100644 --- a/apps/web/src/page/alerts/shared/test-rows.ts +++ b/apps/web/src/page/alerts/shared/test-rows.ts @@ -5,8 +5,7 @@ import type { AlertRuleStateRow, } from "@maple/web-data/collections/alerts" import { alertsSectionRoute } from "../../../route" -import type { AlertsHubSearch } from "../../../route/search" -import type { AlertsTab } from "./links" +import type { AlertsHubSearch, AlertsTab } from "../../../route/search" import { isoMs } from "./time" /** A hub section and its filters, as the tests and labs spell where they stand. */ diff --git a/apps/web/src/page/settings/audit-log/view-detail.ts b/apps/web/src/page/settings/audit-log/view-detail.ts index 8e1120611f..8a070ddf1c 100644 --- a/apps/web/src/page/settings/audit-log/view-detail.ts +++ b/apps/web/src/page/settings/audit-log/view-detail.ts @@ -30,7 +30,7 @@ type H = HtmlBuilder export const resourceHref = (link: AuditResourceLink): string => { switch (link.to) { case "/alerts/$ruleId": - return `/alerts/${encodeURIComponent(link.params.ruleId)}` + return `/alerts/rules/${encodeURIComponent(link.params.ruleId)}` case "/dashboards/$dashboardId": return `/dashboards/${encodeURIComponent(link.params.dashboardId)}` case "/errors/issues/$issueId": diff --git a/apps/web/src/page/settings/automation/view.ts b/apps/web/src/page/settings/automation/view.ts index e7072574c8..f00504f671 100644 --- a/apps/web/src/page/settings/automation/view.ts +++ b/apps/web/src/page/settings/automation/view.ts @@ -24,6 +24,7 @@ import { canManage, Message, type Model, type PolicyDraft } from "./model" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" import { Array as Arr, String as Str, Option } from "effect" +import { routingTabHref } from "../../alerts/shared/escalation-chains" type H = HtmlBuilder type Inputs = Readonly<{ nowMs: number; timeZone: string; isKnownMember: boolean }> @@ -124,7 +125,7 @@ const destinationsBlock = (h: H, model: Model, draft: PolicyDraft): Html => { "Escalation sends triaged issues to a destination. Add a Slack, email or webhook destination first.", ]), hstack(h, { wrap: true, className: "gap-x-4 gap-y-2" }, [ - linkButton(h, { size: "sm", variant: "outline", href: "/alerts?tab=routing" }, [ + linkButton(h, { size: "sm", variant: "outline", href: routingTabHref() }, [ "Add a destination", ]), docsLink(h, "destinations"), diff --git a/apps/web/src/page/settings/setup-health/derive.ts b/apps/web/src/page/settings/setup-health/derive.ts index c9e66ab507..01f5b37f9d 100644 --- a/apps/web/src/page/settings/setup-health/derive.ts +++ b/apps/web/src/page/settings/setup-health/derive.ts @@ -50,7 +50,7 @@ const INTEGRATION_FOR_CHECK = new Map([ const link = (label: string, href: string): SetupHealthAction => ({ kind: "link", label, href }) const settingsTab = (tab: "ingestion" | "data-platform") => hrefOf(AppRoute.Settings({ search: { tab } })) -const alertsTab = (tab: "rules" | "routing") => hrefOf(AppRoute.Alerts({ search: { tab } })) +const alertsTab = (section: "rules" | "routing") => hrefOf(AppRoute.AlertsSection({ section, search: {} })) const integrations = (integration?: string) => hrefOf(AppRoute.Integrations({ search: integration === undefined ? {} : { integration } })) diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 69069410ea..4d03b0a0b6 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -29,6 +29,7 @@ export const AppRoute = defineRouteUnion({ Unsubscribe: { search: Search.UnsubscribeSearch }, AgentSession: { sessionId: Schema.String, search: Search.AgentSessionSearch }, AlertRule: { ruleId: AlertRuleId, search: Search.AlertRuleSearch }, + AlertRuleLegacy: { ruleId: AlertRuleId, search: Search.AlertRuleSearch }, AlertsCreate: { search: Search.AlertsCreateSearch }, AlertsUptimeNew: { search: Search.AlertsUptimeNewSearch }, AlertsUptimeMonitor: { monitorId: Schema.String, search: Search.AlertsUptimeMonitorSearch }, diff --git a/apps/web/src/route/redirect.ts b/apps/web/src/route/redirect.ts index a897eff99f..db4ae480eb 100644 --- a/apps/web/src/route/redirect.ts +++ b/apps/web/src/route/redirect.ts @@ -7,6 +7,7 @@ import { type AlertsHubSearch, type AlertsSearch, AlertsSection, + type AlertsTab, type AnomaliesSearch, ErrorsSearch, } from "./search" @@ -69,7 +70,7 @@ const anomaliesViewSearch = (search: typeof AnomaliesSearch.Type): typeof Alerts const decodeAlertsSection = Schema.decodeUnknownOption(AlertsSection) /** The section an old `?tab=` named: "oncall" and "settings" were renamed, the rest is the Inbox. */ -const legacyAlertsSection = (tab: string): AlertsSection | "inbox" => +const legacyAlertsSection = (tab: string): AlertsTab => tab === "oncall" ? "on-call" : tab === "settings" @@ -78,7 +79,7 @@ const legacyAlertsSection = (tab: string): AlertsSection | "inbox" => /** `/alerts` for the Inbox, else `/alerts/
`, with the hub's filters. */ export const alertsSectionRoute = ( - section: AlertsSection | "inbox", + section: AlertsTab, search: typeof AlertsHubSearch.Type, ): Extract => section === "inbox" ? AppRoute.Alerts({ search }) : AppRoute.AlertsSection({ section, search }) @@ -120,6 +121,8 @@ export const routeRedirect = (route: AppRoute): Option.Option => InfraDiscover: () => redirect(AppRoute.Infra({ search: {} }), true), InfraLegacyHost: ({ hostName }) => redirect(AppRoute.InfraHost({ hostName, search: {} }), true), AlertIncidentsIndex: () => redirect(AppRoute.Alerts({ search: {} }), true), + /** A rule moved under Rules; old links (notifications, the apps) keep their tab and window. */ + AlertRuleLegacy: ({ ruleId, search }) => redirect(AppRoute.AlertRule({ ruleId, search }), true), /** The anomalies list is the Alerts Inbox's Anomalies view; one service and the status carry over. */ Anomalies: ({ search }) => redirect(AppRoute.Alerts({ search: anomaliesViewSearch(search) }), true), diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 91812635e3..e1e46c1249 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -83,7 +83,8 @@ describe("routes", () => { expect(parse("/alerts/schedules/sch_1")._tag).toBe("AlertsSchedule") expect(parse("/alerts/incidents/inc_1")._tag).toBe("AlertIncident") expect(parse("/alerts/create")._tag).toBe("AlertsCreate") - expect(parse("/alerts/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") + expect(parse("/alerts/rules/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") + expect(parse("/alerts/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRuleLegacy") expect(parse("/alerts/nope")._tag).toBe("NotFound") }) @@ -102,6 +103,13 @@ describe("routes", () => { expect(routeRedirect(parse("/alerts?tab=uptime"))).toEqual( Option.some({ url: "/alerts/uptime", isReplace: true }), ) + // A rule's old address (notifications, the apps) opens it under Rules, keeping its tab and window. + expect(redirectOf("/alerts/0199a001-0000-7000-8000-000000000001?tab=history&timePreset=7d")).toEqual( + Option.some("/alerts/rules/0199a001-0000-7000-8000-000000000001?tab=history&timePreset=7d"), + ) + expect(routeRedirect(parse("/alerts/rules/0199a001-0000-7000-8000-000000000001"))).toEqual( + Option.none(), + ) expect(routeRedirect(parse("/alerts?signal=anomaly"))).toEqual(Option.none()) expect(routeRedirect(parse("/alerts/rules"))).toEqual(Option.none()) }) diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index e1710c59c2..02f882e357 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -71,11 +71,18 @@ const routers = { Route.mapTo(AppRoute.AgentSession), ), AlertRule: pipe( - path("alerts"), + path("alerts", "rules"), slash(schemaSegment("ruleId", AlertRuleId)), search(Search.AlertRuleSearch), Route.mapTo(AppRoute.AlertRule), ), + // A rule's page before it moved under Rules; notifications still link here, `routeRedirect` sends them on. + AlertRuleLegacy: pipe( + path("alerts"), + slash(schemaSegment("ruleId", AlertRuleId)), + search(Search.AlertRuleSearch), + Route.mapTo(AppRoute.AlertRuleLegacy), + ), AlertsCreate: pipe( path("alerts", "create"), search(Search.AlertsCreateSearch), diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index c0999c525f..80ab3c0dd5 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -526,6 +526,10 @@ export const AlertsSection = Schema.Literals([ ]) export type AlertsSection = typeof AlertsSection.Type +/** A hub section: the Inbox at `/alerts`, the rest at `/alerts/
`. */ +export const AlertsTab = Schema.Union([Schema.Literal("inbox"), AlertsSection]) +export type AlertsTab = typeof AlertsTab.Type + const alertsHubSearchFields = { serviceName: Schema.optional(Schema.String), status: Schema.optional(Schema.String), diff --git a/apps/web/src/shell/alerts-inbox.ts b/apps/web/src/shell/alerts-inbox.ts new file mode 100644 index 0000000000..0860e5bd8f --- /dev/null +++ b/apps/web/src/shell/alerts-inbox.ts @@ -0,0 +1,23 @@ +import { Array as Arr, Stream } from "effect" +import { type OrgCollections, orgLive } from "../data/collections/org-collections" +import { liveQueryStream } from "../data/live-query" + +/** + * The Alerts › Inbox count: open incidents nobody has acknowledged, over the org's live + * `alert_incidents`. The alerts pages already read that collection, so reading it while one is open + * requests no other shape. + */ +export const alertsInboxCounts = ( + orgId: string, + toMessage: (count: number) => Message, +): Stream.Stream => + orgLive(orgId, (set) => + liveQueryStream( + (q) => q.from({ i: set.alertIncidents }), + (rows) => + toMessage( + Arr.filter(rows, (row) => row.status === "open" && (row.acknowledged_at ?? null) === null) + .length, + ), + ), + ) diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index 6fa454d18f..aa21e01c2a 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -18,6 +18,7 @@ export { } from "./subscription" export { pinnedDashboardRows } from "./dashboards-shape" +export { alertsInboxCounts } from "./alerts-inbox" export { BUILT_COMMIT, versionChecks } from "./version" export { checklistTicked, diff --git a/apps/web/src/shell/message.ts b/apps/web/src/shell/message.ts index 966f002435..9b8d333801 100644 --- a/apps/web/src/shell/message.ts +++ b/apps/web/src/shell/message.ts @@ -80,6 +80,8 @@ export const Message = defineMessageUnion({ FailedLoadSources: { orgId: Schema.String }, SucceededLoadDashboards: { orgId: Schema.String, dashboards: Schema.Array(PinnedDashboardRow) }, FailedLoadDashboards: { orgId: Schema.String }, + /** The Alerts › Inbox count: open incidents nobody has acknowledged. */ + UpdatedAlertsInboxCount: { orgId: Schema.String, count: Schema.Number }, /** `isFromCache`: the org's fresh "no schedules" answer stood in for the request. */ SucceededLoadOnCall: { orgId: Schema.String, diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index e36edc6948..61bb54465e 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -78,6 +78,8 @@ export const Model = Schema.Struct({ /** `maple.infra-surfaces.`: each org's last settled surfaces, so the sidebar lays out once. */ rememberedSurfaces: Schema.Record(Schema.String, Schema.Array(NavSurface)), dashboards: DashboardsData.schema, + /** Read only while an alerts page is open; `null` until then. */ + alertsInboxCount: Schema.NullOr(Schema.Number), /** The org's schedules and who holds each now, for the footer's on-call pill. */ onCall: OnCallData.schema, /** `maple.sidebar.collapsed-groups`: nav group ids the user folded. */ @@ -173,6 +175,7 @@ export const init = (flags: ShellFlags): Model => ({ integrations: PENDING_INTEGRATIONS, rememberedSurfaces: flags.rememberedSurfaces ?? {}, dashboards: DashboardsData.Idle(), + alertsInboxCount: null, onCall: OnCallData.Idle(), collapsedNavGroups: flags.collapsedNavGroups ?? [], onCallNowMs: 0, diff --git a/apps/web/src/shell/mounts.ts b/apps/web/src/shell/mounts.ts index 6c583acff7..4465f37236 100644 --- a/apps/web/src/shell/mounts.ts +++ b/apps/web/src/shell/mounts.ts @@ -1,4 +1,4 @@ -import { Effect, Queue, Schema, Stream } from "effect" +import { Array as Arr, Effect, Queue, Schema, Stream } from "effect" import { Dom, Mount } from "foldkit" import { Align, measurePosition, Side } from "@maple/views/ui/anchor" import { applyScrollOverflow } from "@maple/views/ui/scroll-area" @@ -57,17 +57,35 @@ export const MountNamespaceSearch = Mount.defineStream("MountNamespaceSearch", { }) /** The sidebar nav's `ScrollArea` overflow (its bottom fade), kept current on scroll and resize. */ -/** Scrolls the nav so the active row clears the pinned footer on a deep-linked load. */ +/** + * Scrolls the nav so the open section (its row and every sub-item) clears the pinned footer, or + * the active row alone when the section is taller than the nav. + */ const revealActiveRow = (viewport: HTMLElement): void => { const rows = viewport.querySelectorAll('[data-active="true"]') const row = rows.item(rows.length - 1) if (row === null) return - const top = row.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop - const bottom = top + row.offsetHeight - if (bottom > viewport.scrollTop + viewport.clientHeight) - viewport.scrollTop = bottom - viewport.clientHeight + 8 + const section = row.closest('[data-sidebar="menu-item"]') ?? row + // Past the scroll area's 1.5rem edge fades, so the last sub-item reads at full strength. + const margin = 28 + const target = section.offsetHeight + 2 * margin <= viewport.clientHeight ? section : row + const top = target.getBoundingClientRect().top - viewport.getBoundingClientRect().top + viewport.scrollTop + const bottom = top + target.offsetHeight + if (top < viewport.scrollTop + margin) viewport.scrollTop = Math.max(0, top - margin) + else if (bottom > viewport.scrollTop + viewport.clientHeight - margin) + viewport.scrollTop = bottom - viewport.clientHeight + margin } +/** Which rows are active: a re-render that leaves them alone doesn't move the nav. */ +const activeKeyOf = (viewport: HTMLElement): string => + Arr.join( + Arr.map( + Arr.fromIterable(viewport.querySelectorAll('[data-active="true"]')), + (row) => row.getAttribute("href") ?? "", + ), + " ", + ) + export const MeasureSidebarOverflow = Mount.defineStream("MeasureSidebarOverflow", { messages: [Message.MeasuredSidebarOverflow], execute: ({ element }) => @@ -81,12 +99,27 @@ export const MeasureSidebarOverflow = Mount.defineStream("MeasureSidebarOverflow if (element.firstElementChild !== null) observer.observe(element.firstElementChild) element.addEventListener("scroll", measure, { passive: true }) revealActiveRow(element) + // A route change moves the active row (or opens a section): bring it into view too. + const seen = { key: activeKeyOf(element) } + const routed = new MutationObserver(() => { + const key = activeKeyOf(element) + if (key === seen.key) return + seen.key = key + revealActiveRow(element) + }) + routed.observe(element, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["data-active"], + }) measure() Queue.offerUnsafe(queue, Message.MeasuredSidebarOverflow()) - return { observer, measure } + return { observer, measure, routed } }), (handles) => Effect.sync(() => { + handles?.routed.disconnect() handles?.observer.disconnect() if (handles !== undefined) element.removeEventListener("scroll", handles.measure) }), diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 71f756559c..d41ccc49d9 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -36,6 +36,10 @@ export interface NavSubItem { readonly paletteTitle?: string /** Pages under another path that this row stands in for (schedules under On-call). */ readonly alsoActive?: ReadonlyArray + /** A live count beside the title, read by the shell (the Inbox's unacknowledged incidents). */ + readonly count?: "alertsInbox" + /** Starts a group of rows: a thin rule above it, no heading. */ + readonly startsGroup?: boolean } export interface NavItem { @@ -143,6 +147,7 @@ const alertsItem: NavItem = { icon: "BellIcon", paletteTitle: "Alerts", alsoActive: ["/anomalies"], + count: "alertsInbox", }, { title: "Rules", @@ -150,17 +155,24 @@ const alertsItem: NavItem = { icon: "ChartLineIcon", paletteTitle: "Alert rules", alsoActive: ["/alerts/create"], + startsGroup: true, }, { title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" }, { title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" }, - { title: "Silences", href: "/alerts/silences", icon: "BellSlashIcon" }, + { title: "Silences", href: "/alerts/silences", icon: "BellSlashIcon", startsGroup: true }, { title: "Maintenance", href: "/alerts/maintenance", icon: "CalendarIcon", paletteTitle: "Maintenance windows", }, - { title: "On-call", href: "/alerts/on-call", icon: "UsersIcon", alsoActive: ["/alerts/schedules"] }, + { + title: "On-call", + href: "/alerts/on-call", + icon: "UsersIcon", + alsoActive: ["/alerts/schedules"], + startsGroup: true, + }, { title: "Routing", href: "/alerts/routing", icon: "BranchForkIcon", paletteTitle: "Alert routing" }, ], } @@ -246,6 +258,9 @@ export const isNavItemActive = (currentPath: string, item: NavItem): boolean => Arr.some(item.alsoActive ?? [], (href) => isPathActive(currentPath, href)) || Arr.some(item.subItems ?? [], (sub) => matchSubItem(currentPath, sub) !== undefined) +/** The Alerts row is open here, so its Inbox count is shown (and worth reading). */ +export const isAlertsOpenAt = (currentPath: string): boolean => isNavItemActive(currentPath, alertsItem) + export interface NavPreview { readonly glyphs: ReadonlyArray /** Marks past the cap, drawn as `+N`. */ diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index d1f5daa5a0..fd4ae2e8c8 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -462,6 +462,34 @@ describe("alerts section", () => { measured, expect(role("link", { name: "Uptime" })).toHaveAttr("aria-current", "page"), ) + scene( + { update, view: shellAt("/alerts/rules/0199a001-0000-7000-8000-000000000001") }, + given(signedIn()), + measured, + expect(role("link", { name: "Rules" })).toHaveAttr("aria-current", "page"), + expect(role("link", { name: "Inbox" })).not.toHaveAttr("aria-current", "page"), + ) + }) + + test("the Inbox counts unacknowledged incidents for this org, and thin rules split the groups", () => { + const counted = update(signedIn(), Message.UpdatedAlertsInboxCount({ orgId: "org_parity", count: 3 })) + scene( + { update, view: shellAt("/alerts/rules") }, + given(counted.model), + measured, + expect(text("3")).toExist(), + expect(role("separator")).toExist(), + ) + // Nothing to act on shows no badge; another org's count is dropped, and an org change clears it. + scene( + { update, view: shellAt("/alerts/rules") }, + given(signedIn({ alertsInboxCount: 0 })), + measured, + expect(text("0")).toBeAbsent(), + ) + const stray = update(signedIn(), Message.UpdatedAlertsInboxCount({ orgId: "org_other", count: 4 })) + expectVitest(stray.model.alertsInboxCount).toBeNull() + expectVitest(enter("org_other")(counted.model).model.alertsInboxCount).toBeNull() }) test("elsewhere the sections stay hidden", () => { diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 0bdca57292..96d0d55382 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -65,6 +65,7 @@ export const enter = infraPresence: () => InfraPresenceData.Idle(), integrations: () => PENDING_INTEGRATIONS, dashboards: () => DashboardsData.Idle(), + alertsInboxCount: () => null, onCall: () => OnCallData.Idle(), onCallNextReadAtMs: () => null, onCallBackoffMs: () => 0, @@ -550,6 +551,10 @@ const foldMessage = (model: Model, message: Message): UpdateReturn => modifyFields(current, { sources: () => SourcesData.Failure({ error: "Failed" }) }), ), // A result for an org the shell has left is dropped. + UpdatedAlertsInboxCount: ({ orgId, count }) => + Option.contains(model.maybeOrgId, orgId) + ? { model: modifyFields(model, { alertsInboxCount: () => count }) } + : { model }, SucceededLoadDashboards: ({ orgId, dashboards }) => Option.contains(model.maybeOrgId, orgId) ? { diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 749a57a46f..4206e21fe9 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -10,11 +10,13 @@ import { sidebarGroupLabel, sidebarGroupToggle, sidebarMenu, + sidebarMenuBadge, sidebarMenuButton, sidebarMenuItem, sidebarMenuSub, sidebarMenuSubButton, sidebarMenuSubItem, + sidebarSeparator, } from "@maple/views/ui/sidebar" import type { PinnedDashboardRow } from "./dashboards-shape" import { Menu, Message } from "./message" @@ -96,41 +98,75 @@ const collapsedSectionRow = (h: HtmlBuilder, model: Model, item: NavIte ), ]) -const subList = (h: HtmlBuilder, subItems: ReadonlyArray, currentPath: string): Html => { +/** The live count a sub-item shows, when it has one and it is above zero (an org change clears it). */ +const subCountOf = (model: Model, sub: NavSubItem): number | undefined => + sub.count === "alertsInbox" && model.alertsInboxCount !== null && model.alertsInboxCount > 0 + ? model.alertsInboxCount + : undefined + +// A rule between groups keeps the sub-list's lane line unbroken. +const groupRule = (h: HtmlBuilder): Html => + sidebarMenuSubItem(h, "border-sidebar-border border-l-2 py-1 ps-2.5", [sidebarSeparator(h)]) + +const subList = ( + h: HtmlBuilder, + model: Model, + subItems: ReadonlyArray, + currentPath: string, +): Html => { const activeSubHref = activeSubHrefOf(subItems, currentPath) return sidebarMenuSub( h, "mx-0 ms-4 translate-x-0 gap-0 border-l-0 px-0 py-0", - Arr.map(subItems, (sub) => - sidebarMenuSubItem( - h, - sub.href === activeSubHref - ? "border-sidebar-primary border-l-2 ps-2.5" - : "border-sidebar-border border-l-2 ps-2.5", - [ - sidebarMenuSubButton( - h, - { - className: - "translate-x-0 data-[active=true]:text-sidebar-primary [&>svg]:text-current", - isActive: sub.href === activeSubHref, - link: { href: sub.href, currentPath }, - }, - [ - ...(sub.icon === undefined - ? [] - : [ - icon(h, sub.icon, { - className: "size-3.5", - ...iconColor(sub.iconColor), - }), - ]), - h.span([], [sub.title]), - ], - ), - ], - ), - ), + Arr.flatMap(subItems, (sub, index) => { + const count = subCountOf(model, sub) + return [ + ...(sub.startsGroup === true && index > 0 ? [groupRule(h)] : []), + sidebarMenuSubItem( + h, + sub.href === activeSubHref + ? "border-sidebar-primary border-l-2 ps-2.5" + : "border-sidebar-border border-l-2 ps-2.5", + [ + sidebarMenuSubButton( + h, + { + className: + "h-7 translate-x-0 data-[active=true]:text-sidebar-primary sm:h-6 [&>svg]:text-current", + isActive: sub.href === activeSubHref, + link: { href: sub.href, currentPath }, + }, + [ + ...(sub.icon === undefined + ? [] + : [ + icon(h, sub.icon, { + className: "size-3.5", + ...iconColor(sub.iconColor), + }), + ]), + h.span([], [sub.title]), + ], + ), + ...(count === undefined + ? [] + : [ + sidebarMenuBadge( + h, + [ + typography( + h, + { size: "xs", mono: true, numeric: true, tone: "error" }, + [String(count)], + ), + ], + "top-1 sm:top-0.5", + ), + ]), + ], + ), + ] + }), ) } @@ -199,7 +235,7 @@ const navRow = ( ], ), ...(item.badge === undefined ? [] : [navBadge(h, item.badge)]), - ...(subItems !== undefined && isActive ? [subList(h, subItems, currentPath)] : []), + ...(subItems !== undefined && isActive ? [subList(h, model, subItems, currentPath)] : []), ]) } diff --git a/apps/web/src/subscription.ts b/apps/web/src/subscription.ts index da920252d9..134b8cdb59 100644 --- a/apps/web/src/subscription.ts +++ b/apps/web/src/subscription.ts @@ -1,5 +1,15 @@ import { isClerkAuthEnabled } from "./lib/services/common/auth-mode" -import { Clock, Duration, Effect, Option, Schedule, Schema, Stream } from "effect" +import { + Array as Arr, + Clock, + Duration, + Effect, + Option, + Schedule, + Schema, + Stream, + String as Str, +} from "effect" import { Dom, Subscription } from "foldkit" import { Message } from "./message" import type { Resources } from "./platform/resources" @@ -17,6 +27,8 @@ import { connectSubscriptions } from "./page/onboarding/quick-start-connect" import * as SourcesFlow from "./page/sources/new/embed" import { clerkSnapshots } from "./platform/clerk" import * as Shell from "./shell" +import { isAlertsOpenAt } from "./shell/nav" +import { hrefOf } from "./route" import { staleBuildRecovery } from "./shell/version" import { pollTicks } from "./data/poll" import { collectionLoadFailed } from "./page/alerts/shared/collection-load" @@ -78,6 +90,14 @@ const shellDashboardsOrgId = (model: Model): string | null => ? Option.getOrNull(model.shell.maybeOrgId) : null +/** The org whose Alerts › Inbox count the sidebar shows: while an alerts page has the row open. */ +const shellAlertsInboxOrgId = (model: Model): string | null => + model.session._tag === "SignedIn" && + rendersShell(model.route) && + isAlertsOpenAt(Arr.headNonEmpty(Str.split(hrefOf(model.route), "?"))) + ? Option.getOrNull(model.shell.maybeOrgId) + : null + const root = Subscription.make()((entry) => ({ /** `subscribeSelfHostedAuthChanges`: a renewal elsewhere ended or replaced the session. */ selfHostedSession: entry( @@ -180,6 +200,21 @@ const root = Subscription.make()((entry) => ({ ), }, ), + /** The Alerts › Inbox count, over the `alert_incidents` collection the alerts pages already sync. */ + shellAlertsInbox: entry( + { orgId: Schema.NullOr(Schema.String) }, + { + modelToDependencies: (model) => ({ orgId: shellAlertsInboxOrgId(model) }), + dependenciesToStream: ({ orgId }) => + orgId === null + ? Stream.empty + : Shell.alertsInboxCounts(orgId, (count) => + Message.GotShellMessage({ + message: Shell.Message.UpdatedAlertsInboxCount({ orgId, count }), + }), + ), + }, + ), /** `useDashboardsRead` for the Pinned group: the org's live `dashboards` collection. */ shellDashboards: entry( { orgId: Schema.NullOr(Schema.String) }, diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 754840a01d..ae98087bc1 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -39,7 +39,7 @@ const SAMPLE_PATHS = [ "/services/checkout", "/alerts", "/alerts/incidents/inc_1", - "/alerts/00000000-0000-4000-8000-000000000002", + "/alerts/rules/00000000-0000-4000-8000-000000000002", "/alerts/create", "/alerts/uptime/new", "/alerts/slos/new", diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index b632d9b440..fae9c9bff9 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -313,10 +313,15 @@ export const sidebarMenuItem = ( children, ) -export const sidebarMenuBadge = (h: HtmlBuilder, children: Children): Html => +/** ``; `className` places it beside a sub-item, which the peer offsets don't cover. */ +export const sidebarMenuBadge = ( + h: HtmlBuilder, + children: Children, + className?: string, +): Html => h.div( [ - h.Class(cn(...sidebarMenuBadgeStyles)), + h.Class(cn(...sidebarMenuBadgeStyles, className)), h.DataAttribute("sidebar", "menu-badge"), h.DataAttribute("slot", "sidebar-menu-badge"), ], From 5068806324fdffe87e4a0f033796f124da9f7cc0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:45:58 +0100 Subject: [PATCH 12/69] feat(alerts): live coverage preview in the schedule editor and detail page The schedule editor renders the draft with the server's renderOnCall (alerting-core/oncall) on every edit: a lane per layer, the final lane with No one gap bars, a gap summary with links that focus each gap, weekend shading, a now line and a 1/2/4 week range. The detail page draws the same timeline from the server's shifts. --- apps/web/package.json | 1 + .../src/page/alerts/schedule-form/command.ts | 10 +- .../src/page/alerts/schedule-form/message.ts | 8 + .../src/page/alerts/schedule-form/model.ts | 4 + .../src/page/alerts/schedule-form/preview.ts | 82 ++++++ .../schedule-form/schedule-form.scene.test.ts | 55 ++++ .../src/page/alerts/schedule-form/update.ts | 18 ++ .../web/src/page/alerts/schedule-form/view.ts | 17 +- apps/web/src/page/alerts/schedule/command.ts | 10 +- apps/web/src/page/alerts/schedule/message.ts | 5 + apps/web/src/page/alerts/schedule/model.ts | 3 + .../alerts/schedule/schedule.scene.test.ts | 24 +- apps/web/src/page/alerts/schedule/update.ts | 16 +- apps/web/src/page/alerts/schedule/view.ts | 210 ++++++--------- .../page/alerts/shared/coverage-preview.ts | 241 ++++++++++++++++++ .../alerts/shared/schedule-coverage.test.ts | 236 +++++++++++++++++ .../page/alerts/shared/schedule-coverage.ts | 227 +++++++++++++++++ bun.lock | 1 + packages/alerting-core/package.json | 3 +- packages/views/src/ui/shift-timeline.ts | 47 +++- 20 files changed, 1075 insertions(+), 143 deletions(-) create mode 100644 apps/web/src/page/alerts/schedule-form/preview.ts create mode 100644 apps/web/src/page/alerts/shared/coverage-preview.ts create mode 100644 apps/web/src/page/alerts/shared/schedule-coverage.test.ts create mode 100644 apps/web/src/page/alerts/shared/schedule-coverage.ts 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/page/alerts/schedule-form/command.ts b/apps/web/src/page/alerts/schedule-form/command.ts index ffa4c6ff33..5637ab0cc6 100644 --- a/apps/web/src/page/alerts/schedule-form/command.ts +++ b/apps/web/src/page/alerts/schedule-form/command.ts @@ -1,7 +1,7 @@ import { OnCallScheduleId } from "@maple/domain/http" import { V2OnCallScheduleCreateParams } from "@maple/domain/http/v2" import { Clock, Effect, Schema } from "effect" -import { Command } from "foldkit" +import { Command, Dom } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { browserZone, readSupportedTimeZones } from "../../../platform/time-zone" import { retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" @@ -42,6 +42,14 @@ export const FetchScheduleFormSession = Command.define("FetchScheduleFormSession Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), }) +/** A gap's bar takes focus, which scrolls it into view. */ +export const FocusPreviewGap = Command.define("FocusScheduleFormGap", { + args: { selector: Schema.String }, + messages: [Message.CompletedFocusGap], + execute: ({ selector }) => + Dom.focus(selector).pipe(Effect.ignore, Effect.as(Message.CompletedFocusGap())), +}) + export const SaveSchedule = Command.define("SaveOnCallSchedule", { args: { editingId: Schema.NullOr(OnCallScheduleId), payload: V2OnCallScheduleCreateParams }, messages: [Message.CompletedSave], diff --git a/apps/web/src/page/alerts/schedule-form/message.ts b/apps/web/src/page/alerts/schedule-form/message.ts index 922636d3dc..fb69c903e0 100644 --- a/apps/web/src/page/alerts/schedule-form/message.ts +++ b/apps/web/src/page/alerts/schedule-form/message.ts @@ -7,6 +7,7 @@ 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) @@ -46,6 +47,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..f192a593d3 100644 --- a/apps/web/src/page/alerts/schedule-form/model.ts +++ b/apps/web/src/page/alerts/schedule-form/model.ts @@ -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=`). @@ -35,6 +36,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.ts b/apps/web/src/page/alerts/schedule-form/preview.ts new file mode 100644 index 0000000000..9688614b37 --- /dev/null +++ b/apps/web/src/page/alerts/schedule-form/preview.ts @@ -0,0 +1,82 @@ +import { Array as Arr, Option, Schema, String as Str } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { OnCallTimeZone } from "@maple/domain/http" +import { coveragePreview, type PreviewShift } from "../shared/coverage-preview" +import { memberName } from "../shared/org-members" +import { draftCoverage, previewLayerOf, previewRange } from "../shared/schedule-coverage" +import { localInputMs } 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 on every edit, no save needed. + +const isZone = Schema.is(OnCallTimeZone) + +const layerName = (name: string, index: number) => + Str.trim(name) === "" ? `Layer ${index + 1}` : Str.trim(name) + +/** Plain-language notes on the draft's layers, one per issue that changes the preview. */ +export const previewWarnings = (model: Pick): ReadonlyArray => + Arr.flatMap(model.form.layers, (layer, index) => { + const name = layerName(layer.name, index) + if (layer.participants.length === 0) return [`${name} has no people, so it covers nothing.`] + const startMs = + layer.startsAtMs === null + ? localInputMs(layer.startsAt, model.form.timeZone) + : Option.some(layer.startsAtMs) + return Option.exists(startMs, (ms) => ms < model.nowMs) + ? [`${name} starts in the past. Shifts are scheduled from now, keeping its rotation order.`] + : [] + }) + +const asPreview = (shift: { + userId: string + startMs: number + endMs: number + source: { kind: string } +}): PreviewShift => ({ + userId: shift.userId, + startMs: shift.startMs, + endMs: shift.endMs, + isOverride: shift.source.kind === "override", +}) + +export const schedulePreview = (h: HtmlBuilder, model: Model): Html => { + const timeZone = isZone(model.form.timeZone) ? model.form.timeZone : "UTC" + const days = Number(model.previewRange) + const range = previewRange(model.nowMs, timeZone, model.previewWeekOffset, days) + const coverage = draftCoverage(model.form, [], range.startMs, range.endMs, model.nowMs) + return coveragePreview(h, { + scope: PREVIEW_SCOPE, + title: "Coverage preview", + timeZone, + fromMs: range.startMs, + toMs: range.endMs, + nowMs: model.nowMs, + weekOffset: model.previewWeekOffset, + rangeDays: model.previewRange, + lanes: Arr.map(model.form.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 the rotation to see shifts" + : "Not active in this period", + })), + final: Arr.map(coverage.final, asPreview), + gaps: coverage.gaps, + nameOf: (userId) => memberName(model.members, userId), + onPrevious: Message.ClickedPreviewPrevious(), + onNext: Message.ClickedPreviewNext(), + onToday: Message.ClickedPreviewToday(), + onRange: (days) => Message.PickedPreviewRange({ days }), + onFocusGap: (index) => Message.ClickedPreviewGap({ index }), + warnings: previewWarnings(model), + note: `Times in ${timeZone}. Overrides are not shown here.`, + }) +} 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..3d24459790 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 @@ -306,4 +306,59 @@ describe("schedule form", () => { "FetchScheduleFormSchedule", ) }) + + describe("coverage preview", () => { + test("a layer with nobody in it leaves the next 14 days uncovered and says why", () => { + scene( + { update: pageUpdate, view }, + given(ready()), + expectView(text("Primary has no people, so it covers nothing.")).toExist(), + expectView(text(/^1 gap in the next 14 days, longest 13d 12h/u)).toExist(), + expectView(text("Add people to see shifts")).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 for the next 14 days")).toExist(), + expectView(text(/Primary starts in the past\. Shifts are scheduled from now/u)).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 the next 14 days, 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(["FocusScheduleFormGap"]) + }) + + 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 for the next 7 days")).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) + }) + }) }) diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts index f8ee3957de..b90a2260de 100644 --- a/apps/web/src/page/alerts/schedule-form/update.ts +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -7,11 +7,13 @@ 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, gapBarId } from "../shared/coverage-preview" import { scheduleHref, scheduleIdFromUrl } from "../shared/oncall-schedules" import { FetchExistingSchedule, FetchScheduleFormMembers, FetchScheduleFormSession, + FocusPreviewGap, ReadScheduleFormDefaults, SaveSchedule, } from "./command" @@ -35,6 +37,9 @@ 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}` export const init = (route: FormRoute, context: PageContext): Return => { @@ -58,6 +63,8 @@ export const init = (route: FormRoute, context: PageContext): Return => { saveFailure: null, grants: [], isSessionLoaded: false, + previewWeekOffset: 0, + previewRange: DEFAULT_RANGE_DAYS, } const common = [ ReadScheduleFormDefaults(), @@ -301,6 +308,17 @@ export const update = (model: Model, message: Message, context: PageContext): Re editRestriction(model, key, restrictionKey, (restriction) => edge === "start" ? { ...restriction, startTime: value } : { ...restriction, endTime: value }, ), + ClickedPreviewPrevious: () => ({ + model: { ...model, previewWeekOffset: 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: [FocusPreviewGap({ selector: `#${gapBarId(PREVIEW_SCOPE, index)}` })], + }), + 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..8dfa133e27 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" @@ -558,7 +559,21 @@ export const view = Submodel.defineView((model, 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), + // The form on the left and its live preview on the right; stacked under xl. + h.div( + [h.Class("grid gap-6 xl:grid-cols-[minmax(0,40rem)_minmax(0,1fr)] xl: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 xl:sticky xl:top-0")], + [schedulePreview(h, model)], + ), + ], + ), ]), actionBar(h, model), ]) diff --git a/apps/web/src/page/alerts/schedule/command.ts b/apps/web/src/page/alerts/schedule/command.ts index 9cceccf980..13f9457ab5 100644 --- a/apps/web/src/page/alerts/schedule/command.ts +++ b/apps/web/src/page/alerts/schedule/command.ts @@ -1,7 +1,7 @@ import { OnCallOverrideId, OnCallScheduleId } from "@maple/domain/http" import { V2OnCallOverrideCreateParams } from "@maple/domain/http/v2" import { Clock, Effect, Schema } from "effect" -import { Command } from "foldkit" +import { Command, Dom } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { createOverride, @@ -83,3 +83,11 @@ export const DeleteOverride = Command.define("DeleteOnCallOverride", { Effect.map((result) => Message.CompletedDeleteOverride({ overrideId, result })), ), }) + +/** A gap's bar takes focus, which scrolls it into view. */ +export const FocusScheduleGap = Command.define("FocusScheduleGap", { + args: { selector: Schema.String }, + messages: [Message.CompletedFocusGap], + execute: ({ selector }) => + Dom.focus(selector).pipe(Effect.ignore, Effect.as(Message.CompletedFocusGap())), +}) 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..38fad37c6b 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -110,7 +110,7 @@ describe("on-call schedule page", () => { { 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 +122,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( @@ -456,4 +456,24 @@ 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 for the next 14 days")).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(), + ) + }) }) diff --git a/apps/web/src/page/alerts/schedule/update.ts b/apps/web/src/page/alerts/schedule/update.ts index 47b0e8f77d..713a3bfc58 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, gapBarId } from "../shared/coverage-preview" import { scheduleIdFromUrl } from "../shared/oncall-schedules" import { localInputMs, localInputOf } from "../schedule-form/form" import { @@ -18,6 +19,7 @@ import { FetchScheduleMembers, FetchScheduleSession, FetchShifts, + FocusScheduleGap, ReadScheduleClock, } from "./command" import { Message } from "./message" @@ -30,9 +32,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 +51,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 +350,12 @@ 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: [FocusScheduleGap({ selector: `#${gapBarId(CALENDAR_SCOPE, index)}` })], + }), + 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..d8467c71bf 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,95 @@ 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" }, - ], - }) + const zoneNote = + viewerZone === schedule.time_zone + ? `Times in ${schedule.time_zone}.` + : `Times in ${schedule.time_zone}. Yours is ${viewerZone}.` + const note = `${zoneNote}${canManage(model) ? " Pick a shift in the final row to cover it with an override." : ""}` + 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 still in flight) draw as the skeleton, not blank rows. + const loaded = (fromMs: number, toMs: number, shifts: V2OnCallShifts): Html => + Option.exists(readRangeOf(model), (read) => isoMs(shifts.from) === read.fromMs) + ? 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/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts new file mode 100644 index 0000000000..78950de51d --- /dev/null +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -0,0 +1,241 @@ +import { Array as Arr, Option, Schema } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +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 { heading } from "@maple/views/ui/heading" +import { typography } from "@maple/views/ui/typography" +import { formatShiftEdge } from "./oncall-schedules" +import { gapRangeText, gapSummary, 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 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 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 +}> + +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 final shift's bar action (the detail page's "cover with an override"). */ + onFinalShift?: (shift: PreviewShift) => Option.Option + /** Plain-language notes over the timeline. */ + warnings?: ReadonlyArray + /** A line under the header, after the zone note. */ + note?: string + /** 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, + 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 => ({ + key: `gap-${gap.startMs}`, + id: gapBarId(options.scope, index), + startMs: gap.startMs, + endMs: gap.endMs, + label: "No one", + title: `No one on call, ${gapRangeText(gap, options.timeZone)}`, + tone: "warning", + })) + 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, + 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 gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { + const days = Number(options.rangeDays) + const summary = gapSummary(options.gaps, options.timeZone, options.weekOffset, days) + const isCovered = options.gaps.length === 0 + return vstack(h, { gap: "1.5", attributes: [h.DataAttribute("slot", "coverage-summary")] }, [ + hstack(h, { gap: "1.5" }, [ + h.span( + [h.AriaHidden(true), h.Class(isCovered ? "text-primary" : "text-warning-foreground")], + [icon(h, isCovered ? "CircleCheckIcon" : "AlertWarningIcon")], + ), + typography(h, { size: "sm", weight: "medium", as: "p", attributes: [h.AriaLive("polite")] }, [ + summary, + ]), + ]), + ...(isCovered + ? [] + : [ + hstack( + h, + { gap: "1", wrap: true }, + Arr.map(options.gaps, (gap, index) => + button( + h, + { + size: "xs", + variant: "outline", + attributes: [ + h.Key(`gap-link-${gap.startMs}`), + h.OnClick(options.onFocusGap(index)), + ], + }, + [gapRangeText(gap, options.timeZone)], + ), + ), + ), + ]), + ]) +} + +export const coveragePreview = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => + h.section( + [h.Class("min-w-0 space-y-3"), h.DataAttribute("slot", "coverage-preview")], + [ + hstack(h, { gap: "2", justify: "between", wrap: true }, [ + vstack(h, { gap: "0" }, [ + heading(h, { level: 2, variant: "section" }, [options.title]), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + periodLabel(options.fromMs, options.toMs, options.timeZone), + ]), + ]), + hstack(h, { gap: "1", wrap: true }, [ + segmentedSelect(h, { + options: RANGE_OPTIONS, + value: options.rangeDays, + onChange: options.onRange, + size: "xs", + ariaLabel: "Range", + }), + iconButton(h, { label: "Previous week", 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"), + ]), + ]), + ]), + ...(options.note === undefined + ? [] + : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.note])]), + ...Arr.map(options.warnings ?? [], (warning) => + hstack(h, { gap: "1.5", attributes: [h.Key(warning)] }, [ + h.span( + [h.AriaHidden(true), h.Class("text-warning-foreground")], + [icon(h, "CircleInfoIcon")], + ), + typography(h, { size: "xs", tone: "muted", as: "p" }, [warning]), + ]), + ), + ...(options.placeholder === undefined + ? [gapSummaryView(h, options), timelineView(h, options)] + : [options.placeholder]), + ], + ) + +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: "warning", label: "No one" }, + ], + }) 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..cc374d6fe7 --- /dev/null +++ b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts @@ -0,0 +1,236 @@ +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, + gapsOf, + gapSummary, + 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", () => { + it("says covered when there are no gaps", () => { + expect(gapSummary([], "Europe/Berlin", 0, 14)).toBe("Covered for the next 14 days") + }) + 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, "Europe/Berlin", 0, 14)).toBe( + "2 gaps in the next 14 days, longest 16h (Sat 21 Nov 00:00 to 16:00)", + ) + }) + it("formats durations", () => { + expect(durationText(90 * 60_000)).toBe("1h 30m") + expect(durationText(76 * HOUR)).toBe("3d 4h") + }) +}) 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..554ae22547 --- /dev/null +++ b/apps/web/src/page/alerts/shared/schedule-coverage.ts @@ -0,0 +1,227 @@ +import { renderOnCall, type OnCallShift } from "@maple/alerting-core/oncall" +import { OnCallLayer, OnCallLocalTime, OnCallScheduleConfig, OnCallTimeZone } from "@maple/domain/http" +import type { OnCallOverride } from "@maple/domain/http" +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 { localInputMs, 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 isLocalTime = Schema.is(OnCallLocalTime) +const isZone = Schema.is(OnCallTimeZone) +const decodeLayer = Schema.decodeUnknownOption(OnCallLayer) +const decodeConfig = Schema.decodeUnknownOption(OnCallScheduleConfig) + +const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => + layer.startsAtMs === null ? localInputMs(layer.startsAt, timeZone) : Option.some(layer.startsAtMs) + +/** A draft layer as the engine's layer, while it has people and a valid rotation; bad restrictions drop out. */ +export const previewLayerOf = (layer: LayerDraft, timeZone: string): Option.Option => + Option.flatMap(layerStartMs(layer, timeZone), (startsAt) => + decodeLayer({ + // 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( + Arr.filter( + layer.restrictions, + (r) => r.weekdays.length > 0 && isLocalTime(r.startTime) && isLocalTime(r.endTime), + ), + ({ weekdays, startTime, endTime }) => ({ weekdays, startTime, endTime }), + ), + }), + ) + +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. + +export const RANGE_DAYS = [7, 14, 28] as const +export type RangeDays = (typeof RANGE_DAYS)[number] + +/** `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(",", "") + +/** "Sat 21 Nov 00:00 to 16:00", or both ends in full across days. */ +export const gapRangeText = (gap: Interval, timeZone: string): string => { + const start = dropComma(formatterOf(timeZone, "day").format(gap.startMs)) + 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, rounded to minutes. */ +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), + }), + ) + +/** "2 gaps in the next 14 days, longest 16h (Sat 21 Nov 00:00 to 16:00)", or "Covered for the next 14 days". */ +export const gapSummary = ( + gaps: ReadonlyArray, + timeZone: string, + weekOffset: number, + days: number, +): string => + Option.match(longestGap(gaps), { + onNone: () => (weekOffset === 0 ? `Covered for the next ${days} days` : "Covered in this period"), + onSome: (longest) => + `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} ${weekOffset === 0 ? `in the next ${days} days` : "in this period"}, longest ${durationText(longest.endMs - longest.startMs)} (${gapRangeText(longest, timeZone)})`, + }) diff --git a/bun.lock b/bun.lock index b093e6bd9c..a879a71fbe 100644 --- a/bun.lock +++ b/bun.lock @@ -323,6 +323,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/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/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index 6fe6520d72..928eed657e 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -12,8 +12,8 @@ import { typography } from "./typography" // on-call schedule's layers and its final row). Days follow the zone's wall clock, so a DST day is // 23 or 25 hours wide. -/** A bar's look: a layer's shift, the effective shift, or an override over it. */ -export type TimelineBarTone = "neutral" | "primary" | "info" +/** A bar's look: a layer's shift, the effective shift, an override over it, or a gap nobody covers. */ +export type TimelineBarTone = "neutral" | "primary" | "info" | "warning" export type TimelineBar = Readonly<{ key: string @@ -24,6 +24,8 @@ export type TimelineBar = Readonly<{ title: string tone: TimelineBarTone onClick?: M + /** The bar's element id, so a summary elsewhere can scroll to and focus it. */ + id?: string }> export type TimelineRow = Readonly<{ @@ -43,6 +45,7 @@ export type TimelineDay = Readonly<{ dayOfMonth: number leftPct: number widthPct: number + isWeekend: boolean }> const weekdayFormatters = new Map() @@ -53,6 +56,8 @@ const weekdayOf = (ms: number, timeZone: string): string => { return formatter.format(ms) } +const WEEKEND = new Set(["Sat", "Sun"]) + /** The instant the wall-clock day containing `ms` starts in `timeZone`, `offsetDays` later. */ export const zonedDayStart = (ms: number, timeZone: string, offsetDays = 0): number => { const parts = zonedDateParts(ms, timeZone) @@ -90,6 +95,7 @@ export const timelineDays = (fromMs: number, toMs: number, timeZone: string): Re startMs, endMs, weekday: weekdayOf(Math.max(startMs, fromMs), timeZone), + isWeekend: WEEKEND.has(weekdayOf(Math.max(startMs, fromMs), timeZone)), dayOfMonth: zonedDateParts(Math.max(startMs, fromMs), timeZone).day, ...place, }, @@ -104,6 +110,8 @@ const BAR_TONE = { // An override is the one bar that must stand out, even 8px wide on a dark surface: a stronger // wash and the tone's own edge. info: "bg-severity-info-a3 border-severity-info text-severity-info", + // A gap reads as missing cover: a dashed warning edge over a faint wash. + warning: "border-dashed bg-warning-a2 border-warning text-warning-foreground", } as const satisfies Record /** The narrowest a bar draws, so a short shift stays visible and clickable. */ @@ -151,7 +159,12 @@ const barView = ( const text = typography(h, { size: "xs", truncate: true, className: "block leading-6" }, [ bar.label, ]) - const attributes = [h.Key(bar.key), style, ...tooltipTrigger(h, bar.title)] + const attributes = [ + h.Key(bar.key), + style, + ...(bar.id === undefined ? [] : [h.Id(bar.id)]), + ...tooltipTrigger(h, bar.title), + ] return [ bar.onClick === undefined ? h.div( @@ -200,6 +213,8 @@ export const shiftTimeline = ( ariaLabel: string /** Read out in place of the now line, which is drawn only ("Now, Tue 10 Nov, 11:30"). */ nowLabel?: string + /** Names the axis's zone in the label column's header ("Europe/Berlin"). */ + zoneLabel?: string /** A key under the timeline for the bar tones it uses. */ legend?: ReadonlyArray> }>, @@ -213,8 +228,14 @@ export const shiftTimeline = ( h.div( [ h.Key(`rule-${day.startMs}`), - h.Class("absolute inset-y-0 border-l border-border-subtle"), - h.Style({ left: `${day.leftPct}%` }), + ...(day.isWeekend ? [h.DataAttribute("weekend", "true")] : []), + h.Class( + cn( + "absolute inset-y-0 border-l border-border-subtle", + day.isWeekend && "bg-surface-sunken", + ), + ), + h.Style({ left: `${day.leftPct}%`, width: `${day.widthPct}%` }), ], [], ), @@ -238,7 +259,21 @@ export const shiftTimeline = ( const header = h.div( [h.Class("flex border-b"), h.Role("row")], [ - h.div([h.Class(LABEL_COLUMN), h.Role("columnheader")], [h.span([h.Class("sr-only")], ["Lane"])]), + h.div( + [h.Class(cn(LABEL_COLUMN, "flex items-center")), h.Role("columnheader")], + [ + h.span([h.Class("sr-only")], ["Lane"]), + ...(options.zoneLabel === undefined + ? [] + : [ + typography( + h, + { size: "2xs", tone: "muted", truncate: true, className: "block" }, + [options.zoneLabel], + ), + ]), + ], + ), // One header for the track, as each row has one cell for it; the days label it visually. h.div( [h.Class("relative h-8 grow"), h.Role("columnheader"), h.AriaLabel("Shifts")], From 5fd8fbc8b66080901565e8a6c2573ef9fe49ec28 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:46:38 +0100 Subject: [PATCH 13/69] feat(alerts): oncall lab states for a covered and a gappy schedule draft --- apps/web/src/lab/oncall-lab.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index 5f7dd1883f..d4cd0ac5e9 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -54,6 +54,8 @@ export const LabState = Schema.Literals([ "schedules", "strip", "editor-new", + "editor-covered", + "editor-gaps", "editor-edit", "editor-invalid", "editor-error", @@ -186,6 +188,12 @@ 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 start = FormUpdate.init( @@ -252,7 +260,17 @@ const formFor = (state: LabState): Step => ] : state === "editor-new" ? [FormMessage.Message.ChangedName({ value: "Checkout primary" })] - : [] + : state === "editor-covered" || state === "editor-gaps" + ? [ + 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" })] + : []), + ] + : [] return fold(start, [...base, ...extra], formStep) } From 1cb4cef916ba54c045b017d473d092f1cdcef5f8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:48:02 +0100 Subject: [PATCH 14/69] feat(alerts): full-page escalation chain editor Escalation chains move out of the Routing dialog into /alerts/escalation-chains/new and /alerts/escalation-chains/:chainId. The chain reads as a column of sentence cards (start, steps, repeat) with a + between cards to insert a step and a per-step menu to move, duplicate or remove it. The aside holds the name, notes, a one-line preview, the rules that use the chain, and delete, whose confirm lists those rules. Validation moved from the dialog into a shared form module; the data model and API are unchanged. Routing and the rule editor link to the new pages and the dialog is deleted. --- apps/web/src/lab/entries/escalation.ts | 2 +- apps/web/src/lab/escalation-lab.ts | 64 +- .../alerts/editor/editor-chains.scene.test.ts | 5 +- .../page/alerts/editor/view-notifications.ts | 4 +- .../page/alerts/escalation-chain/command.ts | 66 ++ .../escalation-chain.scene.test.ts | 231 +++++++ .../page/alerts/escalation-chain/form.test.ts | 133 ++++ .../src/page/alerts/escalation-chain/form.ts | 289 ++++++++ .../src/page/alerts/escalation-chain/index.ts | 26 + .../page/alerts/escalation-chain/message.ts | 45 ++ .../src/page/alerts/escalation-chain/model.ts | 44 ++ .../alerts/escalation-chain/subscription.ts | 25 + .../page/alerts/escalation-chain/update.ts | 274 ++++++++ .../src/page/alerts/escalation-chain/view.ts | 636 ++++++++++++++++++ .../escalation-dialog/dialog.scene.test.ts | 76 --- .../alerts/escalation-dialog/dialog.test.ts | 176 ----- .../page/alerts/escalation-dialog/dialog.ts | 404 ----------- .../src/page/alerts/escalation-dialog/view.ts | 454 ------------- .../page/alerts/hub/hub-chains.scene.test.ts | 59 +- apps/web/src/page/alerts/hub/message.ts | 3 - apps/web/src/page/alerts/hub/model.ts | 2 - apps/web/src/page/alerts/hub/update.ts | 42 +- apps/web/src/page/alerts/hub/view-chains.ts | 24 +- apps/web/src/page/alerts/hub/view.ts | 18 +- .../page/alerts/shared/escalation-chains.ts | 51 +- apps/web/src/page/registry.ts | 2 + apps/web/src/route/app-route.ts | 2 + apps/web/src/route/route.test.ts | 5 + apps/web/src/route/routers.ts | 13 + apps/web/src/route/search.ts | 5 + apps/web/src/shell/nav.ts | 8 +- apps/web/src/view.lazy.test.ts | 2 + 32 files changed, 1906 insertions(+), 1284 deletions(-) create mode 100644 apps/web/src/page/alerts/escalation-chain/command.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/form.test.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/form.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/index.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/message.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/model.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/subscription.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/update.ts create mode 100644 apps/web/src/page/alerts/escalation-chain/view.ts delete mode 100644 apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts delete mode 100644 apps/web/src/page/alerts/escalation-dialog/dialog.test.ts delete mode 100644 apps/web/src/page/alerts/escalation-dialog/dialog.ts delete mode 100644 apps/web/src/page/alerts/escalation-dialog/view.ts diff --git a/apps/web/src/lab/entries/escalation.ts b/apps/web/src/lab/entries/escalation.ts index 462078241e..0c1e5e2524 100644 --- a/apps/web/src/lab/entries/escalation.ts +++ b/apps/web/src/lab/entries/escalation.ts @@ -1,7 +1,7 @@ import { defineShellLab } from "../shell-lab" import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../escalation-lab" -// `/lab/escalation`: escalation chains, their dialog, the rule tier picker and the incident rail (`?state=`). +// `/lab/escalation`: escalation chains, the rule tier picker and the incident rail (`?state=`). export const lab = defineShellLab("Escalation chains", { Model, diff --git a/apps/web/src/lab/escalation-lab.ts b/apps/web/src/lab/escalation-lab.ts index f710bea7d1..35a01cfe6b 100644 --- a/apps/web/src/lab/escalation-lab.ts +++ b/apps/web/src/lab/escalation-lab.ts @@ -1,13 +1,10 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" -import type { LoadFailure } from "@maple/views/data/load-failure" import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" -import * as Menu from "@maple/views/ui/menu" import * as Select from "@maple/views/ui/select" -import * as ChainDialog from "../page/alerts/escalation-dialog/dialog" import * as EditorMessage from "../page/alerts/editor/message" import * as EditorModel from "../page/alerts/editor/model" import { init as editorInit } from "../page/alerts/editor/init" @@ -33,16 +30,12 @@ import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" import { keepLocal } from "./local-commands" -// `/lab/escalation`: escalation chains in the Routing tab, their dialog, a rule tier's chain picker +// `/lab/escalation`: escalation chains in the Routing tab, a rule tier's chain picker // and the incident rail, over fixtures. `?state=` picks one. export const LabState = Schema.Literals([ "chains-empty", "chains", - "dialog-new", - "dialog-edit", - "dialog-invalid", - "dialog-error", "rule-picker", "incident-escalating", "incident-paused", @@ -99,18 +92,6 @@ const fold = ( const hubStep = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT) -const dialog = (message: ChainDialog.Message) => HubMessage.Message.GotChainDialogMessage({ message }) - -const SAVE_FAILURE: LoadFailure = { - _tag: "@maple/http/errors/AlertValidationError", - type: "invalid_request_error", - code: "invalid_request", - title: "Invalid request", - message: "Step 2 names a destination that no longer exists.", - retryable: false, - recovery: "fix_request", -} - const hubFor = (state: LabState): Step => { const entered = HubUpdate.init(hubAt({ tab: "routing" }), CONTEXT).model const chains = state === "chains-empty" ? [] : populatedChains([ruleId(1), ruleId(2), ruleId(3)]) @@ -128,45 +109,7 @@ const hubFor = (state: LabState): Step => { result: Result.succeed(chains), }), ] - const firstStepKey = "s0" - const extra: ReadonlyArray = - state === "dialog-new" - ? [HubMessage.Message.ClickedNewChain()] - : state === "dialog-invalid" - ? [ - HubMessage.Message.ClickedNewChain(), - dialog(ChainDialog.Message.ChangedWait({ key: firstStepKey, value: "2000" })), - dialog(ChainDialog.Message.ClickedSave()), - ] - : state === "dialog-error" - ? [ - HubMessage.Message.GotRowMenuMessage({ - menuId: `chain:${chainUuid(1)}`, - message: Menu.Message.ClickedItem({ - index: 0, - item: { kind: "Item", value: "edit" }, - }), - }), - dialog(ChainDialog.Message.ClickedSave()), - dialog( - ChainDialog.Message.CompletedSave({ - openCount: 1, - result: Result.fail(SAVE_FAILURE), - }), - ), - ] - : state === "dialog-edit" - ? [ - HubMessage.Message.GotRowMenuMessage({ - menuId: `chain:${chainUuid(3)}`, - message: Menu.Message.ClickedItem({ - index: 0, - item: { kind: "Item", value: "edit" }, - }), - }), - ] - : [] - return fold({ model: entered }, [...base, ...extra], hubStep) + return fold({ model: entered }, base, hubStep) } const editorStep = (model: EditorModel.Model, message: EditorMessage.Message) => @@ -250,8 +193,7 @@ const incidentFor = (state: LabState): Step - state === "chains" || state === "chains-empty" || Str.startsWith("dialog-")(state) +const isHub = (state: LabState) => state === "chains" || state === "chains-empty" const isIncident = (state: LabState) => Str.startsWith("incident-")(state) export const init = (flags: Flags): Return => { diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index 496064507e..829ebdadb1 100644 --- a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts @@ -62,7 +62,10 @@ describe("alert rule editor, escalation chains", () => { given(ready()), themeResolved(), expectView(role("combobox", { name: "Escalation chain" })).toExist(), - expectView(role("link", { name: "Create a chain" })).toHaveAttr("href", "/alerts/routing"), + expectView(role("link", { name: "Create a chain" })).toHaveAttr( + "href", + "/alerts/escalation-chains/new", + ), ) }) diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index e46c68bfce..f03dd78d10 100644 --- a/apps/web/src/page/alerts/editor/view-notifications.ts +++ b/apps/web/src/page/alerts/editor/view-notifications.ts @@ -21,7 +21,7 @@ import { destinationList, type NotifyView } from "./view-notify" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" import { textLink } from "@maple/views/ui/text-link" -import { chainSummary, routingTabHref } from "../shared/escalation-chains" +import { chainSummary, newChainHref } from "../shared/escalation-chains" import { chainSelectIdOf, NO_CHAIN } from "./form-steps" import type { Tier } from "./message" import { formSelect } from "./view-select" @@ -157,7 +157,7 @@ const chainPicker = ( textLink( h, { - href: routingTabHref(), + href: newChainHref(), tone: "muted", className: "text-xs", attributes: [h.Target("_blank"), h.Rel("noopener")], diff --git a/apps/web/src/page/alerts/escalation-chain/command.ts b/apps/web/src/page/alerts/escalation-chain/command.ts new file mode 100644 index 0000000000..4eb548a801 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/command.ts @@ -0,0 +1,66 @@ +import { EscalationChainId } from "@maple/domain/http" +import { V2EscalationChainCreateParams } from "@maple/domain/http/v2" +import { Effect, Schema } from "effect" +import { Command } from "foldkit" +import { readGrants } from "../../../data/session-tenant" +import { + deleteEscalationChain, + retrieveEscalationChain, + saveEscalationChain, +} from "../shared/escalation-chains" +import { listOnCallSchedules } from "../shared/oncall-schedules" +import { listOrgMembers } from "../shared/org-members" +import { Message } from "./message" + +export const FetchExistingChain = Command.define("FetchEscalationChainEditorChain", { + args: { key: Schema.String, chainId: EscalationChainId }, + messages: [Message.CompletedFetchExisting], + execute: ({ key, chainId }) => + retrieveEscalationChain(chainId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchExisting({ key, result })), + ), +}) + +export const FetchChainEditorMembers = Command.define("FetchEscalationChainEditorMembers", { + messages: [Message.CompletedFetchMembers], + execute: listOrgMembers.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMembers({ result })), + ), +}) + +export const FetchChainEditorSchedules = Command.define("FetchEscalationChainEditorSchedules", { + messages: [Message.CompletedFetchSchedules], + execute: listOnCallSchedules.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSchedules({ result })), + ), +}) + +export const FetchChainEditorSession = Command.define("FetchEscalationChainEditorSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), +}) + +export const SaveChain = Command.define("SaveEscalationChainFromEditor", { + args: { editingId: Schema.NullOr(EscalationChainId), payload: V2EscalationChainCreateParams }, + messages: [Message.CompletedSave], + execute: ({ editingId, payload }) => + saveEscalationChain(editingId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSave({ result })), + ), +}) + +export const DeleteChain = Command.define("DeleteEscalationChainFromEditor", { + args: { chainId: EscalationChainId }, + messages: [Message.CompletedDelete], + execute: ({ chainId }) => + deleteEscalationChain(chainId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDelete({ result })), + ), +}) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts new file mode 100644 index 0000000000..755b6f57e8 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -0,0 +1,231 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { EscalationChainId } from "@maple/domain/http" +import { EscalationChainPublicId } from "@maple/domain/http/v2" +import { Option, Result, Schema, Array as Arr } from "effect" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { click, expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import type { PageContext } from "../../contract" +import { CHAIN_IN_USE_CODE } from "../shared/escalation-chains" +import { + chainDestinations, + chainDestinationUuid, + chainUuid, + populatedChains, +} from "../shared/escalation-test-fixtures" +import { ORG, ruleId, ruleRow } from "../shared/test-rows" +import { payloadOf } from "./form" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const RULES = [ruleRow(1, { name: "Checkout errors" }), ruleRow(2, { name: "Checkout p95" }), ruleRow(3)] +const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) +const chainPublicId = (n: number) => + Schema.encodeSync(EscalationChainPublicId)(Schema.decodeSync(EscalationChainId)(chainUuid(n))) + +const pick = (key: string, value: string) => + Message.GotMenuMessage({ + kind: "targets", + key, + message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value } }, + }) +const act = (key: string, value: string) => + Message.GotMenuMessage({ + kind: "actions", + key, + message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value } }, + }) + +const ready = (route: Parameters[0]) => { + const entered = init(route, context) + return run(entered.model, [ + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.CompletedFetchMembers({ result: Result.succeed([]) }), + Message.CompletedFetchSchedules({ result: Result.succeed([]) }), + Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + Message.UpdatedRules({ orgId: ORG, rows: RULES }), + ]) +} +const newChain = (returnTo?: string) => + ready({ _tag: "AlertsEscalationChainNew", search: returnTo === undefined ? {} : { returnTo } }) + +const editing = (n: number) => { + const base = ready({ _tag: "AlertsEscalationChain", chainId: chainPublicId(n), search: {} }) + const chain = CHAINS[n - 1] + return chain === undefined + ? base + : run(base, [ + Message.CompletedFetchExisting({ + key: base.existing.key ?? "", + result: Result.succeed(chain), + }), + ]) +} + +describe("escalation chain editor", () => { + test("a new chain reads as a sentence from the start card to the end card", () => { + scene( + { update: pageUpdate, view }, + given(newChain()), + expectView(text("New escalation chain")).toExist(), + expectView(text("When an alert escalates to this chain")).toExist(), + expectView(text("Notify")).toExist(), + expectView(text("minutes for an acknowledgement")).toExist(), + expectView(text("If nobody acknowledges, repeat the chain")).toExist(), + expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/routing"), + expectView(role("button", { name: "Create chain" })).toExist(), + ) + }) + + test("builds a chain: add steps, insert between, reorder, remove, then save", () => { + const first = run(newChain(), [ + Message.ChangedName({ value: "Checkout on-call" }), + pick("s0", `destination:${chainDestinationUuid(1)}`), + Message.ClickedInsertStep({ index: 1 }), + pick("s1", `destination:${chainDestinationUuid(2)}`), + Message.ChangedWait({ key: "s0", value: "5" }), + ]) + expect(Arr.map(first.form.steps, (step) => step.key)).toEqual(["s0", "s1"]) + // The + between the two cards puts a new step in the middle. + scene( + { update: pageUpdate, view }, + given(first), + click(role("button", { name: "Add a step after step 1" })), + expectView(role("button", { name: "Actions for step 3" })).toExist(), + ) + const middle = pageUpdate(first, Message.ClickedInsertStep({ index: 1 })).model + expect(Arr.map(middle.form.steps, (step) => step.key)).toEqual(["s0", "s2", "s1"]) + const reordered = run(middle, [act("s1", "up"), act("s1", "up"), act("s2", "remove")]) + expect(Arr.map(reordered.form.steps, (step) => step.key)).toEqual(["s1", "s0"]) + const saved = pageUpdate(reordered, Message.ClickedSave()) + expect(names(saved.commands)).toEqual(["SaveEscalationChainFromEditor"]) + expect( + Option.map(payloadOf(reordered.form), (payload) => Arr.map(payload.steps, (s) => s.wait_minutes)), + ).toEqual(Option.some([10, 5])) + const chain = CHAINS[0] + if (chain === undefined) throw new Error("fixture chain missing") + const done = pageUpdate(saved.model, Message.CompletedSave({ result: Result.succeed(chain) })) + expect(done.outMessage).toMatchObject({ + _tag: "RequestedNavigation", + url: `/alerts/escalation-chains/${chainPublicId(1)}`, + toast: { title: "Escalation chain created" }, + }) + }) + + test("saving with missing fields shows each problem and sends nothing", () => { + scene( + { update: pageUpdate, view }, + given(newChain()), + click(role("button", { name: "Create chain" })), + expectView(text("Name the chain")).toExist(), + expectView(text("Pick at least one destination, person or schedule")).toExist(), + ) + expect(names(pageUpdate(newChain(), Message.ClickedSave()).commands)).toEqual([]) + }) + + test("an existing chain fills the form, previews itself and lists the rules that use it", () => { + const model = editing(1) + expect(model.form.name).toBe("Checkout on-call") + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Used by 2 rules")).toExist(), + expectView(role("link", { name: "Checkout errors" })).toExist(), + expectView( + text( + "Slack #alerts-api, then PagerDuty Checkout after 10m, then Email on-call after 15m; repeats twice", + ), + ).toExist(), + expectView(role("button", { name: "Save chain" })).toExist(), + ) + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual([ + "SaveEscalationChainFromEditor", + ]) + }) + + test("delete lists the rules that would stop escalating and holds the delete while they use it", () => { + const asked = pageUpdate(editing(1), Message.ClickedDelete()).model + expect(asked.deleteDialog.isOpen).toBe(true) + scene( + { update: pageUpdate, view }, + given(asked), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/These rules would stop escalating/u)).toExist(), + expectView(text("Checkout p95")).toExist(), + ) + // A stale page still gets the API's refusal as a message in the dialog. + const refused = pageUpdate( + { ...asked, isDeleting: true }, + Message.CompletedDelete({ + result: Result.fail({ + _tag: "@maple/http/errors/EscalationChainInUseError", + type: "conflict_error", + code: CHAIN_IN_USE_CODE, + title: "Escalation chain is in use", + message: "The escalation chain is used by one or more alert rules.", + retryable: false, + recovery: "fix_request", + }), + }), + ) + expect(refused.model.deleteError).toMatch(/Remove it from the rule's severity tiers first/u) + }) + + test("an unused chain deletes and goes back to Routing", () => { + const unused = editing(2) + const confirmed = pageUpdate( + pageUpdate(unused, Message.ClickedDelete()).model, + Message.ClickedConfirmDelete(), + ) + expect(names(confirmed.commands)).toEqual(["DeleteEscalationChainFromEditor"]) + const done = pageUpdate(confirmed.model, Message.CompletedDelete({ result: Result.void })) + expect(done.outMessage).toMatchObject({ _tag: "RequestedNavigation", url: "/alerts/routing" }) + }) + + test("Cancel and Save go back to an in-app returnTo, never another origin", () => { + scene( + { update: pageUpdate, view }, + given(newChain("/alerts/create")), + expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/create"), + ) + scene( + { update: pageUpdate, view }, + given(newChain("https://evil.example")), + expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/routing"), + ) + const filled = run(newChain("/alerts/create"), [ + Message.ChangedName({ value: "On-call" }), + pick("s0", `destination:${chainDestinationUuid(1)}`), + ]) + const saving = pageUpdate(filled, Message.ClickedSave()).model + const chain = CHAINS[0] + if (chain === undefined) throw new Error("fixture chain missing") + expect( + pageUpdate(saving, Message.CompletedSave({ result: Result.succeed(chain) })).outMessage, + ).toMatchObject({ + url: "/alerts/create", + }) + }) + + test("a link with a malformed chain id says the chain was not found", () => { + scene( + { update: pageUpdate, view }, + given(ready({ _tag: "AlertsEscalationChain", chainId: "nope", search: {} })), + expectView(text("Escalation chain not found")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/escalation-chain/form.test.ts b/apps/web/src/page/alerts/escalation-chain/form.test.ts new file mode 100644 index 0000000000..45acc7ce4e --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/form.test.ts @@ -0,0 +1,133 @@ +import { Option, Array as Arr, Schema } from "effect" +import { AlertDestinationId, MAX_ESCALATION_STEPS } from "@maple/domain/http" +import { describe, expect, it } from "vitest" +import { chainSummary, safeReturnTo } from "../shared/escalation-chains" +import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" +import { mixedTargetChain } from "../shared/oncall-test-fixtures" +import { + addTarget, + duplicateStep, + emptyForm, + type Form, + formFromChain, + formIssues, + insertStep, + moveStep, + payloadOf, + previewOf, + removeStep, + repeatError, + waitError, +} from "./form" + +const asDestination = Schema.decodeSync(AlertDestinationId) +const destination = (n: number) => ({ + type: "destination" as const, + destination_id: asDestination(chainDestinationUuid(n)), +}) +const keys = (form: Form) => Arr.map(form.steps, (step) => step.key) + +const loaded = (): Form => + formFromChain( + chainOf({ + n: 1, + name: "Checkout on-call", + steps: [ + { destinations: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + ], + repeat: 1, + }), + ) + +describe("escalation chain form", () => { + it("an edited chain round-trips to the payload it was read from", () => { + expect(payloadOf(loaded())).toEqual( + Option.some({ + name: "Checkout on-call", + description: null, + steps: [ + { targets: [destination(1)], wait_minutes: 10 }, + { targets: [destination(2)], wait_minutes: 15 }, + ], + repeat_count: 1, + }), + ) + }) + + it("keeps a chain's people and schedules through a load", () => { + const chain = mixedTargetChain(chainDestinationUuid(1)) + expect(Option.map(payloadOf(formFromChain(chain)), (payload) => payload.steps)).toEqual( + Option.some(chain.steps), + ) + }) + + it("a new form has no payload until it is named and its step pages someone", () => { + expect(payloadOf(emptyForm)).toEqual(Option.none()) + expect(formIssues(emptyForm)).toEqual([ + "Name the chain", + "Pick at least one destination, person or schedule", + ]) + const ready = addTarget({ ...emptyForm, name: "On-call" }, "s0", destination(1)) + expect(Option.isSome(payloadOf(ready))).toBe(true) + }) + + it("rejects waits and repeats outside the API's limits", () => { + expect(Option.isSome(waitError("0"))).toBe(true) + expect(Option.isSome(waitError("2000"))).toBe(true) + expect(Option.isSome(waitError(""))).toBe(true) + expect(waitError("10")).toEqual(Option.none()) + expect(Option.isSome(repeatError("-1"))).toBe(true) + expect(repeatError("0")).toEqual(Option.none()) + }) + + it("inserts a step where the + was pressed, each with its own key", () => { + const form = loaded() + const between = insertStep(form, 1) + expect(keys(between)).toEqual(["s0", "s2", "s1"]) + expect(keys(insertStep(between, 0))).toEqual(["s3", "s0", "s2", "s1"]) + expect(keys(insertStep(form, 2))).toEqual(["s0", "s1", "s2"]) + }) + + it("stops adding steps at the API's limit", () => { + const full = Arr.reduce(Arr.range(1, MAX_ESCALATION_STEPS + 2), emptyForm, (form) => + insertStep(form, form.steps.length), + ) + expect(full.steps.length).toBe(MAX_ESCALATION_STEPS) + expect(duplicateStep(full, "s0").steps.length).toBe(MAX_ESCALATION_STEPS) + }) + + it("moves, duplicates and removes steps, keeping at least one", () => { + const form = loaded() + expect(keys(moveStep(form, "s1", "up"))).toEqual(["s1", "s0"]) + expect(keys(moveStep(form, "s0", "up"))).toEqual(["s0", "s1"]) + const copied = duplicateStep(form, "s0") + expect(keys(copied)).toEqual(["s0", "s2", "s1"]) + expect(copied.steps[1]?.targets).toEqual(form.steps[0]?.targets) + expect(keys(removeStep(form, "s0"))).toEqual(["s1"]) + expect(keys(removeStep(removeStep(form, "s0"), "s1"))).toEqual(["s1"]) + }) + + it("adds a target to a step once", () => { + const twice = addTarget(addTarget(emptyForm, "s0", destination(1)), "s0", destination(1)) + expect(twice.steps[0]?.targets).toEqual([destination(1)]) + }) + + it("previews the chain as it reads elsewhere, skipping steps that page no one yet", () => { + const names = (id: string) => + id === chainDestinationUuid(1) ? "Slack #alerts-api" : "PagerDuty Checkout" + const form = insertStep({ ...loaded(), repeatCount: "2" }, 1) + expect(chainSummary(previewOf(form), names)).toBe( + "Slack #alerts-api, then PagerDuty Checkout after 10m; repeats twice", + ) + expect(previewOf(emptyForm).steps).toEqual([]) + }) + + it("follows only an in-app returnTo", () => { + expect(safeReturnTo("/alerts/rules/abc")).toEqual(Option.some("/alerts/rules/abc")) + expect(safeReturnTo("https://evil.example")).toEqual(Option.none()) + expect(safeReturnTo("//evil.example")).toEqual(Option.none()) + expect(safeReturnTo("/\\evil.example")).toEqual(Option.none()) + expect(safeReturnTo(undefined)).toEqual(Option.none()) + }) +}) diff --git a/apps/web/src/page/alerts/escalation-chain/form.ts b/apps/web/src/page/alerts/escalation-chain/form.ts new file mode 100644 index 0000000000..61faa3649f --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/form.ts @@ -0,0 +1,289 @@ +import { + AlertDestinationId, + EscalationChainDescription, + EscalationChainName, + EscalationRepeatCount, + EscalationWaitMinutes, + MAX_ESCALATION_REPEATS, + MAX_ESCALATION_STEPS, + MAX_ESCALATION_STEP_TARGETS, + MAX_ESCALATION_WAIT_MINUTES, + OnCallScheduleId, + UserId, +} from "@maple/domain/http" +import { + type V2EscalationChain, + type V2EscalationChainCreateParams, + type V2EscalationStep, + V2EscalationTarget, +} from "@maple/domain/http/v2" +import { Option, Schema, Array as Arr, String as Str } from "effect" +import type { ChainOutline } from "../shared/escalation-chains" + +// The escalation chain editor's form: its drafts, the API's limits as field errors, and step edits. + +/** One step as the form holds it; `key` keeps a step's card (and its menus) across reorders. */ +export const StepDraft = Schema.Struct({ + key: Schema.String, + /** Destinations, people and schedules, in the order they were added; all kept on load. */ + targets: Schema.Array(V2EscalationTarget), + waitMinutes: Schema.String, +}) +export type StepDraft = typeof StepDraft.Type + +export const Form = Schema.Struct({ + name: Schema.String, + description: Schema.String, + steps: Schema.Array(StepDraft), + repeatCount: Schema.String, + nextKey: Schema.Number, +}) +export type Form = typeof Form.Type + +export const DEFAULT_WAIT_MINUTES = "10" + +export const emptyStep = (key: number): StepDraft => ({ + key: `s${key}`, + targets: [], + waitMinutes: DEFAULT_WAIT_MINUTES, +}) + +export const emptyForm: Form = { + name: "", + description: "", + steps: [emptyStep(0)], + repeatCount: "0", + nextKey: 1, +} + +export const formFromChain = (chain: V2EscalationChain): Form => ({ + name: chain.name, + description: chain.description ?? "", + steps: Arr.map(chain.steps, (step, index) => ({ + key: `s${index}`, + targets: step.targets, + waitMinutes: String(step.wait_minutes), + })), + repeatCount: String(chain.repeat_count), + nextKey: chain.steps.length, +}) + +// VALIDATION: the same limits the API's schemas hold. + +const isName = Schema.is(EscalationChainName) +const isDescription = Schema.is(EscalationChainDescription) +const isWait = Schema.is(EscalationWaitMinutes) +const isRepeat = Schema.is(EscalationRepeatCount) + +export const numberOf = (raw: string): number | null => + Str.isEmpty(Str.trim(raw)) ? null : Number(Str.trim(raw)) + +export const nameError = (name: string): Option.Option => + Str.isEmpty(Str.trim(name)) + ? Option.some("Name the chain") + : isName(Str.trim(name)) + ? Option.none() + : Option.some("Keep the name to 120 characters") + +export const descriptionError = (description: string): Option.Option => + isDescription(Str.trim(description)) + ? Option.none() + : Option.some("Keep the description to 500 characters") + +export const waitError = (raw: string): Option.Option => + isWait(numberOf(raw)) ? Option.none() : Option.some(`Wait 1 to ${MAX_ESCALATION_WAIT_MINUTES} minutes`) + +export const repeatError = (raw: string): Option.Option => + isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) + +export const targetsError = (targets: ReadonlyArray): Option.Option => + targets.length === 0 + ? Option.some("Pick at least one destination, person or schedule") + : targets.length > MAX_ESCALATION_STEP_TARGETS + ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets`) + : Option.none() + +export const stepsError = (steps: ReadonlyArray): Option.Option => + steps.length === 0 + ? Option.some("Add at least one step") + : steps.length > MAX_ESCALATION_STEPS + ? Option.some(`A chain has at most ${MAX_ESCALATION_STEPS} steps`) + : Option.none() + +/** Every field's problem, in form order, for the action bar. */ +export const formIssues = (form: Form): ReadonlyArray => + Arr.getSomes([ + nameError(form.name), + descriptionError(form.description), + stepsError(form.steps), + ...Arr.flatMap(form.steps, (step) => [targetsError(step.targets), waitError(step.waitMinutes)]), + repeatError(form.repeatCount), + ]) + +/** A target as a menu value and a row key: `destination:`, `user:`, `schedule:`. */ +export const targetKey = (target: V2EscalationTarget): string => { + switch (target.type) { + case "destination": + return `destination:${target.destination_id}` + case "user": + return `user:${target.user_id}` + case "schedule": + return `schedule:${target.schedule_id}` + } +} + +const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) +const decodeUserId = Schema.decodeUnknownOption(UserId) +const decodeScheduleId = Schema.decodeUnknownOption(OnCallScheduleId) + +/** The target a menu value names, if it is well formed. */ +export const targetOfKey = (key: string): Option.Option => { + const [kind, ...rest] = Str.split(":")(key) + const id = Arr.join(rest, ":") + switch (kind) { + case "destination": + return Option.map(decodeDestinationId(id), (destination_id): V2EscalationTarget => ({ + type: "destination", + destination_id, + })) + case "user": + return Option.map(decodeUserId(id), (user_id): V2EscalationTarget => ({ type: "user", user_id })) + case "schedule": + return Option.map(decodeScheduleId(id), (schedule_id): V2EscalationTarget => ({ + type: "schedule", + schedule_id, + })) + default: + return Option.none() + } +} + +const stepOf = (step: StepDraft): Option.Option => { + const targets = step.targets + const minutes = numberOf(step.waitMinutes) + return Arr.isReadonlyArrayNonEmpty(targets) && + Option.isNone(targetsError(targets)) && + minutes !== null && + isWait(minutes) + ? Option.some({ targets, wait_minutes: minutes }) + : Option.none() +} + +/** The create payload the form describes, or none while any field is wrong. */ +export const payloadOf = (form: Form): Option.Option => { + const steps = Option.all(Arr.map(form.steps, stepOf)) + const repeat = numberOf(form.repeatCount) + if ( + Option.isSome(nameError(form.name)) || + Option.isSome(descriptionError(form.description)) || + Option.isSome(stepsError(form.steps)) || + repeat === null || + !isRepeat(repeat) + ) + return Option.none() + return Option.flatMap(steps, (all) => + Arr.isReadonlyArrayNonEmpty(all) + ? Option.some({ + name: Str.trim(form.name), + description: Str.isEmpty(Str.trim(form.description)) ? null : Str.trim(form.description), + steps: all, + repeat_count: repeat, + }) + : Option.none(), + ) +} + +/** The chain the form describes so far, for the live preview: only steps with targets count. */ +export const previewOf = (form: Form): ChainOutline => ({ + steps: Arr.map( + Arr.filter(form.steps, (step) => step.targets.length > 0), + (step) => ({ + targets: step.targets, + wait_minutes: Option.isNone(waitError(step.waitMinutes)) ? Number(step.waitMinutes) : 0, + }), + ), + repeat_count: Option.isNone(repeatError(form.repeatCount)) ? Number(form.repeatCount) : 0, +}) + +// STEP EDITS + +export const Direction = Schema.Literals(["up", "down"]) +export type Direction = typeof Direction.Type + +export const canAddStep = (form: Form): boolean => form.steps.length < MAX_ESCALATION_STEPS + +/** A new empty step at `index` (0 puts it first, `steps.length` last). */ +export const insertStep = (form: Form, index: number): Form => + canAddStep(form) + ? { + ...form, + steps: Arr.insertAt( + form.steps, + Math.max(0, Math.min(index, form.steps.length)), + emptyStep(form.nextKey), + ).pipe(Option.getOrElse(() => form.steps)), + nextKey: form.nextKey + 1, + } + : form + +/** A copy of the step right after it, with its own key. */ +export const duplicateStep = (form: Form, key: string): Form => + Option.match( + Arr.findFirstIndex(form.steps, (step) => step.key === key), + { + onNone: () => form, + onSome: (index) => { + const source = form.steps[index] + if (source === undefined || !canAddStep(form)) return form + const copy: StepDraft = { ...source, key: `s${form.nextKey}` } + return { + ...form, + steps: Option.getOrElse(Arr.insertAt(form.steps, index + 1, copy), () => form.steps), + nextKey: form.nextKey + 1, + } + }, + }, + ) + +/** Removes a step; the last one stays, since a chain needs at least one. */ +export const removeStep = (form: Form, key: string): Form => + form.steps.length <= 1 ? form : { ...form, steps: Arr.filter(form.steps, (step) => step.key !== key) } + +/** Swaps a step with its neighbour; at either end the steps stay as they are. */ +export const moveStep = (form: Form, key: string, direction: Direction): Form => + Option.match( + Arr.findFirstIndex(form.steps, (step) => step.key === key), + { + onNone: () => form, + onSome: (from) => { + const to = direction === "up" ? from - 1 : from + 1 + const moving = form.steps[from] + const other = form.steps[to] + if (moving === undefined || other === undefined) return form + return { + ...form, + steps: Arr.map(form.steps, (step, index) => + index === from ? other : index === to ? moving : step, + ), + } + }, + }, + ) + +export const editStep = (form: Form, key: string, edit: (step: StepDraft) => StepDraft): Form => ({ + ...form, + steps: Arr.map(form.steps, (step) => (step.key === key ? edit(step) : step)), +}) + +export const addTarget = (form: Form, key: string, target: V2EscalationTarget): Form => + editStep(form, key, (step) => + Arr.some(step.targets, (existing) => targetKey(existing) === targetKey(target)) + ? step + : { ...step, targets: [...step.targets, target] }, + ) + +export const removeTarget = (form: Form, key: string, target: string): Form => + editStep(form, key, (step) => ({ + ...step, + targets: Arr.filter(step.targets, (existing) => targetKey(existing) !== target), + })) diff --git a/apps/web/src/page/alerts/escalation-chain/index.ts b/apps/web/src/page/alerts/escalation-chain/index.ts new file mode 100644 index 0000000000..ec917920fd --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/index.ts @@ -0,0 +1,26 @@ +import { definePage } from "../../contract" +import { routingTabHref } from "../shared/escalation-chains" +import { Message } from "./message" +import { Model } from "./model" +import { subscriptions } from "./subscription" +import { init, update } from "./update" +import { chainTitle, view } from "./view" + +/** `/alerts/escalation-chains/new` and `/:chainId`: one instance per chain, or the new one. */ +export const page = definePage( + "AlertsEscalationChain", + { Model, Message }, + { + routes: ["AlertsEscalationChainNew", "AlertsEscalationChain"], + key: (route) => (route._tag === "AlertsEscalationChain" ? route.chainId : "new"), + init, + update, + view, + subscriptions, + breadcrumbs: (model) => [ + { label: "Alerts", href: "/alerts" }, + { label: "Routing", href: routingTabHref() }, + { label: chainTitle(model) }, + ], + }, +) diff --git a/apps/web/src/page/alerts/escalation-chain/message.ts b/apps/web/src/page/alerts/escalation-chain/message.ts new file mode 100644 index 0000000000..49415ab705 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/message.ts @@ -0,0 +1,45 @@ +import * as Policy from "@maple/domain/policy" +import { V2EscalationChain, V2OnCallSchedule } from "@maple/domain/http/v2" +import { AlertDestinationRowSchema, AlertRuleRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import { OrgMember } from "../shared/org-members" +import { Direction } from "./form" + +const rows = (row: S) => ({ orgId: Schema.String, rows: Schema.Array(row) }) +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const MenuKind = Schema.Literals(["targets", "actions"]) +export type MenuKind = typeof MenuKind.Type + +export const Message = defineMessageUnion({ + CompletedFetchExisting: { key: Schema.String, result: outcome(V2EscalationChain) }, + ClickedRetryExisting: {}, + UpdatedDestinations: rows(AlertDestinationRowSchema), + UpdatedRules: rows(AlertRuleRowSchema), + CompletedFetchMembers: { result: Schema.Result(Schema.Array(OrgMember), Schema.String) }, + CompletedFetchSchedules: { result: outcome(Schema.Array(V2OnCallSchedule)) }, + CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, + ChangedName: { value: Schema.String }, + ChangedDescription: { value: Schema.String }, + ChangedRepeatCount: { value: Schema.String }, + ChangedWait: { key: Schema.String, value: Schema.String }, + /** The "+" between two cards: a new step at `index`. */ + ClickedInsertStep: { index: Schema.Number }, + ClickedRemoveStep: { key: Schema.String }, + ClickedDuplicateStep: { key: Schema.String }, + ClickedMoveStep: { key: Schema.String, direction: Direction }, + /** A step's target picker or its actions menu. */ + GotMenuMessage: { kind: MenuKind, key: Schema.String, message: Menu.Message }, + ClickedRemoveTarget: { key: Schema.String, target: Schema.String }, + ClickedSave: {}, + CompletedSave: { result: outcome(V2EscalationChain) }, + GotDeleteDialogMessage: { message: Dialog.Message }, + ClickedDelete: {}, + ClickedConfirmDelete: {}, + CompletedDelete: { result: outcome(Schema.Void) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/escalation-chain/model.ts b/apps/web/src/page/alerts/escalation-chain/model.ts new file mode 100644 index 0000000000..11cb862749 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/model.ts @@ -0,0 +1,44 @@ +import * as Policy from "@maple/domain/policy" +import { EscalationChainId } from "@maple/domain/http" +import { V2EscalationChain, V2OnCallSchedule } from "@maple/domain/http/v2" +import { AlertDestinationRowSchema, AlertRuleRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import { AlertsEscalationChainSearch } from "../../../route/search" +import { OrgMember } from "../shared/org-members" +import { Form } from "./form" + +// `/alerts/escalation-chains/new` and `/alerts/escalation-chains/:chainId`: the chain editor. + +export const Model = Schema.Struct({ + search: AlertsEscalationChainSearch, + /** The chain being edited; `null` creates. */ + editingId: Schema.NullOr(EscalationChainId), + /** The URL named a chain id that does not decode. */ + isBadId: Schema.Boolean, + /** The edited chain's read; the form fills from it once. */ + existing: KeyedSchema(V2EscalationChain, LoadFailure), + form: Form, + destinations: Schema.NullOr(Schema.Array(AlertDestinationRowSchema)), + rules: Schema.NullOr(Schema.Array(AlertRuleRowSchema)), + members: Schema.NullOr(Schema.Array(OrgMember)), + schedules: Schema.NullOr(Schema.Array(V2OnCallSchedule)), + /** Each step's target picker and actions menu, by menu id. */ + menus: Schema.Record(Schema.String, Menu.Model), + isSaving: Schema.Boolean, + /** A save was tried: each field shows what is wrong with it. */ + isShowingErrors: Schema.Boolean, + saveFailure: Schema.NullOr(LoadFailure), + deleteDialog: Dialog.Model, + isDeleting: Schema.Boolean, + deleteError: Schema.NullOr(Schema.String), + grants: Schema.Array(Policy.PermissionName), + isSessionLoaded: Schema.Boolean, +}) +export type Model = typeof Model.Type + +/** The viewer may change chains (`alerts:manage`); the API refuses anyone else. */ +export const canManage = (model: Pick): boolean => Policy.has(model.grants, "alerts:manage") diff --git a/apps/web/src/page/alerts/escalation-chain/subscription.ts b/apps/web/src/page/alerts/escalation-chain/subscription.ts new file mode 100644 index 0000000000..dab0329dc8 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/subscription.ts @@ -0,0 +1,25 @@ +import { Schema } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import type { PageSubscriptionInput } from "../../contract" +import { destinationRows, ruleRows } from "../shared/collections" +import { Message } from "./message" +import type { Model } from "./model" + +const byOrg = { orgId: Schema.String } + +/** The synced destinations steps pick from, and the rules that name the chain ("Used by"). */ +export const subscriptions = Subscription.make, Message, Resources>()( + (entry) => ({ + destinations: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), + }), + rules: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, rows })), + }), + }), +) diff --git a/apps/web/src/page/alerts/escalation-chain/update.ts b/apps/web/src/page/alerts/escalation-chain/update.ts new file mode 100644 index 0000000000..89b3bd1ad2 --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/update.ts @@ -0,0 +1,274 @@ +import { Option, Result } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import * as Dialog from "@maple/views/ui/dialog" +import * as Menu from "@maple/views/ui/menu" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { + CHAIN_IN_USE_CODE, + chainHref, + chainIdFromUrl, + routingTabHref, + safeReturnTo, +} from "../shared/escalation-chains" +import { + DeleteChain, + FetchChainEditorMembers, + FetchChainEditorSchedules, + FetchChainEditorSession, + FetchExistingChain, + SaveChain, +} from "./command" +import { + addTarget, + duplicateStep, + editStep, + emptyForm, + type Form, + formFromChain, + insertStep, + moveStep, + payloadOf, + removeStep, + removeTarget, + targetOfKey, +} from "./form" +import { Message, type MenuKind } from "./message" +import type { Model } from "./model" + +type Return = PageReturn +export type ChainRoute = Extract< + AppRoute, + { readonly _tag: "AlertsEscalationChainNew" } | { readonly _tag: "AlertsEscalationChain" } +> + +export const DELETE_DIALOG_ID = "escalation-chain-delete" + +export const menuIdOf = (kind: MenuKind, key: string): string => `escalation-chain-${kind}-${key}` + +/** Where Cancel goes: the `returnTo` the link carried, else the Routing section. */ +export const backHref = (model: Pick): string => + Option.getOrElse(safeReturnTo(model.search.returnTo), routingTabHref) + +export const init = (route: ChainRoute, context: PageContext): Return => { + const rawId = route._tag === "AlertsEscalationChain" ? Option.some(route.chainId) : Option.none() + const editingId = Option.getOrNull(Option.flatMap(rawId, chainIdFromUrl)) + const model: Model = { + search: route.search, + editingId, + isBadId: Option.isSome(rawId) && editingId === null, + existing: Keyed.idle(), + form: emptyForm, + destinations: null, + rules: null, + members: null, + schedules: null, + menus: {}, + isSaving: false, + isShowingErrors: false, + saveFailure: null, + deleteDialog: Dialog.init(DELETE_DIALOG_ID, "alert-dialog"), + isDeleting: false, + deleteError: null, + grants: [], + isSessionLoaded: false, + } + const common = [ + FetchChainEditorMembers(), + FetchChainEditorSchedules(), + FetchChainEditorSession({ orgId: context.orgId }), + ] + if (editingId === null) return { model, commands: common } + const key = requestKey(context.orgId, "escalationChains.retrieve", { id: editingId }) + return Option.match(Keyed.request(model.existing, key, "Load"), { + onNone: () => ({ model, commands: common }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [...common, FetchExistingChain({ key, chainId: editingId })], + }), + }) +} + +/** A form edit is dropped while a save is in flight: the saved chain is what the page shows. */ +const withForm = (model: Model, edit: (form: Form) => Form): Return => + model.isSaving ? { model } : { model: { ...model, form: edit(model.form) } } + +const stepAction = (form: Form, key: string, value: string): Form => { + switch (value) { + case "up": + return moveStep(form, key, "up") + case "down": + return moveStep(form, key, "down") + case "duplicate": + return duplicateStep(form, key) + case "remove": + return removeStep(form, key) + default: + return form + } +} + +/** A step's menus: a target pick adds it to the step, an action edits the steps. */ +const stepMenu = (model: Model, kind: MenuKind, key: string, message: Menu.Message): Return => { + const id = menuIdOf(kind, key) + const result = Menu.update(model.menus[id] ?? Menu.init(id), message) + const next: Model = { ...model, menus: { ...model.menus, [id]: result.model } } + const commands = FoldkitCommand.mapMessages(result.commands ?? [], (child) => + Message.GotMenuMessage({ kind, key, message: child }), + ) + const picked = + result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none() + // The menus still open and close while saving, but a pick no longer edits the chain. + return Option.match(model.isSaving ? Option.none() : picked, { + onNone: () => ({ model: next, commands }), + onSome: (value) => + kind === "actions" + ? { model: { ...next, form: stepAction(next.form, key, value) }, commands } + : Option.match(targetOfKey(value), { + onNone: () => ({ model: next, commands }), + onSome: (target) => ({ + model: { ...next, form: addTarget(next.form, key, target) }, + commands, + }), + }), + }) +} + +const save = (model: Model): Return => + model.isSaving + ? { model } + : Option.match(payloadOf(model.form), { + onNone: () => ({ model: { ...model, isShowingErrors: true } }), + onSome: (payload) => ({ + model: { ...model, isSaving: true, isShowingErrors: true, saveFailure: null }, + commands: [SaveChain({ editingId: model.editingId, payload })], + }), + }) + +const liftDelete = (model: Model, step: ReturnType): Return => ({ + model: { + ...model, + deleteDialog: step.model, + deleteError: step.model.isOpen ? model.deleteError : null, + }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteDialogMessage({ message }), + ), +}) + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + CompletedFetchExisting: ({ key, result }) => { + const existing = Keyed.accept(model.existing, key, result) + // The form fills once, from the first read. + const isFirst = !AsyncData.isSuccess(model.existing.data) + return { + model: Result.match(result, { + onSuccess: (chain) => ({ + ...model, + existing, + form: isFirst ? formFromChain(chain) : model.form, + }), + onFailure: () => ({ ...model, existing }), + }), + } + }, + ClickedRetryExisting: () => { + const editingId = model.editingId + if (editingId === null) return { model } + const key = requestKey(context.orgId, "escalationChains.retrieve", { id: editingId }) + return Option.match(Keyed.request(model.existing, key, "Refresh"), { + onNone: () => ({ model }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [FetchExistingChain({ key, chainId: editingId })], + }), + }) + }, + UpdatedDestinations: ({ orgId, rows }) => + orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, + UpdatedRules: ({ orgId, rows }) => + orgId === context.orgId ? { model: { ...model, rules: rows } } : { model }, + // Without members a person reads "Member"; destinations and schedules still show. + CompletedFetchMembers: ({ result }) => + Result.match(result, { + onSuccess: (members) => ({ model: { ...model, members } }), + onFailure: () => ({ model: { ...model, members: [] } }), + }), + CompletedFetchSchedules: ({ result }) => + Result.match(result, { + onSuccess: (schedules) => ({ model: { ...model, schedules } }), + onFailure: () => ({ model: { ...model, schedules: [] } }), + }), + CompletedFetchSession: ({ orgId, grants }) => + orgId === context.orgId ? { model: { ...model, grants, isSessionLoaded: true } } : { model }, + ChangedName: ({ value }) => withForm(model, (form) => ({ ...form, name: value })), + ChangedDescription: ({ value }) => withForm(model, (form) => ({ ...form, description: value })), + ChangedRepeatCount: ({ value }) => withForm(model, (form) => ({ ...form, repeatCount: value })), + ChangedWait: ({ key, value }) => + withForm(model, (form) => editStep(form, key, (step) => ({ ...step, waitMinutes: value }))), + ClickedInsertStep: ({ index }) => withForm(model, (form) => insertStep(form, index)), + ClickedRemoveStep: ({ key }) => withForm(model, (form) => removeStep(form, key)), + ClickedDuplicateStep: ({ key }) => withForm(model, (form) => duplicateStep(form, key)), + ClickedMoveStep: ({ key, direction }) => withForm(model, (form) => moveStep(form, key, direction)), + GotMenuMessage: ({ kind, key, message: child }) => stepMenu(model, kind, key, child), + ClickedRemoveTarget: ({ key, target }) => withForm(model, (form) => removeTarget(form, key, target)), + ClickedSave: () => save(model), + CompletedSave: ({ result }) => + Result.match(result, { + onSuccess: (chain): Return => ({ + model: { ...model, isSaving: false }, + outMessage: PageOutMessage.RequestedNavigation({ + url: Option.getOrElse(safeReturnTo(model.search.returnTo), () => chainHref(chain.id)), + isReplace: true, + toast: { + type: "success", + title: + model.editingId === null + ? "Escalation chain created" + : "Escalation chain saved", + description: "", + }, + }), + }), + onFailure: (failure): Return => ({ + model: { ...model, isSaving: false, saveFailure: failure }, + }), + }), + GotDeleteDialogMessage: ({ message: child }) => + model.isDeleting ? { model } : liftDelete(model, Dialog.update(model.deleteDialog, child)), + ClickedDelete: () => + model.editingId === null + ? { model } + : liftDelete({ ...model, deleteError: null }, Dialog.open(model.deleteDialog)), + ClickedConfirmDelete: () => + model.editingId === null || model.isDeleting + ? { model } + : { + model: { ...model, isDeleting: true, deleteError: null }, + commands: [DeleteChain({ chainId: model.editingId })], + }, + CompletedDelete: ({ result }) => + Result.match(result, { + onSuccess: (): Return => ({ + model: { ...model, isDeleting: false }, + outMessage: PageOutMessage.RequestedNavigation({ + url: routingTabHref(), + isReplace: true, + toast: { type: "success", title: "Escalation chain deleted", description: "" }, + }), + }), + onFailure: (failure): Return => ({ + model: { + ...model, + isDeleting: false, + deleteError: + failure.code === CHAIN_IN_USE_CODE + ? "An alert rule still starts this chain. Remove it from the rule's severity tiers first." + : failure.message, + }, + }), + }), + }) diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts new file mode 100644 index 0000000000..f9099298be --- /dev/null +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -0,0 +1,636 @@ +import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" +import type { AlertDestinationId } from "@maple/domain/http" +import type { V2EscalationTarget } from "@maple/domain/http/v2" +import { pluralize } from "@maple/ui/lib/format" +import { Option, Array as Arr, String as Str } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "@maple/views/data/load-failure" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { button, linkButton } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { separator } from "@maple/views/ui/display" +import { errorState } from "@maple/views/ui/error-state" +import { resourceNotFound } from "../../kit/resource-not-found" +import { fieldErrorText, type FieldIds, fieldLabel, input } from "@maple/views/ui/field" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import type { FieldState } from "@maple/views/ui/input" +import * as Menu from "@maple/views/ui/menu" +import type { MenuEntry } from "@maple/views/ui/menu-view" +import { panel } from "@maple/views/ui/panel" +import { personAvatar } from "@maple/views/ui/person-avatar" +import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { textarea } from "@maple/views/ui/textarea" +import { typography } from "@maple/views/ui/typography" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { managersOnlyPage } from "../../kit/form-section" +import { type PickedEntry, pickList } from "../../kit/pick-list" +import { chainSummary } from "../shared/escalation-chains" +import { ruleHref } from "../shared/links" +import { memberName, memberOf } from "../shared/org-members" +import { + canAddStep, + descriptionError, + formIssues, + nameError, + numberOf, + previewOf, + repeatError, + type StepDraft, + stepsError, + targetKey, + targetsError, + waitError, +} from "./form" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { backHref, DELETE_DIALOG_ID, menuIdOf } from "./update" + +// The escalation chain editor: the chain as a column of sentence cards, its name and preview beside it. + +type H = HtmlBuilder + +/** The page's name: the chain as last read, or "New escalation chain". */ +export const chainTitle = (model: Pick): string => + model.editingId === null + ? "New escalation chain" + : Option.getOrElse( + Option.map(AsyncData.getData(model.existing.data), (chain) => chain.name), + () => "Escalation chain", + ) + +const nameOr = ( + rows: ReadonlyArray | null, + id: string, + fallback: string, +): string => + Option.getOrElse( + Option.map( + Arr.findFirst(rows ?? [], (row) => row.id === id), + (row) => row.name, + ), + () => (rows === null ? fallback : `Unavailable ${Str.toLowerCase(fallback)}`), + ) + +/** A field's error once a save was tried, or at once for a number typed out of range. */ +const shown = (model: Model, error: Option.Option, isImmediate = false): Option.Option => + model.isShowingErrors || isImmediate ? error : Option.none() + +const errorText = (h: H, error: Option.Option): ReadonlyArray => + Option.match(error, { + onNone: () => [], + onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], + }) + +const isOutOfRange = (raw: string, error: Option.Option) => raw !== "" && Option.isSome(error) + +const idsOf = (id: string): FieldIds => ({ control: id, label: `${id}-label`, error: `${id}-error` }) + +const stateOf = (value: string, isInvalid: boolean): FieldState => ({ + isInvalid, + isDirty: false, + isFilled: value !== "", + isFocused: false, + isTouched: false, +}) + +// STEP CARDS + +/** A picked target's row: a destination's bell, a member's avatar, a schedule's calendar. */ +const targetEntry = (h: H, model: Model, key: string, target: V2EscalationTarget): PickedEntry => { + const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) + switch (target.type) { + case "destination": { + const name = nameOr(model.destinations, target.destination_id, "Destination") + return { + key: targetKey(target), + label: name, + media: icon(h, "BellIcon", { className: "text-muted-foreground" }), + removeLabel: `Stop notifying ${name}`, + onRemove, + } + } + case "user": { + const name = memberName(model.members, target.user_id) + return { + key: targetKey(target), + label: name, + media: personAvatar(h, { + name, + size: "xs", + imageUrl: Option.getOrUndefined( + Option.map(memberOf(model.members, target.user_id), (member) => member.imageUrl), + ), + }), + detail: "email and chat DM", + removeLabel: `Stop paging ${name}`, + onRemove, + } + } + case "schedule": { + const name = nameOr(model.schedules, target.schedule_id, "Schedule") + return { + key: targetKey(target), + label: name, + media: icon(h, "CalendarIcon", { className: "text-muted-foreground" }), + detail: "whoever is on call", + removeLabel: `Stop paging ${name}`, + onRemove, + } + } + } +} + +/** The target picker: destinations, people and schedules not yet on the step, by kind. */ +const addEntries = (model: Model, step: StepDraft): ReadonlyArray => { + const picked = new Set(Arr.map(step.targets, targetKey)) + const group = ( + groupLabel: string, + items: ReadonlyArray>, + ): ReadonlyArray => { + const open = Arr.filter(items, (item) => !picked.has(item.value)) + return open.length === 0 + ? [] + : [ + { + _tag: "Group", + label: groupLabel, + entries: Arr.map(open, (item): MenuEntry => ({ _tag: "Item", ...item })), + }, + ] + } + return [ + ...group( + "Destinations", + Arr.map(model.destinations ?? [], (row) => ({ value: `destination:${row.id}`, label: row.name })), + ), + ...group( + "People", + Arr.map(model.members ?? [], (member) => ({ + value: `user:${member.userId}`, + label: member.name, + })), + ), + ...group( + "Whoever is on call on", + Arr.map(model.schedules ?? [], (schedule) => ({ + value: `schedule:${schedule.id}`, + label: schedule.name, + })), + ), + ] +} + +const stepActions = (model: Model, index: number): ReadonlyArray => { + const count = model.form.steps.length + return [ + { _tag: "Item", value: "up", label: "Move up", icon: "ArrowUpIcon", isDisabled: index === 0 }, + { + _tag: "Item", + value: "down", + label: "Move down", + icon: "ArrowDownIcon", + isDisabled: index === count - 1, + }, + { + _tag: "Item", + value: "duplicate", + label: "Duplicate", + icon: "CopyIcon", + isDisabled: !canAddStep(model.form), + }, + { _tag: "Separator" }, + { + _tag: "Item", + value: "remove", + label: "Remove", + icon: "TrashIcon", + variant: "destructive", + isDisabled: count === 1, + }, + ] +} + +/** What the wait after a step is for: the next step, a repeat, or nothing once the chain stops. */ +const waitTail = (model: Model, index: number): string => + index < model.form.steps.length - 1 + ? "for an acknowledgement" + : (numberOf(model.form.repeatCount) ?? 0) > 0 + ? "for an acknowledgement, then repeat" + : "for an acknowledgement" + +const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { + const key = step.key + const wait = waitError(step.waitMinutes) + const targetsMenuId = menuIdOf("targets", key) + const actionsMenuId = menuIdOf("actions", key) + const waitId = `escalation-chain-wait-${key}` + const waitState = stateOf( + step.waitMinutes, + Option.isSome(shown(model, wait, isOutOfRange(step.waitMinutes, wait))), + ) + return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ + hstack(h, { gap: "2", justify: "between" }, [ + typography(h, { size: "sm", weight: "medium" }, [index === 0 ? "Notify" : "Then notify"]), + rowActionsMenu(h, { + slotId: actionsMenuId, + model: model.menus[actionsMenuId] ?? Menu.init(actionsMenuId), + entries: stepActions(model, index), + label: `Actions for step ${index + 1}`, + toParentMessage: (message) => Message.GotMenuMessage({ kind: "actions", key, message }), + }), + ]), + pickList(h, { + entries: Arr.map(step.targets, (target) => targetEntry(h, model, key, target)), + menu: model.menus[targetsMenuId] ?? Menu.init(targetsMenuId), + toMenuMessage: (message) => Message.GotMenuMessage({ kind: "targets", key, message }), + addEntries: addEntries(model, step), + addLabel: "Add destination, person or schedule", + isDisabled: model.isSaving, + context: `step ${index + 1}`, + emptyText: "Pick who this step pages", + }), + ...errorText(h, shown(model, targetsError(step.targets))), + hstack(h, { gap: "2", wrap: true }, [ + fieldLabel(h, { ids: idsOf(waitId), state: waitState, className: "font-normal" }, "then wait"), + input(h, { + ids: idsOf(waitId), + state: waitState, + className: "w-20", + isDisabled: model.isSaving, + attributes: [ + h.Type("number"), + h.Min("1"), + h.Attribute("max", String(MAX_ESCALATION_WAIT_MINUTES)), + h.Attribute("step", "1"), + h.AriaLabel(`Step ${index + 1} wait, in minutes`), + h.Value(step.waitMinutes), + h.OnInput((value) => Message.ChangedWait({ key, value })), + ], + }), + typography(h, { size: "sm" }, [`minutes ${waitTail(model, index)}`]), + ]), + ...errorText(h, shown(model, wait, isOutOfRange(step.waitMinutes, wait))), + ]) +} + +/** The line between two cards, with a "+" that adds a step there. */ +const connector = (h: H, model: Model, index: number): Html => + vstack(h, { align: "center", attributes: [h.Key(`connector-${index}`)] }, [ + separator(h, "vertical", "h-3"), + iconButton( + h, + { + label: index === 0 ? "Add a step at the start" : `Add a step after step ${index}`, + variant: "outline", + size: "icon-xs", + isDisabled: model.isSaving || !canAddStep(model.form), + attributes: [h.OnClick(Message.ClickedInsertStep({ index }))], + }, + [icon(h, "PlusIcon", { size: 12 })], + ), + separator(h, "vertical", "h-3"), + ]) + +const startCard = (h: H): Html => + panel(h, { padded: "sm", tone: "muted" }, [ + hstack(h, { gap: "2" }, [ + icon(h, "BellIcon", { className: "text-muted-foreground" }), + typography(h, { size: "sm" }, ["When an alert escalates to this chain"]), + ]), + ]) + +const endCard = (h: H, model: Model): Html => { + const repeat = repeatError(model.form.repeatCount) + const count = numberOf(model.form.repeatCount) ?? 0 + const id = "escalation-chain-repeat" + const state = stateOf( + model.form.repeatCount, + Option.isSome(shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat))), + ) + return panel(h, { padded: "sm", tone: "muted", className: "gap-2" }, [ + hstack(h, { gap: "2", wrap: true }, [ + icon(h, "ArrowPathIcon", { className: "text-muted-foreground" }), + fieldLabel( + h, + { ids: idsOf(id), state, className: "font-normal" }, + "If nobody acknowledges, repeat the chain", + ), + input(h, { + ids: idsOf(id), + state, + className: "w-20", + isDisabled: model.isSaving, + attributes: [ + h.Type("number"), + h.Min("0"), + h.Attribute("max", String(MAX_ESCALATION_REPEATS)), + h.Attribute("step", "1"), + h.Value(model.form.repeatCount), + h.OnInput((value) => Message.ChangedRepeatCount({ value })), + ], + }), + typography(h, { size: "sm" }, [count === 0 ? "times, then stop" : pluralize(count, "time")]), + ]), + ...errorText(h, shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat))), + ]) +} + +const chainColumn = (h: H, model: Model): Html => + vstack(h, { className: "min-w-0" }, [ + startCard(h), + ...Arr.flatMap(model.form.steps, (step, index) => [ + connector(h, model, index), + stepCard(h, model, step, index), + ]), + connector(h, model, model.form.steps.length), + endCard(h, model), + ...errorText(h, shown(model, stepsError(model.form.steps))), + ]) + +// ASIDE + +const destinationName = + (model: Model) => + (id: AlertDestinationId): string => + nameOr(model.destinations, id, "Destination") + +/** The chain in one line, as it reads on the Routing section and in the rule editor. */ +const preview = (h: H, model: Model): Html => { + const chain = previewOf(model.form) + const summary = + chain.steps.length === 0 + ? "Pick who the first step pages to see the chain in one line." + : chainSummary(chain, destinationName(model), { + userName: (id) => memberName(model.members, id), + scheduleName: (id) => + model.schedules === null ? undefined : nameOr(model.schedules, id, "Schedule"), + }) + return vstack(h, { gap: "1.5" }, [ + typography(h, { size: "xs", tone: "muted", weight: "medium" }, ["Preview"]), + typography(h, { size: "sm", as: "p", className: "break-words" }, [summary]), + ]) +} + +/** The rules whose tiers start this chain, as links; ones not synced yet are counted. */ +const usedBy = (h: H, model: Model): ReadonlyArray => + Option.match(AsyncData.getData(model.existing.data), { + onNone: () => [], + onSome: (chain) => { + const ruleIds = chain.rule_ids + const rules = Arr.filter(model.rules ?? [], (rule) => Arr.some(ruleIds, (id) => id === rule.id)) + const others = ruleIds.length - rules.length + return [ + vstack(h, { gap: "1.5" }, [ + typography(h, { size: "xs", tone: "muted", weight: "medium" }, [ + ruleIds.length === 0 + ? "Not used by any rule" + : `Used by ${ruleIds.length} ${pluralize(ruleIds.length, "rule")}`, + ]), + ...(ruleIds.length === 0 + ? [] + : [ + h.ul( + [h.Class("space-y-1")], + [ + ...Arr.map(rules, (rule) => + h.li( + [h.Key(rule.id)], + [ + typography(h, { size: "sm" }, [ + textLink(h, { href: ruleHref(rule.id) }, [rule.name]), + ]), + ], + ), + ), + ...(others > 0 + ? [ + h.li( + [], + [ + typography(h, { size: "sm", tone: "muted" }, [ + `and ${others} more`, + ]), + ], + ), + ] + : []), + ], + ), + ]), + ]), + ] + }, + }) + +const aside = (h: H, model: Model): Html => { + const name = shown(model, nameError(model.form.name)) + const description = shown(model, descriptionError(model.form.description)) + const nameState = stateOf(model.form.name, Option.isSome(name)) + const notesIds = idsOf("escalation-chain-description") + return h.aside( + [h.Class("order-first min-w-0 lg:order-none")], + [ + panel(h, { padded: "sm", className: "gap-4 lg:sticky lg:top-4" }, [ + vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids: idsOf("escalation-chain-name"), state: nameState }, "Name"), + input(h, { + ids: idsOf("escalation-chain-name"), + state: nameState, + isDisabled: model.isSaving, + attributes: [ + h.Value(model.form.name), + h.Placeholder("Checkout on-call"), + h.Attribute("autocomplete", "off"), + h.OnInput((value) => Message.ChangedName({ value })), + ], + }), + ...errorText(h, name), + ]), + vstack(h, { gap: "1.5" }, [ + fieldLabel( + h, + { ids: notesIds, state: stateOf(model.form.description, Option.isSome(description)) }, + "Notes", + ), + textarea(h, { + id: notesIds.control, + defaultValue: model.form.description, + isDisabled: model.isSaving, + attributes: [ + // Uncontrolled: keyed on the chain, so a loaded chain rebuilds it with its text. + h.Key(`escalation-chain-description-${model.editingId ?? "new"}`), + h.AriaLabelledBy(notesIds.label), + h.Placeholder("Optional: who is on this chain and when to change it"), + h.Rows(3), + h.OnInput((value) => Message.ChangedDescription({ value })), + ], + }), + ...errorText(h, description), + ]), + preview(h, model), + ...usedBy(h, model), + ...(model.editingId === null + ? [] + : [ + button( + h, + { + variant: "outline", + size: "sm", + className: "self-start", + isDisabled: model.isSaving || model.isDeleting, + attributes: [h.OnClick(Message.ClickedDelete())], + }, + [icon(h, "TrashIcon"), "Delete chain"], + ), + ]), + ]), + ], + ) +} + +/** "Delete {chain}?": lists the rules that start it, which the API makes move off it first. */ +const deleteDialog = (h: H, model: Model): Html => { + const chain = AsyncData.getData(model.existing.data) + const ruleIds: ReadonlyArray = Option.match(chain, { + onNone: () => [], + onSome: (found) => found.rule_ids, + }) + const names = Arr.map( + Arr.filter(model.rules ?? [], (rule) => Arr.some(ruleIds, (id) => id === rule.id)), + (rule) => rule.name, + ) + const isInUse = ruleIds.length > 0 + return confirmDialog(h, { + slotId: DELETE_DIALOG_ID, + model: model.deleteDialog, + toParentMessage: (message) => Message.GotDeleteDialogMessage({ message }), + title: `Delete ${chainTitle(model)}?`, + description: isInUse + ? "These rules would stop escalating. Remove the chain from their severity tiers first." + : "Escalations already running finish on their own copy. This cannot be undone.", + children: [ + ...(isInUse + ? [ + h.ul( + [h.Class("list-disc space-y-1 pl-5")], + [ + ...Arr.map(names, (ruleName) => + h.li([h.Key(ruleName)], [typography(h, { size: "sm" }, [ruleName])]), + ), + ...(ruleIds.length > names.length + ? [ + h.li( + [], + [ + typography(h, { size: "sm", tone: "muted" }, [ + `and ${ruleIds.length - names.length} more`, + ]), + ], + ), + ] + : []), + ], + ), + ] + : []), + ...(model.deleteError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't delete chain"]), + alertDescription(h, [model.deleteError]), + ]), + ]), + ], + confirmLabel: "Delete chain", + isPending: model.isDeleting, + isConfirmDisabled: isInUse, + onConfirm: Message.ClickedConfirmDelete(), + }) +} + +// PAGE + +const actions = (h: H, model: Model): Html => + hstack(h, { gap: "2" }, [ + linkButton(h, { variant: "outline", href: backHref(model) }, ["Cancel"]), + button( + h, + { + isDisabled: !canManage(model), + isLoading: model.isSaving, + attributes: [h.OnClick(Message.ClickedSave())], + }, + [model.editingId === null ? "Create chain" : "Save chain"], + ), + ]) + +const saveNotice = (h: H, model: Model): ReadonlyArray => [ + ...(model.saveFailure === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, [ + model.editingId === null ? "Couldn't create chain" : "Couldn't save chain", + ]), + alertDescription(h, [failureMessage(model.saveFailure)]), + ]), + ]), + ...(model.isShowingErrors && formIssues(model.form).length > 0 + ? [ + typography(h, { size: "sm", tone: "destructive", as: "p", attributes: [h.Role("status")] }, [ + "Fix the highlighted fields to save the chain.", + ]), + ] + : []), +] + +export const view = Submodel.defineView((model, _inputs, h) => { + const isEdit = model.editingId !== null + const header = { + title: chainTitle(model), + description: + "Page step by step until someone acknowledges. Acknowledging pauses it, resolving ends it.", + } + if (model.isSessionLoaded && !canManage(model)) + return managersOnlyPage(h, { + header, + title: "Only org admins and engineers can create or change escalation chains", + back: { href: backHref(model), label: "Back to routing" }, + }) + if (model.isBadId) + return dashboardPage(h, header, [ + resourceNotFound(h, { + title: "Escalation chain not found", + description: "This link does not name an escalation chain.", + back: { href: backHref(model), label: "Back to routing" }, + }), + ]) + if (isEdit && AsyncData.isFailure(model.existing.data)) + return dashboardPage(h, header, [ + errorState(h, { + failure: model.existing.data.error, + title: "Couldn't load the chain", + onRetry: Message.ClickedRetryExisting(), + }), + ]) + if (isEdit && !AsyncData.isSuccess(model.existing.data)) + return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + return dashboardPage(h, { ...header, actions: actions(h, model) }, [ + ...saveNotice(h, model), + h.div( + [h.Class("grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start")], + [chainColumn(h, model), aside(h, model)], + ), + deleteDialog(h, model), + ]) +}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts deleted file mode 100644 index 47c7a2b37e..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.scene.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" -import { expect as expectView, given, Mount, role, scene, withViewInputs } from "foldkit/scene" -import { describe, test } from "vitest" -import * as Dialog from "@maple/views/ui/dialog" -import { MountDialog } from "@maple/views/ui/dialog" -import { chainDestinations, chainOf } from "../shared/escalation-test-fixtures" -import { init, Message, openEdit, update } from "./dialog" -import { view as dialogView } from "./view" - -const view = withViewInputs(dialogView, { - id: "chain", - destinations: chainDestinations, - members: [], - schedules: [], -})() -const editing = () => - openEdit( - init("chain"), - chainOf({ - n: 1, - name: "Checkout on-call", - steps: [ - { destinations: [1], wait: 10 }, - { destinations: [2], wait: 15 }, - ], - repeat: 1, - }), - ).model - -describe("escalation chain dialog view", () => { - test("while a save is in flight the fields and step controls are disabled", () => { - scene( - { update, view }, - given(update(editing(), Message.ClickedSave()).model), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), - expectView(role("textbox", { name: "Name" })).toBeDisabled(), - expectView(role("textbox", { name: "Description" })).toBeDisabled(), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).toBeDisabled(), - expectView(role("button", { name: "Move step 1 down" })).toBeDisabled(), - expectView(role("button", { name: "Remove step 1" })).toBeDisabled(), - expectView(role("button", { name: "Add step" })).toBeDisabled(), - expectView(role("button", { name: /^Stop notifying/ })).toBeDisabled(), - ) - }) - - test("before a save the same controls are editable", () => { - scene( - { update, view }, - given(editing()), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), - expectView(role("textbox", { name: "Name" })).not.toBeDisabled(), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).not.toBeDisabled(), - expectView(role("button", { name: "Add step" })).not.toBeDisabled(), - expectView(role("button", { name: /^Stop notifying .+ in step 1$/u })).toExist(), - ) - }) - - test("the wait and repeat inputs take their range and copy from the API's limits", () => { - scene( - { update, view }, - given(editing()), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAttr( - "max", - String(MAX_ESCALATION_REPEATS), - ), - expectView(role("spinbutton", { name: "Repeat the whole chain" })).toHaveAccessibleDescription( - `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, - ), - expectView(role("spinbutton", { name: /^Step 1: then wait/ })).toHaveAttr( - "max", - String(MAX_ESCALATION_WAIT_MINUTES), - ), - ) - }) -}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts deleted file mode 100644 index 61ad9a0595..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Option, Array as Arr, Schema } from "effect" -import { AlertDestinationId } from "@maple/domain/http" -import * as Menu from "@maple/views/ui/menu" -import { describe, expect, it } from "vitest" -import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" -import { memberId, mixedTargetChain, scheduleUuid } from "../shared/oncall-test-fixtures" -import { init, Message, type Model, openEdit, payloadOf, update } from "./dialog" - -const asDestination = Schema.decodeSync(AlertDestinationId) -const run = (model: Model, messages: ReadonlyArray) => - Arr.reduce(messages, model, (current, message) => update(current, message).model) -const opened = (): Model => - openEdit( - init("chain"), - chainOf({ - n: 1, - name: "Checkout on-call", - steps: [ - { destinations: [1], wait: 10 }, - { destinations: [2], wait: 15 }, - ], - repeat: 1, - }), - ).model - -describe("escalation chain dialog", () => { - it("an edited chain round-trips to the payload it was read from", () => { - expect(payloadOf(opened())).toEqual( - Option.some({ - name: "Checkout on-call", - description: null, - steps: [ - { - targets: [{ type: "destination", destination_id: chainDestinationUuid(1) }], - wait_minutes: 10, - }, - { - targets: [{ type: "destination", destination_id: chainDestinationUuid(2) }], - wait_minutes: 15, - }, - ], - repeat_count: 1, - }), - ) - }) - - it("a chain built through the API keeps its people and schedules through an edit", () => { - const chain = mixedTargetChain(chainDestinationUuid(1)) - const model = openEdit(init("chain"), chain).model - expect(Option.map(payloadOf(model), (payload) => payload.steps)).toEqual(Option.some(chain.steps)) - const key = model.steps[1]?.key ?? "" - const removed = run(model, [ - Message.ClickedRemoveTarget({ key, target: `schedule:${scheduleUuid(1)}` }), - ]) - expect(Option.map(payloadOf(removed), (payload) => payload.steps[1]?.targets)).toEqual( - Option.some([{ type: "user", user_id: memberId(1) }]), - ) - }) - - it("adds a person or a schedule from the step's menu, once each", () => { - const model = opened() - const key = model.steps[0]?.key ?? "" - const pick = (value: string) => - Message.GotStepMenuMessage({ - key, - message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value } }), - }) - const added = run(model, [ - pick(`user:${memberId(3)}`), - pick(`schedule:${scheduleUuid(2)}`), - pick(`user:${memberId(3)}`), - ]) - expect(added.steps[0]?.targets).toEqual([ - { type: "destination", destination_id: chainDestinationUuid(1) }, - { type: "user", user_id: memberId(3) }, - { type: "schedule", schedule_id: scheduleUuid(2) }, - ]) - }) - - it("holds the API's limits: a name, a destination per step, a 1 to 1440 minute wait, 0 to 5 repeats", () => { - const model = opened() - const key = model.steps[0]?.key ?? "" - const broken = (messages: ReadonlyArray) => Option.isNone(payloadOf(run(model, messages))) - expect(broken([Message.ChangedName({ value: " " })])).toBe(true) - expect(broken([Message.ChangedWait({ key, value: "0" })])).toBe(true) - expect(broken([Message.ChangedWait({ key, value: "1441" })])).toBe(true) - expect(broken([Message.ChangedWait({ key, value: "2.5" })])).toBe(true) - expect(broken([Message.ChangedRepeatCount({ value: "6" })])).toBe(true) - expect( - broken([ - Message.ClickedRemoveTarget({ - key, - target: `destination:${asDestination(chainDestinationUuid(1))}`, - }), - ]), - ).toBe(true) - expect( - broken([Message.ChangedWait({ key, value: "1440" }), Message.ChangedRepeatCount({ value: "5" })]), - ).toBe(false) - }) - - it("a save with errors sends nothing and shows them; a valid one sends once", () => { - const model = run(opened(), [Message.ChangedName({ value: "" })]) - const refused = update(model, Message.ClickedSave()) - expect(refused.commands ?? []).toEqual([]) - expect(refused.model.isShowingErrors).toBe(true) - const saving = update(opened(), Message.ClickedSave()) - expect(Arr.map(saving.commands ?? [], ({ name }) => name)).toEqual(["SaveEscalationChain"]) - expect(update(saving.model, Message.ClickedSave()).commands ?? []).toEqual([]) - }) - - it("edits sent while a save is in flight leave the form as it was sent", () => { - const saving = update(opened(), Message.ClickedSave()).model - const key = saving.steps[0]?.key ?? "" - const edited = run(saving, [ - Message.ChangedName({ value: "Renamed mid-save" }), - Message.ChangedDescription({ value: "typed while saving" }), - Message.ChangedWait({ key, value: "30" }), - Message.ChangedRepeatCount({ value: "3" }), - Message.ClickedAddStep(), - Message.ClickedRemoveStep({ key }), - Message.ClickedMoveStep({ key, direction: "down" }), - Message.ClickedRemoveTarget({ - key, - target: `destination:${asDestination(chainDestinationUuid(1))}`, - }), - ]) - expect(edited).toEqual(saving) - }) - - it("steps reorder up and down, add up to ten, and the last one cannot move further", () => { - const model = opened() - const [first, second] = model.steps - const moved = run(model, [Message.ClickedMoveStep({ key: first?.key ?? "", direction: "down" })]) - expect(Arr.map(moved.steps, (step) => step.key)).toEqual([second?.key, first?.key]) - expect( - run(moved, [Message.ClickedMoveStep({ key: first?.key ?? "", direction: "down" })]).steps, - ).toEqual(moved.steps) - const full = run( - model, - Arr.makeBy(12, () => Message.ClickedAddStep()), - ) - expect(full.steps).toHaveLength(10) - }) - - it("an edit sends a step's member and schedule targets back, and a schedule alone is enough", () => { - const chain = chainOf({ - n: 2, - name: "Checkout on-call", - steps: [ - { destinations: [1], users: ["user_ben"], wait: 10 }, - { destinations: [], schedules: [1], wait: 15 }, - ], - }) - const renamed = run(openEdit(init("chain"), chain).model, [ - Message.ChangedName({ value: "Checkout" }), - ]) - const saving = update(renamed, Message.ClickedSave()) - expect(saving.commands?.[0]?.args).toMatchObject({ - payload: { - name: "Checkout", - steps: Arr.map(chain.steps, (step) => ({ targets: step.targets })), - }, - }) - const key = renamed.steps[0]?.key ?? "" - const withoutDestination = run(renamed, [ - Message.ClickedRemoveTarget({ - key, - target: `destination:${asDestination(chainDestinationUuid(1))}`, - }), - ]) - expect(Option.map(payloadOf(withoutDestination), ({ steps }) => steps[0].targets)).toEqual( - Option.some([{ type: "user", user_id: "user_ben" }]), - ) - }) -}) diff --git a/apps/web/src/page/alerts/escalation-dialog/dialog.ts b/apps/web/src/page/alerts/escalation-dialog/dialog.ts deleted file mode 100644 index c89f0ce055..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/dialog.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { - AlertDestinationId, - EscalationChainDescription, - EscalationChainId, - EscalationChainName, - EscalationRepeatCount, - EscalationWaitMinutes, - MAX_ESCALATION_REPEATS, - MAX_ESCALATION_STEPS, - MAX_ESCALATION_STEP_TARGETS, - MAX_ESCALATION_WAIT_MINUTES, - OnCallScheduleId, - UserId, -} from "@maple/domain/http" -import { - V2EscalationChain, - V2EscalationChainCreateParams, - type V2EscalationStep, - V2EscalationTarget, -} from "@maple/domain/http/v2" -import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" -import { Command, type Update } from "foldkit" -import { defineMessageUnion } from "foldkit/message" -import { LoadFailure } from "@maple/views/data/load-failure" -import * as Dialog from "@maple/views/ui/dialog" -import * as Menu from "@maple/views/ui/menu" -import type { Resources } from "../../../platform/resources" -import { saveEscalationChain } from "../shared/escalation-chains" - -// "New escalation chain" / "Edit escalation chain": the Routing tab's dialog, as one Submodel. - -/** One step as the form holds it; `key` keeps a step's row (and its menu) across reorders. */ -export const StepDraft = Schema.Struct({ - key: Schema.String, - /** Destinations, people and schedules, in the order they were added; all kept on load. */ - targets: Schema.Array(V2EscalationTarget), - waitMinutes: Schema.String, -}) -export type StepDraft = typeof StepDraft.Type - -export const Model = Schema.Struct({ - dialog: Dialog.Model, - editingId: Schema.NullOr(EscalationChainId), - name: Schema.String, - description: Schema.String, - steps: Schema.Array(StepDraft), - repeatCount: Schema.String, - nextKey: Schema.Number, - /** Each step's "Add target" menu, by step key. */ - menus: Schema.Record(Schema.String, Menu.Model), - isSaving: Schema.Boolean, - /** A save was tried: every field shows what is wrong with it. */ - isShowingErrors: Schema.Boolean, - saveError: Schema.NullOr(Schema.String), - /** Bumped on every open: a save's result only settles the form it was sent from. */ - openCount: Schema.Number, -}) -export type Model = typeof Model.Type - -export const DEFAULT_WAIT_MINUTES = "10" - -const emptyStep = (key: number): StepDraft => ({ - key: `s${key}`, - targets: [], - waitMinutes: DEFAULT_WAIT_MINUTES, -}) - -export const init = (id: string): Model => ({ - dialog: Dialog.init(id, "dialog"), - editingId: null, - name: "", - description: "", - steps: [emptyStep(0)], - repeatCount: "0", - nextKey: 1, - menus: {}, - isSaving: false, - isShowingErrors: false, - saveError: null, - openCount: 0, -}) - -export const Direction = Schema.Literals(["up", "down"]) - -export const Message = defineMessageUnion({ - GotDialogMessage: { message: Dialog.Message }, - ChangedName: { value: Schema.String }, - ChangedDescription: { value: Schema.String }, - ChangedRepeatCount: { value: Schema.String }, - ChangedWait: { key: Schema.String, value: Schema.String }, - ClickedAddStep: {}, - ClickedRemoveStep: { key: Schema.String }, - ClickedMoveStep: { key: Schema.String, direction: Direction }, - GotStepMenuMessage: { key: Schema.String, message: Menu.Message }, - ClickedRemoveTarget: { key: Schema.String, target: Schema.String }, - ClickedSave: {}, - CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2EscalationChain, LoadFailure) }, -}) -export type Message = typeof Message.Type - -export const OutMessage = defineMessageUnion({ - SavedChain: { chain: V2EscalationChain, isNew: Schema.Boolean }, -}) -export type OutMessage = typeof OutMessage.Type - -type Return = Update.ReturnWithOutMessage - -export const menuIdOf = (model: Pick, key: string): string => - `${model.dialog.id}-step-${key}` - -// VALIDATION: the same limits the API's schemas hold. - -const isName = Schema.is(EscalationChainName) -const isDescription = Schema.is(EscalationChainDescription) -const isWait = Schema.is(EscalationWaitMinutes) -const isRepeat = Schema.is(EscalationRepeatCount) - -export const numberOf = (raw: string): number | null => - Str.isEmpty(Str.trim(raw)) ? null : Number(Str.trim(raw)) - -export const nameError = (name: string): Option.Option => - Str.isEmpty(Str.trim(name)) - ? Option.some("Name the chain") - : isName(Str.trim(name)) - ? Option.none() - : Option.some("Keep the name to 120 characters") - -export const descriptionError = (description: string): Option.Option => - isDescription(Str.trim(description)) - ? Option.none() - : Option.some("Keep the description to 500 characters") - -export const waitError = (raw: string): Option.Option => - isWait(numberOf(raw)) ? Option.none() : Option.some(`Wait 1 to ${MAX_ESCALATION_WAIT_MINUTES} minutes`) - -export const repeatError = (raw: string): Option.Option => - isRepeat(numberOf(raw)) ? Option.none() : Option.some(`Repeat 0 to ${MAX_ESCALATION_REPEATS} times`) - -export const targetsError = (targets: ReadonlyArray): Option.Option => - targets.length === 0 - ? Option.some("Pick at least one destination, person or schedule") - : targets.length > MAX_ESCALATION_STEP_TARGETS - ? Option.some(`A step notifies at most ${MAX_ESCALATION_STEP_TARGETS} targets`) - : Option.none() - -/** A target as a menu value and a row key: `destination:`, `user:`, `schedule:`. */ -export const targetKey = (target: V2EscalationTarget): string => { - switch (target.type) { - case "destination": - return `destination:${target.destination_id}` - case "user": - return `user:${target.user_id}` - case "schedule": - return `schedule:${target.schedule_id}` - } -} - -const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) -const decodeUserId = Schema.decodeUnknownOption(UserId) -const decodeScheduleId = Schema.decodeUnknownOption(OnCallScheduleId) - -/** The target a menu value names, if it is well formed. */ -export const targetOfKey = (key: string): Option.Option => { - const [kind, ...rest] = Str.split(":")(key) - const id = Arr.join(rest, ":") - switch (kind) { - case "destination": - return Option.map(decodeDestinationId(id), (destination_id): V2EscalationTarget => ({ - type: "destination", - destination_id, - })) - case "user": - return Option.map(decodeUserId(id), (user_id): V2EscalationTarget => ({ type: "user", user_id })) - case "schedule": - return Option.map(decodeScheduleId(id), (schedule_id): V2EscalationTarget => ({ - type: "schedule", - schedule_id, - })) - default: - return Option.none() - } -} - -export const stepsError = (steps: ReadonlyArray): Option.Option => - steps.length === 0 - ? Option.some("Add at least one step") - : steps.length > MAX_ESCALATION_STEPS - ? Option.some(`A chain has at most ${MAX_ESCALATION_STEPS} steps`) - : Option.none() - -const stepOf = (step: StepDraft): Option.Option => { - const targets = step.targets - const minutes = numberOf(step.waitMinutes) - return Arr.isReadonlyArrayNonEmpty(targets) && - Option.isNone(targetsError(targets)) && - minutes !== null && - isWait(minutes) - ? Option.some({ targets, wait_minutes: minutes }) - : Option.none() -} - -/** The create payload the form describes, or none while any field is wrong. */ -export const payloadOf = (model: Model): Option.Option => { - const steps = Option.all(Arr.map(model.steps, stepOf)) - const repeat = numberOf(model.repeatCount) - if ( - Option.isSome(nameError(model.name)) || - Option.isSome(descriptionError(model.description)) || - Option.isSome(stepsError(model.steps)) || - repeat === null || - !isRepeat(repeat) - ) - return Option.none() - return Option.flatMap(steps, (all) => - Arr.isReadonlyArrayNonEmpty(all) - ? Option.some({ - name: Str.trim(model.name), - description: Str.isEmpty(Str.trim(model.description)) - ? null - : Str.trim(model.description), - steps: all, - repeat_count: repeat, - }) - : Option.none(), - ) -} - -// COMMANDS - -export const SaveEscalationChain = Command.define("SaveEscalationChain", { - args: { - openCount: Schema.Number, - editingId: Schema.NullOr(EscalationChainId), - payload: V2EscalationChainCreateParams, - }, - messages: [Message.CompletedSave], - execute: ({ openCount, editingId, payload }) => - saveEscalationChain(editingId, payload).pipe( - Effect.result, - Effect.map((result) => Message.CompletedSave({ openCount, result })), - ), -}) - -// UPDATE - -const liftDialog = (model: Model, result: ReturnType): Return => ({ - model: { ...model, dialog: result.model }, - commands: Command.mapMessages(result.commands ?? [], (message) => Message.GotDialogMessage({ message })), -}) - -const open = ( - model: Model, - fields: Pick, -): Return => - liftDialog( - { - ...model, - ...fields, - menus: {}, - isSaving: false, - isShowingErrors: false, - saveError: null, - openCount: model.openCount + 1, - }, - Dialog.open(model.dialog), - ) - -export const openNew = (model: Model): Return => - open(model, { - editingId: null, - name: "", - description: "", - steps: [emptyStep(0)], - repeatCount: "0", - nextKey: 1, - }) - -export const openEdit = (model: Model, chain: V2EscalationChain): Return => - open(model, { - editingId: chain.id, - name: chain.name, - description: chain.description ?? "", - steps: Arr.map(chain.steps, (step, index) => ({ - key: `s${index}`, - targets: step.targets, - waitMinutes: String(step.wait_minutes), - })), - repeatCount: String(chain.repeat_count), - nextKey: chain.steps.length, - }) - -const editStep = (model: Model, key: string, edit: (step: StepDraft) => StepDraft): Return => ({ - model: { ...model, steps: Arr.map(model.steps, (step) => (step.key === key ? edit(step) : step)) }, -}) - -const moveStep = (steps: ReadonlyArray, key: string, direction: "up" | "down") => { - const from = Arr.findFirstIndex(steps, (step) => step.key === key) - if (Option.isNone(from)) return steps - const to = direction === "up" ? from.value - 1 : from.value + 1 - const moving = steps[from.value] - const other = steps[to] - if (moving === undefined || other === undefined) return steps - return Arr.map(steps, (step, index) => (index === from.value ? other : index === to ? moving : step)) -} - -const stepMenu = (model: Model, key: string, message: Menu.Message): Return => { - const id = menuIdOf(model, key) - const result = Menu.update(model.menus[key] ?? Menu.init(id), message) - const next: Model = { ...model, menus: { ...model.menus, [key]: result.model } } - const commands = Command.mapMessages(result.commands ?? [], (child) => - Message.GotStepMenuMessage({ key, message: child }), - ) - const picked = Option.flatMap( - result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none(), - targetOfKey, - ) - // The menu still opens and closes while saving, but a pick no longer edits the step. - return Option.match(model.isSaving ? Option.none() : picked, { - onNone: () => ({ model: next, commands }), - onSome: (target) => ({ - ...editStep(next, key, (step) => - Arr.some(step.targets, (existing) => targetKey(existing) === targetKey(target)) - ? step - : { ...step, targets: [...step.targets, target] }, - ), - commands, - }), - }) -} - -const save = (model: Model): Return => - model.isSaving - ? { model } - : Option.match(payloadOf(model), { - onNone: () => ({ model: { ...model, isShowingErrors: true } }), - onSome: (payload) => ({ - model: { ...model, isSaving: true, isShowingErrors: true, saveError: null }, - commands: [ - SaveEscalationChain({ - openCount: model.openCount, - editingId: model.editingId, - payload, - }), - ], - }), - }) - -/** A form edit is dropped while a save is in flight: the saved chain is what the dialog shows. */ -const unlessSaving = (model: Model, edit: () => Return): Return => (model.isSaving ? { model } : edit()) - -export const update = (model: Model, message: Message): Return => - Message.match(message, { - GotDialogMessage: ({ message: child }) => - // The dialog stays open while a save is in flight. - model.isSaving ? { model } : liftDialog(model, Dialog.update(model.dialog, child)), - ChangedName: ({ value }) => unlessSaving(model, () => ({ model: { ...model, name: value } })), - ChangedDescription: ({ value }) => - unlessSaving(model, () => ({ model: { ...model, description: value } })), - ChangedRepeatCount: ({ value }) => - unlessSaving(model, () => ({ model: { ...model, repeatCount: value } })), - ChangedWait: ({ key, value }) => - unlessSaving(model, () => editStep(model, key, (step) => ({ ...step, waitMinutes: value }))), - ClickedAddStep: () => - unlessSaving(model, () => - model.steps.length >= MAX_ESCALATION_STEPS - ? { model } - : { - model: { - ...model, - steps: [...model.steps, emptyStep(model.nextKey)], - nextKey: model.nextKey + 1, - }, - }, - ), - ClickedRemoveStep: ({ key }) => - unlessSaving(model, () => ({ - model: { ...model, steps: Arr.filter(model.steps, (step) => step.key !== key) }, - })), - ClickedMoveStep: ({ key, direction }) => - unlessSaving(model, () => ({ - model: { ...model, steps: moveStep(model.steps, key, direction) }, - })), - GotStepMenuMessage: ({ key, message: child }) => stepMenu(model, key, child), - ClickedRemoveTarget: ({ key, target }) => - unlessSaving(model, () => - editStep(model, key, (step) => ({ - ...step, - targets: Arr.filter(step.targets, (existing) => targetKey(existing) !== target), - })), - ), - ClickedSave: () => save(model), - CompletedSave: ({ openCount, result }) => - openCount !== model.openCount - ? { model } - : Result.match(result, { - onSuccess: (chain) => ({ - ...liftDialog({ ...model, isSaving: false }, Dialog.close(model.dialog)), - outMessage: OutMessage.SavedChain({ chain, isNew: model.editingId === null }), - }), - onFailure: (failure) => ({ - model: { ...model, isSaving: false, saveError: failure.message }, - }), - }), - }) diff --git a/apps/web/src/page/alerts/escalation-dialog/view.ts b/apps/web/src/page/alerts/escalation-dialog/view.ts deleted file mode 100644 index b0bc3107ea..0000000000 --- a/apps/web/src/page/alerts/escalation-dialog/view.ts +++ /dev/null @@ -1,454 +0,0 @@ -import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_STEPS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" -import type { V2EscalationTarget, V2OnCallSchedule } from "@maple/domain/http/v2" -import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" -import { Option, Array as Arr, String as Str } from "effect" -import { Submodel } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" -import { button } from "@maple/views/ui/button" -import { - fieldDescription, - fieldError, - fieldErrorText, - type FieldIds, - fieldLabel, - input, -} from "@maple/views/ui/field" -import { formDialog } from "@maple/views/ui/form-dialog" -import { icon } from "@maple/views/ui/icon" -import { iconButton } from "@maple/views/ui/icon-button" -import type { FieldState } from "@maple/views/ui/input" -import * as Menu from "@maple/views/ui/menu" -import type { MenuEntry } from "@maple/views/ui/menu-view" -import { panel } from "@maple/views/ui/panel" -import { personAvatar } from "@maple/views/ui/person-avatar" -import { hstack, vstack } from "@maple/views/ui/stack" -import { textarea } from "@maple/views/ui/textarea" -import { typography } from "@maple/views/ui/typography" -import { type PickedEntry, pickList } from "../../kit/pick-list" -import { formatWait } from "../shared/escalation-chains" -import { memberName, memberOf, type OrgMember } from "../shared/org-members" -import { - descriptionError, - targetKey, - menuIdOf, - Message, - type Model, - nameError, - numberOf, - repeatError, - type StepDraft, - stepsError, - targetsError, - waitError, -} from "./dialog" - -type H = HtmlBuilder - -export type ViewInputs = Readonly<{ - id: string - /** The synced destinations steps pick from; `null` until they arrive. */ - destinations: ReadonlyArray | null - /** The org's members a step can page; `null` until they load. */ - members: ReadonlyArray | null - /** The on-call schedules a step can page; `null` until they load. */ - schedules: ReadonlyArray | null -}> - -const nameOr = ( - rows: ReadonlyArray | null, - id: string, - fallback: string, -): string => - Option.getOrElse( - Option.map( - Arr.findFirst(rows ?? [], (row) => row.id === id), - (row) => row.name, - ), - () => (rows === null ? fallback : `Unavailable ${Str.toLowerCase(fallback)}`), - ) - -/** A picked target's row: a destination's bell, a member's avatar, a schedule's calendar. */ -const targetEntry = ( - h: H, - inputs: ViewInputs, - key: string, - target: V2EscalationTarget, -): PickedEntry => { - const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) - switch (target.type) { - case "destination": { - const name = nameOr(inputs.destinations, target.destination_id, "Destination") - return { - key: targetKey(target), - label: name, - media: icon(h, "BellIcon", { className: "text-muted-foreground" }), - removeLabel: `Stop notifying ${name}`, - onRemove, - } - } - case "user": { - const name = memberName(inputs.members, target.user_id) - return { - key: targetKey(target), - label: name, - media: personAvatar(h, { - name, - size: "xs", - imageUrl: Option.getOrUndefined( - Option.map(memberOf(inputs.members, target.user_id), (member) => member.imageUrl), - ), - }), - detail: "email and chat DM", - removeLabel: `Stop paging ${name}`, - onRemove, - } - } - case "schedule": { - const name = nameOr(inputs.schedules, target.schedule_id, "Schedule") - return { - key: targetKey(target), - label: name, - media: icon(h, "CalendarIcon", { className: "text-muted-foreground" }), - detail: "whoever is on call", - removeLabel: `Stop paging ${name}`, - onRemove, - } - } - } -} - -/** The "Add target" menu: destinations, people and schedules not yet on the step, by kind. */ -const addEntries = (inputs: ViewInputs, step: StepDraft): ReadonlyArray => { - const picked = new Set(Arr.map(step.targets, targetKey)) - const group = ( - label: string, - items: ReadonlyArray>, - ): ReadonlyArray => { - const open = Arr.filter(items, (item) => !picked.has(item.value)) - return open.length === 0 - ? [] - : [ - { - _tag: "Group", - label, - entries: Arr.map(open, (item): MenuEntry => ({ _tag: "Item", ...item })), - }, - ] - } - return [ - ...group( - "Destinations", - Arr.map(inputs.destinations ?? [], (row) => ({ - value: `destination:${row.id}`, - label: row.name, - })), - ), - ...group( - "People", - Arr.map(inputs.members ?? [], (member) => ({ - value: `user:${member.userId}`, - label: member.name, - })), - ), - ...group( - "Whoever is on call on", - Arr.map(inputs.schedules ?? [], (schedule) => ({ - value: `schedule:${schedule.id}`, - label: schedule.name, - })), - ), - ] -} - -const stateOf = (value: string, isInvalid: boolean): FieldState => ({ - isInvalid, - isDirty: false, - isFilled: value !== "", - isFocused: false, - isTouched: false, -}) - -const idsOf = (id: string, name: string, hasDescription = false): FieldIds => ({ - control: `${id}-${name}`, - label: `${id}-${name}-label`, - error: `${id}-${name}-error`, - ...(hasDescription ? { description: `${id}-${name}-description` } : undefined), -}) - -/** A field's error once a save was tried, or at once for a number typed out of range. */ -const shownError = (model: Model, error: Option.Option, isImmediate = false): Option.Option => - model.isShowingErrors || isImmediate ? error : Option.none() - -const errorLine = ( - h: H, - ids: FieldIds, - state: FieldState, - error: Option.Option, -): ReadonlyArray => - Option.match(error, { - onNone: () => [], - onSome: (text) => [fieldError(h, { ids, state, attributes: [h.Role("alert")] }, [text])], - }) - -const textField = ( - h: H, - options: Readonly<{ - ids: FieldIds - label: string - value: string - placeholder: string - error: Option.Option - isDisabled: boolean - onInput: (value: string) => Message - }>, -): Html => { - const state = stateOf(options.value, Option.isSome(options.error)) - return vstack(h, { gap: "1.5" }, [ - fieldLabel(h, { ids: options.ids, state }, options.label), - input(h, { - ids: options.ids, - state, - isDisabled: options.isDisabled, - attributes: [ - h.Value(options.value), - h.Placeholder(options.placeholder), - h.OnInput(options.onInput), - h.Attribute("autocomplete", "off"), - ], - }), - ...errorLine(h, options.ids, state, options.error), - ]) -} - -/** A short number input on one line with its label. */ -const numberField = ( - h: H, - options: Readonly<{ - ids: FieldIds - label: string - value: string - min: number - max: number - description?: string - error: Option.Option - isDisabled: boolean - onInput: (value: string) => Message - }>, -): Html => { - const state = stateOf(options.value, Option.isSome(options.error)) - return vstack(h, { gap: "1.5" }, [ - hstack(h, { gap: "3", wrap: true }, [ - fieldLabel(h, { ids: options.ids, state, className: "font-normal" }, options.label), - input(h, { - ids: options.ids, - state, - className: "w-20", - isDisabled: options.isDisabled, - attributes: [ - h.Type("number"), - h.Min(String(options.min)), - h.Attribute("max", String(options.max)), - h.Attribute("step", "1"), - h.Value(options.value), - h.OnInput(options.onInput), - ], - }), - ]), - ...(options.description === undefined - ? [] - : [fieldDescription(h, { ids: options.ids, state }, [options.description])]), - ...errorLine(h, options.ids, state, options.error), - ]) -} - -const isOutOfRange = (raw: string, error: Option.Option) => raw !== "" && Option.isSome(error) - -// Numbered, so each step's wait input has its own accessible name. -const waitLabel = (model: Model, index: number): string => - `Step ${index + 1}: ${ - index < model.steps.length - 1 - ? "then wait, in minutes" - : (numberOf(model.repeatCount) ?? 0) > 0 - ? "wait before repeating, in minutes" - : // The run ends as soon as the last step pages, so this wait only matters once it repeats. - "wait before repeating, in minutes (unused unless the chain repeats)" - }` - -const stepBlock = (h: H, model: Model, inputs: ViewInputs, step: StepDraft, index: number): Html => { - const count = model.steps.length - const previous = index === 0 ? undefined : model.steps[index - 1] - const wait = waitError(step.waitMinutes) - const targets = shownError(model, targetsError(step.targets)) - const key = step.key - return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ - hstack(h, { gap: "2", justify: "between" }, [ - hstack(h, { gap: "2", shrink: true }, [ - typography(h, { size: "sm", weight: "medium", className: "shrink-0 whitespace-nowrap" }, [ - `Step ${index + 1}`, - ]), - typography(h, { size: "xs", tone: "muted", truncate: true }, [ - previous === undefined - ? "pages at once" - : Option.isNone(waitError(previous.waitMinutes)) - ? `after ${formatWait(Number(previous.waitMinutes))} without an ack` - : "after the wait above", - ]), - ]), - hstack(h, { gap: "0.5" }, [ - iconButton( - h, - { - label: `Move step ${index + 1} up`, - isDisabled: model.isSaving || index === 0, - attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "up" }))], - }, - [icon(h, "ArrowUpIcon")], - ), - iconButton( - h, - { - label: `Move step ${index + 1} down`, - isDisabled: model.isSaving || index === count - 1, - attributes: [h.OnClick(Message.ClickedMoveStep({ key, direction: "down" }))], - }, - [icon(h, "ArrowDownIcon")], - ), - iconButton( - h, - { - label: `Remove step ${index + 1}`, - isDisabled: model.isSaving || count === 1, - attributes: [h.OnClick(Message.ClickedRemoveStep({ key }))], - }, - [icon(h, "TrashIcon")], - ), - ]), - ]), - pickList(h, { - entries: Arr.map(step.targets, (target) => targetEntry(h, inputs, key, target)), - menu: model.menus[key] ?? Menu.init(menuIdOf(model, key)), - toMenuMessage: (message) => Message.GotStepMenuMessage({ key, message }), - addEntries: addEntries(inputs, step), - addLabel: "Add destination, person or schedule", - isDisabled: model.isSaving, - context: `step ${index + 1}`, - emptyText: "Pick who this step pages", - }), - ...Option.match(targets, { - onNone: () => [], - onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], - }), - numberField(h, { - ids: idsOf(model.dialog.id, `wait-${key}`), - label: waitLabel(model, index), - value: step.waitMinutes, - min: 1, - max: MAX_ESCALATION_WAIT_MINUTES, - error: shownError(model, wait, isOutOfRange(step.waitMinutes, wait)), - isDisabled: model.isSaving, - onInput: (value) => Message.ChangedWait({ key, value }), - }), - ]) -} - -const stepsSection = (h: H, model: Model, inputs: ViewInputs): Html => - vstack(h, { gap: "3" }, [ - hstack(h, { gap: "2", justify: "between" }, [ - typography(h, { size: "sm", weight: "medium" }, ["Steps"]), - typography(h, { size: "xs", tone: "muted", numeric: true }, [ - `${model.steps.length} of ${MAX_ESCALATION_STEPS}`, - ]), - ]), - ...Arr.map(model.steps, (step, index) => stepBlock(h, model, inputs, step, index)), - ...Option.match(shownError(model, stepsError(model.steps)), { - onNone: () => [], - onSome: (text) => [fieldErrorText(h, { attributes: [h.Role("alert")] }, [text])], - }), - button( - h, - { - variant: "outline", - size: "sm", - className: "self-start", - isDisabled: model.isSaving || model.steps.length >= MAX_ESCALATION_STEPS, - attributes: [h.OnClick(Message.ClickedAddStep())], - }, - [icon(h, "PlusIcon"), "Add step"], - ), - ]) - -const descriptionField = (h: H, model: Model): Html => { - const ids = idsOf(model.dialog.id, "description") - const error = shownError(model, descriptionError(model.description)) - const state = stateOf(model.description, Option.isSome(error)) - return vstack(h, { gap: "1.5" }, [ - fieldLabel(h, { ids, state }, "Description"), - textarea(h, { - id: ids.control, - defaultValue: model.description, - isDisabled: model.isSaving, - attributes: [ - // Uncontrolled: keyed on the open, so a reopened dialog rebuilds it with that chain's text. - h.Key(`${ids.control}-${model.openCount}`), - h.AriaLabelledBy(ids.label), - h.Placeholder("Optional: who is on this chain and when to change it"), - h.Rows(2), - h.OnInput((value) => Message.ChangedDescription({ value })), - ], - }), - ...errorLine(h, ids, state, error), - ]) -} - -/** The escalation chain dialog: name, ordered steps of targets and waits, and repeats. */ -export const view = Submodel.defineView((model, inputs, h) => { - const repeat = repeatError(model.repeatCount) - const isEditing = model.editingId !== null - return formDialog(h, { - slotId: `${inputs.id}-dialog`, - model: model.dialog, - toParentMessage: (message) => Message.GotDialogMessage({ message }), - className: "sm:max-w-xl", - title: isEditing ? "Edit escalation chain" : "New escalation chain", - description: - "Each step pages its destinations, people and whoever is on call, then waits for someone to acknowledge before paging the next. Acknowledging pauses the chain and resolving ends it.", - panelClassName: "space-y-5", - fields: [ - textField(h, { - ids: idsOf(model.dialog.id, "name"), - label: "Name", - value: model.name, - placeholder: "Checkout on-call", - error: shownError(model, nameError(model.name)), - isDisabled: model.isSaving, - onInput: (value) => Message.ChangedName({ value }), - }), - descriptionField(h, model), - stepsSection(h, model, inputs), - numberField(h, { - ids: idsOf(model.dialog.id, "repeat", true), - label: "Repeat the whole chain", - value: model.repeatCount, - min: 0, - max: MAX_ESCALATION_REPEATS, - description: `Extra passes after the first, 0 to ${MAX_ESCALATION_REPEATS}. 0 runs it once.`, - error: shownError(model, repeat, isOutOfRange(model.repeatCount, repeat)), - isDisabled: model.isSaving, - onInput: (value) => Message.ChangedRepeatCount({ value }), - }), - ...(model.saveError === null - ? [] - : [ - alert(h, { variant: "crit", size: "sm" }, [ - icon(h, "CircleXmarkIcon"), - alertTitle(h, [isEditing ? "Couldn't save chain" : "Couldn't create chain"]), - alertDescription(h, [model.saveError]), - ]), - ]), - ], - onSubmit: Message.ClickedSave(), - submitLabel: isEditing ? "Save chain" : "Create chain", - isPending: model.isSaving, - }) -}) diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index f7adc99b7f..529b1e8bc2 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -1,24 +1,9 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr } from "effect" -import * as Animation from "@foldkit/ui/animation" import { EscalationChainId } from "@maple/domain/http" -import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" -import { MountDialog } from "@maple/views/ui/dialog" -import { - click, - Command, - expect as expectView, - Mount, - given, - role, - scene, - selector, - submit, - text, - withViewInputs, -} from "foldkit/scene" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { V2EscalationChain } from "@maple/domain/http/v2" import type { PageContext } from "../../contract" @@ -61,12 +46,6 @@ const onRouting = (grants: ReturnType, chains: ReadonlyA ) } -const openDialog = () => [ - Command.resolve(Animation.WaitForPaint, Animation.Message.CompletedWaitForPaint({ generation: 1 })), - Command.resolve(Animation.WaitForAnimationSettled, Animation.Message.EndedAnimation({ generation: 1 })), - Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), -] - describe("alerts hub, escalation chains", () => { test("an org without chains learns what one is and can create the first", () => { scene( @@ -74,7 +53,10 @@ describe("alerts hub, escalation chains", () => { given(onRouting(ENGINEER, [])), expectView(text("No escalation chains yet")).toExist(), expectView(text(/waits for someone to acknowledge, then pages the next/u)).toExist(), - expectView(role("button", { name: "Create chain" })).toExist(), + expectView(role("link", { name: "Create chain" })).toHaveAttr( + "href", + "/alerts/escalation-chains/new", + ), ) }) @@ -91,7 +73,11 @@ describe("alerts hub, escalation chains", () => { expectView(text("Used by 2 rules")).toExist(), expectView(text("Not used by any rule")).toExist(), expectView(role("button", { name: "Actions for Checkout on-call" })).toExist(), - expectView(role("button", { name: "New chain" })).toExist(), + expectView(role("link", { name: "New chain" })).toHaveAttr( + "href", + "/alerts/escalation-chains/new", + ), + expectView(role("link", { name: "Checkout on-call" })).toExist(), ) }) @@ -107,7 +93,7 @@ describe("alerts hub, escalation chains", () => { scene( { update: pageUpdate, view }, given(onRouting(MEMBER, [])), - expectView(role("button", { name: "Create chain" })).not.toExist(), + expectView(role("link", { name: "Create chain" })).not.toExist(), expectView(text(/Ask an org admin to create one/u)).toExist(), ) scene( @@ -117,18 +103,19 @@ describe("alerts hub, escalation chains", () => { ) }) - test("saving a new chain without a name or destinations shows what is missing and sends nothing", () => { - scene( - { update: pageUpdate, view }, - given(onRouting(ENGINEER, CHAINS)), - click(role("button", { name: "New chain" })), - ...openDialog(), - expectView(role("dialog", { name: "New escalation chain" })).toExist(), - submit(selector("form")), - Command.expectNone(), - expectView(text("Name the chain")).toExist(), - expectView(text("Pick at least one destination, person or schedule")).toExist(), + test("Edit in a chain's menu opens its editor page", () => { + const chainId = Schema.decodeSync(EscalationChainId)(chainUuid(1)) + const picked = pageUpdate( + onRouting(ENGINEER, CHAINS), + Message.GotRowMenuMessage({ + menuId: chainMenuId(chainId), + message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value: "edit" } }), + }), ) + expect(picked.outMessage).toMatchObject({ + _tag: "RequestedNavigation", + url: expect.stringMatching(/^\/alerts\/escalation-chains\/esc_/u), + }) }) test("a refused delete stays open and names the rules that still start the chain", () => { diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 75a499ae12..12b9a4da52 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -30,7 +30,6 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" -import * as ChainDialog from "../escalation-dialog/dialog" import { OrgMember } from "../shared/org-members" import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" @@ -110,8 +109,6 @@ export const Message = defineMessageUnion({ ClickedRetrySlos: {}, CompletedFetchChains: { key: Schema.String, result: outcome(Schema.Array(V2EscalationChain)) }, ClickedRetryChains: {}, - ClickedNewChain: {}, - GotChainDialogMessage: { message: ChainDialog.Message }, ClickedConfirmDeleteChain: {}, CompletedDeleteChain: { chainId: EscalationChainId, result: outcome(Schema.Void) }, GotDeleteChainDialogMessage: { message: Dialog.Message }, diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index fb584a2b40..43a02b27c9 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -32,7 +32,6 @@ import { AlertsHubSearch, AlertsTab } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" -import * as ChainDialog from "../escalation-dialog/dialog" import { OrgMember } from "../shared/org-members" import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" @@ -106,7 +105,6 @@ export const Model = Schema.Struct({ slos: KeyedSchema(Schema.Array(SloListRow), LoadFailure), /** Routing tab: `escalationChains.list`, read only while the tab is open. */ chains: KeyedSchema(Schema.Array(V2EscalationChain), LoadFailure), - chainDialog: ChainDialog.Model, /** The chain awaiting delete confirmation, the delete in flight, and why the last one was refused. */ confirmDeleteChainId: Schema.NullOr(EscalationChainId), deletingChainId: Schema.NullOr(EscalationChainId), diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 89fb77bfeb..082f2296d0 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -23,13 +23,12 @@ import type { AppRoute } from "../../../route" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" -import * as ChainDialog from "../escalation-dialog/dialog" import * as WindowDialog from "../maintenance-dialog/dialog" import * as SilenceDialog from "../silence-dialog/dialog" import { isCancelledSilence } from "../shared/silences" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" -import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT } from "../shared/escalation-chains" +import { CHAIN_IN_USE_CODE, CHAIN_LIST_LIMIT, chainHref } from "../shared/escalation-chains" import { editScheduleHref, handoffMs, @@ -89,7 +88,6 @@ const DELETE_DIALOG_ID = "alerts-delete-destination" const DELETE_MONITOR_DIALOG_ID = "alerts-delete-monitor" const DELETE_CHAIN_DIALOG_ID = "alerts-delete-chain" export const DELETE_SCHEDULE_DIALOG_ID = "alerts-delete-schedule" -export const CHAIN_DIALOG_ID = "alerts-escalation-chain" export const NEW_RULE_MENU_ID = "alerts-new-rule-menu" export const WINDOW_DIALOG_ID = "alerts-maintenance-window" const DELETE_WINDOW_DIALOG_ID = "alerts-delete-window" @@ -337,7 +335,6 @@ export const init = (route: HubRoute, context: PageContext): Return => { deleteMonitorDialog: Dialog.init(DELETE_MONITOR_DIALOG_ID, "alert-dialog"), slos: Keyed.idle(), chains: Keyed.idle(), - chainDialog: ChainDialog.init(CHAIN_DIALOG_ID), confirmDeleteChainId: null, deletingChainId: null, deleteChainError: null, @@ -503,28 +500,6 @@ const refreshChains = (model: Model, context: PageContext): Return => { return { model: schedules.model, commands: [...chains.commands, ...schedules.commands] } } -/** The chain dialog's outcome: a toast, and the list read again. */ -const liftChainDialog = ( - model: Model, - context: PageContext, - step: ReturnType, -): Return => { - const lifted: Model = { ...model, chainDialog: step.model } - const commands = FoldkitCommand.mapMessages(step.commands ?? [], (message) => - Message.GotChainDialogMessage({ message }), - ) - if (step.outMessage === undefined) return { model: lifted, commands } - const refreshed = refreshChains(lifted, context) - return { - model: refreshed.model, - commands: [...commands, ...(refreshed.commands ?? [])], - outMessage: toast( - "success", - step.outMessage.isNew ? "Escalation chain created" : "Escalation chain saved", - ), - } -} - /** The names of the rules that start `chain` that this tab has loaded; the rest are counted, not named. */ export const chainRuleNames = ( model: Pick, @@ -575,7 +550,7 @@ const updateDeleteChainDialog = (model: Model, step: ReturnType +const pickedChainAction = (model: Model, rawId: string, value: string): Return => Option.match( Array.findFirst(chainsOf(model), (chain) => chain.id === rawId), { @@ -591,7 +566,13 @@ const pickedChainAction = (model: Model, context: PageContext, rawId: string, va }, Dialog.open(model.deleteChainDialog), ) - : liftChainDialog(model, context, ChainDialog.openEdit(model.chainDialog, chain)), + : { + model, + outMessage: PageOutMessage.RequestedNavigation({ + url: chainHref(chain.id), + isReplace: false, + }), + }, }, ) @@ -1036,7 +1017,7 @@ const pickedFromMenu = (model: Model, context: PageContext, menuId: string, valu const [kind, ...rest] = Str.split(":")(menuId) const target = Array.join(rest, ":") if (kind === "monitor") return pickedMonitorAction(model, target, value) - if (kind === "chain") return pickedChainAction(model, context, target, value) + if (kind === "chain") return pickedChainAction(model, target, value) if (kind === "schedule") return pickedScheduleAction(model, target, value) if (kind === "window") return pickedWindowAction(model, context, target, value) if (kind === "rule") { @@ -1295,9 +1276,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re } }, ClickedRetryChains: () => refreshChains(model, context), - ClickedNewChain: () => liftChainDialog(model, context, ChainDialog.openNew(model.chainDialog)), - GotChainDialogMessage: ({ message: child }) => - liftChainDialog(model, context, ChainDialog.update(model.chainDialog, child)), ClickedConfirmDeleteChain: () => confirmDeleteChain(model), CompletedDeleteChain: ({ chainId, result }) => deletedChain(model, context, chainId, result), GotDeleteChainDialogMessage: ({ message: child }) => diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index b533835c0e..f98f0af2a7 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -6,7 +6,7 @@ import { Array as Arr, Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { borderedBox } from "@maple/views/ui/bordered-box" -import { button } from "@maple/views/ui/button" +import { linkButton } from "@maple/views/ui/button" import { confirmDialog } from "@maple/views/ui/confirm-dialog" import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" import { errorState } from "@maple/views/ui/error-state" @@ -18,10 +18,11 @@ import { emptyActions } from "@maple/views/ui/signal-empty-state" import { skeleton } from "@maple/views/ui/skeleton" import { hstack, vstack } from "@maple/views/ui/stack" import { tooltipTrigger } from "@maple/views/ui/tooltip-host" +import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { sectionHeading } from "../../kit/section-heading" -import { chainSummary } from "../shared/escalation-chains" +import { chainHref, chainSummary, newChainHref } from "../shared/escalation-chains" import { memberName } from "../shared/org-members" import { Message } from "./message" import { canManage, type Model } from "./model" @@ -89,7 +90,9 @@ export const chainRow = ( hstack(h, { gap: "3", align: "start" }, [ rowIcon(h, "BellIcon"), vstack(h, { gap: "0.5", shrink: true, grow: true }, [ - typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [chain.name]), + typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [ + textLink(h, { href: chainHref(chain.id), tone: "inherit" }, [chain.name]), + ]), typography( h, { @@ -150,7 +153,7 @@ const chainsEmpty = (h: H, model: Model): Html => ...(canManage(model) ? [ emptyActions(h, [ - button(h, { size: "sm", attributes: [h.OnClick(Message.ClickedNewChain())] }, [ + linkButton(h, { size: "sm", href: newChainHref() }, [ icon(h, "PlusIcon"), "Create chain", ]), @@ -211,15 +214,10 @@ export const chainsSection = (h: H, model: Model): Html => title: "Escalation chains", actions: canManage(model) && hasChains(model) - ? button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewChain())], - }, - [icon(h, "PlusIcon"), "New chain"], - ) + ? linkButton(h, { size: "sm", variant: "outline", href: newChainHref() }, [ + icon(h, "PlusIcon"), + "New chain", + ]) : undefined, }), typography(h, { size: "sm", tone: "muted", as: "p" }, [ diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index e9e8dd6843..75e3c07ccc 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -2,7 +2,7 @@ import { type AlertIncidentId, isManagedSignalType } from "@maple/domain/http" import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" import { makeClientErrorBody } from "@maple/web-data/lib/error-messages" import { Option, Array as Arr } from "effect" -import { AsyncData, Submodel } from "foldkit" +import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { button, linkButton } from "@maple/views/ui/button" import { splitButtonClass } from "@maple/views/ui/split-button" @@ -36,9 +36,8 @@ import { anomalyInbox, inboxFilters } from "./view-anomalies" import { inboxTab } from "./view-inbox" import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" -import * as ChainDialogView from "../escalation-dialog/view" import { deleteChainDialog } from "./view-chains" -import { CHAIN_DIALOG_ID, WINDOW_DIALOG_ID } from "./update" +import { WINDOW_DIALOG_ID } from "./update" import * as SilenceDialogView from "../silence-dialog/view" import { silencesTab } from "./view-silences" import { deleteWindowDialog, hasWindows, maintenanceTab } from "./view-maintenance" @@ -380,19 +379,6 @@ export const view = Submodel.defineView((model, deleteChainDialog(h, model), deleteScheduleDialog(h, model), deleteWindowDialog(h, model), - h.submodel({ - slotId: CHAIN_DIALOG_ID, - model: model.chainDialog, - view: ChainDialogView.view, - viewInputs: { - id: CHAIN_DIALOG_ID, - destinations: model.destinations, - members: model.members, - // `null` until the schedules load (or after a failed read): names read neutrally. - schedules: Option.getOrNull(AsyncData.getData(model.schedules.data)), - }, - toParentMessage: (message) => Message.GotChainDialogMessage({ message }), - }), h.submodel({ slotId: "alerts-destination", model: model.destinationDialog, diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index c2c01db6b0..4926ab9812 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -5,16 +5,18 @@ import type { OnCallScheduleId, UserId, } from "@maple/domain/http" -import type { - V2AlertIncidentEscalation, - V2EscalationChain, - V2EscalationChainCreateParams, +import { + EscalationChainPublicId, + type V2AlertIncidentEscalation, + type V2EscalationChain, + type V2EscalationChainCreateParams, } from "@maple/domain/http/v2" import { formatAlertTimeNear } from "@maple/web-data/alerts/form-utils" -import { Array as Arr, Effect, Option } from "effect" +import { Array as Arr, Effect, Option, Schema, String as Str } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" +import type { AlertsEscalationChainSearch } from "../../../route/search" // Escalation chain reads and writes as Effects the pages' Commands wrap, and how a chain and an // incident's run read in one line. @@ -31,6 +33,12 @@ export const listEscalationChains = Effect.gen(function* () { return page.data }).pipe(Effect.mapError(toLoadFailure)) +export const retrieveEscalationChain = (id: EscalationChainId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.escalationChains.retrieve({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + /** Creates a chain, or replaces `editingId`'s fields with the form's. */ export const saveEscalationChain = ( editingId: EscalationChainId | null, @@ -62,6 +70,27 @@ export const retrieveIncidentEscalation = (id: AlertIncidentId) => export const routingTabHref = (): string => hrefOf(AppRoute.AlertsSection({ section: "routing", search: {} })) +const encodeChainId = Schema.encodeSync(EscalationChainPublicId) +const decodeChainId = Schema.decodeUnknownOption(EscalationChainPublicId) + +/** The `esc_…` id in a URL as the internal id. */ +export const chainIdFromUrl = (raw: string): Option.Option => decodeChainId(raw) + +export const chainHref = ( + id: EscalationChainId, + search: typeof AlertsEscalationChainSearch.Type = {}, +): string => hrefOf(AppRoute.AlertsEscalationChain({ chainId: encodeChainId(id), search })) + +export const newChainHref = (search: typeof AlertsEscalationChainSearch.Type = {}): string => + hrefOf(AppRoute.AlertsEscalationChainNew({ search })) + +/** A `returnTo` the editor may follow: an in-app path only, never another origin. */ +export const safeReturnTo = (raw: string | undefined): Option.Option => + Option.filter( + Option.fromNullishOr(raw), + (path) => Str.startsWith("/")(path) && !Str.startsWith("//")(path) && !Str.includes("\\")(path), + ) + // SUMMARY /** `10m`, `1h`, `1h 30m`, `1d`. */ @@ -97,8 +126,18 @@ export type TargetNames = Readonly<{ * One line for a chain: "Slack #alerts-api, then PagerDuty after 10m, then email after 15m; * repeats twice". Each step waits for an ack before the next, so a step's delay is the one before it. */ +export type ChainOutline = Readonly<{ + steps: ReadonlyArray< + Readonly<{ + targets: ReadonlyArray + wait_minutes: number + }> + > + repeat_count: number +}> + export const chainSummary = ( - chain: Pick, + chain: ChainOutline, nameOf: (id: AlertDestinationId) => string, people: TargetNames = {}, ): string => { diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 4c826a97ad..1deb10a79a 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -31,6 +31,7 @@ import * as AlertsSlo from "./alerts/slo" import * as AlertsSloNew from "./alerts/slo-form" import * as AlertsSchedule from "./alerts/schedule" import * as AlertsScheduleNew from "./alerts/schedule-form" +import * as AlertsEscalationChain from "./alerts/escalation-chain" import * as IngestRuleEditor from "./settings/ingest-rule" import * as DashboardsList from "./dashboards/list" import * as DashboardTemplates from "./dashboards/templates" @@ -126,6 +127,7 @@ export const pages = [ AlertsSloNew.page, AlertsSchedule.page, AlertsScheduleNew.page, + AlertsEscalationChain.page, StatusPagesList.page, StatusPageBuilder.page, StatusPageEditor.page, diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 4d03b0a0b6..47969e2b1a 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -37,6 +37,8 @@ export const AppRoute = defineRouteUnion({ AlertsSlo: { sloId: Schema.String, search: Search.AlertsSloSearch }, AlertsScheduleNew: { search: Search.AlertsScheduleNewSearch }, AlertsSchedule: { scheduleId: Schema.String, search: Search.AlertsScheduleSearch }, + AlertsEscalationChainNew: { search: Search.AlertsEscalationChainSearch }, + AlertsEscalationChain: { chainId: Schema.String, search: Search.AlertsEscalationChainSearch }, IngestRuleNew: { search: Search.IngestRuleNewSearch }, IngestRule: { ruleId: Schema.String }, AlertsStatusPage: { pageId: Schema.String, search: Search.AlertsStatusPageSearch }, diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index e1e46c1249..f6dc38312a 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -81,6 +81,11 @@ describe("routes", () => { expect(parse("/alerts/uptime/mon_1")._tag).toBe("AlertsUptimeMonitor") expect(parse("/alerts/slos/slo_1")._tag).toBe("AlertsSlo") expect(parse("/alerts/schedules/sch_1")._tag).toBe("AlertsSchedule") + expect(parse("/alerts/escalation-chains/new")._tag).toBe("AlertsEscalationChainNew") + expect(parse("/alerts/escalation-chains/esc_1")).toMatchObject({ + _tag: "AlertsEscalationChain", + chainId: "esc_1", + }) expect(parse("/alerts/incidents/inc_1")._tag).toBe("AlertIncident") expect(parse("/alerts/create")._tag).toBe("AlertsCreate") expect(parse("/alerts/rules/0199a001-0000-7000-8000-000000000001")._tag).toBe("AlertRule") diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 02f882e357..5b8d1b5536 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -121,6 +121,17 @@ const routers = { search(Search.AlertsScheduleSearch), Route.mapTo(AppRoute.AlertsSchedule), ), + AlertsEscalationChainNew: pipe( + path("alerts", "escalation-chains", "new"), + search(Search.AlertsEscalationChainSearch), + Route.mapTo(AppRoute.AlertsEscalationChainNew), + ), + AlertsEscalationChain: pipe( + path("alerts", "escalation-chains"), + slash(segment("chainId")), + search(Search.AlertsEscalationChainSearch), + Route.mapTo(AppRoute.AlertsEscalationChain), + ), IngestRuleNew: pipe( path("settings", "ingestion", "rules", "new"), search(Search.IngestRuleNewSearch), @@ -542,6 +553,8 @@ export const urlToAppRoute = Route.parseUrlWithFallback( routers.AlertsSlo, routers.AlertsScheduleNew, routers.AlertsSchedule, + routers.AlertsEscalationChainNew, + routers.AlertsEscalationChain, routers.IngestRuleNew, routers.IngestRule, routers.AlertsStatusPage, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 80ab3c0dd5..eaddd00b69 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -380,6 +380,11 @@ export const AlertsScheduleNewSearch = Schema.Struct({ export const AlertsScheduleSearch = Schema.Struct({}) +/** `/alerts/escalation-chains/new` and `/:chainId`: `returnTo` is where Cancel and Save go back to. */ +export const AlertsEscalationChainSearch = Schema.Struct({ + returnTo: Schema.optional(Schema.String), +}) + /** `/settings/ingestion/rules/new`: `template` prefills the editor from an empty-state template. */ export const IngestRuleNewSearch = Schema.Struct({ template: Schema.optional(Schema.String), diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index d41ccc49d9..eb8c3693d1 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -173,7 +173,13 @@ const alertsItem: NavItem = { alsoActive: ["/alerts/schedules"], startsGroup: true, }, - { title: "Routing", href: "/alerts/routing", icon: "BranchForkIcon", paletteTitle: "Alert routing" }, + { + title: "Routing", + href: "/alerts/routing", + icon: "BranchForkIcon", + paletteTitle: "Alert routing", + alsoActive: ["/alerts/escalation-chains"], + }, ], } diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index ae98087bc1..27a543acf9 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -46,6 +46,8 @@ const SAMPLE_PATHS = [ "/alerts/slos/slo_YofPTrK9782DWwcnXhpcCw", "/alerts/schedules/new", "/alerts/schedules/onc_gQtGH2s65G6a7sJmuayXL6", + "/alerts/escalation-chains/new", + "/alerts/escalation-chains/esc_gQtGH2s65G6a7sJmuayXL6", "/alerts/uptime/upm_abc", "/status-pages", "/status-pages/new", From 56b163134a9983451df32fd6304534e1d7ff3024 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:52:32 +0100 Subject: [PATCH 15/69] fix(alerts): preview beside the form from 2xl, cap the gap links at six --- .../web/src/page/alerts/schedule-form/view.ts | 6 +++--- .../page/alerts/shared/coverage-preview.ts | 20 ++++++++++++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index 8dfa133e27..e9cdb6e022 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -559,9 +559,9 @@ export const view = Submodel.defineView((model, return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) return dashboardPage(h, { ...header, fill: true }, [ formScrollArea(h, [ - // The form on the left and its live preview on the right; stacked under xl. + // The form on the left and its live preview on the right; under 2xl the preview follows the form. h.div( - [h.Class("grid gap-6 xl:grid-cols-[minmax(0,40rem)_minmax(0,1fr)] xl:items-start")], + [h.Class("grid gap-6 2xl:grid-cols-[minmax(0,40rem)_minmax(0,1fr)] 2xl:items-start")], [ formCard( h, @@ -569,7 +569,7 @@ export const view = Submodel.defineView((model, FORM_WIDTH, ), h.div( - [h.Class("min-w-0 rounded-md border bg-card p-4 xl:sticky xl:top-0")], + [h.Class("min-w-0 rounded-md border bg-card p-4 2xl:sticky 2xl:top-0")], [schedulePreview(h, model)], ), ], diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts index 78950de51d..6b10165e54 100644 --- a/apps/web/src/page/alerts/shared/coverage-preview.ts +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -24,6 +24,9 @@ const RANGE_OPTIONS: ReadonlyArray { value: "28", label: "4 weeks" }, ] +/** The summary links this many gaps; the rest are on the timeline. */ +const MAX_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}` @@ -145,10 +148,8 @@ const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions ...(isCovered ? [] : [ - hstack( - h, - { gap: "1", wrap: true }, - Arr.map(options.gaps, (gap, index) => + hstack(h, { gap: "1", wrap: true }, [ + ...Arr.map(Arr.take(options.gaps, MAX_GAP_LINKS), (gap, index) => button( h, { @@ -162,7 +163,16 @@ const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions [gapRangeText(gap, options.timeZone)], ), ), - ), + ...(options.gaps.length > MAX_GAP_LINKS + ? [ + typography( + h, + { size: "xs", tone: "muted", attributes: [h.Key("more")] }, + [`and ${options.gaps.length - MAX_GAP_LINKS} more on the timeline`], + ), + ] + : []), + ]), ]), ]) } From f96ab12dd862692b4ebfddb1c042f461606f3921 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:53:38 +0100 Subject: [PATCH 16/69] feat(alerts): escalation chain editor lab /lab/escalation-editor renders the editor page over fixtures: new, populated, a step inserted, invalid, and the delete confirm. --- apps/web/src/lab/entries/escalation-editor.ts | 16 ++ apps/web/src/lab/escalation-editor-lab.ts | 145 ++++++++++++++++++ apps/web/src/lab/registry.ts | 10 +- 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lab/entries/escalation-editor.ts create mode 100644 apps/web/src/lab/escalation-editor-lab.ts diff --git a/apps/web/src/lab/entries/escalation-editor.ts b/apps/web/src/lab/entries/escalation-editor.ts new file mode 100644 index 0000000000..e2f1ac07c1 --- /dev/null +++ b/apps/web/src/lab/entries/escalation-editor.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../escalation-editor-lab" + +// `/lab/escalation-editor`: the escalation chain editor page over fixtures (`?state=`). + +export const lab = defineShellLab("Escalation chain editor", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/escalation-editor", + breadcrumbs, +}) diff --git a/apps/web/src/lab/escalation-editor-lab.ts b/apps/web/src/lab/escalation-editor-lab.ts new file mode 100644 index 0000000000..307fd958cc --- /dev/null +++ b/apps/web/src/lab/escalation-editor-lab.ts @@ -0,0 +1,145 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { EscalationChainId } from "@maple/domain/http" +import { EscalationChainPublicId } from "@maple/domain/http/v2" +import { Effect, Option, Result, Schema, Array as Arr } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import * as ChainMessage from "../page/alerts/escalation-chain/message" +import * as ChainModel from "../page/alerts/escalation-chain/model" +import { init as chainInit, update as chainUpdate } from "../page/alerts/escalation-chain/update" +import { chainTitle, view as chainView } from "../page/alerts/escalation-chain/view" +import { routingTabHref } from "../page/alerts/shared/escalation-chains" +import { + chainDestinations, + chainDestinationUuid, + chainUuid, + populatedChains, +} from "../page/alerts/shared/escalation-test-fixtures" +import { ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import type { PageContext } from "../page/contract" +import type { Resources } from "../platform/resources" +import { keepLocal } from "./local-commands" + +// `/lab/escalation-editor`: the escalation chain editor page over fixtures. `?state=` picks one. + +export const LabState = Schema.Literals(["new", "populated", "inserted", "invalid", "delete"]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "populated", + ), +})) + +export const Model = Schema.Struct({ state: LabState, page: ChainModel.Model }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotPageMessage: { message: ChainMessage.Message } }) +export type Message = typeof Message.Type + +type Return = Update.Return +type PageCommands = ReadonlyArray> + +const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const RULES = [ + ruleRow(1, { name: "Checkout error rate" }), + ruleRow(2, { name: "Checkout p95 latency" }), + ruleRow(3, { name: "Payments webhook failures" }), +] +const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) +const M = ChainMessage.Message + +const NO_COMMANDS: PageCommands = [] + +const run = (model: ChainModel.Model, messages: ReadonlyArray) => + Arr.reduce(messages, { model, commands: NO_COMMANDS }, (current, message) => { + const next = chainUpdate(current.model, message, CONTEXT) + return { model: next.model, commands: [...current.commands, ...keepLocal(next.commands)] } + }) + +const pageFor = (state: LabState) => { + const isNew = state === "new" || state === "invalid" + const chainId = Schema.encodeSync(EscalationChainPublicId)( + Schema.decodeSync(EscalationChainId)(chainUuid(1)), + ) + const entered = chainInit( + isNew + ? { _tag: "AlertsEscalationChainNew", search: {} } + : { _tag: "AlertsEscalationChain", chainId, search: {} }, + CONTEXT, + ).model + const base = [ + M.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), + M.CompletedFetchMembers({ result: Result.succeed([]) }), + M.CompletedFetchSchedules({ result: Result.succeed([]) }), + M.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + M.UpdatedRules({ orgId: ORG, rows: RULES }), + ] + const loaded = isNew + ? [] + : Arr.map(Arr.take(CHAINS, 1), (chain) => + M.CompletedFetchExisting({ key: entered.existing.key ?? "", result: Result.succeed(chain) }), + ) + const extra: ReadonlyArray = + state === "invalid" + ? [M.ChangedWait({ key: "s0", value: "2000" }), M.ClickedSave()] + : state === "inserted" + ? [ + M.ClickedInsertStep({ index: 1 }), + M.GotMenuMessage({ + kind: "targets", + key: "s3", + message: { + _tag: "ClickedItem", + index: 0, + item: { kind: "Item", value: `destination:${chainDestinationUuid(3)}` }, + }, + }), + ] + : state === "delete" + ? [M.ClickedDelete()] + : [] + return run(entered, [...base, ...loaded, ...extra]) +} + +export const init = (flags: Flags): Return => { + const page = pageFor(flags.state) + return { + model: { state: flags.state, page: page.model }, + commands: Command.mapMessages(page.commands, (message) => Message.GotPageMessage({ message })), + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotPageMessage: ({ message: child }) => { + const next = chainUpdate(model.page, child, CONTEXT) + return { + model: { ...model, page: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotPageMessage({ message: inner }), + ), + } + }, + }) + +export const breadcrumbs = (model: Model) => [ + { label: "Alerts", href: "/alerts" }, + { label: "Routing", href: routingTabHref() }, + { label: chainTitle(model.page) }, +] + +export const view = (model: Model, h: HtmlBuilder): Html => + h.submodel({ + slotId: "lab-escalation-editor", + model: model.page, + view: chainView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: ChainMessage.Message) => Message.GotPageMessage({ message }), + }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 843e1bdbb2..fbd3e687f8 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -73,7 +73,15 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/escalation", title: "Escalation chains", description: - "The Routing tab's escalation chains empty and populated, the chain dialog new, editing, invalid and failing, a rule tier's chain picker, and the incident rail escalating, paused or finished (`?state=`).", + "The Routing tab's escalation chains empty and populated, a rule tier's chain picker, and the incident rail escalating, paused or finished (`?state=`).", + kind: "lab", + session: "none", + }, + { + path: "/lab/escalation-editor", + title: "Escalation chain editor", + description: + "The escalation chain editor page new, populated, with a step inserted, invalid and asking to delete (`?state=`).", kind: "lab", session: "none", }, From ef301707e6e647cadfdd939c6c15e5e91e4c2a1f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Mon, 9 Nov 2026 21:55:35 +0100 Subject: [PATCH 17/69] test(shell): lab on-call fixture no longer expires on 16 Nov 2026 --- apps/web/src/lab/shell-states-lab.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 6d017f71c7..9e78acc414 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -37,8 +37,8 @@ export type OnCallFixture = typeof OnCallFixture.Type export const StandOn = Schema.Literals(["lab", "alerts"]) export type StandOn = typeof StandOn.Type -/** Mon 16 Nov 2026, 08:00 UTC: 09:00 CET for a Berlin viewer. */ -const HANDOFF_MS = Date.UTC(2026, 10, 16, 8) +/** Mon 16 Nov 2099, 08:00 UTC (09:00 CET): far enough out that the fixture shift never ends. */ +const HANDOFF_MS = Date.UTC(2099, 10, 16, 8) const LAB_USER = "user_lab" From 176023c8475af4e07d48bc5ef105629fb4d55d91 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 01:45:16 +0100 Subject: [PATCH 18/69] fix(alerts): escalation chain editor review follow-ups Safety and state: - Drop returnTo and its helper: no caller passed it, and it could be steered off-site. - Ask before leaving with unsaved edits (link, Back or Cancel). A clean form, or one put back to how it was, leaves at once. - Save and Create go back to Routing with the toast, so the list shows the saved name and rules. - A chain deleted elsewhere reads as not found with the way back, and a save that hits it says the chain no longer exists. - A delete the API refuses reads the chain again, so the dialog names the rule that took it and holds its Delete. - Viewers without alerts:manage read the chain and who uses it; nothing can be edited, saved or deleted. Nothing renders until the session answers. - Step edits move focus: a new or duplicated step to its Add button, a removed step to the one before it or the leading +. - Remove the unused step messages. Layout: - A step is two lines: "Notify" with its targets as inline chips and an Add chip, then the wait. A three-step chain fits in 800px. - Targets draw their own mark: the destination's logo, a person's avatar, a schedule's calendar with who is on call now. - The last step of a chain that does not repeat reads "then stop". - The picker says what is still loading and where to make a destination; someone removed from the org reads "Unavailable member". - The connector is a 24px line whose + shows on hover, focus and touch. - Delete is a quiet destructive button under the details; a paused rule says so in Used by. Kit: pickChips, chainConnector, and a destructive-ghost button variant. --- apps/web/src/lab/entries/kit/buttons.ts | 18 +- apps/web/src/lab/escalation-editor-lab.ts | 112 +++- apps/web/src/lab/registry.ts | 2 +- .../page/alerts/escalation-chain/command.ts | 10 +- .../escalation-chain.scene.test.ts | 321 ++++++++-- .../page/alerts/escalation-chain/form.test.ts | 16 +- .../src/page/alerts/escalation-chain/form.ts | 12 +- .../src/page/alerts/escalation-chain/index.ts | 4 +- .../page/alerts/escalation-chain/message.ts | 9 +- .../src/page/alerts/escalation-chain/model.ts | 11 +- .../page/alerts/escalation-chain/update.ts | 289 ++++++--- .../src/page/alerts/escalation-chain/view.ts | 556 +++++++++++------- .../page/alerts/shared/escalation-chains.ts | 20 +- .../web/src/page/alerts/shared/org-members.ts | 9 +- apps/web/src/page/kit/chain-connector.ts | 40 ++ apps/web/src/page/kit/pick-chips.ts | 128 ++++ apps/web/src/route/search.ts | 6 +- .../ui/src/components/ui/button.styles.ts | 3 + packages/views/src/ui/INDEX.md | 2 + 19 files changed, 1136 insertions(+), 432 deletions(-) create mode 100644 apps/web/src/page/kit/chain-connector.ts create mode 100644 apps/web/src/page/kit/pick-chips.ts diff --git a/apps/web/src/lab/entries/kit/buttons.ts b/apps/web/src/lab/entries/kit/buttons.ts index 8e112e463b..7654482579 100644 --- a/apps/web/src/lab/entries/kit/buttons.ts +++ b/apps/web/src/lab/entries/kit/buttons.ts @@ -31,6 +31,7 @@ const VARIANTS = [ "ghost", "destructive", "destructive-outline", + "destructive-ghost", "link", "dashed", ] as const @@ -417,16 +418,27 @@ const actionBars = (h: HtmlBuilder): ReadonlyArray => [ statusDot(h, { tone: "live" }), typography(h, { size: "sm", tone: "foreground" }, ["Unsaved changes"]), ], - actions: [button(h, { variant: "ghost", size: "sm" }, ["Discard"]), button(h, { size: "sm" }, ["Save"])], + actions: [ + button(h, { variant: "ghost", size: "sm" }, ["Discard"]), + button(h, { size: "sm" }, ["Save"]), + ], }), ]), kitRow(h, "Editor, with a notice", [ actionBar(h, { label: "Rule actions", className: "static w-full", - notice: [alert(h, { variant: "crit", size: "sm" }, [icon(h, "CircleXmarkIcon"), alertTitle(h, ["Couldn't save the rule"])])], + notice: [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't save the rule"]), + ]), + ], leading: [typography(h, { size: "sm", tone: "muted" }, ["Drop health checks from api-gateway"])], - actions: [button(h, { variant: "outline", size: "sm" }, ["Cancel"]), button(h, { size: "sm" }, ["Save changes"])], + actions: [ + button(h, { variant: "outline", size: "sm" }, ["Cancel"]), + button(h, { size: "sm" }, ["Save changes"]), + ], }), ]), kitRow(h, "Step flow", [ diff --git a/apps/web/src/lab/escalation-editor-lab.ts b/apps/web/src/lab/escalation-editor-lab.ts index 307fd958cc..2ab2679f7e 100644 --- a/apps/web/src/lab/escalation-editor-lab.ts +++ b/apps/web/src/lab/escalation-editor-lab.ts @@ -1,8 +1,8 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" -import { EscalationChainId } from "@maple/domain/http" +import { OnCallScheduleId } from "@maple/domain/http" import { EscalationChainPublicId } from "@maple/domain/http/v2" -import { Effect, Option, Result, Schema, Array as Arr } from "effect" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" @@ -13,10 +13,11 @@ import { chainTitle, view as chainView } from "../page/alerts/escalation-chain/v import { routingTabHref } from "../page/alerts/shared/escalation-chains" import { chainDestinations, - chainDestinationUuid, - chainUuid, + chainOf, + chainScheduleUuid, populatedChains, } from "../page/alerts/shared/escalation-test-fixtures" +import { memberId, oncallMembers, populatedSchedules } from "../page/alerts/shared/oncall-test-fixtures" import { ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" @@ -24,7 +25,16 @@ import { keepLocal } from "./local-commands" // `/lab/escalation-editor`: the escalation chain editor page over fixtures. `?state=` picks one. -export const LabState = Schema.Literals(["new", "populated", "inserted", "invalid", "delete"]) +export const LabState = Schema.Literals([ + "new", + "populated", + "inserted", + "invalid", + "delete", + "leave", + "readonly", + "long", +]) export type LabState = typeof LabState.Type export const Flags = Schema.Struct({ state: LabState }) @@ -49,12 +59,58 @@ type PageCommands = ReadonlyArray = [ + ["Slack", "chat", "slack"], + ["PagerDuty", "pagerduty"], + ["Email", "email"], + ["Discord", "discord"], + ["Microsoft Teams", "microsoft-teams"], +] +const DESTINATIONS = Arr.map(chainDestinations, (row) => + Option.match( + Arr.findFirst(TYPES, ([prefix]) => Str.startsWith(prefix)(row.name)), + { + onNone: () => row, + onSome: ([, type, chatConnector]) => ({ + ...row, + type, + config_json: { + summary: row.name, + channelLabel: null, + ...(chatConnector === undefined ? undefined : { chatConnector }), + }, + }), + }, + ), +) + +/** One schedule, under the id the fixture chains page. */ +const SCHEDULES = Arr.map(Arr.take(populatedSchedules(), 1), (schedule) => ({ + ...schedule, + id: Schema.decodeSync(OnCallScheduleId)(chainScheduleUuid(1)), +})) + +/** The everyday chain, paging a schedule and a person beside its destinations. */ +const EVERYDAY = chainOf({ + n: 1, + name: "Checkout on-call", + description: "Checkout team first, then the platform lead.", + steps: [ + { destinations: [1], schedules: [1], wait: 10 }, + { destinations: [2], wait: 15 }, + { destinations: [3], users: [memberId(0)], wait: 30 }, + ], + repeat: 2, + ruleIds: [ruleId(1), ruleId(2)], +}) +const LONG = populatedChains([])[2] + const NO_COMMANDS: PageCommands = [] const run = (model: ChainModel.Model, messages: ReadonlyArray) => @@ -65,46 +121,40 @@ const run = (model: ChainModel.Model, messages: ReadonlyArray { const isNew = state === "new" || state === "invalid" - const chainId = Schema.encodeSync(EscalationChainPublicId)( - Schema.decodeSync(EscalationChainId)(chainUuid(1)), - ) + const chain = state === "long" ? (LONG ?? EVERYDAY) : EVERYDAY const entered = chainInit( isNew ? { _tag: "AlertsEscalationChainNew", search: {} } - : { _tag: "AlertsEscalationChain", chainId, search: {} }, + : { + _tag: "AlertsEscalationChain", + chainId: Schema.encodeSync(EscalationChainPublicId)(chain.id), + search: {}, + }, CONTEXT, ).model const base = [ - M.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:engineer"]) }), - M.CompletedFetchMembers({ result: Result.succeed([]) }), - M.CompletedFetchSchedules({ result: Result.succeed([]) }), - M.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), + M.CompletedFetchSession({ + orgId: ORG, + grants: Policy.grantsOf([state === "readonly" ? "org:member" : "org:engineer"]), + }), + M.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + M.CompletedFetchSchedules({ result: Result.succeed(SCHEDULES) }), + M.UpdatedDestinations({ orgId: ORG, rows: DESTINATIONS }), M.UpdatedRules({ orgId: ORG, rows: RULES }), ] const loaded = isNew ? [] - : Arr.map(Arr.take(CHAINS, 1), (chain) => - M.CompletedFetchExisting({ key: entered.existing.key ?? "", result: Result.succeed(chain) }), - ) + : [M.CompletedFetchExisting({ key: entered.existing.key ?? "", result: Result.succeed(chain) })] const extra: ReadonlyArray = state === "invalid" ? [M.ChangedWait({ key: "s0", value: "2000" }), M.ClickedSave()] : state === "inserted" - ? [ - M.ClickedInsertStep({ index: 1 }), - M.GotMenuMessage({ - kind: "targets", - key: "s3", - message: { - _tag: "ClickedItem", - index: 0, - item: { kind: "Item", value: `destination:${chainDestinationUuid(3)}` }, - }, - }), - ] + ? [M.ClickedInsertStep({ index: 1 })] : state === "delete" ? [M.ClickedDelete()] - : [] + : state === "leave" + ? [M.ChangedWait({ key: "s0", value: "20" }), M.ClickedCancel()] + : [] return run(entered, [...base, ...loaded, ...extra]) } diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index fbd3e687f8..b328010c37 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -81,7 +81,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/escalation-editor", title: "Escalation chain editor", description: - "The escalation chain editor page new, populated, with a step inserted, invalid and asking to delete (`?state=`).", + "The escalation chain editor page new, populated, with a step inserted, invalid, asking to delete or to leave, read-only and with a long chain (`?state=`).", kind: "lab", session: "none", }, diff --git a/apps/web/src/page/alerts/escalation-chain/command.ts b/apps/web/src/page/alerts/escalation-chain/command.ts index 4eb548a801..fc0fa0b217 100644 --- a/apps/web/src/page/alerts/escalation-chain/command.ts +++ b/apps/web/src/page/alerts/escalation-chain/command.ts @@ -1,7 +1,7 @@ import { EscalationChainId } from "@maple/domain/http" import { V2EscalationChainCreateParams } from "@maple/domain/http/v2" import { Effect, Schema } from "effect" -import { Command } from "foldkit" +import { Command, Dom } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { deleteEscalationChain, @@ -64,3 +64,11 @@ export const DeleteChain = Command.define("DeleteEscalationChainFromEditor", { Effect.map((result) => Message.CompletedDelete({ result })), ), }) + +/** Moves focus after a step edit, to an element by id once it is on the page. */ +export const FocusChainElement = Command.define("FocusEscalationChainElement", { + args: { id: Schema.String }, + messages: [Message.CompletedFocus], + execute: ({ id }) => + Dom.focus(`#${CSS.escape(id)}`).pipe(Effect.ignore, Effect.as(Message.CompletedFocus())), +}) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts index 755b6f57e8..99b85cfdb8 100644 --- a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -5,21 +5,37 @@ import { EscalationChainPublicId } from "@maple/domain/http/v2" import { Option, Result, Schema, Array as Arr } from "effect" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" -import { click, expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import type { LoadFailure } from "@maple/views/data/load-failure" +import * as Menu from "@maple/views/ui/menu" +import { triggerId } from "@maple/views/ui/overlay/popup-state" +import { + click, + Command, + expect as expectView, + given, + Mount, + role, + scene, + text, + within, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { CHAIN_IN_USE_CODE } from "../shared/escalation-chains" +import { CHAIN_IN_USE_CODE, CHAIN_NOT_FOUND_CODE } from "../shared/escalation-chains" import { chainDestinations, chainDestinationUuid, chainUuid, populatedChains, } from "../shared/escalation-test-fixtures" +import { memberId, mixedTargetChain, oncallMembers, populatedSchedules } from "../shared/oncall-test-fixtures" import { ORG, ruleId, ruleRow } from "../shared/test-rows" +import { FocusChainElement } from "./command" import { payloadOf } from "./form" import { Message } from "./message" import type { Model } from "./model" -import { init, update } from "./update" +import { init, insertButtonId, isBlockingNavigation, menuIdOf, update } from "./update" import { view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) @@ -30,13 +46,27 @@ const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) = Arr.map(commands ?? [], ({ name }) => name) const run = (model: Model, messages: ReadonlyArray) => Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) +const keys = (model: Model) => Arr.map(model.form.steps, (step) => step.key) const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) const RULES = [ruleRow(1, { name: "Checkout errors" }), ruleRow(2, { name: "Checkout p95" }), ruleRow(3)] const CHAINS = populatedChains([ruleId(1), ruleId(2), ruleId(3)]) const chainPublicId = (n: number) => Schema.encodeSync(EscalationChainPublicId)(Schema.decodeSync(EscalationChainId)(chainUuid(n))) +const failureOf = (code: string, message: string): LoadFailure => ({ + _tag: "@maple/http/errors/EscalationChainError", + type: code === CHAIN_IN_USE_CODE ? "conflict_error" : "not_found_error", + code, + title: "Escalation chain", + message, + retryable: false, + recovery: "fix_request", +}) +const IN_USE = failureOf(CHAIN_IN_USE_CODE, "The escalation chain is used by one or more alert rules.") +const GONE = failureOf(CHAIN_NOT_FOUND_CODE, "Escalation chain not found.") + const pick = (key: string, value: string) => Message.GotMenuMessage({ kind: "targets", @@ -50,31 +80,29 @@ const act = (key: string, value: string) => message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value } }, }) -const ready = (route: Parameters[0]) => { - const entered = init(route, context) - return run(entered.model, [ - Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), - Message.CompletedFetchMembers({ result: Result.succeed([]) }), - Message.CompletedFetchSchedules({ result: Result.succeed([]) }), +const entered = (route: Parameters[0], grants = ENGINEER) => + run(init(route, context).model, [ + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }), + Message.CompletedFetchSchedules({ result: Result.succeed(populatedSchedules()) }), Message.UpdatedDestinations({ orgId: ORG, rows: chainDestinations }), Message.UpdatedRules({ orgId: ORG, rows: RULES }), ]) -} -const newChain = (returnTo?: string) => - ready({ _tag: "AlertsEscalationChainNew", search: returnTo === undefined ? {} : { returnTo } }) - -const editing = (n: number) => { - const base = ready({ _tag: "AlertsEscalationChain", chainId: chainPublicId(n), search: {} }) - const chain = CHAINS[n - 1] +const newChain = (grants = ENGINEER) => entered({ _tag: "AlertsEscalationChainNew", search: {} }, grants) +const opening = (n: number, grants = ENGINEER) => + entered({ _tag: "AlertsEscalationChain", chainId: chainPublicId(n), search: {} }, grants) +const loaded = (model: Model, chain = CHAINS[0]) => { + expect(chain).toBeDefined() return chain === undefined - ? base - : run(base, [ + ? model + : run(model, [ Message.CompletedFetchExisting({ - key: base.existing.key ?? "", + key: model.existing.key ?? "", result: Result.succeed(chain), }), ]) } +const editing = (n: number, grants = ENGINEER) => loaded(opening(n, grants), CHAINS[n - 1]) describe("escalation chain editor", () => { test("a new chain reads as a sentence from the start card to the end card", () => { @@ -84,14 +112,20 @@ describe("escalation chain editor", () => { expectView(text("New escalation chain")).toExist(), expectView(text("When an alert escalates to this chain")).toExist(), expectView(text("Notify")).toExist(), - expectView(text("minutes for an acknowledgement")).toExist(), + // Nothing follows the last step of a chain that does not repeat. + expectView(text("minutes, then stop")).toExist(), expectView(text("If nobody acknowledges, repeat the chain")).toExist(), - expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/routing"), + expectView(role("button", { name: "Cancel" })).toExist(), expectView(role("button", { name: "Create chain" })).toExist(), ) + scene( + { update: pageUpdate, view }, + given(run(newChain(), [Message.ChangedRepeatCount({ value: "2" })])), + expectView(text("minutes for an acknowledgement, then repeat")).toExist(), + ) }) - test("builds a chain: add steps, insert between, reorder, remove, then save", () => { + test("builds a chain: add steps, insert between, reorder, remove, then save back to Routing", () => { const first = run(newChain(), [ Message.ChangedName({ value: "Checkout on-call" }), pick("s0", `destination:${chainDestinationUuid(1)}`), @@ -99,31 +133,58 @@ describe("escalation chain editor", () => { pick("s1", `destination:${chainDestinationUuid(2)}`), Message.ChangedWait({ key: "s0", value: "5" }), ]) - expect(Arr.map(first.form.steps, (step) => step.key)).toEqual(["s0", "s1"]) + expect(keys(first)).toEqual(["s0", "s1"]) // The + between the two cards puts a new step in the middle. scene( { update: pageUpdate, view }, given(first), + expectView(text("Slack #alerts-api")).toExist(), + expectView(text("minutes for an acknowledgement")).toExist(), click(role("button", { name: "Add a step after step 1" })), + Command.resolve(FocusChainElement, Message.CompletedFocus()), expectView(role("button", { name: "Actions for step 3" })).toExist(), ) const middle = pageUpdate(first, Message.ClickedInsertStep({ index: 1 })).model - expect(Arr.map(middle.form.steps, (step) => step.key)).toEqual(["s0", "s2", "s1"]) + expect(keys(middle)).toEqual(["s0", "s2", "s1"]) const reordered = run(middle, [act("s1", "up"), act("s1", "up"), act("s2", "remove")]) - expect(Arr.map(reordered.form.steps, (step) => step.key)).toEqual(["s1", "s0"]) + expect(keys(reordered)).toEqual(["s1", "s0"]) const saved = pageUpdate(reordered, Message.ClickedSave()) expect(names(saved.commands)).toEqual(["SaveEscalationChainFromEditor"]) expect( Option.map(payloadOf(reordered.form), (payload) => Arr.map(payload.steps, (s) => s.wait_minutes)), ).toEqual(Option.some([10, 5])) const chain = CHAINS[0] - if (chain === undefined) throw new Error("fixture chain missing") + expect(chain).toBeDefined() + if (chain === undefined) return const done = pageUpdate(saved.model, Message.CompletedSave({ result: Result.succeed(chain) })) expect(done.outMessage).toMatchObject({ _tag: "RequestedNavigation", - url: `/alerts/escalation-chains/${chainPublicId(1)}`, + url: "/alerts/routing", toast: { title: "Escalation chain created" }, }) + // The saved form is not an unsaved edit: leaving does not ask. + expect(isBlockingNavigation(done.model)).toBe(false) + }) + + test("a step edit moves focus to where the work continues", () => { + const two = pageUpdate(newChain(), Message.ClickedInsertStep({ index: 1 })) + // A new step: its Add button, so its targets come next. + expect(two.commands).toMatchObject([ + { name: "FocusEscalationChainElement", args: { id: triggerId(menuIdOf("targets", "s1")) } }, + ]) + const copied = pageUpdate(two.model, act("s0", "duplicate")) + expect(Arr.last(copied.commands ?? [])).toMatchObject( + Option.some({ args: { id: triggerId(menuIdOf("targets", "s2")) } }), + ) + // A removed step: the step before it, or the + that now leads the chain. + const removedLast = pageUpdate(two.model, act("s1", "remove")) + expect(Arr.last(removedLast.commands ?? [])).toMatchObject( + Option.some({ args: { id: triggerId(menuIdOf("actions", "s0")) } }), + ) + const removedFirst = pageUpdate(two.model, act("s0", "remove")) + expect(Arr.last(removedFirst.commands ?? [])).toMatchObject( + Option.some({ args: { id: insertButtonId(0) } }), + ) }) test("saving with missing fields shows each problem and sends nothing", () => { @@ -152,9 +213,63 @@ describe("escalation chain editor", () => { ).toExist(), expectView(role("button", { name: "Save chain" })).toExist(), ) - expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual([ - "SaveEscalationChainFromEditor", - ]) + const saving = pageUpdate( + run(model, [Message.ChangedName({ value: "Checkout" })]), + Message.ClickedSave(), + ) + expect(names(saving.commands)).toEqual(["SaveEscalationChainFromEditor"]) + const chain = CHAINS[0] + expect(chain).toBeDefined() + if (chain === undefined) return + // Back to the list, which reads the saved name and rules fresh. + expect( + pageUpdate(saving.model, Message.CompletedSave({ result: Result.succeed(chain) })).outMessage, + ).toMatchObject({ url: "/alerts/routing", toast: { title: "Escalation chain saved" } }) + }) + + test("people and schedules read by name, with who is on call and who has left", () => { + const schedule = populatedSchedules()[0] + expect(schedule).toBeDefined() + const chain = mixedTargetChain(chainDestinationUuid(1)) + const model = loaded(opening(1), chain) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text(schedule?.name ?? "")).toExist(), + expectView(text(/ on call$/u)).toExist(), + expectView(text(oncallMembers[1]?.name ?? "")).toExist(), + ) + // Someone removed from the org since the chain was saved. + scene( + { update: pageUpdate, view }, + given({ + ...model, + members: Arr.filter(model.members ?? [], (member) => member.userId !== memberId(0)), + }), + expectView(text("Unavailable member")).toExist(), + ) + }) + + test("the target picker says what is still loading, and where to make a destination", () => { + const menuId = menuIdOf("targets", "s0") + const open = (model: Model): Model => ({ + ...model, + menus: { [menuId]: Menu.init(menuId, { isOpen: true }) }, + }) + scene( + { update: pageUpdate, view }, + given(open({ ...newChain(), destinations: null, schedules: null })), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(text("Loading destinations")).toExist(), + expectView(text("Loading schedules")).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(open({ ...newChain(), destinations: [], members: [], schedules: [] })), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(text(/No destinations yet/u)).toExist(), + expectView(role("link", { name: "Create a destination" })).toHaveAttr("href", "/alerts/routing"), + ) }) test("delete lists the rules that would stop escalating and holds the delete while they use it", () => { @@ -166,27 +281,40 @@ describe("escalation chain editor", () => { Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), expectView(text(/These rules would stop escalating/u)).toExist(), expectView(text("Checkout p95")).toExist(), + expectView(within(role("alertdialog"), role("button", { name: "Delete chain" }))).toBeDisabled(), ) - // A stale page still gets the API's refusal as a message in the dialog. - const refused = pageUpdate( - { ...asked, isDeleting: true }, - Message.CompletedDelete({ - result: Result.fail({ - _tag: "@maple/http/errors/EscalationChainInUseError", - type: "conflict_error", - code: CHAIN_IN_USE_CODE, - title: "Escalation chain is in use", - message: "The escalation chain is used by one or more alert rules.", - retryable: false, - recovery: "fix_request", - }), + }) + + test("a delete the API refuses reads the chain again, so the dialog names the rule that took it", () => { + const asked = run(editing(2), [Message.ClickedDelete(), Message.ClickedConfirmDelete()]) + expect(asked.isDeleting).toBe(true) + const refused = pageUpdate(asked, Message.CompletedDelete({ result: Result.fail(IN_USE) })) + expect(refused.model.deleteError).toMatch(/Remove it from the rule's severity tiers first/u) + expect(names(refused.commands)).toEqual(["FetchEscalationChainEditorChain"]) + const chain = CHAINS[1] + expect(chain).toBeDefined() + if (chain === undefined) return + const edited = run(refused.model, [Message.ChangedName({ value: "Payments" })]) + const reread = pageUpdate( + edited, + Message.CompletedFetchExisting({ + key: edited.existing.key ?? "", + result: Result.succeed({ ...chain, rule_ids: CHAINS[0]?.rule_ids ?? [] }), }), + ).model + // The re-read brings the rules, never the form: an edit in progress stays. + expect(reread.form.name).toBe("Payments") + scene( + { update: pageUpdate, view }, + given(reread), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Used by 2 rules")).toExist(), + expectView(text(/These rules would stop escalating/u)).toExist(), ) - expect(refused.model.deleteError).toMatch(/Remove it from the rule's severity tiers first/u) }) test("an unused chain deletes and goes back to Routing", () => { - const unused = editing(2) + const unused = editing(3) const confirmed = pageUpdate( pageUpdate(unused, Message.ClickedDelete()).model, Message.ClickedConfirmDelete(), @@ -194,37 +322,104 @@ describe("escalation chain editor", () => { expect(names(confirmed.commands)).toEqual(["DeleteEscalationChainFromEditor"]) const done = pageUpdate(confirmed.model, Message.CompletedDelete({ result: Result.void })) expect(done.outMessage).toMatchObject({ _tag: "RequestedNavigation", url: "/alerts/routing" }) + // Deleted elsewhere first: the same outcome. + expect( + pageUpdate(confirmed.model, Message.CompletedDelete({ result: Result.fail(GONE) })).outMessage, + ).toMatchObject({ url: "/alerts/routing", toast: { title: "Escalation chain deleted" } }) }) - test("Cancel and Save go back to an in-app returnTo, never another origin", () => { + test("Cancel leaves at once with nothing to lose, and asks first over unsaved edits", () => { + const clean = editing(1) + expect(isBlockingNavigation(clean)).toBe(false) + expect(pageUpdate(clean, Message.ClickedCancel()).outMessage).toMatchObject({ + _tag: "RequestedNavigation", + url: "/alerts/routing", + }) + const dirty = run(clean, [Message.ChangedWait({ key: "s0", value: "20" })]) + expect(isBlockingNavigation(dirty)).toBe(true) + // An edit put back is no edit. + expect(isBlockingNavigation(run(dirty, [Message.ChangedWait({ key: "s0", value: "10" })]))).toBe( + false, + ) + const asked = pageUpdate(dirty, Message.ClickedCancel()) + expect(asked.outMessage).toBeUndefined() + expect(asked.model.leaveDialog.isOpen).toBe(true) scene( { update: pageUpdate, view }, - given(newChain("/alerts/create")), - expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/create"), + given(asked.model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Unsaved changes")).toExist(), + expectView(role("button", { name: "Stay" })).toExist(), + expectView(role("button", { name: "Discard changes" })).toExist(), ) + const discarded = pageUpdate(asked.model, Message.ClickedDiscard()) + expect(discarded.outMessage).toMatchObject({ url: "/alerts/routing" }) + expect(isBlockingNavigation(discarded.model)).toBe(false) + }) + + test("a chain deleted elsewhere reads as not found, with the way back and nothing to retry", () => { + const opened = opening(1) + const gone = pageUpdate( + opened, + Message.CompletedFetchExisting({ key: opened.existing.key ?? "", result: Result.fail(GONE) }), + ).model scene( { update: pageUpdate, view }, - given(newChain("https://evil.example")), - expectView(role("link", { name: "Cancel" })).toHaveAttr("href", "/alerts/routing"), + given(gone), + expectView(text("Escalation chain not found")).toExist(), + expectView(role("link", { name: "Back to routing" })).toHaveAttr("href", "/alerts/routing"), + expectView(role("button", { name: "Try again" })).not.toExist(), + ) + // Deleted while it was being edited: the save says so. + const saving = pageUpdate(editing(1), Message.ClickedSave()).model + scene( + { update: pageUpdate, view }, + given(pageUpdate(saving, Message.CompletedSave({ result: Result.fail(GONE) })).model), + expectView(text(/This chain no longer exists/u)).toExist(), + ) + }) + + test("a viewer who cannot manage alerts reads the chain and its rules, and can change nothing", () => { + const model = editing(1, MEMBER) + scene( + { update: pageUpdate, view }, + given(model), + expectView(text("Only org admins and engineers can change escalation chains")).toExist(), + expectView(text("Slack #alerts-api")).toExist(), + expectView(text("Used by 2 rules")).toExist(), + expectView(role("textbox", { name: "Name" })).toBeDisabled(), + expectView(role("button", { name: "Save chain" })).not.toExist(), + expectView(role("button", { name: "Delete chain" })).not.toExist(), + expectView(role("button", { name: "Actions for step 1" })).not.toExist(), + expectView(role("button", { name: "Add a step after step 1" })).not.toExist(), + expectView(role("button", { name: "Stop notifying Slack #alerts-api" })).not.toExist(), + expectView(role("link", { name: "Back to routing" })).toExist(), + ) + // The update holds the same line as the view. + expect(run(model, [Message.ChangedName({ value: "Mine now" })]).form.name).toBe("Checkout on-call") + expect(names(pageUpdate(model, Message.ClickedSave()).commands)).toEqual([]) + expect(pageUpdate(model, Message.ClickedDelete()).model.deleteDialog.isOpen).toBe(false) + // A new chain has nothing to read: the viewer learns who can make one. + scene( + { update: pageUpdate, view }, + given(newChain(MEMBER)), + expectView(text("Only org admins and engineers can create escalation chains")).toExist(), + ) + }) + + test("nothing can be saved or changed until the session says who the viewer is", () => { + const opened = init({ _tag: "AlertsEscalationChainNew", search: {} }, context).model + scene( + { update: pageUpdate, view }, + given(opened), + expectView(role("button", { name: "Create chain" })).not.toExist(), ) - const filled = run(newChain("/alerts/create"), [ - Message.ChangedName({ value: "On-call" }), - pick("s0", `destination:${chainDestinationUuid(1)}`), - ]) - const saving = pageUpdate(filled, Message.ClickedSave()).model - const chain = CHAINS[0] - if (chain === undefined) throw new Error("fixture chain missing") - expect( - pageUpdate(saving, Message.CompletedSave({ result: Result.succeed(chain) })).outMessage, - ).toMatchObject({ - url: "/alerts/create", - }) }) test("a link with a malformed chain id says the chain was not found", () => { scene( { update: pageUpdate, view }, - given(ready({ _tag: "AlertsEscalationChain", chainId: "nope", search: {} })), + given(entered({ _tag: "AlertsEscalationChain", chainId: "nope", search: {} })), expectView(text("Escalation chain not found")).toExist(), ) }) diff --git a/apps/web/src/page/alerts/escalation-chain/form.test.ts b/apps/web/src/page/alerts/escalation-chain/form.test.ts index 45acc7ce4e..c30f3098dc 100644 --- a/apps/web/src/page/alerts/escalation-chain/form.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/form.test.ts @@ -1,7 +1,7 @@ import { Option, Array as Arr, Schema } from "effect" import { AlertDestinationId, MAX_ESCALATION_STEPS } from "@maple/domain/http" import { describe, expect, it } from "vitest" -import { chainSummary, safeReturnTo } from "../shared/escalation-chains" +import { chainSummary } from "../shared/escalation-chains" import { chainDestinationUuid, chainOf } from "../shared/escalation-test-fixtures" import { mixedTargetChain } from "../shared/oncall-test-fixtures" import { @@ -12,6 +12,7 @@ import { formFromChain, formIssues, insertStep, + isSameChain, moveStep, payloadOf, previewOf, @@ -123,11 +124,12 @@ describe("escalation chain form", () => { expect(previewOf(emptyForm).steps).toEqual([]) }) - it("follows only an in-app returnTo", () => { - expect(safeReturnTo("/alerts/rules/abc")).toEqual(Option.some("/alerts/rules/abc")) - expect(safeReturnTo("https://evil.example")).toEqual(Option.none()) - expect(safeReturnTo("//evil.example")).toEqual(Option.none()) - expect(safeReturnTo("/\\evil.example")).toEqual(Option.none()) - expect(safeReturnTo(undefined)).toEqual(Option.none()) + it("reads an edit that was undone as the chain it started from", () => { + const form = loaded() + expect(isSameChain(form, form)).toBe(true) + expect(isSameChain(removeStep(insertStep(form, 1), "s2"), form)).toBe(true) + expect(isSameChain({ ...form, name: "Checkout on-call (EU)" }, form)).toBe(false) + expect(isSameChain(moveStep(form, "s1", "up"), form)).toBe(false) + expect(isSameChain(addTarget(form, "s0", destination(3)), form)).toBe(false) }) }) diff --git a/apps/web/src/page/alerts/escalation-chain/form.ts b/apps/web/src/page/alerts/escalation-chain/form.ts index 61faa3649f..d5143a08cd 100644 --- a/apps/web/src/page/alerts/escalation-chain/form.ts +++ b/apps/web/src/page/alerts/escalation-chain/form.ts @@ -17,7 +17,7 @@ import { type V2EscalationStep, V2EscalationTarget, } from "@maple/domain/http/v2" -import { Option, Schema, Array as Arr, String as Str } from "effect" +import { Equal, Option, Schema, Array as Arr, String as Str } from "effect" import type { ChainOutline } from "../shared/escalation-chains" // The escalation chain editor's form: its drafts, the API's limits as field errors, and step edits. @@ -68,6 +68,16 @@ export const formFromChain = (chain: V2EscalationChain): Form => ({ nextKey: chain.steps.length, }) +const contentOf = (form: Form) => ({ + name: form.name, + description: form.description, + repeatCount: form.repeatCount, + steps: Arr.map(form.steps, (step) => ({ targets: step.targets, waitMinutes: step.waitMinutes })), +}) + +/** Two forms describe the same chain: step keys aside, nothing a save sends differs. */ +export const isSameChain = (a: Form, b: Form): boolean => Equal.equals(contentOf(a), contentOf(b)) + // VALIDATION: the same limits the API's schemas hold. const isName = Schema.is(EscalationChainName) diff --git a/apps/web/src/page/alerts/escalation-chain/index.ts b/apps/web/src/page/alerts/escalation-chain/index.ts index ec917920fd..6ae2fa11fc 100644 --- a/apps/web/src/page/alerts/escalation-chain/index.ts +++ b/apps/web/src/page/alerts/escalation-chain/index.ts @@ -3,7 +3,7 @@ import { routingTabHref } from "../shared/escalation-chains" import { Message } from "./message" import { Model } from "./model" import { subscriptions } from "./subscription" -import { init, update } from "./update" +import { askToLeave, init, isBlockingNavigation, update } from "./update" import { chainTitle, view } from "./view" /** `/alerts/escalation-chains/new` and `/:chainId`: one instance per chain, or the new one. */ @@ -17,6 +17,8 @@ export const page = definePage( update, view, subscriptions, + isBlockingNavigation, + blockedNavigation: (model, url) => askToLeave(model, url), breadcrumbs: (model) => [ { label: "Alerts", href: "/alerts" }, { label: "Routing", href: routingTabHref() }, diff --git a/apps/web/src/page/alerts/escalation-chain/message.ts b/apps/web/src/page/alerts/escalation-chain/message.ts index 49415ab705..61cc0d7c0a 100644 --- a/apps/web/src/page/alerts/escalation-chain/message.ts +++ b/apps/web/src/page/alerts/escalation-chain/message.ts @@ -7,7 +7,6 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import { OrgMember } from "../shared/org-members" -import { Direction } from "./form" const rows = (row: S) => ({ orgId: Schema.String, rows: Schema.Array(row) }) const outcome = (success: S) => Schema.Result(success, LoadFailure) @@ -29,9 +28,6 @@ export const Message = defineMessageUnion({ ChangedWait: { key: Schema.String, value: Schema.String }, /** The "+" between two cards: a new step at `index`. */ ClickedInsertStep: { index: Schema.Number }, - ClickedRemoveStep: { key: Schema.String }, - ClickedDuplicateStep: { key: Schema.String }, - ClickedMoveStep: { key: Schema.String, direction: Direction }, /** A step's target picker or its actions menu. */ GotMenuMessage: { kind: MenuKind, key: Schema.String, message: Menu.Message }, ClickedRemoveTarget: { key: Schema.String, target: Schema.String }, @@ -41,5 +37,10 @@ export const Message = defineMessageUnion({ ClickedDelete: {}, ClickedConfirmDelete: {}, CompletedDelete: { result: outcome(Schema.Void) }, + ClickedCancel: {}, + GotLeaveDialogMessage: { message: Dialog.Message }, + ClickedDiscard: {}, + FailedLoadProviderLogo: { type: Schema.String }, + CompletedFocus: {}, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/escalation-chain/model.ts b/apps/web/src/page/alerts/escalation-chain/model.ts index 11cb862749..ec014ffef8 100644 --- a/apps/web/src/page/alerts/escalation-chain/model.ts +++ b/apps/web/src/page/alerts/escalation-chain/model.ts @@ -7,14 +7,12 @@ import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" -import { AlertsEscalationChainSearch } from "../../../route/search" import { OrgMember } from "../shared/org-members" import { Form } from "./form" // `/alerts/escalation-chains/new` and `/alerts/escalation-chains/:chainId`: the chain editor. export const Model = Schema.Struct({ - search: AlertsEscalationChainSearch, /** The chain being edited; `null` creates. */ editingId: Schema.NullOr(EscalationChainId), /** The URL named a chain id that does not decode. */ @@ -22,10 +20,16 @@ export const Model = Schema.Struct({ /** The edited chain's read; the form fills from it once. */ existing: KeyedSchema(V2EscalationChain, LoadFailure), form: Form, + /** The form as it was opened or last saved: `form` differing from it is an unsaved edit. */ + baseline: Form, destinations: Schema.NullOr(Schema.Array(AlertDestinationRowSchema)), rules: Schema.NullOr(Schema.Array(AlertRuleRowSchema)), members: Schema.NullOr(Schema.Array(OrgMember)), + /** `null` until the schedules load, and after a failed read (`isSchedulesFailed`). */ schedules: Schema.NullOr(Schema.Array(V2OnCallSchedule)), + isSchedulesFailed: Schema.Boolean, + /** Destination types whose brand image failed to load: they draw the monogram. */ + brokenLogos: Schema.Array(Schema.String), /** Each step's target picker and actions menu, by menu id. */ menus: Schema.Record(Schema.String, Menu.Model), isSaving: Schema.Boolean, @@ -35,6 +39,9 @@ export const Model = Schema.Struct({ deleteDialog: Dialog.Model, isDeleting: Schema.Boolean, deleteError: Schema.NullOr(Schema.String), + /** "Unsaved changes": a navigation held while the form has edits, and where it was going. */ + leaveDialog: Dialog.Model, + pendingLeaveUrl: Schema.NullOr(Schema.String), grants: Schema.Array(Policy.PermissionName), isSessionLoaded: Schema.Boolean, }) diff --git a/apps/web/src/page/alerts/escalation-chain/update.ts b/apps/web/src/page/alerts/escalation-chain/update.ts index 89b3bd1ad2..7f5ee38ddb 100644 --- a/apps/web/src/page/alerts/escalation-chain/update.ts +++ b/apps/web/src/page/alerts/escalation-chain/update.ts @@ -1,17 +1,17 @@ -import { Option, Result } from "effect" +import { Option, Result, Array as Arr } from "effect" import { AsyncData, Command as FoldkitCommand } from "foldkit" import * as Keyed from "@maple/views/data/keyed" import { requestKey } from "@maple/views/data/request-key" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" +import { triggerId } from "@maple/views/ui/overlay/popup-state" import type { AppRoute } from "../../../route" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { CHAIN_IN_USE_CODE, - chainHref, + CHAIN_NOT_FOUND_CODE, chainIdFromUrl, routingTabHref, - safeReturnTo, } from "../shared/escalation-chains" import { DeleteChain, @@ -19,6 +19,7 @@ import { FetchChainEditorSchedules, FetchChainEditorSession, FetchExistingChain, + FocusChainElement, SaveChain, } from "./command" import { @@ -29,6 +30,7 @@ import { type Form, formFromChain, insertStep, + isSameChain, moveStep, payloadOf, removeStep, @@ -36,7 +38,7 @@ import { targetOfKey, } from "./form" import { Message, type MenuKind } from "./message" -import type { Model } from "./model" +import { canManage, type Model } from "./model" type Return = PageReturn export type ChainRoute = Extract< @@ -45,26 +47,31 @@ export type ChainRoute = Extract< > export const DELETE_DIALOG_ID = "escalation-chain-delete" +export const LEAVE_DIALOG_ID = "escalation-chain-leave" export const menuIdOf = (kind: MenuKind, key: string): string => `escalation-chain-${kind}-${key}` -/** Where Cancel goes: the `returnTo` the link carried, else the Routing section. */ -export const backHref = (model: Pick): string => - Option.getOrElse(safeReturnTo(model.search.returnTo), routingTabHref) +/** The + on the connector above the step at `index` (or below the last one). */ +export const insertButtonId = (index: number): string => `escalation-chain-insert-${index}` + +/** Where Cancel, Save and Delete go back to. */ +export const backHref = (): string => routingTabHref() export const init = (route: ChainRoute, context: PageContext): Return => { const rawId = route._tag === "AlertsEscalationChain" ? Option.some(route.chainId) : Option.none() const editingId = Option.getOrNull(Option.flatMap(rawId, chainIdFromUrl)) const model: Model = { - search: route.search, editingId, isBadId: Option.isSome(rawId) && editingId === null, existing: Keyed.idle(), form: emptyForm, + baseline: emptyForm, destinations: null, rules: null, members: null, schedules: null, + isSchedulesFailed: false, + brokenLogos: [], menus: {}, isSaving: false, isShowingErrors: false, @@ -72,6 +79,8 @@ export const init = (route: ChainRoute, context: PageContext): Return => { deleteDialog: Dialog.init(DELETE_DIALOG_ID, "alert-dialog"), isDeleting: false, deleteError: null, + leaveDialog: Dialog.init(LEAVE_DIALOG_ID, "alert-dialog"), + pendingLeaveUrl: null, grants: [], isSessionLoaded: false, } @@ -91,22 +100,110 @@ export const init = (route: ChainRoute, context: PageContext): Return => { }) } -/** A form edit is dropped while a save is in flight: the saved chain is what the page shows. */ +/** Reads the chain again (a retry, or a refused delete whose rule list may have moved). */ +const refetch = (model: Model, context: PageContext): Return => { + const editingId = model.editingId + if (editingId === null) return { model } + const key = requestKey(context.orgId, "escalationChains.retrieve", { id: editingId }) + return Option.match(Keyed.request(model.existing, key, "Refresh"), { + onNone: () => ({ model }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [FetchExistingChain({ key, chainId: editingId })], + }), + }) +} + +// LEAVING + +/** The form holds edits a save has not sent. */ +export const isDirty = (model: Pick): boolean => + !isSameChain(model.form, model.baseline) + +/** Leaving with unsaved edits asks first; a save or delete in flight is on its way out already. */ +export const isBlockingNavigation = (model: Model): boolean => + !model.isSaving && !model.isDeleting && canManage(model) && isDirty(model) + +const navigate = (model: Model, url: string): Return => ({ + model, + outMessage: PageOutMessage.RequestedNavigation({ url, isReplace: false }), +}) + +/** A save or delete went through: back to Routing in place of this page, with its toast. */ +const finished = (model: Model, title: string): Return => ({ + // The form is what was saved (or is gone): nothing left to ask about on the way out. + model: { ...model, isSaving: false, isDeleting: false, baseline: model.form }, + outMessage: PageOutMessage.RequestedNavigation({ + url: backHref(), + isReplace: true, + toast: { type: "success", title, description: "" }, + }), +}) + +/** The confirmation over a held navigation (a link, Back, or Cancel). */ +export const askToLeave = (model: Model, url: string): Return => { + const next = Dialog.open(model.leaveDialog) + return { + model: { ...model, leaveDialog: next.model, pendingLeaveUrl: url }, + commands: FoldkitCommand.mapMessages(next.commands ?? [], (message) => + Message.GotLeaveDialogMessage({ message }), + ), + } +} + +const leave = (model: Model, url: string): Return => + isBlockingNavigation(model) ? askToLeave(model, url) : navigate(model, url) + +// STEP EDITS + +/** A form edit is dropped while a save is in flight, and for a viewer who may not change chains. */ +const canEdit = (model: Model): boolean => !model.isSaving && canManage(model) + const withForm = (model: Model, edit: (form: Form) => Form): Return => - model.isSaving ? { model } : { model: { ...model, form: edit(model.form) } } + canEdit(model) ? { model: { ...model, form: edit(model.form) } } : { model } + +const focus = (id: string) => [FocusChainElement({ id })] + +/** A step that was just added: focus lands on its "Add" button, so its targets come next. */ +const added = (model: Model, form: Form): Return => + form.nextKey === model.form.nextKey + ? { model } + : { + model: { ...model, form }, + commands: focus(triggerId(menuIdOf("targets", `s${model.form.nextKey}`))), + } -const stepAction = (form: Form, key: string, value: string): Form => { +/** A removed step hands focus to the step before it, or to the + that now leads the chain. */ +const removed = (model: Model, key: string): Return => { + const form = removeStep(model.form, key) + if (form.steps.length === model.form.steps.length) return { model } + const index = Option.getOrElse( + Arr.findFirstIndex(model.form.steps, (step) => step.key === key), + () => 0, + ) + const previous = index === 0 ? undefined : model.form.steps[index - 1] + return { + model: { ...model, form }, + commands: focus( + previous === undefined ? insertButtonId(0) : triggerId(menuIdOf("actions", previous.key)), + ), + } +} + +const stepAction = (model: Model, key: string, value: string): Return => { switch (value) { case "up": - return moveStep(form, key, "up") case "down": - return moveStep(form, key, "down") + return { + model: { ...model, form: moveStep(model.form, key, value) }, + commands: focus(triggerId(menuIdOf("actions", key))), + } case "duplicate": - return duplicateStep(form, key) + return added(model, duplicateStep(model.form, key)) case "remove": - return removeStep(form, key) + return removed(model, key) default: - return form + return { model } } } @@ -121,23 +218,26 @@ const stepMenu = (model: Model, kind: MenuKind, key: string, message: Menu.Messa const picked = result.outMessage?._tag === "SelectedItem" ? Option.some(result.outMessage.value) : Option.none() // The menus still open and close while saving, but a pick no longer edits the chain. - return Option.match(model.isSaving ? Option.none() : picked, { + return Option.match(canEdit(model) ? picked : Option.none(), { onNone: () => ({ model: next, commands }), - onSome: (value) => - kind === "actions" - ? { model: { ...next, form: stepAction(next.form, key, value) }, commands } - : Option.match(targetOfKey(value), { - onNone: () => ({ model: next, commands }), - onSome: (target) => ({ - model: { ...next, form: addTarget(next.form, key, target) }, - commands, - }), - }), + onSome: (value) => { + if (kind === "actions") { + const acted = stepAction(next, key, value) + return { model: acted.model, commands: [...commands, ...(acted.commands ?? [])] } + } + return Option.match(targetOfKey(value), { + onNone: () => ({ model: next, commands }), + onSome: (target) => ({ + model: { ...next, form: addTarget(next.form, key, target) }, + commands, + }), + }) + }, }) } const save = (model: Model): Return => - model.isSaving + !canEdit(model) ? { model } : Option.match(payloadOf(model.form), { onNone: () => ({ model: { ...model, isShowingErrors: true } }), @@ -158,35 +258,30 @@ const liftDelete = (model: Model, step: ReturnType): Retur ), }) +const deleted = (model: Model): Return => finished(model, "Escalation chain deleted") + export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { CompletedFetchExisting: ({ key, result }) => { const existing = Keyed.accept(model.existing, key, result) - // The form fills once, from the first read. - const isFirst = !AsyncData.isSuccess(model.existing.data) + // The form fills once, from the first read; a later read only refreshes who uses the chain. + const isFirst = Option.isNone(AsyncData.getData(model.existing.data)) return { model: Result.match(result, { - onSuccess: (chain) => ({ - ...model, - existing, - form: isFirst ? formFromChain(chain) : model.form, - }), + onSuccess: (chain) => + isFirst + ? { + ...model, + existing, + form: formFromChain(chain), + baseline: formFromChain(chain), + } + : { ...model, existing }, onFailure: () => ({ ...model, existing }), }), } }, - ClickedRetryExisting: () => { - const editingId = model.editingId - if (editingId === null) return { model } - const key = requestKey(context.orgId, "escalationChains.retrieve", { id: editingId }) - return Option.match(Keyed.request(model.existing, key, "Refresh"), { - onNone: () => ({ model }), - onSome: (existing) => ({ - model: { ...model, existing }, - commands: [FetchExistingChain({ key, chainId: editingId })], - }), - }) - }, + ClickedRetryExisting: () => refetch(model, context), UpdatedDestinations: ({ orgId, rows }) => orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, UpdatedRules: ({ orgId, rows }) => @@ -195,12 +290,12 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchMembers: ({ result }) => Result.match(result, { onSuccess: (members) => ({ model: { ...model, members } }), - onFailure: () => ({ model: { ...model, members: [] } }), + onFailure: () => ({ model }), }), CompletedFetchSchedules: ({ result }) => Result.match(result, { - onSuccess: (schedules) => ({ model: { ...model, schedules } }), - onFailure: () => ({ model: { ...model, schedules: [] } }), + onSuccess: (schedules) => ({ model: { ...model, schedules, isSchedulesFailed: false } }), + onFailure: () => ({ model: { ...model, isSchedulesFailed: true } }), }), CompletedFetchSession: ({ orgId, grants }) => orgId === context.orgId ? { model: { ...model, grants, isSessionLoaded: true } } : { model }, @@ -209,30 +304,19 @@ export const update = (model: Model, message: Message, context: PageContext): Re ChangedRepeatCount: ({ value }) => withForm(model, (form) => ({ ...form, repeatCount: value })), ChangedWait: ({ key, value }) => withForm(model, (form) => editStep(form, key, (step) => ({ ...step, waitMinutes: value }))), - ClickedInsertStep: ({ index }) => withForm(model, (form) => insertStep(form, index)), - ClickedRemoveStep: ({ key }) => withForm(model, (form) => removeStep(form, key)), - ClickedDuplicateStep: ({ key }) => withForm(model, (form) => duplicateStep(form, key)), - ClickedMoveStep: ({ key, direction }) => withForm(model, (form) => moveStep(form, key, direction)), + ClickedInsertStep: ({ index }) => + canEdit(model) ? added(model, insertStep(model.form, index)) : { model }, GotMenuMessage: ({ kind, key, message: child }) => stepMenu(model, kind, key, child), ClickedRemoveTarget: ({ key, target }) => withForm(model, (form) => removeTarget(form, key, target)), ClickedSave: () => save(model), CompletedSave: ({ result }) => Result.match(result, { - onSuccess: (chain): Return => ({ - model: { ...model, isSaving: false }, - outMessage: PageOutMessage.RequestedNavigation({ - url: Option.getOrElse(safeReturnTo(model.search.returnTo), () => chainHref(chain.id)), - isReplace: true, - toast: { - type: "success", - title: - model.editingId === null - ? "Escalation chain created" - : "Escalation chain saved", - description: "", - }, - }), - }), + // Back to Routing, as the rule editor goes back to Rules: the list shows the saved chain. + onSuccess: (): Return => + finished( + model, + model.editingId === null ? "Escalation chain created" : "Escalation chain saved", + ), onFailure: (failure): Return => ({ model: { ...model, isSaving: false, saveFailure: failure }, }), @@ -240,11 +324,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re GotDeleteDialogMessage: ({ message: child }) => model.isDeleting ? { model } : liftDelete(model, Dialog.update(model.deleteDialog, child)), ClickedDelete: () => - model.editingId === null + model.editingId === null || !canManage(model) ? { model } : liftDelete({ ...model, deleteError: null }, Dialog.open(model.deleteDialog)), ClickedConfirmDelete: () => - model.editingId === null || model.isDeleting + model.editingId === null || model.isDeleting || !canManage(model) ? { model } : { model: { ...model, isDeleting: true, deleteError: null }, @@ -252,23 +336,50 @@ export const update = (model: Model, message: Message, context: PageContext): Re }, CompletedDelete: ({ result }) => Result.match(result, { - onSuccess: (): Return => ({ - model: { ...model, isDeleting: false }, - outMessage: PageOutMessage.RequestedNavigation({ - url: routingTabHref(), - isReplace: true, - toast: { type: "success", title: "Escalation chain deleted", description: "" }, - }), - }), - onFailure: (failure): Return => ({ - model: { - ...model, - isDeleting: false, - deleteError: - failure.code === CHAIN_IN_USE_CODE - ? "An alert rule still starts this chain. Remove it from the rule's severity tiers first." - : failure.message, - }, - }), + onSuccess: (): Return => deleted(model), + onFailure: (failure): Return => { + // Someone else deleted it first: the outcome the viewer asked for. + if (failure.code === CHAIN_NOT_FOUND_CODE) return deleted(model) + const settled = { ...model, isDeleting: false } + // A rule took the chain since this page read it: read it again, so the dialog + // lists the rule and holds its Delete. + return failure.code === CHAIN_IN_USE_CODE + ? refetch( + { + ...settled, + deleteError: + "An alert rule still starts this chain. Remove it from the rule's severity tiers first.", + }, + context, + ) + : { model: { ...settled, deleteError: failure.message } } + }, }), + ClickedCancel: () => leave(model, backHref()), + GotLeaveDialogMessage: ({ message: child }) => { + const next = Dialog.update(model.leaveDialog, child) + return { + model: { ...model, leaveDialog: next.model }, + commands: FoldkitCommand.mapMessages(next.commands ?? [], (inner) => + Message.GotLeaveDialogMessage({ message: inner }), + ), + } + }, + ClickedDiscard: () => { + const closed = Dialog.close(model.leaveDialog) + return { + ...navigate( + { ...model, leaveDialog: closed.model, baseline: model.form }, + model.pendingLeaveUrl ?? backHref(), + ), + commands: FoldkitCommand.mapMessages(closed.commands ?? [], (inner) => + Message.GotLeaveDialogMessage({ message: inner }), + ), + } + }, + FailedLoadProviderLogo: ({ type }) => + model.brokenLogos.includes(type) + ? { model } + : { model: { ...model, brokenLogos: [...model.brokenLogos, type] } }, + CompletedFocus: () => ({ model }), }) diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index f9099298be..45b4c451ec 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -1,7 +1,8 @@ -import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" +import { MAX_ESCALATION_REPEATS, MAX_ESCALATION_STEPS, MAX_ESCALATION_WAIT_MINUTES } from "@maple/domain/http" import type { AlertDestinationId } from "@maple/domain/http" import type { V2EscalationTarget } from "@maple/domain/http/v2" import { pluralize } from "@maple/ui/lib/format" +import { rowToAlertDestinationDocument } from "@maple/web-data/collections/alerts" import { Option, Array as Arr, String as Str } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -9,12 +10,9 @@ import { failureMessage } from "@maple/views/data/load-failure" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { button, linkButton } from "@maple/views/ui/button" import { confirmDialog } from "@maple/views/ui/confirm-dialog" -import { separator } from "@maple/views/ui/display" import { errorState } from "@maple/views/ui/error-state" -import { resourceNotFound } from "../../kit/resource-not-found" import { fieldErrorText, type FieldIds, fieldLabel, input } from "@maple/views/ui/field" import { icon } from "@maple/views/ui/icon" -import { iconButton } from "@maple/views/ui/icon-button" import type { FieldState } from "@maple/views/ui/input" import * as Menu from "@maple/views/ui/menu" import type { MenuEntry } from "@maple/views/ui/menu-view" @@ -22,17 +20,20 @@ import { panel } from "@maple/views/ui/panel" import { personAvatar } from "@maple/views/ui/person-avatar" import { rowActionsMenu } from "@maple/views/ui/row-actions-menu" import { skeleton } from "@maple/views/ui/skeleton" -import { hstack, vstack } from "@maple/views/ui/stack" +import { hstack, hstackClass, vstack } from "@maple/views/ui/stack" import { textLink } from "@maple/views/ui/text-link" import { textarea } from "@maple/views/ui/textarea" import { typography } from "@maple/views/ui/typography" import type { PageViewInputs } from "../../contract" +import { chainConnector } from "../../kit/chain-connector" import { dashboardPage } from "../../kit/dashboard-page" import { managersOnlyPage } from "../../kit/form-section" -import { type PickedEntry, pickList } from "../../kit/pick-list" -import { chainSummary } from "../shared/escalation-chains" +import { type PickedChip, pickChips } from "../../kit/pick-chips" +import { resourceNotFound } from "../../kit/resource-not-found" +import { CHAIN_NOT_FOUND_CODE, chainSummary, routingTabHref } from "../shared/escalation-chains" import { ruleHref } from "../shared/links" import { memberName, memberOf } from "../shared/org-members" +import { providerLogo } from "../shared/providers" import { canAddStep, descriptionError, @@ -49,7 +50,7 @@ import { } from "./form" import { Message } from "./message" import { canManage, type Model } from "./model" -import { backHref, DELETE_DIALOG_ID, menuIdOf } from "./update" +import { backHref, DELETE_DIALOG_ID, insertButtonId, LEAVE_DIALOG_ID, menuIdOf } from "./update" // The escalation chain editor: the chain as a column of sentence cards, its name and preview beside it. @@ -77,6 +78,10 @@ const nameOr = ( () => (rows === null ? fallback : `Unavailable ${Str.toLowerCase(fallback)}`), ) +/** A person by id; someone removed from the org reads "Unavailable member", as a deleted destination does. */ +const personName = (model: Model, userId: string): string => + memberName(model.members, userId, "Unavailable member") + /** A field's error once a save was tried, or at once for a number typed out of range. */ const shown = (model: Model, error: Option.Option, isImmediate = false): Option.Option => model.isShowingErrors || isImmediate ? error : Option.none() @@ -99,10 +104,47 @@ const stateOf = (value: string, isInvalid: boolean): FieldState => ({ isTouched: false, }) +/** Nothing can be typed: a save is in flight, or the viewer may only read the chain. */ +const isLocked = (model: Model): boolean => model.isSaving || !canManage(model) + // STEP CARDS -/** A picked target's row: a destination's bell, a member's avatar, a schedule's calendar. */ -const targetEntry = (h: H, model: Model, key: string, target: V2EscalationTarget): PickedEntry => { +/** A destination's own mark (Slack, PagerDuty, email); a bell once the destination is gone. */ +const destinationMedia = (h: H, model: Model, id: AlertDestinationId): Html => + Option.match( + Option.flatMap( + Arr.findFirst(model.destinations ?? [], (row) => row.id === id), + rowToAlertDestinationDocument, + ), + { + onNone: () => icon(h, "BellIcon", { size: 14, className: "shrink-0 text-muted-foreground" }), + onSome: (destination) => + providerLogo(h, { + type: destination.type, + chatConnector: destination.chatConnector ?? undefined, + size: 30, + isBare: true, + className: hstackClass({ className: "shrink-0" }), + isLogoBroken: model.brokenLogos.includes(destination.type), + onLogoError: Message.FailedLoadProviderLogo({ type: destination.type }), + }), + }, + ) + +/** Who a schedule pages right now, once the schedules have loaded. */ +const onCallNote = (model: Model, scheduleId: string): string | undefined => + Option.getOrUndefined( + Option.map( + Arr.findFirst(model.schedules ?? [], (schedule) => schedule.id === scheduleId), + (schedule) => + schedule.on_call_now === null + ? "nobody on call" + : `${personName(model, schedule.on_call_now.user_id)} on call`, + ), + ) + +/** A picked target's chip: a destination's logo, a member's avatar, a schedule's calendar. */ +const targetChip = (h: H, model: Model, key: string, target: V2EscalationTarget): PickedChip => { const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) switch (target.type) { case "destination": { @@ -110,13 +152,13 @@ const targetEntry = (h: H, model: Model, key: string, target: V2EscalationTarget return { key: targetKey(target), label: name, - media: icon(h, "BellIcon", { className: "text-muted-foreground" }), + media: destinationMedia(h, model, target.destination_id), removeLabel: `Stop notifying ${name}`, onRemove, } } case "user": { - const name = memberName(model.members, target.user_id) + const name = personName(model, target.user_id) return { key: targetKey(target), label: name, @@ -127,18 +169,18 @@ const targetEntry = (h: H, model: Model, key: string, target: V2EscalationTarget Option.map(memberOf(model.members, target.user_id), (member) => member.imageUrl), ), }), - detail: "email and chat DM", removeLabel: `Stop paging ${name}`, onRemove, } } case "schedule": { const name = nameOr(model.schedules, target.schedule_id, "Schedule") + const detail = onCallNote(model, target.schedule_id) return { key: targetKey(target), label: name, - media: icon(h, "CalendarIcon", { className: "text-muted-foreground" }), - detail: "whoever is on call", + media: icon(h, "CalendarIcon", { size: 14, className: "shrink-0 text-muted-foreground" }), + ...(detail === undefined ? undefined : { detail }), removeLabel: `Stop paging ${name}`, onRemove, } @@ -146,8 +188,26 @@ const targetEntry = (h: H, model: Model, key: string, target: V2EscalationTarget } } +/** The picker with nothing to offer: where a destination is made (in its own tab, keeping the draft). */ +const nothingToAdd = (h: H, model: Model): Html => + h.div( + [h.Class("px-2 py-1.5")], + [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + (model.destinations ?? []).length === 0 + ? "No destinations yet. " + : "Everything is on this step already. ", + textLink(h, { href: routingTabHref(), attributes: [h.Target("_blank"), h.Rel("noopener")] }, [ + "Create a destination", + ]), + ]), + ], + ) + +const loadingEntry = (label: string): MenuEntry => ({ _tag: "Label", label }) + /** The target picker: destinations, people and schedules not yet on the step, by kind. */ -const addEntries = (model: Model, step: StepDraft): ReadonlyArray => { +const addEntries = (h: H, model: Model, step: StepDraft): ReadonlyArray => { const picked = new Set(Arr.map(step.targets, targetKey)) const group = ( groupLabel: string, @@ -164,11 +224,16 @@ const addEntries = (model: Model, step: StepDraft): ReadonlyArray => }, ] } - return [ - ...group( - "Destinations", - Arr.map(model.destinations ?? [], (row) => ({ value: `destination:${row.id}`, label: row.name })), - ), + const entries: ReadonlyArray = [ + ...(model.destinations === null + ? [loadingEntry("Loading destinations")] + : group( + "Destinations", + Arr.map(model.destinations, (row) => ({ + value: `destination:${row.id}`, + label: row.name, + })), + )), ...group( "People", Arr.map(model.members ?? [], (member) => ({ @@ -176,14 +241,19 @@ const addEntries = (model: Model, step: StepDraft): ReadonlyArray => label: member.name, })), ), - ...group( - "Whoever is on call on", - Arr.map(model.schedules ?? [], (schedule) => ({ - value: `schedule:${schedule.id}`, - label: schedule.name, - })), - ), + ...(model.schedules === null + ? model.isSchedulesFailed + ? [] + : [loadingEntry("Loading schedules")] + : group( + "Whoever is on call on", + Arr.map(model.schedules, (schedule) => ({ + value: `schedule:${schedule.id}`, + label: schedule.name, + })), + )), ] + return entries.length === 0 ? [{ _tag: "Content", content: nothingToAdd(h, model) }] : entries } const stepActions = (model: Model, index: number): ReadonlyArray => { @@ -216,13 +286,13 @@ const stepActions = (model: Model, index: number): ReadonlyArray => { ] } -/** What the wait after a step is for: the next step, a repeat, or nothing once the chain stops. */ -const waitTail = (model: Model, index: number): string => - index < model.form.steps.length - 1 - ? "for an acknowledgement" - : (numberOf(model.form.repeatCount) ?? 0) > 0 - ? "for an acknowledgement, then repeat" - : "for an acknowledgement" +/** What follows a step's wait: the next step, another pass, or nothing once the chain stops. */ +const waitTail = (model: Model, index: number): string => { + if (index < model.form.steps.length - 1) return "minutes for an acknowledgement" + return (numberOf(model.form.repeatCount) ?? 0) > 0 + ? "minutes for an acknowledgement, then repeat" + : "minutes, then stop" +} const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { const key = step.key @@ -230,31 +300,39 @@ const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { const targetsMenuId = menuIdOf("targets", key) const actionsMenuId = menuIdOf("actions", key) const waitId = `escalation-chain-wait-${key}` - const waitState = stateOf( - step.waitMinutes, - Option.isSome(shown(model, wait, isOutOfRange(step.waitMinutes, wait))), - ) - return panel(h, { padded: "sm", className: "gap-3", attributes: [h.Key(key)] }, [ - hstack(h, { gap: "2", justify: "between" }, [ - typography(h, { size: "sm", weight: "medium" }, [index === 0 ? "Notify" : "Then notify"]), - rowActionsMenu(h, { - slotId: actionsMenuId, - model: model.menus[actionsMenuId] ?? Menu.init(actionsMenuId), - entries: stepActions(model, index), - label: `Actions for step ${index + 1}`, - toParentMessage: (message) => Message.GotMenuMessage({ kind: "actions", key, message }), - }), + const shownWait = shown(model, wait, isOutOfRange(step.waitMinutes, wait)) + const waitState = stateOf(step.waitMinutes, Option.isSome(shownWait)) + return panel(h, { padded: "sm", className: "gap-2", attributes: [h.Key(key)] }, [ + hstack(h, { gap: "2", align: "start" }, [ + hstack(h, { gap: "1.5", wrap: true, grow: true, shrink: true, className: "min-h-7" }, [ + typography(h, { size: "sm", weight: "medium", className: "mr-0.5" }, [ + index === 0 ? "Notify" : "Then notify", + ]), + ...pickChips(h, { + entries: Arr.map(step.targets, (target) => targetChip(h, model, key, target)), + menu: model.menus[targetsMenuId] ?? Menu.init(targetsMenuId), + toMenuMessage: (message) => Message.GotMenuMessage({ kind: "targets", key, message }), + addEntries: addEntries(h, model, step), + addLabel: "Add destination, person or schedule", + addAriaLabel: `Add destination, person or schedule to step ${index + 1}`, + isDisabled: model.isSaving, + isReadOnly: !canManage(model), + emptyText: "nobody", + }), + ]), + ...(canManage(model) + ? [ + rowActionsMenu(h, { + slotId: actionsMenuId, + model: model.menus[actionsMenuId] ?? Menu.init(actionsMenuId), + entries: stepActions(model, index), + label: `Actions for step ${index + 1}`, + toParentMessage: (message) => + Message.GotMenuMessage({ kind: "actions", key, message }), + }), + ] + : []), ]), - pickList(h, { - entries: Arr.map(step.targets, (target) => targetEntry(h, model, key, target)), - menu: model.menus[targetsMenuId] ?? Menu.init(targetsMenuId), - toMenuMessage: (message) => Message.GotMenuMessage({ kind: "targets", key, message }), - addEntries: addEntries(model, step), - addLabel: "Add destination, person or schedule", - isDisabled: model.isSaving, - context: `step ${index + 1}`, - emptyText: "Pick who this step pages", - }), ...errorText(h, shown(model, targetsError(step.targets))), hstack(h, { gap: "2", wrap: true }, [ fieldLabel(h, { ids: idsOf(waitId), state: waitState, className: "font-normal" }, "then wait"), @@ -262,7 +340,7 @@ const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { ids: idsOf(waitId), state: waitState, className: "w-20", - isDisabled: model.isSaving, + isDisabled: isLocked(model), attributes: [ h.Type("number"), h.Min("1"), @@ -273,34 +351,37 @@ const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { h.OnInput((value) => Message.ChangedWait({ key, value })), ], }), - typography(h, { size: "sm" }, [`minutes ${waitTail(model, index)}`]), + typography(h, { size: "sm" }, [waitTail(model, index)]), ]), - ...errorText(h, shown(model, wait, isOutOfRange(step.waitMinutes, wait))), + ...errorText(h, shownWait), ]) } -/** The line between two cards, with a "+" that adds a step there. */ -const connector = (h: H, model: Model, index: number): Html => - vstack(h, { align: "center", attributes: [h.Key(`connector-${index}`)] }, [ - separator(h, "vertical", "h-3"), - iconButton( - h, - { - label: index === 0 ? "Add a step at the start" : `Add a step after step ${index}`, - variant: "outline", - size: "icon-xs", - isDisabled: model.isSaving || !canAddStep(model.form), - attributes: [h.OnClick(Message.ClickedInsertStep({ index }))], - }, - [icon(h, "PlusIcon", { size: 12 })], - ), - separator(h, "vertical", "h-3"), - ]) +/** The line between two cards; while a step can be added, an editor gets a + on it that adds one there. */ +const connector = (h: H, model: Model, index: number): Html => { + const count = model.form.steps.length + return chainConnector(h, { + attributes: [h.Key(`connector-${index}`)], + insert: + canManage(model) && !model.isSaving && canAddStep(model.form) + ? { + id: insertButtonId(index), + label: + index === 0 + ? "Add a step at the start" + : index === count + ? "Add a step at the end" + : `Add a step after step ${index}`, + onClick: Message.ClickedInsertStep({ index }), + } + : null, + }) +} const startCard = (h: H): Html => panel(h, { padded: "sm", tone: "muted" }, [ hstack(h, { gap: "2" }, [ - icon(h, "BellIcon", { className: "text-muted-foreground" }), + icon(h, "BellIcon", { size: 16, className: "shrink-0 text-muted-foreground" }), typography(h, { size: "sm" }, ["When an alert escalates to this chain"]), ]), ]) @@ -309,35 +390,35 @@ const endCard = (h: H, model: Model): Html => { const repeat = repeatError(model.form.repeatCount) const count = numberOf(model.form.repeatCount) ?? 0 const id = "escalation-chain-repeat" - const state = stateOf( - model.form.repeatCount, - Option.isSome(shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat))), - ) + const shownRepeat = shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat)) + const state = stateOf(model.form.repeatCount, Option.isSome(shownRepeat)) return panel(h, { padded: "sm", tone: "muted", className: "gap-2" }, [ - hstack(h, { gap: "2", wrap: true }, [ - icon(h, "ArrowPathIcon", { className: "text-muted-foreground" }), - fieldLabel( - h, - { ids: idsOf(id), state, className: "font-normal" }, - "If nobody acknowledges, repeat the chain", - ), - input(h, { - ids: idsOf(id), - state, - className: "w-20", - isDisabled: model.isSaving, - attributes: [ - h.Type("number"), - h.Min("0"), - h.Attribute("max", String(MAX_ESCALATION_REPEATS)), - h.Attribute("step", "1"), - h.Value(model.form.repeatCount), - h.OnInput((value) => Message.ChangedRepeatCount({ value })), - ], - }), - typography(h, { size: "sm" }, [count === 0 ? "times, then stop" : pluralize(count, "time")]), + hstack(h, { gap: "2" }, [ + icon(h, "ArrowPathIcon", { size: 16, className: "shrink-0 text-muted-foreground" }), + hstack(h, { gap: "2", wrap: true, shrink: true }, [ + fieldLabel( + h, + { ids: idsOf(id), state, className: "font-normal" }, + "If nobody acknowledges, repeat the chain", + ), + input(h, { + ids: idsOf(id), + state, + className: "w-16", + isDisabled: isLocked(model), + attributes: [ + h.Type("number"), + h.Min("0"), + h.Attribute("max", String(MAX_ESCALATION_REPEATS)), + h.Attribute("step", "1"), + h.Value(model.form.repeatCount), + h.OnInput((value) => Message.ChangedRepeatCount({ value })), + ], + }), + typography(h, { size: "sm" }, [count === 0 ? "times, then stop" : pluralize(count, "time")]), + ]), ]), - ...errorText(h, shown(model, repeat, isOutOfRange(model.form.repeatCount, repeat))), + ...errorText(h, shownRepeat), ]) } @@ -351,6 +432,13 @@ const chainColumn = (h: H, model: Model): Html => connector(h, model, model.form.steps.length), endCard(h, model), ...errorText(h, shown(model, stepsError(model.form.steps))), + ...(canManage(model) && !canAddStep(model.form) + ? [ + typography(h, { size: "xs", tone: "muted", as: "p", className: "mt-2" }, [ + `A chain has at most ${MAX_ESCALATION_STEPS} steps`, + ]), + ] + : []), ]) // ASIDE @@ -367,7 +455,7 @@ const preview = (h: H, model: Model): Html => { chain.steps.length === 0 ? "Pick who the first step pages to see the chain in one line." : chainSummary(chain, destinationName(model), { - userName: (id) => memberName(model.members, id), + userName: (id) => personName(model, id), scheduleName: (id) => model.schedules === null ? undefined : nameOr(model.schedules, id, "Schedule"), }) @@ -377,6 +465,53 @@ const preview = (h: H, model: Model): Html => { ]) } +/** Name, notes and the one-line preview. */ +const details = (h: H, model: Model): Html => { + const name = shown(model, nameError(model.form.name)) + const description = shown(model, descriptionError(model.form.description)) + const nameState = stateOf(model.form.name, Option.isSome(name)) + const notesIds = idsOf("escalation-chain-description") + return panel(h, { padded: "sm", className: "order-first gap-4 lg:order-none" }, [ + vstack(h, { gap: "1.5" }, [ + fieldLabel(h, { ids: idsOf("escalation-chain-name"), state: nameState }, "Name"), + input(h, { + ids: idsOf("escalation-chain-name"), + state: nameState, + isDisabled: isLocked(model), + attributes: [ + h.Value(model.form.name), + h.Placeholder("Checkout on-call"), + h.Attribute("autocomplete", "off"), + h.OnInput((value) => Message.ChangedName({ value })), + ], + }), + ...errorText(h, name), + ]), + vstack(h, { gap: "1.5" }, [ + fieldLabel( + h, + { ids: notesIds, state: stateOf(model.form.description, Option.isSome(description)) }, + "Notes", + ), + textarea(h, { + id: notesIds.control, + defaultValue: model.form.description, + isDisabled: isLocked(model), + attributes: [ + // Uncontrolled: keyed on the chain, so a loaded chain rebuilds it with its text. + h.Key(`escalation-chain-description-${model.editingId ?? "new"}`), + h.AriaLabelledBy(notesIds.label), + h.Placeholder("Optional: who is on this chain and when to change it"), + h.Rows(2), + h.OnInput((value) => Message.ChangedDescription({ value })), + ], + }), + ...errorText(h, description), + ]), + preview(h, model), + ]) +} + /** The rules whose tiers start this chain, as links; ones not synced yet are counted. */ const usedBy = (h: H, model: Model): ReadonlyArray => Option.match(AsyncData.getData(model.existing.data), { @@ -405,6 +540,14 @@ const usedBy = (h: H, model: Model): ReadonlyArray => typography(h, { size: "sm" }, [ textLink(h, { href: ruleHref(rule.id) }, [rule.name]), ]), + // A paused rule starts no chain until it is resumed. + ...(rule.enabled + ? [] + : [ + typography(h, { size: "xs", tone: "muted" }, [ + " · paused", + ]), + ]), ], ), ), @@ -428,73 +571,36 @@ const usedBy = (h: H, model: Model): ReadonlyArray => }, }) -const aside = (h: H, model: Model): Html => { - const name = shown(model, nameError(model.form.name)) - const description = shown(model, descriptionError(model.form.description)) - const nameState = stateOf(model.form.name, Option.isSome(name)) - const notesIds = idsOf("escalation-chain-description") - return h.aside( - [h.Class("order-first min-w-0 lg:order-none")], - [ - panel(h, { padded: "sm", className: "gap-4 lg:sticky lg:top-4" }, [ - vstack(h, { gap: "1.5" }, [ - fieldLabel(h, { ids: idsOf("escalation-chain-name"), state: nameState }, "Name"), - input(h, { - ids: idsOf("escalation-chain-name"), - state: nameState, - isDisabled: model.isSaving, - attributes: [ - h.Value(model.form.name), - h.Placeholder("Checkout on-call"), - h.Attribute("autocomplete", "off"), - h.OnInput((value) => Message.ChangedName({ value })), - ], - }), - ...errorText(h, name), - ]), - vstack(h, { gap: "1.5" }, [ - fieldLabel( +/** Under the details: who uses the chain, and its quiet Delete. */ +const meta = (h: H, model: Model): ReadonlyArray => { + const rows = [ + ...usedBy(h, model), + ...(model.editingId !== null && canManage(model) + ? [ + button( h, - { ids: notesIds, state: stateOf(model.form.description, Option.isSome(description)) }, - "Notes", + { + variant: "destructive-ghost", + size: "sm", + className: "-ml-2 self-start", + isDisabled: model.isSaving || model.isDeleting, + attributes: [h.OnClick(Message.ClickedDelete())], + }, + [icon(h, "TrashIcon"), "Delete chain"], ), - textarea(h, { - id: notesIds.control, - defaultValue: model.form.description, - isDisabled: model.isSaving, - attributes: [ - // Uncontrolled: keyed on the chain, so a loaded chain rebuilds it with its text. - h.Key(`escalation-chain-description-${model.editingId ?? "new"}`), - h.AriaLabelledBy(notesIds.label), - h.Placeholder("Optional: who is on this chain and when to change it"), - h.Rows(3), - h.OnInput((value) => Message.ChangedDescription({ value })), - ], - }), - ...errorText(h, description), - ]), - preview(h, model), - ...usedBy(h, model), - ...(model.editingId === null - ? [] - : [ - button( - h, - { - variant: "outline", - size: "sm", - className: "self-start", - isDisabled: model.isSaving || model.isDeleting, - attributes: [h.OnClick(Message.ClickedDelete())], - }, - [icon(h, "TrashIcon"), "Delete chain"], - ), - ]), - ]), - ], - ) + ] + : []), + ] + return rows.length === 0 ? [] : [vstack(h, { gap: "3", className: "px-1" }, rows)] } +// Below `lg` the aside dissolves into the grid: the details lead, the chain follows, then the rest. +const aside = (h: H, model: Model): Html => + h.aside( + [h.Class("contents lg:sticky lg:top-0 lg:block lg:space-y-4")], + [details(h, model), ...meta(h, model)], + ) + /** "Delete {chain}?": lists the rules that start it, which the API makes move off it first. */ const deleteDialog = (h: H, model: Model): Html => { const chain = AsyncData.getData(model.existing.data) @@ -530,7 +636,9 @@ const deleteDialog = (h: H, model: Model): Html => { [], [ typography(h, { size: "sm", tone: "muted" }, [ - `and ${ruleIds.length - names.length} more`, + names.length === 0 + ? `${ruleIds.length} ${pluralize(ruleIds.length, "rule")}` + : `and ${ruleIds.length - names.length} more`, ]), ], ), @@ -557,23 +665,42 @@ const deleteDialog = (h: H, model: Model): Html => { }) } +/** "Unsaved changes": a link, Back or Cancel held while the form has edits. */ +const leaveDialog = (h: H, model: Model): Html => + confirmDialog(h, { + slotId: LEAVE_DIALOG_ID, + model: model.leaveDialog, + toParentMessage: (message) => Message.GotLeaveDialogMessage({ message }), + title: "Unsaved changes", + description: "This chain has changes you have not saved. Leave without saving them?", + cancelLabel: "Stay", + confirmLabel: "Discard changes", + onConfirm: Message.ClickedDiscard(), + }) + // PAGE const actions = (h: H, model: Model): Html => - hstack(h, { gap: "2" }, [ - linkButton(h, { variant: "outline", href: backHref(model) }, ["Cancel"]), - button( - h, - { - isDisabled: !canManage(model), - isLoading: model.isSaving, - attributes: [h.OnClick(Message.ClickedSave())], - }, - [model.editingId === null ? "Create chain" : "Save chain"], - ), - ]) + canManage(model) + ? hstack(h, { gap: "2" }, [ + button(h, { variant: "outline", attributes: [h.OnClick(Message.ClickedCancel())] }, [ + "Cancel", + ]), + button(h, { isLoading: model.isSaving, attributes: [h.OnClick(Message.ClickedSave())] }, [ + model.editingId === null ? "Create chain" : "Save chain", + ]), + ]) + : linkButton(h, { variant: "outline", href: backHref() }, ["Back to routing"]) -const saveNotice = (h: H, model: Model): ReadonlyArray => [ +const notices = (h: H, model: Model): ReadonlyArray => [ + ...(canManage(model) + ? [] + : [ + alert(h, { variant: "info", size: "sm" }, [ + icon(h, "ShieldIcon"), + alertTitle(h, ["Only org admins and engineers can change escalation chains"]), + ]), + ]), ...(model.saveFailure === null ? [] : [ @@ -582,7 +709,11 @@ const saveNotice = (h: H, model: Model): ReadonlyArray => [ alertTitle(h, [ model.editingId === null ? "Couldn't create chain" : "Couldn't save chain", ]), - alertDescription(h, [failureMessage(model.saveFailure)]), + alertDescription(h, [ + model.saveFailure.code === CHAIN_NOT_FOUND_CODE + ? "This chain no longer exists. Someone deleted it while you were editing." + : failureMessage(model.saveFailure), + ]), ]), ]), ...(model.isShowingErrors && formIssues(model.form).length > 0 @@ -594,6 +725,13 @@ const saveNotice = (h: H, model: Model): ReadonlyArray => [ : []), ] +const notFound = (h: H): Html => + resourceNotFound(h, { + title: "Escalation chain not found", + description: "It may have been deleted, or the link is wrong.", + back: { href: backHref(), label: "Back to routing" }, + }) + export const view = Submodel.defineView((model, _inputs, h) => { const isEdit = model.editingId !== null const header = { @@ -601,36 +739,36 @@ export const view = Submodel.defineView((model, description: "Page step by step until someone acknowledges. Acknowledging pauses it, resolving ends it.", } - if (model.isSessionLoaded && !canManage(model)) + const loading = dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + if (model.isBadId) return dashboardPage(h, header, [notFound(h)]) + if (!model.isSessionLoaded) return loading + // Nothing to read on a new chain: a viewer who cannot create one is told who can. + if (!isEdit && !canManage(model)) return managersOnlyPage(h, { header, - title: "Only org admins and engineers can create or change escalation chains", - back: { href: backHref(model), label: "Back to routing" }, + title: "Only org admins and engineers can create escalation chains", + back: { href: backHref(), label: "Back to routing" }, }) - if (model.isBadId) - return dashboardPage(h, header, [ - resourceNotFound(h, { - title: "Escalation chain not found", - description: "This link does not name an escalation chain.", - back: { href: backHref(model), label: "Back to routing" }, - }), - ]) if (isEdit && AsyncData.isFailure(model.existing.data)) return dashboardPage(h, header, [ - errorState(h, { - failure: model.existing.data.error, - title: "Couldn't load the chain", - onRetry: Message.ClickedRetryExisting(), - }), + model.existing.data.error.code === CHAIN_NOT_FOUND_CODE + ? notFound(h) + : errorState(h, { + failure: model.existing.data.error, + title: "Couldn't load the chain", + onRetry: Message.ClickedRetryExisting(), + }), ]) - if (isEdit && !AsyncData.isSuccess(model.existing.data)) - return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + if (isEdit && Option.isNone(AsyncData.getData(model.existing.data))) return loading return dashboardPage(h, { ...header, actions: actions(h, model) }, [ - ...saveNotice(h, model), - h.div( - [h.Class("grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start")], - [chainColumn(h, model), aside(h, model)], - ), + vstack(h, { gap: "4" }, [ + ...notices(h, model), + h.div( + [h.Class("grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start")], + [chainColumn(h, model), aside(h, model)], + ), + ]), deleteDialog(h, model), + leaveDialog(h, model), ]) }) diff --git a/apps/web/src/page/alerts/shared/escalation-chains.ts b/apps/web/src/page/alerts/shared/escalation-chains.ts index 4926ab9812..e9482b8cb2 100644 --- a/apps/web/src/page/alerts/shared/escalation-chains.ts +++ b/apps/web/src/page/alerts/shared/escalation-chains.ts @@ -12,11 +12,10 @@ import { type V2EscalationChainCreateParams, } from "@maple/domain/http/v2" import { formatAlertTimeNear } from "@maple/web-data/alerts/form-utils" -import { Array as Arr, Effect, Option, Schema, String as Str } from "effect" +import { Array as Arr, Effect, Option, Schema } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" -import type { AlertsEscalationChainSearch } from "../../../route/search" // Escalation chain reads and writes as Effects the pages' Commands wrap, and how a chain and an // incident's run read in one line. @@ -76,20 +75,13 @@ const decodeChainId = Schema.decodeUnknownOption(EscalationChainPublicId) /** The `esc_…` id in a URL as the internal id. */ export const chainIdFromUrl = (raw: string): Option.Option => decodeChainId(raw) -export const chainHref = ( - id: EscalationChainId, - search: typeof AlertsEscalationChainSearch.Type = {}, -): string => hrefOf(AppRoute.AlertsEscalationChain({ chainId: encodeChainId(id), search })) +export const chainHref = (id: EscalationChainId): string => + hrefOf(AppRoute.AlertsEscalationChain({ chainId: encodeChainId(id), search: {} })) -export const newChainHref = (search: typeof AlertsEscalationChainSearch.Type = {}): string => - hrefOf(AppRoute.AlertsEscalationChainNew({ search })) +export const newChainHref = (): string => hrefOf(AppRoute.AlertsEscalationChainNew({ search: {} })) -/** A `returnTo` the editor may follow: an in-app path only, never another origin. */ -export const safeReturnTo = (raw: string | undefined): Option.Option => - Option.filter( - Option.fromNullishOr(raw), - (path) => Str.startsWith("/")(path) && !Str.startsWith("//")(path) && !Str.includes("\\")(path), - ) +/** 404 on a read or a write: the chain was deleted elsewhere. */ +export const CHAIN_NOT_FOUND_CODE = "escalation_chain_not_found" // SUMMARY diff --git a/apps/web/src/page/alerts/shared/org-members.ts b/apps/web/src/page/alerts/shared/org-members.ts index 92651bae91..506c314207 100644 --- a/apps/web/src/page/alerts/shared/org-members.ts +++ b/apps/web/src/page/alerts/shared/org-members.ts @@ -72,7 +72,12 @@ export const listOrgMembers: Effect.Effect, string> = c * A member by id; someone who left the org reads as "A former member". Until the members load, after * a failed read, or in an org past `MAX_MEMBERS`, an unknown id reads neutrally as "Member". */ -export const memberName = (members: ReadonlyArray | null, userId: string): string => +export const memberName = ( + members: ReadonlyArray | null, + userId: string, + /** What someone who left the org reads as, where the default does not fit. */ + formerName = "A former member", +): string => members === null || members.length >= MAX_MEMBERS ? Option.getOrElse( Option.map( @@ -86,7 +91,7 @@ export const memberName = (members: ReadonlyArray | null, userId: str Arr.findFirst(members, (member) => member.userId === userId), (member) => member.name, ), - () => "A former member", + () => formerName, ) export const memberOf = ( diff --git a/apps/web/src/page/kit/chain-connector.ts b/apps/web/src/page/kit/chain-connector.ts new file mode 100644 index 0000000000..b73b59e06f --- /dev/null +++ b/apps/web/src/page/kit/chain-connector.ts @@ -0,0 +1,40 @@ +import type { Attribute, Html, HtmlBuilder } from "foldkit/html" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" + +// `ChainConnector`: the short line that joins two cards of a vertical chain. + +/** + * `ChainConnector`: the line between two cards of a vertical chain, with a + on it that adds a card + * there. The + shows while the gap is hovered or holds focus, and always on a touch screen. + */ +export const chainConnector = ( + h: HtmlBuilder, + options: Readonly<{ + /** The + button's id (a focus target) and accessible name; `null` draws the line alone. */ + insert: Readonly<{ id: string; label: string; onClick: M }> | null + attributes?: ReadonlyArray> + }>, +): Html => + h.div( + [h.Class("group/connector relative flex h-6 justify-center"), ...(options.attributes ?? [])], + [ + h.div([h.Class("h-full w-px bg-border"), h.AriaHidden(true)], []), + ...(options.insert === null + ? [] + : [ + iconButton( + h, + { + label: options.insert.label, + variant: "outline", + size: "icon-xs", + className: + "absolute top-1/2 -translate-y-1/2 opacity-0 group-hover/connector:opacity-100 group-active/connector:opacity-100 group-focus-within/connector:opacity-100 pointer-coarse:opacity-100", + attributes: [h.Id(options.insert.id), h.OnClick(options.insert.onClick)], + }, + [icon(h, "PlusIcon")], + ), + ]), + ], + ) diff --git a/apps/web/src/page/kit/pick-chips.ts b/apps/web/src/page/kit/pick-chips.ts new file mode 100644 index 0000000000..6cd33e14b4 --- /dev/null +++ b/apps/web/src/page/kit/pick-chips.ts @@ -0,0 +1,128 @@ +import { Array as Arr } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { icon } from "@maple/views/ui/icon" +import type * as Menu from "@maple/views/ui/menu" +import * as MenuView from "@maple/views/ui/menu-view" +import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" + +// `PickChips`: `PickList` for a sentence, where the picks sit inline on the sentence's own line. + +/** One picked chip: its media (a logo, an avatar, an icon), its name and how to remove it. */ +export type PickedChip = Readonly<{ + key: string + label: string + media: Html + /** A muted note after the name ("Ben Brook on call"). */ + detail?: string + removeLabel: string + onRemove: M +}> + +const chip = (h: HtmlBuilder, entry: PickedChip, canEdit: boolean, isDisabled: boolean): Html => + hstack( + h, + { + gap: "1.5", + inline: true, + as: "span", + className: `h-7 max-w-full rounded-md border border-border bg-surface-sunken pl-1.5 ${canEdit ? "pr-1" : "pr-2"}`, + attributes: [h.Key(entry.key)], + }, + [ + entry.media, + typography(h, { size: "sm", truncate: true }, [entry.label]), + ...(entry.detail === undefined + ? [] + : [ + typography( + h, + { size: "xs", tone: "muted", className: "hidden whitespace-nowrap sm:inline" }, + [entry.detail], + ), + ]), + ...(canEdit + ? [ + button( + h, + { + variant: "ghost", + size: "icon-2xs", + isDisabled, + attributes: [h.AriaLabel(entry.removeLabel), h.OnClick(entry.onRemove)], + }, + [icon(h, "XmarkIcon")], + ), + ] + : []), + ], + ) + +/** + * `PickChips`: what a form has picked as removable chips, then an "Add" chip that opens a menu of + * the rest; the chips and the button go inline on the caller's own wrapping line. + */ +export const pickChips = ( + h: HtmlBuilder, + options: Readonly<{ + entries: ReadonlyArray> + menu: Menu.Model + toMenuMessage: (message: Menu.Message) => M + addEntries: ReadonlyArray + /** The add button's text while nothing is picked; after that it reads "Add". */ + addLabel: string + /** The add button's accessible name, when a form shows several pickers ("... to step 2"). */ + addAriaLabel?: string + /** A save in flight: the picks show, but nothing can be removed or added. */ + isDisabled?: boolean + /** A viewer who cannot edit: the picks alone, and `emptyText` when there are none. */ + isReadOnly?: boolean + emptyText: string + }>, +): ReadonlyArray => { + const isDisabled = options.isDisabled ?? false + const canEdit = !(options.isReadOnly ?? false) + return [ + ...Arr.map(options.entries, (entry) => chip(h, entry, canEdit, isDisabled)), + ...(canEdit + ? [ + h.submodel({ + slotId: options.menu.id, + model: options.menu, + view: MenuView.view, + viewInputs: { + entries: options.addEntries, + align: "start", + className: "w-72", + toView: ({ trigger, withTrigger }) => + h.span( + [h.Class("contents")], + withTrigger( + button( + h, + { + variant: "dashed", + size: "xs", + isDisabled, + attributes: [ + h.AriaLabel(options.addAriaLabel ?? options.addLabel), + ...trigger, + ], + }, + [ + icon(h, "PlusIcon"), + options.entries.length === 0 ? options.addLabel : "Add", + ], + ), + ), + ), + }, + toParentMessage: options.toMenuMessage, + }), + ] + : options.entries.length === 0 + ? [typography(h, { size: "sm", tone: "muted" }, [options.emptyText])] + : []), + ] +} diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index eaddd00b69..90015c4a0a 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -380,10 +380,8 @@ export const AlertsScheduleNewSearch = Schema.Struct({ export const AlertsScheduleSearch = Schema.Struct({}) -/** `/alerts/escalation-chains/new` and `/:chainId`: `returnTo` is where Cancel and Save go back to. */ -export const AlertsEscalationChainSearch = Schema.Struct({ - returnTo: Schema.optional(Schema.String), -}) +/** `/alerts/escalation-chains/new` and `/:chainId`. */ +export const AlertsEscalationChainSearch = Schema.Struct({}) /** `/settings/ingestion/rules/new`: `template` prefills the editor from an empty-state template. */ export const IngestRuleNewSearch = Schema.Struct({ diff --git a/packages/ui/src/components/ui/button.styles.ts b/packages/ui/src/components/ui/button.styles.ts index eef0df92c5..296ba2dbf2 100644 --- a/packages/ui/src/components/ui/button.styles.ts +++ b/packages/ui/src/components/ui/button.styles.ts @@ -30,6 +30,9 @@ export const buttonVariants = cva( "not-disabled:inset-shadow-highlight border-destructive bg-destructive text-on-fill shadow-raised hover:bg-destructive/90 active:bg-destructive/90 data-pressed:bg-destructive/90 dark:border-destructive/80 dark:bg-destructive/80 dark:hover:bg-destructive/72 dark:active:bg-destructive/72 dark:data-pressed:bg-destructive/72 *:data-[slot=button-loading-indicator]:text-on-fill [:active,[data-pressed]]:inset-shadow-pressed [:disabled,:active,[data-pressed]]:shadow-none", "destructive-outline": "border-input bg-popover not-dark:bg-clip-padding text-destructive-foreground shadow-raised not-disabled:not-active:not-data-pressed:before:shadow-control hover:border-destructive-a3 active:border-destructive-a3 hover:bg-destructive-a1 active:bg-destructive-a1 data-pressed:border-destructive-a3 data-pressed:bg-destructive-a1 *:data-[slot=button-loading-indicator]:text-foreground dark:bg-input/32 [:disabled,:active,[data-pressed]]:shadow-none", + // A quiet delete beside other content ("Delete chain"): destructive ink, a wash only on hover. + "destructive-ghost": + "border-transparent text-destructive-foreground hover:bg-destructive-a1 active:bg-destructive-a1 data-pressed:bg-destructive-a1 *:data-[slot=button-loading-indicator]:text-foreground", // Add-a-row affordances ("Add threshold", "New dashboard"): an outline that reads as an empty slot. dashed: "active:scale-100! border-dashed border-input bg-transparent text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground data-pressed:bg-surface-sunken *:data-[slot=button-loading-indicator]:text-foreground", ghost: "border-transparent text-foreground hover:bg-accent active:bg-accent data-pressed:bg-accent *:data-[slot=button-loading-indicator]:text-foreground", diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 3ce934a3bd..68c79dc9d2 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -181,6 +181,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). ## apps/web/src/page/kit - `apps/web/src/page/kit/autocomplete-lookups.ts` `lookupField`, `autocompleteLookup`: The autocomplete lookups a query builder fetches once it is first focused, each reduced to the names it suggests. +- `apps/web/src/page/kit/chain-connector.ts` `chainConnector`: `ChainConnector`: the short line that joins two cards of a vertical chain. - `apps/web/src/page/kit/clipboard.ts` `writeClipboard`, `copyToast`: `useCopy`'s write: the clipboard API, then the `execCommand` fallback; `true` when it landed. - `apps/web/src/page/kit/commit-card.ts` `isResolvableSha`, `commitDetailKey`, `commitDetailsKey`, `readCommitDetail`, `readCommitDetails`, `loadCommitDetail`, `commitDetailBody`: The commit SHA hover card: the body `CommitDetailBody` renders once `commitQueryAtom(sha)` settles, and the lookup itself. - `apps/web/src/page/kit/compliance-line.ts` `compliance`: The compliance footnote under the plan pickers (DPA available, SOC 2 and ISO 27001 in progress). @@ -206,6 +207,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/nav-shell.ts` `navShell`: `NavShell`: a page's own nav rail of titled sections (settings, security, alerts). - `apps/web/src/page/kit/page-rail-sheet.ts` `pageRailSheet`: `PageLayout.FilterSidebar` / `RightSidebar` below `lg`: the page's rail in a side sheet (``) with an sr-only header. - `apps/web/src/page/kit/peek-keys.ts` `peekStepFor`: The list peeks' step keys: down/J forward, up/K back, except in a text field or with a modifier. +- `apps/web/src/page/kit/pick-chips.ts` `pickChips`: `PickChips`: `PickList` for a sentence, where the picks sit inline on the sentence's own line. - `apps/web/src/page/kit/pick-list.ts` `pickList`: `PickList`: what a form has picked (destinations, people, schedules), each removable and, in an ordered list, movable, then an "Add" menu of the rest. - `apps/web/src/page/kit/pinned-namespace-notice.ts` `pinnedNamespaceNotice`: `PinnedNamespaceNotice`: replaces a rail's Namespace section while the org-global pin is on. - `apps/web/src/page/kit/query-builder/change.ts` `withRootOnly`, `switchDataSource`, `applyQueryChange`: One edit a `QueryPanel` makes to its draft (its `onUpdate`, `onAggregationChange` and `onDataSourceChange` callbacks), as data the host applies. From 19047dfa8b96486a4b537b71b3bf5dc4bfea7641 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 01:52:29 +0100 Subject: [PATCH 19/69] chore(lab): run the escalation editor's focus moves in its lab --- apps/web/src/lab/escalation-editor-lab.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lab/escalation-editor-lab.ts b/apps/web/src/lab/escalation-editor-lab.ts index 2ab2679f7e..8a4a84110b 100644 --- a/apps/web/src/lab/escalation-editor-lab.ts +++ b/apps/web/src/lab/escalation-editor-lab.ts @@ -6,6 +6,7 @@ import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "eff import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" +import { FocusChainElement } from "../page/alerts/escalation-chain/command" import * as ChainMessage from "../page/alerts/escalation-chain/message" import * as ChainModel from "../page/alerts/escalation-chain/model" import { init as chainInit, update as chainUpdate } from "../page/alerts/escalation-chain/update" @@ -113,10 +114,16 @@ const LONG = populatedChains([])[2] const NO_COMMANDS: PageCommands = [] +/** The page's local Commands: the kit's, and its own focus moves, which only touch the DOM. */ +const kept = (commands: PageCommands | undefined): PageCommands => [ + ...keepLocal(commands), + ...Arr.filter(commands ?? [], (command) => command.name === FocusChainElement.name), +] + const run = (model: ChainModel.Model, messages: ReadonlyArray) => Arr.reduce(messages, { model, commands: NO_COMMANDS }, (current, message) => { const next = chainUpdate(current.model, message, CONTEXT) - return { model: next.model, commands: [...current.commands, ...keepLocal(next.commands)] } + return { model: next.model, commands: [...current.commands, ...kept(next.commands)] } }) const pageFor = (state: LabState) => { @@ -172,7 +179,7 @@ export const update = (model: Model, message: Message): Return => const next = chainUpdate(model.page, child, CONTEXT) return { model: { ...model, page: next.model }, - commands: Command.mapMessages(keepLocal(next.commands), (inner) => + commands: Command.mapMessages(kept(next.commands), (inner) => Message.GotPageMessage({ message: inner }), ), } From 7b0906572d4067f378cba125d31de80516b3b082 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:01:42 +0100 Subject: [PATCH 20/69] feat(ui): shift timeline gap tone, labels that leave when they cannot fit, narrow day headers A gap is a hollow, hatched bar in the error tone instead of a warning wash. A bar hides its label under 3.75rem and shows initials when two characters fit. Layer rows can leave the tab order. Day headers stack and thin out their dates as days narrow, and the label column sizes to the timeline's own width. --- packages/ui/src/styles/tokens.css | 9 ++ .../views/src/ui/shift-timeline.scene.test.ts | 26 +++++ packages/views/src/ui/shift-timeline.test.ts | 11 ++ packages/views/src/ui/shift-timeline.ts | 107 ++++++++++++++---- 4 files changed, 134 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/styles/tokens.css b/packages/ui/src/styles/tokens.css index 580c9a01f6..6666780c0c 100644 --- a/packages/ui/src/styles/tokens.css +++ b/packages/ui/src/styles/tokens.css @@ -825,3 +825,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/ui/shift-timeline.scene.test.ts b/packages/views/src/ui/shift-timeline.scene.test.ts index 0181893586..23536c2692 100644 --- a/packages/views/src/ui/shift-timeline.scene.test.ts +++ b/packages/views/src/ui/shift-timeline.scene.test.ts @@ -33,11 +33,21 @@ const view = (model: Model, h: HtmlBuilder): Html => tone: "primary", onClick: Message.ClickedBar(), }, + { + key: "gap", + id: "gap-0", + startMs: FROM + HOUR, + endMs: FROM + 3 * HOUR, + label: "No one", + title: "No one on call, Mon 9 Nov 01:00 to 03:00", + tone: "gap", + }, ], }, { key: "layer", label: "Primary", + isTabStop: false, bars: [ { key: "b", @@ -98,4 +108,20 @@ describe("shift timeline", () => { expectView(role("img", { name: "Cy, next week" })).not.toExist(), ) }) + + test("a gap is a named, focusable bar a summary can reach by id; a layer's bars leave the tab order", () => { + scene( + { update, view }, + given({ nowMs: FROM + 35 * HOUR }), + expectView(role("img", { name: "No one on call, Mon 9 Nov 01:00 to 03:00" })).toHaveAttr( + "id", + "gap-0", + ), + expectView(role("img", { name: "No one on call, Mon 9 Nov 01:00 to 03:00" })).toHaveAttr( + "tabIndex", + "0", + ), + expectView(role("img", { name: "Ben, Mon" })).toHaveAttr("tabIndex", "-1"), + ) + }) }) diff --git a/packages/views/src/ui/shift-timeline.test.ts b/packages/views/src/ui/shift-timeline.test.ts index a34045cf4f..dbc5c86208 100644 --- a/packages/views/src/ui/shift-timeline.test.ts +++ b/packages/views/src/ui/shift-timeline.test.ts @@ -55,4 +55,15 @@ describe("shift timeline layout", () => { "2026-11-11T05:00:00.000Z", ) }) + + test("Saturdays and Sundays are marked as the weekend, on the zone's own days", () => { + const from = Date.parse("2026-11-12T23:00:00.000Z") // Fri 13 Nov, 00:00 in Berlin + const days = timelineDays(from, from + 4 * 24 * HOUR, "Europe/Berlin") + expect(Arr.map(days, (day) => [day.weekday, day.isWeekend])).toEqual([ + ["Fri", false], + ["Sat", true], + ["Sun", true], + ["Mon", false], + ]) + }) }) diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index 928eed657e..58656c254c 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -1,6 +1,6 @@ import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetime" import { cn } from "@maple/ui/lib/utils" -import { Array as Arr, Option, Result } from "effect" +import { Array as Arr, Option, Result, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { pressable } from "./pressable" import { hstack, vstack } from "./stack" @@ -13,13 +13,15 @@ import { typography } from "./typography" // 23 or 25 hours wide. /** A bar's look: a layer's shift, the effective shift, an override over it, or a gap nobody covers. */ -export type TimelineBarTone = "neutral" | "primary" | "info" | "warning" +export type TimelineBarTone = "neutral" | "primary" | "info" | "gap" export type TimelineBar = Readonly<{ key: string startMs: number endMs: number label: string + /** Drawn when the label cannot fit but two characters can (a person's initials). */ + shortLabel?: string /** The tooltip and accessible name: who, and from when to when. */ title: string tone: TimelineBarTone @@ -33,6 +35,8 @@ export type TimelineRow = Readonly<{ label: string detail?: string bars: ReadonlyArray> + /** False keeps the row's read-only bars out of the tab order (a layer under the final row). */ + isTabStop?: boolean /** What an empty row says, for a row with no bars in the range. */ emptyText?: string }> @@ -110,8 +114,8 @@ const BAR_TONE = { // An override is the one bar that must stand out, even 8px wide on a dark surface: a stronger // wash and the tone's own edge. info: "bg-severity-info-a3 border-severity-info text-severity-info", - // A gap reads as missing cover: a dashed warning edge over a faint wash. - warning: "border-dashed bg-warning-a2 border-warning text-warning-foreground", + // A gap reads as missing cover: no fill, a dashed error edge and the error tone's hatch. + gap: "hatch-error border-dashed border-severity-error text-severity-error", } as const satisfies Record /** The narrowest a bar draws, so a short shift stays visible and clickable. */ @@ -119,7 +123,8 @@ const MIN_BAR_PX = 8 const FOCUS_CLASS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" -const BAR_CLASS = "absolute inset-y-1 overflow-hidden rounded-sm border px-1.5 text-left" +// A container, so the label can leave when the bar is too narrow to hold it. +const BAR_CLASS = "@container absolute inset-y-1 overflow-hidden rounded-sm border text-left" /** How far a bar may grow to reach its floor: up to the next bar's start in the lane, or the range's end. */ export const barRoomPct = ( @@ -145,6 +150,7 @@ const barView = ( toMs: number, bar: TimelineBar, limitPct: number, + isTabStop: boolean, ): ReadonlyArray => Option.match(placeBar(fromMs, toMs, bar.startMs, bar.endMs), { onNone: () => [], @@ -156,9 +162,29 @@ const barView = ( width: `calc(${widthPct}% - 2px)`, "min-width": `min(${MIN_BAR_PX}px, calc(${limitPct - leftPct}% - 2px))`, }) - const text = typography(h, { size: "xs", truncate: true, className: "block leading-6" }, [ - bar.label, - ]) + // The label shows from 3.75rem of bar (3rem of text), the short one from two characters' + // room; the name stays in the tooltip. + const text = [ + typography( + h, + { size: "xs", truncate: true, className: "hidden px-1.5 leading-6 @min-[3.75rem]:block" }, + [bar.label], + ), + ...(bar.shortLabel === undefined + ? [] + : [ + typography( + h, + { + size: "xs", + className: + "hidden text-center leading-6 @min-[1.25rem]:block @min-[3.75rem]:hidden", + attributes: [h.AriaHidden(true)], + }, + [bar.shortLabel], + ), + ]), + ] const attributes = [ h.Key(bar.key), style, @@ -173,10 +199,10 @@ const barView = ( h.Class(cn(BAR_CLASS, BAR_TONE[bar.tone], FOCUS_CLASS)), // Read-only bars still reach the keyboard and a screen reader, named by their tooltip. h.Role("img"), - h.Tabindex(0), + h.Tabindex(isTabStop ? 0 : -1), h.AriaLabel(bar.title), ], - [text], + text, ) : pressable( h, @@ -190,13 +216,14 @@ const barView = ( ), attributes, }, - [text], + text, ), ] }, }) -const LABEL_COLUMN = "sticky left-0 z-10 w-32 shrink-0 border-r bg-background px-3 sm:w-40" +// Sized by the timeline's own width, so it stays narrow in a side panel on a wide screen. +const LABEL_COLUMN = "sticky left-0 z-10 w-32 shrink-0 border-r bg-background px-3 @2xl:w-40" /** * The timeline: a header of days, then one row per lane (label column, bars track), ruled by day, @@ -277,16 +304,17 @@ export const shiftTimeline = ( // One header for the track, as each row has one cell for it; the days label it visually. h.div( [h.Class("relative h-8 grow"), h.Role("columnheader"), h.AriaLabel("Shifts")], - Arr.map(days, (day) => + Arr.map(days, (day, index) => h.div( [ h.Key(`day-${day.startMs}`), h.Class( - "absolute inset-y-0 overflow-hidden border-l border-border-subtle px-1.5 py-1.5", + "@container absolute inset-y-0 overflow-hidden border-l border-border-subtle", ), h.Style({ left: `${day.leftPct}%`, width: `${day.widthPct}%` }), ], [ + // "Tue 10" where a day is wide enough, else the weekday's letter over the date. typography( h, { @@ -294,10 +322,39 @@ export const shiftTimeline = ( tone: "muted", numeric: true, truncate: true, - className: "block", + className: "hidden px-1.5 py-1.5 @min-[3.5rem]:block", }, [`${day.weekday} ${day.dayOfMonth}`], ), + vstack( + h, + { + gap: "0", + align: "center", + justify: "center", + className: "h-full @min-[3.5rem]:hidden", + attributes: [h.AriaHidden(true)], + }, + [ + typography(h, { size: "2xs", tone: "faint", className: "leading-3" }, [ + Str.slice(0, 1)(day.weekday), + ]), + typography( + h, + { + size: "2xs", + tone: "muted", + numeric: true, + // Days too narrow for two digits date only the first day and each Monday. + className: + index === 0 || day.weekday === "Mon" + ? "leading-3" + : "invisible leading-3 @min-[1.25rem]:visible", + }, + [String(day.dayOfMonth)], + ), + ], + ), ], ), ), @@ -338,7 +395,14 @@ export const shiftTimeline = ( ), ] : Arr.flatMap(row.bars, (bar) => - barView(h, fromMs, toMs, bar, barRoomPct(fromMs, toMs, row.bars, bar)), + barView( + h, + fromMs, + toMs, + bar, + barRoomPct(fromMs, toMs, row.bars, bar), + row.isTabStop ?? true, + ), ), ), ], @@ -350,10 +414,15 @@ export const shiftTimeline = ( ]), ) const frame = h.div( - [h.Class("overflow-x-auto rounded-md border"), h.DataAttribute("slot", "shift-timeline")], + [h.Class("@container overflow-x-auto rounded-md border"), h.DataAttribute("slot", "shift-timeline")], [ h.div( - [h.Class("relative min-w-[720px]"), h.Role("table"), h.AriaLabel(options.ariaLabel)], + [ + // Fits a frame from 30rem; a narrower one (a phone) scrolls a 45rem track sideways. + h.Class("relative min-w-[45rem] @min-[30rem]:min-w-0"), + h.Role("table"), + h.AriaLabel(options.ariaLabel), + ], [ ...(options.nowLabel === undefined || Option.isNone(now) ? [] @@ -365,7 +434,7 @@ export const shiftTimeline = ( h.div( [ h.Class( - "pointer-events-none absolute inset-y-0 right-0 left-32 sm:left-40", + "pointer-events-none absolute inset-y-0 right-0 left-32 @2xl:left-40", ), ], [rules, ...nowLine], From b551d1454500bcfa0dcddba32e2bdc4be9a069a6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:01:47 +0100 Subject: [PATCH 21/69] fix(alerts): coverage preview review follow-ups - The detail calendar draws a skeleton until the shifts for the picked range land, instead of old shifts read as one long gap. - A wholly past range says its gaps are not checked; a draft cannot step back from today. - A layer with a half-typed window does not preview as always active. - The preview redraws only when the zone, layers, members, minute or range change. - An uncovered schedule says nobody is on call; a gap cut off by the range's end has no end time. - The start note shows only for a new schedule started on an earlier day and names who is on call now. - The editor is two columns from 46rem of room, the preview held in view while the form scrolls. - One shared focus command for gap links. --- apps/web/src/lab/oncall-lab.ts | 17 +- .../src/page/alerts/schedule-form/command.ts | 10 +- .../web/src/page/alerts/schedule-form/form.ts | 4 +- .../schedule-form/preview.scene.test.ts | 71 +++++ .../src/page/alerts/schedule-form/preview.ts | 123 +++++--- .../schedule-form/schedule-form.scene.test.ts | 70 +++- .../src/page/alerts/schedule-form/update.ts | 12 +- .../web/src/page/alerts/schedule-form/view.ts | 48 ++- apps/web/src/page/alerts/schedule/command.ts | 10 +- .../alerts/schedule/schedule.scene.test.ts | 38 ++- apps/web/src/page/alerts/schedule/update.ts | 9 +- apps/web/src/page/alerts/schedule/view.ts | 19 +- .../page/alerts/shared/coverage-preview.ts | 298 +++++++++++------- .../alerts/shared/schedule-coverage.test.ts | 59 +++- .../page/alerts/shared/schedule-coverage.ts | 126 +++++--- apps/web/src/page/kit/form-section.ts | 32 +- packages/views/src/ui/INDEX.md | 2 +- 17 files changed, 685 insertions(+), 263 deletions(-) create mode 100644 apps/web/src/page/alerts/schedule-form/preview.scene.test.ts diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index d4cd0ac5e9..4cda5eb6a2 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -56,6 +56,7 @@ export const LabState = Schema.Literals([ "editor-new", "editor-covered", "editor-gaps", + "editor-early", "editor-edit", "editor-invalid", "editor-error", @@ -260,7 +261,7 @@ const formFor = (state: LabState): Step => ] : state === "editor-new" ? [FormMessage.Message.ChangedName({ value: "Checkout primary" })] - : state === "editor-covered" || state === "editor-gaps" + : state === "editor-covered" || state === "editor-gaps" || state === "editor-early" ? [ FormMessage.Message.ChangedName({ value: "Checkout primary" }), addPerson(0), @@ -269,6 +270,20 @@ const formFor = (state: LabState): Step => ...(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", + }), + ] + : []), ] : [] return fold(start, [...base, ...extra], formStep) diff --git a/apps/web/src/page/alerts/schedule-form/command.ts b/apps/web/src/page/alerts/schedule-form/command.ts index 5637ab0cc6..ffa4c6ff33 100644 --- a/apps/web/src/page/alerts/schedule-form/command.ts +++ b/apps/web/src/page/alerts/schedule-form/command.ts @@ -1,7 +1,7 @@ import { OnCallScheduleId } from "@maple/domain/http" import { V2OnCallScheduleCreateParams } from "@maple/domain/http/v2" import { Clock, Effect, Schema } from "effect" -import { Command, Dom } from "foldkit" +import { Command } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { browserZone, readSupportedTimeZones } from "../../../platform/time-zone" import { retrieveOnCallSchedule, saveOnCallSchedule } from "../shared/oncall-schedules" @@ -42,14 +42,6 @@ export const FetchScheduleFormSession = Command.define("FetchScheduleFormSession Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), }) -/** A gap's bar takes focus, which scrolls it into view. */ -export const FocusPreviewGap = Command.define("FocusScheduleFormGap", { - args: { selector: Schema.String }, - messages: [Message.CompletedFocusGap], - execute: ({ selector }) => - Dom.focus(selector).pipe(Effect.ignore, Effect.as(Message.CompletedFocusGap())), -}) - export const SaveSchedule = Command.define("SaveOnCallSchedule", { args: { editingId: Schema.NullOr(OnCallScheduleId), payload: V2OnCallScheduleCreateParams }, messages: [Message.CompletedSave], 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/preview.scene.test.ts b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts new file mode 100644 index 0000000000..df5ffd7c15 --- /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 for the next 14 days")).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 for the next 28 days")).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 index 9688614b37..3147b1949a 100644 --- a/apps/web/src/page/alerts/schedule-form/preview.ts +++ b/apps/web/src/page/alerts/schedule-form/preview.ts @@ -1,62 +1,92 @@ -import { Array as Arr, Option, Schema, String as Str } from "effect" -import type { Html, HtmlBuilder } from "foldkit/html" +import type { OnCallShift } from "@maple/alerting-core/oncall" import { OnCallTimeZone } from "@maple/domain/http" -import { coveragePreview, type PreviewShift } from "../shared/coverage-preview" -import { memberName } from "../shared/org-members" -import { draftCoverage, previewLayerOf, previewRange } from "../shared/schedule-coverage" -import { localInputMs } from "./form" +import { zonedDayStart } from "@maple/views/ui/shift-timeline" +import { Array as Arr, Option, Schema, String as Str } from "effect" +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 { 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 on every edit, no save needed. +// 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) -/** Plain-language notes on the draft's layers, one per issue that changes the preview. */ -export const previewWarnings = (model: Pick): ReadonlyArray => - Arr.flatMap(model.form.layers, (layer, index) => { +/** 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 [`${name} has no people, so it covers nothing.`] - const startMs = - layer.startsAtMs === null - ? localInputMs(layer.startsAt, model.form.timeZone) - : Option.some(layer.startsAtMs) - return Option.exists(startMs, (ms) => ms < model.nowMs) - ? [`${name} starts in the past. Shifts are scheduled from now, keeping its rotation order.`] - : [] + 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 asPreview = (shift: { - userId: string - startMs: number - endMs: number - source: { kind: string } -}): PreviewShift => ({ +const asPreview = (shift: OnCallShift): PreviewShift => ({ userId: shift.userId, startMs: shift.startMs, endMs: shift.endMs, isOverride: shift.source.kind === "override", }) -export const schedulePreview = (h: HtmlBuilder, model: Model): Html => { - const timeZone = isZone(model.form.timeZone) ? model.form.timeZone : "UTC" - const days = Number(model.previewRange) - const range = previewRange(model.nowMs, timeZone, model.previewWeekOffset, days) - const coverage = draftCoverage(model.form, [], range.startMs, range.endMs, model.nowMs) +const render = ( + h: HtmlBuilder, + zone: string, + layers: ReadonlyArray, + members: ReadonlyArray | null, + nowMs: number, + weekOffset: number, + rangeDays: RangeDays, + isNew: boolean, +): Html => { + const timeZone = isZone(zone) ? zone : "UTC" + const range = previewRange(nowMs, timeZone, weekOffset, Number(rangeDays)) + const coverage = draftCoverage({ timeZone: zone, layers }, [], range.startMs, range.endMs, nowMs) return coveragePreview(h, { scope: PREVIEW_SCOPE, title: "Coverage preview", timeZone, fromMs: range.startMs, toMs: range.endMs, - nowMs: model.nowMs, - weekOffset: model.previewWeekOffset, - rangeDays: model.previewRange, - lanes: Arr.map(model.form.layers, (layer, index) => ({ + nowMs, + weekOffset, + rangeDays, + lanes: Arr.map(layers, (layer, index) => ({ key: layer.key, label: layerName(layer.name, index), detail: `Layer ${index + 1}`, @@ -65,18 +95,37 @@ export const schedulePreview = (h: HtmlBuilder, model: Model): Html => layer.participants.length === 0 ? "Add people to see shifts" : Option.isNone(previewLayerOf(layer, timeZone)) - ? "Fix the rotation to see shifts" + ? "Fix this layer to see shifts" : "Not active in this period", })), final: Arr.map(coverage.final, asPreview), gaps: coverage.gaps, - nameOf: (userId) => memberName(model.members, userId), + nameOf: (userId) => memberName(members, userId), onPrevious: Message.ClickedPreviewPrevious(), onNext: Message.ClickedPreviewNext(), onToday: Message.ClickedPreviewToday(), onRange: (days) => Message.PickedPreviewRange({ days }), onFocusGap: (index) => Message.ClickedPreviewGap({ index }), - warnings: previewWarnings(model), - note: `Times in ${timeZone}. Overrides are not shown here.`, + // A draft has no past to look back on. + isPreviousDisabled: weekOffset <= 0, + notes: previewNotes({ timeZone: zone, layers, members, nowMs, isNew }), + note: isNew ? undefined : "Overrides are not shown here.", }) } + +const slot = createLazy() + +const MINUTE_MS = 60_000 + +/** Redrawn only when the zone, the layers, the members, the minute or the 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, + ]) 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 3d24459790..fed1c5e6fe 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 @@ -49,6 +49,8 @@ const ready = (search: { scheduleId?: string } = {}) => { ]) } +const memberNameOf = (index: number) => oncallMembers[index]?.name ?? "" + const addPerson = (key: string, index: number) => Message.GotParticipantMenuMessage({ key, @@ -313,8 +315,11 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(ready()), expectView(text("Primary has no people, so it covers nothing.")).toExist(), - expectView(text(/^1 gap in the next 14 days, longest 13d 12h/u)).toExist(), + expectView(text("Nobody is on call for the next 14 days")).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(), ) }) @@ -324,7 +329,8 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(staffed), expectView(text("Covered for the next 14 days")).toExist(), - expectView(text(/Primary starts in the past\. Shifts are scheduled from now/u)).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. @@ -340,7 +346,65 @@ describe("schedule form", () => { 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(["FocusScheduleFormGap"]) + 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 for the next 14 days")).toExist(), + expectView(text("Covered for the next 14 days")).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", () => { diff --git a/apps/web/src/page/alerts/schedule-form/update.ts b/apps/web/src/page/alerts/schedule-form/update.ts index b90a2260de..0a4af206fa 100644 --- a/apps/web/src/page/alerts/schedule-form/update.ts +++ b/apps/web/src/page/alerts/schedule-form/update.ts @@ -7,13 +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, gapBarId } from "../shared/coverage-preview" +import { DEFAULT_RANGE_DAYS, FocusGap } from "../shared/coverage-preview" import { scheduleHref, scheduleIdFromUrl } from "../shared/oncall-schedules" import { FetchExistingSchedule, FetchScheduleFormMembers, FetchScheduleFormSession, - FocusPreviewGap, ReadScheduleFormDefaults, SaveSchedule, } from "./command" @@ -308,15 +307,20 @@ 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: model.previewWeekOffset - 1 }, + 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: [FocusPreviewGap({ selector: `#${gapBarId(PREVIEW_SCOPE, index)}` })], + commands: [ + FoldkitCommand.mapMessage(FocusGap({ scope: PREVIEW_SCOPE, index }), () => + Message.CompletedFocusGap(), + ), + ], }), CompletedFocusGap: () => ({ model }), ClickedSave: () => save(model), diff --git a/apps/web/src/page/alerts/schedule-form/view.ts b/apps/web/src/page/alerts/schedule-form/view.ts index e9cdb6e022..05e63085ab 100644 --- a/apps/web/src/page/alerts/schedule-form/view.ts +++ b/apps/web/src/page/alerts/schedule-form/view.ts @@ -558,23 +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, [ - // The form on the left and its live preview on the right; under 2xl the preview follows the form. - h.div( - [h.Class("grid gap-6 2xl:grid-cols-[minmax(0,40rem)_minmax(0,1fr)] 2xl: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 2xl:sticky 2xl:top-0")], - [schedulePreview(h, model)], - ), - ], - ), - ]), + 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/command.ts b/apps/web/src/page/alerts/schedule/command.ts index 13f9457ab5..9cceccf980 100644 --- a/apps/web/src/page/alerts/schedule/command.ts +++ b/apps/web/src/page/alerts/schedule/command.ts @@ -1,7 +1,7 @@ import { OnCallOverrideId, OnCallScheduleId } from "@maple/domain/http" import { V2OnCallOverrideCreateParams } from "@maple/domain/http/v2" import { Clock, Effect, Schema } from "effect" -import { Command, Dom } from "foldkit" +import { Command } from "foldkit" import { readGrants } from "../../../data/session-tenant" import { createOverride, @@ -83,11 +83,3 @@ export const DeleteOverride = Command.define("DeleteOnCallOverride", { Effect.map((result) => Message.CompletedDeleteOverride({ overrideId, result })), ), }) - -/** A gap's bar takes focus, which scrolls it into view. */ -export const FocusScheduleGap = Command.define("FocusScheduleGap", { - args: { selector: Schema.String }, - messages: [Message.CompletedFocusGap], - execute: ({ selector }) => - Dom.focus(selector).pipe(Effect.ignore, Effect.as(Message.CompletedFocusGap())), -}) 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 38fad37c6b..9507a948a6 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -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(), @@ -394,7 +392,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(), ) }) @@ -476,4 +474,36 @@ describe("on-call schedule page", () => { 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 713a3bfc58..51d897df50 100644 --- a/apps/web/src/page/alerts/schedule/update.ts +++ b/apps/web/src/page/alerts/schedule/update.ts @@ -8,7 +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, gapBarId } from "../shared/coverage-preview" +import { DEFAULT_RANGE_DAYS, FocusGap } from "../shared/coverage-preview" import { scheduleIdFromUrl } from "../shared/oncall-schedules" import { localInputMs, localInputOf } from "../schedule-form/form" import { @@ -19,7 +19,6 @@ import { FetchScheduleMembers, FetchScheduleSession, FetchShifts, - FocusScheduleGap, ReadScheduleClock, } from "./command" import { Message } from "./message" @@ -353,7 +352,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re PickedRange: ({ days }) => requestShifts({ ...model, rangeDays: days }, context, "Load"), ClickedGap: ({ index }) => ({ model, - commands: [FocusScheduleGap({ selector: `#${gapBarId(CALENDAR_SCOPE, index)}` })], + 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. diff --git a/apps/web/src/page/alerts/schedule/view.ts b/apps/web/src/page/alerts/schedule/view.ts index d8467c71bf..b2bd2e7796 100644 --- a/apps/web/src/page/alerts/schedule/view.ts +++ b/apps/web/src/page/alerts/schedule/view.ts @@ -71,11 +71,12 @@ const previewShiftOf = (shift: V2OnCallShift): PreviewShift => ({ const calendarSection = (h: H, model: Model, schedule: V2OnCallSchedule, viewerZone: string): Html => { const range = rangeOf(model) const nowMs = model.nowMs ?? 0 - const zoneNote = - viewerZone === schedule.time_zone - ? `Times in ${schedule.time_zone}.` - : `Times in ${schedule.time_zone}. Yours is ${viewerZone}.` - const note = `${zoneNote}${canManage(model) ? " Pick a shift in the final row to cover it with an override." : ""}` + // 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, @@ -121,9 +122,13 @@ const calendarSection = (h: H, model: Model, schedule: V2OnCallSchedule, viewerZ } const loading = (fromMs: number, toMs: number) => preview(fromMs, toMs, Option.none(), skeleton(h, "h-40 w-full")) - // Shifts kept from another range (a read still in flight) draw as the skeleton, not blank rows. + // 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) + 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, { diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts index 6b10165e54..d873cd7292 100644 --- a/apps/web/src/page/alerts/shared/coverage-preview.ts +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -1,15 +1,18 @@ -import { Array as Arr, Option, Schema } from "effect" +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 { heading } from "@maple/views/ui/heading" import { typography } from "@maple/views/ui/typography" import { formatShiftEdge } from "./oncall-schedules" -import { gapRangeText, gapSummary, type Interval } from "./schedule-coverage" +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. @@ -24,12 +27,26 @@ const RANGE_OPTIONS: ReadonlyArray { value: "28", label: "4 weeks" }, ] -/** The summary links this many gaps; the rest are on the timeline. */ -const MAX_GAP_LINKS = 6 +/** 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<{ @@ -41,6 +58,9 @@ export type PreviewLane = Readonly<{ emptyText: string }> +/** A plain-language line over the timeline: `warn` changes what is covered, `info` only explains. */ +export type PreviewNote = Readonly<{ tone: "info" | "warn"; text: string }> + export type CoveragePreviewOptions = Readonly<{ /** Prefixes the gap bars' ids, unique on the page. */ scope: string @@ -60,12 +80,13 @@ export type CoveragePreviewOptions = Readonly<{ 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 - /** Plain-language notes over the timeline. */ - warnings?: ReadonlyArray - /** A line under the header, after the zone note. */ - note?: string + 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 }> @@ -82,6 +103,7 @@ export const previewRows = (options: CoveragePreviewOptions): ReadonlyArra 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", } @@ -89,15 +111,19 @@ export const previewRows = (options: CoveragePreviewOptions): ReadonlyArra 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 => ({ - key: `gap-${gap.startMs}`, - id: gapBarId(options.scope, index), - startMs: gap.startMs, - endMs: gap.endMs, - label: "No one", - title: `No one on call, ${gapRangeText(gap, options.timeZone)}`, - tone: "warning", - })) + 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", @@ -114,6 +140,8 @@ export const previewRows = (options: CoveragePreviewOptions): ReadonlyArra 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) => @@ -131,106 +159,128 @@ const periodLabel = (fromMs: number, toMs: number, timeZone: string): string => return `${day.format(fromMs)} to ${day.format(toMs - 1)}` } -const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { - const days = Number(options.rangeDays) - const summary = gapSummary(options.gaps, options.timeZone, options.weekOffset, days) - const isCovered = options.gaps.length === 0 - return vstack(h, { gap: "1.5", attributes: [h.DataAttribute("slot", "coverage-summary")] }, [ - hstack(h, { gap: "1.5" }, [ - h.span( - [h.AriaHidden(true), h.Class(isCovered ? "text-primary" : "text-warning-foreground")], - [icon(h, isCovered ? "CircleCheckIcon" : "AlertWarningIcon")], +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))], ), - typography(h, { size: "sm", weight: "medium", as: "p", attributes: [h.AriaLive("polite")] }, [ - summary, - ]), - ]), - ...(isCovered - ? [] - : [ - hstack(h, { gap: "1", wrap: true }, [ - ...Arr.map(Arr.take(options.gaps, MAX_GAP_LINKS), (gap, index) => - button( - h, - { - size: "xs", - variant: "outline", - attributes: [ - h.Key(`gap-link-${gap.startMs}`), - h.OnClick(options.onFocusGap(index)), - ], - }, - [gapRangeText(gap, options.timeZone)], - ), - ), - ...(options.gaps.length > MAX_GAP_LINKS - ? [ - typography( - h, - { size: "xs", tone: "muted", attributes: [h.Key("more")] }, - [`and ${options.gaps.length - MAX_GAP_LINKS} more on the timeline`], - ), - ] - : []), - ]), - ]), + ), + ...more(NARROW_GAP_LINKS, "@min-[30rem]:hidden"), + ...more(WIDE_GAP_LINKS, "hidden @min-[30rem]:inline"), ]) } -export const coveragePreview = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => - h.section( - [h.Class("min-w-0 space-y-3"), h.DataAttribute("slot", "coverage-preview")], - [ - hstack(h, { gap: "2", justify: "between", wrap: true }, [ - vstack(h, { gap: "0" }, [ - heading(h, { level: 2, variant: "section" }, [options.title]), - typography(h, { size: "xs", tone: "muted", as: "p" }, [ - periodLabel(options.fromMs, options.toMs, options.timeZone), - ]), - ]), - hstack(h, { gap: "1", wrap: true }, [ - segmentedSelect(h, { - options: RANGE_OPTIONS, - value: options.rangeDays, - onChange: options.onRange, - size: "xs", - ariaLabel: "Range", - }), - iconButton(h, { label: "Previous week", attributes: [h.OnClick(options.onPrevious)] }, [ - icon(h, "ChevronLeftIcon"), - ]), - button( +const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { + const scope = { + timeZone: options.timeZone, + weekOffset: options.weekOffset, + days: Number(options.rangeDays), + 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, - { - size: "sm", - variant: "outline", - isDisabled: options.weekOffset === 0, - attributes: [h.OnClick(options.onToday)], - }, - ["Today"], + "ClockIcon", + "text-muted-foreground", + "Past shifts. Gaps are only checked from now.", ), - iconButton(h, { label: "Next week", attributes: [h.OnClick(options.onNext)] }, [ - icon(h, "ChevronRightIcon"), - ]), - ]), - ]), - ...(options.note === undefined - ? [] - : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.note])]), - ...Arr.map(options.warnings ?? [], (warning) => - hstack(h, { gap: "1.5", attributes: [h.Key(warning)] }, [ - h.span( - [h.AriaHidden(true), h.Class("text-warning-foreground")], - [icon(h, "CircleInfoIcon")], - ), - typography(h, { size: "xs", tone: "muted", as: "p" }, [warning]), - ]), + ] + : 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]), + ]) + +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")], ), - ...(options.placeholder === undefined - ? [gapSummaryView(h, options), timelineView(h, options)] - : [options.placeholder]), - ], - ) + 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, { @@ -246,6 +296,30 @@ const timelineView = (h: HtmlBuilder, options: CoveragePreviewOptions): { tone: "primary", label: "On call" }, { tone: "info", label: "Override" }, { tone: "neutral", label: "Layer shift" }, - { tone: "warning", label: "No one" }, + { 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 }, [ + vstack(h, { gap: "0" }, [ + heading(h, { level: 2, variant: "section" }, [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/schedule-coverage.test.ts b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts index cc374d6fe7..adcc07d41b 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts @@ -7,8 +7,11 @@ import { draftConfigOf, draftCoverage, durationText, + gapRangeText, gapsOf, gapSummary, + layerOnCallAt, + previewLayerOf, previewRange, type Interval, } from "./schedule-coverage" @@ -217,20 +220,72 @@ describe("previewRange", () => { }) describe("gapSummary", () => { + const scope = { + timeZone: "Europe/Berlin", + weekOffset: 0, + days: 14, + checkedFromMs: at("2026-11-10T10:30:00Z"), + checkedToMs: at("2026-11-23T23:00:00Z"), + } it("says covered when there are no gaps", () => { - expect(gapSummary([], "Europe/Berlin", 0, 14)).toBe("Covered for the next 14 days") + expect(gapSummary([], scope)).toBe("Covered for the next 14 days") + expect(gapSummary([], { ...scope, weekOffset: 2 })).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, "Europe/Berlin", 0, 14)).toBe( + expect(gapSummary(gaps, scope)).toBe( "2 gaps in the next 14 days, 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 for the next 14 days") + expect(gapSummary(whole, { ...scope, weekOffset: 1 })).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 the next 14 days, 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") }) }) + +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 index 554ae22547..2a85dbf5ea 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.ts @@ -1,10 +1,11 @@ -import { renderOnCall, type OnCallShift } from "@maple/alerting-core/oncall" -import { OnCallLayer, OnCallLocalTime, OnCallScheduleConfig, OnCallTimeZone } from "@maple/domain/http" +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 { localInputMs, type Form, type LayerDraft } from "../schedule-form/form" +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. @@ -36,40 +37,49 @@ export const gapsOf = ( return cursor < toMs ? [...gaps, { startMs: cursor, endMs: toMs }] : gaps } -const isLocalTime = Schema.is(OnCallLocalTime) const isZone = Schema.is(OnCallTimeZone) const decodeLayer = Schema.decodeUnknownOption(OnCallLayer) const decodeConfig = Schema.decodeUnknownOption(OnCallScheduleConfig) -const layerStartMs = (layer: LayerDraft, timeZone: string): Option.Option => - layer.startsAtMs === null ? localInputMs(layer.startsAt, timeZone) : Option.some(layer.startsAtMs) - -/** A draft layer as the engine's layer, while it has people and a valid rotation; bad restrictions drop out. */ +/** + * 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 => - Option.flatMap(layerStartMs(layer, timeZone), (startsAt) => - decodeLayer({ - // 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( - Arr.filter( - layer.restrictions, - (r) => r.weekdays.length > 0 && isLocalTime(r.startTime) && isLocalTime(r.endTime), - ), - ({ weekdays, startTime, endTime }) => ({ weekdays, startTime, endTime }), - ), - }), + 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 { @@ -134,9 +144,6 @@ export const draftCoverage = ( // RANGE: whole wall-clock days in the schedule's zone, so DST days are 23 or 25 hours. -export const RANGE_DAYS = [7, 14, 28] as const -export type RangeDays = (typeof RANGE_DAYS)[number] - /** `days` wall-clock days from the start of today, `weekOffset` weeks on. */ export const previewRange = ( nowMs: number, @@ -181,9 +188,13 @@ const sameDay = (a: number, b: number, timeZone: string) => { const dropComma = Str.replace(",", "") -/** "Sat 21 Nov 00:00 to 16:00", or both ends in full across days. */ -export const gapRangeText = (gap: Interval, timeZone: string): string => { +/** 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)) @@ -213,15 +224,38 @@ export const longestGap = (gaps: ReadonlyArray): Option.Option + +/** One gap spanning everything checked: nobody is on call at all. */ +export const isUncovered = ( gaps: ReadonlyArray, - timeZone: string, - weekOffset: number, - days: number, -): string => - Option.match(longestGap(gaps), { - onNone: () => (weekOffset === 0 ? `Covered for the next ${days} days` : "Covered in this period"), + 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 the next 14 days, longest 16h (Sat 21 Nov 00:00 to 16:00)", "Covered for the next + * 14 days", or "Nobody is on call for the next 14 days" when the one gap is everything checked. + */ +export const gapSummary = (gaps: ReadonlyArray, scope: GapScope): string => { + const isFromToday = scope.weekOffset === 0 + if (isUncovered(gaps, scope)) + return isFromToday + ? `Nobody is on call for the next ${scope.days} days` + : "Nobody is on call in this period" + return Option.match(longestGap(gaps), { + onNone: () => (isFromToday ? `Covered for the next ${scope.days} days` : "Covered in this period"), onSome: (longest) => - `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} ${weekOffset === 0 ? `in the next ${days} days` : "in this period"}, longest ${durationText(longest.endMs - longest.startMs)} (${gapRangeText(longest, timeZone)})`, + `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} ${isFromToday ? `in the next ${scope.days} days` : "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/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/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 3ce934a3bd..48a083523b 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -190,7 +190,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/dashboard-page.ts` `pageBody`, `filterRail`, `pageContent`, `pageHeader`, `pageScrollArea`, `dashboardPage`: The dashboard page below the shell's top bar: `PageLayout.Body` with its filter rail, content column, sticky header and scroll area. - `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. From d6373386c54879120367a36d2491c4de8f63b2c2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:04:58 +0100 Subject: [PATCH 22/69] fix(alerts): the last gap of an escalation chain always shows its add button --- apps/web/src/page/alerts/escalation-chain/view.ts | 1 + apps/web/src/page/kit/chain-connector.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index 45b4c451ec..2e768f1808 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -373,6 +373,7 @@ const connector = (h: H, model: Model, index: number): Html => { ? "Add a step at the end" : `Add a step after step ${index}`, onClick: Message.ClickedInsertStep({ index }), + isPinned: index === count, } : null, }) diff --git a/apps/web/src/page/kit/chain-connector.ts b/apps/web/src/page/kit/chain-connector.ts index b73b59e06f..3fd1a75319 100644 --- a/apps/web/src/page/kit/chain-connector.ts +++ b/apps/web/src/page/kit/chain-connector.ts @@ -1,3 +1,4 @@ +import { cn } from "@maple/ui/lib/utils" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" @@ -6,13 +7,14 @@ import { iconButton } from "@maple/views/ui/icon-button" /** * `ChainConnector`: the line between two cards of a vertical chain, with a + on it that adds a card - * there. The + shows while the gap is hovered or holds focus, and always on a touch screen. + * there. The + shows while the gap is hovered or holds focus, and always on a touch screen or + * when `isPinned` (the last gap, so a mouse user can see that the chain takes another card). */ export const chainConnector = ( h: HtmlBuilder, options: Readonly<{ /** The + button's id (a focus target) and accessible name; `null` draws the line alone. */ - insert: Readonly<{ id: string; label: string; onClick: M }> | null + insert: Readonly<{ id: string; label: string; onClick: M; isPinned?: boolean }> | null attributes?: ReadonlyArray> }>, ): Html => @@ -29,8 +31,11 @@ export const chainConnector = ( label: options.insert.label, variant: "outline", size: "icon-xs", - className: - "absolute top-1/2 -translate-y-1/2 opacity-0 group-hover/connector:opacity-100 group-active/connector:opacity-100 group-focus-within/connector:opacity-100 pointer-coarse:opacity-100", + className: cn( + "absolute top-1/2 -translate-y-1/2", + options.insert.isPinned !== true && + "opacity-0 group-hover/connector:opacity-100 group-active/connector:opacity-100 group-focus-within/connector:opacity-100 pointer-coarse:opacity-100", + ), attributes: [h.Id(options.insert.id), h.OnClick(options.insert.onClick)], }, [icon(h, "PlusIcon")], From dba6fcf7eb6eeb61a2cfdea34d997fe94cd73540 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:20:42 +0100 Subject: [PATCH 23/69] feat(views): pickers that keep the keyboard, a select that opens below Select takes alignItemWithTrigger={false} to open below its trigger. The chips field takes a size. ComboboxPicker's list is 360px wide and its trigger takes a tooltip. A popup with its own search input keeps focus there when an option is pressed, so typing, Enter and Escape go on working after a mouse pick that leaves the list open. --- packages/views/src/ui/combobox-picker.ts | 9 ++++++++- packages/views/src/ui/combobox-view.ts | 13 +++++++++++-- packages/views/src/ui/combobox.ts | 18 ++++++++++++++++++ packages/views/src/ui/select-view.ts | 20 +++++++++++++------- packages/views/src/ui/select.ts | 10 +++++++--- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/views/src/ui/combobox-picker.ts b/packages/views/src/ui/combobox-picker.ts index 9988a4d954..1b0d3d7b4f 100644 --- a/packages/views/src/ui/combobox-picker.ts +++ b/packages/views/src/ui/combobox-picker.ts @@ -6,6 +6,7 @@ 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 @@ -21,6 +22,8 @@ export type ComboboxPickerInputs = Readonly<{ /** 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 @@ -50,6 +53,10 @@ export const comboboxPicker = Submodel.defineView}`: a plain anchor, no router active state (apps/local-ui's hash links). */ @@ -400,7 +406,10 @@ export const sidebarMenuButton = ( ) const attributes: Array | ChildAttribute> = [ h.Class(linkActive ? `${className} active` : className), - h.DataAttribute("active", String(options.isActive ?? false)), + h.DataAttribute( + "active", + options.hasActiveChild === true ? "child" : String(options.isActive ?? false), + ), ...(options.hasTooltip === true ? [h.DataAttribute("base-ui-tooltip-trigger", "")] : []), h.DataAttribute("sidebar", "menu-button"), h.DataAttribute("size", size), From 3c3e99600c1d707754fb64de22094e8feae31d8e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 02:23:06 +0100 Subject: [PATCH 27/69] fix(alerts): one primary action per section, chips and viewer time in the silence panel - Each section's header has one primary action: New rule (split) on the Inbox and Rules, New monitor, New SLO, New silence, New window, New schedule and Add destination on their own sections. A section whose empty state already offers it shows none until it has rows. - The Alerts sub-list sets its groups apart with a 4px gap instead of rules, and its rows take the kit's height again. - The silence panel's Services, Environments and Tags are chips inputs that suggest what the org's rules name (and the services Uptime listed); a typed name can still be added. Its labels share one style. - A picked end is on the viewer's clock and the field names the zone ("Ends, CET"); a scheduled row names its day once. The lab reads the viewer's zone instead of UTC. - The duration choice uses the larger segmented size. --- apps/web/src/lab/alerts-hub-lab.ts | 114 ++++++--- apps/web/src/lab/registry.ts | 2 +- apps/web/src/page/alerts/hub/derive.ts | 30 ++- .../alerts/hub/hub-silences.scene.test.ts | 82 ++++++- .../page/alerts/hub/hub-slos.scene.test.ts | 5 +- .../page/alerts/hub/hub-uptime.scene.test.ts | 25 +- .../web/src/page/alerts/hub/hub.scene.test.ts | 42 ++++ apps/web/src/page/alerts/hub/view-oncall.ts | 7 - apps/web/src/page/alerts/hub/view-silences.ts | 4 +- apps/web/src/page/alerts/hub/view.ts | 114 ++++----- .../src/page/alerts/shared/matcher-fields.ts | 25 ++ .../src/page/alerts/shared/silences.test.ts | 29 ++- apps/web/src/page/alerts/shared/silences.ts | 14 ++ .../page/alerts/silence-dialog/dialog.test.ts | 109 ++++++++- .../src/page/alerts/silence-dialog/dialog.ts | 79 +++++- .../src/page/alerts/silence-dialog/view.ts | 227 ++++++++++++------ apps/web/src/page/kit/timezone-zones.ts | 31 +++ apps/web/src/shell/nav.ts | 2 +- apps/web/src/shell/shell.scene.test.ts | 3 +- apps/web/src/shell/view-nav.ts | 98 ++++---- packages/views/src/ui/INDEX.md | 2 +- packages/views/src/ui/segmented-select.ts | 3 +- 22 files changed, 765 insertions(+), 282 deletions(-) diff --git a/apps/web/src/lab/alerts-hub-lab.ts b/apps/web/src/lab/alerts-hub-lab.ts index e87756b96b..a1a60d4719 100644 --- a/apps/web/src/lab/alerts-hub-lab.ts +++ b/apps/web/src/lab/alerts-hub-lab.ts @@ -6,6 +6,8 @@ import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "eff import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" +import { zonedDateParts } from "@maple/query-engine/datetime" +import * as Combobox from "@maple/views/ui/combobox" import * as HubMessage from "../page/alerts/hub/message" import * as HubModel from "../page/alerts/hub/model" import * as HubUpdate from "../page/alerts/hub/update" @@ -15,11 +17,13 @@ import { alertsHref } from "../page/alerts/shared/links" import { destinationRow, NOW, ORG, ruleRow, stateRow, hubAt } from "../page/alerts/shared/test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" +import { readEffectiveTimeZone } from "../platform/time-zone" // `/lab/alerts-hub`: the hub's Inbox, Rules and Silences sections and the silence side panel over // fixtures, including a rule skipped for too few samples and a cancelled silence. `?state=` picks -// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom. The shell -// stands on the section's own path, and `?sheet=` is played here rather than pushed. +// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom, +// silence-dialog-filled. The shell stands on the section's own path, `?sheet=` is played here +// rather than pushed, and times read on the viewer's own clock, as in the app. export const LabState = Schema.Literals([ "inbox", @@ -29,20 +33,22 @@ export const LabState = Schema.Literals([ "silences", "silence-dialog", "silence-dialog-custom", + "silence-dialog-filled", ]) export type LabState = typeof LabState.Type -export const Flags = Schema.Struct({ state: LabState }) +export const Flags = Schema.Struct({ state: LabState, timeZone: Schema.String }) export type Flags = typeof Flags.Type -export const flags: Effect.Effect = Effect.sync(() => ({ +export const flags: Effect.Effect = Effect.map(readEffectiveTimeZone, (timeZone) => ({ state: Option.getOrElse( Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), (): LabState => "inbox", ), + timeZone, })) -export const Model = Schema.Struct({ state: LabState, hub: HubModel.Model }) +export const Model = Schema.Struct({ state: LabState, timeZone: Schema.String, hub: HubModel.Model }) export type Model = typeof Model.Type export const Message = defineMessageUnion({ GotHubMessage: { message: HubMessage.Message } }) @@ -50,11 +56,8 @@ export type Message = typeof Message.Type type Return = Update.Return -const CONTEXT: PageContext = { - orgId: Schema.decodeSync(OrgId)(ORG), - pinnedNamespace: null, - timeZone: "UTC", -} +const LAB_ORG = Schema.decodeSync(OrgId)(ORG) +const contextOf = (timeZone: string): PageContext => ({ orgId: LAB_ORG, pinnedNamespace: null, timeZone }) const GRANTS = Policy.grantsOf(["org:engineer"]) /** The hub's reads, writes and URL changes are dropped: the fixture is all the data there is. */ @@ -131,7 +134,10 @@ const SILENCES = [ silence("11111111-1111-4111-8111-111111111111", "Deploying checkout v2.3", -20, 40, { services: ["checkout"], }), - silence("22222222-2222-4222-8222-222222222222", "Database migration", 180, 240, { tags: ["db"] }), + // Tomorrow: its row names the day. + silence("22222222-2222-4222-8222-222222222222", "Database migration", 26 * 60, 27 * 60, { + tags: ["db"], + }), // Cancelled before it started: the API collapses its window to one instant. silence("33333333-3333-4333-8333-333333333333", "Load test on staging", -45, -45, { environments: ["staging"], @@ -148,23 +154,24 @@ const tabOf = (state: LabState) => ? "inbox" : "silences" -const routeTo = (model: HubModel.Model, sheet: string | undefined) => - HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), CONTEXT) +const routeTo = (model: HubModel.Model, sheet: string | undefined, context: PageContext) => + HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), context) /** The hub's update, with the `?sheet=` push and dismissal it asks for played back as route changes. */ -const step = (model: HubModel.Model, message: HubMessage.Message) => { - const next = HubUpdate.update(model, message, CONTEXT) +const step = (model: HubModel.Model, message: HubMessage.Message, context: PageContext) => { + const next = HubUpdate.update(model, message, context) const asked = Arr.findFirst(next.commands ?? [], (command) => Str.endsWith("HubSheet")(command.name)) if (asked._tag === "None") return next const routed = routeTo( next.model, asked.value.name === "PushAlertsHubSheet" ? HubUpdate.SILENCE_SHEET : undefined, + context, ) return { ...routed, commands: [...(next.commands ?? []), ...(routed.commands ?? [])] } } -const hubFor = (state: LabState): HubModel.Model => { - const entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), CONTEXT).model +const hubFor = (state: LabState, context: PageContext): HubModel.Model => { + const entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), context).model const base = [ HubMessage.Message.ReadClock({ nowMs: NOW }), HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), @@ -184,38 +191,71 @@ const hubFor = (state: LabState): HubModel.Model => { result: Result.succeed(SILENCES), }), ] - return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message).model) + return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message, context).model) +} + +const pad = (value: number): string => Str.padStart(2, "0")(String(value)) + +/** `ms` as a `datetime-local` value on the viewer's clock. */ +const localInput = (ms: number, timeZone: string): string => { + const parts = zonedDateParts(ms, timeZone) + return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}` } +const dialog = (message: SilenceDialog.Message) => HubMessage.Message.GotSilenceDialogMessage({ message }) +const chip = (field: SilenceDialog.ScopeField, value: string) => + dialog( + SilenceDialog.Message.GotScopeComboMessage({ + field, + message: Combobox.Message.ClickedItem({ value }), + }), + ) + +/** What each silence-panel state does once the panel is open. */ +const panelSteps = (state: LabState, timeZone: string): ReadonlyArray => + state === "silence-dialog-custom" + ? [dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" }))] + : state === "silence-dialog-filled" + ? [ + chip("services", "checkout"), + chip("services", "payment-svc"), + chip("environments", "production"), + dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" })), + dialog( + SilenceDialog.Message.ChangedUntil({ + value: localInput(NOW + 26 * 60 * MINUTE, timeZone), + }), + ), + dialog(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ] + : [] + const toLab = ( next: Readonly<{ commands?: ReadonlyArray> }>, ) => Command.mapMessages(keepLocal(next.commands), (message) => Message.GotHubMessage({ message })) export const init = (flags: Flags): Return => { - const hub = hubFor(flags.state) - if (flags.state !== "silence-dialog" && flags.state !== "silence-dialog-custom") - return { model: { state: flags.state, hub } } + const context = contextOf(flags.timeZone) + const hub = hubFor(flags.state, context) + const lab = { state: flags.state, timeZone: flags.timeZone } + if (!Str.startsWith("silence-dialog")(flags.state)) return { model: { ...lab, hub } } // The dialog opens through its own commands, so they run here rather than in the fold. - const opened = step(hub, HubMessage.Message.ClickedNewSilence()) - const picked = - flags.state === "silence-dialog-custom" - ? step( - opened.model, - HubMessage.Message.GotSilenceDialogMessage({ - message: SilenceDialog.Message.ChangedDuration({ duration: "custom" }), - }), - ) - : { model: opened.model, commands: [] } - return { - model: { state: flags.state, hub: picked.model }, - commands: [...toLab(opened), ...toLab(picked)], - } + const opened = step(hub, HubMessage.Message.ClickedNewSilence(), context) + const filled = Arr.reduce( + panelSteps(flags.state, flags.timeZone), + { model: opened.model, commands: toLab(opened) }, + (current, message) => { + const next = step(current.model, message, context) + return { model: next.model, commands: [...current.commands, ...toLab(next)] } + }, + ) + return { model: { ...lab, hub: filled.model }, commands: filled.commands } } export const update = (model: Model, message: Message): Return => Message.match(message, { GotHubMessage: ({ message: child }) => { - const next = step(model.hub, child) + const next = step(model.hub, child, contextOf(model.timeZone)) return { model: { ...model, hub: next.model }, commands: toLab(next) } }, }) @@ -230,6 +270,6 @@ export const view = (model: Model, h: HtmlBuilder): Html => slotId: "lab-alerts-hub", model: model.hub, view: hubView, - viewInputs: { context: CONTEXT }, + viewInputs: { context: contextOf(model.timeZone) }, toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), }) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 843e1bdbb2..5059ffb8f8 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -57,7 +57,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/alerts-hub", title: "Alerts hub", description: - "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence side panel (`?state=`).", + "The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence side panel empty, picking a time and filled in (`?state=`).", kind: "lab", session: "none", }, diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index e9f48fefbb..669786b354 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -6,11 +6,12 @@ import { rowsToAlertIncidentDocuments, rowsToAlertRuleDocuments, } from "@maple/web-data/collections/alerts" -import { Option, Array as Arr, String as Str } from "effect" +import { Option, Order, Array as Arr, Record as Rec, String as Str } from "effect" import { AsyncData } from "foldkit" import { lastBy } from "../../kit/last-by" import type { InboxSignal, IncidentStatusFilter, RuleBucket } from "./message" import type { Model } from "./model" +import type { ScopeSuggestions } from "../silence-dialog/dialog" type OverviewKey = readonly [ Model["rules"], @@ -112,6 +113,33 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) +const sortedNames = (names: ReadonlyArray): ReadonlyArray => + Arr.sort(Arr.dedupe(Arr.filter(names, Str.isNonEmpty)), Order.String) + +const silenceSuggestionsFor = lastBy( + ([rules, namespaces]: readonly [ + Model["rules"], + Model["serviceNamespaces"]["data"], + ]): ScopeSuggestions => { + const documents = rowsToAlertRuleDocuments(rules ?? [], buildRuleStatesByRuleId([])) + return { + services: sortedNames([ + ...Arr.flatMap(documents, (rule) => rule.serviceNames), + ...Option.match(AsyncData.getData(namespaces), { onNone: () => [], onSome: Rec.keys }), + ]), + environments: sortedNames(Arr.flatMap(documents, (rule) => rule.environments)), + tags: sortedNames(Arr.flatMap(documents, (rule) => rule.tags)), + } + }, +) + +/** + * What the silence panel suggests, from what the hub already holds: the services, environments and + * tags its rules name, and the services the Uptime section listed if it was opened. + */ +export const silenceSuggestionsOf = (model: Model): ScopeSuggestions => + silenceSuggestionsFor([model.rules, model.serviceNamespaces.data]) + /** `attention` is the legacy name of the blind bucket. */ export const resolveStatus = (status: string | undefined): RuleBucket | undefined => { switch (status) { diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 395a2fcd02..e0b5755d6f 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -3,12 +3,14 @@ import { OrgId } from "@maple/domain" import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import { AlertMaintenanceWindowPublicId, type V2AlertSilence } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" +import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" import { expect as expectView, Mount, given, role, scene, text, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" -import { NOW, ORG, hubAt } from "../shared/test-rows" +import { NOW, ORG, hubAt, ruleRow } from "../shared/test-rows" +import { silenceSuggestionsOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" import { init, routeChanged, update } from "./update" @@ -193,6 +195,8 @@ const callsOf = (commands: ReadonlyArray<{ readonly name: string; readonly args? const namesOf = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => Arr.map(commands ?? [], ({ name }) => name) const silenceMessage = (message: SilenceDialog.Message) => Message.GotSilenceDialogMessage({ message }) +const scopeCombo = (field: SilenceDialog.ScopeField, message: Combobox.Message) => + silenceMessage(SilenceDialog.Message.GotScopeComboMessage({ field, message })) /** The Silences tab with `?sheet=silence` followed: the panel is open. */ const withPanel = (grants = ENGINEER) => @@ -208,7 +212,7 @@ describe("alerts hub, silence side panel", () => { expect(step.model.silenceDialog.dialog.isOpen).toBe(false) }) - test("the URL opens it with typed scope fields, Cancel and a save that waits for a valid form", () => { + test("the URL opens it with a chips input per scope field, Cancel and a save that waits for a valid form", () => { const opened = withPanel() expect(opened.model.silenceDialog.dialog.isOpen).toBe(true) scene( @@ -216,12 +220,80 @@ describe("alerts hub, silence side panel", () => { given(opened.model), Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), expectView(role("dialog", { name: "Silence notifications" })).toExist(), - expectView(role("textbox", { name: "Services" })).toExist(), + expectView(role("combobox", { name: "Services" })).toExist(), + expectView(role("combobox", { name: "Environments" })).toExist(), + expectView(role("combobox", { name: "Tags" })).toExist(), + expectView( + text("Fill in at least one. A rule is silenced only if it matches every field you fill in."), + ).toExist(), expectView(role("button", { name: "Cancel" })).toExist(), expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), ) }) + test("the chips inputs suggest what the org's rules name, and a picked one becomes a chip", () => { + const withRules = pageUpdate( + withPanel().model, + Message.UpdatedRules({ + orgId: ORG, + rows: [ + ruleRow(1, { service_names_json: ["checkout"], tags_json: ["db"] }), + ruleRow(2, { + service_names_json: ["payments", "checkout"], + environments_json: ["staging"], + }), + ], + }), + ).model + expect(silenceSuggestionsOf(withRules)).toEqual({ + services: ["checkout", "payments"], + environments: ["production", "staging"], + tags: ["db"], + }) + const listed = pageUpdate(withRules, scopeCombo("services", Combobox.Message.ClickedInput())).model + scene( + { update: pageUpdate, view }, + given(listed), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("option", { name: "checkout" })).toExist(), + expectView(role("option", { name: "payments" })).toExist(), + ) + const picked = pageUpdate( + listed, + scopeCombo("services", Combobox.Message.ClickedItem({ value: "payments" })), + ).model + scene( + { update: pageUpdate, view }, + given(picked), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("button", { name: "Remove payments" })).toExist(), + ) + }) + + test("a picked end is on the viewer's clock, and the field names the zone", () => { + const berlin: PageContext = { ...context, timeZone: "Europe/Berlin" } + const opened = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "silences", sheet: "silence" }), + berlin, + ).model + const custom = update( + opened, + silenceMessage(SilenceDialog.Message.ChangedDuration({ duration: "custom" })), + berlin, + ).model + scene( + { update: (model: Model, message: Message) => update(model, message, berlin), view }, + given(custom), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + // NOW is 1 October: Berlin is on summer time. + expectView(text("Ends, CEST")).toExist(), + expectView(text("Your local time. At most 30 days from now.")).toExist(), + ) + }) + test("a link straight to the panel waits for the clock and the viewer's grants, and a member never gets it", () => { const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model expect(entered.silenceDialog.dialog.isOpen).toBe(false) @@ -306,9 +378,7 @@ describe("alerts hub, silence side panel", () => { test("saving creates the silence, closes the panel, clears the URL and reads the list again", () => { const filled = Arr.reduce( [ - silenceMessage( - SilenceDialog.Message.ChangedScopeField({ field: "services", value: "checkout" }), - ), + scopeCombo("services", Combobox.Message.ClickedItem({ value: "checkout" })), silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), ], withPanel().model, diff --git a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts index 6526d14694..84d833a2dc 100644 --- a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts @@ -200,18 +200,19 @@ describe("alerts hub, SLOs tab", () => { expect(names(pageUpdate(failed, Message.ClickedRetrySlos()).commands)).toEqual(["FetchAlertsHubSlos"]) }) - test("the header's main create button makes an SLO here, for admins only, and the menu offers it too", () => { + test("the header's one primary action is New SLO here, for admins only, and the Inbox's menu offers it too", () => { scene( { update: pageUpdate, view }, given(withSlos(HEALTHY_AND_BURNING)), expectView(role("link", { name: "New SLO" })).toHaveAttr("href", "/alerts/slos/new"), expectView(role("link", { name: "New rule" })).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), ) scene( { update: pageUpdate, view }, given(withSlos(HEALTHY_AND_BURNING, onSlos(MEMBER))), expectView(role("link", { name: "New SLO" })).not.toExist(), - expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), + expectView(role("link", { name: "New rule" })).not.toExist(), ) const picked = pageUpdate( withSlos(HEALTHY_AND_BURNING), diff --git a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts index 5694c615e2..83ffcfbd3b 100644 --- a/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-uptime.scene.test.ts @@ -320,21 +320,29 @@ describe("alerts hub, Uptime tab", () => { expect(done.model.deletingMonitorId).toBeNull() }) - test("the header's main create button makes an uptime check here, for admins only", () => { + test("the header's one primary action is New monitor here, for admins only", () => { scene( { update: pageUpdate, view }, given(withMonitors(THREE)), - expectView(role("link", { name: "New check" })).toHaveAttr("href", "/alerts/uptime/new"), + expectView(role("link", { name: "New monitor" })).toHaveAttr("href", "/alerts/uptime/new"), expectView(role("link", { name: "New rule" })).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), ) scene( { update: pageUpdate, view }, - given({ ...withMonitors(THREE), search: { tab: "uptime", serviceName: "checkout-api" } }), - expectView(role("link", { name: "New check" })).toHaveAttr( + given({ ...withMonitors(THREE), search: { serviceName: "checkout-api" } }), + expectView(role("link", { name: "New monitor" })).toHaveAttr( "href", "/alerts/uptime/new?serviceName=checkout-api", ), ) + // The empty state offers the first check itself, so the header stays empty. + scene( + { update: pageUpdate, view }, + given(withMonitors([])), + expectView(role("link", { name: "New monitor" })).not.toExist(), + expectView(role("link", { name: "New uptime check" })).toExist(), + ) const asMember = pageUpdate( withMonitors(THREE), Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER }), @@ -342,15 +350,16 @@ describe("alerts hub, Uptime tab", () => { scene( { update: pageUpdate, view }, given(asMember), - expectView(role("link", { name: "New check" })).not.toExist(), - expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), + expectView(role("link", { name: "New monitor" })).not.toExist(), + expectView(role("link", { name: "New rule" })).not.toExist(), ) }) - test("the New rule chevron opens both kinds, and Uptime check goes to the uptime form", () => { + test("on the Inbox the New rule chevron opens every kind, and Uptime check goes to the uptime form", () => { scene( { update: pageUpdate, view }, - given(withMonitors(THREE)), + given({ ...withMonitors(THREE), tab: "inbox" as const }), + expectView(role("link", { name: "New rule" })).toHaveAttr("href", "/alerts/create"), click(role("button", { name: "More rule types" })), Command.resolve( Animation.WaitForPaint, diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 9e44f27e3d..5d0c3508cb 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -138,6 +138,48 @@ describe("alerts hub", () => { ) }) + test("each section's header has one primary action: its own, and New rule only on the Inbox and Rules", () => { + const newRule = role("link", { name: "New rule" }) + const moreKinds = role("button", { name: "More rule types" }) + for (const tab of ["inbox", "rules"] as const) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(newRule).toHaveAttr("href", "/alerts/create"), + expectView(moreKinds).toExist(), + ) + } + for (const tab of ["uptime", "slos", "silences", "maintenance", "on-call", "routing"] as const) { + scene( + { update: pageUpdate, view }, + given(loaded({ tab })), + expectView(newRule).not.toExist(), + expectView(moreKinds).not.toExist(), + ) + } + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "silences" })), + expectView(role("button", { name: "New silence" })).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ tab: "routing" })), + expectView(role("button", { name: "Add destination" })).toExist(), + ) + // A viewer who can't create a section's thing gets no header action there. + const asMember = pageUpdate( + loaded({ tab: "silences" }), + Message.CompletedFetchSession({ orgId: ORG, grants: Policy.grantsOf(["org:member"]) }), + ).model + scene( + { update: pageUpdate, view }, + given(asMember), + expectView(role("button", { name: "New silence" })).not.toExist(), + expectView(newRule).not.toExist(), + ) + }) + test("moving between sections keeps what was read: the Inbox and Rules share it, a section reads its own once", () => { const inbox = loaded() expect(names(routeChanged(inbox, hubAt({ tab: "rules" }), context).commands)).toEqual([ diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts index 7aad64110e..13c16cc1b9 100644 --- a/apps/web/src/page/alerts/hub/view-oncall.ts +++ b/apps/web/src/page/alerts/hub/view-oncall.ts @@ -284,10 +284,3 @@ export const deleteScheduleDialog = (h: H, model: Model): Html => { onConfirm: Message.ClickedConfirmDeleteSchedule(), }) } - -/** The tab header's "New schedule", once one exists. */ -export const newScheduleButton = (h: H): Html => - linkButton(h, { size: "sm", variant: "outline", href: newScheduleHref() }, [ - icon(h, "PlusIcon"), - "New schedule", - ]) diff --git a/apps/web/src/page/alerts/hub/view-silences.ts b/apps/web/src/page/alerts/hub/view-silences.ts index 61ba57ff02..b4fae5ff48 100644 --- a/apps/web/src/page/alerts/hub/view-silences.ts +++ b/apps/web/src/page/alerts/hub/view-silences.ts @@ -18,6 +18,7 @@ import { sectionHeading } from "../../kit/section-heading" import { windowIdOfParam } from "../shared/maintenance-windows" import { endsAtMs, + formatSilenceRange, formatSilenceTime, isCancelledSilence, silenceScopeParts, @@ -51,12 +52,11 @@ const silencesEmptyState = (h: H): Html => const windowText = (silence: V2AlertSilence, nowMs: number, timeZone: string): string => { const status = silenceStatusAt(silence, nowMs) - const start = formatSilenceTime(startsAtMs(silence), nowMs, timeZone) const end = formatSilenceTime(endsAtMs(silence), nowMs, timeZone) return status === "active" ? `Until ${end}` : status === "scheduled" - ? `${start} to ${end}` + ? formatSilenceRange(startsAtMs(silence), endsAtMs(silence), nowMs, timeZone) : isCancelledSilence(silence) ? `Cancelled ${end}` : `Ended ${end}` diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index e9e8dd6843..45ca0d6631 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -18,6 +18,7 @@ import { dashboardPage } from "../../kit/dashboard-page" import type { BreadcrumbEntry } from "../../../shell" import type { AlertsTab } from "../../../route/search" import { alertsHref, createHref } from "../shared/links" +import { newScheduleHref } from "../shared/oncall-schedules" import { newSloHref } from "../shared/slo-commands" import { newMonitorHref } from "../shared/uptime-commands" import { @@ -28,6 +29,7 @@ import { resolveIncidentStatus, resolveSignal, resolveStatus, + silenceSuggestionsOf, } from "./derive" import { Message } from "./message" import { canManage, type Model } from "./model" @@ -46,13 +48,7 @@ import * as WindowDialogView from "../maintenance-dialog/view" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" import { slosTab } from "./view-slos" -import { - deleteScheduleDialog, - hasSchedules, - newScheduleButton, - oncallTab, - whoIsOnCallStrip, -} from "./view-oncall" +import { deleteScheduleDialog, hasSchedules, oncallTab, whoIsOnCallStrip } from "./view-oncall" import { hstack, vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" @@ -99,26 +95,18 @@ const newRuleItem = ( ], }) -/** The split create button: its main part makes the section's own kind (an uptime check on Uptime, an SLO - * on SLOs, else a telemetry rule), the chevron offers every kind. */ -const newRuleButton = (h: H, model: Model, tab: AlertsTab): Html => { +/** The Inbox's and Rules' split create button: a telemetry rule, and behind the chevron every kind. */ +const newRuleButton = (h: H, model: Model): Html => { const serviceName = model.search.serviceName - // Uptime checks and SLOs are admin-only to create; members keep the telemetry rule. - const lead = - canManage(model) && tab === "uptime" - ? { href: newMonitorHref({ serviceName }), label: "New check" } - : canManage(model) && tab === "slos" - ? { href: newSloHref({ serviceName }), label: "New SLO" } - : { href: createHref({ serviceName }), label: "New rule" } return hstack(h, { align: "center" }, [ linkButton( h, { size: "sm", - href: lead.href, + href: createHref({ serviceName }), className: splitButtonClass("lead"), }, - [icon(h, "PlusIcon"), lead.label], + [icon(h, "PlusIcon"), "New rule"], ), h.submodel({ slotId: model.newRuleMenu.id, @@ -183,52 +171,46 @@ const newRuleButton = (h: H, model: Model, tab: AlertsTab): Html => { ]) } -// Destinations are a Routing concern, so "New destination" only shows on that tab. -const headerActions = (h: H, model: Model, tab: AlertsTab): Html => - hstack(h, { gap: "2" }, [ - ...(canManage(model) && tab === "silences" - ? [ - button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewSilence())], - }, - [icon(h, "BellSlashIcon"), "New silence"], - ), - ] - : []), - ...(canManage(model) && tab === "maintenance" && hasWindows(model) - ? [ - button( - h, - { - size: "sm", - variant: "outline", - isDisabled: model.nowMs === null, - attributes: [h.OnClick(Message.ClickedNewWindow())], - }, - [icon(h, "CalendarIcon"), "New window"], - ), - ] - : []), - ...(canManage(model) && tab === "on-call" && hasSchedules(model) ? [newScheduleButton(h)] : []), - ...(canManage(model) && tab === "routing" - ? [ - button( - h, - { - size: "sm", - variant: "outline", - attributes: [h.OnClick(Message.ClickedNewDestination())], - }, - [icon(h, "PlusIcon"), "New destination"], - ), - ] - : []), - newRuleButton(h, model, tab), - ]) +const hasRows = (rows: Option.Option>): boolean => + Option.exists(rows, (list) => list.length > 0) + +const primaryLink = (h: H, href: string, label: string): Html => + linkButton(h, { size: "sm", href }, [icon(h, "PlusIcon"), label]) + +const primaryButton = (h: H, onClick: Message, label: string, isDisabled = false): Html => + button(h, { size: "sm", isDisabled, attributes: [h.OnClick(onClick)] }, [icon(h, "PlusIcon"), label]) + +/** + * The header's one primary action: what the open section lists. A section whose empty state + * already offers it shows none until it has rows, and a viewer who can't create it sees none. + */ +const headerActions = (h: H, model: Model, tab: AlertsTab): Html | undefined => { + if (tab === "inbox" || tab === "rules") return newRuleButton(h, model) + if (!canManage(model)) return undefined + const serviceName = model.search.serviceName + switch (tab) { + case "uptime": + return hasRows(AsyncData.getData(model.monitors.data)) + ? primaryLink(h, newMonitorHref({ serviceName }), "New monitor") + : undefined + case "slos": + return hasRows(AsyncData.getData(model.slos.data)) + ? primaryLink(h, newSloHref({ serviceName }), "New SLO") + : undefined + case "silences": + return primaryButton(h, Message.ClickedNewSilence(), "New silence") + case "maintenance": + return hasWindows(model) + ? primaryButton(h, Message.ClickedNewWindow(), "New window", model.nowMs === null) + : undefined + case "on-call": + return hasSchedules(model) ? primaryLink(h, newScheduleHref(), "New schedule") : undefined + case "routing": + return destinationsOf(model).length > 0 + ? primaryButton(h, Message.ClickedNewDestination(), "Add destination") + : undefined + } +} /** `SyncUnavailable`: the rules or incidents stream stopped before its first answer. */ const SYNC_UNAVAILABLE = makeClientErrorBody({ @@ -404,7 +386,7 @@ export const view = Submodel.defineView((model, slotId: "alerts-silence", model: model.silenceDialog, view: SilenceDialogView.view, - viewInputs: { id: "alerts-silence" }, + viewInputs: { id: "alerts-silence", suggestions: silenceSuggestionsOf(model) }, toParentMessage: (message) => Message.GotSilenceDialogMessage({ message }), }), h.submodel({ diff --git a/apps/web/src/page/alerts/shared/matcher-fields.ts b/apps/web/src/page/alerts/shared/matcher-fields.ts index 44e0a356f2..bcc96dc098 100644 --- a/apps/web/src/page/alerts/shared/matcher-fields.ts +++ b/apps/web/src/page/alerts/shared/matcher-fields.ts @@ -3,6 +3,7 @@ import { Option, Schema, Array as Arr, String as Str } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { fieldDescription, fieldError, fieldLabel, type FieldIds, input } from "@maple/views/ui/field" import type { FieldState } from "@maple/views/ui/input" +import { label } from "@maple/views/ui/label" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" @@ -59,6 +60,30 @@ const stateOf = (value: string, isInvalid = false): FieldState => ({ isTouched: false, }) +/** A dialog field's label for a control the text field doesn't draw (chips, a segmented choice): the same look. */ +export const dialogLabel = ( + h: HtmlBuilder, + options: Readonly<{ id: string; htmlFor?: string }>, + text: string, +): Html => + label( + h, + { + className: "text-xs", + attributes: [ + h.Id(options.id), + ...(options.htmlFor === undefined ? [] : [h.For(options.htmlFor)]), + ], + }, + [text], + ) + +/** A line of help under a group of dialog fields, in the face a field's own description uses. */ +export const dialogHelp = (h: HtmlBuilder, id: string, text: string): Html => + fieldDescription(h, { ids: { control: id, label: id, error: id, description: id }, state: stateOf("") }, [ + text, + ]) + export type DialogTextFieldOptions = Readonly<{ idPrefix: string name: string diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts index 1d0e7e8f89..c482cf734a 100644 --- a/apps/web/src/page/alerts/shared/silences.test.ts +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest" import { Option, Schema } from "effect" import { AlertRuleId, AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" -import { activeSilenceFor, matchersForRule } from "./silences" +import { activeSilenceFor, formatSilenceRange, formatSilenceTime, matchersForRule } from "./silences" const ruleId = Schema.decodeSync(AlertRuleId)("11111111-1111-4111-8111-111111111111") const asSilenceId = Schema.decodeSync(AlertSilenceId) @@ -39,6 +39,33 @@ const groupedRule = { queryBuilderDraft: null, } +describe("silence times", () => { + const BERLIN = "Europe/Berlin" + + it("reads on the viewer's clock, and names the day when it is not today there", () => { + // NOW is 13:00 in Berlin. + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 14, 0), NOW, BERLIN)).toBe("03:00 PM") + expect(formatSilenceTime(Date.UTC(2026, 10, 7, 8, 0), NOW, BERLIN)).toBe("Nov 7, 09:00 AM") + // 23:30 UTC is still today in UTC, and already tomorrow in Berlin. + const late = Date.UTC(2026, 10, 6, 23, 30) + expect(formatSilenceTime(late, NOW, "UTC")).toBe("11:30 PM") + expect(formatSilenceTime(late, NOW, BERLIN)).toBe("Nov 7, 12:30 AM") + }) + + it("names the day of a window once when both ends share it", () => { + const start = Date.UTC(2026, 10, 7, 14, 0) + expect(formatSilenceRange(start, start + 60 * 60_000, NOW, BERLIN)).toBe( + "Nov 7, 03:00 PM to 04:00 PM", + ) + expect(formatSilenceRange(start, start + 24 * 60 * 60_000, NOW, BERLIN)).toBe( + "Nov 7, 03:00 PM to Nov 8, 03:00 PM", + ) + expect(formatSilenceRange(NOW + 60 * 60_000, NOW + 2 * 60 * 60_000, NOW, BERLIN)).toBe( + "02:00 PM to 03:00 PM", + ) + }) +}) + describe("activeSilenceFor", () => { it("picks the latest-ending silence in force, from the window not the stored status", () => { const long = silence("22222222-2222-4222-8222-222222222222", { rule_ids: [ruleId] }, -10, 120) diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 8db968ee93..5a5be70bf7 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -78,6 +78,20 @@ export const formatSilenceTime = (ms: number, nowMs: number, timeZone: string): : `${formatDateInTimezone(ms, { timeZone, withYear: false })}, ${time}` } +/** "15:00 to 16:00" today, "Nov 7, 15:00 to 16:00" on another day: the day is named once when both ends share it. */ +export const formatSilenceRange = ( + startMs: number, + endMs: number, + nowMs: number, + timeZone: string, +): string => { + const isOneDay = formatDateInTimezone(startMs, { timeZone }) === formatDateInTimezone(endMs, { timeZone }) + const end = isOneDay + ? formatTimeInTimezone(endMs, { timeZone, withSeconds: false }) + : formatSilenceTime(endMs, nowMs, timeZone) + return `${formatSilenceTime(startMs, nowMs, timeZone)} to ${end}` +} + /** Where a silence stands at `nowMs`, from its window rather than the status it was read with. */ export const silenceStatusAt = (silence: V2AlertSilence, nowMs: number): V2AlertSilence["status"] => nowMs < startsAtMs(silence) ? "scheduled" : nowMs < endsAtMs(silence) ? "active" : "ended" diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 8e8c49ab56..3b62de3bad 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -2,13 +2,19 @@ import { describe, expect, it } from "vitest" import { AlertSilenceId } from "@maple/domain/http" import type { V2AlertSilence } from "@maple/domain/http/v2" import { Option, Result, Schema } from "effect" +import * as Combobox from "@maple/views/ui/combobox" import { + endZoneLabel, init, isFormReady, matchersOf, Message, + type Model, + openFor, openNew, OutMessage, + parseLocalDateTime, + type ScopeField, silenceEndMs, untilError, update, @@ -36,6 +42,41 @@ describe("silenceEndMs", () => { ) }) + it("reads a custom end on the viewer's clock on either side of a DST change", () => { + // Berlin springs forward on 2026-03-29: 09:00 the day before is CET, 09:00 that day is CEST. + const before = Date.UTC(2026, 2, 28, 12, 0) + expect(parseLocalDateTime("2026-03-28T09:00", BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 28, 8, 0)), + ) + expect(parseLocalDateTime("2026-03-29T09:00", BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 29, 7, 0)), + ) + expect(silenceEndMs("custom", "2026-03-29T09:00", before, BERLIN)).toEqual( + Option.some(Date.UTC(2026, 2, 29, 7, 0)), + ) + // New York falls back on 2026-11-01: 12:00 that day is EST, five hours behind UTC. + expect(parseLocalDateTime("2026-10-31T12:00", "America/New_York")).toEqual( + Option.some(Date.UTC(2026, 9, 31, 16, 0)), + ) + expect(parseLocalDateTime("2026-11-01T12:00", "America/New_York")).toEqual( + Option.some(Date.UTC(2026, 10, 1, 17, 0)), + ) + // The same wall clock read as UTC is a different instant: the form never assumes UTC. + expect(parseLocalDateTime("2026-03-29T09:00", "UTC")).toEqual( + Option.some(Date.UTC(2026, 2, 29, 9, 0)), + ) + }) + + it("names the zone the custom end is in, as it is at that end", () => { + const model = { timeZone: BERLIN, nowMs: Date.UTC(2026, 2, 28, 12, 0) } + expect(endZoneLabel({ ...model, until: "" })).toBe("CET") + expect(endZoneLabel({ ...model, until: "2026-03-28T18:00" })).toBe("CET") + expect(endZoneLabel({ ...model, until: "2026-03-29T09:00" })).toBe("CEST") + expect(endZoneLabel({ ...model, timeZone: "UTC", until: "" })).toBe("UTC") + // A zone ICU has no abbreviation for reads as its offset. + expect(endZoneLabel({ ...model, timeZone: "Asia/Kolkata", until: "" })).toBe("UTC+5:30") + }) + it("reads a custom end in the viewer's zone and refuses one in the past", () => { expect(silenceEndMs("custom", "2026-11-06T18:30", NOW, BERLIN)).toEqual( Option.some(Date.UTC(2026, 10, 6, 17, 30)), @@ -46,20 +87,76 @@ describe("silenceEndMs", () => { }) describe("matchersOf", () => { - it("splits typed lists and drops empty fields", () => { + it("takes the chips of each field and drops the empty ones", () => { expect( matchersOf({ _tag: "Typed", - services: " checkout, payments ,checkout", - tags: "", - environments: "production", + services: ["checkout", "payments"], + tags: [], + environments: ["production"], }), ).toEqual({ services: ["checkout", "payments"], environments: ["production"] }) }) }) +describe("scope chips", () => { + const pick = (model: Model, field: ScopeField, value: string) => + update( + model, + Message.GotScopeComboMessage({ field, message: Combobox.Message.ClickedItem({ value }) }), + ).model + + it("picking a suggestion adds a chip, picking it again or removing it takes it off", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const picked = pick(pick(opened, "services", "checkout"), "environments", "production") + expect(matchersOf(picked.scope)).toEqual({ services: ["checkout"], environments: ["production"] }) + expect(matchersOf(pick(picked, "services", "checkout").scope)).toEqual({ + environments: ["production"], + }) + const removed = update( + picked, + Message.GotScopeComboMessage({ + field: "environments", + message: Combobox.Message.RemovedChip({ value: "production" }), + }), + ).model + expect(matchersOf(removed.scope)).toEqual({ services: ["checkout"] }) + }) + + it("a typed name is picked with Enter, and the next open starts without chips", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const combo = (message: Combobox.Message) => Message.GotScopeComboMessage({ field: "tags", message }) + const enter = (model: Model, value: string) => + update( + update(model, combo(Combobox.Message.ChangedInput({ value }))).model, + // The view lists the suggestions, then what is typed when it is none of them. + combo(Combobox.Message.PressedInputKey({ key: "Enter", items: ["database", value] })), + ).model + // The first suggestion that matches wins; a name nothing suggests is added as typed. + const entered = enter(enter(opened, "dat"), "queue") + expect(matchersOf(entered.scope)).toEqual({ tags: ["database", "queue"] }) + expect(entered.combos.tags.inputValue).toBe("") + expect(matchersOf(openNew(entered, BERLIN, NOW).model.scope)).toEqual({}) + }) + + it("a scope fixed by a rule ignores the chips", () => { + const fixed = openFor( + init("silence"), + { label: "Rule", matchers: { rule_ids: [] } }, + BERLIN, + NOW, + ).model + expect(pick(fixed, "services", "checkout").scope).toEqual(fixed.scope) + }) +}) + describe("isFormReady", () => { - const typed = (services: string) => ({ _tag: "Typed" as const, services, tags: "", environments: "" }) + const typed = (service: string) => ({ + _tag: "Typed" as const, + services: service === "" ? [] : [service], + tags: [], + environments: [], + }) it("needs a reason and something to match", () => { const model = init("silence") @@ -106,7 +203,7 @@ describe("saving", () => { } const saving = () => { const opened = openNew(init("silence"), BERLIN, NOW).model - const typed = { ...opened, reason: "deploy", scope: { ...opened.scope, services: "checkout" } } + const typed = { ...opened, reason: "deploy", scope: { ...opened.scope, services: ["checkout"] } } return update(typed, Message.ClickedSave()).model } diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index 2eba7bba2e..8b69c61740 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -3,10 +3,12 @@ import { zonedDateParts, zonedPartsToEpochMs } from "@maple/query-engine/datetim import { Clock, DateTime, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command, type Update } from "foldkit" import { defineMessageUnion } from "foldkit/message" +import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import type { Resources } from "../../../platform/resources" import { MAX_ALERT_SILENCE_DURATION_MS } from "@maple/domain/http" -import { hasMatchers, MatcherField, matchersFromTyped, TypedMatchers } from "../shared/matcher-fields" +import { shortZoneName } from "../../kit/timezone-zones" +import { hasMatchers, MatcherField } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" // "Silence notifications": the side panel the hub, a rule and an incident open, as one Submodel. @@ -17,16 +19,41 @@ export type SilenceDuration = typeof SilenceDuration.Type export const ScopeField = MatcherField export type ScopeField = MatcherField -/** What the silence covers: fixed by the page that opened it, or typed in (the hub's "New silence"). */ +const Chips = Schema.Array(Schema.String) + +/** What the silence covers: fixed by the page that opened it, or picked as chips (the hub's "New silence"). */ export const Scope = Schema.Union([ Schema.TaggedStruct("Fixed", { label: Schema.String, matchers: V2AlertSilenceMatchers }), - Schema.TaggedStruct("Typed", TypedMatchers.fields), + Schema.TaggedStruct("Typed", { services: Chips, tags: Chips, environments: Chips }), ]) export type Scope = typeof Scope.Type +/** What each chips input offers before anything is typed: the names the org's rules already use. */ +export type ScopeSuggestions = Readonly>> + +const NO_CHIPS: Extract = { + _tag: "Typed", + services: [], + tags: [], + environments: [], +} + +/** One chips input per scope field; each field's chips live on the scope. */ +const ScopeCombos = Schema.Struct({ + services: Combobox.Model, + tags: Combobox.Model, + environments: Combobox.Model, +}) + +const initCombos = (id: string): typeof ScopeCombos.Type => { + const combo = (field: ScopeField) => Combobox.init(`${id}-${field}`, "multiple", { autoHighlight: true }) + return { services: combo("services"), tags: combo("tags"), environments: combo("environments") } +} + export const Model = Schema.Struct({ dialog: Dialog.Model, scope: Scope, + combos: ScopeCombos, reason: Schema.String, duration: SilenceDuration, /** The custom end, as a `datetime-local` input holds it (`2026-11-06T18:30`), in `timeZone`. */ @@ -42,7 +69,8 @@ export type Model = typeof Model.Type export const init = (id: string): Model => ({ dialog: Dialog.init(id, "sheet"), - scope: { _tag: "Typed", services: "", tags: "", environments: "" }, + scope: NO_CHIPS, + combos: initCombos(id), reason: "", duration: "1h", until: "", @@ -57,7 +85,7 @@ export const Message = defineMessageUnion({ ChangedReason: { value: Schema.String }, ChangedDuration: { duration: SilenceDuration }, ChangedUntil: { value: Schema.String }, - ChangedScopeField: { field: ScopeField, value: Schema.String }, + GotScopeComboMessage: { field: ScopeField, message: Combobox.Message }, ClickedCancel: {}, ClickedSave: {}, CompletedSave: { openCount: Schema.Number, result: Schema.Result(V2AlertSilence, Schema.String) }, @@ -122,9 +150,22 @@ export const silenceEndMs = ( } } -/** The matchers the form describes; empty matchers mean there is nothing to save yet. */ +/** The matchers the form describes; a field without chips is left out, and none mean nothing to save yet. */ export const matchersOf = (scope: Scope): V2AlertSilenceMatchers => - scope._tag === "Fixed" ? scope.matchers : matchersFromTyped(scope) + scope._tag === "Fixed" + ? scope.matchers + : { + ...(scope.services.length === 0 ? undefined : { services: scope.services }), + ...(scope.tags.length === 0 ? undefined : { tags: scope.tags }), + ...(scope.environments.length === 0 ? undefined : { environments: scope.environments }), + } + +/** The zone the custom end is read in, as the viewer knows it at that end ("CET", then "CEST" past the change). */ +export const endZoneLabel = (model: Pick): string => + shortZoneName( + model.timeZone, + Option.getOrElse(parseLocalDateTime(model.until, model.timeZone), () => model.nowMs), + ) /** Why a typed custom end cannot be used, or none when it can (or nothing is typed yet). */ export const untilError = (model: Model): Option.Option => { @@ -187,6 +228,7 @@ const open = (model: Model, scope: Scope, timeZone: string, nowMs: number): Retu { ...model, scope, + combos: initCombos(model.dialog.id), timeZone, nowMs, reason: "", @@ -208,7 +250,23 @@ export const openFor = ( /** A silence the user scopes by service, tag and environment. */ export const openNew = (model: Model, timeZone: string, nowMs: number): Return => - open(model, { _tag: "Typed", services: "", tags: "", environments: "" }, timeZone, nowMs) + open(model, NO_CHIPS, timeZone, nowMs) + +/** A chips input's step: its popup state stays on the combo, a changed selection lands on the scope. */ +const scopeComboStep = (model: Model, field: ScopeField, message: Combobox.Message): Return => { + if (model.scope._tag === "Fixed") return { model } + const step = Combobox.update({ ...model.combos[field], selected: model.scope[field] }, message) + const scope = + step.outMessage?._tag === "ChangedSelection" + ? { ...model.scope, [field]: step.outMessage.selected } + : model.scope + return { + model: { ...model, scope, combos: { ...model.combos, [field]: step.model } }, + commands: Command.mapMessages(step.commands ?? [], (inner) => + Message.GotScopeComboMessage({ field, message: inner }), + ), + } +} /** Closes the panel, as Cancel does; a page whose URL holds it closes it on Back. */ export const close = (model: Model): Return => liftDialog(model, Dialog.close(model.dialog)) @@ -236,10 +294,7 @@ export const update = (model: Model, message: Message): Return => ChangedReason: ({ value }) => ({ model: { ...model, reason: value } }), ChangedDuration: ({ duration }) => ({ model: { ...model, duration } }), ChangedUntil: ({ value }) => ({ model: { ...model, until: value } }), - ChangedScopeField: ({ field, value }) => - model.scope._tag === "Fixed" - ? { model } - : { model: { ...model, scope: { ...model.scope, [field]: value } } }, + GotScopeComboMessage: ({ field, message: child }) => scopeComboStep(model, field, child), ClickedCancel: () => close(model), ClickedSave: () => save(model), CompletedSave: ({ openCount, result }) => diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index d2758eb2df..73a6d6e305 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -1,16 +1,33 @@ -import { Option, Array as Arr } from "effect" +import { Option, Array as Arr, String as Str } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" -import { eyebrow } from "@maple/views/ui/eyebrow" import { button } from "@maple/views/ui/button" +import * as Combobox from "@maple/views/ui/combobox" +import { chipsView } from "@maple/views/ui/combobox-view" import { panel } from "@maple/views/ui/panel" import { segmentedSelect } from "@maple/views/ui/segmented-select" import { sidePanelSheet } from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" -import { isFormReady, Message, type Model, type SilenceDuration, silenceEndMs, untilError } from "./dialog" -import { dialogTextField, type DialogTextFieldOptions, matcherFields } from "../shared/matcher-fields" +import { + endZoneLabel, + isFormReady, + Message, + type Model, + type Scope, + type ScopeField, + type ScopeSuggestions, + type SilenceDuration, + silenceEndMs, + untilError, +} from "./dialog" +import { + dialogHelp, + dialogLabel, + dialogTextField, + type DialogTextFieldOptions, +} from "../shared/matcher-fields" import { formatSilenceTime } from "../shared/silences" type H = HtmlBuilder @@ -45,89 +62,145 @@ const fixedScopeLine = (matchers: V2AlertSilenceMatchers): string => { : "Every notification of this rule" } -const scopeSection = (h: H, model: Model): Html => - vstack(h, { gap: "2" }, [ - eyebrow(h, { variant: "label", as: "div" }, ["Covers"]), - model.scope._tag === "Fixed" - ? panel(h, { padded: true, className: "gap-1" }, [ - typography(h, { size: "sm", weight: "medium" }, [model.scope.label]), - typography(h, { size: "xs", tone: "muted" }, [fixedScopeLine(model.scope.matchers)]), - ]) - : panel( +const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholder: string }> = [ + { field: "services", label: "Services", placeholder: "Add a service" }, + { field: "environments", label: "Environments", placeholder: "Add an environment" }, + { field: "tags", label: "Tags", placeholder: "Add a tag" }, +] + +const SCOPE_HELP_ID = "silence-scope-help" + +const NO_SUGGESTIONS: ScopeSuggestions = { services: [], environments: [], tags: [] } + +/** The suggestions, then what is typed when it is none of them: a name no rule lists can still be added. */ +const itemsOf = (suggested: ReadonlyArray, typed: string): ReadonlyArray => { + const draft = Str.trim(typed) + return draft === "" || suggested.includes(draft) ? suggested : [...suggested, draft] +} + +/** One scope field: its label and the chips input, suggesting what the org's rules already name. */ +const chipsField = ( + h: H, + model: Model, + scope: Extract, + suggestions: ScopeSuggestions, + { field, label, placeholder }: (typeof SCOPE_FIELDS)[number], +): Html => { + const combo = model.combos[field] + const labelId = `${combo.id}-label` + return vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: labelId, htmlFor: Combobox.inputId(combo.id) }, label), + h.submodel({ + slotId: `${combo.id}-chips`, + model: { ...combo, selected: scope[field] }, + view: chipsView, + toParentMessage: (message) => Message.GotScopeComboMessage({ field, message }), + viewInputs: { + items: itemsOf(suggestions[field], combo.inputValue), + ariaLabelledBy: labelId, + ariaDescribedBy: SCOPE_HELP_ID, + placeholder, + emptyText: "Type a name, then press Enter", + isDisabled: model.isSaving, + toView: (parts) => h.div([], parts), + }, + }), + ]) +} + +const scopeSection = (h: H, model: Model, suggestions: ScopeSuggestions): ReadonlyArray => { + const scope = model.scope + return scope._tag === "Fixed" + ? [ + vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: "silence-covers-label" }, "Covers"), + panel(h, { padded: true, className: "gap-1" }, [ + typography(h, { size: "sm", weight: "medium" }, [scope.label]), + typography(h, { size: "xs", tone: "muted" }, [fixedScopeLine(scope.matchers)]), + ]), + ]), + ] + : [ + ...Arr.map(SCOPE_FIELDS, (entry) => chipsField(h, model, scope, suggestions, entry)), + dialogHelp( h, - { padded: true, className: "gap-3" }, - matcherFields(h, { - idPrefix: "silence", - typed: model.scope, - onInput: (field, value) => Message.ChangedScopeField({ field, value }), - }), + SCOPE_HELP_ID, + "Fill in at least one. A rule is silenced only if it matches every field you fill in.", ), - ]) + ] +} -const durationSection = (h: H, model: Model): Html => - vstack(h, { gap: "2" }, [ - eyebrow(h, { variant: "label", as: "div" }, ["For"]), +const durationSection = (h: H, model: Model): ReadonlyArray => [ + vstack(h, { gap: "1.5" }, [ + dialogLabel(h, { id: "silence-duration-label" }, "Duration"), segmentedSelect(h, { ariaLabel: "Silence duration", options: DURATIONS, value: model.duration, onChange: (duration) => Message.ChangedDuration({ duration }), - size: "sm", + size: "lg", className: "w-fit flex-wrap", }), - ...(model.duration === "custom" - ? [ - textField(h, { - name: "until", - label: "Ends", - value: model.until, - placeholder: "", - type: "datetime-local", - onInput: (value) => Message.ChangedUntil({ value }), - description: `In ${model.timeZone}. At most 30 days from now.`, - error: untilError(model), - }), - ] - : []), - ]) - -/** The "Silence notifications" side panel: what it covers, for how long, and why. */ -export const view = Submodel.defineView((model, _inputs, h) => - sidePanelSheet(h, { - sheet: model.dialog, - toParentMessage: (message) => Message.GotDialogMessage({ message }), - title: "Silence notifications", - description: - "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", - body: [ - vstack(h, { gap: "5" }, [ - scopeSection(h, model), - durationSection(h, model), + ]), + ...(model.duration === "custom" + ? [ textField(h, { - name: "reason", - label: "Reason", - value: model.reason, - placeholder: "Deploying checkout v2.3", - onInput: (value) => Message.ChangedReason({ value }), + name: "until", + // The viewer's own clock, named: "Ends, CET". + label: `Ends, ${endZoneLabel(model)}`, + value: model.until, + placeholder: "", + type: "datetime-local", + onInput: (value) => Message.ChangedUntil({ value }), + description: "Your local time. At most 30 days from now.", + error: untilError(model), }), - ]), - ], - onSubmit: Message.ClickedSave(), - footer: (close) => [ - button( - h, - { variant: "outline", isDisabled: model.isSaving, attributes: model.isSaving ? [] : close }, - ["Cancel"], - ), - button( - h, - { - type: "submit", - isLoading: model.isSaving, - isDisabled: model.isSaving || !isFormReady(model), - }, - [submitLabel(model)], - ), - ], - }), + ] + : []), +] + +/** The "Silence notifications" side panel: what it covers, for how long, and why. */ +export const view = Submodel.defineView( + (model, inputs, h) => + sidePanelSheet(h, { + sheet: model.dialog, + toParentMessage: (message) => Message.GotDialogMessage({ message }), + title: "Silence notifications", + description: + "Rules keep checking and incidents still open and resolve, but no one is paged. If an incident is still open when the silence ends, its page goes out then.", + body: [ + vstack(h, { gap: "5" }, [ + ...scopeSection(h, model, inputs.suggestions ?? NO_SUGGESTIONS), + ...durationSection(h, model), + textField(h, { + name: "reason", + label: "Reason", + value: model.reason, + placeholder: "Deploying checkout v2.3", + onInput: (value) => Message.ChangedReason({ value }), + }), + ]), + ], + onSubmit: Message.ClickedSave(), + footer: (close) => [ + button( + h, + { + variant: "outline", + isDisabled: model.isSaving, + attributes: model.isSaving ? [] : close, + }, + ["Cancel"], + ), + button( + h, + { + type: "submit", + isLoading: model.isSaving, + isDisabled: model.isSaving || !isFormReady(model), + }, + [submitLabel(model)], + ), + ], + }), ) diff --git a/apps/web/src/page/kit/timezone-zones.ts b/apps/web/src/page/kit/timezone-zones.ts index 6cf9a54649..091b719af5 100644 --- a/apps/web/src/page/kit/timezone-zones.ts +++ b/apps/web/src/page/kit/timezone-zones.ts @@ -26,6 +26,37 @@ const clockFormatter = Option.liftThrowable( new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hour12: false }), ) +const abbreviationFormatter = (locale: string) => + Option.liftThrowable( + (timeZone: string) => new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: "short" }), + ) +const ABBREVIATION_FORMATTERS = [abbreviationFormatter("en-GB"), abbreviationFormatter("en-US")] + +/** + * A zone's short name at `atMs`: its abbreviation where ICU has one (`CET`, `CEST`, `EST`), else + * its offset (`UTC+5:30`). An unknown zone reads `UTC`. + */ +export const shortZoneName = (timeZone: string, atMs: number): string => + Option.getOrElse( + Arr.findFirst( + Arr.flatMap(ABBREVIATION_FORMATTERS, (formatterFor) => + Option.toArray( + Option.flatMap(formatterFor(timeZone), (formatter) => + Option.map( + Arr.findFirst( + formatter.formatToParts(atMs), + (entry) => entry.type === "timeZoneName", + ), + (entry) => entry.value, + ), + ), + ), + ), + (name) => /^[A-Z]{2,5}$/.test(name), + ), + () => formatUtcOffset(timeZone, atMs), + ) + /** `formatUtcOffset`: `UTC+2`, `UTC-5:30`, `UTC`, the offset a zone is on at `atMs`. */ export const formatUtcOffset = (timeZone: string, atMs: number): string => { const part = Option.match(offsetFormatter(timeZone), { diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index d41ccc49d9..e38119c24d 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -38,7 +38,7 @@ export interface NavSubItem { readonly alsoActive?: ReadonlyArray /** A live count beside the title, read by the shell (the Inbox's unacknowledged incidents). */ readonly count?: "alertsInbox" - /** Starts a group of rows: a thin rule above it, no heading. */ + /** Starts a group of rows: a small gap above it, no heading. */ readonly startsGroup?: boolean } diff --git a/apps/web/src/shell/shell.scene.test.ts b/apps/web/src/shell/shell.scene.test.ts index fd4ae2e8c8..c7444e9066 100644 --- a/apps/web/src/shell/shell.scene.test.ts +++ b/apps/web/src/shell/shell.scene.test.ts @@ -471,14 +471,13 @@ describe("alerts section", () => { ) }) - test("the Inbox counts unacknowledged incidents for this org, and thin rules split the groups", () => { + test("the Inbox counts unacknowledged incidents for this org", () => { const counted = update(signedIn(), Message.UpdatedAlertsInboxCount({ orgId: "org_parity", count: 3 })) scene( { update, view: shellAt("/alerts/rules") }, given(counted.model), measured, expect(text("3")).toExist(), - expect(role("separator")).toExist(), ) // Nothing to act on shows no badge; another org's count is dropped, and an org change clears it. scene( diff --git a/apps/web/src/shell/view-nav.ts b/apps/web/src/shell/view-nav.ts index 4206e21fe9..6137a5ff5a 100644 --- a/apps/web/src/shell/view-nav.ts +++ b/apps/web/src/shell/view-nav.ts @@ -1,4 +1,5 @@ import { isClerkAuthEnabled } from "../lib/services/common/auth-mode" +import { cn } from "@maple/ui/lib/utils" import { Option, Array as Arr, String as Str } from "effect" import type { InfraSurfaceLiteral } from "@maple/domain/http" import { AsyncData } from "foldkit" @@ -16,7 +17,6 @@ import { sidebarMenuSub, sidebarMenuSubButton, sidebarMenuSubItem, - sidebarSeparator, } from "@maple/views/ui/sidebar" import type { PinnedDashboardRow } from "./dashboards-shape" import { Menu, Message } from "./message" @@ -104,10 +104,6 @@ const subCountOf = (model: Model, sub: NavSubItem): number | undefined => ? model.alertsInboxCount : undefined -// A rule between groups keeps the sub-list's lane line unbroken. -const groupRule = (h: HtmlBuilder): Html => - sidebarMenuSubItem(h, "border-sidebar-border border-l-2 py-1 ps-2.5", [sidebarSeparator(h)]) - const subList = ( h: HtmlBuilder, model: Model, @@ -118,54 +114,54 @@ const subList = ( return sidebarMenuSub( h, "mx-0 ms-4 translate-x-0 gap-0 border-l-0 px-0 py-0", - Arr.flatMap(subItems, (sub, index) => { + Arr.map(subItems, (sub, index) => { const count = subCountOf(model, sub) - return [ - ...(sub.startsGroup === true && index > 0 ? [groupRule(h)] : []), - sidebarMenuSubItem( - h, - sub.href === activeSubHref - ? "border-sidebar-primary border-l-2 ps-2.5" - : "border-sidebar-border border-l-2 ps-2.5", - [ - sidebarMenuSubButton( - h, - { - className: - "h-7 translate-x-0 data-[active=true]:text-sidebar-primary sm:h-6 [&>svg]:text-current", - isActive: sub.href === activeSubHref, - link: { href: sub.href, currentPath }, - }, - [ - ...(sub.icon === undefined - ? [] - : [ - icon(h, sub.icon, { - className: "size-3.5", - ...iconColor(sub.iconColor), - }), - ]), - h.span([], [sub.title]), - ], - ), - ...(count === undefined - ? [] - : [ - sidebarMenuBadge( - h, - [ - typography( - h, - { size: "xs", mono: true, numeric: true, tone: "error" }, - [String(count)], - ), - ], - "top-1 sm:top-0.5", - ), - ]), - ], + return sidebarMenuSubItem( + h, + cn( + "border-l-2 ps-2.5", + sub.href === activeSubHref ? "border-sidebar-primary" : "border-sidebar-border", + // A 4px gap, not a rule, sets a group of rows apart. + sub.startsGroup === true && index > 0 && "mt-1", ), - ] + [ + sidebarMenuSubButton( + h, + { + className: + "translate-x-0 data-[active=true]:text-sidebar-primary [&>svg]:text-current", + isActive: sub.href === activeSubHref, + link: { href: sub.href, currentPath }, + }, + [ + ...(sub.icon === undefined + ? [] + : [ + icon(h, sub.icon, { + className: "size-3.5", + ...iconColor(sub.iconColor), + }), + ]), + h.span([], [sub.title]), + ], + ), + ...(count === undefined + ? [] + : [ + sidebarMenuBadge( + h, + [ + typography( + h, + { size: "xs", mono: true, numeric: true, tone: "error" }, + [String(count)], + ), + ], + "top-1/2 -translate-y-1/2", + ), + ]), + ], + ) }), ) } diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 3ce934a3bd..585ba45007 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -236,7 +236,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/time-controls.ts` `isPresetValue`, `presetLabel`, `presetSpanLabel`, `searchPreset`, `timeControls`, `timeRangeTriggerContent`: The time range header controls: the picker trigger, the reset for a custom range, and Reload. - `apps/web/src/page/kit/time-range-picker.ts` `searchPatchOf`, `dashboardTimeRangeOf`, `rejectedToast`, `unpicked`, `isActivePreset`, `isOpenKey`, `openOnHotkey`, `view` (stateful: Model, init, update): `TimeRangePicker`: the header popover with the preset rail and quick select. - `apps/web/src/page/kit/timezone-select-view.ts` `zoneListView`: The timezone select's list: the search input, "No timezone matches that." and the grouped zone list with clock and offset. -- `apps/web/src/page/kit/timezone-zones.ts` `formatUtcOffset`, `zoneLabel`, `matchesZoneQuery`, `zoneRows`, `readZoneRows`: The zone list logic of the timezone select. +- `apps/web/src/page/kit/timezone-zones.ts` `shortZoneName`, `formatUtcOffset`, `zoneLabel`, `matchesZoneQuery`, `zoneRows`, `readZoneRows`: The zone list logic of the timezone select. - `apps/web/src/page/kit/underline-tabs.ts` `underlineTabStrip`, `underlineTabClass`, `linkTabStrip`: `UnderlineTabStrip` and `underlineTabClass`: a row of tabs with an underlined active tab. - `apps/web/src/page/kit/where-autocomplete-view.ts` `suggestionListbox`, `whereKeyHandler`: `WhereClauseEditor`'s suggestion list and key handling, generic over the owner's Message. - `apps/web/src/page/kit/where-autocomplete.ts` `caretEvents`, `keepFocusEvents`, `listboxEvents`, `placeCaret`, `initWhereCaret`, `isOpenOf`, `activeKeysAt`, `syncedCaret`, `applied`, `listboxStyle`: The where clause autocomplete and its suggestion list, shared by every where editor (see lib/query-builder/where-clause-autocomplete.ts). diff --git a/packages/views/src/ui/segmented-select.ts b/packages/views/src/ui/segmented-select.ts index abc39de79f..a48365741f 100644 --- a/packages/views/src/ui/segmented-select.ts +++ b/packages/views/src/ui/segmented-select.ts @@ -122,7 +122,8 @@ export type SegmentedSelectConfig = Readonly<{ options: ReadonlyArray> value: T onChange: (value: T) => M - size?: "xs" | "sm" | "default" + /** `lg` keeps each segment at least as tall as a small button, for a choice in a form. */ + size?: "xs" | "sm" | "default" | "lg" ariaLabel?: string className?: string }> From d834035ae166573b57f980a7cbe8bd17301927a7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 04:34:55 +0100 Subject: [PATCH 28/69] fix(alerts): calmer quiet Inbox, one headroom tone, tidier rules table - Rules toolbar: a fixed-width search keeps the four status chips on one line. The ribbon column gives its spare width to Routes, "Fired 30d" stays on one line, and a lone "No tag" group header is left out. - One headroom tone for the rules table and the Inbox: neutral unless under a tenth of the threshold is left (warning) or it is breached (error). The Inbox's bars are thinner and their threshold tick is neutral. - Inbox: Acknowledge and Unacknowledge are the same small outline button, an empty MTTR reads "n/a", the Anomalies toggle is left out when the window has no anomaly (and shows no zero count), and an ungrouped reading says "all services". - A scene test pins that a rule's row is one link to the rule with its actions menu beside it. --- .../web/src/page/alerts/hub/hub.scene.test.ts | 83 +++++++++++++++++++ .../web/src/page/alerts/hub/view-anomalies.ts | 20 ++++- apps/web/src/page/alerts/hub/view-inbox.ts | 48 +++++------ apps/web/src/page/alerts/hub/view-rules.ts | 54 +++++++----- apps/web/src/page/alerts/hub/view.ts | 3 +- .../src/page/alerts/shared/headroom-tone.ts | 18 ++++ packages/web-data/src/alerts/headroom.ts | 3 + .../src/alerts/overview-derive.test.ts | 24 +++++- .../web-data/src/alerts/overview-derive.ts | 17 +++- 9 files changed, 217 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/page/alerts/shared/headroom-tone.ts diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 5d0c3508cb..7a2ee09e79 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -366,6 +366,89 @@ 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, ungrouped readings say all services", () => { + // 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 services")).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 }, 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-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index cc2e0ec9f8..00ae008c7d 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. @@ -260,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)), @@ -289,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, @@ -320,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 @@ -522,9 +519,13 @@ const headroomList = (h: HtmlBuilder, rows: ReadonlyArray, n ) } +/** The query engine's key for an ungrouped reading, which older state rows still carry. */ +const ALL_GROUPS = "all" + 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( [ @@ -549,40 +550,37 @@ const headroomRow = (h: HtmlBuilder, row: RuleHeadroom, now: number): Html typography( h, { size: "2xs", tone: "muted", mono: true, truncate: true, as: "p" }, - [row.groupLabel], + [row.groupLabel === ALL_GROUPS ? "all services" : row.groupLabel], ), ] : []), ], ), 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-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.ts b/apps/web/src/page/alerts/hub/view.ts index 45ca0d6631..9c2488f315 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -272,6 +272,7 @@ const hubBody = ( signal, status: resolveIncidentStatus(model.search.anomalyStatus), serviceName: model.search.serviceName, + hasAnomalies, // Counted under the same service filter the list applies. openAnomalies: Arr.filter( data.openIncidents, @@ -293,7 +294,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, 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/packages/web-data/src/alerts/headroom.ts b/packages/web-data/src/alerts/headroom.ts index facbe10d18..1bdd5fd410 100644 --- a/packages/web-data/src/alerts/headroom.ts +++ b/packages/web-data/src/alerts/headroom.ts @@ -21,6 +21,8 @@ export interface RuleHeadroom { readonly headroom: number | null /** Past the threshold or close to it, by the same test as the Rules tab's meter. */ readonly isNear: boolean + /** Past the threshold without an open incident yet (it has to breach a few times in a row). */ + readonly breached: boolean readonly checkedAt: number | null } @@ -85,6 +87,7 @@ export function ruleHeadroom( ...Struct.pick(thresholdHeadroom(rule, state.last_value), [ "headroom", "isNear", + "breached", ]), checkedAt: state.last_evaluated_at === null diff --git a/packages/web-data/src/alerts/overview-derive.test.ts b/packages/web-data/src/alerts/overview-derive.test.ts index 8049aa5d5f..282f3613c8 100644 --- a/packages/web-data/src/alerts/overview-derive.test.ts +++ b/packages/web-data/src/alerts/overview-derive.test.ts @@ -6,6 +6,7 @@ import { defaultRuleForm } from "./form-utils" import { blindSpots, fireLanes, + headroomTone, incidentScope, incidentSubline, incidentTitle, @@ -272,15 +273,30 @@ describe("ruleRibbon", () => { }) describe("ruleHeadroom near firing", () => { - it("calls a value near when under a fifth of the threshold is left, for floors and ceilings alike", () => { + it("calls a value near when under a tenth of the threshold is left, for floors and ceilings alike", () => { const ceiling = makeRule({ comparator: "gt", threshold: 0.05 }) const floor = makeRule({ comparator: "lt", threshold: 0.05 }) - expect(ruleHeadroom(ceiling, 0.042).isNear).toBe(true) - expect(ruleHeadroom(ceiling, 0.03).isNear).toBe(false) + expect(ruleHeadroom(ceiling, 0.046).isNear).toBe(true) + // 4.2% against a 5% ceiling leaves 16%: not near. + expect(ruleHeadroom(ceiling, 0.042).isNear).toBe(false) expect(ruleHeadroom(ceiling, 0.063).isNear).toBe(true) // 6.3% against a 5% floor leaves 26%: healthy, not near. expect(ruleHeadroom(floor, 0.063).isNear).toBe(false) - expect(ruleHeadroom(floor, 0.055).isNear).toBe(true) + expect(ruleHeadroom(floor, 0.054).isNear).toBe(true) + }) + + it("draws one tone everywhere: neutral, warning under a tenth left, error once breached", () => { + const ceiling = makeRule({ comparator: "gt", threshold: 0.05 }) + const floor = makeRule({ comparator: "lt", threshold: 0.05 }) + expect(headroomTone(ruleHeadroom(ceiling, 0.03))).toBe("neutral") + expect(headroomTone(ruleHeadroom(ceiling, 0.042))).toBe("neutral") + expect(headroomTone(ruleHeadroom(ceiling, 0.046))).toBe("warning") + expect(headroomTone(ruleHeadroom(ceiling, 0.063))).toBe("error") + expect(headroomTone(ruleHeadroom(floor, 0.063))).toBe("neutral") + expect(headroomTone(ruleHeadroom(floor, 0.054))).toBe("warning") + expect(headroomTone(ruleHeadroom(floor, 0.04))).toBe("error") + // A range has no single direction: neutral until it is breached. + expect(headroomTone({ breached: false, headroom: null })).toBe("neutral") }) }) diff --git a/packages/web-data/src/alerts/overview-derive.ts b/packages/web-data/src/alerts/overview-derive.ts index 21dbcbcb26..bfbf3f582c 100644 --- a/packages/web-data/src/alerts/overview-derive.ts +++ b/packages/web-data/src/alerts/overview-derive.ts @@ -126,8 +126,21 @@ export interface Headroom { readonly isNear: boolean } -/** Under a fifth of the threshold left counts as near firing, on every surface that says so. */ -export const NEAR_HEADROOM = 0.2 +/** Under a tenth of the threshold left counts as near firing, on every surface that says so. */ +export const NEAR_HEADROOM = 0.1 + +export type HeadroomTone = "neutral" | "warning" | "error" + +/** + * How a reading against its threshold is drawn, in the rules table and the Inbox alike: neutral + * unless under `NEAR_HEADROOM` is left (warning) or the threshold is passed (error). + */ +export const headroomTone = (reading: Pick): HeadroomTone => + reading.breached + ? "error" + : reading.headroom !== null && reading.headroom < NEAR_HEADROOM + ? "warning" + : "neutral" const breaches = (rule: AlertRuleDocument, value: number): boolean => { const upper = rule.thresholdUpper ?? rule.threshold From 6a7cfa6bc0d1a31adb14873dcac98f637b0acdb7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 04:44:49 +0100 Subject: [PATCH 29/69] fix(alerts): restore the AsyncData import the hub header needs after the merge --- apps/web/src/page/alerts/hub/view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 6474a11260..0527ff86e2 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -2,7 +2,7 @@ import { type AlertIncidentId, isManagedSignalType } from "@maple/domain/http" import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" import { makeClientErrorBody } from "@maple/web-data/lib/error-messages" import { Option, Array as Arr } from "effect" -import { Submodel } from "foldkit" +import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { button, linkButton } from "@maple/views/ui/button" import { splitButtonClass } from "@maple/views/ui/split-button" From 42fddb7d16e60dc9f21dbe67ba2979c365e572a5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 04:49:26 +0100 Subject: [PATCH 30/69] feat(alerts): rule editor sentence rows, one action bar state, errors after touch Alert when reads as sentences: each tier is one row with the same severity chip, and the checks and noise settings are inline sentences with short number fields, capped near max-w-3xl. The check interval and breach mode are selects inside the sentence; the mode switch is a select on a phone. The action bar says one thing (No changes, Ready to save, or what to fix as jump links that open the card and focus the field). Field errors show only after the field was left or save was pressed. An open card keeps one border around header and body, Done sits last in it, and Escape collapses a complete card. Notify no longer says no one is notified while a chain routes the tier. The rule chart clips to its window, leaves room for the last tick, draws the series apart from the threshold tones, and says what it is waiting for in text instead of a drawn trend. The lab runs the templates dialog's exit and answers previews from fixtures. --- apps/web/src/lab/rule-editor-lab.ts | 105 ++- apps/web/src/page/alerts/editor/actions.ts | 21 +- apps/web/src/page/alerts/editor/command.ts | 8 +- .../web/src/page/alerts/editor/derive-rows.ts | 26 +- .../editor/editor-anomaly.scene.test.ts | 13 +- .../alerts/editor/editor-cards.scene.test.ts | 184 +++++ .../alerts/editor/editor-chains.scene.test.ts | 22 +- .../page/alerts/editor/editor.scene.test.ts | 44 +- apps/web/src/page/alerts/editor/form-steps.ts | 17 + apps/web/src/page/alerts/editor/init.ts | 3 + apps/web/src/page/alerts/editor/interval.ts | 7 +- apps/web/src/page/alerts/editor/message.ts | 8 +- apps/web/src/page/alerts/editor/model.ts | 6 + apps/web/src/page/alerts/editor/steps.ts | 28 +- apps/web/src/page/alerts/editor/sub-steps.ts | 42 +- .../src/page/alerts/editor/summary.test.ts | 38 + apps/web/src/page/alerts/editor/summary.ts | 51 +- apps/web/src/page/alerts/editor/update.ts | 60 +- .../src/page/alerts/editor/view-action-bar.ts | 97 ++- .../src/page/alerts/editor/view-condition.ts | 716 ++++++++++-------- .../src/page/alerts/editor/view-details.ts | 48 +- .../web/src/page/alerts/editor/view-fields.ts | 99 +-- apps/web/src/page/alerts/editor/view-hero.ts | 24 +- apps/web/src/page/alerts/editor/view-noise.ts | 174 +++-- .../page/alerts/editor/view-notifications.ts | 227 +++--- .../web/src/page/alerts/editor/view-notify.ts | 6 +- apps/web/src/page/alerts/editor/view-query.ts | 421 +++++----- .../web/src/page/alerts/editor/view-select.ts | 3 + apps/web/src/page/alerts/editor/view.ts | 25 +- apps/web/src/page/alerts/shared/rule-chart.ts | 26 +- apps/web/src/page/kit/sentence-card.ts | 249 ++++-- .../src/charts/definitions/alert-rule.ts | 3 +- packages/views/src/ui/INDEX.md | 2 +- scripts/oxlint-plugins/ui-kit-baseline.json | 3 +- 34 files changed, 1781 insertions(+), 1025 deletions(-) diff --git a/apps/web/src/lab/rule-editor-lab.ts b/apps/web/src/lab/rule-editor-lab.ts index 8fe52998b9..5050a7f106 100644 --- a/apps/web/src/lab/rule-editor-lab.ts +++ b/apps/web/src/lab/rule-editor-lab.ts @@ -1,7 +1,7 @@ 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 } from "effect" +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" @@ -9,6 +9,8 @@ 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" @@ -17,17 +19,23 @@ 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`). +// 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 }) +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 and `?variant=`; `?theme=light|dark` sets the theme class as `index.html` does. */ +/** + * 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(() => { @@ -40,6 +48,9 @@ export const flags: Effect.Effect = Effect.all({ } 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 }) @@ -98,25 +109,23 @@ const ERROR_RATE_RULE = ruleRow(2, { const decodePreview = Schema.decodeUnknownSync(AlertRulePreviewResponse) -/** A week of error rate around 2%, with two bursts over the 5% line. */ -const previewOf = (nowMs: number): 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 start = nowMs - 7 * 24 * 60 * MIN - const count = Math.floor((nowMs - start) / bucketMs) - const burst = (i: number) => (i > 120 && i < 128) || (i > 250 && i < 255) + 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 value = burst(i) ? 0.072 + Math.sin(i) * 0.008 : 0.02 + Math.sin(i * 0.7) * 0.006 - const point = { bucket: new Date(start + i * bucketMs).toISOString(), value, sampleCount: 300 } + 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" } }) - const span = (from: number, to: number) => ({ - groupKey: "all", - start: new Date(start + from * bucketMs).toISOString(), - end: new Date(start + to * bucketMs).toISOString(), - severity: "warning", - }) return decodePreview({ bucketSeconds: bucketMs / 1000, windowMinutes: 5, @@ -128,7 +137,12 @@ const previewOf = (nowMs: number): AlertRulePreviewResponse => { comparator: "gt", truncatedToStart: null, series: [{ groupKey: "all", points }], - wouldFire: [span(123, 128), span(252, 255)], + 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", + })), }) } @@ -142,26 +156,40 @@ const opened = (search: EditorModel["search"], nowMs: number, rules: ReadonlyArr EditorMessage.CompletedFetchEscalationChains({ chains: populatedChains([]) }), ]) -const withPreview = (model: EditorModel, nowMs: number): EditorModel => - run(model, [ - EditorMessage.CompletedFetchPreview({ - key: model.preview.key ?? "", - isSaved: false, - result: Result.succeed(previewOf(nowMs)), - }), - ]) +/** 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))), + }) -const seed = (variant: Variant, nowMs: number): EditorModel => { - if (variant === "new") return run(opened({}, nowMs, []), [EditorMessage.ClickedStartBlank()]) - if (variant === "anomaly") return opened({ ruleId: ruleId(8) }, nowMs, [scopedAnomalyRuleRow()]) - // Every card on its summary except "Alert when", open for editing. - return run(withPreview(opened({ ruleId: ruleId(2) }, nowMs, [ERROR_RATE_RULE]), nowMs), [ - EditorMessage.ClickedExpandCard({ card: "condition" }), - ]) +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( @@ -171,15 +199,20 @@ const toLab = (commands: ReadonlyArray => { const started = editorInit({ _tag: "AlertsCreate", search: {} }, LAB_CONTEXT) + const seeded = seed(labFlags.variant, labFlags.open, labFlags.nowMs) return { - model: { variant: labFlags.variant, editor: seed(labFlags.variant, labFlags.nowMs) }, - commands: toLab(started.commands), + 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) - return { model: { ...model, editor: next.model }, commands: toLab(next.commands) } + // 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 => diff --git a/apps/web/src/page/alerts/editor/actions.ts b/apps/web/src/page/alerts/editor/actions.ts index 0e6108183b..dd502cd9eb 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) })], diff --git a/apps/web/src/page/alerts/editor/command.ts b/apps/web/src/page/alerts/editor/command.ts index c9233f85b6..ead4aea8a4 100644 --- a/apps/web/src/page/alerts/editor/command.ts +++ b/apps/web/src/page/alerts/editor/command.ts @@ -163,17 +163,19 @@ export const FocusSqlCaret = Command.define("FocusAlertSqlCaret", { }), }) -/** After the card opens or closes: its heading or its summary button, once the next frame has it. */ +/** Focuses an element by id once it is painted: a card's heading, its summary button, or a field. */ export const FocusRuleCard = Command.define("FocusAlertRuleCard", { args: { id: Schema.String }, messages: [Message.CompletedFocusCard], execute: ({ id }) => Effect.callback((resume) => { - const handle = requestAnimationFrame(() => { + 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)) }), }) 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/editor-anomaly.scene.test.ts b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts index 05d0353672..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 @@ -56,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/ @@ -136,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", () => { @@ -270,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 index f53545c25d..0bbb766787 100644 --- a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -1,24 +1,32 @@ 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 { 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 { FocusRuleCard } from "./command" +import { BREACH_MODE_SELECT_ID } from "./form-steps" import { init } from "./init" import { Message } from "./message" import type { Model } from "./model" @@ -139,6 +147,182 @@ describe("alert rule editor, sentence cards", () => { ) }) + 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), + 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"), + 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 }), 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..27a479c09e 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" @@ -80,7 +91,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 +153,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 +179,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.scene.test.ts b/apps/web/src/page/alerts/editor/editor.scene.test.ts index 5d509883f5..346b9465b1 100644 --- a/apps/web/src/page/alerts/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor.scene.test.ts @@ -27,12 +27,13 @@ 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, 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" @@ -163,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" }), ]) @@ -176,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", () => { @@ -222,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), @@ -235,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( @@ -368,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(), ) }) @@ -389,7 +400,7 @@ describe("alert rule editor", () => { 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"), ) }) @@ -575,7 +586,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/init.ts b/apps/web/src/page/alerts/editor/init.ts index 09ca171c50..0c9228f013 100644 --- a/apps/web/src/page/alerts/editor/init.ts +++ b/apps/web/src/page/alerts/editor/init.ts @@ -68,6 +68,9 @@ 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 2dab1c03a1..bacfdd599a 100644 --- a/apps/web/src/page/alerts/editor/message.ts +++ b/apps/web/src/page/alerts/editor/message.ts @@ -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: {}, @@ -146,9 +146,13 @@ export const Message = defineMessageUnion({ ClickedAddNotes: {}, /** A closed sentence card's summary: opens the card for editing. */ ClickedExpandCard: { card: RuleCard }, - /** An open card's Done: back to its summary. */ + /** 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 03aae8a7be..2145b93bda 100644 --- a/apps/web/src/page/alerts/editor/model.ts +++ b/apps/web/src/page/alerts/editor/model.ts @@ -124,6 +124,12 @@ export const Model = Schema.Struct({ 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 0215f2ea31..7cf25101dd 100644 --- a/apps/web/src/page/alerts/editor/steps.ts +++ b/apps/web/src/page/alerts/editor/steps.ts @@ -3,7 +3,8 @@ 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 { isProtectedDefault } from "./derive" @@ -59,6 +60,10 @@ export const initialize = (model: Model, context: PageContext): Return => { } /** 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 @@ -88,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 index fb1f6d1af5..a7da23d78c 100644 --- a/apps/web/src/page/alerts/editor/summary.test.ts +++ b/apps/web/src/page/alerts/editor/summary.test.ts @@ -3,15 +3,19 @@ import { anomalyRuleForm, defaultRuleForm } from "@maple/web-data/alerts/form-ut 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, + issueFieldId, missingCards, nameSummary, notifySummary, type NameLookup, type NotifyNames, + revealedInvalidFields, watchSummary, } from "./summary" @@ -222,6 +226,40 @@ describe("nameSummary", () => { }) }) +describe("field errors", () => { + 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([]) diff --git a/apps/web/src/page/alerts/editor/summary.ts b/apps/web/src/page/alerts/editor/summary.ts index d6e3fe8c57..624138b8b5 100644 --- a/apps/web/src/page/alerts/editor/summary.ts +++ b/apps/web/src/page/alerts/editor/summary.ts @@ -29,7 +29,8 @@ export const cardsOf = (form: RuleForm): ReadonlyArray => const WATCH_ISSUES = new Set(["SQL query", "$__orgFilter in SQL", "SQL value column", "Scope"]) const NOTIFY_ISSUES = new Set(["Who gets notified", "Critical destinations"]) -const cardOfIssue = (issue: string): RuleCard => +/** The card an issue is fixed on. */ +export const cardOfIssue = (issue: string): RuleCard => issue === "Rule name" ? "name" : NOTIFY_ISSUES.has(issue) @@ -38,6 +39,54 @@ const cardOfIssue = (issue: string): RuleCard => ? "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. diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index a8159553e9..e3fdec106d 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -13,11 +13,23 @@ 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, withMissingCardsOpen } 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" @@ -46,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 => @@ -144,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 } @@ -174,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 }) => @@ -225,6 +231,15 @@ export const update = (model: Model, message: Message, context: PageContext): Re 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 @@ -248,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..9305e14f88 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,20 +151,27 @@ 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"]), @@ -139,7 +179,8 @@ const bar = (h: H, model: BarView, form: RuleForm, timing: AlertQueryTiming | nu 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..006e4f6942 100644 --- a/apps/web/src/page/alerts/editor/view-details.ts +++ b/apps/web/src/page/alerts/editor/view-details.ts @@ -4,7 +4,6 @@ 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 { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" @@ -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,6 +121,7 @@ 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 })), ], }), ]) @@ -160,30 +160,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 +188,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..5b447ad7a1 100644 --- a/apps/web/src/page/alerts/editor/view-hero.ts +++ b/apps/web/src/page/alerts/editor/view-hero.ts @@ -10,7 +10,7 @@ import { 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 +24,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" @@ -156,13 +156,19 @@ const header = (h: H, model: HeaderView, form: RuleForm, timeZone: string): Html } /** 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 +186,15 @@ 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: `Pick a signal 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 +232,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..ea3f77d7cb 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" @@ -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,12 +149,12 @@ 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, @@ -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.ts b/apps/web/src/page/alerts/editor/view.ts index ba5e9f4b9a..bbe92e1782 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -166,12 +166,17 @@ const notifyNamesOf = (model: Model): NotifyNames => { } } +// 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 - body: () => Html + /** The open card's body: a width cap for a card of short fields. */ + bodyClassName?: string | undefined + body: () => ReadonlyArray }> const ruleCard = ( @@ -195,8 +200,9 @@ const ruleCard = ( 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()], + spec.body, ) } @@ -211,11 +217,8 @@ const surface = (h: H, model: Model, form: RuleForm, timeZone: string): Readonly lead: "Alert when", hint: "The dashed line on the chart is your threshold.", summary: conditionSummary(form), - body: () => - h.div( - [h.Class("space-y-4")], - [conditionFields(h, model, evaluated), noiseSection(h, model, evaluated)], - ), + bodyClassName: SHORT_FIELDS, + body: () => [conditionFields(h, model, evaluated), noiseSection(h, model, evaluated)], } const cards: ReadonlyArray = [ { @@ -225,20 +228,22 @@ const surface = (h: H, model: Model, form: RuleForm, timeZone: string): Readonly ? "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), + body: () => [querySection(h, model, evaluated)], }, ...(isAnomaly ? [] : [condition]), { card: "notify", lead: "Notify", summary: notifySummary(form, notifyNamesOf(model)), - body: () => notificationsSection(h, model, form), + bodyClassName: SHORT_FIELDS, + body: () => [notificationsSection(h, model, form)], }, { card: "name", lead: "Name", summary: nameSummary(form), - body: () => detailsSection(h, model, form), + bodyClassName: SHORT_FIELDS, + body: () => [detailsSection(h, model, form)], }, ] return [ 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/kit/sentence-card.ts b/apps/web/src/page/kit/sentence-card.ts index a09958829c..2d22ca7124 100644 --- a/apps/web/src/page/kit/sentence-card.ts +++ b/apps/web/src/page/kit/sentence-card.ts @@ -1,6 +1,7 @@ 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" @@ -8,7 +9,7 @@ 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 } from "effect" +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. @@ -29,8 +30,10 @@ export type SentenceCardOptions = Readonly<{ summary: string isExpanded: boolean onExpand: M - /** Closes an open card back to its summary; a missing card stays open, so it has none. */ + /** 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. */ @@ -41,7 +44,7 @@ const panelIdOf = (id: string): string => `sentence-card-${id}-panel` const DOT_TONE = { done: "done", missing: "warn" } as const -const LEAD_CLASS = "w-20 shrink-0 sm:w-24" +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 => @@ -72,27 +75,54 @@ const closedCard = (h: HtmlBuilder, options: SentenceCardOptions): Html ], }, [ - // Top-aligned with the first line on the rail dot: a phone wraps the summary to two lines. - hstack(h, { gap: "3", align: "start", as: "span", className: "min-h-8 py-1.5" }, [ - typography(h, { size: "sm", weight: "semibold", className: LEAD_CLASS }, [options.lead]), - typography( - h, - { - size: "sm", - weight: "normal", - className: "line-clamp-2 min-w-0 grow sm:line-clamp-1", - }, - [options.summary], - ), - hstack(h, { gap: "1", align: "center", as: "span", className: "h-5 shrink-0" }, [ - icon(h, "PencilIcon", { size: 12, className: "text-muted-foreground" }), - typography(h, { size: "xs", weight: "normal", tone: "muted" }, ["Edit"]), - ]), - ]), + // 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, @@ -100,78 +130,100 @@ const openCard = ( ): Html => { const headingId = sentenceCardHeadingId(options.id) const issues = options.issues ?? [] - // No frame of its own: the sections inside are cards. The header lines up with a closed card's row. - return h.section( - [h.AriaLabelledBy(headingId), h.Class("flex min-w-0 flex-col gap-3 pt-[calc(--spacing(3)+1px)]")], + 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, [ - hstack( - h, - { - gap: "3", - align: "start", - justify: "between", - className: "min-h-8 pl-[calc(--spacing(4)+1px)]", - }, + h.section( [ - hstack(h, { gap: "3", align: "baseline", wrap: true, className: "min-w-0 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" }, [ - issues.length === 0 - ? "Required" - : `Missing: ${Arr.join(issues, ", ")}`, - ]), - ] - : []), - ]), - ...(options.onCollapse === undefined || options.status === "missing" + h.AriaLabelledBy(headingId), + h.Class("flex min-w-0 flex-col gap-4 px-4 pt-3 pb-4"), + ...(onCollapse === undefined ? [] : [ - button( - h, - { - variant: "ghost", - size: "xs", - attributes: [ - h.AriaExpanded(true), - h.AriaControls(panelIdOf(options.id)), - h.OnClick(options.onCollapse), - ], - }, - ["Done"], + 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" }, [ + issues.length === 0 + ? "Required" + : `Missing: ${Arr.join(issues, ", ")}`, + ]), + ] + : []), + ], + ), + 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"], + ), + ]), + ]), + ], + ), + ], ), - h.div([h.Id(panelIdOf(options.id)), h.Class("flex min-w-0 flex-col gap-3")], body()), ], + "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. + * that opens it on click. A missing card is always open; Done or Escape closes a complete one. */ export const sentenceCard = ( h: HtmlBuilder, @@ -201,3 +253,46 @@ export const sentenceChain = ( 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/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 44ded0baf8..21da867cf6 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -230,7 +230,7 @@ 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`: Sentence cards: a form that reads top to bottom as one sentence ("When ... +- `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. diff --git a/scripts/oxlint-plugins/ui-kit-baseline.json b/scripts/oxlint-plugins/ui-kit-baseline.json index 7ab0ea764e..97fe1c66a6 100644 --- a/scripts/oxlint-plugins/ui-kit-baseline.json +++ b/scripts/oxlint-plugins/ui-kit-baseline.json @@ -33,9 +33,8 @@ "apps/web/src/page/agent-sessions/tools/view-table.ts": 1, "apps/web/src/page/alerts/destination/view.ts": 2, "apps/web/src/page/alerts/editor/view-action-bar.ts": 3, - "apps/web/src/page/alerts/editor/view-condition.ts": 4, "apps/web/src/page/alerts/editor/view-details.ts": 4, - "apps/web/src/page/alerts/editor/view-notifications.ts": 10, + "apps/web/src/page/alerts/editor/view-notifications.ts": 9, "apps/web/src/page/alerts/editor/view-notify.ts": 4, "apps/web/src/page/alerts/editor/view-sql.ts": 14, "apps/web/src/page/alerts/editor/view-templates.ts": 2, From 9090847e2d17d358562db1d8d6f59d0a34644579 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:00:42 +0100 Subject: [PATCH 31/69] fix(query-builder): where controls wrap inside a narrow panel --- apps/web/src/page/kit/query-builder/parts.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/kit/query-builder/parts.ts b/apps/web/src/page/kit/query-builder/parts.ts index 3539f6a130..3efcc7b4a0 100644 --- a/apps/web/src/page/kit/query-builder/parts.ts +++ b/apps/web/src/page/kit/query-builder/parts.ts @@ -106,10 +106,12 @@ export const whereClauseEditor = ( ariaLabel: `Where clause editor for query ${query.name}`, className: "shrink-0", }) - const controls = hstack(h, { gap: "2", className: "float-right mb-1 ml-2" }, [ - toggle, - ...(options.trailing ?? []), - ]) + // 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), { onNone: () => [], onSome: (reason) => [ From 503f89fd7ad617442cdbec41898b04da37ccdf1e Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:08:46 +0100 Subject: [PATCH 32/69] fix(alerts): a long chain name in the routing list ends in an ellipsis --- apps/web/src/page/alerts/hub/view-chains.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index f98f0af2a7..fd2e617007 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -91,7 +91,12 @@ export const chainRow = ( rowIcon(h, "BellIcon"), vstack(h, { gap: "0.5", shrink: true, grow: true }, [ typography(h, { size: "sm", weight: "medium", truncate: true, as: "p" }, [ - textLink(h, { href: chainHref(chain.id), tone: "inherit" }, [chain.name]), + // `block truncate`: an inline-flex link would be clipped without an ellipsis. + textLink( + h, + { href: chainHref(chain.id), tone: "inherit", className: "block truncate" }, + [chain.name], + ), ]), typography( h, From 9708166dce82f3ece39f19b82fc8e3d6e140f33d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:13:09 +0100 Subject: [PATCH 33/69] fix(alerts): chain target remove buttons name their step; a failed people read is said --- .../escalation-chain.scene.test.ts | 9 ++++- .../src/page/alerts/escalation-chain/model.ts | 2 + .../page/alerts/escalation-chain/update.ts | 7 ++-- .../src/page/alerts/escalation-chain/view.ts | 38 +++++++++++-------- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts index 99b85cfdb8..71674951eb 100644 --- a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -270,6 +270,13 @@ describe("escalation chain editor", () => { expectView(text(/No destinations yet/u)).toExist(), expectView(role("link", { name: "Create a destination" })).toHaveAttr("href", "/alerts/routing"), ) + // A failed read is said, so an empty People group is not mistaken for an org of one. + scene( + { update: pageUpdate, view }, + given(open({ ...newChain(), members: null, isMembersFailed: true })), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(text("People could not be loaded")).toExist(), + ) }) test("delete lists the rules that would stop escalating and holds the delete while they use it", () => { @@ -392,7 +399,7 @@ describe("escalation chain editor", () => { expectView(role("button", { name: "Delete chain" })).not.toExist(), expectView(role("button", { name: "Actions for step 1" })).not.toExist(), expectView(role("button", { name: "Add a step after step 1" })).not.toExist(), - expectView(role("button", { name: "Stop notifying Slack #alerts-api" })).not.toExist(), + expectView(role("button", { name: "Stop notifying Slack #alerts-api on step 1" })).not.toExist(), expectView(role("link", { name: "Back to routing" })).toExist(), ) // The update holds the same line as the view. diff --git a/apps/web/src/page/alerts/escalation-chain/model.ts b/apps/web/src/page/alerts/escalation-chain/model.ts index ec014ffef8..b805a30ab6 100644 --- a/apps/web/src/page/alerts/escalation-chain/model.ts +++ b/apps/web/src/page/alerts/escalation-chain/model.ts @@ -24,7 +24,9 @@ export const Model = Schema.Struct({ baseline: Form, destinations: Schema.NullOr(Schema.Array(AlertDestinationRowSchema)), rules: Schema.NullOr(Schema.Array(AlertRuleRowSchema)), + /** `null` until the members load, and after a failed read (`isMembersFailed`). */ members: Schema.NullOr(Schema.Array(OrgMember)), + isMembersFailed: Schema.Boolean, /** `null` until the schedules load, and after a failed read (`isSchedulesFailed`). */ schedules: Schema.NullOr(Schema.Array(V2OnCallSchedule)), isSchedulesFailed: Schema.Boolean, diff --git a/apps/web/src/page/alerts/escalation-chain/update.ts b/apps/web/src/page/alerts/escalation-chain/update.ts index 7f5ee38ddb..4c92b77107 100644 --- a/apps/web/src/page/alerts/escalation-chain/update.ts +++ b/apps/web/src/page/alerts/escalation-chain/update.ts @@ -69,6 +69,7 @@ export const init = (route: ChainRoute, context: PageContext): Return => { destinations: null, rules: null, members: null, + isMembersFailed: false, schedules: null, isSchedulesFailed: false, brokenLogos: [], @@ -286,11 +287,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, UpdatedRules: ({ orgId, rows }) => orgId === context.orgId ? { model: { ...model, rules: rows } } : { model }, - // Without members a person reads "Member"; destinations and schedules still show. + // Without members a person reads "Member", and the picker says people could not be loaded. CompletedFetchMembers: ({ result }) => Result.match(result, { - onSuccess: (members) => ({ model: { ...model, members } }), - onFailure: () => ({ model }), + onSuccess: (members) => ({ model: { ...model, members, isMembersFailed: false } }), + onFailure: () => ({ model: { ...model, isMembersFailed: true } }), }), CompletedFetchSchedules: ({ result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index 2e768f1808..e924e1acf1 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -144,8 +144,16 @@ const onCallNote = (model: Model, scheduleId: string): string | undefined => ) /** A picked target's chip: a destination's logo, a member's avatar, a schedule's calendar. */ -const targetChip = (h: H, model: Model, key: string, target: V2EscalationTarget): PickedChip => { +const targetChip = ( + h: H, + model: Model, + key: string, + index: number, + target: V2EscalationTarget, +): PickedChip => { const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) + // The step is named: the same target may sit on several steps. + const onStep = `on step ${index + 1}` switch (target.type) { case "destination": { const name = nameOr(model.destinations, target.destination_id, "Destination") @@ -153,7 +161,7 @@ const targetChip = (h: H, model: Model, key: string, target: V2EscalationTarget) key: targetKey(target), label: name, media: destinationMedia(h, model, target.destination_id), - removeLabel: `Stop notifying ${name}`, + removeLabel: `Stop notifying ${name} ${onStep}`, onRemove, } } @@ -169,7 +177,7 @@ const targetChip = (h: H, model: Model, key: string, target: V2EscalationTarget) Option.map(memberOf(model.members, target.user_id), (member) => member.imageUrl), ), }), - removeLabel: `Stop paging ${name}`, + removeLabel: `Stop paging ${name} ${onStep}`, onRemove, } } @@ -181,7 +189,7 @@ const targetChip = (h: H, model: Model, key: string, target: V2EscalationTarget) label: name, media: icon(h, "CalendarIcon", { size: 14, className: "shrink-0 text-muted-foreground" }), ...(detail === undefined ? undefined : { detail }), - removeLabel: `Stop paging ${name}`, + removeLabel: `Stop paging ${name} ${onStep}`, onRemove, } } @@ -234,17 +242,17 @@ const addEntries = (h: H, model: Model, step: StepDraft): ReadonlyArray ({ - value: `user:${member.userId}`, - label: member.name, - })), - ), + ...(model.members === null + ? [loadingEntry(model.isMembersFailed ? "People could not be loaded" : "Loading people")] + : group( + "People", + Arr.map(model.members, (member) => ({ + value: `user:${member.userId}`, + label: member.name, + })), + )), ...(model.schedules === null - ? model.isSchedulesFailed - ? [] - : [loadingEntry("Loading schedules")] + ? [loadingEntry(model.isSchedulesFailed ? "Schedules could not be loaded" : "Loading schedules")] : group( "Whoever is on call on", Arr.map(model.schedules, (schedule) => ({ @@ -309,7 +317,7 @@ const stepCard = (h: H, model: Model, step: StepDraft, index: number): Html => { index === 0 ? "Notify" : "Then notify", ]), ...pickChips(h, { - entries: Arr.map(step.targets, (target) => targetChip(h, model, key, target)), + entries: Arr.map(step.targets, (target) => targetChip(h, model, key, index, target)), menu: model.menus[targetsMenuId] ?? Menu.init(targetsMenuId), toMenuMessage: (message) => Message.GotMenuMessage({ kind: "targets", key, message }), addEntries: addEntries(h, model, step), From 109147bfad253d0bfd77f4e3b1adfc5c84a8ce79 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:14:22 +0100 Subject: [PATCH 34/69] refactor(alerts): the Inbox reads the engine's ungrouped key instead of re-spelling it --- apps/web/src/page/alerts/hub/view-inbox.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 00ae008c7d..693693d956 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -1,4 +1,5 @@ import type { AlertIncidentDocument, AlertIncidentId, AlertRuleDocument } from "@maple/domain/http" +import { ENGINE_UNGROUPED_GROUP_KEY } from "@maple/query-engine" import { acknowledgedLine } from "@maple/web-data/alerts/incident-timeline" import { countLabel } from "@maple/ui/lib/format" import { formatRelativeShortFrom } from "@maple/ui/lib/time-format" @@ -519,9 +520,6 @@ const headroomList = (h: HtmlBuilder, rows: ReadonlyArray, n ) } -/** The query engine's key for an ungrouped reading, which older state rows still carry. */ -const ALL_GROUPS = "all" - const headroomRow = (h: HtmlBuilder, row: RuleHeadroom, now: number): Html => { const { rule, closeness } = row const tone = headroomTone(row) @@ -550,7 +548,11 @@ const headroomRow = (h: HtmlBuilder, row: RuleHeadroom, now: number): Html typography( h, { size: "2xs", tone: "muted", mono: true, truncate: true, as: "p" }, - [row.groupLabel === ALL_GROUPS ? "all services" : row.groupLabel], + [ + row.groupLabel === ENGINE_UNGROUPED_GROUP_KEY + ? "all services" + : row.groupLabel, + ], ), ] : []), From 7e5000c073b013824b83d7c40dadf4098a6bd0db Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:32:34 +0100 Subject: [PATCH 35/69] fix(alerts): layer a saved schedule's overrides into the editor preview Editing a saved schedule now reads its overrides and renders the draft with them, so the preview's gaps match paging and the detail page. The timeline is a skeleton until they land; a failed read previews without them, says so and offers another try. A new schedule asks for none. The timeline's day header keeps each day's full name for screen readers when its drawn label is shortened, and durationText's comment says minutes show only under a day. --- apps/web/src/lab/oncall-lab.ts | 16 ++- .../src/page/alerts/schedule-form/command.ts | 13 +- .../src/page/alerts/schedule-form/message.ts | 4 +- .../src/page/alerts/schedule-form/model.ts | 4 +- .../src/page/alerts/schedule-form/preview.ts | 73 ++++++++++- .../schedule-form/schedule-form.scene.test.ts | 119 ++++++++++++++++++ .../src/page/alerts/schedule-form/update.ts | 33 ++++- .../page/alerts/shared/coverage-preview.ts | 30 ++++- .../alerts/shared/schedule-coverage.test.ts | 3 + .../page/alerts/shared/schedule-coverage.ts | 2 +- .../views/src/ui/shift-timeline.scene.test.ts | 11 +- packages/views/src/ui/shift-timeline.test.ts | 6 + packages/views/src/ui/shift-timeline.ts | 24 +++- 13 files changed, 314 insertions(+), 24 deletions(-) diff --git a/apps/web/src/lab/oncall-lab.ts b/apps/web/src/lab/oncall-lab.ts index 4cda5eb6a2..d53e3cd1dd 100644 --- a/apps/web/src/lab/oncall-lab.ts +++ b/apps/web/src/lab/oncall-lab.ts @@ -57,6 +57,7 @@ export const LabState = Schema.Literals([ "editor-covered", "editor-gaps", "editor-early", + "editor-override", "editor-edit", "editor-invalid", "editor-error", @@ -196,7 +197,7 @@ const addPerson = (index: number) => }) 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, @@ -215,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)), + }), ] : []), ] @@ -285,7 +290,14 @@ const formFor = (state: LabState): Step => ] : []), ] - : [] + : 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/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/message.ts b/apps/web/src/page/alerts/schedule-form/message.ts index fb69c903e0..53db9f5d13 100644 --- a/apps/web/src/page/alerts/schedule-form/message.ts +++ b/apps/web/src/page/alerts/schedule-form/message.ts @@ -1,6 +1,6 @@ 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" @@ -23,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 }, diff --git a/apps/web/src/page/alerts/schedule-form/model.ts b/apps/web/src/page/alerts/schedule-form/model.ts index f192a593d3..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" @@ -19,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, diff --git a/apps/web/src/page/alerts/schedule-form/preview.ts b/apps/web/src/page/alerts/schedule-form/preview.ts index 3147b1949a..e131afad45 100644 --- a/apps/web/src/page/alerts/schedule-form/preview.ts +++ b/apps/web/src/page/alerts/schedule-form/preview.ts @@ -1,7 +1,10 @@ import type { OnCallShift } from "@maple/alerting-core/oncall" -import { OnCallTimeZone } from "@maple/domain/http" +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, @@ -11,6 +14,7 @@ import { } 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" @@ -57,6 +61,39 @@ export const previewNotes = (inputs: PreviewInputs): ReadonlyArray }) } +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, @@ -73,10 +110,33 @@ const render = ( 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)) - const coverage = draftCoverage({ timeZone: zone, layers }, [], range.startMs, range.endMs, nowMs) + // 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", @@ -108,8 +168,9 @@ const render = ( onFocusGap: (index) => Message.ClickedPreviewGap({ index }), // A draft has no past to look back on. isPreviousDisabled: weekOffset <= 0, - notes: previewNotes({ timeZone: zone, layers, members, nowMs, isNew }), - note: isNew ? undefined : "Overrides are not shown here.", + 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, }) } @@ -117,7 +178,7 @@ const slot = createLazy() const MINUTE_MS = 60_000 -/** Redrawn only when the zone, the layers, the members, the minute or the range change, not on every key. */ +/** 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, @@ -128,4 +189,6 @@ export const schedulePreview = (h: HtmlBuilder, model: Model): Html => 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 fed1c5e6fe..d311bdc489 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, @@ -51,6 +52,24 @@ 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, @@ -425,4 +444,104 @@ describe("schedule form", () => { 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 for the next 14 days")).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 the next/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 the next 14 days, 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 0a4af206fa..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" @@ -12,6 +12,7 @@ import { scheduleHref, scheduleIdFromUrl } from "../shared/oncall-schedules" import { FetchExistingSchedule, FetchScheduleFormMembers, + FetchScheduleFormOverrides, FetchScheduleFormSession, ReadScheduleFormDefaults, SaveSchedule, @@ -41,6 +42,18 @@ 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), @@ -49,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, @@ -70,13 +84,19 @@ export const init = (route: FormRoute, context: PageContext): Return => { 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, + ], }), }) } @@ -269,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 })) diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts index d873cd7292..183b6c5263 100644 --- a/apps/web/src/page/alerts/shared/coverage-preview.ts +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -10,6 +10,7 @@ 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" @@ -58,8 +59,15 @@ export type PreviewLane = Readonly<{ emptyText: string }> -/** A plain-language line over the timeline: `warn` changes what is covered, `info` only explains. */ -export type PreviewNote = Readonly<{ tone: "info" | "warn"; text: 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. */ @@ -84,7 +92,7 @@ export type CoveragePreviewOptions = Readonly<{ isPreviousDisabled?: boolean /** A final shift's bar action (the detail page's "cover with an override"). */ onFinalShift?: (shift: PreviewShift) => Option.Option - notes?: ReadonlyArray + 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. */ @@ -231,7 +239,7 @@ const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions return vstack(h, { gap: "1.5", attributes: [h.DataAttribute("slot", "coverage-summary")] }, body) } -const noteView = (h: HtmlBuilder, note: PreviewNote): Html => +const noteView = (h: HtmlBuilder, note: PreviewNote): Html => hstack(h, { gap: "1.5", align: "start", attributes: [h.Key(note.text)] }, [ h.span( [ @@ -242,7 +250,19 @@ const noteView = (h: HtmlBuilder, note: PreviewNote): Html => ], [icon(h, note.tone === "warn" ? "CircleWarningIcon" : "CircleInfoIcon", { size: 14 })], ), - typography(h, { size: "xs", tone: "muted", as: "p" }, [note.text]), + 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 => diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts index adcc07d41b..b459ff3bee 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts @@ -257,6 +257,9 @@ describe("gapSummary", () => { 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") }) }) diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.ts b/apps/web/src/page/alerts/shared/schedule-coverage.ts index 2a85dbf5ea..f4c206818a 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.ts @@ -201,7 +201,7 @@ export const gapRangeText = (gap: Interval, timeZone: string, checkedToMs: numbe return `${start} to ${end}` } -/** "16h", "1h 30m", "3d 4h": a gap's length, rounded to minutes. */ +/** "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) diff --git a/packages/views/src/ui/shift-timeline.scene.test.ts b/packages/views/src/ui/shift-timeline.scene.test.ts index 23536c2692..1f49587692 100644 --- a/packages/views/src/ui/shift-timeline.scene.test.ts +++ b/packages/views/src/ui/shift-timeline.scene.test.ts @@ -1,4 +1,4 @@ -import { expect as expectView, given, role, scene, text } from "foldkit/scene" +import { expect as expectView, given, role, scene, text, within } from "foldkit/scene" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import { describe, test } from "vitest" @@ -124,4 +124,13 @@ describe("shift timeline", () => { expectView(role("img", { name: "Ben, Mon" })).toHaveAttr("tabIndex", "-1"), ) }) + + test("every day of the header is named in full, however its drawn label is shortened", () => { + scene( + { update, view }, + given({ nowMs: FROM + 35 * HOUR }), + expectView(within(role("columnheader", { name: "Shifts" }), text("Mon 9 Nov"))).toExist(), + expectView(within(role("columnheader", { name: "Shifts" }), text("Sun 15 Nov"))).toExist(), + ) + }) }) diff --git a/packages/views/src/ui/shift-timeline.test.ts b/packages/views/src/ui/shift-timeline.test.ts index dbc5c86208..dc0f4d85c0 100644 --- a/packages/views/src/ui/shift-timeline.test.ts +++ b/packages/views/src/ui/shift-timeline.test.ts @@ -56,6 +56,12 @@ describe("shift timeline layout", () => { ) }) + test("each day carries its full name, month included, across a month's end", () => { + const from = Date.parse("2026-11-28T23:00:00.000Z") // Sun 29 Nov, 00:00 in Berlin + const days = timelineDays(from, from + 3 * 24 * HOUR, "Europe/Berlin") + expect(Arr.map(days, (day) => day.label)).toEqual(["Sun 29 Nov", "Mon 30 Nov", "Tue 1 Dec"]) + }) + test("Saturdays and Sundays are marked as the weekend, on the zone's own days", () => { const from = Date.parse("2026-11-12T23:00:00.000Z") // Fri 13 Nov, 00:00 in Berlin const days = timelineDays(from, from + 4 * 24 * HOUR, "Europe/Berlin") diff --git a/packages/views/src/ui/shift-timeline.ts b/packages/views/src/ui/shift-timeline.ts index 58656c254c..b8ab8deb98 100644 --- a/packages/views/src/ui/shift-timeline.ts +++ b/packages/views/src/ui/shift-timeline.ts @@ -47,6 +47,8 @@ export type TimelineDay = Readonly<{ endMs: number weekday: string dayOfMonth: number + /** The day in full ("Tue 10 Nov"): its name wherever the drawn label is shortened or hidden. */ + label: string leftPct: number widthPct: number isWeekend: boolean @@ -60,6 +62,14 @@ const weekdayOf = (ms: number, timeZone: string): string => { return formatter.format(ms) } +const monthFormatters = new Map() +const monthOf = (ms: number, timeZone: string): string => { + const cached = monthFormatters.get(timeZone) + const formatter = cached ?? new Intl.DateTimeFormat("en-US", { timeZone, month: "short" }) + if (cached === undefined) monthFormatters.set(timeZone, formatter) + return formatter.format(ms) +} + const WEEKEND = new Set(["Sat", "Sun"]) /** The instant the wall-clock day containing `ms` starts in `timeZone`, `offsetDays` later. */ @@ -92,15 +102,20 @@ export const timelineDays = (fromMs: number, toMs: number, timeZone: string): Re return Arr.flatMap(Arr.range(0, count), (offset): ReadonlyArray => { const startMs = zonedDayStart(fromMs, timeZone, offset) const endMs = zonedDayStart(fromMs, timeZone, offset + 1) + // The day's first instant inside the range names it. + const atMs = Math.max(startMs, fromMs) + const weekday = weekdayOf(atMs, timeZone) + const dayOfMonth = zonedDateParts(atMs, timeZone).day return Option.match(placeBar(fromMs, toMs, startMs, endMs), { onNone: () => [], onSome: (place) => [ { startMs, endMs, - weekday: weekdayOf(Math.max(startMs, fromMs), timeZone), - isWeekend: WEEKEND.has(weekdayOf(Math.max(startMs, fromMs), timeZone)), - dayOfMonth: zonedDateParts(Math.max(startMs, fromMs), timeZone).day, + weekday, + isWeekend: WEEKEND.has(weekday), + dayOfMonth, + label: `${weekday} ${dayOfMonth} ${monthOf(atMs, timeZone)}`, ...place, }, ], @@ -314,6 +329,8 @@ export const shiftTimeline = ( h.Style({ left: `${day.leftPct}%`, width: `${day.widthPct}%` }), ], [ + // Read out in full; the two drawn forms below shorten or hide it by the day's width. + h.span([h.Class("sr-only")], [day.label]), // "Tue 10" where a day is wide enough, else the weekday's letter over the date. typography( h, @@ -323,6 +340,7 @@ export const shiftTimeline = ( numeric: true, truncate: true, className: "hidden px-1.5 py-1.5 @min-[3.5rem]:block", + attributes: [h.AriaHidden(true)], }, [`${day.weekday} ${day.dayOfMonth}`], ), From 26c44d431b1c0ce958104dc8261afb85a1f506b0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:48:37 +0100 Subject: [PATCH 36/69] fix(alerts): ungrouped readings and incidents show no group, with fixtures on the stored key --- apps/web/src/page/alerts/hub/hub.scene.test.ts | 9 +++++---- apps/web/src/page/alerts/hub/view-inbox.ts | 7 +------ apps/web/src/page/alerts/shared/test-rows.ts | 5 +++-- packages/web-data/src/alerts/overview-derive.test.ts | 7 +++++++ packages/web-data/src/alerts/overview-derive.ts | 10 +++------- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 7a2ee09e79..6f4fe9892f 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -106,7 +106,7 @@ describe("alerts hub", () => { { update: pageUpdate, view }, given(loaded()), expectView(text("1 firing")).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", ), @@ -247,7 +247,7 @@ describe("alerts hub", () => { expectView(text("0 firing")).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(), @@ -396,7 +396,7 @@ describe("alerts hub", () => { ) }) - test("a quiet Inbox stays calm: no Anomalies toggle without anomalies, MTTR n/a, ungrouped readings say all services", () => { + 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( [ @@ -419,7 +419,8 @@ describe("alerts hub", () => { 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 services")).toExist(), + expectView(text("all")).not.toExist(), + expectView(text("__total__")).not.toExist(), ) }) diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 693693d956..a7c7bd9a6e 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -1,5 +1,4 @@ import type { AlertIncidentDocument, AlertIncidentId, AlertRuleDocument } from "@maple/domain/http" -import { ENGINE_UNGROUPED_GROUP_KEY } from "@maple/query-engine" import { acknowledgedLine } from "@maple/web-data/alerts/incident-timeline" import { countLabel } from "@maple/ui/lib/format" import { formatRelativeShortFrom } from "@maple/ui/lib/time-format" @@ -548,11 +547,7 @@ const headroomRow = (h: HtmlBuilder, row: RuleHeadroom, now: number): Html typography( h, { size: "2xs", tone: "muted", mono: true, truncate: true, as: "p" }, - [ - row.groupLabel === ENGINE_UNGROUPED_GROUP_KEY - ? "all services" - : row.groupLabel, - ], + [row.groupLabel], ), ] : []), diff --git a/apps/web/src/page/alerts/shared/test-rows.ts b/apps/web/src/page/alerts/shared/test-rows.ts index 74df831c09..9f304a19f6 100644 --- a/apps/web/src/page/alerts/shared/test-rows.ts +++ b/apps/web/src/page/alerts/shared/test-rows.ts @@ -1,3 +1,4 @@ +import { UNGROUPED_GROUP_KEY } from "@maple/domain/http" import type { AlertDestinationRow, AlertIncidentRow, @@ -66,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", @@ -84,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/packages/web-data/src/alerts/overview-derive.test.ts b/packages/web-data/src/alerts/overview-derive.test.ts index 282f3613c8..442c113cbb 100644 --- a/packages/web-data/src/alerts/overview-derive.test.ts +++ b/packages/web-data/src/alerts/overview-derive.test.ts @@ -171,6 +171,13 @@ describe("incident lines", () => { expect(incidentSubline(incident)).toBe("High error rate · checkout") }) + it("titles an ungrouped telemetry incident without a group, whichever way storage spells ungrouped", () => { + const ungrouped = (groupKey: string | null) => + makeIncident({ groupKey, status: "open", resolvedAt: null }) + expect(incidentTitle(ungrouped(null))).toBe("Error rate 8.0%") + expect(incidentTitle(ungrouped("__total__"))).toBe("Error rate 8.0%") + }) + it("shows an anomaly incident's stored label, never its incident-id group key", () => { const anomaly = (groupLabel?: string) => makeIncident({ diff --git a/packages/web-data/src/alerts/overview-derive.ts b/packages/web-data/src/alerts/overview-derive.ts index bfbf3f582c..1e022cbb7b 100644 --- a/packages/web-data/src/alerts/overview-derive.ts +++ b/packages/web-data/src/alerts/overview-derive.ts @@ -52,10 +52,6 @@ type IncidentLine = Pick< "signalType" | "ruleName" | "groupKey" | "groupLabel" | "status" | "lastObservedValue" | "threshold" > -/** The group as people read it: the stored label, else the key; an anomaly's key is never shown. */ -const shownGroup = (incident: IncidentLine): string | null => - incident.groupLabel ?? (incident.signalType === "anomaly" ? null : incident.groupKey) - /** Uptime incidents name the monitor ("checkout-api is down"), not a signal and a value. */ const isUptime = (incident: Pick) => incident.signalType === "uptime" @@ -77,18 +73,18 @@ export function incidentTitle(incident: IncidentLine): string { return `${sloNameOf(incident.ruleName)} ${incident.status === "resolved" ? "stopped burning its error budget" : "is burning its error budget"}` if (isQueryIncident(incident)) return incident.ruleName if (incident.signalType === "anomaly") { - const group = shownGroup(incident) + const group = incidentGroup(incident) return group === null ? "Anomaly" : `Anomaly on ${group}` } const value = formatSignalValue(incident.signalType, incident.lastObservedValue) const base = `${signalLabels[incident.signalType]} ${value}` - const group = shownGroup(incident) + const group = incidentGroup(incident) return group === null ? base : `${base} on ${group}` } /** The group shown next to the rule name: an uptime or SLO incident's only when it is a real one. */ export function incidentScope(incident: IncidentLine): string | null { - return isUptime(incident) || isSlo(incident) ? incidentGroup(incident) : shownGroup(incident) + return isUptime(incident) || isSlo(incident) ? incidentGroup(incident) : incidentGroup(incident) } /** The line under the title: the rule and group, or a query rule's kind and group (its name is the title). */ From ee110de0072164c06033e1a4dbdc62a6c7a9c751 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:56:34 +0100 Subject: [PATCH 37/69] fix(query-builder): rows in progress survive a look at Text; clearer control names --- .../kit/query-builder/condition-builder-view.ts | 9 ++++++--- .../page/kit/query-builder/conditions.test.ts | 17 +++++++++++++++++ .../src/page/kit/query-builder/conditions.ts | 3 ++- 3 files changed, 25 insertions(+), 4 deletions(-) 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 index c7030d35b8..453cd9ce58 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder-view.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -154,14 +154,16 @@ const fieldPicker = ( const rowFieldLabel = (row: ConditionRow, dataSource: QueryDraft["dataSource"]): string => row.key === "" ? "new condition" : fieldOf(row.key, dataSource).label -/** "Service is checkout, api": what a row's remove button removes. */ +/** "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), - OPERATOR_LABEL[row.operator], + ...(isValueless(row.operator) || row.values.length > 0 + ? [OPERATOR_LABEL[row.operator]] + : []), ...(row.values.length === 0 ? [] : [Arr.join(row.values, ", ")]), ], " ", @@ -288,7 +290,8 @@ const conditionRow = ( size: "sm", // As wide as the operator's words, not the select's default 9rem. className: cn(OPERATOR_CELL, "min-w-16"), - ariaLabel: `Operator for ${fieldLabel}`, + // 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([ diff --git a/apps/web/src/page/kit/query-builder/conditions.test.ts b/apps/web/src/page/kit/query-builder/conditions.test.ts index 740778909b..8c7c433f85 100644 --- a/apps/web/src/page/kit/query-builder/conditions.test.ts +++ b/apps/web/src/page/kit/query-builder/conditions.test.ts @@ -129,6 +129,23 @@ describe("mode", () => { 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", () => { diff --git a/apps/web/src/page/kit/query-builder/conditions.ts b/apps/web/src/page/kit/query-builder/conditions.ts index 3de32381b0..3a60129698 100644 --- a/apps/web/src/page/kit/query-builder/conditions.ts +++ b/apps/web/src/page/kit/query-builder/conditions.ts @@ -153,8 +153,9 @@ const withRow = ( 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: { ...withoutBuilderState(model, queryId), whereModes: { ...model.whereModes, [queryId]: mode } }, + model: { ...model, whereModes: { ...model.whereModes, [queryId]: mode } }, }) /** A new row opens its field picker, as the first thing to choose; the clause is unchanged. */ From 48df8a00f483796b520439076ce923d0b272d5df Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 05:59:39 +0100 Subject: [PATCH 38/69] refactor(alerts): incidentScope drops its no-op branch --- packages/web-data/src/alerts/overview-derive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web-data/src/alerts/overview-derive.ts b/packages/web-data/src/alerts/overview-derive.ts index 1e022cbb7b..ac3a45bf17 100644 --- a/packages/web-data/src/alerts/overview-derive.ts +++ b/packages/web-data/src/alerts/overview-derive.ts @@ -82,9 +82,9 @@ export function incidentTitle(incident: IncidentLine): string { return group === null ? base : `${base} on ${group}` } -/** The group shown next to the rule name: an uptime or SLO incident's only when it is a real one. */ +/** The group shown next to the rule name: none for an ungrouped incident, never an anomaly's incident-id key. */ export function incidentScope(incident: IncidentLine): string | null { - return isUptime(incident) || isSlo(incident) ? incidentGroup(incident) : incidentGroup(incident) + return incidentGroup(incident) } /** The line under the title: the rule and group, or a query rule's kind and group (its name is the title). */ From d3e87a18a02a48d9c1bd4307133f37357389905b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 06:26:16 +0100 Subject: [PATCH 39/69] fix(alerts): rule cards say problems in full; the empty chart asks for a number when the threshold is not one --- .../alerts/editor/editor-cards.scene.test.ts | 16 +++++++++++++++- apps/web/src/page/alerts/editor/summary.ts | 9 +++++++++ apps/web/src/page/alerts/editor/view-hero.ts | 6 +++++- apps/web/src/page/alerts/editor/view.ts | 4 +++- apps/web/src/page/kit/sentence-card.ts | 11 ++++++++--- 5 files changed, 40 insertions(+), 6 deletions(-) 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 index 0bbb766787..116b4d28e3 100644 --- a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -135,6 +135,20 @@ describe("alert rule editor, sentence cards", () => { ) }) + test("with a threshold that is not a number, the empty chart asks for one, not for a signal", () => { + 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("an anomaly rule reads as its scope and has no Alert when card", () => { scene( { update: pageUpdate, view }, @@ -341,7 +355,7 @@ describe("alert rule editor, sentence cards", () => { { update: pageUpdate, view }, given(timed), themeResolved(), - expectView(text("Missing: Query too slow for this check interval")).toExist(), + expectView(text("Query too slow for this check interval")).toExist(), ) }) }) diff --git a/apps/web/src/page/alerts/editor/summary.ts b/apps/web/src/page/alerts/editor/summary.ts index 624138b8b5..7e52cdd326 100644 --- a/apps/web/src/page/alerts/editor/summary.ts +++ b/apps/web/src/page/alerts/editor/summary.ts @@ -1,4 +1,13 @@ import { Array as Arr, String as Str } from "effect" + +// Blockers that are a problem with the rule, not a field left empty: a card says them in full. +const PROBLEM_ISSUES: ReadonlyArray = [ + "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) import type { AlertDestinationId, AlertQueryTiming, EscalationChainId } from "@maple/domain/http" import { comparatorSupportsTiers, diff --git a/apps/web/src/page/alerts/editor/view-hero.ts b/apps/web/src/page/alerts/editor/view-hero.ts index 5b447ad7a1..ea20ae1c9e 100644 --- a/apps/web/src/page/alerts/editor/view-hero.ts +++ b/apps/web/src/page/alerts/editor/view-hero.ts @@ -190,7 +190,11 @@ const chartBody = (h: H, model: ChartView, form: RuleForm, timeZone: string): Ht isLoading: preview.isLoading || (!isChartReady && preview.preview !== null), loadingText: `Loading the ${range}…`, emptyText: isIdle - ? { title: `Pick a signal to preview it against the ${range}` } + ? { + title: Number.isFinite(Number(form.threshold)) + ? `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", diff --git a/apps/web/src/page/alerts/editor/view.ts b/apps/web/src/page/alerts/editor/view.ts index bbe92e1782..ba8c8c2e89 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -27,6 +27,7 @@ import { type NameLookup, type NotifyNames, watchSummary, + isProblemIssue, } from "./summary" import { sentenceCard, sentenceChain } from "../../kit/sentence-card" import { actionBar } from "./view-action-bar" @@ -195,7 +196,8 @@ const ruleCard = ( lead: spec.lead, hint: spec.hint, status: issues.length > 0 ? "missing" : "done", - issues, + 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 }), diff --git a/apps/web/src/page/kit/sentence-card.ts b/apps/web/src/page/kit/sentence-card.ts index 2d22ca7124..6b982c0cdf 100644 --- a/apps/web/src/page/kit/sentence-card.ts +++ b/apps/web/src/page/kit/sentence-card.ts @@ -26,6 +26,8 @@ export type SentenceCardOptions = Readonly<{ 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 @@ -130,6 +132,11 @@ const openCard = ( ): 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( @@ -177,9 +184,7 @@ const openCard = ( ...(options.status === "missing" ? [ typography(h, { size: "xs", tone: "warning", as: "p" }, [ - issues.length === 0 - ? "Required" - : `Missing: ${Arr.join(issues, ", ")}`, + issueLine === "" ? "Required" : issueLine, ]), ] : []), From 67cbbe785b8d7dd9b1734048709f3a0d1e92c54f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 06:29:58 +0100 Subject: [PATCH 40/69] fix(query-builder): product events take no operator on resource.* keys --- .../src/query-builder/where-key-rules.test.ts | 14 ++++++++++++++ .../src/query-builder/where-key-rules.ts | 6 ++++-- 2 files changed, 18 insertions(+), 2 deletions(-) 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 index aec3551a62..62a8458652 100644 --- a/packages/query-engine/src/query-builder/where-key-rules.test.ts +++ b/packages/query-engine/src/query-builder/where-key-rules.test.ts @@ -88,6 +88,20 @@ describe("where key rules", () => { 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([]) diff --git a/packages/query-engine/src/query-builder/where-key-rules.ts b/packages/query-engine/src/query-builder/where-key-rules.ts index 148cfa506a..bc8050daf8 100644 --- a/packages/query-engine/src/query-builder/where-key-rules.ts +++ b/packages/query-engine/src/query-builder/where-key-rules.ts @@ -111,8 +111,10 @@ const dimensionsOf = ( /** 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) || Str.startsWith("resource.")(canonical)) - return { _tag: "Attribute" } + 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. From cc4cce29610d6edc7b45ab69b41a1e55fec2c116 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 06:35:20 +0100 Subject: [PATCH 41/69] fix(views): Escape in a picker's search input stops at the popup --- packages/views/src/ui/combobox.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/views/src/ui/combobox.ts b/packages/views/src/ui/combobox.ts index c736a1e240..802d1f773e 100644 --- a/packages/views/src/ui/combobox.ts +++ b/packages/views/src/ui/combobox.ts @@ -243,8 +243,10 @@ export const MountCombobox = Mount.defineStream("MountCombobox", { } }), Stream.merge(escapeContained(anchorSelector)), - // A popup with its own search input: a pressed option leaves focus (and the keys) there. + // A popup with its own search input: a pressed option leaves focus (and the keys) there, + // and Escape there is stopped at the portal, which the anchor does not enclose. Stream.merge(returnFocusId === undefined ? Stream.empty : optionPressesKeepFocus(element)), + Stream.merge(returnFocusId === undefined ? Stream.empty : escapeContainedIn(element)), ), }) @@ -264,6 +266,10 @@ const optionPressesKeepFocus = (root: Element): Stream.Stream => ).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(() => From d7170fd52dee84d0f7229d8ef4bcfb909ae64513 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 06:56:18 +0100 Subject: [PATCH 42/69] fix(alerts): threshold-order blockers are said in full, not as missing fields --- .../src/page/alerts/editor/summary.test.ts | 10 ++++++++++ apps/web/src/page/alerts/editor/summary.ts | 20 ++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/web/src/page/alerts/editor/summary.test.ts b/apps/web/src/page/alerts/editor/summary.test.ts index a7da23d78c..8c043972b2 100644 --- a/apps/web/src/page/alerts/editor/summary.test.ts +++ b/apps/web/src/page/alerts/editor/summary.test.ts @@ -9,6 +9,7 @@ import { cardOfIssue, cardsOf, conditionSummary, + isProblemIssue, issueFieldId, missingCards, nameSummary, @@ -227,6 +228,15 @@ describe("nameSummary", () => { }) 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") diff --git a/apps/web/src/page/alerts/editor/summary.ts b/apps/web/src/page/alerts/editor/summary.ts index 7e52cdd326..f541ee943e 100644 --- a/apps/web/src/page/alerts/editor/summary.ts +++ b/apps/web/src/page/alerts/editor/summary.ts @@ -1,13 +1,4 @@ import { Array as Arr, String as Str } from "effect" - -// Blockers that are a problem with the rule, not a field left empty: a card says them in full. -const PROBLEM_ISSUES: ReadonlyArray = [ - "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) import type { AlertDestinationId, AlertQueryTiming, EscalationChainId } from "@maple/domain/http" import { comparatorSupportsTiers, @@ -27,6 +18,17 @@ import { 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"] From 44ac0a00e82111315d84f011e5e549fb6355ebfe Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 07:04:32 +0100 Subject: [PATCH 43/69] fix(query-builder): the builder shows only what the query applies An OR group on a field the source does not OR (a named field like service.name, anything on product events) stays in Text instead of reading as a one-value row whose next pick dropped the rest of a saved filter. Reading rows is now per data source, by the engine's own whereKeyMatchesAnyOf. A key the source drops (resource.* on product events) is not listed, cannot be typed in as a new field, and so cannot be picked; a saved row on one stays as written. The dropped OR group warning names its group, one line per distinct group. --- apps/web/src/lab/condition-builder-lab.ts | 9 +- .../query-builder/condition-builder-view.ts | 2 +- .../condition-builder.scene.test.ts | 56 ++++++++- .../query-builder/condition-fields.test.ts | 65 +++++++++- .../kit/query-builder/condition-fields.ts | 45 +++++-- .../kit/query-builder/condition-rows.test.ts | 93 +++++++++++--- .../page/kit/query-builder/condition-rows.ts | 59 ++++++--- .../page/kit/query-builder/conditions.test.ts | 116 ++++++++++++++++-- .../src/page/kit/query-builder/conditions.ts | 19 +-- apps/web/src/page/kit/query-builder/parts.ts | 3 +- apps/web/src/page/kit/query-builder/view.ts | 44 +++++-- packages/views/src/ui/INDEX.md | 2 +- 12 files changed, 428 insertions(+), 85 deletions(-) diff --git a/apps/web/src/lab/condition-builder-lab.ts b/apps/web/src/lab/condition-builder-lab.ts index b2c21e0f8b..ae60de661d 100644 --- a/apps/web/src/lab/condition-builder-lab.ts +++ b/apps/web/src/lab/condition-builder-lab.ts @@ -65,8 +65,13 @@ export const SCENARIOS: ReadonlyArray = [ }, { title: "OR group the query drops", - hint: "Named fields can't be OR-ed: the warning says the group is not applied.", - query: draft("dropped", "E", "traces", '(service.name = "checkout" OR span.name = "GET /health")'), + 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", 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 index 453cd9ce58..5c5ad98216 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder-view.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -110,7 +110,7 @@ const fieldPicker = ( 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) + 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({ 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 index fb1571fba3..33d34251cd 100644 --- 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 @@ -171,21 +171,67 @@ describe("condition builder", () => { 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: only attribute filters can be OR-ed."), - ).not.toExist(), + 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("an OR group the query drops also says it is not applied", () => { + 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: only attribute filters can be OR-ed.")).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(), ) }) 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 index 7c9980d48c..ac472897fc 100644 --- a/apps/web/src/page/kit/query-builder/condition-fields.test.ts +++ b/apps/web/src/page/kit/query-builder/condition-fields.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "vitest" -import { canMatchAnyOf, fieldOf, fieldsOf, operatorsOf, sharesLabel } from "./condition-fields" +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). @@ -72,3 +80,58 @@ describe("sharesLabel", () => { 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 index 0975ce70da..acab2a4056 100644 --- a/apps/web/src/page/kit/query-builder/condition-fields.ts +++ b/apps/web/src/page/kit/query-builder/condition-fields.ts @@ -90,21 +90,33 @@ export const canMatchAnyOf = (field: ConditionField, dataSource: DataSource): bo export const isOwnedElsewhere = (key: string, dataSource: DataSource): boolean => dataSource === "traces" && key === "root_only" -/** Every field the query offers: the common ones, then resource and own attributes as the editor writes them. */ +/** + * 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 => - Arr.dedupeWith( - [ - ...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)), - ], - (a, b) => a.key === b.key, +): 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 => @@ -135,7 +147,10 @@ const matchesQuery = (field: ConditionField, query: string): boolean => { ) } -/** What a typed key means inside a group: the group's prefix unless it already has one. */ +/** + * 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}` @@ -144,10 +159,12 @@ const customKeyOf = (typed: string, drill: FieldDrill, 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) @@ -155,7 +172,9 @@ export const fieldPickerItems = ( if (typed !== "") { const key = customKeyOf(typed, drill, fields) const custom: ReadonlyArray = - KEY_PATTERN.test(typed) && !Arr.some(fields, (field) => field.key === key) + KEY_PATTERN.test(typed) && + isPickable(key, dataSource) && + !Arr.some(fields, (field) => field.key === key) ? [{ _tag: "Custom", value: `${CUSTOM_VALUE}${key}`, key }] : [] return [ 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 index 130f5fdc73..c9c13ed7e8 100644 --- a/apps/web/src/page/kit/query-builder/condition-rows.test.ts +++ b/apps/web/src/page/kit/query-builder/condition-rows.test.ts @@ -12,8 +12,10 @@ import { } from "./condition-rows" import { Array as Arr, Option } from "effect" -const rowsOf = (whereClause: string) => - Option.map(parseConditionRows(whereClause), (rows) => +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 })), ) @@ -29,8 +31,15 @@ describe("parseConditionRows", () => { ["", []], ['service.name = "checkout"', [{ key: "service.name", operator: "=", values: ["checkout"] }]], [ - '(service.name = "checkout" OR service.name = "api")', - [{ key: "service.name", operator: "=", values: ["checkout", "api"] }], + '(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: [] }]], @@ -62,15 +71,16 @@ describe("parseConditionRows", () => { // Not the grammar at all. "service.name ~ checkout", 'service.name = "unclosed', - // OR groups a row cannot show: mixed keys, mixed or negated operators, nesting. + // 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))).toBe(true) + expect(Option.isNone(parseConditionRows(whereClause, "traces"))).toBe(true) }) }) @@ -78,8 +88,8 @@ describe("printConditionRows", () => { test.each([ [[row("service.name", "=", ["checkout"])], 'service.name = "checkout"'], [ - [row("service.name", "=", ["checkout", "api"])], - '(service.name = "checkout" OR service.name = "api")', + [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"], @@ -124,19 +134,19 @@ describe("round trip", () => { test.each([ 'service.name = "checkout"', 'service.name = checkout AND span.name contains "GET /api"', - '(service.name = "a" OR service.name = "b") AND has_error = true', + '(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"'`, - '(body contains "timeout" OR body contains "refused")', + '(attr.error.message contains "timeout" OR attr.error.message contains "refused")', ])("%s", (whereClause) => { - const rows = Option.getOrThrow(parseConditionRows(whereClause)) + const rows = Option.getOrThrow(parseConditionRows(whereClause, "traces")) const printed = printConditionRows(rows) expect(meaning(printed)).toEqual(meaning(whereClause)) - expect(Option.map(parseConditionRows(printed), (again) => Arr.map(again, (r) => r.values))).toEqual( - Option.some(Arr.map(rows, (r) => r.values)), - ) + expect( + Option.map(parseConditionRows(printed, "traces"), (again) => Arr.map(again, (r) => r.values)), + ).toEqual(Option.some(Arr.map(rows, (r) => r.values))) }) }) @@ -150,13 +160,13 @@ describe("nextRowId", () => { describe("printConditionRowsOver", () => { const original = "service = checkout AND attr.http.route exists AND severity != DEBUG AND severity != TRACE" - const rows = Option.getOrThrow(parseConditionRows(original)) + 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)).toBe( + expect(printConditionRowsOver(edited, original, "logs")).toBe( "service = checkout AND attr.http.route !exists AND severity != DEBUG AND severity != TRACE", ) }) @@ -166,6 +176,7 @@ describe("printConditionRowsOver", () => { printConditionRowsOver( Arr.filter(rows, (row) => row.key !== "attr.http.route"), original, + "logs", ), ).toBe("service = checkout AND severity != DEBUG AND severity != TRACE") }) @@ -198,9 +209,57 @@ describe("unshowableReasonOf", () => { ['(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")], ['service.name = "a" AND (service.name = "b" AND span.name = "c")', Option.some("syntax")], ])("%s", (whereClause, expected) => { - expect(unshowableReasonOf(whereClause)).toEqual(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 index 8a7632ff50..83fb4fcf41 100644 --- a/apps/web/src/page/kit/query-builder/condition-rows.ts +++ b/apps/web/src/page/kit/query-builder/condition-rows.ts @@ -6,11 +6,12 @@ import { 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`. +// 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({ @@ -43,13 +44,19 @@ const draftOf = (clause: ParsedClause): Draft => ({ 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, or other syntax. */ -export type UnshowableReason = "mixedFields" | "orOperators" | "syntax" +/** + * 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 -/** One AND-ed part: a single clause, or an OR group whose members share key and an any-of operator. */ -const readPart = (part: string): 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 [clause] = parsed.clauses @@ -64,14 +71,19 @@ const readPart = (part: string): Part => { 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) }) } -const partOf = (part: string): Option.Option => Result.getSuccess(readPart(part)) - -/** Why the builder can't show a clause, or none when it reads as rows. */ -export const unshowableReasonOf = (whereClause: string): Option.Option => - Arr.findFirst(splitWhereClause(Str.trim(whereClause)), (part) => Result.getFailure(readPart(part))) +/** 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 }> @@ -101,19 +113,28 @@ const mergeNoneOf = (parts: ReadonlyArray): ReadonlyArray => }) }) -const spelledRows = (whereClause: string): Option.Option> => +const spelledRows = ( + whereClause: string, + dataSource: QueryBuilderDataSource, +): Option.Option> => Option.map( Option.all( Arr.map(splitWhereClause(Str.trim(whereClause)), (text) => - Option.map(partOf(text), (draft): Spelled => ({ draft, text })), + Option.map(Result.getSuccess(readPart(text, dataSource)), (draft): Spelled => ({ + draft, + text, + })), ), ), mergeNoneOf, ) -/** The rows a where clause reads as, or none when it uses syntax a row cannot show. */ -export const parseConditionRows = (whereClause: string): Option.Option> => - Option.map(spelledRows(whereClause), (parts) => +/** 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 })), ) @@ -165,8 +186,12 @@ export const printConditionRows = (rows: ReadonlyArray): string => * `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): string => { - const spelled = Option.getOrElse(spelledRows(original), () => Arr.empty()) +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, diff --git a/apps/web/src/page/kit/query-builder/conditions.test.ts b/apps/web/src/page/kit/query-builder/conditions.test.ts index 8c7c433f85..98e483b89d 100644 --- a/apps/web/src/page/kit/query-builder/conditions.test.ts +++ b/apps/web/src/page/kit/query-builder/conditions.test.ts @@ -2,8 +2,9 @@ import { createQueryDraft, resetQueryForDataSource } from "@maple/query-engine/q 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 } from "effect" +import { Array as Arr, Option } from "effect" // The builder's operator and value steps, as the where-clause edit they emit. @@ -43,14 +44,14 @@ const pickOperator = (rowId: string, value: string) => describe("operator", () => { test("is set drops the values", () => { expect( - emitted('(service.name = "checkout" OR service.name = "api")', pickOperator("c0", "exists")), - ).toBe("service.name exists") + 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('(service.name = "checkout" OR service.name = "api")', pickOperator("c0", "!="))).toBe( - 'service.name != "checkout" AND service.name != "api"', - ) + 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", () => { @@ -111,10 +112,10 @@ describe("values", () => { const message = Message.GotConditionValues({ queryId: "q", rowId: "c0", - message: { _tag: "RemovedChip", value: "api" }, + message: { _tag: "RemovedChip", value: "/pay" }, }) - expect(emitted('(service.name = "checkout" OR service.name = "api")', message)).toBe( - 'service.name = "checkout"', + expect(emitted('(attr.http.route = "/cart" OR attr.http.route = "/pay")', message)).toBe( + 'attr.http.route = "/cart"', ) }) }) @@ -205,7 +206,7 @@ describe("field picker highlight", () => { 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), (item) => item.value) + 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. */ @@ -258,3 +259,98 @@ describe("field picker highlight", () => { 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 index 3a60129698..a9278d6cfa 100644 --- a/apps/web/src/page/kit/query-builder/conditions.ts +++ b/apps/web/src/page/kit/query-builder/conditions.ts @@ -49,9 +49,9 @@ export const isOperatorSelect = (id: string): boolean => Str.startsWith(`${OPERA /** 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), (parsed) => sameConditions(parsed, kept)).pipe( - Option.map(() => 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. */ @@ -59,7 +59,7 @@ export const conditionRowsOf = ( model: Model, query: QueryDraft, ): Option.Option> => - Option.orElse(keptRowsOf(model, query), () => parseConditionRows(query.whereClause)) + 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 => { @@ -101,7 +101,7 @@ const emitted = ( queryId: query.id, change: QueryChange.SetText({ field: "whereClause", - value: printConditionRowsOver(rows, query.whereClause), + value: printConditionRowsOver(rows, query.whereClause, query.dataSource), }), }), } @@ -121,7 +121,7 @@ const withRows = ( 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), { + Option.match(parseConditionRows(query.whereClause, query.dataSource), { onNone: () => ({ model }), onSome: (rows) => { const fresh = @@ -243,7 +243,8 @@ export const fieldStep = ( 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)) + 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), ) @@ -264,7 +265,7 @@ export const fieldStep = ( ...current, inputValue: "", highlighted: fieldHighlightOf( - fieldPickerItems(fields, navigated.value, ""), + fieldPickerItems(fields, navigated.value, "", query.dataSource), row.key, drill, ), @@ -281,7 +282,7 @@ export const fieldStep = ( ? { ...next.model, highlighted: fieldHighlightOf( - fieldPickerItems(fields, drill, Combobox.queryOf(next.model)), + fieldPickerItems(fields, drill, Combobox.queryOf(next.model), query.dataSource), row.key, ), } diff --git a/apps/web/src/page/kit/query-builder/parts.ts b/apps/web/src/page/kit/query-builder/parts.ts index 3efcc7b4a0..655b486b14 100644 --- a/apps/web/src/page/kit/query-builder/parts.ts +++ b/apps/web/src/page/kit/query-builder/parts.ts @@ -75,6 +75,7 @@ const whereTextEditor = ( 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 @@ -112,7 +113,7 @@ export const whereClauseEditor = ( { 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), { + 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. diff --git a/apps/web/src/page/kit/query-builder/view.ts b/apps/web/src/page/kit/query-builder/view.ts index 603cf86f86..21067cef25 100644 --- a/apps/web/src/page/kit/query-builder/view.ts +++ b/apps/web/src/page/kit/query-builder/view.ts @@ -21,9 +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 { unshowableReasonOf } from "./condition-rows" import { panelSelect } from "./panel-select" import { addOnSections, ADD_ON_KEYS, addOnToggles, textInput, whereClauseEditor } from "./parts" -import { Array as Arr, String as Str } from "effect" +import { Array as Arr, Option, String as Str } from "effect" // `QueryPanel` and `QueryPanelShell`: one query's builder panel and its frame. @@ -191,14 +192,33 @@ const body = (h: H, model: Model, inputs: QueryPanelInputs): ReadonlyArray ] } -// The engine names the dropped group in full; the panel shows it right above, so it says only that. -const OR_GROUP_DROPPED = /OR group ignored: only attribute filters can be OR-ed/ -const OR_GROUP_DROPPED_COPY = "This OR group is not applied: only attribute filters can be OR-ed." +// 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, each once. */ -export const panelWarningsOf = (warnings: ReadonlyArray): ReadonlyArray => +/** + * 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) => (OR_GROUP_DROPPED.test(warning) ? OR_GROUP_DROPPED_COPY : warning)), + 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. */ @@ -227,7 +247,15 @@ export const queryPanelView = Submodel.defineView [ ...body(h, model, inputs), // TODO(query-builder): the product-events setup hint (`SignalEmptyState`). - ...warningList(h, panelWarningsOf(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/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index d68cd4ad67..007981ae34 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -211,7 +211,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `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`, `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-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. From 043d2d08b5fda6963b4cc60a4e3bf8bed4066808 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 12:51:16 +0100 Subject: [PATCH 44/69] fix(status-page): import escapeHtml from @maple/strings in the markdown renderer #2447 moved escapeHtml out of escape.ts and #2430 added markdown.ts importing it from there. Each passed alone; together main fails typecheck, lint and the web build, which blocks the production deploy. --- packages/status-page/src/markdown.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/status-page/src/markdown.ts b/packages/status-page/src/markdown.ts index ec84643882..9c2baa618d 100644 --- a/packages/status-page/src/markdown.ts +++ b/packages/status-page/src/markdown.ts @@ -1,5 +1,6 @@ import { Array as Arr, String as Str } from "effect" -import { escapeHtml, safeUrl } from "./escape" +import { escapeHtml } from "@maple/strings" +import { safeUrl } from "./escape" // Incident update text: paragraphs, bullet and numbered lists, **bold**, *italic*, `code`, // [links](https://...) and bare URLs. Everything else is escaped text; no raw HTML passes. From 78ebb4a21244a375422b28d0fea0605104374c7d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:01:09 +0100 Subject: [PATCH 45/69] fix(alerts): silence form says what is missing instead of a silent disabled save --- .../alerts/hub/hub-silences.scene.test.ts | 44 ++++++++++++++-- .../page/alerts/silence-dialog/dialog.test.ts | 19 +++++++ .../src/page/alerts/silence-dialog/dialog.ts | 52 ++++++++++++------- .../src/page/alerts/silence-dialog/view.ts | 34 +++++++++--- 4 files changed, 121 insertions(+), 28 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index e0b5755d6f..db20e245b5 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -6,7 +6,17 @@ import { Result, Schema, Array as Arr } from "effect" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" import { MountDialog } from "@maple/views/ui/dialog" -import { expect as expectView, Mount, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { + expect as expectView, + Mount, + given, + role, + scene, + selector, + submit, + text, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" import type { PageContext } from "../../contract" import { NOW, ORG, hubAt, ruleRow } from "../shared/test-rows" @@ -212,7 +222,7 @@ describe("alerts hub, silence side panel", () => { expect(step.model.silenceDialog.dialog.isOpen).toBe(false) }) - test("the URL opens it with a chips input per scope field, Cancel and a save that waits for a valid form", () => { + test("the URL opens it with a chips input per scope field, Cancel and a save", () => { const opened = withPanel() expect(opened.model.silenceDialog.dialog.isOpen).toBe(true) scene( @@ -227,7 +237,35 @@ describe("alerts hub, silence side panel", () => { text("Fill in at least one. A rule is silenced only if it matches every field you fill in."), ).toExist(), expectView(role("button", { name: "Cancel" })).toExist(), - expectView(role("button", { name: "Silence for 1 hour" })).toBeDisabled(), + expectView(role("button", { name: "Silence for 1 hour" })).not.toBeDisabled(), + expectView(role("alert")).not.toExist(), + ) + }) + + test("saving an incomplete form sends nothing and says what is missing, field by field", () => { + scene( + { update: pageUpdate, view }, + given(withPanel().model), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + submit(selector("form")), + expectView(text("Add at least one service, environment or tag")).toExist(), + expectView(text("Add a reason")).toExist(), + expectView(role("textbox", { name: "Reason" })).toHaveAttr("aria-invalid", "true"), + expectView(role("button", { name: "Silence for 1 hour" })).not.toBeDisabled(), + ) + const tried = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedSave())) + expect(namesOf(tried.commands)).toEqual([]) + // Each line goes as its field is filled in. + const reasoned = pageUpdate( + tried.model, + silenceMessage(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })), + ).model + scene( + { update: pageUpdate, view }, + given(reasoned), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Add a reason")).not.toExist(), + expectView(text("Add at least one service, environment or tag")).toExist(), ) }) diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 3b62de3bad..0bdddb0dde 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -14,6 +14,8 @@ import { openNew, OutMessage, parseLocalDateTime, + reasonError, + scopeError, type ScopeField, silenceEndMs, untilError, @@ -185,6 +187,23 @@ describe("isFormReady", () => { Option.some("A silence can last at most 30 days"), ) }) + + it("a tried save on an incomplete form sends nothing and turns the errors on, an empty custom end included", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const custom = update(opened, Message.ChangedDuration({ duration: "custom" })).model + expect(untilError(custom)).toEqual(Option.none()) + const tried = update(custom, Message.ClickedSave()) + expect(tried.commands ?? []).toEqual([]) + expect(tried.model.isSaving).toBe(false) + expect(tried.model.isShowingErrors).toBe(true) + expect(untilError(tried.model)).toEqual(Option.some("Pick a date and time")) + expect(reasonError(tried.model.reason)).toEqual(Option.some("Add a reason")) + expect(scopeError(tried.model.scope)).toEqual( + Option.some("Add at least one service, environment or tag"), + ) + // The next open starts quiet again. + expect(openNew(tried.model, BERLIN, NOW).model.isShowingErrors).toBe(false) + }) }) describe("saving", () => { diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index 8b69c61740..c1c3ed72af 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -62,6 +62,8 @@ export const Model = Schema.Struct({ /** The wall clock when the dialog opened: a custom end is checked against it. */ nowMs: Schema.Number, isSaving: Schema.Boolean, + /** A save was tried on an incomplete form: each field left out says so. */ + isShowingErrors: Schema.Boolean, /** Bumped on every open: a save's result only settles the form it was sent from. */ openCount: Schema.Number, }) @@ -77,6 +79,7 @@ export const init = (id: string): Model => ({ timeZone: "UTC", nowMs: 0, isSaving: false, + isShowingErrors: false, openCount: 0, }) @@ -167,9 +170,10 @@ export const endZoneLabel = (model: Pick) Option.getOrElse(parseLocalDateTime(model.until, model.timeZone), () => model.nowMs), ) -/** Why a typed custom end cannot be used, or none when it can (or nothing is typed yet). */ +/** Why a typed custom end cannot be used, or none when it can; an empty one waits for a tried save. */ export const untilError = (model: Model): Option.Option => { - if (model.duration !== "custom" || model.until === "") return Option.none() + if (model.duration !== "custom") return Option.none() + if (model.until === "") return model.isShowingErrors ? Option.some("Pick a date and time") : Option.none() return Option.match(parseLocalDateTime(model.until, model.timeZone), { onNone: () => Option.some("Pick a date and time"), onSome: (endMs) => @@ -181,6 +185,15 @@ export const untilError = (model: Model): Option.Option => { }) } +/** A silence always says why: the reason shows on its row and on the pages it covers. */ +export const reasonError = (reason: string): Option.Option => + Str.isEmpty(Str.trim(reason)) ? Option.some("Add a reason") : Option.none() + +export const scopeError = (scope: Scope): Option.Option => + hasMatchers(matchersOf(scope)) + ? Option.none() + : Option.some("Add at least one service, environment or tag") + /** Ready to save: a reason, something to match and an end that is ahead and within 30 days. */ export const isFormReady = (model: Model): boolean => Str.isNonEmpty(Str.trim(model.reason)) && @@ -235,6 +248,7 @@ const open = (model: Model, scope: Scope, timeZone: string, nowMs: number): Retu duration: "1h", until: "", isSaving: false, + isShowingErrors: false, openCount: model.openCount + 1, }, Dialog.open(model.dialog), @@ -271,22 +285,24 @@ const scopeComboStep = (model: Model, field: ScopeField, message: Combobox.Messa /** Closes the panel, as Cancel does; a page whose URL holds it closes it on Back. */ export const close = (model: Model): Return => liftDialog(model, Dialog.close(model.dialog)) -const save = (model: Model): Return => - model.isSaving || !isFormReady(model) - ? { model } - : { - model: { ...model, isSaving: true }, - commands: [ - SaveSilence({ - openCount: model.openCount, - reason: Str.trim(model.reason), - matchers: matchersOf(model.scope), - duration: model.duration, - until: model.until, - timeZone: model.timeZone, - }), - ], - } +const save = (model: Model): Return => { + if (model.isSaving) return { model } + // An incomplete form stays open and names what is missing, as the other alert forms do. + if (!isFormReady(model)) return { model: { ...model, isShowingErrors: true } } + return { + model: { ...model, isSaving: true }, + commands: [ + SaveSilence({ + openCount: model.openCount, + reason: Str.trim(model.reason), + matchers: matchersOf(model.scope), + duration: model.duration, + until: model.until, + timeZone: model.timeZone, + }), + ], + } +} export const update = (model: Model, message: Message): Return => Message.match(message, { diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 0c1db820aa..93e90ad049 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -9,13 +9,15 @@ import { segmentedSelect } from "@maple/views/ui/segmented-select" import { sidePanelSheet } from "@maple/views/ui/sheet" import { vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" +import { fieldErrorText } from "@maple/views/ui/field" import type { V2AlertSilenceMatchers } from "@maple/domain/http/v2" import { endZoneLabel, - isFormReady, Message, type Model, + reasonError, type Scope, + scopeError, type ScopeField, type ScopeSuggestions, type SilenceDuration, @@ -51,6 +53,10 @@ const submitLabel = (model: Model): string => `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone)}`, }) +/** A field's error once a save was tried. */ +const shown = (model: Model, error: Option.Option): Option.Option => + model.isShowingErrors ? error : Option.none() + const textField = (h: H, options: Omit, "idPrefix">): Html => dialogTextField(h, { ...options, idPrefix: "silence" }) @@ -69,6 +75,7 @@ const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholde ] const SCOPE_HELP_ID = "silence-scope-help" +const SCOPE_ERROR_ID = "silence-scope-error" const NO_SUGGESTIONS: ScopeSuggestions = { services: [], environments: [], tags: [] } @@ -122,11 +129,22 @@ const scopeSection = (h: H, model: Model, suggestions: ScopeSuggestions): Readon ] : [ ...Arr.map(SCOPE_FIELDS, (entry) => chipsField(h, model, scope, suggestions, entry)), - dialogHelp( - h, - SCOPE_HELP_ID, - "Fill in at least one. A rule is silenced only if it matches every field you fill in.", - ), + vstack(h, { gap: "1.5" }, [ + dialogHelp( + h, + SCOPE_HELP_ID, + "Fill in at least one. A rule is silenced only if it matches every field you fill in.", + ), + ...Option.toArray( + Option.map(shown(model, scopeError(scope)), (message) => + fieldErrorText( + h, + { attributes: [h.Id(SCOPE_ERROR_ID), h.Role("alert")] }, + message, + ), + ), + ), + ]), ] } @@ -179,6 +197,7 @@ export const view = Submodel.defineView Message.ChangedReason({ value }), + error: shown(model, reasonError(model.reason)), }), ]), ], @@ -198,7 +217,8 @@ export const view = Submodel.defineView Date: Tue, 10 Nov 2026 14:01:49 +0100 Subject: [PATCH 46/69] fix(alerts): silence chips split on commas and keep a chip typed twice --- .../src/page/alerts/shared/matcher-fields.ts | 3 +- .../page/alerts/silence-dialog/dialog.test.ts | 47 +++++++++++++++++++ .../src/page/alerts/silence-dialog/dialog.ts | 19 ++++++-- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/shared/matcher-fields.ts b/apps/web/src/page/alerts/shared/matcher-fields.ts index bcc96dc098..722e5698e0 100644 --- a/apps/web/src/page/alerts/shared/matcher-fields.ts +++ b/apps/web/src/page/alerts/shared/matcher-fields.ts @@ -30,7 +30,8 @@ export const typedFromMatchers = (matchers: V2AlertSilenceMatchers): TypedMatche environments: Arr.join(matchers.environments ?? [], ", "), }) -const splitList = (value: string): ReadonlyArray => +/** A comma-separated list as its names: trimmed, the empty ones dropped, each once. */ +export const splitList = (value: string): ReadonlyArray => Arr.dedupe(Arr.filter(Arr.map(Str.split(value, ","), Str.trim), Str.isNonEmpty)) /** The matchers the fields describe; an empty field is left out. */ diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts index 0bdddb0dde..9b3b282415 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.test.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.test.ts @@ -141,6 +141,53 @@ describe("scope chips", () => { expect(matchersOf(openNew(entered, BERLIN, NOW).model.scope)).toEqual({}) }) + it("commas in a typed entry make one chip per name, trimmed, without empties or repeats", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const services = (message: Combobox.Message) => + Message.GotScopeComboMessage({ field: "services", message }) + const enter = (model: Model, value: string) => + update( + update(model, services(Combobox.Message.ChangedInput({ value }))).model, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api", value] })), + ).model + const entered = enter(opened, "demo-worker, demo-api,,") + expect(matchersOf(entered.scope)).toEqual({ services: ["demo-worker", "demo-api"] }) + expect(entered.combos.services.inputValue).toBe("") + // A name that is already a chip stays where it is; only the new one is added. + expect(matchersOf(enter(entered, "demo-api,demo-web").scope)).toEqual({ + services: ["demo-worker", "demo-api", "demo-web"], + }) + // A list picked from the popup with the pointer splits the same way. + expect(matchersOf(pick(opened, "services", "a , b").scope)).toEqual({ services: ["a", "b"] }) + }) + + it("typing a name that is already a chip, then Enter, keeps the chip and clears the input", () => { + const opened = openNew(init("silence"), BERLIN, NOW).model + const services = (message: Combobox.Message) => + Message.GotScopeComboMessage({ field: "services", message }) + const picked = pick(opened, "services", "demo-api") + const typed = update(picked, services(Combobox.Message.ChangedInput({ value: "demo-api" }))).model + const entered = update( + typed, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api"] })), + ).model + expect(matchersOf(entered.scope)).toEqual({ services: ["demo-api"] }) + expect(entered.combos.services.inputValue).toBe("") + // With nothing typed, Enter on a highlighted option still toggles it, as a pointer pick does. + const highlighted = update( + picked, + services(Combobox.Message.PressedInputKey({ key: "ArrowDown", items: ["demo-api"] })), + ).model + expect( + matchersOf( + update( + highlighted, + services(Combobox.Message.PressedInputKey({ key: "Enter", items: ["demo-api"] })), + ).model.scope, + ), + ).toEqual({}) + }) + it("a scope fixed by a rule ignores the chips", () => { const fixed = openFor( init("silence"), diff --git a/apps/web/src/page/alerts/silence-dialog/dialog.ts b/apps/web/src/page/alerts/silence-dialog/dialog.ts index c1c3ed72af..a29b1de01b 100644 --- a/apps/web/src/page/alerts/silence-dialog/dialog.ts +++ b/apps/web/src/page/alerts/silence-dialog/dialog.ts @@ -8,7 +8,7 @@ import * as Dialog from "@maple/views/ui/dialog" import type { Resources } from "../../../platform/resources" import { MAX_ALERT_SILENCE_DURATION_MS } from "@maple/domain/http" import { shortZoneName } from "../../kit/timezone-zones" -import { hasMatchers, MatcherField } from "../shared/matcher-fields" +import { hasMatchers, MatcherField, splitList } from "../shared/matcher-fields" import { createSilence } from "../shared/silences" // "Silence notifications": the side panel the hub, a rule and an incident open, as one Submodel. @@ -266,13 +266,26 @@ export const openFor = ( export const openNew = (model: Model, timeZone: string, nowMs: number): Return => open(model, NO_CHIPS, timeZone, nowMs) +/** The chips for what was picked or typed: "checkout, payments" is two names, each listed once. */ +const chipsOf = (names: ReadonlyArray): ReadonlyArray => + Arr.dedupe(Arr.flatMap(names, splitList)) + /** A chips input's step: its popup state stays on the combo, a changed selection lands on the scope. */ const scopeComboStep = (model: Model, field: ScopeField, message: Combobox.Message): Return => { if (model.scope._tag === "Fixed") return { model } - const step = Combobox.update({ ...model.combos[field], selected: model.scope[field] }, message) + const chips = model.scope[field] + const step = Combobox.update({ ...model.combos[field], selected: chips }, message) + // Enter on typed text only ever adds: the combobox would toggle a name that is already a chip off. + const isTypedEntry = + message._tag === "PressedInputKey" && Str.isNonEmpty(Str.trim(model.combos[field].inputValue)) const scope = step.outMessage?._tag === "ChangedSelection" - ? { ...model.scope, [field]: step.outMessage.selected } + ? { + ...model.scope, + [field]: chipsOf( + isTypedEntry ? [...chips, ...step.outMessage.selected] : step.outMessage.selected, + ), + } : model.scope return { model: { ...model, scope, combos: { ...model.combos, [field]: step.model } }, From a037e66d7038644300995d74f95c40ea1ad5b549 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:02:48 +0100 Subject: [PATCH 47/69] fix(alerts): silence chips hint sits in the form, not in a popup over the next label --- .../alerts/hub/hub-silences.scene.test.ts | 27 ++++++++++++++++ .../src/page/alerts/silence-dialog/view.ts | 10 ++++-- packages/views/src/ui/combobox-view.ts | 12 ++++--- packages/views/src/ui/combobox.scene.test.ts | 32 +++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index db20e245b5..6dedbb673c 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -242,6 +242,33 @@ describe("alerts hub, silence side panel", () => { ) }) + test("a chips input with nothing to suggest opens no list: how to add a name is said in the form", () => { + const focused = pageUpdate( + withPanel().model, + scopeCombo("services", Combobox.Message.ClickedInput()), + ).model + scene( + { update: pageUpdate, view }, + given(focused), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Type a name and press Enter to add it. Commas separate several.")).toExist(), + expectView(role("listbox")).not.toExist(), + expectView(role("combobox", { name: "Services" })).toHaveAttr("aria-expanded", "false"), + ) + // What is typed is listed, so Enter has something to pick. + const typed = pageUpdate( + focused, + scopeCombo("services", Combobox.Message.ChangedInput({ value: "demo-api" })), + ).model + scene( + { update: pageUpdate, view }, + given(typed), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + Mount.resolve(Combobox.MountCombobox, Combobox.Message.CompletedMountCombobox()), + expectView(role("option", { name: "demo-api" })).toExist(), + ) + }) + test("saving an incomplete form sends nothing and says what is missing, field by field", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 93e90ad049..73e5106252 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -75,6 +75,7 @@ const SCOPE_FIELDS: ReadonlyArray<{ field: ScopeField; label: string; placeholde ] const SCOPE_HELP_ID = "silence-scope-help" +const SCOPE_ENTRY_HELP_ID = "silence-scope-entry-help" const SCOPE_ERROR_ID = "silence-scope-error" const NO_SUGGESTIONS: ScopeSuggestions = { services: [], environments: [], tags: [] } @@ -105,9 +106,8 @@ const chipsField = ( viewInputs: { items: itemsOf(suggestions[field], combo.inputValue), ariaLabelledBy: labelId, - ariaDescribedBy: SCOPE_HELP_ID, + ariaDescribedBy: `${SCOPE_ENTRY_HELP_ID} ${SCOPE_HELP_ID}`, placeholder, - emptyText: "Type a name, then press Enter", isDisabled: model.isSaving, toView: (parts) => h.div([], parts), }, @@ -130,6 +130,12 @@ const scopeSection = (h: H, model: Model, suggestions: ScopeSuggestions): Readon : [ ...Arr.map(SCOPE_FIELDS, (entry) => chipsField(h, model, scope, suggestions, entry)), vstack(h, { gap: "1.5" }, [ + // In the flow, not in the list's popup: there it sat over the next field's label. + dialogHelp( + h, + SCOPE_ENTRY_HELP_ID, + "Type a name and press Enter to add it. Commas separate several.", + ), dialogHelp( h, SCOPE_HELP_ID, diff --git a/packages/views/src/ui/combobox-view.ts b/packages/views/src/ui/combobox-view.ts index 28819cdbea..568beb04cc 100644 --- a/packages/views/src/ui/combobox-view.ts +++ b/packages/views/src/ui/combobox-view.ts @@ -390,7 +390,8 @@ export type ChipsViewInputs = Readonly<{ ariaLabelledBy?: string ariaDescribedBy?: string placeholder: string - emptyText: string + /** The Empty line. Left out, a list with no item to show stays closed instead of opening on nothing. */ + emptyText?: string className?: string /** * `MultiSelectCombobox disabled`: the field stays mounted, dimmed and inert, so cards holding @@ -461,7 +462,10 @@ const chip = ( /** `` with `ComboboxChips`: chips for the selection, then the chips input. */ export const chipsView = Submodel.defineView((model, inputs, h) => { const chipsSelector = `[data-slot=combobox-chips]:has(> #${inputId(model.id)})` - const isOpen = isMounted(model.isOpen, model.animation) + const listed = inputs.isUnfiltered === true ? inputs.items : visibleItems(model, inputs.items) + // No Empty line and no item: the field reads closed, whatever opened it. + const hasList = inputs.emptyText !== undefined || listed.length > 0 + const isOpen = hasList && isMounted(model.isOpen, model.animation) const isDisabled = inputs.isDisabled ?? false const chips = h.div( [ @@ -490,7 +494,7 @@ export const chipsView = Submodel.defineView((m ...(isDisabled ? [h.Disabled(true)] : []), ...inputAttributes( h, - model, + hasList ? model : { ...model, isOpen: false }, { slot: "combobox-chips-input", ariaLabel: inputs.ariaLabel, @@ -513,7 +517,7 @@ export const chipsView = Submodel.defineView((m listPopup(h, model, { slot: "combobox", items: inputs.items, - emptyText: inputs.emptyText, + emptyText: inputs.emptyText ?? "", anchorSelector: chipsSelector, isUnfiltered: inputs.isUnfiltered, renderItem: (value: string) => inputs.renderItem?.(h, value) ?? [value], diff --git a/packages/views/src/ui/combobox.scene.test.ts b/packages/views/src/ui/combobox.scene.test.ts index 78e926812a..ce1e06b096 100644 --- a/packages/views/src/ui/combobox.scene.test.ts +++ b/packages/views/src/ui/combobox.scene.test.ts @@ -131,6 +131,38 @@ describe("combobox scene (chips)", () => { ) }) + test("without an Empty line the list opens only once it has an item to show", () => { + const quiet = { + update, + view: withViewInputs(chipsView, { + items: ["checkout-svc"], + ariaLabel: "Services", + placeholder: "Add a service", + toView: (parts) => ih.main([], parts), + })(), + } + scene( + quiet, + given(init("services", "multiple")), + type(input("Services"), "zzz"), + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 1 }), + ), + Command.resolve( + Animation.WaitForAnimationSettled, + Animation.Message.EndedAnimation({ generation: 1 }), + ), + expect(role("listbox")).toBeAbsent(), + expect(role("status")).toBeAbsent(), + expect(input("Services")).toHaveAttr("aria-expanded", "false"), + type(input("Services"), "che"), + Mount.resolve(MountCombobox, Message.CompletedMountCombobox()), + expect(option("checkout-svc")).toExist(), + expect(input("Services")).toHaveAttr("aria-expanded", "true"), + ) + }) + test("no match shows the empty status; Escape closes", () => { scene( chips, From 2b33c5e0b86c640ea5fc01253e111734ea90db8f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:31 +0100 Subject: [PATCH 48/69] fix(shell): re-check a stale 'no schedules' answer so a teammate's first schedule shows the on-call row The marker lived a day and only a schedule write in the same tab cleared it, so the row stayed hidden across reloads after someone else created the org's first schedule. It now lives five minutes, and coming back into focus asks again once it has aged. --- apps/web/src/shell/oncall.scene.test.ts | 42 +++++++++++++++++++++++-- apps/web/src/shell/oncall.ts | 7 +++-- apps/web/src/shell/update.ts | 29 ++++++++++++----- 3 files changed, 66 insertions(+), 12 deletions(-) diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 801ee39f55..029b0c5301 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -21,6 +21,7 @@ import { MeasureSidebarOverflow } from "./mounts" import { isNoSchedulesFresh, nextHandoffMs, + NO_SCHEDULES_TTL_MS, ONCALL_IDLE_READ_MS, ONCALL_RETRY_MS, type OnCallShift, @@ -234,8 +235,45 @@ describe("on-call reads", () => { { name: "LoadOnCall", args: { orgId: ORG, noSchedulesCheckedAtMs: null } }, { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: null } }, ]) - expectVitest(isNoSchedulesFresh(NOW, NOW + 23 * 60 * 60_000)).toBe(true) - expectVitest(isNoSchedulesFresh(NOW, NOW + 25 * 60 * 60_000)).toBe(false) + }) + + test("a teammate's first schedule: back in focus, an aged 'no schedules' answer is asked again", () => { + // The marker was read from storage at sign-in, so the first read never reached the API. + const booted = enter(ORG)(init({ ...flags, noScheduleOrgs: { [ORG]: NOW } })) + const cached = update(booted.model, answer([], NOW + 1_000, true)).model + expectVitest(commandsOf(update(cached, Message.WokeOnCall({ nowMs: NOW + 61_000 })))).toEqual([]) + + const agedAtMs = NOW + NO_SCHEDULES_TTL_MS + expectVitest(isNoSchedulesFresh(NOW, agedAtMs - 1)).toBe(true) + expectVitest(isNoSchedulesFresh(NOW, agedAtMs)).toBe(false) + const woke = update(cached, Message.WokeOnCall({ nowMs: agedAtMs })) + expectVitest(commandsOf(woke)).toEqual([ + { name: "LoadOnCall", args: { orgId: ORG, noSchedulesCheckedAtMs: NOW } }, + ]) + + const found = update(woke.model, answer([shift({ userId: "user_someone" })], agedAtMs)) + expectVitest(commandsOf(found)).toEqual([ + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: null } }, + ]) + scene( + { update, view }, + given(found.model), + measured, + expect(role("link", { name: "Not on call" })).toExist(), + ) + }) + + test("an org still without schedules is asked at most once per lifetime of its answer", () => { + const empty = update(signedIn(), answer([])).model + const agedAtMs = NOW + NO_SCHEDULES_TTL_MS + const woke = update(empty, Message.WokeOnCall({ nowMs: agedAtMs })) + const stillEmpty = update(woke.model, answer([], agedAtMs)) + expectVitest(commandsOf(stillEmpty)).toEqual([ + { name: "SaveNoSchedules", args: { orgId: ORG, checkedAtMs: agedAtMs } }, + ]) + expectVitest( + commandsOf(update(stillEmpty.model, Message.WokeOnCall({ nowMs: agedAtMs + 61_000 }))), + ).toEqual([]) }) }) diff --git a/apps/web/src/shell/oncall.ts b/apps/web/src/shell/oncall.ts index e29cee4d4d..bdb87b1a88 100644 --- a/apps/web/src/shell/oncall.ts +++ b/apps/web/src/shell/oncall.ts @@ -117,8 +117,11 @@ const ONCALL_RETRY_CAP_MS = 15 * 60_000 export const ONCALL_IDLE_READ_MS = 60 * 60_000 /** Focus re-reads at most this often. */ export const ONCALL_WAKE_MIN_MS = 60_000 -/** An org's "no schedules" answer is trusted this long before asking again. */ -export const NO_SCHEDULES_TTL_MS = 24 * 60 * 60_000 +/** + * An org's "no schedules" answer is trusted this long. Minutes, not a day: only a schedule written + * in this tab clears it, so a teammate's first schedule shows once it ages out. + */ +export const NO_SCHEDULES_TTL_MS = 5 * 60_000 export type OnCallSchedule = Readonly<{ nextReadAtMs: number | null; backoffMs: number }> diff --git a/apps/web/src/shell/update.ts b/apps/web/src/shell/update.ts index 0bdca57292..269f59789c 100644 --- a/apps/web/src/shell/update.ts +++ b/apps/web/src/shell/update.ts @@ -28,7 +28,13 @@ import { isSameMenu, popupIdOf, triggerIdOf } from "./menus" import { BUILT_COMMIT, CheckDeployedCommit, ReloadPage } from "./version" import { openPalette, openShortcuts, paletteStep, shortcutsDialogStep, toggleTheme } from "./update-palette" import { Menu, Message, OutMessage } from "./message" -import { ONCALL_WAKE_MIN_MS, type OnCallSnapshot, scheduleAfterAnswer, scheduleAfterFailure } from "./oncall" +import { + isNoSchedulesFresh, + ONCALL_WAKE_MIN_MS, + type OnCallSnapshot, + scheduleAfterAnswer, + scheduleAfterFailure, +} from "./oncall" import { DashboardsData, InfraPresenceData, @@ -166,13 +172,20 @@ const onCallFailed = (model: Model, orgId: string, failedAtMs: number): UpdateRe } } -/** Back in view or focus: move the pill's clock, and read again if the last event is a minute old. */ -const onCallWoke = (model: Model, nowMs: number): UpdateReturn => { - const hasSchedules = Option.exists(AsyncData.getData(model.onCall), (data) => data.shifts.length > 0) - if (!hasSchedules) return { model } - const moved = modifyFields(model, { onCallNowMs: () => nowMs }) - return nowMs - model.onCallNowMs >= ONCALL_WAKE_MIN_MS ? readOnCall(moved) : { model: moved } -} +/** + * Back in view or focus: move the pill's clock, and read again if the last event is a minute old. + * An org without schedules is asked again once that answer has aged: a teammate may have made one. + */ +const onCallWoke = (model: Model, nowMs: number): UpdateReturn => + Option.match(Option.all([model.maybeOrgId, AsyncData.getData(model.onCall)]), { + onNone: () => ({ model }), + onSome: ([orgId, data]) => { + if (data.shifts.length === 0) + return isNoSchedulesFresh(model.noScheduleOrgs[orgId], nowMs) ? { model } : readOnCall(model) + const moved = modifyFields(model, { onCallNowMs: () => nowMs }) + return nowMs - model.onCallNowMs >= ONCALL_WAKE_MIN_MS ? readOnCall(moved) : { model: moved } + }, + }) /** A page wrote a schedule or override: the org may have its first schedule now. */ const onCallChanged = (model: Model): UpdateReturn => From 82145ab326135e4e7a11d21f1739a6636ee27fb7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:31 +0100 Subject: [PATCH 49/69] fix(shell): only the current page's link carries aria-current in the sidebar A section row whose sub-item is the current page keeps data-active="child" and leaves aria-current to the sub-item. The on-call footer row is a plain anchor, so it never claims the page its nav row already marks. --- apps/web/src/shell/nav-groups.scene.test.ts | 31 ++++++++++++++++++++- apps/web/src/shell/oncall.scene.test.ts | 22 +++++++++++++-- apps/web/src/shell/view-sidebar.ts | 12 ++------ packages/views/src/ui/sidebar.ts | 6 ++-- 4 files changed, 56 insertions(+), 15 deletions(-) diff --git a/apps/web/src/shell/nav-groups.scene.test.ts b/apps/web/src/shell/nav-groups.scene.test.ts index a85dab0187..558131f7d7 100644 --- a/apps/web/src/shell/nav-groups.scene.test.ts +++ b/apps/web/src/shell/nav-groups.scene.test.ts @@ -1,6 +1,17 @@ import { Option } from "effect" import type { HtmlBuilder } from "foldkit/html" -import { Command, click, expect, given, Mount, role, scene, withViewInputs } from "foldkit/scene" +import { + all, + Command, + click, + expect, + expectAll, + given, + Mount, + role, + scene, + withViewInputs, +} from "foldkit/scene" import { describe, expect as expectVitest, test } from "vitest" import { SaveCollapsedNavGroups } from "./command" import { Message } from "./message" @@ -100,6 +111,24 @@ describe("sidebar groups", () => { ) }) + test("one link is the current page: the sub-item while its section is open, the section once folded", () => { + const explore = role("link", { name: "Explore" }) + const current = all.role("link", { current: "page" }) + scene( + // Explore links to `/traces`, the page its Traces sub-item is. + { update, view: shellAt("/traces") }, + given(signedIn()), + measured, + expectAll(current).toHaveCount(1), + expect(role("link", { name: "Traces" })).toHaveAttr("aria-current", "page"), + expect(explore).toHaveAttr("data-active", "child"), + click(analyze), + Command.resolve(SaveCollapsedNavGroups, Message.CompletedSaveNavGroups()), + expectAll(current).toHaveCount(1), + expect(explore).toHaveAttr("aria-current", "page"), + ) + }) + test("the collapsed rail shows every row of a folded group and its labels are no tab stops", () => { scene( { update, view: shellAt("/") }, diff --git a/apps/web/src/shell/oncall.scene.test.ts b/apps/web/src/shell/oncall.scene.test.ts index 029b0c5301..4409b856db 100644 --- a/apps/web/src/shell/oncall.scene.test.ts +++ b/apps/web/src/shell/oncall.scene.test.ts @@ -69,15 +69,17 @@ const answer = (shifts: ReadonlyArray, readAtMs = NOW, isFromCache const commandsOf = (step: UpdateReturn) => Arr.map(step.commands ?? [], (command) => ({ name: command.name, args: command.args })) -const view = (() => { +const viewAt = (currentPath: string) => { const frame = withViewInputs(frameView, { - currentPath: "/", + currentPath, breadcrumbs: [{ label: "Overview" }], page: () => null, topbarActions: () => null, })() return (model: Model, h: HtmlBuilder) => frame(model, h) -})() +} + +const view = viewAt("/") const measured = Mount.resolve(MeasureSidebarOverflow, Message.MeasuredSidebarOverflow()) @@ -141,6 +143,20 @@ describe("on-call pill", () => { ) }) + test("the pill is a shortcut: on the page it links to, the nav's row is the current page, not the pill", () => { + const pill = role("link", { name: "Not on call" }) + // Where the pill links today, and where it links once on-call has a page of its own. + Arr.forEach(["/alerts?tab=oncall", "/alerts/on-call"], (currentPath) => + scene( + { update, view: viewAt(currentPath) }, + given(withOnCall([shift({ userId: "user_someone" })])), + measured, + expect(pill).toExist(), + expect(pill).not.toHaveAttr("aria-current"), + ), + ) + }) + test("on the collapsed rail the pill is a badged glyph named by the detail, which its tooltip shows", () => { const pill = role("link", { name: "On call until Mon 09:00 CET, 16 Nov · Checkout primary" }) scene( diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index b2e4ef78f6..12f1b99b71 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -262,7 +262,6 @@ export const shellOnCallStatus = (model: Model): OnCallStatus => */ const onCallPill = ( h: HtmlBuilder, - currentPath: string, status: OnCallStatus, tip: RailTip, isRail: boolean, @@ -281,7 +280,8 @@ const onCallPill = ( h, { className: isOnCall ? undefined : "text-muted-foreground", - link: { href: oncallTabHref(), currentPath }, + // A shortcut, not a nav row: a plain anchor, so it never claims `aria-current="page"`. + href: oncallTabHref(), hasTooltip: true, attributes: [ // The rail's tooltip names the glyph; the open sidebar's adds what the row leaves out. @@ -405,13 +405,7 @@ export const shellSidebarFooter = ( ), sidebarMenu(h, undefined, [ // A row of the footer's list, so it keeps the list's 4px rhythm. - ...onCallPill( - h, - currentPath, - shellOnCallStatus(model), - tip, - !model.isSidebarOpen && !model.isMobile, - ), + ...onCallPill(h, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), footerNavRow(h, currentPath, { href: "/sources", title: "Sources", icon: "ConnectionIcon" }, tip), // `/integrations` renders inside the settings rail, so Settings owns it. footerNavRow( diff --git a/packages/views/src/ui/sidebar.ts b/packages/views/src/ui/sidebar.ts index 42bdbeb995..dfe450e12e 100644 --- a/packages/views/src/ui/sidebar.ts +++ b/packages/views/src/ui/sidebar.ts @@ -367,7 +367,7 @@ export type SidebarMenuButtonOptions = Readonly<{ isActive?: boolean /** * A sub-item of this row is the current page: the row keeps its medium weight and leaves the - * tint to that sub-item (`data-active="child"`). + * tint and `aria-current="page"` to that sub-item (`data-active="child"`). */ hasActiveChild?: boolean /** Rendered as a router link instead of a button. */ @@ -421,7 +421,9 @@ export const sidebarMenuButton = ( return h.a( [ h.Href(options.link.href), - ...(linkActive ? [h.AriaCurrent("page"), h.DataAttribute("status", "active")] : []), + // One link is the current page: a section row leaves that to its current sub-item. + ...(linkActive && options.hasActiveChild !== true ? [h.AriaCurrent("page")] : []), + ...(linkActive ? [h.DataAttribute("status", "active")] : []), ...attributes, ], children, From f6754063a0f3b1c36046e4ce2ae80ce2d7b1fa6c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:40 +0100 Subject: [PATCH 50/69] fix(alerts): silence save button names its end on the 24h clock the picker uses --- .../alerts/hub/hub-silences.scene.test.ts | 27 +++++++++++++++++++ .../src/page/alerts/shared/silences.test.ts | 6 +++++ apps/web/src/page/alerts/shared/silences.ts | 14 +++++++--- .../src/page/alerts/silence-dialog/view.ts | 3 ++- packages/web-data/src/lib/timezone-format.ts | 10 +++++-- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index 6dedbb673c..cfbb3daa93 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -359,6 +359,33 @@ describe("alerts hub, silence side panel", () => { ) }) + test("the save button names its end on the 24h clock the duration choice and the picker use", () => { + const until = (duration: SilenceDialog.SilenceDuration, value: string) => + Arr.reduce( + [ + silenceMessage(SilenceDialog.Message.ChangedDuration({ duration })), + silenceMessage(SilenceDialog.Message.ChangedUntil({ value })), + ], + withPanel().model, + (model, message) => pageUpdate(model, message).model, + ) + const saveButton = role("button", { name: /^Silence until / }) + scene( + { update: pageUpdate, view }, + given(until("morning", "")), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text("Until 09:00")).toExist(), + expectView(saveButton).toHaveText(/ 09:00$/u), + ) + // NOW is noon on 1 October, so 15:30 that day is still ahead. + scene( + { update: pageUpdate, view }, + given(until("custom", "2026-10-01T15:30")), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(saveButton).toHaveText(/ 15:30$/u), + ) + }) + test("a link straight to the panel waits for the clock and the viewer's grants, and a member never gets it", () => { const entered = init(hubAt({ tab: "silences", sheet: "silence" }), context).model expect(entered.silenceDialog.dialog.isOpen).toBe(false) diff --git a/apps/web/src/page/alerts/shared/silences.test.ts b/apps/web/src/page/alerts/shared/silences.test.ts index c482cf734a..0c2566425f 100644 --- a/apps/web/src/page/alerts/shared/silences.test.ts +++ b/apps/web/src/page/alerts/shared/silences.test.ts @@ -52,6 +52,12 @@ describe("silence times", () => { expect(formatSilenceTime(late, NOW, BERLIN)).toBe("Nov 7, 12:30 AM") }) + it("reads on a 24h clock when asked, midnight as 00", () => { + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 14, 30), NOW, BERLIN, "h23")).toBe("15:30") + expect(formatSilenceTime(Date.UTC(2026, 10, 7, 8, 0), NOW, BERLIN, "h23")).toBe("Nov 7, 09:00") + expect(formatSilenceTime(Date.UTC(2026, 10, 6, 23, 30), NOW, BERLIN, "h23")).toBe("Nov 7, 00:30") + }) + it("names the day of a window once when both ends share it", () => { const start = Date.UTC(2026, 10, 7, 14, 0) expect(formatSilenceRange(start, start + 60 * 60_000, NOW, BERLIN)).toBe( diff --git a/apps/web/src/page/alerts/shared/silences.ts b/apps/web/src/page/alerts/shared/silences.ts index 5a5be70bf7..77db386276 100644 --- a/apps/web/src/page/alerts/shared/silences.ts +++ b/apps/web/src/page/alerts/shared/silences.ts @@ -70,9 +70,17 @@ export const endsAtMs = (silence: V2AlertSilence): number => isoMs(silence.ends_ export const isCancelledSilence = (silence: V2AlertSilence): boolean => startsAtMs(silence) === endsAtMs(silence) -/** "15:00" today, "Nov 7, 09:00" on another day, in the viewer's zone. */ -export const formatSilenceTime = (ms: number, nowMs: number, timeZone: string): string => { - const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false }) +/** + * "03:00 PM" today, "Nov 7, 09:00 AM" on another day, in the viewer's zone. `h23` reads "15:00", + * for a label beside the form's 24h time picker. + */ +export const formatSilenceTime = ( + ms: number, + nowMs: number, + timeZone: string, + hourCycle?: "h12" | "h23", +): string => { + const time = formatTimeInTimezone(ms, { timeZone, withSeconds: false, hourCycle }) return formatDateInTimezone(ms, { timeZone }) === formatDateInTimezone(nowMs, { timeZone }) ? time : `${formatDateInTimezone(ms, { timeZone, withYear: false })}, ${time}` diff --git a/apps/web/src/page/alerts/silence-dialog/view.ts b/apps/web/src/page/alerts/silence-dialog/view.ts index 73e5106252..1c8b288f3e 100644 --- a/apps/web/src/page/alerts/silence-dialog/view.ts +++ b/apps/web/src/page/alerts/silence-dialog/view.ts @@ -50,7 +50,8 @@ const submitLabel = (model: Model): string => : Option.match(silenceEndMs(model.duration, model.until, model.nowMs, model.timeZone), { onNone: () => "Silence", onSome: (endMs) => - `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone)}`, + // 24h, as the "Until 09:00" choice and the end's time picker read. + `Silence until ${formatSilenceTime(endMs, model.nowMs, model.timeZone, "h23")}`, }) /** A field's error once a save was tried. */ diff --git a/packages/web-data/src/lib/timezone-format.ts b/packages/web-data/src/lib/timezone-format.ts index 5a3405a46d..e43e139dd7 100644 --- a/packages/web-data/src/lib/timezone-format.ts +++ b/packages/web-data/src/lib/timezone-format.ts @@ -80,13 +80,18 @@ export function formatTimestampInTimezone( export function formatTimeInTimezone( input: TimezoneFormatInput, - options: { timeZone: string; withSeconds?: boolean }, + options: { + timeZone: string + withSeconds?: boolean + /** A 24h clock (`h23`) where the time sits beside a 24h picker. */ + hourCycle?: "h12" | "h23" + }, ): string { const date = toValidDate(input) if (!date) return EMPTY_VALUE const tz = resolveTimeZone(options.timeZone) - const key = `${tz}|${options.withSeconds ? "s" : ""}` + const key = `${tz}|${options.withSeconds ? "s" : ""}|${options.hourCycle ?? ""}` let formatter = timeFormatters.get(key) if (!formatter) { formatter = new Intl.DateTimeFormat("en-US", { @@ -94,6 +99,7 @@ export function formatTimeInTimezone( hour: "2-digit", minute: "2-digit", second: options.withSeconds ? "2-digit" : undefined, + hourCycle: options.hourCycle, }) timeFormatters.set(key, formatter) } From d599b1d7bf5d8f4cb42900281daf39de7ed35dde Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:03:43 +0100 Subject: [PATCH 51/69] fix(ui): make the sidebar row hover visible in the light theme surface-sunken sits under 1% from the light sidebar's grey. Rows now hover at half the selected tint (bg-sidebar-accent/50): visible in light, unchanged in dark. --- docs/design-rules.md | 3 ++- packages/ui/src/components/ui/sidebar.styles.ts | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/design-rules.md b/docs/design-rules.md index 7b109f0724..2ae063d8ae 100644 --- a/docs/design-rules.md +++ b/docs/design-rules.md @@ -98,7 +98,8 @@ bordered box takes its parent's radius minus 1px: `before:rounded-lg-inset` insi - Hover fills: a row, list item or chip hovers `bg-surface-sunken`, and a chip already on `surface-sunken` hovers `surface-strong`. A control (ghost button, toggle, tab, menu trigger) hovers `bg-accent`, and only the kit in `packages/ui` draws one. `hover:bg-muted` is never right - (`maple/surface-washes`). + (`maple/surface-washes`). A sidebar row hovers `bg-sidebar-accent/50`, half its selected tint: + the sidebar is already grey in the light theme, where a neutral wash does not show. - Hover outlines: a pickable card or chip hovers `border-border-strong`, a toned one its tone's `-a4` edge. `ring` is focus only, and `foreground` or a `/NN` tone is never a hover edge (`maple/hover-edge`). diff --git a/packages/ui/src/components/ui/sidebar.styles.ts b/packages/ui/src/components/ui/sidebar.styles.ts index 26c394eebe..13183bebcf 100644 --- a/packages/ui/src/components/ui/sidebar.styles.ts +++ b/packages/ui/src/components/ui/sidebar.styles.ts @@ -1,7 +1,7 @@ import { cva } from "class-variance-authority" export const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] duration-200 ease-move group-data-instant:transition-none hover:bg-surface-sunken hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-surface-sunken active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=child]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:active:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[state=open]:active:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-xs outline-hidden ring-sidebar-ring transition-[width,height,padding] duration-200 ease-move group-data-instant:transition-none hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent/50 active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-64 data-[active=child]:font-medium data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:active:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground data-[state=open]:active:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", { defaultVariants: { size: "default", @@ -14,9 +14,10 @@ export const sidebarMenuButtonVariants = cva( sm: "h-7 text-xs", }, variant: { - // Hover is a neutral wash; the accent tint is the selected row's alone. + // Hover is the selected tint at half strength, so the full tint stays the selected row's. + // A neutral wash (`surface-sunken`) is under 1% from the light sidebar's own grey. default: - "hover:bg-surface-sunken active:bg-surface-sunken hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground", + "hover:bg-sidebar-accent/50 active:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground", outline: "bg-background ring-1 ring-sidebar-border hover:bg-sidebar-accent active:bg-sidebar-accent hover:text-sidebar-accent-foreground active:text-sidebar-accent-foreground hover:ring-sidebar-accent active:ring-sidebar-accent", }, @@ -123,7 +124,7 @@ export const sidebarMenuSubItemStyles = "group/menu-sub-item relative" export const sidebarMenuSubButtonStyles = [ // 36px in the mobile sheet (below `md`, a touch target), 28px on the desktop rail. - "flex h-9 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-surface-sunken hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-surface-sunken active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 md:h-7 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "flex h-9 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-lg px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent/50 hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent/50 active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-64 aria-disabled:pointer-events-none aria-disabled:opacity-64 md:h-7 [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", ] as const From 717a12fa65eee712248d91d54cba66e9fb1841f5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:04:29 +0100 Subject: [PATCH 52/69] fix(alerts): first-run Inbox and Rules leave the create actions to the empty state --- .../web/src/page/alerts/hub/hub.scene.test.ts | 37 +++++++++++++++++++ apps/web/src/page/alerts/hub/view.ts | 36 +++++++++++++----- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index 5d0c3508cb..a821b640e2 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -180,6 +180,43 @@ describe("alerts hub", () => { ) }) + test("an org without rules gets the create actions from the empty state alone, on the Inbox and on Rules", () => { + const newRule = role("link", { name: "New rule" }) + const firstRun = (at: HubAt, grants = ENGINEER): Model => + Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedRules({ orgId: ORG, rows: [] }), + Message.UpdatedRuleStates({ orgId: ORG, rows: [] }), + Message.UpdatedIncidents({ orgId: ORG, rows: [] }), + Message.UpdatedDestinations({ orgId: ORG, rows: [] }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + ], + entered(at).model, + (model, message) => pageUpdate(model, message).model, + ) + const tabs: ReadonlyArray<"inbox" | "rules"> = ["inbox", "rules"] + Arr.forEach(tabs, (tab) => { + scene( + { update: pageUpdate, view }, + given(firstRun({ tab })), + expectView(role("link", { name: "Start from scratch" })).toExist(), + expectView(role("button", { name: "Add destination" })).toExist(), + expectView(newRule).not.toExist(), + expectView(role("button", { name: "More rule types" })).not.toExist(), + ) + // A member's empty state offers nothing to create, so the header keeps its action. + scene( + { update: pageUpdate, view }, + given(firstRun({ tab }, Policy.grantsOf(["org:member"]))), + expectView(role("link", { name: "Start from scratch" })).not.toExist(), + expectView(newRule).toExist(), + ) + }) + // Still loading: nothing says the org is empty yet. + scene({ update: pageUpdate, view }, given(entered().model), expectView(newRule).toExist()) + }) + test("moving between sections keeps what was read: the Inbox and Rules share it, a section reads its own once", () => { const inbox = loaded() expect(names(routeChanged(inbox, hubAt({ tab: "rules" }), context).commands)).toEqual([ diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 45ca0d6631..7278c9c6c1 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -180,12 +180,35 @@ const primaryLink = (h: H, href: string, label: string): Html => const primaryButton = (h: H, onClick: Message, label: string, isDisabled = false): Html => button(h, { size: "sm", isDisabled, attributes: [h.OnClick(onClick)] }, [icon(h, "PlusIcon"), label]) +/** + * The first-run empty state fills the section: no rules yet, and on the Inbox nothing else to list + * (an org with only monitors or SLOs has no visible rules but may have incidents to show). + */ +const isFirstRun = (model: Model, data: AlertsOverviewReady, tab: "inbox" | "rules"): boolean => { + if (data.rules.length > 0) return false + if (tab === "rules") return true + return ( + resolveSignal(model.search.signal) === null && + !Arr.some(data.openIncidents, (incident) => isManagedSignalType(incident.signalType)) && + !Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") + ) +} + /** * The header's one primary action: what the open section lists. A section whose empty state * already offers it shows none until it has rows, and a viewer who can't create it sees none. */ -const headerActions = (h: H, model: Model, tab: AlertsTab): Html | undefined => { - if (tab === "inbox" || tab === "rules") return newRuleButton(h, model) +const headerActions = ( + h: H, + model: Model, + tab: AlertsTab, + overview: Option.Option, +): Html | undefined => { + if (tab === "inbox" || tab === "rules") + // The first-run empty state offers the templates and "Start from scratch" to who can create. + return canManage(model) && Option.exists(overview, (data) => isFirstRun(model, data, tab)) + ? undefined + : newRuleButton(h, model) if (!canManage(model)) return undefined const serviceName = model.search.serviceName switch (tab) { @@ -253,10 +276,6 @@ const hubBody = ( serviceName: model.search.serviceName, onAddDestination: canManage(model) ? Message.ClickedNewDestination() : undefined, }) - // An org with only monitors or SLOs has no visible rules but may have open incidents to show. - const hasManagedIncidents = Arr.some(data.openIncidents, (incident) => - isManagedSignalType(incident.signalType), - ) const acknowledge = { onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => Message.ClickedAcknowledgeIncident({ incidentId, acknowledged }), @@ -264,9 +283,8 @@ const hubBody = ( } if (tab === "inbox") { const signal = resolveSignal(model.search.signal) - const hasAnomalies = Arr.some(data.incidents, (incident) => incident.signalType === "anomaly") // A team can set up on-call before its first rule, so the strip shows on the empty inbox too. - if (data.rules.length === 0 && !hasManagedIncidents && !hasAnomalies && signal === null) + if (isFirstRun(model, data, "inbox")) return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), emptyState]) const filters = inboxFilters(h, { signal, @@ -335,7 +353,7 @@ export const view = Submodel.defineView((model, h, { title: TAB_LABEL[tab], - actions: headerActions(h, model, tab), + actions: headerActions(h, model, tab, overview), }, [ tab === "routing" From 994eb5af29e4cc31e154fab424f49649f34846f7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:05:14 +0100 Subject: [PATCH 53/69] fix(alerts): hub replaces away a ?sheet= that opens nothing --- .../alerts/hub/hub-silences.scene.test.ts | 34 +++++++++++++++++++ apps/web/src/page/alerts/hub/update.ts | 14 +++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts index cfbb3daa93..01243dfb4b 100644 --- a/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-silences.scene.test.ts @@ -423,6 +423,40 @@ describe("alerts hub, silence side panel", () => { ).toBe(false) }) + test("a `?sheet=` that opens nothing is replaced away: another section's panel, or a made-up one", () => { + const dismissals = (commands: Parameters[0]) => + Arr.filter(callsOf(commands), ([name]) => name === "DismissAlertsHubSheet") + const onRules = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "rules", sheet: "silence" }), + context, + ) + expect(onRules.model.search.sheet).toBeUndefined() + expect(dismissals(onRules.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: "/alerts/rules?sheet=silence", id: "silence" }], + ]) + const onInbox = routeChanged(onSilences(ENGINEER, []), hubAt({ sheet: "silence" }), context) + expect(dismissals(onInbox.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: "/alerts?sheet=silence", id: "silence" }], + ]) + const bogus = routeChanged( + onSilences(ENGINEER, []), + hubAt({ tab: "silences", sheet: "bogus" }), + context, + ) + expect(bogus.model.search.sheet).toBeUndefined() + expect(bogus.model.silenceDialog.dialog.isOpen).toBe(false) + expect(dismissals(bogus.commands)).toEqual([ + ["DismissAlertsHubSheet", { href: `${SILENCES_URL}?sheet=bogus`, id: "bogus" }], + ]) + // Asked once: the clock tick that follows finds nothing left to drop. + expect(dismissals(pageUpdate(bogus.model, Message.ReadClock({ nowMs: NOW })).commands)).toEqual([]) + // The open panel leaving its section closes with the param. + const left = routeChanged(withPanel().model, hubAt({ tab: "rules", sheet: "silence" }), context) + expect(left.model.silenceDialog.dialog.isOpen).toBe(false) + expect(dismissals(left.commands)).toHaveLength(1) + }) + test("a clock tick after Cancel, before the URL change lands, leaves it closed", () => { const cancelled = pageUpdate(withPanel().model, silenceMessage(SilenceDialog.Message.ClickedCancel())) expect(cancelled.model.search.sheet).toBeUndefined() diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 89fb77bfeb..c559c3f07c 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -759,7 +759,19 @@ const silenceCommands = (step: ReturnType): Command * a viewer who may save. */ const followSilenceSheet = (model: Model, context: PageContext): Requested => { - const isInUrl = model.search.sheet === SILENCE_SHEET && model.tab === "silences" + const sheet = model.search.sheet + const isInUrl = sheet === SILENCE_SHEET && model.tab === "silences" + // A `?sheet=` nothing here opens (another section's panel, a made-up id) is replaced away. + if (sheet !== undefined && !isInUrl) { + const dropped = followSilenceSheet( + { ...model, search: { ...model.search, sheet: undefined } }, + context, + ) + return { + model: dropped.model, + commands: [...dropped.commands, DismissHubSheet({ href: hubHref(model), id: sheet })], + } + } const isOpen = model.silenceDialog.dialog.isOpen if (isInUrl && !isOpen && model.nowMs !== null && canManage(model)) { const step = SilenceDialog.openNew(model.silenceDialog, context.timeZone, model.nowMs) From b9a7016d3de34e2281dc8e6ecb6ac5bd80cc5a74 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:05:58 +0100 Subject: [PATCH 54/69] fix(views): a mouse pick in a combobox list leaves focus in its input A pressed option took focus to the listbox, where Escape and Enter reached nothing, so a chips picker stayed open until a press outside. Presses in any combobox list now leave focus in the input that handles the keys, and a field's list closes once focus leaves the field (Tab). --- packages/views/src/ui/INDEX.md | 4 +- packages/views/src/ui/combobox-focus.test.ts | 60 ++++++++++++++++++++ packages/views/src/ui/combobox.ts | 47 ++++++++++----- packages/views/src/ui/overlay/portal.test.ts | 37 +++++++++++- packages/views/src/ui/overlay/portal.ts | 20 +++++++ 5 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 packages/views/src/ui/combobox-focus.test.ts diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 871b8d1fbc..0f351163c2 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -37,7 +37,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `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. @@ -118,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... 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.ts b/packages/views/src/ui/combobox.ts index 802d1f773e..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,26 +243,43 @@ export const MountCombobox = Mount.defineStream("MountCombobox", { } }), Stream.merge(escapeContained(anchorSelector)), - // A popup with its own search input: a pressed option leaves focus (and the keys) there, - // and Escape there is stopped at the portal, which the anchor does not enclose. - Stream.merge(returnFocusId === undefined ? Stream.empty : optionPressesKeepFocus(element)), - Stream.merge(returnFocusId === undefined ? Stream.empty : escapeContainedIn(element)), + // 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), + ), ), }) -/** Base UI keeps focus in the input while an option is pressed, so typing and Enter go on working. */ -const optionPressesKeepFocus = (root: Element): Stream.Stream => - Stream.callback(() => +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 onMouseDown = (event: Event) => { - if (event.target instanceof Element && event.target.closest('[role="option"]') !== null) - event.preventDefault() + 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()) } - root.addEventListener("mousedown", onMouseDown) - return onMouseDown + document.addEventListener("focusin", onFocusIn) + return onFocusIn }), - (onMouseDown) => Effect.sync(() => root.removeEventListener("mousedown", onMouseDown)), + (onFocusIn) => Effect.sync(() => document.removeEventListener("focusin", onFocusIn)), ).pipe(Effect.andThen(Effect.never)), ) 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]" From 7fbd687d9de36b98b30c2c74aa39404fe9914162 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:07:11 +0100 Subject: [PATCH 55/69] fix(query-builder): the value list says why a value with both quote kinds is refused The note sat under the field, covered by the open list, which still said "Type a value and press Enter". --- .../query-builder/condition-builder-view.ts | 12 +++++--- .../condition-builder.scene.test.ts | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) 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 index 5c5ad98216..20cb9cb4e9 100644 --- a/apps/web/src/page/kit/query-builder/condition-builder-view.ts +++ b/apps/web/src/page/kit/query-builder/condition-builder-view.ts @@ -169,7 +169,8 @@ const rowSummary = (row: ConditionRow, dataSource: QueryDraft["dataSource"]): st " ", ) -const UNSPELLABLE_NOTE = `A value can't hold both ' and "` +const UNSPELLABLE_NOTE = `A value can't contain both ' and "` +const VALUE_HINT = "Type a value and press Enter" const valuePicker = ( h: H, @@ -186,7 +187,10 @@ const valuePicker = ( // 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) - const items = isTypedNew ? [typed, ...suggestions] : suggestions + // 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 }) @@ -206,7 +210,7 @@ const valuePicker = ( items, ariaLabel, placeholder: row.values.length === 0 ? "Value" : "", - emptyText: "Type a value and press Enter", + emptyText, size: "sm", renderItem: (_, value) => renderItem(value), toView: (parts) => h.div([h.Class("min-w-0")], parts), @@ -221,7 +225,7 @@ const valuePicker = ( items, ariaLabel, placeholder: "Value", - emptyText: "Type a value and press Enter", + emptyText, size: "sm", highlightsSelected: true, renderItem, 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 index 33d34251cd..cc79bff157 100644 --- 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 @@ -114,6 +114,34 @@ describe("condition builder", () => { ) }) + 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 }, From 9ca9b4afc532a5df7fb224a6975f99ce200c4608 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:07:50 +0100 Subject: [PATCH 56/69] fix(alerts): routing row's chain delete lists the rules using it and holds the delete --- .../src/page/alerts/escalation-chain/view.ts | 45 ++----------- .../page/alerts/hub/hub-chains.scene.test.ts | 41 +++++++++++- apps/web/src/page/alerts/hub/view-chains.ts | 33 ++++++---- .../alerts/shared/chain-delete-confirm.ts | 66 +++++++++++++++++++ 4 files changed, 132 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/page/alerts/shared/chain-delete-confirm.ts diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index e924e1acf1..91a1b0da94 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -26,6 +26,7 @@ import { textarea } from "@maple/views/ui/textarea" import { typography } from "@maple/views/ui/typography" import type { PageViewInputs } from "../../contract" import { chainConnector } from "../../kit/chain-connector" +import { chainDeleteConfirm } from "../shared/chain-delete-confirm" import { dashboardPage } from "../../kit/dashboard-page" import { managersOnlyPage } from "../../kit/form-section" import { type PickedChip, pickChips } from "../../kit/pick-chips" @@ -612,51 +613,15 @@ const aside = (h: H, model: Model): Html => /** "Delete {chain}?": lists the rules that start it, which the API makes move off it first. */ const deleteDialog = (h: H, model: Model): Html => { - const chain = AsyncData.getData(model.existing.data) - const ruleIds: ReadonlyArray = Option.match(chain, { - onNone: () => [], - onSome: (found) => found.rule_ids, - }) - const names = Arr.map( - Arr.filter(model.rules ?? [], (rule) => Arr.some(ruleIds, (id) => id === rule.id)), - (rule) => rule.name, - ) - const isInUse = ruleIds.length > 0 + const confirm = chainDeleteConfirm(h, AsyncData.getData(model.existing.data), model.rules) return confirmDialog(h, { slotId: DELETE_DIALOG_ID, model: model.deleteDialog, toParentMessage: (message) => Message.GotDeleteDialogMessage({ message }), title: `Delete ${chainTitle(model)}?`, - description: isInUse - ? "These rules would stop escalating. Remove the chain from their severity tiers first." - : "Escalations already running finish on their own copy. This cannot be undone.", + description: confirm.description, children: [ - ...(isInUse - ? [ - h.ul( - [h.Class("list-disc space-y-1 pl-5")], - [ - ...Arr.map(names, (ruleName) => - h.li([h.Key(ruleName)], [typography(h, { size: "sm" }, [ruleName])]), - ), - ...(ruleIds.length > names.length - ? [ - h.li( - [], - [ - typography(h, { size: "sm", tone: "muted" }, [ - names.length === 0 - ? `${ruleIds.length} ${pluralize(ruleIds.length, "rule")}` - : `and ${ruleIds.length - names.length} more`, - ]), - ], - ), - ] - : []), - ], - ), - ] - : []), + ...confirm.ruleList, ...(model.deleteError === null ? [] : [ @@ -669,7 +634,7 @@ const deleteDialog = (h: H, model: Model): Html => { ], confirmLabel: "Delete chain", isPending: model.isDeleting, - isConfirmDisabled: isInUse, + isConfirmDisabled: confirm.isInUse, onConfirm: Message.ClickedConfirmDelete(), }) } diff --git a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts index 529b1e8bc2..b7756ceb63 100644 --- a/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-chains.scene.test.ts @@ -3,7 +3,9 @@ import { OrgId } from "@maple/domain" import { Result, Schema, Array as Arr } from "effect" import { EscalationChainId } from "@maple/domain/http" import * as Menu from "@maple/views/ui/menu" -import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import * as Dialog from "@maple/views/ui/dialog" +import { MountDialog } from "@maple/views/ui/dialog" +import { expect as expectView, given, Mount, role, scene, text, within, withViewInputs } from "foldkit/scene" import { describe, expect, test } from "vitest" import type { V2EscalationChain } from "@maple/domain/http/v2" import type { PageContext } from "../../contract" @@ -118,6 +120,43 @@ describe("alerts hub, escalation chains", () => { }) }) + test("Delete on a chain that rules start lists them and holds the delete, as its own page does", () => { + const askToDelete = (n: number) => + pageUpdate( + onRouting(ENGINEER, CHAINS), + Message.GotRowMenuMessage({ + menuId: chainMenuId(Schema.decodeSync(EscalationChainId)(chainUuid(n))), + message: Menu.Message.ClickedItem({ index: 2, item: { kind: "Item", value: "delete" } }), + }), + ).model + const confirm = within(role("alertdialog"), role("button", { name: "Delete chain" })) + scene( + { update: pageUpdate, view }, + given(askToDelete(1)), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/These rules would stop escalating/u)).toExist(), + expectView(within(role("alertdialog"), text("Checkout errors"))).toExist(), + expectView(within(role("alertdialog"), text("Checkout p95"))).toExist(), + expectView(confirm).toBeDisabled(), + ) + // Before the rules list has loaded they are counted instead of named, and still hold it. + scene( + { update: pageUpdate, view }, + given({ ...askToDelete(1), rules: null }), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(within(role("alertdialog"), text("2 rules"))).toExist(), + expectView(confirm).toBeDisabled(), + ) + // A chain no rule starts deletes as before. + scene( + { update: pageUpdate, view }, + given(askToDelete(3)), + Mount.resolve(MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(text(/Escalations already running finish on their own copy/u)).toExist(), + expectView(confirm).not.toBeDisabled(), + ) + }) + test("a refused delete stays open and names the rules that still start the chain", () => { const model = onRouting(ENGINEER, CHAINS) const chainId = Schema.decodeSync(EscalationChainId)(chainUuid(1)) diff --git a/apps/web/src/page/alerts/hub/view-chains.ts b/apps/web/src/page/alerts/hub/view-chains.ts index fd2e617007..42838fa2b6 100644 --- a/apps/web/src/page/alerts/hub/view-chains.ts +++ b/apps/web/src/page/alerts/hub/view-chains.ts @@ -22,6 +22,7 @@ import { textLink } from "@maple/views/ui/text-link" import { typography } from "@maple/views/ui/typography" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { sectionHeading } from "../../kit/section-heading" +import { chainDeleteConfirm } from "../shared/chain-delete-confirm" import { chainHref, chainSummary, newChainHref } from "../shared/escalation-chains" import { memberName } from "../shared/org-members" import { Message } from "./message" @@ -234,7 +235,10 @@ export const chainsSection = (h: H, model: Model): Html => ], ) -/** "Delete {chain}?": the 409 that names the rules holding it stays in the dialog. */ +/** + * "Delete {chain}?": as on the chain's page, the rules that start it are listed and hold the + * delete. A 409 from a rule attached since the list was read stays in the dialog. + */ export const deleteChainDialog = (h: H, model: Model): Html => { const chain = Arr.findFirst( Option.getOrElse(AsyncData.getData(model.chains.data), () => []), @@ -244,24 +248,29 @@ export const deleteChainDialog = (h: H, model: Model): Html => { Option.map(chain, (found) => found.name), () => "escalation chain", ) + const confirm = chainDeleteConfirm(h, chain, model.rules) + const children = [ + ...confirm.ruleList, + ...(model.deleteChainError === null + ? [] + : [ + alert(h, { variant: "crit", size: "sm" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't delete chain"]), + alertDescription(h, [model.deleteChainError]), + ]), + ]), + ] return confirmDialog(h, { slotId: "alerts-delete-chain", model: model.deleteChainDialog, toParentMessage: (message) => Message.GotDeleteChainDialogMessage({ message }), title: `Delete ${name}?`, - description: "Escalations already running finish on their own copy. This cannot be undone.", - children: - model.deleteChainError === null - ? undefined - : [ - alert(h, { variant: "crit", size: "sm" }, [ - icon(h, "CircleXmarkIcon"), - alertTitle(h, ["Couldn't delete chain"]), - alertDescription(h, [model.deleteChainError]), - ]), - ], + description: confirm.description, + children: children.length === 0 ? undefined : children, confirmLabel: "Delete chain", isPending: model.deletingChainId !== null, + isConfirmDisabled: confirm.isInUse, onConfirm: Message.ClickedConfirmDeleteChain(), }) } diff --git a/apps/web/src/page/alerts/shared/chain-delete-confirm.ts b/apps/web/src/page/alerts/shared/chain-delete-confirm.ts new file mode 100644 index 0000000000..0662646ad3 --- /dev/null +++ b/apps/web/src/page/alerts/shared/chain-delete-confirm.ts @@ -0,0 +1,66 @@ +import type { V2EscalationChain } from "@maple/domain/http/v2" +import { pluralize } from "@maple/ui/lib/format" +import { Option, Array as Arr } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { typography } from "@maple/views/ui/typography" + +export type ChainDeleteConfirm = Readonly<{ + /** A rule still starts the chain: the API refuses the delete, so the confirm holds it. */ + isInUse: boolean + description: string + /** The rules that start it by name; ones this tab has not loaded are counted. */ + ruleList: ReadonlyArray +}> + +/** + * What a chain's delete confirm says, on its own page and on its Routing row: the rules that + * start it, which the API makes move off it first, and Delete held while there are any. + */ +export const chainDeleteConfirm = ( + h: HtmlBuilder, + chain: Option.Option>, + rules: ReadonlyArray<{ readonly id: string; readonly name: string }> | null, +): ChainDeleteConfirm => { + const ruleIds: ReadonlyArray = Option.match(chain, { + onNone: () => [], + onSome: (found) => found.rule_ids, + }) + const names = Arr.map( + Arr.filter(rules ?? [], (rule) => Arr.some(ruleIds, (id) => id === rule.id)), + (rule) => rule.name, + ) + if (ruleIds.length === 0) + return { + isInUse: false, + description: "Escalations already running finish on their own copy. This cannot be undone.", + ruleList: [], + } + return { + isInUse: true, + description: "These rules would stop escalating. Remove the chain from their severity tiers first.", + ruleList: [ + h.ul( + [h.Class("list-disc space-y-1 pl-5")], + [ + ...Arr.map(names, (ruleName) => + h.li([h.Key(ruleName)], [typography(h, { size: "sm" }, [ruleName])]), + ), + ...(ruleIds.length > names.length + ? [ + h.li( + [], + [ + typography(h, { size: "sm", tone: "muted" }, [ + names.length === 0 + ? `${ruleIds.length} ${pluralize(ruleIds.length, "rule")}` + : `and ${ruleIds.length - names.length} more`, + ]), + ], + ), + ] + : []), + ], + ), + ], + } +} From d04fb31f41c94e4a3fa6183852264ff730435255 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:08:24 +0100 Subject: [PATCH 57/69] fix(alerts): a chain id that matches nothing is titled Escalation chain, not New escalation chain --- .../escalation-chain.scene.test.ts | 23 +++++++++++++------ .../src/page/alerts/escalation-chain/view.ts | 6 ++--- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts index 71674951eb..91441ef15a 100644 --- a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -36,7 +36,7 @@ import { payloadOf } from "./form" import { Message } from "./message" import type { Model } from "./model" import { init, insertButtonId, isBlockingNavigation, menuIdOf, update } from "./update" -import { view as pageView } from "./view" +import { chainTitle, view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -423,11 +423,20 @@ describe("escalation chain editor", () => { ) }) - test("a link with a malformed chain id says the chain was not found", () => { - scene( - { update: pageUpdate, view }, - given(entered({ _tag: "AlertsEscalationChain", chainId: "nope", search: {} })), - expectView(text("Escalation chain not found")).toExist(), - ) + test("a link with a malformed chain id says the chain was not found, under the heading a deleted one gets", () => { + Arr.forEach(["nope", "esc_notAChainId"], (chainId) => { + const bad = entered({ _tag: "AlertsEscalationChain", chainId, search: {} }) + scene( + { update: pageUpdate, view }, + given(bad), + expectView(text("Escalation chain not found")).toExist(), + expectView(role("heading", { name: "Escalation chain" })).toExist(), + expectView(role("heading", { name: "New escalation chain" })).not.toExist(), + ) + // The breadcrumb's last entry is the same title. + expect(chainTitle(bad)).toBe("Escalation chain") + }) + // The new-chain page keeps its own name. + expect(chainTitle(newChain())).toBe("New escalation chain") }) }) diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index 91a1b0da94..153f25a0cd 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -57,9 +57,9 @@ import { backHref, DELETE_DIALOG_ID, insertButtonId, LEAVE_DIALOG_ID, menuIdOf } type H = HtmlBuilder -/** The page's name: the chain as last read, or "New escalation chain". */ -export const chainTitle = (model: Pick): string => - model.editingId === null +/** The page's name: the chain as last read, or "New escalation chain". A URL id that is no chain's is not a new one. */ +export const chainTitle = (model: Pick): string => + model.editingId === null && !model.isBadId ? "New escalation chain" : Option.getOrElse( Option.map(AsyncData.getData(model.existing.data), (chain) => chain.name), From 07724a10a378aa873986df4d1c0612260668c17c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:08:45 +0100 Subject: [PATCH 58/69] fix(query-builder): a doubled operator or missing value stays in Text The grammar reads `service.name ==` as the value "=", so the builder showed the row "Service is =". A bare value that is the rest of a mistyped operator now keeps the clause in Text with the syntax reason. --- .../query-builder/condition-builder.scene.test.ts | 14 ++++++++++++++ .../page/kit/query-builder/condition-rows.test.ts | 11 +++++++++++ .../src/page/kit/query-builder/condition-rows.ts | 11 +++++++++++ 3 files changed, 36 insertions(+) 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 index cc79bff157..4fb03880bf 100644 --- 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 @@ -205,6 +205,20 @@ describe("condition builder", () => { ) }) + 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 }, 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 index c9c13ed7e8..4a6321292a 100644 --- a/apps/web/src/page/kit/query-builder/condition-rows.test.ts +++ b/apps/web/src/page/kit/query-builder/condition-rows.test.ts @@ -212,6 +212,17 @@ describe("unshowableReasonOf", () => { ['(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) diff --git a/apps/web/src/page/kit/query-builder/condition-rows.ts b/apps/web/src/page/kit/query-builder/condition-rows.ts index 83fb4fcf41..e6e8dfe533 100644 --- a/apps/web/src/page/kit/query-builder/condition-rows.ts +++ b/apps/web/src/page/kit/query-builder/condition-rows.ts @@ -52,6 +52,15 @@ export type UnshowableReason = "mixedFields" | "orOperators" | "orNotApplied" | 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`). @@ -59,6 +68,8 @@ type Part = Result.Result 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)) From af1108a671674fcbe76ffd6145725544f1639a99 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:09:40 +0100 Subject: [PATCH 59/69] fix(alerts): removing a chain target keeps focus in the step --- .../escalation-chain.scene.test.ts | 39 ++++++++++++++++++- .../page/alerts/escalation-chain/update.ts | 36 ++++++++++++++++- .../src/page/alerts/escalation-chain/view.ts | 13 ++++++- apps/web/src/page/kit/pick-chips.ts | 8 +++- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts index 91441ef15a..99fb456d4c 100644 --- a/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts +++ b/apps/web/src/page/alerts/escalation-chain/escalation-chain.scene.test.ts @@ -35,7 +35,7 @@ import { FocusChainElement } from "./command" import { payloadOf } from "./form" import { Message } from "./message" import type { Model } from "./model" -import { init, insertButtonId, isBlockingNavigation, menuIdOf, update } from "./update" +import { init, insertButtonId, isBlockingNavigation, menuIdOf, removeTargetButtonId, update } from "./update" import { chainTitle, view as pageView } from "./view" const orgId = Schema.decodeSync(OrgId)(ORG) @@ -187,6 +187,43 @@ describe("escalation chain editor", () => { ) }) + test("removing a target moves focus to the chip that takes its place, or to the step's Add button", () => { + const slack = `destination:${chainDestinationUuid(1)}` + const pagerDuty = `destination:${chainDestinationUuid(2)}` + const both = run(newChain(), [pick("s0", slack), pick("s0", pagerDuty)]) + const focused = (model: Model, target: string) => + pageUpdate(model, Message.ClickedRemoveTarget({ key: "s0", target })).commands + // The first of two: the one after it, which now leads the step. + expect(focused(both, slack)).toMatchObject([ + { name: "FocusEscalationChainElement", args: { id: removeTargetButtonId("s0", pagerDuty) } }, + ]) + // The last one, and the only one: the Add button that follows the chips. + const add = { + name: "FocusEscalationChainElement", + args: { id: triggerId(menuIdOf("targets", "s0")) }, + } + expect(focused(both, pagerDuty)).toMatchObject([add]) + const one = pageUpdate(both, Message.ClickedRemoveTarget({ key: "s0", target: slack })).model + expect(focused(one, pagerDuty)).toMatchObject([add]) + // The chip the focus is sent to is on the page under that id. + scene( + { update: pageUpdate, view }, + given(both), + click(role("button", { name: "Stop notifying Slack #alerts-api on step 1" })), + Command.resolve(FocusChainElement, Message.CompletedFocus()), + expectView(role("button", { name: "Stop notifying Slack #alerts-api on step 1" })).not.toExist(), + expectView(role("button", { name: /^Stop notifying .+ on step 1$/u })).toHaveAttr( + "id", + removeTargetButtonId("s0", pagerDuty), + ), + ) + // A target that is not on the step, and a viewer who cannot edit: nothing moves. + expect(focused(both, "destination:missing") ?? []).toEqual([]) + expect( + focused(run(both, [Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER })]), slack) ?? [], + ).toEqual([]) + }) + test("saving with missing fields shows each problem and sends nothing", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/escalation-chain/update.ts b/apps/web/src/page/alerts/escalation-chain/update.ts index 4c92b77107..2f2ca2143a 100644 --- a/apps/web/src/page/alerts/escalation-chain/update.ts +++ b/apps/web/src/page/alerts/escalation-chain/update.ts @@ -35,6 +35,7 @@ import { payloadOf, removeStep, removeTarget, + targetKey, targetOfKey, } from "./form" import { Message, type MenuKind } from "./message" @@ -54,6 +55,10 @@ export const menuIdOf = (kind: MenuKind, key: string): string => `escalation-cha /** The + on the connector above the step at `index` (or below the last one). */ export const insertButtonId = (index: number): string => `escalation-chain-insert-${index}` +/** The remove button of one target chip on a step. */ +export const removeTargetButtonId = (key: string, target: string): string => + `escalation-chain-remove-${key}-${target}` + /** Where Cancel, Save and Delete go back to. */ export const backHref = (): string => routingTabHref() @@ -191,6 +196,35 @@ const removed = (model: Model, key: string): Return => { } } +/** A removed target hands focus to the chip that takes its place, or to the step's Add button after the last. */ +const removedTarget = (model: Model, key: string, target: string): Return => { + if (!canEdit(model)) return { model } + const targets = Option.match( + Arr.findFirst(model.form.steps, (step) => step.key === key), + { + onNone: (): ReadonlyArray => [], + onSome: (step) => Arr.map(step.targets, targetKey), + }, + ) + return Option.match( + Arr.findFirstIndex(targets, (existing) => existing === target), + { + onNone: () => ({ model }), + onSome: (index) => { + const next = targets[index + 1] + return { + model: { ...model, form: removeTarget(model.form, key, target) }, + commands: focus( + next === undefined + ? triggerId(menuIdOf("targets", key)) + : removeTargetButtonId(key, next), + ), + } + }, + }, + ) +} + const stepAction = (model: Model, key: string, value: string): Return => { switch (value) { case "up": @@ -308,7 +342,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedInsertStep: ({ index }) => canEdit(model) ? added(model, insertStep(model.form, index)) : { model }, GotMenuMessage: ({ kind, key, message: child }) => stepMenu(model, kind, key, child), - ClickedRemoveTarget: ({ key, target }) => withForm(model, (form) => removeTarget(form, key, target)), + ClickedRemoveTarget: ({ key, target }) => removedTarget(model, key, target), ClickedSave: () => save(model), CompletedSave: ({ result }) => Result.match(result, { diff --git a/apps/web/src/page/alerts/escalation-chain/view.ts b/apps/web/src/page/alerts/escalation-chain/view.ts index 153f25a0cd..f6f7365310 100644 --- a/apps/web/src/page/alerts/escalation-chain/view.ts +++ b/apps/web/src/page/alerts/escalation-chain/view.ts @@ -51,7 +51,14 @@ import { } from "./form" import { Message } from "./message" import { canManage, type Model } from "./model" -import { backHref, DELETE_DIALOG_ID, insertButtonId, LEAVE_DIALOG_ID, menuIdOf } from "./update" +import { + backHref, + DELETE_DIALOG_ID, + insertButtonId, + LEAVE_DIALOG_ID, + menuIdOf, + removeTargetButtonId, +} from "./update" // The escalation chain editor: the chain as a column of sentence cards, its name and preview beside it. @@ -153,6 +160,7 @@ const targetChip = ( target: V2EscalationTarget, ): PickedChip => { const onRemove = Message.ClickedRemoveTarget({ key, target: targetKey(target) }) + const removeId = removeTargetButtonId(key, targetKey(target)) // The step is named: the same target may sit on several steps. const onStep = `on step ${index + 1}` switch (target.type) { @@ -163,6 +171,7 @@ const targetChip = ( label: name, media: destinationMedia(h, model, target.destination_id), removeLabel: `Stop notifying ${name} ${onStep}`, + removeId, onRemove, } } @@ -179,6 +188,7 @@ const targetChip = ( ), }), removeLabel: `Stop paging ${name} ${onStep}`, + removeId, onRemove, } } @@ -191,6 +201,7 @@ const targetChip = ( media: icon(h, "CalendarIcon", { size: 14, className: "shrink-0 text-muted-foreground" }), ...(detail === undefined ? undefined : { detail }), removeLabel: `Stop paging ${name} ${onStep}`, + removeId, onRemove, } } diff --git a/apps/web/src/page/kit/pick-chips.ts b/apps/web/src/page/kit/pick-chips.ts index 6cd33e14b4..6bf72b6447 100644 --- a/apps/web/src/page/kit/pick-chips.ts +++ b/apps/web/src/page/kit/pick-chips.ts @@ -17,6 +17,8 @@ export type PickedChip = Readonly<{ /** A muted note after the name ("Ben Brook on call"). */ detail?: string removeLabel: string + /** The remove button's id, for a form that moves focus to it when the chip before it goes. */ + removeId?: string onRemove: M }> @@ -50,7 +52,11 @@ const chip = (h: HtmlBuilder, entry: PickedChip, canEdit: boolean, isDi variant: "ghost", size: "icon-2xs", isDisabled, - attributes: [h.AriaLabel(entry.removeLabel), h.OnClick(entry.onRemove)], + attributes: [ + ...(entry.removeId === undefined ? [] : [h.Id(entry.removeId)]), + h.AriaLabel(entry.removeLabel), + h.OnClick(entry.onRemove), + ], }, [icon(h, "XmarkIcon")], ), From 4fab01963db08a3a951f1d926a3cc569f6e241b1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:11:24 +0100 Subject: [PATCH 60/69] fix(alerts): a blank threshold is missing, not zero, in the rule preview Number("") is 0, so clearing the threshold previewed the rule against 0 and drew "Breach above 0%". A blank threshold now sends no preview and the chart asks for a number. --- .../alerts/editor/editor-cards.scene.test.ts | 82 +++++++++++++++++-- apps/web/src/page/alerts/editor/view-hero.ts | 14 +++- apps/web/src/page/alerts/shared/preview.ts | 5 +- 3 files changed, 90 insertions(+), 11 deletions(-) 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 index 116b4d28e3..a24925b4d9 100644 --- a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -19,6 +19,8 @@ import { 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" @@ -76,6 +78,41 @@ const slowPreview = () => }, }) +/** 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" }) @@ -135,17 +172,48 @@ describe("alert rule editor, sentence cards", () => { ) }) - test("with a threshold that is not a number, the empty chart asks for one, not for a signal", () => { + // 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( - run(opened({ template: "slow_p95" }), [ - Message.ChangedField({ field: "threshold", value: "-" }), - ]), - ), + 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(/^Pick a signal/u)).not.toExist(), + expectView(text(/^Breach above/u)).not.toExist(), + expectView(text("Would have fired")).not.toExist(), ) }) diff --git a/apps/web/src/page/alerts/editor/view-hero.ts b/apps/web/src/page/alerts/editor/view-hero.ts index ea20ae1c9e..39e85fb10f 100644 --- a/apps/web/src/page/alerts/editor/view-hero.ts +++ b/apps/web/src/page/alerts/editor/view-hero.ts @@ -3,7 +3,12 @@ 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, @@ -155,6 +160,11 @@ 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< Model, @@ -191,7 +201,7 @@ const chartBody = (h: H, model: ChartView, form: RuleForm, timeZone: string): Ht loadingText: `Loading the ${range}…`, emptyText: isIdle ? { - title: Number.isFinite(Number(form.threshold)) + title: hasThreshold(form) ? `Pick a signal to preview it against the ${range}` : `Enter a number for the threshold to preview it against the ${range}`, } 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 From b6039bcb307d4f6eb33fed2718b6be6bae31e627 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:12:10 +0100 Subject: [PATCH 61/69] fix(alerts): the empty rule name shows the error its input is described by The invalid input pointed aria-describedby at rule-name-error, which nothing rendered. --- .../web/src/page/alerts/editor/editor-cards.scene.test.ts | 8 ++++++++ apps/web/src/page/alerts/editor/view-details.ts | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) 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 index a24925b4d9..066d233198 100644 --- a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -301,6 +301,8 @@ describe("alert rule editor, sentence cards", () => { 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( @@ -309,6 +311,12 @@ describe("alert rule editor, sentence cards", () => { 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" })), diff --git a/apps/web/src/page/alerts/editor/view-details.ts b/apps/web/src/page/alerts/editor/view-details.ts index 006e4f6942..acb6883f18 100644 --- a/apps/web/src/page/alerts/editor/view-details.ts +++ b/apps/web/src/page/alerts/editor/view-details.ts @@ -4,7 +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 { 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" @@ -124,6 +124,8 @@ const nameField = (h: H, model: DetailsView, form: RuleForm, isNameInvalid: bool 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.")] : []), ]) } From 91061a3fe06921b7b38fcd5b77b1cbc2e8b8271c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:13:57 +0100 Subject: [PATCH 62/69] fix(alerts): opening an empty add-on section is not an unsaved change The rule diff compared the builder draft's JSON, add-on flags included, so opening Group by on a saved rule read as "1 unsaved change". It now compares the draft as the query reads it: an add-on left empty counts as closed. --- .../alerts/editor/editor-cards.scene.test.ts | 40 ++++++++++++++++++- .../src/alerts/preview-insights.test.ts | 26 ++++++++++++ .../web-data/src/alerts/preview-insights.ts | 22 +++++++++- 3 files changed, 86 insertions(+), 2 deletions(-) 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 index 066d233198..71c76d3a01 100644 --- a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -27,7 +27,7 @@ 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 { FocusRuleCard } from "./command" +import { FetchEditorPreview, FocusRuleCard } from "./command" import { BREACH_MODE_SELECT_ID } from "./form-steps" import { init } from "./init" import { Message } from "./message" @@ -217,6 +217,44 @@ describe("alert rule editor, sentence cards", () => { ) }) + 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 }, diff --git a/packages/web-data/src/alerts/preview-insights.test.ts b/packages/web-data/src/alerts/preview-insights.test.ts index 980c2148e5..888d2752bd 100644 --- a/packages/web-data/src/alerts/preview-insights.test.ts +++ b/packages/web-data/src/alerts/preview-insights.test.ts @@ -177,6 +177,32 @@ describe("diffRuleForms", () => { ]) }) + it("opening an add-on section and leaving it empty is not a change; filling it in is", () => { + const base = defaultRuleForm() + const builder = (addOns: Partial, groupBy: string[]) => ({ + ...base, + name: "Checkout errors", + signalType: "builder_query" as const, + queryBuilderDraft: { + ...base.queryBuilderDraft, + addOns: { ...base.queryBuilderDraft.addOns, groupBy: false, ...addOns }, + groupBy, + }, + }) + const saved = builder({}, []) + expect(diffRuleForms(saved, builder({ groupBy: true, having: true, limit: true }, []))).toEqual([]) + expect( + Arr.map(diffRuleForms(saved, builder({ groupBy: true }, ["service.name"])), (row) => row.field), + ).toContain("Query") + // Closing a section that holds a value drops it from the query. + expect( + Arr.map( + diffRuleForms(builder({ groupBy: true }, ["service.name"]), builder({}, ["service.name"])), + (row) => row.field, + ), + ).toContain("Query") + }) + it("finds nothing when the draft equals the saved rule", () => { const saved = { ...defaultRuleForm(), name: "Checkout errors" } expect(diffRuleForms(saved, { ...saved })).toEqual([]) diff --git a/packages/web-data/src/alerts/preview-insights.ts b/packages/web-data/src/alerts/preview-insights.ts index c4dcbae5b0..d96b3a6b1d 100644 --- a/packages/web-data/src/alerts/preview-insights.ts +++ b/packages/web-data/src/alerts/preview-insights.ts @@ -142,6 +142,26 @@ export function incidentsClearedByDraft( ) } +/** + * The builder draft as the query reads it: an add-on section opened but left empty (Group by with + * no keys) is the same query as with it closed, so opening one is not an edit. + */ +const appliedQueryDraft = (form: RuleFormState): unknown => { + if (form.signalType !== "builder_query") return null + const draft = form.queryBuilderDraft + const isFilled = (value: string) => Str.trim(value).length > 0 + return { + ...draft, + addOns: { + groupBy: draft.addOns.groupBy && draft.groupBy.length > 0, + having: draft.addOns.having && isFilled(draft.having), + orderBy: draft.addOns.orderBy && isFilled(draft.orderBy), + limit: draft.addOns.limit && isFilled(draft.limit), + legend: draft.addOns.legend && isFilled(draft.legend), + }, + } +} + export interface RuleFieldChange { readonly field: string readonly before: string @@ -175,7 +195,7 @@ export function diffRuleForms(saved: RuleFormState, draft: RuleFormState): RuleF after: query(draft), same: a.signal_type === b.signal_type && - sameJson(a.query_builder_draft, b.query_builder_draft) && + sameJson(appliedQueryDraft(saved), appliedQueryDraft(draft)) && a.raw_query_sql === b.raw_query_sql, }, { From f3c1222195a9e02c7d0fe926bb517b13fcf879e6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:14:18 +0100 Subject: [PATCH 63/69] fix(alerts): name the applied query draft's type in the rule diff --- packages/web-data/src/alerts/preview-insights.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-data/src/alerts/preview-insights.ts b/packages/web-data/src/alerts/preview-insights.ts index d96b3a6b1d..1652984e56 100644 --- a/packages/web-data/src/alerts/preview-insights.ts +++ b/packages/web-data/src/alerts/preview-insights.ts @@ -146,7 +146,7 @@ export function incidentsClearedByDraft( * The builder draft as the query reads it: an add-on section opened but left empty (Group by with * no keys) is the same query as with it closed, so opening one is not an edit. */ -const appliedQueryDraft = (form: RuleFormState): unknown => { +const appliedQueryDraft = (form: RuleFormState): RuleFormState["queryBuilderDraft"] | null => { if (form.signalType !== "builder_query") return null const draft = form.queryBuilderDraft const isFilled = (value: string) => Str.trim(value).length > 0 From 9a1429a63fd8249e7eff2c4e850fe9610be454ca Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:14:22 +0100 Subject: [PATCH 64/69] fix(alerts): long schedule names truncate in the shifts heading and wrap in the delete confirm --- .../page/alerts/hub/hub-oncall.scene.test.ts | 32 +++++++++++++++++-- apps/web/src/page/alerts/hub/view-oncall.ts | 3 +- .../alerts/schedule/schedule.scene.test.ts | 24 +++++++++++++- .../page/alerts/shared/coverage-preview.ts | 15 +++++++-- 4 files changed, 68 insertions(+), 6 deletions(-) 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 37f8bc9d73..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 @@ -3,9 +3,11 @@ 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" @@ -220,6 +222,32 @@ 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("on-call", ENGINEER, SCHEDULES) const scheduleId = Schema.decodeSync(OnCallScheduleId)(scheduleUuid(1)) diff --git a/apps/web/src/page/alerts/hub/view-oncall.ts b/apps/web/src/page/alerts/hub/view-oncall.ts index 13c16cc1b9..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 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 9507a948a6..b9b58b1ffe 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" @@ -102,6 +102,28 @@ 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( diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts index 183b6c5263..138685b8ff 100644 --- a/apps/web/src/page/alerts/shared/coverage-preview.ts +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -326,8 +326,19 @@ export const coveragePreview = (h: HtmlBuilder, options: CoveragePreviewOp [h.Class("@container min-w-0 space-y-3"), h.DataAttribute("slot", "coverage-preview")], [ hstack(h, { gap: "2", justify: "between", align: "start", wrap: true }, [ - vstack(h, { gap: "0" }, [ - heading(h, { level: 2, variant: "section" }, [options.title]), + // 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), ]), From a8edc84ec4f4b59b6b375565ba37c5619560ce78 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:15:29 +0100 Subject: [PATCH 65/69] fix(alerts): coverage summary names the period it counts, not the next N days --- .../schedule-form/preview.scene.test.ts | 4 ++-- .../schedule-form/schedule-form.scene.test.ts | 24 ++++++++----------- .../alerts/schedule/schedule.scene.test.ts | 2 +- .../page/alerts/shared/coverage-preview.ts | 2 -- .../alerts/shared/schedule-coverage.test.ts | 24 +++++++++++-------- .../page/alerts/shared/schedule-coverage.ts | 19 ++++++--------- 6 files changed, 34 insertions(+), 41 deletions(-) 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 index df5ffd7c15..e277485c83 100644 --- a/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts +++ b/apps/web/src/page/alerts/schedule-form/preview.scene.test.ts @@ -56,13 +56,13 @@ test("typing the name keeps the drawn preview; a new range draws it again", () = scene( { update: pageUpdate, view }, given(staffed()), - expectView(text("Covered for the next 14 days")).toExist(), + 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 for the next 28 days")).toExist(), + expectView(text("Covered in this period")).toExist(), keep, ) expect(drawn[0]).not.toBeNull() 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 d311bdc489..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 @@ -329,12 +329,12 @@ describe("schedule form", () => { }) describe("coverage preview", () => { - test("a layer with nobody in it leaves the next 14 days uncovered and says why", () => { + 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 for the next 14 days")).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(), @@ -347,7 +347,7 @@ describe("schedule form", () => { scene( { update: pageUpdate, view }, given(staffed), - expectView(text("Covered for the next 14 days")).toExist(), + 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(), @@ -358,9 +358,7 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(nights), expectView( - text( - "10 gaps in the next 14 days, longest 2d 10h (Sat 14 Nov 08:00 to Mon 16 Nov 18:00)", - ), + 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(), ) @@ -380,8 +378,8 @@ describe("schedule form", () => { { update: pageUpdate, view }, given(noDays), expectView(text("Fix this layer to see shifts")).toExist(), - expectView(text("Nobody is on call for the next 14 days")).toExist(), - expectView(text("Covered for the next 14 days")).not.toExist(), + expectView(text("Nobody is on call in this period")).toExist(), + expectView(text("Covered in this period")).not.toExist(), ) }) @@ -431,7 +429,7 @@ describe("schedule form", () => { scene( { update: pageUpdate, view }, given(staffed), - expectView(text("Covered for the next 7 days")).toExist(), + expectView(text("Covered in this period")).toExist(), expectView(text("10 Nov to 16 Nov")).toExist(), ) const later = run(staffed, [Message.ClickedPreviewNext()]) @@ -460,7 +458,7 @@ describe("schedule form", () => { scene( { update: pageUpdate, view }, given(run(ready(), [addPerson("l0", 0)])), - expectView(text("Covered for the next 14 days")).toExist(), + expectView(text("Covered in this period")).toExist(), ) }) @@ -470,7 +468,7 @@ describe("schedule form", () => { given(weeknightsOnly(ready({ scheduleId: schedulePublicId(1) }))), expectView(text("Coverage preview")).toExist(), expectView(role("rowheader", { name: /Final schedule/u })).not.toExist(), - expectView(text(/gaps? in the next/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(), ) @@ -496,9 +494,7 @@ describe("schedule form", () => { 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 the next 14 days, longest 2d 10h (Sat 21 Nov 08:00 to Mon 23 Nov 18:00)", - ), + 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(), 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 b9b58b1ffe..575b910021 100644 --- a/apps/web/src/page/alerts/schedule/schedule.scene.test.ts +++ b/apps/web/src/page/alerts/schedule/schedule.scene.test.ts @@ -482,7 +482,7 @@ describe("on-call schedule page", () => { scene( { update: pageUpdate, view }, given(model), - expectView(text("Covered for the next 14 days")).toExist(), + expectView(text("Covered in this period")).toExist(), expectView(role("group", { name: "Range" })).toExist(), ) const month = pageUpdate(model, Message.PickedRange({ days: "28" })) diff --git a/apps/web/src/page/alerts/shared/coverage-preview.ts b/apps/web/src/page/alerts/shared/coverage-preview.ts index 138685b8ff..de9569b886 100644 --- a/apps/web/src/page/alerts/shared/coverage-preview.ts +++ b/apps/web/src/page/alerts/shared/coverage-preview.ts @@ -212,8 +212,6 @@ const gapLinks = (h: HtmlBuilder, options: CoveragePreviewOptions): Htm const gapSummaryView = (h: HtmlBuilder, options: CoveragePreviewOptions): Html => { const scope = { timeZone: options.timeZone, - weekOffset: options.weekOffset, - days: Number(options.rangeDays), checkedFromMs: Math.max(options.fromMs, options.nowMs), checkedToMs: options.toMs, } diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts index b459ff3bee..3a3821353e 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.test.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.test.ts @@ -222,34 +222,38 @@ describe("previewRange", () => { describe("gapSummary", () => { const scope = { timeZone: "Europe/Berlin", - weekOffset: 0, - days: 14, 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 for the next 14 days") - expect(gapSummary([], { ...scope, weekOffset: 2 })).toBe("Covered in this period") + 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 the next 14 days, longest 16h (Sat 21 Nov 00:00 to 16:00)", - ) + 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 for the next 14 days") - expect(gapSummary(whole, { ...scope, weekOffset: 1 })).toBe("Nobody is on call in this period") + 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 the next 14 days, longest 2d 16h or longer (from Sat 21 Nov 08:00)", + "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") diff --git a/apps/web/src/page/alerts/shared/schedule-coverage.ts b/apps/web/src/page/alerts/shared/schedule-coverage.ts index f4c206818a..6788ffa3df 100644 --- a/apps/web/src/page/alerts/shared/schedule-coverage.ts +++ b/apps/web/src/page/alerts/shared/schedule-coverage.ts @@ -224,11 +224,9 @@ export const longestGap = (gaps: ReadonlyArray): Option.Option, scope: GapScope): string => { - const isFromToday = scope.weekOffset === 0 - if (isUncovered(gaps, scope)) - return isFromToday - ? `Nobody is on call for the next ${scope.days} days` - : "Nobody is on call in this period" + if (isUncovered(gaps, scope)) return "Nobody is on call in this period" return Option.match(longestGap(gaps), { - onNone: () => (isFromToday ? `Covered for the next ${scope.days} days` : "Covered in this period"), + onNone: () => "Covered in this period", onSome: (longest) => - `${gaps.length} ${gaps.length === 1 ? "gap" : "gaps"} ${isFromToday ? `in the next ${scope.days} days` : "in this period"}, longest ${durationText(longest.endMs - longest.startMs)}${isOpenGap(longest, scope.checkedToMs) ? " or longer" : ""} (${gapRangeText(longest, scope.timeZone, scope.checkedToMs)})`, + `${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)})`, }) } From b0d1169d176100a81a5daa9651446f126e338c60 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:16:25 +0100 Subject: [PATCH 66/69] fix(alerts): the preview verdict wraps under the chart title on a phone It was hidden below sm; from sm the header is unchanged. --- apps/web/src/page/alerts/editor/view-hero.ts | 54 ++++++++++---------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/apps/web/src/page/alerts/editor/view-hero.ts b/apps/web/src/page/alerts/editor/view-hero.ts index 39e85fb10f..ebf98d9cce 100644 --- a/apps/web/src/page/alerts/editor/view-hero.ts +++ b/apps/web/src/page/alerts/editor/view-hero.ts @@ -42,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), ]), - ], - ), + ]), + ]), ] } @@ -115,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), ]), From fc1ac22c51b7a210d4827f5b88e486db3d4365e6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:17:46 +0100 Subject: [PATCH 67/69] test(e2e): a saved rule returns to the Rules section --- apps/web/e2e/tests/alert-rules.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/tests/alert-rules.e2e.ts b/apps/web/e2e/tests/alert-rules.e2e.ts index feeef87386..4c8a829eba 100644 --- a/apps/web/e2e/tests/alert-rules.e2e.ts +++ b/apps/web/e2e/tests/alert-rules.e2e.ts @@ -87,7 +87,7 @@ test.describe("alert rules", () => { await screen.getByLabel("Rule name").fill(`e2e tiers renamed ${tag}`) await screen.getByRole("button", "Save changes").tap() - await expect(browser).toHaveURL(/\/alerts$/) + await expect(browser).toHaveURL(/\/alerts\/rules$/) const renamed = await read() expect(renamed.body.name).toBe(`e2e tiers renamed ${tag}`) expect(renamed.body.thresholds).toEqual(tiers) @@ -96,7 +96,7 @@ test.describe("alert rules", () => { await app.open(editUrl) await screen.getByLabel("Critical threshold").fill("4") await screen.getByRole("button", "Save changes").tap() - await expect(browser).toHaveURL(/\/alerts$/) + await expect(browser).toHaveURL(/\/alerts\/rules$/) const retuned = await read() expect(Arr.map(retuned.body.thresholds, (tier) => tier.value)).toEqual([0.007, 0.04]) } finally { From a4f05c413ca1b05ef4ba8b31bde6346d8a068a71 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:17:46 +0100 Subject: [PATCH 68/69] test(e2e): alert rule tests follow the sentence editor's closed cards and labels --- apps/web/e2e/tests/alert-rules.e2e.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/e2e/tests/alert-rules.e2e.ts b/apps/web/e2e/tests/alert-rules.e2e.ts index feeef87386..b4de2c88d1 100644 --- a/apps/web/e2e/tests/alert-rules.e2e.ts +++ b/apps/web/e2e/tests/alert-rules.e2e.ts @@ -81,10 +81,13 @@ 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$/) @@ -94,6 +97,7 @@ test.describe("alert rules", () => { 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$/) @@ -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 }) => { From c4bc214bdd1127b5b7a146263af637406caad7ec Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 10 Nov 2026 14:20:07 +0100 Subject: [PATCH 69/69] fix(alerts): inbox filters read hasAnomalies after the first-run predicate moved --- apps/web/src/page/alerts/hub/view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index a6976d467a..355522c723 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -290,7 +290,7 @@ const hubBody = ( signal, status: resolveIncidentStatus(model.search.anomalyStatus), serviceName: model.search.serviceName, - hasAnomalies, + hasAnomalies: Arr.some(data.incidents, (incident) => incident.signalType === "anomaly"), // Counted under the same service filter the list applies. openAnomalies: Arr.filter( data.openIncidents,