Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 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
ef30170
test(shell): lab on-call fixture no longer expires on 16 Nov 2026
Makisuo Nov 9, 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
109147b
refactor(alerts): the Inbox reads the engine's ungrouped key instead …
Makisuo Nov 10, 2026
26c44d4
fix(alerts): ungrouped readings and incidents show no group, with fix…
Makisuo Nov 10, 2026
48df8a0
refactor(alerts): incidentScope drops its no-op branch
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
3bc455d
Merge branch 'ux/alerting-pages' into ux/alerts-hub-polish
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
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
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
dda1e13
Merge branch 'ux/alerting-pages' into ux/alerts-hub-polish
Makisuo Nov 10, 2026
c4bc214
fix(alerts): inbox filters read hasAnomalies after the first-run pred…
Makisuo Nov 10, 2026
139093f
refactor(alerts): headroomTone reads isNear, so one expression decide…
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
db9c742
Merge branch 'ux/alerting-pages' into ux/alerts-hub-polish
Makisuo Nov 10, 2026
6a9012e
Merge remote-tracking branch 'origin/main' into ux/alerting-pages
Makisuo Nov 10, 2026
bb8290b
Merge branch 'ux/alerting-pages' into ux/alerts-hub-polish
Makisuo Nov 10, 2026
761f02c
Merge remote-tracking branch 'origin/main' into ux/alerts-hub-polish
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
88 changes: 86 additions & 2 deletions apps/web/src/page/alerts/hub/hub.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ describe("alerts hub", () => {
{ update: pageUpdate, view },
given(loaded()),
expectView(text("1 firing")).toExist(),
expectView(role("link", { name: "P95 latency 1.84s on all" })).toHaveAttr(
expectView(role("link", { name: "P95 latency 1.84s" })).toHaveAttr(
"href",
"/alerts/incidents/0199a003-0000-7000-8000-000000000001",
),
Expand Down Expand Up @@ -284,7 +284,7 @@ describe("alerts hub", () => {
expectView(text("0 firing")).toExist(),
expectView(text(/1 acknowledged/)).toExist(),
expectView(text(/^Acknowledged by Dana Kim ·/)).toExist(),
expectView(role("button", { name: "Unacknowledge P95 latency 1.84s on all" })).toExist(),
expectView(role("button", { name: "Unacknowledge P95 latency 1.84s" })).toExist(),
)
const clicked = pageUpdate(
loaded(),
Expand Down Expand Up @@ -403,6 +403,90 @@ describe("alerts hub", () => {
)
})

test("a rule's whole row is one link to the rule, with its actions menu beside it", () => {
scene(
{ update: pageUpdate, view },
given(loaded({ tab: "rules" })),
expectView(role("link", { name: "Payment p95 latency" })).toHaveAttr(
"href",
`/alerts/rules/${ruleId(2)}`,
),
expectView(role("button", { name: "Actions for Payment p95 latency" })).toExist(),
)
})

test("untagged rules alone get no group header; with a tagged rule each group is named", () => {
scene(
{ update: pageUpdate, view },
given(loaded({ tab: "rules" })),
expectView(text("No tag")).not.toExist(),
)
const tagged = pageUpdate(
loaded({ tab: "rules" }),
Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(1), ruleRow(2, { tags_json: ["api"] })] }),
).model
scene(
{ update: pageUpdate, view },
given(tagged),
expectView(text("No tag")).toExist(),
expectView(text("api")).toExist(),
)
})

test("a quiet Inbox stays calm: no Anomalies toggle without anomalies, MTTR n/a, an ungrouped reading has no group line", () => {
// Nothing open, and one rule routed nowhere: a blind spot, so the last 24 hours are drawn.
const quiet = Arr.reduce(
[
Message.UpdatedIncidents({ orgId: ORG, rows: [] }),
Message.UpdatedRules({
orgId: ORG,
rows: [
ruleRow(1, { destination_ids_json: [] }),
ruleRow(2, { name: "Payment p95 latency" }),
],
}),
],
loaded(),
(model, message) => pageUpdate(model, message).model,
)
scene(
{ update: pageUpdate, view },
given(quiet),
expectView(role("heading", { name: /^Nothing firing/ })).toExist(),
expectView(role("button", { name: /^Anomalies/ })).not.toExist(),
expectView(text("0 incidents · MTTR n/a · 0 rules fired")).toExist(),
expectView(role("link", { name: "Payment p95 latency" })).toExist(),
expectView(text("all")).not.toExist(),
expectView(text("__total__")).not.toExist(),
)
})

test("acknowledged and unacknowledged incidents both offer a button to flip the state", () => {
const acknowledged = pageUpdate(
loaded(),
Message.UpdatedIncidents({
orgId: ORG,
rows: [
incidentRow({ rule_name: "Payment p95 latency" }),
incidentRow({
id: "0199a003-0000-7000-8000-000000000002",
rule_name: "Payment p95 latency",
group_key: "checkout",
acknowledged_at: new Date(NOW - 60_000).toISOString(),
acknowledged_by_name: "Dana Kim",
acknowledged_via: "slack",
}),
],
}),
).model
scene(
{ update: pageUpdate, view },
given(acknowledged),
expectView(role("button", { name: /^Acknowledge / })).toExist(),
expectView(role("button", { name: /^Unacknowledge / })).toExist(),
)
})

test("a status chip writes its bucket to the URL, and pressing it again clears it", () => {
scene(
{ update: pageUpdate, view },
Expand Down
20 changes: 18 additions & 2 deletions apps/web/src/page/alerts/hub/view-anomalies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,24 @@ const STATUS_OPTIONS: ReadonlyArray<{ value: IncidentStatusFilter; label: string
{ value: "all", label: "All" },
]

/** The Inbox's filter row: the Anomalies view toggle and, inside it, the status and service. */
/**
* The Inbox's filter row: the Anomalies view toggle and, inside it, the status and service. An
* org with no anomaly in the window has no row at all, and a zero count is left off the toggle.
*/
export const inboxFilters = (
h: H,
options: Readonly<{
signal: InboxSignal
status: IncidentStatusFilter
serviceName: string | undefined
openAnomalies: number
/** Any anomaly incident on hand, open or resolved: the view has something to show. */
hasAnomalies: boolean
}>,
): ReadonlyArray<Html> =>
options.signal !== "anomaly" && !options.hasAnomalies ? [] : [inboxFilterRow(h, options)]

const inboxFilterRow = (
h: H,
options: Readonly<{
signal: InboxSignal
Expand All @@ -58,7 +74,7 @@ export const inboxFilters = (
onClick: Message.ClickedInboxSignal({
signal: options.signal === "anomaly" ? null : "anomaly",
}),
count: String(options.openAnomalies),
...(options.openAnomalies > 0 ? { count: String(options.openAnomalies) } : undefined),
},
["Anomalies"],
),
Expand Down
43 changes: 19 additions & 24 deletions apps/web/src/page/alerts/hub/view-inbox.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AlertIncidentDocument, AlertIncidentId, AlertRuleDocument } from "@maple/domain/http"
import { acknowledgedLine } from "@maple/web-data/alerts/incident-timeline"
import { countLabel, EMPTY_VALUE } from "@maple/ui/lib/format"
import { countLabel } from "@maple/ui/lib/format"
import { formatRelativeShortFrom } from "@maple/ui/lib/time-format"
import { cn } from "@maple/ui/lib/utils"
import {
Expand All @@ -17,6 +17,7 @@ import {
blindSpots as deriveBlindSpots,
type FireLane,
fireLanes,
headroomTone,
incidentSubline,
incidentTitle,
incidentValues,
Expand All @@ -30,6 +31,7 @@ import { eyebrow } from "@maple/views/ui/eyebrow"
import { icon } from "@maple/views/ui/icon"
import { ROW_LINK_LIFT, rowLink } from "@maple/views/ui/table"
import { severityBadge } from "../shared/badges"
import { HEADROOM_FILL, HEADROOM_TEXT } from "../shared/headroom-tone"
import { incidentHref, ruleHref } from "../shared/links"
import { sortIncidents } from "../shared/sort"
import { isoMs, msIso } from "../shared/time"
Expand All @@ -45,7 +47,7 @@ import { Array as Arr } from "effect"
// `InboxTab` and `HeadroomList`: the hub's Inbox tab.

const HOUR_MS = 60 * 60 * 1000
const INCIDENT_GRID = "grid grid-cols-[minmax(0,1fr)_150px_90px_120px] items-center gap-4"
const INCIDENT_GRID = "grid grid-cols-[minmax(0,1fr)_150px_90px_140px] items-center gap-4"
const MAX_LANES = 8
const MAX_HEADROOM_ROWS = 8
// Below `sm` the bar has no room: the row keeps the name, the value and when it was checked.
Expand Down Expand Up @@ -260,8 +262,9 @@ const openIncidentRow = <M>(
button(
h,
{
variant: acked === null ? "outline" : "ghost",
size: "xs",
// Both states are the same small outline button: one row never reads as bare text.
variant: "outline",
size: "sm",
isLoading: acknowledge.pendingIds.includes(incident.id),
attributes: [
h.OnClick(acknowledge.onAcknowledge(incident.id, acked === null)),
Expand Down Expand Up @@ -289,7 +292,7 @@ const fireStrip = <M>(
const resolved = Arr.filter(inWindow, (i) => i.resolvedAt !== null)
const mttr =
resolved.length === 0
? EMPTY_VALUE
? "n/a"
: formatResolutionDuration(
Arr.reduce(
resolved,
Expand Down Expand Up @@ -320,13 +323,7 @@ const fireStrip = <M>(
hstack(h, { gap: "4", align: "baseline", justify: "between" }, [
eyebrow(h, { as: "h3" }, ["Last 24 hours"]),
typography(h, { size: "xs", tone: "muted", mono: true }, [
countLabel(inWindow.length, "incident"),
" · MTTR ",
mttr,
" ·",
" ",
countLabel(lanes.length, "rule"),
" fired",
`${countLabel(inWindow.length, "incident")} · MTTR ${mttr} · ${countLabel(lanes.length, "rule")} fired`,
]),
]),
shown.length === 0
Expand Down Expand Up @@ -524,7 +521,8 @@ const headroomList = <M>(h: HtmlBuilder<M>, rows: ReadonlyArray<RuleHeadroom>, n

const headroomRow = <M>(h: HtmlBuilder<M>, row: RuleHeadroom, now: number): Html => {
const { rule, closeness } = row
const { isNear } = row
const tone = headroomTone(row)
const valueTone = HEADROOM_TEXT[tone]
const isFloor = rule.comparator === "lt" || rule.comparator === "lte"
return h.li(
[
Expand Down Expand Up @@ -556,33 +554,30 @@ const headroomRow = <M>(h: HtmlBuilder<M>, row: RuleHeadroom, now: number): Html
],
),
h.div(
[h.Class("relative hidden h-2 rounded-xs bg-surface-strong sm:block"), h.AriaHidden(true)],
[
h.Class("relative hidden h-1.5 rounded-full bg-surface-strong sm:block"),
h.AriaHidden(true),
],
[
...(closeness !== null
? [
h.span([
h.Class(
barFillClass(isNear ? "bg-severity-warn" : "bg-foreground-faint"),
),
h.Class(barFillClass(HEADROOM_FILL[tone])),
h.Style({ width: `${Math.max(closeness * 100, 1.5)}%` }),
]),
]
: []),
// The threshold, at the bar's end: a neutral tick, as in the rules table.
h.span([
h.AriaHidden(true),
h.Class(
cn(
"absolute -inset-y-1 right-0 w-0.5 rounded-full",
rule.severity === "critical" ? "bg-severity-error" : "bg-severity-warn",
),
),
h.Class("absolute -inset-y-0.5 right-0 w-px bg-foreground/70"),
]),
],
),
h.span(
[h.Class("truncate text-right font-mono text-xs tabular-nums")],
[
h.span(isNear ? [h.Class("text-severity-warn")] : [], [ruleValue(rule, row.value)]),
h.span(valueTone === undefined ? [] : [h.Class(valueTone)], [ruleValue(rule, row.value)]),
typography(h, { tone: "muted" }, [
isFloor ? " / min " : " / ",
ruleValue(rule, rule.threshold),
Expand Down
54 changes: 35 additions & 19 deletions apps/web/src/page/alerts/hub/view-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { formatSignalValue } from "@maple/web-data/alerts/form-utils"
import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview"
import {
type RibbonCell,
headroomTone,
ruleBucket,
ruleConditionSummary,
ruleHeadroom,
Expand All @@ -26,6 +27,7 @@ import { statusDot } from "@maple/views/ui/status-dot"
import { ROW_LINK_LIFT, rowLink } from "@maple/views/ui/table"
import { listToolbar, toolbarCount } from "../../kit/list-toolbar"
import { filteredEmpty } from "@maple/views/ui/signal-empty-state"
import { HEADROOM_FILL } from "../shared/headroom-tone"
import { ruleHref } from "../shared/links"
import { isoMs } from "../shared/time"
import { Message, type RuleBucket } from "./message"
Expand Down Expand Up @@ -55,8 +57,9 @@ const BUCKETS: ReadonlyArray<{ value: RuleBucket; label: string; tone: Tone }> =
]
const BUCKET_TONE = new Map<RuleBucket, Tone>(Arr.map(BUCKETS, (bucket) => [bucket.value, bucket.tone]))

// The ribbon is 28 cells of 4px; Routes takes the width that leaves, and Fired fits its heading on one line.
const ROW_GRID =
"grid grid-cols-[12px_minmax(0,1fr)_170px_150px_minmax(0,160px)_48px_32px] items-center gap-4"
"grid grid-cols-[12px_minmax(0,1fr)_170px_112px_minmax(0,200px)_68px_32px] items-center gap-4"

const capGroups = <T>(groups: ReadonlyArray<TagGroup<T>>, limit: number): Array<TagGroup<T>> =>
Arr.reduce<TagGroup<T>, { out: Array<TagGroup<T>>; left: number }>(
Expand Down Expand Up @@ -111,6 +114,8 @@ export const rulesTab = (h: H, model: Model, inputs: RulesTabInputs): Html => {
)
})
const groups = groupByTag(filtered, (rule) => rule.tags)
// One group of untagged rules is the whole list: its "No tag" header says nothing.
const isLoneUntagged = groups.length === 1 && groups[0]?.key === UNTAGGED_KEY
const totalRows = Arr.reduce(groups, 0, (sum, group) => sum + group.items.length)
const isCapped = !model.isShowingAllRules && totalRows > RENDER_LIMIT
const visibleGroups = isCapped ? capGroups(groups, RENDER_LIMIT) : groups
Expand Down Expand Up @@ -210,7 +215,7 @@ export const rulesTab = (h: H, model: Model, inputs: RulesTabInputs): Html => {
h.span([], ["Last ", String(RIBBON_DAYS), " days"]),
h.span([], ["Routes to"]),
h.span(
[h.Class("text-right")],
[h.Class("whitespace-nowrap text-right")],
["Fired ", String(FIRED_WINDOW_DAYS), "d"],
),
h.span([], []),
Expand All @@ -224,21 +229,31 @@ export const rulesTab = (h: H, model: Model, inputs: RulesTabInputs): Html => {
return h.section(
[h.Key(group.key), h.Class("border-b last:border-b-0")],
[
h.div(
[h.Class("border-b bg-surface-sunken px-4 py-2 text-xs")],
[
typography(h, { weight: "medium" }, [
group.key === UNTAGGED_KEY ? "No tag" : group.label,
...(isLoneUntagged
? []
: [
h.div(
[
h.Class(
"border-b bg-surface-sunken px-4 py-2 text-xs",
),
],
[
typography(h, { weight: "medium" }, [
group.key === UNTAGGED_KEY
? "No tag"
: group.label,
]),
typography(h, { tone: "muted" }, [
" ",
countLabel(group.count, "rule"),
...(firingInGroup > 0
? [` · ${firingInGroup} firing`]
: []),
]),
],
),
]),
typography(h, { tone: "muted" }, [
" ",
countLabel(group.count, "rule"),
...(firingInGroup > 0
? [` · ${firingInGroup} firing`]
: []),
]),
],
),
dividedList(
h,
{ as: "ul", bordered: false },
Expand Down Expand Up @@ -274,7 +289,8 @@ const rulesSearch = (h: H, value: string): Html =>
searchInput(h, {
id: "alerts-rules-search",
size: "sm",
className: "w-full max-w-xs",
// A fixed width: a fluid one makes the row measure short, and the last filter chip wraps alone.
className: "w-64 max-w-full shrink-0",
placeholder: "Search rules, services, tags…",
value,
onInput: (next) => Message.ChangedRulesQuery({ value: next }),
Expand Down Expand Up @@ -395,7 +411,7 @@ const headroomMeter = (
return typography(h, { size: "xs", tone: "muted" }, ["Too few samples to check"])
if (value === null) return typography(h, { size: "xs", tone: "muted" }, ["Waiting on data"])
const meter = ruleHeadroom(rule, value)
const tone = meter.breached ? "bg-severity-error" : meter.isNear ? "bg-severity-warn" : "bg-severity-info"
const tone = headroomTone(meter)
const label = meter.breached
? "over threshold"
: meter.headroom !== null
Expand All @@ -408,7 +424,7 @@ const headroomMeter = (
[h.Class("relative h-1.5 rounded-full bg-muted")],
[
h.span([
h.Class(cn("absolute inset-y-0 left-0 rounded-full", tone)),
h.Class(cn("absolute inset-y-0 left-0 rounded-full", HEADROOM_FILL[tone])),
h.Style({ width: `${meter.fill * 100}%` }),
]),
...Arr.map(meter.ticks, (tick) =>
Expand Down
Loading
Loading