diff --git a/apps/web/e2e/tests/alert-rules.e2e.ts b/apps/web/e2e/tests/alert-rules.e2e.ts index 4c8a829eba..0524ea4ea1 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\/rules$/) @@ -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\/rules$/) @@ -117,10 +121,12 @@ test.describe("alert rules", () => { // The template picker opens first; Cancel closes it on the default rule. await screen.getByRole("button", "Cancel").tap() await screen.getByLabel("Rule name").fill(`e2e range ${runTag()}`) - await screen.getByRole("combobox", "Alert when the value").tap() + await screen.getByRole("combobox", "Condition").tap() await screen.getByRole("option", "is between").tap() - await expect(screen.getByText(/^Fix before saving:/)).toContainText("Upper threshold") + // The action bar lists what blocks the save, each a link to its field. + await expect(screen.getByText("Fix before saving:")).toBeVisible() + await expect(screen.getByRole("button", "Upper threshold")).toBeVisible() }) test("previews a SQL rule", { session: "reviewer" }, async ({ app, screen }) => { diff --git a/apps/web/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 53d78d9dcf..743657b211 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -459,6 +459,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..5050a7f106 --- /dev/null +++ b/apps/web/src/lab/rule-editor-lab.ts @@ -0,0 +1,230 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { AlertRulePreviewResponse } from "@maple/domain/http" +import { Clock, Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import type { PageContext } from "../page/contract" +import { chainDestinations, chainUuid, populatedChains } from "../page/alerts/shared/escalation-test-fixtures" +import { scopedAnomalyRuleRow } from "../page/alerts/shared/anomaly-test-fixtures" +import { DESTINATION_ID, destinationRow, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows" +import { windowMsOf } from "../page/alerts/editor/derive-rows" +import { RuleCard } from "../page/alerts/editor/form" +import { init as editorInit } from "../page/alerts/editor/init" +import { Message as EditorMessage } from "../page/alerts/editor/message" +import { Model as EditorModel } from "../page/alerts/editor/model" +import { update as editorUpdate } from "../page/alerts/editor/update" +import { view as editorView } from "../page/alerts/editor/view" +import type { Resources } from "../platform/resources" + +// `/lab/rule-editor`: the alert rule editor over fixture rows, no API needed. `?variant=` picks a +// saved rule (`populated`), a blank new rule (`new`) or a scoped anomaly rule (`anomaly`); +// `?open=condition,notify` opens cards. + +export const Variant = Schema.Literals(["populated", "new", "anomaly"]) +export type Variant = typeof Variant.Type + +export const Flags = Schema.Struct({ nowMs: Schema.Number, variant: Variant, open: Schema.Array(RuleCard) }) +export type Flags = typeof Flags.Type + +const decodeVariant = Schema.decodeUnknownOption(Variant) +const decodeCard = Schema.decodeUnknownOption(RuleCard) +const searchParams = Effect.sync(() => new URLSearchParams(window.location.search)) + +/** + * The boot clock, `?variant=` and `?open=` (the cards opened for editing, comma-separated; a saved + * rule opens on its summaries). `?theme=light|dark` sets the theme class as `index.html` does. + */ +export const flags: Effect.Effect = Effect.all({ + nowMs: Clock.currentTimeMillis, + variant: Effect.sync(() => { + const params = new URLSearchParams(window.location.search) + const theme = params.get("theme") + if (theme === "light" || theme === "dark") { + document.documentElement.classList.remove("light", "dark") + document.documentElement.classList.add(theme) + document.documentElement.style.colorScheme = theme + } + return Option.getOrElse(decodeVariant(params.get("variant")), (): Variant => "populated") + }), + open: Effect.map(searchParams, (params) => + Arr.flatMap(Str.split(params.get("open") ?? "", ","), (card) => Option.toArray(decodeCard(card))), + ), +}) + +export const Model = Schema.Struct({ variant: Variant, editor: EditorModel }) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ GotEditorMessage: { message: EditorMessage } }) +export type Message = typeof Message.Type + +const LAB_CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)(ORG), + pinnedNamespace: null, + timeZone: "UTC", +} + +const run = (model: EditorModel, messages: ReadonlyArray): EditorModel => + Arr.reduce(messages, model, (current, message) => editorUpdate(current, message, LAB_CONTEXT).model) + +const DRAFT_ID = "6f1c2e3a-0000-4000-8000-000000000001" +const MIN = 60_000 + +const CHAT_DESTINATION = destinationRow({ + name: "Checkout on-call", + type: "chat", + config_json: { summary: "Slack · acme", channelLabel: "#checkout-oncall" }, +}) + +/** A saved error rate rule on checkout-api, two tiers, the critical one starting a chain. */ +const ERROR_RATE_RULE = ruleRow(2, { + name: "Checkout error rate", + notes: "Pages the checkout team. Runbook: wiki/checkout/errors", + signal_type: "error_rate", + service_names_json: ["checkout-api"], + environments_json: ["production"], + tags_json: ["checkout", "tier-1"], + severity: "warning", + threshold: 0.05, + thresholds_json: [ + { + severity: "warning", + value: 0.05, + upper: null, + recoveryValue: null, + destinationIds: [DESTINATION_ID], + }, + { + severity: "critical", + value: 0.1, + upper: null, + recoveryValue: null, + destinationIds: [], + escalationChainId: chainUuid(1), + }, + ], + consecutive_breaches_required: 3, +}) + +const decodePreview = Schema.decodeUnknownSync(AlertRulePreviewResponse) + +/** The preview window's error rate around 2%, with two bursts over the 5% line. */ +const previewOf = (window: Readonly<{ min: number; max: number }>): AlertRulePreviewResponse => { + const bucketMs = 30 * MIN + const count = Math.max(1, Math.floor((window.max - window.min) / bucketMs)) + const at = (share: number) => Math.round(count * share) + const bursts = [ + { from: at(0.36), to: at(0.38) }, + { from: at(0.75), to: at(0.76) }, + ] + const points = Arr.map(Arr.range(0, count - 1), (i) => { + const isBurst = Arr.some(bursts, (burst) => i >= burst.from && i < burst.to) + const value = isBurst ? 0.072 + Math.sin(i) * 0.008 : 0.02 + Math.sin(i * 0.7) * 0.006 + const point = { bucket: new Date(window.min + i * bucketMs).toISOString(), value, sampleCount: 300 } + return value > 0.05 + ? { ...point, status: "breached", level: "warning" } + : { ...point, status: "healthy" } + }) + return decodePreview({ + bucketSeconds: bucketMs / 1000, + windowMinutes: 5, + threshold: 0.05, + thresholdUpper: null, + thresholds: [ + { severity: "warning", value: 0.05, upper: null, recoveryValue: null, destinationIds: [] }, + ], + comparator: "gt", + truncatedToStart: null, + series: [{ groupKey: "all", points }], + wouldFire: Arr.map(bursts, (burst) => ({ + groupKey: "all", + start: new Date(window.min + burst.from * bucketMs).toISOString(), + end: new Date(window.min + burst.to * bucketMs).toISOString(), + severity: "warning", + })), + }) +} + +const opened = (search: EditorModel["search"], nowMs: number, rules: ReadonlyArray) => + run(editorInit({ _tag: "AlertsCreate", search }, LAB_CONTEXT).model, [ + EditorMessage.GeneratedDraftId({ id: DRAFT_ID }), + EditorMessage.CompletedReadClock({ nowMs }), + EditorMessage.CompletedReadGrants({ grants: Policy.allPermissions }), + EditorMessage.UpdatedDestinations({ orgId: ORG, rows: [CHAT_DESTINATION, ...chainDestinations] }), + EditorMessage.UpdatedRules({ orgId: ORG, rows: rules }), + EditorMessage.CompletedFetchEscalationChains({ chains: populatedChains([]) }), + ]) + +/** The preview the lab's "API" answers with: the fixture series, or no data for a new rule's org. */ +const previewAnswer = (variant: Variant, model: EditorModel): EditorMessage => + EditorMessage.CompletedFetchPreview({ + key: model.preview.key ?? "", + isSaved: false, + result: + variant === "new" ? Result.fail({ message: null }) : Result.succeed(previewOf(windowMsOf(model))), + }) + +type EditorCommands = ReadonlyArray> +type Step = Readonly<{ model: EditorModel; commands: EditorCommands }> + +/** A user action on the seeded page: unlike the setup reads, its commands run (a dialog's exit). */ +const act = (step: Step, message: EditorMessage): Step => { + const next = editorUpdate(step.model, message, LAB_CONTEXT) + return { model: next.model, commands: [...step.commands, ...(next.commands ?? [])] } +} + +const seed = (variant: Variant, open: ReadonlyArray, nowMs: number): Step => { + const loaded = + variant === "new" + ? opened({}, nowMs, []) + : variant === "anomaly" + ? opened({ ruleId: ruleId(8) }, nowMs, [scopedAnomalyRuleRow()]) + : opened({ ruleId: ruleId(2) }, nowMs, [ERROR_RATE_RULE]) + const answered: Step = { model: run(loaded, [previewAnswer(variant, loaded)]), commands: [] } + // "Start blank" closes the templates dialog: its exit transition has to run, or it stays mounted. + const started = variant === "new" ? act(answered, EditorMessage.ClickedStartBlank()) : answered + return Arr.reduce(open, started, (step, card) => act(step, EditorMessage.ClickedExpandCard({ card }))) +} + +// Reads and writes need a session; the lab keeps the editor's local commands (chart, focus, menus). +const BLOCKED = /^(FetchAlert|RunAlert|SaveAlert|TrackAlert|ReloadAlert|ReadAlertEditorGrants|GenerateAlert)/ +const isPreviewFetch = (command: Readonly<{ name: string }>) => command.name === "FetchAlertEditorPreview" + +const toLab = (commands: ReadonlyArray> | undefined) => + Command.mapMessages( + Arr.filter(commands ?? [], (command) => !BLOCKED.test(command.name)), + (message) => Message.GotEditorMessage({ message }), + ) + +export const init = (labFlags: Flags): Update.Return => { + const started = editorInit({ _tag: "AlertsCreate", search: {} }, LAB_CONTEXT) + const seeded = seed(labFlags.variant, labFlags.open, labFlags.nowMs) + return { + model: { variant: labFlags.variant, editor: seeded.model }, + commands: toLab([...(started.commands ?? []), ...seeded.commands]), + } +} + +export const update = (model: Model, message: Message): Update.Return => { + const next = editorUpdate(model.editor, message.message, LAB_CONTEXT) + // An edit that asks for a new preview gets the lab's answer at once, in place of the API's. + const editor = Arr.some(next.commands ?? [], isPreviewFetch) + ? run(next.model, [previewAnswer(model.variant, next.model)]) + : next.model + return { model: { ...model, editor }, commands: toLab(next.commands) } +} + +export const view = (model: Model, h: HtmlBuilder): Html => + h.div( + [h.Class("flex h-dvh flex-col bg-background")], + [ + h.submodel({ + slotId: "rule-editor-lab", + model: model.editor, + view: editorView, + viewInputs: { context: LAB_CONTEXT }, + toParentMessage: (message) => Message.GotEditorMessage({ message }), + }), + ], + ) diff --git a/apps/web/src/page/alerts/editor/actions.ts b/apps/web/src/page/alerts/editor/actions.ts index d2bdb298ff..7bf019e3cd 100644 --- a/apps/web/src/page/alerts/editor/actions.ts +++ b/apps/web/src/page/alerts/editor/actions.ts @@ -3,15 +3,17 @@ import { buildRuleTestParamsV2, isRulePreviewReady, } from "@maple/web-data/alerts/form-utils" -import { Result } from "effect" +import { Array as Arr, Result } from "effect" import { PageOutMessage } from "../../contract" import { alertsHref } from "../shared/links" -import { RunRuleTest, SaveRule, TrackRuleCreated } from "./command" +import { sentenceCardHeadingId } from "../../kit/sentence-card" +import { FocusRuleCard, RunRuleTest, SaveRule, TrackRuleCreated } from "./command" import { isProtectedDefault, previewIdentityKey, saveIssues } from "./derive" import { draftTimingOf } from "./derive-rows" import type { Message } from "./message" import type { Model } from "./model" import type { Return } from "./steps" +import { cardOfIssue, issueFieldId } from "./summary" // The writes: Dry run and Send test (`runTest`), and Create / Save changes (`handleSave`). @@ -63,7 +65,20 @@ export const completeTest = (model: Model, message: Extract { const form = model.form if (form === null || model.isSaving) return { model } - if (saveIssues(form, draftTimingOf(model, form), isProtectedDefault(model)).length > 0) return { model } + const issues = saveIssues(form, draftTimingOf(model, form), isProtectedDefault(model)) + const first = issues[0] + // A blocked save shows every field's error and lands on the first thing to fix. + if (first !== undefined) { + const card = cardOfIssue(first) + return { + model: { + ...model, + hasAttemptedSave: true, + expandedCards: Arr.union(model.expandedCards, [card]), + }, + commands: [FocusRuleCard({ id: issueFieldId(first) ?? sentenceCardHeadingId(card) })], + } + } return { model: { ...model, isSaving: true }, commands: [SaveRule({ ruleId: model.editingRuleId, payload: buildRuleCreateParamsV2(form) })], diff --git a/apps/web/src/page/alerts/editor/command.ts b/apps/web/src/page/alerts/editor/command.ts index c5bc8415cb..ead4aea8a4 100644 --- a/apps/web/src/page/alerts/editor/command.ts +++ b/apps/web/src/page/alerts/editor/command.ts @@ -163,6 +163,23 @@ export const FocusSqlCaret = Command.define("FocusAlertSqlCaret", { }), }) +/** 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 focus = () => { + const element = document.getElementById(id) + if (element instanceof HTMLElement) element.focus() + resume(Effect.succeed(Message.CompletedFocusCard())) + } + // Two frames: the first renders the opened card, the second finds its controls. + const handle = requestAnimationFrame(() => requestAnimationFrame(focus)) + return Effect.sync(() => cancelAnimationFrame(handle)) + }), +}) + /** `onRetry={() => window.location.reload()}`: a live query past its recovery has no other retry. */ export const ReloadPage = Command.define("ReloadAlertEditorPage", { messages: [Message.ReloadedPage], diff --git a/apps/web/src/page/alerts/editor/derive-rows.ts b/apps/web/src/page/alerts/editor/derive-rows.ts index 1d00b3fe31..e146dd5be3 100644 --- a/apps/web/src/page/alerts/editor/derive-rows.ts +++ b/apps/web/src/page/alerts/editor/derive-rows.ts @@ -12,7 +12,9 @@ import { AsyncData } from "foldkit" import { lookupField } from "../../kit/autocomplete-lookups" import { previewIssue, previewPayload, previewStateOf, type PreviewState } from "../shared/preview" import { isoMs } from "../shared/time" +import { isProtectedDefault, saveIssues } from "./derive" import type { RuleForm } from "./form" +import { revealedInvalidFields } from "./summary" import type { Model, TraceFacets } from "./model" import { Array as Arr } from "effect" @@ -89,8 +91,10 @@ export const windowMsOf = (model: Pick): { min: number; max: isoMs(normalizeTimestampInput(model.previewWindow.endTime)), } -const hasPayload = (model: Pick, form: RuleForm): boolean => +/** Whether the draft can be previewed at all: a window, and a query the preview can run. */ +export const hasPreviewPayload = (model: Pick, form: RuleForm): boolean => model.previewWindow !== null && previewPayload(form, model.previewWindow)._tag === "Some" +const hasPayload = hasPreviewPayload /** The timing of the draft's own preview: none while a new one loads, so nothing gates on an older query. */ export const draftTimingOf = ( @@ -113,3 +117,23 @@ export const draftPreviewOf = ( model: Pick, form: RuleForm, ): PreviewState => previewStateOf(model.preview, hasPayload(model, form), previewIssue(form)) + +/** + * The fields that show their error (by element id), as one string: a `createLazy` slot compares it + * by value. Empty on a pristine form. + */ +export const invalidFieldsOf = ( + model: Pick< + Model, + "preview" | "previewWindow" | "touchedFields" | "hasAttemptedSave" | "editingRuleId" | "savedForm" + >, + form: RuleForm, +): string => + Arr.join( + revealedInvalidFields( + saveIssues(form, draftTimingOf(model, form), isProtectedDefault(model)), + model.touchedFields, + model.hasAttemptedSave, + ), + ",", + ) diff --git a/apps/web/src/page/alerts/editor/derive.ts b/apps/web/src/page/alerts/editor/derive.ts index 2ffe94d7a4..a6e53edc29 100644 --- a/apps/web/src/page/alerts/editor/derive.ts +++ b/apps/web/src/page/alerts/editor/derive.ts @@ -49,6 +49,24 @@ export const conditionSubject = (form: RuleForm): string => { return Str.toLowerCase(signalLabels[form.signalType]) } +/** A threshold with its unit: "5%", "800 ms", "?" while empty. */ +export const thresholdValue = (form: RuleForm, raw: string): string => { + const unit = thresholdUnitLabel(form) ?? "" + return `${Str.trim(raw) || "?"}${unit === "%" ? "%" : unit ? ` ${unit}` : ""}` +} + +/** How many checks fire the rule: "in 3 checks in a row (5 min each)", "over 5 min". */ +export const checksPhrase = (form: RuleForm): string => { + const checks = Str.trim(form.consecutiveBreachesRequired) || "?" + const outOf = Str.trim(form.breachesOutOf) + const windowText = `${Str.trim(form.windowMinutes) || "?"} min` + return outOf.length > 0 + ? `in ${checks} of the last ${outOf} checks (${windowText} each)` + : checks === "1" + ? `over ${windowText}` + : `in ${checks} checks in a row (${windowText} each)` +} + /** The rule in one plain sentence, shown in the action bar. */ export const describeRuleCondition = (form: RuleForm, isDefault = false): string => { if (form.signalType === "anomaly") @@ -57,21 +75,12 @@ export const describeRuleCondition = (form: RuleForm, isDefault = false): string : isDefaultAnomalyRule(form) ? "Anomalies in a service, environment or signal" : `Pages on anomalies in ${anomalyScopeSummary(form)}` - const unit = thresholdUnitLabel(form) ?? "" - const value = (raw: string) => `${Str.trim(raw) || "?"}${unit === "%" ? "%" : unit ? ` ${unit}` : ""}` + const value = (raw: string) => thresholdValue(form, raw) const threshold = isRangeComparator(form.comparator) ? `${value(form.threshold)} and ${value(form.thresholdUpper)}` : value(form.threshold) const escalation = comparatorSupportsTiers(form.comparator) ? form.escalation : null - const checks = Str.trim(form.consecutiveBreachesRequired) || "?" - const outOf = Str.trim(form.breachesOutOf) - const windowText = `${Str.trim(form.windowMinutes) || "?"} min` - const when = - outOf.length > 0 - ? `in ${checks} of the last ${outOf} checks (${windowText} each)` - : checks === "1" - ? `over ${windowText}` - : `in ${checks} checks in a row (${windowText} each)` + const when = checksPhrase(form) const severity = escalation === null && form.severity === "critical" ? "Critical" : "Warning" const critical = escalation === null diff --git a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts index 603a765e60..3e36aa836e 100644 --- a/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor-anomaly.scene.test.ts @@ -3,6 +3,7 @@ import { AlertDestinationId } from "@maple/domain/http" import { Result, Schema, Array as Arr } from "effect" import { click, + Command, expect as expectView, given, Mount, @@ -20,6 +21,7 @@ import { menuIdOf } from "./dest-steps" import type { PageContext } from "../../contract" import { defaultAnomalyRuleRow, scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" import { DESTINATION_ID, destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import { FocusRuleCard } from "./command" import { init } from "./init" import { Message } from "./message" import type { Model } from "./model" @@ -54,6 +56,9 @@ const pickExisting = (tier: "warning" | "critical", id: string) => message: Menu.Message.ClickedItem({ index: 0, item: { kind: "Item", value: `existing:${id}` } }), }) +const savesOf = (model: Model) => + Arr.map(pageUpdate(model, Message.ClickedSave()).commands ?? [], ({ name }) => name) + const DESCRIPTION = /^Pages when Maple detects unusual error rate, latency, throughput/ const DEFAULT_NOTE = /^This is the default anomaly rule/ @@ -64,10 +69,10 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(blank), themeResolved(), - expectView(text("When to alert")).toExist(), + expectView(text("Alert when")).toExist(), click(role("button", { name: "Anomaly" })), expectView(text(DESCRIPTION)).toExist(), - expectView(text("When to alert")).not.toExist(), + expectView(text("Alert when")).not.toExist(), expectView(role("button", { name: "Dry run" })).not.toExist(), expectView(role("group", { name: "Anomaly signals" })).toExist(), expectView(text("None picked: every signal pages.")).toExist(), @@ -134,9 +139,9 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(unrouted), themeResolved(), - expectView(text("Who gets notified")).toExist(), - expectView(role("button", { name: "Create rule" })).toBeDisabled(), + expectView(role("button", { name: "Who gets notified" })).toExist(), ) + expect(savesOf(unrouted)).not.toContain("SaveAlertRule") }) test("editing a scoped anomaly rule opens on its scope and keeps the save unchanged", () => { @@ -148,6 +153,8 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(scoped), themeResolved(), + click(role("button", { name: /^When: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(text("No changes")).toExist(), expectView(text(DEFAULT_NOTE)).not.toExist(), // Each chip's remove button names its chip. @@ -162,6 +169,8 @@ describe("alert rule editor: anomaly rules", () => { { update: pageUpdate, view }, given(fallback), themeResolved(), + click(role("button", { name: /^When: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(text(DEFAULT_NOTE)).toExist(), expectView(role("button", { name: "Error rate" })).toBeDisabled(), expectView(role("button", { name: "Built-in" })).toBeDisabled(), @@ -264,12 +273,12 @@ describe("alert rule editor: anomaly rules", () => { given(unscoped), themeResolved(), expectView(text(/^Pick at least one service, environment or signal/)).toExist(), - expectView(role("button", { name: "Create rule" })).toBeDisabled(), + expectView(role("button", { name: "Scope" })).toExist(), click(role("button", { name: "Throughput" })), expectView(text(/^Pick at least one service, environment or signal/)).not.toExist(), - expectView(role("button", { name: "Create rule" })).not.toBeDisabled(), + expectView(text("Ready to save")).toExist(), ) - expect(pageUpdate(unscoped, Message.ClickedSave()).commands ?? []).toHaveLength(0) + expect(savesOf(unscoped)).not.toContain("SaveAlertRule") }) test("an anomaly rule's name suggests what it covers", () => { diff --git a/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts new file mode 100644 index 0000000000..71c76d3a01 --- /dev/null +++ b/apps/web/src/page/alerts/editor/editor-cards.scene.test.ts @@ -0,0 +1,475 @@ +import { OrgId } from "@maple/domain" +import { AlertRulePreviewResponse } from "@maple/domain/http" +import { Result, Schema, Array as Arr } from "effect" +import * as Animation from "@foldkit/ui/animation" +import { + all, + click, + Command, + expect as expectView, + expectAll, + given, + keydown, + Mount, + role, + scene, + selector, + text, + type, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import * as Chart from "@maple/views/charts/chart" +import { MountChart } from "@maple/views/charts/chart-mounts" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import * as Dialog from "@maple/views/ui/dialog" +import type { PageContext } from "../../contract" +import { scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" +import { destinationRow, NOW, ORG, ruleId, ruleRow } from "../shared/test-rows" +import * as Select from "@maple/views/ui/select" +import { FetchEditorPreview, FocusRuleCard } from "./command" +import { BREACH_MODE_SELECT_ID } from "./form-steps" +import { init } from "./init" +import { Message } from "./message" +import type { Model } from "./model" +import { update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const themeResolved = () => + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) +const opened = (search: Model["search"], rules = [ruleRow(2)]) => + run(init({ _tag: "AlertsCreate", search }, context).model, [ + Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + Message.CompletedReadClock({ nowMs: NOW }), + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + Message.UpdatedRules({ orgId: ORG, rows: rules }), + ]) + +const SQL = + "SELECT toStartOfInterval(Timestamp, INTERVAL $__interval_s SECOND) AS bucket, count() AS value FROM traces WHERE $__orgFilter AND $__timeFilter(Timestamp) GROUP BY bucket ORDER BY bucket" + +/** A preview whose one check takes 7.8s: too slow for 1-minute checks. */ +const slowPreview = () => + Schema.decodeSync(AlertRulePreviewResponse)({ + bucketSeconds: 300, + windowMinutes: 5, + threshold: 800, + thresholdUpper: null, + thresholds: [ + { severity: "critical", value: 800, upper: null, recoveryValue: null, destinationIds: [] }, + ], + comparator: "gt", + truncatedToStart: null, + series: [], + wouldFire: [], + queryTiming: { + elapsedMs: 7_800, + failure: null, + probeLimitMs: 25_000, + intervalMinutes: 1, + limitMs: 5_000, + fitsIntervalMinutes: [5, 10, 15, 30, 60], + }, + }) + +/** A preview with one point over the threshold: a chart, its threshold line and a would-fire band. */ +const breachingPreview = () => + Schema.decodeSync(AlertRulePreviewResponse)({ + bucketSeconds: 300, + windowMinutes: 5, + threshold: 800, + thresholdUpper: null, + thresholds: [ + { severity: "critical", value: 800, upper: null, recoveryValue: null, destinationIds: [] }, + ], + comparator: "gt", + truncatedToStart: null, + series: [ + { + groupKey: "__ungrouped__", + points: [ + { + bucket: "2026-10-01T11:00:00.000Z", + value: 1800, + sampleCount: 40, + status: "breached", + }, + ], + }, + ], + wouldFire: [ + { + groupKey: "__ungrouped__", + start: "2026-10-01T11:00:00.000Z", + end: "2026-10-01T11:10:00.000Z", + severity: "critical", + }, + ], + }) + +const summary = (lead: string, rest: string) => role("button", { name: `${lead}: ${rest}. Edit` }) +const queryMode = role("button", { name: "Built-in" }) + +describe("alert rule editor, sentence cards", () => { + test("a saved rule opens on one-line summaries; clicking one opens its card, Done closes it", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(2) })), + themeResolved(), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + expectView( + summary( + "Alert when", + "Above 800 ms (critical), in 2 checks in a row (5 min each), resolves after 2 healthy checks", + ), + ).toExist(), + expectView(summary("Notify", "https://hooks.acme.test/maple")).toExist(), + expectView(summary("Name", "Rule 2")).toExist(), + expectView(queryMode).not.toExist(), + click(summary("When", "P95 latency on payment-svc in production")), + // Opening moves focus to the card's heading; closing returns it to the summary. + Command.expectHas(FocusRuleCard({ id: "sentence-card-watch" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + expectView(queryMode).toExist(), + expectView(role("heading", { name: "When" })).toExist(), + click(role("button", { name: "Done" })), + Command.expectHas(FocusRuleCard({ id: "sentence-card-watch-summary" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + expectView(queryMode).not.toExist(), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + ) + }) + + test("a card with something missing stays open, and stays open once it is filled in", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(2) }, [ruleRow(2, { name: "" })])), + themeResolved(), + expectView(text("Missing: Rule name")).toExist(), + expectView(role("textbox", { name: "Rule name" })).toExist(), + type(role("textbox", { name: "Rule name" }), "Payment latency"), + expectView(text("Missing: Rule name")).not.toExist(), + expectView(role("textbox", { name: "Rule name" })).toExist(), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + ) + }) + + test("a new rule opens every card", () => { + scene( + { update: pageUpdate, view }, + given(opened({ template: "slow_p95" })), + themeResolved(), + expectView(queryMode).toExist(), + expectView(role("heading", { name: "Alert when" })).toExist(), + expectView(role("heading", { name: "Notify" })).toExist(), + expectView(role("textbox", { name: "Rule name" })).toExist(), + ) + }) + + // Blank counts as missing: `Number("")` is 0, which once drew a threshold line at zero. + test.each([["-"], [""], [" "]])( + "with the threshold %j, the empty chart asks for a number, not for a signal", + (value) => { + scene( + { update: pageUpdate, view }, + given( + run(opened({ template: "slow_p95" }), [ + Message.ChangedField({ field: "threshold", value }), + ]), + ), + themeResolved(), + expectView( + text(/^Enter a number for the threshold to preview it against the last/u), + ).toExist(), + expectView(text(/^Pick a signal/u)).not.toExist(), + ) + }, + ) + + test("clearing the threshold drops the chart and its threshold line, and asks for no new preview", () => { + const loading = opened({ template: "slow_p95" }) + const draft = run(loading, [ + Message.CompletedFetchPreview({ + key: loading.preview.key ?? "", + isSaved: false, + result: Result.succeed(breachingPreview()), + }), + ]) + scene( + { update: pageUpdate, view }, + given(draft), + themeResolved(), + Mount.resolve(MountChart, Chart.Message.CompletedMountChart()), + expectView(text(/^Breach above/u)).toExist(), + expectView(text("Would have fired")).toExist(), + // A preview at threshold 0 would be a command left unanswered here. + type(selector("#rule-threshold"), ""), + Mount.expectEnded(MountChart), + expectView(text(/^Enter a number for the threshold to preview it against the last/u)).toExist(), + expectView(text(/^Breach above/u)).not.toExist(), + expectView(text("Would have fired")).not.toExist(), + ) + }) + + test("opening an empty add-on section on a saved builder rule is not an unsaved change", () => { + const builderRule = ruleRow(2, { + signal_type: "builder_query", + query_builder_draft_json: { + id: "q1", + name: "A", + dataSource: "traces", + aggregation: "count", + whereClause: 'service.name = "checkout"', + addOns: { groupBy: false, having: false, orderBy: false, limit: false, legend: false }, + groupBy: [], + }, + }) + const saved = opened({ ruleId: ruleId(2) }, [builderRule]) + const groupBy = role("button", { name: "Group by" }) + scene( + { update: pageUpdate, view }, + given(saved), + themeResolved(), + expectView(text("No changes")).toExist(), + click(role("button", { name: /^When: .* Edit$/u })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + click(groupBy), + // The draft's JSON did change, so its preview is asked for again; the answer is not needed here. + Command.resolve( + FetchEditorPreview, + Message.CompletedFetchPreview({ + key: "unanswered", + isSaved: false, + result: Result.succeed(slowPreview()), + }), + ), + expectView(groupBy).toHaveAttr("aria-pressed", "true"), + expectView(text("No changes")).toExist(), + expectView(text(/unsaved change/u)).not.toExist(), + ) + }) + + test("an anomaly rule reads as its scope and has no Alert when card", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(8) }, [scopedAnomalyRuleRow()])), + themeResolved(), + expectView( + summary("When", "Anomalies in checkout · production · Error rate, p95 latency"), + ).toExist(), + expectView(text("Alert when")).not.toExist(), + ) + }) + + test("the action bar says one thing: no changes, ready, or what to fix", () => { + const saved = opened({ ruleId: ruleId(2) }) + scene( + { update: pageUpdate, view }, + given(saved), + themeResolved(), + expectView(text("No changes")).toExist(), + expectView(text("Ready to save")).not.toExist(), + expectView(role("button", { name: "Save changes" })).toBeDisabled(), + ) + const edited = run(saved, [Message.ChangedField({ field: "threshold", value: "900" })]) + scene( + { update: pageUpdate, view }, + given(edited), + themeResolved(), + expectView(text("Ready to save")).toExist(), + expectView(text("No changes")).not.toExist(), + expectView(text("1 unsaved change")).toExist(), + expectView(role("button", { name: "Save changes" })).toBeEnabled(), + ) + scene( + { update: pageUpdate, view }, + given(run(edited, [Message.ChangedField({ field: "name", value: "" })])), + themeResolved(), + expectView(text("Fix before saving:")).toExist(), + expectView(text("Ready to save")).not.toExist(), + expectView(role("button", { name: "Rule name" })).toExist(), + ) + }) + + test("a thing to fix in the action bar opens its card and focuses its field", () => { + const blank = run(opened({ ruleId: ruleId(2) }), [ + Message.ChangedField({ field: "threshold", value: "" }), + Message.ClickedCollapseCard({ card: "name" }), + ]) + scene( + { update: pageUpdate, view }, + given(blank), + themeResolved(), + expectView(role("textbox", { name: "Rule name" })).not.toExist(), + click(role("button", { name: "Threshold" })), + Command.expectHas(FocusRuleCard({ id: "rule-threshold" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + // The field it lands on now shows its error. + expectView(selector("#rule-threshold")).toHaveAttr("aria-invalid", "true"), + ) + // An issue on a closed card opens the card first. + const next = pageUpdate(opened({ ruleId: ruleId(2) }), Message.ClickedIssue({ issue: "Rule name" })) + expect(next.model.expandedCards).toContain("name") + expect(Arr.map(next.commands ?? [], ({ name }) => name)).toEqual(["FocusAlertRuleCard"]) + }) + + test("a pristine form shows no red errors until a field is left or save is pressed", () => { + // A new rule in an org with no destination: Notify and Name are missing from the start. + const pristine = run( + init({ _tag: "AlertsCreate", search: { template: "slow_p95" } }, context).model, + [ + Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + Message.CompletedReadClock({ nowMs: NOW }), + Message.UpdatedDestinations({ orgId: ORG, rows: [] }), + Message.UpdatedRules({ orgId: ORG, rows: [] }), + Message.ChangedField({ field: "name", value: "" }), + ], + ) + const routeError = text("Pick a destination or an escalation chain") + scene( + { update: pageUpdate, view }, + given(pristine), + themeResolved(), + expectView(text("Missing: Who gets notified")).toExist(), + expectView(text("Missing: Rule name")).toExist(), + expectAll(all.selector('[aria-invalid="true"]')).toHaveCount(0), + expectAll(all.selector("[aria-describedby='rule-name-error']")).toHaveCount(0), + expectView(selector("#rule-name-error")).not.toExist(), + expectView(routeError).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + // Leaving the empty name shows its error, and only its own. + given(run(pristine, [Message.BlurredField({ id: "rule-name" })])), + themeResolved(), + expectView(role("textbox", { name: "Rule name" })).toHaveAttr("aria-invalid", "true"), + // The error the input is described by is on the page, under the field. + expectView(role("textbox", { name: "Rule name" })).toHaveAttr( + "aria-describedby", + "rule-name-error", + ), + expectView(selector("#rule-name-error")).toHaveText("Give the rule a name."), + expectView(routeError).not.toExist(), + // Pressing save shows the rest, and lands on the first thing to fix. + click(role("button", { name: "Create rule" })), + Command.expectHas(FocusRuleCard({ id: "rule-name" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + expectView(routeError).toExist(), + ) + }) + + test("Escape inside an open, complete card collapses it and returns focus to its summary", () => { + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(2) })), + themeResolved(), + click(summary("When", "P95 latency on payment-svc in production")), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + keydown(role("region", { name: "When" }), "Escape"), + Command.expectHas(FocusRuleCard({ id: "sentence-card-watch-summary" })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), + expectView(summary("When", "P95 latency on payment-svc in production")).toExist(), + expectView(queryMode).not.toExist(), + ) + // A card with something missing has nothing to collapse to: Escape is not handled. + scene( + { update: pageUpdate, view }, + given(opened({ ruleId: ruleId(2) }, [ruleRow(2, { name: "" })])), + themeResolved(), + expectView(role("region", { name: "Name" })).not.toHaveHandler("keydown"), + expectView(role("button", { name: "Done" })).not.toExist(), + ) + }) + + test("Start blank closes the templates dialog and unmounts it, leaving nothing over the form", () => { + const fresh = run(init({ _tag: "AlertsCreate", search: {} }, context).model, [ + Message.GeneratedDraftId({ id: "6f1c2e3a-0000-4000-8000-000000000001" }), + Message.CompletedReadClock({ nowMs: NOW }), + ]) + expect(fresh.templates.isOpen).toBe(true) + scene( + { update: pageUpdate, view }, + given(fresh), + themeResolved(), + Mount.resolve(Dialog.MountDialog, Dialog.Message.CompletedMountDialog()), + expectView(role("dialog")).toExist(), + click(role("button", { name: /^Start blank/ })), + // The exit transition settles, then the dialog leaves the tree. + Command.resolve( + Animation.WaitForPaint, + Animation.Message.CompletedWaitForPaint({ generation: 2 }), + ), + Command.resolve( + Dialog.WaitForDialogTransition, + Dialog.Message.GotAnimationMessage({ + message: Animation.Message.EndedAnimation({ generation: 2 }), + }), + ), + Mount.expectEnded(Dialog.MountDialog), + expectView(role("dialog")).not.toExist(), + expectView(text("Start with a template")).not.toExist(), + ) + }) + + test("breaches count in a row, or out of the last N once that is picked", () => { + const draft = opened({ template: "slow_p95" }) + const pick = (value: string) => + Message.GotSelectMessage({ + id: BREACH_MODE_SELECT_ID, + message: Select.Message.SelectedItem({ index: 0, value }), + }) + const mode = role("combobox", { name: "How breaches are counted" }) + scene( + { update: pageUpdate, view }, + given(draft), + themeResolved(), + expectView(mode).toContainText("in a row"), + expectView(selector("#rule-breaches-out-of")).not.toExist(), + ) + const windowed = run(draft, [pick("window")]) + scene( + { update: pageUpdate, view }, + given(windowed), + themeResolved(), + expectView(mode).toContainText("out of the last"), + expectView(selector("#rule-breaches-out-of")).toHaveValue(windowed.form?.breachesOutOf ?? ""), + ) + // Retyping N passes through empty: the field stays until "in a row" is picked. + const emptied = run(windowed, [Message.ChangedField({ field: "breachesOutOf", value: "" })]) + scene( + { update: pageUpdate, view }, + given(emptied), + themeResolved(), + expectView(selector("#rule-breaches-out-of")).toExist(), + ) + expect(run(emptied, [pick("row")]).isBreachWindowOpen).toBe(false) + }) + + test("a SQL rule whose timing lands too slow opens Alert when with the blocker", () => { + const sql = opened({ ruleId: ruleId(2) }, [ + ruleRow(2, { signal_type: "raw_query", raw_query_sql: SQL }), + ]) + expect(sql.expandedCards).toEqual([]) + const timed = pageUpdate( + sql, + Message.CompletedFetchPreview({ + key: sql.preview.key ?? "", + isSaved: false, + result: Result.succeed(slowPreview()), + }), + ).model + expect(timed.expandedCards).toContain("condition") + scene( + { update: pageUpdate, view }, + given(timed), + themeResolved(), + expectView(text("Query too slow for this check interval")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts b/apps/web/src/page/alerts/editor/editor-chains.scene.test.ts index 496064507e..a9ef05deb5 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 f153e5b516..fdc6dfa0f0 100644 --- a/apps/web/src/page/alerts/editor/editor.scene.test.ts +++ b/apps/web/src/page/alerts/editor/editor.scene.test.ts @@ -27,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, ReloadPage } from "./command" +import { FetchEditorPreview, FocusRuleCard, ReloadPage } from "./command" import { init } from "./init" +import { INTERVAL_SELECT_ID } from "./interval" import { Message } from "./message" import type { Model } from "./model" import { update } from "./update" @@ -153,6 +154,8 @@ describe("alert rule editor", () => { { update: pageUpdate, view }, given(withWindow(editing, "60")), themeResolved(), + click(role("button", { name: /^Alert when: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(text(`${saved} min`)).toExist(), expectView(hint("5 min")).toExist(), ) @@ -161,10 +164,11 @@ describe("alert rule editor", () => { test("the check interval picker sets how often the rule runs, and refuses one longer than the window", () => { const hint = (every: string) => text(`Each check looks at this much data, checked every ${every}.`) const pick = (value: string) => - Message.GotRadioMessage({ - group: "interval", - message: RadioGroup.Message.SelectedRadio({ value }), + Message.GotSelectMessage({ + id: INTERVAL_SELECT_ID, + message: Select.Message.SelectedItem({ index: 0, value }), }) + const interval = role("combobox", { name: "Check every" }) const draft = run(withLists(entered({ template: "slow_p95" })), [ Message.ChangedField({ field: "windowMinutes", value: "15" }), ]) @@ -174,17 +178,26 @@ describe("alert rule editor", () => { { update: pageUpdate, view }, given(draft), themeResolved(), - expectView(role("radio", { name: /^Auto · 3 min/ })).toHaveAttr("aria-checked", "true"), - // The preview pins the interval, so picking one never re-queries. - click(role("radio", { name: /^10 min/ })), + expectView(interval).toContainText("Auto · 3 min"), + ) + scene( + { update: pageUpdate, view }, + given(every10), + themeResolved(), expectView(hint("10 min")).toExist(), - expectView(role("radio", { name: /^10 min/ })).toHaveAttr("aria-checked", "true"), - click(role("radio", { name: /^30 min/ })), + expectView(interval).toContainText("10 min"), + ) + scene( + { update: pageUpdate, view }, + given(every30), + themeResolved(), + expectView(interval).toContainText("30 min"), expectView(text("Longer than the window, so data between checks would go unread.")).toExist(), ) + // The preview pins the interval, so picking one never re-queries. expect(every10.preview.key).toBe(draft.preview.key) expect(every30.form?.evaluationIntervalMinutes).toBe("30") - expect(names(pageUpdate(every30, Message.ClickedSave()).commands)).toEqual([]) + expect(names(pageUpdate(every30, Message.ClickedSave()).commands)).not.toContain("SaveAlertRule") }) test("a raw SQL query too slow for its interval says so, blocks save, and switches in one click", () => { @@ -220,7 +233,7 @@ describe("alert rule editor", () => { Message.ClickedSignalKind({ kind: "raw_query" }), ]) const slow = run(sql, [answer(sql, 7_800)]) - expect(names(pageUpdate(slow, Message.ClickedSave()).commands)).toEqual([]) + expect(names(pageUpdate(slow, Message.ClickedSave()).commands)).not.toContain("SaveAlertRule") scene( { update: pageUpdate, view }, given(slow), @@ -233,7 +246,7 @@ describe("alert rule editor", () => { ), ).toExist(), click(role("button", { name: "Check every 5 min" })), - expectView(role("radio", { name: /^5 min/ })).toHaveAttr("aria-checked", "true"), + expectView(role("combobox", { name: "Check every" })).toContainText("5 min"), expectView(text("Fits checks every 5 min.")).toExist(), ) scene( @@ -366,7 +379,7 @@ describe("alert rule editor", () => { "It ran out of memory with the most any interval allows, so checking less often will not help.", ), ).toExist(), - expectView(role("radio", { name: /too slow/ })).not.toExist(), + expectView(text(/^Over or too close to/)).not.toExist(), ) }) @@ -382,10 +395,12 @@ describe("alert rule editor", () => { { update: pageUpdate, view }, given(slowed), themeResolved(), + click(role("button", { name: /^Alert when: / })), + Command.resolve(FocusRuleCard, Message.CompletedFocusCard()), expectView(notice).toExist(), click(role("button", { name: "Keep 5 min" })), expectView(notice).not.toExist(), - expectView(role("radio", { name: /^5 min/ })).toHaveAttr("aria-checked", "true"), + expectView(role("combobox", { name: "Check every" })).toContainText("5 min"), ) }) @@ -576,7 +591,10 @@ describe("alert rule editor", () => { test("an incomplete rule cannot be saved", () => { const unnamed = pageUpdate(withLists(entered()), Message.ChangedField({ field: "name", value: "" })) - expect(names(pageUpdate(unnamed.model, Message.ClickedSave()).commands)).toEqual([]) + // The press is an attempt: it lands on what to fix and sends nothing. + expect(names(pageUpdate(unnamed.model, Message.ClickedSave()).commands)).toEqual([ + "FocusAlertRuleCard", + ]) }) test("switching SQL to Built-in says what it dropped; a template lands in the query builder", () => { diff --git a/apps/web/src/page/alerts/editor/form-steps.ts b/apps/web/src/page/alerts/editor/form-steps.ts index 9f3e5d0a46..2d191ecb53 100644 --- a/apps/web/src/page/alerts/editor/form-steps.ts +++ b/apps/web/src/page/alerts/editor/form-steps.ts @@ -27,6 +27,23 @@ export const setEscalationField = ( ): RuleForm => form.escalation === null ? form : { ...form, escalation: { ...form.escalation, [field]: value } } +/** How breaches are counted, a select inside the "Fire after" sentence. */ +export const BREACH_MODE_SELECT_ID = "rule-breach-mode" + +/** Breaches counted in a row (no window), or out of the last N checks, starting two past the count. */ +export const setBreachMode = (form: RuleForm, mode: string): RuleForm => { + if (mode !== "window") return { ...form, breachesOutOf: "" } + if (Str.trim(form.breachesOutOf).length > 0) return form + const required = Number(Str.trim(form.consecutiveBreachesRequired)) + return { + ...form, + breachesOutOf: Number.isInteger(required) && required > 0 ? String(required + 2) : "5", + } +} + +/** The mode switch as a select, below `sm` where the four segments do not fit. */ +export const KIND_SELECT_ID = "rule-kind" + /** A tier's escalation chain select, and its "None" value. */ export const chainSelectIdOf = (tier: Tier): string => `rule-chain-${tier}` export const NO_CHAIN = "none" diff --git a/apps/web/src/page/alerts/editor/form.ts b/apps/web/src/page/alerts/editor/form.ts index aad6b6dcb7..12e1f389c0 100644 --- a/apps/web/src/page/alerts/editor/form.ts +++ b/apps/web/src/page/alerts/editor/form.ts @@ -64,6 +64,10 @@ export type RuleForm = typeof RuleForm.Type export const asFormState = (form: RuleForm): RuleFormState => form export const fromFormState = (form: RuleFormState): RuleForm => form +/** The editor's sentence cards: "When" (signal and scope), "Alert when", "Notify", "Name". */ +export const RuleCard = Schema.Literals(["watch", "condition", "notify", "name"]) +export type RuleCard = typeof RuleCard.Type + /** The warning tier's destinations, or the critical tier's. */ export const Tier = Schema.Literals(["warning", "critical"]) export type Tier = typeof Tier.Type diff --git a/apps/web/src/page/alerts/editor/init.ts b/apps/web/src/page/alerts/editor/init.ts index c1f6d82704..0c9228f013 100644 --- a/apps/web/src/page/alerts/editor/init.ts +++ b/apps/web/src/page/alerts/editor/init.ts @@ -67,6 +67,10 @@ export const init = (route: CreateRoute, _context: PageContext): PageReturn +/** The check interval select, inside the "How it is checked" sentence. */ +export const INTERVAL_SELECT_ID = "rule-interval" + +export const intervalSelectValue = (formValue: string): string => Str.trim(formValue).length === 0 ? AUTO_INTERVAL : formValue export interface IntervalOption { diff --git a/apps/web/src/page/alerts/editor/message.ts b/apps/web/src/page/alerts/editor/message.ts index ea1fc8fb30..bacfdd599a 100644 --- a/apps/web/src/page/alerts/editor/message.ts +++ b/apps/web/src/page/alerts/editor/message.ts @@ -31,7 +31,7 @@ import * as TimeRangePicker from "../../kit/time-range-picker" import * as DestinationDialog from "../destination/dialog" import * as ChatConnect from "../shared/chat-connect" import { PreviewFailure } from "../shared/preview" -import { Tier } from "./form" +import { RuleCard, Tier } from "./form" import { DryRun, TraceFacets } from "./model" /** The form's free-text fields, each one input. */ @@ -107,7 +107,7 @@ export const Message = defineMessageUnion({ GotTimePickerMessage: { message: TimeRangePicker.Message }, ClickedSignalKind: { kind: SignalKind }, GotRadioMessage: { - group: Schema.Literals(["signal", "severity", "interval"]), + group: Schema.Literals(["signal", "severity"]), message: RadioGroup.Message, }, ClickedOpenInBuilder: {}, @@ -144,6 +144,15 @@ export const Message = defineMessageUnion({ BlurredTagInput: {}, ClickedRemoveTag: { tag: Schema.String }, ClickedAddNotes: {}, + /** A closed sentence card's summary: opens the card for editing. */ + ClickedExpandCard: { card: RuleCard }, + /** An open card's Done, or Escape inside it: back to its summary. */ + ClickedCollapseCard: { card: RuleCard }, + CompletedFocusCard: {}, + /** A form control lost focus (by element id): its error may show from now on. */ + BlurredField: { id: Schema.String }, + /** One of the action bar's "Fix before saving" items: opens its card and focuses its field. */ + ClickedIssue: { issue: Schema.String }, ToggledSqlCollapsed: {}, ClickedSqlToken: { token: Schema.String }, CompletedInsertSqlToken: { sql: Schema.String, caret: Schema.Number }, diff --git a/apps/web/src/page/alerts/editor/model.ts b/apps/web/src/page/alerts/editor/model.ts index 78d46e0859..2145b93bda 100644 --- a/apps/web/src/page/alerts/editor/model.ts +++ b/apps/web/src/page/alerts/editor/model.ts @@ -28,7 +28,7 @@ import * as TimeRangePicker from "../../kit/time-range-picker" import * as DestinationDialog from "../destination/dialog" import * as ChatConnect from "../shared/chat-connect" import { PreviewSlot } from "../shared/preview" -import { RuleForm, Tier } from "./form" +import { RuleCard, RuleForm, Tier } from "./form" // `/alerts/create`: `AlertCreatePageContent` and the // `AlertCreateFormSurface` it mounts once the draft is known. @@ -122,6 +122,14 @@ export const Model = Schema.Struct({ /** The switch notice under the mode toggle (`notice`), cleared by the next switch. */ kindNotice: Schema.NullOr(Schema.String), isNotesOpen: Schema.Boolean, + /** The sentence cards opened for editing; a card with something missing is open regardless. */ + expandedCards: Schema.Array(RuleCard), + /** "Out of the last N" was picked for the breach count: its field stays while N is retyped. */ + isBreachWindowOpen: Schema.Boolean, + /** Save was pressed with something missing: every field then shows its error. */ + hasAttemptedSave: Schema.Boolean, + /** The controls the user has left (by element id): each shows its error from then on. */ + touchedFields: Schema.Array(Schema.String), /** Tags already in use across the org's rules, sorted; kept by reference while they stay the same. */ tagSuggestions: Schema.Array(Schema.String), isSqlCollapsed: Schema.Boolean, diff --git a/apps/web/src/page/alerts/editor/steps.ts b/apps/web/src/page/alerts/editor/steps.ts index a7ff3e147e..7cf25101dd 100644 --- a/apps/web/src/page/alerts/editor/steps.ts +++ b/apps/web/src/page/alerts/editor/steps.ts @@ -3,10 +3,14 @@ import * as Dialog from "@maple/views/ui/dialog" import type { PageContext, PageReturn } from "../../contract" import { editingRuleOf, initialization } from "./draft" import type { RuleForm } from "./form" -import { Message } from "./message" +import * as F from "./form-steps" +import { Message, type SignalKind } from "./message" import type { Model } from "./model" import { applyDefaultDestination, planMetrics, planPreviews } from "./plan" -import { Option, String as Str } from "effect" +import { isProtectedDefault } from "./derive" +import { draftTimingOf } from "./derive-rows" +import { missingCards, RULE_CARDS } from "./summary" +import { Option, String as Str, Array as Arr } from "effect" export type Return = PageReturn @@ -43,15 +47,39 @@ export const initialize = (model: Model, context: PageContext): Return => { isNotesOpen: Str.trim(form.notes).length > 0, isMessageTemplateOpen: form.notificationTitle.length > 0 || form.notificationBody.length > 0, } - const loaded = planned(applyDefaultDestination(ready), context) + // A new rule opens every card; a saved one opens on its summaries, with any gap left open. + const withCards: Model = { + ...ready, + expandedCards: + draft.editingRule === null ? RULE_CARDS : missingCards(form, null, isProtectedDefault(ready)), + } + const loaded = planned(applyDefaultDestination(withCards), context) if (!draft.isShowingTemplates) return loaded const opened = liftTemplates(loaded.model, Dialog.open(loaded.model.templates)) return { model: opened.model, commands: [...(loaded.commands ?? []), ...(opened.commands ?? [])] } } /** An edit to the draft; the preview follows it (`useDeferredValue` over the form). */ +/** Marks a control as left (by element id), so it shows its error from now on. */ +export const touched = (model: Model, id: string): Model => + model.touchedFields.includes(id) ? model : { ...model, touchedFields: [...model.touchedFields, id] } + +/** Opens every card with a save blocker; it stays open once fixed, so it never closes under the cursor. */ +export const withMissingCardsOpen = (model: Model): Model => + model.form === null + ? model + : { + ...model, + expandedCards: Arr.union( + model.expandedCards, + missingCards(model.form, draftTimingOf(model, model.form), isProtectedDefault(model)), + ), + } + export const editForm = (model: Model, context: PageContext, edit: (form: RuleForm) => RuleForm): Return => - model.form === null ? { model } : planned({ ...model, form: edit(model.form) }, context) + model.form === null + ? { model } + : planned(withMissingCardsOpen({ ...model, form: edit(model.form) }), context) /** A list answered: the draft may now be known, and the default destination picked. */ export const followRows = (model: Model, context: PageContext): Return => { @@ -65,3 +93,24 @@ export const followRows = (model: Model, context: PageContext): Return => { return { model: { ...model, form: null, status: "Unreadable" } } return { model: applyDefaultDestination(model) } } + +/** The mode switch: the filters carry over, and a destination the editor chose leaves an anomaly rule. */ +export const switchKindStep = (model: Model, context: PageContext, kind: SignalKind): Return => { + if (model.form === null || isProtectedDefault(model)) return { model } + const moved = F.switchKind(model.form, kind) + // Anomaly paging is opt-in: a destination the editor chose, not the user, does not carry over. + const auto = model.autoDestinationId + const form = + kind === "anomaly" && auto !== null + ? F.updateTierIds( + F.updateTierIds(moved.form, "warning", F.removeId(auto)), + "critical", + F.removeId(auto), + ) + : moved.form + return editForm( + { ...model, kindNotice: moved.notice, autoDestinationId: kind === "anomaly" ? null : auto }, + context, + () => form, + ) +} diff --git a/apps/web/src/page/alerts/editor/sub-steps.ts b/apps/web/src/page/alerts/editor/sub-steps.ts index 75eba25e79..409bb176d3 100644 --- a/apps/web/src/page/alerts/editor/sub-steps.ts +++ b/apps/web/src/page/alerts/editor/sub-steps.ts @@ -13,20 +13,23 @@ import * as QueryPanel from "../../kit/query-builder/panel" import * as TimeRangePicker from "../../kit/time-range-picker" import { isProtectedDefault } from "./derive" import { + BREACH_MODE_SELECT_ID, chainSelectIdOf, + KIND_SELECT_ID, NO_CHAIN, + setBreachMode, setBuiltinSignal, setChips, setSeverity, setTierChain, } from "./form-steps" -import { AUTO_INTERVAL, intervalRadioValue } from "./interval" -import { Message } from "./message" +import { AUTO_INTERVAL, INTERVAL_SELECT_ID } from "./interval" +import { Message, SignalKind } from "./message" import { panelInputsOf } from "./where-inputs" import type { Model } from "./model" import { ReadEditorClock } from "./command" import { planMetrics } from "./plan" -import { editForm, type Return } from "./steps" +import { editForm, type Return, switchKindStep } from "./steps" // The child Submodels the form is built from: selects, chip pickers, the query panel, the chart, // and the preview's own lookback picker. @@ -36,22 +39,17 @@ const decodeReducer = Schema.decodeUnknownOption(QueryEngineAlertReducer) const decodeSignal = Schema.decodeUnknownOption(AlertSignalType) const decodeSeverity = Schema.decodeUnknownOption(AlertSeverity) const decodeChainId = Schema.decodeUnknownOption(EscalationChainId) +const decodeKind = Schema.decodeUnknownOption(SignalKind) -/** The radio groups: built-in signal, severity and check interval; their value is the form's. */ +/** The radio groups: built-in signal and severity; their value is the form's. */ export const radioStep = ( model: Model, context: PageContext, - group: "signal" | "severity" | "interval", + group: "signal" | "severity", message: RadioGroup.Message, ): Return => { const current = - model.form === null - ? null - : group === "signal" - ? model.form.signalType - : group === "severity" - ? model.form.severity - : intervalRadioValue(model.form.evaluationIntervalMinutes) + model.form === null ? null : group === "signal" ? model.form.signalType : model.form.severity const next = RadioGroup.update(RadioGroup.init(current ?? undefined), message) const commands = Command.mapMessages(next.commands ?? [], (inner) => Message.GotRadioMessage({ group, message: inner }), @@ -59,9 +57,6 @@ export const radioStep = ( const value = next.model.maybeValue if (value._tag === "None" || value.value === current) return { model, commands } const edited = editForm(model, context, (form) => { - if (group === "interval") { - return { ...form, evaluationIntervalMinutes: value.value === AUTO_INTERVAL ? "" : value.value } - } if (group === "signal") { const signal = decodeSignal(value.value) return signal._tag === "Some" ? setBuiltinSignal(form, signal.value) : form @@ -91,7 +86,24 @@ export const selectStep = ( ) if (next.outMessage === undefined) return { model: lifted, commands } const value = next.outMessage.value + if (id === KIND_SELECT_ID) { + const kind = decodeKind(value) + return kind._tag === "Some" + ? withCommands(switchKindStep(lifted, context, kind.value), commands) + : { model: lifted, commands } + } + if (id === BREACH_MODE_SELECT_ID) { + return withCommands( + editForm({ ...lifted, isBreachWindowOpen: value === "window" }, context, (form) => + setBreachMode(form, value), + ), + commands, + ) + } const edited = editForm(lifted, context, (form) => { + if (id === INTERVAL_SELECT_ID) { + return { ...form, evaluationIntervalMinutes: value === AUTO_INTERVAL ? "" : value } + } const tier = id === chainSelectIdOf("warning") ? "warning" diff --git a/apps/web/src/page/alerts/editor/summary.test.ts b/apps/web/src/page/alerts/editor/summary.test.ts new file mode 100644 index 0000000000..8c043972b2 --- /dev/null +++ b/apps/web/src/page/alerts/editor/summary.test.ts @@ -0,0 +1,300 @@ +import { AlertDestinationId, AlertQueryTiming, EscalationChainId } from "@maple/domain/http" +import { anomalyRuleForm, defaultRuleForm } from "@maple/web-data/alerts/form-utils" +import { Schema } from "effect" +import { describe, expect, it } from "vitest" +import { fromFormState, type RuleForm } from "./form" +import { setBreachMode } from "./form-steps" +import { + cardIssues, + cardOfIssue, + cardsOf, + conditionSummary, + isProblemIssue, + issueFieldId, + missingCards, + nameSummary, + notifySummary, + type NameLookup, + type NotifyNames, + revealedInvalidFields, + watchSummary, +} from "./summary" + +const destinationId = Schema.decodeSync(AlertDestinationId) +const chainId = Schema.decodeSync(EscalationChainId) +const ONCALL = destinationId("0199a001-0000-7000-8000-000000000001") +const PAGER = destinationId("0199a001-0000-7000-8000-000000000002") +const GONE = destinationId("0199a001-0000-7000-8000-000000000009") +const CHAIN = chainId("0199a00e-0000-7000-8000-000000000001") + +const form = (fields: Partial = {}): RuleForm => ({ + ...fromFormState(defaultRuleForm()), + name: "Checkout errors", + destinationIds: [ONCALL], + ...fields, +}) + +const found = (name: string): NameLookup => ({ _tag: "Found", name }) +const GONE_LOOKUP: NameLookup = { _tag: "Gone" } +const NAMES: NotifyNames = { + destination: (id) => + id === ONCALL ? found("#oncall") : id === PAGER ? found("PagerDuty Checkout") : GONE_LOOKUP, + chain: (id) => (id === CHAIN ? found("Checkout primary") : GONE_LOOKUP), +} +const LOADING: NotifyNames = { destination: () => ({ _tag: "Loading" }), chain: () => ({ _tag: "Loading" }) } + +/** One check of the SQL takes 7.8s: too slow for 1-minute checks, fits 5-minute ones. */ +const SLOW = Schema.decodeSync(AlertQueryTiming)({ + elapsedMs: 7_800, + failure: null, + probeLimitMs: 25_000, + intervalMinutes: 1, + limitMs: 5_000, + fitsIntervalMinutes: [5, 10, 15, 30, 60], +}) + +const criticalTier = (fields: Partial> = {}) => ({ + threshold: "10", + recoveryValue: "", + destinationIds: [], + escalationChainId: null, + ...fields, +}) + +describe("watchSummary", () => { + it("names a built-in signal with its services and environments", () => { + expect(watchSummary(form({ serviceNames: ["checkout-api"], environments: ["production"] }))).toBe( + "Error rate on checkout-api in production", + ) + expect(watchSummary(form({ signalType: "p95_latency", excludeServiceNames: ["batch"] }))).toBe( + "P95 latency on all services except batch", + ) + }) + + it("shortens a long service list and says how the rule is split", () => { + expect(watchSummary(form({ signalType: "throughput", serviceNames: ["a", "b", "c", "d"] }))).toBe( + "Throughput on a, b and 2 more", + ) + expect(watchSummary(form({ signalType: "apdex", groupBy: ["http.route"] }))).toBe( + "Apdex on all services per http.route", + ) + }) + + it("reads a builder query as its aggregation, source and where clause", () => { + const base = form({ signalType: "builder_query" }) + const draft = { ...base.queryBuilderDraft, whereClause: 'service.name = "checkout"' } + expect(watchSummary({ ...base, queryBuilderDraft: draft })).toBe( + 'Error rate of traces, where service.name = "checkout", per service.name', + ) + }) + + it("reads a SQL rule by its reducer", () => { + expect(watchSummary(form({ signalType: "raw_query", rawQueryReducer: "max" }))).toBe( + "SQL query, maximum of value", + ) + }) + + it("reads an anomaly rule by its scope, and the default rule as the catch-all", () => { + const anomaly = fromFormState(anomalyRuleForm(form({ serviceNames: ["checkout"] }))) + expect(watchSummary(anomaly)).toBe("Anomalies in checkout · all environments · all signals") + expect(watchSummary(fromFormState(anomalyRuleForm(form())), true)).toBe( + "Every anomaly no narrower rule handles", + ) + }) +}) + +describe("conditionSummary", () => { + it("says the threshold with its unit and how many checks fire it", () => { + expect(conditionSummary(form({ consecutiveBreachesRequired: "3" }))).toBe( + "Above 5%, in 3 checks in a row (5 min each), resolves after 2 healthy checks", + ) + }) + + it("adds the critical tier, in the same direction", () => { + expect( + conditionSummary( + form({ comparator: "lt", threshold: "20", escalation: criticalTier({ threshold: "10" }) }), + ), + ).toBe( + "Below 20%, critical below 10%, in 2 checks in a row (5 min each), resolves after 2 healthy checks", + ) + }) + + it("reads a range on a latency signal, and a single critical tier", () => { + expect( + conditionSummary( + form({ + signalType: "p95_latency", + comparator: "between", + threshold: "200", + thresholdUpper: "800", + severity: "critical", + consecutiveBreachesRequired: "1", + consecutiveHealthyRequired: "1", + }), + ), + ).toBe("Between 200 ms and 800 ms (critical), over 5 min") + }) + + it("reads a breach window and alerting on no data", () => { + expect( + conditionSummary( + form({ consecutiveBreachesRequired: "3", breachesOutOf: "5", alertOnNoData: true }), + ), + ).toBe( + "Above 5%, in 3 of the last 5 checks (5 min each), resolves after 2 healthy checks, alerts on no data", + ) + }) + + it("says the recovery value when one is set", () => { + expect(conditionSummary(form({ recoveryValue: "4", consecutiveHealthyRequired: "1" }))).toBe( + "Above 5%, in 2 checks in a row (5 min each), recovers below 4%", + ) + }) + + it("marks a blank threshold", () => { + expect(conditionSummary(form({ threshold: "" }))).toMatch(/^Above \?%/) + }) +}) + +describe("notifySummary", () => { + it("lists the destinations, and the chain after them", () => { + expect(notifySummary(form(), NAMES)).toBe("#oncall") + expect( + notifySummary(form({ destinationIds: [ONCALL, PAGER], escalationChainId: CHAIN }), NAMES), + ).toBe("#oncall and PagerDuty Checkout, then escalate to Checkout primary") + }) + + it("says where the critical tier goes", () => { + expect(notifySummary(form({ escalation: criticalTier({ escalationChainId: CHAIN }) }), NAMES)).toBe( + "#oncall, critical escalates to Checkout primary", + ) + expect(notifySummary(form({ escalation: criticalTier({ destinationIds: [PAGER] }) }), NAMES)).toBe( + "#oncall, critical also PagerDuty Checkout", + ) + }) + + it("reads a rule routed only by a chain, or by nobody", () => { + expect(notifySummary(form({ destinationIds: [], escalationChainId: CHAIN }), NAMES)).toBe( + "Escalate to Checkout primary", + ) + expect(notifySummary(form({ destinationIds: [] }), NAMES)).toBe("No one is notified") + }) + + it("names a destination the org no longer has instead of dropping it", () => { + expect(notifySummary(form({ destinationIds: [GONE] }), NAMES)).toBe("A removed destination") + }) + + it("never calls a destination removed while the lists are still loading", () => { + expect(notifySummary(form({ destinationIds: [GONE], escalationChainId: CHAIN }), LOADING)).toBe( + "A destination, then escalate to a chain", + ) + }) + + it("names a destination on both tiers once", () => { + expect(notifySummary(form({ escalation: criticalTier({ destinationIds: [ONCALL] }) }), NAMES)).toBe( + "#oncall", + ) + }) + + it("reads a rule routed only on its critical tier as the critical route", () => { + const anomaly = fromFormState( + anomalyRuleForm(form({ serviceNames: ["checkout"], destinationIds: [] })), + ) + const criticalOnly = { + ...anomaly, + escalation: { ...criticalTier(), ...anomaly.escalation, destinationIds: [PAGER] }, + } + expect(notifySummary(criticalOnly, NAMES)).toBe("Critical: PagerDuty Checkout") + }) + + it("reads both tiers of an anomaly rule", () => { + const anomaly = fromFormState(anomalyRuleForm(form({ serviceNames: ["checkout"] }))) + const routed = { + ...anomaly, + escalation: { ...criticalTier(), ...anomaly.escalation, destinationIds: [PAGER] }, + } + expect(notifySummary(routed, NAMES)).toBe("#oncall, critical also PagerDuty Checkout") + }) +}) + +describe("nameSummary", () => { + it("is the name, then its tags", () => { + expect(nameSummary(form({ tags: ["checkout", "tier-1"] }))).toBe( + "Checkout errors · checkout and tier-1", + ) + expect(nameSummary(form({ name: " " }))).toBe("Unnamed rule") + }) +}) + +describe("field errors", () => { + it("tells a problem to fix from a field left empty, so a card says only empty fields are missing", () => { + expect(isProblemIssue("Threshold")).toBe(false) + expect(isProblemIssue("Rule name")).toBe(false) + expect(isProblemIssue("Critical threshold past warning")).toBe(true) + expect(isProblemIssue("Critical recovery past warning")).toBe(true) + expect(isProblemIssue("Check interval longer than the window")).toBe(true) + expect(isProblemIssue("Query too slow for this check interval")).toBe(true) + }) + + it("points each issue at the control that fixes it", () => { + expect(issueFieldId("Threshold")).toBe("rule-threshold") + expect(issueFieldId("Critical threshold past warning")).toBe("rule-critical-threshold") + expect(issueFieldId("Query too slow for this check interval")).toBe("rule-interval-trigger") + expect(issueFieldId("Who gets notified")).toBe("rule-chain-warning-trigger") + // A query that does not compile has no one field: its card's heading is the target. + expect(issueFieldId("Query: pick a metric")).toBeNull() + expect(cardOfIssue("Query: pick a metric")).toBe("watch") + }) + + it("shows no field error on a pristine form, one a left field has, and all after a save attempt", () => { + const issues = ["Rule name", "Threshold", "Who gets notified"] + expect(revealedInvalidFields(issues, [], false)).toEqual([]) + expect(revealedInvalidFields(issues, ["rule-threshold", "rule-window-minutes"], false)).toEqual([ + "rule-threshold", + ]) + expect(revealedInvalidFields(issues, [], true)).toEqual([ + "rule-name", + "rule-threshold", + "rule-chain-warning-trigger", + ]) + }) +}) + +describe("setBreachMode", () => { + it("starts the window two past the breaches to fire, keeps one already typed, and clears it in a row", () => { + expect(setBreachMode(form({ consecutiveBreachesRequired: "3" }), "window").breachesOutOf).toBe("5") + expect(setBreachMode(form({ consecutiveBreachesRequired: "" }), "window").breachesOutOf).toBe("5") + expect(setBreachMode(form({ breachesOutOf: "8" }), "window").breachesOutOf).toBe("8") + expect(setBreachMode(form({ breachesOutOf: "8" }), "row").breachesOutOf).toBe("") + }) +}) + +describe("card issues", () => { + it("puts each missing field on the card that edits it", () => { + expect(missingCards(form(), null)).toEqual([]) + expect(missingCards(form({ name: "" }), null)).toEqual(["name"]) + expect(missingCards(form({ threshold: "" }), null)).toEqual(["condition"]) + expect(missingCards(form({ destinationIds: [] }), null)).toEqual(["notify"]) + expect( + cardIssues(form({ signalType: "raw_query", rawQuerySql: "SELECT 1 AS value" }), "watch", null), + ).toEqual(["$__orgFilter in SQL"]) + expect(cardIssues(form({ escalation: criticalTier() }), "notify", null)).toEqual([ + "Critical destinations", + ]) + }) + + it("puts a query too slow for its interval on Alert when, as a save blocker", () => { + const sql = form({ signalType: "raw_query" }) + expect(missingCards(sql, null)).toEqual([]) + expect(cardIssues(sql, "condition", SLOW)).toEqual(["Query too slow for this check interval"]) + expect(missingCards(sql, SLOW)).toEqual(["condition"]) + }) + + it("drops the condition card for an anomaly rule, whose scope is required unless it is the default", () => { + const unscoped = fromFormState(anomalyRuleForm(form())) + expect(cardsOf(unscoped)).toEqual(["watch", "notify", "name"]) + expect(missingCards(unscoped, null)).toEqual(["watch"]) + expect(missingCards(unscoped, null, true)).toEqual([]) + }) +}) diff --git a/apps/web/src/page/alerts/editor/summary.ts b/apps/web/src/page/alerts/editor/summary.ts new file mode 100644 index 0000000000..f541ee943e --- /dev/null +++ b/apps/web/src/page/alerts/editor/summary.ts @@ -0,0 +1,264 @@ +import { Array as Arr, String as Str } from "effect" +import type { AlertDestinationId, AlertQueryTiming, EscalationChainId } from "@maple/domain/http" +import { + comparatorSupportsTiers, + isRangeComparator, + RAW_QUERY_REDUCER_LABELS, + signalLabels, +} from "@maple/web-data/alerts/form-utils" +import { anomalyScopeSummary } from "@maple/web-data/alerts/anomaly-rule" +import type { RuleCard, RuleForm } from "./form" +import { + checksPhrase, + comparatorWords, + conditionSubject, + groupBySummary, + recoveryLabel, + saveIssues, + thresholdValue, +} from "./derive" + +// Blockers that are a problem with the rule, not a field left empty: a card says them in full. +const PROBLEM_ISSUES: ReadonlyArray = [ + "Critical threshold past warning", + "Critical recovery past warning", + "Check interval longer than the window", + "Query too slow for this check interval", +] + +/** Whether a blocker is a problem to fix (said as it is), not a missing field (said after "Missing:"). */ +export const isProblemIssue = (issue: string): boolean => Arr.contains(PROBLEM_ISSUES, issue) + +// The rule editor's sentence cards: which card each field lives on, and each card's one-line summary. + +export const RULE_CARDS: ReadonlyArray = ["watch", "condition", "notify", "name"] + +/** The cards an anomaly rule shows: the detector decides when, so it has no condition card. */ +export const cardsOf = (form: RuleForm): ReadonlyArray => + form.signalType === "anomaly" ? Arr.filter(RULE_CARDS, (card) => card !== "condition") : RULE_CARDS + +const WATCH_ISSUES = new Set(["SQL query", "$__orgFilter in SQL", "SQL value column", "Scope"]) +const NOTIFY_ISSUES = new Set(["Who gets notified", "Critical destinations"]) + +/** The card an issue is fixed on. */ +export const cardOfIssue = (issue: string): RuleCard => + issue === "Rule name" + ? "name" + : NOTIFY_ISSUES.has(issue) + ? "notify" + : WATCH_ISSUES.has(issue) || Str.startsWith("Query:")(issue) + ? "watch" + : "condition" + +// Each issue's control, by element id. A query that does not compile has no one field. +const ISSUE_FIELDS = new Map([ + ["Rule name", "rule-name"], + ["Threshold", "rule-threshold"], + ["Upper threshold", "rule-threshold-upper"], + ["Recovery value", "rule-recovery"], + ["Critical threshold", "rule-critical-threshold"], + ["Critical threshold past warning", "rule-critical-threshold"], + ["Critical recovery past warning", "rule-critical-recovery"], + ["Critical recovery value", "rule-critical-recovery"], + ["Window", "rule-window-minutes"], + ["Checks to fire", "rule-consecutive-breaches"], + ["Breach window", "rule-breaches-out-of"], + ["Min samples", "rule-minimum-samples"], + ["Checks to resolve", "rule-consecutive-healthy"], + ["Remind interval", "rule-renotify"], + ["Check interval longer than the window", "rule-interval-trigger"], + ["Query too slow for this check interval", "rule-interval-trigger"], + ["SQL query", "alert-rule-sql"], + ["$__orgFilter in SQL", "alert-rule-sql"], + ["SQL value column", "alert-rule-sql"], + ["Scope", "rule-services-input"], + ["Who gets notified", "rule-chain-warning-trigger"], + ["Critical destinations", "rule-chain-critical-trigger"], +]) + +/** The control that fixes an issue, by element id; `null` when the card itself is the best target. */ +export const issueFieldId = (issue: string): string | null => ISSUE_FIELDS.get(issue) ?? null + +/** + * The fields that show their error: one with an issue, once the user left it or tried to save. + * A pristine form shows none, only the card's "Missing" note. + */ +export const revealedInvalidFields = ( + issues: ReadonlyArray, + touchedFields: ReadonlyArray, + hasAttemptedSave: boolean, +): ReadonlyArray => + Arr.dedupe( + Arr.filter( + Arr.flatMap(issues, (issue) => { + const id = issueFieldId(issue) + return id === null ? [] : [id] + }), + (id) => hasAttemptedSave || touchedFields.includes(id), + ), + ) + +/** + * What blocks saving on one card: the action bar's own list (`saveIssues`), so a closed card never + * hides a blocker. A query too slow for its check interval lands on "Alert when", where the interval is. + */ +export const cardIssues = ( + form: RuleForm, + card: RuleCard, + timing: AlertQueryTiming | null, + isDefault = false, +): ReadonlyArray => + Arr.filter(saveIssues(form, timing, isDefault), (issue) => cardOfIssue(issue) === card) + +/** The cards with something missing, which stay open until it is filled in. */ +export const missingCards = ( + form: RuleForm, + timing: AlertQueryTiming | null, + isDefault = false, +): ReadonlyArray => + Arr.filter(cardsOf(form), (card) => cardIssues(form, card, timing, isDefault).length > 0) + +const capitalize = (text: string): string => Str.toUpperCase(Str.slice(0, 1)(text)) + Str.slice(1)(text) + +/** "a, b and c", or "a, b and 3 more" past three names. */ +const listOf = (names: ReadonlyArray): string => { + if (names.length <= 1) return Arr.join(names, "") + const shown = names.length > 3 ? Arr.take(names, 2) : Arr.take(names, names.length - 1) + const rest = names.length > 3 ? `${names.length - 2} more` : (names[names.length - 1] ?? "") + return `${Arr.join(shown, ", ")} and ${rest}` +} + +const builtinScope = (form: RuleForm): ReadonlyArray => [ + form.serviceNames.length === 0 ? "on all services" : `on ${listOf(form.serviceNames)}`, + ...(form.serviceNames.length === 0 && form.excludeServiceNames.length > 0 + ? [`except ${listOf(form.excludeServiceNames)}`] + : []), + ...(form.environments.length > 0 ? [`in ${listOf(form.environments)}`] : []), +] + +/** The "When" card: the signal and what it watches. "Error rate on checkout-api in production". */ +export const watchSummary = (form: RuleForm, isDefault = false): string => { + if (form.signalType === "anomaly") + return isDefault + ? "Every anomaly no narrower rule handles" + : `Anomalies in ${anomalyScopeSummary(form)}` + const groupBy = groupBySummary(form) + const perGroup = groupBy === null ? [] : [`per ${groupBy}`] + if (form.signalType === "raw_query") + return `SQL query, ${Str.toLowerCase(RAW_QUERY_REDUCER_LABELS[form.rawQueryReducer])} of value` + if (form.signalType === "builder_query") { + const draft = form.queryBuilderDraft + const source = + draft.dataSource === "metrics" && draft.metricName.length > 0 + ? draft.metricName + : draft.dataSource + const where = Str.trim(draft.whereClause) + return Arr.join( + [ + `${capitalize(conditionSubject(form))} of ${source}`, + ...(where.length > 0 ? [`where ${where}`] : []), + ...perGroup, + ], + ", ", + ) + } + return Arr.join([signalLabels[form.signalType], ...builtinScope(form), ...perGroup], " ") +} + +/** The "Alert when" card: "Above 5%, critical above 10%, in 3 checks in a row (5 min each)". */ +export const conditionSummary = (form: RuleForm): string => { + const words = Str.replace(/^is /, "")(comparatorWords(form.comparator)) + const threshold = isRangeComparator(form.comparator) + ? `${thresholdValue(form, form.threshold)} and ${thresholdValue(form, form.thresholdUpper)}` + : thresholdValue(form, form.threshold) + const escalation = comparatorSupportsTiers(form.comparator) ? form.escalation : null + const healthy = Str.trim(form.consecutiveHealthyRequired) + const recovery = comparatorSupportsTiers(form.comparator) ? Str.trim(form.recoveryValue) : "" + return Arr.join( + [ + `${capitalize(words)} ${threshold}${escalation === null && form.severity === "critical" ? " (critical)" : ""}`, + ...(escalation === null + ? [] + : [`critical ${words} ${thresholdValue(form, escalation.threshold)}`]), + checksPhrase(form), + ...(recovery.length > 0 + ? [`${Str.toLowerCase(recoveryLabel(form.comparator))} ${thresholdValue(form, recovery)}`] + : []), + ...(healthy.length > 0 && healthy !== "1" ? [`resolves after ${healthy} healthy checks`] : []), + ...(form.alertOnNoData ? ["alerts on no data"] : []), + ], + ", ", + ) +} + +/** A destination or chain by id: its list still loading, its name, or gone from the loaded list. */ +export type NameLookup = + | Readonly<{ _tag: "Loading" }> + | Readonly<{ _tag: "Found"; name: string }> + | Readonly<{ _tag: "Gone" }> + +/** How the summary names destinations and chains. */ +export type NotifyNames = Readonly<{ + destination: (id: AlertDestinationId) => NameLookup + chain: (id: EscalationChainId) => NameLookup +}> + +const nameOf = (lookup: NameLookup, noun: string): string => + lookup._tag === "Found" ? lookup.name : lookup._tag === "Gone" ? `a removed ${noun}` : `a ${noun}` + +type Route = Readonly<{ + destinationIds: ReadonlyArray + escalationChainId: EscalationChainId | null +}> + +const routeText = (route: Route, names: NotifyNames): string | null => { + const destinations = Arr.map(route.destinationIds, (id) => nameOf(names.destination(id), "destination")) + const chain = + route.escalationChainId === null ? null : nameOf(names.chain(route.escalationChainId), "chain") + if (destinations.length === 0) return chain === null ? null : `escalate to ${chain}` + return chain === null ? listOf(destinations) : `${listOf(destinations)}, then escalate to ${chain}` +} + +/** The "Notify" card: "#oncall, critical escalates to Checkout on-call". */ +export const notifySummary = (form: RuleForm, names: NotifyNames): string => { + const primary = routeText(form, names) + const tiered = form.signalType === "anomaly" || comparatorSupportsTiers(form.comparator) + // Critical pages on top of warning, so a destination already on warning is not named twice. + const critical = + tiered && form.escalation !== null + ? routeText( + { + ...form.escalation, + destinationIds: Arr.filter( + form.escalation.destinationIds, + (id) => !form.destinationIds.includes(id), + ), + }, + names, + ) + : null + if (primary === null && critical === null) return "No one is notified" + // With no warning route, critical is the whole route: "Critical: #pager", not "Critical also". + const criticalText = + critical === null + ? null + : Str.startsWith("escalate to ")(critical) + ? `critical ${Str.replace(/^escalate/, "escalates")(critical)}` + : primary === null + ? `critical: ${critical}` + : `critical also ${critical}` + // Only our own words are capitalised: a destination keeps its spelling ("#oncall", an email). + const lead = (text: string) => (/^(escalate|critical|a |an )/.test(text) ? capitalize(text) : text) + if (primary === null) return lead(criticalText ?? "") + return Arr.join( + Arr.filter([lead(primary), criticalText], (part): part is string => part !== null), + ", ", + ) +} + +/** The "Name" card: the name, then its tags. */ +export const nameSummary = (form: RuleForm): string => { + const name = Str.trim(form.name) + const tags = form.tags.length === 0 ? [] : [listOf(form.tags)] + return Arr.join([name.length > 0 ? name : "Unnamed rule", ...tags], " · ") +} diff --git a/apps/web/src/page/alerts/editor/update.ts b/apps/web/src/page/alerts/editor/update.ts index 206340027a..e3fdec106d 100644 --- a/apps/web/src/page/alerts/editor/update.ts +++ b/apps/web/src/page/alerts/editor/update.ts @@ -7,16 +7,29 @@ import { Array as Arr, Result } from "effect" import type { PageContext } from "../../contract" import * as DestinationDialog from "../destination/dialog" import { completeSave, completeTest, runTest, save } from "./actions" -import { FetchEditorChains, FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" +import { FetchEditorChains, FocusRuleCard, FocusSqlCaret, InsertSqlToken, ReloadPage } from "./command" +import { sentenceCardHeadingId, sentenceCardSummaryId } from "../../kit/sentence-card" import { SQL_TEXTAREA_ID } from "./view-sql" import { chatConnectStep, connectorsAnswered, liftDialog, menuStep, removeDestination } from "./dest-steps" import { isProtectedDefault, suggestedName } from "./derive" import { tagSuggestionsOf } from "./derive-rows" +import { triggerId } from "@maple/views/ui/overlay/popup-state" +import { chainSelectIdOf } from "./form-steps" +import { cardOfIssue, issueFieldId } from "./summary" import * as F from "./form-steps" import { Message } from "./message" import type { Model } from "./model" import { planAutocomplete, planPreviews } from "./plan" -import { editForm, followRows, initialize, liftTemplates, type Return } from "./steps" +import { + editForm, + followRows, + initialize, + liftTemplates, + type Return, + switchKindStep, + touched, + withMissingCardsOpen, +} from "./steps" import { chartStep, comboStep, queryPanelStep, radioStep, selectStep, timePickerStep } from "./sub-steps" const AUTOCOMPLETE_PRESET = "24h" @@ -45,6 +58,11 @@ const readClock = (model: Model, context: PageContext, nowMs: number): Return => return { model: previewed.model, commands: [...looked.commands, ...previewed.commands] } } +const isChainSelect = (id: string): boolean => + id === chainSelectIdOf("warning") || id === chainSelectIdOf("critical") +// The select was left: a pick, Escape or a press outside. +const LEFT_SELECT = new Set(["SelectedItem", "PressedEscape", "PressedOutside"]) + const closeTemplates = (model: Model): Return => liftTemplates(model, Dialog.close(model.templates)) export const update = (model: Model, message: Message, context: PageContext): Return => @@ -126,14 +144,15 @@ export const update = (model: Model, message: Message, context: PageContext): Re }, // Both slots take an answer for their key: the replay can share the draft's first request. CompletedFetchPreview: ({ key, result }) => ({ - model: { + // A timing that lands as too slow opens "Alert when", where the interval is picked. + model: withMissingCardsOpen({ ...model, preview: model.preview.key === key ? Keyed.accept(model.preview, key, result) : model.preview, savedPreview: model.savedPreview.key === key ? Keyed.accept(model.savedPreview, key, result) : model.savedPreview, - }, + }), }), ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), CompletedLoadChartRuntime: () => ({ model: { ...model, chartRuntime: { _tag: "Ready" } } }), @@ -142,25 +161,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), GotChartMessage: ({ message: inner }) => chartStep(model, inner), GotTimePickerMessage: ({ message: inner }) => timePickerStep(model, inner), - ClickedSignalKind: ({ kind }) => { - if (model.form === null || isProtectedDefault(model)) return { model } - const moved = F.switchKind(model.form, kind) - // Anomaly paging is opt-in: a destination the editor chose, not the user, does not carry over. - const auto = model.autoDestinationId - const form = - kind === "anomaly" && auto !== null - ? F.updateTierIds( - F.updateTierIds(moved.form, "warning", F.removeId(auto)), - "critical", - F.removeId(auto), - ) - : moved.form - return editForm( - { ...model, kindNotice: moved.notice, autoDestinationId: kind === "anomaly" ? null : auto }, - context, - () => form, - ) - }, + ClickedSignalKind: ({ kind }) => switchKindStep(model, context, kind), GotRadioMessage: ({ group, message: inner }) => radioStep(model, context, group, inner), ClickedOpenInBuilder: () => { if (model.form === null) return { model } @@ -172,7 +173,14 @@ export const update = (model: Model, message: Message, context: PageContext): Re ? { model } : editForm(model, context, (form) => F.toggleAnomalySignal(form, signal)), GotQueryPanelMessage: ({ message: inner }) => queryPanelStep(model, context, inner), - GotSelectMessage: ({ id, message: inner }) => selectStep(model, context, id, inner), + GotSelectMessage: ({ id, message: inner }) => + selectStep( + // Leaving a tier's chain picker without a route shows its error from then on. + isChainSelect(id) && LEFT_SELECT.has(inner._tag) ? touched(model, triggerId(id)) : model, + context, + id, + inner, + ), GotComboMessage: ({ id, message: inner }) => comboStep(model, context, id, inner), ChangedField: ({ field, value }) => editForm(model, context, (form) => F.setText(form, field, value)), ChangedEscalationField: ({ field, value }) => @@ -214,6 +222,24 @@ export const update = (model: Model, message: Message, context: PageContext): Re editForm({ ...model, tagDraft: "" }, context, (form) => F.commitTag(form, model.tagDraft)), ClickedRemoveTag: ({ tag }) => editForm(model, context, (form) => F.removeTag(form, tag)), ClickedAddNotes: () => ({ model: { ...model, isNotesOpen: true } }), + ClickedExpandCard: ({ card }) => ({ + model: { ...model, expandedCards: Arr.union(model.expandedCards, [card]) }, + commands: [FocusRuleCard({ id: sentenceCardHeadingId(card) })], + }), + ClickedCollapseCard: ({ card }) => ({ + model: { ...model, expandedCards: Arr.filter(model.expandedCards, (open) => open !== card) }, + commands: [FocusRuleCard({ id: sentenceCardSummaryId(card) })], + }), + CompletedFocusCard: () => ({ model }), + BlurredField: ({ id }) => ({ model: touched(model, id) }), + ClickedIssue: ({ issue }) => { + const card = cardOfIssue(issue) + const id = issueFieldId(issue) ?? sentenceCardHeadingId(card) + return { + model: touched({ ...model, expandedCards: Arr.union(model.expandedCards, [card]) }, id), + commands: [FocusRuleCard({ id })], + } + }, ToggledSqlCollapsed: () => ({ model: { ...model, isSqlCollapsed: !model.isSqlCollapsed } }), ClickedSqlToken: ({ token }) => model.form === null @@ -237,7 +263,8 @@ export const update = (model: Model, message: Message, context: PageContext): Re }), ClickedTemplateVariable: ({ key }) => editForm(model, context, (form) => F.appendVariable(form, key)), GotMenuMessage: ({ id, message: inner }) => menuStep(model, id, inner), - ClickedRemoveDestination: ({ tier, id }) => removeDestination(model, tier, id), + ClickedRemoveDestination: ({ tier, id }) => + removeDestination(touched(model, triggerId(chainSelectIdOf(tier))), tier, id), GotDestinationDialogMessage: ({ message: inner }) => liftDialog(model, DestinationDialog.update(model.destinationDialog, inner)), GotIssuesTooltipMessage: ({ message: inner }) => { diff --git a/apps/web/src/page/alerts/editor/view-action-bar.ts b/apps/web/src/page/alerts/editor/view-action-bar.ts index dbf195f94e..cda4d2233a 100644 --- a/apps/web/src/page/alerts/editor/view-action-bar.ts +++ b/apps/web/src/page/alerts/editor/view-action-bar.ts @@ -64,31 +64,64 @@ const issuesDetails = (h: H, model: BarView, issues: ReadonlyArray): Htm }, }) -/** `ValidationSummary`. */ -const validationSummary = (h: H, model: BarView, issues: ReadonlyArray): Html => { - if (issues.length === 0) - return hstack( - h, - { - gap: "1.5", - as: "span", - className: typographyClass({ size: "xs", className: "text-severity-info" }), - }, - [icon(h, "CheckIcon", { size: 14 }), "Ready to save"], - ) - const visible = issues.slice(0, MAX_VISIBLE_ISSUES) - const hiddenCount = issues.length - visible.length - const summary = Arr.join(visible, ", ") + (hiddenCount > 0 ? ` +${hiddenCount} more` : "") - return h.span( - [h.Class(cn("flex min-w-0 items-center gap-1.5 truncate text-xs text-muted-foreground"))], - [ +/** A blocker as a jump link: it opens the blocker's card and focuses its field. */ +const issueLink = (h: H, issue: string): Html => + button( + h, + { + variant: "link", + size: "xs", + className: "h-9 px-0 sm:h-7", + attributes: [h.Key(issue), h.OnClick(Message.ClickedIssue({ issue }))], + }, + [issue], + ) + +/** + * `ValidationSummary`: one state at a time. Nothing to save says so; a valid edit is ready; a + * blocked one lists what to fix, each item a jump to its field. + */ +const validationSummary = (h: H, model: BarView, issues: ReadonlyArray, isClean: boolean): Html => { + if (issues.length > 0) { + const visible = issues.slice(0, MAX_VISIBLE_ISSUES) + const hiddenCount = issues.length - visible.length + return hstack(h, { align: "center", wrap: true, shrink: true, className: "gap-x-1.5 gap-y-0.5" }, [ icon(h, "CircleWarningIcon", { size: 14, className: "shrink-0 text-severity-warn" }), - typography(h, { truncate: true }, [ - "Fix before saving: ", - typography(h, { tone: "foreground" }, [summary]), + typography(h, { size: "xs", tone: "muted" }, ["Fix before saving:"]), + ...Arr.flatMap(visible, (issue, index) => [ + issueLink(h, issue), + ...(index < visible.length - 1 + ? [ + typography( + h, + { + size: "xs", + tone: "muted", + className: "-ml-1.5", + attributes: [h.AriaHidden(true)], + }, + [","], + ), + ] + : []), ]), - ...(hiddenCount > 0 ? [issuesDetails(h, model, issues)] : []), - ], + ...(hiddenCount > 0 + ? [ + typography(h, { size: "xs", tone: "muted" }, [`+${hiddenCount} more`]), + issuesDetails(h, model, issues), + ] + : []), + ]) + } + if (isClean) return typography(h, { size: "xs", tone: "muted", as: "p" }, ["No changes"]) + return hstack( + h, + { + gap: "1.5", + as: "span", + className: typographyClass({ size: "xs", className: "text-severity-info" }), + }, + [icon(h, "CheckIcon", { size: 14 }), "Ready to save"], ) } @@ -118,28 +151,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", + 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({}, "rules") }, ["Cancel"]), @@ -147,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..acb6883f18 100644 --- a/apps/web/src/page/alerts/editor/view-details.ts +++ b/apps/web/src/page/alerts/editor/view-details.ts @@ -4,8 +4,7 @@ import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { bareInput } from "@maple/views/ui/bare-input" import { badge } from "@maple/views/ui/display" import { button } from "@maple/views/ui/button" -import { card } from "@maple/views/ui/card" -import { field, fieldDescription, fieldLabel, input } from "@maple/views/ui/field" +import { field, fieldDescription, fieldError, fieldLabel, input } from "@maple/views/ui/field" import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import { textarea } from "@maple/views/ui/textarea" @@ -86,10 +85,10 @@ const tagInput = (h: H, model: DetailsView, form: RuleForm): Html => { ) } -const nameField = (h: H, model: DetailsView, form: RuleForm): Html => { +const nameField = (h: H, model: DetailsView, form: RuleForm, isNameInvalid: boolean): Html => { const suggestion = suggestedName(form) const isSuggesting = Str.trim(form.name).length === 0 && suggestion !== null && suggestion.length > 0 - const state = fieldStateOf(form.name, model.mountedForm?.name) + const state = { ...fieldStateOf(form.name, model.mountedForm?.name), isInvalid: isNameInvalid } const ids = idsOf("rule-name", false) return field(h, { className: "items-stretch gap-1.5", state }, [ hstack(h, { gap: "2", justify: "between", className: "min-w-0" }, [ @@ -122,8 +121,11 @@ const nameField = (h: H, model: DetailsView, form: RuleForm): Html => { ), h.Value(form.name), h.OnInput((value) => Message.ChangedField({ field: "name", value })), + h.OnBlur(Message.BlurredField({ id: ids.control })), ], }), + // The invalid input names this id in `aria-describedby`: the two render together. + ...(isNameInvalid ? [fieldError(h, { ids, state }, "Give the rule a name.")] : []), ]) } @@ -160,30 +162,24 @@ const notes = (h: H, model: DetailsView, form: RuleForm): Html => { ]) } -const details = (h: H, model: DetailsView, form: RuleForm): Html => { +const details = (h: H, model: DetailsView, form: RuleForm, isNameInvalid: boolean): Html => { const tagIds = idsOf("rule-tags", true) const tagState = fieldStateOf("", undefined) - return card( - h, + return h.div( + [h.Class("space-y-3")], [ - h.div( - [h.Class("space-y-3")], - [ - nameField(h, model, form), - field(h, { className: "items-stretch gap-1.5", state: tagState }, [ - fieldLabel(h, { ids: tagIds, state: tagState }, "Tags"), - tagInput(h, model, form), - fieldDescription( - h, - { ids: tagIds, state: tagState }, - "Group and filter rules in the alerts list. Press Enter to add.", - ), - ]), - notes(h, model, form), - ], - ), + nameField(h, model, form, isNameInvalid), + field(h, { className: "items-stretch gap-1.5", state: tagState }, [ + fieldLabel(h, { ids: tagIds, state: tagState }, "Tags"), + tagInput(h, model, form), + fieldDescription( + h, + { ids: tagIds, state: tagState }, + "Group and filter rules in the alerts list. Press Enter to add.", + ), + ]), + notes(h, model, form), ], - "p-4", ) } @@ -194,4 +190,10 @@ const viewOf = stableSlice((model: Model) => /** `DetailsSection`: the name with a one-click suggestion, tags, and notes behind a button. */ export const detailsSection = (h: H, model: Model, form: RuleForm): Html => - slot(details, [h, viewOf(model), form]) + slot(details, [ + h, + viewOf(model), + form, + Str.trim(form.name).length === 0 && + (model.hasAttemptedSave || model.touchedFields.includes("rule-name")), + ]) diff --git a/apps/web/src/page/alerts/editor/view-fields.ts b/apps/web/src/page/alerts/editor/view-fields.ts index 3cd96e6a2e..8d2a41352f 100644 --- a/apps/web/src/page/alerts/editor/view-fields.ts +++ b/apps/web/src/page/alerts/editor/view-fields.ts @@ -1,21 +1,18 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" -import { field, fieldDescription, fieldLabel, type FieldIds } from "@maple/views/ui/field" +import type { FieldIds } from "@maple/views/ui/field" import type { FieldState } from "@maple/views/ui/input" import { inputGroup, inputGroupAddon, inputGroupInput, inputGroupText } from "@maple/views/ui/input-group" -import type { Message } from "./message" +import { Message } from "./message" import { typography } from "@maple/views/ui/typography" import { Array as Arr } from "effect" -// `NumberField` and `NoiseRow`: a Base UI field -// holding an input group with the unit as its trailing addon. +// The editor's field helpers: Base UI's field state, and the number that sits inside a sentence row. type H = HtmlBuilder export const NUMERIC_CONTROL_CLASS = "font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" -export const NOISE_CONTROL_CLASS = - "font-mono tabular-nums [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" /** Base UI's field state from the value and the value the field mounted with. */ export const fieldStateOf = (value: string, mounted: string | undefined): FieldState => ({ @@ -31,72 +28,76 @@ export const idsOf = (id: string, hasDescription: boolean): FieldIds => { return hasDescription ? { ...ids, description: `${id}-description` } : ids } -export type NumberFieldOptions = Readonly<{ +export type InlineNumberOptions = Readonly<{ id: string - label: string + /** The field's name: a sentence row has no label element, the words around it are its label. */ + ariaLabel: string value: string mounted: string | undefined - unit: string | null + /** A unit read as part of the number ("%", "ms"); a word unit is written after the field. */ + unit?: string | null onInput: (value: string) => Message - description?: string placeholder?: string - ariaLabel?: string inputMode?: "decimal" | "numeric" - controlClassName?: string min?: string - /** The saved value when it differs (`previous`): the border warns and a note says what it was. */ + /** The saved value when it differs (`previous`): the border warns. */ previous?: string | null + /** The field has an error the user has seen a reason for (left it, or tried to save). */ + isInvalid?: boolean + className?: string }> -/** `WasNote`: what a changed field was before the edit. */ -export const wasNote = (h: H, children: ReadonlyArray): Html => - typography(h, { size: "xs", tone: "warning", as: "p" }, [ - "Was ", - typography( - h, - { mono: true }, - Arr.filter(children, (child) => child !== ""), - ), - ]) - -export const numberField = (h: H, options: NumberFieldOptions): Html => { - const previous = options.previous ?? null - const description = options.description - const state = fieldStateOf(options.value, options.mounted) - const ids = idsOf(options.id, description !== undefined) - const described = ids.description - const unit = options.unit - return field(h, { className: "items-stretch gap-1.5", state }, [ - fieldLabel(h, { ids, state }, options.label), - inputGroup(h, { className: cn("h-9", previous !== null && "border-severity-warn/60") }, [ +/** A number inside a sentence row: about 96px wide, 32px tall (36px on a phone). */ +export const inlineNumber = (h: H, options: InlineNumberOptions): Html => { + const unit = options.unit ?? null + const state = { ...fieldStateOf(options.value, options.mounted), isInvalid: options.isInvalid ?? false } + return inputGroup( + h, + { + className: cn( + "h-9 w-24 shrink-0 sm:h-8", + options.className, + (options.previous ?? null) !== null && "border-severity-warn/60", + ), + }, + [ inputGroupInput(h, { id: options.id, type: "number", - controlClassName: options.controlClassName ?? NUMERIC_CONTROL_CLASS, + controlClassName: NUMERIC_CONTROL_CLASS, fieldState: state, attributes: [ - ...(described === undefined ? [] : [h.AriaDescribedBy(described)]), - ...(options.ariaLabel === undefined ? [] : [h.AriaLabel(options.ariaLabel)]), - h.AriaLabelledBy(ids.label), + h.AriaLabel(options.ariaLabel), + ...(state.isInvalid ? [h.AriaInvalid(true)] : []), h.InputMode(options.inputMode ?? "decimal"), ...(options.min === undefined ? [] : [h.Min(options.min)]), ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), h.Value(options.value), h.OnInput(options.onInput), + h.OnBlur(Message.BlurredField({ id: options.id })), ], }), ...(unit === null ? [] : [inputGroupAddon(h, { align: "inline-end" }, [inputGroupText(h, {}, [unit])])]), - ]), - ...(previous === null - ? [] - : [ - wasNote(h, [ - previous.length > 0 ? previous : "none", - previous.length > 0 && unit !== null ? (unit === "%" ? "%" : ` ${unit}`) : "", - ]), - ]), - ...(description === undefined ? [] : [fieldDescription(h, { ids, state }, description)]), - ]) + ], + ) } + +/** Under a sentence row: what each changed field was before the edit ("Threshold was 5%"). */ +export const wasLine = ( + h: H, + changes: ReadonlyArray>, +): ReadonlyArray => + Arr.flatMap(changes, (change) => { + const previous = change.previous + if (previous === null) return [] + const unit = change.unit ?? null + const suffix = previous.length > 0 && unit !== null ? (unit === "%" ? "%" : ` ${unit}`) : "" + return [ + typography(h, { size: "xs", tone: "warning", as: "p" }, [ + `${change.label} was `, + typography(h, { mono: true }, [previous.length > 0 ? `${previous}${suffix}` : "none"]), + ]), + ] + }) diff --git a/apps/web/src/page/alerts/editor/view-hero.ts b/apps/web/src/page/alerts/editor/view-hero.ts index 9a5658d573..ebf98d9cce 100644 --- a/apps/web/src/page/alerts/editor/view-hero.ts +++ b/apps/web/src/page/alerts/editor/view-hero.ts @@ -3,14 +3,19 @@ import { type BreachStats, formatBreachDuration, } from "@maple/web-data/alerts/breach-stats" -import { displaySignalType, formatSignalValue } from "@maple/web-data/alerts/form-utils" +import { + displaySignalType, + formatSignalValue, + isFilledNumber, + isRangeComparator, +} from "@maple/web-data/alerts/form-utils" import { countSkippedWindows, draftThresholds, focusPreviewOnClosestGroups, } from "@maple/web-data/alerts/preview-insights" import { cn } from "@maple/ui/lib/utils" -import { Struct, Array as Arr } from "effect" +import { Struct, Array as Arr, String as Str } from "effect" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" import { isChartRuntimeReady } from "@maple/views/charts/chart-runtime" import { button } from "@maple/views/ui/button" @@ -24,7 +29,7 @@ import { alertStatusBadge } from "../shared/badges" import { previewStateOf } from "../shared/preview" import { ruleChart } from "../shared/rule-chart" import { conditionSubject, groupBySummary, previewIdentityKey } from "./derive" -import { draftPreviewOf, windowMsOf } from "./derive-rows" +import { draftPreviewOf, hasPreviewPayload, windowMsOf } from "./derive-rows" import type { RuleForm } from "./form" import { Message } from "./message" import type { DryRun, Model } from "./model" @@ -37,38 +42,40 @@ import { hstack } from "@maple/views/ui/stack" type H = HtmlBuilder +// The verdict is the point of the chart: a phone gives it its own line under the title, and from +// `sm` it sits beside the title as before. +const verdictClass = (tone: string): string => + typographyClass({ size: "xs", className: cn("basis-full sm:basis-auto", tone) }) + const breachPill = (h: H, stats: BreachStats): ReadonlyArray => { if (stats.bucketCount === 0) return [] if (stats.breachCount === 0) return [ - h.span( - [h.Class("hidden items-center gap-1 text-xs text-severity-info sm:inline-flex")], - [icon(h, "CheckIcon", { size: 12 }), "No breaches in this window"], - ), + hstack(h, { gap: "1", inline: true, as: "span", className: verdictClass("text-severity-info") }, [ + icon(h, "CheckIcon", { size: 12 }), + "No breaches in this window", + ]), ] return [ - h.span( - [h.Class("hidden items-center gap-1 text-xs text-severity-error sm:inline-flex")], - [ - icon(h, "FireIcon", { size: 12 }), - "Would have fired", - " ", - typography(h, { weight: "semibold", mono: true, numeric: true }, [ - String(stats.breachCount), - "×", - ]), - ...(stats.longestRunMs === null - ? [] - : [ - " ", - "· longest", - " ", - typography(h, { weight: "semibold", mono: true, numeric: true }, [ - formatBreachDuration(stats.longestRunMs), - ]), + hstack(h, { gap: "1", inline: true, as: "span", className: verdictClass("text-severity-error") }, [ + icon(h, "FireIcon", { size: 12 }), + "Would have fired", + " ", + typography(h, { weight: "semibold", mono: true, numeric: true }, [ + String(stats.breachCount), + "×", + ]), + ...(stats.longestRunMs === null + ? [] + : [ + " ", + "· longest", + " ", + typography(h, { weight: "semibold", mono: true, numeric: true }, [ + formatBreachDuration(stats.longestRunMs), ]), - ], - ), + ]), + ]), ] } @@ -110,7 +117,7 @@ const header = (h: H, model: HeaderView, form: RuleForm, timeZone: string): Html h, { gap: "2", align: "center", justify: "between", wrap: true, className: "px-4 pt-3 pb-2" }, [ - hstack(h, { gap: "2", shrink: true }, [ + hstack(h, { gap: "2", shrink: true, wrap: true }, [ badge(h, { variant: "outline", className: typographyClass({ size: "xs", mono: true }) }, [ conditionSubject(form), ]), @@ -155,14 +162,25 @@ const header = (h: H, model: HeaderView, form: RuleForm, timeZone: string): Html ) } +/** The threshold (both bounds of a range) is a number; blank is missing, where `Number("")` is 0. */ +const hasThreshold = (form: RuleForm): boolean => + isFilledNumber(form.threshold) && + (!isRangeComparator(form.comparator) || isFilledNumber(form.thresholdUpper)) + /** The chart with the threshold and would-fire bands, and the notes under it. */ -type ChartView = Pick +type ChartView = Pick< + Model, + "preview" | "previewWindow" | "previewPreset" | "chart" | "themeColors" | "chartRuntime" +> const chartBody = (h: H, model: ChartView, form: RuleForm, timeZone: string): Html => { const preview = draftPreviewOf(model, form) const thresholds = draftThresholds(form) const focused = focusPreviewOnClosestGroups(preview.preview) const isChartReady = model.themeColors !== null && isChartRuntimeReady(model.chartRuntime) + const range = Str.toLowerCase(presetLabel(model.previewPreset)) + // No request to wait for: the rule has nothing previewable yet (no query, or a blank threshold). + const isIdle = !hasPreviewPayload(model, form) return h.div( [h.Class("space-y-2 px-4 pt-3 pb-4")], [ @@ -180,7 +198,19 @@ const chartBody = (h: H, model: ChartView, form: RuleForm, timeZone: string): Ht comparator: form.comparator, signalType: displaySignalType(form), window: windowMsOf(model), - isLoading: preview.isLoading || !isChartReady, + // With nothing to draw the text states need no chart runtime, so they never flash "Loading". + isLoading: preview.isLoading || (!isChartReady && preview.preview !== null), + loadingText: `Loading the ${range}…`, + emptyText: isIdle + ? { + title: hasThreshold(form) + ? `Pick a signal to preview it against the ${range}` + : `Enter a number for the threshold to preview it against the ${range}`, + } + : { + title: `No data for this signal in the ${range}`, + hint: "Pick another signal or widen the range", + }, error: preview.error, }), ...(focused.hiddenGroupCount > 0 @@ -218,7 +248,7 @@ const impactOf = stableSlice((model: Model): ImpactView => ]), ) const chartOf = stableSlice((model: Model): ChartView => - Struct.pick(model, ["preview", "previewWindow", "chart", "themeColors", "chartRuntime"]), + Struct.pick(model, ["preview", "previewWindow", "previewPreset", "chart", "themeColors", "chartRuntime"]), ) /** Step 2's chart card; the comparison block joins it while editing with unsaved changes. */ diff --git a/apps/web/src/page/alerts/editor/view-noise.ts b/apps/web/src/page/alerts/editor/view-noise.ts index e9e03a4b0a..9cfba091b1 100644 --- a/apps/web/src/page/alerts/editor/view-noise.ts +++ b/apps/web/src/page/alerts/editor/view-noise.ts @@ -1,92 +1,118 @@ import { ruleFormIsGrouped } from "@maple/web-data/alerts/form-utils" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" -import { card } from "@maple/views/ui/card" -import { eyebrow } from "@maple/views/ui/eyebrow" -import { settingRow } from "@maple/views/ui/setting-row" +import { label } from "@maple/views/ui/label" import { switchControl } from "@maple/views/ui/switch" +import { typography } from "@maple/views/ui/typography" +import { sentenceClause, sentenceRow, sentenceSection } from "../../kit/sentence-card" import type { RuleForm } from "./form" import { Message } from "./message" import type { Model } from "./model" -import { NOISE_CONTROL_CLASS, numberField } from "./view-fields" +import { invalidFieldsOf } from "./derive-rows" +import { inlineNumber, wasLine } from "./view-fields" +import { String as Str } from "effect" -// `RuleNoiseSection`: how an open incident behaves. +// `RuleNoiseSection`: how an open incident behaves, as sentences. type H = HtmlBuilder -const NO_DATA_IDS = { control: "rule-no-data-input", label: "rule-no-data-label", description: "rule-no-data-description" } +const NO_DATA_IDS = { + control: "rule-no-data-input", + label: "rule-no-data-label", + description: "rule-no-data-description", +} -const noise = (h: H, mounted: Model["mountedForm"], form: RuleForm): Html => { +const noise = ( + h: H, + mounted: Model["mountedForm"], + saved: Model["savedForm"], + form: RuleForm, + invalidIds: string, +): Html => { const isGrouped = ruleFormIsGrouped(form) - const noiseRow = ( - id: string, - label: string, - unit: string, - pick: (form: RuleForm) => string, - field: "consecutiveHealthyRequired" | "renotifyIntervalMinutes", - ) => - numberField(h, { - id, - label, - unit, - value: pick(form), - mounted: mounted === null ? undefined : pick(mounted), - inputMode: "numeric", - min: "1", - controlClassName: NOISE_CONTROL_CLASS, - onInput: (value) => Message.ChangedField({ field, value }), - }) - return card( - h, - [ - h.div( - [h.Class("space-y-3 p-4")], - [ - eyebrow(h, { as: "h3" }, ["After it fires"]), - h.div( - [h.Class("grid items-start gap-4 md:grid-cols-3")], - [ - noiseRow( - "rule-consecutive-healthy", - "Resolve after", - "healthy checks", - (f) => f.consecutiveHealthyRequired, - "consecutiveHealthyRequired", - ), - noiseRow( - "rule-renotify", - "Remind while open, every", - "min", - (f) => f.renotifyIntervalMinutes, - "renotifyIntervalMinutes", - ), - settingRow(h, { - ids: NO_DATA_IDS, - label: "Alert when there is no data", - description: isGrouped - ? "Not available on split rules: a quiet group keeps its incident open until data returns." - : "Count an empty window as a breach. Off, those windows are skipped.", - disabled: isGrouped, - control: switchControl(h, { - id: "rule-no-data", - inputId: NO_DATA_IDS.control, - isChecked: form.alertOnNoData && !isGrouped, - isDisabled: isGrouped, - labelledBy: NO_DATA_IDS.label, - describedBy: NO_DATA_IDS.description, - onToggle: Message.ToggledAlertOnNoData(), - }), - }), - ], - ), - ], - ), - ], - "gap-0 p-0", - ) + const invalid = new Set(Str.split(invalidIds, ",")) + const values = (pick: (form: RuleForm) => string) => ({ + value: pick(form), + mounted: mounted === null ? undefined : pick(mounted), + previous: saved !== null && Str.trim(pick(saved)) !== Str.trim(pick(form)) ? pick(saved) : null, + }) + const healthy = values((f) => f.consecutiveHealthyRequired) + const renotify = values((f) => f.renotifyIntervalMinutes) + return sentenceSection(h, { title: "After it fires" }, [ + sentenceClause( + h, + sentenceRow(h, {}, [ + "Resolve after", + inlineNumber(h, { + id: "rule-consecutive-healthy", + ariaLabel: "Resolve after", + inputMode: "numeric", + min: "1", + isInvalid: invalid.has("rule-consecutive-healthy"), + onInput: (value) => Message.ChangedField({ field: "consecutiveHealthyRequired", value }), + ...healthy, + }), + "healthy checks.", + ]), + wasLine(h, [{ label: "Resolve after", previous: healthy.previous }]), + ), + sentenceClause( + h, + sentenceRow(h, {}, [ + "Remind every", + inlineNumber(h, { + id: "rule-renotify", + ariaLabel: "Remind while open, every", + inputMode: "numeric", + min: "1", + isInvalid: invalid.has("rule-renotify"), + onInput: (value) => Message.ChangedField({ field: "renotifyIntervalMinutes", value }), + ...renotify, + }), + "min while open.", + ]), + wasLine(h, [{ label: "Remind interval", previous: renotify.previous, unit: "min" }]), + ), + sentenceClause( + h, + sentenceRow(h, {}, [ + // The label wraps the switch: its whole line toggles, at the row's control height. + label( + h, + { + className: "inline-flex min-h-9 items-center gap-2 sm:min-h-8", + attributes: [h.Id(NO_DATA_IDS.label), h.For(NO_DATA_IDS.control)], + }, + [ + ...switchControl(h, { + id: "rule-no-data", + inputId: NO_DATA_IDS.control, + isChecked: form.alertOnNoData && !isGrouped, + isDisabled: isGrouped, + labelledBy: NO_DATA_IDS.label, + describedBy: NO_DATA_IDS.description, + onToggle: Message.ToggledAlertOnNoData(), + }), + "Alert when there is no data", + ], + ), + ]), + [ + typography( + h, + { size: "xs", tone: "muted", as: "p", attributes: [h.Id(NO_DATA_IDS.description)] }, + [ + isGrouped + ? "Not available on split rules: a quiet group keeps its incident open until data returns." + : "Count an empty window as a breach. Off, those windows are skipped.", + ], + ), + ], + ), + ]) } const slot = createLazy() /** "After it fires"; `form` is the evaluated draft (`evaluatedFormOf`). */ export const noiseSection = (h: H, model: Model, form: RuleForm): Html => - slot(noise, [h, model.mountedForm, form]) + slot(noise, [h, model.mountedForm, model.savedForm, form, invalidFieldsOf(model, form)]) diff --git a/apps/web/src/page/alerts/editor/view-notifications.ts b/apps/web/src/page/alerts/editor/view-notifications.ts index e46c68bfce..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-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 0632c3ec1f..3e92bdfa58 100644 --- a/apps/web/src/page/alerts/editor/view.ts +++ b/apps/web/src/page/alerts/editor/view.ts @@ -1,4 +1,3 @@ -import { deriveTierIssues, isFilledNumber } from "@maple/web-data/alerts/form-utils" import { cn } from "@maple/ui/lib/utils" import { Submodel } from "foldkit" import { createLazy, type Html, type HtmlBuilder } from "foldkit/html" @@ -16,7 +15,21 @@ import { RULE_CHART_TOKENS } from "../shared/rule-chart" import type { RuleForm } from "./form" import { Message } from "./message" import type { Model, Notice } from "./model" -import { isAnomalyUnrouted, isProtectedDefault } from "./derive" +import { destinationTarget, isProtectedDefault } from "./derive" +import type { AlertQueryTiming } from "@maple/domain/http" +import { destinationsOf, draftTimingOf } from "./derive-rows" +import type { RuleCard } from "./form" +import { + cardIssues, + conditionSummary, + nameSummary, + notifySummary, + type NameLookup, + type NotifyNames, + watchSummary, + isProblemIssue, +} from "./summary" +import { sentenceCard, sentenceChain } from "../../kit/sentence-card" import { actionBar } from "./view-action-bar" import { conditionFields } from "./view-condition" import { detailsSection } from "./view-details" @@ -24,9 +37,9 @@ import { liveChartHero } from "./view-hero" import { noiseSection } from "./view-noise" import { notificationsSection } from "./view-notifications" import { querySection } from "./view-query" -import { RULE_FORM_MAX_WIDTH, ruleStep } from "./view-step" +import { RULE_FORM_MAX_WIDTH } from "./view-step" import { templatesOverlay } from "./view-templates" -import { Array as Arr, String as Str } from "effect" +import { Array as Arr, Option } from "effect" // `AlertCreatePageContent` and `AlertCreateFormSurface`: the rule editor page and its form. @@ -123,85 +136,128 @@ const evaluatedFormOf = stableSlice((form: RuleForm): RuleForm => ({ notificationBody: "", })) -/** Who gets paged is missing: no destination or escalation chain on a tier that needs one. */ -const isNotifyMissingOf = (form: RuleForm, isDefault: boolean): boolean => - form.signalType === "anomaly" - ? !isDefault && isAnomalyUnrouted(form) - : (form.destinationIds.length === 0 && form.escalationChainId === null) || - deriveTierIssues(form).includes("Critical destinations") +/** How the Notify summary names destinations and chains, from the lists the editor holds. */ +const notifyNamesOf = (model: Model): NotifyNames => { + const destinations = destinationsOf(model) + // A list that has not answered (or failed, for chains) is loading, never "removed". + const lookup = ( + list: ReadonlyArray | null, + isMatch: (item: A) => boolean, + nameOf: (item: A) => string, + ): NameLookup => + list === null + ? { _tag: "Loading" } + : Option.match(Arr.findFirst(list, isMatch), { + onNone: (): NameLookup => ({ _tag: "Gone" }), + onSome: (item): NameLookup => ({ _tag: "Found", name: nameOf(item) }), + }) + return { + destination: (id) => + lookup( + model.destinations === null ? null : destinations, + (destination) => destination.id === id, + (destination) => destinationTarget(destination).primary, + ), + chain: (id) => + lookup( + model.isEscalationChainsFailed ? null : model.escalationChains, + (chain) => chain.id === id, + (chain) => chain.name, + ), + } +} + +// A card of short fields stops near 768px, so nothing stretches across the page. +const SHORT_FIELDS = "max-w-3xl" + +type CardSpec = Readonly<{ + card: RuleCard + lead: string + hint?: string | undefined + summary: string + /** The open card's body: a width cap for a card of short fields. */ + bodyClassName?: string | undefined + body: () => ReadonlyArray +}> + +const ruleCard = ( + h: H, + model: Model, + form: RuleForm, + timing: AlertQueryTiming | null, + isDefault: boolean, + spec: CardSpec, +): Html => { + const issues = cardIssues(form, spec.card, timing, isDefault) + return sentenceCard( + h, + { + id: spec.card, + lead: spec.lead, + hint: spec.hint, + status: issues.length > 0 ? "missing" : "done", + issues: Arr.filter(issues, (issue) => !isProblemIssue(issue)), + problems: Arr.filter(issues, isProblemIssue), + summary: spec.summary, + isExpanded: model.expandedCards.includes(spec.card), + onExpand: Message.ClickedExpandCard({ card: spec.card }), + onCollapse: Message.ClickedCollapseCard({ card: spec.card }), + ...(spec.bodyClassName === undefined ? undefined : { bodyClassName: spec.bodyClassName }), + }, + spec.body, + ) +} const surface = (h: H, model: Model, form: RuleForm, timeZone: string): ReadonlyArray => { const evaluated = evaluatedFormOf(form) - // An anomaly rule has no condition to tune: the detector decides, so "When to alert" goes. + // An anomaly rule has no condition to tune: the detector decides, so "Alert when" and the chart go. const isAnomaly = form.signalType === "anomaly" - const stepAfterQuery = isAnomaly ? 2 : 3 + const isDefault = isProtectedDefault(model) + const timing = draftTimingOf(model, form) + const condition: CardSpec = { + card: "condition", + lead: "Alert when", + hint: "The dashed line on the chart is your threshold.", + summary: conditionSummary(form), + bodyClassName: SHORT_FIELDS, + body: () => [conditionFields(h, model, evaluated), noiseSection(h, model, evaluated)], + } + const cards: ReadonlyArray = [ + { + card: "watch", + lead: "When", + hint: isAnomaly + ? "Which anomalies this rule pages on." + : "A built-in signal, or a query built like a dashboard widget.", + summary: watchSummary(form, isDefault), + body: () => [querySection(h, model, evaluated)], + }, + ...(isAnomaly ? [] : [condition]), + { + card: "notify", + lead: "Notify", + summary: notifySummary(form, notifyNamesOf(model)), + bodyClassName: SHORT_FIELDS, + body: () => [notificationsSection(h, model, form)], + }, + { + card: "name", + lead: "Name", + summary: nameSummary(form), + bodyClassName: SHORT_FIELDS, + body: () => [detailsSection(h, model, form)], + }, + ] return [ h.div( - [h.Class(cn("mx-auto w-full space-y-10", RULE_FORM_MAX_WIDTH))], + [h.Class(cn("mx-auto w-full space-y-6", RULE_FORM_MAX_WIDTH))], [ ...noticeBanner(h, model.notices), - ruleStep( + ...(isAnomaly ? [] : [liveChartHero(h, model, form, evaluated, timeZone)]), + sentenceChain( h, - { - number: 1, - hasDivider: false, - title: "What to watch", - hint: isAnomaly - ? "Pick which anomalies this rule pages on." - : "Pick a built-in signal, or build the query like a dashboard widget.", - status: "done", - }, - [querySection(h, model, evaluated)], - ), - ...(isAnomaly - ? [] - : [ - ruleStep( - h, - { - number: 2, - title: "When to alert", - hint: "The dashed line is your threshold. Shaded bands show when the rule would have fired.", - status: isFilledNumber(form.threshold) ? "done" : "missing", - }, - [ - h.div( - [h.Class("space-y-4")], - [ - liveChartHero(h, model, form, evaluated, timeZone), - conditionFields(h, model, evaluated), - noiseSection(h, model, evaluated), - ], - ), - ], - ), - ]), - h.div( - [h.Class("grid grid-cols-1 gap-6 border-t pt-8 lg:grid-cols-2 lg:items-start")], - [ - ruleStep( - h, - { - number: stepAfterQuery, - hasDivider: false, - title: "Who gets notified", - status: isNotifyMissingOf(form, isProtectedDefault(model)) - ? "missing" - : "done", - }, - [notificationsSection(h, model, form)], - ), - ruleStep( - h, - { - number: stepAfterQuery + 1, - hasDivider: false, - title: "Name it", - status: Str.trim(form.name).length === 0 ? "missing" : "done", - }, - [detailsSection(h, model, form)], - ), - ], + { label: "Rule" }, + Arr.map(cards, (spec) => ruleCard(h, model, form, timing, isDefault, spec)), ), ], ), diff --git a/apps/web/src/page/alerts/shared/preview.ts b/apps/web/src/page/alerts/shared/preview.ts index 31726f85ae..dd6e50a1c1 100644 --- a/apps/web/src/page/alerts/shared/preview.ts +++ b/apps/web/src/page/alerts/shared/preview.ts @@ -28,8 +28,9 @@ export type PreviewSlot = typeof PreviewSlot.Type const isPreviewQueryReady = (form: RuleFormState): boolean => { // An anomaly rule has no query: the detector's own chart is its evidence. if (form.signalType === "anomaly") return false - if (!Number.isFinite(Number(form.threshold))) return false - if (isRangeComparator(form.comparator) && !Number.isFinite(Number(form.thresholdUpper))) return false + // A blank threshold is missing, not zero: nothing to draw a line at or to ask the server for. + if (!isFilledNumber(form.threshold)) return false + if (isRangeComparator(form.comparator) && !isFilledNumber(form.thresholdUpper)) return false if (form.signalType === "raw_query") { const sql = Str.trim(form.rawQuerySql) if (sql.length === 0 || !Str.includes("$__orgFilter")(sql)) return false diff --git a/apps/web/src/page/alerts/shared/rule-chart.ts b/apps/web/src/page/alerts/shared/rule-chart.ts index 1f83845eb0..b731be2bae 100644 --- a/apps/web/src/page/alerts/shared/rule-chart.ts +++ b/apps/web/src/page/alerts/shared/rule-chart.ts @@ -58,10 +58,11 @@ export const RULE_CHART_TOKENS: ReadonlyArray = [ "--background", ] -const SIGNAL_TOKEN = "--chart-1" +// Blue in both themes: `--chart-1` is the dark theme's orange, the warning threshold's colour. +const SIGNAL_TOKEN = "--chart-p50" const CHART_HEIGHT = 300 const Y_AXIS_WIDTH = 72 -const PLOT_RIGHT = 12 +const PLOT_RIGHT = 28 const RAIL_CELLS = 60 type RailStatus = "breached" | "error" | "skipped" | "healthy" | "empty" @@ -101,6 +102,10 @@ export type RuleChartInputs = Readonly<{ /** `onSelectBucket`: the rail cells are buttons when set. */ onSelectBucket?: (index: number | null) => M isLoading?: boolean + /** Said in place of the loading skeleton, whose drawn trend reads like data (the rule editor). */ + loadingText?: string + /** Said when there is nothing to chart and nothing failed, in place of "No data in this window". */ + emptyText?: Readonly<{ title: string; hint?: string | undefined }> error?: string | null className?: string }> @@ -133,7 +138,11 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm source === "preview" ? checksChart.rows : previewChart.rows, (domain.max - domain.min) / RAIL_CELLS, ) - const chartData = merged.rows + // A point outside the window would draw past the plot, over the axis labels. + const chartData = Arr.filter(merged.rows, (row) => { + const t = num(row.t) + return t >= domain.min && t <= domain.max + }) const hasSignal = chartData.length > 0 const bucketSeconds = inputs.preview != null @@ -242,6 +251,9 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm gradientId: `${inputs.chartId}-signal`, }) + const hint = inputs.emptyText === undefined ? "Try widening the range" : inputs.emptyText.hint + const emptyHint = hint === undefined ? {} : { hint } + const chartArea = hasSignal ? h.div( [h.Class("w-full")], @@ -274,7 +286,9 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm ], ) : inputs.isLoading === true - ? chartLoading(h, { variant: "area", height: CHART_HEIGHT }) + ? inputs.loadingText === undefined + ? chartLoading(h, { variant: "area", height: CHART_HEIGHT }) + : chartEmpty(h, { height: CHART_HEIGHT }, [inputs.loadingText]) : inputs.error != null ? chartError(h, { height: CHART_HEIGHT }, [ h.div( @@ -289,8 +303,8 @@ export const ruleChart = (h: HtmlBuilder, inputs: RuleChartInputs): Htm ], ), ]) - : chartEmpty(h, { height: CHART_HEIGHT, hint: "Try widening the range" }, [ - "No data in this window", + : chartEmpty(h, { height: CHART_HEIGHT, ...emptyHint }, [ + inputs.emptyText?.title ?? "No data in this window", ]) const thresholdCaption = hstack( diff --git a/apps/web/src/page/kit/sentence-card.ts b/apps/web/src/page/kit/sentence-card.ts new file mode 100644 index 0000000000..6b982c0cdf --- /dev/null +++ b/apps/web/src/page/kit/sentence-card.ts @@ -0,0 +1,303 @@ +import { cn } from "@maple/ui/lib/utils" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { card } from "@maple/views/ui/card" +import { cardButton } from "@maple/views/ui/card-button" +import { separator } from "@maple/views/ui/display" +import { heading } from "@maple/views/ui/heading" +import { icon } from "@maple/views/ui/icon" +import { hstack, vstack } from "@maple/views/ui/stack" +import { statusDot } from "@maple/views/ui/status-dot" +import { typography } from "@maple/views/ui/typography" +import { Array as Arr, Option } from "effect" + +// Sentence cards: a form that reads top to bottom as one sentence ("When ... Alert when ... +// Notify ... Name"), each card a clause. No numbers: a status dot on a rail joins them. + +export type SentenceCardStatus = "done" | "missing" + +export type SentenceCardOptions = Readonly<{ + /** Unique on the page; the card's element ids derive from it. */ + id: string + /** The clause's lead word, the card's heading: "When", "Alert when", "Notify". */ + lead: string + /** A line beside the lead while the card is open. */ + hint?: string | undefined + status: SentenceCardStatus + /** What is missing, said while `status` is `missing`: "Threshold", "Rule name". */ + issues?: ReadonlyArray + /** Blockers that are not a missing field, said as they are: "Query too slow for this check interval". */ + problems?: ReadonlyArray + /** The clause in plain words, shown while the card is closed. */ + summary: string + isExpanded: boolean + onExpand: M + /** Closes an open card back to its summary (Done, or Escape); a missing card stays open. */ + onCollapse?: M + /** Layout of the open card's body: a width cap for a card of short fields. */ + bodyClassName?: string +}> + +/** The open card's heading: focus lands here when the card opens. */ +export const sentenceCardHeadingId = (id: string): string => `sentence-card-${id}` +/** The closed card's summary button: focus returns here when the card closes. */ +export const sentenceCardSummaryId = (id: string): string => `sentence-card-${id}-summary` +const panelIdOf = (id: string): string => `sentence-card-${id}-panel` + +const DOT_TONE = { done: "done", missing: "warn" } as const + +const LEAD_CLASS = "shrink-0 sm:w-24" + +/** The rail beside a card: its status dot, then the line down to the next card (none after the last). */ +const rail = (h: HtmlBuilder, status: SentenceCardStatus): Html => + vstack(h, { align: "center", className: "h-full" }, [ + h.span( + [h.Class("mt-[calc(--spacing(5)+1px)] grid size-4 shrink-0 place-items-center")], + [statusDot(h, { tone: DOT_TONE[status], size: "lg" })], + ), + // In a row the separator stretches to the row's height: the rest of the step, gap included. + hstack(h, { justify: "center", grow: true, className: "mt-1.5 group-last/sentence:hidden" }, [ + separator(h, "vertical", undefined), + ]), + ]) + +/** The lead as the heading, its whole card a disclosure button that opens the card. */ +const closedCard = (h: HtmlBuilder, options: SentenceCardOptions): Html => + heading(h, { level: 2, variant: "section" }, [ + cardButton( + h, + { + onClick: options.onExpand, + label: `${options.lead}: ${options.summary}. Edit`, + className: "block w-full px-4 py-3", + attributes: [ + h.Id(sentenceCardSummaryId(options.id)), + h.AriaExpanded(false), + h.AriaControls(panelIdOf(options.id)), + ], + }, + [ + // A phone stacks the lead over the summary; from `sm` they share one line. + h.span( + [ + h.Class( + "grid min-h-8 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-0.5 py-1.5 sm:grid-cols-[--spacing(24)_minmax(0,1fr)_auto]", + ), + ], + [ + typography( + h, + { size: "sm", weight: "semibold", className: "col-start-1 row-start-1" }, + [options.lead], + ), + typography( + h, + { + size: "sm", + weight: "normal", + className: + "col-span-2 row-start-2 line-clamp-3 min-w-0 sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:line-clamp-1", + }, + [options.summary], + ), + hstack( + h, + { + gap: "1", + align: "center", + as: "span", + className: "col-start-2 row-start-1 h-5 sm:col-start-3", + }, + [ + icon(h, "PencilIcon", { size: 12, className: "text-muted-foreground" }), + typography(h, { size: "xs", weight: "normal", tone: "muted" }, ["Edit"]), + ], + ), + ], + ), + ], + ), + ]) + +// A popup open over the card (a select, a menu, a chip picker) takes Escape for itself: it is read +// off the page at the keypress, the one moment the popup is certain to still be mounted. +const isPopupOpen = (): boolean => + typeof document !== "undefined" && + document.querySelector('[data-base-ui-portal]:not([data-slot^="toast"])') !== null + +const openCard = ( + h: HtmlBuilder, + options: SentenceCardOptions, + body: () => ReadonlyArray, +): Html => { + const headingId = sentenceCardHeadingId(options.id) + const issues = options.issues ?? [] + // "Missing: Threshold. Query too slow for this check interval": fields by name, problems in full. + const issueLine = Arr.join( + [...(issues.length === 0 ? [] : [`Missing: ${Arr.join(issues, ", ")}`]), ...(options.problems ?? [])], + ". ", + ) + const onCollapse = options.status === "missing" ? undefined : options.onCollapse + // One frame around the header and the body, the same box the closed card draws. + return card( + h, + [ + h.section( + [ + h.AriaLabelledBy(headingId), + h.Class("flex min-w-0 flex-col gap-4 px-4 pt-3 pb-4"), + ...(onCollapse === undefined + ? [] + : [ + h.OnKeyDownPreventDefault((key) => + key === "Escape" && !isPopupOpen() + ? Option.some(onCollapse) + : Option.none(), + ), + ]), + ], + [ + hstack( + h, + { gap: "3", align: "baseline", wrap: true, className: "min-h-8 gap-y-1 pt-1.5" }, + [ + heading( + h, + { + level: 2, + variant: "section", + id: headingId, + className: cn(LEAD_CLASS, "outline-none"), + attributes: [h.Tabindex(-1)], + }, + [options.lead], + ), + ...(options.hint === undefined + ? [] + : [ + typography( + h, + { size: "xs", tone: "muted", as: "p", className: "min-w-0" }, + [options.hint], + ), + ]), + ...(options.status === "missing" + ? [ + typography(h, { size: "xs", tone: "warning", as: "p" }, [ + issueLine === "" ? "Required" : issueLine, + ]), + ] + : []), + ], + ), + h.div( + [ + h.Id(panelIdOf(options.id)), + h.Class(cn("flex min-w-0 flex-col gap-4", options.bodyClassName)), + ], + [ + ...body(), + // Last in the tab order, after the fields it closes. + ...(onCollapse === undefined + ? [] + : [ + hstack(h, { justify: "end" }, [ + button( + h, + { + variant: "outline", + attributes: [ + h.AriaExpanded(true), + h.AriaControls(panelIdOf(options.id)), + h.OnClick(onCollapse), + ], + }, + ["Done"], + ), + ]), + ]), + ], + ), + ], + ), + ], + "gap-0 p-0", + ) +} + +/** + * `SentenceCard`: one clause of a sentence-card form, on its rail. Open, it shows its lead and + * controls (`body`, called only then); closed (complete and not being edited), a one-line summary + * that opens it on click. A missing card is always open; Done or Escape closes a complete one. + */ +export const sentenceCard = ( + h: HtmlBuilder, + options: SentenceCardOptions, + body: () => ReadonlyArray, +): Html => { + const isOpen = options.isExpanded || options.status === "missing" + return h.li( + [ + h.Key(options.id), + h.Class("group/sentence grid grid-cols-[--spacing(4)_minmax(0,1fr)] gap-x-3"), + h.DataAttribute("state", isOpen ? "open" : "closed"), + ], + [ + rail(h, options.status), + h.div( + [h.Class("min-w-0 pb-4 group-last/sentence:pb-0")], + [isOpen ? openCard(h, options, body) : closedCard(h, options)], + ), + ], + ) +} + +/** `SentenceChain`: the sentence cards top to bottom, their rails joined into one line. */ +export const sentenceChain = ( + h: HtmlBuilder, + options: Readonly<{ label: string; className?: string }>, + cards: ReadonlyArray, +): Html => h.ol([h.AriaLabel(options.label), h.Class(cn("flex flex-col", options.className))], cards) + +/** + * `SentenceRow`: one line of a sentence form, its words and inline controls wrapping together + * ("Fire after [3] breaches [in a row]."). Controls sit at 32px, 36px on a phone. + */ +export const sentenceRow = ( + h: HtmlBuilder, + options: Readonly<{ className?: string }>, + children: ReadonlyArray, +): Html => + h.div( + [ + h.Class( + cn("flex flex-wrap items-center gap-x-2 gap-y-2 text-sm text-foreground", options.className), + ), + ], + children, + ) + +/** `SentencePhrase`: words and their control kept together, so a wrap never strands the control. */ +export const sentencePhrase = (h: HtmlBuilder, children: ReadonlyArray): Html => + h.span([h.Class("inline-flex min-w-0 items-center gap-2")], children) + +/** `SentenceClause`: a sentence row with the notes that belong to it (a hint, what it was) under it. */ +export const sentenceClause = (h: HtmlBuilder, row: Html, notes: ReadonlyArray = []): Html => + notes.length === 0 ? row : h.div([h.Class("flex min-w-0 flex-col gap-1.5")], [row, ...notes]) + +/** `SentenceSection`: a titled group of sentence rows inside a card, ruled off from the one above. */ +export const sentenceSection = ( + h: HtmlBuilder, + options: Readonly<{ title: string; className?: string }>, + children: ReadonlyArray, +): Html => + h.section( + [ + h.AriaLabel(options.title), + h.Class(cn("flex flex-col gap-4 border-t pt-4 first:border-t-0 first:pt-0", options.className)), + ], + [ + typography(h, { size: "xs", weight: "medium", tone: "muted", as: "p" }, [options.title]), + ...children, + ], + ) diff --git a/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 fd22e50cff..d209e60c3e 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -232,6 +232,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`, `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/packages/views/src/ui/card-button.ts b/packages/views/src/ui/card-button.ts index f31e4ccbf9..0aa2d9a479 100644 --- a/packages/views/src/ui/card-button.ts +++ b/packages/views/src/ui/card-button.ts @@ -8,12 +8,15 @@ import type { Attribute, ChildAttribute, Html, HtmlBuilder } from "foldkit/html" const BASE = "group rounded-md border bg-card text-left hover:border-border-strong active:border-border-strong hover:bg-surface-sunken active:bg-surface-sunken focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-64 disabled:hover:border-border disabled:active:border-border disabled:hover:bg-card disabled:active:bg-card" -const SELECTED = "border-primary-a4 bg-primary-a1 hover:border-primary-a4 active:border-primary-a4 hover:bg-primary-a2 active:bg-primary-a2" +const SELECTED = + "border-primary-a4 bg-primary-a1 hover:border-primary-a4 active:border-primary-a4 hover:bg-primary-a2 active:bg-primary-a2" export type CardButtonOptions = Readonly<{ onClick: M isSelected?: boolean isDisabled?: boolean + /** `aria-label`, when the card's text alone does not name the action. */ + label?: string className?: string attributes?: ReadonlyArray | ChildAttribute> }> @@ -35,6 +38,7 @@ export const cardButton = ( ? [] : [h.AriaPressed(options.isSelected ? "true" : "false")]), ...((options.isDisabled ?? false) ? [h.Disabled(true)] : []), + ...(options.label === undefined ? [] : [h.AriaLabel(options.label)]), ...(options.attributes ?? []), ], children, diff --git a/packages/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..1652984e56 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): RuleFormState["queryBuilderDraft"] | null => { + 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, }, { diff --git a/scripts/oxlint-plugins/ui-kit-baseline.json b/scripts/oxlint-plugins/ui-kit-baseline.json index 6ff1b5b510..c9743219e1 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,