Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
116 commits
Select commit Hold shift + click to select a range
3ec81ce
feat(query-builder): condition builder for the where clause
Makisuo Nov 9, 2026
2bcbbf0
feat(shell): URL-held right sheet, foldable sidebar groups, on-call pill
Makisuo Nov 9, 2026
5535bc2
fix(shell): sheet history, on-call freshness, foldable active group
Makisuo Nov 9, 2026
2b09626
fix(query-builder): condition builder review fixes
Makisuo Nov 9, 2026
0c0b786
Merge remote-tracking branch 'origin/main' into ux/shell
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
18c83a3
feat(alerts): sections at their own paths, sidebar sub-items, silence…
Makisuo Nov 9, 2026
5888395
fix(shell): read window and document on subscribe, not on import
Makisuo Nov 9, 2026
f649d8f
Merge remote-tracking branch 'origin/ux/shell' into ux/alerting-pages
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
280ea47
fix(alerts): review follow-ups for section routes and the silence panel
Makisuo Nov 9, 2026
5068806
feat(alerts): live coverage preview in the schedule editor and detail…
Makisuo Nov 9, 2026
5fd8fbc
feat(alerts): oncall lab states for a covered and a gappy schedule draft
Makisuo Nov 9, 2026
1cb4cef
feat(alerts): full-page escalation chain editor
Makisuo Nov 9, 2026
56b1631
fix(alerts): preview beside the form from 2xl, cap the gap links at six
Makisuo Nov 9, 2026
f96ab12
feat(alerts): escalation chain editor lab
Makisuo Nov 9, 2026
ef30170
test(shell): lab on-call fixture no longer expires on 16 Nov 2026
Makisuo Nov 9, 2026
176023c
fix(alerts): escalation chain editor review follow-ups
Makisuo Nov 10, 2026
19047df
chore(lab): run the escalation editor's focus moves in its lab
Makisuo Nov 10, 2026
7b09065
feat(ui): shift timeline gap tone, labels that leave when they cannot…
Makisuo Nov 10, 2026
b551d14
fix(alerts): coverage preview review follow-ups
Makisuo Nov 10, 2026
d637338
fix(alerts): the last gap of an escalation chain always shows its add…
Makisuo Nov 10, 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
f9d010f
fix(views): side panel dims the page lightly, and comes in a compact …
Makisuo Nov 10, 2026
f1179ea
fix(shell): one-row on-call pill, calmer sidebar states
Makisuo Nov 10, 2026
3c3e996
fix(alerts): one primary action per section, chips and viewer time in…
Makisuo Nov 10, 2026
21974a5
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
d834035
fix(alerts): calmer quiet Inbox, one headroom tone, tidier rules table
Makisuo Nov 10, 2026
1c0bfcd
Merge remote-tracking branch 'origin/ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
af4d55e
Merge remote-tracking branch 'origin/ux/alerting-pages' into ux/alert…
Makisuo Nov 10, 2026
4ab5881
Merge remote-tracking branch 'origin/ux/alerting-pages' into ux/escal…
Makisuo Nov 10, 2026
8c9f5c6
Merge remote-tracking branch 'origin/ux/alerting-pages' into ux/sched…
Makisuo Nov 10, 2026
6a7cfa6
fix(alerts): restore the AsyncData import the hub header needs after …
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
503f89f
fix(alerts): a long chain name in the routing list ends in an ellipsis
Makisuo Nov 10, 2026
9708166
fix(alerts): chain target remove buttons name their step; a failed pe…
Makisuo Nov 10, 2026
109147b
refactor(alerts): the Inbox reads the engine's ungrouped key instead …
Makisuo Nov 10, 2026
7e5000c
fix(alerts): layer a saved schedule's overrides into the editor preview
Makisuo Nov 10, 2026
26c44d4
fix(alerts): ungrouped readings and incidents show no group, with fix…
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
48df8a0
refactor(alerts): incidentScope drops its no-op branch
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
043d2d0
fix(status-page): import escapeHtml from @maple/strings in the markdo…
Makisuo Nov 10, 2026
d17a22e
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
4b22e15
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
b4fe115
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
3bc455d
Merge branch 'ux/alerting-pages' into ux/alerts-hub-polish
Makisuo Nov 10, 2026
99f46f8
Merge branch 'ux/alerting-pages' into ux/escalation-editor
Makisuo Nov 10, 2026
9974496
Merge branch 'ux/alerting-pages' into ux/schedule-preview
Makisuo Nov 10, 2026
8507952
Merge branch 'ux/condition-builder' into ux/rule-sentence-editor
Makisuo Nov 10, 2026
49b4357
Merge branch 'ux/schedule-preview' into ux/alerting-preview
Makisuo Nov 10, 2026
f01aae9
Merge branch 'ux/alerts-hub-polish' into ux/alerting-preview
Makisuo Nov 10, 2026
2c04379
Merge branch 'ux/rule-sentence-editor' into ux/alerting-preview
Makisuo Nov 10, 2026
f0ea639
Merge remote-tracking branch 'origin/fix/status-page-escape-import' i…
Makisuo Nov 10, 2026
cd3b2c5
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
43c444a
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
8bc6d63
Merge branch 'ux/alerting-pages' into ux/escalation-editor
Makisuo Nov 10, 2026
1abff55
Merge branch 'ux/alerting-pages' into ux/schedule-preview
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
2025903
Merge branch 'ux/alerting-pages' into ux/alerts-hub-polish
Makisuo Nov 10, 2026
78ebb4a
fix(alerts): silence form says what is missing instead of a silent di…
Makisuo Nov 10, 2026
5afb72a
fix(alerts): silence chips split on commas and keep a chip typed twice
Makisuo Nov 10, 2026
a037e66
fix(alerts): silence chips hint sits in the form, not in a popup over…
Makisuo Nov 10, 2026
2b33c5e
fix(shell): re-check a stale 'no schedules' answer so a teammate's fi…
Makisuo Nov 10, 2026
82145ab
fix(shell): only the current page's link carries aria-current in the …
Makisuo Nov 10, 2026
f675406
fix(alerts): silence save button names its end on the 24h clock the p…
Makisuo Nov 10, 2026
d599b1d
fix(ui): make the sidebar row hover visible in the light theme
Makisuo Nov 10, 2026
717a12f
fix(alerts): first-run Inbox and Rules leave the create actions to th…
Makisuo Nov 10, 2026
994eb5a
fix(alerts): hub replaces away a ?sheet= that opens nothing
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
9ca9b4a
fix(alerts): routing row's chain delete lists the rules using it and …
Makisuo Nov 10, 2026
d04fb31
fix(alerts): a chain id that matches nothing is titled Escalation cha…
Makisuo Nov 10, 2026
07724a1
fix(query-builder): a doubled operator or missing value stays in Text
Makisuo Nov 10, 2026
af1108a
fix(alerts): removing a chain target keeps focus in the step
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
9a1429a
fix(alerts): long schedule names truncate in the shifts heading and w…
Makisuo Nov 10, 2026
a8edc84
fix(alerts): coverage summary names the period it counts, not the nex…
Makisuo Nov 10, 2026
b0d1169
fix(alerts): the preview verdict wraps under the chart title on a phone
Makisuo Nov 10, 2026
fc1ac22
test(e2e): a saved rule returns to the Rules section
Makisuo Nov 10, 2026
a4f05c4
test(e2e): alert rule tests follow the sentence editor's closed cards…
Makisuo Nov 10, 2026
5f2c136
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
4766623
Merge remote-tracking branch 'origin/main' into ux/condition-builder
Makisuo Nov 10, 2026
c51ad1b
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
aae48a6
Merge branch 'ux/alerting-pages' into ux/escalation-editor
Makisuo Nov 10, 2026
ed1e3ca
Merge branch 'ux/alerting-pages' into ux/schedule-preview
Makisuo Nov 10, 2026
21bfb1e
Merge branch 'ux/condition-builder' into ux/rule-sentence-editor
Makisuo Nov 10, 2026
dda1e13
Merge branch 'ux/alerting-pages' into ux/alerts-hub-polish
Makisuo Nov 10, 2026
36cba20
Merge branch 'ux/alerting-pages' into ux/alerting-preview
Makisuo Nov 10, 2026
19ce04f
Merge branch 'ux/escalation-editor' into ux/alerting-preview
Makisuo Nov 10, 2026
184f3a9
Merge branch 'ux/schedule-preview' into ux/alerting-preview
Makisuo Nov 10, 2026
08d36bd
Merge branch 'ux/alerts-hub-polish' into ux/alerting-preview
Makisuo Nov 10, 2026
b8f2f7a
Merge branch 'ux/rule-sentence-editor' into ux/alerting-preview
Makisuo Nov 10, 2026
c4bc214
fix(alerts): inbox filters read hasAnomalies after the first-run pred…
Makisuo Nov 10, 2026
b3169db
Merge branch 'ux/alerts-hub-polish' into ux/alerting-preview
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
14 changes: 10 additions & 4 deletions apps/web/e2e/tests/alert-rules.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,26 @@ test.describe("alert rules", () => {

try {
await app.open(editUrl)
// A saved rule opens as closed cards: each field sits behind its card's summary.
await browser.locator("#sentence-card-condition-summary").tap()
await expect(browser.locator("#rule-threshold")).toHaveValue("0.7")
await expect(screen.getByLabel("Critical threshold")).toHaveValue("3")
await expect(screen.getByLabel("Out of the last")).toHaveValue("4")

await browser.locator("#sentence-card-name-summary").tap()
await screen.getByLabel("Rule name").fill(`e2e tiers renamed ${tag}`)
await screen.getByRole("button", "Save changes").tap()
await expect(browser).toHaveURL(/\/alerts$/)
await expect(browser).toHaveURL(/\/alerts\/rules$/)
const renamed = await read()
expect(renamed.body.name).toBe(`e2e tiers renamed ${tag}`)
expect(renamed.body.thresholds).toEqual(tiers)
expect(renamed.body.breaches_out_of).toBe(4)

await app.open(editUrl)
await browser.locator("#sentence-card-condition-summary").tap()
await screen.getByLabel("Critical threshold").fill("4")
await screen.getByRole("button", "Save changes").tap()
await expect(browser).toHaveURL(/\/alerts$/)
await expect(browser).toHaveURL(/\/alerts\/rules$/)
const retuned = await read()
expect(Arr.map(retuned.body.thresholds, (tier) => tier.value)).toEqual([0.007, 0.04])
} finally {
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@fontsource-variable/geist-mono": "^5.3.0",
"@maple-dev/effect-sdk": "workspace:*",
"@maple/agent-sessions": "workspace:*",
"@maple/alerting-core": "workspace:*",
"@maple/chat-platform": "workspace:*",
"@maple/domain": "workspace:*",
"@maple/effect-db": "workspace:*",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/settings/audit-log-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type AuditResourceLink =
| { readonly to: "/errors/issues/$issueId"; readonly params: { readonly issueId: string } }
| { readonly to: "/investigations/$id"; readonly params: { readonly id: string } }
| { readonly to: "/anomalies/$incidentId"; readonly params: { readonly incidentId: string } }
| { readonly to: "/alerts"; readonly search: { readonly tab: "routing" } }
| { readonly to: "/alerts/routing" }
| { readonly to: "/integrations"; readonly search: { readonly integration?: string } }
| { readonly to: "/settings"; readonly search: { readonly tab: string } }

Expand Down Expand Up @@ -79,7 +79,7 @@ function resourceLink(entry: AuditEntryResource): AuditResourceLink | null {
return incidentId === null ? null : { to: "/anomalies/$incidentId", params: { incidentId } }
}
case "alert_destination":
return { to: "/alerts", search: { tab: "routing" } }
return { to: "/alerts/routing" }
case "scrape_target":
return { to: "/integrations", search: { integration: "prometheus" } }
case "gcp_connector":
Expand Down
140 changes: 102 additions & 38 deletions apps/web/src/lab/alerts-hub-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,28 @@ import * as Policy from "@maple/domain/policy"
import { OrgId } from "@maple/domain"
import { AlertSilenceId } from "@maple/domain/http"
import type { V2AlertSilence } from "@maple/domain/http/v2"
import { Effect, Option, Result, Schema, Array as Arr } from "effect"
import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect"
import { Command, type Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { defineMessageUnion } from "foldkit/message"
import { zonedDateParts } from "@maple/query-engine/datetime"
import * as Combobox from "@maple/views/ui/combobox"
import * as HubMessage from "../page/alerts/hub/message"
import * as HubModel from "../page/alerts/hub/model"
import * as HubUpdate from "../page/alerts/hub/update"
import { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view"
import * as SilenceDialog from "../page/alerts/silence-dialog/dialog"
import { destinationRow, NOW, ORG, ruleRow, stateRow } from "../page/alerts/shared/test-rows"
import { alertsHref } from "../page/alerts/shared/links"
import { destinationRow, NOW, ORG, ruleRow, stateRow, hubAt } from "../page/alerts/shared/test-rows"
import type { PageContext } from "../page/contract"
import type { Resources } from "../platform/resources"
import { readEffectiveTimeZone } from "../platform/time-zone"

// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences tabs and the silence dialog over
// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences sections and the silence side panel over
// fixtures, including a rule skipped for too few samples and a cancelled silence. `?state=` picks
// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom.
// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom,
// silence-dialog-filled. The shell stands on the section's own path, `?sheet=` is played here
// rather than pushed, and times read on the viewer's own clock, as in the app.

export const LabState = Schema.Literals([
"inbox",
Expand All @@ -27,37 +33,42 @@ export const LabState = Schema.Literals([
"silences",
"silence-dialog",
"silence-dialog-custom",
"silence-dialog-filled",
])
export type LabState = typeof LabState.Type

export const Flags = Schema.Struct({ state: LabState })
export const Flags = Schema.Struct({ state: LabState, timeZone: Schema.String })
export type Flags = typeof Flags.Type

export const flags: Effect.Effect<Flags> = Effect.sync(() => ({
export const flags: Effect.Effect<Flags> = Effect.map(readEffectiveTimeZone, (timeZone) => ({
state: Option.getOrElse(
Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")),
(): LabState => "inbox",
),
timeZone,
}))

export const Model = Schema.Struct({ state: LabState, hub: HubModel.Model })
export const Model = Schema.Struct({ state: LabState, timeZone: Schema.String, hub: HubModel.Model })
export type Model = typeof Model.Type

export const Message = defineMessageUnion({ GotHubMessage: { message: HubMessage.Message } })
export type Message = typeof Message.Type

type Return = Update.Return<Model, Message, Resources>

const CONTEXT: PageContext = {
orgId: Schema.decodeSync(OrgId)(ORG),
pinnedNamespace: null,
timeZone: "UTC",
}
const LAB_ORG = Schema.decodeSync(OrgId)(ORG)
const contextOf = (timeZone: string): PageContext => ({ orgId: LAB_ORG, pinnedNamespace: null, timeZone })
const GRANTS = Policy.grantsOf(["org:engineer"])

/** The hub's reads and writes are dropped: the fixture is all the data there is. */
/** The hub's reads, writes and URL changes are dropped: the fixture is all the data there is. */
const keepLocal = <M>(commands: ReadonlyArray<Command.Command<M, never, Resources>> | undefined) =>
Arr.filter(commands ?? [], (command) => !/^(Fetch|Toggle|Delete|Save|Read|End)/.test(command.name))
Arr.filter(
commands ?? [],
(command) =>
!/^(Fetch|Toggle|Delete|Save|Read|End|PushAlertsHubSheet|DismissAlertsHubSheet)/.test(
command.name,
),
)

const MINUTE = 60_000
const iso = (ms: number) => new Date(ms).toISOString()
Expand Down Expand Up @@ -123,7 +134,10 @@ const SILENCES = [
silence("11111111-1111-4111-8111-111111111111", "Deploying checkout v2.3", -20, 40, {
services: ["checkout"],
}),
silence("22222222-2222-4222-8222-222222222222", "Database migration", 180, 240, { tags: ["db"] }),
// Tomorrow: its row names the day.
silence("22222222-2222-4222-8222-222222222222", "Database migration", 26 * 60, 27 * 60, {
tags: ["db"],
}),
// Cancelled before it started: the API collapses its window to one instant.
silence("33333333-3333-4333-8333-333333333333", "Load test on staging", -45, -45, {
environments: ["staging"],
Expand All @@ -140,10 +154,24 @@ const tabOf = (state: LabState) =>
? "inbox"
: "silences"

const step = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT)
const routeTo = (model: HubModel.Model, sheet: string | undefined, context: PageContext) =>
HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), context)

/** The hub's update, with the `?sheet=` push and dismissal it asks for played back as route changes. */
const step = (model: HubModel.Model, message: HubMessage.Message, context: PageContext) => {
const next = HubUpdate.update(model, message, context)
const asked = Arr.findFirst(next.commands ?? [], (command) => Str.endsWith("HubSheet")(command.name))
if (asked._tag === "None") return next
const routed = routeTo(
next.model,
asked.value.name === "PushAlertsHubSheet" ? HubUpdate.SILENCE_SHEET : undefined,
context,
)
return { ...routed, commands: [...(next.commands ?? []), ...(routed.commands ?? [])] }
}

const hubFor = (state: LabState): HubModel.Model => {
const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: tabOf(state) } }, CONTEXT).model
const hubFor = (state: LabState, context: PageContext): HubModel.Model => {
const entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), context).model
const base = [
HubMessage.Message.ReadClock({ nowMs: NOW }),
HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }),
Expand All @@ -163,49 +191,85 @@ const hubFor = (state: LabState): HubModel.Model => {
result: Result.succeed(SILENCES),
}),
]
return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message).model)
return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message, context).model)
}

const pad = (value: number): string => Str.padStart(2, "0")(String(value))

/** `ms` as a `datetime-local` value on the viewer's clock. */
const localInput = (ms: number, timeZone: string): string => {
const parts = zonedDateParts(ms, timeZone)
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}`
}

const dialog = (message: SilenceDialog.Message) => HubMessage.Message.GotSilenceDialogMessage({ message })
const chip = (field: SilenceDialog.ScopeField, value: string) =>
dialog(
SilenceDialog.Message.GotScopeComboMessage({
field,
message: Combobox.Message.ClickedItem({ value }),
}),
)

/** What each silence-panel state does once the panel is open. */
const panelSteps = (state: LabState, timeZone: string): ReadonlyArray<HubMessage.Message> =>
state === "silence-dialog-custom"
? [dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" }))]
: state === "silence-dialog-filled"
? [
chip("services", "checkout"),
chip("services", "payment-svc"),
chip("environments", "production"),
dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" })),
dialog(
SilenceDialog.Message.ChangedUntil({
value: localInput(NOW + 26 * 60 * MINUTE, timeZone),
}),
),
dialog(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })),
]
: []

const toLab = (
next: Readonly<{ commands?: ReadonlyArray<Command.Command<HubMessage.Message, never, Resources>> }>,
) => Command.mapMessages(keepLocal(next.commands), (message) => Message.GotHubMessage({ message }))

export const init = (flags: Flags): Return => {
const hub = hubFor(flags.state)
if (flags.state !== "silence-dialog" && flags.state !== "silence-dialog-custom")
return { model: { state: flags.state, hub } }
const context = contextOf(flags.timeZone)
const hub = hubFor(flags.state, context)
const lab = { state: flags.state, timeZone: flags.timeZone }
if (!Str.startsWith("silence-dialog")(flags.state)) return { model: { ...lab, hub } }
// The dialog opens through its own commands, so they run here rather than in the fold.
const opened = step(hub, HubMessage.Message.ClickedNewSilence())
const picked =
flags.state === "silence-dialog-custom"
? step(
opened.model,
HubMessage.Message.GotSilenceDialogMessage({
message: SilenceDialog.Message.ChangedDuration({ duration: "custom" }),
}),
)
: { model: opened.model, commands: [] }
return {
model: { state: flags.state, hub: picked.model },
commands: [...toLab(opened), ...toLab(picked)],
}
const opened = step(hub, HubMessage.Message.ClickedNewSilence(), context)
const filled = Arr.reduce(
panelSteps(flags.state, flags.timeZone),
{ model: opened.model, commands: toLab(opened) },
(current, message) => {
const next = step(current.model, message, context)
return { model: next.model, commands: [...current.commands, ...toLab(next)] }
},
)
return { model: { ...lab, hub: filled.model }, commands: filled.commands }
}

export const update = (model: Model, message: Message): Return =>
Message.match<Return>(message, {
GotHubMessage: ({ message: child }) => {
const next = step(model.hub, child)
const next = step(model.hub, child, contextOf(model.timeZone))
return { model: { ...model, hub: next.model }, commands: toLab(next) }
},
})

export const breadcrumbs = (model: Model) => hubBreadcrumbs(model.hub)

/** The section's own path, so the sidebar opens Alerts on it. */
export const currentPath = (model: Model): string => alertsHref({}, model.hub.tab)

export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
h.submodel({
slotId: "lab-alerts-hub",
model: model.hub,
view: hubView,
viewInputs: { context: CONTEXT },
viewInputs: { context: contextOf(model.timeZone) },
toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }),
})
6 changes: 4 additions & 2 deletions apps/web/src/lab/anomaly-alerts-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
ruleId,
ruleRow,
stateRow,
hubAt,
type HubAt,
} from "../page/alerts/shared/test-rows"
import type { PageContext } from "../page/contract"
import type { Resources } from "../platform/resources"
Expand Down Expand Up @@ -156,7 +158,7 @@ const RESOLVED = [
]

const hubFor = (state: LabState) => {
const search: HubModel.Model["search"] =
const search: HubAt =
state === "hub-rules"
? { tab: "rules" }
: state === "hub-anomalies-resolved"
Expand All @@ -167,7 +169,7 @@ const hubFor = (state: LabState) => {
? [incidentRow({ rule_name: "Payment p95 latency" })]
: [incidentRow({ rule_name: "Payment p95 latency" }), ...OPEN, ...RESOLVED]
return fold(
HubUpdate.init({ _tag: "Alerts", search }, CONTEXT).model,
HubUpdate.init(hubAt(search), CONTEXT).model,
[
HubMessage.Message.ReadClock({ nowMs: NOW }),
HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }),
Expand Down
Loading
Loading