diff --git a/apps/web/src/lab/analyst-home-fixture.ts b/apps/web/src/lab/analyst-home-fixture.ts new file mode 100644 index 0000000000..d7f6097a74 --- /dev/null +++ b/apps/web/src/lab/analyst-home-fixture.ts @@ -0,0 +1,151 @@ +import { OrgId } from "@maple/domain" +import type { V2UptimeMonitor } from "@maple/domain/http/v2" +import type { AlertIncidentRow } from "@maple/web-data/collections/alerts" +import { Result, Schema, Array as Arr } from "effect" +import { toLoadFailure } from "@maple/views/data/load-failure" +import type { SignalPresences } from "@maple/views/data/signal-presence" +import { checkoutSlo, searchSlo, sloStatus, sloUuid } from "../page/alerts/shared/slo-test-fixtures" +import type { PageContext } from "../page/contract" +import { incidentRow, NOW, ORG, ruleId } from "../page/kit/alert-test-rows" +import { downMonitor, httpMonitor, monitorId } from "../page/kit/uptime/test-monitors" +import { topErrorsOf } from "../page/overview/analyst/derive" +import { Message } from "../page/overview/analyst/message" +import type { Model, SloRow, TopError } from "../page/overview/analyst/model" +import { init, update } from "../page/overview/analyst/update" +import { buildErrorsLabFixture } from "./errors-fixture" + +// The analyst Overview's fixtures, shared by `/lab/analyst-home` and its scene tests. + +export const STATES = ["clear", "attention", "empty", "loading", "failed"] as const +export type AnalystHomeState = (typeof STATES)[number] + +export const ANALYST_CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)(ORG), + pinnedNamespace: null, + timeZone: "UTC", + audience: "analyst", +} + +const MINUTE = 60_000 +const iso = (msBefore: number) => new Date(NOW - msBefore).toISOString() + +export const CRITICAL_INCIDENT = incidentRow({ + rule_name: "Checkout p95 latency", + group_key: "checkout-api", +}) +export const WARNING_INCIDENT = incidentRow({ + id: "0199a003-0000-7000-8000-000000000002", + rule_id: ruleId(3), + incident_key: "rule-3:all", + dedupe_key: "rule-3:all", + rule_name: "Search error rate", + group_key: "search-api", + signal_type: "error_rate", + severity: "warning", + threshold: 0.02, + last_observed_value: 0.034, + first_triggered_at: iso(26 * MINUTE), + last_triggered_at: iso(2 * MINUTE), +}) + +const UP_MONITORS: ReadonlyArray = Arr.map( + ["checkout-api", "search-api", "Marketing site", "Customer dashboard", "Public API"], + (name, index) => httpMonitor({ id: monitorId(index + 4), name }), +) +export const DOWN_MONITOR = downMonitor() + +const HEALTHY_SLOS: ReadonlyArray = [ + { slo: checkoutSlo(), status: sloStatus("healthy") }, + { slo: searchSlo(), status: sloStatus("healthy", { sloId: sloUuid(2), targetPercent: 99.5 }) }, +] +const TROUBLED_SLOS: ReadonlyArray = [ + { slo: checkoutSlo(), status: sloStatus("burning") }, + { slo: searchSlo(), status: sloStatus("exhausted", { sloId: sloUuid(2), targetPercent: 99.5 }) }, +] + +const errorsFixture = buildErrorsLabFixture(NOW) + +/** The Errors lab's issues, ranked and joined the way the page's own read joins them. */ +export const TOP_ERRORS: ReadonlyArray = topErrorsOf( + Arr.map(errorsFixture.signals, (signal) => signal.issue), + Arr.flatMap(errorsFixture.signals, (signal) => + signal.windowCount === null + ? [] + : [ + { + fingerprintHash: signal.fingerprintHash, + errorLabel: signal.title, + sampleMessage: signal.detail, + count: signal.windowCount, + affectedServicesCount: signal.affectedServicesCount ?? 1, + firstSeen: new Date(signal.firstSeenAt), + lastSeen: new Date(signal.lastSeenAt), + }, + ], + ), +) + +type Data = Readonly<{ + rules: number + incidents: ReadonlyArray + /** `null`: the uptime read fails. */ + monitors: ReadonlyArray | null + slos: ReadonlyArray + errors: ReadonlyArray + traces: "present" | "absent" +}> + +const CLEAR: Data = { + rules: 6, + incidents: [], + monitors: UP_MONITORS, + slos: HEALTHY_SLOS, + errors: TOP_ERRORS, + traces: "present", +} + +const DATA = { + clear: CLEAR, + attention: { + ...CLEAR, + incidents: [WARNING_INCIDENT, CRITICAL_INCIDENT], + monitors: [...UP_MONITORS, DOWN_MONITOR], + slos: TROUBLED_SLOS, + }, + empty: { rules: 0, incidents: [], monitors: [], slos: [], errors: [], traces: "absent" }, + failed: { ...CLEAR, monitors: null }, +} as const satisfies Record, Data> + +const presences = (traces: Data["traces"]): SignalPresences => ({ + traces: { status: traces, lastSeenMs: traces === "present" ? NOW - MINUTE : null }, +}) + +const step = (model: Model, message: Message): Model => update(model, message, ANALYST_CONTEXT).model + +/** The page as its reads leave it in `state`, on the fixtures' fixed clock. */ +export const analystHomeModel = (state: AnalystHomeState): Model => { + const entered = step(init(ANALYST_CONTEXT).model, Message.CompletedReadClock({ nowMs: NOW })) + if (state === "loading") return entered + const data: Data = DATA[state] + const answers: ReadonlyArray<(model: Model) => Message> = [ + () => Message.UpdatedRules({ orgId: ORG, count: data.rules }), + () => Message.UpdatedIncidents({ orgId: ORG, rows: data.incidents }), + (model) => + Message.CompletedFetchMonitors({ + key: model.monitors.key ?? "", + result: + data.monitors === null + ? Result.fail(toLoadFailure("The request timed out")) + : Result.succeed(data.monitors), + }), + (model) => + Message.CompletedFetchSlos({ key: model.slos.key ?? "", result: Result.succeed(data.slos) }), + (model) => + Message.CompletedFetchErrors({ + key: model.errors.key ?? "", + result: Result.succeed(data.errors), + }), + () => Message.CompletedLoadSignalPresence({ result: Result.succeed(presences(data.traces)) }), + ] + return Arr.reduce(answers, entered, (model, answer) => step(model, answer(model))) +} diff --git a/apps/web/src/lab/entries/analyst-home.ts b/apps/web/src/lab/entries/analyst-home.ts new file mode 100644 index 0000000000..829f9c8022 --- /dev/null +++ b/apps/web/src/lab/entries/analyst-home.ts @@ -0,0 +1,67 @@ +import { Effect, Option, Schema, Array as Arr } from "effect" +import { Command } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { Message as PageMessage } from "../../page/overview/analyst/message" +import { Model as PageModel } from "../../page/overview/analyst/model" +import { update as pageUpdate } from "../../page/overview/analyst/update" +import { view as pageView } from "../../page/overview/analyst/view" +import { ANALYST_CONTEXT, type AnalystHomeState, analystHomeModel, STATES } from "../analyst-home-fixture" +import { ANALYST_CLERK } from "../shell-frame" +import { defineShellLab } from "../shell-lab" + +// `/lab/analyst-home`: the Overview an analyst gets, inside the analyst's shell, with nothing +// wrong, with incidents and failing checks, with nothing set up, loading and with one read failing +// (`?state=clear|attention|empty|loading|failed`). + +const Model = Schema.Struct({ page: PageModel }) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ GotPageMessage: { message: PageMessage } }) +type Message = typeof Message.Type + +const Flags = Schema.Struct({ state: Schema.Literals(STATES) }) +type Flags = typeof Flags.Type + +const decodeState = Schema.decodeUnknownOption(Schema.Literals(STATES)) + +// Reads that would reach the API or move the fixtures' fixed clock; the lab has neither. +const REMOTE = /^(Fetch|Load|Read|Resolve)/ + +const update = (model: Model, message: Message) => { + const next = pageUpdate(model.page, message.message, ANALYST_CONTEXT) + return { + model: { page: next.model }, + commands: Command.mapMessages( + Arr.filter(next.commands ?? [], (command) => !REMOTE.test(command.name)), + (inner) => Message.GotPageMessage({ message: inner }), + ), + } +} + +const view = (model: Model, h: HtmlBuilder): Html => + h.submodel({ + slotId: "analyst-home-lab", + model: model.page, + view: pageView, + viewInputs: { context: ANALYST_CONTEXT }, + toParentMessage: (message) => Message.GotPageMessage({ message }), + }) + +export const lab = defineShellLab("Analyst overview", { + Model, + Message, + Flags, + flags: Effect.sync(() => ({ + state: Option.getOrElse( + decodeState(new URLSearchParams(window.location.search).get("state")), + (): AnalystHomeState => "clear", + ), + })), + init: (flags: Flags) => ({ model: { page: analystHomeModel(flags.state) } }), + update, + view, + clerk: ANALYST_CLERK, + currentPath: "/", + breadcrumbs: [{ label: "Overview" }], +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index b26a880730..889bbd954b 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -579,6 +579,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/analyst-home", + title: "Analyst overview", + description: + "The Overview an analyst gets, in the analyst's shell: nothing wrong, incidents open and checks failing, nothing set up, loading and one read failing (`?state=clear|attention|empty|loading|failed`).", + kind: "lab", + session: "none", + }, { path: "/lab/shell", title: "Shell states", diff --git a/apps/web/src/lab/shell-frame.ts b/apps/web/src/lab/shell-frame.ts index 889bdc871e..0aab6ed7e8 100644 --- a/apps/web/src/lab/shell-frame.ts +++ b/apps/web/src/lab/shell-frame.ts @@ -21,6 +21,15 @@ export const foldLabShell = (model: Model) => ({ model }), }) +/** A signed-in analyst with nothing else read yet: all the shell needs to pick their sidebar. */ +export const ANALYST_CLERK: ShellClerk = { + maybeUser: Option.none(), + maybeOrganization: Option.none(), + memberships: [], + customer: undefined, + maybeRole: Option.some("org:analyst"), +} + /** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */ export const labShellView = ( h: HtmlBuilder, diff --git a/apps/web/src/lab/shell-lab.ts b/apps/web/src/lab/shell-lab.ts index 921480308d..482dad02ee 100644 --- a/apps/web/src/lab/shell-lab.ts +++ b/apps/web/src/lab/shell-lab.ts @@ -41,6 +41,8 @@ export type ShellLabPage) => Html /** Shell state the root's reads would bring (the Alerts › Inbox count), as a fixture. */ seedShell?: (shell: Shell.Model) => Shell.Model + /** Who the shell is drawn for (`ANALYST_CLERK`); an operator when absent. */ + clerk?: Shell.ShellClerk }> export const defineShellLab = ( @@ -161,6 +163,7 @@ export const defineShellLab = h.submodel({ slotId: "lab-page", diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 2f2df77060..35f3a4fb36 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -22,10 +22,10 @@ import type { Resources } from "../platform/resources" import { dashboardPage } from "../page/kit/dashboard-page" import { dismissSheetUrl, pushSheetUrl, sheetParamOf } from "../page/kit/sheet-param" import { Message as ShellMessage, Theme } from "../shell/message" -import { init as initShell, Model as ShellModel, OnCallData, type ShellClerk } from "../shell/model" +import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model" import { NavAudience } from "../shell/nav" import type { OnCallSnapshot } from "../shell/oncall" -import { foldLabShell, labShellView } from "./shell-frame" +import { foldLabShell, labShellView, ANALYST_CLERK } from "./shell-frame" // `/lab/shell`: the real sidebar with fixture on-call states and a page to stand on, and the side // panel held in `?sheet=` (Back closes it, the link reopens it) in both of its sizes. @@ -167,15 +167,6 @@ const sheetStep = (model: Model, step: SheetStep): Return => ({ ], }) -/** A signed-in analyst with nothing else read yet: all the shell needs to pick their sidebar. */ -const ANALYST_CLERK: ShellClerk = { - maybeUser: Option.none(), - maybeOrganization: Option.none(), - memberships: [], - customer: undefined, - maybeRole: Option.some("org:analyst"), -} - /** The fixture's shifts, read on the lab's fixed clock. */ const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({ ...shell, diff --git a/apps/web/src/page/alerts/hub/analyst.scene.test.ts b/apps/web/src/page/alerts/hub/analyst.scene.test.ts index 0ec29ece44..3c0cee9573 100644 --- a/apps/web/src/page/alerts/hub/analyst.scene.test.ts +++ b/apps/web/src/page/alerts/hub/analyst.scene.test.ts @@ -74,6 +74,20 @@ describe("alerts inbox, analyst", () => { ) }) + test("an analyst's Incidents page has no New rule button, and a member's keeps it", () => { + const analystView = withViewInputs(pageView, { context: { ...context, audience: "analyst" } })() + scene( + { update: pageUpdate, view: analystView }, + given(loaded(ANALYST)), + expectView(role("link", { name: "New rule" })).toBeAbsent(), + ) + scene( + { update: pageUpdate, view }, + given(loaded(MEMBER)), + expectView(role("link", { name: "New rule" })).toExist(), + ) + }) + test("a member gets a button to flip each row's acknowledgement", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/alerts/hub/view-slos.ts b/apps/web/src/page/alerts/hub/view-slos.ts index 4b4415518d..58810d665a 100644 --- a/apps/web/src/page/alerts/hub/view-slos.ts +++ b/apps/web/src/page/alerts/hub/view-slos.ts @@ -1,5 +1,4 @@ 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" @@ -26,14 +25,12 @@ import { newSloHref, sloHref } from "../shared/slo-commands" import { burnTone, fastestBurn, - firingTier, formatBurn, formatSli, formatTarget, formatWindow, - holdingTier, isBudgetSpent, - openIncident, + isBurning, scopeLine, sliLabel, sliTone, @@ -70,12 +67,6 @@ const slosEmptyState = (h: H, serviceName: string | undefined, canEdit: boolean) "py-12", ) -/** 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 => { // 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)) { diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index a3a8e620b1..ec31779fb5 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -201,7 +201,10 @@ const headerActions = ( model: Model, tab: AlertsTab, overview: Option.Option, + audience: PageViewInputs["context"]["audience"], ): Html | undefined => { + // An analyst follows incidents and sets nothing up. + if (audience === "analyst") return undefined if (tab === "inbox" || tab === "rules") // The first-run empty state offers the templates and "Start from scratch" to who can create. return canManageAlerts(model) && Option.exists(overview, (data) => isFirstRun(model, data, tab)) @@ -354,7 +357,7 @@ export const view = Submodel.defineView((model, h, { title: TAB_LABEL[tab], - actions: headerActions(h, model, tab, overview), + actions: headerActions(h, model, tab, overview, inputs.context.audience), }, [ tab === "routing" diff --git a/apps/web/src/page/alerts/shared/slo-derive.ts b/apps/web/src/page/alerts/shared/slo-derive.ts index 4eb58036e2..c6aedf2072 100644 --- a/apps/web/src/page/alerts/shared/slo-derive.ts +++ b/apps/web/src/page/alerts/shared/slo-derive.ts @@ -134,6 +134,15 @@ export const openIncident = ( ): Option.Option => Arr.findFirst(sloIncidents(slo, incidents), (row) => row.status === "open") +/** Burning: a tier fires, or a tier holds (no events) while the SLO's incident stays open. */ +export const isBurning = ( + row: Readonly<{ slo: Pick; status: SloStatus | null }>, + incidents: ReadonlyArray, +): boolean => + row.status !== null && + (Option.isSome(firingTier(row.status)) || + (Option.isSome(holdingTier(row.status)) && Option.isSome(openIncident(row.slo, incidents)))) + /** "checkout · production, staging", or the service alone when every environment counts. */ export const scopeLine = (slo: Pick): string => slo.environments.length === 0 diff --git a/apps/web/src/page/contract.ts b/apps/web/src/page/contract.ts index f5bdf63dc8..fae9bb313d 100644 --- a/apps/web/src/page/contract.ts +++ b/apps/web/src/page/contract.ts @@ -8,7 +8,7 @@ import type { IntegrationFlags } from "../data/infra-surfaces" import type { Resources } from "../platform/resources" import type { AppRoute, RouteTag } from "../route" import type { BreadcrumbEntry } from "../shell" -import type { NavSurface } from "../shell/nav" +import type { NavAudience, NavSurface } from "../shell/nav" import type { PageSourceRef } from "../shell/source-link" import { ToastType } from "@maple/views/ui/toast" @@ -22,6 +22,8 @@ export interface PageContext { readonly timeZone: string /** The infra surfaces as the shell reads them; absent off the shell (a public page). */ readonly infra?: ShellInfraSurfaces + /** Who the shell is drawn for, from the same Clerk role; absent means an operator. */ + readonly audience?: NavAudience } /** The sidebar's last-hour presence probe (`null` until it answers) and the integration statuses. */ diff --git a/apps/web/src/page/investigations/conversation/view.ts b/apps/web/src/page/investigations/conversation/view.ts index a7ceca4575..600cf8abf4 100644 --- a/apps/web/src/page/investigations/conversation/view.ts +++ b/apps/web/src/page/investigations/conversation/view.ts @@ -51,6 +51,8 @@ export type ConversationViewOptions = Readonly<{ suggestions?: Readonly<{ items: ReadonlyArray; onSelect: (text: string) => M }> /** The report card for a `submit_diagnosis` turn; without it the turn draws nothing. */ diagnosisCard?: (report: AiTriageResult) => Html + /** False for a viewer who follows the thread without driving it: the composer is left out. */ + canReply?: boolean }> /** Approvals, sub-agent rows, inline cards and the chat's tables and charts. */ @@ -291,7 +293,7 @@ export const conversationView = ( transcript(h, transcriptHostOf(h, model, options), model.messages, () => emptyStateOf(h, model, options), ), - ...(model.readOnly !== null + ...(model.readOnly !== null || options.canReply === false ? [] : [ h.div( diff --git a/apps/web/src/page/investigations/detail/investigation.scene.test.ts b/apps/web/src/page/investigations/detail/investigation.scene.test.ts index 9f4302bf49..70ffe3aad9 100644 --- a/apps/web/src/page/investigations/detail/investigation.scene.test.ts +++ b/apps/web/src/page/investigations/detail/investigation.scene.test.ts @@ -243,6 +243,27 @@ describe("investigation detail", () => { expect(failed.model.busy).toBeNull() }) + test("an analyst follows a diagnosis without the controls that drive it", () => { + const ready = synced(entered(), investigationOf()).model + const analystView = withViewInputs(pageView, { context: { ...context, audience: "analyst" } })() + scene( + { update: pageUpdate, view: analystView }, + given(ready), + ...canvas(), + expectView(text("payment-svc 3.5.0 exhausts its connection pool.")).toExist(), + expectView(role("button", { name: "Resolve" })).toBeAbsent(), + expectView(role("button", { name: /Ask a follow-up/ })).toBeAbsent(), + ) + // The same diagnosis for a member keeps both. + scene( + { update: pageUpdate, view }, + given(ready), + ...canvas(), + expectView(role("button", { name: "Resolve" })).toExist(), + expectView(role("button", { name: /Ask a follow-up/ })).toExist(), + ) + }) + test("a run still in flight offers no resolve, which the server would refuse", () => { const ready = synced( entered(), diff --git a/apps/web/src/page/investigations/detail/view.ts b/apps/web/src/page/investigations/detail/view.ts index 55980a7ba3..590240aff5 100644 --- a/apps/web/src/page/investigations/detail/view.ts +++ b/apps/web/src/page/investigations/detail/view.ts @@ -201,6 +201,7 @@ const documentTab = ( tab: Tab, timeZone: string, trigger: TipTrigger, + canDrive: boolean, ): ReadonlyArray => { const isResolved = investigation.status === "resolved" const canvas = provenanceCanvas(h, model, investigation, timeZone) @@ -218,7 +219,7 @@ const documentTab = ( pageScrollArea(h, {}, [ vstack(h, { className: "gap-7" }, [...sections, investigationMeta(h, model, investigation)]), ]), - ...(isResolved ? [] : [h.div([h.Class("shrink-0 px-4 pb-4")], [followUpComposer(h)])]), + ...(isResolved || !canDrive ? [] : [h.div([h.Class("shrink-0 px-4 pb-4")], [followUpComposer(h)])]), ] } @@ -249,6 +250,7 @@ const conversationTab = ( investigation: V2Investigation, timeZone: string, trigger: TipTrigger, + canDrive: boolean, ): ReadonlyArray => { const { conversation } = model if (conversation === null) return [] @@ -260,6 +262,7 @@ const conversationTab = ( toMessage, timeZone, textareaId: `investigation-chat-${model.id}`, + canReply: canDrive, placeholder: placeholderOf(investigation), emptyState: () => investigationLead(h, investigation), scrollerMount: Mount.mapMessage(MountMessageScroller({ focusMessageId: null }), () => @@ -281,7 +284,14 @@ const conversationTab = ( ] } -const readyPage = (h: H, model: Model, investigation: V2Investigation, timeZone: string): Html => { +// `canDrive` is false for an analyst, who follows an investigation: no status actions, no composer. +const readyPage = ( + h: H, + model: Model, + investigation: V2Investigation, + timeZone: string, + canDrive: boolean, +): Html => { const tab = tabOf(model) const isConversation = tab === "chat" || tab === "transcript" const tips = traceTips(investigation) @@ -299,14 +309,14 @@ const readyPage = (h: H, model: Model, investigation: V2Investigation, timeZone: h, { titleContent: titleContent(h, investigation), - actions: headerActions(h, model, investigation), + actions: canDrive ? headerActions(h, model, investigation) : undefined, tabs: tabs(h, investigation, tab), fill: true, }, [ ...(isConversation - ? conversationTab(h, model, investigation, timeZone, trigger) - : documentTab(h, model, investigation, tab, timeZone, trigger)), + ? conversationTab(h, model, investigation, timeZone, trigger, canDrive) + : documentTab(h, model, investigation, tab, timeZone, trigger, canDrive)), actionSheet(h, model, investigation, timeZone, trigger), ...(portal === null ? [] : [portal]), ], @@ -319,7 +329,13 @@ export const view = Submodel.defineView((model, const timeZone = inputs.context.timeZone switch (model.sync._tag) { case "Ready": - return readyPage(h, model, model.sync.investigation, timeZone) + return readyPage( + h, + model, + model.sync.investigation, + timeZone, + inputs.context.audience !== "analyst", + ) case "Missing": return dashboardPage(h, {}, [ resourceNotFound(h, { diff --git a/apps/web/src/page/overview/analyst/analyst.scene.test.ts b/apps/web/src/page/overview/analyst/analyst.scene.test.ts new file mode 100644 index 0000000000..d08238198b --- /dev/null +++ b/apps/web/src/page/overview/analyst/analyst.scene.test.ts @@ -0,0 +1,378 @@ +import { Result, Array as Arr } from "effect" +import { + click, + Command, + expect as expectView, + given, + Mount, + role, + scene, + text, + withViewInputs, +} from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import { buildErrorsLabFixture } from "../../../lab/errors-fixture" +import { + CRITICAL_INCIDENT, + DOWN_MONITOR, + ANALYST_CONTEXT, + type AnalystHomeState, + analystHomeModel, + TOP_ERRORS, + WARNING_INCIDENT, +} from "../../../lab/analyst-home-fixture" +import { AppRoute, hrefOf } from "../../../route" +import { sloHref } from "../../alerts/shared/slo-commands" +import { checkoutSlo, searchSlo } from "../../alerts/shared/slo-test-fixtures" +import type { PageContext } from "../../contract" +import { incidentHref } from "../../kit/alert-links" +import { NOW } from "../../kit/alert-test-rows" +import { monitorHref } from "../../kit/uptime/commands" +import { httpMonitor } from "../../kit/uptime/test-monitors" +import { Message } from "../message" +import type { Model } from "../model" +import { init, update } from "../update" +import { view as pageView } from "../view" +import { + FetchMonitors, + FetchSlos, + FetchTopErrors, + LoadSignalPresence, + MountAnalystHome, + ReadClock, +} from "./command" +import { topErrorsOf } from "./derive" +import { Message as AnalystMessage } from "./message" +import type { Model as AnalystModel } from "./model" +import { subscriptions } from "../subscription" + +// The Overview an analyst gets: a verdict, what backs it, and no control that sets anything up. + +const OPERATOR_CONTEXT: PageContext = { ...ANALYST_CONTEXT, audience: "operator" } + +const pageUpdate = (model: Model, message: Message) => update(model, message, ANALYST_CONTEXT) +const view = withViewInputs(pageView, { context: ANALYST_CONTEXT })() + +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +/** The Overview page holding the analyst page as its reads left it in `state`. */ +const at = (state: AnalystHomeState): Model => ({ + ...init({ search: {} }, ANALYST_CONTEXT).model, + analyst: analystHomeModel(state), +}) + +const verdict = (name: string | RegExp) => role("heading", { level: 1, name }) + +/** The page's Mount: a no-op once `init` started the reads. */ +const mounted = Mount.resolve(MountAnalystHome, AnalystMessage.Entered()) + +describe("overview, analyst", () => { + test("with nothing open or failing the verdict says so, backed by the counts", () => { + scene( + { update: pageUpdate, view }, + given(at("clear")), + mounted, + expectView(verdict("Everything is working.")).toExist(), + expectView( + text("No open incidents · 5 of 5 uptime checks passing · 2 of 2 SLOs within budget"), + ).toExist(), + expectView(text("All 5 checks are passing")).toExist(), + expectView(text("All 2 SLOs are within budget")).toExist(), + expectView(role("link", { name: /All incidents/ })).toHaveAttr("href", "/alerts"), + expectView(role("link", { name: /All uptime checks/ })).toHaveAttr("href", "/alerts/uptime"), + expectView(role("link", { name: /All SLOs/ })).toHaveAttr("href", "/alerts/slos"), + expectView(role("link", { name: /All errors/ })).toHaveAttr("href", "/errors"), + expectView(role("link", { name: "Get a weekly summary by email" })).toHaveAttr( + "href", + "/account?tab=email-preferences", + ), + expectView(role("link", { name: "Build a dashboard" })).toHaveAttr("href", "/dashboards"), + ) + }) + + test("open incidents, a failing check and troubled SLOs are named, and each row links to its page", () => { + const topError = Arr.getUnsafe(TOP_ERRORS, 0) + scene( + { update: pageUpdate, view }, + given(at("attention")), + mounted, + expectView( + verdict("2 incidents open. 1 check failing. 1 SLO burning. 1 SLO out of budget."), + ).toExist(), + expectView( + text( + "2 open incidents, 1 critical · 5 of 6 uptime checks passing · 0 of 2 SLOs within budget", + ), + ).toExist(), + expectView(role("link", { name: /Critical.*checkout-api.*Open 1h 50m/ })).toHaveAttr( + "href", + incidentHref(CRITICAL_INCIDENT.id), + ), + expectView(role("link", { name: /Warning.*search-api.*Open 26m/ })).toHaveAttr( + "href", + incidentHref(WARNING_INCIDENT.id), + ), + expectView(role("link", { name: /payments-api.*Down 6m/ })).toHaveAttr( + "href", + monitorHref(DOWN_MONITOR.id), + ), + expectView(role("link", { name: /Checkout API.*Burning.*12%/ })).toHaveAttr( + "href", + sloHref(checkoutSlo().id), + ), + expectView(role("link", { name: /Search latency.*Out of budget.*-35%/ })).toHaveAttr( + "href", + sloHref(searchSlo().id), + ), + expectView( + role("link", { name: new RegExp(`${topError.serviceName}.*12,408 events`) }), + ).toHaveAttr("href", hrefOf(AppRoute.ErrorIssue({ issueId: topError.id, search: {} }))), + ) + }) + + test("an org with nothing set up is told what is missing and who can add it, with no setup control", () => { + scene( + { update: pageUpdate, view }, + given(at("empty")), + mounted, + expectView(verdict("Nothing is monitored yet.")).toExist(), + expectView( + text("An engineer on your team can set up alert rules, uptime checks and SLOs."), + ).toExist(), + expectView(text("No alert rules yet. An engineer on your team can set them up.")).toExist(), + expectView(text("No uptime checks yet. An engineer on your team can set them up.")).toExist(), + expectView(text("No SLOs yet. An engineer on your team can set them up.")).toExist(), + expectView(text("No error data yet. An engineer on your team can connect your app.")).toExist(), + expectView(verdict("Everything is working.")).toBeAbsent(), + expectView(role("button", { name: /connect|new|create|set up/i })).toBeAbsent(), + expectView(role("link", { name: /connect|new|create|settings|set up/i })).toBeAbsent(), + ) + }) + + test("while the reads are out there is no verdict", () => { + scene( + { update: pageUpdate, view }, + given(at("loading")), + mounted, + expectView(role("heading", { level: 1 })).toBeAbsent(), + expectView(text("Everything is working.")).toBeAbsent(), + ) + }) + + test("a failed uptime read is named in the verdict, the other sections still show, and Try again asks again", () => { + const failed = at("failed") + scene( + { update: pageUpdate, view }, + given(failed), + mounted, + expectView(verdict("No problems found, but uptime couldn't be checked.")).toExist(), + expectView(verdict("Everything is working.")).toBeAbsent(), + expectView( + text("No open incidents · Couldn't check uptime · 2 of 2 SLOs within budget"), + ).toExist(), + expectView(text("Couldn't load uptime checks")).toExist(), + expectView(text("No open incidents")).toExist(), + expectView(text("All 2 SLOs are within budget")).toExist(), + expectView(role("link", { name: /12,408 events/ })).toExist(), + click(role("button", { name: "Try again" })), + Command.resolve( + FetchMonitors, + AnalystMessage.CompletedFetchMonitors({ + key: failed.analyst?.monitors.key ?? "", + result: Result.succeed([httpMonitor()]), + }), + ), + expectView(verdict("Everything is working.")).toExist(), + expectView(text("The 1 check is passing")).toExist(), + ) + }) + + /** `state` after one more answer from the analyst page's reads. */ + const after = (state: AnalystHomeState, answer: (analyst: AnalystModel) => AnalystMessage): Model => { + const model = at(state) + if (model.analyst === null) return model + return pageUpdate(model, Message.GotAnalystMessage({ message: answer(model.analyst) })).model + } + + test("checks that are all paused do not count as everything working", () => { + const paused = after("clear", (analyst) => + AnalystMessage.CompletedFetchMonitors({ + key: analyst.monitors.key ?? "", + result: Result.succeed([httpMonitor({ enabled: false })]), + }), + ) + scene( + { update: pageUpdate, view }, + given(paused), + mounted, + expectView(verdict("No problems found, but uptime couldn't be checked.")).toExist(), + expectView(verdict("Everything is working.")).toBeAbsent(), + ) + }) + + test("an empty error list is only called a clean day when the app is known to send data", () => { + const noErrors = (analyst: AnalystModel) => + AnalystMessage.CompletedFetchErrors({ + key: analyst.errors.key ?? "", + result: Result.succeed([]), + }) + scene( + { update: pageUpdate, view }, + given(after("clear", noErrors)), + mounted, + expectView(text("No open errors in the last 24 hours")).toExist(), + ) + // The presence read has not answered: nothing says the app was watched. + scene( + { update: pageUpdate, view }, + given(after("loading", noErrors)), + mounted, + expectView(text("No open errors in the last 24 hours")).toBeAbsent(), + expectView(text("No open errors found")).toExist(), + ) + }) + + test("the engineers' live rows are not read while an analyst's page is drawn", () => { + const drawn = (model: Model) => + subscriptions.incidents.modelToDependencies({ model, context: ANALYST_CONTEXT }).isDrawn + expect(drawn(at("clear"))).toBe(false) + expect(drawn(init({ search: {} }, OPERATOR_CONTEXT).model)).toBe(true) + }) + + test("an analyst's entry starts their reads and none of the engineers'", () => { + const entered = init({ search: {} }, ANALYST_CONTEXT) + expect(names(entered.commands)).toEqual( + expect.arrayContaining([ + "ReadAnalystHomeClock", + "FetchAnalystHomeMonitors", + "FetchAnalystHomeSlos", + "LoadAnalystHomeSignalPresence", + ]), + ) + expect(names(entered.commands)).not.toContain("ReadOverviewEntry") + expect(entered.model.setup).toBeNull() + }) + + test("a role that arrives after entry draws the analyst page, and its mount starts the reads once", () => { + const asOperator = init({ search: {} }, OPERATOR_CONTEXT).model + const loading = analystHomeModel("loading") + scene( + { update: pageUpdate, view }, + given(asOperator), + expectView(role("heading", { level: 1, name: "Overview" })).toBeAbsent(), + expectView(text("Open incidents")).toExist(), + mounted, + Command.resolve(ReadClock, AnalystMessage.CompletedReadClock({ nowMs: NOW })), + Command.resolve( + LoadSignalPresence, + AnalystMessage.CompletedLoadSignalPresence({ result: Result.succeed({}) }), + ), + Command.resolve( + FetchMonitors, + AnalystMessage.CompletedFetchMonitors({ + key: loading.monitors.key ?? "", + result: Result.succeed([]), + }), + ), + Command.resolve( + FetchSlos, + AnalystMessage.CompletedFetchSlos({ + key: loading.slos.key ?? "", + result: Result.succeed([]), + }), + ), + Command.resolve( + FetchTopErrors, + AnalystMessage.CompletedFetchErrors({ + key: loading.errors.key ?? "", + result: Result.succeed([]), + }), + ), + expectView(text("No uptime checks yet. An engineer on your team can set them up.")).toExist(), + ) + const entered = pageUpdate( + asOperator, + Message.GotAnalystMessage({ message: AnalystMessage.Entered() }), + ) + const again = pageUpdate( + entered.model, + Message.GotAnalystMessage({ message: AnalystMessage.Entered() }), + ) + expect(names(again.commands)).toEqual([]) + }) + + test("the top errors wait for the clock, then are asked for over the last 24 hours", () => { + const entered = init({ search: {} }, ANALYST_CONTEXT) + expect(names(entered.commands)).not.toContain("FetchAnalystHomeTopErrors") + const clocked = pageUpdate( + entered.model, + Message.GotAnalystMessage({ message: AnalystMessage.CompletedReadClock({ nowMs: NOW }) }), + ) + expect(names(clocked.commands)).toEqual(["FetchAnalystHomeTopErrors"]) + expect(clocked.model.analyst?.window).toEqual({ + startTime: "2026-09-30 12:00:00", + endTime: "2026-10-01 12:00:00", + }) + }) + + test("an operator still gets the engineers' overview and its reads", () => { + const entered = init({ search: {} }, OPERATOR_CONTEXT) + expect(entered.model.analyst).toBeNull() + expect(names(entered.commands)).toEqual( + expect.arrayContaining(["ReadOverviewEntry", "LoadOverviewSignalPresence"]), + ) + expect(names(entered.commands)).not.toContain("FetchAnalystHomeMonitors") + scene( + { + update: (model: Model, message: Message) => update(model, message, OPERATOR_CONTEXT), + view: withViewInputs(pageView, { context: OPERATOR_CONTEXT })(), + }, + given(entered.model), + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), + Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()), + expectView(role("heading", { level: 1, name: "Overview" })).toExist(), + expectView(text("Open incidents")).toBeAbsent(), + expectView(text("Top errors")).toBeAbsent(), + ) + }) +}) + +describe("topErrorsOf", () => { + const issues = Arr.map(buildErrorsLabFixture(NOW).signals, (signal) => signal.issue) + const counted = (fingerprintHash: string, count: number) => ({ + fingerprintHash, + errorLabel: "", + sampleMessage: "", + count, + affectedServicesCount: 1, + firstSeen: new Date(NOW), + lastSeen: new Date(NOW), + }) + + test("ranks issues by their events in the window and leaves out the ones with none", () => { + const first = Arr.getUnsafe(issues, 0) + const second = Arr.getUnsafe(issues, 1) + const top = topErrorsOf(issues, [ + counted(second.fingerprintHash, 40), + counted(first.fingerprintHash, 7), + ]) + expect(Arr.map(top, (error) => [error.id, error.count])).toEqual([ + [second.id, 40], + [first.id, 7], + ]) + }) + + test("lists five at most", () => { + const top = topErrorsOf( + issues, + Arr.map(issues, (issue, index) => counted(issue.fingerprintHash, index + 1)), + ) + expect(top).toHaveLength(Math.min(5, issues.length)) + expect(Arr.map(top, (error) => error.count)).toEqual( + Arr.map(top, (_, index) => issues.length - index), + ) + }) +}) diff --git a/apps/web/src/page/overview/analyst/command.ts b/apps/web/src/page/overview/analyst/command.ts new file mode 100644 index 0000000000..709d37d52d --- /dev/null +++ b/apps/web/src/page/overview/analyst/command.ts @@ -0,0 +1,83 @@ +import { getErrorsByType } from "@maple/web-data/warehouse/errors" +import { Effect, Schema, Array as Arr } from "effect" +import { Command, Mount } from "foldkit" +import { settleTo } from "@maple/views/data/load-failure" +import { readClock, reloadSpin } from "@maple/views/data/timing" +import { errorIssueFromV2 } from "../../../data/error-issues" +import { TimeWindow } from "../../../data/time-range" +import { MapleApiV2Client } from "../../../platform/api-clients" +import { listSloRows } from "../../alerts/shared/slo-commands" +import { loadSignalPresences } from "../../kit/signal-presence" +import { listMonitors } from "../../kit/uptime/commands" +import { RANK_LIMIT, topErrorsOf } from "./derive" +import { Message } from "./message" + +/** The page is in the DOM: the reads it has not started yet start now. */ +export const MountAnalystHome = Mount.define("MountAnalystHome", { + messages: [Message.Entered], + execute: () => Effect.succeed(Message.Entered()), +}) + +export const ReadClock = readClock("ReadAnalystHomeClock", Message.CompletedReadClock) + +/** Reload and a return to the page: the real clock, so every read is asked again. */ +export const ResolveClock = readClock("ResolveAnalystHomeClock", Message.CompletedResolveClock) + +export const CompleteReloadSpin = reloadSpin("CompleteAnalystHomeReloadSpin", Message.CompletedReloadSpin) + +/** `uptimeMonitors.list`, the read the Uptime tab makes. */ +export const FetchMonitors = Command.define("FetchAnalystHomeMonitors", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchMonitors], + execute: ({ key }) => + listMonitors.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchMonitors({ key, result })), + ), +}) + +/** `slos.list` with every status, the read the SLOs tab makes. */ +export const FetchSlos = Command.define("FetchAnalystHomeSlos", { + args: { key: Schema.String }, + messages: [Message.CompletedFetchSlos], + execute: ({ key }) => + listSloRows.pipe( + Effect.result, + Effect.map((result) => Message.CompletedFetchSlos({ key, result })), + ), +}) + +/** + * The Errors page's volume sort: `getErrorsByType` ranks the window's fingerprints, then + * `errorIssues.list` reads exactly those open issues. + */ +export const FetchTopErrors = Command.define("FetchAnalystHomeTopErrors", { + args: { key: Schema.String, window: TimeWindow }, + messages: [Message.CompletedFetchErrors], + execute: ({ key, window }) => + Effect.gen(function* () { + const ranked = yield* getErrorsByType({ + data: { startTime: window.startTime, endTime: window.endTime, limit: RANK_LIMIT }, + }) + if (ranked.data.length === 0) return [] + const client = yield* MapleApiV2Client + const response = yield* client.errorIssues.list({ + query: { + limit: RANK_LIMIT, + state: "open", + fingerprint_hash: Arr.join( + Arr.map(ranked.data, (row) => row.fingerprintHash), + ",", + ), + }, + }) + return topErrorsOf(Arr.map(response.data, errorIssueFromV2), ranked.data) + }).pipe(settleTo((result) => Message.CompletedFetchErrors({ key, result }))), +}) + +export const LoadSignalPresence = Command.define("LoadAnalystHomeSignalPresence", { + args: { orgId: Schema.String }, + messages: [Message.CompletedLoadSignalPresence], + execute: ({ orgId }) => + Effect.map(loadSignalPresences(orgId), (result) => Message.CompletedLoadSignalPresence({ result })), +}) diff --git a/apps/web/src/page/overview/analyst/derive.ts b/apps/web/src/page/overview/analyst/derive.ts new file mode 100644 index 0000000000..eb58159f47 --- /dev/null +++ b/apps/web/src/page/overview/analyst/derive.ts @@ -0,0 +1,254 @@ +import type { AlertIncidentDocument, ErrorIssueDocument } from "@maple/domain/http" +import { countLabel, pluralize } from "@maple/ui/lib/format" +import type { Tone } from "@maple/ui/lib/tone" +import { rowsToAlertIncidentDocuments } from "@maple/web-data/collections/alerts" +import type { ErrorByType } from "@maple/web-data/warehouse/errors" +import { Option, Order, Array as Arr, String as Str } from "effect" +import { AsyncData } from "foldkit" +import { isBudgetSpent, isBurning } from "../../alerts/shared/slo-derive" +import { sortIncidents } from "../../alerts/shared/sort" +import { GENERIC_TYPES } from "../../errors/hub/view-signal" +import { buildErrorSignals, type ErrorSignal } from "../../errors/kit/error-signal" +import { displayStatus, type UptimeMonitor } from "../../kit/uptime/derive" +import type { Model, SloRow, TopError } from "./model" + +/** Rows a section lists before its footer link takes over. */ +export const SECTION_ROWS = 5 +/** How many of the window's fingerprints are ranked to find the open issues among them. */ +export const RANK_LIMIT = 25 + +/** One read as the page shows it: still out, failed, or answered. */ +export type Part = + | Readonly<{ _tag: "Loading" }> + | Readonly<{ _tag: "Failed" }> + | Readonly<{ _tag: "Ready"; value: A }> + +const LOADING: Part = { _tag: "Loading" } +const FAILED: Part = { _tag: "Failed" } +const ready = (value: A): Part => ({ _tag: "Ready", value }) + +const partOf = (data: AsyncData.AsyncData, read: (value: A) => Part): Part => + Option.match(AsyncData.getData(data), { + onNone: () => (AsyncData.isFailure(data) ? FAILED : LOADING), + onSome: read, + }) + +export type IncidentsSummary = Readonly<{ + /** Open incidents, critical first, then the latest trigger. */ + open: ReadonlyArray + critical: number + /** The org has alert rules (or had incidents); an unreadable rule count reads as set up. */ + isSetUp: boolean +}> + +export const incidentsOf = (model: Model): Part => + partOf(model.incidents, (rows) => { + const open = sortIncidents( + Arr.filter(rowsToAlertIncidentDocuments(rows), (incident) => incident.status === "open"), + ) + const summary = (isSetUp: boolean): Part => + ready({ + open, + critical: Arr.filter(open, (incident) => incident.severity === "critical").length, + isSetUp, + }) + if (rows.length > 0) return summary(true) + // No incident ever: whether that is "all quiet" or "no rules" waits for the rule count. + return Option.match(AsyncData.getData(model.ruleCount), { + onNone: () => (AsyncData.isFailure(model.ruleCount) ? summary(true) : LOADING), + onSome: (count) => summary(count > 0), + }) + }) + +export type UptimeSummary = Readonly<{ + /** Checks that are running and have reported: paused and pending ones are left out. */ + total: number + passing: number + /** Down first, then the ones whose latest checks failed. */ + failing: ReadonlyArray + isSetUp: boolean +}> + +/** Checks exist but none is running and reporting. */ +const isIdle = (uptime: UptimeSummary): boolean => uptime.isSetUp && uptime.total === 0 + +export const uptimeOf = (model: Model): Part => + partOf(model.monitors.data, (monitors) => { + const withStatus = (status: ReturnType) => + Arr.filter(monitors, (monitor) => displayStatus(monitor) === status) + const down = withStatus("down") + const degraded = withStatus("degraded") + const passing = withStatus("up").length + return ready({ + total: passing + down.length + degraded.length, + passing, + failing: [...down, ...degraded], + isSetUp: monitors.length > 0, + }) + }) + +export type SloTrouble = Readonly<{ + slo: SloRow["slo"] + state: "burning" | "spent" + /** The share of the error budget left; negative once it is overspent. */ + budgetRemaining: number +}> + +export type SlosSummary = Readonly<{ + total: number + within: number + /** Burning first, then the ones out of budget. */ + troubled: ReadonlyArray + isSetUp: boolean +}> + +export const slosOf = (model: Model): Part => + partOf(model.slos.data, (rows) => { + // Every status arrives in one read: none at all means that read failed, not that all is well. + if (rows.length > 0 && Arr.every(rows, (row) => row.status === null)) return FAILED + const incidents = Option.getOrElse(AsyncData.getData(model.incidents), () => []) + const troubleOf = (row: SloRow): ReadonlyArray => { + if (row.status === null) return [] + const budgetRemaining = row.status.budget_remaining + if (isBurning(row, incidents)) return [{ slo: row.slo, state: "burning", budgetRemaining }] + return isBudgetSpent(budgetRemaining) ? [{ slo: row.slo, state: "spent", budgetRemaining }] : [] + } + const troubled = Arr.flatMap(rows, troubleOf) + const known = Arr.filter(rows, (row) => row.status !== null).length + return ready({ + total: rows.length, + within: known - troubled.length, + troubled: [ + ...Arr.filter(troubled, (trouble) => trouble.state === "burning"), + ...Arr.filter(troubled, (trouble) => trouble.state === "spent"), + ], + isSetUp: rows.length > 0, + }) + }) + +export type Verdict = + | Readonly<{ _tag: "Loading" }> + | Readonly<{ + _tag: "Ready" + tone: Tone + sentence: string + /** The counts that back the sentence, one per source. */ + support: ReadonlyArray + }> + +const incidentsLine = (part: Part): string => { + if (part._tag !== "Ready") return "Couldn't check incidents" + const { open, critical, isSetUp } = part.value + if (!isSetUp) return "No alert rules" + if (open.length === 0) return "No open incidents" + const count = `${open.length} open ${pluralize(open.length, "incident")}` + return critical === 0 ? count : `${count}, ${critical} critical` +} + +const uptimeLine = (part: Part): string => { + if (part._tag !== "Ready") return "Couldn't check uptime" + const { total, passing, isSetUp } = part.value + if (!isSetUp) return "No uptime checks" + if (total === 0) return "No uptime checks running" + return `${passing} of ${total} uptime ${pluralize(total, "check")} passing` +} + +const slosLine = (part: Part): string => { + if (part._tag !== "Ready") return "Couldn't check SLOs" + const { total, within, isSetUp } = part.value + return isSetUp ? `${within} of ${total} ${pluralize(total, "SLO")} within budget` : "No SLOs" +} + +const listOf = (names: ReadonlyArray): string => + names.length <= 1 + ? Arr.join(names, "") + : `${Arr.join(names.slice(0, -1), ", ")} and ${names[names.length - 1] ?? ""}` + +/** What is wrong, in the order a reader should hear it: incidents, failing checks, SLOs. */ +const problemsOf = ( + incidents: Part, + uptime: Part, + slos: Part, +): ReadonlyArray => { + const open = incidents._tag === "Ready" ? incidents.value.open.length : 0 + const failing = uptime._tag === "Ready" ? uptime.value.failing.length : 0 + const troubled = slos._tag === "Ready" ? slos.value.troubled : [] + const burning = Arr.filter(troubled, (trouble) => trouble.state === "burning").length + const spent = troubled.length - burning + return [ + ...(open > 0 ? [`${countLabel(open, "incident")} open.`] : []), + ...(failing > 0 ? [`${countLabel(failing, "check")} failing.`] : []), + ...(burning > 0 ? [`${countLabel(burning, "SLO")} burning.`] : []), + ...(spent > 0 ? [`${countLabel(spent, "SLO")} out of budget.`] : []), + ] +} + +/** The page's one-sentence answer; no verdict until incidents, uptime and SLOs have all answered. */ +export const verdictOf = (model: Model): Verdict => { + const incidents = incidentsOf(model) + const uptime = uptimeOf(model) + const slos = slosOf(model) + if (incidents._tag === "Loading" || uptime._tag === "Loading" || slos._tag === "Loading") + return { _tag: "Loading" } + const support = [incidentsLine(incidents), uptimeLine(uptime), slosLine(slos)] + const problems = problemsOf(incidents, uptime, slos) + if (problems.length > 0) + return { _tag: "Ready", tone: "crit", sentence: Arr.join(problems, " "), support } + const unchecked = [ + ...(incidents._tag === "Failed" ? ["incidents"] : []), + // Checks that are all paused or yet to report say nothing about whether the product is up. + ...(uptime._tag === "Failed" || (uptime._tag === "Ready" && isIdle(uptime.value)) ? ["uptime"] : []), + ...(slos._tag === "Failed" ? ["SLOs"] : []), + ] + if (unchecked.length === 3) + return { _tag: "Ready", tone: "neutral", sentence: "Couldn't check the status.", support: [] } + if (unchecked.length > 0) + return { + _tag: "Ready", + tone: "neutral", + sentence: `No problems found, but ${listOf(unchecked)} couldn't be checked.`, + support, + } + const isAnySetUp = + (incidents._tag === "Ready" && incidents.value.isSetUp) || + (uptime._tag === "Ready" && uptime.value.isSetUp) || + (slos._tag === "Ready" && slos.value.isSetUp) + return isAnySetUp + ? { _tag: "Ready", tone: "ok", sentence: "Everything is working.", support } + : { + _tag: "Ready", + tone: "neutral", + sentence: "Nothing is monitored yet.", + support: ["An engineer on your team can set up alert rules, uptime checks and SLOs."], + } +} + +const firstLine = (text: string): string => Str.trim(Str.split("\n")(text)[0] ?? "") + +/** The Errors list's title rule: a generic type gives way to its message, any other leads it. */ +const titleOf = (signal: ErrorSignal): string => { + const message = firstLine(signal.detail) + if (message === "") return signal.title + return GENERIC_TYPES.has(signal.title) ? message : `${signal.title}: ${message}` +} + +const byWindowCount = Order.flip( + Order.mapInput(Order.Number, (signal: ErrorSignal) => signal.windowCount ?? 0), +) + +/** The issues with the most events in the window, joined to their counts the way the Errors list joins them. */ +export const topErrorsOf = ( + issues: ReadonlyArray, + warehouse: ReadonlyArray, +): ReadonlyArray => { + const signals = buildErrorSignals({ issues, warehouse, spark: [], investigations: new Map() }) + const counted = Arr.filter(signals, (signal) => (signal.windowCount ?? 0) > 0) + return Arr.map(Arr.take(Arr.sort(counted, byWindowCount), SECTION_ROWS), (signal): TopError => ({ + id: signal.id, + title: titleOf(signal), + serviceName: signal.serviceName, + severity: signal.severity, + count: signal.windowCount ?? 0, + lastSeenAt: signal.lastSeenAt, + })) +} diff --git a/apps/web/src/page/overview/analyst/message.ts b/apps/web/src/page/overview/analyst/message.ts new file mode 100644 index 0000000000..d8a8a3cb45 --- /dev/null +++ b/apps/web/src/page/overview/analyst/message.ts @@ -0,0 +1,36 @@ +import { V2UptimeMonitor } from "@maple/domain/http/v2" +import { AlertIncidentRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { LoadFailure } from "@maple/views/data/load-failure" +import { SignalPresences } from "@maple/views/data/signal-presence" +import { SloRow, TopError } from "./model" + +/** The sections whose read can be asked again from their error state. */ +export const RetryTarget = Schema.Literals(["Monitors", "Slos", "Errors"]) +export type RetryTarget = typeof RetryTarget.Type + +const result = (schema: A) => ({ + key: Schema.String, + result: Schema.Result(schema, LoadFailure), +}) + +export const Message = defineMessageUnion({ + /** The page is on screen: its reads start here, since the role can arrive after `init`. */ + Entered: {}, + CompletedReadClock: { nowMs: Schema.Number }, + /** Reload's clock: every read is asked again against it. */ + CompletedResolveClock: { nowMs: Schema.Number }, + UpdatedIncidents: { orgId: Schema.String, rows: Schema.Array(AlertIncidentRowSchema) }, + FailedLoadIncidents: { orgId: Schema.String }, + UpdatedRules: { orgId: Schema.String, count: Schema.Number }, + FailedLoadRules: { orgId: Schema.String }, + CompletedFetchMonitors: result(Schema.Array(V2UptimeMonitor)), + CompletedFetchSlos: result(Schema.Array(SloRow)), + CompletedFetchErrors: result(Schema.Array(TopError)), + CompletedLoadSignalPresence: { result: Schema.Result(SignalPresences, LoadFailure) }, + ClickedReload: {}, + CompletedReloadSpin: { version: Schema.Number }, + ClickedRetry: { target: RetryTarget }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/overview/analyst/model.ts b/apps/web/src/page/overview/analyst/model.ts new file mode 100644 index 0000000000..c4adeb1b18 --- /dev/null +++ b/apps/web/src/page/overview/analyst/model.ts @@ -0,0 +1,55 @@ +import { ErrorIssueId, IssueSeverity } from "@maple/domain/http" +import { V2Slo, V2SloStatus, V2UptimeMonitor } from "@maple/domain/http/v2" +import { AlertIncidentRowSchema } from "@maple/web-data/collections/alerts" +import { Schema } from "effect" +import { AsyncData } from "foldkit" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import { SignalPresenceData } from "@maple/views/data/signal-presence" +import { TimeWindow } from "../../../data/time-range" + +// The analyst's Overview: is the product working, and what is worth knowing. + +/** The synced alert incidents; `Unavailable` when the live sync failed or stalled. */ +export const IncidentsData = AsyncData.Schema( + Schema.Array(AlertIncidentRowSchema), + Schema.Literal("Unavailable"), +) + +/** How many alert rules the org has, to tell "nothing firing" from "nothing set up". */ +export const RuleCountData = AsyncData.Schema(Schema.Number, Schema.Literal("Unavailable")) + +/** An SLO beside its status (`listSloRows`); `status` is `null` when that read failed. */ +export const SloRow = Schema.Struct({ slo: V2Slo, status: Schema.NullOr(V2SloStatus) }) +export type SloRow = typeof SloRow.Type + +/** One error issue with its events in the window. */ +export const TopError = Schema.Struct({ + id: ErrorIssueId, + title: Schema.String, + serviceName: Schema.String, + severity: Schema.NullOr(IssueSeverity), + count: Schema.Number, + lastSeenAt: Schema.String, +}) +export type TopError = typeof TopError.Type + +export const Model = Schema.Struct({ + /** The reads were started (by `init`, or by the Mount when the role arrived after it). */ + isEntered: Schema.Boolean, + /** The clock the page was last read at; `null` until the entry Command answered. */ + nowMs: Schema.NullOr(Schema.Number), + /** The last 24 hours the top errors are counted over. */ + window: Schema.NullOr(TimeWindow), + incidents: IncidentsData.schema, + ruleCount: RuleCountData.schema, + monitors: KeyedSchema(Schema.Array(V2UptimeMonitor), LoadFailure), + slos: KeyedSchema(Schema.Array(SloRow), LoadFailure), + errors: KeyedSchema(Schema.Array(TopError), LoadFailure), + /** Whether the org ever sent traces, so "no errors" is not said of an app that reports nothing. */ + presence: SignalPresenceData.schema, + /** Reloads so far (non-zero stops snapping) and the 600 ms spinner. */ + refreshVersion: Schema.Number, + isReloading: Schema.Boolean, +}) +export type Model = typeof Model.Type diff --git a/apps/web/src/page/overview/analyst/subscription.ts b/apps/web/src/page/overview/analyst/subscription.ts new file mode 100644 index 0000000000..58b89190b8 --- /dev/null +++ b/apps/web/src/page/overview/analyst/subscription.ts @@ -0,0 +1,58 @@ +import { Schema, Stream } from "effect" +import { AsyncData, Subscription } from "foldkit" +import type { Resources } from "../../../platform/resources" +import type { PageContext } from "../../contract" +import { incidentRows, ruleRows } from "../../kit/alert-collections" +import { collectionLoadFailed, ENRICHMENT_LOAD_TIMEOUT_MS } from "../../kit/collection-load" +import { Message } from "./message" +import type { Model } from "./model" + +/** How long the verdict waits on the live incidents before saying they could not be checked. */ +const INCIDENTS_LOAD_TIMEOUT_MS = 10_000 + +export type Input = Readonly<{ model: Model; context: PageContext }> + +const byOrg = { orgId: Schema.String } +const waiting = { orgId: Schema.String, isWaiting: Schema.Boolean } + +/** The synced alert incidents and rules the alerts pages read, with an end state for a stalled sync. */ +export const subscriptions = Subscription.make()((entry) => ({ + analystIncidents: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + }), + analystIncidentsLoadFailed: entry(waiting, { + modelToDependencies: ({ model, context }) => ({ + orgId: context.orgId, + isWaiting: AsyncData.isLoading(model.incidents), + }), + dependenciesToStream: ({ orgId, isWaiting }) => + isWaiting + ? collectionLoadFailed( + `alert_incidents:${orgId}`, + () => Message.FailedLoadIncidents({ orgId }), + INCIDENTS_LOAD_TIMEOUT_MS, + ) + : Stream.empty, + }), + analystRules: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, count: rows.length })), + }), + analystRulesLoadFailed: entry(waiting, { + modelToDependencies: ({ model, context }) => ({ + orgId: context.orgId, + isWaiting: AsyncData.isLoading(model.ruleCount), + }), + dependenciesToStream: ({ orgId, isWaiting }) => + isWaiting + ? collectionLoadFailed( + `alert_rules:${orgId}`, + () => Message.FailedLoadRules({ orgId }), + ENRICHMENT_LOAD_TIMEOUT_MS, + ) + : Stream.empty, + }), +})) diff --git a/apps/web/src/page/overview/analyst/update.ts b/apps/web/src/page/overview/analyst/update.ts new file mode 100644 index 0000000000..1fb03a9ec1 --- /dev/null +++ b/apps/web/src/page/overview/analyst/update.ts @@ -0,0 +1,167 @@ +import { AsyncData } from "foldkit" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import { SignalPresenceData } from "@maple/views/data/signal-presence" +import { resolveWindow, type TimeWindow } from "../../../data/time-range" +import type { PageContext, PageReturn } from "../../contract" +import { keyed as keyedStep, runSteps, type Step as KitStep } from "../../kit/keyed-step" +import { + CompleteReloadSpin, + FetchMonitors, + FetchSlos, + FetchTopErrors, + LoadSignalPresence, + ReadClock, + ResolveClock, +} from "./command" +import { Message, type RetryTarget } from "./message" +import { IncidentsData, type Model, RuleCountData } from "./model" + +export type Return = PageReturn +type Step = KitStep + +/** The window the top errors are counted over. */ +export const ERRORS_WINDOW = "24h" + +/** Nothing read yet: every section shows its skeleton. */ +export const EMPTY: Model = { + isEntered: false, + nowMs: null, + window: null, + incidents: IncidentsData.Loading(), + ruleCount: RuleCountData.Loading(), + monitors: Keyed.idle(), + slos: Keyed.idle(), + errors: Keyed.idle(), + presence: SignalPresenceData.Loading(), + refreshVersion: 0, + isReloading: false, +} + +/** The last 24 hours, snapped to the cache grid until a Reload. */ +const windowAt = (model: Model, context: PageContext, nowMs: number): TimeWindow => + resolveWindow( + { preset: ERRORS_WINDOW, snap: model.refreshVersion === 0, timeZone: context.timeZone }, + nowMs, + ) + +/** Each read on its own slot, so one failing leaves the others on screen; a failure waits for a retry. */ +const sync = (model: Model, context: PageContext, mode: Keyed.RequestMode): Return => { + const { window } = model + const steps: ReadonlyArray<(current: Model) => Step> = [ + (current) => + keyedStep( + current.monitors, + requestKey(context.orgId, "uptimeMonitors.list", {}), + mode, + (monitors) => ({ monitors }), + (key) => FetchMonitors({ key }), + { keepFailure: true }, + ), + (current) => + keyedStep( + current.slos, + requestKey(context.orgId, "slos.listRows", {}), + mode, + (slos) => ({ slos }), + (key) => FetchSlos({ key }), + { keepFailure: true }, + ), + ...(window === null + ? [] + : [ + (current: Model): Step => + keyedStep( + current.errors, + requestKey(context.orgId, "analystHome.topErrors", window), + mode, + (errors) => ({ errors }), + (key) => FetchTopErrors({ key, window }), + { keepFailure: true }, + ), + ]), + ] + return runSteps(model, steps) +} + +/** Starts the reads once: `init` for a known analyst, else the page's Mount. */ +export const enter = (model: Model, context: PageContext): Return => { + if (model.isEntered) return { model } + const synced = sync({ ...model, isEntered: true }, context, "Load") + return { + model: synced.model, + commands: [ReadClock(), LoadSignalPresence({ orgId: context.orgId }), ...(synced.commands ?? [])], + } +} + +export const init = (context: PageContext): Return => enter(EMPTY, context) + +/** Back after leaving: the last answers stay on screen while every read is asked again. */ +export const revive = (model: Model, context: PageContext): Return => + model.isEntered + ? { + model: { + ...model, + refreshVersion: 0, + isReloading: false, + monitors: Keyed.forgetFailure(model.monitors), + slos: Keyed.forgetFailure(model.slos), + errors: Keyed.forgetFailure(model.errors), + }, + commands: [ResolveClock()], + } + : enter(model, context) + +const RETRY_SLOT = { + Monitors: "monitors", + Slos: "slos", + Errors: "errors", +} as const satisfies Record + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + Entered: () => enter(model, context), + CompletedReadClock: ({ nowMs }) => + sync({ ...model, nowMs, window: windowAt(model, context, nowMs) }, context, "Load"), + CompletedResolveClock: ({ nowMs }) => + sync({ ...model, nowMs, window: windowAt(model, context, nowMs) }, context, "Refresh"), + UpdatedIncidents: ({ orgId, rows }) => + orgId === context.orgId + ? { model: { ...model, incidents: IncidentsData.Success({ data: rows }) } } + : { model }, + FailedLoadIncidents: ({ orgId }) => + orgId === context.orgId && AsyncData.isLoading(model.incidents) + ? { model: { ...model, incidents: IncidentsData.Failure({ error: "Unavailable" }) } } + : { model }, + UpdatedRules: ({ orgId, count }) => + orgId === context.orgId + ? { model: { ...model, ruleCount: RuleCountData.Success({ data: count }) } } + : { model }, + FailedLoadRules: ({ orgId }) => + orgId === context.orgId && AsyncData.isLoading(model.ruleCount) + ? { model: { ...model, ruleCount: RuleCountData.Failure({ error: "Unavailable" }) } } + : { model }, + CompletedFetchMonitors: ({ key, result }) => ({ + model: { ...model, monitors: Keyed.accept(model.monitors, key, result) }, + }), + CompletedFetchSlos: ({ key, result }) => ({ + model: { ...model, slos: Keyed.accept(model.slos, key, result) }, + }), + CompletedFetchErrors: ({ key, result }) => ({ + model: { ...model, errors: Keyed.accept(model.errors, key, result) }, + }), + CompletedLoadSignalPresence: ({ result }) => ({ + model: { ...model, presence: AsyncData.settle(model.presence, result) }, + }), + ClickedReload: () => { + const reloaded = { ...model, refreshVersion: model.refreshVersion + 1, isReloading: true } + return { + model: reloaded, + commands: [ResolveClock(), CompleteReloadSpin({ version: reloaded.refreshVersion })], + } + }, + CompletedReloadSpin: ({ version }) => + version === model.refreshVersion ? { model: { ...model, isReloading: false } } : { model }, + // A failed slot keeps its failure under `Load`: forget it, so the next sync asks again. + ClickedRetry: ({ target }) => sync({ ...model, [RETRY_SLOT[target]]: Keyed.idle() }, context, "Load"), + }) diff --git a/apps/web/src/page/overview/analyst/view.ts b/apps/web/src/page/overview/analyst/view.ts new file mode 100644 index 0000000000..21cefbddf1 --- /dev/null +++ b/apps/web/src/page/overview/analyst/view.ts @@ -0,0 +1,387 @@ +import type { AlertIncidentDocument } from "@maple/domain/http" +import { pluralize } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" +import { formatRelativeTime } from "@maple/ui/lib/time-format" +import { formatAlertDuration } from "@maple/web-data/alerts/form-utils" +import { incidentSubline, incidentTitle } from "@maple/web-data/alerts/overview-derive" +import { Option, Array as Arr } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type { LoadFailure } from "@maple/views/data/load-failure" +import { presenceOf } from "@maple/views/data/signal-presence" +import { errorState } from "@maple/views/ui/error-state" +import { grid } from "@maple/views/ui/grid" +import { icon } from "@maple/views/ui/icon" +import { listRow } from "@maple/views/ui/list-row" +import { pageDescription, pageTitle } from "@maple/views/ui/page-layout" +import { panel, panelHeader } from "@maple/views/ui/panel" +import { refreshButton } from "@maple/views/ui/refresh-button" +import { strip } from "@maple/views/ui/rule" +import { skeleton, skeletonLine, skeletonList } from "@maple/views/ui/skeleton" +import { hstack, vstack } from "@maple/views/ui/stack" +import { statusDot } from "@maple/views/ui/status-dot" +import { textLink } from "@maple/views/ui/text-link" +import { typography } from "@maple/views/ui/typography" +import { AppRoute, hrefOf } from "../../../route" +import { severityBadge } from "../../alerts/shared/badges" +import { sloHref, slosTabHref } from "../../alerts/shared/slo-commands" +import { scopeLine } from "../../alerts/shared/slo-derive" +import { budgetBar } from "../../alerts/shared/slo-view" +import type { PageViewInputs } from "../../contract" +import { alertsHref, incidentHref } from "../../kit/alert-links" +import { msIso } from "../../kit/alert-time" +import { dashboardPage } from "../../kit/dashboard-page" +import { severityIcon } from "../../kit/issue-severity" +import { monitorHref, uptimeTabHref } from "../../kit/uptime/commands" +import { targetLine, type UptimeMonitor } from "../../kit/uptime/derive" +import { uptimeStatusChip, uptimeStatusSquare } from "../../kit/uptime/view" +import { MountAnalystHome } from "./command" +import { + incidentsOf, + SECTION_ROWS, + type SloTrouble, + slosOf, + uptimeOf, + type Verdict, + verdictOf, +} from "./derive" +import { Message, type RetryTarget } from "./message" +import type { Model, TopError } from "./model" + +type H = HtmlBuilder + +/** What an analyst cannot do themselves, said once per empty section. */ +const ASK_AN_ENGINEER = "An engineer on your team can set them up." + +const SKELETON_WIDTHS = ["48", "40", "44"] as const + +/** The status line: the verdict as the page's `

`, then the counts that back it. */ +const statusLine = (h: H, verdict: Verdict): Html => + verdict._tag === "Loading" + ? vstack(h, { gap: "2.5", attributes: [h.AriaBusy(true), h.AriaLabel("Checking the status")] }, [ + skeleton(h, "h-7 w-64 max-w-full"), + skeleton(h, "h-3.5 w-96 max-w-full"), + ]) + : vstack(h, { gap: "1" }, [ + hstack(h, { gap: "2.5", align: "start" }, [ + statusDot(h, { tone: verdict.tone, size: "lg", className: "mt-3" }), + pageTitle(h, { wrap: true }, [verdict.sentence]), + ]), + ...(verdict.support.length === 0 + ? [] + : [pageDescription(h, [Arr.join(verdict.support, " · ")], "pl-4.5 text-sm")]), + ]) + +type SectionOptions = Readonly<{ + title: string + /** The header's right side: the count that sums the section up. */ + summary?: string + footer: Readonly<{ label: string; href: string }> +}> + +/** One panel per question: a quiet title and count, its rows or one line, then the way to the full page. */ +const section = (h: H, options: SectionOptions, body: ReadonlyArray): Html => + panel(h, {}, [ + panelHeader(h, { + title: options.title, + action: + options.summary === undefined + ? null + : typography(h, { size: "2xs", tone: "muted", numeric: true }, [options.summary]), + }), + // Grows, so two panels side by side keep their footers on one line. + vstack(h, { grow: true }, body), + strip(h, { kind: "foot" }, [ + typography(h, { size: "xs", as: "div" }, [ + textLink(h, { href: options.footer.href, tone: "muted" }, [ + options.footer.label, + icon(h, "ArrowRightIcon", { size: 12 }), + ]), + ]), + ]), + ]) + +/** A section's single line: nothing to list, or nothing set up. */ +const note = (h: H, text: string): Html => + strip(h, { kind: "row" }, [typography(h, { size: "sm", tone: "muted", as: "p" }, [text])]) + +const loadingRows = (h: H, label: string): Html => + skeletonList(h, { + rows: 3, + gap: "0", + label, + renderRow: (index) => + strip(h, { kind: "row", attributes: [h.Key(String(index))] }, [ + vstack(h, { gap: "2", className: "py-1" }, [ + skeletonLine(h, { width: SKELETON_WIDTHS[index % 3] ?? "44" }), + skeletonLine(h, { width: "28" }), + ]), + ]), + }) + +/** A read that failed says so in its own section, with a way to ask again when there is one. */ +const failed = (h: H, title: string, failure: LoadFailure, target: RetryTarget): Html => + errorState(h, { + failure, + title, + variant: "inline", + onRetry: Message.ClickedRetry({ target }), + className: "px-4", + }) + +const rowsOr = ( + rows: ReadonlyArray, + toRow: (row: A) => Html, + whenEmpty: () => Html, +): ReadonlyArray => (rows.length === 0 ? [whenEmpty()] : Arr.map(Arr.take(rows, SECTION_ROWS), toRow)) + +/** "5 of 8 shown" once a section lists fewer rows than it has. */ +const shownOf = (count: number): string | undefined => + count > SECTION_ROWS ? `${SECTION_ROWS} of ${count} shown` : undefined + +const trailingText = (h: H, text: string, className?: string): Html => + typography( + h, + { size: "xs", tone: "muted", numeric: true, className: cn("whitespace-nowrap", className) }, + [text], + ) + +const incidentRow = (h: H, incident: AlertIncidentDocument, nowMs: number): Html => { + const openFor = `Open ${formatAlertDuration(incident.firstTriggeredAt, msIso(nowMs))}` + return listRow(h, { + href: incidentHref(incident.id), + divided: true, + leading: severityBadge(h, incident.severity, "w-18 justify-center"), + title: + incident.holdReason === null ? incidentTitle(incident) : `${incident.ruleName}: waiting on data`, + // A narrow page has no room for the trailing lane: the duration leads the second line instead. + meta: h.span( + [], + [h.span([h.Class("@md/page:hidden")], [`${openFor} · `]), incidentSubline(incident)], + ), + trailing: trailingText(h, openFor, "hidden @md/page:inline"), + }) +} + +const incidentsSection = (h: H, model: Model, nowMs: number | null): Html => { + const part = incidentsOf(model) + const body = (): ReadonlyArray => { + if (part._tag === "Failed") return [note(h, "Couldn't check incidents")] + if (part._tag === "Loading" || nowMs === null) return [loadingRows(h, "Loading incidents")] + const { open, isSetUp } = part.value + return rowsOr( + open, + (incident) => incidentRow(h, incident, nowMs), + () => note(h, isSetUp ? "No open incidents" : `No alert rules yet. ${ASK_AN_ENGINEER}`), + ) + } + return section( + h, + { + title: "Open incidents", + summary: part._tag === "Ready" ? shownOf(part.value.open.length) : undefined, + footer: { label: "All incidents", href: alertsHref({}) }, + }, + body(), + ) +} + +const monitorRow = (h: H, monitor: UptimeMonitor, nowMs: number): Html => + listRow(h, { + href: monitorHref(monitor.id), + divided: true, + leading: uptimeStatusSquare(h, monitor), + title: monitor.name, + meta: targetLine(monitor, nowMs), + trailing: uptimeStatusChip(h, monitor, nowMs), + }) + +const allPassing = (total: number): string => + total === 0 + ? "No uptime checks are running" + : total === 1 + ? "The 1 check is passing" + : `All ${total} checks are passing` + +const uptimeSection = (h: H, model: Model, nowMs: number | null): Html => { + const part = uptimeOf(model) + const body = (): ReadonlyArray => { + if (part._tag === "Failed") + return Option.match(AsyncData.getError(model.monitors.data), { + onNone: () => [note(h, "Couldn't check uptime")], + onSome: (failure) => [failed(h, "Couldn't load uptime checks", failure, "Monitors")], + }) + if (part._tag === "Loading" || nowMs === null) return [loadingRows(h, "Loading uptime checks")] + const { failing, total, isSetUp } = part.value + return rowsOr( + failing, + (monitor) => monitorRow(h, monitor, nowMs), + () => note(h, isSetUp ? allPassing(total) : `No uptime checks yet. ${ASK_AN_ENGINEER}`), + ) + } + return section( + h, + { + title: "Uptime", + // With nothing failing the body's one line already gives the count. + summary: + part._tag === "Ready" && part.value.failing.length > 0 + ? `${part.value.passing} of ${part.value.total} passing` + : undefined, + footer: { label: "All uptime checks", href: uptimeTabHref() }, + }, + body(), + ) +} + +const SLO_STATE = { + burning: "Burning", + spent: "Out of budget", +} as const satisfies Record + +const sloRow = (h: H, { slo, state, budgetRemaining }: SloTrouble): Html => + listRow(h, { + href: sloHref(slo.id), + divided: true, + leading: statusDot(h, { tone: "crit", size: "lg" }), + title: slo.name, + meta: `${SLO_STATE[state]} · ${scopeLine(slo)}`, + trailing: budgetBar(h, budgetRemaining), + }) + +const slosSection = (h: H, model: Model): Html => { + const part = slosOf(model) + const body = (): ReadonlyArray => { + if (part._tag === "Failed") + return Option.match(AsyncData.getError(model.slos.data), { + // The list loaded but none of its figures did: nothing here can say how an SLO is doing. + onNone: () => [note(h, "Couldn't check the SLOs' current figures. Reload to try again.")], + onSome: (failure) => [failed(h, "Couldn't load SLOs", failure, "Slos")], + }) + if (part._tag === "Loading") return [loadingRows(h, "Loading SLOs")] + const { troubled, total, isSetUp } = part.value + return rowsOr( + troubled, + (trouble) => sloRow(h, trouble), + () => + note( + h, + !isSetUp + ? `No SLOs yet. ${ASK_AN_ENGINEER}` + : total === 1 + ? "The 1 SLO is within budget" + : `All ${total} SLOs are within budget`, + ), + ) + } + return section( + h, + { + title: "SLOs", + summary: + part._tag === "Ready" && part.value.troubled.length > 0 + ? `${part.value.within} of ${part.value.total} within budget` + : undefined, + footer: { label: "All SLOs", href: slosTabHref() }, + }, + body(), + ) +} + +const errorRow = (h: H, error: TopError, nowMs: number): Html => + listRow(h, { + href: hrefOf(AppRoute.ErrorIssue({ issueId: error.id, search: {} })), + divided: true, + leading: severityIcon(h, error.severity, 16), + title: error.title, + meta: `${error.serviceName} · last seen ${formatRelativeTime(error.lastSeenAt, nowMs)}`, + // The noun gives way on a narrow page, so the title keeps its room; a screen reader still gets it. + trailing: typography( + h, + { size: "xs", tone: "muted", numeric: true, className: "whitespace-nowrap" }, + [ + error.count.toLocaleString(), + h.span([h.Class("sr-only @md/page:not-sr-only")], [` ${pluralize(error.count, "event")}`]), + ], + ), + }) + +/** An empty list only means a clean window when the app is known to be sending data. */ +const errorsNote = (presence: ReturnType["status"]): string => { + if (presence === "present") return "No open errors in the last 24 hours" + return presence === "absent" + ? "No error data yet. An engineer on your team can connect your app." + : "No open errors found" +} + +const errorsSection = (h: H, model: Model, nowMs: number | null): Html => { + const data = model.errors.data + const body = (): ReadonlyArray => { + if (AsyncData.isFailure(data)) return [failed(h, "Couldn't load errors", data.error, "Errors")] + return Option.match(nowMs === null ? Option.none() : AsyncData.getData(data), { + onNone: () => [loadingRows(h, "Loading errors")], + onSome: (errors) => + rowsOr( + errors, + (error) => errorRow(h, error, nowMs ?? 0), + () => note(h, errorsNote(presenceOf(model.presence, "traces").status)), + ), + }) + } + return section( + h, + { + title: "Top errors", + summary: "Last 24 hours", + footer: { label: "All errors", href: hrefOf(AppRoute.Errors({ search: {} })) }, + }, + body(), + ) +} + +/** The quiet last row: what an analyst does next, build a dashboard or turn on the weekly digests. */ +const nextSteps = (h: H): Html => + typography(h, { size: "xs", tone: "muted", as: "div", className: "px-4" }, [ + hstack(h, { gap: "5", wrap: true }, [ + textLink(h, { href: hrefOf(AppRoute.Dashboards({ search: {} })), tone: "muted" }, [ + icon(h, "GridSquareCirclePlusIcon", { size: 14 }), + "Build a dashboard", + ]), + textLink( + h, + { href: hrefOf(AppRoute.Account({ search: { tab: "email-preferences" } })), tone: "muted" }, + [icon(h, "EnvelopeIcon", { size: 14 }), "Get a weekly summary by email"], + ), + ]), + ]) + +export const view = Submodel.defineView((model, _inputs, h) => + h.div( + [h.Class("contents"), h.OnMount(MountAnalystHome())], + [ + dashboardPage( + h, + { + titleContent: statusLine(h, verdictOf(model)), + actions: refreshButton(h, { + onRefresh: Message.ClickedReload(), + isPending: model.isReloading, + isIconOnly: true, + label: "Reload", + }), + width: "settings", + gap: "md", + }, + [ + incidentsSection(h, model, model.nowMs), + grid(h, { lg: 2, gap: "4" }, [ + uptimeSection(h, model, model.nowMs), + slosSection(h, model), + ]), + errorsSection(h, model, model.nowMs), + nextSteps(h), + ], + ), + ], + ), +) diff --git a/apps/web/src/page/overview/index.ts b/apps/web/src/page/overview/index.ts index 4f26f48957..9f2afdb281 100644 --- a/apps/web/src/page/overview/index.ts +++ b/apps/web/src/page/overview/index.ts @@ -18,6 +18,7 @@ export const page = definePage( contextChanged, view, subscriptions, - breadcrumbs: () => [{ label: "Overview", isPageHeading: true }], + // An analyst's `

` is the status line, so the crumb is what names the page. + breadcrumbs: (model) => [{ label: "Overview", isPageHeading: model.analyst === null }], }, ) diff --git a/apps/web/src/page/overview/message.ts b/apps/web/src/page/overview/message.ts index 4655b84bdb..3474631ecd 100644 --- a/apps/web/src/page/overview/message.ts +++ b/apps/web/src/page/overview/message.ts @@ -8,6 +8,7 @@ import * as Select from "@maple/views/ui/select" import { SignalPresences } from "@maple/views/data/signal-presence" import * as TimeRangePicker from "../kit/time-range-picker" import * as SetupChecklist from "./setup-checklist" +import { Message as AnalystMessage } from "./analyst/message" import { HealthMessages } from "../kit/service-health" import { FacetsHint, HealthSnapshot, OverviewFacets, OverviewPoint, RefinementPoint, Usage } from "./model" @@ -47,5 +48,6 @@ export const Message = defineMessageUnion({ GotTimePickerMessage: { message: TimeRangePicker.Message }, GotChartMessage: { chartId: Schema.String, message: Chart.Message }, GotSetupMessage: { message: SetupChecklist.Message }, + GotAnalystMessage: { message: AnalystMessage }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/page/overview/model.ts b/apps/web/src/page/overview/model.ts index 9d2eb0a4ae..ad34b99bfb 100644 --- a/apps/web/src/page/overview/model.ts +++ b/apps/web/src/page/overview/model.ts @@ -12,6 +12,7 @@ import * as Select from "@maple/views/ui/select" import { SignalPresenceData } from "@maple/views/data/signal-presence" import * as TimeRangePicker from "../kit/time-range-picker" import * as SetupChecklist from "./setup-checklist" +import * as Analyst from "./analyst/model" import { IncidentsData } from "../kit/service-health" /** The facets hint, persisted per org. */ @@ -103,5 +104,7 @@ export const Model = Schema.Struct({ isReloading: Schema.Boolean, /** `SetupChecklist`, Clerk mode only (`null` self-hosted). */ setup: Schema.NullOr(SetupChecklist.Model), + /** The analyst's Overview, once the viewer is known to be one; `null` for an operator. */ + analyst: Schema.NullOr(Analyst.Model), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/overview/subscription.ts b/apps/web/src/page/overview/subscription.ts index 6bcc46a17e..99bef866ec 100644 --- a/apps/web/src/page/overview/subscription.ts +++ b/apps/web/src/page/overview/subscription.ts @@ -1,4 +1,4 @@ -import { Schema, Stream, Array as Arr } from "effect" +import { Option, Schema, Stream, Array as Arr } from "effect" import { AsyncData, Subscription } from "foldkit" import { type OrgCollectionSet, type OrgCollections, orgLive } from "../../data/collections/org-collections" import { liveQueryStream } from "../../data/live-query" @@ -11,6 +11,7 @@ import type { Model } from "./model" import { Message as SetupMessage } from "./setup-checklist" import { isPolling as isSetupPolling } from "./setup-checklist-update" import { backoffPollTicks } from "../../data/poll" +import { subscriptions as analystSubscriptions } from "./analyst/subscription" /** Five minutes of 15 s polls before the setup card slows to one a minute. */ const SETUP_FAST_POLLS = 20 @@ -42,62 +43,74 @@ const ruleStates = (orgId: string) => ), ) +/** An org's live rows, read only while the engineers' page is the one drawn. */ +const WhileDrawn = { orgId: Schema.String, isDrawn: Schema.Boolean } + const byOrg = (stream: (orgId: string) => Stream.Stream) => ({ - modelToDependencies: ({ context }: PageSubscriptionInput) => ({ orgId: context.orgId }), - dependenciesToStream: ({ orgId }: { readonly orgId: string }) => stream(orgId), + modelToDependencies: ({ model, context }: PageSubscriptionInput) => ({ + orgId: context.orgId, + isDrawn: model.analyst === null, + }), + dependenciesToStream: ({ orgId, isDrawn }: { readonly orgId: string; readonly isDrawn: boolean }) => + isDrawn ? stream(orgId) : Stream.empty, }) -export const subscriptions = Subscription.make, Message, Resources>()( - (entry) => ({ - incidents: entry({ orgId: Schema.String }, byOrg(incidents)), - /** The banner and health counts stop waiting on an alert sync that failed or stalled. */ - incidentsLoadFailed: entry( - { orgId: Schema.String, isWaiting: Schema.Boolean }, - { - modelToDependencies: ({ model, context }) => ({ - orgId: context.orgId, - isWaiting: AsyncData.isLoading(model.incidents), - }), - dependenciesToStream: ({ orgId, isWaiting }) => - incidentsLoadFailedStream(orgId, isWaiting, () => Message.FailedLoadIncidents({ orgId })), - }, - ), - /** Same for the rules: without an end state a stalled sync would leave the page loading for good. */ - rulesLoadFailed: entry( - { orgId: Schema.String, isWaiting: Schema.Boolean }, - { - modelToDependencies: ({ model, context }) => ({ - orgId: context.orgId, - isWaiting: AsyncData.isLoading(model.rules), - }), - dependenciesToStream: ({ orgId, isWaiting }) => - isWaiting - ? collectionLoadFailed( - `alert_rules:${orgId}`, - () => Message.FailedLoadRules({ orgId }), - ENRICHMENT_LOAD_TIMEOUT_MS, - ) - : Stream.empty, - }, - ), - rules: entry({ orgId: Schema.String }, byOrg(rules)), - ruleStates: entry({ orgId: Schema.String }, byOrg(ruleStates)), - /** Waits for the first real service: every 15 s for five minutes, then every minute. */ - setupPoll: entry( - { isPolling: Schema.Boolean }, - { - modelToDependencies: ({ model }) => ({ - isPolling: model.setup !== null && isSetupPolling(model.setup), - }), - dependenciesToStream: ({ isPolling }) => - isPolling - ? backoffPollTicks("15 seconds", SETUP_FAST_POLLS, "1 minute").pipe( - Stream.map(() => - Message.GotSetupMessage({ message: SetupMessage.TickedPoll() }), - ), - ) - : Stream.empty, - }, - ), - }), -) +/** The analyst page's live alert rows, only while there is an analyst page. */ +const analyst = Subscription.lift(analystSubscriptions), Message>({ + read: ({ model, context }) => + model.analyst === null ? Option.none() : Option.some({ model: model.analyst, context }), + toParentMessage: (message) => Message.GotAnalystMessage({ message }), +}) + +const operator = Subscription.make, Message, Resources>()((entry) => ({ + incidents: entry(WhileDrawn, byOrg(incidents)), + /** The banner and health counts stop waiting on an alert sync that failed or stalled. */ + incidentsLoadFailed: entry( + { orgId: Schema.String, isWaiting: Schema.Boolean }, + { + modelToDependencies: ({ model, context }) => ({ + orgId: context.orgId, + isWaiting: model.analyst === null && AsyncData.isLoading(model.incidents), + }), + dependenciesToStream: ({ orgId, isWaiting }) => + incidentsLoadFailedStream(orgId, isWaiting, () => Message.FailedLoadIncidents({ orgId })), + }, + ), + /** Same for the rules: without an end state a stalled sync would leave the page loading for good. */ + rulesLoadFailed: entry( + { orgId: Schema.String, isWaiting: Schema.Boolean }, + { + modelToDependencies: ({ model, context }) => ({ + orgId: context.orgId, + isWaiting: model.analyst === null && AsyncData.isLoading(model.rules), + }), + dependenciesToStream: ({ orgId, isWaiting }) => + isWaiting + ? collectionLoadFailed( + `alert_rules:${orgId}`, + () => Message.FailedLoadRules({ orgId }), + ENRICHMENT_LOAD_TIMEOUT_MS, + ) + : Stream.empty, + }, + ), + rules: entry(WhileDrawn, byOrg(rules)), + ruleStates: entry(WhileDrawn, byOrg(ruleStates)), + /** Waits for the first real service: every 15 s for five minutes, then every minute. */ + setupPoll: entry( + { isPolling: Schema.Boolean }, + { + modelToDependencies: ({ model }) => ({ + isPolling: model.setup !== null && isSetupPolling(model.setup), + }), + dependenciesToStream: ({ isPolling }) => + isPolling + ? backoffPollTicks("15 seconds", SETUP_FAST_POLLS, "1 minute").pipe( + Stream.map(() => Message.GotSetupMessage({ message: SetupMessage.TickedPoll() })), + ) + : Stream.empty, + }, + ), +})) + +export const subscriptions = Subscription.aggregate(operator, analyst) diff --git a/apps/web/src/page/overview/update.ts b/apps/web/src/page/overview/update.ts index 6b77325ef5..a55252d70a 100644 --- a/apps/web/src/page/overview/update.ts +++ b/apps/web/src/page/overview/update.ts @@ -53,6 +53,9 @@ import { storageKeyOf } from "../onboarding/quick-start-state" import type * as SetupChecklist from "./setup-checklist" import { Message as SetupMessage } from "./setup-checklist" import * as Setup from "./setup-checklist-update" +import type { Message as AnalystMessage } from "./analyst/message" +import type * as AnalystModel from "./analyst/model" +import * as Analyst from "./analyst/update" export type Return = PageReturn type Search = typeof HomeSearch.Type @@ -267,6 +270,17 @@ const withSetup = ( return next.outMessage === undefined ? lifted : { ...lifted, outMessage: next.outMessage } } +const toAnalyst = (message: AnalystMessage) => Message.GotAnalystMessage({ message }) + +/** Runs an analyst step; the Submodel starts empty when the role arrived after `init`. */ +const withAnalyst = (model: Model, step: (analyst: AnalystModel.Model) => Analyst.Return): Return => { + const next = step(model.analyst ?? Analyst.EMPTY) + return { + model: { ...model, analyst: next.model }, + commands: Command.mapMessages(next.commands ?? [], toAnalyst), + } +} + /** The setup card's last-hour window. */ const resolveSetupWindow = (result: Return, context: PageContext): Return => { const nowMs = result.model.nowMs @@ -292,7 +306,10 @@ const loadChartRuntime = () => LoadChartRuntime() export const initWith = (isClerk: boolean) => (route: { readonly search: Search }, context: PageContext): Return => { - const setup = isClerk ? Setup.init(storageKeyOf(Option.some(context.orgId))) : null + // An analyst gets their own page: the engineers' reads and setup card never start. + const analyst = context.audience === "analyst" ? Analyst.init(context) : null + const setup = + isClerk && analyst === null ? Setup.init(storageKeyOf(Option.some(context.orgId))) : null const model: Model = { search: route.search, nowMs: null, @@ -320,7 +337,10 @@ export const initWith = refreshVersion: 0, isReloading: false, setup: setup?.model ?? null, + analyst: analyst?.model ?? null, } + if (analyst !== null) + return { model, commands: Command.mapMessages(analyst.commands ?? [], toAnalyst) } return { model, commands: [ @@ -350,6 +370,10 @@ export const init = initWith(isClerkAuthEnabled) /** Back after leaving: the last data stays on screen while entry re-reads the clock (snapped, so cached reads serve). */ export const revive = (model: Model, route: { readonly search: Search }, context: PageContext): Return => { + if (context.audience === "analyst") + return withAnalyst({ ...model, search: route.search }, (analyst) => + Analyst.revive(analyst, context), + ) // The open-anomalies key never changes, so entry alone would keep the parked counts. const anomalies = Keyed.request( Keyed.forgetFailure(model.anomalies), @@ -574,4 +598,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re GotChartMessage: ({ chartId, message: inner }) => withChart(model, chartId, inner), GotSetupMessage: ({ message: inner }) => withSetup({ model }, (setup) => Setup.update(setup, inner, context)), + GotAnalystMessage: ({ message: inner }) => + withAnalyst(model, (analyst) => Analyst.update(analyst, inner, context)), }) diff --git a/apps/web/src/page/overview/view.ts b/apps/web/src/page/overview/view.ts index 4087daf0d8..2a0bd2ef31 100644 --- a/apps/web/src/page/overview/view.ts +++ b/apps/web/src/page/overview/view.ts @@ -33,6 +33,8 @@ import { usageCards } from "./view-usage" import { Message as SetupMessage } from "./setup-checklist" import * as SetupChecklistView from "./setup-checklist-view" +import { EMPTY as ANALYST_EMPTY } from "./analyst/update" +import * as AnalystView from "./analyst/view" import { hstack } from "@maple/views/ui/stack" const EMPTY_COLORS = {} @@ -115,6 +117,15 @@ const pageFailures = (model: Model): ReadonlyArray => { } export const view = Submodel.defineView((model, inputs, h) => { + // The role can arrive after `init`: until the page's Mount starts its reads, it draws its skeletons. + if (inputs.context.audience === "analyst") + return h.submodel({ + slotId: "analyst-home", + model: model.analyst ?? ANALYST_EMPTY, + view: AnalystView.view, + viewInputs: inputs, + toParentMessage: (message) => Message.GotAnalystMessage({ message }), + }) const slice: SliceSearch = { startTime: model.window?.startTime, endTime: model.window?.endTime, diff --git a/apps/web/src/shell/index.ts b/apps/web/src/shell/index.ts index bffa528e4e..73790e1c2f 100644 --- a/apps/web/src/shell/index.ts +++ b/apps/web/src/shell/index.ts @@ -3,7 +3,7 @@ export { readShellFlags } from "./flags" export { NavSurface } from "./nav" export { Message, OutMessage, Theme } from "./message" -export { init, Model, type ShellClerk } from "./model" +export { init, Model, type ShellClerk, shellAudience } from "./model" export { Platform } from "./palette/shortcuts" export { diff --git a/apps/web/src/update.audience.test.ts b/apps/web/src/update.audience.test.ts new file mode 100644 index 0000000000..6e9ad84aa0 --- /dev/null +++ b/apps/web/src/update.audience.test.ts @@ -0,0 +1,116 @@ +import { OrgId, UserId } from "@maple/domain" +import { Option, Schema } from "effect" +import { describe, expect, test, vi } from "vitest" + +// Clerk mode is read at import; the shell's favorites listener targets `window` at import too. +vi.hoisted(() => { + vi.stubEnv("VITE_MAPLE_AUTH_MODE", "clerk") + Object.assign(globalThis, { window: new EventTarget() }) +}) +import { ClerkPhase, type Model, Session } from "./model" +import { initCustomer } from "./page/onboarding/plan-gate" +import * as Preload from "./page/preload" +import * as SignIn from "./page/sign-in" +import type { ClerkSnapshot } from "./platform/clerk" +import { AppRoute } from "./route" +import * as Shell from "./shell" +import * as Toast from "@maple/views/ui/toast" +import { pageContextOf, settle, shellClerkOf, update } from "./update" +import { view } from "./view" +import type { HtmlBuilder } from "foldkit/html" +import { expect as expectView, given, Mount, scene, text } from "foldkit/scene" +import { LinkedCursorMessage, MountLinkedCursor } from "@maple/views/charts/linked-cursor" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import { MountAnalystHome } from "./page/overview/analyst/command" +import { Message as AnalystMessage } from "./page/overview/analyst/message" +import { MeasureSidebarOverflow } from "./shell/mounts" +import { Message as ShellMessage } from "./shell/message" +import type { Message } from "./message" +import * as TooltipHost from "@maple/views/ui/tooltip-host" + +// The page and the shell read the viewer's audience from the same Clerk role, so they never disagree. + +const orgId = Schema.decodeSync(OrgId)("org_acme") + +const snapshotOf = (role: string | null): ClerkSnapshot => ({ + maybeUserId: Option.some(UserId.make("user_ada")), + maybeOrgId: Option.some(orgId), + maybeUser: Option.some({ fullName: "Ada Lovelace", email: "ada@acme.test", imageUrl: "" }), + maybeOrganization: Option.none(), + memberships: [], + maybeRole: Option.fromNullishOr(role), + maybePendingTask: Option.none(), +}) + +const signedIn = (role: string | null): Model => ({ + route: AppRoute.Home({ search: {} }), + session: Session.SignedIn({ maybeOrgId: Option.some(orgId) }), + clerk: ClerkPhase.Unused(), + signIn: SignIn.init(), + shell: Shell.init({ isMobile: false, theme: "light", pinnedNamespaces: {}, dashboardFavorites: [] }), + toasts: Toast.init("toasts"), + toastActions: {}, + toastRootActions: {}, + tooltip: TooltipHost.init("tooltip"), + page: null, + pageInstances: 0, + parkedPages: [], + onboarding: null, + clerkSnapshot: snapshotOf(role), + customer: initCustomer(), + knownToRenderApp: {}, + preload: Preload.init(), + sessionTimeRange: null, + timeZone: "UTC", +}) + +const audienceOf = (model: Model) => Option.getOrThrow(pageContextOf(model)).audience + +describe("page context audience", () => { + test("an analyst's pages are told so, as their shell is", () => { + const model = signedIn("org:analyst") + expect(audienceOf(model)).toBe("analyst") + expect(Shell.shellAudience(shellClerkOf(model))).toBe("analyst") + }) + + test("any other role, and a role Clerk has not read yet, is an operator", () => { + expect(audienceOf(signedIn("org:admin"))).toBe("operator") + expect(audienceOf(signedIn("org:member"))).toBe("operator") + expect(audienceOf(signedIn(null))).toBe("operator") + }) +}) + +describe("the Overview the root draws", () => { + const body = (model: Model, h: HtmlBuilder) => view(model, h).body + // An org known to render the app is past the plan gate, so the page itself is drawn. + const entered = (role: string) => + given(settle({ ...signedIn(role), knownToRenderApp: { [orgId]: true } }).model) + + const shellMounted = [ + Mount.resolve(Toast.PortalToasts, Toast.Message.CompletedPortalToasts()), + Mount.resolve(Toast.PortalToasts, Toast.Message.CompletedPortalToasts()), + Mount.resolve(MeasureSidebarOverflow, ShellMessage.MeasuredSidebarOverflow()), + ] + + test("an analyst gets their own page, not the engineers'", () => { + scene( + { update, view: body }, + entered("org:analyst"), + ...shellMounted, + Mount.resolve(MountAnalystHome, AnalystMessage.Entered()), + expectView(text("Open incidents")).toExist(), + expectView(text("Top errors")).toExist(), + ) + }) + + test("an admin keeps the engineers' page", () => { + scene( + { update, view: body }, + entered("org:admin"), + ...shellMounted, + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), + Mount.resolve(MountLinkedCursor, LinkedCursorMessage.CompletedMountLinkedCursor()), + expectView(text("Open incidents")).toBeAbsent(), + ) + }) +}) diff --git a/apps/web/src/update.ts b/apps/web/src/update.ts index 751f7030e3..00dc7c4cef 100644 --- a/apps/web/src/update.ts +++ b/apps/web/src/update.ts @@ -251,6 +251,7 @@ export const pageContextOf: (model: Model) => Option.Option = lastB integrations: model.shell.integrations, remembered: model.shell.rememberedSurfaces[orgId], }, + audience: Shell.shellAudience(shellClerkOf(model)), })), ), }), diff --git a/apps/web/src/view.ts b/apps/web/src/view.ts index b12c1d97a1..82af72947d 100644 --- a/apps/web/src/view.ts +++ b/apps/web/src/view.ts @@ -71,6 +71,7 @@ const renderPage = ( isPresenceFailed: boolean | undefined, integrations: ShellInfraSurfaces["integrations"] | undefined, remembered: ShellInfraSurfaces["remembered"], + audience: PageContext["audience"], isMobile: boolean, isBelowSm: boolean, isBelowLg: boolean, @@ -86,6 +87,7 @@ const renderPage = ( ...(presence === undefined || isPresenceFailed === undefined || integrations === undefined ? undefined : { infra: { presence, isPresenceFailed, integrations, remembered } }), + ...(audience === undefined ? undefined : { audience }), }, isMobile, isBelowSm, @@ -101,7 +103,7 @@ const renderPage = ( */ const lazyPageView = (model: Model, h: HtmlBuilder, position: PagePosition): Option.Option => Option.flatMap(Option.fromNullishOr(model.page), (page) => - Option.map(pageContextOf(model), ({ orgId, pinnedNamespace, timeZone, infra }) => + Option.map(pageContextOf(model), ({ orgId, pinnedNamespace, timeZone, infra, audience }) => lazyPage(position, renderPage, [ h, page, @@ -112,6 +114,7 @@ const lazyPageView = (model: Model, h: HtmlBuilder, position: PagePosit infra?.isPresenceFailed, infra?.integrations, infra?.remembered, + audience, model.shell.isMobile, model.shell.isBelowSm, model.shell.isFilterCollapsed, diff --git a/packages/views/src/ui/page-layout.styles.ts b/packages/views/src/ui/page-layout.styles.ts index cc88ab5f54..dda87dd2c9 100644 --- a/packages/views/src/ui/page-layout.styles.ts +++ b/packages/views/src/ui/page-layout.styles.ts @@ -2,6 +2,9 @@ export const pageLayoutStyles = "flex min-h-0 flex-1 flex-col" export const pageTitleStyles = "font-display text-2xl font-semibold tracking-tight truncate leading-6.5" +/** A title that is a sentence: it wraps instead of truncating, on a line height made for two lines. */ +export const pageTitleWrapStyles = "font-display text-2xl font-semibold tracking-tight text-balance leading-8" + export const pageDescriptionStyles = "font-prose text-muted-foreground" export const pageHeaderStyles = diff --git a/packages/views/src/ui/page-layout.ts b/packages/views/src/ui/page-layout.ts index c63040273f..4986b3a6d8 100644 --- a/packages/views/src/ui/page-layout.ts +++ b/packages/views/src/ui/page-layout.ts @@ -6,6 +6,7 @@ import { pageHeaderTextStyles, pageLayoutStyles, pageTitleStyles, + pageTitleWrapStyles, } from "./page-layout.styles" import type { Attribute, Html, HtmlBuilder } from "foldkit/html" @@ -26,12 +27,14 @@ export const pageTitle = ( readonly attributes?: ReadonlyArray> /** `2` when the page title sits under another page's `h1` (a flow in a drawer). */ readonly level?: 1 | 2 + /** A title that is a sentence (a status line): wraps onto a second line, never truncates. */ + readonly wrap?: boolean }, children: Children, ): Html => { const attributes = [ h.DataAttribute("slot", "page-title"), - h.Class(cn(pageTitleStyles, options.className)), + h.Class(cn(options.wrap === true ? pageTitleWrapStyles : pageTitleStyles, options.className)), ...(options.title === undefined ? [] : [h.Title(options.title)]), ...(options.attributes ?? []), ]