Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
3ec81ce
feat(query-builder): condition builder for the where clause
Makisuo Nov 9, 2026
2b09626
fix(query-builder): condition builder review fixes
Makisuo Nov 9, 2026
178965e
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 9, 2026
be018fa
feat(alerts): rule editor lab over fixture rows
Makisuo Nov 9, 2026
bb3897c
feat(alerts): rule editor as sentence cards with plain-language summa…
Makisuo Nov 9, 2026
2de1f25
fix(query-builder): field picker's accessible name includes the picke…
Makisuo Nov 9, 2026
b6ae4b0
fix(alerts): sentence card review fixes
Makisuo Nov 9, 2026
cead47c
Merge remote-tracking branch 'origin/ux/condition-builder' into ux/ru…
Makisuo Nov 9, 2026
dba6fcf
feat(views): pickers that keep the keyboard, a select that opens below
Makisuo Nov 10, 2026
1d0d2e9
fix(query-builder): condition builder UX pass
Makisuo Nov 10, 2026
aacec21
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
42fddb7
feat(alerts): rule editor sentence rows, one action bar state, errors…
Makisuo Nov 10, 2026
d614cb6
Merge remote-tracking branch 'origin/ux/condition-builder' into ux/ru…
Makisuo Nov 10, 2026
9090847
fix(query-builder): where controls wrap inside a narrow panel
Makisuo Nov 10, 2026
fadf767
Merge remote-tracking branch 'origin/ux/condition-builder' into ux/ru…
Makisuo Nov 10, 2026
ee110de
fix(query-builder): rows in progress survive a look at Text; clearer …
Makisuo Nov 10, 2026
9231793
Merge remote-tracking branch 'origin/ux/condition-builder' into ux/ru…
Makisuo Nov 10, 2026
d3e87a1
fix(alerts): rule cards say problems in full; the empty chart asks fo…
Makisuo Nov 10, 2026
67cbbe7
fix(query-builder): product events take no operator on resource.* keys
Makisuo Nov 10, 2026
57373e4
Merge remote-tracking branch 'origin/ux/condition-builder' into ux/ru…
Makisuo Nov 10, 2026
cc4cce2
fix(views): Escape in a picker's search input stops at the popup
Makisuo Nov 10, 2026
f5c915c
Merge remote-tracking branch 'origin/ux/condition-builder' into ux/ru…
Makisuo Nov 10, 2026
d7170fd
fix(alerts): threshold-order blockers are said in full, not as missin…
Makisuo Nov 10, 2026
44ac0a0
fix(query-builder): the builder shows only what the query applies
Makisuo Nov 10, 2026
a55efd5
Merge remote-tracking branch 'origin/ux/condition-builder' into ux/ru…
Makisuo Nov 10, 2026
4b22e15
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
8507952
Merge branch 'ux/condition-builder' into ux/rule-sentence-editor
Makisuo Nov 10, 2026
6640df8
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
2ea7a73
Merge branch 'ux/condition-builder' into ux/rule-sentence-editor
Makisuo Nov 10, 2026
b9a7016
fix(views): a mouse pick in a combobox list leaves focus in its input
Makisuo Nov 10, 2026
7fbd687
fix(query-builder): the value list says why a value with both quote k…
Makisuo Nov 10, 2026
07724a1
fix(query-builder): a doubled operator or missing value stays in Text
Makisuo Nov 10, 2026
4fab019
fix(alerts): a blank threshold is missing, not zero, in the rule preview
Makisuo Nov 10, 2026
b6039bc
fix(alerts): the empty rule name shows the error its input is describ…
Makisuo Nov 10, 2026
91061a3
fix(alerts): opening an empty add-on section is not an unsaved change
Makisuo Nov 10, 2026
f3c1222
fix(alerts): name the applied query draft's type in the rule diff
Makisuo Nov 10, 2026
b0d1169
fix(alerts): the preview verdict wraps under the chart title on a phone
Makisuo Nov 10, 2026
a4f05c4
test(e2e): alert rule tests follow the sentence editor's closed cards…
Makisuo Nov 10, 2026
4766623
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
21bfb1e
Merge branch 'ux/condition-builder' into ux/rule-sentence-editor
Makisuo Nov 10, 2026
2258d80
fix(query-builder): row controls are named by condition number; a sib…
Makisuo Nov 10, 2026
b366112
Merge branch 'ux/condition-builder' into ux/rule-sentence-editor
Makisuo Nov 10, 2026
1ac178f
test(query-engine): pin the operator list to the grammar and the OR-g…
Makisuo Nov 10, 2026
b872562
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
d4f364f
Merge branch 'ux/condition-builder' into ux/rule-sentence-editor
Makisuo Nov 10, 2026
145b4c4
Merge remote-tracking branch 'origin/main' into ux/rule-sentence-editor
Makisuo Nov 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/web/e2e/tests/alert-rules.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/)
Expand All @@ -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$/)
Expand All @@ -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 }) => {
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/lab/entries/rule-editor.ts
Original file line number Diff line number Diff line change
@@ -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 })
8 changes: 8 additions & 0 deletions apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,14 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
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",
Expand Down
230 changes: 230 additions & 0 deletions apps/web/src/lab/rule-editor-lab.ts
Original file line number Diff line number Diff line change
@@ -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<Flags> = 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<EditorMessage>): 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<typeof ERROR_RATE_RULE>) =>
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<Command.Command<EditorMessage, never, Resources>>
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<RuleCard>, 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<Command.Command<EditorMessage, never, Resources>> | undefined) =>
Command.mapMessages(
Arr.filter(commands ?? [], (command) => !BLOCKED.test(command.name)),
(message) => Message.GotEditorMessage({ message }),
)

export const init = (labFlags: Flags): Update.Return<Model, Message, Resources> => {
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<Model, Message, Resources> => {
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<Message>): 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 }),
}),
],
)
21 changes: 18 additions & 3 deletions apps/web/src/page/alerts/editor/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down Expand Up @@ -63,7 +65,20 @@ export const completeTest = (model: Model, message: Extract<Message, { _tag: "Co
export const save = (model: Model): Return => {
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) })],
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/page/alerts/editor/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Message.CompletedFocusCard.Type>((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],
Expand Down
Loading
Loading