From 5f3816a4776d0f7cc25015e958f8631dd6d5a3a7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 03:20:24 +0100 Subject: [PATCH 1/6] feat(overview): give observers an Overview that says whether the product is working An observer opening Overview got the engineers' page: a service health table, latency charts and a setup checklist. They now get a verdict in one sentence (Everything is working, or what is open and failing, with the counts behind it), then open incidents, uptime, SLOs and the top errors of the last 24 hours, each linking to its page, and a link to the weekly email summary. The page reuses the reads the alerts and errors pages make. Each has its own slot, so one failing read is named in the verdict instead of being counted as healthy, and where nothing is set up the page says an engineer can do it. The root passes the viewer's audience to pages in the page context, from the same role the shell reads, so the two always agree. Operators get the unchanged Overview. --- apps/web/src/lab/entries/observer-home.ts | 67 ++++ apps/web/src/lab/observer-home-fixture.ts | 151 +++++++ apps/web/src/lab/registry.ts | 8 + apps/web/src/lab/shell-frame.ts | 9 + apps/web/src/lab/shell-lab.ts | 3 + apps/web/src/lab/shell-states-lab.ts | 13 +- apps/web/src/page/alerts/hub/view-slos.ts | 11 +- apps/web/src/page/alerts/shared/slo-derive.ts | 9 + apps/web/src/page/contract.ts | 4 +- apps/web/src/page/overview/index.ts | 3 +- apps/web/src/page/overview/message.ts | 2 + apps/web/src/page/overview/model.ts | 3 + .../web/src/page/overview/observer/command.ts | 83 ++++ apps/web/src/page/overview/observer/derive.ts | 250 ++++++++++++ .../web/src/page/overview/observer/message.ts | 36 ++ apps/web/src/page/overview/observer/model.ts | 55 +++ .../overview/observer/observer.scene.test.ts | 323 +++++++++++++++ .../page/overview/observer/subscription.ts | 58 +++ apps/web/src/page/overview/observer/update.ts | 167 ++++++++ apps/web/src/page/overview/observer/view.ts | 379 ++++++++++++++++++ apps/web/src/page/overview/subscription.ts | 116 +++--- apps/web/src/page/overview/update.ts | 28 +- apps/web/src/page/overview/view.ts | 11 + apps/web/src/shell/index.ts | 2 +- apps/web/src/update.audience.test.ts | 71 ++++ apps/web/src/update.ts | 1 + packages/views/src/ui/INDEX.md | 2 +- packages/views/src/ui/page-layout.styles.ts | 3 + packages/views/src/ui/page-layout.ts | 5 +- 29 files changed, 1791 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/lab/entries/observer-home.ts create mode 100644 apps/web/src/lab/observer-home-fixture.ts create mode 100644 apps/web/src/page/overview/observer/command.ts create mode 100644 apps/web/src/page/overview/observer/derive.ts create mode 100644 apps/web/src/page/overview/observer/message.ts create mode 100644 apps/web/src/page/overview/observer/model.ts create mode 100644 apps/web/src/page/overview/observer/observer.scene.test.ts create mode 100644 apps/web/src/page/overview/observer/subscription.ts create mode 100644 apps/web/src/page/overview/observer/update.ts create mode 100644 apps/web/src/page/overview/observer/view.ts create mode 100644 apps/web/src/update.audience.test.ts diff --git a/apps/web/src/lab/entries/observer-home.ts b/apps/web/src/lab/entries/observer-home.ts new file mode 100644 index 0000000000..f0b9af341f --- /dev/null +++ b/apps/web/src/lab/entries/observer-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/observer/message" +import { Model as PageModel } from "../../page/overview/observer/model" +import { update as pageUpdate } from "../../page/overview/observer/update" +import { view as pageView } from "../../page/overview/observer/view" +import { OBSERVER_CONTEXT, type ObserverHomeState, observerHomeModel, STATES } from "../observer-home-fixture" +import { OBSERVER_CLERK } from "../shell-frame" +import { defineShellLab } from "../shell-lab" + +// `/lab/observer-home`: the Overview an observer gets, inside the observer'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, OBSERVER_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: "observer-home-lab", + model: model.page, + view: pageView, + viewInputs: { context: OBSERVER_CONTEXT }, + toParentMessage: (message) => Message.GotPageMessage({ message }), + }) + +export const lab = defineShellLab("Observer overview", { + Model, + Message, + Flags, + flags: Effect.sync(() => ({ + state: Option.getOrElse( + decodeState(new URLSearchParams(window.location.search).get("state")), + (): ObserverHomeState => "clear", + ), + })), + init: (flags: Flags) => ({ model: { page: observerHomeModel(flags.state) } }), + update, + view, + clerk: OBSERVER_CLERK, + currentPath: "/", + breadcrumbs: [{ label: "Overview" }], +}) diff --git a/apps/web/src/lab/observer-home-fixture.ts b/apps/web/src/lab/observer-home-fixture.ts new file mode 100644 index 0000000000..f49b53f794 --- /dev/null +++ b/apps/web/src/lab/observer-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/observer/derive" +import { Message } from "../page/overview/observer/message" +import type { Model, SloRow, TopError } from "../page/overview/observer/model" +import { init, update } from "../page/overview/observer/update" +import { buildErrorsLabFixture } from "./errors-fixture" + +// The observer Overview's fixtures, shared by `/lab/observer-home` and its scene tests. + +export const STATES = ["clear", "attention", "empty", "loading", "failed"] as const +export type ObserverHomeState = (typeof STATES)[number] + +export const OBSERVER_CONTEXT: PageContext = { + orgId: Schema.decodeSync(OrgId)(ORG), + pinnedNamespace: null, + timeZone: "UTC", + audience: "observer", +} + +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, OBSERVER_CONTEXT).model + +/** The page as its reads leave it in `state`, on the fixtures' fixed clock. */ +export const observerHomeModel = (state: ObserverHomeState): Model => { + const entered = step(init(OBSERVER_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/registry.ts b/apps/web/src/lab/registry.ts index e435d23de4..29f74fd0a4 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -587,6 +587,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/observer-home", + title: "Observer overview", + description: + "The Overview an observer gets, in the observer'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..25bdfb71d9 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 observer with nothing else read yet: all the shell needs to pick their sidebar. */ +export const OBSERVER_CLERK: ShellClerk = { + maybeUser: Option.none(), + maybeOrganization: Option.none(), + memberships: [], + customer: undefined, + maybeRole: Option.some("org:observer"), +} + /** 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..7449e462fe 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 (`OBSERVER_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 2234470dbc..88ee11a8bc 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, OBSERVER_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 observer with nothing else read yet: all the shell needs to pick their sidebar. */ -const OBSERVER_CLERK: ShellClerk = { - maybeUser: Option.none(), - maybeOrganization: Option.none(), - memberships: [], - customer: undefined, - maybeRole: Option.some("org:observer"), -} - /** 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/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/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/overview/index.ts b/apps/web/src/page/overview/index.ts index 4f26f48957..3242ca48a6 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 observer's `

` is the status line, so the crumb is what names the page. + breadcrumbs: (model) => [{ label: "Overview", isPageHeading: model.observer === null }], }, ) diff --git a/apps/web/src/page/overview/message.ts b/apps/web/src/page/overview/message.ts index 4655b84bdb..1262cf888e 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 ObserverMessage } from "./observer/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 }, + GotObserverMessage: { message: ObserverMessage }, }) 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..e47bece5bb 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 Observer from "./observer/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 observer's Overview, once the viewer is known to be one; `null` for an operator. */ + observer: Schema.NullOr(Observer.Model), }) export type Model = typeof Model.Type diff --git a/apps/web/src/page/overview/observer/command.ts b/apps/web/src/page/overview/observer/command.ts new file mode 100644 index 0000000000..b02d4559b0 --- /dev/null +++ b/apps/web/src/page/overview/observer/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 MountObserverHome = Mount.define("MountObserverHome", { + messages: [Message.Entered], + execute: () => Effect.succeed(Message.Entered()), +}) + +export const ReadClock = readClock("ReadObserverHomeClock", Message.CompletedReadClock) + +/** Reload and a return to the page: the real clock, so every read is asked again. */ +export const ResolveClock = readClock("ResolveObserverHomeClock", Message.CompletedResolveClock) + +export const CompleteReloadSpin = reloadSpin("CompleteObserverHomeReloadSpin", Message.CompletedReloadSpin) + +/** `uptimeMonitors.list`, the read the Uptime tab makes. */ +export const FetchMonitors = Command.define("FetchObserverHomeMonitors", { + 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("FetchObserverHomeSlos", { + 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("FetchObserverHomeTopErrors", { + 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("LoadObserverHomeSignalPresence", { + 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/observer/derive.ts b/apps/web/src/page/overview/observer/derive.ts new file mode 100644 index 0000000000..1fb39ea695 --- /dev/null +++ b/apps/web/src/page/overview/observer/derive.ts @@ -0,0 +1,250 @@ +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 +}> + +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"] : []), + ...(uptime._tag === "Failed" ? ["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/observer/message.ts b/apps/web/src/page/overview/observer/message.ts new file mode 100644 index 0000000000..d8a8a3cb45 --- /dev/null +++ b/apps/web/src/page/overview/observer/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/observer/model.ts b/apps/web/src/page/overview/observer/model.ts new file mode 100644 index 0000000000..095a21b152 --- /dev/null +++ b/apps/web/src/page/overview/observer/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 observer's Overview: is the product working, and what is worth knowing, read-only. + +/** 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/observer/observer.scene.test.ts b/apps/web/src/page/overview/observer/observer.scene.test.ts new file mode 100644 index 0000000000..38b750b47c --- /dev/null +++ b/apps/web/src/page/overview/observer/observer.scene.test.ts @@ -0,0 +1,323 @@ +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, + OBSERVER_CONTEXT, + type ObserverHomeState, + observerHomeModel, + TOP_ERRORS, + WARNING_INCIDENT, +} from "../../../lab/observer-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, + MountObserverHome, + ReadClock, +} from "./command" +import { topErrorsOf } from "./derive" +import { Message as ObserverMessage } from "./message" + +// The Overview an observer gets: a verdict, what backs it, and no control that sets anything up. + +const OPERATOR_CONTEXT: PageContext = { ...OBSERVER_CONTEXT, audience: "operator" } + +const pageUpdate = (model: Model, message: Message) => update(model, message, OBSERVER_CONTEXT) +const view = withViewInputs(pageView, { context: OBSERVER_CONTEXT })() + +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +/** The Overview page holding the observer page as its reads left it in `state`. */ +const at = (state: ObserverHomeState): Model => ({ + ...init({ search: {} }, OBSERVER_CONTEXT).model, + observer: observerHomeModel(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(MountObserverHome, ObserverMessage.Entered()) + +describe("overview, observer", () => { + 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", + ), + ) + }) + + 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, + ObserverMessage.CompletedFetchMonitors({ + key: failed.observer?.monitors.key ?? "", + result: Result.succeed([httpMonitor()]), + }), + ), + expectView(verdict("Everything is working.")).toExist(), + expectView(text("The 1 check is passing")).toExist(), + ) + }) + + test("an observer's entry starts their reads and none of the engineers'", () => { + const entered = init({ search: {} }, OBSERVER_CONTEXT) + expect(names(entered.commands)).toEqual( + expect.arrayContaining([ + "ReadObserverHomeClock", + "FetchObserverHomeMonitors", + "FetchObserverHomeSlos", + "LoadObserverHomeSignalPresence", + ]), + ) + expect(names(entered.commands)).not.toContain("ReadOverviewEntry") + expect(entered.model.setup).toBeNull() + }) + + test("a role that arrives after entry draws the observer page, and its mount starts the reads once", () => { + const asOperator = init({ search: {} }, OPERATOR_CONTEXT).model + const loading = observerHomeModel("loading") + scene( + { update: pageUpdate, view }, + given(asOperator), + expectView(role("heading", { level: 1, name: "Overview" })).toBeAbsent(), + expectView(text("Open incidents")).toExist(), + mounted, + Command.resolve(ReadClock, ObserverMessage.CompletedReadClock({ nowMs: NOW })), + Command.resolve( + LoadSignalPresence, + ObserverMessage.CompletedLoadSignalPresence({ result: Result.succeed({}) }), + ), + Command.resolve( + FetchMonitors, + ObserverMessage.CompletedFetchMonitors({ + key: loading.monitors.key ?? "", + result: Result.succeed([]), + }), + ), + Command.resolve( + FetchSlos, + ObserverMessage.CompletedFetchSlos({ + key: loading.slos.key ?? "", + result: Result.succeed([]), + }), + ), + Command.resolve( + FetchTopErrors, + ObserverMessage.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.GotObserverMessage({ message: ObserverMessage.Entered() }), + ) + const again = pageUpdate( + entered.model, + Message.GotObserverMessage({ message: ObserverMessage.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: {} }, OBSERVER_CONTEXT) + expect(names(entered.commands)).not.toContain("FetchObserverHomeTopErrors") + const clocked = pageUpdate( + entered.model, + Message.GotObserverMessage({ message: ObserverMessage.CompletedReadClock({ nowMs: NOW }) }), + ) + expect(names(clocked.commands)).toEqual(["FetchObserverHomeTopErrors"]) + expect(clocked.model.observer?.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.observer).toBeNull() + expect(names(entered.commands)).toEqual( + expect.arrayContaining(["ReadOverviewEntry", "LoadOverviewSignalPresence"]), + ) + expect(names(entered.commands)).not.toContain("FetchObserverHomeMonitors") + 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/observer/subscription.ts b/apps/web/src/page/overview/observer/subscription.ts new file mode 100644 index 0000000000..17a89ed3eb --- /dev/null +++ b/apps/web/src/page/overview/observer/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) => ({ + observerIncidents: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), + }), + observerIncidentsLoadFailed: 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, + }), + observerRules: entry(byOrg, { + modelToDependencies: ({ context }) => ({ orgId: context.orgId }), + dependenciesToStream: ({ orgId }) => + ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, count: rows.length })), + }), + observerRulesLoadFailed: 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/observer/update.ts b/apps/web/src/page/overview/observer/update.ts new file mode 100644 index 0000000000..591ef1efbc --- /dev/null +++ b/apps/web/src/page/overview/observer/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, "observerHome.topErrors", window), + mode, + (errors) => ({ errors }), + (key) => FetchTopErrors({ key, window }), + { keepFailure: true }, + ), + ]), + ] + return runSteps(model, steps) +} + +/** Starts the reads once: `init` for a known observer, 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/observer/view.ts b/apps/web/src/page/overview/observer/view.ts new file mode 100644 index 0000000000..730b0d76a5 --- /dev/null +++ b/apps/web/src/page/overview/observer/view.ts @@ -0,0 +1,379 @@ +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 { MountObserverHome } 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 observer 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. Reload to try again.")] + 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. + trailing: typography( + h, + { size: "xs", tone: "muted", numeric: true, className: "whitespace-nowrap" }, + [ + error.count.toLocaleString(), + h.span([h.Class("hidden @md/page:inline")], [` ${pluralize(error.count, "event")}`]), + ], + ), + }) + +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, + presenceOf(model.presence, "traces").status === "absent" + ? "No error data yet. An engineer on your team can connect your app." + : "No open errors in the last 24 hours", + ), + ), + }) + } + return section( + h, + { + title: "Top errors", + summary: "Last 24 hours", + footer: { label: "All errors", href: hrefOf(AppRoute.Errors({ search: {} })) }, + }, + body(), + ) +} + +/** The quiet last row: where the weekly digests are turned on. */ +const weeklySummary = (h: H): Html => + typography(h, { size: "xs", tone: "muted", as: "div", className: "px-4" }, [ + 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(MountObserverHome())], + [ + 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), + weeklySummary(h), + ], + ), + ], + ), +) diff --git a/apps/web/src/page/overview/subscription.ts b/apps/web/src/page/overview/subscription.ts index 6bcc46a17e..56b5dc3fb2 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 observerSubscriptions } from "./observer/subscription" /** Five minutes of 15 s polls before the setup card slows to one a minute. */ const SETUP_FAST_POLLS = 20 @@ -47,57 +48,62 @@ const byOrg = (stream: (orgId: string) => Stream.Stream stream(orgId), }) -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 observer page's live alert rows, only while there is an observer page. */ +const observer = Subscription.lift(observerSubscriptions), Message>({ + read: ({ model, context }) => + model.observer === null ? Option.none() : Option.some({ model: model.observer, context }), + toParentMessage: (message) => Message.GotObserverMessage({ message }), +}) + +const operator = 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, + }, + ), +})) + +export const subscriptions = Subscription.aggregate(operator, observer) diff --git a/apps/web/src/page/overview/update.ts b/apps/web/src/page/overview/update.ts index 6b77325ef5..acb5a53acc 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 ObserverMessage } from "./observer/message" +import type * as ObserverModel from "./observer/model" +import * as Observer from "./observer/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 toObserver = (message: ObserverMessage) => Message.GotObserverMessage({ message }) + +/** Runs an observer step; the Submodel starts empty when the role arrived after `init`. */ +const withObserver = (model: Model, step: (observer: ObserverModel.Model) => Observer.Return): Return => { + const next = step(model.observer ?? Observer.EMPTY) + return { + model: { ...model, observer: next.model }, + commands: Command.mapMessages(next.commands ?? [], toObserver), + } +} + /** 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 observer gets their own page: the engineers' reads and setup card never start. + const observer = context.audience === "observer" ? Observer.init(context) : null + const setup = + isClerk && observer === 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, + observer: observer?.model ?? null, } + if (observer !== null) + return { model, commands: Command.mapMessages(observer.commands ?? [], toObserver) } 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 === "observer") + return withObserver({ ...model, search: route.search }, (observer) => + Observer.revive(observer, 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)), + GotObserverMessage: ({ message: inner }) => + withObserver(model, (observer) => Observer.update(observer, inner, context)), }) diff --git a/apps/web/src/page/overview/view.ts b/apps/web/src/page/overview/view.ts index 4087daf0d8..2bcad3a129 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 OBSERVER_EMPTY } from "./observer/update" +import * as ObserverView from "./observer/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 === "observer") + return h.submodel({ + slotId: "observer-home", + model: model.observer ?? OBSERVER_EMPTY, + view: ObserverView.view, + viewInputs: inputs, + toParentMessage: (message) => Message.GotObserverMessage({ 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..555126ad9f --- /dev/null +++ b/apps/web/src/update.audience.test.ts @@ -0,0 +1,71 @@ +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, shellClerkOf } from "./update" +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 observer's pages are told so, as their shell is", () => { + const model = signedIn("org:observer") + expect(audienceOf(model)).toBe("observer") + expect(Shell.shellAudience(shellClerkOf(model))).toBe("observer") + }) + + 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") + }) +}) 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/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 1129c1f61b..7ca14d124f 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -290,7 +290,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/service-metrics/markers-view.ts` `markersOverlay`: Commit markers over a chart, idle. - `apps/web/src/page/kit/service-metrics/markers.ts` `commitMarkers`, `markerShas`, `markerLayout`: Commit markers over a chart: the markers, their commit labels and their geometry (./marker-layout.ts). - `apps/web/src/page/kit/service-runtimes.ts` `runtimeLink`, `workloadLink`: Where a service runs, as links to the infra pages: its platform resources (`ServiceRuntimesResponse`) and its Kubernetes workloads. -- `apps/web/src/page/kit/session-grants.ts` `fetchSessionGrants`, `acceptGrants`, `canManageAlerts`: A page's read of the viewer's permissions: the answer carries the org it was asked for, so a late answer for another org is dropped. +- `apps/web/src/page/kit/session-grants.ts` `fetchSessionGrants`, `acceptGrants`, `canManageAlerts`, `canRespondToIncidents`, `canRunInvestigations`: A page's read of the viewer's permissions: the answer carries the org it was asked for, so a late answer for another org is dropped. - `apps/web/src/page/kit/settings-section.ts` `settingsSection`, `settingsSections`, `adminOnlyNote`, `sectionNote`: A settings tab's titled sections, the stack they sit in, and the admin-only note. - `apps/web/src/page/kit/severity-tokens.ts` `statusLabel`: Canonical severity/status → Tailwind class maps for the infrastructure UI. - `apps/web/src/page/kit/sheet-detail-header.ts` `sheetDetailHeader`: A detail sheet's eyebrow kind, title, screen-reader description and meta row. 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 ?? []), ] From 816aa2659ecadee968c8f683b3981ff37c98778b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 03:33:12 +0100 Subject: [PATCH 2/6] fix(overview): do not call idle checks or an unwatched app healthy for an observer Checks that are all paused or yet to report no longer pass as everything working: the verdict says uptime could not be checked. An empty error list is called a clean day only when the app is known to send data. The incidents failure note no longer promises a Reload that does not re-read incidents, the event count keeps its noun for screen readers on a narrow page, and the engineers' live rows are not read while an observer's page is drawn. --- apps/web/src/page/overview/observer/derive.ts | 6 ++- .../overview/observer/observer.scene.test.ts | 54 +++++++++++++++++++ apps/web/src/page/overview/observer/view.ts | 22 ++++---- apps/web/src/page/overview/subscription.ts | 21 +++++--- 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/apps/web/src/page/overview/observer/derive.ts b/apps/web/src/page/overview/observer/derive.ts index 1fb39ea695..eb58159f47 100644 --- a/apps/web/src/page/overview/observer/derive.ts +++ b/apps/web/src/page/overview/observer/derive.ts @@ -69,6 +69,9 @@ export type UptimeSummary = Readonly<{ 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) => @@ -193,7 +196,8 @@ export const verdictOf = (model: Model): Verdict => { return { _tag: "Ready", tone: "crit", sentence: Arr.join(problems, " "), support } const unchecked = [ ...(incidents._tag === "Failed" ? ["incidents"] : []), - ...(uptime._tag === "Failed" ? ["uptime"] : []), + // 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) diff --git a/apps/web/src/page/overview/observer/observer.scene.test.ts b/apps/web/src/page/overview/observer/observer.scene.test.ts index 38b750b47c..e5644f3220 100644 --- a/apps/web/src/page/overview/observer/observer.scene.test.ts +++ b/apps/web/src/page/overview/observer/observer.scene.test.ts @@ -45,6 +45,8 @@ import { } from "./command" import { topErrorsOf } from "./derive" import { Message as ObserverMessage } from "./message" +import type { Model as ObserverModel } from "./model" +import { subscriptions } from "../subscription" // The Overview an observer gets: a verdict, what backs it, and no control that sets anything up. @@ -187,6 +189,58 @@ describe("overview, observer", () => { ) }) + /** `state` after one more answer from the observer page's reads. */ + const after = (state: ObserverHomeState, answer: (observer: ObserverModel) => ObserverMessage): Model => { + const model = at(state) + if (model.observer === null) return model + return pageUpdate(model, Message.GotObserverMessage({ message: answer(model.observer) })).model + } + + test("checks that are all paused do not count as everything working", () => { + const paused = after("clear", (observer) => + ObserverMessage.CompletedFetchMonitors({ + key: observer.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 = (observer: ObserverModel) => + ObserverMessage.CompletedFetchErrors({ + key: observer.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 observer's page is drawn", () => { + const drawn = (model: Model) => + subscriptions.incidents.modelToDependencies({ model, context: OBSERVER_CONTEXT }).isDrawn + expect(drawn(at("clear"))).toBe(false) + expect(drawn(init({ search: {} }, OPERATOR_CONTEXT).model)).toBe(true) + }) + test("an observer's entry starts their reads and none of the engineers'", () => { const entered = init({ search: {} }, OBSERVER_CONTEXT) expect(names(entered.commands)).toEqual( diff --git a/apps/web/src/page/overview/observer/view.ts b/apps/web/src/page/overview/observer/view.ts index 730b0d76a5..e730a8750e 100644 --- a/apps/web/src/page/overview/observer/view.ts +++ b/apps/web/src/page/overview/observer/view.ts @@ -166,7 +166,7 @@ const incidentRow = (h: H, incident: AlertIncidentDocument, nowMs: number): Html 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. Reload to try again.")] + 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( @@ -295,17 +295,25 @@ const errorRow = (h: H, error: TopError, nowMs: number): Html => 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. + // 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("hidden @md/page:inline")], [` ${pluralize(error.count, "event")}`]), + 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 => { @@ -316,13 +324,7 @@ const errorsSection = (h: H, model: Model, nowMs: number | null): Html => { rowsOr( errors, (error) => errorRow(h, error, nowMs ?? 0), - () => - note( - h, - presenceOf(model.presence, "traces").status === "absent" - ? "No error data yet. An engineer on your team can connect your app." - : "No open errors in the last 24 hours", - ), + () => note(h, errorsNote(presenceOf(model.presence, "traces").status)), ), }) } diff --git a/apps/web/src/page/overview/subscription.ts b/apps/web/src/page/overview/subscription.ts index 56b5dc3fb2..f08dc4da17 100644 --- a/apps/web/src/page/overview/subscription.ts +++ b/apps/web/src/page/overview/subscription.ts @@ -43,9 +43,16 @@ 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.observer === null, + }), + dependenciesToStream: ({ orgId, isDrawn }: { readonly orgId: string; readonly isDrawn: boolean }) => + isDrawn ? stream(orgId) : Stream.empty, }) /** The observer page's live alert rows, only while there is an observer page. */ @@ -56,14 +63,14 @@ const observer = Subscription.lift(observerSubscriptions), Message, Resources>()((entry) => ({ - incidents: entry({ orgId: Schema.String }, byOrg(incidents)), + 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: AsyncData.isLoading(model.incidents), + isWaiting: model.observer === null && AsyncData.isLoading(model.incidents), }), dependenciesToStream: ({ orgId, isWaiting }) => incidentsLoadFailedStream(orgId, isWaiting, () => Message.FailedLoadIncidents({ orgId })), @@ -75,7 +82,7 @@ const operator = Subscription.make, Message, Resour { modelToDependencies: ({ model, context }) => ({ orgId: context.orgId, - isWaiting: AsyncData.isLoading(model.rules), + isWaiting: model.observer === null && AsyncData.isLoading(model.rules), }), dependenciesToStream: ({ orgId, isWaiting }) => isWaiting @@ -87,8 +94,8 @@ const operator = Subscription.make, Message, Resour : Stream.empty, }, ), - rules: entry({ orgId: Schema.String }, byOrg(rules)), - ruleStates: entry({ orgId: Schema.String }, byOrg(ruleStates)), + 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 }, From d9f7baed55d62b08314ed105246c1c7160937451 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 04:11:14 +0100 Subject: [PATCH 3/6] fix(overview): pass the viewer's audience to the page's view The root view rebuilds the page context field by field and left audience out, so a page's update knew the viewer was an observer while its view did not: a real observer got the engineers' Overview on skeletons, with the observer page's reads running behind it. The lab passes the context whole, which is why only the preview run showed it. A root scene test now draws the Overview for an observer and for an admin. The Incidents page also drops its New rule button for an observer. --- .../page/alerts/hub/observer.scene.test.ts | 14 ++++++ apps/web/src/page/alerts/hub/view.ts | 5 +- apps/web/src/update.audience.test.ts | 47 ++++++++++++++++++- apps/web/src/view.ts | 5 +- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/alerts/hub/observer.scene.test.ts b/apps/web/src/page/alerts/hub/observer.scene.test.ts index a7eb2a95e9..8684f0f6cd 100644 --- a/apps/web/src/page/alerts/hub/observer.scene.test.ts +++ b/apps/web/src/page/alerts/hub/observer.scene.test.ts @@ -74,6 +74,20 @@ describe("alerts inbox, observer", () => { ) }) + test("an observer's Incidents page has no New rule button, and a member's keeps it", () => { + const observerView = withViewInputs(pageView, { context: { ...context, audience: "observer" } })() + scene( + { update: pageUpdate, view: observerView }, + given(loaded(OBSERVER)), + 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.ts b/apps/web/src/page/alerts/hub/view.ts index a3a8e620b1..f4c4776e4c 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 observer follows incidents and sets nothing up. + if (audience === "observer") 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/update.audience.test.ts b/apps/web/src/update.audience.test.ts index 555126ad9f..a907766ed1 100644 --- a/apps/web/src/update.audience.test.ts +++ b/apps/web/src/update.audience.test.ts @@ -15,7 +15,17 @@ 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, shellClerkOf } from "./update" +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 { MountObserverHome } from "./page/overview/observer/command" +import { Message as ObserverMessage } from "./page/overview/observer/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. @@ -69,3 +79,38 @@ describe("page context audience", () => { 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 observer gets their own page, not the engineers'", () => { + scene( + { update, view: body }, + entered("org:observer"), + ...shellMounted, + Mount.resolve(MountObserverHome, ObserverMessage.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/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, From 85b7307f332fbafafbf729b2905d15754c746995 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:12:11 +0100 Subject: [PATCH 4/6] refactor: rename the observer role to analyst --- CLAUDE.md | 4 +- apps/ai/src/chat/agents.test.ts | 6 +- apps/ai/src/chat/profiles.ts | 4 +- apps/ai/src/mcp/server.ts | 2 +- .../mcp/tools/__tests__/read-only-key.test.ts | 8 +- apps/ai/src/mcp/tools/types.ts | 2 +- .../src/routes/v2/mobile-devices.http.test.ts | 4 +- .../routes/v2/phase1-resources.http.test.ts | 6 +- apps/api/src/routes/v2/telemetry.http.test.ts | 4 +- apps/web/src/lab/alerts-lab/update.ts | 2 +- ...ome-fixture.ts => analyst-home-fixture.ts} | 24 ++-- apps/web/src/lab/anomaly-alerts-lab.ts | 2 +- .../{observer-home.ts => analyst-home.ts} | 28 ++--- apps/web/src/lab/entries/dashboards.ts | 8 +- apps/web/src/lab/entries/error-issue.ts | 6 +- apps/web/src/lab/entries/status-pages.ts | 2 +- apps/web/src/lab/errors-lab/model.ts | 6 +- apps/web/src/lab/registry.ts | 18 +-- apps/web/src/lab/shell-frame.ts | 6 +- apps/web/src/lab/shell-lab.ts | 2 +- .../src/lab/shell-states-lab.scene.test.ts | 6 +- apps/web/src/lab/shell-states-lab.ts | 6 +- apps/web/src/lab/viewer-role.ts | 4 +- ...er.scene.test.ts => analyst.scene.test.ts} | 22 ++-- apps/web/src/page/alerts/hub/view.ts | 4 +- ...er.scene.test.ts => analyst.scene.test.ts} | 18 +-- ...er.scene.test.ts => analyst.scene.test.ts} | 18 +-- apps/web/src/page/anomalies/detail/model.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 14 +-- apps/web/src/page/dashboards/list/model.ts | 2 +- .../web/src/page/dashboards/list/view-list.ts | 12 +- apps/web/src/page/dashboards/list/view.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 8 +- .../src/page/dashboards/templates/model.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 18 +-- apps/web/src/page/dashboards/view/model.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 22 ++-- apps/web/src/page/errors/hub/model.ts | 2 +- apps/web/src/page/errors/hub/selection.ts | 2 +- apps/web/src/page/errors/hub/view-list.ts | 2 +- apps/web/src/page/errors/hub/view-row.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 22 ++-- apps/web/src/page/errors/issue/model.ts | 2 +- apps/web/src/page/errors/issue/view.ts | 4 +- ...er.scene.test.ts => analyst.scene.test.ts} | 14 +-- .../analyst.scene.test.ts} | 106 +++++++++--------- .../overview/{observer => analyst}/command.ts | 16 +-- .../overview/{observer => analyst}/derive.ts | 0 .../overview/{observer => analyst}/message.ts | 0 .../overview/{observer => analyst}/model.ts | 2 +- .../{observer => analyst}/subscription.ts | 8 +- .../overview/{observer => analyst}/update.ts | 4 +- .../overview/{observer => analyst}/view.ts | 6 +- apps/web/src/page/overview/index.ts | 4 +- apps/web/src/page/overview/message.ts | 4 +- apps/web/src/page/overview/model.ts | 6 +- apps/web/src/page/overview/subscription.ts | 18 +-- apps/web/src/page/overview/update.ts | 40 +++---- apps/web/src/page/overview/view.ts | 14 +-- .../page/settings/kit/org-membership.test.ts | 14 +-- .../src/page/settings/kit/org-membership.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 18 +-- apps/web/src/shell/model.ts | 4 +- apps/web/src/shell/nav.test.ts | 8 +- apps/web/src/shell/nav.ts | 16 +-- apps/web/src/shell/palette/entries.ts | 10 +- apps/web/src/shell/palette/palette.test.ts | 8 +- apps/web/src/shell/view-sidebar.ts | 4 +- apps/web/src/shell/view.ts | 4 +- apps/web/src/update.audience.test.ts | 18 +-- .../src/services/auth/ApiAuthorizationLive.ts | 6 +- .../services/auth/ApiAuthorizationV2Live.ts | 8 +- .../services/auth/SessionAuthorizationLive.ts | 6 +- ...rver-gate.test.ts => analyst-gate.test.ts} | 32 +++--- .../{observer-gate.ts => analyst-gate.ts} | 16 +-- packages/domain/src/http/ai-models.ts | 6 +- packages/domain/src/http/ai-sessions.ts | 6 +- ...-access.test.ts => analyst-access.test.ts} | 16 +-- .../{observer-access.ts => analyst-access.ts} | 16 +-- packages/domain/src/http/auth.ts | 14 +-- packages/domain/src/http/billing.ts | 4 +- packages/domain/src/http/current-tenant.ts | 2 +- packages/domain/src/http/digest.ts | 10 +- packages/domain/src/http/errors.ts | 4 +- packages/domain/src/http/index.ts | 2 +- packages/domain/src/http/integrations.ts | 6 +- packages/domain/src/http/mcp.ts | 6 +- packages/domain/src/http/query-engine.ts | 6 +- packages/domain/src/http/session-replay.ts | 6 +- packages/domain/src/http/users.ts | 6 +- packages/domain/src/http/v2/agent-feedback.ts | 4 +- .../domain/src/http/v2/device-credentials.ts | 8 +- .../domain/src/http/v2/integrations-chat.ts | 4 +- .../domain/src/http/v2/mcp-connections.ts | 4 +- packages/domain/src/http/v2/mobile-devices.ts | 10 +- .../domain/src/http/v2/widget-credentials.ts | 6 +- packages/domain/src/policy/index.ts | 8 +- packages/domain/src/policy/permissions.ts | 4 +- packages/domain/src/policy/policy.test.ts | 24 ++-- packages/domain/src/policy/roles.ts | 16 +-- 100 files changed, 478 insertions(+), 478 deletions(-) rename apps/web/src/lab/{observer-home-fixture.ts => analyst-home-fixture.ts} (86%) rename apps/web/src/lab/entries/{observer-home.ts => analyst-home.ts} (64%) rename apps/web/src/page/alerts/hub/{observer.scene.test.ts => analyst.scene.test.ts} (83%) rename apps/web/src/page/alerts/incident/{observer.scene.test.ts => analyst.scene.test.ts} (84%) rename apps/web/src/page/anomalies/detail/{observer.scene.test.ts => analyst.scene.test.ts} (90%) rename apps/web/src/page/dashboards/list/{observer.scene.test.ts => analyst.scene.test.ts} (91%) rename apps/web/src/page/dashboards/templates/{observer.scene.test.ts => analyst.scene.test.ts} (92%) rename apps/web/src/page/dashboards/view/{observer.scene.test.ts => analyst.scene.test.ts} (94%) rename apps/web/src/page/errors/hub/{observer.scene.test.ts => analyst.scene.test.ts} (90%) rename apps/web/src/page/errors/issue/{observer.scene.test.ts => analyst.scene.test.ts} (93%) rename apps/web/src/page/investigations/list/{observer.scene.test.ts => analyst.scene.test.ts} (87%) rename apps/web/src/page/overview/{observer/observer.scene.test.ts => analyst/analyst.scene.test.ts} (78%) rename apps/web/src/page/overview/{observer => analyst}/command.ts (80%) rename apps/web/src/page/overview/{observer => analyst}/derive.ts (100%) rename apps/web/src/page/overview/{observer => analyst}/message.ts (100%) rename apps/web/src/page/overview/{observer => analyst}/model.ts (96%) rename apps/web/src/page/overview/{observer => analyst}/subscription.ts (93%) rename apps/web/src/page/overview/{observer => analyst}/update.ts (97%) rename apps/web/src/page/overview/{observer => analyst}/view.ts (98%) rename apps/web/src/page/status-pages/editor/{observer.scene.test.ts => analyst.scene.test.ts} (89%) rename packages/backend/src/services/auth/{observer-gate.test.ts => analyst-gate.test.ts} (52%) rename packages/backend/src/services/auth/{observer-gate.ts => analyst-gate.ts} (71%) rename packages/domain/src/http/{observer-access.test.ts => analyst-access.test.ts} (75%) rename packages/domain/src/http/{observer-access.ts => analyst-access.ts} (57%) diff --git a/CLAUDE.md b/CLAUDE.md index e91207cae8..42a0c57ba0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,8 @@ with `packageManager`. - LLM code (Effect AI + `@yielded/agent`) is only in `apps/ai`; Maple-specific wiring lives in `apps/ai/src/platform/Llm.ts`. - Shared services live in `packages/backend`, imported via `@maple/backend/*` subpaths. -- Observers (`org:observer`) only read. The auth middlewares refuse them every non-GET endpoint unless - it is marked `ObserverAccess` (`packages/domain/src/http/observer-access.ts`): `read` for a POST that +- Analysts (`org:analyst`) only read. The auth middlewares refuse them every non-GET endpoint unless + it is marked `AnalystAccess` (`packages/domain/src/http/analyst-access.ts`): `read` for a POST that only queries, `personal` for the caller's own state. An unmarked read-only POST is a 403 for them. ### Service conventions diff --git a/apps/ai/src/chat/agents.test.ts b/apps/ai/src/chat/agents.test.ts index 6f206ceef8..0412a4587e 100644 --- a/apps/ai/src/chat/agents.test.ts +++ b/apps/ai/src/chat/agents.test.ts @@ -137,14 +137,14 @@ describe("profileForTurn", () => { } }) - it("offers an observer's turn the read tools and none that change data", () => { + it("offers an analyst's turn the read tools and none that change data", () => { const profile = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ - "org:observer", + "org:analyst", ]) const { tools } = buildChatToolkit(executor, tenant, profile.ruleset, profile.surface).toolkit assert.property(tools, "search_traces") for (const name of MUTATING_TOOL_NAMES) { - assert.notProperty(tools, name, `${name} was offered to an observer`) + assert.notProperty(tools, name, `${name} was offered to an analyst`) } // A member's turn on the same session still proposes them. const member = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ diff --git a/apps/ai/src/chat/profiles.ts b/apps/ai/src/chat/profiles.ts index bcdf976a03..f80614f1cd 100644 --- a/apps/ai/src/chat/profiles.ts +++ b/apps/ai/src/chat/profiles.ts @@ -23,13 +23,13 @@ export interface TurnProfile { readonly prompt: string } -/** The ruleset with every approval-gated tool withheld: an observer could not approve one. */ +/** The ruleset with every approval-gated tool withheld: an analyst could not approve one. */ const withoutProposals = (ruleset: PermissionRuleset): PermissionRuleset => Arr.map(ruleset, (rule) => rule.action === "ask" ? new PermissionRule({ tool: rule.tool, action: "deny" }) : rule, ) -/** The profile for whoever the turn's tools run as: an observer is offered nothing that changes data. */ +/** The profile for whoever the turn's tools run as: an analyst is offered nothing that changes data. */ export const profileForRoles = (profile: TurnProfile, roles: ReadonlyArray): TurnProfile => Policy.isReadOnly(roles) ? { ...profile, ruleset: withoutProposals(profile.ruleset) } : profile diff --git a/apps/ai/src/mcp/server.ts b/apps/ai/src/mcp/server.ts index c00bc1e4bd..264fcf9293 100644 --- a/apps/ai/src/mcp/server.ts +++ b/apps/ai/src/mcp/server.ts @@ -69,7 +69,7 @@ const annotateToolFailure = (result: McpToolResult) => : Effect.void /** - * Hides a tool from a read-only credential's or an observer's `tools/list`, and answers a call to it as unknown: + * Hides a tool from a read-only credential's or an analyst's `tools/list`, and answers a call to it as unknown: * the registry would refuse every such call anyway, and a listed tool invites it. */ const HiddenFromReadOnlyCredentials = EffectMcpServer.visibility(() => diff --git a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts index 090ad00061..52c6d0e614 100644 --- a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts @@ -71,15 +71,15 @@ describe("read-only API key over MCP", () => { ) }) -describe("an observer over MCP", () => { +describe("an analyst over MCP", () => { // A layer per test: each owns a database that `afterEach` removes. - const observer = () => makeLayer(false, undefined, "org:observer") + const analyst = () => makeLayer(false, undefined, "org:analyst") it.effect("runs read tools", () => Effect.gen(function* () { const result = yield* call("get_error_notification_policy", {}) expect(Result.isSuccess(result)).toBe(true) - }).pipe(Effect.provide(observer())), + }).pipe(Effect.provide(analyst())), ) it.effect("refuses a tool that changes data and says which role can", () => @@ -91,7 +91,7 @@ describe("an observer over MCP", () => { expect(result.failure.message).toContain("update_error_notification_policy") expect(result.failure.message).toContain("Ask an org admin for the Member role") } - }).pipe(Effect.provide(observer())), + }).pipe(Effect.provide(analyst())), ) }) diff --git a/apps/ai/src/mcp/tools/types.ts b/apps/ai/src/mcp/tools/types.ts index 384e0240f6..3b949cf34e 100644 --- a/apps/ai/src/mcp/tools/types.ts +++ b/apps/ai/src/mcp/tools/types.ts @@ -124,7 +124,7 @@ export const FEEDBACK_TOOL = "send_maple_feedback" export const refusesReadOnlyCredential = (name: string, readOnly: boolean): boolean => !readOnly && name !== FEEDBACK_TOOL -/** Whether the caller may only read: a read-only API key, or anyone whose role is observer. */ +/** Whether the caller may only read: a read-only API key, or anyone whose role is analyst. */ export const isReadOnlyTenant = (tenant: { readonly readOnly?: true readonly roles: ReadonlyArray diff --git a/apps/api/src/routes/v2/mobile-devices.http.test.ts b/apps/api/src/routes/v2/mobile-devices.http.test.ts index 61c527929c..08fc58a2b4 100644 --- a/apps/api/src/routes/v2/mobile-devices.http.test.ts +++ b/apps/api/src/routes/v2/mobile-devices.http.test.ts @@ -190,10 +190,10 @@ describe("v2 mobile devices", () => { } }) - it("lets an observer register and remove their own device", async () => { + it("lets an analyst register and remove their own device", async () => { const harness = makeHarness() try { - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const created = await harness.request("PUT", `/v2/mobile_devices/${TOKEN}`, key.secret, { platform: "ios", environment: "sandbox", diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index b37296d26b..f863cf37f8 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -902,9 +902,9 @@ describe("v2 error_issues over HTTP", () => { await harness.dispose() }) - it("lets an observer read an issue and refuses to let them triage it", async () => { + it("lets an analyst read an issue and refuses to let them triage it", async () => { const harness = makeHarness() - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const read = await harness.request("GET", `/v2/error_issues/${ISS_ID}`, { token: key.secret }) expect(read.status).toBe(200) @@ -915,7 +915,7 @@ describe("v2 error_issues over HTTP", () => { }) expect(refused.status).toBe(403) expect(refused.body.error.code).toBe("insufficient_permissions") - expect(refused.body.error.message).toContain("Observers can read Maple") + expect(refused.body.error.message).toContain("Analysts can read Maple") expect(harness.issueActions()).toEqual([]) await harness.dispose() }) diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index 205b616bda..92fdacb02b 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -571,9 +571,9 @@ describe("v2 telemetry reads over HTTP", () => { await harness.dispose() }) - it("lets an observer search traces, a POST that only reads", async () => { + it("lets an analyst search traces, a POST that only reads", async () => { const harness = makeHarness() - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const searched = await harness.request("POST", "/v2/traces/search", key.secret, { start_time: START, end_time: END, diff --git a/apps/web/src/lab/alerts-lab/update.ts b/apps/web/src/lab/alerts-lab/update.ts index d75c5912e6..3a10e0c822 100644 --- a/apps/web/src/lab/alerts-lab/update.ts +++ b/apps/web/src/lab/alerts-lab/update.ts @@ -17,7 +17,7 @@ export type Return = Update.Return export const AlertsLabFlags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String, - /** `?role=member|observer`: an observer gets no acknowledge, rule or routing control. */ + /** `?role=member|analyst`: an analyst gets no acknowledge, rule or routing control. */ role: LabRole, }) export type AlertsLabFlags = typeof AlertsLabFlags.Type diff --git a/apps/web/src/lab/observer-home-fixture.ts b/apps/web/src/lab/analyst-home-fixture.ts similarity index 86% rename from apps/web/src/lab/observer-home-fixture.ts rename to apps/web/src/lab/analyst-home-fixture.ts index f49b53f794..d7f6097a74 100644 --- a/apps/web/src/lab/observer-home-fixture.ts +++ b/apps/web/src/lab/analyst-home-fixture.ts @@ -8,22 +8,22 @@ import { checkoutSlo, searchSlo, sloStatus, sloUuid } from "../page/alerts/share 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/observer/derive" -import { Message } from "../page/overview/observer/message" -import type { Model, SloRow, TopError } from "../page/overview/observer/model" -import { init, update } from "../page/overview/observer/update" +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 observer Overview's fixtures, shared by `/lab/observer-home` and its scene tests. +// 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 ObserverHomeState = (typeof STATES)[number] +export type AnalystHomeState = (typeof STATES)[number] -export const OBSERVER_CONTEXT: PageContext = { +export const ANALYST_CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC", - audience: "observer", + audience: "analyst", } const MINUTE = 60_000 @@ -114,17 +114,17 @@ const DATA = { }, empty: { rules: 0, incidents: [], monitors: [], slos: [], errors: [], traces: "absent" }, failed: { ...CLEAR, monitors: null }, -} as const satisfies Record, Data> +} 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, OBSERVER_CONTEXT).model +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 observerHomeModel = (state: ObserverHomeState): Model => { - const entered = step(init(OBSERVER_CONTEXT).model, Message.CompletedReadClock({ nowMs: NOW })) +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> = [ diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index d38c81d719..052aaf9dd8 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -56,7 +56,7 @@ import { labGrants, LabRole, readLabRole } from "./viewer-role" // `/lab/anomaly-alerts`: anomalies paging through alert rules, over fixtures. `?state=` picks one: // editor-new, editor-blank, editor-scoped, editor-default, editor-critical-destination, incident-warning, incident-critical, incident-spike, // hub-anomalies, hub-anomalies-resolved, hub-anomalies-empty, hub-rules, rule-anomaly, overview-banner. -// `?role=member|observer` reads the hub and the incident as that viewer: an observer gets no acknowledge. +// `?role=member|analyst` reads the hub and the incident as that viewer: an analyst gets no acknowledge. export const LabState = Schema.Literals([ "editor-new", diff --git a/apps/web/src/lab/entries/observer-home.ts b/apps/web/src/lab/entries/analyst-home.ts similarity index 64% rename from apps/web/src/lab/entries/observer-home.ts rename to apps/web/src/lab/entries/analyst-home.ts index f0b9af341f..829f9c8022 100644 --- a/apps/web/src/lab/entries/observer-home.ts +++ b/apps/web/src/lab/entries/analyst-home.ts @@ -2,15 +2,15 @@ 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/observer/message" -import { Model as PageModel } from "../../page/overview/observer/model" -import { update as pageUpdate } from "../../page/overview/observer/update" -import { view as pageView } from "../../page/overview/observer/view" -import { OBSERVER_CONTEXT, type ObserverHomeState, observerHomeModel, STATES } from "../observer-home-fixture" -import { OBSERVER_CLERK } from "../shell-frame" +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/observer-home`: the Overview an observer gets, inside the observer's shell, with nothing +// `/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`). @@ -29,7 +29,7 @@ const decodeState = Schema.decodeUnknownOption(Schema.Literals(STATES)) const REMOTE = /^(Fetch|Load|Read|Resolve)/ const update = (model: Model, message: Message) => { - const next = pageUpdate(model.page, message.message, OBSERVER_CONTEXT) + const next = pageUpdate(model.page, message.message, ANALYST_CONTEXT) return { model: { page: next.model }, commands: Command.mapMessages( @@ -41,27 +41,27 @@ const update = (model: Model, message: Message) => { const view = (model: Model, h: HtmlBuilder): Html => h.submodel({ - slotId: "observer-home-lab", + slotId: "analyst-home-lab", model: model.page, view: pageView, - viewInputs: { context: OBSERVER_CONTEXT }, + viewInputs: { context: ANALYST_CONTEXT }, toParentMessage: (message) => Message.GotPageMessage({ message }), }) -export const lab = defineShellLab("Observer overview", { +export const lab = defineShellLab("Analyst overview", { Model, Message, Flags, flags: Effect.sync(() => ({ state: Option.getOrElse( decodeState(new URLSearchParams(window.location.search).get("state")), - (): ObserverHomeState => "clear", + (): AnalystHomeState => "clear", ), })), - init: (flags: Flags) => ({ model: { page: observerHomeModel(flags.state) } }), + init: (flags: Flags) => ({ model: { page: analystHomeModel(flags.state) } }), update, view, - clerk: OBSERVER_CLERK, + clerk: ANALYST_CLERK, currentPath: "/", breadcrumbs: [{ label: "Overview" }], }) diff --git a/apps/web/src/lab/entries/dashboards.ts b/apps/web/src/lab/entries/dashboards.ts index af3434e860..a3cbe5b386 100644 --- a/apps/web/src/lab/entries/dashboards.ts +++ b/apps/web/src/lab/entries/dashboards.ts @@ -19,11 +19,11 @@ import { view as boardView } from "../../page/dashboards/view/view" import type { Resources } from "../../platform/resources" import { defineShellLab } from "../shell-lab" -// `/lab/dashboards`: the dashboards list and one board as a member or an observer, who reads -// dashboards but cannot change or share them (`?page=list|detail&role=member|observer&mode=edit`). +// `/lab/dashboards`: the dashboards list and one board as a member or an analyst, who reads +// dashboards but cannot change or share them (`?page=list|detail&role=member|analyst&mode=edit`). const PAGES = ["list", "detail"] as const -const ROLES = ["member", "observer"] as const +const ROLES = ["member", "analyst"] as const type LabPage = (typeof PAGES)[number] type LabRole = (typeof ROLES)[number] @@ -172,7 +172,7 @@ const boardModel = (role: LabRole, isEdit: boolean): BoardModel => const Model = Schema.Struct({ page: Schema.Literals(PAGES), role: Schema.Literals(ROLES), - /** The board was opened at `?mode=edit`: an observer still gets the read view. */ + /** The board was opened at `?mode=edit`: an analyst still gets the read view. */ isEdit: Schema.Boolean, list: ListModel, board: BoardModel, diff --git a/apps/web/src/lab/entries/error-issue.ts b/apps/web/src/lab/entries/error-issue.ts index 2bf8aa2583..a6128cbd4e 100644 --- a/apps/web/src/lab/entries/error-issue.ts +++ b/apps/web/src/lab/entries/error-issue.ts @@ -21,8 +21,8 @@ import { view as pageView } from "../../page/errors/issue/view" import { defineShellLab } from "../shell-lab" // `/lab/error-issue`: the error issue page over one fixture issue, a surging high-severity error -// with a regression, as a member or an observer, who reads it and gets no triage control -// (`?role=member|observer&tab=overview|occurrences|activity`). API Commands are dropped; the page +// with a regression, as a member or an analyst, who reads it and gets no triage control +// (`?role=member|analyst&tab=overview|occurrences|activity`). API Commands are dropped; the page // is sent the answers its reads would get. const context: PageContext = { @@ -121,7 +121,7 @@ const send = (step: Step, message: Message): Step => { } } -const ROLES = ["member", "observer"] as const +const ROLES = ["member", "analyst"] as const const TABS = ["overview", "occurrences", "activity"] as const const Flags = Schema.Struct({ role: Schema.Literals(ROLES), tab: Schema.Literals(TABS) }) type Flags = typeof Flags.Type diff --git a/apps/web/src/lab/entries/status-pages.ts b/apps/web/src/lab/entries/status-pages.ts index a9fcee53f4..85d3739c87 100644 --- a/apps/web/src/lab/entries/status-pages.ts +++ b/apps/web/src/lab/entries/status-pages.ts @@ -22,7 +22,7 @@ import { labGrants, LabRole, readLabRole } from "../viewer-role" // `/lab/status-pages`: the status pages list, the look picker and the form fields over fixture // pages, or a page's editor open on an incident (`?page=list|editor`), as the viewer `?role=` names: -// an observer reads the incident and gets no control that posts, edits or deletes one. +// an analyst reads the incident and gets no control that posts, edits or deletes one. const context: PageContext = { orgId: Schema.decodeSync(OrgId)(LAB_ORG), diff --git a/apps/web/src/lab/errors-lab/model.ts b/apps/web/src/lab/errors-lab/model.ts index 12bab048f7..99b350d093 100644 --- a/apps/web/src/lab/errors-lab/model.ts +++ b/apps/web/src/lab/errors-lab/model.ts @@ -4,15 +4,15 @@ import { Message as HubMessage } from "../../page/errors/hub/message" import { Model as HubModel } from "../../page/errors/hub/model" // `/lab/errors`: the errors hub over a fixture holding every row -// state at once, with the lab's State, Role and Width toggles (`?role=member|observer`). +// state at once, with the lab's State, Role and Width toggles (`?role=member|analyst`). // Shell-free; the entry wraps it. export const LAB_STATES = ["ready", "loading", "failed", "empty"] as const export const LabState = Schema.Literals(LAB_STATES) export type LabState = typeof LabState.Type -/** A member triages; an observer reads the list and gets no checkbox, picker or bulk bar. */ -export const LAB_ROLES = ["member", "observer"] as const +/** A member triages; an analyst reads the list and gets no checkbox, picker or bulk bar. */ +export const LAB_ROLES = ["member", "analyst"] as const export const LabRole = Schema.Literals(LAB_ROLES) export type LabRole = typeof LabRole.Type diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 29f74fd0a4..e6c7a9d79d 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -129,7 +129,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/anomaly-alerts", title: "Anomaly alerts", description: - "Anomaly rules in the rule editor (scoped and the protected default), an anomaly rule's incident with the detector's evidence, the Inbox's Anomalies view and the Overview's alert banner with open anomalies (`?state=`). The hub and the incident as an admin, a member or an observer, who gets no Acknowledge (`?role=member|observer`).", + "Anomaly rules in the rule editor (scoped and the protected default), an anomaly rule's incident with the detector's evidence, the Inbox's Anomalies view and the Overview's alert banner with open anomalies (`?state=`). The hub and the incident as an admin, a member or an analyst, who gets no Acknowledge (`?role=member|analyst`).", kind: "lab", session: "none", }, @@ -376,7 +376,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/dashboards", title: "Dashboards", description: - "The `/dashboards` list and one board as a member or an observer, who reads dashboards but cannot change or share them (?page=list|detail&role=member|observer&mode=edit).", + "The `/dashboards` list and one board as a member or an analyst, who reads dashboards but cannot change or share them (?page=list|detail&role=member|analyst&mode=edit).", kind: "lab", session: "none", }, @@ -479,7 +479,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/errors", title: "Errors list", description: - "The `/errors` list over a fixture holding every row state at once: surging critical with an open incident, live and diagnosed investigations, a truncating message, a fingerprint gone quiet, an unset severity. As a member or an observer, who gets no checkbox, picker or bulk bar (?role=member|observer).", + "The `/errors` list over a fixture holding every row state at once: surging critical with an open incident, live and diagnosed investigations, a truncating message, a fingerprint gone quiet, an unset severity. As a member or an analyst, who gets no checkbox, picker or bulk bar (?role=member|analyst).", kind: "lab", session: "none", }, @@ -487,7 +487,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/error-issue", title: "Error issue", description: - "The error issue page over one surging, regressed high-severity TypeError: the occurrence chart and its tooltip, the Get it fixed panel, the stat rail and the rail. As a member or an observer, who reads it and gets no triage control (?role=member|observer&tab=overview|occurrences|activity).", + "The error issue page over one surging, regressed high-severity TypeError: the occurrence chart and its tooltip, the Get it fixed panel, the stat rail and the rail. As a member or an analyst, who reads it and gets no triage control (?role=member|analyst&tab=overview|occurrences|activity).", kind: "lab", session: "none", }, @@ -495,7 +495,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/alerts", title: "Alerts", description: - "The `/alerts` Inbox, Rules and Routing tabs and the incident page over one fixture org: two firing, one held, four blind spots, a delivery retry and a diagnosed investigation. As an admin, a member or an observer, who gets no Acknowledge (`?role=member|observer`).", + "The `/alerts` Inbox, Rules and Routing tabs and the incident page over one fixture org: two firing, one held, four blind spots, a delivery retry and a diagnosed investigation. As an admin, a member or an analyst, who gets no Acknowledge (`?role=member|analyst`).", kind: "lab", session: "none", }, @@ -511,7 +511,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/status-pages", title: "Status pages", description: - "The status pages list, the look picker and the form fields (hint and error) over fixture pages, or a page's editor open on an incident (`?page=list|editor`). As an admin, a member or an observer, who gets no control that posts, edits or deletes an incident (`?role=member|observer`).", + "The status pages list, the look picker and the form fields (hint and error) over fixture pages, or a page's editor open on an incident (`?page=list|editor`). As an admin, a member or an analyst, who gets no control that posts, edits or deletes an incident (`?role=member|analyst`).", kind: "lab", session: "none", }, @@ -588,10 +588,10 @@ export const LAB_ENTRIES: ReadonlyArray = [ session: "none", }, { - path: "/lab/observer-home", - title: "Observer overview", + path: "/lab/analyst-home", + title: "Analyst overview", description: - "The Overview an observer gets, in the observer's shell: nothing wrong, incidents open and checks failing, nothing set up, loading and one read failing (`?state=clear|attention|empty|loading|failed`).", + "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", }, diff --git a/apps/web/src/lab/shell-frame.ts b/apps/web/src/lab/shell-frame.ts index 25bdfb71d9..0aab6ed7e8 100644 --- a/apps/web/src/lab/shell-frame.ts +++ b/apps/web/src/lab/shell-frame.ts @@ -21,13 +21,13 @@ export const foldLabShell = (model: Model) => ({ model }), }) -/** A signed-in observer with nothing else read yet: all the shell needs to pick their sidebar. */ -export const OBSERVER_CLERK: ShellClerk = { +/** 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:observer"), + maybeRole: Option.some("org:analyst"), } /** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */ diff --git a/apps/web/src/lab/shell-lab.ts b/apps/web/src/lab/shell-lab.ts index 7449e462fe..482dad02ee 100644 --- a/apps/web/src/lab/shell-lab.ts +++ b/apps/web/src/lab/shell-lab.ts @@ -41,7 +41,7 @@ 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 (`OBSERVER_CLERK`); an operator when absent. */ + /** Who the shell is drawn for (`ANALYST_CLERK`); an operator when absent. */ clerk?: Shell.ShellClerk }> diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 870399739b..0470e67128 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -140,7 +140,7 @@ describe("URL-held right sheet", () => { }) }) -describe("the shell for an observer", () => { +describe("the shell for an analyst", () => { const setup = [ role("link", { name: "Sources" }), role("link", { name: "Settings" }), @@ -156,12 +156,12 @@ describe("the shell for an observer", () => { ) }) - test("an observer has none of them, and keeps their own pages", () => { + test("an analyst has none of them, and keeps their own pages", () => { scene( { update, view }, given(at("/lab/shell")), measured, - click(role("button", { name: "Observer" })), + click(role("button", { name: "Analyst" })), ...Arr.map(setup, (it) => expect(it).toBeAbsent()), expect(role("link", { name: "Web analytics" })).toExist(), ) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 88ee11a8bc..35f3a4fb36 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -25,7 +25,7 @@ import { Message as ShellMessage, Theme } from "../shell/message" 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, OBSERVER_CLERK } 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. @@ -355,7 +355,7 @@ const page = (h: HtmlBuilder, model: Model): Html => onChange: (viewer) => Message.ClickedViewer({ viewer }), options: [ { value: "operator", label: "Member and above" }, - { value: "observer", label: "Observer" }, + { value: "analyst", label: "Analyst" }, ], }), ), @@ -386,7 +386,7 @@ export const view = (model: Model, h: HtmlBuilder): Html => TooltipHost.view(h, model.tooltip, toTooltip), ...labShellView(h, model.shell, toShell, { currentPath: STAND_ON_PATH[model.standOn], - clerk: model.viewer === "observer" ? OBSERVER_CLERK : null, + clerk: model.viewer === "analyst" ? ANALYST_CLERK : null, breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], page: () => page(h, model), }), diff --git a/apps/web/src/lab/viewer-role.ts b/apps/web/src/lab/viewer-role.ts index d99e58f831..bce02440f9 100644 --- a/apps/web/src/lab/viewer-role.ts +++ b/apps/web/src/lab/viewer-role.ts @@ -1,10 +1,10 @@ import * as Policy from "@maple/domain/policy" import { Effect, Option, Schema } from "effect" -// `?role=admin|member|observer` for a lab that renders a page as the viewer would see it. Without +// `?role=admin|member|analyst` for a lab that renders a page as the viewer would see it. Without // the flag a lab keeps the full rights it always had. -const ROLES = ["admin", "member", "observer"] as const +const ROLES = ["admin", "member", "analyst"] as const export const LabRole = Schema.Literals(ROLES) export type LabRole = typeof LabRole.Type diff --git a/apps/web/src/page/alerts/hub/observer.scene.test.ts b/apps/web/src/page/alerts/hub/analyst.scene.test.ts similarity index 83% rename from apps/web/src/page/alerts/hub/observer.scene.test.ts rename to apps/web/src/page/alerts/hub/analyst.scene.test.ts index 8684f0f6cd..3c0cee9573 100644 --- a/apps/web/src/page/alerts/hub/observer.scene.test.ts +++ b/apps/web/src/page/alerts/hub/analyst.scene.test.ts @@ -11,14 +11,14 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the Inbox; acknowledging an incident from its row needs `incidents:respond`. +// An analyst reads the Inbox; acknowledging an incident from its row needs `incidents:respond`. const context: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } const pageUpdate = (model: Model, message: Message) => update(model, message, context) const view = withViewInputs(pageView, { context })() const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) const ACKNOWLEDGED = incidentRow({ id: "0199a003-0000-7000-8000-000000000002", @@ -62,11 +62,11 @@ const loaded = (grants: ReadonlyArray, at: HubAt = {}): Model (model, message) => pageUpdate(model, message).model, ) -describe("alerts inbox, observer", () => { - test("an observer reads the open incidents and gets no acknowledge button on a row", () => { +describe("alerts inbox, analyst", () => { + test("an analyst reads the open incidents and gets no acknowledge button on a row", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER)), + given(loaded(ANALYST)), expectView(text(/1 acknowledged/)).toExist(), expectView(text(/^Acknowledged by Dana Kim ·/)).toExist(), expectView(role("button", { name: /^Acknowledge / })).toBeAbsent(), @@ -74,11 +74,11 @@ describe("alerts inbox, observer", () => { ) }) - test("an observer's Incidents page has no New rule button, and a member's keeps it", () => { - const observerView = withViewInputs(pageView, { context: { ...context, audience: "observer" } })() + 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: observerView }, - given(loaded(OBSERVER)), + { update: pageUpdate, view: analystView }, + given(loaded(ANALYST)), expectView(role("link", { name: "New rule" })).toBeAbsent(), ) scene( @@ -96,10 +96,10 @@ describe("alerts inbox, observer", () => { ) }) - test("the Anomalies view offers Acknowledge to a member and not to an observer", () => { + test("the Anomalies view offers Acknowledge to a member and not to an analyst", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, { signal: "anomaly" })), + given(loaded(ANALYST, { signal: "anomaly" })), expectView(text(/^1 firing/)).toExist(), expectView(role("button", { name: /^Acknowledge / })).toBeAbsent(), ) diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index f4c4776e4c..ec31779fb5 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -203,8 +203,8 @@ const headerActions = ( overview: Option.Option, audience: PageViewInputs["context"]["audience"], ): Html | undefined => { - // An observer follows incidents and sets nothing up. - if (audience === "observer") return 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)) diff --git a/apps/web/src/page/alerts/incident/observer.scene.test.ts b/apps/web/src/page/alerts/incident/analyst.scene.test.ts similarity index 84% rename from apps/web/src/page/alerts/incident/observer.scene.test.ts rename to apps/web/src/page/alerts/incident/analyst.scene.test.ts index ef903091b7..cdabe4c0d1 100644 --- a/apps/web/src/page/alerts/incident/observer.scene.test.ts +++ b/apps/web/src/page/alerts/incident/analyst.scene.test.ts @@ -12,7 +12,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the incident; acknowledging it needs `incidents:respond` and starting an +// An analyst reads the incident; acknowledging it needs `incidents:respond` and starting an // investigation from it needs `investigations:run`. const context: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } @@ -21,7 +21,7 @@ const view = withViewInputs(pageView, { context })() const themed = () => Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) type Fixture = Readonly<{ grants: ReadonlyArray @@ -58,11 +58,11 @@ const ACKNOWLEDGED = { acknowledged_via: "slack", } -describe("alert incident, observer", () => { - test("an observer is not offered Acknowledge or Investigate, and a member is", () => { +describe("alert incident, analyst", () => { + test("an analyst is not offered Acknowledge or Investigate, and a member is", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER })), + given(loaded({ grants: ANALYST })), themed(), expectView(role("button", { name: "Acknowledge" })).toBeAbsent(), expectView(role("button", { name: "Investigate" })).toBeAbsent(), @@ -76,10 +76,10 @@ describe("alert incident, observer", () => { ) }) - test("an observer reads who acknowledged it and is not offered Unacknowledge", () => { + test("an analyst reads who acknowledged it and is not offered Unacknowledge", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, row: ACKNOWLEDGED })), + given(loaded({ grants: ANALYST, row: ACKNOWLEDGED })), themed(), expectView(text(/Acknowledged by Dana Kim at/)).toExist(), expectView(role("button", { name: "Unacknowledge" })).toBeAbsent(), @@ -92,10 +92,10 @@ describe("alert incident, observer", () => { ) }) - test("an observer can still open an investigation that already ran", () => { + test("an analyst can still open an investigation that already ran", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, hasInvestigation: true })), + given(loaded({ grants: ANALYST, hasInvestigation: true })), themed(), expectView(role("link", { name: "Open investigation" })).toExist(), ) diff --git a/apps/web/src/page/anomalies/detail/observer.scene.test.ts b/apps/web/src/page/anomalies/detail/analyst.scene.test.ts similarity index 90% rename from apps/web/src/page/anomalies/detail/observer.scene.test.ts rename to apps/web/src/page/anomalies/detail/analyst.scene.test.ts index 79c01f5d9d..f40cf2797b 100644 --- a/apps/web/src/page/anomalies/detail/observer.scene.test.ts +++ b/apps/web/src/page/anomalies/detail/analyst.scene.test.ts @@ -13,7 +13,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the anomaly; resolving it and linking an issue need `incidents:respond`, and +// An analyst reads the anomaly; resolving it and linking an issue need `incidents:respond`, and // opening an investigation on it needs `investigations:run`. const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -25,7 +25,7 @@ const ID = Schema.decodeSync(AnomalyIncidentId)("0199a060-0000-7000-8000-0000000 const NOW = Date.UTC(2026, 9, 1, 12) const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) const incidentOf = (overrides: Readonly>) => Schema.decodeSync(V2AnomalyIncident)({ @@ -88,11 +88,11 @@ const linked = (grants: ReadonlyArray): Model => { ).model } -describe("anomaly detail, observer", () => { - test("an observer reads the anomaly and is not offered Resolve, Link issue or an investigation", () => { +describe("anomaly detail, analyst", () => { + test("an analyst reads the anomaly and is not offered Resolve, Link issue or an investigation", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER)), + given(loaded(ANALYST)), themed(), expectView(text("Not linked to an issue")).toExist(), expectView(text("Details")).toExist(), @@ -116,10 +116,10 @@ describe("anomaly detail, observer", () => { ) }) - test("an observer can open the linked issue and cannot relink or unlink it; a member can", () => { + test("an analyst can open the linked issue and cannot relink or unlink it; a member can", () => { scene( { update: pageUpdate, view }, - given(linked(OBSERVER)), + given(linked(ANALYST)), themed(), expectView(role("link", { name: "Open issue" })).toExist(), expectView(role("button", { name: "Link a different issue" })).toBeAbsent(), @@ -135,8 +135,8 @@ describe("anomaly detail, observer", () => { }) test("an answer for another org leaves the page's grants as they were", () => { - const model = loaded(OBSERVER) + const model = loaded(ANALYST) const other = pageUpdate(model, Message.CompletedFetchSession({ orgId: "org_other", grants: MEMBER })) - expect(other.model.grants).toEqual(OBSERVER) + expect(other.model.grants).toEqual(ANALYST) }) }) diff --git a/apps/web/src/page/anomalies/detail/model.ts b/apps/web/src/page/anomalies/detail/model.ts index e24cb87f21..71046f7ad8 100644 --- a/apps/web/src/page/anomalies/detail/model.ts +++ b/apps/web/src/page/anomalies/detail/model.ts @@ -15,7 +15,7 @@ export type Busy = typeof Busy.Type export const Model = Schema.Struct({ incidentId: AnomalyIncidentId, nowMs: Schema.Number, - /** What the viewer may do; an observer reads the anomaly and gets no resolve or link control. */ + /** What the viewer may do; an analyst reads the anomaly and gets no resolve or link control. */ grants: Schema.Array(Policy.PermissionName), incident: KeyedSchema(V2AnomalyIncident, LoadFailure), timeseries: KeyedSchema(V2AnomalyIncidentTimeseries, LoadFailure), diff --git a/apps/web/src/page/dashboards/list/observer.scene.test.ts b/apps/web/src/page/dashboards/list/analyst.scene.test.ts similarity index 91% rename from apps/web/src/page/dashboards/list/observer.scene.test.ts rename to apps/web/src/page/dashboards/list/analyst.scene.test.ts index 9c9a9bbcd7..9525b344da 100644 --- a/apps/web/src/page/dashboards/list/observer.scene.test.ts +++ b/apps/web/src/page/dashboards/list/analyst.scene.test.ts @@ -10,7 +10,7 @@ import type { ListDashboard, Model } from "./model" import { init, rowMenuIdOf, update } from "./update" import { view as pageView } from "./view" -// An observer reads the list; creating, importing and the row's writes need `dashboards:edit`. +// An analyst reads the list; creating, importing and the row's writes need `dashboards:edit`. const orgId = Schema.decodeSync(OrgId)("org_acme") const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -31,7 +31,7 @@ const board: ListDashboard = { } const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) /** Entered with its clock, preferences, the session's grants and rows. */ const loaded = (grants: ReadonlyArray, dashboards: ReadonlyArray): Model => @@ -52,11 +52,11 @@ const withRowMenuOpen = (model: Model): Model => ({ rowMenu: Menu.init(rowMenuIdOf(board.id), { isOpen: true }), }) -describe("dashboards list for an observer", () => { +describe("dashboards list for an analyst", () => { test("the header keeps Browse templates and drops Import and Create", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, [board])), + given(loaded(ANALYST, [board])), expectView(text("Platform overview")).toExist(), expectView(role("button", { name: "Browse templates" })).toExist(), expectView(role("button", { name: "Import" })).toBeAbsent(), @@ -78,7 +78,7 @@ describe("dashboards list for an observer", () => { test("the row menu exports and offers no write", () => { scene( { update: pageUpdate, view }, - given(withRowMenuOpen(loaded(OBSERVER, [board]))), + given(withRowMenuOpen(loaded(ANALYST, [board]))), Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), expectView(role("menuitem", { name: "Export JSON" })).toExist(), expectView(role("menuitem", { name: "Duplicate" })).toBeAbsent(), @@ -101,7 +101,7 @@ describe("dashboards list for an observer", () => { test("an empty org offers nothing to create", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, [])), + given(loaded(ANALYST, [])), expectView(text("No dashboards yet")).toExist(), expectView(role("button", { name: "Create blank dashboard" })).toBeAbsent(), ) @@ -111,7 +111,7 @@ describe("dashboards list for an observer", () => { const other = Schema.decodeSync(OrgId)("org_other") const model = pageUpdate( loaded(MEMBER, [board]), - Message.CompletedFetchSession({ orgId: other, grants: OBSERVER }), + Message.CompletedFetchSession({ orgId: other, grants: ANALYST }), ).model scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/dashboards/list/model.ts b/apps/web/src/page/dashboards/list/model.ts index db97429d1d..095b7715f5 100644 --- a/apps/web/src/page/dashboards/list/model.ts +++ b/apps/web/src/page/dashboards/list/model.ts @@ -47,7 +47,7 @@ export type Scope = typeof Scope.Type export const Model = Schema.Struct({ search: DashboardsSearch, - /** What the viewer may do; an observer reads the list and gets no write control. */ + /** What the viewer may do; an analyst reads the list and gets no write control. */ grants: Schema.Array(Policy.PermissionName), /** `null` until the collection is ready. */ dashboards: Schema.NullOr(Schema.Array(ListDashboard)), diff --git a/apps/web/src/page/dashboards/list/view-list.ts b/apps/web/src/page/dashboards/list/view-list.ts index e5ecb1e56f..afbe60ac00 100644 --- a/apps/web/src/page/dashboards/list/view-list.ts +++ b/apps/web/src/page/dashboards/list/view-list.ts @@ -158,8 +158,8 @@ const READ_ONLY_NOTE: ReadonlyArray = [ const EXPORT_ENTRY: MenuView.MenuEntry = { _tag: "Item", value: "export", label: "Export JSON" } -/** All an observer's row menu holds: the entries that write are not offered. */ -const OBSERVER_ROW_ENTRIES: ReadonlyArray = [{ _tag: "Group", entries: [EXPORT_ENTRY] }] +/** All an analyst's row menu holds: the entries that write are not offered. */ +const ANALYST_ROW_ENTRIES: ReadonlyArray = [{ _tag: "Group", entries: [EXPORT_ENTRY] }] /** Export reads what the list holds, so it survives a write failure; the rest write, so they don't. */ const rowMenuEntries = (isReadOnly: boolean): ReadonlyArray => [ @@ -188,7 +188,7 @@ const rowMenu = (h: H, model: Model, dashboard: ListDashboard): Html => { return rowActionsMenu(h, { slotId: `row-menu-${dashboard.id}`, model: menu, - entries: canEditDashboards(model) ? rowMenuEntries(isReadOnly(model)) : OBSERVER_ROW_ENTRIES, + entries: canEditDashboards(model) ? rowMenuEntries(isReadOnly(model)) : ANALYST_ROW_ENTRIES, label: `More actions for ${displayName(dashboard.name)}`, className: "text-muted-foreground", toParentMessage: (message) => Message.GotRowMenuMessage({ rowId: dashboard.id, message }), @@ -403,8 +403,8 @@ const firstRunEmpty = (h: H, isReadOnly: boolean): Html => "py-16", ) -/** An observer's empty list: nothing to start from, so it says where dashboards come from. */ -const observerEmpty = (h: H): Html => +/** An analyst's empty list: nothing to start from, so it says where dashboards come from. */ +const analystEmpty = (h: H): Html => empty( h, [ @@ -513,7 +513,7 @@ export const listBody = ( const canEdit = canEditDashboards(model) const context: RowContext = { model, nowMs, timeZone } const body: ReadonlyArray = isNothingAtAll - ? [canEdit ? firstRunEmpty(h, isWriteDisabled) : observerEmpty(h)] + ? [canEdit ? firstRunEmpty(h, isWriteDisabled) : analystEmpty(h)] : sections.matched === 0 ? [nothingMatched(h, model)] : sections.scoped === 0 diff --git a/apps/web/src/page/dashboards/list/view.ts b/apps/web/src/page/dashboards/list/view.ts index 0c87047726..7e44d388d5 100644 --- a/apps/web/src/page/dashboards/list/view.ts +++ b/apps/web/src/page/dashboards/list/view.ts @@ -160,7 +160,7 @@ export const view = Submodel.defineView((model, h, { titleContent: title, - // An observer browses the templates but neither imports nor creates. + // An analyst browses the templates but neither imports nor creates. actions: canEditDashboards(model) ? actions(h, reason) : browseTemplates(h), sticky: persistenceBanner(h, model.persistenceError, isLoadFailed(model)), }, diff --git a/apps/web/src/page/dashboards/templates/observer.scene.test.ts b/apps/web/src/page/dashboards/templates/analyst.scene.test.ts similarity index 92% rename from apps/web/src/page/dashboards/templates/observer.scene.test.ts rename to apps/web/src/page/dashboards/templates/analyst.scene.test.ts index ca47737f71..e03f3b2186 100644 --- a/apps/web/src/page/dashboards/templates/observer.scene.test.ts +++ b/apps/web/src/page/dashboards/templates/analyst.scene.test.ts @@ -12,7 +12,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer previews templates; creating a dashboard from one needs `dashboards:edit`. +// An analyst previews templates; creating a dashboard from one needs `dashboards:edit`. const orgId = Schema.decodeSync(OrgId)("org_acme") const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -36,7 +36,7 @@ const template = Schema.decodeUnknownSync(Schema.toType(V2DashboardTemplate))({ }) const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) /** The gallery with its one template selected and the session's grants answered. */ const selected = (grants: ReadonlyArray): Model => { @@ -54,9 +54,9 @@ const selected = (grants: ReadonlyArray): Model => { ) } -describe("dashboard templates for an observer", () => { +describe("dashboard templates for an analyst", () => { test("the template previews with no way to create from it", () => { - const model = selected(OBSERVER) + const model = selected(ANALYST) expect(names(pageUpdate(model, Message.ClickedCreate({ templateId: template.id })).commands)).toEqual( [], ) diff --git a/apps/web/src/page/dashboards/templates/model.ts b/apps/web/src/page/dashboards/templates/model.ts index bb89ea0072..2ae82aa65c 100644 --- a/apps/web/src/page/dashboards/templates/model.ts +++ b/apps/web/src/page/dashboards/templates/model.ts @@ -21,7 +21,7 @@ export type MetricSeen = typeof MetricSeen.Type export const Model = Schema.Struct({ search: DashboardsTemplatesSearch, - /** What the viewer may do; an observer previews templates and gets no create control. */ + /** What the viewer may do; an analyst previews templates and gets no create control. */ grants: Schema.Array(Policy.PermissionName), nowMs: Schema.NullOr(Schema.Number), templates: KeyedSchema(Schema.Array(V2DashboardTemplate), LoadFailure), diff --git a/apps/web/src/page/dashboards/view/observer.scene.test.ts b/apps/web/src/page/dashboards/view/analyst.scene.test.ts similarity index 94% rename from apps/web/src/page/dashboards/view/observer.scene.test.ts rename to apps/web/src/page/dashboards/view/analyst.scene.test.ts index e7c4f49d84..9a06b72f5f 100644 --- a/apps/web/src/page/dashboards/view/observer.scene.test.ts +++ b/apps/web/src/page/dashboards/view/analyst.scene.test.ts @@ -14,7 +14,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads a board; editing needs `dashboards:edit` and sharing `dashboards:share`. +// An analyst reads a board; editing needs `dashboards:edit` and sharing `dashboards:share`. const orgId = Schema.decodeSync(OrgId)("org_acme") const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -62,7 +62,7 @@ const version = { } const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) type Grants = ReadonlyArray /** Entered, clocked, holding the session's grants and the document. */ @@ -93,11 +93,11 @@ const previewV2 = (model: Model): Model => Message.CompletedFetchSnapshot({ versionId: version.id, result: Result.succeed(board([note])) }), ]) -describe("a dashboard for an observer", () => { +describe("a dashboard for an analyst", () => { test("the board and its widgets render without an Edit control", () => { scene( { update: pageUpdate, view }, - given(ready(OBSERVER)), + given(ready(ANALYST)), ...mounted(), expectView(text("Page the platform on-call first.")).toExist(), expectView(role("button", { name: "Reload" })).toExist(), @@ -115,7 +115,7 @@ describe("a dashboard for an observer", () => { }) test("?mode=edit lands on the read view", () => { - const model = ready(OBSERVER, { mode: "edit" }) + const model = ready(ANALYST, { mode: "edit" }) expect(isEditing(model)).toBe(false) scene( { update: pageUpdate, view }, @@ -141,7 +141,7 @@ describe("a dashboard for an observer", () => { }) test("the name is a heading, not a rename control", () => { - const model = ready(OBSERVER) + const model = ready(ANALYST) expect(pageUpdate(model, Message.ClickedTitle()).model.titleDraft).toBeNull() scene( { update: pageUpdate, view }, @@ -166,7 +166,7 @@ describe("a dashboard for an observer", () => { test("the more menu keeps history and export and drops Share", () => { scene( { update: pageUpdate, view }, - given(withMoreMenuOpen(ready(OBSERVER))), + given(withMoreMenuOpen(ready(ANALYST))), ...mounted(), Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), expectView(role("menuitem", { name: "Version history" })).toExist(), @@ -186,7 +186,7 @@ describe("a dashboard for an observer", () => { }) test("a previewed version can be left but not restored", () => { - const previewed = previewV2(ready(OBSERVER)) + const previewed = previewV2(ready(ANALYST)) expect(previewed.history.previewed?.id).toBe(version.id) expect(pageUpdate(previewed, Message.ClickedRestoreVersion()).model.history.restoreDialog).toEqual( previewed.history.restoreDialog, @@ -212,7 +212,7 @@ describe("a dashboard for an observer", () => { test("an empty board offers nothing to add", () => { scene( { update: pageUpdate, view }, - given(ready(OBSERVER, {}, board([]))), + given(ready(ANALYST, {}, board([]))), themed(), expectView(text("Nothing on this board yet")).toExist(), expectView(role("button", { name: "Add your first widget" })).toBeAbsent(), diff --git a/apps/web/src/page/dashboards/view/model.ts b/apps/web/src/page/dashboards/view/model.ts index 4cc77dd64e..e9d74a88fd 100644 --- a/apps/web/src/page/dashboards/view/model.ts +++ b/apps/web/src/page/dashboards/view/model.ts @@ -152,7 +152,7 @@ export const Model = Schema.Struct({ dashboardId: Schema.String, search: DashboardSearch, status: Status, - /** What the viewer may do; an observer reads the board and gets no edit or share control. */ + /** What the viewer may do; an analyst reads the board and gets no edit or share control. */ grants: Schema.Array(Policy.PermissionName), document: Schema.NullOr(DashboardDocument), /** The live board never answered within `COLLECTION_LOAD_TIMEOUT_MS`: the HTTP snapshot's state. */ diff --git a/apps/web/src/page/errors/hub/observer.scene.test.ts b/apps/web/src/page/errors/hub/analyst.scene.test.ts similarity index 90% rename from apps/web/src/page/errors/hub/observer.scene.test.ts rename to apps/web/src/page/errors/hub/analyst.scene.test.ts index f5e4bab9f9..1b6f6f72c5 100644 --- a/apps/web/src/page/errors/hub/observer.scene.test.ts +++ b/apps/web/src/page/errors/hub/analyst.scene.test.ts @@ -13,7 +13,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the list; selection, the row pickers, the bulk bar and the row menu's +// An analyst reads the list; selection, the row pickers, the bulk bar and the row menu's // status and lease entries need `error_issues:triage`. const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -25,7 +25,7 @@ const pageUpdate = (model: Model, message: Message) => update(model, message, co const view = withViewInputs(pageView, { context })() const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) const RECT = { left: 2, right: 115, top: 2, bottom: 2, width: 51, height: 28 } const mounted = [ @@ -106,11 +106,11 @@ const pickedFromRowMenu = (model: Model, index: number) => { ) } -describe("errors list for an observer", () => { +describe("errors list for an analyst", () => { test("a row reads its severity and status with no checkbox or picker", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER)), + given(loaded(ANALYST)), ...mounted, expectView(role("link", { name: /ConnectionTimeout/ })).toHaveAttr( "href", @@ -136,7 +136,7 @@ describe("errors list for an observer", () => { ) }) - test("a selection brings a member the bulk bar and an observer nothing", () => { + test("a selection brings a member the bulk bar and an analyst nothing", () => { const bulkBar = role("region", { name: "Bulk actions" }) scene( { update: pageUpdate, view }, @@ -147,7 +147,7 @@ describe("errors list for an observer", () => { ) scene( { update: pageUpdate, view }, - given({ ...loaded(OBSERVER), selectedIds: [ISSUE_ID] }), + given({ ...loaded(ANALYST), selectedIds: [ISSUE_ID] }), ...mounted, expectView(bulkBar).toBeAbsent(), expectView(role("button", { name: "Claim" })).toBeAbsent(), @@ -155,7 +155,7 @@ describe("errors list for an observer", () => { }) test("x, s and p do nothing; j still moves the focus and Enter opens the issue", () => { - const focused = press(loaded(OBSERVER), "j") + const focused = press(loaded(ANALYST), "j") expect(focused.focusedId).toBe(ISSUE_ID) expect(press(focused, "x").selectedIds).toEqual([]) expect(press(focused, "s").picker).toBeNull() @@ -173,9 +173,9 @@ describe("errors list for an observer", () => { }) test("the row menu starts at Open in new tab and writes nothing", () => { - expect(names(pickedFromRowMenu(loaded(OBSERVER), 0).commands)).toContain("OpenErrorsRowInNewTab") - // Where a member's menu has Claim, an observer's has Copy link. - const second = names(pickedFromRowMenu(loaded(OBSERVER), 1).commands) + expect(names(pickedFromRowMenu(loaded(ANALYST), 0).commands)).toContain("OpenErrorsRowInNewTab") + // Where a member's menu has Claim, an analyst's has Copy link. + const second = names(pickedFromRowMenu(loaded(ANALYST), 1).commands) expect(second).toContain("CopyErrorsRowText") expect(second).not.toContain("RunErrorsHubWrite") }) @@ -188,7 +188,7 @@ describe("errors list for an observer", () => { const other = Schema.decodeSync(OrgId)("org_other") const model = pageUpdate( loaded(MEMBER), - Message.CompletedFetchSession({ orgId: other, grants: OBSERVER }), + Message.CompletedFetchSession({ orgId: other, grants: ANALYST }), ).model scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/errors/hub/model.ts b/apps/web/src/page/errors/hub/model.ts index 18df5b2988..0e2b0a7636 100644 --- a/apps/web/src/page/errors/hub/model.ts +++ b/apps/web/src/page/errors/hub/model.ts @@ -57,7 +57,7 @@ export type PageSlot = typeof PageSlot.Type export const Model = Schema.Struct({ search: ErrorsSearch, - /** What the viewer may do; an observer reads the list and gets no triage control. */ + /** What the viewer may do; an analyst reads the list and gets no triage control. */ grants: Schema.Array(Policy.PermissionName), /** The trend window (`ERRORS_WINDOW`) and the clock it was resolved at. */ window: Schema.NullOr(TimeWindow), diff --git a/apps/web/src/page/errors/hub/selection.ts b/apps/web/src/page/errors/hub/selection.ts index 3f186b48e2..0c056fbff6 100644 --- a/apps/web/src/page/errors/hub/selection.ts +++ b/apps/web/src/page/errors/hub/selection.ts @@ -46,7 +46,7 @@ export const toggledSelection = (model: Model, id: string, isShift: boolean): Mo const cleared = (model: Model): Model => ({ ...model, selectedIds: [], selectionAnchor: null }) -/** `x` selects for the bulk bar and `s`/`p` open the pickers: triage, so nothing for an observer. */ +/** `x` selects for the bulk bar and `s`/`p` open the pickers: triage, so nothing for an analyst. */ const TRIAGE_KEYS = new Set(["x", "X", "s", "S", "p", "P"]) /** `j`/`k`/arrows move the focus, Enter opens, `x` selects, Escape clears; `s`/`p` open its pickers. */ diff --git a/apps/web/src/page/errors/hub/view-list.ts b/apps/web/src/page/errors/hub/view-list.ts index 0f9d2ca65b..c7056fad10 100644 --- a/apps/web/src/page/errors/hub/view-list.ts +++ b/apps/web/src/page/errors/hub/view-list.ts @@ -121,7 +121,7 @@ const memoRow = (h: H, signal: ErrorSignal, index: number, state: RowState): Htm /** The rows, or the empty state, under the column header. */ const readyBody = (h: H, model: Model, inputs: ListInputs): ReadonlyArray => { const { signals } = inputs - // Selection and the bulk bar are triage: an observer gets neither. + // Selection and the bulk bar are triage: an analyst gets neither. const canTriage = canTriageIssues(model) if (signals.length === 0) { return [ diff --git a/apps/web/src/page/errors/hub/view-row.ts b/apps/web/src/page/errors/hub/view-row.ts index e8cb5dadaa..55bd53a630 100644 --- a/apps/web/src/page/errors/hub/view-row.ts +++ b/apps/web/src/page/errors/hub/view-row.ts @@ -171,7 +171,7 @@ export const signalRow = (h: H, signal: ErrorSignal, state: RowState): Html => { h.span( [ h.Class(cn(LANE.select, "flex items-center justify-center")), - // Selection only feeds the bulk bar: an observer's lane is empty and selects nothing. + // Selection only feeds the bulk bar: an analyst's lane is empty and selects nothing. ...(state.canTriage ? [h.DataAttribute("row-select", signal.id)] : []), h.Role("presentation"), ], diff --git a/apps/web/src/page/errors/issue/observer.scene.test.ts b/apps/web/src/page/errors/issue/analyst.scene.test.ts similarity index 93% rename from apps/web/src/page/errors/issue/observer.scene.test.ts rename to apps/web/src/page/errors/issue/analyst.scene.test.ts index b85118993e..befe6b1794 100644 --- a/apps/web/src/page/errors/issue/observer.scene.test.ts +++ b/apps/web/src/page/errors/issue/analyst.scene.test.ts @@ -18,7 +18,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the issue; state, severity, the lease, merges, pull request links and +// An analyst reads the issue; state, severity, the lease, merges, pull request links and // comments need `error_issues:triage`. const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -30,7 +30,7 @@ const pageUpdate = (model: Model, message: Message) => update(model, message, co const view = withViewInputs(pageView, { context })() const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) const holder = { id: encodePublicId("actor", "0199a005-0000-7000-8000-000000000001"), @@ -179,11 +179,11 @@ const mounted = [ Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), ] as const -describe("error issue for an observer", () => { +describe("error issue for an analyst", () => { test("the rail reads state and severity and offers no pick, claim or attach", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER })), + given(loaded({ grants: ANALYST })), ...mounted, expectView(text("In progress")).toExist(), expectView(text("High")).toExist(), @@ -211,7 +211,7 @@ describe("error issue for an observer", () => { test("a held lease shows its countdown without Extend or Release", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, detail: HELD })), + given(loaded({ grants: ANALYST, detail: HELD })), ...mounted, expectView(text("Active lease")).toExist(), expectView(text("5:00")).toExist(), @@ -233,7 +233,7 @@ describe("error issue for an observer", () => { test("a merged issue says where it went and offers no Unmerge", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, detail: MERGED })), + given(loaded({ grants: ANALYST, detail: MERGED })), ...mounted, expectView(text("Merged into another issue")).toExist(), expectView(role("link", { name: "the issue it was merged into" })).toExist(), @@ -253,7 +253,7 @@ describe("error issue for an observer", () => { test("a linked pull request is a link with no detach", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, hasPullRequest: true })), + given(loaded({ grants: ANALYST, hasPullRequest: true })), ...mounted, expectView(role("link", { name: "acme/payments#412" })).toExist(), expectView(role("button", { name: "Detach acme/payments#412" })).toBeAbsent(), @@ -272,7 +272,7 @@ describe("error issue for an observer", () => { test("the activity tab keeps the timeline and has no comment box", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, tab: "activity" })), + given(loaded({ grants: ANALYST, tab: "activity" })), ...mounted, expectView(text("triage → in_progress")).toExist(), expectView(role("textbox")).toBeAbsent(), @@ -290,10 +290,10 @@ describe("error issue for an observer", () => { ) }) - test("an observer is not offered an investigation or the agent prompt, and a member is", () => { + test("an analyst is not offered an investigation or the agent prompt, and a member is", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER })), + given(loaded({ grants: ANALYST })), ...mounted, expectView(role("button", { name: "Start investigation" })).toBeAbsent(), expectView(role("button", { name: "Copy agent prompt" })).toBeAbsent(), @@ -311,7 +311,7 @@ describe("error issue for an observer", () => { const other = Schema.decodeSync(OrgId)("org_other") const model = pageUpdate( loaded({ grants: MEMBER }), - Message.CompletedFetchSession({ orgId: other, grants: OBSERVER }), + Message.CompletedFetchSession({ orgId: other, grants: ANALYST }), ).model scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/errors/issue/model.ts b/apps/web/src/page/errors/issue/model.ts index 308913c384..262a295eb5 100644 --- a/apps/web/src/page/errors/issue/model.ts +++ b/apps/web/src/page/errors/issue/model.ts @@ -33,7 +33,7 @@ export const DestinationName = Schema.Struct({ id: Schema.String, name: Schema.S export const Model = Schema.Struct({ issueId: ErrorIssueId, search: ErrorIssueSearch, - /** What the viewer may do; an observer reads the issue and gets no triage control. */ + /** What the viewer may do; an analyst reads the issue and gets no triage control. */ grants: Schema.Array(Policy.PermissionName), /** The resolved window and the clock it was resolved at; `null` until the first resolve. */ window: Schema.NullOr(TimeWindow), diff --git a/apps/web/src/page/errors/issue/view.ts b/apps/web/src/page/errors/issue/view.ts index 0e0053150d..460069b724 100644 --- a/apps/web/src/page/errors/issue/view.ts +++ b/apps/web/src/page/errors/issue/view.ts @@ -132,7 +132,7 @@ const mergedNotice = (h: H, model: Model, issue: ErrorIssueDetailResponse["issue ), canTriageIssues(model) ? ". Unmerge it to track it on its own again." : ".", ]), - // Unmerging is triage: an observer reads where the occurrences go. + // Unmerging is triage: an analyst reads where the occurrences go. ...(canTriageIssues(model) ? [ alertAction(h, [ @@ -192,7 +192,7 @@ const overview = ( surge: signal?.surge ?? null, ...clock, }) - // Getting it fixed is a member's job: an observer only reads an investigation that already ran. + // Getting it fixed is a member's job: an analyst only reads an investigation that already ran. const investigation = linkedInvestigationOf(model) const fix = canRunInvestigations(model) || investigation !== null diff --git a/apps/web/src/page/investigations/list/observer.scene.test.ts b/apps/web/src/page/investigations/list/analyst.scene.test.ts similarity index 87% rename from apps/web/src/page/investigations/list/observer.scene.test.ts rename to apps/web/src/page/investigations/list/analyst.scene.test.ts index fa35905c39..822d2dd9ed 100644 --- a/apps/web/src/page/investigations/list/observer.scene.test.ts +++ b/apps/web/src/page/investigations/list/analyst.scene.test.ts @@ -12,7 +12,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the investigations; starting one needs `investigations:run`. +// An analyst reads the investigations; starting one needs `investigations:run`. const context: PageContext = { orgId: Schema.decodeSync(OrgId)("org_acme"), @@ -33,7 +33,7 @@ const measured = () => const rowsMounted = () => Mount.resolve(MountInvestigationRows, Message.ClickedRow({ id: investigation.id })) const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) const NOW = Date.UTC(2026, 9, 1, 12) const investigation = buildAlertsLabFixture(NOW).incident.investigation const bar = role("textbox", { name: "What should Maple investigate?" }) @@ -54,11 +54,11 @@ const loaded = ( ).model } -describe("investigations list, observer", () => { - test("an observer reads the list without the investigate bar; a member has it", () => { +describe("investigations list, analyst", () => { + test("an analyst reads the list without the investigate bar; a member has it", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, [investigation])), + given(loaded(ANALYST, [investigation])), measured(), rowsMounted(), expectView(role("tab", { name: /Active/ })).toExist(), @@ -73,10 +73,10 @@ describe("investigations list, observer", () => { ) }) - test("an empty workspace shows an observer the plain empty list, and a member the first-run hero", () => { + test("an empty workspace shows an analyst the plain empty list, and a member the first-run hero", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, [])), + given(loaded(ANALYST, [])), measured(), expectView(text("Nothing under investigation")).toExist(), expectView(text("When an alert rule fires, Maple opens one on its own.")).toExist(), diff --git a/apps/web/src/page/overview/observer/observer.scene.test.ts b/apps/web/src/page/overview/analyst/analyst.scene.test.ts similarity index 78% rename from apps/web/src/page/overview/observer/observer.scene.test.ts rename to apps/web/src/page/overview/analyst/analyst.scene.test.ts index e5644f3220..8d13655011 100644 --- a/apps/web/src/page/overview/observer/observer.scene.test.ts +++ b/apps/web/src/page/overview/analyst/analyst.scene.test.ts @@ -17,12 +17,12 @@ import { buildErrorsLabFixture } from "../../../lab/errors-fixture" import { CRITICAL_INCIDENT, DOWN_MONITOR, - OBSERVER_CONTEXT, - type ObserverHomeState, - observerHomeModel, + ANALYST_CONTEXT, + type AnalystHomeState, + analystHomeModel, TOP_ERRORS, WARNING_INCIDENT, -} from "../../../lab/observer-home-fixture" +} 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" @@ -40,36 +40,36 @@ import { FetchSlos, FetchTopErrors, LoadSignalPresence, - MountObserverHome, + MountAnalystHome, ReadClock, } from "./command" import { topErrorsOf } from "./derive" -import { Message as ObserverMessage } from "./message" -import type { Model as ObserverModel } from "./model" +import { Message as AnalystMessage } from "./message" +import type { Model as AnalystModel } from "./model" import { subscriptions } from "../subscription" -// The Overview an observer gets: a verdict, what backs it, and no control that sets anything up. +// The Overview an analyst gets: a verdict, what backs it, and no control that sets anything up. -const OPERATOR_CONTEXT: PageContext = { ...OBSERVER_CONTEXT, audience: "operator" } +const OPERATOR_CONTEXT: PageContext = { ...ANALYST_CONTEXT, audience: "operator" } -const pageUpdate = (model: Model, message: Message) => update(model, message, OBSERVER_CONTEXT) -const view = withViewInputs(pageView, { context: OBSERVER_CONTEXT })() +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 observer page as its reads left it in `state`. */ -const at = (state: ObserverHomeState): Model => ({ - ...init({ search: {} }, OBSERVER_CONTEXT).model, - observer: observerHomeModel(state), +/** 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(MountObserverHome, ObserverMessage.Entered()) +const mounted = Mount.resolve(MountAnalystHome, AnalystMessage.Entered()) -describe("overview, observer", () => { +describe("overview, analyst", () => { test("with nothing open or failing the verdict says so, backed by the counts", () => { scene( { update: pageUpdate, view }, @@ -179,8 +179,8 @@ describe("overview, observer", () => { click(role("button", { name: "Try again" })), Command.resolve( FetchMonitors, - ObserverMessage.CompletedFetchMonitors({ - key: failed.observer?.monitors.key ?? "", + AnalystMessage.CompletedFetchMonitors({ + key: failed.analyst?.monitors.key ?? "", result: Result.succeed([httpMonitor()]), }), ), @@ -189,17 +189,17 @@ describe("overview, observer", () => { ) }) - /** `state` after one more answer from the observer page's reads. */ - const after = (state: ObserverHomeState, answer: (observer: ObserverModel) => ObserverMessage): Model => { + /** `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.observer === null) return model - return pageUpdate(model, Message.GotObserverMessage({ message: answer(model.observer) })).model + 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", (observer) => - ObserverMessage.CompletedFetchMonitors({ - key: observer.monitors.key ?? "", + const paused = after("clear", (analyst) => + AnalystMessage.CompletedFetchMonitors({ + key: analyst.monitors.key ?? "", result: Result.succeed([httpMonitor({ enabled: false })]), }), ) @@ -213,9 +213,9 @@ describe("overview, observer", () => { }) test("an empty error list is only called a clean day when the app is known to send data", () => { - const noErrors = (observer: ObserverModel) => - ObserverMessage.CompletedFetchErrors({ - key: observer.errors.key ?? "", + const noErrors = (analyst: AnalystModel) => + AnalystMessage.CompletedFetchErrors({ + key: analyst.errors.key ?? "", result: Result.succeed([]), }) scene( @@ -234,58 +234,58 @@ describe("overview, observer", () => { ) }) - test("the engineers' live rows are not read while an observer's page is drawn", () => { + test("the engineers' live rows are not read while an analyst's page is drawn", () => { const drawn = (model: Model) => - subscriptions.incidents.modelToDependencies({ model, context: OBSERVER_CONTEXT }).isDrawn + 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 observer's entry starts their reads and none of the engineers'", () => { - const entered = init({ search: {} }, OBSERVER_CONTEXT) + 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([ - "ReadObserverHomeClock", - "FetchObserverHomeMonitors", - "FetchObserverHomeSlos", - "LoadObserverHomeSignalPresence", + "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 observer page, and its mount starts the reads once", () => { + 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 = observerHomeModel("loading") + 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, ObserverMessage.CompletedReadClock({ nowMs: NOW })), + Command.resolve(ReadClock, AnalystMessage.CompletedReadClock({ nowMs: NOW })), Command.resolve( LoadSignalPresence, - ObserverMessage.CompletedLoadSignalPresence({ result: Result.succeed({}) }), + AnalystMessage.CompletedLoadSignalPresence({ result: Result.succeed({}) }), ), Command.resolve( FetchMonitors, - ObserverMessage.CompletedFetchMonitors({ + AnalystMessage.CompletedFetchMonitors({ key: loading.monitors.key ?? "", result: Result.succeed([]), }), ), Command.resolve( FetchSlos, - ObserverMessage.CompletedFetchSlos({ + AnalystMessage.CompletedFetchSlos({ key: loading.slos.key ?? "", result: Result.succeed([]), }), ), Command.resolve( FetchTopErrors, - ObserverMessage.CompletedFetchErrors({ + AnalystMessage.CompletedFetchErrors({ key: loading.errors.key ?? "", result: Result.succeed([]), }), @@ -294,24 +294,24 @@ describe("overview, observer", () => { ) const entered = pageUpdate( asOperator, - Message.GotObserverMessage({ message: ObserverMessage.Entered() }), + Message.GotAnalystMessage({ message: AnalystMessage.Entered() }), ) const again = pageUpdate( entered.model, - Message.GotObserverMessage({ message: ObserverMessage.Entered() }), + 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: {} }, OBSERVER_CONTEXT) - expect(names(entered.commands)).not.toContain("FetchObserverHomeTopErrors") + const entered = init({ search: {} }, ANALYST_CONTEXT) + expect(names(entered.commands)).not.toContain("FetchAnalystHomeTopErrors") const clocked = pageUpdate( entered.model, - Message.GotObserverMessage({ message: ObserverMessage.CompletedReadClock({ nowMs: NOW }) }), + Message.GotAnalystMessage({ message: AnalystMessage.CompletedReadClock({ nowMs: NOW }) }), ) - expect(names(clocked.commands)).toEqual(["FetchObserverHomeTopErrors"]) - expect(clocked.model.observer?.window).toEqual({ + 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", }) @@ -319,11 +319,11 @@ describe("overview, observer", () => { test("an operator still gets the engineers' overview and its reads", () => { const entered = init({ search: {} }, OPERATOR_CONTEXT) - expect(entered.model.observer).toBeNull() + expect(entered.model.analyst).toBeNull() expect(names(entered.commands)).toEqual( expect.arrayContaining(["ReadOverviewEntry", "LoadOverviewSignalPresence"]), ) - expect(names(entered.commands)).not.toContain("FetchObserverHomeMonitors") + expect(names(entered.commands)).not.toContain("FetchAnalystHomeMonitors") scene( { update: (model: Model, message: Message) => update(model, message, OPERATOR_CONTEXT), diff --git a/apps/web/src/page/overview/observer/command.ts b/apps/web/src/page/overview/analyst/command.ts similarity index 80% rename from apps/web/src/page/overview/observer/command.ts rename to apps/web/src/page/overview/analyst/command.ts index b02d4559b0..709d37d52d 100644 --- a/apps/web/src/page/overview/observer/command.ts +++ b/apps/web/src/page/overview/analyst/command.ts @@ -13,20 +13,20 @@ 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 MountObserverHome = Mount.define("MountObserverHome", { +export const MountAnalystHome = Mount.define("MountAnalystHome", { messages: [Message.Entered], execute: () => Effect.succeed(Message.Entered()), }) -export const ReadClock = readClock("ReadObserverHomeClock", Message.CompletedReadClock) +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("ResolveObserverHomeClock", Message.CompletedResolveClock) +export const ResolveClock = readClock("ResolveAnalystHomeClock", Message.CompletedResolveClock) -export const CompleteReloadSpin = reloadSpin("CompleteObserverHomeReloadSpin", Message.CompletedReloadSpin) +export const CompleteReloadSpin = reloadSpin("CompleteAnalystHomeReloadSpin", Message.CompletedReloadSpin) /** `uptimeMonitors.list`, the read the Uptime tab makes. */ -export const FetchMonitors = Command.define("FetchObserverHomeMonitors", { +export const FetchMonitors = Command.define("FetchAnalystHomeMonitors", { args: { key: Schema.String }, messages: [Message.CompletedFetchMonitors], execute: ({ key }) => @@ -37,7 +37,7 @@ export const FetchMonitors = Command.define("FetchObserverHomeMonitors", { }) /** `slos.list` with every status, the read the SLOs tab makes. */ -export const FetchSlos = Command.define("FetchObserverHomeSlos", { +export const FetchSlos = Command.define("FetchAnalystHomeSlos", { args: { key: Schema.String }, messages: [Message.CompletedFetchSlos], execute: ({ key }) => @@ -51,7 +51,7 @@ export const FetchSlos = Command.define("FetchObserverHomeSlos", { * 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("FetchObserverHomeTopErrors", { +export const FetchTopErrors = Command.define("FetchAnalystHomeTopErrors", { args: { key: Schema.String, window: TimeWindow }, messages: [Message.CompletedFetchErrors], execute: ({ key, window }) => @@ -75,7 +75,7 @@ export const FetchTopErrors = Command.define("FetchObserverHomeTopErrors", { }).pipe(settleTo((result) => Message.CompletedFetchErrors({ key, result }))), }) -export const LoadSignalPresence = Command.define("LoadObserverHomeSignalPresence", { +export const LoadSignalPresence = Command.define("LoadAnalystHomeSignalPresence", { args: { orgId: Schema.String }, messages: [Message.CompletedLoadSignalPresence], execute: ({ orgId }) => diff --git a/apps/web/src/page/overview/observer/derive.ts b/apps/web/src/page/overview/analyst/derive.ts similarity index 100% rename from apps/web/src/page/overview/observer/derive.ts rename to apps/web/src/page/overview/analyst/derive.ts diff --git a/apps/web/src/page/overview/observer/message.ts b/apps/web/src/page/overview/analyst/message.ts similarity index 100% rename from apps/web/src/page/overview/observer/message.ts rename to apps/web/src/page/overview/analyst/message.ts diff --git a/apps/web/src/page/overview/observer/model.ts b/apps/web/src/page/overview/analyst/model.ts similarity index 96% rename from apps/web/src/page/overview/observer/model.ts rename to apps/web/src/page/overview/analyst/model.ts index 095a21b152..d1986dc347 100644 --- a/apps/web/src/page/overview/observer/model.ts +++ b/apps/web/src/page/overview/analyst/model.ts @@ -8,7 +8,7 @@ import { LoadFailure } from "@maple/views/data/load-failure" import { SignalPresenceData } from "@maple/views/data/signal-presence" import { TimeWindow } from "../../../data/time-range" -// The observer's Overview: is the product working, and what is worth knowing, read-only. +// The analyst's Overview: is the product working, and what is worth knowing, read-only. /** The synced alert incidents; `Unavailable` when the live sync failed or stalled. */ export const IncidentsData = AsyncData.Schema( diff --git a/apps/web/src/page/overview/observer/subscription.ts b/apps/web/src/page/overview/analyst/subscription.ts similarity index 93% rename from apps/web/src/page/overview/observer/subscription.ts rename to apps/web/src/page/overview/analyst/subscription.ts index 17a89ed3eb..58b89190b8 100644 --- a/apps/web/src/page/overview/observer/subscription.ts +++ b/apps/web/src/page/overview/analyst/subscription.ts @@ -17,12 +17,12 @@ 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) => ({ - observerIncidents: entry(byOrg, { + analystIncidents: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), dependenciesToStream: ({ orgId }) => incidentRows(orgId, (rows) => Message.UpdatedIncidents({ orgId, rows })), }), - observerIncidentsLoadFailed: entry(waiting, { + analystIncidentsLoadFailed: entry(waiting, { modelToDependencies: ({ model, context }) => ({ orgId: context.orgId, isWaiting: AsyncData.isLoading(model.incidents), @@ -36,12 +36,12 @@ export const subscriptions = Subscription.make()((ent ) : Stream.empty, }), - observerRules: entry(byOrg, { + analystRules: entry(byOrg, { modelToDependencies: ({ context }) => ({ orgId: context.orgId }), dependenciesToStream: ({ orgId }) => ruleRows(orgId, (rows) => Message.UpdatedRules({ orgId, count: rows.length })), }), - observerRulesLoadFailed: entry(waiting, { + analystRulesLoadFailed: entry(waiting, { modelToDependencies: ({ model, context }) => ({ orgId: context.orgId, isWaiting: AsyncData.isLoading(model.ruleCount), diff --git a/apps/web/src/page/overview/observer/update.ts b/apps/web/src/page/overview/analyst/update.ts similarity index 97% rename from apps/web/src/page/overview/observer/update.ts rename to apps/web/src/page/overview/analyst/update.ts index 591ef1efbc..1fb03a9ec1 100644 --- a/apps/web/src/page/overview/observer/update.ts +++ b/apps/web/src/page/overview/analyst/update.ts @@ -73,7 +73,7 @@ const sync = (model: Model, context: PageContext, mode: Keyed.RequestMode): Retu (current: Model): Step => keyedStep( current.errors, - requestKey(context.orgId, "observerHome.topErrors", window), + requestKey(context.orgId, "analystHome.topErrors", window), mode, (errors) => ({ errors }), (key) => FetchTopErrors({ key, window }), @@ -84,7 +84,7 @@ const sync = (model: Model, context: PageContext, mode: Keyed.RequestMode): Retu return runSteps(model, steps) } -/** Starts the reads once: `init` for a known observer, else the page's Mount. */ +/** 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") diff --git a/apps/web/src/page/overview/observer/view.ts b/apps/web/src/page/overview/analyst/view.ts similarity index 98% rename from apps/web/src/page/overview/observer/view.ts rename to apps/web/src/page/overview/analyst/view.ts index e730a8750e..3e3358a57a 100644 --- a/apps/web/src/page/overview/observer/view.ts +++ b/apps/web/src/page/overview/analyst/view.ts @@ -35,7 +35,7 @@ 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 { MountObserverHome } from "./command" +import { MountAnalystHome } from "./command" import { incidentsOf, SECTION_ROWS, @@ -50,7 +50,7 @@ import type { Model, TopError } from "./model" type H = HtmlBuilder -/** What an observer cannot do themselves, said once per empty section. */ +/** 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 @@ -351,7 +351,7 @@ const weeklySummary = (h: H): Html => export const view = Submodel.defineView((model, _inputs, h) => h.div( - [h.Class("contents"), h.OnMount(MountObserverHome())], + [h.Class("contents"), h.OnMount(MountAnalystHome())], [ dashboardPage( h, diff --git a/apps/web/src/page/overview/index.ts b/apps/web/src/page/overview/index.ts index 3242ca48a6..9f2afdb281 100644 --- a/apps/web/src/page/overview/index.ts +++ b/apps/web/src/page/overview/index.ts @@ -18,7 +18,7 @@ export const page = definePage( contextChanged, view, subscriptions, - // An observer's `

` is the status line, so the crumb is what names the page. - breadcrumbs: (model) => [{ label: "Overview", isPageHeading: model.observer === null }], + // 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 1262cf888e..3474631ecd 100644 --- a/apps/web/src/page/overview/message.ts +++ b/apps/web/src/page/overview/message.ts @@ -8,7 +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 ObserverMessage } from "./observer/message" +import { Message as AnalystMessage } from "./analyst/message" import { HealthMessages } from "../kit/service-health" import { FacetsHint, HealthSnapshot, OverviewFacets, OverviewPoint, RefinementPoint, Usage } from "./model" @@ -48,6 +48,6 @@ export const Message = defineMessageUnion({ GotTimePickerMessage: { message: TimeRangePicker.Message }, GotChartMessage: { chartId: Schema.String, message: Chart.Message }, GotSetupMessage: { message: SetupChecklist.Message }, - GotObserverMessage: { message: ObserverMessage }, + 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 e47bece5bb..ad34b99bfb 100644 --- a/apps/web/src/page/overview/model.ts +++ b/apps/web/src/page/overview/model.ts @@ -12,7 +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 Observer from "./observer/model" +import * as Analyst from "./analyst/model" import { IncidentsData } from "../kit/service-health" /** The facets hint, persisted per org. */ @@ -104,7 +104,7 @@ export const Model = Schema.Struct({ isReloading: Schema.Boolean, /** `SetupChecklist`, Clerk mode only (`null` self-hosted). */ setup: Schema.NullOr(SetupChecklist.Model), - /** The observer's Overview, once the viewer is known to be one; `null` for an operator. */ - observer: Schema.NullOr(Observer.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 f08dc4da17..99bef866ec 100644 --- a/apps/web/src/page/overview/subscription.ts +++ b/apps/web/src/page/overview/subscription.ts @@ -11,7 +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 observerSubscriptions } from "./observer/subscription" +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 @@ -49,17 +49,17 @@ const WhileDrawn = { orgId: Schema.String, isDrawn: Schema.Boolean } const byOrg = (stream: (orgId: string) => Stream.Stream) => ({ modelToDependencies: ({ model, context }: PageSubscriptionInput) => ({ orgId: context.orgId, - isDrawn: model.observer === null, + isDrawn: model.analyst === null, }), dependenciesToStream: ({ orgId, isDrawn }: { readonly orgId: string; readonly isDrawn: boolean }) => isDrawn ? stream(orgId) : Stream.empty, }) -/** The observer page's live alert rows, only while there is an observer page. */ -const observer = Subscription.lift(observerSubscriptions), Message>({ +/** The analyst page's live alert rows, only while there is an analyst page. */ +const analyst = Subscription.lift(analystSubscriptions), Message>({ read: ({ model, context }) => - model.observer === null ? Option.none() : Option.some({ model: model.observer, context }), - toParentMessage: (message) => Message.GotObserverMessage({ message }), + model.analyst === null ? Option.none() : Option.some({ model: model.analyst, context }), + toParentMessage: (message) => Message.GotAnalystMessage({ message }), }) const operator = Subscription.make, Message, Resources>()((entry) => ({ @@ -70,7 +70,7 @@ const operator = Subscription.make, Message, Resour { modelToDependencies: ({ model, context }) => ({ orgId: context.orgId, - isWaiting: model.observer === null && AsyncData.isLoading(model.incidents), + isWaiting: model.analyst === null && AsyncData.isLoading(model.incidents), }), dependenciesToStream: ({ orgId, isWaiting }) => incidentsLoadFailedStream(orgId, isWaiting, () => Message.FailedLoadIncidents({ orgId })), @@ -82,7 +82,7 @@ const operator = Subscription.make, Message, Resour { modelToDependencies: ({ model, context }) => ({ orgId: context.orgId, - isWaiting: model.observer === null && AsyncData.isLoading(model.rules), + isWaiting: model.analyst === null && AsyncData.isLoading(model.rules), }), dependenciesToStream: ({ orgId, isWaiting }) => isWaiting @@ -113,4 +113,4 @@ const operator = Subscription.make, Message, Resour ), })) -export const subscriptions = Subscription.aggregate(operator, observer) +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 acb5a53acc..a55252d70a 100644 --- a/apps/web/src/page/overview/update.ts +++ b/apps/web/src/page/overview/update.ts @@ -53,9 +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 ObserverMessage } from "./observer/message" -import type * as ObserverModel from "./observer/model" -import * as Observer from "./observer/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 @@ -270,14 +270,14 @@ const withSetup = ( return next.outMessage === undefined ? lifted : { ...lifted, outMessage: next.outMessage } } -const toObserver = (message: ObserverMessage) => Message.GotObserverMessage({ message }) +const toAnalyst = (message: AnalystMessage) => Message.GotAnalystMessage({ message }) -/** Runs an observer step; the Submodel starts empty when the role arrived after `init`. */ -const withObserver = (model: Model, step: (observer: ObserverModel.Model) => Observer.Return): Return => { - const next = step(model.observer ?? Observer.EMPTY) +/** 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, observer: next.model }, - commands: Command.mapMessages(next.commands ?? [], toObserver), + model: { ...model, analyst: next.model }, + commands: Command.mapMessages(next.commands ?? [], toAnalyst), } } @@ -306,10 +306,10 @@ const loadChartRuntime = () => LoadChartRuntime() export const initWith = (isClerk: boolean) => (route: { readonly search: Search }, context: PageContext): Return => { - // An observer gets their own page: the engineers' reads and setup card never start. - const observer = context.audience === "observer" ? Observer.init(context) : 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 && observer === null ? Setup.init(storageKeyOf(Option.some(context.orgId))) : null + isClerk && analyst === null ? Setup.init(storageKeyOf(Option.some(context.orgId))) : null const model: Model = { search: route.search, nowMs: null, @@ -337,10 +337,10 @@ export const initWith = refreshVersion: 0, isReloading: false, setup: setup?.model ?? null, - observer: observer?.model ?? null, + analyst: analyst?.model ?? null, } - if (observer !== null) - return { model, commands: Command.mapMessages(observer.commands ?? [], toObserver) } + if (analyst !== null) + return { model, commands: Command.mapMessages(analyst.commands ?? [], toAnalyst) } return { model, commands: [ @@ -370,9 +370,9 @@ 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 === "observer") - return withObserver({ ...model, search: route.search }, (observer) => - Observer.revive(observer, context), + 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( @@ -598,6 +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)), - GotObserverMessage: ({ message: inner }) => - withObserver(model, (observer) => Observer.update(observer, 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 2bcad3a129..2a0bd2ef31 100644 --- a/apps/web/src/page/overview/view.ts +++ b/apps/web/src/page/overview/view.ts @@ -33,8 +33,8 @@ import { usageCards } from "./view-usage" import { Message as SetupMessage } from "./setup-checklist" import * as SetupChecklistView from "./setup-checklist-view" -import { EMPTY as OBSERVER_EMPTY } from "./observer/update" -import * as ObserverView from "./observer/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 = {} @@ -118,13 +118,13 @@ 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 === "observer") + if (inputs.context.audience === "analyst") return h.submodel({ - slotId: "observer-home", - model: model.observer ?? OBSERVER_EMPTY, - view: ObserverView.view, + slotId: "analyst-home", + model: model.analyst ?? ANALYST_EMPTY, + view: AnalystView.view, viewInputs: inputs, - toParentMessage: (message) => Message.GotObserverMessage({ message }), + toParentMessage: (message) => Message.GotAnalystMessage({ message }), }) const slice: SliceSearch = { startTime: model.window?.startTime, diff --git a/apps/web/src/page/settings/kit/org-membership.test.ts b/apps/web/src/page/settings/kit/org-membership.test.ts index 25784a1999..3fd4bff249 100644 --- a/apps/web/src/page/settings/kit/org-membership.test.ts +++ b/apps/web/src/page/settings/kit/org-membership.test.ts @@ -22,21 +22,21 @@ describe("leaveEligibility", () => { }) describe("toOrgRole", () => { - it("reads unknown Clerk roles as observer, the least a person can hold", () => { + it("reads unknown Clerk roles as analyst, the least a person can hold", () => { expect(toOrgRole("org:admin")).toBe("org:admin") - expect(toOrgRole("org:billing")).toBe("org:observer") - expect(orgRoleLabel("org:viewer")).toBe("Observer") + expect(toOrgRole("org:billing")).toBe("org:analyst") + expect(orgRoleLabel("org:viewer")).toBe("Analyst") }) it("shows root, which has no entry of its own, as a member", () => { expect(orgRoleLabel("root")).toBe("Member") }) - it("names the observer role in a role change prompt", () => { - expect(roleChangePrompt("Ada", "org:observer")).toEqual({ + it("names the analyst role in a role change prompt", () => { + expect(roleChangePrompt("Ada", "org:analyst")).toEqual({ question: - "Make Ada an observer? Follows incidents, reliability, product analytics and cost, without changing anything.", - confirmLabel: "Make observer", + "Make Ada an analyst? Follows incidents, reliability, product analytics and cost, without changing anything.", + confirmLabel: "Make analyst", }) }) }) diff --git a/apps/web/src/page/settings/kit/org-membership.ts b/apps/web/src/page/settings/kit/org-membership.ts index 4b421a693f..6443e5b73d 100644 --- a/apps/web/src/page/settings/kit/org-membership.ts +++ b/apps/web/src/page/settings/kit/org-membership.ts @@ -10,7 +10,7 @@ export const orgRoleKeys: ReadonlyArray = Arr.map(Policy.assignableRole // `root`, which Clerk never assigns, has no entry of its own and shows as a member. const memberEntry = Policy.assignableRoles[1] -/** A Clerk role string's entry, read through `Policy.roleOf` (unknown roles are observers). */ +/** A Clerk role string's entry, read through `Policy.roleOf` (unknown roles are analysts). */ const entryOf = (role: string): Policy.AssignableRole => { const policyRole = Policy.roleOf(role) return Option.getOrElse( diff --git a/apps/web/src/page/status-pages/editor/observer.scene.test.ts b/apps/web/src/page/status-pages/editor/analyst.scene.test.ts similarity index 89% rename from apps/web/src/page/status-pages/editor/observer.scene.test.ts rename to apps/web/src/page/status-pages/editor/analyst.scene.test.ts index 81fb267b71..2101d07174 100644 --- a/apps/web/src/page/status-pages/editor/observer.scene.test.ts +++ b/apps/web/src/page/status-pages/editor/analyst.scene.test.ts @@ -13,7 +13,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads a status page and its incidents; posting, updating, editing and deleting an +// An analyst reads a status page and its incidents; posting, updating, editing and deleting an // incident or maintenance need `status_page_incidents:post`. const context: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } @@ -21,7 +21,7 @@ const pageUpdate = (model: Model, message: Message) => update(model, message, co const view = withViewInputs(pageView, { context })() const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) /** The editor's Page tab over `incidents`, read by a session holding `grants`. */ const loaded = ( @@ -61,11 +61,11 @@ const MAINTENANCE = incident(2, { kind: "maintenance", title: "Database upgrade" const opened = (grants: ReadonlyArray, row: ReturnType, n: number) => pageUpdate(loaded(grants, [row]), Message.ClickedIncident({ incidentId: incidentId(n) })).model -describe("status page editor, observer", () => { - test("Post incident is offered to a member and not to an observer", () => { +describe("status page editor, analyst", () => { + test("Post incident is offered to a member and not to an analyst", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, [])), + given(loaded(ANALYST, [])), expectView(text("Nothing posted.")).toExist(), expectView(role("button", { name: "Post incident" })).toBeAbsent(), ) @@ -77,10 +77,10 @@ describe("status page editor, observer", () => { ) }) - test("an observer reads an open incident's updates and gets none of its actions", () => { + test("an analyst reads an open incident's updates and gets none of its actions", () => { scene( { update: pageUpdate, view }, - given(opened(OBSERVER, DRAFT, 1)), + given(opened(ANALYST, DRAFT, 1)), expectView(text("Draft, not public")).toExist(), expectView(role("region", { name: "Updates" })).toExist(), expectView(role("button", { name: "Publish" })).toBeAbsent(), @@ -103,10 +103,10 @@ describe("status page editor, observer", () => { ) }) - test("a maintenance notice is the same: a member completes or deletes it, an observer reads it", () => { + test("a maintenance notice is the same: a member completes or deletes it, an analyst reads it", () => { scene( { update: pageUpdate, view }, - given(opened(OBSERVER, MAINTENANCE, 2)), + given(opened(ANALYST, MAINTENANCE, 2)), expectView(role("button", { name: "Complete" })).toBeAbsent(), expectView(role("button", { name: "Delete maintenance" })).toBeAbsent(), ) diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index b205ea7592..f791a14b85 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -134,10 +134,10 @@ export type ShellClerk = Readonly<{ maybeRole: Option.Option }> -/** Who the shell is drawn for; self-hosted has no Clerk role, so it is never an observer. */ +/** Who the shell is drawn for; self-hosted has no Clerk role, so it is never an analyst. */ export const shellAudience = (clerk: ShellClerk | null): NavAudience => clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.isReadOnly([role])) - ? "observer" + ? "analyst" : "operator" /** A rail item's tooltip: right of the item, centred. */ diff --git a/apps/web/src/shell/nav.test.ts b/apps/web/src/shell/nav.test.ts index e9bc82a8c5..8ecc8cb7af 100644 --- a/apps/web/src/shell/nav.test.ts +++ b/apps/web/src/shell/nav.test.ts @@ -62,12 +62,12 @@ describe("isNavItemActive", () => { }) }) -describe("navGroups for an observer", () => { +describe("navGroups for an analyst", () => { const off = { cloudCost: false, releases: false, securityAudit: false, statusPages: false } - /** Every page the observer's sidebar links to: rows, then their sub-rows. */ + /** Every page the analyst's sidebar links to: rows, then their sub-rows. */ const hrefs = (flags: NavFlags) => Arr.dedupe( - Arr.flatMap(navGroups(flags, "observer"), (group) => + Arr.flatMap(navGroups(flags, "analyst"), (group) => Arr.flatMap(group.items, (item) => [ item.href, ...Arr.map(item.subItems ?? [], (sub) => sub.href), @@ -98,7 +98,7 @@ describe("navGroups for an observer", () => { }) test("marks only Incidents on an uptime page, with its Uptime row", () => { - const items = Arr.flatMap(navGroups(off, "observer"), (group) => group.items) + const items = Arr.flatMap(navGroups(off, "analyst"), (group) => group.items) const active = Arr.filter(items, (item) => isNavItemActive("/alerts/uptime", item)) expect(Arr.map(active, (item) => item.title)).toEqual(["Incidents"]) }) diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 4e96819ddf..cf85977aa7 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -182,8 +182,8 @@ const alertsItem: NavItem = { ], } -/** The observer's Alerts: what is wrong now and how reliable things are, without the setup pages. */ -const observerIncidentsItem: NavItem = { +/** The analyst's Alerts: what is wrong now and how reliable things are, without the setup pages. */ +const analystIncidentsItem: NavItem = { title: "Incidents", href: "/alerts", icon: "BellIcon", @@ -203,10 +203,10 @@ const observerIncidentsItem: NavItem = { } /** - * Who the sidebar is drawn for. An observer follows the product without operating it, so they get + * Who the sidebar is drawn for. An analyst follows the product without operating it, so they get * the pages that answer "is it working and how is it doing", not the ones for digging or setup. */ -export const NavAudience = Schema.Literals(["operator", "observer"]) +export const NavAudience = Schema.Literals(["operator", "analyst"]) export type NavAudience = typeof NavAudience.Type /** The org rollout flags that gate a nav row each. */ @@ -217,13 +217,13 @@ export type NavFlags = Readonly<{ statusPages: boolean }> -const observerNavGroups = (flags: NavFlags): ReadonlyArray => [ +const analystNavGroups = (flags: NavFlags): ReadonlyArray => [ { id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] }, { id: "reliability", label: "Reliability", items: [ - observerIncidentsItem, + analystIncidentsItem, { title: "Errors", href: "/errors", icon: "CircleWarningIcon" }, ...(flags.statusPages ? [{ title: "Status pages", href: "/status-pages", icon: "WindowChartLineIcon" } as const] @@ -304,10 +304,10 @@ const operatorNavGroups = (flags: NavFlags): ReadonlyArray => [ /** * `navGroups(flags, audience)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a - * row each, and an observer gets {@link observerNavGroups}. + * row each, and an analyst gets {@link analystNavGroups}. */ export const navGroups = (flags: NavFlags, audience: NavAudience = "operator"): ReadonlyArray => - audience === "observer" ? observerNavGroups(flags) : operatorNavGroups(flags) + audience === "analyst" ? analystNavGroups(flags) : operatorNavGroups(flags) /** Segment-aware prefix match: `/services` must not claim `/service-map`. */ export const isPathActive = (currentPath: string, href: string): boolean => diff --git a/apps/web/src/shell/palette/entries.ts b/apps/web/src/shell/palette/entries.ts index 0f074f4f3b..1ba68a5cf5 100644 --- a/apps/web/src/shell/palette/entries.ts +++ b/apps/web/src/shell/palette/entries.ts @@ -88,14 +88,14 @@ const navRowsOf = (groups: ReadonlyArray) => ]), ) -/** What an observer's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */ -const OBSERVER_TITLES: ReadonlyMap = new Map([ - ...Arr.map(navRowsOf(navGroups(EVERY_FLAG, "observer")), (row) => [row.href, row.title] as const), +/** What an analyst's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */ +const ANALYST_TITLES: ReadonlyMap = new Map([ + ...Arr.map(navRowsOf(navGroups(EVERY_FLAG, "analyst")), (row) => [row.href, row.title] as const), ["/account", "Account"] as const, ]) /** - * The entries as the viewer's palette lists them. An observer gets their own pages under their + * The entries as the viewer's palette lists them. An analyst gets their own pages under their * sidebar's names, dashboards and the app actions; the ids stay, so a pick resolves either way. */ export const entriesFor = ( @@ -107,7 +107,7 @@ export const entriesFor = ( : Arr.flatMap(entries, (entry): ReadonlyArray => { if (entry.group === "Actions" || entry.group === "Dashboards") return [entry] if (entry.group !== "Navigation" || entry.target._tag !== "Href") return [] - const title = OBSERVER_TITLES.get(entry.target.href) + const title = ANALYST_TITLES.get(entry.target.href) return title === undefined ? [] : [{ ...entry, title, keywords: `${entry.keywords} ${title}` }] diff --git a/apps/web/src/shell/palette/palette.test.ts b/apps/web/src/shell/palette/palette.test.ts index afa5a94390..2830cc59ed 100644 --- a/apps/web/src/shell/palette/palette.test.ts +++ b/apps/web/src/shell/palette/palette.test.ts @@ -151,11 +151,11 @@ describe("command palette", () => { expect(titles).toEqual(expect.arrayContaining(["Connect Slack", "Connect Discord"])) }) - test("an observer finds their own pages and dashboards, not setup or the explorers", () => { + test("an analyst finds their own pages and dashboards, not setup or the explorers", () => { const model = loadedWith(["storefront"]) const shown = (query: string) => Arr.map( - shownEntries(typed(model, query).model, context, ["/releases", "/cost"], "observer").entries, + shownEntries(typed(model, query).model, context, ["/releases", "/cost"], "analyst").entries, (entry) => entry.title, ) expect(shown("analytics")).toContain("Web analytics") @@ -175,11 +175,11 @@ describe("command palette", () => { expect(operator).toContain("Traces") }) - test("Enter opens the page an observer sees highlighted, not the full list's", () => { + test("Enter opens the page an analyst sees highlighted, not the full list's", () => { const model = typed(loadedWith([]), "uptime").model const result = update( model, - Message.PressedEnter({ visibleFlagged: [], audience: "observer" }), + Message.PressedEnter({ visibleFlagged: [], audience: "analyst" }), context, ) expect(result.outMessage).toEqual(OutMessage.Navigated({ href: "/alerts/uptime" })) diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 6e4392524f..1ec2c911d0 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -405,8 +405,8 @@ export const shellSidebarFooter = ( sidebarMenu(h, undefined, [ // A row of the footer's list, so it keeps the list's 4px rhythm. ...onCallPill(h, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), - // An observer sets nothing up: their account is in the user menu below. - ...(shellAudience(clerk) === "observer" + // An analyst sets nothing up: their account is in the user menu below. + ...(shellAudience(clerk) === "analyst" ? [] : [ footerNavRow( diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index c6fac636cd..db0784c776 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -68,7 +68,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), ...(isPillShown(onCall) ? [onCallDetail(onCall)] : []), - ...(shellAudience(clerk) === "observer" ? [] : ["Sources", "Settings"]), + ...(shellAudience(clerk) === "analyst" ? [] : ["Sources", "Settings"]), ] } @@ -196,7 +196,7 @@ export const frameView = Submodel.defineView((model inputs.topbarActions(), railTriggers, !hidesTopbarConnect(inputs.currentPath) && - shellAudience(inputs.clerk ?? null) !== "observer", + shellAudience(inputs.clerk ?? null) !== "analyst", inputs.sourceLink ?? null, ), ...(inputs.clerk === undefined || inputs.clerk === null diff --git a/apps/web/src/update.audience.test.ts b/apps/web/src/update.audience.test.ts index a907766ed1..6e9ad84aa0 100644 --- a/apps/web/src/update.audience.test.ts +++ b/apps/web/src/update.audience.test.ts @@ -21,8 +21,8 @@ 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 { MountObserverHome } from "./page/overview/observer/command" -import { Message as ObserverMessage } from "./page/overview/observer/message" +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" @@ -67,10 +67,10 @@ const signedIn = (role: string | null): Model => ({ const audienceOf = (model: Model) => Option.getOrThrow(pageContextOf(model)).audience describe("page context audience", () => { - test("an observer's pages are told so, as their shell is", () => { - const model = signedIn("org:observer") - expect(audienceOf(model)).toBe("observer") - expect(Shell.shellAudience(shellClerkOf(model))).toBe("observer") + 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", () => { @@ -92,12 +92,12 @@ describe("the Overview the root draws", () => { Mount.resolve(MeasureSidebarOverflow, ShellMessage.MeasuredSidebarOverflow()), ] - test("an observer gets their own page, not the engineers'", () => { + test("an analyst gets their own page, not the engineers'", () => { scene( { update, view: body }, - entered("org:observer"), + entered("org:analyst"), ...shellMounted, - Mount.resolve(MountObserverHome, ObserverMessage.Entered()), + Mount.resolve(MountAnalystHome, AnalystMessage.Entered()), expectView(text("Open incidents")).toExist(), expectView(text("Top errors")).toExist(), ) diff --git a/packages/backend/src/services/auth/ApiAuthorizationLive.ts b/packages/backend/src/services/auth/ApiAuthorizationLive.ts index 95ba60f791..11c7b604a7 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationLive.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationLive.ts @@ -11,7 +11,7 @@ import { withAuditedRead } from "@maple/backend/services/audit/audit-access" import { Env } from "@maple/backend/platform/Env" import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" -import { refuseObserverWrites } from "./observer-gate" +import { refuseAnalystWrites } from "./analyst-gate" export const ApiAuthorizationLive = Layer.effect( CurrentTenant.Authorization, @@ -75,7 +75,7 @@ export const ApiAuthorizationLive = Layer.effect( roles: resolved.roles ?? API_KEY_DEFAULT_ROLES, authMode: "self_hosted", }) - yield* refuseObserverWrites(tenant.roles, options).pipe( + yield* refuseAnalystWrites(tenant.roles, options).pipe( Effect.tapError((error) => recordDenied(error.message)), ) return yield* httpEffect.pipe( @@ -103,7 +103,7 @@ export const ApiAuthorizationLive = Layer.effect( yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) // API keys are minted per instance; only a session can name an org from elsewhere. yield* regions.ensureServedHere(tenant.orgId) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), diff --git a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts index 6bd974d9d3..aa2c1e8b79 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts @@ -33,7 +33,7 @@ import { deviceRateLimitKey, } from "./ApiV2RateLimiter" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" -import { refuseObserverWritesV2 } from "./observer-gate" +import { refuseAnalystWritesV2 } from "./analyst-gate" /** The access token of an RFC 9449 `Authorization: DPoP ` request. */ const getDpopToken = (headers: Record): string | undefined => { @@ -181,7 +181,7 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", scopes: resolved.scopes, }) - yield* refuseObserverWritesV2(tenant.roles, options, route.path) + yield* refuseAnalystWritesV2(tenant.roles, options, route.path) const label = "Device credential" return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, tenant), @@ -302,7 +302,7 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", ...(resolved.scopes !== null ? { scopes: resolved.scopes } : undefined), }) - yield* refuseObserverWritesV2(tenant.roles, options, route.path).pipe( + yield* refuseAnalystWritesV2(tenant.roles, options, route.path).pipe( Effect.tapError((error) => recordDenied(error.message)), ) return yield* httpEffect.pipe( @@ -347,7 +347,7 @@ export const ApiAuthorizationV2Live = Layer.effect( .pipe(Effect.mapError((error) => V2OrganizationAccessDenied.make(error.message))) } const { route } = yield* HttpRouter.RouteContext - yield* refuseObserverWritesV2(tenant.roles, options, route.path) + yield* refuseAnalystWritesV2(tenant.roles, options, route.path) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), diff --git a/packages/backend/src/services/auth/SessionAuthorizationLive.ts b/packages/backend/src/services/auth/SessionAuthorizationLive.ts index 7760b8ab5a..3aa7548927 100644 --- a/packages/backend/src/services/auth/SessionAuthorizationLive.ts +++ b/packages/backend/src/services/auth/SessionAuthorizationLive.ts @@ -10,7 +10,7 @@ import { withAuditedRead } from "@maple/backend/services/audit/audit-access" import { Env } from "@maple/backend/platform/Env" import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { getBearerToken } from "./bearer-token" -import { refuseObserverWrites } from "./observer-gate" +import { refuseAnalystWrites } from "./analyst-gate" /** * Authorization for the internal API: Clerk sessions only. @@ -48,7 +48,7 @@ export const SessionAuthorizationLive = Layer.effect( const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) yield* regions.ensureServedHere(tenant.orgId) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), @@ -87,7 +87,7 @@ export const RegionlessSessionAuthorizationLive = Layer.effect( } const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), diff --git a/packages/backend/src/services/auth/observer-gate.test.ts b/packages/backend/src/services/auth/analyst-gate.test.ts similarity index 52% rename from packages/backend/src/services/auth/observer-gate.test.ts rename to packages/backend/src/services/auth/analyst-gate.test.ts index b59b4a992c..abc15270e9 100644 --- a/packages/backend/src/services/auth/observer-gate.test.ts +++ b/packages/backend/src/services/auth/analyst-gate.test.ts @@ -2,9 +2,9 @@ import { assert, describe, expect, it } from "@effect/vitest" import { CurrentTenant, MapleInternalApi } from "@maple/domain/http" import { MapleApiV2 } from "@maple/domain/http/v2" import { Effect, Exit } from "effect" -import { refuseObserverWrites, refuseObserverWritesV2 } from "./observer-gate" +import { refuseAnalystWrites, refuseAnalystWritesV2 } from "./analyst-gate" -const OBSERVER = ["org:observer"] +const ANALYST = ["org:analyst"] const MEMBER = ["org:member"] const errors = MapleInternalApi.groups.errors @@ -16,10 +16,10 @@ const dashboards = MapleApiV2.groups.dashboards /** Whether the gate let the call through. */ const allowed = (gate: Effect.Effect) => Effect.map(Effect.exit(gate), Exit.isSuccess) -describe("refuseObserverWrites", () => { - it.effect("refuses an observer an issue transition and says how to get it", () => +describe("refuseAnalystWrites", () => { + it.effect("refuses an analyst an issue transition and says how to get it", () => Effect.gen(function* () { - const error = yield* Effect.flip(refuseObserverWrites(OBSERVER, transition)) + const error = yield* Effect.flip(refuseAnalystWrites(ANALYST, transition)) assert.instanceOf(error, CurrentTenant.ReadOnlyRoleError) expect(error.message).toContain("Ask an org admin for the Member role") }), @@ -27,34 +27,34 @@ describe("refuseObserverWrites", () => { it.effect("lets a member, and Maple's own agents with no roles, make the same change", () => Effect.gen(function* () { - expect(yield* allowed(refuseObserverWrites(MEMBER, transition))).toBe(true) - expect(yield* allowed(refuseObserverWrites([], transition))).toBe(true) + expect(yield* allowed(refuseAnalystWrites(MEMBER, transition))).toBe(true) + expect(yield* allowed(refuseAnalystWrites([], transition))).toBe(true) }), ) - it.effect("lets an observer run a dashboard query", () => + it.effect("lets an analyst run a dashboard query", () => Effect.gen(function* () { const query = { group: queries, endpoint: queries.endpoints.executeBatch } - expect(yield* allowed(refuseObserverWrites(OBSERVER, query))).toBe(true) + expect(yield* allowed(refuseAnalystWrites(ANALYST, query))).toBe(true) }), ) }) -describe("refuseObserverWritesV2", () => { +describe("refuseAnalystWritesV2", () => { const create = { group: dashboards, endpoint: dashboards.endpoints.create } - it.effect("lets an observer search traces, which the scope registry names as a read", () => + it.effect("lets an analyst search traces, which the scope registry names as a read", () => Effect.gen(function* () { const search = { group: traces, endpoint: traces.endpoints.search } - expect(yield* allowed(refuseObserverWritesV2(OBSERVER, search, "/v2/traces/search"))).toBe(true) + expect(yield* allowed(refuseAnalystWritesV2(ANALYST, search, "/v2/traces/search"))).toBe(true) }), ) - it.effect("refuses an observer a new dashboard and lets a member create one", () => + it.effect("refuses an analyst a new dashboard and lets a member create one", () => Effect.gen(function* () { - const error = yield* Effect.flip(refuseObserverWritesV2(OBSERVER, create, "/v2/dashboards")) - expect(error.message).toContain("Observers can read Maple") - expect(yield* allowed(refuseObserverWritesV2(MEMBER, create, "/v2/dashboards"))).toBe(true) + const error = yield* Effect.flip(refuseAnalystWritesV2(ANALYST, create, "/v2/dashboards")) + expect(error.message).toContain("Analysts can read Maple") + expect(yield* allowed(refuseAnalystWritesV2(MEMBER, create, "/v2/dashboards"))).toBe(true) }), ) }) diff --git a/packages/backend/src/services/auth/observer-gate.ts b/packages/backend/src/services/auth/analyst-gate.ts similarity index 71% rename from packages/backend/src/services/auth/observer-gate.ts rename to packages/backend/src/services/auth/analyst-gate.ts index d3bc7b0025..a3afc86ef6 100644 --- a/packages/backend/src/services/auth/observer-gate.ts +++ b/packages/backend/src/services/auth/analyst-gate.ts @@ -1,34 +1,34 @@ -import { CurrentTenant, isOpenToObservers } from "@maple/domain/http" +import { CurrentTenant, isOpenToAnalysts } from "@maple/domain/http" import { requiredScopeForRoute, V2InsufficientPermissions } from "@maple/domain/http/v2" import * as Policy from "@maple/domain/policy" import { Effect } from "effect" -type Called = Parameters[0] +type Called = Parameters[0] /** * Refuses a read-only role every endpoint that changes the organization. It runs in the auth * layers, so an endpoint added later is refused until it, or its whole group, is marked - * `ObserverAccess`. + * `AnalystAccess`. */ -export const refuseObserverWrites = ( +export const refuseAnalystWrites = ( roles: ReadonlyArray, called: Called, ): Effect.Effect => - Policy.isReadOnly(roles) && !isOpenToObservers(called) + Policy.isReadOnly(roles) && !isOpenToAnalysts(called) ? Effect.fail(new CurrentTenant.ReadOnlyRoleError({ message: Policy.readOnlyMessage })) : Effect.void /** - * {@link refuseObserverWrites} for `/v2`, where the scope registry already names the POSTs that + * {@link refuseAnalystWrites} for `/v2`, where the scope registry already names the POSTs that * only read. `routePath` is the matched route template, as that registry expects. */ -export const refuseObserverWritesV2 = ( +export const refuseAnalystWritesV2 = ( roles: ReadonlyArray, called: Called, routePath: string, ): Effect.Effect> => Policy.isReadOnly(roles) && - !isOpenToObservers(called) && + !isOpenToAnalysts(called) && requiredScopeForRoute(called.endpoint.method, routePath)?.access !== "read" ? Effect.fail(V2InsufficientPermissions.make(Policy.readOnlyMessage)) : Effect.void diff --git a/packages/domain/src/http/ai-models.ts b/packages/domain/src/http/ai-models.ts index d15ad4d15e..555f03a866 100644 --- a/packages/domain/src/http/ai-models.ts +++ b/packages/domain/src/http/ai-models.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { SessionAuthorization } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // AI model detection: a model string as an instrumentation reported it // (`gen_ai.request.model`, an OpenRouter id, a Bedrock id) → the model's @@ -75,5 +75,5 @@ export class AiModelsInternalApiGroup extends HttpApiGroup.make("aiModelsInterna ) .prefix("/internal/ai-models") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index c20c2ed8da..92357cff53 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -6,7 +6,7 @@ import { BucketSeconds } from "./query-engine" import { SessionAuthorization } from "./current-tenant" import { HttpTaggedError } from "./error-policy" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Backed by the `maple_ai.*` span attributes the ingest gateway stamps at // decode time; a session is resolved at trace granularity by @@ -1101,5 +1101,5 @@ export class AiSessionsInternalApiGroup extends HttpApiGroup.make("aiSessionsInt ) .prefix("/internal/ai-sessions") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/observer-access.test.ts b/packages/domain/src/http/analyst-access.test.ts similarity index 75% rename from packages/domain/src/http/observer-access.test.ts rename to packages/domain/src/http/analyst-access.test.ts index fc4a9907c2..cb31658008 100644 --- a/packages/domain/src/http/observer-access.test.ts +++ b/packages/domain/src/http/analyst-access.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "@effect/vitest" import { Array as Arr } from "effect" import { MapleApi } from "./api" import { MapleInternalApi } from "./internal-api" -import { isOpenToObservers } from "./observer-access" +import { isOpenToAnalysts } from "./analyst-access" import { MapleApiV2 } from "./v2/api" -type Called = Parameters[0] +type Called = Parameters[0] type Group = Called["group"] & { readonly endpoints: { readonly [name: string]: Called["endpoint"] & { readonly identifier: string } } } @@ -16,18 +16,18 @@ const endpointsOf = (group: Group) => Object.values(group.endpoints) const open = (group: Group, name: string): boolean => Arr.some( endpointsOf(group), - (endpoint) => endpoint.identifier === name && isOpenToObservers({ group, endpoint }), + (endpoint) => endpoint.identifier === name && isOpenToAnalysts({ group, endpoint }), ) const internal = MapleInternalApi.groups const v2 = MapleApiV2.groups -describe("isOpenToObservers", () => { - it("lets an observer run every dashboard query, which are all POSTs", () => { +describe("isOpenToAnalysts", () => { + it("lets an analyst run every dashboard query, which are all POSTs", () => { const queries = endpointsOf(internal.queryEngine) expect(queries.length).toBeGreaterThan(0) expect( - Arr.every(queries, (endpoint) => isOpenToObservers({ group: internal.queryEngine, endpoint })), + Arr.every(queries, (endpoint) => isOpenToAnalysts({ group: internal.queryEngine, endpoint })), ).toBe(true) }) @@ -41,7 +41,7 @@ describe("isOpenToObservers", () => { it("keeps every GET of a group that also changes things", () => { const reads = Arr.filter(endpointsOf(internal.errors), (endpoint) => endpoint.method === "GET") expect(reads.length).toBeGreaterThan(0) - expect(Arr.every(reads, (endpoint) => isOpenToObservers({ group: internal.errors, endpoint }))).toBe( + expect(Arr.every(reads, (endpoint) => isOpenToAnalysts({ group: internal.errors, endpoint }))).toBe( true, ) }) @@ -51,7 +51,7 @@ describe("isOpenToObservers", () => { expect(open(internal.digest, "preview")).toBe(true) }) - it("lets an observer manage their own digest, devices and connections", () => { + it("lets an analyst manage their own digest, devices and connections", () => { expect(open(internal.digest, "upsertSubscription")).toBe(true) expect(open(internal.digest, "deleteSubscription")).toBe(true) expect(open(v2.mobileDevices, "register")).toBe(true) diff --git a/packages/domain/src/http/observer-access.ts b/packages/domain/src/http/analyst-access.ts similarity index 57% rename from packages/domain/src/http/observer-access.ts rename to packages/domain/src/http/analyst-access.ts index ebcf0bde89..8b7a4159bd 100644 --- a/packages/domain/src/http/observer-access.ts +++ b/packages/domain/src/http/analyst-access.ts @@ -1,12 +1,12 @@ import { Context } from "effect" /** - * Why an observer, who only reads, may still call an endpoint that is not a GET: `read` only looks + * Why an analyst, who only reads, may still call an endpoint that is not a GET: `read` only looks * (a query with a body), `personal` changes the caller's own state. Unset means the endpoint changes - * the organization, and the auth middlewares refuse it to an observer. + * the organization, and the auth middlewares refuse it to an analyst. */ -export class ObserverAccess extends Context.Reference<"read" | "personal" | undefined>( - "@maple/http/ObserverAccess", +export class AnalystAccess extends Context.Reference<"read" | "personal" | undefined>( + "@maple/http/AnalystAccess", { defaultValue: () => undefined }, ) {} @@ -14,8 +14,8 @@ interface Annotated { readonly annotations: Context.Context } -/** Whether a read-only role may call the endpoint: a GET, or one marked {@link ObserverAccess}. */ -export const isOpenToObservers = (options: { +/** Whether a read-only role may call the endpoint: a GET, or one marked {@link AnalystAccess}. */ +export const isOpenToAnalysts = (options: { readonly endpoint: Annotated & { readonly method: string } readonly group: Annotated }): boolean => { @@ -23,7 +23,7 @@ export const isOpenToObservers = (options: { if (method === "GET" || method === "HEAD") return true // A group-level `.annotate` lands on the group only, so both are read; the endpoint wins. const access = - Context.get(options.endpoint.annotations, ObserverAccess) ?? - Context.get(options.group.annotations, ObserverAccess) + Context.get(options.endpoint.annotations, AnalystAccess) ?? + Context.get(options.group.annotations, AnalystAccess) return access !== undefined } diff --git a/packages/domain/src/http/auth.ts b/packages/domain/src/http/auth.ts index 72e2981dee..f2c40962ce 100644 --- a/packages/domain/src/http/auth.ts +++ b/packages/domain/src/http/auth.ts @@ -2,7 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { OrgId, UserId } from "@maple/primitives" import { Authorization, SessionAuthorization, TenantSchema, UnauthorizedError } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export class CliDeviceStartRequest extends Schema.Class("CliDeviceStartRequest")({ deviceName: Schema.String, @@ -265,13 +265,13 @@ export class AuthApiGroup extends HttpApiGroup.make("auth") HttpApiEndpoint.post("sessionRefresh", "/session/refresh", { success: SelfHostedLoginResponse, error: [SelfHostedAuthDisabledError, UnauthorizedError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.delete("cliSessionRevoke", "/cli/session", { success: CliDeviceActionResponse, error: [CliDeviceConflictError, CliDevicePersistenceError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .prefix("/api/auth") .middleware(Authorization) {} @@ -298,7 +298,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("cliDeviceDeny", "/cli/device/:userCode/deny", { @@ -310,7 +310,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.get("mcpOAuthAuthorizationInspect", "/mcp/oauth/authorization/:requestId", { @@ -333,7 +333,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("mcpOAuthAuthorizationDeny", "/mcp/oauth/authorization/:requestId/deny", { @@ -345,7 +345,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .prefix("/api/auth") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/billing.ts b/packages/domain/src/http/billing.ts index 23b430de22..ae110490ae 100644 --- a/packages/domain/src/http/billing.ts +++ b/packages/domain/src/http/billing.ts @@ -15,7 +15,7 @@ import { LinkBillingOrgRequest, } from "./billing-account" import { WarehouseQueryError } from "./warehouse-errors" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Contract for raw Autumn proxy responses. Schemas model only consumed fields // and tolerate additive upstream fields. @@ -726,7 +726,7 @@ export class BillingApiGroup extends HttpApiGroup.make("billing") payload: PreviewAttachRequest, success: PreviewAttachResult, error: [BillingManagedByBillingOrgError, ...billingRequestErrors], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("openCustomerPortal", "/portal", { diff --git a/packages/domain/src/http/current-tenant.ts b/packages/domain/src/http/current-tenant.ts index 1c4706cb48..c76c0bdfb3 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -91,7 +91,7 @@ export class OrganizationWrongRegionError extends HttpTaggedError()( "@maple/http/errors/ReadOnlyRoleError", { message: Schema.String }, diff --git a/packages/domain/src/http/digest.ts b/packages/domain/src/http/digest.ts index ac8ec68c6e..c0e38e6bc2 100644 --- a/packages/domain/src/http/digest.ts +++ b/packages/domain/src/http/digest.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { DateTime, Option, Schema } from "effect" import { SessionAuthorization } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export const DigestSubscriptionId = Schema.String.check(Schema.isUUID()).pipe( Schema.brand("@maple/DigestSubscriptionId"), @@ -164,25 +164,25 @@ export class DigestApiGroup extends HttpApiGroup.make("digest") payload: UpsertDigestSubscriptionRequest, success: DigestSubscriptionResponse, error: [DigestPersistenceError, DigestNotFoundError, DigestEmailUnavailableError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.delete("deleteSubscription", "/", { success: Schema.Void, error: DigestPersistenceError, - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("preview", "/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("previewWebAnalytics", "/web-analytics/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .prefix("/internal/digest") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/errors.ts b/packages/domain/src/http/errors.ts index 89750ffcba..b46fae0a47 100644 --- a/packages/domain/src/http/errors.ts +++ b/packages/domain/src/http/errors.ts @@ -43,7 +43,7 @@ import { IssueSeveritySource, WorkflowState, } from "../error-issues" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export { ActorType, ErrorIncidentReason, @@ -920,7 +920,7 @@ export class ErrorsApiGroup extends HttpApiGroup.make("errors") payload: EscalationPolicyEvaluationRequest, success: EscalationPolicyEvaluationDocument, error: ErrorPersistenceError, - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.get("listIssueEscalations", "/issues/:issueId/escalations", { diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index e079d9a9d5..491c80a7a0 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -21,7 +21,7 @@ export * from "./anomalies" export * from "./api-keys" export * from "./device-credentials" export * from "./audit-log" -export * from "./observer-access" +export * from "./analyst-access" export * from "./alerts" export * from "./escalation-chains" export * from "./oncall-schedules" diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index 9902e298a2..a0f0d24bd9 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -35,7 +35,7 @@ import { GcpCloudSqlSettings } from "../gcp" export { GcpCloudSqlSettings } from "../gcp" import { SupabaseAdvisorLint, SupabaseLogDrainSummary, SupabaseTopQuery } from "../supabase" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export { SupabaseAdvisorKind, SupabaseAdvisorLevel, @@ -1736,7 +1736,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") HttpApiEndpoint.post("infraSurfaces", "/infra-surfaces", { payload: InfraPresenceRequest, success: InfraSurfacesResponse, - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.get("cloudflareStatus", "/cloudflare/status", { @@ -1761,7 +1761,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") IntegrationsUpstreamError, IntegrationsPersistenceError, ], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("cloudflareStart", "/cloudflare/start", { diff --git a/packages/domain/src/http/mcp.ts b/packages/domain/src/http/mcp.ts index 3975ecb914..a278f9c6ef 100644 --- a/packages/domain/src/http/mcp.ts +++ b/packages/domain/src/http/mcp.ts @@ -5,7 +5,7 @@ import { TinybirdDateTime } from "../query-engine" import { BucketSeconds } from "./query-engine" import { SessionAuthorization } from "./current-tenant" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // MCP hub endpoint schemas (`/mcp-servers`, a server's overview, its tool page and its sessions). // @@ -341,5 +341,5 @@ export class McpInternalApiGroup extends HttpApiGroup.make("mcpInternal") ) .prefix("/internal/mcp-servers") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index d60d43dc93..afbd27dc42 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -44,7 +44,7 @@ import { PathsInclude, } from "@maple/query-model" import { WireRow } from "../wire" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" /** * A timeseries bucket width. @@ -5069,7 +5069,7 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") ) .prefix("/internal/query-engine") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") // Every endpoint here reads telemetry for the dashboard. .annotate(AuditedRead, "telemetry.read") {} diff --git a/packages/domain/src/http/session-replay.ts b/packages/domain/src/http/session-replay.ts index bebe700627..623f91f91a 100644 --- a/packages/domain/src/http/session-replay.ts +++ b/packages/domain/src/http/session-replay.ts @@ -6,7 +6,7 @@ import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" import { QueryEngineExecutionError, QueryEngineTimeoutError } from "./query-engine" import { warehouseHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Session replay schemas shared by the v2 contract and the internal replay group. @@ -170,6 +170,6 @@ export class SessionReplaysInternalApiGroup extends HttpApiGroup.make("sessionRe ) .prefix("/internal/session-replays") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") .annotate(AuditedRead, "session_replay.read") {} diff --git a/packages/domain/src/http/users.ts b/packages/domain/src/http/users.ts index 1deb76bd37..05b76487b8 100644 --- a/packages/domain/src/http/users.ts +++ b/packages/domain/src/http/users.ts @@ -7,7 +7,7 @@ import { TinybirdDateTime } from "../query-engine" import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // End users endpoint schemas (`/users`): the people Maple's customers identified with `identify()` // or a `user.id` span attribute, read by `ch/queries/users.ts` and `user-activity.ts`. Times are @@ -459,6 +459,6 @@ export class UsersInternalApiGroup extends HttpApiGroup.make("usersInternal") ) .prefix("/internal/users") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") .annotate(AuditedRead, "telemetry.read") {} diff --git a/packages/domain/src/http/v2/agent-feedback.ts b/packages/domain/src/http/v2/agent-feedback.ts index 78ce306610..a894ae206c 100644 --- a/packages/domain/src/http/v2/agent-feedback.ts +++ b/packages/domain/src/http/v2/agent-feedback.ts @@ -12,7 +12,7 @@ import { ListOf, ListQuery, Timestamp, wireExample } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { AgentFeedbackPublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" const isNotBlank = Schema.makeFilter((value: string) => Str.trim(value).length > 0, { description: "Must contain a non-whitespace character", @@ -136,7 +136,7 @@ export class V2AgentFeedbackApiGroup extends HttpApiGroup.make("agentFeedback") success: V2AgentFeedback, error: [persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "createAgentFeedback", diff --git a/packages/domain/src/http/v2/device-credentials.ts b/packages/domain/src/http/v2/device-credentials.ts index 9d50979fc4..a9000a67ed 100644 --- a/packages/domain/src/http/v2/device-credentials.ts +++ b/packages/domain/src/http/v2/device-credentials.ts @@ -18,7 +18,7 @@ import { V2RateLimited } from "./errors" import { publicError } from "./public-error" import { DeviceCredentialPublicId } from "./resource-ids" import { InstallationId } from "./widget-credentials" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" /** * Device-bound credentials (DPoP, RFC 9449) for the mobile Home Screen widgets. @@ -190,7 +190,7 @@ export class V2DeviceCredentialsApiGroup extends HttpApiGroup.make("deviceCreden persistence, ], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerDeviceCredential", @@ -206,7 +206,7 @@ export class V2DeviceCredentialsApiGroup extends HttpApiGroup.make("deviceCreden success: V2DeviceCredentialDeleteResponse, error: [sessionRequired, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeDeviceCredential", @@ -253,7 +253,7 @@ export class V2DeviceTokensApiGroup extends HttpApiGroup.make("deviceTokens") publicError(DeviceCredentialPersistenceError), ], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "issueDeviceToken", diff --git a/packages/domain/src/http/v2/integrations-chat.ts b/packages/domain/src/http/v2/integrations-chat.ts index 3307d82d2c..704970eef5 100644 --- a/packages/domain/src/http/v2/integrations-chat.ts +++ b/packages/domain/src/http/v2/integrations-chat.ts @@ -14,7 +14,7 @@ import { wireExample, Timestamp } from "./envelopes" import { V2CallbackHostUnavailable, V2InsufficientPermissions } from "./errors" import { publicErrors } from "./public-error" import { PublicId, PublicIdPrefixes } from "./public-id" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" // The chat-platform surface: one contract for every chat platform Maple can // connect to. The platform is a value (`connector`), never a path segment or a @@ -391,7 +391,7 @@ export class V2ChatIntegrationsApiGroup extends HttpApiGroup.make("chatIntegrati success: V2ChatIdentityDeleteResponse, error: [V2InsufficientPermissions.schema, chatNotFound, chatPersistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "deleteChatIdentity", diff --git a/packages/domain/src/http/v2/mcp-connections.ts b/packages/domain/src/http/v2/mcp-connections.ts index 6da5af4814..2b2a67af52 100644 --- a/packages/domain/src/http/v2/mcp-connections.ts +++ b/packages/domain/src/http/v2/mcp-connections.ts @@ -7,7 +7,7 @@ import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { McpConnectionPublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" const mcpConnectionExample = { id: "mcpc_p12S8T8agEHUkYc825nMz6", @@ -82,7 +82,7 @@ export class V2McpConnectionsApiGroup extends HttpApiGroup.make("mcpConnections" success: V2McpConnection, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeMcpConnection", diff --git a/packages/domain/src/http/v2/mobile-devices.ts b/packages/domain/src/http/v2/mobile-devices.ts index 46fa78a8f7..ba55eb443f 100644 --- a/packages/domain/src/http/v2/mobile-devices.ts +++ b/packages/domain/src/http/v2/mobile-devices.ts @@ -11,7 +11,7 @@ import { AuthorizationV2 } from "./auth" import { wireExample, ListOf, Timestamp } from "./envelopes" import { publicError } from "./public-error" import { AlertIncidentPublicId, MobileDevicePublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" // Code points, not UTF-16 units: identical for these ASCII values, and the only length the // JSON Schema `minLength` can state exactly (Effect halves a UTF-16 bound when it exports one). @@ -196,7 +196,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2MobileDevice, error: [persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerMobileDevice", @@ -212,7 +212,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2MobileDeviceDeleteResponse, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "unregisterMobileDevice", @@ -229,7 +229,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2LiveActivity, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerLiveActivity", @@ -245,7 +245,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2LiveActivityDeleteResponse, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "endLiveActivity", diff --git a/packages/domain/src/http/v2/widget-credentials.ts b/packages/domain/src/http/v2/widget-credentials.ts index dd1a91d729..cd48e0a652 100644 --- a/packages/domain/src/http/v2/widget-credentials.ts +++ b/packages/domain/src/http/v2/widget-credentials.ts @@ -5,7 +5,7 @@ import { OrgId } from "@maple/primitives" import { AuthorizationV2 } from "./auth" import { wireExample, Timestamp } from "./envelopes" import { publicError } from "./public-error" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" /** * The credential a device's Home Screen widgets fetch with. @@ -100,7 +100,7 @@ export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCreden success: V2WidgetCredential, error: [publicError(ApiKeyPersistenceError)], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "mintWidgetCredential", @@ -116,7 +116,7 @@ export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCreden success: V2WidgetCredentialDeleteResponse, error: [publicError(ApiKeyPersistenceError)], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeWidgetCredential", diff --git a/packages/domain/src/policy/index.ts b/packages/domain/src/policy/index.ts index d31d176803..d80afc066f 100644 --- a/packages/domain/src/policy/index.ts +++ b/packages/domain/src/policy/index.ts @@ -51,12 +51,12 @@ export const check = ( ): Effect.Effect => can(roles, permission) ? Effect.void : Effect.fail(onDenied(denialFor(permission))) -/** Whether the roles may only read: an observer, with no higher role beside it. */ -export const isReadOnly = (roles: ReadonlyArray): boolean => highestRole(roles) === "observer" +/** Whether the roles may only read: an analyst, with no higher role beside it. */ +export const isReadOnly = (roles: ReadonlyArray): boolean => highestRole(roles) === "analyst" -/** What an observer is told when a change is refused, on every surface. */ +/** What an analyst is told when a change is refused, on every surface. */ export const readOnlyMessage = - "Observers can read Maple but not change it. Ask an org admin for the Member role to make changes." + "Analysts can read Maple but not change it. Ask an org admin for the Member role to make changes." /** Every permission the roles hold, for a page model to keep instead of role flags. */ export const grantsOf = (roles: ReadonlyArray): ReadonlyArray => diff --git a/packages/domain/src/policy/permissions.ts b/packages/domain/src/policy/permissions.ts index 42fc50c4bd..c9156e8058 100644 --- a/packages/domain/src/policy/permissions.ts +++ b/packages/domain/src/policy/permissions.ts @@ -11,8 +11,8 @@ export const permissions = { // The public ingest key ships in browser bundles, so every member may read it to set up an app. "ingest_keys:view_public": { min: "member", action: "view the public ingest key" }, - // Shared work on what the telemetry shows: members and above. Observers read it. The API refuses - // an observer every change in one place (`ObserverAccess`); these name them for the dashboard. + // Shared work on what the telemetry shows: members and above. Analysts read it. The API refuses + // an analyst every change in one place (`AnalystAccess`); these name them for the dashboard. "dashboards:edit": { min: "member", action: "create and edit dashboards" }, "dashboards:share": { min: "member", action: "share dashboards outside the organization" }, "error_issues:triage": { min: "member", action: "triage error issues" }, diff --git a/packages/domain/src/policy/policy.test.ts b/packages/domain/src/policy/policy.test.ts index 1e5631d6af..5a1bdce69b 100644 --- a/packages/domain/src/policy/policy.test.ts +++ b/packages/domain/src/policy/policy.test.ts @@ -32,8 +32,8 @@ describe("Policy.can", () => { } }) - it("holds nothing a member has for an observer or a Clerk role it does not know", () => { - for (const roles of [["org:observer"], ["org:billing"]]) { + it("holds nothing a member has for an analyst or a Clerk role it does not know", () => { + for (const roles of [["org:analyst"], ["org:billing"]]) { expect(Policy.can(roles, "ingest_keys:view_public")).toBe(false) expect(Policy.grantsOf(roles)).toEqual([]) } @@ -67,11 +67,11 @@ describe("Policy.check", () => { }), ) - it.effect("tells an observer which roles hold a member permission", () => + it.effect("tells an analyst which roles hold a member permission", () => Effect.gen(function* () { const error = yield* Effect.flip( Policy.check( - ["org:observer"], + ["org:analyst"], "ingest_keys:view_public", (denial) => new Forbidden({ message: denial.message }), ), @@ -113,7 +113,7 @@ describe("Policy.grantsOf", () => { describe("roleOf", () => { it("maps the Clerk keys the Members page assigns", () => { expect(Arr.map(Policy.assignableRoles, (entry) => Policy.roleOf(entry.clerkKey))).toEqual([ - "observer", + "analyst", "member", "engineer", "admin", @@ -131,17 +131,17 @@ describe("roleOf", () => { }) describe("highestRole", () => { - it("keeps an observer an observer instead of lifting them to member", () => { - expect(Policy.highestRole(["org:observer"])).toBe("observer") - expect(Policy.highestRole(["org:observer", "org:billing"])).toBe("observer") + it("keeps an analyst an analyst instead of lifting them to member", () => { + expect(Policy.highestRole(["org:analyst"])).toBe("analyst") + expect(Policy.highestRole(["org:analyst", "org:billing"])).toBe("analyst") }) - it("takes the highest when an observer role sits beside a higher one", () => { - expect(Policy.highestRole(["org:observer", "org:engineer"])).toBe("engineer") + it("takes the highest when an analyst role sits beside a higher one", () => { + expect(Policy.highestRole(["org:analyst", "org:engineer"])).toBe("engineer") }) - it("retires a member-pinned credential when its owner becomes an observer", () => { + it("retires a member-pinned credential when its owner becomes an analyst", () => { const pinned = Policy.highestRole(["org:member"]) - expect(Policy.outranks(pinned, Policy.highestRole(["org:observer"]))).toBe(true) + expect(Policy.outranks(pinned, Policy.highestRole(["org:analyst"]))).toBe(true) }) }) diff --git a/packages/domain/src/policy/roles.ts b/packages/domain/src/policy/roles.ts index 8ba1dcfefc..26988c35ef 100644 --- a/packages/domain/src/policy/roles.ts +++ b/packages/domain/src/policy/roles.ts @@ -5,11 +5,11 @@ import { Array as Arr, Option, Order, Schema } from "effect" * permission names only its lowest role. `root` is Maple's own operator access and self-hosted * mode; it is never assigned in Clerk. */ -export const Role = Schema.Literals(["observer", "member", "engineer", "admin", "root"]) +export const Role = Schema.Literals(["analyst", "member", "engineer", "admin", "root"]) export type Role = typeof Role.Type const RANK = { - observer: 0, + analyst: 0, member: 1, engineer: 2, admin: 3, @@ -21,9 +21,9 @@ const byRank: Order.Order = Order.mapInput(Order.Number, (role) => RANK[ro /** What the Members page shows for a role a person can be given in Clerk. */ export const assignableRoles = [ { - role: "observer", - clerkKey: "org:observer", - label: "Observer", + role: "analyst", + clerkKey: "org:analyst", + label: "Analyst", summary: "Follows incidents, reliability, product analytics and cost, without changing anything.", }, { @@ -51,7 +51,7 @@ export type ClerkRoleKey = AssignableRole["clerkKey"] /** * A Clerk or session role string as a {@link Role}. A role this code does not know reads as - * observer, the least it can hold. + * analyst, the least it can hold. */ export const roleOf = (name: string): Role => { if (name === "root") return "root" @@ -60,7 +60,7 @@ export const roleOf = (name: string): Role => { Arr.findFirst(assignableRoles, (entry) => entry.clerkKey === name), (entry): Role => entry.role, ), - (): Role => "observer", + (): Role => "analyst", ) } @@ -82,7 +82,7 @@ export const outranks = (role: Role, other: Role): boolean => RANK[role] > RANK[ /** "org admins and engineers": who holds a permission whose lowest role is `min`, for messages. */ export const holdersPhrase = (min: Role): string => { switch (min) { - case "observer": + case "analyst": return "people in the organization" case "member": return "org admins, engineers and members" From 5d9456184c8b9e03e1d5fe9806c39918d13785aa Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:20:22 +0100 Subject: [PATCH 5/6] feat(overview): point analysts at building a dashboard --- .../overview/analyst/analyst.scene.test.ts | 1 + apps/web/src/page/overview/analyst/model.ts | 2 +- apps/web/src/page/overview/analyst/view.ts | 22 ++++++++++++------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/web/src/page/overview/analyst/analyst.scene.test.ts b/apps/web/src/page/overview/analyst/analyst.scene.test.ts index 8d13655011..d08238198b 100644 --- a/apps/web/src/page/overview/analyst/analyst.scene.test.ts +++ b/apps/web/src/page/overview/analyst/analyst.scene.test.ts @@ -89,6 +89,7 @@ describe("overview, analyst", () => { "href", "/account?tab=email-preferences", ), + expectView(role("link", { name: "Build a dashboard" })).toHaveAttr("href", "/dashboards"), ) }) diff --git a/apps/web/src/page/overview/analyst/model.ts b/apps/web/src/page/overview/analyst/model.ts index d1986dc347..c4adeb1b18 100644 --- a/apps/web/src/page/overview/analyst/model.ts +++ b/apps/web/src/page/overview/analyst/model.ts @@ -8,7 +8,7 @@ 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, read-only. +// 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( diff --git a/apps/web/src/page/overview/analyst/view.ts b/apps/web/src/page/overview/analyst/view.ts index 3e3358a57a..21cefbddf1 100644 --- a/apps/web/src/page/overview/analyst/view.ts +++ b/apps/web/src/page/overview/analyst/view.ts @@ -339,14 +339,20 @@ const errorsSection = (h: H, model: Model, nowMs: number | null): Html => { ) } -/** The quiet last row: where the weekly digests are turned on. */ -const weeklySummary = (h: H): Html => +/** 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" }, [ - textLink( - h, - { href: hrefOf(AppRoute.Account({ search: { tab: "email-preferences" } })), tone: "muted" }, - [icon(h, "EnvelopeIcon", { size: 14 }), "Get a weekly summary by email"], - ), + 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) => @@ -373,7 +379,7 @@ export const view = Submodel.defineView((model, slosSection(h, model), ]), errorsSection(h, model, model.nowMs), - weeklySummary(h), + nextSteps(h), ], ), ], From a3f0e445c04165d41675f8efd01be24e56c86fa0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:34:29 +0100 Subject: [PATCH 6/6] feat(investigations): let an analyst follow an investigation without its controls An analyst reads the diagnosis, evidence and thread. The status actions and the follow-up composer are left out for them, since the API refuses both. --- .../page/investigations/conversation/view.ts | 4 ++- .../detail/investigation.scene.test.ts | 21 ++++++++++++++ .../src/page/investigations/detail/view.ts | 28 +++++++++++++++---- 3 files changed, 46 insertions(+), 7 deletions(-) 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, {