Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
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
0c0b786
Merge remote-tracking branch 'origin/main' into ux/shell
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
280ea47
fix(alerts): review follow-ups for section routes and the silence panel
Makisuo Nov 9, 2026
1cb4cef
feat(alerts): full-page escalation chain editor
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
d637338
fix(alerts): the last gap of an escalation chain always shows its add…
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
1c0bfcd
Merge remote-tracking branch 'origin/ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
4ab5881
Merge remote-tracking branch 'origin/ux/alerting-pages' into ux/escal…
Makisuo Nov 10, 2026
6a7cfa6
fix(alerts): restore the AsyncData import the hub header needs after …
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
d17a22e
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
b4fe115
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
99f46f8
Merge branch 'ux/alerting-pages' into ux/escalation-editor
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
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
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
af1108a
fix(alerts): removing a chain target keeps focus in the step
Makisuo Nov 10, 2026
fc1ac22
test(e2e): a saved rule returns to the Rules section
Makisuo Nov 10, 2026
5f2c136
Merge remote-tracking branch 'origin/main' into ux/shell
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
c5adf7d
fix(shell): an org without schedules is asked again hourly, so an ope…
Makisuo Nov 10, 2026
655534e
docs(design-rules): list the form sheet size
Makisuo Nov 10, 2026
479e46c
fix(shell): a folded group keeps the current sub-item; on-call is re-…
Makisuo Nov 10, 2026
7360717
docs(shell): navRow's comment follows the folded group's current sub-…
Makisuo Nov 10, 2026
3c7b214
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
8310e9d
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
e2767e2
Merge branch 'ux/alerting-pages' into ux/escalation-editor
Makisuo Nov 10, 2026
6a9012e
Merge remote-tracking branch 'origin/main' into ux/alerting-pages
Makisuo Nov 10, 2026
b2ed364
Merge branch 'ux/alerting-pages' into ux/escalation-editor
Makisuo Nov 10, 2026
8253674
Merge remote-tracking branch 'origin/main' into ux/escalation-editor
Makisuo Nov 10, 2026
0c18b56
fix(alerts): chain delete confirm speaks of one rule in the singular;…
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
16 changes: 16 additions & 0 deletions apps/web/src/lab/entries/escalation-editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineShellLab } from "../shell-lab"
import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../escalation-editor-lab"

// `/lab/escalation-editor`: the escalation chain editor page over fixtures (`?state=`).

export const lab = defineShellLab("Escalation chain editor", {
Model,
Message,
Flags,
flags,
init,
update,
view,
currentPath: "/lab/escalation-editor",
breadcrumbs,
})
2 changes: 1 addition & 1 deletion apps/web/src/lab/entries/escalation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineShellLab } from "../shell-lab"
import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../escalation-lab"

// `/lab/escalation`: escalation chains, their dialog, the rule tier picker and the incident rail (`?state=`).
// `/lab/escalation`: escalation chains, the rule tier picker and the incident rail (`?state=`).

export const lab = defineShellLab("Escalation chains", {
Model,
Expand Down
18 changes: 15 additions & 3 deletions apps/web/src/lab/entries/kit/buttons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const VARIANTS = [
"ghost",
"destructive",
"destructive-outline",
"destructive-ghost",
"link",
"dashed",
] as const
Expand Down Expand Up @@ -417,16 +418,27 @@ const actionBars = (h: HtmlBuilder<Message>): ReadonlyArray<Html> => [
statusDot(h, { tone: "live" }),
typography(h, { size: "sm", tone: "foreground" }, ["Unsaved changes"]),
],
actions: [button(h, { variant: "ghost", size: "sm" }, ["Discard"]), button(h, { size: "sm" }, ["Save"])],
actions: [
button(h, { variant: "ghost", size: "sm" }, ["Discard"]),
button(h, { size: "sm" }, ["Save"]),
],
}),
]),
kitRow(h, "Editor, with a notice", [
actionBar(h, {
label: "Rule actions",
className: "static w-full",
notice: [alert(h, { variant: "crit", size: "sm" }, [icon(h, "CircleXmarkIcon"), alertTitle(h, ["Couldn't save the rule"])])],
notice: [
alert(h, { variant: "crit", size: "sm" }, [
icon(h, "CircleXmarkIcon"),
alertTitle(h, ["Couldn't save the rule"]),
]),
],
leading: [typography(h, { size: "sm", tone: "muted" }, ["Drop health checks from api-gateway"])],
actions: [button(h, { variant: "outline", size: "sm" }, ["Cancel"]), button(h, { size: "sm" }, ["Save changes"])],
actions: [
button(h, { variant: "outline", size: "sm" }, ["Cancel"]),
button(h, { size: "sm" }, ["Save changes"]),
],
}),
]),
kitRow(h, "Step flow", [
Expand Down
202 changes: 202 additions & 0 deletions apps/web/src/lab/escalation-editor-lab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import * as Policy from "@maple/domain/policy"
import { OrgId } from "@maple/domain"
import { OnCallScheduleId } from "@maple/domain/http"
import { EscalationChainPublicId } from "@maple/domain/http/v2"
import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect"
import { Command, type Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { defineMessageUnion } from "foldkit/message"
import { FocusChainElement } from "../page/alerts/escalation-chain/command"
import * as ChainMessage from "../page/alerts/escalation-chain/message"
import * as ChainModel from "../page/alerts/escalation-chain/model"
import { init as chainInit, update as chainUpdate } from "../page/alerts/escalation-chain/update"
import { chainTitle, view as chainView } from "../page/alerts/escalation-chain/view"
import { routingTabHref } from "../page/alerts/shared/escalation-chains"
import {
chainDestinations,
chainOf,
chainScheduleUuid,
populatedChains,
} from "../page/alerts/shared/escalation-test-fixtures"
import { memberId, oncallMembers, populatedSchedules } from "../page/alerts/shared/oncall-test-fixtures"
import { ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows"
import type { PageContext } from "../page/contract"
import type { Resources } from "../platform/resources"
import { keepLocal } from "./local-commands"

// `/lab/escalation-editor`: the escalation chain editor page over fixtures. `?state=` picks one.

export const LabState = Schema.Literals([
"new",
"populated",
"inserted",
"invalid",
"delete",
"leave",
"readonly",
"long",
])
export type LabState = typeof LabState.Type

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

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

export const Model = Schema.Struct({ state: LabState, page: ChainModel.Model })
export type Model = typeof Model.Type

export const Message = defineMessageUnion({ GotPageMessage: { message: ChainMessage.Message } })
export type Message = typeof Message.Type

type Return = Update.Return<Model, Message, Resources>
type PageCommands = ReadonlyArray<Command.Command<ChainMessage.Message, never, Resources>>

const CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" }
const RULES = [
ruleRow(1, { name: "Checkout error rate" }),
ruleRow(2, { name: "Checkout p95 latency", enabled: false }),
ruleRow(3, { name: "Payments webhook failures" }),
]
const M = ChainMessage.Message

/** The fixture destinations with the type their name says, so each draws its own mark. */
const TYPES: ReadonlyArray<readonly [prefix: string, type: string, chatConnector?: string]> = [
["Slack", "chat", "slack"],
["PagerDuty", "pagerduty"],
["Email", "email"],
["Discord", "discord"],
["Microsoft Teams", "microsoft-teams"],
]
const DESTINATIONS = Arr.map(chainDestinations, (row) =>
Option.match(
Arr.findFirst(TYPES, ([prefix]) => Str.startsWith(prefix)(row.name)),
{
onNone: () => row,
onSome: ([, type, chatConnector]) => ({
...row,
type,
config_json: {
summary: row.name,
channelLabel: null,
...(chatConnector === undefined ? undefined : { chatConnector }),
},
}),
},
),
)

/** One schedule, under the id the fixture chains page. */
const SCHEDULES = Arr.map(Arr.take(populatedSchedules(), 1), (schedule) => ({
...schedule,
id: Schema.decodeSync(OnCallScheduleId)(chainScheduleUuid(1)),
}))

/** The everyday chain, paging a schedule and a person beside its destinations. */
const EVERYDAY = chainOf({
n: 1,
name: "Checkout on-call",
description: "Checkout team first, then the platform lead.",
steps: [
{ destinations: [1], schedules: [1], wait: 10 },
{ destinations: [2], wait: 15 },
{ destinations: [3], users: [memberId(0)], wait: 30 },
],
repeat: 2,
ruleIds: [ruleId(1), ruleId(2)],
})
const LONG = populatedChains([])[2]

const NO_COMMANDS: PageCommands = []

/** The page's local Commands: the kit's, and its own focus moves, which only touch the DOM. */
const kept = (commands: PageCommands | undefined): PageCommands => [
...keepLocal(commands),
...Arr.filter(commands ?? [], (command) => command.name === FocusChainElement.name),
]

const run = (model: ChainModel.Model, messages: ReadonlyArray<ChainMessage.Message>) =>
Arr.reduce(messages, { model, commands: NO_COMMANDS }, (current, message) => {
const next = chainUpdate(current.model, message, CONTEXT)
return { model: next.model, commands: [...current.commands, ...kept(next.commands)] }
})

const pageFor = (state: LabState) => {
const isNew = state === "new" || state === "invalid"
const chain = state === "long" ? (LONG ?? EVERYDAY) : EVERYDAY
const entered = chainInit(
isNew
? { _tag: "AlertsEscalationChainNew", search: {} }
: {
_tag: "AlertsEscalationChain",
chainId: Schema.encodeSync(EscalationChainPublicId)(chain.id),
search: {},
},
CONTEXT,
).model
const base = [
M.CompletedFetchSession({
orgId: ORG,
grants: Policy.grantsOf([state === "readonly" ? "org:member" : "org:engineer"]),
}),
M.CompletedFetchMembers({ result: Result.succeed(oncallMembers) }),
M.CompletedFetchSchedules({ result: Result.succeed(SCHEDULES) }),
M.UpdatedDestinations({ orgId: ORG, rows: DESTINATIONS }),
M.UpdatedRules({ orgId: ORG, rows: RULES }),
]
const loaded = isNew
? []
: [M.CompletedFetchExisting({ key: entered.existing.key ?? "", result: Result.succeed(chain) })]
const extra: ReadonlyArray<ChainMessage.Message> =
state === "invalid"
? [M.ChangedWait({ key: "s0", value: "2000" }), M.ClickedSave()]
: state === "inserted"
? [M.ClickedInsertStep({ index: 1 })]
: state === "delete"
? [M.ClickedDelete()]
: state === "leave"
? [M.ChangedWait({ key: "s0", value: "20" }), M.ClickedCancel()]
: []
return run(entered, [...base, ...loaded, ...extra])
}

export const init = (flags: Flags): Return => {
const page = pageFor(flags.state)
return {
model: { state: flags.state, page: page.model },
commands: Command.mapMessages(page.commands, (message) => Message.GotPageMessage({ message })),
}
}

export const update = (model: Model, message: Message): Return =>
Message.match<Return>(message, {
GotPageMessage: ({ message: child }) => {
const next = chainUpdate(model.page, child, CONTEXT)
return {
model: { ...model, page: next.model },
commands: Command.mapMessages(kept(next.commands), (inner) =>
Message.GotPageMessage({ message: inner }),
),
}
},
})

export const breadcrumbs = (model: Model) => [
{ label: "Alerts", href: "/alerts" },
{ label: "Routing", href: routingTabHref() },
{ label: chainTitle(model.page) },
]

export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
h.submodel({
slotId: "lab-escalation-editor",
model: model.page,
view: chainView,
viewInputs: { context: CONTEXT },
toParentMessage: (message: ChainMessage.Message) => Message.GotPageMessage({ message }),
})
64 changes: 3 additions & 61 deletions apps/web/src/lab/escalation-lab.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import * as Policy from "@maple/domain/policy"
import { OrgId } from "@maple/domain"
import type { LoadFailure } from "@maple/views/data/load-failure"
import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect"
import { Command, type Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { defineMessageUnion } from "foldkit/message"
import * as Menu from "@maple/views/ui/menu"
import * as Select from "@maple/views/ui/select"
import * as ChainDialog from "../page/alerts/escalation-dialog/dialog"
import * as EditorMessage from "../page/alerts/editor/message"
import * as EditorModel from "../page/alerts/editor/model"
import { init as editorInit } from "../page/alerts/editor/init"
Expand All @@ -33,16 +30,12 @@ import type { PageContext } from "../page/contract"
import type { Resources } from "../platform/resources"
import { keepLocal } from "./local-commands"

// `/lab/escalation`: escalation chains in the Routing tab, their dialog, a rule tier's chain picker
// `/lab/escalation`: escalation chains in the Routing tab, a rule tier's chain picker
// and the incident rail, over fixtures. `?state=` picks one.

export const LabState = Schema.Literals([
"chains-empty",
"chains",
"dialog-new",
"dialog-edit",
"dialog-invalid",
"dialog-error",
"rule-picker",
"incident-escalating",
"incident-paused",
Expand Down Expand Up @@ -99,18 +92,6 @@ const fold = <PM, PS>(

const hubStep = (model: HubModel.Model, message: HubMessage.Message) =>
HubUpdate.update(model, message, CONTEXT)
const dialog = (message: ChainDialog.Message) => HubMessage.Message.GotChainDialogMessage({ message })

const SAVE_FAILURE: LoadFailure = {
_tag: "@maple/http/errors/AlertValidationError",
type: "invalid_request_error",
code: "invalid_request",
title: "Invalid request",
message: "Step 2 names a destination that no longer exists.",
retryable: false,
recovery: "fix_request",
}

const hubFor = (state: LabState): Step<HubModel.Model, HubMessage.Message> => {
const entered = HubUpdate.init(hubAt({ tab: "routing" }), CONTEXT).model
const chains = state === "chains-empty" ? [] : populatedChains([ruleId(1), ruleId(2), ruleId(3)])
Expand All @@ -128,45 +109,7 @@ const hubFor = (state: LabState): Step<HubModel.Model, HubMessage.Message> => {
result: Result.succeed(chains),
}),
]
const firstStepKey = "s0"
const extra: ReadonlyArray<HubMessage.Message> =
state === "dialog-new"
? [HubMessage.Message.ClickedNewChain()]
: state === "dialog-invalid"
? [
HubMessage.Message.ClickedNewChain(),
dialog(ChainDialog.Message.ChangedWait({ key: firstStepKey, value: "2000" })),
dialog(ChainDialog.Message.ClickedSave()),
]
: state === "dialog-error"
? [
HubMessage.Message.GotRowMenuMessage({
menuId: `chain:${chainUuid(1)}`,
message: Menu.Message.ClickedItem({
index: 0,
item: { kind: "Item", value: "edit" },
}),
}),
dialog(ChainDialog.Message.ClickedSave()),
dialog(
ChainDialog.Message.CompletedSave({
openCount: 1,
result: Result.fail(SAVE_FAILURE),
}),
),
]
: state === "dialog-edit"
? [
HubMessage.Message.GotRowMenuMessage({
menuId: `chain:${chainUuid(3)}`,
message: Menu.Message.ClickedItem({
index: 0,
item: { kind: "Item", value: "edit" },
}),
}),
]
: []
return fold({ model: entered }, [...base, ...extra], hubStep)
return fold({ model: entered }, base, hubStep)
}

const editorStep = (model: EditorModel.Model, message: EditorMessage.Message) =>
Expand Down Expand Up @@ -250,8 +193,7 @@ const incidentFor = (state: LabState): Step<IncidentModel.Model, IncidentMessage
return settled
}

const isHub = (state: LabState) =>
state === "chains" || state === "chains-empty" || Str.startsWith("dialog-")(state)
const isHub = (state: LabState) => state === "chains" || state === "chains-empty"
const isIncident = (state: LabState) => Str.startsWith("incident-")(state)

export const init = (flags: Flags): Return => {
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,15 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
path: "/lab/escalation",
title: "Escalation chains",
description:
"The Routing tab's escalation chains empty and populated, the chain dialog new, editing, invalid and failing, a rule tier's chain picker, and the incident rail escalating, paused or finished (`?state=`).",
"The Routing tab's escalation chains empty and populated, a rule tier's chain picker, and the incident rail escalating, paused or finished (`?state=`).",
kind: "lab",
session: "none",
},
{
path: "/lab/escalation-editor",
title: "Escalation chain editor",
description:
"The escalation chain editor page new, populated, with a step inserted, invalid, asking to delete or to leave, read-only and with a long chain (`?state=`).",
kind: "lab",
session: "none",
},
Expand Down
Loading
Loading