From 64980a11b6147d32766cea56b861071d6901c0af Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 01:49:49 +0100 Subject: [PATCH 01/11] feat(slos): SLOs tab, SLO page and SLO form in the dashboard The alerts hub gets an SLOs tab beside Uptime: each SLO with its indicator, target, current SLI, budget left, fastest burn and alerting state, and an empty state that says what an SLO is and offers New SLO. /alerts/slos/:sloId shows the SLI, budget and events, the hourly budget burn-down against 0% and the SLI against the target, each burn window against the 14.4x and 6x tiers, alerting with its destinations, and the incidents of the SLO's rule. /alerts/slos/new creates or edits one. SLO incidents read as managed, like uptime: no rule preview or edit link, a link to the SLO, SLO copy in titles and service health, and the inbox no longer hides them when an org has no other rules. Kit: formSection, formCard, formActionBar and destinationPicker, with the uptime form moved onto them. /lab/slos renders every state over fixtures. --- .../dashboard/service-health.test.ts | 9 + .../components/dashboard/service-health.ts | 8 +- apps/web/src/lab/entries/slos.ts | 16 + apps/web/src/lab/registry.ts | 8 + apps/web/src/lab/slos-lab.ts | 308 ++++++++++++ apps/web/src/page/alerts/hub/command.ts | 12 + apps/web/src/page/alerts/hub/derive.ts | 4 +- .../page/alerts/hub/hub-slos.scene.test.ts | 220 +++++++++ apps/web/src/page/alerts/hub/message.ts | 4 +- apps/web/src/page/alerts/hub/model.ts | 8 +- apps/web/src/page/alerts/hub/update.ts | 40 +- apps/web/src/page/alerts/hub/view-slos.ts | 201 ++++++++ apps/web/src/page/alerts/hub/view.ts | 64 ++- apps/web/src/page/alerts/incident/command.ts | 12 + apps/web/src/page/alerts/incident/derive.ts | 9 +- .../alerts/incident/incident.scene.test.ts | 51 ++ apps/web/src/page/alerts/incident/message.ts | 3 +- apps/web/src/page/alerts/incident/model.ts | 4 +- apps/web/src/page/alerts/incident/update.ts | 23 + .../web/src/page/alerts/incident/view-rail.ts | 6 +- apps/web/src/page/alerts/incident/view.ts | 100 ++-- apps/web/src/page/alerts/shared/services.ts | 12 + .../src/page/alerts/shared/slo-commands.ts | 135 ++++++ apps/web/src/page/alerts/shared/slo-derive.ts | 133 ++++++ .../page/alerts/shared/slo-test-fixtures.ts | 139 ++++++ apps/web/src/page/alerts/shared/slo-view.ts | 44 ++ apps/web/src/page/alerts/slo-form/command.ts | 40 ++ apps/web/src/page/alerts/slo-form/derive.ts | 86 ++++ apps/web/src/page/alerts/slo-form/form.ts | 64 +++ apps/web/src/page/alerts/slo-form/index.ts | 39 ++ apps/web/src/page/alerts/slo-form/message.ts | 30 ++ apps/web/src/page/alerts/slo-form/model.ts | 36 ++ .../alerts/slo-form/slo-form.scene.test.ts | 236 +++++++++ apps/web/src/page/alerts/slo-form/update.ts | 145 ++++++ apps/web/src/page/alerts/slo-form/view.ts | 370 ++++++++++++++ apps/web/src/page/alerts/slo/command.ts | 68 +++ apps/web/src/page/alerts/slo/index.ts | 39 ++ apps/web/src/page/alerts/slo/message.ts | 36 ++ apps/web/src/page/alerts/slo/model.ts | 40 ++ .../web/src/page/alerts/slo/slo.scene.test.ts | 272 +++++++++++ apps/web/src/page/alerts/slo/update.ts | 228 +++++++++ apps/web/src/page/alerts/slo/view-panels.ts | 451 ++++++++++++++++++ apps/web/src/page/alerts/slo/view.ts | 244 ++++++++++ .../src/page/alerts/uptime-form/command.ts | 12 +- .../page/alerts/uptime-form/view-fields.ts | 18 +- .../src/page/alerts/uptime-form/view-kinds.ts | 94 +--- apps/web/src/page/alerts/uptime-form/view.ts | 160 +++---- apps/web/src/page/kit/destination-picker.ts | 118 +++++ apps/web/src/page/kit/form-section.ts | 118 +++++ apps/web/src/page/registry.ts | 4 + apps/web/src/route/app-route.ts | 2 + apps/web/src/route/route.test.ts | 10 + apps/web/src/route/routers.ts | 13 + apps/web/src/route/search.ts | 8 + packages/views/src/ui/INDEX.md | 2 + .../web-data/src/alerts/incident-timeline.ts | 4 +- .../src/alerts/overview-derive.test.ts | 21 + .../web-data/src/alerts/overview-derive.ts | 14 +- 58 files changed, 4316 insertions(+), 279 deletions(-) create mode 100644 apps/web/src/lab/entries/slos.ts create mode 100644 apps/web/src/lab/slos-lab.ts create mode 100644 apps/web/src/page/alerts/hub/hub-slos.scene.test.ts create mode 100644 apps/web/src/page/alerts/hub/view-slos.ts create mode 100644 apps/web/src/page/alerts/shared/services.ts create mode 100644 apps/web/src/page/alerts/shared/slo-commands.ts create mode 100644 apps/web/src/page/alerts/shared/slo-derive.ts create mode 100644 apps/web/src/page/alerts/shared/slo-test-fixtures.ts create mode 100644 apps/web/src/page/alerts/shared/slo-view.ts create mode 100644 apps/web/src/page/alerts/slo-form/command.ts create mode 100644 apps/web/src/page/alerts/slo-form/derive.ts create mode 100644 apps/web/src/page/alerts/slo-form/form.ts create mode 100644 apps/web/src/page/alerts/slo-form/index.ts create mode 100644 apps/web/src/page/alerts/slo-form/message.ts create mode 100644 apps/web/src/page/alerts/slo-form/model.ts create mode 100644 apps/web/src/page/alerts/slo-form/slo-form.scene.test.ts create mode 100644 apps/web/src/page/alerts/slo-form/update.ts create mode 100644 apps/web/src/page/alerts/slo-form/view.ts create mode 100644 apps/web/src/page/alerts/slo/command.ts create mode 100644 apps/web/src/page/alerts/slo/index.ts create mode 100644 apps/web/src/page/alerts/slo/message.ts create mode 100644 apps/web/src/page/alerts/slo/model.ts create mode 100644 apps/web/src/page/alerts/slo/slo.scene.test.ts create mode 100644 apps/web/src/page/alerts/slo/update.ts create mode 100644 apps/web/src/page/alerts/slo/view-panels.ts create mode 100644 apps/web/src/page/alerts/slo/view.ts create mode 100644 apps/web/src/page/kit/destination-picker.ts create mode 100644 apps/web/src/page/kit/form-section.ts diff --git a/apps/web/src/components/dashboard/service-health.test.ts b/apps/web/src/components/dashboard/service-health.test.ts index febcf48ec4..a43acb7c44 100644 --- a/apps/web/src/components/dashboard/service-health.test.ts +++ b/apps/web/src/components/dashboard/service-health.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import { + alertIncidentCauseLabel, anomalyDirection, baselineKey, buildBaselineMap, @@ -18,6 +19,14 @@ describe("anomalyDirection", () => { }) }) +describe("alertIncidentCauseLabel", () => { + it("names what a managed incident means, and any other rule as firing", () => { + expect(alertIncidentCauseLabel("uptime")).toBe("Uptime check failing") + expect(alertIncidentCauseLabel("slo_burn_rate")).toBe("SLO burning error budget") + expect(alertIncidentCauseLabel("error_rate")).toBe("Alert firing") + }) +}) + describe("deriveServiceHealthFromCauses", () => { it("only degrades for validated warning or critical incidents", () => { expect(deriveServiceHealthFromCauses([])).toBe("healthy") diff --git a/apps/web/src/components/dashboard/service-health.ts b/apps/web/src/components/dashboard/service-health.ts index 6b15ade094..1e9f554a4c 100644 --- a/apps/web/src/components/dashboard/service-health.ts +++ b/apps/web/src/components/dashboard/service-health.ts @@ -13,9 +13,13 @@ export const HEALTH_TONE: Record = { unhealthy: "crit", } satisfies Record -/** The health cause an open alert incident reads as: a monitor's failing check, or a rule firing. */ +/** The health cause an open alert incident reads as: a failing check, a burning SLO, or a rule firing. */ export const alertIncidentCauseLabel = (signalType: AlertSignalType): string => - signalType === "uptime" ? "Uptime check failing" : "Alert firing" + signalType === "uptime" + ? "Uptime check failing" + : signalType === "slo_burn_rate" + ? "SLO burning error budget" + : "Alert firing" export interface ServiceHealthCause { severity: "warning" | "critical" diff --git a/apps/web/src/lab/entries/slos.ts b/apps/web/src/lab/entries/slos.ts new file mode 100644 index 0000000000..c3dcc6dd3d --- /dev/null +++ b/apps/web/src/lab/entries/slos.ts @@ -0,0 +1,16 @@ +import { defineShellLab } from "../shell-lab" +import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../slos-lab" + +// `/lab/slos`: the SLOs tab, the SLO page and the SLO form over fixtures (`?state=`). + +export const lab = defineShellLab("SLOs", { + Model, + Message, + Flags, + flags, + init, + update, + view, + currentPath: "/lab/slos", + breadcrumbs, +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 5541cd8a7c..4f4161505a 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -53,6 +53,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/slos", + title: "SLOs", + description: + "The SLOs tab, an SLO page healthy, burning, out of budget or failing its status read, and the SLO form (`?state=`).", + kind: "lab", + session: "none", + }, { path: "/lab/billing-orgs", title: "Billing orgs", diff --git a/apps/web/src/lab/slos-lab.ts b/apps/web/src/lab/slos-lab.ts new file mode 100644 index 0000000000..2c30e593c8 --- /dev/null +++ b/apps/web/src/lab/slos-lab.ts @@ -0,0 +1,308 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { SloPublicId } from "@maple/domain/http/v2" +import { Effect, Option, Result, Schema, Array as Arr } from "effect" +import { Command, type Update } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { toLoadFailure } from "@maple/views/data/load-failure" +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 { view as hubView } from "../page/alerts/hub/view" +import { + checkoutSlo, + SLO_RULE_UUID, + searchSlo, + sloHistory, + sloStatus, + sloUuid, + type SloScenario, +} from "../page/alerts/shared/slo-test-fixtures" +import { destinationRow, incidentRow, NOW, ORG } from "../page/alerts/shared/test-rows" +import * as SloMessage from "../page/alerts/slo/message" +import * as SloModel from "../page/alerts/slo/model" +import * as SloUpdate from "../page/alerts/slo/update" +import { breadcrumbsOf, view as sloView } from "../page/alerts/slo/view" +import * as FormMessage from "../page/alerts/slo-form/message" +import * as FormModel from "../page/alerts/slo-form/model" +import * as FormUpdate from "../page/alerts/slo-form/update" +import { view as formView } from "../page/alerts/slo-form/view" +import type { PageContext } from "../page/contract" +import type { Resources } from "../platform/resources" + +// `/lab/slos`: the SLOs tab, the SLO page and the SLO form over fixtures. `?state=` picks one: +// tab-empty, tab, loading, healthy, burning, exhausted, status-error, form-new, form-invalid, form-edit. + +export const LabState = Schema.Literals([ + "tab-empty", + "tab", + "loading", + "healthy", + "burning", + "exhausted", + "status-error", + "form-new", + "form-invalid", + "form-edit", +]) +export type LabState = typeof LabState.Type + +export const Flags = Schema.Struct({ state: LabState }) +export type Flags = typeof Flags.Type + +export const flags: Effect.Effect = Effect.sync(() => ({ + state: Option.getOrElse( + Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), + (): LabState => "tab", + ), +})) + +export const Model = Schema.Struct({ + state: LabState, + hub: HubModel.Model, + slo: SloModel.Model, + form: FormModel.Model, +}) +export type Model = typeof Model.Type + +export const Message = defineMessageUnion({ + GotHubMessage: { message: HubMessage.Message }, + GotSloMessage: { message: SloMessage.Message }, + GotFormMessage: { message: FormMessage.Message }, +}) +export type Message = typeof Message.Type + +type Return = Update.Return + +const CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)(ORG), + pinnedNamespace: null, + timeZone: "UTC", +} +const GRANTS = Policy.grantsOf(["org:engineer"]) +const publicId = Schema.encodeSync(SloPublicId) + +/** The pages' reads and writes are dropped: the fixture is all the data there is. */ +const keepLocal = (commands: ReadonlyArray> | undefined) => + Arr.filter(commands ?? [], (command) => !/^(Fetch|Toggle|Delete|Save|Read)/.test(command.name)) + +const fold = ( + model: PM, + messages: ReadonlyArray, + step: (model: PM, message: PS) => { readonly model: PM }, +): PM => Arr.reduce(messages, model, (current, message) => step(current, message).model) + +const INCIDENT = incidentRow({ + rule_id: SLO_RULE_UUID, + rule_name: "SLO burn: Checkout API", + signal_type: "slo_burn_rate", + status: "open", + severity: "critical", +}) + +const hubFor = (state: LabState) => { + const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "slos" } }, CONTEXT).model + const rows = + state === "tab-empty" + ? [] + : [ + { slo: checkoutSlo(), status: sloStatus("burning") }, + { + slo: searchSlo(), + status: sloStatus("healthy", { sloId: sloUuid(2), targetPercent: 99.5 }), + }, + { + slo: checkoutSlo({ + id: sloUuid(3), + name: "Payments webhooks", + alert_rule_id: "0199a002-0000-7000-8000-00000000000a", + service_name: "payments", + environments: ["production", "eu-production"], + sli: { type: "latency", threshold_ms: 1000 }, + target_percent: 99, + window_days: 7, + }), + status: sloStatus("exhausted", { sloId: sloUuid(3), targetPercent: 99 }), + }, + ] + return fold( + entered, + [ + HubMessage.Message.ReadClock({ nowMs: NOW }), + HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + HubMessage.Message.UpdatedIncidents({ orgId: ORG, rows: [INCIDENT] }), + HubMessage.Message.CompletedFetchSlos({ + key: entered.slos.key ?? "", + result: Result.succeed(rows), + }), + ], + (model, message) => HubUpdate.update(model, message, CONTEXT), + ) +} + +const scenarioOf = (state: LabState): SloScenario => + state === "burning" ? "burning" : state === "exhausted" ? "exhausted" : "healthy" + +const sloFor = (state: LabState) => { + const slo = checkoutSlo() + const entered = SloUpdate.init({ _tag: "AlertsSlo", sloId: publicId(slo.id), search: {} }, CONTEXT).model + const base = [ + SloMessage.Message.ReadClock({ nowMs: NOW }), + SloMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + ] + if (state === "loading") + return fold(entered, base, (model, message) => SloUpdate.update(model, message, CONTEXT)) + const scenario = scenarioOf(state) + return fold( + entered, + [ + ...base, + SloMessage.Message.CompletedFetchSlo({ key: entered.slo.key ?? "", result: Result.succeed(slo) }), + SloMessage.Message.CompletedFetchStatus({ + key: entered.status.key ?? "", + result: + state === "status-error" + ? Result.fail(toLoadFailure(new Error("The warehouse query timed out."))) + : Result.succeed(sloStatus(scenario)), + }), + SloMessage.Message.CompletedFetchHistory({ + key: entered.history.key ?? "", + result: Result.succeed(sloHistory(scenario)), + }), + SloMessage.Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + SloMessage.Message.UpdatedIncidents({ + orgId: ORG, + rows: + state === "burning" + ? [INCIDENT] + : [ + { + ...INCIDENT, + status: "resolved", + severity: "warning", + resolved_at: INCIDENT.last_triggered_at, + }, + ], + }), + ], + (model, message) => SloUpdate.update(model, message, CONTEXT), + ) +} + +const formFor = (state: LabState) => { + const slo = searchSlo({ target_percent: 99.7 }) + const entered = FormUpdate.init( + { _tag: "AlertsSloNew", search: state === "form-edit" ? { sloId: publicId(slo.id) } : {} }, + CONTEXT, + ).model + const common = [ + FormMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }), + FormMessage.Message.CompletedFetchServices({ services: ["checkout", "search", "payments"] }), + FormMessage.Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + ] + const extra = + state === "form-edit" + ? [ + FormMessage.Message.CompletedFetchExisting({ + key: entered.existing.key ?? "", + result: Result.succeed(slo), + }), + ] + : state === "form-invalid" + ? [ + FormMessage.Message.ChangedText({ field: "environments", value: "production" }), + FormMessage.Message.PickedTarget({ target: "custom" }), + FormMessage.Message.ChangedText({ field: "customTarget", value: "100" }), + ] + : [ + FormMessage.Message.ChangedText({ field: "name", value: "Checkout API" }), + FormMessage.Message.ChangedText({ field: "serviceName", value: "checkout" }), + FormMessage.Message.ChangedText({ field: "environments", value: "production" }), + ] + return fold(entered, [...common, ...extra], (model, message) => + FormUpdate.update(model, message, CONTEXT), + ) +} + +export const init = (flags: Flags): Return => { + const slo = sloFor(flags.state) + return { + model: { state: flags.state, hub: hubFor(flags.state), slo, form: formFor(flags.state) }, + // The SLO page's chart runtime still loads, so its charts draw. + commands: Command.mapMessages( + Arr.filter( + SloUpdate.init({ _tag: "AlertsSlo", sloId: publicId(checkoutSlo().id), search: {} }, CONTEXT) + .commands ?? [], + (command) => command.name === "LoadChartRuntime", + ), + (message) => Message.GotSloMessage({ message }), + ), + } +} + +export const update = (model: Model, message: Message): Return => + Message.match(message, { + GotHubMessage: ({ message: child }) => { + const next = HubUpdate.update(model.hub, child, CONTEXT) + return { + model: { ...model, hub: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotHubMessage({ message: inner }), + ), + } + }, + GotSloMessage: ({ message: child }) => { + const next = SloUpdate.update(model.slo, child, CONTEXT) + return { + model: { ...model, slo: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotSloMessage({ message: inner }), + ), + } + }, + GotFormMessage: ({ message: child }) => { + const next = FormUpdate.update(model.form, child, CONTEXT) + return { + model: { ...model, form: next.model }, + commands: Command.mapMessages(keepLocal(next.commands), (inner) => + Message.GotFormMessage({ message: inner }), + ), + } + }, + }) + +const isTab = (state: LabState) => state === "tab" || state === "tab-empty" +const isForm = (state: LabState) => state === "form-new" || state === "form-invalid" || state === "form-edit" + +export const breadcrumbs = (model: Model) => + isTab(model.state) + ? [{ label: "Alerts" }] + : isForm(model.state) + ? [{ label: "Alerts" }, { label: "SLOs" }] + : breadcrumbsOf() + +export const view = (model: Model, h: HtmlBuilder): Html => + isTab(model.state) + ? h.submodel({ + slotId: "lab-slos-hub", + model: model.hub, + view: hubView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }), + }) + : isForm(model.state) + ? h.submodel({ + slotId: "lab-slos-form", + model: model.form, + view: formView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: FormMessage.Message) => Message.GotFormMessage({ message }), + }) + : h.submodel({ + slotId: "lab-slos-page", + model: model.slo, + view: sloView, + viewInputs: { context: CONTEXT }, + toParentMessage: (message: SloMessage.Message) => Message.GotSloMessage({ message }), + }) diff --git a/apps/web/src/page/alerts/hub/command.ts b/apps/web/src/page/alerts/hub/command.ts index c60a1f4b36..060548be35 100644 --- a/apps/web/src/page/alerts/hub/command.ts +++ b/apps/web/src/page/alerts/hub/command.ts @@ -17,6 +17,7 @@ import { testDestination, updateDestination, } from "../shared/commands" +import { listSloRows } from "../shared/slo-commands" import { deleteMonitor, listMonitors, setMonitorEnabled } from "../shared/uptime-commands" import { Message } from "./message" import { ServiceNamespaces, serviceNamespaces } from "./uptime-groups" @@ -152,3 +153,14 @@ export const DeleteMonitor = Command.define("DeleteUptimeMonitor", { Effect.map((result) => Message.CompletedDeleteMonitor({ monitorId, result })), ), }) + +/** The SLOs tab's list, each row with its status. */ +export const FetchHubSlos = Command.define("FetchAlertsHubSlos", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSlos], + execute: ({ key }) => + listSloRows.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSlos({ key, result })), + ), +}) diff --git a/apps/web/src/page/alerts/hub/derive.ts b/apps/web/src/page/alerts/hub/derive.ts index 16ade20842..200125c77b 100644 --- a/apps/web/src/page/alerts/hub/derive.ts +++ b/apps/web/src/page/alerts/hub/derive.ts @@ -53,7 +53,7 @@ const destinationsFor = lastBy( export const destinationsOf = (model: Model): ReadonlyArray => destinationsFor([model.destinations]) -export type AlertsTab = "inbox" | "rules" | "uptime" | "routing" +export type AlertsTab = "inbox" | "rules" | "uptime" | "slos" | "routing" /** Legacy `tab` values map onto the current tabs so old links keep resolving. */ export const resolveTab = (tab: string | undefined): AlertsTab => { @@ -62,6 +62,8 @@ export const resolveTab = (tab: string | undefined): AlertsTab => { return "rules" case "uptime": return "uptime" + case "slos": + return "slos" case "routing": case "settings": return "routing" diff --git a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts new file mode 100644 index 0000000000..a7650030a3 --- /dev/null +++ b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts @@ -0,0 +1,220 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, role, scene, text, within, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { toLoadFailure } from "@maple/views/data/load-failure" +import * as Menu from "@maple/views/ui/menu" +import { PageOutMessage, type PageContext } from "../../contract" +import { sloHref } from "../shared/slo-commands" +import { checkoutSlo, SLO_RULE_UUID, searchSlo, sloStatus, sloUuid } from "../shared/slo-test-fixtures" +import { incidentRow, NOW, ORG } from "../shared/test-rows" +import { Message } from "./message" +import type { Model, SloListRow } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +const onSlos = (grants = ENGINEER): Model => { + const entered = init({ _tag: "Alerts", search: { tab: "slos" } }, context).model + return Arr.reduce( + [Message.ReadClock({ nowMs: NOW }), Message.CompletedFetchSession({ orgId: ORG, grants })], + entered, + (model, message) => pageUpdate(model, message).model, + ) +} + +const withSlos = (rows: ReadonlyArray, model = onSlos()): Model => + pageUpdate(model, Message.CompletedFetchSlos({ key: model.slos.key ?? "", result: Result.succeed(rows) })) + .model + +const table = role("table", { name: "SLOs" }) + +const HEALTHY_AND_BURNING: ReadonlyArray = [ + { slo: checkoutSlo(), status: sloStatus("burning") }, + { slo: searchSlo(), status: sloStatus("healthy", { sloId: sloUuid(2), targetPercent: 99.5 }) }, +] + +describe("alerts hub, SLOs tab", () => { + test("the SLOs and their statuses are read only while the tab is open", () => { + expect(names(init({ _tag: "Alerts", search: {} }, context).commands)).not.toContain( + "FetchAlertsHubSlos", + ) + expect(names(init({ _tag: "Alerts", search: { tab: "slos" } }, context).commands)).toContain( + "FetchAlertsHubSlos", + ) + }) + + test("an org without SLOs is told what one is and offered the first", () => { + scene( + { update: pageUpdate, view }, + given(withSlos([])), + expectView(text("No SLOs yet")).toExist(), + expectView( + text( + "An SLO sets the share of a service's requests that must succeed or stay fast, and alerts when the error budget burns too fast.", + ), + ).toExist(), + expectView(role("link", { name: "New SLO" })).toHaveAttr("href", "/alerts/slos/new"), + ) + }) + + test("a member sees the empty tab without a way to create one", () => { + scene( + { update: pageUpdate, view }, + given(withSlos([], onSlos(MEMBER))), + expectView(text("No SLOs yet")).toExist(), + expectView(role("link", { name: "New SLO" })).not.toExist(), + ) + }) + + test("each row names the SLO, its indicator and target, and links to its page", () => { + scene( + { update: pageUpdate, view }, + given(withSlos(HEALTHY_AND_BURNING)), + expectView(within(table, role("link", { name: "Checkout API" }))).toHaveAttr( + "href", + sloHref(checkoutSlo().id), + ), + expectView(within(table, text("checkout · production"))).toExist(), + expectView(within(table, text("Availability"))).toExist(), + expectView(within(table, text("99.9% over 30 days"))).toExist(), + expectView(within(table, text("Latency < 500 ms"))).toExist(), + expectView(within(table, text("99.5% over 28 days"))).toExist(), + ) + }) + + test("a row reads the SLI, the budget left and the fastest burn off its status", () => { + scene( + { update: pageUpdate, view }, + given(withSlos(HEALTHY_AND_BURNING)), + // Burning: 12% left, the 5m window at 18.2x is the fastest. + expectView(within(table, text("12%"))).toExist(), + expectView(within(table, text("18x"))).toExist(), + expectView(within(table, role("meter", { name: "12% of the error budget left" }))).toExist(), + // Healthy: 62% left, the 30m window at 0.6x the fastest. + expectView(within(table, text("62%"))).toExist(), + expectView(within(table, text("0.6x"))).toExist(), + ) + }) + + test("the readout counts the SLOs, the burning ones and those out of budget", () => { + scene( + { update: pageUpdate, view }, + given( + withSlos([ + ...HEALTHY_AND_BURNING, + { + slo: checkoutSlo({ id: sloUuid(3), name: "Payments" }), + status: sloStatus("exhausted"), + }, + ]), + ), + expectView(text(/^3\s*SLOs$/u)).toExist(), + expectView(text(/^1\s*burning$/u)).toExist(), + expectView(text(/^1\s*out of budget$/u)).toExist(), + ) + }) + + test("alerting reads as on, off, or an open incident on the SLO's rule", () => { + const withIncident = pageUpdate( + withSlos(HEALTHY_AND_BURNING), + Message.UpdatedIncidents({ + orgId: ORG, + rows: [incidentRow({ rule_id: SLO_RULE_UUID, signal_type: "slo_burn_rate", status: "open" })], + }), + ).model + scene( + { update: pageUpdate, view }, + given(withIncident), + expectView(within(table, text("Incident open"))).toExist(), + expectView(within(table, text("Alerting off"))).toExist(), + ) + scene( + { update: pageUpdate, view }, + given(withSlos(HEALTHY_AND_BURNING)), + expectView(within(table, text("Alerting on"))).toExist(), + ) + }) + + test("a row whose status read failed keeps its name and shows no figures", () => { + scene( + { update: pageUpdate, view }, + given(withSlos([{ slo: checkoutSlo(), status: null }])), + expectView(within(table, role("link", { name: "Checkout API" }))).toExist(), + expectView(within(table, role("meter"))).not.toExist(), + ) + }) + + test("a failed list read says so with a retry that reads again", () => { + const model = onSlos() + const failed = pageUpdate( + model, + Message.CompletedFetchSlos({ + key: model.slos.key ?? "", + result: Result.fail(toLoadFailure(new Error("boom"))), + }), + ).model + scene({ update: pageUpdate, view }, given(failed), expectView(text("Couldn't load SLOs")).toExist()) + expect(names(pageUpdate(failed, Message.ClickedRetrySlos()).commands)).toEqual(["FetchAlertsHubSlos"]) + }) + + test("the header offers New SLO beside the list, and the New rule menu goes to the SLO form", () => { + scene( + { update: pageUpdate, view }, + given(withSlos(HEALTHY_AND_BURNING)), + expectView(role("link", { name: "New SLO" })).toHaveAttr("href", "/alerts/slos/new"), + ) + const picked = pageUpdate( + withSlos(HEALTHY_AND_BURNING), + Message.GotNewRuleMenuMessage({ + message: Menu.Message.ClickedItem({ index: 2, item: { kind: "Item", value: "slo" } }), + }), + ) + expect(picked.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ url: "/alerts/slos/new", isReplace: false }), + ) + }) +}) + +describe("alerts hub inbox, SLO incidents", () => { + test("an org whose only rules are SLOs' sees their open incidents, not the empty state", () => { + const entered = init({ _tag: "Alerts", search: {} }, context).model + const model = Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedRules({ orgId: ORG, rows: [] }), + Message.UpdatedIncidents({ + orgId: ORG, + rows: [ + incidentRow({ + rule_name: "SLO burn: Checkout API", + signal_type: "slo_burn_rate", + comparator: "gte", + threshold: 6, + last_observed_value: 16.1, + group_key: "__total__", + }), + ], + }), + ], + entered, + (current, message) => pageUpdate(current, message).model, + ) + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("link", { name: "Checkout API is burning its error budget" })).toExist(), + expectView(text(/__total__/u)).not.toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/hub/message.ts b/apps/web/src/page/alerts/hub/message.ts index 8423043357..d33bbad627 100644 --- a/apps/web/src/page/alerts/hub/message.ts +++ b/apps/web/src/page/alerts/hub/message.ts @@ -14,7 +14,7 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" -import { TestOutcome, UptimeKindFilter, UptimeStatusFilter } from "./model" +import { SloListRow, TestOutcome, UptimeKindFilter, UptimeStatusFilter } from "./model" import { ServiceNamespaces } from "./uptime-groups" export const RuleBucket = Schema.Literals(["firing", "blind", "healthy", "disabled"]) @@ -69,5 +69,7 @@ export const Message = defineMessageUnion({ ClickedConfirmDeleteMonitor: {}, CompletedDeleteMonitor: { monitorId: UptimeMonitorId, result: outcome(Schema.Void) }, GotDeleteMonitorDialogMessage: { message: Dialog.Message }, + CompletedFetchSlos: { key: Schema.String, result: outcome(Schema.Array(SloListRow)) }, + ClickedRetrySlos: {}, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/hub/model.ts b/apps/web/src/page/alerts/hub/model.ts index 6b460b6489..2eedc2959c 100644 --- a/apps/web/src/page/alerts/hub/model.ts +++ b/apps/web/src/page/alerts/hub/model.ts @@ -1,7 +1,7 @@ import * as Policy from "@maple/domain/policy" import { UptimeMonitorId } from "@maple/domain" import { AlertDeliveryEventDocument, AlertDestinationId, AlertRuleId } from "@maple/domain/http" -import { V2UptimeMonitor } from "@maple/domain/http/v2" +import { V2Slo, V2SloStatus, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertDestinationRowSchema, AlertIncidentRowSchema, @@ -26,6 +26,10 @@ export const UptimeStatusFilter = Schema.Literals(["all", "down", "degraded", "p export const UptimeKindFilter = Schema.Literals(["all", "http", "tcp", "dns", "heartbeat"]) export type UptimeKindFilter = typeof UptimeKindFilter.Type +/** An SLO beside its status (`listSloRows`); `status` is `null` when that read failed. */ +export const SloListRow = Schema.Struct({ slo: V2Slo, status: Schema.NullOr(V2SloStatus) }) +export type SloListRow = typeof SloListRow.Type + export const TestOutcome = Schema.Struct({ message: Schema.String, success: Schema.Boolean }) export const Model = Schema.Struct({ @@ -74,6 +78,8 @@ export const Model = Schema.Struct({ deletingMonitorId: Schema.NullOr(UptimeMonitorId), confirmDeleteMonitorId: Schema.NullOr(UptimeMonitorId), deleteMonitorDialog: Dialog.Model, + /** SLOs tab: `slos.list` and each SLO's status, read only while the tab is open. */ + slos: KeyedSchema(Schema.Array(SloListRow), LoadFailure), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/alerts/hub/update.ts b/apps/web/src/page/alerts/hub/update.ts index 7319acbf6a..466ac5520d 100644 --- a/apps/web/src/page/alerts/hub/update.ts +++ b/apps/web/src/page/alerts/hub/update.ts @@ -16,11 +16,13 @@ import * as Menu from "@maple/views/ui/menu" import * as DestinationDialog from "../destination/dialog" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { alertsHref, createHref, ruleHref } from "../shared/links" +import { newSloHref, SLO_LIST_LIMIT } from "../shared/slo-commands" import { editMonitorHref, newMonitorHref, UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { DeleteDestination, DeleteMonitor, FetchHubMonitors, + FetchHubSlos, FetchServiceNamespaces, ToggleMonitor, FetchHubDeliveries, @@ -114,6 +116,18 @@ const requestMonitors = (model: Model, context: PageContext, mode: Keyed.ReadMod }) } +const slosKey = (context: PageContext) => + requestKey(context.orgId, "slos.listWithStatus", { query: { limit: SLO_LIST_LIMIT } }) + +/** The SLOs tab reads the SLOs and their statuses only while it is open. */ +const requestSlos = (model: Model, context: PageContext, mode: Keyed.ReadMode): Requested => { + if (resolveTab(model.search.tab) !== "slos") return { model, commands: [] } + return Option.match(Keyed.requestFor(model.slos, slosKey(context), mode), { + onNone: () => ({ model, commands: [] }), + onSome: (slos) => ({ model: { ...model, slos }, commands: [FetchHubSlos({ key: slos.key })] }), + }) +} + /** `reactivityKeys: ["alertDeliveryEvents"]`: both reads revalidate. */ const refreshDeliveries = (model: Model, context: PageContext) => { const overview = requestDeliveries(model, context, "Refresh") @@ -154,18 +168,21 @@ export const init = (route: HubRoute, context: PageContext): Return => { deletingMonitorId: null, confirmDeleteMonitorId: null, deleteMonitorDialog: Dialog.init(DELETE_MONITOR_DIALOG_ID, "alert-dialog"), + slos: Keyed.idle(), } const deliveries = requestDeliveries(model, context, "Load") const routing = requestRoutingDeliveries(deliveries.model, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") + const slos = requestSlos(monitors.model, context, "Load") return { - model: monitors.model, + model: slos.model, commands: [ ReadHubClock(), FetchHubSession({ orgId: context.orgId }), ...deliveries.commands, ...routing.commands, ...monitors.commands, + ...slos.commands, ], } } @@ -173,7 +190,11 @@ export const init = (route: HubRoute, context: PageContext): Return => { export const routeChanged = (model: Model, route: HubRoute, context: PageContext): Return => { const routing = requestRoutingDeliveries({ ...model, search: route.search }, context, "Load") const monitors = requestMonitors(routing.model, context, "Load") - return { model: monitors.model, commands: [...routing.commands, ...monitors.commands] } + const slos = requestSlos(monitors.model, context, "Load") + return { + model: slos.model, + commands: [...routing.commands, ...monitors.commands, ...slos.commands], + } } const navigate = (search: Model["search"]): PageOutMessage => @@ -324,7 +345,7 @@ const refreshMonitors = (model: Model, context: PageContext): Return => { return { model: step.model, commands: step.commands } } -/** "New rule" chevron: the telemetry rule editor or the uptime check form. */ +/** "New rule" chevron: the telemetry rule editor, the uptime check form or the SLO form. */ const updateNewRuleMenu = (model: Model, message: Menu.Message): Return => { const result = Menu.update(model.newRuleMenu, message) const commands = FoldkitCommand.mapMessages(result.commands ?? [], (child) => @@ -334,7 +355,11 @@ const updateNewRuleMenu = (model: Model, message: Menu.Message): Return => { if (result.outMessage?._tag !== "SelectedItem") return { model: next, commands } const serviceName = model.search.serviceName const url = - result.outMessage.value === "uptime" ? newMonitorHref({ serviceName }) : createHref({ serviceName }) + result.outMessage.value === "uptime" + ? newMonitorHref({ serviceName }) + : result.outMessage.value === "slo" + ? newSloHref({ serviceName }) + : createHref({ serviceName }) return { model: next, commands, @@ -520,6 +545,13 @@ export const update = (model: Model, message: Message, context: PageContext): Re ClickedClearUptimeFilters: () => ({ model: { ...model, uptimeQuery: "", uptimeStatus: "all", uptimeKind: "all" }, }), + CompletedFetchSlos: ({ key, result }) => ({ + model: { ...model, slos: Keyed.accept(model.slos, key, result) }, + }), + ClickedRetrySlos: () => { + const step = requestSlos(model, context, "Supersede") + return { model: step.model, commands: step.commands } + }, CompletedToggleMonitor: ({ monitorId, result }) => { const settled = { ...model, diff --git a/apps/web/src/page/alerts/hub/view-slos.ts b/apps/web/src/page/alerts/hub/view-slos.ts new file mode 100644 index 0000000000..69491b8ed4 --- /dev/null +++ b/apps/web/src/page/alerts/hub/view-slos.ts @@ -0,0 +1,201 @@ +import { pluralize } from "@maple/ui/lib/format" +import { Option, Array as Arr } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { linkButton } from "@maple/views/ui/button" +import { empty, emptyDescription, emptyHeader, emptyMedia, emptyTitle } from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import { emptyActions } from "@maple/views/ui/signal-empty-state" +import { skeleton } from "@maple/views/ui/skeleton" +import { vstack } from "@maple/views/ui/stack" +import { + numericTableCell, + rowLink, + table, + tableBody, + tableCell, + tableHead, + tableHeader, + tableRow, +} from "@maple/views/ui/table" +import { typography, typographyClass } from "@maple/views/ui/typography" +import { listToolbar, toolbarStat } from "../../kit/list-toolbar" +import { newSloHref, sloHref } from "../shared/slo-commands" +import { + burnTone, + fastestBurn, + firingTier, + formatBurn, + formatSli, + formatTarget, + formatWindow, + scopeLine, + sliLabel, + sliTone, +} from "../shared/slo-derive" +import { alertingState, budgetBar, toneText } from "../shared/slo-view" +import { Message } from "./message" +import { canManage, type Model, type SloListRow } from "./model" + +// The hub's SLOs tab: each SLO with its SLI, budget left, fastest burn and alerting state. + +type H = HtmlBuilder + +/** The empty org: what an SLO is, in one line, and the first one to add. */ +const slosEmptyState = (h: H, serviceName: string | undefined, canEdit: boolean): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "GaugeIcon", { size: 18 })]), + emptyTitle(h, ["No SLOs yet"]), + emptyDescription(h, [ + "An SLO sets the share of a service's requests that must succeed or stay fast, and alerts when the error budget burns too fast.", + ]), + ]), + ...(canEdit + ? [ + emptyActions(h, [ + linkButton(h, { size: "sm", href: newSloHref({ serviceName }) }, [ + icon(h, "PlusIcon"), + "New SLO", + ]), + ]), + ] + : []), + ], + "py-12", + ) + +const readout = (h: H, rows: ReadonlyArray): ReadonlyArray => { + const burning = Arr.filter(rows, (row) => row.status !== null && Option.isSome(firingTier(row.status))) + const spent = Arr.filter(rows, (row) => row.status !== null && row.status.budget_remaining <= 0) + return [ + toolbarStat(h, { value: String(rows.length), label: pluralize(rows.length, "SLO") }), + toolbarStat(h, { + value: String(burning.length), + label: "burning", + dot: burning.length > 0 ? "crit" : undefined, + attributes: + burning.length === 0 + ? [] + : [ + h.Title( + Arr.join( + Arr.map(burning, (row) => row.slo.name), + ", ", + ), + ), + ], + }), + toolbarStat(h, { + value: String(spent.length), + label: "out of budget", + dot: spent.length > 0 ? "warn" : undefined, + }), + ] +} + +const dash = (h: H): Html => typography(h, { size: "xs", tone: "faint" }, ["–"]) + +const sloRow = (h: H, model: Model, row: SloListRow): Html => { + const { slo, status } = row + const burn = status === null ? null : fastestBurn(status) + return tableRow( + h, + [ + tableCell(h, [ + vstack(h, { gap: "0.5", shrink: true }, [ + rowLink( + h, + { + href: sloHref(slo.id), + className: typographyClass({ size: "sm", weight: "medium", truncate: true }), + }, + [slo.name], + ), + typography(h, { size: "xs", tone: "muted", mono: true, truncate: true }, [ + scopeLine(slo), + ]), + ]), + ]), + tableCell(h, [ + vstack(h, { gap: "0.5" }, [ + typography(h, { size: "sm" }, [sliLabel(slo.sli)]), + typography(h, { size: "xs", tone: "muted" }, [ + `${formatTarget(slo)} over ${formatWindow(slo.window_days)}`, + ]), + ]), + ]), + numericTableCell(h, [ + status === null + ? dash(h) + : typography( + h, + { + size: "xs", + mono: true, + numeric: true, + tone: toneText(sliTone(slo, status.sli)), + }, + [formatSli(status.sli)], + ), + ]), + tableCell(h, [status === null ? dash(h) : budgetBar(h, status.budget_remaining)]), + numericTableCell(h, [ + burn === null + ? dash(h) + : typography( + h, + { size: "xs", mono: true, numeric: true, tone: toneText(burnTone(burn)) }, + [formatBurn(burn)], + ), + ]), + tableCell(h, [alertingState(h, slo, model.incidents ?? [])]), + ], + undefined, + [h.Key(slo.id)], + ) +} + +const slosTable = (h: H, model: Model, rows: ReadonlyArray): Html => + table(h, { variant: "card", ariaLabel: "SLOs", className: "min-w-[760px]" }, [ + tableHeader(h, [ + tableRow(h, [ + tableHead(h, ["SLO"]), + tableHead(h, ["Indicator"]), + tableHead(h, ["Current SLI"], "text-right"), + tableHead(h, ["Budget left"]), + tableHead(h, ["Fastest burn"], "text-right", [ + h.Title("The fastest of the 5m, 30m, 1h and 6h burn rates"), + ]), + tableHead(h, ["Alerting"]), + ]), + ]), + tableBody( + h, + Arr.map(rows, (row) => sloRow(h, model, row)), + ), + ]) + +export const slosTab = (h: H, model: Model): Html => { + const data = model.slos.data + if (AsyncData.isFailure(data)) + return errorState(h, { + failure: data.error, + title: "Couldn't load SLOs", + onRetry: Message.ClickedRetrySlos(), + className: "py-12", + }) + return Option.match(AsyncData.getData(data), { + onNone: () => vstack(h, { gap: "4" }, [skeleton(h, "h-9 w-2/3"), skeleton(h, "h-64 w-full")]), + onSome: (rows) => + rows.length === 0 + ? slosEmptyState(h, model.search.serviceName, canManage(model)) + : vstack(h, { gap: "4" }, [ + listToolbar(h, { className: "pb-0", trailing: readout(h, rows) }), + slosTable(h, model, rows), + ]), + }) +} diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 6c1cd9174a..a1ba9b7ba2 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -1,3 +1,4 @@ +import { isManagedSignalType } from "@maple/domain/http" import type { AlertsOverviewReady } from "@maple/web-data/alerts/overview" import { makeClientErrorBody } from "@maple/web-data/lib/error-messages" import { Option, Array as Arr } from "effect" @@ -14,6 +15,7 @@ import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { linkTabStrip } from "../../kit/underline-tabs" import { alertsHref, createHref } from "../shared/links" +import { newSloHref } from "../shared/slo-commands" import { type AlertsTab, destinationsOf, overviewOf, resolveStatus, resolveTab } from "./derive" import { Message } from "./message" import { canManage, type Model } from "./model" @@ -23,6 +25,7 @@ import { routingTab } from "./view-routing" import * as DestinationDialogView from "../destination/view" import { rulesTab } from "./view-rules" import { deleteMonitorDialog, uptimeTab } from "./view-uptime" +import { slosTab } from "./view-slos" import { hstack, vstack } from "@maple/views/ui/stack" // `AlertsPage` and `AlertsHub`: the `/alerts` page and its tabs. @@ -33,6 +36,7 @@ const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [ { value: "inbox", label: "Inbox" }, { value: "rules", label: "Rules" }, { value: "uptime", label: "Uptime" }, + { value: "slos", label: "SLOs" }, { value: "routing", label: "Routing" }, ] @@ -45,6 +49,7 @@ const tabBar = ( active: AlertsTab, ): Html => { const monitorCount = Option.map(AsyncData.getData(model.monitors.data), (monitors) => monitors.length) + const sloCount = Option.map(AsyncData.getData(model.slos.data), (slos) => slos.length) return linkTabStrip( h, "Alerts sections", @@ -52,15 +57,17 @@ const tabBar = ( const count = tab.value === "uptime" ? Option.getOrUndefined(monitorCount) - : Option.match(overview, { - onNone: () => undefined, - onSome: (data) => - tab.value === "inbox" - ? data.openIncidents.length - : tab.value === "rules" - ? data.rules.length - : undefined, - }) + : tab.value === "slos" + ? Option.getOrUndefined(sloCount) + : Option.match(overview, { + onNone: () => undefined, + onSome: (data) => + tab.value === "inbox" + ? data.openIncidents.length + : tab.value === "rules" + ? data.rules.length + : undefined, + }) return { key: tab.value, href: alertsHref({ ...model.search, tab: tab.value }), @@ -131,6 +138,13 @@ const newRuleButton = (h: H, model: Model): Html => "GlobeIcon", "Check a URL, port, DNS record or cron job from outside.", ), + newRuleItem( + h, + "slo", + "SLO", + "GaugeIcon", + "An error budget on a service's availability or latency, with burn-rate alerts.", + ), ] : []), ], @@ -158,9 +172,26 @@ const newRuleButton = (h: H, model: Model): Html => ], ) -// Destinations are a Routing concern, so "New destination" only shows on that tab. +/** The SLOs tab lists some; the empty one offers "New SLO" itself. */ +const hasSlos = (model: Model): boolean => + Option.exists(AsyncData.getData(model.slos.data), (rows) => rows.length > 0) + +// Destinations are a Routing concern, so "New destination" only shows on that tab; "New SLO" likewise. const headerActions = (h: H, model: Model, tab: AlertsTab): Html => hstack(h, { gap: "2" }, [ + ...(canManage(model) && tab === "slos" && hasSlos(model) + ? [ + linkButton( + h, + { + size: "sm", + variant: "outline", + href: newSloHref({ serviceName: model.search.serviceName }), + }, + [icon(h, "PlusIcon"), "New SLO"], + ), + ] + : []), ...(canManage(model) && tab === "routing" ? [ button( @@ -218,13 +249,12 @@ const hubBody = ( serviceName: model.search.serviceName, onAddDestination: canManage(model) ? Message.ClickedNewDestination() : undefined, }) - // An org with only uptime monitors has no visible rules but may have open incidents to show. - const hasUptimeIncidents = Arr.some( - data.openIncidents, - (incident) => incident.signalType === "uptime", + // An org with only monitors or SLOs has no visible rules but may have open incidents to show. + const hasManagedIncidents = Arr.some(data.openIncidents, (incident) => + isManagedSignalType(incident.signalType), ) if (tab === "inbox") - return data.rules.length === 0 && !hasUptimeIncidents ? emptyState : inboxTab(h, data) + return data.rules.length === 0 && !hasManagedIncidents ? emptyState : inboxTab(h, data) return rulesTab(h, model, { data, destinations, @@ -277,7 +307,9 @@ export const view = Submodel.defineView((model, }) : tab === "uptime" ? uptimeTab(h, model, model.nowMs ?? 0, inputs.context.pinnedNamespace) - : hubBody(h, model, overview, tab), + : tab === "slos" + ? slosTab(h, model) + : hubBody(h, model, overview, tab), ], ), deleteDialog(h, model), diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index 2e03a05593..c54c56b905 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -5,6 +5,7 @@ import { Command } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { listDeliveries } from "../shared/commands" +import { listSlos } from "../shared/slo-commands" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { Message } from "./message" @@ -77,3 +78,14 @@ export const CreateInvestigation = Command.define("CreateAlertInvestigation", { Effect.map((result) => Message.CompletedCreateInvestigation({ result })), ), }) + +/** An SLO incident's SLOs, to find the one whose burn-rate rule opened it. */ +export const FetchIncidentSlos = Command.define("FetchAlertIncidentSlos", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSlos], + execute: ({ key }) => + listSlos.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSlos({ key, result })), + ), +}) diff --git a/apps/web/src/page/alerts/incident/derive.ts b/apps/web/src/page/alerts/incident/derive.ts index e3c10df51c..1fca929685 100644 --- a/apps/web/src/page/alerts/incident/derive.ts +++ b/apps/web/src/page/alerts/incident/derive.ts @@ -4,6 +4,7 @@ import { type AlertRuleDocument, type AlertRuleId, IsoDateTimeString, + isManagedSignalType, } from "@maple/domain/http" import type { V2AlertRulePreviewParams } from "@maple/domain/http/v2" import { formatWarehouseDateTime } from "@maple/query-engine" @@ -28,7 +29,7 @@ export interface IncidentView { readonly ruleIncidents: ReadonlyArray readonly range: Readonly<{ start: number; end: number }> readonly payload: Option.Option - /** The rule's recorded checks over `range`; `None` without a rule or for an uptime incident. */ + /** The rule's recorded checks over `range`; `None` without a rule or for a monitor or SLO incident. */ readonly checksQuery: Option.Option< Readonly<{ ruleId: AlertRuleId; since: IsoDateTimeString; until: IsoDateTimeString }> > @@ -88,16 +89,16 @@ const deriveIncidentView = (model: ViewKey): Option.Option => { ), range, checksQuery: - rule === null || incident.signalType === "uptime" + rule === null || isManagedSignalType(incident.signalType) ? Option.none() : Option.some({ ruleId: rule.id, since: IsoDateTimeString.make(new Date(range.start).toISOString()), until: IsoDateTimeString.make(new Date(range.end).toISOString()), }), - // An uptime rule has no query to replay: its signal is the monitor's checks. + // A monitor's or SLO's rule has no query to replay: its signal is the checks or the burn rate. payload: - rule === null || incident.signalType === "uptime" + rule === null || isManagedSignalType(incident.signalType) ? Option.none() : previewPayload(ruleToFormState(rule), { startTime: formatWarehouseDateTime(range.start), diff --git a/apps/web/src/page/alerts/incident/incident.scene.test.ts b/apps/web/src/page/alerts/incident/incident.scene.test.ts index 85b8e1ffdd..1de4d7db1e 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -20,6 +20,8 @@ import { MountChart } from "@maple/views/charts/chart-mounts" import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" import { toLoadFailure } from "@maple/views/data/load-failure" import { PageOutMessage, type PageContext } from "../../contract" +import { sloHref, slosTabHref } from "../shared/slo-commands" +import { checkoutSlo } from "../shared/slo-test-fixtures" import { incidentRow, INCIDENT_ID, NOW, ORG, ruleRow, stateRow } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" @@ -178,6 +180,55 @@ describe("alert incident", () => { ) }) + test("an SLO incident asks for no rule preview, offers no rule edit, and links its SLO once known", () => { + const sloIncident = incidentRow({ + rule_name: "SLO burn: Checkout API", + signal_type: "slo_burn_rate", + comparator: "gte", + threshold: 6, + last_observed_value: 16.1, + group_key: "__total__", + }) + const loaded = Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [sloIncident] }), + ], + init(route(), context).model, + (model, message) => step(model, message).model, + ) + const withRule = step( + loaded, + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2, { signal_type: "slo_burn_rate" })] }), + ) + expect(names(withRule.commands)).not.toContain("FetchAlertIncidentPreview") + expect(names(withRule.commands)).not.toContain("FetchAlertIncidentChecks") + expect(names(step(loaded, Message.ReadClock({ nowMs: NOW })).commands)).not.toContain( + "FetchAlertIncidentSlos", + ) + expect(withRule.model.slos.key).not.toBeNull() + scene( + { update: pageUpdate, view }, + given(withRule.model), + themeResolved(), + expectView(role("heading", { name: "Checkout API is burning its error budget" })).toExist(), + expectView(text(/__total__/u)).not.toExist(), + expectView(role("link", { name: "Edit rule" })).not.toExist(), + expectView(role("link", { name: "Open SLOs" })).toHaveAttr("href", slosTabHref()), + ) + const slo = checkoutSlo({ alert_rule_id: sloIncident.rule_id }) + const linked = step( + withRule.model, + Message.CompletedFetchSlos({ key: withRule.model.slos.key ?? "", result: Result.succeed([slo]) }), + ).model + scene( + { update: pageUpdate, view }, + given(linked), + themeResolved(), + expectView(role("link", { name: "Open SLO" })).toHaveAttr("href", sloHref(slo.id)), + ) + }) + test("a failed delivery read is not refetched when the live rows change", () => { const entered = init(route(), context).model const loaded = Arr.reduce( diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index 9aff3da93a..8fa07dd72d 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -1,5 +1,5 @@ import { AlertDeliveryEventDocument, AlertRulePreviewResponse } from "@maple/domain/http" -import { V2Investigation } from "@maple/domain/http/v2" +import { V2Investigation, V2Slo } from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -33,6 +33,7 @@ export const Message = defineMessageUnion({ key: Schema.String, result: Schema.Result(Schema.NullOr(V2Investigation), LoadFailure), }, + CompletedFetchSlos: { key: Schema.String, result: Schema.Result(Schema.Array(V2Slo), LoadFailure) }, ClickedInvestigate: {}, CompletedCreateInvestigation: { result: Schema.Result(Schema.String, LoadFailure) }, CompletedLoadChartRuntime: {}, diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index e8d0dadd7d..70cb3e7b97 100644 --- a/apps/web/src/page/alerts/incident/model.ts +++ b/apps/web/src/page/alerts/incident/model.ts @@ -1,5 +1,5 @@ import { AlertDeliveryEventDocument } from "@maple/domain/http" -import { V2Investigation } from "@maple/domain/http/v2" +import { V2Investigation, V2Slo } from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -30,6 +30,8 @@ export const Model = Schema.Struct({ checks: KeyedSchema(CheckSummary, LoadFailure), deliveries: KeyedSchema(Schema.Array(AlertDeliveryEventDocument), LoadFailure), investigation: KeyedSchema(Schema.NullOr(V2Investigation), LoadFailure), + /** An SLO incident's link to its SLO: `slos.list`, read only for one. */ + slos: KeyedSchema(Schema.Array(V2Slo), LoadFailure), /** `useAsyncAction`: the create request is running, so repeat clicks cannot start duplicates. */ isStartingInvestigation: Schema.Boolean, chart: Chart.Model, diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index dbd17b7557..41a2ee8743 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -21,8 +21,10 @@ import { FetchIncidentDeliveries, FetchIncidentInvestigation, FetchIncidentPreview, + FetchIncidentSlos, ReadIncidentClock, } from "./command" +import { SLO_LIST_LIMIT } from "../shared/slo-commands" import { incidentViewOf, minuteOf, ruleStatesOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" @@ -42,6 +44,7 @@ export const init = (route: IncidentRoute, _context: PageContext): Return => ({ checks: Keyed.idle(), deliveries: Keyed.idle(), investigation: Keyed.idle(), + slos: Keyed.idle(), isStartingInvestigation: false, chart: Chart.init(), chartRuntime: { _tag: "Loading" }, @@ -57,6 +60,9 @@ export const init = (route: IncidentRoute, _context: PageContext): Return => ({ ], }) +const slosKey = (context: PageContext) => + requestKey(context.orgId, "slos.list", { query: { limit: SLO_LIST_LIMIT } }) + /** An atom never refetches a failed read on its own: a same-key `Load` leaves the slot alone. */ const sameKey = (slot: Keyed.Keyed, key: string) => slot.key === key @@ -112,6 +118,20 @@ const followData = (model: Model, context: PageContext): Return => ) }), ), + ...Option.toArray( + Option.flatMap( + view.incident.signalType === "slo_burn_rate" + ? Option.some(slosKey(context)) + : Option.none(), + (key) => + Option.map( + sameKey(model.slos, key) + ? Option.none() + : Keyed.request(model.slos, key, "Load"), + (slos) => ({ patch: { slos }, commands: [FetchIncidentSlos({ key })] }), + ), + ), + ), ...Option.toArray( Option.flatMap(view.payload, (payload) => { const key = requestKey(context.orgId, "alertRules.preview", payload) @@ -245,6 +265,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchInvestigation: ({ key, result }) => ({ model: { ...model, investigation: Keyed.accept(model.investigation, key, result) }, }), + CompletedFetchSlos: ({ key, result }) => ({ + model: { ...model, slos: Keyed.accept(model.slos, key, result) }, + }), ClickedInvestigate: () => model.isStartingInvestigation ? { model } diff --git a/apps/web/src/page/alerts/incident/view-rail.ts b/apps/web/src/page/alerts/incident/view-rail.ts index fc976a3cb9..50ca652d0a 100644 --- a/apps/web/src/page/alerts/incident/view-rail.ts +++ b/apps/web/src/page/alerts/incident/view-rail.ts @@ -1,5 +1,5 @@ import { pluralize } from "@maple/ui/lib/format" -import type { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" +import { type AlertIncidentDocument, type AlertRuleDocument, isManagedSignalType } from "@maple/domain/http" import { formatWarehouseDateTime } from "@maple/query-engine" import { cn } from "@maple/ui/lib/utils" import { formatSignalValue } from "@maple/web-data/alerts/form-utils" @@ -293,7 +293,9 @@ export const incidentRail = ( ] : []), evidenceSection(h, rule, incident, options.window), - ...(rule !== null ? [ruleSection(h, rule, options.ruleIncidents, options.now)] : []), + ...(rule !== null && !isManagedSignalType(rule.signalType) + ? [ruleSection(h, rule, options.ruleIncidents, options.now)] + : []), ], ) } diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 112d0c880d..e0742c5063 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -34,11 +34,13 @@ import { eyebrow } from "@maple/views/ui/eyebrow" import { icon } from "@maple/views/ui/icon" import { pageTitle } from "@maple/views/ui/page-layout" import { skeleton } from "@maple/views/ui/skeleton" +import { vstack } from "@maple/views/ui/stack" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { resourceNotFound } from "../../kit/resource-not-found" import { severityBadge } from "../shared/badges" import { alertsHref } from "../shared/links" +import { sloHref, slosTabHref } from "../shared/slo-commands" import { incidentChecksOf, summaryChecksOf } from "../shared/check-summary" import { previewIssue, previewStateOf } from "../shared/preview" import { providerOf } from "../shared/providers" @@ -74,23 +76,30 @@ export const headerTitle = ( ) => { const group = incidentGroup(incident) const isUptime = incident.signalType === "uptime" + const isSlo = incident.signalType === "slo_burn_rate" const isQuery = isQueryIncident(incident) - // An uptime incident names its monitor and a query incident its rule; neither has a signal to spell out. + // An uptime or SLO incident names its monitor or SLO and a query incident its rule. const title = - isUptime || isQuery + isUptime || isSlo || isQuery ? incidentTitle(incident) : `${signalTitle(incident.signalType)} ${formatSignalValue(incident.signalType, incident.lastObservedValue)}${group != null ? ` on ${group}` : ""}` const subline = Arr.join( Arr.filter( isUptime ? ["Uptime check", group] - : [ - isQuery ? group : `Rule ${rule?.name ?? incident.ruleName}`, - `${isQuery ? "value" : Str.toLowerCase(signalLabels[incident.signalType])} ${conditionPhrase(incident)}${rule != null ? ` over ${rule.windowMinutes}m` : ""}`, - rule != null && rule.environments.length > 0 - ? Arr.join(rule.environments, ", ") - : null, - ], + : isSlo + ? [ + "SLO burn rate", + `now ${formatSignalValue(incident.signalType, incident.lastObservedValue)}`, + group, + ] + : [ + isQuery ? group : `Rule ${rule?.name ?? incident.ruleName}`, + `${isQuery ? "value" : Str.toLowerCase(signalLabels[incident.signalType])} ${conditionPhrase(incident)}${rule != null ? ` over ${rule.windowMinutes}m` : ""}`, + rule != null && rule.environments.length > 0 + ? Arr.join(rule.environments, ", ") + : null, + ], (part) => part != null, ), " · ", @@ -153,6 +162,8 @@ export type IncidentBodyInputs = Readonly<{ entries: ReadonlyArray isEntriesLoading: boolean isEntriesFailed: boolean + /** An SLO incident's SLO page, or the SLOs tab until the SLO is known. */ + sloHref?: string | null chartId: string chart: Chart.Model toChartMessage: (message: Chart.Message) => M @@ -180,28 +191,48 @@ export const incidentBody = (h: HtmlBuilder, inputs: IncidentBodyInputs ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ "This incident follows an uptime monitor: it opened when the monitor's checks went down and resolves when one passes again. The monitor's page lists each check with its failing trace.", ]) - : rule === null - ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ - "The rule behind this incident was deleted, so its signal cannot be replayed.", + : incident.signalType === "slo_burn_rate" + ? vstack(h, { gap: "3", align: "start" }, [ + typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "This incident follows an SLO burning its error budget: it opened when the burn rate held at a tier's factor over both of its windows, and resolves when the short window cools. The SLO's page charts the budget and each window's burn rate.", + ]), + linkButton( + h, + { + size: "sm", + variant: "outline", + href: inputs.sloHref ?? slosTabHref(), + }, + [ + inputs.sloHref === undefined || inputs.sloHref === null + ? "Open SLOs" + : "Open SLO", + icon(h, "ArrowRightIcon"), + ], + ), ]) - : ruleChart(h, { - chartId: inputs.chartId, - chart: inputs.chart, - toChartMessage: inputs.toChartMessage, - colors: inputs.colors, - timeZone: inputs.timeZone, - preview: inputs.preview, - checks: inputs.checks, - incidents: [incident], - threshold: incident.threshold, - thresholdUpper: incident.thresholdUpper, - comparator: incident.comparator, - signalType: incident.signalType, - window: { min: inputs.range.start, max: inputs.range.end }, - source: "preview", - isLoading: inputs.isPreviewLoading, - error: inputs.previewError, - }), + : rule === null + ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ + "The rule behind this incident was deleted, so its signal cannot be replayed.", + ]) + : ruleChart(h, { + chartId: inputs.chartId, + chart: inputs.chart, + toChartMessage: inputs.toChartMessage, + colors: inputs.colors, + timeZone: inputs.timeZone, + preview: inputs.preview, + checks: inputs.checks, + incidents: [incident], + threshold: incident.threshold, + thresholdUpper: incident.thresholdUpper, + comparator: incident.comparator, + signalType: incident.signalType, + window: { min: inputs.range.start, max: inputs.range.end }, + source: "preview", + isLoading: inputs.isPreviewLoading, + error: inputs.previewError, + }), ], ), incidentTimeline(h, { @@ -235,9 +266,18 @@ const body = (h: H, model: Model, view: IncidentView, timeZone: string, now: num const deliveries = AsyncData.getOrElse(model.deliveries.data, () => []) const investigation = AsyncData.getOrElse(model.investigation.data, () => null) const isChartReady = model.themeColors !== null && isChartRuntimeReady(model.chartRuntime) + const sloLink = Option.getOrUndefined( + Option.flatMap(AsyncData.getData(model.slos.data), (slos) => + Option.map( + Arr.findFirst(slos, (slo) => slo.alert_rule_id === incident.ruleId), + (slo) => sloHref(slo.id), + ), + ), + ) return incidentBody(h, { incident, rule, + sloHref: sloLink ?? null, ruleIncidents: view.ruleIncidents, states: ruleStatesOf(model, incident.ruleId), preview: groupPreviewOf(previewState.preview, incident), diff --git a/apps/web/src/page/alerts/shared/services.ts b/apps/web/src/page/alerts/shared/services.ts new file mode 100644 index 0000000000..115905d30b --- /dev/null +++ b/apps/web/src/page/alerts/shared/services.ts @@ -0,0 +1,12 @@ +import { getTracesFacets } from "@maple/web-data/warehouse/traces" +import { Array as Arr, Effect } from "effect" +import { currentWindow } from "../../../data/time-range" + +// The org's services for the alert forms' Service pickers. + +/** The services traced in the last day, from the traces facets; empty when the read fails. */ +export const recentServiceNames = currentWindow({ preset: "24h", snap: true }).pipe( + Effect.flatMap((window) => getTracesFacets({ data: window })), + Effect.map((response) => Arr.map(response.data.services, (row) => row.name)), + Effect.orElseSucceed((): ReadonlyArray => []), +) diff --git a/apps/web/src/page/alerts/shared/slo-commands.ts b/apps/web/src/page/alerts/shared/slo-commands.ts new file mode 100644 index 0000000000..55d82520f7 --- /dev/null +++ b/apps/web/src/page/alerts/shared/slo-commands.ts @@ -0,0 +1,135 @@ +import { SloId } from "@maple/domain" +import { + SloPublicId, + type V2Slo, + V2SloAlertingParams, + V2SloCreateParams, + type V2SloStatus, + V2SloUpdateParams, +} from "@maple/domain/http/v2" +import { Effect, Option, Ref, Schema } from "effect" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { AppRoute, hrefOf } from "../../../route" +import type { AlertsSloNewSearch } from "../../../route/search" + +// The SLO reads and writes, as Effects the pages' Commands wrap, and their links. + +export const SLO_LIST_LIMIT = 100 + +/** Pages read at most per list: 2000 SLOs. */ +const MAX_LIST_PAGES = 20 + +/** How many status reads the SLOs tab runs at once. */ +const STATUS_CONCURRENCY = 6 + +interface ListProgress { + readonly rows: ReadonlyArray + readonly cursor: string | null + readonly pages: number +} + +/** `slos.list`: every one of the org's SLOs, newest first, following `next_cursor`. */ +export const listSlos = Effect.gen(function* () { + const client = yield* MapleApiV2Client + const progress = yield* Ref.make({ rows: [], cursor: null, pages: 0 }) + const readPage = Effect.gen(function* () { + const { rows, cursor, pages } = yield* Ref.get(progress) + const response = yield* client.slos.list({ + query: { limit: SLO_LIST_LIMIT, ...(cursor === null ? undefined : { cursor }) }, + }) + const next = response.has_more ? response.next_cursor : null + yield* Ref.set(progress, { rows: [...rows, ...response.data], cursor: next, pages: pages + 1 }) + return next !== null && pages + 1 < MAX_LIST_PAGES + }) + yield* readPage.pipe(Effect.repeat({ while: (hasMore) => hasMore })) + return (yield* Ref.get(progress)).rows +}).pipe(Effect.mapError(toLoadFailure)) + +/** An SLO beside its status; `status` is `null` when that one read failed. */ +export interface SloListRow { + readonly slo: V2Slo + readonly status: V2SloStatus | null +} + +/** The SLOs tab: the list, then each SLO's status, a failed status leaving its row without figures. */ +export const listSloRows = Effect.gen(function* () { + const client = yield* MapleApiV2Client + const slos = yield* listSlos + return yield* Effect.forEach( + slos, + (slo) => + client.slos.status({ params: { id: slo.id } }).pipe( + Effect.map((status): SloListRow => ({ slo, status })), + Effect.orElseSucceed((): SloListRow => ({ slo, status: null })), + ), + { concurrency: STATUS_CONCURRENCY }, + ) +}) + +export const retrieveSlo = (id: SloId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.slos.retrieve({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const sloStatus = (id: SloId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.slos.status({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const sloHistory = (id: SloId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + return yield* client.slos.history({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** Turns the burn-rate alert on or off, keeping its destinations. */ +export const setSloAlerting = (id: SloId, enabled: boolean) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + const alerting = yield* Schema.decodeEffect(V2SloAlertingParams)({ enabled }) + return yield* client.slos.update({ params: { id }, payload: { alerting } }) + }).pipe(Effect.mapError(toLoadFailure)) + +export const deleteSlo = (id: SloId) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + yield* client.slos.delete({ params: { id } }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** Creates an SLO, or updates `editingId`; the raw payload is validated before it is sent. */ +export const saveSlo = (editingId: SloId | null, raw: unknown) => + Effect.gen(function* () { + const client = yield* MapleApiV2Client + if (editingId !== null) { + const payload = yield* Schema.decodeUnknownEffect(V2SloUpdateParams)(raw) + return yield* client.slos.update({ params: { id: editingId }, payload }) + } + const payload = yield* Schema.decodeUnknownEffect(V2SloCreateParams)(raw) + return yield* client.slos.create({ payload }) + }).pipe(Effect.mapError(toLoadFailure)) + +/** `slos.retrieve` and `slos.status` answer this for an id the org has no SLO under. */ +export const SLO_NOT_FOUND_CODE = "slo_not_found" + +// LINKS + +const encodePublicId = Schema.encodeSync(SloPublicId) +const decodePublicId = Schema.decodeUnknownOption(SloPublicId) +const decodeInternalId = Schema.decodeUnknownOption(SloId) + +/** The `slo_…` id in a URL, or a raw UUID, as the internal id. */ +export const sloIdFromUrl = (raw: string): Option.Option => + Option.orElse(decodePublicId(raw), () => decodeInternalId(raw)) + +export const sloHref = (id: SloId): string => + hrefOf(AppRoute.AlertsSlo({ sloId: encodePublicId(id), search: {} })) + +export const newSloHref = (search: typeof AlertsSloNewSearch.Type = {}): string => + hrefOf(AppRoute.AlertsSloNew({ search })) + +export const editSloHref = (id: SloId): string => newSloHref({ sloId: encodePublicId(id) }) + +export const slosTabHref = (): string => hrefOf(AppRoute.Alerts({ search: { tab: "slos" } })) diff --git a/apps/web/src/page/alerts/shared/slo-derive.ts b/apps/web/src/page/alerts/shared/slo-derive.ts new file mode 100644 index 0000000000..35130a7db4 --- /dev/null +++ b/apps/web/src/page/alerts/shared/slo-derive.ts @@ -0,0 +1,133 @@ +import type { AlertIncidentRow } from "@maple/web-data/collections/alerts" +import type { V2Slo, V2SloSli, V2SloStatus } from "@maple/domain/http/v2" +import { SLO_BURN_TIERS, type SloBurnTier, tierBreached } from "@maple/domain/slo" +import type { Tone } from "@maple/ui/lib/tone" +import { Array as Arr, Option } from "effect" + +// What the SLO pages say about an SLO and its status: the SLI and target in words, the +// budget and burn figures, and which burn-rate tier would fire. + +export type Slo = V2Slo +export type SloStatus = V2SloStatus +export type SloTier = SloBurnTier + +/** `1000` as "1 s", `250` as "250 ms": a latency threshold the way the form's ladder names it. */ +export const formatThreshold = (ms: number): string => (ms < 1000 ? `${ms} ms` : `${ms / 1000} s`) + +/** "Availability", or "Latency < 500 ms". */ +export const sliLabel = (sli: V2SloSli): string => + sli.type === "availability" ? "Availability" : `Latency < ${formatThreshold(sli.threshold_ms)}` + +/** What a good event is, for the detail page's subline. */ +export const sliDescription = (sli: V2SloSli): string => + sli.type === "availability" + ? "Entry-point spans that are not errors" + : `Entry-point spans faster than ${formatThreshold(sli.threshold_ms)}` + +/** A percentage with up to `digits` decimals and no trailing zeros: 99.9%, 99.95%, 100%. */ +export const formatPercent = (percent: number, digits = 3): string => + `${Number(percent.toFixed(digits)).toString()}%` + +export const formatTarget = (slo: Pick): string => formatPercent(slo.target_percent) + +export const formatWindow = (days: number): string => `${days} days` + +/** The SLI as a share, or a dash before any event. */ +export const formatSli = (sli: number | null): string => (sli === null ? "–" : formatPercent(sli * 100)) + +/** Budget left as a whole percent; overspent reads negative. */ +export const formatBudget = (budget: number): string => `${Math.round(budget * 100)}%` + +/** "2.3x", the rate at which the window spends the budget. */ +export const formatBurn = (rate: number): string => `${rate < 10 ? rate.toFixed(1) : Math.round(rate)}x` + +/** "12.4k" events: counts are sampling-weighted, so they are rounded. */ +export const formatEvents = (count: number): string => + count >= 1_000_000 + ? `${Number((count / 1_000_000).toFixed(1))}M` + : count >= 1000 + ? `${Number((count / 1000).toFixed(1))}k` + : String(Math.round(count)) + +/** Spent is red, under a quarter left amber, anything else neutral. */ +export const budgetTone = (budget: number): Tone => (budget <= 0 ? "crit" : budget < 0.25 ? "warn" : "ok") + +export const sliTone = (slo: Pick, sli: number | null): Tone => + sli === null ? "neutral" : sli * 100 >= slo.target_percent ? "ok" : "crit" + +/** The tier colours follow the alert pages: critical red, warning amber. */ +export const TIER_TONE = { critical: "crit", warning: "warn" } as const satisfies Record< + SloTier["severity"], + Tone +> + +export const burnTone = (rate: number): Tone => + rate >= (SLO_BURN_TIERS[0]?.factor ?? Infinity) + ? "crit" + : rate >= (SLO_BURN_TIERS[1]?.factor ?? Infinity) + ? "warn" + : "neutral" + +const WINDOW_LABEL = new Map([ + [5, "5m"], + [30, "30m"], + [60, "1h"], + [360, "6h"], +]) + +/** "1h" for a burn window of 60 minutes. */ +export const windowLabel = (minutes: number): string => WINDOW_LABEL.get(minutes) ?? `${minutes}m` + +const domainWindows = (status: SloStatus) => + Arr.map(status.burn_windows, (window) => ({ + windowMinutes: window.window_minutes, + burnRate: window.burn_rate, + total: window.total_events, + good: window.good_events, + })) + +/** Whether each tier's long and short windows both burn at its factor. */ +export const tierStates = ( + status: SloStatus, +): ReadonlyArray> => { + const windows = domainWindows(status) + return Arr.map(SLO_BURN_TIERS, (tier) => ({ tier, isFiring: tierBreached(tier, windows) })) +} + +/** The most severe tier whose windows burn at its factor: critical before warning. */ +export const firingTier = (status: SloStatus): Option.Option => + Option.map( + Arr.findFirst(tierStates(status), (state) => state.isFiring), + (state) => state.tier, + ) + +/** The fastest of the four windows' burn rates, 0 without windows. */ +export const fastestBurn = (status: SloStatus): number => + Arr.reduce(status.burn_windows, 0, (fastest, window) => Math.max(fastest, window.burn_rate)) + +/** A burn window's rate, 0 when the status does not carry it. */ +export const burnOf = (status: SloStatus, minutes: number): number => + Option.match( + Arr.findFirst(status.burn_windows, (window) => window.window_minutes === minutes), + { onNone: () => 0, onSome: (window) => window.burn_rate }, + ) + +/** The SLO's burn-rate rule incidents, newest first as synced. */ +export const sloIncidents = ( + slo: Pick, + incidents: ReadonlyArray, +): ReadonlyArray => + slo.alert_rule_id === null ? [] : Arr.filter(incidents, (row) => row.rule_id === slo.alert_rule_id) + +/** The open incident on the SLO's rule, if any. */ +export const openIncident = ( + slo: Pick, + incidents: ReadonlyArray, +): Option.Option => + Arr.findFirst(sloIncidents(slo, incidents), (row) => row.status === "open") + +/** "checkout · production, staging", or the service alone when every environment counts. */ +export const scopeLine = (slo: Pick): string => + slo.environments.length === 0 + ? slo.service_name + : `${slo.service_name} · ${Arr.join(slo.environments, ", ")}` diff --git a/apps/web/src/page/alerts/shared/slo-test-fixtures.ts b/apps/web/src/page/alerts/shared/slo-test-fixtures.ts new file mode 100644 index 0000000000..b96c3e3b55 --- /dev/null +++ b/apps/web/src/page/alerts/shared/slo-test-fixtures.ts @@ -0,0 +1,139 @@ +import { V2Slo, V2SloHistory, V2SloStatus } from "@maple/domain/http/v2" +import { Array as Arr, Schema } from "effect" +import { DESTINATION_ID, NOW, ruleId } from "./test-rows" + +// SLOs, statuses and budget histories as the v2 client decodes them, for the scene tests and the lab. + +export const sloUuid = (n: number) => `0199a00a-0000-7000-8000-00000000000${n}` +/** The burn-rate rule behind the default SLO; its incidents carry it as `rule_id`. */ +export const SLO_RULE_UUID = ruleId(8) +export const WARNING_DESTINATION_UUID = "0199a001-0000-7000-8000-000000000002" + +const decodeSlo = Schema.decodeUnknownSync(V2Slo) +const decodeStatus = Schema.decodeUnknownSync(V2SloStatus) +const decodeHistory = Schema.decodeUnknownSync(V2SloHistory) + +const HOUR = 3_600_000 +const DAY = 24 * HOUR +const iso = (msBefore: number) => new Date(NOW - msBefore).toISOString() + +/** `checkout` availability at 99.9% over 30 days, paging the on-call webhook. */ +export const checkoutSlo = (fields: Record = {}): V2Slo => + decodeSlo({ + id: sloUuid(1), + object: "slo", + name: "Checkout API", + description: "Checkout requests succeed.", + service_name: "checkout", + environments: ["production"], + sli: { type: "availability" }, + target_percent: 99.9, + window_days: 30, + alert_rule_id: SLO_RULE_UUID, + alerting: { + enabled: true, + critical_destination_ids: [DESTINATION_ID], + warning_destination_ids: [], + }, + created_at: iso(40 * DAY), + updated_at: iso(2 * DAY), + ...fields, + }) + +/** `search` latency under 500 ms at 99.5% over 28 days, alerting off. */ +export const searchSlo = (fields: Record = {}): V2Slo => + decodeSlo({ + id: sloUuid(2), + object: "slo", + name: "Search latency", + description: null, + service_name: "search", + environments: [], + sli: { type: "latency", threshold_ms: 500 }, + target_percent: 99.5, + window_days: 28, + alert_rule_id: ruleId(9), + alerting: { enabled: false, critical_destination_ids: [], warning_destination_ids: [] }, + created_at: iso(20 * DAY), + updated_at: iso(20 * DAY), + ...fields, + }) + +export type SloScenario = "healthy" | "burning" | "exhausted" + +/** Budget left over the window, and the 5m, 30m, 1h and 6h burn rates, per scenario. */ +const SCENARIOS = { + healthy: { budget: 0.62, burns: [0.4, 0.6, 0.5, 0.45] }, + // Both critical windows (1h, 5m) at 14.4x or more: the page tier fires. + burning: { budget: 0.12, burns: [18.2, 9.4, 16.1, 7.3] }, + exhausted: { budget: -0.35, burns: [2.1, 1.8, 1.6, 1.4] }, +} as const satisfies Record + +const WINDOWS = [5, 30, 60, 360] as const +const TOTAL = 1_240_000 + +/** A status for an SLO with `targetPercent`, its counts consistent with the budget and burns. */ +export const sloStatus = ( + scenario: SloScenario, + options: Readonly<{ sloId?: string; targetPercent?: number }> = {}, +): V2SloStatus => { + const budgetRatio = 1 - (options.targetPercent ?? 99.9) / 100 + const { budget, burns } = SCENARIOS[scenario] + const badShare = (1 - budget) * budgetRatio + return decodeStatus({ + object: "slo.status", + slo_id: options.sloId ?? sloUuid(1), + as_of: iso(0), + total_events: TOTAL, + good_events: TOTAL * (1 - badShare), + sli: 1 - badShare, + budget_remaining: budget, + burn_windows: Arr.map(WINDOWS, (minutes, index) => { + const rate = burns[index] ?? 0 + const total = (TOTAL / (30 * 24 * 60)) * minutes + return { + window_minutes: minutes, + burn_rate: rate, + total_events: total, + good_events: total * (1 - rate * budgetRatio), + } + }), + }) +} + +/** Hourly points over `days`, oldest first, the budget falling to the scenario's figure. */ +export const sloHistory = ( + scenario: SloScenario, + options: Readonly<{ sloId?: string; days?: number; targetPercent?: number }> = {}, +): V2SloHistory => { + const hours = (options.days ?? 30) * 24 + const budgetRatio = 1 - (options.targetPercent ?? 99.9) / 100 + const end = SCENARIOS[scenario].budget + // A steady spend, then the last day burning hard when the scenario is burning. + const budgetAt = (index: number): number => { + const share = index / (hours - 1) + if (scenario !== "burning") return 1 - (1 - end) * share + const steady = 0.7 + const cutoff = (hours - 24) / (hours - 1) + return share <= cutoff + ? 1 - (1 - steady) * (share / cutoff) + : steady - (steady - end) * ((share - cutoff) / (1 - cutoff)) + } + return decodeHistory({ + object: "slo.history", + slo_id: options.sloId ?? sloUuid(1), + as_of: iso(0), + bucket_seconds: 3600, + points: Arr.makeBy(hours, (index) => { + const budget = budgetAt(index) + const previous = index === 0 ? 1 : budgetAt(index - 1) + const burn = Math.max(0, (previous - budget) * hours) + return { + at: iso((hours - 1 - index) * HOUR), + sli: 1 - burn * budgetRatio, + budget_remaining: budget, + burn_rate: burn, + } + }), + }) +} diff --git a/apps/web/src/page/alerts/shared/slo-view.ts b/apps/web/src/page/alerts/shared/slo-view.ts new file mode 100644 index 0000000000..831262ecc2 --- /dev/null +++ b/apps/web/src/page/alerts/shared/slo-view.ts @@ -0,0 +1,44 @@ +import type { Tone } from "@maple/ui/lib/tone" +import type { AlertIncidentRow } from "@maple/web-data/collections/alerts" +import { Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { meter } from "@maple/views/ui/meter" +import { hstack } from "@maple/views/ui/stack" +import { typography, type TypographyTone } from "@maple/views/ui/typography" +import { alertStatusBadge } from "./badges" +import { budgetTone, formatBudget, openIncident, type Slo } from "./slo-derive" + +// The SLO marks the tab and the detail page share: the budget bar and the alerting state. + +/** Budget left as a short bar and its percent; an overspent budget shows an empty red bar. */ +export const budgetBar = (h: HtmlBuilder, budget: number, className?: string): Html => { + const tone = budgetTone(budget) + return hstack(h, { gap: "2", className }, [ + meter(h, { + value: Math.max(0, Math.min(1, budget)), + tone, + minVisible: 0, + label: `${formatBudget(budget)} of the error budget left`, + className: "w-16 shrink-0", + }), + typography(h, { size: "xs", mono: true, numeric: true, tone: toneText(tone) }, [ + formatBudget(budget), + ]), + ]) +} + +/** The typography tone of a severity tone: red and amber, anything else the default ink. */ +export const toneText = (tone: Tone): TypographyTone | undefined => + tone === "crit" ? "error" : tone === "warn" ? "warning" : undefined + +/** "Incident open" while the burn-rate rule has one, else whether alerting is on. */ +export const alertingState = ( + h: HtmlBuilder, + slo: Pick, + incidents: ReadonlyArray, +): Html => + Option.isSome(openIncident(slo, incidents)) + ? alertStatusBadge(h, "firing", { label: "Incident open" }) + : slo.alerting.enabled + ? alertStatusBadge(h, "ok", { label: "Alerting on" }) + : alertStatusBadge(h, "disabled", { label: "Alerting off" }) diff --git a/apps/web/src/page/alerts/slo-form/command.ts b/apps/web/src/page/alerts/slo-form/command.ts new file mode 100644 index 0000000000..2ab5cc9a26 --- /dev/null +++ b/apps/web/src/page/alerts/slo-form/command.ts @@ -0,0 +1,40 @@ +import { SloId } from "@maple/domain" +import { Effect, Schema } from "effect" +import { Command } from "foldkit" +import { readGrants } from "../../../data/session-tenant" +import { recentServiceNames } from "../shared/services" +import { retrieveSlo, saveSlo } from "../shared/slo-commands" +import { Message } from "./message" + +export const FetchExistingSlo = Command.define("FetchSloFormSlo", { + args: { key: Schema.String, sloId: SloId }, + messages: [Message.CompletedFetchExisting], + execute: ({ key, sloId }) => + retrieveSlo(sloId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchExisting({ key, result })), + ), +}) + +/** The services traced in the last day: the ones an SLO can count. Empty on failure. */ +export const FetchSloFormServices = Command.define("FetchSloFormServices", { + messages: [Message.CompletedFetchServices], + execute: Effect.map(recentServiceNames, (services) => Message.CompletedFetchServices({ services })), +}) + +export const FetchSloFormSession = Command.define("FetchSloFormSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), +}) + +export const SaveSloDraft = Command.define("SaveSloDraft", { + args: { editingId: Schema.NullOr(SloId), payload: Schema.Unknown }, + messages: [Message.CompletedSave], + execute: ({ editingId, payload }) => + saveSlo(editingId, payload).pipe( + Effect.result, + Effect.map((result) => Message.CompletedSave({ result })), + ), +}) diff --git a/apps/web/src/page/alerts/slo-form/derive.ts b/apps/web/src/page/alerts/slo-form/derive.ts new file mode 100644 index 0000000000..44553d4d98 --- /dev/null +++ b/apps/web/src/page/alerts/slo-form/derive.ts @@ -0,0 +1,86 @@ +import type { V2Slo } from "@maple/domain/http/v2" +import { countLabel } from "@maple/ui/lib/format" +import { Array as Arr, Option, String as Str } from "effect" +import { type Form, TARGET_PRESETS, type TargetPick } from "./form" + +// The SLO form's draft read back: the target it sets, what still blocks saving, and the payload. + +const MIN_TARGET = 50 +const MAX_TARGET = 99.999 + +const isPreset = (value: string): value is (typeof TARGET_PRESETS)[number] => + Arr.some(TARGET_PRESETS, (preset) => preset === value) + +/** The target in percent, or none while the custom value is not a number in range. */ +export const targetOf = (form: Form): Option.Option => { + const raw = form.target === "custom" ? Str.trim(form.customTarget) : form.target + const value = raw === "" ? Number.NaN : Number(raw) + return Number.isFinite(value) && value >= MIN_TARGET && value <= MAX_TARGET + ? Option.some(value) + : Option.none() +} + +/** The environments typed, trimmed, empty ones and repeats dropped. */ +export const environmentsOf = (form: Form): ReadonlyArray => + Arr.dedupe( + Arr.filter(Arr.map(Str.split(",")(form.environments), Str.trim), (environment) => environment !== ""), + ) + +/** What still blocks saving, one sentence each. */ +export const formIssues = (form: Form): ReadonlyArray => [ + ...(Str.trim(form.name) === "" ? ["Name the SLO."] : []), + ...(Str.trim(form.serviceName) === "" ? ["Pick a service."] : []), + ...(Option.isNone(targetOf(form)) ? [`Set a target between ${MIN_TARGET}% and ${MAX_TARGET}%.`] : []), +] + +const MINUTES_PER_DAY = 24 * 60 + +/** "43 minutes": the full outage the budget allows over the window, for the target's hint. */ +export const budgetDowntime = (targetPercent: number, windowDays: number): string => { + const minutes = (1 - targetPercent / 100) * windowDays * MINUTES_PER_DAY + if (minutes < 1) return countLabel(Math.round(minutes * 60), "second") + if (minutes < 120) return countLabel(Math.round(minutes), "minute") + const hours = minutes / 60 + return hours < 48 + ? countLabel(Number(hours.toFixed(1)), "hour") + : countLabel(Number((hours / 24).toFixed(1)), "day") +} + +/** The form the edited SLO fills: a target off the presets reads as custom. */ +export const formFromSlo = (slo: V2Slo): Form => { + const target = String(slo.target_percent) + const pick: TargetPick = isPreset(target) ? target : "custom" + return { + name: slo.name, + description: slo.description ?? "", + serviceName: slo.service_name, + environments: Arr.join(slo.environments, ", "), + sliType: slo.sli.type, + thresholdMs: slo.sli.type === "latency" ? slo.sli.threshold_ms : 500, + target: pick, + customTarget: pick === "custom" ? target : "", + windowDays: slo.window_days, + alertingEnabled: slo.alerting.enabled, + criticalDestinationIds: slo.alerting.critical_destination_ids, + warningDestinationIds: slo.alerting.warning_destination_ids, + } +} + +/** The create or update body as the wire takes it; `saveSlo` decodes it before it is sent. */ +export const wirePayload = (form: Form) => ({ + name: Str.trim(form.name), + description: Str.trim(form.description) === "" ? null : Str.trim(form.description), + service_name: Str.trim(form.serviceName), + environments: environmentsOf(form), + sli: + form.sliType === "availability" + ? { type: "availability" } + : { type: "latency", threshold_ms: form.thresholdMs }, + target_percent: Option.getOrElse(targetOf(form), () => 0), + window_days: form.windowDays, + alerting: { + enabled: form.alertingEnabled, + critical_destination_ids: form.criticalDestinationIds, + warning_destination_ids: form.warningDestinationIds, + }, +}) diff --git a/apps/web/src/page/alerts/slo-form/form.ts b/apps/web/src/page/alerts/slo-form/form.ts new file mode 100644 index 0000000000..9d60f0f392 --- /dev/null +++ b/apps/web/src/page/alerts/slo-form/form.ts @@ -0,0 +1,64 @@ +import { SLO_LATENCY_THRESHOLDS_MS, SloLatencyThresholdMs, SloWindowDays } from "@maple/domain" +import { AlertDestinationId } from "@maple/domain/http" +import { Schema } from "effect" + +// The SLO form's draft: every field as the inputs hold it, strings until the payload is built. + +export const SliType = Schema.Literals(["availability", "latency"]) +export type SliType = typeof SliType.Type + +/** The free-text inputs, addressed by name from one Message. */ +export const TextField = Schema.Literals([ + "name", + "description", + "serviceName", + "environments", + "customTarget", +]) +export type TextField = typeof TextField.Type + +/** The target presets, in percent; `custom` reads `customTarget`. */ +export const TARGET_PRESETS = ["99", "99.5", "99.9", "99.95", "99.99"] as const +export const TargetPick = Schema.Literals([...TARGET_PRESETS, "custom"]) +export type TargetPick = typeof TargetPick.Type + +export const WINDOW_DAYS = SloWindowDays.literals + +export const THRESHOLDS = SLO_LATENCY_THRESHOLDS_MS + +export const Tier = Schema.Literals(["critical", "warning"]) +export type Tier = typeof Tier.Type + +export const Form = Schema.Struct({ + name: Schema.String, + description: Schema.String, + serviceName: Schema.String, + /** Comma-separated; empty counts every environment. */ + environments: Schema.String, + sliType: SliType, + thresholdMs: SloLatencyThresholdMs, + target: TargetPick, + customTarget: Schema.String, + windowDays: SloWindowDays, + alertingEnabled: Schema.Boolean, + criticalDestinationIds: Schema.Array(AlertDestinationId), + warningDestinationIds: Schema.Array(AlertDestinationId), +}) +export type Form = typeof Form.Type + +/** A new SLO: availability at 99.9% over 30 days, alerting on with nobody notified yet. */ +export const emptyForm = (fields: Partial
= {}): Form => ({ + name: "", + description: "", + serviceName: "", + environments: "", + sliType: "availability", + thresholdMs: 500, + target: "99.9", + customTarget: "", + windowDays: 30, + alertingEnabled: true, + criticalDestinationIds: [], + warningDestinationIds: [], + ...fields, +}) diff --git a/apps/web/src/page/alerts/slo-form/index.ts b/apps/web/src/page/alerts/slo-form/index.ts new file mode 100644 index 0000000000..66d0b76305 --- /dev/null +++ b/apps/web/src/page/alerts/slo-form/index.ts @@ -0,0 +1,39 @@ +import { Schema } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { definePage, type PageSubscriptionInput } from "../../contract" +import { destinationRows } from "../shared/collections" +import { slosTabHref } from "../shared/slo-commands" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { view } from "./view" + +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + destinations: entry( + { orgId: Schema.String }, + { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), + }, + ), +})) + +/** `/alerts/slos/new`: one instance per SLO being edited, or the new one. */ +export const page = definePage( + "AlertsSloNew", + { Model, Message }, + { + routes: ["AlertsSloNew"], + key: (route) => route.search.sloId ?? "new", + init, + update, + view, + subscriptions, + breadcrumbs: () => [ + { label: "Alerts", href: "/alerts" }, + { label: "SLOs", href: slosTabHref() }, + ], + }, +) diff --git a/apps/web/src/page/alerts/slo-form/message.ts b/apps/web/src/page/alerts/slo-form/message.ts new file mode 100644 index 0000000000..175300e4d2 --- /dev/null +++ b/apps/web/src/page/alerts/slo-form/message.ts @@ -0,0 +1,30 @@ +import * as Policy from "@maple/domain/policy" +import { SloLatencyThresholdMs, SloWindowDays } from "@maple/domain" +import { AlertDestinationId } from "@maple/domain/http" +import { V2Slo } from "@maple/domain/http/v2" +import { AlertDestinationRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Menu from "@maple/views/ui/menu" +import { SliType, TargetPick, TextField, Tier } from "./form" + +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const Message = defineMessageUnion({ + CompletedFetchExisting: { key: Schema.String, result: outcome(V2Slo) }, + UpdatedDestinations: { orgId: Schema.String, rows: Schema.Array(AlertDestinationRowSchema) }, + CompletedFetchServices: { services: Schema.Array(Schema.String) }, + CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, + ChangedText: { field: TextField, value: Schema.String }, + PickedSliType: { sliType: SliType }, + PickedThreshold: { thresholdMs: SloLatencyThresholdMs }, + PickedTarget: { target: TargetPick }, + PickedWindow: { windowDays: SloWindowDays }, + ToggledAlerting: {}, + RemovedDestination: { tier: Tier, id: AlertDestinationId }, + GotDestinationMenuMessage: { tier: Tier, message: Menu.Message }, + ClickedSave: {}, + CompletedSave: { result: outcome(V2Slo) }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/slo-form/model.ts b/apps/web/src/page/alerts/slo-form/model.ts new file mode 100644 index 0000000000..af92ded123 --- /dev/null +++ b/apps/web/src/page/alerts/slo-form/model.ts @@ -0,0 +1,36 @@ +import * as Policy from "@maple/domain/policy" +import { SloId } from "@maple/domain" +import { V2Slo } from "@maple/domain/http/v2" +import { AlertDestinationRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Menu from "@maple/views/ui/menu" +import { AlertsSloNewSearch } from "../../../route/search" +import { Form } from "./form" + +// `/alerts/slos/new`: create an SLO, or edit one (`?sloId=`). + +export const Model = Schema.Struct({ + search: AlertsSloNewSearch, + /** The SLO being edited; `null` creates. */ + editingId: Schema.NullOr(SloId), + /** The edited SLO's read; the form fills from it once. */ + existing: KeyedSchema(V2Slo, LoadFailure), + form: Form, + isSaving: Schema.Boolean, + /** The last save's failure, shown beside the action bar. */ + saveFailure: Schema.NullOr(LoadFailure), + /** The org's traced services of the last day, for the Service select. */ + services: Schema.Array(Schema.String), + destinations: Schema.NullOr(Schema.Array(AlertDestinationRowSchema)), + criticalMenu: Menu.Model, + warningMenu: Menu.Model, + grants: Schema.Array(Policy.PermissionName), + /** The session answered; until then a member is not yet told the form is admin-only. */ + isSessionLoaded: Schema.Boolean, +}) +export type Model = typeof Model.Type + +/** The viewer may change SLOs (`alerts:manage`); the API refuses anyone else. */ +export const canManage = (model: Pick): boolean => Policy.has(model.grants, "alerts:manage") diff --git a/apps/web/src/page/alerts/slo-form/slo-form.scene.test.ts b/apps/web/src/page/alerts/slo-form/slo-form.scene.test.ts new file mode 100644 index 0000000000..76eef5d7cd --- /dev/null +++ b/apps/web/src/page/alerts/slo-form/slo-form.scene.test.ts @@ -0,0 +1,236 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { SloPublicId } from "@maple/domain/http/v2" +import { Result, Schema, Array as Arr } from "effect" +import { click, expect as expectView, given, role, scene, text, type, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { toLoadFailure } from "@maple/views/data/load-failure" +import * as Menu from "@maple/views/ui/menu" +import { PageOutMessage, type PageContext } from "../../contract" +import { sloHref } from "../shared/slo-commands" +import { checkoutSlo, searchSlo } from "../shared/slo-test-fixtures" +import { DESTINATION_ID, destinationRow, ORG } from "../shared/test-rows" +import { wirePayload } from "./derive" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) + +const orgId = Schema.decodeSync(OrgId)(ORG) +const publicId = Schema.encodeSync(SloPublicId) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +const steps = (model: Model, messages: ReadonlyArray): Model => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) + +const fresh = (search: Model["search"] = {}, grants = ENGINEER): Model => + steps(init({ _tag: "AlertsSloNew", search }, context).model, [ + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.CompletedFetchServices({ services: ["checkout", "search"] }), + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + ]) + +const named = (): Model => + steps(fresh(), [ + Message.ChangedText({ field: "name", value: "Checkout API" }), + Message.ChangedText({ field: "serviceName", value: "checkout" }), + ]) + +describe("SLO form", () => { + test("a new SLO lists what is missing and cannot be created", () => { + scene( + { update: pageUpdate, view }, + given(fresh()), + expectView(role("heading", { name: "New SLO" })).toExist(), + expectView(text("Name the SLO.")).toExist(), + expectView(text("Pick a service.")).toExist(), + expectView(role("button", { name: "Create SLO" })).toBeDisabled(), + ) + }) + + test("a custom target outside 50% to 99.999% blocks saving until it is in range", () => { + scene( + { update: pageUpdate, view }, + given(named()), + click(role("button", { name: "Custom" })), + type(role("textbox", { name: "Custom target" }), "99.9999"), + expectView(text("Set a target between 50% and 99.999%.")).toExist(), + expectView(role("button", { name: "Create SLO" })).toBeDisabled(), + ) + scene( + { update: pageUpdate, view }, + given(named()), + click(role("button", { name: "Custom" })), + type(role("textbox", { name: "Custom target" }), "99.8"), + expectView(text("Ready to save")).toExist(), + expectView(role("button", { name: "Create SLO" })).not.toBeDisabled(), + ) + }) + + test("the target's hint says what budget it leaves over the window", () => { + scene( + { update: pageUpdate, view }, + given(named()), + expectView( + text("An error budget of 0.1% of events, about 43 minutes of full outage over 30 days."), + ).toExist(), + click(role("button", { name: "99.99%" })), + click(role("button", { name: "7 days" })), + expectView( + text("An error budget of 0.01% of events, about 1 minute of full outage over 7 days."), + ).toExist(), + ) + }) + + test("latency picks a threshold off the ladder the rollups count", () => { + scene( + { update: pageUpdate, view }, + given(named()), + expectView(role("group", { name: "Latency threshold" })).not.toExist(), + click(role("button", { name: "Latency" })), + expectView(role("button", { name: "50 ms" })).toExist(), + expectView(role("button", { name: "10 s" })).toExist(), + click(role("button", { name: "1 s" })), + expectView(text("Good events are entry-point spans faster than 1 s.")).toExist(), + ) + }) + + test("Create sends the SLO as the API takes it and opens the new SLO", () => { + const ready = steps(named(), [ + Message.ChangedText({ field: "environments", value: "production, staging, production" }), + Message.GotDestinationMenuMessage({ + tier: "critical", + message: Menu.Message.ClickedItem({ + index: 0, + item: { kind: "Item", value: DESTINATION_ID }, + }), + }), + ]) + const saving = pageUpdate(ready, Message.ClickedSave()) + expect(saving.commands).toMatchObject([ + { + name: "SaveSloDraft", + args: { + editingId: null, + payload: { + name: "Checkout API", + service_name: "checkout", + environments: ["production", "staging"], + sli: { type: "availability" }, + target_percent: 99.9, + window_days: 30, + alerting: { + enabled: true, + critical_destination_ids: [DESTINATION_ID], + warning_destination_ids: [], + }, + }, + }, + }, + ]) + const created = checkoutSlo() + expect( + pageUpdate(saving.model, Message.CompletedSave({ result: Result.succeed(created) })).outMessage, + ).toEqual( + PageOutMessage.RequestedNavigation({ + url: sloHref(created.id), + isReplace: true, + toast: { type: "success", title: "SLO created", description: "" }, + }), + ) + }) + + test("the destinations a tier notifies are picked from the org's and can be removed", () => { + const picked = pageUpdate( + named(), + Message.GotDestinationMenuMessage({ + tier: "warning", + message: Menu.Message.ClickedItem({ + index: 0, + item: { kind: "Item", value: DESTINATION_ID }, + }), + }), + ).model + expect(picked.form.warningDestinationIds).toEqual([DESTINATION_ID]) + expect(picked.form.criticalDestinationIds).toEqual([]) + scene( + { update: pageUpdate, view }, + given(picked), + click(role("button", { name: "Stop notifying On-call webhook" })), + expectView(role("button", { name: "Stop notifying On-call webhook" })).not.toExist(), + ) + }) + + test("turning alerting off hides the tiers' destinations", () => { + scene( + { update: pageUpdate, view }, + given(named()), + expectView(text("Pages when the budget burns at 14.4x over 1h and 5m.")).toExist(), + click(role("switch")), + expectView(text("Pages when the budget burns at 14.4x over 1h and 5m.")).not.toExist(), + ) + }) + + test("a failed save stays on the form and says why", () => { + const saving = pageUpdate(named(), Message.ClickedSave()).model + const failed = pageUpdate( + saving, + Message.CompletedSave({ result: Result.fail(toLoadFailure(new Error("refused"))) }), + ).model + scene( + { update: pageUpdate, view }, + given(failed), + expectView(text("Couldn't save the SLO")).toExist(), + expectView(role("button", { name: "Create SLO" })).not.toBeDisabled(), + ) + }) + + test("editing reads the SLO and fills the form from it", () => { + const slo = searchSlo({ target_percent: 99.7 }) + const entry = init({ _tag: "AlertsSloNew", search: { sloId: publicId(slo.id) } }, context) + expect(names(entry.commands)).toContain("FetchSloFormSlo") + const loaded = steps(entry.model, [ + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.CompletedFetchServices({ services: ["checkout"] }), + Message.CompletedFetchExisting({ + key: entry.model.existing.key ?? "", + result: Result.succeed(slo), + }), + ]) + scene( + { update: pageUpdate, view }, + given(loaded), + expectView(role("heading", { name: "Edit SLO" })).toExist(), + expectView(role("textbox", { name: "Name" })).toHaveValue("Search latency"), + expectView(role("button", { name: "Latency" })).toHaveAttr("aria-pressed", "true"), + expectView(role("button", { name: "500 ms" })).toHaveAttr("aria-pressed", "true"), + // 99.7 is off the presets, so it reads as a custom target. + expectView(role("textbox", { name: "Custom target" })).toHaveValue("99.7"), + expectView(role("button", { name: "28 days" })).toHaveAttr("aria-pressed", "true"), + expectView(role("button", { name: "Save changes" })).not.toBeDisabled(), + ) + expect(wirePayload(loaded.form)).toMatchObject({ + service_name: "search", + sli: { type: "latency", threshold_ms: 500 }, + target_percent: 99.7, + window_days: 28, + alerting: { enabled: false }, + }) + }) + + test("a member is told only admins and engineers change SLOs", () => { + scene( + { update: pageUpdate, view }, + given(fresh({}, MEMBER)), + expectView(text("Only org admins and engineers can create or change SLOs")).toExist(), + expectView(role("button", { name: "Create SLO" })).not.toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/slo-form/update.ts b/apps/web/src/page/alerts/slo-form/update.ts new file mode 100644 index 0000000000..42c26cf63b --- /dev/null +++ b/apps/web/src/page/alerts/slo-form/update.ts @@ -0,0 +1,145 @@ +import { AlertDestinationId } from "@maple/domain/http" +import { Option, Result, Schema, Array as Arr } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import * as Menu from "@maple/views/ui/menu" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { pickedDestination } from "../../kit/destination-picker" +import { sloHref, sloIdFromUrl } from "../shared/slo-commands" +import { FetchExistingSlo, FetchSloFormServices, FetchSloFormSession, SaveSloDraft } from "./command" +import { formFromSlo, formIssues, wirePayload } from "./derive" +import { emptyForm, type Form, type Tier } from "./form" +import { Message } from "./message" +import { canManage, type Model } from "./model" + +type Return = PageReturn +type FormRoute = Extract + +export const CRITICAL_MENU_ID = "slo-form-critical-destinations" +export const WARNING_MENU_ID = "slo-form-warning-destinations" + +const decodeDestinationId = Schema.decodeUnknownOption(AlertDestinationId) + +export const init = (route: FormRoute, context: PageContext): Return => { + const editingId = Option.getOrNull(Option.flatMap(Option.fromNullishOr(route.search.sloId), sloIdFromUrl)) + const model: Model = { + search: route.search, + editingId, + existing: Keyed.idle(), + form: emptyForm({ serviceName: route.search.serviceName ?? "" }), + isSaving: false, + saveFailure: null, + services: [], + destinations: null, + criticalMenu: Menu.init(CRITICAL_MENU_ID), + warningMenu: Menu.init(WARNING_MENU_ID), + grants: [], + isSessionLoaded: false, + } + const common = [FetchSloFormServices(), FetchSloFormSession({ orgId: context.orgId })] + if (editingId === null) return { model, commands: common } + const key = requestKey(context.orgId, "slos.retrieve", { sloId: editingId }) + return Option.match(Keyed.request(model.existing, key, "Load"), { + onNone: () => ({ model, commands: common }), + onSome: (existing) => ({ + model: { ...model, existing }, + commands: [...common, FetchExistingSlo({ key, sloId: editingId })], + }), + }) +} + +const withForm = (model: Model, patch: Partial): Return => ({ + model: { ...model, form: { ...model.form, ...patch } }, +}) + +const idsOf = (form: Form, tier: Tier) => + tier === "critical" ? form.criticalDestinationIds : form.warningDestinationIds + +const withIds = (tier: Tier, ids: ReadonlyArray): Partial => + tier === "critical" ? { criticalDestinationIds: ids } : { warningDestinationIds: ids } + +/** One tier's "Add destination" menu: a pick of a synced destination adds it to that tier. */ +const updateDestinationMenu = (model: Model, tier: Tier, message: Menu.Message): Return => { + const result = Menu.update(tier === "critical" ? model.criticalMenu : model.warningMenu, message) + const commands = FoldkitCommand.mapMessages(result.commands ?? [], (child) => + Message.GotDestinationMenuMessage({ tier, message: child }), + ) + const next: Model = + tier === "critical" + ? { ...model, criticalMenu: result.model } + : { ...model, warningMenu: result.model } + const ids = idsOf(model.form, tier) + return Option.match( + Option.flatMap(pickedDestination(result.outMessage, model.destinations, ids), decodeDestinationId), + { + onNone: () => ({ model: next, commands }), + onSome: (id) => ({ + model: { ...next, form: { ...next.form, ...withIds(tier, [...ids, id]) } }, + commands, + }), + }, + ) +} + +const save = (model: Model): Return => + model.isSaving || !canManage(model) || formIssues(model.form).length > 0 + ? { model } + : { + model: { ...model, isSaving: true, saveFailure: null }, + commands: [SaveSloDraft({ editingId: model.editingId, payload: wirePayload(model.form) })], + } + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + CompletedFetchExisting: ({ key, result }) => { + const existing = Keyed.accept(model.existing, key, result) + const loaded = Option.getOrNull(AsyncData.getData(existing.data)) + return { + model: { + ...model, + existing, + ...(loaded === null ? undefined : { form: formFromSlo(loaded) }), + }, + } + }, + UpdatedDestinations: ({ orgId, rows }) => + orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, + CompletedFetchServices: ({ services }) => ({ model: { ...model, services } }), + CompletedFetchSession: ({ orgId, grants }) => + orgId === context.orgId ? { model: { ...model, grants, isSessionLoaded: true } } : { model }, + ChangedText: ({ field, value }) => withForm(model, { [field]: value }), + PickedSliType: ({ sliType }) => withForm(model, { sliType }), + PickedThreshold: ({ thresholdMs }) => withForm(model, { thresholdMs }), + PickedTarget: ({ target }) => withForm(model, { target }), + PickedWindow: ({ windowDays }) => withForm(model, { windowDays }), + ToggledAlerting: () => withForm(model, { alertingEnabled: !model.form.alertingEnabled }), + RemovedDestination: ({ tier, id }) => + withForm( + model, + withIds( + tier, + Arr.filter(idsOf(model.form, tier), (existing) => existing !== id), + ), + ), + GotDestinationMenuMessage: ({ tier, message: child }) => updateDestinationMenu(model, tier, child), + ClickedSave: () => save(model), + CompletedSave: ({ result }) => + Result.match(result, { + onSuccess: (slo): Return => ({ + model: { ...model, isSaving: false }, + outMessage: PageOutMessage.RequestedNavigation({ + url: sloHref(slo.id), + isReplace: true, + toast: { + type: "success", + title: model.editingId === null ? "SLO created" : "Changes saved", + description: "", + }, + }), + }), + // Inline beside the action bar, never a toast over its buttons. + onFailure: (saveFailure): Return => ({ model: { ...model, isSaving: false, saveFailure } }), + }), + }) diff --git a/apps/web/src/page/alerts/slo-form/view.ts b/apps/web/src/page/alerts/slo-form/view.ts new file mode 100644 index 0000000000..69a7b4b2d3 --- /dev/null +++ b/apps/web/src/page/alerts/slo-form/view.ts @@ -0,0 +1,370 @@ +import { Option, Array as Arr } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { failureMessage } from "@maple/views/data/load-failure" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { button, linkButton } from "@maple/views/ui/button" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import { input } from "@maple/views/ui/input" +import { inputGroup, inputGroupAddon, inputGroupInput, inputGroupText } from "@maple/views/ui/input-group" +import { label } from "@maple/views/ui/label" +import { nativeOption, nativeSelect } from "@maple/views/ui/native-select" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { skeleton } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { switchControl } from "@maple/views/ui/switch" +import { textarea } from "@maple/views/ui/textarea" +import { typography, typographyClass } from "@maple/views/ui/typography" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { destinationPicker } from "../../kit/destination-picker" +import { formActionBar, formCard, formSection } from "../../kit/form-section" +import { sloHref, slosTabHref } from "../shared/slo-commands" +import { formatPercent, formatThreshold, formatWindow } from "../shared/slo-derive" +import { budgetDowntime, formIssues, targetOf } from "./derive" +import { type Form, TARGET_PRESETS, type TextField, THRESHOLDS, type Tier, WINDOW_DAYS } from "./form" +import { Message } from "./message" +import { canManage, type Model } from "./model" + +// `/alerts/slos/new`: what the SLO counts, the target and window, and who its burn-rate alerts notify. + +type H = HtmlBuilder + +const FORM_WIDTH = "max-w-3xl" + +const fieldId = (field: TextField) => `slo-${field}` + +/** A text input bound to one form field; the section names it, so its label is for screen readers. */ +const textInput = ( + h: H, + options: Readonly<{ + field: TextField + label: string + value: string + placeholder?: string + hint?: string + }>, +): Html => + vstack(h, { gap: "1.5" }, [ + label(h, { className: "sr-only", attributes: [h.For(fieldId(options.field))] }, [options.label]), + input(h, { + id: fieldId(options.field), + attributes: [ + h.Value(options.value), + h.OnInput((value) => Message.ChangedText({ field: options.field, value })), + ...(options.placeholder === undefined ? [] : [h.Placeholder(options.placeholder)]), + ], + }), + ...(options.hint === undefined + ? [] + : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.hint])]), + ]) + +const nameSection = (h: H, form: Form): Html => + formSection(h, { title: "Name" }, [ + textInput(h, { field: "name", label: "Name", value: form.name, placeholder: "Checkout API" }), + vstack(h, { gap: "1.5" }, [ + label( + h, + { + className: typographyClass({ size: "xs", tone: "muted" }), + attributes: [h.For(fieldId("description"))], + }, + ["Description (optional)"], + ), + textarea(h, { + id: fieldId("description"), + attributes: [ + h.Value(form.description), + h.OnInput((value) => Message.ChangedText({ field: "description", value })), + h.Placeholder("What the objective promises, for the people reading it"), + ], + }), + ]), + ]) + +/** The service from the ones traced in the last day; a saved one no longer traced stays listed. */ +const serviceSection = (h: H, model: Model): Html => { + const current = model.form.serviceName + const names = + current === "" || model.services.includes(current) ? model.services : [current, ...model.services] + return formSection(h, { title: "Service", hint: "Its entry-point spans are counted" }, [ + vstack(h, { gap: "1.5" }, [ + label(h, { className: "sr-only", attributes: [h.For(fieldId("serviceName"))] }, ["Service"]), + nativeSelect( + h, + { + defaultValue: current, + className: "w-full max-w-xs", + attributes: [ + // Keyed on the services: the select is uncontrolled, so a late list must rebuild it. + h.Key(`slo-service-${names.length}-${current}`), + h.Id(fieldId("serviceName")), + h.OnChange((value) => Message.ChangedText({ field: "serviceName", value })), + ], + }, + [ + nativeOption( + "", + names.length === 0 ? "No services traced in the last day" : "Pick a service", + ), + ...Arr.map(names, (name) => nativeOption(name, name)), + ], + ), + ]), + textInput(h, { + field: "environments", + label: "Environments", + value: model.form.environments, + placeholder: "production, staging", + hint: "Comma-separated. Empty counts every environment.", + }), + ]) +} + +const indicatorSection = (h: H, form: Form): Html => + formSection(h, { title: "Indicator", hint: "What a good event is", isCompact: true }, [ + segmentedSelect(h, { + ariaLabel: "Indicator", + size: "sm", + className: "w-fit", + value: form.sliType, + onChange: (sliType) => Message.PickedSliType({ sliType }), + options: [ + { value: "availability", label: "Availability" }, + { value: "latency", label: "Latency" }, + ], + }), + ...(form.sliType === "latency" + ? [ + segmentedSelect(h, { + ariaLabel: "Latency threshold", + size: "sm", + className: "w-fit flex-wrap", + value: String(form.thresholdMs), + onChange: (value) => + Option.match( + Arr.findFirst(THRESHOLDS, (ms) => String(ms) === value), + { + onNone: () => Message.PickedThreshold({ thresholdMs: form.thresholdMs }), + onSome: (thresholdMs) => Message.PickedThreshold({ thresholdMs }), + }, + ), + options: Arr.map(THRESHOLDS, (ms) => ({ + value: String(ms), + label: formatThreshold(ms), + })), + }), + ] + : []), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + form.sliType === "availability" + ? "Good events are entry-point spans whose status is not Error." + : `Good events are entry-point spans faster than ${formatThreshold(form.thresholdMs)}.`, + ]), + ]) + +const targetHint = (form: Form): string => + Option.match(targetOf(form), { + onNone: () => "Between 50% and 99.999%.", + onSome: (target) => + `An error budget of ${formatPercent(100 - target)} of events, about ${budgetDowntime(target, form.windowDays)} of full outage over ${formatWindow(form.windowDays)}.`, + }) + +const targetSection = (h: H, form: Form): Html => + formSection(h, { title: "Target", isCompact: true }, [ + segmentedSelect(h, { + ariaLabel: "Target", + size: "sm", + className: "w-fit flex-wrap", + value: form.target, + onChange: (target) => Message.PickedTarget({ target }), + options: [ + ...Arr.map(TARGET_PRESETS, (preset) => ({ value: preset, label: `${preset}%` })), + { value: "custom" as const, label: "Custom" }, + ], + }), + ...(form.target === "custom" + ? [ + vstack(h, { gap: "1.5", className: "w-40" }, [ + label(h, { className: "sr-only", attributes: [h.For(fieldId("customTarget"))] }, [ + "Custom target", + ]), + inputGroup(h, {}, [ + inputGroupInput(h, { + id: fieldId("customTarget"), + attributes: [ + h.Value(form.customTarget), + h.Attribute("inputmode", "decimal"), + h.Placeholder("99.8"), + h.OnInput((value) => + Message.ChangedText({ field: "customTarget", value }), + ), + ], + }), + inputGroupAddon(h, { align: "inline-end" }, [inputGroupText(h, {}, ["%"])]), + ]), + ]), + ] + : []), + typography(h, { size: "xs", tone: "muted", as: "p" }, [targetHint(form)]), + ]) + +const windowSection = (h: H, form: Form): Html => + formSection(h, { title: "Window", hint: "Rolling", isCompact: true }, [ + segmentedSelect(h, { + ariaLabel: "Window", + size: "sm", + className: "w-fit", + value: String(form.windowDays), + onChange: (value) => + Option.match( + Arr.findFirst(WINDOW_DAYS, (days) => String(days) === value), + { + onNone: () => Message.PickedWindow({ windowDays: form.windowDays }), + onSome: (windowDays) => Message.PickedWindow({ windowDays }), + }, + ), + options: Arr.map(WINDOW_DAYS, (days) => ({ value: String(days), label: formatWindow(days) })), + }), + ]) + +const TIERS: ReadonlyArray<{ tier: Tier; title: string; rule: string }> = [ + { tier: "critical", title: "Critical", rule: "Pages when the budget burns at 14.4x over 1h and 5m." }, + { + tier: "warning", + title: "Warning", + rule: "Opens a ticket at 6x over 6h and 30m. A critical incident notifies these too.", + }, +] + +const tierPicker = (h: H, model: Model, entry: (typeof TIERS)[number]): Html => + vstack(h, { gap: "1.5", attributes: [h.Key(entry.tier)] }, [ + hstack(h, { gap: "2", align: "baseline", wrap: true }, [ + typography(h, { size: "sm", weight: "medium" }, [entry.title]), + typography(h, { size: "xs", tone: "muted" }, [entry.rule]), + ]), + destinationPicker(h, { + rows: model.destinations, + selectedIds: + entry.tier === "critical" + ? model.form.criticalDestinationIds + : model.form.warningDestinationIds, + menu: entry.tier === "critical" ? model.criticalMenu : model.warningMenu, + toMenuMessage: (message) => Message.GotDestinationMenuMessage({ tier: entry.tier, message }), + onRemove: (id) => Message.RemovedDestination({ tier: entry.tier, id }), + emptyText: "Nobody yet. The incident still opens in the inbox.", + }), + ]) + +const alertingSection = (h: H, model: Model): Html => + formSection(h, { title: "Alerting", hint: "Multiwindow burn rate", isCompact: true }, [ + hstack(h, { gap: "2" }, [ + ...switchControl(h, { + id: "slo-form-alerting", + inputId: "slo-form-alerting-input", + isChecked: model.form.alertingEnabled, + labelledBy: "slo-form-alerting-label", + onToggle: Message.ToggledAlerting(), + }), + typography(h, { size: "sm", attributes: [h.Id("slo-form-alerting-label")] }, [ + "Open incidents when the error budget burns too fast", + ]), + ]), + ...(model.form.alertingEnabled ? Arr.map(TIERS, (entry) => tierPicker(h, model, entry)) : []), + ]) + +const actionBar = (h: H, model: Model): Html => { + const issues = formIssues(model.form) + return formActionBar(h, { + issues, + widthClass: FORM_WIDTH, + notice: + model.saveFailure === null + ? null + : alert(h, { variant: "crit", size: "sm", className: `mb-3 ${FORM_WIDTH}` }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Couldn't save the SLO"]), + alertDescription(h, [failureMessage(model.saveFailure)]), + ]), + actions: [ + linkButton( + h, + { + variant: "outline", + href: model.editingId === null ? slosTabHref() : sloHref(model.editingId), + }, + ["Cancel"], + ), + button( + h, + { + // Until the session says this is a manager, a click would be dropped by `save`. + isDisabled: issues.length > 0 || !canManage(model), + isLoading: model.isSaving, + attributes: [h.OnClick(Message.ClickedSave())], + }, + [model.editingId === null ? "Create SLO" : "Save changes"], + ), + ], + }) +} + +/** Members can read SLOs but not create or change them; the API refuses them too. */ +const managersOnlyPage = (h: H, model: Model, header: Readonly<{ title: string; description: string }>) => + dashboardPage(h, header, [ + alert(h, { variant: "info" }, [ + icon(h, "ShieldIcon"), + alertTitle(h, ["Only org admins and engineers can create or change SLOs"]), + alertDescription(h, ["Ask an admin to make this change."]), + ]), + h.div( + [], + [ + linkButton( + h, + { + variant: "outline", + href: model.editingId === null ? slosTabHref() : sloHref(model.editingId), + }, + [model.editingId === null ? "Back to SLOs" : "Back to the SLO"], + ), + ], + ), + ]) + +export const view = Submodel.defineView((model, _inputs, h) => { + const isEdit = model.editingId !== null + const header = { + title: isEdit ? "Edit SLO" : "New SLO", + description: "The share of a service's requests that must be good, and the error budget that leaves.", + } + if (model.isSessionLoaded && !canManage(model)) return managersOnlyPage(h, model, header) + if (isEdit && AsyncData.isFailure(model.existing.data)) + return dashboardPage(h, header, [ + errorState(h, { failure: model.existing.data.error, title: "Couldn't load the SLO" }), + ]) + if (isEdit && !AsyncData.isSuccess(model.existing.data) && !AsyncData.isRefreshing(model.existing.data)) + return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) + return dashboardPage(h, { ...header, fill: true }, [ + h.div( + [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], + [ + formCard( + h, + [ + nameSection(h, model.form), + serviceSection(h, model), + indicatorSection(h, model.form), + targetSection(h, model.form), + windowSection(h, model.form), + alertingSection(h, model), + ], + FORM_WIDTH, + ), + ], + ), + actionBar(h, model), + ]) +}) diff --git a/apps/web/src/page/alerts/slo/command.ts b/apps/web/src/page/alerts/slo/command.ts new file mode 100644 index 0000000000..9c3c9e5416 --- /dev/null +++ b/apps/web/src/page/alerts/slo/command.ts @@ -0,0 +1,68 @@ +import { SloId } from "@maple/domain" +import { Clock, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { readGrants } from "../../../data/session-tenant" +import { deleteSlo, retrieveSlo, setSloAlerting, sloHistory, sloStatus } from "../shared/slo-commands" +import { Message } from "./message" + +export const ReadSloClock = Command.define("ReadSloClock", { + messages: [Message.ReadClock], + execute: Effect.map(Clock.currentTimeMillis, (nowMs) => Message.ReadClock({ nowMs })), +}) + +export const FetchSloSession = Command.define("FetchSloSession", { + args: { orgId: Schema.String }, + messages: [Message.CompletedFetchSession], + execute: ({ orgId }) => + Effect.map(readGrants, (grants) => Message.CompletedFetchSession({ orgId, grants })), +}) + +export const FetchSlo = Command.define("FetchSlo", { + args: { key: Schema.String, sloId: SloId }, + messages: [Message.CompletedFetchSlo], + execute: ({ key, sloId }) => + retrieveSlo(sloId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSlo({ key, result })), + ), +}) + +export const FetchSloStatus = Command.define("FetchSloStatus", { + args: { key: Schema.String, sloId: SloId }, + messages: [Message.CompletedFetchStatus], + execute: ({ key, sloId }) => + sloStatus(sloId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchStatus({ key, result })), + ), +}) + +export const FetchSloHistory = Command.define("FetchSloHistory", { + args: { key: Schema.String, sloId: SloId }, + messages: [Message.CompletedFetchHistory], + execute: ({ key, sloId }) => + sloHistory(sloId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchHistory({ key, result })), + ), +}) + +export const ToggleSloAlerting = Command.define("ToggleSloAlerting", { + args: { sloId: SloId, enabled: Schema.Boolean }, + messages: [Message.CompletedToggleAlerting], + execute: ({ sloId, enabled }) => + setSloAlerting(sloId, enabled).pipe( + Effect.result, + Effect.map((result) => Message.CompletedToggleAlerting({ result })), + ), +}) + +export const DeleteSlo = Command.define("DeleteSlo", { + args: { sloId: SloId }, + messages: [Message.CompletedDelete], + execute: ({ sloId }) => + deleteSlo(sloId).pipe( + Effect.result, + Effect.map((result) => Message.CompletedDelete({ result })), + ), +}) diff --git a/apps/web/src/page/alerts/slo/index.ts b/apps/web/src/page/alerts/slo/index.ts new file mode 100644 index 0000000000..41de93da2f --- /dev/null +++ b/apps/web/src/page/alerts/slo/index.ts @@ -0,0 +1,39 @@ +import { Schema } from "effect" +import { Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import { definePage, type PageSubscriptionInput } from "../../contract" +import { destinationRows, incidentRows } from "../shared/collections" +import { Message } from "./message" +import { Model } from "./model" +import { init, update } from "./update" +import { breadcrumbsOf, view } from "./view" + +const byOrg = { orgId: Schema.String } + +const subscriptions = Subscription.make, Message, Resources>()((entry) => ({ + destinations: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + destinationRows(orgId, (rows) => Message.UpdatedDestinations({ orgId, rows })), + }), + incidents: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + }), +})) + +/** `/alerts/slos/$sloId`: one instance per SLO. */ +export const page = definePage( + "AlertsSlo", + { Model, Message }, + { + routes: ["AlertsSlo"], + key: (route) => route.sloId, + init, + update, + view, + subscriptions, + breadcrumbs: breadcrumbsOf, + }, +) diff --git a/apps/web/src/page/alerts/slo/message.ts b/apps/web/src/page/alerts/slo/message.ts new file mode 100644 index 0000000000..6afb271ab8 --- /dev/null +++ b/apps/web/src/page/alerts/slo/message.ts @@ -0,0 +1,36 @@ +import * as Policy from "@maple/domain/policy" +import { V2Slo, V2SloHistory, V2SloStatus } from "@maple/domain/http/v2" +import { AlertDestinationRowSchema, AlertIncidentRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import * as Chart from "@maple/views/charts/chart" +import { ThemeColors } from "@maple/views/charts/theme-colors" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" + +const outcome = (success: S) => Schema.Result(success, LoadFailure) + +export const Message = defineMessageUnion({ + ReadClock: { nowMs: Schema.Number }, + CompletedFetchSlo: { key: Schema.String, result: outcome(V2Slo) }, + CompletedFetchStatus: { key: Schema.String, result: outcome(V2SloStatus) }, + CompletedFetchHistory: { key: Schema.String, result: outcome(V2SloHistory) }, + ClickedRetry: {}, + ClickedRetryStatus: {}, + ClickedRetryHistory: {}, + UpdatedDestinations: { orgId: Schema.String, rows: Schema.Array(AlertDestinationRowSchema) }, + UpdatedIncidents: { orgId: Schema.String, rows: Schema.Array(AlertIncidentRowSchema) }, + CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, + ClickedToggleAlerting: {}, + CompletedToggleAlerting: { result: outcome(V2Slo) }, + ClickedDelete: {}, + GotDeleteDialogMessage: { message: Dialog.Message }, + ClickedConfirmDelete: {}, + CompletedDelete: { result: outcome(Schema.Void) }, + CompletedLoadChartRuntime: {}, + FailedLoadChartRuntime: { reason: Schema.String }, + ResolvedThemeColors: { colors: ThemeColors }, + GotBudgetChartMessage: { message: Chart.Message }, + GotSliChartMessage: { message: Chart.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/alerts/slo/model.ts b/apps/web/src/page/alerts/slo/model.ts new file mode 100644 index 0000000000..2d7b174ea6 --- /dev/null +++ b/apps/web/src/page/alerts/slo/model.ts @@ -0,0 +1,40 @@ +import * as Policy from "@maple/domain/policy" +import { SloId } from "@maple/domain" +import { V2Slo, V2SloHistory, V2SloStatus } from "@maple/domain/http/v2" +import { AlertDestinationRowSchema, AlertIncidentRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import * as Chart from "@maple/views/charts/chart" +import { ChartRuntime } from "@maple/views/charts/chart-runtime" +import { ThemeColors } from "@maple/views/charts/theme-colors" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Dialog from "@maple/views/ui/dialog" + +// `/alerts/slos/$sloId`: one SLO, its error budget, burn rates and incidents. + +const Rows = (row: S) => Schema.NullOr(Schema.Array(row)) + +export const Model = Schema.Struct({ + /** The URL segment as written; `sloId` is `null` when it is not an SLO id. */ + rawSloId: Schema.String, + sloId: Schema.NullOr(SloId), + nowMs: Schema.NullOr(Schema.Number), + slo: KeyedSchema(V2Slo, LoadFailure), + status: KeyedSchema(V2SloStatus, LoadFailure), + history: KeyedSchema(V2SloHistory, LoadFailure), + /** Synced destinations (the alerting panel's names) and incidents (those of the SLO's rule). */ + destinations: Rows(AlertDestinationRowSchema), + incidents: Rows(AlertIncidentRowSchema), + grants: Schema.Array(Policy.PermissionName), + isTogglingAlerting: Schema.Boolean, + isDeleting: Schema.Boolean, + deleteDialog: Dialog.Model, + budgetChart: Chart.Model, + sliChart: Chart.Model, + chartRuntime: ChartRuntime, + themeColors: Schema.NullOr(ThemeColors), +}) +export type Model = typeof Model.Type + +/** The viewer may change SLOs (`alerts:manage`); the API refuses anyone else. */ +export const canManage = (model: Pick): boolean => Policy.has(model.grants, "alerts:manage") diff --git a/apps/web/src/page/alerts/slo/slo.scene.test.ts b/apps/web/src/page/alerts/slo/slo.scene.test.ts new file mode 100644 index 0000000000..bd883c9fd2 --- /dev/null +++ b/apps/web/src/page/alerts/slo/slo.scene.test.ts @@ -0,0 +1,272 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { SloPublicId, type V2Slo } from "@maple/domain/http/v2" +import { Result, Schema, Array as Arr } from "effect" +import { + expect as expectView, + given, + Mount, + role, + scene, + selector, + text, + within, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { PageOutMessage, type PageContext } from "../../contract" +import { slosTabHref } from "../shared/slo-commands" +import { + checkoutSlo, + SLO_RULE_UUID, + searchSlo, + sloHistory, + sloStatus, + type SloScenario, +} from "../shared/slo-test-fixtures" +import { destinationRow, incidentRow, NOW, ORG } from "../shared/test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +const ENGINEER = Policy.grantsOf(["org:engineer"]) +const MEMBER = Policy.grantsOf(["org:member"]) + +const orgId = Schema.decodeSync(OrgId)(ORG) +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) +const publicId = Schema.encodeSync(SloPublicId) +const callout = selector('[data-slot="alert"]') +const burnPanel = role("region", { name: "Burn rate" }) +const alertingPanel = role("region", { name: "Alerting" }) +const incidentsPanel = role("region", { name: "Incidents" }) +const themeResolved = () => + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) + +const entered = (slo: V2Slo = checkoutSlo()) => + init({ _tag: "AlertsSlo", sloId: publicId(slo.id), search: {} }, context) + +/** The page with the SLO read, then `extra` (a status, a history, synced rows). */ +const opened = (slo: V2Slo, extra: (model: Model) => ReadonlyArray = () => []): Model => { + const model = Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.CompletedFetchSession({ orgId: ORG, grants: ENGINEER }), + Message.CompletedFetchSlo({ key: entered(slo).model.slo.key ?? "", result: Result.succeed(slo) }), + ], + entered(slo).model, + (current, message) => pageUpdate(current, message).model, + ) + return Arr.reduce(extra(model), model, (current, message) => pageUpdate(current, message).model) +} + +const withStatus = (scenario: SloScenario, slo = checkoutSlo()) => + opened(slo, (model) => [ + Message.CompletedFetchStatus({ + key: model.status.key ?? "", + result: Result.succeed(sloStatus(scenario)), + }), + Message.CompletedFetchHistory({ + key: model.history.key ?? "", + result: Result.succeed(sloHistory(scenario)), + }), + Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), + Message.UpdatedIncidents({ orgId: ORG, rows: [] }), + ]) + +describe("SLO page", () => { + test("entry reads the SLO, its status and its history by the id in the URL", () => { + expect(names(entered().commands)).toEqual( + expect.arrayContaining(["FetchSlo", "FetchSloStatus", "FetchSloHistory"]), + ) + }) + + test("an id that is not an SLO's reads nothing and says so", () => { + const entry = init({ _tag: "AlertsSlo", sloId: "nope", search: {} }, context) + expect(names(entry.commands)).not.toContain("FetchSlo") + scene( + { update: pageUpdate, view }, + given(entry.model), + themeResolved(), + expectView(text("SLO not found")).toExist(), + ) + }) + + test("while the SLO loads the page shows its skeleton, no figures", () => { + scene( + { update: pageUpdate, view }, + given(entered().model), + themeResolved(), + expectView(role("heading", { name: "Checkout API" })).not.toExist(), + expectView(text("Current SLI")).not.toExist(), + ) + }) + + test("a healthy SLO shows its SLI against the target, the budget left and the events counted", () => { + scene( + { update: pageUpdate, view }, + given(withStatus("healthy")), + themeResolved(), + expectView(role("heading", { name: "Checkout API" })).toExist(), + expectView(text("Current SLI")).toExist(), + expectView(text("99.962%")).toExist(), + expectView(text("Target 99.9% over 30 days")).toExist(), + expectView(text("62%")).toExist(), + expectView(text("1.2M")).toExist(), + // Nothing burns: no callout, and both tiers are quiet. + expectView(callout).not.toExist(), + expectView(within(burnPanel, text("Would fire"))).not.toExist(), + ) + }) + + test("a burning SLO leads with the critical tier, its rates and the incident it opened", () => { + const incident = incidentRow({ rule_id: SLO_RULE_UUID, signal_type: "slo_burn_rate", status: "open" }) + const model = pageUpdate( + withStatus("burning"), + Message.UpdatedIncidents({ orgId: ORG, rows: [incident] }), + ).model + scene( + { update: pageUpdate, view }, + given(model), + themeResolved(), + expectView(within(callout, text("Burning the error budget at 16x over 1h"))).toExist(), + expectView(within(callout, role("link", { name: "Open incident" }))).toHaveAttr( + "href", + `/alerts/incidents/${incident.id}`, + ), + // 1h at 16.1x and 5m at 18.2x both reach 14.4x; 6h at 7.3x and 30m at 9.4x reach 6x. + expectView( + within(burnPanel, text("1h at 16x, 5m at 18x. Critical pages when both reach 14.4x.")), + ).toExist(), + expectView(within(burnPanel, text("Would fire"))).toExist(), + expectView(within(incidentsPanel, role("link", { name: /Open/u }))).toHaveAttr( + "href", + `/alerts/incidents/${incident.id}`, + ), + ) + }) + + test("a spent budget reads negative and says the SLO is out of budget", () => { + scene( + { update: pageUpdate, view }, + given(withStatus("exhausted")), + themeResolved(), + expectView(text("-35%")).toExist(), + expectView(within(callout, text("Error budget spent"))).toExist(), + expectView(text("Spent: 35% over budget")).toExist(), + ) + }) + + test("a failed status read says so in place of the figures, with a retry that reads it again", () => { + const failed = opened(checkoutSlo(), (model) => [ + Message.CompletedFetchStatus({ + key: model.status.key ?? "", + result: Result.fail(toLoadFailure(new Error("warehouse down"))), + }), + ]) + scene( + { update: pageUpdate, view }, + given(failed), + themeResolved(), + expectView(role("heading", { name: "Checkout API" })).toExist(), + expectView(text("Couldn't read the SLO's status")).toExist(), + expectView(text("Current SLI")).not.toExist(), + ) + expect(names(pageUpdate(failed, Message.ClickedRetryStatus()).commands)).toEqual(["FetchSloStatus"]) + }) + + test("alerting lists who each tier notifies, and is said to be off when it is", () => { + scene( + { update: pageUpdate, view }, + given(withStatus("healthy")), + themeResolved(), + expectView(within(alertingPanel, text("On-call webhook"))).toExist(), + expectView( + within(alertingPanel, text("Nobody, the incident still opens in the inbox")), + ).toExist(), + expectView(within(alertingPanel, role("switch", { name: "Burn-rate alerting" }))).toHaveAttr( + "aria-checked", + "true", + ), + ) + scene( + { update: pageUpdate, view }, + given(withStatus("healthy", searchSlo())), + themeResolved(), + expectView( + within( + alertingPanel, + text("Burn-rate alerts are off: no incident opens however fast the budget burns."), + ), + ).toExist(), + ) + }) + + test("the alerting switch PATCHes once and takes the saved SLO", () => { + const model = withStatus("healthy") + const toggling = pageUpdate(model, Message.ClickedToggleAlerting()) + expect(toggling.commands).toMatchObject([{ name: "ToggleSloAlerting", args: { enabled: false } }]) + expect(pageUpdate(toggling.model, Message.ClickedToggleAlerting()).commands ?? []).toEqual([]) + const saved = checkoutSlo({ + alerting: { enabled: false, critical_destination_ids: [], warning_destination_ids: [] }, + }) + const done = pageUpdate( + toggling.model, + Message.CompletedToggleAlerting({ result: Result.succeed(saved) }), + ) + expect(done.outMessage).toEqual( + PageOutMessage.RequestedToast({ type: "success", title: "Alerting turned off", description: "" }), + ) + scene( + { update: pageUpdate, view }, + given(done.model), + themeResolved(), + expectView(within(alertingPanel, role("switch", { name: "Burn-rate alerting" }))).toHaveAttr( + "aria-checked", + "false", + ), + ) + }) + + test("Delete confirms, deletes once and goes back to the SLOs tab", () => { + const asking = pageUpdate(withStatus("healthy"), Message.ClickedDelete()) + const deleting = pageUpdate(asking.model, Message.ClickedConfirmDelete()) + expect(names(deleting.commands)).toEqual(["DeleteSlo"]) + expect(pageUpdate(deleting.model, Message.ClickedConfirmDelete()).commands ?? []).toEqual([]) + const done = pageUpdate( + deleting.model, + Message.CompletedDelete({ result: Result.succeed(undefined) }), + ) + expect(done.outMessage).toEqual( + PageOutMessage.RequestedNavigation({ + url: slosTabHref(), + isReplace: true, + toast: { type: "success", title: "SLO deleted", description: "" }, + }), + ) + }) + + test("a member sees the SLO without edit or delete, and the switch only shows the state", () => { + const member = pageUpdate( + withStatus("healthy"), + Message.CompletedFetchSession({ orgId: ORG, grants: MEMBER }), + ).model + scene( + { update: pageUpdate, view }, + given(member), + themeResolved(), + expectView(role("link", { name: "Edit" })).not.toExist(), + expectView(role("button", { name: "Delete" })).not.toExist(), + expectView(within(alertingPanel, role("switch", { name: "Burn-rate alerting" }))).toHaveAttr( + "aria-disabled", + "true", + ), + ) + }) +}) diff --git a/apps/web/src/page/alerts/slo/update.ts b/apps/web/src/page/alerts/slo/update.ts new file mode 100644 index 0000000000..fbbe5d5180 --- /dev/null +++ b/apps/web/src/page/alerts/slo/update.ts @@ -0,0 +1,228 @@ +import { Option, Result } from "effect" +import { AsyncData, Command as FoldkitCommand } from "foldkit" +import * as Chart from "@maple/views/charts/chart" +import { LoadChartRuntime } from "@maple/views/charts/chart-runtime" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import * as Dialog from "@maple/views/ui/dialog" +import type { AppRoute } from "../../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { sloIdFromUrl, slosTabHref } from "../shared/slo-commands" +import { + DeleteSlo, + FetchSlo, + FetchSloHistory, + FetchSloSession, + FetchSloStatus, + ReadSloClock, + ToggleSloAlerting, +} from "./command" +import { Message } from "./message" +import type { Model } from "./model" + +type Return = PageReturn +type SloRoute = Extract + +export const DELETE_DIALOG_ID = "slo-delete" + +type Slot = "slo" | "status" | "history" + +const keyOf = (context: PageContext, slot: Slot, sloId: string) => + requestKey(context.orgId, `slos.${slot === "slo" ? "retrieve" : slot}`, { sloId }) + +/** Reads one of the page's three slots under `mode`; nothing for an id that does not decode. */ +const request = (model: Model, context: PageContext, slot: Slot, mode: Keyed.ReadMode): Return => { + const sloId = model.sloId + if (sloId === null) return { model } + const key = keyOf(context, slot, sloId) + switch (slot) { + case "slo": + return Option.match(Keyed.requestFor(model.slo, key, mode), { + onNone: () => ({ model }), + onSome: (slo) => ({ model: { ...model, slo }, commands: [FetchSlo({ key, sloId })] }), + }) + case "status": + return Option.match(Keyed.requestFor(model.status, key, mode), { + onNone: () => ({ model }), + onSome: (status) => ({ + model: { ...model, status }, + commands: [FetchSloStatus({ key, sloId })], + }), + }) + case "history": + return Option.match(Keyed.requestFor(model.history, key, mode), { + onNone: () => ({ model }), + onSome: (history) => ({ + model: { ...model, history }, + commands: [FetchSloHistory({ key, sloId })], + }), + }) + } +} + +/** The SLO, its status and its history are read side by side: each only needs the id. */ +const requestAll = (model: Model, context: PageContext, mode: Keyed.ReadMode): Return => { + const slo = request(model, context, "slo", mode) + const status = request(slo.model, context, "status", mode) + const history = request(status.model, context, "history", mode) + return { + model: history.model, + commands: [...(slo.commands ?? []), ...(status.commands ?? []), ...(history.commands ?? [])], + } +} + +export const init = (route: SloRoute, context: PageContext): Return => { + const model: Model = { + rawSloId: route.sloId, + sloId: Option.getOrNull(sloIdFromUrl(route.sloId)), + nowMs: null, + slo: Keyed.idle(), + status: Keyed.idle(), + history: Keyed.idle(), + destinations: null, + incidents: null, + grants: [], + isTogglingAlerting: false, + isDeleting: false, + deleteDialog: Dialog.init(DELETE_DIALOG_ID, "alert-dialog"), + budgetChart: Chart.init(), + sliChart: Chart.init(), + chartRuntime: { _tag: "Loading" }, + themeColors: null, + } + const reads = requestAll(model, context, "Load") + return { + model: reads.model, + commands: [ + ReadSloClock(), + FoldkitCommand.mapMessage(LoadChartRuntime(), (message) => + message._tag === "CompletedLoadChartRuntime" + ? Message.CompletedLoadChartRuntime() + : Message.FailedLoadChartRuntime({ reason: message.reason }), + ), + FetchSloSession({ orgId: context.orgId }), + ...(reads.commands ?? []), + ], + } +} + +export const sloOf = (model: Model) => AsyncData.getData(model.slo.data) + +const toast = (type: "success" | "error", title: string, description = ""): PageOutMessage => + PageOutMessage.RequestedToast({ type, title, description }) + +const toggleAlerting = (model: Model): Return => + Option.match(sloOf(model), { + onNone: () => ({ model }), + onSome: (slo) => + model.isTogglingAlerting + ? { model } + : { + model: { ...model, isTogglingAlerting: true }, + commands: [ToggleSloAlerting({ sloId: slo.id, enabled: !slo.alerting.enabled })], + }, + }) + +/** Runs the delete dialog; `Closed` (Escape, Cancel) is ignored while the delete runs. */ +const updateDeleteDialog = (model: Model, step: ReturnType): Return => ({ + model: { ...model, deleteDialog: step.model }, + commands: FoldkitCommand.mapMessages(step.commands ?? [], (message) => + Message.GotDeleteDialogMessage({ message }), + ), +}) + +const confirmDelete = (model: Model): Return => + model.isDeleting || model.sloId === null + ? { model } + : { model: { ...model, isDeleting: true }, commands: [DeleteSlo({ sloId: model.sloId })] } + +const updateChart = ( + chart: Chart.Model, + message: Chart.Message, + toMessage: (message: Chart.Message) => Message, +) => { + const next = Chart.update(chart, message) + return { + chart: next.model, + commands: FoldkitCommand.mapMessages(next.commands ?? [], toMessage), + } +} + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + ReadClock: ({ nowMs }) => ({ model: { ...model, nowMs } }), + CompletedFetchSlo: ({ key, result }) => ({ + model: { ...model, slo: Keyed.accept(model.slo, key, result) }, + }), + CompletedFetchStatus: ({ key, result }) => ({ + model: { ...model, status: Keyed.accept(model.status, key, result) }, + }), + CompletedFetchHistory: ({ key, result }) => ({ + model: { ...model, history: Keyed.accept(model.history, key, result) }, + }), + ClickedRetry: () => requestAll(model, context, "Supersede"), + ClickedRetryStatus: () => request(model, context, "status", "Supersede"), + ClickedRetryHistory: () => request(model, context, "history", "Supersede"), + UpdatedDestinations: ({ orgId, rows }) => + orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, + UpdatedIncidents: ({ orgId, rows }) => + orgId === context.orgId ? { model: { ...model, incidents: rows } } : { model }, + CompletedFetchSession: ({ orgId, grants }) => + orgId === context.orgId ? { model: { ...model, grants } } : { model }, + ClickedToggleAlerting: () => toggleAlerting(model), + CompletedToggleAlerting: ({ result }) => { + const settled = { ...model, isTogglingAlerting: false } + return Result.match(result, { + // The PATCH answers with the SLO as saved: it is the page's SLO now. + onSuccess: (slo): Return => ({ + model: { ...settled, slo: { ...settled.slo, data: AsyncData.Success({ data: slo }) } }, + outMessage: toast( + "success", + slo.alerting.enabled ? "Alerting turned on" : "Alerting turned off", + ), + }), + onFailure: (failure): Return => ({ + model: settled, + outMessage: toast("error", "Couldn't update alerting", failure.message), + }), + }) + }, + ClickedDelete: () => updateDeleteDialog(model, Dialog.open(model.deleteDialog)), + GotDeleteDialogMessage: ({ message: child }) => + model.isDeleting + ? { model } + : updateDeleteDialog(model, Dialog.update(model.deleteDialog, child)), + ClickedConfirmDelete: () => confirmDelete(model), + CompletedDelete: ({ result }) => + Result.match(result, { + onSuccess: (): Return => ({ + model: { ...model, isDeleting: false }, + outMessage: PageOutMessage.RequestedNavigation({ + url: slosTabHref(), + isReplace: true, + toast: { type: "success", title: "SLO deleted", description: "" }, + }), + }), + onFailure: (failure): Return => ({ + model: { ...model, isDeleting: false }, + outMessage: toast("error", "Couldn't delete the SLO", failure.message), + }), + }), + CompletedLoadChartRuntime: () => ({ model: { ...model, chartRuntime: { _tag: "Ready" } } }), + FailedLoadChartRuntime: ({ reason }) => ({ + model: { ...model, chartRuntime: { _tag: "Failed", reason } }, + }), + ResolvedThemeColors: ({ colors }) => ({ model: { ...model, themeColors: colors } }), + GotBudgetChartMessage: ({ message: child }) => { + const next = updateChart(model.budgetChart, child, (message) => + Message.GotBudgetChartMessage({ message }), + ) + return { model: { ...model, budgetChart: next.chart }, commands: next.commands } + }, + GotSliChartMessage: ({ message: child }) => { + const next = updateChart(model.sliChart, child, (message) => + Message.GotSliChartMessage({ message }), + ) + return { model: { ...model, sliChart: next.chart }, commands: next.commands } + }, + }) diff --git a/apps/web/src/page/alerts/slo/view-panels.ts b/apps/web/src/page/alerts/slo/view-panels.ts new file mode 100644 index 0000000000..b8ad83fa99 --- /dev/null +++ b/apps/web/src/page/alerts/slo/view-panels.ts @@ -0,0 +1,451 @@ +import type { AlertSeverity } from "@maple/domain/http" +import type { AlertIncidentRow } from "@maple/web-data/collections/alerts" +import { Option, Array as Arr } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import * as AreaChart from "@maple/views/charts/area-chart" +import type * as Chart from "@maple/views/charts/chart" +import type { TimeseriesAxisSpec } from "@maple/views/charts/chart-spec" +import { resolveColor, type ThemeColors } from "@maple/views/charts/theme-colors" +import { chartFrameClass } from "@maple/views/ui/chart-frame" +import { chartSkeleton } from "@maple/views/ui/chart-skeleton" +import { emptyMessage } from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { listRow } from "@maple/views/ui/list-row" +import { panel, panelBody, panelChartBody, panelHeader } from "@maple/views/ui/panel" +import { stripClass } from "@maple/views/ui/rule" +import { hstack, vstack } from "@maple/views/ui/stack" +import { statusDot } from "@maple/views/ui/status-dot" +import { switchControl } from "@maple/views/ui/switch" +import { typography } from "@maple/views/ui/typography" +import { relativeTime } from "../../kit/relative-time" +import { statRail, statRailItem, statRailLoading } from "../../kit/stat-rail" +import { severityBadge } from "../shared/badges" +import { incidentHref } from "../shared/links" +import { + burnOf, + burnTone, + budgetTone, + formatBudget, + formatBurn, + formatEvents, + formatSli, + formatTarget, + formatWindow, + type Slo, + type SloStatus, + sliTone, + sloIncidents, + tierStates, + windowLabel, +} from "../shared/slo-derive" +import { toneText } from "../shared/slo-view" +import { isoMs } from "../shared/time" +import { Message } from "./message" +import { canManage, type Model } from "./model" + +// The SLO page's panels: the figures, the two charts, the burn windows, alerting and incidents. + +type H = HtmlBuilder + +/** The tokens the charts resolve: their series and chrome, the 0% line and the target line. */ +export const CHART_TOKENS = AreaChart.AREA_CHART_TOKENS + +export type ChartContext = Readonly<{ theme: ThemeColors; timeZone: string; nowMs: number }> + +const INCIDENT_ROWS = 5 + +// FIGURES + +/** Current SLI against the target, the budget left and the events the window counted. */ +export const figures = (h: H, model: Model, slo: Slo): Html => { + const data = model.status.data + if (AsyncData.isFailure(data)) + // The error card stands on its own: a panel's overflow would clip its icon and button. + return errorState(h, { + failure: data.error, + title: "Couldn't read the SLO's status", + onRetry: Message.ClickedRetryStatus(), + className: "py-8", + }) + return Option.match(AsyncData.getData(data), { + onNone: () => statRailLoading(h, 3, 3), + onSome: (status) => { + const bad = Math.max(0, status.total_events - status.good_events) + return statRail(h, { columns: 3 }, [ + statRailItem(h, { + eyebrow: "Current SLI", + value: formatSli(status.sli), + tone: sliTone(slo, status.sli), + compact: true, + subline: `Target ${formatTarget(slo)} over ${formatWindow(slo.window_days)}`, + }), + statRailItem(h, { + eyebrow: "Error budget left", + value: formatBudget(status.budget_remaining), + tone: budgetTone(status.budget_remaining), + compact: true, + subline: + status.budget_remaining <= 0 + ? `Spent: ${formatBudget(-status.budget_remaining)} over budget` + : `Of the ${slo.window_days}-day budget`, + }), + statRailItem(h, { + eyebrow: "Events counted", + value: formatEvents(status.total_events), + compact: true, + subline: + status.total_events === 0 + ? "No entry-point spans in this window" + : `${formatEvents(bad)} bad, ${formatEvents(status.good_events)} good`, + }), + ]) + }, + }) +} + +// CHARTS + +type Row = Readonly> + +const thresholdAxis = (value: number, color: string, label: string, fit: boolean): TimeseriesAxisSpec => ({ + thresholds: [{ value, color, label }], + logScale: false, + softMin: null, + softMax: null, + fitYAxisToData: fit, +}) + +const chartSlot = ( + h: H, + options: Readonly<{ + slotId: string + chartId: string + chart: Chart.Model + rows: ReadonlyArray + axis: TimeseriesAxisSpec + context: ChartContext + toMessage: (message: Chart.Message) => Message + }>, +): Html => + h.div( + [h.Class("h-56")], + [ + h.submodel({ + slotId: options.slotId, + model: options.chart, + view: AreaChart.view, + toParentMessage: options.toMessage, + viewInputs: { + chartId: options.chartId, + data: options.rows, + theme: options.context.theme, + unit: "percent", + timeZone: options.context.timeZone, + nowMs: options.context.nowMs, + className: chartFrameClass("fill"), + showTooltip: true, + legend: "hidden", + mark: "line", + axis: options.axis, + }, + }), + ], + ) + +/** The history's state as a panel body: a skeleton, the failure, nothing yet, or the chart. */ +const historyBody = ( + h: H, + model: Model, + context: ChartContext | null, + draw: (points: NonNullable>, context: ChartContext) => Html, +): Html => { + const data = model.history.data + if (AsyncData.isFailure(data)) + return errorState(h, { + failure: data.error, + title: "Couldn't load the budget history", + onRetry: Message.ClickedRetryHistory(), + className: "py-8", + }) + const points = historyPoints(model) + if (points === null || context === null) + return model.chartRuntime._tag === "Failed" + ? emptyMessage(h, { className: "py-16 text-center" }, [ + "The chart couldn't load. Reload the page to try again.", + ]) + : h.div([h.Class("h-56")], [chartSkeleton(h, "line")]) + return points.length === 0 + ? emptyMessage(h, { className: "py-16 text-center" }, ["No events in this window"]) + : draw(points, context) +} + +const historyPoints = (model: Model) => + Option.getOrNull(Option.map(AsyncData.getData(model.history.data), (history) => history.points)) + +/** Budget left over the window, hour by hour, against the 0% line it must stay above. */ +export const budgetPanel = (h: H, model: Model, slo: Slo, context: ChartContext | null): Html => + panel(h, { attributes: [h.AriaLabel("Error budget")] }, [ + panelHeader(h, { + title: "Error budget", + divided: false, + action: typography(h, { size: "xs", tone: "muted" }, [ + `Budget left over the trailing ${formatWindow(slo.window_days)}, hourly`, + ]), + }), + panelChartBody(h, [ + historyBody(h, model, context, (points, ready) => + chartSlot(h, { + slotId: "slo-budget-chart", + chartId: "slo-budget", + chart: model.budgetChart, + rows: Arr.map(points, (point) => ({ + bucket: point.at, + "Budget left": point.budget_remaining, + })), + axis: thresholdAxis(0, resolveColor(ready.theme, "var(--severity-error)"), "0%", false), + context: ready, + toMessage: (message) => Message.GotBudgetChartMessage({ message }), + }), + ), + ]), + ]) + +/** The SLI of each hour against the target; hours without events leave a gap. */ +export const sliPanel = (h: H, model: Model, slo: Slo, context: ChartContext | null): Html => + panel(h, { attributes: [h.AriaLabel("SLI")] }, [ + panelHeader(h, { + title: "SLI", + divided: false, + action: typography(h, { size: "xs", tone: "muted" }, [`Hourly, target ${formatTarget(slo)}`]), + }), + panelChartBody(h, [ + historyBody(h, model, context, (points, ready) => + chartSlot(h, { + slotId: "slo-sli-chart", + chartId: "slo-sli", + chart: model.sliChart, + rows: Arr.flatMap(points, (point) => + point.sli === null ? [] : [{ bucket: point.at, SLI: point.sli }], + ), + axis: thresholdAxis( + slo.target_percent / 100, + resolveColor(ready.theme, "var(--severity-warn)"), + `Target ${formatTarget(slo)}`, + true, + ), + context: ready, + toMessage: (message) => Message.GotSliChartMessage({ message }), + }), + ), + ]), + ]) + +// BURN RATES + +const BURN_WINDOWS = [5, 30, 60, 360] as const + +const SEVERITY_LABEL = { critical: "Critical", warning: "Warning" } as const satisfies Record< + AlertSeverity, + string +> +const TIER_ROLE = { critical: "pages", warning: "opens a ticket" } as const satisfies Record< + AlertSeverity, + string +> + +const burnWindowRow = (h: H, status: SloStatus, minutes: number): Html => { + const rate = burnOf(status, minutes) + const window = Arr.findFirst(status.burn_windows, (entry) => entry.window_minutes === minutes) + return h.div( + [h.Key(String(minutes)), h.Class(stripClass("row"))], + [ + hstack(h, { gap: "3", justify: "between" }, [ + typography(h, { size: "sm" }, [`Last ${windowLabel(minutes)}`]), + hstack(h, { gap: "3" }, [ + typography(h, { size: "xs", tone: "muted", mono: true, numeric: true }, [ + Option.match(window, { + onNone: () => "–", + onSome: (entry) => `${formatEvents(entry.total_events)} events`, + }), + ]), + typography( + h, + { + size: "sm", + mono: true, + numeric: true, + weight: "medium", + tone: toneText(burnTone(rate)), + }, + [formatBurn(rate)], + ), + ]), + ]), + ], + ) +} + +const tierRow = (h: H, status: SloStatus, state: ReturnType[number]): Html => { + const { tier, isFiring } = state + const long = burnOf(status, tier.longWindowMinutes) + const short = burnOf(status, tier.shortWindowMinutes) + return h.div( + [h.Key(tier.severity), h.Class(stripClass("row"))], + [ + vstack(h, { gap: "1.5" }, [ + hstack(h, { gap: "2", justify: "between" }, [ + hstack(h, { gap: "2" }, [ + severityBadge(h, tier.severity), + typography(h, { size: "xs", tone: "muted" }, [ + `${tier.factor}x over ${windowLabel(tier.longWindowMinutes)} and ${windowLabel(tier.shortWindowMinutes)}`, + ]), + ]), + isFiring + ? hstack(h, { gap: "1.5", as: "span", className: "shrink-0" }, [ + statusDot(h, { tone: tier.severity === "critical" ? "crit" : "warn" }), + typography( + h, + { + size: "xs", + weight: "medium", + tone: tier.severity === "critical" ? "error" : "warning", + }, + ["Would fire"], + ), + ]) + : typography(h, { size: "xs", tone: "muted" }, ["Quiet"]), + ]), + typography(h, { size: "xs", tone: "muted", mono: true, numeric: true, as: "p" }, [ + `${windowLabel(tier.longWindowMinutes)} at ${formatBurn(long)}, ${windowLabel(tier.shortWindowMinutes)} at ${formatBurn(short)}. ${SEVERITY_LABEL[tier.severity]} ${TIER_ROLE[tier.severity]} when both reach ${tier.factor}x.`, + ]), + ]), + ], + ) +} + +/** Each window's burn rate, then the two tiers against their factors and whether they would fire. */ +export const burnPanel = (h: H, model: Model): Html => + panel(h, { attributes: [h.AriaLabel("Burn rate")] }, [ + panelHeader(h, { title: "Burn rate" }), + Option.match(AsyncData.getData(model.status.data), { + onNone: () => + AsyncData.isFailure(model.status.data) + ? emptyMessage(h, { className: "px-4 py-6" }, ["No burn rates without the status"]) + : panelBody(h, [chartSkeleton(h, "bar")]), + onSome: (status) => + h.div( + [], + [ + ...Arr.map(BURN_WINDOWS, (minutes) => burnWindowRow(h, status, minutes)), + ...Arr.map(tierStates(status), (state) => tierRow(h, status, state)), + ], + ), + }), + ]) + +// ALERTING + +const destinationNames = (model: Model, ids: ReadonlyArray): string => { + if (ids.length === 0) return "Nobody, the incident still opens in the inbox" + const fallback = model.destinations === null ? "Destination" : "Unavailable destination" + return Arr.join( + Arr.map(ids, (id) => + Option.getOrElse( + Option.map( + Arr.findFirst(model.destinations ?? [], (row) => row.id === id), + (row) => row.name, + ), + () => fallback, + ), + ), + ", ", + ) +} + +const tierDestinations = (h: H, label: string, names: string): Html => + h.div( + [h.Class(stripClass("row"))], + [ + vstack(h, { gap: "0.5" }, [ + typography(h, { size: "xs", tone: "muted" }, [label]), + typography(h, { size: "sm" }, [names]), + ]), + ], + ) + +/** Whether the burn-rate alert runs, with its switch for managers, and who each tier notifies. */ +export const alertingPanel = (h: H, model: Model, slo: Slo): Html => + panel(h, { attributes: [h.AriaLabel("Alerting")] }, [ + panelHeader(h, { + title: "Alerting", + action: hstack(h, { gap: "2" }, [ + typography(h, { size: "xs", tone: "muted" }, [slo.alerting.enabled ? "On" : "Off"]), + ...switchControl(h, { + id: "slo-alerting", + inputId: "slo-alerting-input", + isChecked: slo.alerting.enabled, + isDisabled: !canManage(model) || model.isTogglingAlerting, + ariaLabel: "Burn-rate alerting", + onToggle: Message.ClickedToggleAlerting(), + }), + ]), + }), + ...(slo.alerting.enabled + ? [ + tierDestinations( + h, + "Critical notifies", + destinationNames(model, slo.alerting.critical_destination_ids), + ), + tierDestinations( + h, + "Warning notifies", + destinationNames(model, slo.alerting.warning_destination_ids), + ), + ] + : [ + emptyMessage(h, { className: "px-4 py-4" }, [ + "Burn-rate alerts are off: no incident opens however fast the budget burns.", + ]), + ]), + ]) + +// INCIDENTS + +const incidentRow = (h: H, row: AlertIncidentRow, nowMs: number, timeZone: string): Html => + listRow(h, { + href: incidentHref(row.id), + divided: true, + density: "compact", + leading: statusDot(h, { tone: row.status === "open" ? "crit" : "neutral" }), + title: row.status === "open" ? "Open" : "Resolved", + meta: relativeTime(h, { + value: isoMs(row.first_triggered_at), + nowMs, + timeZone, + tooltip: "title", + prefix: "Opened", + }), + trailing: severityBadge(h, row.severity === "critical" ? "critical" : "warning"), + }) + +/** The incidents of the SLO's burn-rate rule, newest first, each linking to its page. */ +export const incidentsPanel = (h: H, model: Model, slo: Slo, timeZone: string): Html => { + const rows = sloIncidents(slo, model.incidents ?? []) + return panel(h, { attributes: [h.AriaLabel("Incidents")] }, [ + panelHeader(h, { title: "Incidents" }), + model.incidents === null + ? panelBody(h, [chartSkeleton(h, "bar")]) + : rows.length === 0 + ? emptyMessage(h, { className: "px-4 py-4" }, [ + slo.alerting.enabled ? "No incidents yet" : "No incidents: alerting is off", + ]) + : h.div( + [], + Arr.map(rows.slice(0, INCIDENT_ROWS), (row) => + incidentRow(h, row, model.nowMs ?? 0, timeZone), + ), + ), + ]) +} diff --git a/apps/web/src/page/alerts/slo/view.ts b/apps/web/src/page/alerts/slo/view.ts new file mode 100644 index 0000000000..a91d2d25f3 --- /dev/null +++ b/apps/web/src/page/alerts/slo/view.ts @@ -0,0 +1,244 @@ +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { isChartRuntimeReady } from "@maple/views/charts/chart-runtime" +import { ResolveThemeColors } from "@maple/views/charts/theme-colors" +import { alert, alertAction, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { button, linkButton } from "@maple/views/ui/button" +import { confirmDialog } from "@maple/views/ui/confirm-dialog" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import { skeleton } from "@maple/views/ui/skeleton" +import { vstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import { AppRoute, hrefOf } from "../../../route" +import type { PageViewInputs } from "../../contract" +import { dashboardPage } from "../../kit/dashboard-page" +import { detailActions, detailTitle, detailTitleSkeleton } from "../../kit/detail-header" +import { resourceNotFound } from "../../kit/resource-not-found" +import { severityBadge } from "../shared/badges" +import { incidentHref } from "../shared/links" +import { editSloHref, SLO_NOT_FOUND_CODE, slosTabHref } from "../shared/slo-commands" +import { + burnOf, + firingTier, + formatBudget, + formatBurn, + formatTarget, + formatWindow, + openIncident, + scopeLine, + sliDescription, + sliLabel, + type Slo, + type SloStatus, + windowLabel, +} from "../shared/slo-derive" +import { Message } from "./message" +import { canManage, type Model } from "./model" +import { sloOf } from "./update" +import { + alertingPanel, + budgetPanel, + burnPanel, + CHART_TOKENS, + type ChartContext, + figures, + incidentsPanel, + sliPanel, +} from "./view-panels" + +// `/alerts/slos/$sloId`: the header, a callout while the budget burns or is spent, then the panels. + +type H = HtmlBuilder + +const headerActions = (h: H, model: Model, slo: Slo): Html => + detailActions( + h, + canManage(model) + ? [ + linkButton(h, { size: "sm", variant: "outline", href: editSloHref(slo.id) }, [ + icon(h, "PencilIcon"), + "Edit", + ]), + button( + h, + { + size: "sm", + variant: "destructive-outline", + attributes: [h.OnClick(Message.ClickedDelete())], + }, + [icon(h, "TrashIcon"), "Delete"], + ), + ] + : [], + ) + +const statusOf = (model: Model): Option.Option => AsyncData.getData(model.status.data) + +/** A firing tier leads the page: what burns, at which rates, and the incident it opened. */ +const callout = (h: H, model: Model, slo: Slo, status: SloStatus): Html | null => { + const incident = openIncident(slo, model.incidents ?? []) + const action = Option.match(incident, { + onNone: () => [], + onSome: (row) => [ + alertAction(h, [ + linkButton(h, { size: "sm", variant: "outline", href: incidentHref(row.id) }, [ + "Open incident", + ]), + ]), + ], + }) + return Option.match(firingTier(status), { + onSome: (tier) => + alert(h, { variant: tier.severity === "critical" ? "crit" : "warn" }, [ + icon(h, "FireIcon"), + alertTitle(h, [ + `Burning the error budget at ${formatBurn(burnOf(status, tier.longWindowMinutes))} over ${windowLabel(tier.longWindowMinutes)}`, + ]), + alertDescription(h, [ + `At this rate the ${slo.window_days}-day budget runs out early. The ${tier.severity} tier fires at ${tier.factor}x over both ${windowLabel(tier.longWindowMinutes)} and ${windowLabel(tier.shortWindowMinutes)}.`, + ]), + ...action, + ]), + onNone: () => + status.budget_remaining <= 0 + ? alert(h, { variant: "crit" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Error budget spent"]), + alertDescription(h, [ + `The last ${formatWindow(slo.window_days)} saw more bad events than ${formatTarget(slo)} allows: ${formatBudget(-status.budget_remaining)} over budget. It recovers as bad hours leave the window.`, + ]), + ...action, + ]) + : null, + }) +} + +const serviceHref = (slo: Slo): string => + hrefOf(AppRoute.Service({ serviceName: slo.service_name, search: {} })) + +const body = (h: H, model: Model, slo: Slo, timeZone: string): ReadonlyArray => { + const context: ChartContext | null = + model.themeColors !== null && isChartRuntimeReady(model.chartRuntime) + ? { theme: model.themeColors, timeZone, nowMs: model.nowMs ?? 0 } + : null + const lead = Option.getOrNull(Option.map(statusOf(model), (status) => callout(h, model, slo, status))) + return [ + ...(lead === null ? [] : [lead]), + figures(h, model, slo), + h.div( + [h.Class("grid gap-4 lg:grid-cols-[minmax(0,1fr)_340px]")], + [ + vstack(h, { gap: "4", shrink: true }, [ + budgetPanel(h, model, slo, context), + sliPanel(h, model, slo, context), + ]), + vstack(h, { gap: "4", shrink: true }, [ + burnPanel(h, model), + alertingPanel(h, model, slo), + incidentsPanel(h, model, slo, timeZone), + ]), + ], + ), + ] +} + +const loadingPage = (h: H): Html => + dashboardPage(h, { titleContent: detailTitleSkeleton(h) }, [ + skeleton(h, "h-24 w-full"), + skeleton(h, "h-64 w-full"), + ]) + +const notFound = (h: H, model: Model): Html => + dashboardPage(h, {}, [ + resourceNotFound(h, { + title: "SLO not found", + description: ["No SLO has the id ", h.code([h.Class("font-mono")], [model.rawSloId]), "."], + back: { href: slosTabHref(), label: "Back to SLOs" }, + }), + ]) + +const deleteDialog = (h: H, model: Model): Html => + confirmDialog(h, { + slotId: "slo-delete", + model: model.deleteDialog, + toParentMessage: (message) => Message.GotDeleteDialogMessage({ message }), + title: `Delete ${Option.getOrElse( + Option.map(sloOf(model), (slo) => slo.name), + () => "this SLO", + )}?`, + description: "Its burn-rate alert rule goes with it and open incidents close. This cannot be undone.", + confirmLabel: "Delete SLO", + isPending: model.isDeleting, + onConfirm: Message.ClickedConfirmDelete(), + }) + +const page = (h: H, model: Model, inputs: PageViewInputs): Html => { + if (model.sloId === null) return notFound(h, model) + const data = model.slo.data + if (AsyncData.isFailure(data)) + return data.error.code === SLO_NOT_FOUND_CODE + ? notFound(h, model) + : dashboardPage(h, {}, [ + errorState(h, { + failure: data.error, + title: "Couldn't load the SLO", + onRetry: Message.ClickedRetry(), + }), + ]) + return Option.match(sloOf(model), { + onNone: () => loadingPage(h), + onSome: (slo) => { + const tier = Option.flatMap(statusOf(model), firingTier) + const title = { + kind: ["SLO", sliLabel(slo.sli)], + title: [slo.name], + meta: [ + typography(h, { size: "xs", tone: "muted", mono: true }, [ + h.a( + [h.Href(serviceHref(slo)), h.Class("hover:underline active:underline")], + [scopeLine(slo)], + ), + ` · ${formatTarget(slo)} over ${formatWindow(slo.window_days)} · ${sliDescription(slo.sli)}`, + ]), + ...(slo.description === null || slo.description === "" + ? [] + : [ + typography( + h, + { size: "sm", tone: "soft", as: "p", className: "basis-full" }, + [slo.description], + ), + ]), + ], + } + return dashboardPage( + h, + { + titleContent: Option.match(tier, { + onNone: () => detailTitle(h, title), + onSome: (firing) => + detailTitle(h, { ...title, adornment: severityBadge(h, firing.severity) }), + }), + actions: headerActions(h, model, slo), + gap: "md", + }, + body(h, model, slo, inputs.context.timeZone), + ) + }, + }) +} + +export const view = Submodel.defineView((model, inputs, h) => + h.div( + [h.Class("contents"), h.OnMount(ResolveThemeColors({ tokens: CHART_TOKENS }))], + [page(h, model, inputs), deleteDialog(h, model)], + ), +) + +/** The H1 names the SLO, so the trail stops at the tab. */ +export const breadcrumbsOf = () => [ + { label: "Alerts", href: "/alerts" }, + { label: "SLOs", href: slosTabHref() }, +] diff --git a/apps/web/src/page/alerts/uptime-form/command.ts b/apps/web/src/page/alerts/uptime-form/command.ts index 00d75c9a34..5f08db7058 100644 --- a/apps/web/src/page/alerts/uptime-form/command.ts +++ b/apps/web/src/page/alerts/uptime-form/command.ts @@ -1,10 +1,9 @@ import { UptimeMonitorId } from "@maple/domain" -import { getTracesFacets } from "@maple/web-data/warehouse/traces" -import { Effect, Schema, Array as Arr } from "effect" +import { Effect, Schema } from "effect" import { Command } from "foldkit" -import { currentWindow } from "../../../data/time-range" import { readSupportedTimeZones } from "../../../platform/time-zone" import { readGrants } from "../../../data/session-tenant" +import { recentServiceNames } from "../shared/services" import { retrieveMonitor, saveMonitor, testMonitorConfig } from "../shared/uptime-commands" import { Message } from "./message" @@ -21,12 +20,7 @@ export const FetchExistingMonitor = Command.define("FetchUptimeFormMonitor", { /** The traces facets of the last day: the services a monitor can belong to. Empty on failure. */ export const FetchFormServices = Command.define("FetchUptimeFormServices", { messages: [Message.CompletedFetchServices], - execute: currentWindow({ preset: "24h", snap: true }).pipe( - Effect.flatMap((window) => getTracesFacets({ data: window })), - Effect.map((response) => Arr.map(response.data.services, (row) => row.name)), - Effect.orElseSucceed((): ReadonlyArray => []), - Effect.map((services) => Message.CompletedFetchServices({ services })), - ), + execute: Effect.map(recentServiceNames, (services) => Message.CompletedFetchServices({ services })), }) export const ReadFormTimeZones = Command.define("ReadUptimeFormTimeZones", { diff --git a/apps/web/src/page/alerts/uptime-form/view-fields.ts b/apps/web/src/page/alerts/uptime-form/view-fields.ts index b1b35f5b9a..9bb25f985c 100644 --- a/apps/web/src/page/alerts/uptime-form/view-fields.ts +++ b/apps/web/src/page/alerts/uptime-form/view-fields.ts @@ -1,7 +1,6 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" -import { heading } from "@maple/views/ui/heading" import { icon } from "@maple/views/ui/icon" import { iconButton } from "@maple/views/ui/icon-button" import { input } from "@maple/views/ui/input" @@ -10,6 +9,7 @@ import { label } from "@maple/views/ui/label" import { nativeOption, nativeSelect } from "@maple/views/ui/native-select" import { segmentedSelect } from "@maple/views/ui/segmented-select" import { textarea } from "@maple/views/ui/textarea" +import { formSection } from "../../kit/form-section" import { ASSERTION_OPS, ASSERTION_TYPES, @@ -125,21 +125,7 @@ export const selectField = ( /** One row of the form card: what it is on the left, its controls on the right. */ const section = (h: H, title: string, hint: string | undefined, children: ReadonlyArray): Html => - h.section( - [h.AriaLabel(title), h.Class("grid gap-x-8 gap-y-2 px-4 py-4 sm:grid-cols-[136px_minmax(0,1fr)]")], - [ - h.div( - [h.Class("flex flex-col gap-0.5 sm:pt-2")], - [ - heading(h, { level: 3, variant: "subsection" }, [title]), - ...(hint === undefined - ? [] - : [typography(h, { size: "xs", tone: "muted", as: "p" }, [hint])]), - ], - ), - h.div([h.Class("flex min-w-0 flex-col gap-3")], children), - ], - ) + formSection(h, { title, hint }, children) /** The monitor's name, its own row so every kind places it the same way. */ export const nameSection = (h: H, model: Model, placeholder: string): Html => diff --git a/apps/web/src/page/alerts/uptime-form/view-kinds.ts b/apps/web/src/page/alerts/uptime-form/view-kinds.ts index 657ed69102..c2261d2b70 100644 --- a/apps/web/src/page/alerts/uptime-form/view-kinds.ts +++ b/apps/web/src/page/alerts/uptime-form/view-kinds.ts @@ -1,16 +1,12 @@ import type { Html } from "foldkit/html" -import { icon } from "@maple/views/ui/icon" -import { iconButton } from "@maple/views/ui/icon-button" -import * as MenuView from "@maple/views/ui/menu-view" -import { panel } from "@maple/views/ui/panel" -import { pressable } from "@maple/views/ui/pressable" import { segmentedSelect } from "@maple/views/ui/segmented-select" import { EveryUnit, GraceUnit, RecordType, ScheduleMode } from "./form" import { Message } from "./message" import type { Model } from "./model" import { type H, nameSection, section, selectField, textField } from "./view-fields" import { hstack } from "@maple/views/ui/stack" -import { Array as Arr, Option } from "effect" +import { Array as Arr } from "effect" +import { destinationPicker } from "../../kit/destination-picker" // The TCP, DNS and heartbeat sections, and the Service and Notify sections every kind shares. @@ -191,80 +187,14 @@ export const serviceSection = (h: H, model: Model): Html => { } /** Who hears about it: the chosen destinations and a menu of the rest. */ -export const notifySection = (h: H, model: Model): Html => { - const rows = model.destinations ?? [] - // `null` until destinations sync: a neutral name, not "unavailable", for the saved ones. - const fallback = model.destinations === null ? "Destination" : "Unavailable destination" - const selected = Arr.map(model.form.destinationIds, (id) => ({ - id, - name: Arr.findFirst(rows, (row) => row.id === id).pipe( - Option.flatMapNullishOr((found) => found.name), - Option.getOrElse(() => fallback), - ), - })) - const available = Arr.filter(rows, (row) => !Arr.some(model.form.destinationIds, (id) => id === row.id)) - return section(h, "Notify", undefined, [ - panel(h, { className: "divide-y shadow-none" }, [ - ...Arr.map(selected, (destination) => - h.div( - [h.Key(destination.id), h.Class("flex items-center gap-2 px-3 py-2")], - [ - icon(h, "BellIcon", { className: "text-muted-foreground" }), - h.span([h.Class("min-w-0 flex-1 truncate text-sm")], [destination.name]), - iconButton( - h, - { - label: `Stop notifying ${destination.name}`, - className: "text-muted-foreground", - attributes: [h.OnClick(Message.RemovedDestination({ id: destination.id }))], - }, - [icon(h, "XmarkIcon")], - ), - ], - ), - ), - ...(selected.length === 0 - ? [ - h.p( - [h.Class("px-3 py-2.5 text-sm text-muted-foreground")], - ["Nobody yet. The incident still opens in the inbox."], - ), - ] - : []), - ...(available.length === 0 - ? [] - : [ - h.submodel({ - slotId: model.destinationMenu.id, - model: model.destinationMenu, - view: MenuView.view, - viewInputs: { - entries: Arr.map(available, (row): MenuView.MenuEntry => ({ - _tag: "Item", - value: row.id, - label: row.name, - })), - align: "start", - className: "w-72", - toView: ({ trigger, withTrigger }) => - h.span( - [h.Class("contents")], - withTrigger( - pressable( - h, - { - className: - "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", - attributes: trigger, - }, - [icon(h, "PlusIcon", { size: 14 }), "Add destination"], - ), - ), - ), - }, - toParentMessage: (message) => Message.GotDestinationMenuMessage({ message }), - }), - ]), - ]), +export const notifySection = (h: H, model: Model): Html => + section(h, "Notify", undefined, [ + destinationPicker(h, { + rows: model.destinations, + selectedIds: model.form.destinationIds, + menu: model.destinationMenu, + toMenuMessage: (message) => Message.GotDestinationMenuMessage({ message }), + onRemove: (id) => Message.RemovedDestination({ id }), + emptyText: "Nobody yet. The incident still opens in the inbox.", + }), ]) -} diff --git a/apps/web/src/page/alerts/uptime-form/view.ts b/apps/web/src/page/alerts/uptime-form/view.ts index 0aa9b2ce0f..a6fe725887 100644 --- a/apps/web/src/page/alerts/uptime-form/view.ts +++ b/apps/web/src/page/alerts/uptime-form/view.ts @@ -5,7 +5,6 @@ import { AsyncData, Submodel } from "foldkit" import type { Html } from "foldkit/html" import { button, linkButton } from "@maple/views/ui/button" import { errorState } from "@maple/views/ui/error-state" -import { heading } from "@maple/views/ui/heading" import { icon, type IconName } from "@maple/views/ui/icon" import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" import { panel, panelHeader } from "@maple/views/ui/panel" @@ -13,6 +12,7 @@ import { segmentedSelect } from "@maple/views/ui/segmented-select" import { skeleton } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" +import { formActionBar, formCard, formSection } from "../../kit/form-section" import { failureMessage, type LoadFailure } from "@maple/views/data/load-failure" import { LIMIT_REACHED_CODE, monitorHref, uptimeTabHref } from "../shared/uptime-commands" import { formatMs } from "../shared/uptime-derive" @@ -57,35 +57,31 @@ const KINDS: ReadonlyArray<{ kind: MonitorKind; label: string; icon: IconName; d /** The kind as a segmented control; the picked kind's sentence sits under it. */ const kindPicker = (h: H, model: Model): Html => - h.section( - [h.AriaLabel("Type"), h.Class("grid gap-x-8 gap-y-2 px-4 py-4 sm:grid-cols-[136px_minmax(0,1fr)]")], - [ - heading(h, { level: 3, variant: "subsection", className: "sm:pt-1.5" }, ["Type"]), - vstack(h, { gap: "2", shrink: true }, [ - segmentedSelect(h, { - ariaLabel: "Monitor type", - size: "sm", - // Four labelled kinds outgrow a phone column, so the track wraps like the old picker. - className: "w-fit flex-wrap", - value: model.form.kind, - onChange: (kind) => Message.PickedKind({ kind }), - // Editing keeps the kind: the others stay visible but can't be picked. - options: Arr.map(KINDS, (option) => ({ - value: option.kind, - label: option.label, - icon: icon(h, option.icon, { size: 14 }), - isDisabled: model.editingId !== null && option.kind !== model.form.kind, - })), - }), - typography(h, { size: "xs", tone: "muted", as: "p" }, [ - Arr.findFirst(KINDS, (option) => option.kind === model.form.kind).pipe( - Option.flatMapNullishOr((found) => found.description), - Option.getOrElse(() => ""), - ), - ]), + formSection(h, { title: "Type", isCompact: true }, [ + vstack(h, { gap: "2", shrink: true }, [ + segmentedSelect(h, { + ariaLabel: "Monitor type", + size: "sm", + // Four labelled kinds outgrow a phone column, so the track wraps like the old picker. + className: "w-fit flex-wrap", + value: model.form.kind, + onChange: (kind) => Message.PickedKind({ kind }), + // Editing keeps the kind: the others stay visible but can't be picked. + options: Arr.map(KINDS, (option) => ({ + value: option.kind, + label: option.label, + icon: icon(h, option.icon, { size: 14 }), + isDisabled: model.editingId !== null && option.kind !== model.form.kind, + })), + }), + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + Arr.findFirst(KINDS, (option) => option.kind === model.form.kind).pipe( + Option.flatMapNullishOr((found) => found.description), + Option.getOrElse(() => ""), + ), ]), - ], - ) + ]), + ]) const describeAssertion = (row: AssertionDraft): string => { const op = Arr.findFirst(ASSERTION_OPS[row.type], (entry) => entry.value === row.op).pipe( @@ -197,81 +193,31 @@ const afterSaveNote = (kind: MonitorKind): string => /** Pinned under the scroller, outside it, so it never floats over the form. */ const actionBar = (h: H, model: Model, widthClass: string): Html => { const issues = formIssues(model.form) - const isEdit = model.editingId !== null - return h.div( - [h.Class("shrink-0 border-t bg-background px-4 py-3")], - [ - ...(model.saveFailure === null ? [] : [saveFailureNotice(h, model.saveFailure, widthClass)]), - h.div( - [h.Class(cn(hstackClass({ gap: "4", justify: "between" }), widthClass))], - [ - issues.length === 0 - ? hstack( - h, - { - gap: "1.5", - as: "span", - className: typographyClass({ - size: "xs", - className: "text-severity-info", - }), - }, - [icon(h, "CheckIcon", { size: 14 }), "Ready to save"], - ) - : h.p( - [ - h.AriaLabel("Fix before saving"), - h.Title(Arr.join(issues, " ")), - h.Class( - hstackClass({ - gap: "1.5", - shrink: true, - className: typographyClass({ size: "xs", tone: "muted" }), - }), - ), - ], - [ - icon(h, "CircleWarningIcon", { - size: 14, - className: "shrink-0 text-severity-warn", - }), - h.span( - [h.Class("truncate")], - [ - "Fix before saving: ", - ...Arr.flatMap(issues, (issue, index) => [ - ...(index === 0 ? [] : [" "]), - h.span([h.Key(issue), h.Class("text-foreground")], [issue]), - ]), - ], - ), - ], - ), - hstack(h, { gap: "2", className: "shrink-0" }, [ - linkButton( - h, - { - variant: "outline", - href: - model.editingId === null ? uptimeTabHref() : monitorHref(model.editingId), - }, - ["Cancel"], - ), - button( - h, - { - // Until the session says this is an admin, a click would be dropped by `save`. - isDisabled: issues.length > 0 || !canManage(model), - isLoading: model.isSaving, - attributes: [h.OnClick(Message.ClickedSave())], - }, - [isEdit ? "Save changes" : "Create monitor"], - ), - ]), - ], + return formActionBar(h, { + issues, + widthClass, + notice: model.saveFailure === null ? null : saveFailureNotice(h, model.saveFailure, widthClass), + actions: [ + linkButton( + h, + { + variant: "outline", + href: model.editingId === null ? uptimeTabHref() : monitorHref(model.editingId), + }, + ["Cancel"], + ), + button( + h, + { + // Until the session says this is an admin, a click would be dropped by `save`. + isDisabled: issues.length > 0 || !canManage(model), + isLoading: model.isSaving, + attributes: [h.OnClick(Message.ClickedSave())], + }, + [model.editingId !== null ? "Save changes" : "Create monitor"], ), ], - ) + }) } /** @@ -359,10 +305,12 @@ export const view = Submodel.defineView((model, return dashboardPage(h, header, [skeleton(h, "h-24 w-full"), skeleton(h, "h-80 w-full")]) const isHttp = model.form.kind === "http" const widthClass = isHttp ? "max-w-6xl" : "max-w-3xl" - const card = h.div( - [h.Class("divide-y rounded-md border bg-card")], - [kindPicker(h, model), ...kindSections(h, model), serviceSection(h, model), notifySection(h, model)], - ) + const card = formCard(h, [ + kindPicker(h, model), + ...kindSections(h, model), + serviceSection(h, model), + notifySection(h, model), + ]) return dashboardPage(h, { ...header, fill: true }, [ h.div( [h.DataAttribute("slot", "page-scroll-area"), h.Class("min-h-0 flex-1 overflow-auto p-4")], diff --git a/apps/web/src/page/kit/destination-picker.ts b/apps/web/src/page/kit/destination-picker.ts new file mode 100644 index 0000000000..51e67080f3 --- /dev/null +++ b/apps/web/src/page/kit/destination-picker.ts @@ -0,0 +1,118 @@ +import type { AlertDestinationRow } from "@maple/web-data/collections/alerts" +import { Array as Arr, Option } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { icon } from "@maple/views/ui/icon" +import { iconButton } from "@maple/views/ui/icon-button" +import type * as Menu from "@maple/views/ui/menu" +import * as MenuView from "@maple/views/ui/menu-view" +import { panel } from "@maple/views/ui/panel" +import { pressable } from "@maple/views/ui/pressable" +import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" + +/** + * `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add + * destination" menu of the rest; the owner keeps the menu's Model and the chosen ids. + */ +export const destinationPicker = ( + h: HtmlBuilder, + options: Readonly<{ + /** The synced destinations; `null` until they arrive. */ + rows: ReadonlyArray | null + selectedIds: ReadonlyArray + menu: Menu.Model + toMenuMessage: (message: Menu.Message) => M + onRemove: (id: Id) => M + /** What an empty pick means, in one line. */ + emptyText: string + }>, +): Html => { + const rows = options.rows ?? [] + // `null` until destinations sync: a neutral name, not "unavailable", for the saved ones. + const fallback = options.rows === null ? "Destination" : "Unavailable destination" + const selected = Arr.map(options.selectedIds, (id) => ({ + id, + name: Option.getOrElse( + Option.map( + Arr.findFirst(rows, (row) => row.id === id), + (row) => row.name, + ), + () => fallback, + ), + })) + const available = Arr.filter(rows, (row) => !Arr.some(options.selectedIds, (id) => id === row.id)) + return panel(h, { className: "divide-y shadow-none" }, [ + ...Arr.map(selected, (destination) => + hstack(h, { gap: "2", className: "px-3 py-2", attributes: [h.Key(destination.id)] }, [ + icon(h, "BellIcon", { className: "text-muted-foreground" }), + typography(h, { size: "sm", truncate: true, className: "min-w-0 flex-1" }, [ + destination.name, + ]), + iconButton( + h, + { + label: `Stop notifying ${destination.name}`, + className: "text-muted-foreground", + attributes: [h.OnClick(options.onRemove(destination.id))], + }, + [icon(h, "XmarkIcon")], + ), + ]), + ), + ...(selected.length === 0 + ? [ + typography(h, { size: "sm", tone: "muted", as: "p", className: "px-3 py-2.5" }, [ + options.emptyText, + ]), + ] + : []), + ...(available.length === 0 + ? [] + : [ + h.submodel({ + slotId: options.menu.id, + model: options.menu, + view: MenuView.view, + viewInputs: { + entries: Arr.map(available, (row): MenuView.MenuEntry => ({ + _tag: "Item", + value: row.id, + label: row.name, + })), + align: "start", + className: "w-72", + toView: ({ trigger, withTrigger }) => + h.span( + [h.Class("contents")], + withTrigger( + pressable( + h, + { + className: + "flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:bg-surface-sunken active:bg-surface-sunken hover:text-foreground active:text-foreground", + attributes: trigger, + }, + [icon(h, "PlusIcon", { size: 14 }), "Add destination"], + ), + ), + ), + }, + toParentMessage: options.toMenuMessage, + }), + ]), + ]) +} + +/** The destination a menu pick added, if it is a synced one not already chosen. */ +export const pickedDestination = ( + outMessage: Menu.OutMessage | undefined, + rows: ReadonlyArray | null, + selectedIds: ReadonlyArray, +): Option.Option => { + const picked = outMessage?._tag === "SelectedItem" ? outMessage.value : null + return picked !== null && + !selectedIds.includes(picked) && + Arr.some(rows ?? [], (row) => row.id === picked) + ? Option.some(picked) + : Option.none() +} diff --git a/apps/web/src/page/kit/form-section.ts b/apps/web/src/page/kit/form-section.ts new file mode 100644 index 0000000000..744d10e185 --- /dev/null +++ b/apps/web/src/page/kit/form-section.ts @@ -0,0 +1,118 @@ +import { cn } from "@maple/ui/lib/utils" +import { Array as Arr } from "effect" +import type { Html, HtmlBuilder } from "foldkit/html" +import { heading } from "@maple/views/ui/heading" +import { icon } from "@maple/views/ui/icon" +import { hstack, hstackClass } from "@maple/views/ui/stack" +import { typography, typographyClass } from "@maple/views/ui/typography" + +/** + * `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) + * and its controls on the right; the two stack on phones. Rows sit in a `divide-y` card. + */ +export const formSection = ( + h: HtmlBuilder, + options: Readonly<{ + title: string + hint?: string | undefined + /** The controls sit lower than a text input's top, so a one-line control centres on the title. */ + isCompact?: boolean + className?: string + }>, + children: ReadonlyArray, +): Html => + h.section( + [ + h.AriaLabel(options.title), + h.Class( + cn("grid gap-x-8 gap-y-2 px-4 py-4 sm:grid-cols-[136px_minmax(0,1fr)]", options.className), + ), + ], + [ + h.div( + [h.Class(cn("flex flex-col gap-0.5", options.isCompact === true ? "sm:pt-1.5" : "sm:pt-2"))], + [ + heading(h, { level: 3, variant: "subsection" }, [options.title]), + ...(options.hint === undefined + ? [] + : [typography(h, { size: "xs", tone: "muted", as: "p" }, [options.hint])]), + ], + ), + h.div([h.Class("flex min-w-0 flex-col gap-3")], children), + ], + ) + +/** `FormCard`: the bordered card a form's sections sit in, ruled between them. */ +export const formCard = (h: HtmlBuilder, children: ReadonlyArray, className?: string): Html => + h.div([h.Class(cn("divide-y rounded-md border bg-card", className))], children) + +/** + * `FormActionBar`: pinned under a form's scroller, outside it, so it never floats over the form: + * "Ready to save" or what still blocks saving, then the actions, with an optional notice above. + */ +export const formActionBar = ( + h: HtmlBuilder, + options: Readonly<{ + /** What still blocks saving, one sentence each; empty is ready. */ + issues: ReadonlyArray + actions: ReadonlyArray + /** The last save's failure, above the row. */ + notice?: Html | null + /** The form's width, so the bar's ends line up with the card. */ + widthClass?: string + }>, +): Html => + h.div( + [h.Class("shrink-0 border-t bg-background px-4 py-3")], + [ + ...(options.notice === undefined || options.notice === null ? [] : [options.notice]), + h.div( + [h.Class(cn(hstackClass({ gap: "4", justify: "between" }), options.widthClass))], + [ + options.issues.length === 0 + ? hstack( + h, + { + gap: "1.5", + as: "span", + className: typographyClass({ + size: "xs", + className: "text-severity-info", + }), + }, + [icon(h, "CheckIcon", { size: 14 }), "Ready to save"], + ) + : h.p( + [ + h.AriaLabel("Fix before saving"), + h.Title(Arr.join(options.issues, " ")), + h.Class( + hstackClass({ + gap: "1.5", + shrink: true, + className: typographyClass({ size: "xs", tone: "muted" }), + }), + ), + ], + [ + icon(h, "CircleWarningIcon", { + size: 14, + className: "shrink-0 text-severity-warn", + }), + h.span( + [h.Class("truncate")], + [ + "Fix before saving: ", + ...Arr.flatMap(options.issues, (issue, index) => [ + ...(index === 0 ? [] : [" "]), + h.span([h.Key(issue), h.Class("text-foreground")], [issue]), + ]), + ], + ), + ], + ), + hstack(h, { gap: "2", className: "shrink-0" }, options.actions), + ], + ), + ], + ) diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 6b138c127d..f5b22298e9 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -28,6 +28,8 @@ import * as AlertIncident from "./alerts/incident" import * as AlertRule from "./alerts/rule" import * as AlertsUptimeMonitor from "./alerts/uptime-monitor" import * as AlertsUptimeNew from "./alerts/uptime-form" +import * as AlertsSlo from "./alerts/slo" +import * as AlertsSloNew from "./alerts/slo-form" import * as IngestRuleEditor from "./settings/ingest-rule" import * as DashboardsList from "./dashboards/list" import * as DashboardTemplates from "./dashboards/templates" @@ -119,6 +121,8 @@ export const pages = [ AlertRule.page, AlertsUptimeMonitor.page, AlertsUptimeNew.page, + AlertsSlo.page, + AlertsSloNew.page, StatusPagesList.page, StatusPageBuilder.page, StatusPageEditor.page, diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index a07412ca28..3d76b019e3 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -32,6 +32,8 @@ export const AppRoute = defineRouteUnion({ AlertsCreate: { search: Search.AlertsCreateSearch }, AlertsUptimeNew: { search: Search.AlertsUptimeNewSearch }, AlertsUptimeMonitor: { monitorId: Schema.String, search: Search.AlertsUptimeMonitorSearch }, + AlertsSloNew: { search: Search.AlertsSloNewSearch }, + AlertsSlo: { sloId: Schema.String, search: Search.AlertsSloSearch }, IngestRuleNew: { search: Search.IngestRuleNewSearch }, IngestRule: { ruleId: Schema.String }, AlertsStatusPage: { pageId: Schema.String, search: Search.AlertsStatusPageSearch }, diff --git a/apps/web/src/route/route.test.ts b/apps/web/src/route/route.test.ts index 67162a6e44..0fe63a400a 100644 --- a/apps/web/src/route/route.test.ts +++ b/apps/web/src/route/route.test.ts @@ -51,6 +51,16 @@ describe("routes", () => { expect(hrefOf(monitor)).toBe("/alerts/uptime/upm_abc") }) + test("SLO routes parse ahead of a rule id and print back", () => { + expect(parse("/alerts/slos/new?serviceName=%22checkout%22")).toMatchObject({ + _tag: "AlertsSloNew", + search: { serviceName: "checkout" }, + }) + const slo = parse("/alerts/slos/slo_abc") + expect(slo).toMatchObject({ _tag: "AlertsSlo", sloId: "slo_abc" }) + expect(hrefOf(slo)).toBe("/alerts/slos/slo_abc") + }) + test("status page routes parse, the builder ahead of a page id, and print back", () => { expect(parse("/status-pages")._tag).toBe("StatusPages") expect(parse("/status-pages/new")._tag).toBe("StatusPageNew") diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 38f942c7e3..2e5d718c41 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -92,6 +92,17 @@ const routers = { search(Search.AlertsUptimeMonitorSearch), Route.mapTo(AppRoute.AlertsUptimeMonitor), ), + AlertsSloNew: pipe( + path("alerts", "slos", "new"), + search(Search.AlertsSloNewSearch), + Route.mapTo(AppRoute.AlertsSloNew), + ), + AlertsSlo: pipe( + path("alerts", "slos"), + slash(segment("sloId")), + search(Search.AlertsSloSearch), + Route.mapTo(AppRoute.AlertsSlo), + ), IngestRuleNew: pipe( path("settings", "ingestion", "rules", "new"), search(Search.IngestRuleNewSearch), @@ -502,6 +513,8 @@ export const urlToAppRoute = Route.parseUrlWithFallback( routers.AlertsCreate, routers.AlertsUptimeNew, routers.AlertsUptimeMonitor, + routers.AlertsSloNew, + routers.AlertsSlo, routers.IngestRuleNew, routers.IngestRule, routers.AlertsStatusPage, diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 60aacbea36..217fccdd5f 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -364,6 +364,14 @@ export const AlertsUptimeNewSearch = Schema.Struct({ export const AlertsUptimeMonitorSearch = Schema.Struct({}) +/** `/alerts/slos/new`: `sloId` edits that SLO; `serviceName` prefills a new one. */ +export const AlertsSloNewSearch = Schema.Struct({ + sloId: Schema.optional(Schema.String), + serviceName: Schema.optional(Schema.String), +}) + +export const AlertsSloSearch = Schema.Struct({}) + /** `/settings/ingestion/rules/new`: `template` prefills the editor from an empty-state template. */ export const IngestRuleNewSearch = Schema.Struct({ template: Schema.optional(Schema.String), diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 10450d0dca..8e93c09178 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -182,7 +182,9 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/custom-range-view.ts` `view`: The custom range picker's DOM: a two-month range calendar (en-US, rdp-* classes, Maple's `calendarClassNames`), then the Start/End time fields and the actions. - `apps/web/src/page/kit/custom-range.ts` `readToday`, `parseTimeInput`, `addToRange`, `appliedRange` (stateful: Model, init, update): The custom range picker: a two-month range calendar and two time inputs in the selected zone's wall clock. - `apps/web/src/page/kit/dashboard-page.ts` `pageBody`, `filterRail`, `pageContent`, `pageHeader`, `pageScrollArea`, `dashboardPage`: The dashboard page below the shell's top bar: `PageLayout.Body` with its filter rail, content column, sticky header and scroll area. +- `apps/web/src/page/kit/destination-picker.ts` `destinationPicker`, `pickedDestination`: `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add destination" menu of the rest; the owner keeps the menu's Model and the chosen ids. - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: `DetailHeader`'s title side and `DetailHeaderSkeleton`. +- `apps/web/src/page/kit/form-section.ts` `formSection`, `formCard`, `formActionBar`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. - `apps/web/src/page/kit/guided-setup.ts` `frameworkIconStrip`, `frameworkPicker`, `agentPromptButton`, `connectInstructions`: `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. - `apps/web/src/page/kit/infra-correlation/command.ts` `operationOf`: The four `*InfraTimeseries` reads the panel's charts make, through the read cache under the atom key (`requestKey(org, "", { data })`), so a reopened panel reuses them. - `apps/web/src/page/kit/infra-correlation/update.ts` `chartIdOf`, `requestsOf` (stateful: Model, init, update): A chart's slot and chart id, `-` (the linked-cursor id). diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 754627e41f..1a54245c50 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -174,7 +174,9 @@ export function buildIncidentTimeline(input: { const condition = incident.signalType === "uptime" ? `${incident.ruleName} went down` - : `${signalTitle(incident.signalType)} ${conditionPhrase(incident)}` + : incident.signalType === "slo_burn_rate" + ? `SLO burning its error budget at ${formatSignalValue(incident.signalType, incident.lastObservedValue)}` + : `${signalTitle(incident.signalType)} ${conditionPhrase(incident)}` const opened: IncidentTimelineEntry = { key: "opened", diff --git a/packages/web-data/src/alerts/overview-derive.test.ts b/packages/web-data/src/alerts/overview-derive.test.ts index 21307a8acc..fd74a833b5 100644 --- a/packages/web-data/src/alerts/overview-derive.test.ts +++ b/packages/web-data/src/alerts/overview-derive.test.ts @@ -135,6 +135,27 @@ describe("incident lines", () => { expect(incidentScope(uptime({ groupKey: "checkout" }))).toBe("checkout") }) + it("names an SLO incident by its SLO, never the ungrouped key", () => { + const slo = (overrides: Record = {}) => + makeIncident({ + ruleName: "SLO burn: Checkout API", + signalType: "slo_burn_rate", + comparator: "gte", + threshold: 6, + lastObservedValue: 16.1, + status: "open", + resolvedAt: null, + groupKey: "__total__", + ...overrides, + }) + expect(incidentTitle(slo())).toBe("Checkout API is burning its error budget") + expect(incidentTitle(slo({ status: "resolved", resolvedAt: iso(HOUR) }))).toBe( + "Checkout API stopped burning its error budget", + ) + expect(incidentScope(slo())).toBeNull() + expect(incidentValues(slo())).toEqual({ now: "16.1x", threshold: "6.0x" }) + }) + it("keeps a telemetry incident's signal, value, group and threshold", () => { const incident = makeIncident({ groupKey: "checkout", status: "open", resolvedAt: null }) expect(incidentTitle(incident)).toBe("Error rate 8.0% on checkout") diff --git a/packages/web-data/src/alerts/overview-derive.ts b/packages/web-data/src/alerts/overview-derive.ts index 23af7e92be..e3f9c23fc8 100644 --- a/packages/web-data/src/alerts/overview-derive.ts +++ b/packages/web-data/src/alerts/overview-derive.ts @@ -51,6 +51,14 @@ type IncidentLine = Pick< /** Uptime incidents name the monitor ("checkout-api is down"), not a signal and a value. */ const isUptime = (incident: Pick) => incident.signalType === "uptime" +/** SLO incidents name the SLO ("Checkout API is burning its error budget"). */ +const isSlo = (incident: Pick) => incident.signalType === "slo_burn_rate" + +/** The rule backing an SLO is named `SLO burn: `; the SLO's own name reads better. */ +const SLO_RULE_PREFIX = "SLO burn: " +export const sloNameOf = (ruleName: string): string => + Str.startsWith(SLO_RULE_PREFIX)(ruleName) ? Str.slice(SLO_RULE_PREFIX.length)(ruleName) : ruleName + /** Raw SQL and builder rules: "Raw SQL 5" says nothing, so the rule name is what fired. */ export const isQueryIncident = (incident: Pick) => incident.signalType === "raw_query" || incident.signalType === "builder_query" @@ -59,15 +67,17 @@ export const isQueryIncident = (incident: Pick Date: Fri, 6 Nov 2026 02:34:56 +0100 Subject: [PATCH 02/11] fix(slos): show held burn tiers, tier-factor incident wording, monitor link - SLO page and hub read tiers through tierState: a tier with no events in its short window reads "Holding", keeps the callout and severity badge while its incident is open, and still counts as burning on the hub. - A resolved SLO incident opened "at 14.4x or more" and drops the "now" burn. - Managed rules keep the incident rail's fired count, without "Edit rule"; uptime incidents get an "Open monitor" button matched by alert_rule_id. - One spent-budget check (<= 0), one SLO list read, kit pickedDestination in the uptime form, domain windowLabel, SLO_BURN_TIERS form copy, sloNameFromRuleName, and a service select keyed on the list length only. - Lab gains a holding state. --- apps/web/src/lab/slos-lab.ts | 9 +- .../page/alerts/hub/hub-slos.scene.test.ts | 21 +++++ apps/web/src/page/alerts/hub/view-slos.ts | 18 +++- apps/web/src/page/alerts/incident/command.ts | 12 +++ .../alerts/incident/incident.scene.test.ts | 88 +++++++++++++++++++ apps/web/src/page/alerts/incident/message.ts | 6 +- apps/web/src/page/alerts/incident/model.ts | 4 +- apps/web/src/page/alerts/incident/update.ts | 26 ++++++ .../web/src/page/alerts/incident/view-rail.ts | 58 ++++++------ apps/web/src/page/alerts/incident/view.ts | 80 ++++++++++------- .../src/page/alerts/shared/slo-commands.ts | 28 ++---- apps/web/src/page/alerts/shared/slo-derive.ts | 51 +++++++---- .../page/alerts/shared/slo-test-fixtures.ts | 20 +++-- apps/web/src/page/alerts/slo-form/view.ts | 27 +++--- .../web/src/page/alerts/slo/slo.scene.test.ts | 38 ++++++++ apps/web/src/page/alerts/slo/view-panels.ts | 58 ++++++------ apps/web/src/page/alerts/slo/view.ts | 84 +++++++++++++----- .../web/src/page/alerts/uptime-form/update.ts | 20 ++--- .../src/alerts/incident-timeline.test.ts | 21 ++++- .../web-data/src/alerts/incident-timeline.ts | 4 +- .../web-data/src/alerts/overview-derive.ts | 5 +- 21 files changed, 485 insertions(+), 193 deletions(-) diff --git a/apps/web/src/lab/slos-lab.ts b/apps/web/src/lab/slos-lab.ts index 2c30e593c8..0da7dd94e0 100644 --- a/apps/web/src/lab/slos-lab.ts +++ b/apps/web/src/lab/slos-lab.ts @@ -1,6 +1,7 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { SloPublicId } from "@maple/domain/http/v2" +import { sloRuleName } from "@maple/domain/slo" import { Effect, Option, Result, Schema, Array as Arr } from "effect" import { Command, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -32,7 +33,8 @@ import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" // `/lab/slos`: the SLOs tab, the SLO page and the SLO form over fixtures. `?state=` picks one: -// tab-empty, tab, loading, healthy, burning, exhausted, status-error, form-new, form-invalid, form-edit. +// tab-empty, tab, loading, healthy, burning, holding, exhausted, status-error, form-new, form-invalid, +// form-edit. export const LabState = Schema.Literals([ "tab-empty", @@ -40,6 +42,7 @@ export const LabState = Schema.Literals([ "loading", "healthy", "burning", + "holding", "exhausted", "status-error", "form-new", @@ -95,7 +98,7 @@ const fold = ( const INCIDENT = incidentRow({ rule_id: SLO_RULE_UUID, - rule_name: "SLO burn: Checkout API", + rule_name: sloRuleName("Checkout API"), signal_type: "slo_burn_rate", status: "open", severity: "critical", @@ -142,7 +145,7 @@ const hubFor = (state: LabState) => { } const scenarioOf = (state: LabState): SloScenario => - state === "burning" ? "burning" : state === "exhausted" ? "exhausted" : "healthy" + state === "burning" || state === "holding" || state === "exhausted" ? state : "healthy" const sloFor = (state: LabState) => { const slo = checkoutSlo() diff --git a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts index a7650030a3..04a461e2cf 100644 --- a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts @@ -125,6 +125,27 @@ describe("alerts hub, SLOs tab", () => { ) }) + test("an SLO whose tiers hold through an outage still counts as burning while its incident is open", () => { + const rows: ReadonlyArray = [ + { slo: checkoutSlo(), status: sloStatus("holding") }, + { slo: searchSlo(), status: sloStatus("healthy", { sloId: sloUuid(2), targetPercent: 99.5 }) }, + ] + const incident = incidentRow({ rule_id: SLO_RULE_UUID, signal_type: "slo_burn_rate", status: "open" }) + scene( + { update: pageUpdate, view }, + given(withSlos(rows)), + expectView(text(/^0\s*burning$/u)).toExist(), + ) + scene( + { update: pageUpdate, view }, + given( + pageUpdate(withSlos(rows), Message.UpdatedIncidents({ orgId: ORG, rows: [incident] })).model, + ), + expectView(text(/^1\s*burning$/u)).toExist(), + expectView(within(table, text("Incident open"))).toExist(), + ) + }) + test("alerting reads as on, off, or an open incident on the SLO's rule", () => { const withIncident = pageUpdate( withSlos(HEALTHY_AND_BURNING), diff --git a/apps/web/src/page/alerts/hub/view-slos.ts b/apps/web/src/page/alerts/hub/view-slos.ts index 69491b8ed4..1bd7735bf4 100644 --- a/apps/web/src/page/alerts/hub/view-slos.ts +++ b/apps/web/src/page/alerts/hub/view-slos.ts @@ -1,4 +1,5 @@ import { pluralize } from "@maple/ui/lib/format" +import type { AlertIncidentRow } from "@maple/web-data/collections/alerts" import { Option, Array as Arr } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -30,6 +31,9 @@ import { formatSli, formatTarget, formatWindow, + holdingTier, + isBudgetSpent, + openIncident, scopeLine, sliLabel, sliTone, @@ -68,9 +72,15 @@ const slosEmptyState = (h: H, serviceName: string | undefined, canEdit: boolean) "py-12", ) -const readout = (h: H, rows: ReadonlyArray): ReadonlyArray => { - const burning = Arr.filter(rows, (row) => row.status !== null && Option.isSome(firingTier(row.status))) - const spent = Arr.filter(rows, (row) => row.status !== null && row.status.budget_remaining <= 0) +/** Burning: a tier fires, or a tier holds (no events) while the SLO's incident stays open. */ +const isBurning = (row: SloListRow, incidents: ReadonlyArray): boolean => + row.status !== null && + (Option.isSome(firingTier(row.status)) || + (Option.isSome(holdingTier(row.status)) && Option.isSome(openIncident(row.slo, incidents)))) + +const readout = (h: H, model: Model, rows: ReadonlyArray): ReadonlyArray => { + const burning = Arr.filter(rows, (row) => isBurning(row, model.incidents ?? [])) + const spent = Arr.filter(rows, (row) => row.status !== null && isBudgetSpent(row.status.budget_remaining)) return [ toolbarStat(h, { value: String(rows.length), label: pluralize(rows.length, "SLO") }), toolbarStat(h, { @@ -194,7 +204,7 @@ export const slosTab = (h: H, model: Model): Html => { rows.length === 0 ? slosEmptyState(h, model.search.serviceName, canManage(model)) : vstack(h, { gap: "4" }, [ - listToolbar(h, { className: "pb-0", trailing: readout(h, rows) }), + listToolbar(h, { className: "pb-0", trailing: readout(h, model, rows) }), slosTable(h, model, rows), ]), }) diff --git a/apps/web/src/page/alerts/incident/command.ts b/apps/web/src/page/alerts/incident/command.ts index c54c56b905..69606def4b 100644 --- a/apps/web/src/page/alerts/incident/command.ts +++ b/apps/web/src/page/alerts/incident/command.ts @@ -6,6 +6,7 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { listDeliveries } from "../shared/commands" import { listSlos } from "../shared/slo-commands" +import { listMonitors } from "../shared/uptime-commands" import { fetchCheckSummary } from "../shared/check-summary" import { fetchPreview } from "../shared/preview" import { Message } from "./message" @@ -89,3 +90,14 @@ export const FetchIncidentSlos = Command.define("FetchAlertIncidentSlos", { Effect.map((result) => Message.CompletedFetchSlos({ key, result })), ), }) + +/** An uptime incident's monitors, to find the one whose rule opened it. */ +export const FetchIncidentMonitors = Command.define("FetchAlertIncidentMonitors", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchMonitors], + execute: ({ key }) => + listMonitors.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMonitors({ key, result })), + ), +}) diff --git a/apps/web/src/page/alerts/incident/incident.scene.test.ts b/apps/web/src/page/alerts/incident/incident.scene.test.ts index 1de4d7db1e..6a84321514 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -1,5 +1,6 @@ import { OrgId } from "@maple/domain" import { AlertRulePreviewResponse } from "@maple/domain/http" +import { sloRuleName } from "@maple/domain/slo" import { Result, Schema, Array as Arr } from "effect" import { click, @@ -22,6 +23,8 @@ import { toLoadFailure } from "@maple/views/data/load-failure" import { PageOutMessage, type PageContext } from "../../contract" import { sloHref, slosTabHref } from "../shared/slo-commands" import { checkoutSlo } from "../shared/slo-test-fixtures" +import { monitorHref, uptimeTabHref } from "../shared/uptime-commands" +import { httpMonitor, MONITOR_RULE, monitorId } from "../shared/uptime-test-monitors" import { incidentRow, INCIDENT_ID, NOW, ORG, ruleRow, stateRow } from "../shared/test-rows" import { Message } from "./message" import type { Model } from "./model" @@ -229,6 +232,91 @@ describe("alert incident", () => { ) }) + test("a resolved SLO incident opened at its tier factor and no longer reads a current burn", () => { + const resolved = incidentRow({ + rule_name: sloRuleName("Checkout API"), + signal_type: "slo_burn_rate", + comparator: "gte", + threshold: 14.4, + last_observed_value: 0.3, + group_key: "__total__", + status: "resolved", + resolved_at: new Date(NOW - 60_000).toISOString(), + }) + const loaded = Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [resolved] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2, { signal_type: "slo_burn_rate" })] }), + ], + init(route(), context).model, + (model, message) => step(model, message).model, + ) + const withDeliveries = step( + loaded, + Message.CompletedFetchDeliveries({ + key: loaded.deliveries.key ?? "", + result: Result.succeed([]), + }), + ).model + scene( + { update: pageUpdate, view }, + given(withDeliveries), + themeResolved(), + expectView(role("heading", { name: "Checkout API stopped burning its error budget" })).toExist(), + expectView(text("SLO burning its error budget at 14.4x or more")).toExist(), + expectView(text(/0\.3x/u)).not.toExist(), + expectView(text(/now /u)).not.toExist(), + ) + }) + + test("an uptime incident links its monitor once known and keeps its rule's history, without a rule edit", () => { + const uptimeIncident = incidentRow({ + rule_id: MONITOR_RULE, + rule_name: "checkout-api", + signal_type: "uptime", + group_key: "__total__", + }) + const loaded = Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [uptimeIncident] }), + ], + init(route(), context).model, + (model, message) => step(model, message).model, + ) + const withRule = step( + loaded, + Message.UpdatedRules({ + orgId: ORG, + rows: [ruleRow(7, { signal_type: "uptime", name: "checkout-api" })], + }), + ) + expect(withRule.model.monitors.key).not.toBeNull() + scene( + { update: pageUpdate, view }, + given(withRule.model), + themeResolved(), + expectView(role("link", { name: "Open monitors" })).toHaveAttr("href", uptimeTabHref()), + expectView(role("link", { name: "Edit rule" })).not.toExist(), + expectView(text(/^Fired 1 time in 30d/u)).toExist(), + ) + const monitor = httpMonitor() + const linked = step( + withRule.model, + Message.CompletedFetchMonitors({ + key: withRule.model.monitors.key ?? "", + result: Result.succeed([httpMonitor({ id: monitorId(5), alert_rule_id: null }), monitor]), + }), + ).model + scene( + { update: pageUpdate, view }, + given(linked), + themeResolved(), + expectView(role("link", { name: "Open monitor" })).toHaveAttr("href", monitorHref(monitor.id)), + ) + }) + test("a failed delivery read is not refetched when the live rows change", () => { const entered = init(route(), context).model const loaded = Arr.reduce( diff --git a/apps/web/src/page/alerts/incident/message.ts b/apps/web/src/page/alerts/incident/message.ts index 8fa07dd72d..3595bb470b 100644 --- a/apps/web/src/page/alerts/incident/message.ts +++ b/apps/web/src/page/alerts/incident/message.ts @@ -1,5 +1,5 @@ import { AlertDeliveryEventDocument, AlertRulePreviewResponse } from "@maple/domain/http" -import { V2Investigation, V2Slo } from "@maple/domain/http/v2" +import { V2Investigation, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -34,6 +34,10 @@ export const Message = defineMessageUnion({ result: Schema.Result(Schema.NullOr(V2Investigation), LoadFailure), }, CompletedFetchSlos: { key: Schema.String, result: Schema.Result(Schema.Array(V2Slo), LoadFailure) }, + CompletedFetchMonitors: { + key: Schema.String, + result: Schema.Result(Schema.Array(V2UptimeMonitor), LoadFailure), + }, ClickedInvestigate: {}, CompletedCreateInvestigation: { result: Schema.Result(Schema.String, LoadFailure) }, CompletedLoadChartRuntime: {}, diff --git a/apps/web/src/page/alerts/incident/model.ts b/apps/web/src/page/alerts/incident/model.ts index 70cb3e7b97..1ad02afa89 100644 --- a/apps/web/src/page/alerts/incident/model.ts +++ b/apps/web/src/page/alerts/incident/model.ts @@ -1,5 +1,5 @@ import { AlertDeliveryEventDocument } from "@maple/domain/http" -import { V2Investigation, V2Slo } from "@maple/domain/http/v2" +import { V2Investigation, V2Slo, V2UptimeMonitor } from "@maple/domain/http/v2" import { AlertIncidentRowSchema, AlertRuleRowSchema, @@ -32,6 +32,8 @@ export const Model = Schema.Struct({ investigation: KeyedSchema(Schema.NullOr(V2Investigation), LoadFailure), /** An SLO incident's link to its SLO: `slos.list`, read only for one. */ slos: KeyedSchema(Schema.Array(V2Slo), LoadFailure), + /** An uptime incident's link to its monitor: `uptimeMonitors.list`, read only for one. */ + monitors: KeyedSchema(Schema.Array(V2UptimeMonitor), LoadFailure), /** `useAsyncAction`: the create request is running, so repeat clicks cannot start duplicates. */ isStartingInvestigation: Schema.Boolean, chart: Chart.Model, diff --git a/apps/web/src/page/alerts/incident/update.ts b/apps/web/src/page/alerts/incident/update.ts index 41a2ee8743..7fe78fb1f2 100644 --- a/apps/web/src/page/alerts/incident/update.ts +++ b/apps/web/src/page/alerts/incident/update.ts @@ -20,11 +20,13 @@ import { FetchIncidentChecks, FetchIncidentDeliveries, FetchIncidentInvestigation, + FetchIncidentMonitors, FetchIncidentPreview, FetchIncidentSlos, ReadIncidentClock, } from "./command" import { SLO_LIST_LIMIT } from "../shared/slo-commands" +import { UPTIME_LIST_LIMIT } from "../shared/uptime-commands" import { incidentViewOf, minuteOf, ruleStatesOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" @@ -45,6 +47,7 @@ export const init = (route: IncidentRoute, _context: PageContext): Return => ({ deliveries: Keyed.idle(), investigation: Keyed.idle(), slos: Keyed.idle(), + monitors: Keyed.idle(), isStartingInvestigation: false, chart: Chart.init(), chartRuntime: { _tag: "Loading" }, @@ -63,6 +66,9 @@ export const init = (route: IncidentRoute, _context: PageContext): Return => ({ const slosKey = (context: PageContext) => requestKey(context.orgId, "slos.list", { query: { limit: SLO_LIST_LIMIT } }) +const monitorsKey = (context: PageContext) => + requestKey(context.orgId, "uptimeMonitors.list", { query: { limit: UPTIME_LIST_LIMIT } }) + /** An atom never refetches a failed read on its own: a same-key `Load` leaves the slot alone. */ const sameKey = (slot: Keyed.Keyed, key: string) => slot.key === key @@ -132,6 +138,23 @@ const followData = (model: Model, context: PageContext): Return => ), ), ), + ...Option.toArray( + Option.flatMap( + view.incident.signalType === "uptime" + ? Option.some(monitorsKey(context)) + : Option.none(), + (key) => + Option.map( + sameKey(model.monitors, key) + ? Option.none() + : Keyed.request(model.monitors, key, "Load"), + (monitors) => ({ + patch: { monitors }, + commands: [FetchIncidentMonitors({ key })], + }), + ), + ), + ), ...Option.toArray( Option.flatMap(view.payload, (payload) => { const key = requestKey(context.orgId, "alertRules.preview", payload) @@ -268,6 +291,9 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedFetchSlos: ({ key, result }) => ({ model: { ...model, slos: Keyed.accept(model.slos, key, result) }, }), + CompletedFetchMonitors: ({ key, result }) => ({ + model: { ...model, monitors: Keyed.accept(model.monitors, key, result) }, + }), ClickedInvestigate: () => model.isStartingInvestigation ? { model } diff --git a/apps/web/src/page/alerts/incident/view-rail.ts b/apps/web/src/page/alerts/incident/view-rail.ts index 50ca652d0a..a305e3f24b 100644 --- a/apps/web/src/page/alerts/incident/view-rail.ts +++ b/apps/web/src/page/alerts/incident/view-rail.ts @@ -221,36 +221,46 @@ const ruleSection = ( ) const breaches = rule.consecutiveBreachesRequired const summary = `${signalTitle(rule.signalType)} ${conditionPhrase(rule)} over ${rule.windowMinutes}m, ${breaches} ${breaches === 1 ? "check" : "checks in a row"} to open${scope.length > 0 ? `, ${Arr.join(scope, ", ")}` : ""}.` + // A monitor's or SLO's rule is edited through its owner, so it keeps only the history. + const isManaged = isManagedSignalType(rule.signalType) return railSection( h, ["Rule"], - h.a( - [ - h.Href(createHref({ ruleId: rule.id })), - h.Class( - typographyClass({ - size: "xs", - tone: "primary", - className: "hover:underline active:underline", - }), + isManaged + ? null + : h.a( + [ + h.Href(createHref({ ruleId: rule.id })), + h.Class( + typographyClass({ + size: "xs", + tone: "primary", + className: "hover:underline active:underline", + }), + ), + ], + ["Edit rule"], ), - ], - ["Edit rule"], - ), [ h.div( [h.Class("space-y-2")], [ - h.a( - [ - h.Href(ruleHref(rule.id)), - h.Class("block text-sm hover:underline active:underline"), - ], - [rule.name], - ), - typography(h, { size: "xs", tone: "muted", as: "p", className: "leading-relaxed" }, [ - summary, - ]), + ...(isManaged + ? [] + : [ + h.a( + [ + h.Href(ruleHref(rule.id)), + h.Class("block text-sm hover:underline active:underline"), + ], + [rule.name], + ), + typography( + h, + { size: "xs", tone: "muted", as: "p", className: "leading-relaxed" }, + [summary], + ), + ]), typography(h, { size: "xs", tone: "muted", as: "p" }, [ "Fired ", String(recent.length), @@ -293,9 +303,7 @@ export const incidentRail = ( ] : []), evidenceSection(h, rule, incident, options.window), - ...(rule !== null && !isManagedSignalType(rule.signalType) - ? [ruleSection(h, rule, options.ruleIncidents, options.now)] - : []), + ...(rule !== null ? [ruleSection(h, rule, options.ruleIncidents, options.now)] : []), ], ) } diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index e0742c5063..34c8ca54d7 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -41,6 +41,7 @@ import { resourceNotFound } from "../../kit/resource-not-found" import { severityBadge } from "../shared/badges" import { alertsHref } from "../shared/links" import { sloHref, slosTabHref } from "../shared/slo-commands" +import { monitorHref, uptimeTabHref } from "../shared/uptime-commands" import { incidentChecksOf, summaryChecksOf } from "../shared/check-summary" import { previewIssue, previewStateOf } from "../shared/preview" import { providerOf } from "../shared/providers" @@ -90,7 +91,10 @@ export const headerTitle = ( : isSlo ? [ "SLO burn rate", - `now ${formatSignalValue(incident.signalType, incident.lastObservedValue)}`, + // A resolved incident's last value is the healthy burn it ended on. + incident.status === "resolved" + ? null + : `now ${formatSignalValue(incident.signalType, incident.lastObservedValue)}`, group, ] : [ @@ -162,14 +166,32 @@ export type IncidentBodyInputs = Readonly<{ entries: ReadonlyArray isEntriesLoading: boolean isEntriesFailed: boolean - /** An SLO incident's SLO page, or the SLOs tab until the SLO is known. */ - sloHref?: string | null + /** An SLO or uptime incident's SLO or monitor page; its tab stands in until it is known. */ + sourceHref?: string | null chartId: string chart: Chart.Model toChartMessage: (message: Chart.Message) => M colors: ThemeColors }> +/** A managed incident's note on what opened it, and a link to its SLO or monitor (its tab until known). */ +const sourceNote = ( + h: HtmlBuilder, + options: Readonly<{ + text: string + href: string | null + label: string + fallback: Readonly<{ href: string; label: string }> + }>, +): Html => + vstack(h, { gap: "3", align: "start" }, [ + typography(h, { size: "sm", tone: "muted", as: "p" }, [options.text]), + linkButton(h, { size: "sm", variant: "outline", href: options.href ?? options.fallback.href }, [ + options.href === null ? options.fallback.label : options.label, + icon(h, "ArrowRightIcon"), + ]), + ]) + /** `IncidentBody`: signal chart, timeline and rail. */ export const incidentBody = (h: HtmlBuilder, inputs: IncidentBodyInputs): Html => { const { incident, rule } = inputs @@ -188,29 +210,19 @@ export const incidentBody = (h: HtmlBuilder, inputs: IncidentBodyInputs ...(group != null ? [` · ${group}`] : []), ]), incident.signalType === "uptime" - ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ - "This incident follows an uptime monitor: it opened when the monitor's checks went down and resolves when one passes again. The monitor's page lists each check with its failing trace.", - ]) + ? sourceNote(h, { + text: "This incident follows an uptime monitor: it opened when the monitor's checks went down and resolves when one passes again. The monitor's page lists each check with its failing trace.", + href: inputs.sourceHref ?? null, + label: "Open monitor", + fallback: { href: uptimeTabHref(), label: "Open monitors" }, + }) : incident.signalType === "slo_burn_rate" - ? vstack(h, { gap: "3", align: "start" }, [ - typography(h, { size: "sm", tone: "muted", as: "p" }, [ - "This incident follows an SLO burning its error budget: it opened when the burn rate held at a tier's factor over both of its windows, and resolves when the short window cools. The SLO's page charts the budget and each window's burn rate.", - ]), - linkButton( - h, - { - size: "sm", - variant: "outline", - href: inputs.sloHref ?? slosTabHref(), - }, - [ - inputs.sloHref === undefined || inputs.sloHref === null - ? "Open SLOs" - : "Open SLO", - icon(h, "ArrowRightIcon"), - ], - ), - ]) + ? sourceNote(h, { + text: "This incident follows an SLO burning its error budget: it opened when the burn rate held at a tier's factor over both of its windows, and resolves when the short window cools. The SLO's page charts the budget and each window's burn rate.", + href: inputs.sourceHref ?? null, + label: "Open SLO", + fallback: { href: slosTabHref(), label: "Open SLOs" }, + }) : rule === null ? typography(h, { size: "sm", tone: "muted", as: "p" }, [ "The rule behind this incident was deleted, so its signal cannot be replayed.", @@ -266,18 +278,22 @@ const body = (h: H, model: Model, view: IncidentView, timeZone: string, now: num const deliveries = AsyncData.getOrElse(model.deliveries.data, () => []) const investigation = AsyncData.getOrElse(model.investigation.data, () => null) const isChartReady = model.themeColors !== null && isChartRuntimeReady(model.chartRuntime) - const sloLink = Option.getOrUndefined( - Option.flatMap(AsyncData.getData(model.slos.data), (slos) => - Option.map( - Arr.findFirst(slos, (slo) => slo.alert_rule_id === incident.ruleId), - (slo) => sloHref(slo.id), - ), + const sloLink = Option.flatMap(AsyncData.getData(model.slos.data), (slos) => + Option.map( + Arr.findFirst(slos, (slo) => slo.alert_rule_id === incident.ruleId), + (slo) => sloHref(slo.id), + ), + ) + const monitorLink = Option.flatMap(AsyncData.getData(model.monitors.data), (monitors) => + Option.map( + Arr.findFirst(monitors, (monitor) => monitor.alert_rule_id === incident.ruleId), + (monitor) => monitorHref(monitor.id), ), ) return incidentBody(h, { incident, rule, - sloHref: sloLink ?? null, + sourceHref: Option.getOrNull(incident.signalType === "uptime" ? monitorLink : sloLink), ruleIncidents: view.ruleIncidents, states: ruleStatesOf(model, incident.ruleId), preview: groupPreviewOf(previewState.preview, incident), diff --git a/apps/web/src/page/alerts/shared/slo-commands.ts b/apps/web/src/page/alerts/shared/slo-commands.ts index 55d82520f7..cdaeee9e82 100644 --- a/apps/web/src/page/alerts/shared/slo-commands.ts +++ b/apps/web/src/page/alerts/shared/slo-commands.ts @@ -7,7 +7,7 @@ import { type V2SloStatus, V2SloUpdateParams, } from "@maple/domain/http/v2" -import { Effect, Option, Ref, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" @@ -15,35 +15,17 @@ import type { AlertsSloNewSearch } from "../../../route/search" // The SLO reads and writes, as Effects the pages' Commands wrap, and their links. +/** One page holds every SLO: the API caps an org at 50. */ export const SLO_LIST_LIMIT = 100 -/** Pages read at most per list: 2000 SLOs. */ -const MAX_LIST_PAGES = 20 - /** How many status reads the SLOs tab runs at once. */ const STATUS_CONCURRENCY = 6 -interface ListProgress { - readonly rows: ReadonlyArray - readonly cursor: string | null - readonly pages: number -} - -/** `slos.list`: every one of the org's SLOs, newest first, following `next_cursor`. */ +/** `slos.list`: the org's SLOs, newest first, in a single read. */ export const listSlos = Effect.gen(function* () { const client = yield* MapleApiV2Client - const progress = yield* Ref.make({ rows: [], cursor: null, pages: 0 }) - const readPage = Effect.gen(function* () { - const { rows, cursor, pages } = yield* Ref.get(progress) - const response = yield* client.slos.list({ - query: { limit: SLO_LIST_LIMIT, ...(cursor === null ? undefined : { cursor }) }, - }) - const next = response.has_more ? response.next_cursor : null - yield* Ref.set(progress, { rows: [...rows, ...response.data], cursor: next, pages: pages + 1 }) - return next !== null && pages + 1 < MAX_LIST_PAGES - }) - yield* readPage.pipe(Effect.repeat({ while: (hasMore) => hasMore })) - return (yield* Ref.get(progress)).rows + const response = yield* client.slos.list({ query: { limit: SLO_LIST_LIMIT } }) + return response.data }).pipe(Effect.mapError(toLoadFailure)) /** An SLO beside its status; `status` is `null` when that one read failed. */ diff --git a/apps/web/src/page/alerts/shared/slo-derive.ts b/apps/web/src/page/alerts/shared/slo-derive.ts index 35130a7db4..2bff3e6f7d 100644 --- a/apps/web/src/page/alerts/shared/slo-derive.ts +++ b/apps/web/src/page/alerts/shared/slo-derive.ts @@ -1,6 +1,12 @@ import type { AlertIncidentRow } from "@maple/web-data/collections/alerts" import type { V2Slo, V2SloSli, V2SloStatus } from "@maple/domain/http/v2" -import { SLO_BURN_TIERS, type SloBurnTier, tierBreached } from "@maple/domain/slo" +import { + SLO_BURN_TIERS, + type SloBurnTier, + type SloTierState, + tierState, + windowLabel, +} from "@maple/domain/slo" import type { Tone } from "@maple/ui/lib/tone" import { Array as Arr, Option } from "effect" @@ -49,8 +55,12 @@ export const formatEvents = (count: number): string => ? `${Number((count / 1000).toFixed(1))}k` : String(Math.round(count)) +/** A spent budget: none left, or overspent. */ +export const isBudgetSpent = (budget: number): boolean => budget <= 0 + /** Spent is red, under a quarter left amber, anything else neutral. */ -export const budgetTone = (budget: number): Tone => (budget <= 0 ? "crit" : budget < 0.25 ? "warn" : "ok") +export const budgetTone = (budget: number): Tone => + isBudgetSpent(budget) ? "crit" : budget < 0.25 ? "warn" : "ok" export const sliTone = (slo: Pick, sli: number | null): Tone => sli === null ? "neutral" : sli * 100 >= slo.target_percent ? "ok" : "crit" @@ -68,16 +78,6 @@ export const burnTone = (rate: number): Tone => ? "warn" : "neutral" -const WINDOW_LABEL = new Map([ - [5, "5m"], - [30, "30m"], - [60, "1h"], - [360, "6h"], -]) - -/** "1h" for a burn window of 60 minutes. */ -export const windowLabel = (minutes: number): string => WINDOW_LABEL.get(minutes) ?? `${minutes}m` - const domainWindows = (status: SloStatus) => Arr.map(status.burn_windows, (window) => ({ windowMinutes: window.window_minutes, @@ -86,21 +86,34 @@ const domainWindows = (status: SloStatus) => good: window.good_events, })) -/** Whether each tier's long and short windows both burn at its factor. */ -export const tierStates = ( - status: SloStatus, -): ReadonlyArray> => { +export type TierReading = Readonly<{ tier: SloTier; state: SloTierState }> + +/** Each tier as the evaluator reads it: breaching, holding (no events in its short window), or clear. */ +export const tierStates = (status: SloStatus): ReadonlyArray => { const windows = domainWindows(status) - return Arr.map(SLO_BURN_TIERS, (tier) => ({ tier, isFiring: tierBreached(tier, windows) })) + return Arr.map(SLO_BURN_TIERS, (tier) => ({ tier, state: tierState(tier, windows) })) } /** The most severe tier whose windows burn at its factor: critical before warning. */ export const firingTier = (status: SloStatus): Option.Option => Option.map( - Arr.findFirst(tierStates(status), (state) => state.isFiring), - (state) => state.tier, + Arr.findFirst(tierStates(status), (reading) => reading.state === "breach"), + (reading) => reading.tier, ) +/** The most severe tier holding its state, when none breaches: an open incident stays open. */ +export const holdingTier = (status: SloStatus): Option.Option => + Option.isSome(firingTier(status)) + ? Option.none() + : Option.map( + Arr.findFirst(tierStates(status), (reading) => reading.state === "hold"), + (reading) => reading.tier, + ) + +/** "Holding: no events in 5m, an open incident stays open." */ +export const holdingLine = (tier: SloTier): string => + `Holding: no events in ${windowLabel(tier.shortWindowMinutes)}, an open incident stays open` + /** The fastest of the four windows' burn rates, 0 without windows. */ export const fastestBurn = (status: SloStatus): number => Arr.reduce(status.burn_windows, 0, (fastest, window) => Math.max(fastest, window.burn_rate)) diff --git a/apps/web/src/page/alerts/shared/slo-test-fixtures.ts b/apps/web/src/page/alerts/shared/slo-test-fixtures.ts index b96c3e3b55..1a80a30254 100644 --- a/apps/web/src/page/alerts/shared/slo-test-fixtures.ts +++ b/apps/web/src/page/alerts/shared/slo-test-fixtures.ts @@ -59,15 +59,20 @@ export const searchSlo = (fields: Record = {}): V2Slo => ...fields, }) -export type SloScenario = "healthy" | "burning" | "exhausted" +export type SloScenario = "healthy" | "burning" | "exhausted" | "holding" -/** Budget left over the window, and the 5m, 30m, 1h and 6h burn rates, per scenario. */ +/** Budget left over the window, and the 5m, 30m, 1h and 6h burn rates (`null`: no events), per scenario. */ const SCENARIOS = { healthy: { budget: 0.62, burns: [0.4, 0.6, 0.5, 0.45] }, // Both critical windows (1h, 5m) at 14.4x or more: the page tier fires. burning: { budget: 0.12, burns: [18.2, 9.4, 16.1, 7.3] }, exhausted: { budget: -0.35, burns: [2.1, 1.8, 1.6, 1.4] }, -} as const satisfies Record + // An outage: no events in 5m or 30m, so both tiers hold after the 1h burned hard. + holding: { budget: 0.12, burns: [null, null, 16.1, 7.3] }, +} as const satisfies Record< + SloScenario, + { budget: number; burns: readonly [number | null, number | null, number | null, number | null] } +> const WINDOWS = [5, 30, 60, 360] as const const TOTAL = 1_240_000 @@ -89,8 +94,9 @@ export const sloStatus = ( sli: 1 - badShare, budget_remaining: budget, burn_windows: Arr.map(WINDOWS, (minutes, index) => { - const rate = burns[index] ?? 0 - const total = (TOTAL / (30 * 24 * 60)) * minutes + const burn = burns[index] ?? null + const rate = burn ?? 0 + const total = burn === null ? 0 : (TOTAL / (30 * 24 * 60)) * minutes return { window_minutes: minutes, burn_rate: rate, @@ -109,10 +115,10 @@ export const sloHistory = ( const hours = (options.days ?? 30) * 24 const budgetRatio = 1 - (options.targetPercent ?? 99.9) / 100 const end = SCENARIOS[scenario].budget - // A steady spend, then the last day burning hard when the scenario is burning. + // A steady spend, then the last day burning hard when the scenario is burning or holding. const budgetAt = (index: number): number => { const share = index / (hours - 1) - if (scenario !== "burning") return 1 - (1 - end) * share + if (scenario !== "burning" && scenario !== "holding") return 1 - (1 - end) * share const steady = 0.7 const cutoff = (hours - 24) / (hours - 1) return share <= cutoff diff --git a/apps/web/src/page/alerts/slo-form/view.ts b/apps/web/src/page/alerts/slo-form/view.ts index 69a7b4b2d3..f58ee1715a 100644 --- a/apps/web/src/page/alerts/slo-form/view.ts +++ b/apps/web/src/page/alerts/slo-form/view.ts @@ -1,3 +1,4 @@ +import { SLO_BURN_TIERS, windowLabel } from "@maple/domain/slo" import { Option, Array as Arr } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -98,8 +99,8 @@ const serviceSection = (h: H, model: Model): Html => { defaultValue: current, className: "w-full max-w-xs", attributes: [ - // Keyed on the services: the select is uncontrolled, so a late list must rebuild it. - h.Key(`slo-service-${names.length}-${current}`), + // Keyed on the list length: a late list rebuilds the uncontrolled select, a pick keeps focus. + h.Key(`slo-service-${names.length}`), h.Id(fieldId("serviceName")), h.OnChange((value) => Message.ChangedText({ field: "serviceName", value })), ], @@ -230,14 +231,20 @@ const windowSection = (h: H, form: Form): Html => }), ]) -const TIERS: ReadonlyArray<{ tier: Tier; title: string; rule: string }> = [ - { tier: "critical", title: "Critical", rule: "Pages when the budget burns at 14.4x over 1h and 5m." }, - { - tier: "warning", - title: "Warning", - rule: "Opens a ticket at 6x over 6h and 30m. A critical incident notifies these too.", - }, -] +const TIER_COPY = { + critical: { title: "Critical", role: "Pages", note: "" }, + warning: { title: "Warning", role: "Opens a ticket", note: " A critical incident notifies these too." }, +} as const satisfies Record> + +/** Each tier's line, from the factors and windows the evaluator uses. */ +const TIERS = Arr.map(SLO_BURN_TIERS, (tier) => { + const copy = TIER_COPY[tier.severity] + return { + tier: tier.severity, + title: copy.title, + rule: `${copy.role} when the budget burns at ${tier.factor}x over ${windowLabel(tier.longWindowMinutes)} and ${windowLabel(tier.shortWindowMinutes)}.${copy.note}`, + } +}) const tierPicker = (h: H, model: Model, entry: (typeof TIERS)[number]): Html => vstack(h, { gap: "1.5", attributes: [h.Key(entry.tier)] }, [ diff --git a/apps/web/src/page/alerts/slo/slo.scene.test.ts b/apps/web/src/page/alerts/slo/slo.scene.test.ts index bd883c9fd2..1bfad5b5c0 100644 --- a/apps/web/src/page/alerts/slo/slo.scene.test.ts +++ b/apps/web/src/page/alerts/slo/slo.scene.test.ts @@ -152,6 +152,44 @@ describe("SLO page", () => { ) }) + test("an outage holds both tiers: the open incident still leads, its severity stays, nothing reads quiet", () => { + const incident = incidentRow({ rule_id: SLO_RULE_UUID, signal_type: "slo_burn_rate", status: "open" }) + const model = pageUpdate( + withStatus("holding"), + Message.UpdatedIncidents({ orgId: ORG, rows: [incident] }), + ).model + scene( + { update: pageUpdate, view }, + given(model), + themeResolved(), + expectView( + within(callout, text("Holding: no events in 5m, an open incident stays open")), + ).toExist(), + expectView(within(callout, role("link", { name: "Open incident" }))).toHaveAttr( + "href", + `/alerts/incidents/${incident.id}`, + ), + expectView(within(role("heading", { name: /Checkout API/u }), text("Critical"))).toExist(), + expectView( + within(burnPanel, text("Holding: no events in 30m, an open incident stays open.")), + ).toExist(), + expectView(within(burnPanel, text("Quiet"))).not.toExist(), + expectView(within(burnPanel, text("Would fire"))).not.toExist(), + ) + }) + + test("a holding tier without an open incident shows in the burn panel, not as a callout", () => { + scene( + { update: pageUpdate, view }, + given(withStatus("holding")), + themeResolved(), + expectView(callout).not.toExist(), + expectView( + within(burnPanel, text("Holding: no events in 5m, an open incident stays open.")), + ).toExist(), + ) + }) + test("a spent budget reads negative and says the SLO is out of budget", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/slo/view-panels.ts b/apps/web/src/page/alerts/slo/view-panels.ts index b8ad83fa99..7ae0386fdc 100644 --- a/apps/web/src/page/alerts/slo/view-panels.ts +++ b/apps/web/src/page/alerts/slo/view-panels.ts @@ -1,4 +1,5 @@ import type { AlertSeverity } from "@maple/domain/http" +import { SLO_BURN_WINDOWS_MINUTES, windowLabel } from "@maple/domain/slo" import type { AlertIncidentRow } from "@maple/web-data/collections/alerts" import { Option, Array as Arr } from "effect" import { AsyncData } from "foldkit" @@ -35,9 +36,11 @@ import { type Slo, type SloStatus, sliTone, + holdingLine, + isBudgetSpent, sloIncidents, + type TierReading, tierStates, - windowLabel, } from "../shared/slo-derive" import { toneText } from "../shared/slo-view" import { isoMs } from "../shared/time" @@ -85,10 +88,9 @@ export const figures = (h: H, model: Model, slo: Slo): Html => { value: formatBudget(status.budget_remaining), tone: budgetTone(status.budget_remaining), compact: true, - subline: - status.budget_remaining <= 0 - ? `Spent: ${formatBudget(-status.budget_remaining)} over budget` - : `Of the ${slo.window_days}-day budget`, + subline: isBudgetSpent(status.budget_remaining) + ? `Spent: ${formatBudget(-status.budget_remaining)} over budget` + : `Of the ${slo.window_days}-day budget`, }), statRailItem(h, { eyebrow: "Events counted", @@ -243,8 +245,6 @@ export const sliPanel = (h: H, model: Model, slo: Slo, context: ChartContext | n // BURN RATES -const BURN_WINDOWS = [5, 30, 60, 360] as const - const SEVERITY_LABEL = { critical: "Critical", warning: "Warning" } as const satisfies Record< AlertSeverity, string @@ -286,8 +286,27 @@ const burnWindowRow = (h: H, status: SloStatus, minutes: number): Html => { ) } -const tierRow = (h: H, status: SloStatus, state: ReturnType[number]): Html => { - const { tier, isFiring } = state +/** "Would fire" while both windows burn, "Holding" while the short window saw nothing, else "Quiet". */ +const tierVerdict = (h: H, reading: TierReading): Html => { + const { tier, state } = reading + if (state === "clear") return typography(h, { size: "xs", tone: "muted" }, ["Quiet"]) + const isCritical = tier.severity === "critical" + return hstack(h, { gap: "1.5", as: "span", className: "shrink-0" }, [ + statusDot(h, { tone: state === "hold" ? "neutral" : isCritical ? "crit" : "warn" }), + typography( + h, + { + size: "xs", + weight: "medium", + tone: state === "hold" ? "soft" : isCritical ? "error" : "warning", + }, + [state === "hold" ? "Holding" : "Would fire"], + ), + ]) +} + +const tierRow = (h: H, status: SloStatus, reading: TierReading): Html => { + const { tier, state } = reading const long = burnOf(status, tier.longWindowMinutes) const short = burnOf(status, tier.shortWindowMinutes) return h.div( @@ -301,23 +320,12 @@ const tierRow = (h: H, status: SloStatus, state: ReturnType[n `${tier.factor}x over ${windowLabel(tier.longWindowMinutes)} and ${windowLabel(tier.shortWindowMinutes)}`, ]), ]), - isFiring - ? hstack(h, { gap: "1.5", as: "span", className: "shrink-0" }, [ - statusDot(h, { tone: tier.severity === "critical" ? "crit" : "warn" }), - typography( - h, - { - size: "xs", - weight: "medium", - tone: tier.severity === "critical" ? "error" : "warning", - }, - ["Would fire"], - ), - ]) - : typography(h, { size: "xs", tone: "muted" }, ["Quiet"]), + tierVerdict(h, reading), ]), typography(h, { size: "xs", tone: "muted", mono: true, numeric: true, as: "p" }, [ - `${windowLabel(tier.longWindowMinutes)} at ${formatBurn(long)}, ${windowLabel(tier.shortWindowMinutes)} at ${formatBurn(short)}. ${SEVERITY_LABEL[tier.severity]} ${TIER_ROLE[tier.severity]} when both reach ${tier.factor}x.`, + state === "hold" + ? `${holdingLine(tier)}.` + : `${windowLabel(tier.longWindowMinutes)} at ${formatBurn(long)}, ${windowLabel(tier.shortWindowMinutes)} at ${formatBurn(short)}. ${SEVERITY_LABEL[tier.severity]} ${TIER_ROLE[tier.severity]} when both reach ${tier.factor}x.`, ]), ]), ], @@ -337,7 +345,7 @@ export const burnPanel = (h: H, model: Model): Html => h.div( [], [ - ...Arr.map(BURN_WINDOWS, (minutes) => burnWindowRow(h, status, minutes)), + ...Arr.map(SLO_BURN_WINDOWS_MINUTES, (minutes) => burnWindowRow(h, status, minutes)), ...Arr.map(tierStates(status), (state) => tierRow(h, status, state)), ], ), diff --git a/apps/web/src/page/alerts/slo/view.ts b/apps/web/src/page/alerts/slo/view.ts index a91d2d25f3..e0e92655b2 100644 --- a/apps/web/src/page/alerts/slo/view.ts +++ b/apps/web/src/page/alerts/slo/view.ts @@ -1,3 +1,5 @@ +import type { AlertSeverity } from "@maple/domain/http" +import { windowLabel } from "@maple/domain/slo" import { Option } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -26,13 +28,15 @@ import { formatBurn, formatTarget, formatWindow, + holdingLine, + holdingTier, + isBudgetSpent, openIncident, scopeLine, sliDescription, sliLabel, type Slo, type SloStatus, - windowLabel, } from "../shared/slo-derive" import { Message } from "./message" import { canManage, type Model } from "./model" @@ -89,32 +93,65 @@ const callout = (h: H, model: Model, slo: Slo, status: SloStatus): Html | null = ]), ], }) - return Option.match(firingTier(status), { - onSome: (tier) => - alert(h, { variant: tier.severity === "critical" ? "crit" : "warn" }, [ + const firing = Option.map(firingTier(status), (tier) => + alert(h, { variant: tier.severity === "critical" ? "crit" : "warn" }, [ + icon(h, "FireIcon"), + alertTitle(h, [ + `Burning the error budget at ${formatBurn(burnOf(status, tier.longWindowMinutes))} over ${windowLabel(tier.longWindowMinutes)}`, + ]), + alertDescription(h, [ + `At this rate the ${slo.window_days}-day budget runs out early. The ${tier.severity} tier fires at ${tier.factor}x over both ${windowLabel(tier.longWindowMinutes)} and ${windowLabel(tier.shortWindowMinutes)}.`, + ]), + ...action, + ]), + ) + // An outage empties the short window: the tier holds, so the open incident leads the page. + const holding = Option.flatMap(holdingTier(status), (tier) => + Option.map(incident, (row) => + alert(h, { variant: row.severity === "critical" ? "crit" : "warn" }, [ icon(h, "FireIcon"), - alertTitle(h, [ - `Burning the error budget at ${formatBurn(burnOf(status, tier.longWindowMinutes))} over ${windowLabel(tier.longWindowMinutes)}`, - ]), + alertTitle(h, [holdingLine(tier)]), alertDescription(h, [ - `At this rate the ${slo.window_days}-day budget runs out early. The ${tier.severity} tier fires at ${tier.factor}x over both ${windowLabel(tier.longWindowMinutes)} and ${windowLabel(tier.shortWindowMinutes)}.`, + `The ${tier.severity} tier cannot judge the burn without events, so it neither fires nor resolves. Check whether ${slo.service_name} is down or has stopped reporting.`, ]), ...action, ]), - onNone: () => - status.budget_remaining <= 0 - ? alert(h, { variant: "crit" }, [ - icon(h, "CircleXmarkIcon"), - alertTitle(h, ["Error budget spent"]), - alertDescription(h, [ - `The last ${formatWindow(slo.window_days)} saw more bad events than ${formatTarget(slo)} allows: ${formatBudget(-status.budget_remaining)} over budget. It recovers as bad hours leave the window.`, - ]), - ...action, - ]) - : null, - }) + ), + ) + const spent = isBudgetSpent(status.budget_remaining) + ? Option.some( + alert(h, { variant: "crit" }, [ + icon(h, "CircleXmarkIcon"), + alertTitle(h, ["Error budget spent"]), + alertDescription(h, [ + `The last ${formatWindow(slo.window_days)} saw more bad events than ${formatTarget(slo)} allows: ${formatBudget(-status.budget_remaining)} over budget. It recovers as bad hours leave the window.`, + ]), + ...action, + ]), + ) + : Option.none() + return Option.getOrNull( + Option.orElse( + Option.orElse(firing, () => holding), + () => spent, + ), + ) } +/** The header's severity: the firing tier, or the open incident's while a tier holds it open. */ +const headerSeverity = (model: Model, slo: Slo): Option.Option => + Option.flatMap(statusOf(model), (status) => + Option.orElse( + Option.map(firingTier(status), (tier) => tier.severity), + () => + Option.flatMap(holdingTier(status), () => + Option.map(openIncident(slo, model.incidents ?? []), (row) => + row.severity === "critical" ? "critical" : "warning", + ), + ), + ), + ) + const serviceHref = (slo: Slo): string => hrefOf(AppRoute.Service({ serviceName: slo.service_name, search: {} })) @@ -190,7 +227,7 @@ const page = (h: H, model: Model, inputs: PageViewInputs): Html => { return Option.match(sloOf(model), { onNone: () => loadingPage(h), onSome: (slo) => { - const tier = Option.flatMap(statusOf(model), firingTier) + const severity = headerSeverity(model, slo) const title = { kind: ["SLO", sliLabel(slo.sli)], title: [slo.name], @@ -216,10 +253,9 @@ const page = (h: H, model: Model, inputs: PageViewInputs): Html => { return dashboardPage( h, { - titleContent: Option.match(tier, { + titleContent: Option.match(severity, { onNone: () => detailTitle(h, title), - onSome: (firing) => - detailTitle(h, { ...title, adornment: severityBadge(h, firing.severity) }), + onSome: (level) => detailTitle(h, { ...title, adornment: severityBadge(h, level) }), }), actions: headerActions(h, model, slo), gap: "md", diff --git a/apps/web/src/page/alerts/uptime-form/update.ts b/apps/web/src/page/alerts/uptime-form/update.ts index 1b01551f02..30979ed494 100644 --- a/apps/web/src/page/alerts/uptime-form/update.ts +++ b/apps/web/src/page/alerts/uptime-form/update.ts @@ -6,6 +6,7 @@ import { requestKey } from "@maple/views/data/request-key" import type { AppRoute } from "../../../route" import * as Menu from "@maple/views/ui/menu" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" +import { pickedDestination } from "../../kit/destination-picker" import { monitorHref, monitorIdFromUrl } from "../shared/uptime-commands" import { FetchExistingMonitor, @@ -120,20 +121,13 @@ const updateDestinationMenu = (model: Model, message: Menu.Message): Return => { Message.GotDestinationMenuMessage({ message: child }), ) const next = { ...model, destinationMenu: result.model } - const picked = result.outMessage?._tag === "SelectedItem" ? result.outMessage.value : null - const isKnown = Arr.some(model.destinations ?? [], (destination) => destination.id === picked) - return Option.match(isKnown ? decodeDestinationId(picked) : Option.none(), { + const picked = pickedDestination(result.outMessage, model.destinations, model.form.destinationIds) + return Option.match(Option.flatMap(picked, decodeDestinationId), { onNone: () => ({ model: next, commands }), - onSome: (id) => - model.form.destinationIds.includes(id) - ? { model: next, commands } - : { - model: { - ...next, - form: { ...next.form, destinationIds: [...next.form.destinationIds, id] }, - }, - commands, - }, + onSome: (id) => ({ + model: { ...next, form: { ...next.form, destinationIds: [...next.form.destinationIds, id] } }, + commands, + }), }) } diff --git a/packages/web-data/src/alerts/incident-timeline.test.ts b/packages/web-data/src/alerts/incident-timeline.test.ts index a8f47bbf17..9ea1356856 100644 --- a/packages/web-data/src/alerts/incident-timeline.test.ts +++ b/packages/web-data/src/alerts/incident-timeline.test.ts @@ -1,5 +1,5 @@ import { AlertDeliveryEventDocument, AlertIncidentDocument } from "@maple/domain/http" -import { Schema, Array as Arr } from "effect" +import { Option, Schema, Array as Arr } from "effect" import { describe, expect, it } from "vitest" import { buildIncidentTimeline } from "./incident-timeline" @@ -69,6 +69,25 @@ const build = (incident: AlertIncidentDocument, deliveries: ReadonlyArray { + it("opens a resolved SLO incident at its tier factor, not the healthy burn it ended on", () => { + const entries = build( + makeIncident({ + ruleName: "SLO burn: Checkout API", + signalType: "slo_burn_rate", + comparator: "gte", + threshold: 14.4, + lastObservedValue: 0.3, + groupKey: "__total__", + status: "resolved", + resolvedAt: "2026-10-15T15:00:00.000Z", + }), + ) + const opened = Arr.findFirst(entries, (entry) => entry.key === "opened") + expect(Option.getOrNull(Option.map(opened, (entry) => entry.detail))).toBe( + "SLO burning its error budget at 14.4x or more", + ) + }) + it("orders the open, deliveries and current state chronologically", () => { const entries = build(makeIncident(), [ makeDelivery({ diff --git a/packages/web-data/src/alerts/incident-timeline.ts b/packages/web-data/src/alerts/incident-timeline.ts index 1a54245c50..a7d927609d 100644 --- a/packages/web-data/src/alerts/incident-timeline.ts +++ b/packages/web-data/src/alerts/incident-timeline.ts @@ -170,12 +170,12 @@ export function buildIncidentTimeline(input: { }): ReadonlyArray { const { incident } = input const group = incidentGroup(incident) - // An uptime rule has no threshold to read out: the monitor's checks failed. + // An uptime rule has no threshold to read out; an SLO's reads its tier factor, not today's burn. const condition = incident.signalType === "uptime" ? `${incident.ruleName} went down` : incident.signalType === "slo_burn_rate" - ? `SLO burning its error budget at ${formatSignalValue(incident.signalType, incident.lastObservedValue)}` + ? `SLO burning its error budget at ${formatSignalValue(incident.signalType, incident.threshold)} or more` : `${signalTitle(incident.signalType)} ${conditionPhrase(incident)}` const opened: IncidentTimelineEntry = { diff --git a/packages/web-data/src/alerts/overview-derive.ts b/packages/web-data/src/alerts/overview-derive.ts index e3f9c23fc8..6d98eda686 100644 --- a/packages/web-data/src/alerts/overview-derive.ts +++ b/packages/web-data/src/alerts/overview-derive.ts @@ -1,4 +1,5 @@ import type { AlertIncidentDocument, AlertRuleDocument } from "@maple/domain/http" +import { sloNameFromRuleName } from "@maple/domain/slo" import { comparatorLabels, @@ -55,9 +56,7 @@ const isUptime = (incident: Pick) => incide const isSlo = (incident: Pick) => incident.signalType === "slo_burn_rate" /** The rule backing an SLO is named `SLO burn: `; the SLO's own name reads better. */ -const SLO_RULE_PREFIX = "SLO burn: " -export const sloNameOf = (ruleName: string): string => - Str.startsWith(SLO_RULE_PREFIX)(ruleName) ? Str.slice(SLO_RULE_PREFIX.length)(ruleName) : ruleName +export const sloNameOf = (ruleName: string): string => sloNameFromRuleName(ruleName) ?? ruleName /** Raw SQL and builder rules: "Raw SQL 5" says nothing, so the rule name is what fired. */ export const isQueryIncident = (incident: Pick) => From b78b1ef8f9bf5ca18e457269fd37a932c229745a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:37:11 +0100 Subject: [PATCH 03/11] fix(slos): the lab's holding state has an open incident --- apps/web/src/lab/slos-lab.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lab/slos-lab.ts b/apps/web/src/lab/slos-lab.ts index 0da7dd94e0..72a8d02602 100644 --- a/apps/web/src/lab/slos-lab.ts +++ b/apps/web/src/lab/slos-lab.ts @@ -177,7 +177,7 @@ const sloFor = (state: LabState) => { SloMessage.Message.UpdatedIncidents({ orgId: ORG, rows: - state === "burning" + state === "burning" || state === "holding" ? [INCIDENT] : [ { From 55eb73dd11870e60b9eb967872329e50e2a1ece6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 02:54:10 +0100 Subject: [PATCH 04/11] chore(slos): drop two unused UI exports --- apps/web/src/page/alerts/shared/slo-derive.ts | 6 ------ apps/web/src/page/alerts/shared/slo-test-fixtures.ts | 1 - 2 files changed, 7 deletions(-) diff --git a/apps/web/src/page/alerts/shared/slo-derive.ts b/apps/web/src/page/alerts/shared/slo-derive.ts index 2bff3e6f7d..89a779b2eb 100644 --- a/apps/web/src/page/alerts/shared/slo-derive.ts +++ b/apps/web/src/page/alerts/shared/slo-derive.ts @@ -65,12 +65,6 @@ export const budgetTone = (budget: number): Tone => export const sliTone = (slo: Pick, sli: number | null): Tone => sli === null ? "neutral" : sli * 100 >= slo.target_percent ? "ok" : "crit" -/** The tier colours follow the alert pages: critical red, warning amber. */ -export const TIER_TONE = { critical: "crit", warning: "warn" } as const satisfies Record< - SloTier["severity"], - Tone -> - export const burnTone = (rate: number): Tone => rate >= (SLO_BURN_TIERS[0]?.factor ?? Infinity) ? "crit" diff --git a/apps/web/src/page/alerts/shared/slo-test-fixtures.ts b/apps/web/src/page/alerts/shared/slo-test-fixtures.ts index 1a80a30254..085962aa81 100644 --- a/apps/web/src/page/alerts/shared/slo-test-fixtures.ts +++ b/apps/web/src/page/alerts/shared/slo-test-fixtures.ts @@ -7,7 +7,6 @@ import { DESTINATION_ID, NOW, ruleId } from "./test-rows" export const sloUuid = (n: number) => `0199a00a-0000-7000-8000-00000000000${n}` /** The burn-rate rule behind the default SLO; its incidents carry it as `rule_id`. */ export const SLO_RULE_UUID = ruleId(8) -export const WARNING_DESTINATION_UUID = "0199a001-0000-7000-8000-000000000002" const decodeSlo = Schema.decodeUnknownSync(V2Slo) const decodeStatus = Schema.decodeUnknownSync(V2SloStatus) From 3723ddbde16c06b6b6239666fa987531bab50369 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 03:05:59 +0100 Subject: [PATCH 05/11] perf(slos): the SLOs tab reads every status in one request --- .../src/page/alerts/shared/slo-commands.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/web/src/page/alerts/shared/slo-commands.ts b/apps/web/src/page/alerts/shared/slo-commands.ts index cdaeee9e82..ae9de12599 100644 --- a/apps/web/src/page/alerts/shared/slo-commands.ts +++ b/apps/web/src/page/alerts/shared/slo-commands.ts @@ -7,7 +7,7 @@ import { type V2SloStatus, V2SloUpdateParams, } from "@maple/domain/http/v2" -import { Effect, Option, Schema } from "effect" +import { Array as Arr, Effect, Option, Schema } from "effect" import { toLoadFailure } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" import { AppRoute, hrefOf } from "../../../route" @@ -18,9 +18,6 @@ import type { AlertsSloNewSearch } from "../../../route/search" /** One page holds every SLO: the API caps an org at 50. */ export const SLO_LIST_LIMIT = 100 -/** How many status reads the SLOs tab runs at once. */ -const STATUS_CONCURRENCY = 6 - /** `slos.list`: the org's SLOs, newest first, in a single read. */ export const listSlos = Effect.gen(function* () { const client = yield* MapleApiV2Client @@ -34,19 +31,22 @@ export interface SloListRow { readonly status: V2SloStatus | null } -/** The SLOs tab: the list, then each SLO's status, a failed status leaving its row without figures. */ +/** The SLOs tab: the list and every status in one batched read; a failed status read leaves the rows without figures. */ export const listSloRows = Effect.gen(function* () { const client = yield* MapleApiV2Client - const slos = yield* listSlos - return yield* Effect.forEach( - slos, - (slo) => - client.slos.status({ params: { id: slo.id } }).pipe( - Effect.map((status): SloListRow => ({ slo, status })), - Effect.orElseSucceed((): SloListRow => ({ slo, status: null })), + const [slos, statuses] = yield* Effect.all( + [ + listSlos, + client.slos.statuses().pipe( + Effect.map( + (list) => new Map(Arr.map(list.data, (status) => [status.slo_id, status] as const)), + ), + Effect.orElseSucceed(() => new Map()), ), - { concurrency: STATUS_CONCURRENCY }, + ], + { concurrency: 2 }, ) + return Arr.map(slos, (slo): SloListRow => ({ slo, status: statuses.get(slo.id) ?? null })) }) export const retrieveSlo = (id: SloId) => From 35af8c7946dab8996c47c5786ea9323e4474b839 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 03:11:03 +0100 Subject: [PATCH 06/11] fix(slos): each history panel names its own chart; a zero budget reads fully spent --- apps/web/src/page/alerts/shared/slo-derive.ts | 4 ++ .../web/src/page/alerts/slo/slo.scene.test.ts | 40 +++++++++++++++++++ apps/web/src/page/alerts/slo/view-panels.ts | 12 ++++-- apps/web/src/page/alerts/slo/view.ts | 4 +- 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/web/src/page/alerts/shared/slo-derive.ts b/apps/web/src/page/alerts/shared/slo-derive.ts index 89a779b2eb..5557ccc0d2 100644 --- a/apps/web/src/page/alerts/shared/slo-derive.ts +++ b/apps/web/src/page/alerts/shared/slo-derive.ts @@ -58,6 +58,10 @@ export const formatEvents = (count: number): string => /** A spent budget: none left, or overspent. */ export const isBudgetSpent = (budget: number): boolean => budget <= 0 +/** How far past the budget a spent SLO is: "fully spent" at exactly 0, else the overspend. */ +export const overspentText = (budget: number): string => + budget < 0 ? `${formatBudget(-budget)} over budget` : "fully spent" + /** Spent is red, under a quarter left amber, anything else neutral. */ export const budgetTone = (budget: number): Tone => isBudgetSpent(budget) ? "crit" : budget < 0.25 ? "warn" : "ok" diff --git a/apps/web/src/page/alerts/slo/slo.scene.test.ts b/apps/web/src/page/alerts/slo/slo.scene.test.ts index 1bfad5b5c0..383f8c111a 100644 --- a/apps/web/src/page/alerts/slo/slo.scene.test.ts +++ b/apps/web/src/page/alerts/slo/slo.scene.test.ts @@ -201,6 +201,46 @@ describe("SLO page", () => { ) }) + test("a budget spent to exactly zero reads fully spent, not 0% over budget", () => { + const zero = opened(checkoutSlo(), (model) => [ + Message.CompletedFetchStatus({ + key: model.status.key ?? "", + result: Result.succeed({ ...sloStatus("exhausted"), budget_remaining: 0 }), + }), + ]) + scene( + { update: pageUpdate, view }, + given(zero), + themeResolved(), + expectView(text("Fully spent")).toExist(), + expectView(text("0% over budget")).not.toExist(), + ) + }) + + test("a failed history read names its own chart in each panel", () => { + const failed = opened(checkoutSlo(), (model) => [ + Message.CompletedFetchStatus({ + key: model.status.key ?? "", + result: Result.succeed(sloStatus("healthy")), + }), + Message.CompletedFetchHistory({ + key: model.history.key ?? "", + result: Result.fail(toLoadFailure(new Error("warehouse down"))), + }), + ]) + scene( + { update: pageUpdate, view }, + given(failed), + themeResolved(), + expectView( + within(role("region", { name: "Error budget" }), text("Couldn't load the budget history")), + ).toExist(), + expectView( + within(role("region", { name: "SLI" }), text("Couldn't load the SLI history")), + ).toExist(), + ) + }) + test("a failed status read says so in place of the figures, with a retry that reads it again", () => { const failed = opened(checkoutSlo(), (model) => [ Message.CompletedFetchStatus({ diff --git a/apps/web/src/page/alerts/slo/view-panels.ts b/apps/web/src/page/alerts/slo/view-panels.ts index 7ae0386fdc..424fe26d89 100644 --- a/apps/web/src/page/alerts/slo/view-panels.ts +++ b/apps/web/src/page/alerts/slo/view-panels.ts @@ -41,6 +41,7 @@ import { sloIncidents, type TierReading, tierStates, + overspentText, } from "../shared/slo-derive" import { toneText } from "../shared/slo-view" import { isoMs } from "../shared/time" @@ -89,7 +90,9 @@ export const figures = (h: H, model: Model, slo: Slo): Html => { tone: budgetTone(status.budget_remaining), compact: true, subline: isBudgetSpent(status.budget_remaining) - ? `Spent: ${formatBudget(-status.budget_remaining)} over budget` + ? status.budget_remaining < 0 + ? `Spent: ${overspentText(status.budget_remaining)}` + : "Fully spent" : `Of the ${slo.window_days}-day budget`, }), statRailItem(h, { @@ -160,13 +163,14 @@ const historyBody = ( h: H, model: Model, context: ChartContext | null, + failureTitle: string, draw: (points: NonNullable>, context: ChartContext) => Html, ): Html => { const data = model.history.data if (AsyncData.isFailure(data)) return errorState(h, { failure: data.error, - title: "Couldn't load the budget history", + title: failureTitle, onRetry: Message.ClickedRetryHistory(), className: "py-8", }) @@ -196,7 +200,7 @@ export const budgetPanel = (h: H, model: Model, slo: Slo, context: ChartContext ]), }), panelChartBody(h, [ - historyBody(h, model, context, (points, ready) => + historyBody(h, model, context, "Couldn't load the budget history", (points, ready) => chartSlot(h, { slotId: "slo-budget-chart", chartId: "slo-budget", @@ -222,7 +226,7 @@ export const sliPanel = (h: H, model: Model, slo: Slo, context: ChartContext | n action: typography(h, { size: "xs", tone: "muted" }, [`Hourly, target ${formatTarget(slo)}`]), }), panelChartBody(h, [ - historyBody(h, model, context, (points, ready) => + historyBody(h, model, context, "Couldn't load the SLI history", (points, ready) => chartSlot(h, { slotId: "slo-sli-chart", chartId: "slo-sli", diff --git a/apps/web/src/page/alerts/slo/view.ts b/apps/web/src/page/alerts/slo/view.ts index e0e92655b2..586e551a97 100644 --- a/apps/web/src/page/alerts/slo/view.ts +++ b/apps/web/src/page/alerts/slo/view.ts @@ -24,7 +24,6 @@ import { editSloHref, SLO_NOT_FOUND_CODE, slosTabHref } from "../shared/slo-comm import { burnOf, firingTier, - formatBudget, formatBurn, formatTarget, formatWindow, @@ -37,6 +36,7 @@ import { sliLabel, type Slo, type SloStatus, + overspentText, } from "../shared/slo-derive" import { Message } from "./message" import { canManage, type Model } from "./model" @@ -124,7 +124,7 @@ const callout = (h: H, model: Model, slo: Slo, status: SloStatus): Html | null = icon(h, "CircleXmarkIcon"), alertTitle(h, ["Error budget spent"]), alertDescription(h, [ - `The last ${formatWindow(slo.window_days)} saw more bad events than ${formatTarget(slo)} allows: ${formatBudget(-status.budget_remaining)} over budget. It recovers as bad hours leave the window.`, + `The last ${formatWindow(slo.window_days)} used every bad event ${formatTarget(slo)} allows: ${overspentText(status.budget_remaining)}. It recovers as bad hours leave the window.`, ]), ...action, ]), From 6c6cff091ea06f13cb9b492b5e8f0fc171419474 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 03:28:50 +0100 Subject: [PATCH 07/11] test(slos): sample URLs for the SLO pages in the root view test --- apps/web/src/view.lazy.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index cf89e42b7a..8aea4963a0 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -42,6 +42,8 @@ const SAMPLE_PATHS = [ "/alerts/00000000-0000-4000-8000-000000000002", "/alerts/create", "/alerts/uptime/new", + "/alerts/slos/new", + "/alerts/slos/slo_YofPTrK9782DWwcnXhpcCw", "/alerts/uptime/upm_abc", "/status-pages", "/status-pages/new", From aff6110210af37ef37d91560c15f94c833540a19 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 03:38:21 +0100 Subject: [PATCH 08/11] fix(slos): polish the SLO pages at phone width and when sync is down - Incidents panel shows "Couldn't load incidents" with a reconnecting Retry once the incidents stream gives up, instead of a skeleton forever. - The form action bar wraps its blocking reason; on phones it sits on its own line under the buttons. - The SLO description moves out of the pinned header into the body. - Burn windows read "1 event", not "1 events". - Error-budget chart tops out at 100% and labels zero "0%": the axis spec gains a hard `max` and a `zeroLabel`. --- apps/web/src/lab/slos-lab.ts | 35 ++++----- apps/web/src/page/alerts/shared/slo-derive.ts | 6 ++ .../alerts/slo-form/slo-form.scene.test.ts | 22 +++++- apps/web/src/page/alerts/slo/index.ts | 19 ++++- apps/web/src/page/alerts/slo/message.ts | 2 + apps/web/src/page/alerts/slo/model.ts | 2 + .../web/src/page/alerts/slo/slo.scene.test.ts | 71 +++++++++++++++++++ apps/web/src/page/alerts/slo/update.ts | 14 +++- apps/web/src/page/alerts/slo/view-panels.ts | 30 +++++++- apps/web/src/page/alerts/slo/view.ts | 13 ++-- apps/web/src/page/kit/form-section.ts | 18 +++-- packages/views/src/charts/chart-spec.ts | 4 ++ packages/views/src/charts/definitions/area.ts | 2 + packages/views/src/charts/definitions/bar.ts | 2 + .../src/charts/timeseries-y-axis.test.ts | 28 ++++++++ packages/views/src/charts/timeseries.ts | 39 ++++++++-- 16 files changed, 264 insertions(+), 43 deletions(-) create mode 100644 packages/views/src/charts/timeseries-y-axis.test.ts diff --git a/apps/web/src/lab/slos-lab.ts b/apps/web/src/lab/slos-lab.ts index 72a8d02602..94669d26a7 100644 --- a/apps/web/src/lab/slos-lab.ts +++ b/apps/web/src/lab/slos-lab.ts @@ -33,8 +33,8 @@ import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" // `/lab/slos`: the SLOs tab, the SLO page and the SLO form over fixtures. `?state=` picks one: -// tab-empty, tab, loading, healthy, burning, holding, exhausted, status-error, form-new, form-invalid, -// form-edit. +// tab-empty, tab, loading, healthy, burning, holding, exhausted, status-error, incidents-unavailable, +// form-new, form-invalid, form-edit. export const LabState = Schema.Literals([ "tab-empty", @@ -45,6 +45,7 @@ export const LabState = Schema.Literals([ "holding", "exhausted", "status-error", + "incidents-unavailable", "form-new", "form-invalid", "form-edit", @@ -174,20 +175,22 @@ const sloFor = (state: LabState) => { result: Result.succeed(sloHistory(scenario)), }), SloMessage.Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), - SloMessage.Message.UpdatedIncidents({ - orgId: ORG, - rows: - state === "burning" || state === "holding" - ? [INCIDENT] - : [ - { - ...INCIDENT, - status: "resolved", - severity: "warning", - resolved_at: INCIDENT.last_triggered_at, - }, - ], - }), + state === "incidents-unavailable" + ? SloMessage.Message.FailedLoadIncidents({ orgId: ORG }) + : SloMessage.Message.UpdatedIncidents({ + orgId: ORG, + rows: + state === "burning" || state === "holding" + ? [INCIDENT] + : [ + { + ...INCIDENT, + status: "resolved", + severity: "warning", + resolved_at: INCIDENT.last_triggered_at, + }, + ], + }), ], (model, message) => SloUpdate.update(model, message, CONTEXT), ) diff --git a/apps/web/src/page/alerts/shared/slo-derive.ts b/apps/web/src/page/alerts/shared/slo-derive.ts index 5557ccc0d2..d2bd7e928e 100644 --- a/apps/web/src/page/alerts/shared/slo-derive.ts +++ b/apps/web/src/page/alerts/shared/slo-derive.ts @@ -55,6 +55,12 @@ export const formatEvents = (count: number): string => ? `${Number((count / 1000).toFixed(1))}k` : String(Math.round(count)) +/** "1 event", "12.4k events": the count with its noun, singular only when it reads 1. */ +export const formatEventCount = (count: number): string => { + const shown = formatEvents(count) + return `${shown} ${shown === "1" ? "event" : "events"}` +} + /** A spent budget: none left, or overspent. */ export const isBudgetSpent = (budget: number): boolean => budget <= 0 diff --git a/apps/web/src/page/alerts/slo-form/slo-form.scene.test.ts b/apps/web/src/page/alerts/slo-form/slo-form.scene.test.ts index 76eef5d7cd..206112c7d2 100644 --- a/apps/web/src/page/alerts/slo-form/slo-form.scene.test.ts +++ b/apps/web/src/page/alerts/slo-form/slo-form.scene.test.ts @@ -2,7 +2,17 @@ import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { SloPublicId } from "@maple/domain/http/v2" import { Result, Schema, Array as Arr } from "effect" -import { click, expect as expectView, given, role, scene, text, type, withViewInputs } from "foldkit/scene" +import { + click, + expect as expectView, + given, + role, + scene, + selector, + text, + type, + withViewInputs, +} from "foldkit/scene" import { describe, expect, test } from "vitest" import { toLoadFailure } from "@maple/views/data/load-failure" import * as Menu from "@maple/views/ui/menu" @@ -55,6 +65,16 @@ describe("SLO form", () => { ) }) + test("the save bar spells out every reason saving is blocked, not a cut-off line", () => { + scene( + { update: pageUpdate, view }, + given(fresh()), + expectView(selector('[aria-label="Fix before saving"]')).toHaveText( + "Fix before saving: Name the SLO. Pick a service.", + ), + ) + }) + test("a custom target outside 50% to 99.999% blocks saving until it is in range", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/slo/index.ts b/apps/web/src/page/alerts/slo/index.ts index 41de93da2f..81ea289470 100644 --- a/apps/web/src/page/alerts/slo/index.ts +++ b/apps/web/src/page/alerts/slo/index.ts @@ -1,7 +1,8 @@ -import { Schema } from "effect" +import { Schema, Stream } from "effect" import { Subscription } from "foldkit" import type { Resources } from "../../../platform/resources" import { definePage, type PageSubscriptionInput } from "../../contract" +import { collectionLoadFailed } from "../shared/collection-load" import { destinationRows, incidentRows } from "../shared/collections" import { Message } from "./message" import { Model } from "./model" @@ -21,6 +22,22 @@ const subscriptions = Subscription.make, Message, R dependenciesToStream: ({ orgId }) => incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), }), + /** A stopped or stalled incidents stream shows the failure in its panel, not a skeleton. */ + incidentsLoadFailed: entry( + { orgId: Schema.String, isWatching: Schema.Boolean }, + { + modelToDependencies: ({ model, context }) => ({ + orgId: context.orgId, + isWatching: model.incidents === null && !model.isIncidentsLoadFailed, + }), + dependenciesToStream: ({ orgId, isWatching }) => + isWatching + ? collectionLoadFailed(`alert_incidents:${orgId}`, () => + Message.FailedLoadIncidents({ orgId }), + ) + : Stream.empty, + }, + ), })) /** `/alerts/slos/$sloId`: one instance per SLO. */ diff --git a/apps/web/src/page/alerts/slo/message.ts b/apps/web/src/page/alerts/slo/message.ts index 6afb271ab8..d7dfd1c224 100644 --- a/apps/web/src/page/alerts/slo/message.ts +++ b/apps/web/src/page/alerts/slo/message.ts @@ -20,6 +20,8 @@ export const Message = defineMessageUnion({ ClickedRetryHistory: {}, UpdatedDestinations: { orgId: Schema.String, rows: Schema.Array(AlertDestinationRowSchema) }, UpdatedIncidents: { orgId: Schema.String, rows: Schema.Array(AlertIncidentRowSchema) }, + FailedLoadIncidents: { orgId: Schema.String }, + ClickedRetryIncidents: {}, CompletedFetchSession: { orgId: Schema.String, grants: Schema.Array(Policy.PermissionName) }, ClickedToggleAlerting: {}, CompletedToggleAlerting: { result: outcome(V2Slo) }, diff --git a/apps/web/src/page/alerts/slo/model.ts b/apps/web/src/page/alerts/slo/model.ts index 2d7b174ea6..3db5cb3ae1 100644 --- a/apps/web/src/page/alerts/slo/model.ts +++ b/apps/web/src/page/alerts/slo/model.ts @@ -25,6 +25,8 @@ export const Model = Schema.Struct({ /** Synced destinations (the alerting panel's names) and incidents (those of the SLO's rule). */ destinations: Rows(AlertDestinationRowSchema), incidents: Rows(AlertIncidentRowSchema), + /** The incidents stream stopped or stalled before its first answer (`collectionLoadFailed`). */ + isIncidentsLoadFailed: Schema.Boolean, grants: Schema.Array(Policy.PermissionName), isTogglingAlerting: Schema.Boolean, isDeleting: Schema.Boolean, diff --git a/apps/web/src/page/alerts/slo/slo.scene.test.ts b/apps/web/src/page/alerts/slo/slo.scene.test.ts index 383f8c111a..4ee5a761d0 100644 --- a/apps/web/src/page/alerts/slo/slo.scene.test.ts +++ b/apps/web/src/page/alerts/slo/slo.scene.test.ts @@ -259,6 +259,77 @@ describe("SLO page", () => { expect(names(pageUpdate(failed, Message.ClickedRetryStatus()).commands)).toEqual(["FetchSloStatus"]) }) + test("incidents that never sync say live sync is unavailable, and Retry reconnects", () => { + const waiting = opened(checkoutSlo(), (model) => [ + Message.CompletedFetchStatus({ + key: model.status.key ?? "", + result: Result.succeed(sloStatus("healthy")), + }), + ]) + const failed = pageUpdate(waiting, Message.FailedLoadIncidents({ orgId: ORG })).model + scene( + { update: pageUpdate, view }, + given(waiting), + themeResolved(), + expectView(within(incidentsPanel, text("Couldn't load incidents"))).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given(failed), + themeResolved(), + expectView(within(incidentsPanel, text("Couldn't load incidents"))).toExist(), + expectView(within(incidentsPanel, text("Live sync is unavailable."))).toExist(), + ) + expect(pageUpdate(failed, Message.ClickedRetryIncidents()).outMessage).toEqual( + PageOutMessage.RequestedReconnectOrgCollections(), + ) + // Rows that arrive late replace the failure; a failure after rows changes nothing. + const synced = pageUpdate(failed, Message.UpdatedIncidents({ orgId: ORG, rows: [] })).model + scene( + { update: pageUpdate, view }, + given(pageUpdate(synced, Message.FailedLoadIncidents({ orgId: ORG })).model), + themeResolved(), + expectView(within(incidentsPanel, text("No incidents yet"))).toExist(), + expectView(within(incidentsPanel, text("Couldn't load incidents"))).not.toExist(), + ) + }) + + test("a window that counted one event says 1 event", () => { + const status = sloStatus("healthy") + const one = opened(checkoutSlo(), (model) => [ + Message.CompletedFetchStatus({ + key: model.status.key ?? "", + result: Result.succeed({ + ...status, + burn_windows: Arr.map(status.burn_windows, (entry) => + entry.window_minutes === 5 ? { ...entry, total_events: 1 } : entry, + ), + }), + }), + ]) + scene( + { update: pageUpdate, view }, + given(one), + themeResolved(), + expectView(within(burnPanel, text("1 event"))).toExist(), + expectView(within(burnPanel, text("1 events"))).not.toExist(), + ) + }) + + test("the description scrolls with the body, out of the pinned header", () => { + scene( + { update: pageUpdate, view }, + given(withStatus("healthy")), + themeResolved(), + expectView( + within(selector('[data-slot="page-scroll-area"]'), text("Checkout requests succeed.")), + ).toExist(), + expectView( + within(selector('[data-slot="page-sticky-area"]'), text("Checkout requests succeed.")), + ).not.toExist(), + ) + }) + test("alerting lists who each tier notifies, and is said to be off when it is", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/slo/update.ts b/apps/web/src/page/alerts/slo/update.ts index fbbe5d5180..483a044e21 100644 --- a/apps/web/src/page/alerts/slo/update.ts +++ b/apps/web/src/page/alerts/slo/update.ts @@ -81,6 +81,7 @@ export const init = (route: SloRoute, context: PageContext): Return => { history: Keyed.idle(), destinations: null, incidents: null, + isIncidentsLoadFailed: false, grants: [], isTogglingAlerting: false, isDeleting: false, @@ -166,7 +167,18 @@ export const update = (model: Model, message: Message, context: PageContext): Re UpdatedDestinations: ({ orgId, rows }) => orgId === context.orgId ? { model: { ...model, destinations: rows } } : { model }, UpdatedIncidents: ({ orgId, rows }) => - orgId === context.orgId ? { model: { ...model, incidents: rows } } : { model }, + orgId === context.orgId + ? { model: { ...model, incidents: rows, isIncidentsLoadFailed: false } } + : { model }, + FailedLoadIncidents: ({ orgId }) => + orgId === context.orgId && model.incidents === null + ? { model: { ...model, isIncidentsLoadFailed: true } } + : { model }, + // `retryOrgCollections`: a stopped shape syncs again only on fresh collections. + ClickedRetryIncidents: () => ({ + model: { ...model, isIncidentsLoadFailed: false }, + outMessage: PageOutMessage.RequestedReconnectOrgCollections(), + }), CompletedFetchSession: ({ orgId, grants }) => orgId === context.orgId ? { model: { ...model, grants } } : { model }, ClickedToggleAlerting: () => toggleAlerting(model), diff --git a/apps/web/src/page/alerts/slo/view-panels.ts b/apps/web/src/page/alerts/slo/view-panels.ts index 424fe26d89..d0448319fd 100644 --- a/apps/web/src/page/alerts/slo/view-panels.ts +++ b/apps/web/src/page/alerts/slo/view-panels.ts @@ -1,6 +1,7 @@ import type { AlertSeverity } from "@maple/domain/http" import { SLO_BURN_WINDOWS_MINUTES, windowLabel } from "@maple/domain/slo" import type { AlertIncidentRow } from "@maple/web-data/collections/alerts" +import { makeClientErrorBody } from "@maple/web-data/lib/error-messages" import { Option, Array as Arr } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -29,6 +30,7 @@ import { budgetTone, formatBudget, formatBurn, + formatEventCount, formatEvents, formatSli, formatTarget, @@ -209,7 +211,12 @@ export const budgetPanel = (h: H, model: Model, slo: Slo, context: ChartContext bucket: point.at, "Budget left": point.budget_remaining, })), - axis: thresholdAxis(0, resolveColor(ready.theme, "var(--severity-error)"), "0%", false), + // Budget never passes 100% but can go negative: cap the top, leave the floor free. + axis: { + ...thresholdAxis(0, resolveColor(ready.theme, "var(--severity-error)"), "0%", false), + max: 1, + zeroLabel: "0%", + }, context: ready, toMessage: (message) => Message.GotBudgetChartMessage({ message }), }), @@ -270,7 +277,7 @@ const burnWindowRow = (h: H, status: SloStatus, minutes: number): Html => { typography(h, { size: "xs", tone: "muted", mono: true, numeric: true }, [ Option.match(window, { onNone: () => "–", - onSome: (entry) => `${formatEvents(entry.total_events)} events`, + onSome: (entry) => formatEventCount(entry.total_events), }), ]), typography( @@ -442,13 +449,30 @@ const incidentRow = (h: H, row: AlertIncidentRow, nowMs: number, timeZone: strin trailing: severityBadge(h, row.severity === "critical" ? "critical" : "warning"), }) +/** `SyncUnavailable`: the incidents stream stopped before its first answer. */ +const SYNC_UNAVAILABLE = makeClientErrorBody({ + _tag: "@maple/web/errors/SyncUnavailable", + code: "sync_unavailable", + title: "Couldn't load incidents", + message: "Live sync is unavailable.", + retryable: false, + recovery: "retry", +}) + /** The incidents of the SLO's burn-rate rule, newest first, each linking to its page. */ export const incidentsPanel = (h: H, model: Model, slo: Slo, timeZone: string): Html => { const rows = sloIncidents(slo, model.incidents ?? []) return panel(h, { attributes: [h.AriaLabel("Incidents")] }, [ panelHeader(h, { title: "Incidents" }), model.incidents === null - ? panelBody(h, [chartSkeleton(h, "bar")]) + ? model.isIncidentsLoadFailed + ? errorState(h, { + failure: SYNC_UNAVAILABLE, + title: "Couldn't load incidents", + onRetry: Message.ClickedRetryIncidents(), + className: "py-8", + }) + : panelBody(h, [chartSkeleton(h, "bar")]) : rows.length === 0 ? emptyMessage(h, { className: "px-4 py-4" }, [ slo.alerting.enabled ? "No incidents yet" : "No incidents: alerting is off", diff --git a/apps/web/src/page/alerts/slo/view.ts b/apps/web/src/page/alerts/slo/view.ts index 586e551a97..081728618b 100644 --- a/apps/web/src/page/alerts/slo/view.ts +++ b/apps/web/src/page/alerts/slo/view.ts @@ -161,7 +161,11 @@ const body = (h: H, model: Model, slo: Slo, timeZone: string): ReadonlyArray callout(h, model, slo, status))) + // The description scrolls with the body: the pinned header stays short on phones. return [ + ...(slo.description === null || slo.description === "" + ? [] + : [typography(h, { size: "sm", tone: "soft", as: "p" }, [slo.description])]), ...(lead === null ? [] : [lead]), figures(h, model, slo), h.div( @@ -239,15 +243,6 @@ const page = (h: H, model: Model, inputs: PageViewInputs): Html => { ), ` · ${formatTarget(slo)} over ${formatWindow(slo.window_days)} · ${sliDescription(slo.sli)}`, ]), - ...(slo.description === null || slo.description === "" - ? [] - : [ - typography( - h, - { size: "sm", tone: "soft", as: "p", className: "basis-full" }, - [slo.description], - ), - ]), ], } return dashboardPage( diff --git a/apps/web/src/page/kit/form-section.ts b/apps/web/src/page/kit/form-section.ts index 744d10e185..77aa561eb5 100644 --- a/apps/web/src/page/kit/form-section.ts +++ b/apps/web/src/page/kit/form-section.ts @@ -49,6 +49,7 @@ export const formCard = (h: HtmlBuilder, children: ReadonlyArray, cl /** * `FormActionBar`: pinned under a form's scroller, outside it, so it never floats over the form: * "Ready to save" or what still blocks saving, then the actions, with an optional notice above. + * The reason wraps rather than truncates; on phones it takes its own line under the actions. */ export const formActionBar = ( h: HtmlBuilder, @@ -67,7 +68,14 @@ export const formActionBar = ( [ ...(options.notice === undefined || options.notice === null ? [] : [options.notice]), h.div( - [h.Class(cn(hstackClass({ gap: "4", justify: "between" }), options.widthClass))], + [ + h.Class( + cn( + "flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4", + options.widthClass, + ), + ), + ], [ options.issues.length === 0 ? hstack( @@ -85,10 +93,10 @@ export const formActionBar = ( : h.p( [ h.AriaLabel("Fix before saving"), - h.Title(Arr.join(options.issues, " ")), h.Class( hstackClass({ gap: "1.5", + align: "start", shrink: true, className: typographyClass({ size: "xs", tone: "muted" }), }), @@ -97,10 +105,10 @@ export const formActionBar = ( [ icon(h, "CircleWarningIcon", { size: 14, - className: "shrink-0 text-severity-warn", + className: "mt-px shrink-0 text-severity-warn", }), h.span( - [h.Class("truncate")], + [], [ "Fix before saving: ", ...Arr.flatMap(options.issues, (issue, index) => [ @@ -111,7 +119,7 @@ export const formActionBar = ( ), ], ), - hstack(h, { gap: "2", className: "shrink-0" }, options.actions), + hstack(h, { gap: "2", className: "shrink-0 self-end sm:self-auto" }, options.actions), ], ), ], diff --git a/packages/views/src/charts/chart-spec.ts b/packages/views/src/charts/chart-spec.ts index 8729f5a2e6..8b11ef51aa 100644 --- a/packages/views/src/charts/chart-spec.ts +++ b/packages/views/src/charts/chart-spec.ts @@ -38,6 +38,10 @@ export const TimeseriesAxisSpec = Schema.Struct({ softMin: Schema.NullOr(Schema.Number), softMax: Schema.NullOr(Schema.Number), fitYAxisToData: Schema.Boolean, + /** A hard ceiling the axis tops out at whatever nicing would round to (a share capped at 100%). */ + max: Schema.optionalKey(Schema.Number), + /** The zero tick's label, when "0" alone would drop the unit (a "0%" line the series crosses). */ + zeroLabel: Schema.optionalKey(Schema.String), }) export type TimeseriesAxisSpec = typeof TimeseriesAxisSpec.Type diff --git a/packages/views/src/charts/definitions/area.ts b/packages/views/src/charts/definitions/area.ts index a36564fc3b..0edb866f12 100644 --- a/packages/views/src/charts/definitions/area.ts +++ b/packages/views/src/charts/definitions/area.ts @@ -105,6 +105,8 @@ export const buildQueryBuilderArea = (spec: AreaSpec): BuiltChart { + test("tops out at the max where nicing alone would round past it", () => { + const free = timeseriesYAxis({ rows, visibleKeys: ["budget"], unit: "percent" }) + expect(free.domain[1]).toBeGreaterThan(1) + const capped = timeseriesYAxis({ rows, visibleKeys: ["budget"], unit: "percent", max: 1 }) + expect(capped.domain[1]).toBe(1) + expect(capped.domain[0]).toBeLessThanOrEqual(-19) + }) + + test("labels its top tick as the max and zero with the unit when asked", () => { + const axis = timeseriesYAxis({ + rows: [{ budget: 1 }, { budget: -0.35 }], + visibleKeys: ["budget"], + unit: "percent", + max: 1, + zeroLabel: "0%", + }).y.axis.ticks + expect(axis.values?.at(-1)).toBe(1) + expect(axis.format(1)).toBe("100%") + expect(axis.format(0)).toBe("0%") + }) +}) diff --git a/packages/views/src/charts/timeseries.ts b/packages/views/src/charts/timeseries.ts index e91e2c1e8e..244333f695 100644 --- a/packages/views/src/charts/timeseries.ts +++ b/packages/views/src/charts/timeseries.ts @@ -1,4 +1,10 @@ -import { axisTickFormatter, inferBucketSeconds, inferRangeMs, parseBucketMs } from "@maple/ui/lib/format" +import { + axisTickFormatter, + axisTickValues, + inferBucketSeconds, + inferRangeMs, + parseBucketMs, +} from "@maple/ui/lib/format" import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" import { DEFAULT_TOKEN_FALLBACK } from "@maple/ui/lib/colors" import { hasOnlyIntegerValues } from "@maple/ui/components/charts/_shared/sparse-series" @@ -141,13 +147,24 @@ export interface YAxisOptions { readonly logScale?: boolean readonly fitYAxisToData?: boolean readonly thresholds?: ReadonlyArray<{ readonly value: number }> + /** A hard ceiling: the domain tops out here, not where nicing rounds it, and its tick is drawn. */ + readonly max?: number + /** The zero tick's label in place of "0". */ + readonly zeroLabel?: string } -/** The zero tick reads "0" on every axis, never "0/s" or "0.00". */ +/** The zero tick reads "0" (or the axis's `zeroLabel`), never "0/s" or "0.00". */ const zeroTick = - (format: (value: number) => string) => + (format: (value: number) => string, zeroLabel = "0") => (value: number): string => - value === 0 ? "0" : format(value) + value === 0 ? zeroLabel : format(value) + +/** A capped axis's ticks: the usual steps under the ceiling, minus any crowding it, then the ceiling. */ +const cappedTickValues = (domain: readonly [number, number]): Array => { + const ticks = axisTickValues(domain, NICE_TICK_COUNT) + const step = ticks.length > 1 ? (ticks[1] ?? 0) - (ticks[0] ?? 0) : domain[1] - domain[0] + return [...Arr.filter(ticks, (tick) => domain[1] - tick >= step / 2), domain[1]] +} /** `timeseriesYAxis`: the shared y axis, domain included (log axes pin their own floor at 1). */ export const timeseriesYAxis = (options: YAxisOptions) => { @@ -161,7 +178,9 @@ export const timeseriesYAxis = (options: YAxisOptions) => { fitYAxisToData: options.fitYAxisToData === true && options.softMin == null && !logScale, thresholds: options.thresholds === undefined ? undefined : [...options.thresholds], }) - const domain: [number, number] = logScale ? logYDomain(dataDomain[1]) : niceLinearDomain(dataDomain) + const max = logScale ? undefined : options.max + const niced: [number, number] = logScale ? logYDomain(dataDomain[1]) : niceLinearDomain(dataDomain) + const domain: [number, number] = max === undefined ? niced : [Math.min(niced[0], max - 1), max] const integerOnly = hasOnlyIntegerValues(options.rows, options.visibleKeys) && options.unit == null && @@ -171,15 +190,21 @@ export const timeseriesYAxis = (options: YAxisOptions) => { y: { grid: DASHED_Y_GRID, scale: logScale ? logYScale(dataDomain[1]) : scaleLinear().domain(domain), - nice: logScale ? false : NICE_TICK_COUNT, + nice: logScale || max !== undefined ? false : NICE_TICK_COUNT, axis: { line: false, ticks: { size: 0, padding: 6, - values: integerOnly && !logScale ? integerTickValues(domain) : undefined, + values: + max !== undefined + ? cappedTickValues(domain) + : integerOnly && !logScale + ? integerTickValues(domain) + : undefined, format: zeroTick( options.format ?? axisTickFormatter(options.unit, domain, NICE_TICK_COUNT), + options.zeroLabel, ), }, }, From 0e875b11450374249d632011aed9b4992df55d59 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 11:08:05 +0100 Subject: [PATCH 09/11] fix(slos): a window without events reads 'No events in this window' on both charts --- .../web/src/page/alerts/slo/slo.scene.test.ts | 31 +++++++++++++++++++ apps/web/src/page/alerts/slo/view-panels.ts | 7 +++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/alerts/slo/slo.scene.test.ts b/apps/web/src/page/alerts/slo/slo.scene.test.ts index 4ee5a761d0..c038bcd479 100644 --- a/apps/web/src/page/alerts/slo/slo.scene.test.ts +++ b/apps/web/src/page/alerts/slo/slo.scene.test.ts @@ -217,6 +217,37 @@ describe("SLO page", () => { ) }) + test("a window that counted no events says so instead of drawing empty charts", () => { + const history = sloHistory("healthy") + const quiet = opened(checkoutSlo(), (model) => [ + Message.CompletedFetchStatus({ + key: model.status.key ?? "", + result: Result.succeed(sloStatus("healthy")), + }), + Message.CompletedFetchHistory({ + key: model.history.key ?? "", + result: Result.succeed({ + ...history, + points: Arr.map(history.points, (point) => ({ + ...point, + sli: null, + budget_remaining: 1, + burn_rate: 0, + })), + }), + }), + ]) + scene( + { update: pageUpdate, view }, + given(quiet), + themeResolved(), + expectView(within(role("region", { name: "SLI" }), text("No events in this window"))).toExist(), + expectView( + within(role("region", { name: "Error budget" }), text("No events in this window")), + ).toExist(), + ) + }) + test("a failed history read names its own chart in each panel", () => { const failed = opened(checkoutSlo(), (model) => [ Message.CompletedFetchStatus({ diff --git a/apps/web/src/page/alerts/slo/view-panels.ts b/apps/web/src/page/alerts/slo/view-panels.ts index d0448319fd..470cf076dc 100644 --- a/apps/web/src/page/alerts/slo/view-panels.ts +++ b/apps/web/src/page/alerts/slo/view-panels.ts @@ -177,15 +177,16 @@ const historyBody = ( className: "py-8", }) const points = historyPoints(model) + // One point per hour comes back either way; a window with no events has no SLI in any of them. + if (points !== null && Arr.every(points, (point) => point.sli === null)) + return emptyMessage(h, { className: "py-16 text-center" }, ["No events in this window"]) if (points === null || context === null) return model.chartRuntime._tag === "Failed" ? emptyMessage(h, { className: "py-16 text-center" }, [ "The chart couldn't load. Reload the page to try again.", ]) : h.div([h.Class("h-56")], [chartSkeleton(h, "line")]) - return points.length === 0 - ? emptyMessage(h, { className: "py-16 text-center" }, ["No events in this window"]) - : draw(points, context) + return draw(points, context) } const historyPoints = (model: Model) => From 3a899597c6b2f18509fb70cf59284199d303c6a2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 11:10:44 +0100 Subject: [PATCH 10/11] refactor(web): one kit page for a form a member may not save The SLO and uptime forms drew the same admin-only notice; managersOnlyPage in the form kit takes the copy and the way back. --- apps/web/src/page/alerts/slo-form/view.ts | 34 ++++++------------ apps/web/src/page/alerts/uptime-form/view.ts | 36 ++++++-------------- apps/web/src/page/kit/form-section.ts | 25 ++++++++++++++ packages/views/src/ui/INDEX.md | 2 +- 4 files changed, 47 insertions(+), 50 deletions(-) diff --git a/apps/web/src/page/alerts/slo-form/view.ts b/apps/web/src/page/alerts/slo-form/view.ts index f58ee1715a..0c4972a774 100644 --- a/apps/web/src/page/alerts/slo-form/view.ts +++ b/apps/web/src/page/alerts/slo-form/view.ts @@ -20,7 +20,7 @@ import { typography, typographyClass } from "@maple/views/ui/typography" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" import { destinationPicker } from "../../kit/destination-picker" -import { formActionBar, formCard, formSection } from "../../kit/form-section" +import { formActionBar, formCard, formSection, managersOnlyPage } from "../../kit/form-section" import { sloHref, slosTabHref } from "../shared/slo-commands" import { formatPercent, formatThreshold, formatWindow } from "../shared/slo-derive" import { budgetDowntime, formIssues, targetOf } from "./derive" @@ -319,27 +319,15 @@ const actionBar = (h: H, model: Model): Html => { } /** Members can read SLOs but not create or change them; the API refuses them too. */ -const managersOnlyPage = (h: H, model: Model, header: Readonly<{ title: string; description: string }>) => - dashboardPage(h, header, [ - alert(h, { variant: "info" }, [ - icon(h, "ShieldIcon"), - alertTitle(h, ["Only org admins and engineers can create or change SLOs"]), - alertDescription(h, ["Ask an admin to make this change."]), - ]), - h.div( - [], - [ - linkButton( - h, - { - variant: "outline", - href: model.editingId === null ? slosTabHref() : sloHref(model.editingId), - }, - [model.editingId === null ? "Back to SLOs" : "Back to the SLO"], - ), - ], - ), - ]) +const notAllowedPage = (h: H, model: Model, header: Readonly<{ title: string; description: string }>) => + managersOnlyPage(h, { + header, + title: "Only org admins and engineers can create or change SLOs", + back: + model.editingId === null + ? { href: slosTabHref(), label: "Back to SLOs" } + : { href: sloHref(model.editingId), label: "Back to the SLO" }, + }) export const view = Submodel.defineView((model, _inputs, h) => { const isEdit = model.editingId !== null @@ -347,7 +335,7 @@ export const view = Submodel.defineView((model, title: isEdit ? "Edit SLO" : "New SLO", description: "The share of a service's requests that must be good, and the error budget that leaves.", } - if (model.isSessionLoaded && !canManage(model)) return managersOnlyPage(h, model, header) + if (model.isSessionLoaded && !canManage(model)) return notAllowedPage(h, model, header) if (isEdit && AsyncData.isFailure(model.existing.data)) return dashboardPage(h, header, [ errorState(h, { failure: model.existing.data.error, title: "Couldn't load the SLO" }), diff --git a/apps/web/src/page/alerts/uptime-form/view.ts b/apps/web/src/page/alerts/uptime-form/view.ts index a6fe725887..9b937391a3 100644 --- a/apps/web/src/page/alerts/uptime-form/view.ts +++ b/apps/web/src/page/alerts/uptime-form/view.ts @@ -12,7 +12,7 @@ import { segmentedSelect } from "@maple/views/ui/segmented-select" import { skeleton } from "@maple/views/ui/skeleton" import type { PageViewInputs } from "../../contract" import { dashboardPage } from "../../kit/dashboard-page" -import { formActionBar, formCard, formSection } from "../../kit/form-section" +import { formActionBar, formCard, formSection, managersOnlyPage } from "../../kit/form-section" import { failureMessage, type LoadFailure } from "@maple/views/data/load-failure" import { LIMIT_REACHED_CODE, monitorHref, uptimeTabHref } from "../shared/uptime-commands" import { formatMs } from "../shared/uptime-derive" @@ -264,31 +264,15 @@ const saveFailureNotice = (h: H, failure: LoadFailure, widthClass: string): Html ]) /** Members can read monitors but not create or change them; the API refuses them too. */ -const adminOnlyPage = ( - h: H, - model: Model, - header: { readonly title: string; readonly description: string }, -) => - dashboardPage(h, header, [ - alert(h, { variant: "info" }, [ - icon(h, "ShieldIcon"), - alertTitle(h, ["Only org admins and engineers can create or change uptime monitors"]), - alertDescription(h, ["Ask an admin to make this change."]), - ]), - h.div( - [], - [ - linkButton( - h, - { - variant: "outline", - href: model.editingId === null ? uptimeTabHref() : monitorHref(model.editingId), - }, - [model.editingId === null ? "Back to uptime" : "Back to the monitor"], - ), - ], - ), - ]) +const adminOnlyPage = (h: H, model: Model, header: Readonly<{ title: string; description: string }>) => + managersOnlyPage(h, { + header, + title: "Only org admins and engineers can create or change uptime monitors", + back: + model.editingId === null + ? { href: uptimeTabHref(), label: "Back to uptime" } + : { href: monitorHref(model.editingId), label: "Back to the monitor" }, + }) export const view = Submodel.defineView((model, _inputs, h) => { const isEdit = model.editingId !== null diff --git a/apps/web/src/page/kit/form-section.ts b/apps/web/src/page/kit/form-section.ts index 77aa561eb5..18e8771cb6 100644 --- a/apps/web/src/page/kit/form-section.ts +++ b/apps/web/src/page/kit/form-section.ts @@ -1,10 +1,13 @@ import { cn } from "@maple/ui/lib/utils" import { Array as Arr } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" +import { alert, alertDescription, alertTitle } from "@maple/views/ui/alert" +import { linkButton } from "@maple/views/ui/button" import { heading } from "@maple/views/ui/heading" import { icon } from "@maple/views/ui/icon" import { hstack, hstackClass } from "@maple/views/ui/stack" import { typography, typographyClass } from "@maple/views/ui/typography" +import { dashboardPage } from "./dashboard-page" /** * `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) @@ -124,3 +127,25 @@ export const formActionBar = ( ), ], ) + +/** + * `ManagersOnlyPage`: what a form shows a member who may not save it, a note naming who can and + * a way back to where they came from. + */ +export const managersOnlyPage = ( + h: HtmlBuilder, + options: Readonly<{ + header: Readonly<{ title: string; description: string }> + /** Who may make the change, e.g. "Only org admins and engineers can create or change SLOs". */ + title: string + back: Readonly<{ href: string; label: string }> + }>, +): Html => + dashboardPage(h, options.header, [ + alert(h, { variant: "info" }, [ + icon(h, "ShieldIcon"), + alertTitle(h, [options.title]), + alertDescription(h, ["Ask an admin to make this change."]), + ]), + h.div([], [linkButton(h, { variant: "outline", href: options.back.href }, [options.back.label])]), + ]) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 8e93c09178..5f12d13f7e 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -184,7 +184,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/dashboard-page.ts` `pageBody`, `filterRail`, `pageContent`, `pageHeader`, `pageScrollArea`, `dashboardPage`: The dashboard page below the shell's top bar: `PageLayout.Body` with its filter rail, content column, sticky header and scroll area. - `apps/web/src/page/kit/destination-picker.ts` `destinationPicker`, `pickedDestination`: `DestinationPicker`: the alert destinations a form notifies, each removable, then an "Add destination" menu of the rest; the owner keeps the menu's Model and the chosen ids. - `apps/web/src/page/kit/detail-header.ts` `detailEyebrow`, `detailTitle`, `detailTitleSkeleton`, `detailActions`: `DetailHeader`'s title side and `DetailHeaderSkeleton`. -- `apps/web/src/page/kit/form-section.ts` `formSection`, `formCard`, `formActionBar`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. +- `apps/web/src/page/kit/form-section.ts` `formSection`, `formCard`, `formActionBar`, `managersOnlyPage`: `FormSection`: one row of a form card, what it sets on the left (a title and an optional hint) and its controls on the right; the two stack on phones. - `apps/web/src/page/kit/guided-setup.ts` `frameworkIconStrip`, `frameworkPicker`, `agentPromptButton`, `connectInstructions`: `FrameworkIconStrip`, `FrameworkPicker` and `ConnectInstructions`, with `PackageManagerCodeBlock`. - `apps/web/src/page/kit/infra-correlation/command.ts` `operationOf`: The four `*InfraTimeseries` reads the panel's charts make, through the read cache under the atom key (`requestKey(org, "", { data })`), so a reopened panel reuses them. - `apps/web/src/page/kit/infra-correlation/update.ts` `chartIdOf`, `requestsOf` (stateful: Model, init, update): A chart's slot and chart id, `-` (the linked-cursor id). From f9daad1c5b0232684749a41c1a738bb5af2744a4 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 6 Nov 2026 11:17:25 +0100 Subject: [PATCH 11/11] fix(slos): a failed status read reads as figures unavailable, not 0 burning --- .../src/page/alerts/hub/hub-slos.scene.test.ts | 15 +++++++++++++++ apps/web/src/page/alerts/hub/view-slos.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts index 04a461e2cf..a7537f7d29 100644 --- a/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts +++ b/apps/web/src/page/alerts/hub/hub-slos.scene.test.ts @@ -176,6 +176,21 @@ describe("alerts hub, SLOs tab", () => { ) }) + test("a failed status read says the figures are unavailable rather than nothing burning", () => { + scene( + { update: pageUpdate, view }, + given( + withSlos([ + { slo: checkoutSlo(), status: null }, + { slo: checkoutSlo({ id: sloUuid(3), name: "Payments" }), status: null }, + ]), + ), + expectView(text(/^2\s*SLOs$/u)).toExist(), + expectView(text(/figures unavailable$/u)).toExist(), + expectView(text(/^0\s*burning$/u)).not.toExist(), + ) + }) + test("a failed list read says so with a retry that reads again", () => { const model = onSlos() const failed = pageUpdate( diff --git a/apps/web/src/page/alerts/hub/view-slos.ts b/apps/web/src/page/alerts/hub/view-slos.ts index 1bd7735bf4..1a4c9cc581 100644 --- a/apps/web/src/page/alerts/hub/view-slos.ts +++ b/apps/web/src/page/alerts/hub/view-slos.ts @@ -79,6 +79,18 @@ const isBurning = (row: SloListRow, incidents: ReadonlyArray): (Option.isSome(holdingTier(row.status)) && Option.isSome(openIncident(row.slo, incidents)))) const readout = (h: H, model: Model, rows: ReadonlyArray): ReadonlyArray => { + // Every SLO's status arrives in one read; none at all means that read failed, not that nothing burns. + if (rows.length > 0 && Arr.every(rows, (row) => row.status === null)) { + return [ + toolbarStat(h, { value: String(rows.length), label: pluralize(rows.length, "SLO") }), + toolbarStat(h, { + value: "–", + label: "figures unavailable", + dot: "warn", + attributes: [h.Title("Couldn't read the SLOs' current figures. Reload to try again.")], + }), + ] + } const burning = Arr.filter(rows, (row) => isBurning(row, model.incidents ?? [])) const spent = Arr.filter(rows, (row) => row.status !== null && isBudgetSpent(row.status.budget_remaining)) return [