diff --git a/apps/web/src/page/alerts/hub/hub.scene.test.ts b/apps/web/src/page/alerts/hub/hub.scene.test.ts index a821b640e2..f810e7473f 100644 --- a/apps/web/src/page/alerts/hub/hub.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub.scene.test.ts @@ -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", ), @@ -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(), @@ -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 }, diff --git a/apps/web/src/page/alerts/hub/view-anomalies.ts b/apps/web/src/page/alerts/hub/view-anomalies.ts index 57217e983c..ba4031a3de 100644 --- a/apps/web/src/page/alerts/hub/view-anomalies.ts +++ b/apps/web/src/page/alerts/hub/view-anomalies.ts @@ -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 => + options.signal !== "anomaly" && !options.hasAnomalies ? [] : [inboxFilterRow(h, options)] + +const inboxFilterRow = ( h: H, options: Readonly<{ signal: InboxSignal @@ -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"], ), diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index cc2e0ec9f8..a7c7bd9a6e 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -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 { @@ -17,6 +17,7 @@ import { blindSpots as deriveBlindSpots, type FireLane, fireLanes, + headroomTone, incidentSubline, incidentTitle, incidentValues, @@ -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" @@ -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. @@ -260,8 +262,9 @@ const openIncidentRow = ( 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)), @@ -289,7 +292,7 @@ const fireStrip = ( const resolved = Arr.filter(inWindow, (i) => i.resolvedAt !== null) const mttr = resolved.length === 0 - ? EMPTY_VALUE + ? "n/a" : formatResolutionDuration( Arr.reduce( resolved, @@ -320,13 +323,7 @@ const fireStrip = ( 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 @@ -524,7 +521,8 @@ const headroomList = (h: HtmlBuilder, rows: ReadonlyArray, n const headroomRow = (h: HtmlBuilder, 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( [ @@ -556,33 +554,30 @@ const headroomRow = (h: HtmlBuilder, 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), diff --git a/apps/web/src/page/alerts/hub/view-rules.ts b/apps/web/src/page/alerts/hub/view-rules.ts index 755b174c5b..6df9b87c6e 100644 --- a/apps/web/src/page/alerts/hub/view-rules.ts +++ b/apps/web/src/page/alerts/hub/view-rules.ts @@ -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, @@ -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" @@ -55,8 +57,9 @@ const BUCKETS: ReadonlyArray<{ value: RuleBucket; label: string; tone: Tone }> = ] const BUCKET_TONE = new Map(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 = (groups: ReadonlyArray>, limit: number): Array> => Arr.reduce, { out: Array>; left: number }>( @@ -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 @@ -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([], []), @@ -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 }, @@ -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 }), @@ -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 @@ -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) => diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 7278c9c6c1..355522c723 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -290,6 +290,7 @@ const hubBody = ( signal, status: resolveIncidentStatus(model.search.anomalyStatus), serviceName: model.search.serviceName, + hasAnomalies: Arr.some(data.incidents, (incident) => incident.signalType === "anomaly"), // Counted under the same service filter the list applies. openAnomalies: Arr.filter( data.openIncidents, @@ -311,7 +312,7 @@ const hubBody = ( }), }) : inboxTab(h, data, acknowledge) - return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), filters, body]) + return h.div([h.Class("space-y-6")], [...whoIsOnCallStrip(h, model), ...filters, body]) } return rulesTab(h, model, { data, diff --git a/apps/web/src/page/alerts/shared/headroom-tone.ts b/apps/web/src/page/alerts/shared/headroom-tone.ts new file mode 100644 index 0000000000..a90ca31ea1 --- /dev/null +++ b/apps/web/src/page/alerts/shared/headroom-tone.ts @@ -0,0 +1,18 @@ +import type { HeadroomTone } from "@maple/web-data/alerts/overview-derive" + +// How a reading against its threshold is coloured. The rules table and the Inbox both pick the +// tone with `headroomTone`, so the same rule never reads calm on one and alarming on the other. + +/** A headroom bar's fill. */ +export const HEADROOM_FILL = { + neutral: "bg-foreground-faint", + warning: "bg-severity-warn", + error: "bg-severity-error", +} as const satisfies Record + +/** The reading's own text colour; a neutral one keeps its row's. */ +export const HEADROOM_TEXT = { + neutral: undefined, + warning: "text-severity-warn", + error: "text-severity-error", +} as const satisfies Record diff --git a/apps/web/src/page/alerts/shared/test-rows.ts b/apps/web/src/page/alerts/shared/test-rows.ts index 74df831c09..9f304a19f6 100644 --- a/apps/web/src/page/alerts/shared/test-rows.ts +++ b/apps/web/src/page/alerts/shared/test-rows.ts @@ -1,3 +1,4 @@ +import { UNGROUPED_GROUP_KEY } from "@maple/domain/http" import type { AlertDestinationRow, AlertIncidentRow, @@ -66,7 +67,7 @@ export const ruleRow = (n: number, fields: Partial = {}): AlertRul export const stateRow = (n: number, fields: Partial = {}): AlertRuleStateRow => ({ org_id: ORG, rule_id: ruleId(n), - group_key: "all", + group_key: UNGROUPED_GROUP_KEY, consecutive_breaches: 0, consecutive_healthy: 40, last_status: "healthy", @@ -84,7 +85,7 @@ export const incidentRow = (fields: Partial = {}): AlertIncide rule_id: ruleId(2), incident_key: "rule-2:all", rule_name: "Rule 2", - group_key: "all", + group_key: UNGROUPED_GROUP_KEY, signal_type: "p95_latency", severity: "critical", status: "open", diff --git a/packages/web-data/src/alerts/headroom.ts b/packages/web-data/src/alerts/headroom.ts index facbe10d18..1bdd5fd410 100644 --- a/packages/web-data/src/alerts/headroom.ts +++ b/packages/web-data/src/alerts/headroom.ts @@ -21,6 +21,8 @@ export interface RuleHeadroom { readonly headroom: number | null /** Past the threshold or close to it, by the same test as the Rules tab's meter. */ readonly isNear: boolean + /** Past the threshold without an open incident yet (it has to breach a few times in a row). */ + readonly breached: boolean readonly checkedAt: number | null } @@ -85,6 +87,7 @@ export function ruleHeadroom( ...Struct.pick(thresholdHeadroom(rule, state.last_value), [ "headroom", "isNear", + "breached", ]), checkedAt: state.last_evaluated_at === null diff --git a/packages/web-data/src/alerts/overview-derive.test.ts b/packages/web-data/src/alerts/overview-derive.test.ts index 8049aa5d5f..765c28419f 100644 --- a/packages/web-data/src/alerts/overview-derive.test.ts +++ b/packages/web-data/src/alerts/overview-derive.test.ts @@ -6,6 +6,7 @@ import { defaultRuleForm } from "./form-utils" import { blindSpots, fireLanes, + headroomTone, incidentScope, incidentSubline, incidentTitle, @@ -170,6 +171,13 @@ describe("incident lines", () => { expect(incidentSubline(incident)).toBe("High error rate · checkout") }) + it("titles an ungrouped telemetry incident without a group, whichever way storage spells ungrouped", () => { + const ungrouped = (groupKey: string | null) => + makeIncident({ groupKey, status: "open", resolvedAt: null }) + expect(incidentTitle(ungrouped(null))).toBe("Error rate 8.0%") + expect(incidentTitle(ungrouped("__total__"))).toBe("Error rate 8.0%") + }) + it("shows an anomaly incident's stored label, never its incident-id group key", () => { const anomaly = (groupLabel?: string) => makeIncident({ @@ -272,15 +280,30 @@ describe("ruleRibbon", () => { }) describe("ruleHeadroom near firing", () => { - it("calls a value near when under a fifth of the threshold is left, for floors and ceilings alike", () => { + it("calls a value near when under a tenth of the threshold is left, for floors and ceilings alike", () => { const ceiling = makeRule({ comparator: "gt", threshold: 0.05 }) const floor = makeRule({ comparator: "lt", threshold: 0.05 }) - expect(ruleHeadroom(ceiling, 0.042).isNear).toBe(true) - expect(ruleHeadroom(ceiling, 0.03).isNear).toBe(false) + expect(ruleHeadroom(ceiling, 0.046).isNear).toBe(true) + // 4.2% against a 5% ceiling leaves 16%: not near. + expect(ruleHeadroom(ceiling, 0.042).isNear).toBe(false) expect(ruleHeadroom(ceiling, 0.063).isNear).toBe(true) // 6.3% against a 5% floor leaves 26%: healthy, not near. expect(ruleHeadroom(floor, 0.063).isNear).toBe(false) - expect(ruleHeadroom(floor, 0.055).isNear).toBe(true) + expect(ruleHeadroom(floor, 0.054).isNear).toBe(true) + }) + + it("draws one tone everywhere: neutral, warning under a tenth left, error once breached", () => { + const ceiling = makeRule({ comparator: "gt", threshold: 0.05 }) + const floor = makeRule({ comparator: "lt", threshold: 0.05 }) + expect(headroomTone(ruleHeadroom(ceiling, 0.03))).toBe("neutral") + expect(headroomTone(ruleHeadroom(ceiling, 0.042))).toBe("neutral") + expect(headroomTone(ruleHeadroom(ceiling, 0.046))).toBe("warning") + expect(headroomTone(ruleHeadroom(ceiling, 0.063))).toBe("error") + expect(headroomTone(ruleHeadroom(floor, 0.063))).toBe("neutral") + expect(headroomTone(ruleHeadroom(floor, 0.054))).toBe("warning") + expect(headroomTone(ruleHeadroom(floor, 0.04))).toBe("error") + // A range has no single direction: neutral until it is breached. + expect(headroomTone({ breached: false, isNear: false })).toBe("neutral") }) }) diff --git a/packages/web-data/src/alerts/overview-derive.ts b/packages/web-data/src/alerts/overview-derive.ts index 21dbcbcb26..9f9543b7f4 100644 --- a/packages/web-data/src/alerts/overview-derive.ts +++ b/packages/web-data/src/alerts/overview-derive.ts @@ -52,10 +52,6 @@ type IncidentLine = Pick< "signalType" | "ruleName" | "groupKey" | "groupLabel" | "status" | "lastObservedValue" | "threshold" > -/** The group as people read it: the stored label, else the key; an anomaly's key is never shown. */ -const shownGroup = (incident: IncidentLine): string | null => - incident.groupLabel ?? (incident.signalType === "anomaly" ? null : incident.groupKey) - /** Uptime incidents name the monitor ("checkout-api is down"), not a signal and a value. */ const isUptime = (incident: Pick) => incident.signalType === "uptime" @@ -77,18 +73,18 @@ export function incidentTitle(incident: IncidentLine): string { return `${sloNameOf(incident.ruleName)} ${incident.status === "resolved" ? "stopped burning its error budget" : "is burning its error budget"}` if (isQueryIncident(incident)) return incident.ruleName if (incident.signalType === "anomaly") { - const group = shownGroup(incident) + const group = incidentGroup(incident) return group === null ? "Anomaly" : `Anomaly on ${group}` } const value = formatSignalValue(incident.signalType, incident.lastObservedValue) const base = `${signalLabels[incident.signalType]} ${value}` - const group = shownGroup(incident) + const group = incidentGroup(incident) return group === null ? base : `${base} on ${group}` } -/** The group shown next to the rule name: an uptime or SLO incident's only when it is a real one. */ +/** The group shown next to the rule name: none for an ungrouped incident, never an anomaly's incident-id key. */ export function incidentScope(incident: IncidentLine): string | null { - return isUptime(incident) || isSlo(incident) ? incidentGroup(incident) : shownGroup(incident) + return incidentGroup(incident) } /** The line under the title: the rule and group, or a query rule's kind and group (its name is the title). */ @@ -122,12 +118,21 @@ export interface Headroom { readonly breached: boolean /** Fraction of the threshold left before breaching; null for range/equality comparators. */ readonly headroom: number | null - /** Past the threshold, or within `NEAR_HEADROOM` of it: what the hub draws in warning colour. */ + /** Past the threshold, or within `NEAR_HEADROOM` of it: what `headroomTone` reads. */ readonly isNear: boolean } -/** Under a fifth of the threshold left counts as near firing, on every surface that says so. */ -export const NEAR_HEADROOM = 0.2 +/** Under a tenth of the threshold left counts as near firing, on every surface that says so. */ +export const NEAR_HEADROOM = 0.1 + +export type HeadroomTone = "neutral" | "warning" | "error" + +/** + * How a reading against its threshold is drawn, in the rules table and the Inbox alike: neutral + * unless under `NEAR_HEADROOM` is left (warning) or the threshold is passed (error). + */ +export const headroomTone = (reading: Pick): HeadroomTone => + reading.breached ? "error" : reading.isNear ? "warning" : "neutral" const breaches = (rule: AlertRuleDocument, value: number): boolean => { const upper = rule.thresholdUpper ?? rule.threshold