diff --git a/apps/web/src/lab/alerts-lab/update.ts b/apps/web/src/lab/alerts-lab/update.ts index bde34007bc..3a10e0c822 100644 --- a/apps/web/src/lab/alerts-lab/update.ts +++ b/apps/web/src/lab/alerts-lab/update.ts @@ -1,5 +1,4 @@ import { chartEnvHandlers } from "@maple/views/charts/chart-env" -import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" import { Clock, Effect, Schema, Array as Arr } from "effect" import { Command } from "foldkit" @@ -10,20 +9,27 @@ import * as AlertsHub from "../../page/alerts/hub/update" import type { PageContext } from "../../page/contract" import type { Resources } from "../../platform/resources" import { foldLabShell, initLabShell, type LabShellFlags } from "../app-shell" +import { labGrants, LabRole, readLabRole } from "../viewer-role" import { Message, type Model } from "./model" export type Return = Update.Return -export const AlertsLabFlags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String }) +export const AlertsLabFlags = Schema.Struct({ + nowMs: Schema.Number, + timeZone: Schema.String, + /** `?role=member|analyst`: an analyst gets no acknowledge, rule or routing control. */ + role: LabRole, +}) export type AlertsLabFlags = typeof AlertsLabFlags.Type -/** The boot clock and the browser's zone (`Intl.DateTimeFormat().resolvedOptions()`). */ +/** The boot clock, the browser's zone (`Intl.DateTimeFormat().resolvedOptions()`) and `?role=`. */ export const alertsLabFlags: Effect.Effect = Effect.all({ nowMs: Clock.currentTimeMillis, timeZone: Effect.sync(() => Intl.DateTimeFormat().resolvedOptions().timeZone), + role: readLabRole, }) -/** The hub views run against a hub Model; its org is the fixture's, its admin rights on. */ +/** The hub views run against a hub Model; its org is the fixture's, its admin rights on by default. */ const LAB_CONTEXT: PageContext = { orgId: Schema.decodeSync(OrgId)("org_lab_alerts"), pinnedNamespace: null, @@ -37,7 +43,7 @@ export const init = (flags: AlertsLabFlags & LabShellFlags): Return => { model: { nowMs: flags.nowMs, timeZone: flags.timeZone, - hub: { ...hub, grants: Policy.allPermissions, nowMs: flags.nowMs }, + hub: { ...hub, grants: labGrants(flags.role), nowMs: flags.nowMs }, status: null, chart: Chart.init(), chartRuntime: { _tag: "Loading" }, diff --git a/apps/web/src/lab/alerts-lab/view-page.ts b/apps/web/src/lab/alerts-lab/view-page.ts index 010cb8d566..3a3ee92f0c 100644 --- a/apps/web/src/lab/alerts-lab/view-page.ts +++ b/apps/web/src/lab/alerts-lab/view-page.ts @@ -19,6 +19,7 @@ import { Message, type Model } from "./model" import { Message as HubMessage } from "../../page/alerts/hub/message" import { FRAME_CLASS, hubSections, section } from "./view" import { toEpochMs } from "@maple/ui/lib/time-format" +import { canRespondToIncidents } from "../../page/kit/session-grants" type H = HtmlBuilder @@ -79,17 +80,19 @@ const incidentFrame = (h: H, model: Model, fixture: AlertsLabFixture, isAcknowle headerTitle(h, incident, rule, model.nowMs, model.timeZone), hstack(h, { gap: "2", wrap: true }, [ // The lab hub drops this message, as it does the inbox buttons': the lab has no API. - ...acknowledgeAction(h, { - incident, - isLoading: false, - onAcknowledge: (acknowledged) => - Message.GotHubMessage({ - message: HubMessage.ClickedAcknowledgeIncident({ - incidentId: incident.id, - acknowledged, - }), - }), - }), + ...(canRespondToIncidents(model.hub) + ? acknowledgeAction(h, { + incident, + isLoading: false, + onAcknowledge: (acknowledged) => + Message.GotHubMessage({ + message: HubMessage.ClickedAcknowledgeIncident({ + incidentId: incident.id, + acknowledged, + }), + }), + }) + : []), linkButton( h, { diff --git a/apps/web/src/lab/alerts-lab/view.ts b/apps/web/src/lab/alerts-lab/view.ts index d95a2496da..81c1743a83 100644 --- a/apps/web/src/lab/alerts-lab/view.ts +++ b/apps/web/src/lab/alerts-lab/view.ts @@ -15,6 +15,7 @@ import { button } from "@maple/views/ui/button" import { icon } from "@maple/views/ui/icon" import type { Model } from "./model" import { Array as Arr } from "effect" +import { canManageAlerts, canRespondToIncidents } from "../../page/kit/session-grants" type AlertsTab = "inbox" | "rules" | "routing" @@ -110,11 +111,15 @@ type HubInputs = Readonly<{ nowMs: number }> -const inboxAcknowledge = (hub: HubModel) => ({ - onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => - HubMessageUnion.ClickedAcknowledgeIncident({ incidentId, acknowledged }), - pendingIds: hub.acknowledgingIncidentIds, -}) +/** As the hub passes it: `null` for a viewer who may not respond, whose rows carry no button. */ +const inboxAcknowledge = (hub: HubModel) => + canRespondToIncidents(hub) + ? { + onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => + HubMessageUnion.ClickedAcknowledgeIncident({ incidentId, acknowledged }), + pendingIds: hub.acknowledgingIncidentIds, + } + : null /** The sections the hub's views draw, run against the lab's hub Model. */ export const hubSections = Submodel.defineView((hub, inputs, h) => { @@ -144,7 +149,7 @@ export const hubSections = Submodel.defineView( destinations: fixture.destinations, status: inputs.status ?? undefined, emptyState: alertsEmptyState(h, { - canEdit: true, + canEdit: canManageAlerts(hub), hasDestinations: true, serviceName: undefined, onAddDestination: undefined, diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index efe45d496f..052aaf9dd8 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -51,10 +51,12 @@ import { } from "../page/kit/alert-test-rows" import type { PageContext } from "../page/contract" import type { Resources } from "../platform/resources" +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|analyst` reads the hub and the incident as that viewer: an analyst gets no acknowledge. export const LabState = Schema.Literals([ "editor-new", @@ -74,14 +76,15 @@ export const LabState = Schema.Literals([ ]) export type LabState = typeof LabState.Type -export const Flags = Schema.Struct({ state: LabState }) +export const Flags = Schema.Struct({ state: LabState, role: LabRole }) export type Flags = typeof Flags.Type -export const flags: Effect.Effect = Effect.sync(() => ({ +export const flags: Effect.Effect = Effect.map(readLabRole, (role) => ({ state: Option.getOrElse( Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")), (): LabState => "hub-anomalies", ), + role, })) export const Model = Schema.Struct({ @@ -157,7 +160,7 @@ const RESOLVED = [ }), ] -const hubFor = (state: LabState) => { +const hubFor = (state: LabState, role: LabRole) => { const search: HubAt = state === "hub-rules" ? { tab: "rules" } @@ -183,7 +186,7 @@ const hubFor = (state: LabState) => { }), HubMessage.Message.UpdatedIncidents({ orgId: ORG, rows: incidents }), HubMessage.Message.UpdatedDestinations({ orgId: ORG, rows: [destinationRow()] }), - HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: Policy.allPermissions }), + HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: labGrants(role) }), ], (model, message) => HubUpdate.update(model, message, CONTEXT), ) @@ -256,7 +259,7 @@ const INCIDENTS = { "incident-spike": { n: 3, signal: "error_spike", severity: "critical", service: "payments-worker" }, } as const -const incidentFor = (state: LabState) => { +const incidentFor = (state: LabState, role: LabRole) => { const scenario = state === "incident-warning" || state === "incident-critical" || state === "incident-spike" ? INCIDENTS[state] @@ -302,7 +305,7 @@ const incidentFor = (state: LabState) => { key: synced.activity.key ?? "", result: Result.succeed([]), }), - IncidentMessage.Message.CompletedFetchSession({ grants: Policy.allPermissions }), + IncidentMessage.Message.CompletedFetchSession({ grants: labGrants(role) }), IncidentMessage.Message.CompletedFetchSilences({ key: synced.silences.key ?? "", result: Result.succeed([]), @@ -355,9 +358,9 @@ const isIncident = Str.startsWith("incident") export const init = (flags: Flags): Return => ({ model: { state: flags.state, - hub: hubFor(flags.state), + hub: hubFor(flags.state, flags.role), editor: editorFor(flags.state), - incident: incidentFor(flags.state), + incident: incidentFor(flags.state, flags.role), rule: ruleFor(), }, // The incident page's chart runtime still loads, so its anomaly chart draws. diff --git a/apps/web/src/lab/entries/error-issue.ts b/apps/web/src/lab/entries/error-issue.ts index eb2244af6a..a6128cbd4e 100644 --- a/apps/web/src/lab/entries/error-issue.ts +++ b/apps/web/src/lab/entries/error-issue.ts @@ -1,4 +1,5 @@ import { OrgId } from "@maple/domain" +import * as Policy from "@maple/domain/policy" import { ErrorIssueEventsResponse, ErrorIssueId, @@ -7,7 +8,7 @@ import { IssueEscalationAttemptsResponse, } from "@maple/domain/http" import { encodePublicId, V2ErrorIssueDetail } from "@maple/domain/http/v2" -import { Effect, Result, Schema, Array as Arr, String as Str } from "effect" +import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect" import { Command } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { toLoadFailure } from "@maple/views/data/load-failure" @@ -20,7 +21,9 @@ 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. API Commands are dropped; the page is sent the answers its reads would get. +// 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 = { orgId: Schema.decodeSync(OrgId)("org_lab"), @@ -118,12 +121,28 @@ const send = (step: Step, message: Message): Step => { } } -const Flags = Schema.Struct({}) +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 +type LabRole = Flags["role"] +type LabTab = Flags["tab"] -const init = () => { - const entered = pageInit({ issueId, search: {} }, context) +const decodeRole = Schema.decodeUnknownOption(Schema.Literals(ROLES)) +const decodeTab = Schema.decodeUnknownOption(Schema.Literals(TABS)) + +const init = (flags: Flags) => { + const entered = pageInit({ issueId, search: flags.tab === "overview" ? {} : { tab: flags.tab } }, context) const start: Step = { model: entered.model, commands: Arr.filter(entered.commands ?? [], isLocalCommand) } - const windowed = send(start, Message.CompletedResolveWindow({ window, nowMs: NOW, mode: "Load" })) + // The session's grants for the role, as the page's own read would answer. + const granted = send( + start, + Message.CompletedFetchSession({ + orgId: context.orgId, + grants: Policy.grantsOf([`org:${flags.role}`]), + }), + ) + const windowed = send(granted, Message.CompletedResolveWindow({ window, nowMs: NOW, mode: "Load" })) const keyOf = (field: "detail" | "events" | "pullRequests" | "verifications" | "escalations") => windowed.model[field].key ?? "" return Arr.reduce( @@ -183,7 +202,13 @@ export const lab = defineShellLab("Error issue", { Model, Message, Flags, - flags: Effect.succeed({}), + flags: Effect.sync((): Flags => { + const params = new URLSearchParams(globalThis.location.search) + return { + role: Option.getOrElse(decodeRole(params.get("role")), (): LabRole => "member"), + tab: Option.getOrElse(decodeTab(params.get("tab")), (): LabTab => "overview"), + } + }), init, update, view, diff --git a/apps/web/src/lab/entries/status-pages.ts b/apps/web/src/lab/entries/status-pages.ts index 1434b59e54..85d3739c87 100644 --- a/apps/web/src/lab/entries/status-pages.ts +++ b/apps/web/src/lab/entries/status-pages.ts @@ -1,6 +1,6 @@ -import * as Policy from "@maple/domain/policy" import { OrgId } from "@maple/domain" -import { Effect, Result, Schema, Array as Arr } from "effect" +import { StatusPagePublicId } from "@maple/domain/http/v2" +import { Effect, Option, Result, Schema, Array as Arr } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" import type { PageContext } from "../../page/contract" @@ -10,10 +10,19 @@ import { Message as ListMessage } from "../../page/status-pages/list/message" import { Model as ListModel } from "../../page/status-pages/list/model" import { init as listInit, update as listUpdate } from "../../page/status-pages/list/update" import { view as listView } from "../../page/status-pages/list/view" +import { Message as EditorMessage } from "../../page/status-pages/editor/message" +import { Model as EditorModel } from "../../page/status-pages/editor/model" +import { init as editorInit, update as editorUpdate } from "../../page/status-pages/editor/update" +import { view as editorView } from "../../page/status-pages/editor/view" +import { component, incident, incidentId, statusPage } from "../../page/status-pages/shared/test-pages" +import { downMonitor, httpMonitor } from "../../page/kit/uptime/test-monitors" import { defineShellLab } from "../shell-lab" import { LAB_NOW_MS, LAB_ORG, LAB_PAGES } from "../status-pages-fixture" +import { labGrants, LabRole, readLabRole } from "../viewer-role" -// `/lab/status-pages`: the status pages list, the look picker and the form fields over fixture pages. +// `/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 analyst reads the incident and gets no control that posts, edits or deletes one. const context: PageContext = { orgId: Schema.decodeSync(OrgId)(LAB_ORG), @@ -21,18 +30,28 @@ const context: PageContext = { timeZone: "UTC", } -const Model = Schema.Struct({ list: ListModel }) +const PAGES = ["list", "editor"] as const +const LabPage = Schema.Literals(PAGES) +type LabPage = typeof LabPage.Type +const decodePage = Schema.decodeUnknownOption(LabPage) + +const Model = Schema.Struct({ page: LabPage, list: ListModel, editor: EditorModel }) type Model = typeof Model.Type -const Message = defineMessageUnion({ GotListMessage: { message: ListMessage }, Ignored: {} }) +const Message = defineMessageUnion({ + GotListMessage: { message: ListMessage }, + GotEditorMessage: { message: EditorMessage }, + Ignored: {}, +}) type Message = typeof Message.Type -const Flags = Schema.Struct({}) +const Flags = Schema.Struct({ page: LabPage, role: LabRole }) +type Flags = typeof Flags.Type -const listModel = (): ListModel => { +const listModel = (role: LabRole): ListModel => { const step = (model: ListModel, message: ListMessage) => listUpdate(model, message, context).model const entered = Arr.reduce( [ ListMessage.ReadClock({ nowMs: LAB_NOW_MS }), - ListMessage.CompletedFetchSession({ orgId: LAB_ORG, grants: Policy.allPermissions }), + ListMessage.CompletedFetchSession({ orgId: LAB_ORG, grants: labGrants(role) }), ], listInit({ _tag: "StatusPages" }, context).model, step, @@ -43,6 +62,48 @@ const listModel = (): ListModel => { ) } +/** The editor over a live page with an open incident, a draft maintenance and one resolved, the first open. */ +const editorModel = (role: LabRole): EditorModel => { + const step = (model: EditorModel, message: EditorMessage) => editorUpdate(model, message, context).model + const entered = editorInit( + { _tag: "StatusPage", pageId: Schema.encodeSync(StatusPagePublicId)(statusPage().id), search: {} }, + context, + ).model + return Arr.reduce( + [ + EditorMessage.ReadClock({ nowMs: LAB_NOW_MS }), + EditorMessage.CompletedFetchSession({ orgId: LAB_ORG, grants: labGrants(role) }), + EditorMessage.CompletedFetchPage({ + key: entered.page.key ?? "", + result: Result.succeed(statusPage()), + }), + EditorMessage.CompletedFetchComponents({ + key: entered.components.key ?? "", + result: Result.succeed([ + component(1, { name: "API" }), + component(2, { name: "Dashboard" }), + component(3, { name: "Ingest" }), + ]), + }), + EditorMessage.CompletedFetchIncidents({ + key: entered.incidents.key ?? "", + result: Result.succeed([ + incident(1, { title: "Elevated API errors" }), + incident(2, { kind: "maintenance", title: "Database upgrade", published: false }), + incident(3, { title: "Delayed ingest", status: "resolved" }), + ]), + }), + EditorMessage.CompletedFetchMonitors({ + key: entered.monitors.key ?? "", + result: Result.succeed([httpMonitor(), downMonitor()]), + }), + EditorMessage.ClickedIncident({ incidentId: incidentId(1) }), + ], + entered, + step, + ) +} + const section = (h: HtmlBuilder, title: string, children: ReadonlyArray): Html => h.section( [h.Class("flex flex-col gap-3")], @@ -50,6 +111,17 @@ const section = (h: HtmlBuilder, title: string, children: ReadonlyArray ) const view = (model: Model, h: HtmlBuilder): Html => + model.page === "editor" + ? h.submodel({ + slotId: "status-pages-lab-editor", + model: model.editor, + view: editorView, + viewInputs: { context }, + toParentMessage: (message) => Message.GotEditorMessage({ message }), + }) + : listPage(model, h) + +const listPage = (model: Model, h: HtmlBuilder): Html => h.div( [h.Class("flex flex-col gap-8 pb-8")], [ @@ -109,8 +181,16 @@ export const lab = defineShellLab("Status pages", { Model, Message, Flags, - flags: Effect.succeed({}), - init: () => ({ model: { list: listModel() } }), + flags: Effect.map(readLabRole, (role): Flags => ({ + page: Option.getOrElse( + decodePage(new URLSearchParams(globalThis.location.search).get("page")), + (): LabPage => "list", + ), + role, + })), + init: (flags: Flags) => ({ + model: { page: flags.page, list: listModel(flags.role), editor: editorModel(flags.role) }, + }), update: (model) => ({ model }), view, currentPath: "/lab/status-pages", diff --git a/apps/web/src/lab/errors-lab/hub-step.ts b/apps/web/src/lab/errors-lab/hub-step.ts index 5c23378dde..cfa4ef837a 100644 --- a/apps/web/src/lab/errors-lab/hub-step.ts +++ b/apps/web/src/lab/errors-lab/hub-step.ts @@ -1,6 +1,7 @@ import { buildErrorsLabFixture } from "../errors-fixture" import type { ErrorSignal } from "../../page/errors/kit/error-signal" import { OrgId } from "@maple/domain" +import * as Policy from "@maple/domain/policy" import { Option, Schema, Array as Arr, String as Str } from "effect" import type { Command } from "foldkit" import { fromString as urlFromString } from "foldkit/url" @@ -11,7 +12,7 @@ import type { Model as HubModel } from "../../page/errors/hub/model" import * as HubUpdate from "../../page/errors/hub/update" import type { Resources } from "../../platform/resources" import { urlToAppRoute } from "../../route" -import type { LabState } from "./model" +import type { LabRole, LabState } from "./model" /** The hub runs against a fixture org; its window stays unresolved, so it never fetches the list. */ export const LAB_CONTEXT: PageContext = { @@ -20,9 +21,13 @@ export const LAB_CONTEXT: PageContext = { timeZone: "UTC", } -export const initHub = (nowMs: number): HubModel => ({ +/** The session's grants for the lab's role, as the page's own read would answer. */ +export const grantsOf = (role: LabRole): ReadonlyArray => Policy.grantsOf([`org:${role}`]) + +export const initHub = (nowMs: number, role: LabRole): HubModel => ({ ...HubUpdate.init({ search: {} }, LAB_CONTEXT).model, nowMs, + grants: grantsOf(role), }) /** The fixture through the lab state, then the view, severity and search filters, sorted. */ diff --git a/apps/web/src/lab/errors-lab/model.ts b/apps/web/src/lab/errors-lab/model.ts index a6f0524b6c..99b350d093 100644 --- a/apps/web/src/lab/errors-lab/model.ts +++ b/apps/web/src/lab/errors-lab/model.ts @@ -4,12 +4,18 @@ 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 and Width toggles. Shell-free; the entry wraps it. +// 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 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 + /** Widths worth checking: the row's lanes are container queries. */ export const LAB_WIDTHS = [ { label: "Full", value: null }, @@ -23,6 +29,7 @@ export const Model = Schema.Struct({ /** The hub's toolbar and row state; its `search` holds the lab's view, sort, severity and query. */ hub: HubModel, state: LabState, + role: LabRole, width: Schema.NullOr(Schema.Number), }) export type Model = typeof Model.Type @@ -30,6 +37,7 @@ export type Model = typeof Model.Type export const Message = defineMessageUnion({ GotHubMessage: { message: HubMessage }, ClickedState: { state: LabState }, + ClickedRole: { role: LabRole }, ClickedWidth: { width: Schema.NullOr(Schema.Number) }, }) export type Message = typeof Message.Type diff --git a/apps/web/src/lab/errors-lab/update.ts b/apps/web/src/lab/errors-lab/update.ts index bf14d3f6c8..8ba69651a3 100644 --- a/apps/web/src/lab/errors-lab/update.ts +++ b/apps/web/src/lab/errors-lab/update.ts @@ -1,24 +1,37 @@ -import { Clock, Effect, Schema } from "effect" +import { Clock, Effect, Option, Schema } from "effect" import { Command, type Update } from "foldkit" import type { Resources } from "../../platform/resources" -import { hubStep, initHub, labSignals } from "./hub-step" -import { Message, type Model } from "./model" +import { grantsOf, hubStep, initHub, labSignals } from "./hub-step" +import { LabRole, Message, type Model } from "./model" export type Return = Update.Return -export const ErrorsLabFlags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String }) +export const ErrorsLabFlags = Schema.Struct({ + nowMs: Schema.Number, + timeZone: Schema.String, + role: LabRole, +}) export type ErrorsLabFlags = typeof ErrorsLabFlags.Type -/** The boot clock (read once) and the browser's zone. */ +const decodeRole = Schema.decodeUnknownOption(LabRole) + +/** The boot clock (read once), the browser's zone and the `?role=` the list is read as. */ export const errorsLabFlags: Effect.Effect = Effect.all({ nowMs: Clock.currentTimeMillis, timeZone: Effect.sync(() => Intl.DateTimeFormat().resolvedOptions().timeZone), + role: Effect.sync(() => + Option.getOrElse( + decodeRole(new URLSearchParams(window.location.search).get("role")), + (): LabRole => "member", + ), + ), }) export const init = (flags: ErrorsLabFlags): Model => ({ timeZone: flags.timeZone, - hub: initHub(flags.nowMs), + hub: initHub(flags.nowMs, flags.role), state: "ready", + role: flags.role, width: null, }) @@ -36,5 +49,13 @@ export const update = (model: Model, message: Message): Return => } }, ClickedState: ({ state }) => ({ model: { ...model, state } }), + // A new role is a new session: its grants, and nothing left selected or open. + ClickedRole: ({ role }) => ({ + model: { + ...model, + role, + hub: { ...model.hub, grants: grantsOf(role), selectedIds: [], picker: null }, + }, + }), ClickedWidth: ({ width }) => ({ model: { ...model, width } }), }) diff --git a/apps/web/src/lab/errors-lab/view.ts b/apps/web/src/lab/errors-lab/view.ts index 7f697a3b19..e29d4c23f2 100644 --- a/apps/web/src/lab/errors-lab/view.ts +++ b/apps/web/src/lab/errors-lab/view.ts @@ -11,7 +11,8 @@ import type { Model as HubModel } from "../../page/errors/hub/model" import { eyebrow } from "@maple/views/ui/eyebrow" import { isMounted } from "@maple/views/ui/overlay/transition" import { labSignals } from "./hub-step" -import { LAB_STATES, LAB_WIDTHS, type LabState, Message, type Model } from "./model" +import { LAB_ROLES, LAB_STATES, LAB_WIDTHS, type LabState, Message, type Model } from "./model" +import { canTriageIssues } from "../../page/errors/kit/grants" import { Array as Arr } from "effect" // The lab controls, stat strip and errors hub view over the fixture. @@ -106,6 +107,7 @@ const hubView = Submodel.defineView((hub, input isMounted(hub.rowMenu.isOpen, hub.rowMenu.animation) ? hub.rowMenu : null, + canTriage: canTriageIssues(hub), }), }), ], @@ -147,6 +149,13 @@ export const view = Submodel.defineView((model, h) => model.state, (state) => Message.ClickedState({ state }), ), + toggleGroup( + h, + "Role", + Arr.map(LAB_ROLES, (value) => ({ value, label: value })), + model.role, + (role) => Message.ClickedRole({ role }), + ), toggleGroup(h, "Width", LAB_WIDTHS, model.width, (width) => Message.ClickedWidth({ width }), ), diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 5f3069d72c..b26a880730 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=`).", + "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", }, @@ -471,7 +471,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.", + "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", }, @@ -479,7 +479,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.", + "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", }, @@ -487,7 +487,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.", + "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", }, @@ -503,7 +503,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.", + "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", }, diff --git a/apps/web/src/lab/viewer-role.ts b/apps/web/src/lab/viewer-role.ts new file mode 100644 index 0000000000..bce02440f9 --- /dev/null +++ b/apps/web/src/lab/viewer-role.ts @@ -0,0 +1,24 @@ +import * as Policy from "@maple/domain/policy" +import { Effect, Option, Schema } from "effect" + +// `?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", "analyst"] as const + +export const LabRole = Schema.Literals(ROLES) +export type LabRole = typeof LabRole.Type + +const decodeRole = Schema.decodeUnknownOption(LabRole) + +/** The URL's `?role=`; every permission (`admin`) when it names none. */ +export const readLabRole: Effect.Effect = Effect.sync(() => + Option.getOrElse( + decodeRole(new URLSearchParams(globalThis.location.search).get("role")), + (): LabRole => "admin", + ), +) + +/** The session's grants for the role, as the page's own read would answer. */ +export const labGrants = (role: LabRole): ReadonlyArray => + role === "admin" ? Policy.allPermissions : Policy.grantsOf([`org:${role}`]) diff --git a/apps/web/src/lab/worst-case-lab/view-errors.ts b/apps/web/src/lab/worst-case-lab/view-errors.ts index bf9e7c0279..3e498b04a8 100644 --- a/apps/web/src/lab/worst-case-lab/view-errors.ts +++ b/apps/web/src/lab/worst-case-lab/view-errors.ts @@ -51,6 +51,7 @@ export const errorsSection = (h: H, model: Model): Html => { sparkWindow: fixture.sparkWindow, picker: null, rowMenu: null, + canTriage: true, }, toParentMessage: (message: SignalMessage) => Message.GotSignalRowMessage({ message }), diff --git a/apps/web/src/page/alerts/hub/analyst.scene.test.ts b/apps/web/src/page/alerts/hub/analyst.scene.test.ts new file mode 100644 index 0000000000..0ec29ece44 --- /dev/null +++ b/apps/web/src/page/alerts/hub/analyst.scene.test.ts @@ -0,0 +1,98 @@ +import { OrgId } from "@maple/domain" +import * as Policy from "@maple/domain/policy" +import { Schema, Array as Arr } from "effect" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, test } from "vitest" +import type { PageContext } from "../../contract" +import { hubAt, type HubAt, incidentRow, NOW, ORG, ruleRow, stateRow } from "../../kit/alert-test-rows" +import { anomalyIncidentRow, scopedAnomalyRuleRow } from "../shared/anomaly-test-fixtures" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// 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 ANALYST = Policy.grantsOf(["org:analyst"]) + +const ACKNOWLEDGED = incidentRow({ + id: "0199a003-0000-7000-8000-000000000002", + rule_name: "Payment p95 latency", + group_key: "checkout", + acknowledged_at: new Date(NOW - 60_000).toISOString(), + acknowledged_by_name: "Dana Kim", + acknowledged_via: "slack", +}) + +/** One firing and one acknowledged incident, plus an open anomaly, read by `grants`. */ +const loaded = (grants: ReadonlyArray, at: HubAt = {}): Model => + Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedRules({ + orgId: ORG, + rows: [ruleRow(2, { name: "Payment p95 latency" }), scopedAnomalyRuleRow()], + }), + Message.UpdatedRuleStates({ + orgId: ORG, + rows: [stateRow(2, { last_status: "breached", last_value: 1840 })], + }), + Message.UpdatedIncidents({ + orgId: ORG, + rows: [ + incidentRow({ rule_name: "Payment p95 latency" }), + ACKNOWLEDGED, + anomalyIncidentRow({ + n: 1, + ruleN: 8, + severity: "critical", + service: "checkout", + environment: "production", + }), + ], + }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + ], + init(hubAt(at), context).model, + (model, message) => pageUpdate(model, message).model, + ) + +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(ANALYST)), + expectView(text(/1 acknowledged/)).toExist(), + expectView(text(/^Acknowledged by Dana Kim ·/)).toExist(), + expectView(role("button", { name: /^Acknowledge / })).toBeAbsent(), + expectView(role("button", { name: /^Unacknowledge / })).toBeAbsent(), + ) + }) + + test("a member gets a button to flip each row's acknowledgement", () => { + scene( + { update: pageUpdate, view }, + given(loaded(MEMBER)), + expectView(role("button", { name: /^Unacknowledge / })).toExist(), + ) + }) + + test("the Anomalies view offers Acknowledge to a member and not to an analyst", () => { + scene( + { update: pageUpdate, view }, + given(loaded(ANALYST, { signal: "anomaly" })), + expectView(text(/^1 firing/)).toExist(), + expectView(role("button", { name: /^Acknowledge / })).toBeAbsent(), + ) + scene( + { update: pageUpdate, view }, + given(loaded(MEMBER, { signal: "anomaly" })), + expectView(role("button", { name: /^Acknowledge / })).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/hub/view-inbox.ts b/apps/web/src/page/alerts/hub/view-inbox.ts index 9d8d763edb..deecc00eb2 100644 --- a/apps/web/src/page/alerts/hub/view-inbox.ts +++ b/apps/web/src/page/alerts/hub/view-inbox.ts @@ -55,11 +55,14 @@ const SECTIONS_GRID = "grid gap-8 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]" type Range = Readonly<{ min: number; max: number }> -/** The inbox rows' acknowledge button: the message it sends, and the rows with a request in flight. */ +/** + * The inbox rows' acknowledge button: the message it sends, and the rows with a request in flight. + * `null` for a viewer who may not respond to incidents: the rows carry no button. + */ export type InboxAcknowledge = Readonly<{ onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => M pendingIds: ReadonlyArray -}> +}> | null const isAcknowledged = (incident: AlertIncidentDocument) => incident.acknowledgedAt !== null @@ -185,7 +188,7 @@ const openIncidentsList = ( h.span([], ["Open · ", String(incidents.length)]), h.span([], ["Now / threshold"]), h.span([], ["Open for"]), - h.span([h.Class("sr-only")], ["Acknowledge"]), + ...(acknowledge === null ? [] : [h.span([h.Class("sr-only")], ["Acknowledge"])]), ], ), dividedList( @@ -253,28 +256,36 @@ const openIncidentRow = ( typography(h, { size: "xs", mono: true, numeric: true }, [ formatAlertDuration(incident.firstTriggeredAt, msIso(now)), ]), - // Lifted above the row's stretched link, so the click acknowledges instead of navigating. - hstack(h, { justify: "end", className: ROW_LINK_LIFT }, [ - button( - h, - { - // Both states are the same small outline button: one row never reads as bare text. - variant: "outline", - size: "sm", - isLoading: acknowledge.pendingIds.includes(incident.id), - attributes: [ - h.OnClick(acknowledge.onAcknowledge(incident.id, acked === null)), - h.AriaLabel( - `${acked === null ? "Acknowledge" : "Unacknowledge"} ${incidentTitle(incident)}`, - ), - ], - }, - acked === null ? [icon(h, "CheckIcon"), "Acknowledge"] : ["Unacknowledge"], - ), - ]), + ...(acknowledge === null ? [] : [acknowledgeCell(h, incident, acknowledge, acked === null)]), ]) } +/** Lifted above the row's stretched link, so the click acknowledges instead of navigating. */ +const acknowledgeCell = ( + h: HtmlBuilder, + incident: AlertIncidentDocument, + acknowledge: NonNullable>, + isUnacknowledged: boolean, +): Html => + hstack(h, { justify: "end", className: ROW_LINK_LIFT }, [ + button( + h, + { + // Both states are the same small outline button: one row never reads as bare text. + variant: "outline", + size: "sm", + isLoading: acknowledge.pendingIds.includes(incident.id), + attributes: [ + h.OnClick(acknowledge.onAcknowledge(incident.id, isUnacknowledged)), + h.AriaLabel( + `${isUnacknowledged ? "Acknowledge" : "Unacknowledge"} ${incidentTitle(incident)}`, + ), + ], + }, + isUnacknowledged ? [icon(h, "CheckIcon"), "Acknowledge"] : ["Unacknowledge"], + ), + ]) + const fireStrip = ( h: HtmlBuilder, lanes: ReadonlyArray, diff --git a/apps/web/src/page/alerts/hub/view.ts b/apps/web/src/page/alerts/hub/view.ts index 0d602d515f..a3a8e620b1 100644 --- a/apps/web/src/page/alerts/hub/view.ts +++ b/apps/web/src/page/alerts/hub/view.ts @@ -50,7 +50,7 @@ import { slosTab } from "./view-slos" import { deleteScheduleDialog, hasSchedules, oncallTab, whoIsOnCallStrip } from "./view-oncall" import { hstack, vstack } from "@maple/views/ui/stack" import { typography } from "@maple/views/ui/typography" -import { canManageAlerts } from "../../kit/session-grants" +import { canManageAlerts, canRespondToIncidents } from "../../kit/session-grants" type H = HtmlBuilder @@ -274,11 +274,13 @@ const hubBody = ( serviceName: model.search.serviceName, onAddDestination: canManageAlerts(model) ? Message.ClickedNewDestination() : undefined, }) - const acknowledge = { - onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => - Message.ClickedAcknowledgeIncident({ incidentId, acknowledged }), - pendingIds: model.acknowledgingIncidentIds, - } + const acknowledge = canRespondToIncidents(model) + ? { + onAcknowledge: (incidentId: AlertIncidentId, acknowledged: boolean) => + Message.ClickedAcknowledgeIncident({ incidentId, acknowledged }), + pendingIds: model.acknowledgingIncidentIds, + } + : null if (tab === "inbox") { const signal = resolveSignal(model.search.signal) // A team can set up on-call before its first rule, so the strip shows on the empty inbox too. diff --git a/apps/web/src/page/alerts/incident/analyst.scene.test.ts b/apps/web/src/page/alerts/incident/analyst.scene.test.ts new file mode 100644 index 0000000000..cdabe4c0d1 --- /dev/null +++ b/apps/web/src/page/alerts/incident/analyst.scene.test.ts @@ -0,0 +1,103 @@ +import { OrgId } from "@maple/domain" +import * as Policy from "@maple/domain/policy" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import { buildAlertsLabFixture } from "../../../lab/alerts-fixture" +import type { PageContext } from "../../contract" +import { incidentRow, INCIDENT_ID, NOW, ORG, ruleRow } from "../../kit/alert-test-rows" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// 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" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const themed = () => Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) + +const MEMBER = Policy.grantsOf(["org:member"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) + +type Fixture = Readonly<{ + grants: ReadonlyArray + row?: Parameters[0] + hasInvestigation?: boolean +}> + +/** The open incident and its rule, with the session and the investigation read answered. */ +const loaded = (fixture: Fixture): Model => { + const synced = Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow(fixture.row)] }), + Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2)] }), + Message.CompletedFetchSession({ grants: fixture.grants }), + ], + init({ _tag: "AlertIncident", incidentId: INCIDENT_ID, search: {} }, context).model, + (model, message) => pageUpdate(model, message).model, + ) + return pageUpdate( + synced, + Message.CompletedFetchInvestigation({ + key: synced.investigation.key ?? "", + result: Result.succeed( + fixture.hasInvestigation === true ? buildAlertsLabFixture(NOW).incident.investigation : null, + ), + }), + ).model +} + +const ACKNOWLEDGED = { + acknowledged_at: new Date(NOW - 60_000).toISOString(), + acknowledged_by_name: "Dana Kim", + acknowledged_via: "slack", +} + +describe("alert incident, analyst", () => { + test("an analyst is not offered Acknowledge or Investigate, and a member is", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: ANALYST })), + themed(), + expectView(role("button", { name: "Acknowledge" })).toBeAbsent(), + expectView(role("button", { name: "Investigate" })).toBeAbsent(), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ grants: MEMBER })), + themed(), + expectView(role("button", { name: "Acknowledge" })).toExist(), + expectView(role("button", { name: "Investigate" })).toExist(), + ) + }) + + test("an analyst reads who acknowledged it and is not offered Unacknowledge", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: ANALYST, row: ACKNOWLEDGED })), + themed(), + expectView(text(/Acknowledged by Dana Kim at/)).toExist(), + expectView(role("button", { name: "Unacknowledge" })).toBeAbsent(), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ grants: MEMBER, row: ACKNOWLEDGED })), + themed(), + expectView(role("button", { name: "Unacknowledge" })).toExist(), + ) + }) + + test("an analyst can still open an investigation that already ran", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: ANALYST, hasInvestigation: true })), + themed(), + expectView(role("link", { name: "Open investigation" })).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/alerts/incident/incident.scene.test.ts b/apps/web/src/page/alerts/incident/incident.scene.test.ts index 9eb682e994..56366edec5 100644 --- a/apps/web/src/page/alerts/incident/incident.scene.test.ts +++ b/apps/web/src/page/alerts/incident/incident.scene.test.ts @@ -61,6 +61,9 @@ const route = (incidentId = INCIDENT_ID) => ({ _tag: "AlertIncident" as const, i const step = (model: Model, message: Message) => pageUpdate(model, message) +/** The lowest role that may acknowledge an incident and start an investigation. */ +const MEMBER = Policy.grantsOf(["org:member"]) + describe("alert incident", () => { test("entry reads the clock and loads the chart runtime; the incident's reads follow its row", () => { const entered = init(route(), context) @@ -434,6 +437,7 @@ describe("alert incident", () => { Message.ReadClock({ nowMs: NOW }), Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), Message.UpdatedRules({ orgId: ORG, rows: [ruleRow(2)] }), + Message.CompletedFetchSession({ grants: MEMBER }), ], init(route(), context).model, (model, message) => step(model, message).model, @@ -534,6 +538,7 @@ describe("alert incident", () => { [ Message.ReadClock({ nowMs: NOW }), Message.UpdatedIncidents({ orgId: ORG, rows: [incidentRow()] }), + Message.CompletedFetchSession({ grants: MEMBER }), ], init(route(), context).model, (model, message) => step(model, message).model, @@ -643,6 +648,7 @@ describe("alert incident", () => { }), ], }), + Message.CompletedFetchSession({ grants: MEMBER }), ], init(route(), context).model, (model, message) => step(model, message).model, diff --git a/apps/web/src/page/alerts/incident/view.ts b/apps/web/src/page/alerts/incident/view.ts index 4f9ddf2018..282dda6f15 100644 --- a/apps/web/src/page/alerts/incident/view.ts +++ b/apps/web/src/page/alerts/incident/view.ts @@ -60,7 +60,7 @@ import { anomalyEvidence, type AnomalyEvidenceInputs } from "./view-anomaly" import { ANOMALY_CHART_TOKENS } from "../../kit/anomaly-evidence" import { timestamp } from "@maple/views/ui/timestamp" import { typography } from "@maple/views/ui/typography" -import { canManageAlerts } from "../../kit/session-grants" +import { canManageAlerts, canRespondToIncidents, canRunInvestigations } from "../../kit/session-grants" type H = HtmlBuilder @@ -218,9 +218,15 @@ export const acknowledgeAction = ( ] } -const investigateAction = (h: H, model: Model, investigation: V2Investigation | null): Html => - investigation !== null - ? linkButton( +/** A link to the incident's investigation, or the button that starts one for who may run one. */ +const investigateAction = ( + h: H, + model: Model, + investigation: V2Investigation | null, +): ReadonlyArray => { + if (investigation !== null) + return [ + linkButton( h, { variant: "outline", @@ -228,20 +234,25 @@ const investigateAction = (h: H, model: Model, investigation: V2Investigation | href: hrefOf(AppRoute.Investigation({ id: String(investigation.id), search: {} })), }, [icon(h, "ChatBubbleSparkleIcon"), "Open investigation"], - ) - : button( - h, - { - variant: "outline", - size: "sm", - isDisabled: - model.isStartingInvestigation || - AsyncData.isLoading(model.investigation.data) || - AsyncData.isIdle(model.investigation.data), - attributes: [h.OnClick(Message.ClickedInvestigate())], - }, - [icon(h, "ChatBubbleSparkleIcon"), "Investigate"], - ) + ), + ] + if (!canRunInvestigations(model)) return [] + return [ + button( + h, + { + variant: "outline", + size: "sm", + isDisabled: + model.isStartingInvestigation || + AsyncData.isLoading(model.investigation.data) || + AsyncData.isIdle(model.investigation.data), + attributes: [h.OnClick(Message.ClickedInvestigate())], + }, + [icon(h, "ChatBubbleSparkleIcon"), "Investigate"], + ), + ] +} /** What `incidentBody` renders: the page reads it off its Model, the lab off a fixture. */ export type IncidentBodyInputs = Readonly<{ @@ -500,15 +511,18 @@ export const view = Submodel.defineView((model, silenceBadges(h, model, view, timeZone), ), actions: hstack(h, { gap: "2", wrap: true }, [ - ...acknowledgeAction(h, { - incident: view.incident, - isLoading: model.isUpdatingAcknowledgement, - onAcknowledge: (acknowledged) => Message.ClickedAcknowledge({ acknowledged }), - }), + ...(canRespondToIncidents(model) + ? acknowledgeAction(h, { + incident: view.incident, + isLoading: model.isUpdatingAcknowledgement, + onAcknowledge: (acknowledged) => + Message.ClickedAcknowledge({ acknowledged }), + }) + : []), ...(canManageAlerts(model) && isOpenIncident(model) ? [silenceButton(h, Message.ClickedSilence(), model.rules === null)] : []), - investigateAction( + ...investigateAction( h, model, AsyncData.getOrElse(model.investigation.data, () => null), diff --git a/apps/web/src/page/anomalies/detail/analyst.scene.test.ts b/apps/web/src/page/anomalies/detail/analyst.scene.test.ts new file mode 100644 index 0000000000..f40cf2797b --- /dev/null +++ b/apps/web/src/page/anomalies/detail/analyst.scene.test.ts @@ -0,0 +1,142 @@ +import { OrgId } from "@maple/domain" +import { AnomalyIncidentId } from "@maple/domain/http" +import { encodePublicId, PublicIdPrefixes, V2AnomalyIncident } from "@maple/domain/http/v2" +import * as Policy from "@maple/domain/policy" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import { toLoadFailure } from "@maple/views/data/load-failure" +import type { PageContext } from "../../contract" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// 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") +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const themed = () => Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) +const ID = Schema.decodeSync(AnomalyIncidentId)("0199a060-0000-7000-8000-000000000001") +const NOW = Date.UTC(2026, 9, 1, 12) + +const MEMBER = Policy.grantsOf(["org:member"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) + +const incidentOf = (overrides: Readonly>) => + Schema.decodeSync(V2AnomalyIncident)({ + id: encodePublicId(PublicIdPrefixes.anomalyIncident, ID), + object: "anomaly_incident", + detector_key: "latency_p95:payment-svc:production", + signal_type: "latency_p95", + service_name: "payment-svc", + deployment_env: "production", + fingerprint_hash: null, + error_issue_id: null, + status: "open", + severity: "critical", + opened_value: 1620, + baseline_median: 412, + baseline_sigma: 100, + threshold_value: 640, + last_observed_value: 1840, + last_sample_count: 3100, + first_triggered_at: new Date(NOW - 3_600_000).toISOString(), + last_triggered_at: new Date(NOW - 60_000).toISOString(), + resolved_at: null, + resolve_reason: null, + triage_status: "completed", + fingerprints: [], + reopen_count: 0, + last_reopened_at: null, + ...overrides, + }) + +/** The open anomaly, read by a session holding `grants`. */ +const loaded = ( + grants: ReadonlyArray, + overrides: Readonly> = {}, +): Model => { + const entered = init({ _tag: "Anomaly", incidentId: ID }, context).model + return Arr.reduce( + [ + Message.ReadClock({ nowMs: NOW }), + Message.CompletedFetchSession({ orgId, grants }), + Message.CompletedFetchIncident({ + key: entered.incident.key ?? "", + result: Result.succeed(incidentOf(overrides)), + }), + ], + entered, + (model, message) => pageUpdate(model, message).model, + ) +} + +/** Linked to an issue whose own read failed: the card names it and keeps its actions. */ +const linked = (grants: ReadonlyArray): Model => { + const model = loaded(grants, { error_issue_id: "iss_YoxMYDoXSr3tsBZyBLmNMv" }) + return pageUpdate( + model, + Message.CompletedFetchLinkedIssue({ + key: model.linkedIssue.key ?? "", + result: Result.fail(toLoadFailure(new Error("down"))), + }), + ).model +} + +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(ANALYST)), + themed(), + expectView(text("Not linked to an issue")).toExist(), + expectView(text("Details")).toExist(), + expectView(role("button", { name: "Resolve" })).toBeAbsent(), + expectView(role("button", { name: "Resolve anomaly" })).toBeAbsent(), + expectView(role("button", { name: "Link issue" })).toBeAbsent(), + expectView(role("button", { name: "Open investigation" })).toBeAbsent(), + expectView(text("Actions")).toBeAbsent(), + ) + }) + + test("a member is offered Resolve and an investigation", () => { + scene( + { update: pageUpdate, view }, + given(loaded(MEMBER)), + themed(), + expectView(role("button", { name: "Resolve" })).toExist(), + expectView(role("button", { name: "Resolve anomaly" })).toExist(), + expectView(role("button", { name: "Open investigation" })).toExist(), + expectView(text("Actions")).toExist(), + ) + }) + + test("an analyst can open the linked issue and cannot relink or unlink it; a member can", () => { + scene( + { update: pageUpdate, view }, + given(linked(ANALYST)), + themed(), + expectView(role("link", { name: "Open issue" })).toExist(), + expectView(role("button", { name: "Link a different issue" })).toBeAbsent(), + expectView(role("button", { name: "Unlink issue" })).toBeAbsent(), + ) + scene( + { update: pageUpdate, view }, + given(linked(MEMBER)), + themed(), + expectView(role("button", { name: "Link a different issue" })).toExist(), + expectView(role("button", { name: "Unlink issue" })).toExist(), + ) + }) + + test("an answer for another org leaves the page's grants as they were", () => { + const model = loaded(ANALYST) + const other = pageUpdate(model, Message.CompletedFetchSession({ orgId: "org_other", grants: MEMBER })) + expect(other.model.grants).toEqual(ANALYST) + }) +}) diff --git a/apps/web/src/page/anomalies/detail/command.ts b/apps/web/src/page/anomalies/detail/command.ts index b7893a2df4..803ab284af 100644 --- a/apps/web/src/page/anomalies/detail/command.ts +++ b/apps/web/src/page/anomalies/detail/command.ts @@ -5,10 +5,13 @@ import { Effect, Schema } from "effect" import { Command } from "foldkit" import { settled } from "@maple/views/data/load-failure" import { MapleApiV2Client } from "../../../platform/api-clients" +import { fetchSessionGrants } from "../../kit/session-grants" import { Message } from "./message" export const ReadAnomalyClock = readClock("ReadAnomalyClock", Message.ReadClock) +export const FetchAnomalySession = fetchSessionGrants("FetchAnomalySession", Message.CompletedFetchSession) + export const FetchIncident = Command.define("FetchAnomalyIncident", { args: { key: Schema.String, id: AnomalyIncidentId }, messages: [Message.CompletedFetchIncident], diff --git a/apps/web/src/page/anomalies/detail/message.ts b/apps/web/src/page/anomalies/detail/message.ts index f31a8acccc..05df921e18 100644 --- a/apps/web/src/page/anomalies/detail/message.ts +++ b/apps/web/src/page/anomalies/detail/message.ts @@ -7,9 +7,11 @@ import * as Chart from "@maple/views/charts/chart" import { LoadFailure } from "@maple/views/data/load-failure" import * as Combobox from "@maple/views/ui/combobox" import * as Dialog from "@maple/views/ui/dialog" +import { SessionGrants } from "../../kit/session-grants" export const Message = defineMessageUnion({ ReadClock: { nowMs: Schema.Number }, + CompletedFetchSession: SessionGrants, CompletedFetchIncident: { key: Schema.String, result: Schema.Result(V2AnomalyIncident, LoadFailure) }, CompletedFetchTimeseries: { key: Schema.String, diff --git a/apps/web/src/page/anomalies/detail/model.ts b/apps/web/src/page/anomalies/detail/model.ts index e30a1c6d1a..71046f7ad8 100644 --- a/apps/web/src/page/anomalies/detail/model.ts +++ b/apps/web/src/page/anomalies/detail/model.ts @@ -1,6 +1,7 @@ import { ChartEnvFields } from "@maple/views/charts/chart-env" import { AnomalyIncidentId } from "@maple/domain/http" import { V2AnomalyIncident, V2AnomalyIncidentTimeseries, V2ErrorIssue } from "@maple/domain/http/v2" +import * as Policy from "@maple/domain/policy" import { Schema } from "effect" import * as Chart from "@maple/views/charts/chart" import { KeyedSchema } from "@maple/views/data/keyed" @@ -14,6 +15,8 @@ export type Busy = typeof Busy.Type export const Model = Schema.Struct({ incidentId: AnomalyIncidentId, nowMs: Schema.Number, + /** 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), /** Read once the incident names an issue. */ diff --git a/apps/web/src/page/anomalies/detail/update.ts b/apps/web/src/page/anomalies/detail/update.ts index 6db804356a..e06bdde0f0 100644 --- a/apps/web/src/page/anomalies/detail/update.ts +++ b/apps/web/src/page/anomalies/detail/update.ts @@ -14,6 +14,7 @@ import * as Dialog from "@maple/views/ui/dialog" import { type PageContext, PageOutMessage, type PageReturn } from "../../contract" import { CreateInvestigation, + FetchAnomalySession, FetchIncident, FetchLinkedIssue, FetchLinkIssues, @@ -25,6 +26,9 @@ import { import { incidentOf, investigationPayload } from "./derive" import { Message } from "./message" import type { Model } from "./model" +import * as Policy from "@maple/domain/policy" +import { isClerkAuthEnabled } from "../../../platform/auth-mode" +import { acceptGrants } from "../../kit/session-grants" type Return = PageReturn type Commands = ReadonlyArray> @@ -125,6 +129,8 @@ export const init = (route: AnomalyRoute, context: PageContext): Return => { const model: Model = { incidentId: route.incidentId, nowMs: 0, + // None until the session answers; self-hosted mode has no session and holds every permission. + grants: isClerkAuthEnabled ? [] : Policy.allPermissions, incident: Keyed.idle(), timeseries: Keyed.idle(), linkedIssue: Keyed.idle(), @@ -141,7 +147,12 @@ export const init = (route: AnomalyRoute, context: PageContext): Return => { const loaded = loadIncident(model, context, "Load") return { model: loaded.model, - commands: [ReadAnomalyClock(), ...(loaded.commands ?? []), LoadChartRuntime()], + commands: [ + ReadAnomalyClock(), + ...(isClerkAuthEnabled ? [FetchAnomalySession({ orgId: context.orgId })] : []), + ...(loaded.commands ?? []), + LoadChartRuntime(), + ], } } @@ -168,6 +179,7 @@ const afterWrite = (model: Model, context: PageContext): Return => export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { ReadClock: ({ nowMs }) => ({ model: { ...model, nowMs } }), + CompletedFetchSession: (answer) => ({ model: acceptGrants(model, context.orgId, answer) }), CompletedFetchIncident: ({ key, result }) => followIncident( { ...model, incident: Keyed.accept(model.incident, key, result) }, diff --git a/apps/web/src/page/anomalies/detail/view-body.ts b/apps/web/src/page/anomalies/detail/view-body.ts index 90562caa8f..b5fca443d7 100644 --- a/apps/web/src/page/anomalies/detail/view-body.ts +++ b/apps/web/src/page/anomalies/detail/view-body.ts @@ -70,6 +70,7 @@ const cardActions = ( h: H, issueId: NonNullable, isBusy: boolean, + canRespond: boolean, ): Html => hstack(h, { gap: "1.5", className: "shrink-0" }, [ linkButton( @@ -81,24 +82,28 @@ const cardActions = ( }, ["Open issue", icon(h, "ArrowRightIcon")], ), - iconButton( - h, - { - label: "Link a different issue", - isDisabled: isBusy, - attributes: [h.OnClick(Message.ClickedLinkIssue())], - }, - [icon(h, "LinkIcon")], - ), - iconButton( - h, - { - label: "Unlink issue", - isDisabled: isBusy, - attributes: [h.OnClick(Message.ClickedUnlink())], - }, - [icon(h, "XmarkIcon")], - ), + ...(canRespond + ? [ + iconButton( + h, + { + label: "Link a different issue", + isDisabled: isBusy, + attributes: [h.OnClick(Message.ClickedLinkIssue())], + }, + [icon(h, "LinkIcon")], + ), + iconButton( + h, + { + label: "Unlink issue", + isDisabled: isBusy, + attributes: [h.OnClick(Message.ClickedUnlink())], + }, + [icon(h, "XmarkIcon")], + ), + ] + : []), ]) export const linkedIssueCard = ( @@ -106,6 +111,8 @@ export const linkedIssueCard = ( model: Model, incident: AnomalyIncidentDocument, isBusy: boolean, + /** The viewer may link, relink and unlink an issue (`incidents:respond`). */ + canRespond: boolean, ): Html => { const issueId = incident.errorIssueId if (issueId === null) @@ -119,11 +126,16 @@ export const linkedIssueCard = ( typography(h, { size: "sm", tone: "foreground", as: "p" }, [ "Not linked to an issue", ]), - typography(h, { size: "xs", tone: "muted", as: "p" }, [ - incident.signalType === "error_spike" - ? "This frequency increase is fingerprint-scoped, so an issue with this fingerprint may already exist for the service." - : "Escalate this anomaly by linking it to an existing error issue.", - ]), + // The second line is an instruction, so it is left off for who cannot link. + ...(incident.signalType === "error_spike" || canRespond + ? [ + typography(h, { size: "xs", tone: "muted", as: "p" }, [ + incident.signalType === "error_spike" + ? "This frequency increase is fingerprint-scoped, so an issue with this fingerprint may already exist for the service." + : "Escalate this anomaly by linking it to an existing error issue.", + ]), + ] + : []), ...(incident.fingerprintHash === null ? [] : [ @@ -138,16 +150,20 @@ export const linkedIssueCard = ( ]), ], ), - button( - h, - { - size: "sm", - variant: "outline", - isDisabled: isBusy, - attributes: [h.OnClick(Message.ClickedLinkIssue())], - }, - [icon(h, "LinkIcon"), "Link issue"], - ), + ...(canRespond + ? [ + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: isBusy, + attributes: [h.OnClick(Message.ClickedLinkIssue())], + }, + [icon(h, "LinkIcon"), "Link issue"], + ), + ] + : []), ]), ], ) @@ -167,7 +183,7 @@ export const linkedIssueCard = ( h.code([h.Class(typographyClass({ size: "xs", mono: true }))], [shortIssueId(id)]), ". Couldn't load its details.", ]), - cardActions(h, id, isBusy), + cardActions(h, id, isBusy, canRespond), ]) } function loadedIssue(wire: Parameters[0]): Html { @@ -234,7 +250,7 @@ export const linkedIssueCard = ( ]), ], ), - cardActions(h, issue.id, isBusy), + cardActions(h, issue.id, isBusy, canRespond), ]) } return card(h, [cardPanel(h, [content], "space-y-3 p-4 text-sm")]) diff --git a/apps/web/src/page/anomalies/detail/view-sidebar.ts b/apps/web/src/page/anomalies/detail/view-sidebar.ts index 4d7a3dfec2..e671ec9c98 100644 --- a/apps/web/src/page/anomalies/detail/view-sidebar.ts +++ b/apps/web/src/page/anomalies/detail/view-sidebar.ts @@ -28,6 +28,8 @@ type H = HtmlBuilder export type SidebarInput = Readonly<{ incident: AnomalyIncidentDocument isBusy: boolean + /** The viewer may resolve the anomaly and link an issue (`incidents:respond`). */ + canRespond: boolean nowMs: number timeZone: string }> @@ -38,6 +40,51 @@ const row = (h: H, label: string, children: ReadonlyArray, title? const mono = (h: H, value: string, className: string): Html => h.span([h.Class(cn(typographyClass({ size: "sm", mono: true, numeric: true }), className))], [value]) +/** Resolve the anomaly, and link it to an error issue or take the link away. */ +const actionsGroup = (h: H, input: SidebarInput): Html => { + const { incident } = input + return detailRailGroup(h, "Actions", [ + ...(incident.status === "open" + ? [ + button( + h, + { + size: "sm", + variant: "outline", + className: "w-full", + isDisabled: input.isBusy, + attributes: [h.OnClick(Message.ClickedResolve())], + }, + ["Resolve anomaly"], + ), + ] + : []), + incident.errorIssueId === null + ? button( + h, + { + size: "sm", + variant: "outline", + className: "w-full", + isDisabled: input.isBusy, + attributes: [h.OnClick(Message.ClickedLinkIssue())], + }, + [icon(h, "LinkIcon"), "Link issue"], + ) + : button( + h, + { + size: "sm", + variant: "outline", + className: "w-full", + isDisabled: input.isBusy, + attributes: [h.OnClick(Message.ClickedUnlink())], + }, + ["Unlink ", shortIssueId(incident.errorIssueId)], + ), + ]) +} + export const anomalySidebar = (h: H, input: SidebarInput): Html => { const { incident } = input const isOpen = incident.status === "open" @@ -50,46 +97,7 @@ export const anomalySidebar = (h: H, input: SidebarInput): Html => { const time = (value: string, className?: string) => relativeTime(h, { value, nowMs: input.nowMs, timeZone: input.timeZone, className }) return vstack(h, {}, [ - detailRailGroup(h, "Actions", [ - ...(isOpen - ? [ - button( - h, - { - size: "sm", - variant: "outline", - className: "w-full", - isDisabled: input.isBusy, - attributes: [h.OnClick(Message.ClickedResolve())], - }, - ["Resolve anomaly"], - ), - ] - : []), - incident.errorIssueId === null - ? button( - h, - { - size: "sm", - variant: "outline", - className: "w-full", - isDisabled: input.isBusy, - attributes: [h.OnClick(Message.ClickedLinkIssue())], - }, - [icon(h, "LinkIcon"), "Link issue"], - ) - : button( - h, - { - size: "sm", - variant: "outline", - className: "w-full", - isDisabled: input.isBusy, - attributes: [h.OnClick(Message.ClickedUnlink())], - }, - ["Unlink ", shortIssueId(incident.errorIssueId)], - ), - ]), + ...(input.canRespond ? [actionsGroup(h, input)] : []), detailRailGroup(h, "Details", [ row(h, "State", [ h.span( diff --git a/apps/web/src/page/anomalies/detail/view.ts b/apps/web/src/page/anomalies/detail/view.ts index a1107b5f15..8741a81233 100644 --- a/apps/web/src/page/anomalies/detail/view.ts +++ b/apps/web/src/page/anomalies/detail/view.ts @@ -28,6 +28,7 @@ import { linkIssueDialog } from "./view-link-dialog" import { anomalySidebar } from "./view-sidebar" import { hstack } from "@maple/views/ui/stack" import { Array as Arr } from "effect" +import { canRespondToIncidents, canRunInvestigations } from "../../kit/session-grants" type H = HtmlBuilder @@ -69,18 +70,22 @@ const headerActions = (h: H, model: Model, incident: AnomalyIncidentDocument, ti : "Resolved", ], ), - button( - h, - { - size: "sm", - variant: "outline", - isDisabled: isBusy, - isLoading: model.busy === "investigate", - attributes: [h.OnClick(Message.ClickedInvestigate())], - }, - [icon(h, "PulseIcon"), "Open investigation"], - ), - ...(isOpen + ...(canRunInvestigations(model) + ? [ + button( + h, + { + size: "sm", + variant: "outline", + isDisabled: isBusy, + isLoading: model.busy === "investigate", + attributes: [h.OnClick(Message.ClickedInvestigate())], + }, + [icon(h, "PulseIcon"), "Open investigation"], + ), + ] + : []), + ...(isOpen && canRespondToIncidents(model) ? [ button( h, @@ -130,7 +135,7 @@ const loadedBody = ( { as: "h2", className: "mb-3 block", attributes: [h.Id("linked-issue-heading")] }, ["Linked issue"], ), - linkedIssueCard(h, model, incident, isBusy), + linkedIssueCard(h, model, incident, isBusy, canRespondToIncidents(model)), ], ), ], @@ -164,6 +169,7 @@ export const view = Submodel.defineView((model, rightPanel: anomalySidebar(h, { incident: state.incident, isBusy: model.busy !== null, + canRespond: canRespondToIncidents(model), nowMs: model.nowMs, timeZone, }), diff --git a/apps/web/src/page/errors/hub/analyst.scene.test.ts b/apps/web/src/page/errors/hub/analyst.scene.test.ts new file mode 100644 index 0000000000..1b6f6f72c5 --- /dev/null +++ b/apps/web/src/page/errors/hub/analyst.scene.test.ts @@ -0,0 +1,200 @@ +import { OrgId } from "@maple/domain" +import { V2ErrorIssue } from "@maple/domain/http/v2" +import * as Policy from "@maple/domain/policy" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, expect, test } from "vitest" +import * as ContextMenu from "@maple/views/ui/context-menu" +import { MeasureTabIndicator, Message as TabsMessage } from "@maple/views/ui/tabs" +import type { PageContext } from "../../contract" +import { MountFilterRail, MountRowSelect } from "./command" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// 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") +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const window = { startTime: "2026-09-30 12:00:00", endTime: "2026-10-01 12:00:00" } +const NOW = Date.parse("2026-10-01T12:00:00Z") + +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const MEMBER = Policy.grantsOf(["org:member"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) + +const RECT = { left: 2, right: 115, top: 2, bottom: 2, width: 51, height: 28 } +const mounted = [ + Mount.resolve(MeasureTabIndicator, TabsMessage.MeasuredIndicator({ rect: RECT })), + Mount.resolve(MountFilterRail, Message.MountedFilterRail()), + Mount.resolve(MountRowSelect, Message.ClickedRowSelect({ id: "", isShift: false })), +] as const + +const ISSUE_ID = "0199a004-0000-7000-8000-000000000001" +const issue = Schema.decodeSync(V2ErrorIssue)({ + id: "iss_YoxMYDoXSr3tsBZyBLmNMv", + object: "error_issue", + kind: "error", + fingerprint_hash: "12660752272409977512", + service_name: "payment-svc", + exception_type: "ConnectionTimeout", + exception_message: "timed out acquiring a connection", + error_label: "ConnectionTimeout", + top_frame: "", + workflow_state: "triage", + priority: 1, + severity: "critical", + severity_source: "detector", + source_ref: null, + assigned_actor: null, + lease_holder: null, + lease_expires_at: null, + claimed_at: null, + notes: null, + first_seen_at: "2026-10-01T10:00:00.000Z", + last_seen_at: "2026-10-01T11:44:00.000Z", + occurrence_count: 12, + resolved_at: null, + last_resolved_at: null, + last_regressed_at: null, + regression_count: 0, + resolved_versions: [], + snooze_until: null, + archived_at: null, + has_open_incident: false, + comment_count: 0, + open_pull_request_count: 0, + merged_pull_request_count: 0, +}) + +/** Entered with the session's grants, its window and one row. */ +const loaded = (grants: ReadonlyArray): Model => { + const windowed = Arr.reduce( + [ + Message.CompletedFetchSession({ orgId, grants }), + Message.CompletedResolveWindow({ window, nowMs: NOW, mode: "Load" }), + Message.MountedFilterRail(), + ], + init({ search: {} }, context).model, + (model, message) => pageUpdate(model, message).model, + ) + return pageUpdate( + windowed, + Message.CompletedFetchPage({ + key: windowed.pages[0]?.page.key ?? "", + result: Result.succeed({ data: [issue], hasMore: false, nextCursor: null }), + }), + ).model +} + +const press = (model: Model, key: string): Model => + pageUpdate(model, Message.PressedListKey({ key, isShift: false })).model + +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +/** The row's right-click menu, opened and its entry at `index` picked. */ +const pickedFromRowMenu = (model: Model, index: number) => { + const opened = pageUpdate(model, Message.OpenedRowMenu({ id: ISSUE_ID })).model + return pageUpdate( + opened, + Message.GotRowMenuMessage({ message: ContextMenu.Message.ClickedItem({ index }) }), + ) +} + +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(ANALYST)), + ...mounted, + expectView(role("link", { name: /ConnectionTimeout/ })).toHaveAttr( + "href", + `/errors/issues/${ISSUE_ID}`, + ), + expectView(role("img", { name: "Critical" })).toExist(), + expectView(text("Triage")).toExist(), + expectView(role("checkbox")).toBeAbsent(), + expectView(role("button", { name: /Change severity/ })).toBeAbsent(), + expectView(role("button", { name: "Triage" })).toBeAbsent(), + ) + }) + + test("a member gets the checkboxes and both pickers", () => { + scene( + { update: pageUpdate, view }, + given(loaded(MEMBER)), + ...mounted, + expectView(role("checkbox", { name: "Select all errors" })).toExist(), + expectView(role("checkbox", { name: "Select this error" })).toExist(), + expectView(role("button", { name: /Change severity/ })).toExist(), + expectView(role("button", { name: "Triage" })).toExist(), + ) + }) + + test("a selection brings a member the bulk bar and an analyst nothing", () => { + const bulkBar = role("region", { name: "Bulk actions" }) + scene( + { update: pageUpdate, view }, + given({ ...loaded(MEMBER), selectedIds: [ISSUE_ID] }), + ...mounted, + expectView(bulkBar).toExist(), + expectView(role("button", { name: "Claim" })).toExist(), + ) + scene( + { update: pageUpdate, view }, + given({ ...loaded(ANALYST), selectedIds: [ISSUE_ID] }), + ...mounted, + expectView(bulkBar).toBeAbsent(), + expectView(role("button", { name: "Claim" })).toBeAbsent(), + ) + }) + + test("x, s and p do nothing; j still moves the focus and Enter opens the issue", () => { + const focused = press(loaded(ANALYST), "j") + expect(focused.focusedId).toBe(ISSUE_ID) + expect(press(focused, "x").selectedIds).toEqual([]) + expect(press(focused, "s").picker).toBeNull() + expect(press(focused, "p").picker).toBeNull() + expect( + pageUpdate(focused, Message.PressedListKey({ key: "Enter", isShift: false })).outMessage, + ).toMatchObject({ _tag: "RequestedNavigation", url: `/errors/issues/${ISSUE_ID}` }) + }) + + test("for a member x selects and s and p open the row's pickers", () => { + const focused = press(loaded(MEMBER), "j") + expect(press(focused, "x").selectedIds).toEqual([ISSUE_ID]) + expect(press(focused, "s").picker).toMatchObject({ issueId: ISSUE_ID, kind: "state" }) + expect(press(focused, "p").picker).toMatchObject({ issueId: ISSUE_ID, kind: "severity" }) + }) + + test("the row menu starts at Open in new tab and writes nothing", () => { + 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") + }) + + test("a member's row menu claims the issue", () => { + expect(names(pickedFromRowMenu(loaded(MEMBER), 1).commands)).toContain("RunErrorsHubWrite") + }) + + test("grants answered for another org are dropped", () => { + const other = Schema.decodeSync(OrgId)("org_other") + const model = pageUpdate( + loaded(MEMBER), + Message.CompletedFetchSession({ orgId: other, grants: ANALYST }), + ).model + scene( + { update: pageUpdate, view }, + given(model), + ...mounted, + expectView(role("checkbox", { name: "Select this error" })).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/errors/hub/command.ts b/apps/web/src/page/errors/hub/command.ts index 0c5e03b24a..7d36a045fe 100644 --- a/apps/web/src/page/errors/hub/command.ts +++ b/apps/web/src/page/errors/hub/command.ts @@ -17,6 +17,7 @@ import { loadSignalPresences } from "../../kit/signal-presence" import { Message } from "./message" import { HubWrite, runHubWrite } from "./writes" import { writeClipboard } from "@maple/ui/lib/clipboard" +import { fetchSessionGrants } from "../../kit/session-grants" /** The warehouse side's filters: the trend window plus the sidebar's service and environment. */ export const WarehouseFilters = Schema.Struct({ @@ -47,6 +48,8 @@ export const ResolveWindow = Command.define("ResolveErrorsWindow", { ), }) +export const FetchHubSession = fetchSessionGrants("FetchErrorsSession", Message.CompletedFetchSession) + export const FetchSummary = Command.define("FetchErrorsSummary", { args: { key: Schema.String, filters: WarehouseFilters }, messages: [Message.CompletedFetchSummary], diff --git a/apps/web/src/page/errors/hub/message.ts b/apps/web/src/page/errors/hub/message.ts index 17944ed6a9..e5715dcfee 100644 --- a/apps/web/src/page/errors/hub/message.ts +++ b/apps/web/src/page/errors/hub/message.ts @@ -13,6 +13,7 @@ import * as Tabs from "@maple/views/ui/tabs" import { SignalPresences } from "@maple/views/data/signal-presence" import { HubWrite, WriteOutcome } from "./writes" import { ErrorsSummary, IssuePage, SparkSeries, VolumeRow } from "./model" +import { SessionGrants } from "../../kit/session-grants" /** The sidebar's single-select params. */ export const FilterParam = Schema.Literals(["service", "env", "kind", "regressed"]) @@ -25,6 +26,7 @@ const result = (schema: A) => ({ export const Message = defineMessageUnion({ CompletedResolveWindow: { window: TimeWindow, nowMs: Schema.Number, mode: RequestMode }, + CompletedFetchSession: SessionGrants, CompletedFetchSummary: result(Schema.NullOr(ErrorsSummary)), CompletedFetchServiceCounts: result(Schema.Array(V2ErrorIssueServiceCount)), CompletedFetchEnvironments: result(Schema.Array(Schema.String)), diff --git a/apps/web/src/page/errors/hub/model.ts b/apps/web/src/page/errors/hub/model.ts index 2109494a1d..0e2b0a7636 100644 --- a/apps/web/src/page/errors/hub/model.ts +++ b/apps/web/src/page/errors/hub/model.ts @@ -1,4 +1,5 @@ import { V2ErrorIssue, V2ErrorIssueServiceCount, V2Investigation } from "@maple/domain/http/v2" +import * as Policy from "@maple/domain/policy" import { Schema } from "effect" import { KeyedSchema } from "@maple/views/data/keyed" import { LoadFailure } from "@maple/views/data/load-failure" @@ -56,6 +57,8 @@ export type PageSlot = typeof PageSlot.Type export const Model = Schema.Struct({ search: ErrorsSearch, + /** 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), nowMs: Schema.Number, diff --git a/apps/web/src/page/errors/hub/row-menu.ts b/apps/web/src/page/errors/hub/row-menu.ts index 73b6c898a4..2b8d48c458 100644 --- a/apps/web/src/page/errors/hub/row-menu.ts +++ b/apps/web/src/page/errors/hub/row-menu.ts @@ -12,6 +12,7 @@ import { signalsOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" import { startHubWrite } from "./selection" +import { canTriageIssues } from "../kit/grants" import { Array as Arr, Option } from "effect" type Return = PageReturn @@ -19,10 +20,15 @@ type Return = PageReturn /** What each top-level entry does, in entry order (the submenu and separator do nothing themselves). */ type Action = "submenu" | "separator" | "claim" | "release" | "open" | "copy-link" | "copy-id" | "copy-prompt" -const actionsOf = (issue: ErrorIssueDocument): ReadonlyArray => [ - "submenu", - ...(issue.leaseHolder === null ? ["claim" as const] : ["release" as const]), - "separator", +/** Changing status, claiming and releasing are triage: without it the menu opens and copies. */ +const actionsOf = (issue: ErrorIssueDocument, canTriage: boolean): ReadonlyArray => [ + ...(canTriage + ? [ + "submenu" as const, + issue.leaseHolder === null ? ("claim" as const) : ("release" as const), + "separator" as const, + ] + : []), "open", "copy-link", "copy-id", @@ -44,10 +50,11 @@ const targetsOf = (issue: ErrorIssueDocument): ReadonlyArray => export const rowMenuEntries = ( h: HtmlBuilder, issue: ErrorIssueDocument, + canTriage: boolean, ): ReadonlyArray => { const targets = targetsOf(issue) const current = issue.workflowState - return Arr.map(actionsOf(issue), (action): ContextMenuView.Entry => { + return Arr.map(actionsOf(issue, canTriage), (action): ContextMenuView.Entry => { switch (action) { case "separator": return { _tag: "Separator" } @@ -131,7 +138,9 @@ export const rowMenuStep = (model: Model, message: ContextMenu.Message): Return ...step, commands: [...lifted.commands, ...(step.commands ?? [])], }) + const canTriage = canTriageIssues(model) if (next.outMessage._tag === "SelectedSubItem") { + if (!canTriage) return lifted // The first row is the current state, disabled; moves follow it. const toState = targetsOf(issue)[next.outMessage.index - 1] return toState === undefined @@ -139,7 +148,7 @@ export const rowMenuStep = (model: Model, message: ContextMenu.Message): Return : done(startHubWrite(lifted.model, [issue.id], { _tag: "Transition", toState }, false)) } const href = `/errors/issues/${issue.id}` - switch (actionsOf(issue)[next.outMessage.index]) { + switch (actionsOf(issue, canTriage)[next.outMessage.index]) { case "claim": return done(startHubWrite(lifted.model, [issue.id], { _tag: "Claim" }, false)) case "release": diff --git a/apps/web/src/page/errors/hub/selection.ts b/apps/web/src/page/errors/hub/selection.ts index 8a1dfa10c6..0c056fbff6 100644 --- a/apps/web/src/page/errors/hub/selection.ts +++ b/apps/web/src/page/errors/hub/selection.ts @@ -13,6 +13,7 @@ import { signalsOf } from "./derive" import { Message } from "./message" import type { Model } from "./model" import { type HubWrite, outcomeToast, type WriteOutcome } from "./writes" +import { canTriageIssues } from "../kit/grants" type Return = PageReturn @@ -45,6 +46,9 @@ 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 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. */ export const pressedListKey = ( model: Model, @@ -52,6 +56,7 @@ export const pressedListKey = ( isShift: boolean, openPicker: (model: Model, id: string, kind: "state" | "severity") => Return, ): Return => { + if (TRIAGE_KEYS.has(key) && !canTriageIssues(model)) return { model } const ids = orderedIds(model) const current = model.focusedId === null diff --git a/apps/web/src/page/errors/hub/update.ts b/apps/web/src/page/errors/hub/update.ts index 3bf62a42b2..2e912954a5 100644 --- a/apps/web/src/page/errors/hub/update.ts +++ b/apps/web/src/page/errors/hub/update.ts @@ -25,6 +25,7 @@ import { type PageContext, PageOutMessage, type PageReturn } from "../../contrac import { SignalPresenceData } from "@maple/views/data/signal-presence" import { FetchEnvironments, + FetchHubSession, FetchInvestigations, FetchPage, FetchRanking, @@ -66,6 +67,8 @@ import { import { SOURCES } from "../kit/source" import type { IssuePage, Model, PageSlot } from "./model" import type { LoadFailure } from "@maple/views/data/load-failure" +import { acceptGrants } from "../../kit/session-grants" +import { initialGrants, readsSessionGrants } from "../kit/grants" export type Return = PageReturn @@ -319,6 +322,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): const search = route.search const model: Model = { search, + grants: initialGrants, window: null, nowMs: 0, summary: Keyed.idle(), @@ -359,6 +363,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): model, commands: [ LoadSignalPresence({ orgId: context.orgId }), + ...(readsSessionGrants ? [FetchHubSession({ orgId: context.orgId })] : []), resolveCommand(context, "Load"), Command.mapMessage(FilterSections.ReadRemembered(), (message) => Message.GotFilterSectionsMessage({ message }), @@ -489,6 +494,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re Message.match(message, { CompletedResolveWindow: ({ window, nowMs, mode }) => requestAll({ ...model, nowMs }, context, window, mode), + CompletedFetchSession: (answer) => ({ model: acceptGrants(model, context.orgId, answer) }), CompletedFetchSummary: ({ key, result }) => ({ model: settled(model, "summary", Keyed.accept(model.summary, key, result)), }), diff --git a/apps/web/src/page/errors/hub/view-list.ts b/apps/web/src/page/errors/hub/view-list.ts index 1a568bf092..c7056fad10 100644 --- a/apps/web/src/page/errors/hub/view-list.ts +++ b/apps/web/src/page/errors/hub/view-list.ts @@ -12,6 +12,7 @@ import { bulkBar } from "./view-bulk" import { hubEmpty } from "./view-empty" import { Message } from "./message" import type { Model } from "./model" +import { canTriageIssues } from "../kit/grants" import { type HeaderSelection, type RowState, signalHeader, signalRow, signalRowSkeleton } from "./view-row" type H = HtmlBuilder @@ -83,6 +84,7 @@ const rowView = ( sparkWindow: RowState["sparkWindow"], picker: RowState["picker"], rowMenu: RowState["rowMenu"], + canTriage: RowState["canTriage"], ): Html => signalRow(h, signal, { isSelected, @@ -93,6 +95,7 @@ const rowView = ( sparkWindow, picker, rowMenu, + canTriage, }) // One slot per list position, `index % 256` so the cache stays bounded: hovering a row (which moves @@ -112,18 +115,21 @@ const memoRow = (h: H, signal: ErrorSignal, index: number, state: RowState): Htm state.sparkWindow, state.picker, state.rowMenu, + state.canTriage, ]) /** 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 analyst gets neither. + const canTriage = canTriageIssues(model) if (signals.length === 0) { return [ h.div( [h.Key("empty"), h.Class("contents")], [hubEmpty(h, model, inputs.isNarrowed, inputs.timeZone)], ), - ...bulkBar(h, model), + ...(canTriage ? bulkBar(h, model) : []), ] } const ids = Arr.map(signals, (signal) => signal.id) @@ -137,7 +143,7 @@ const readyBody = (h: H, model: Model, inputs: ListInputs): ReadonlyArray h.div( [h.Key("rows")], [ - signalHeader(h, selection), + signalHeader(h, canTriage ? selection : null), h.div( [h.Role("list"), h.Class("divide-y divide-border-subtle"), h.OnMount(MountRowSelect())], Arr.map(signals, (signal, index) => @@ -150,7 +156,7 @@ const readyBody = (h: H, model: Model, inputs: ListInputs): ReadonlyArray ...pagingFooter(h, pagingOf(model)), ], ), - ...bulkBar(h, model), + ...(canTriage ? bulkBar(h, model) : []), ] } diff --git a/apps/web/src/page/errors/hub/view-row.ts b/apps/web/src/page/errors/hub/view-row.ts index 368bf61d5d..55bd53a630 100644 --- a/apps/web/src/page/errors/hub/view-row.ts +++ b/apps/web/src/page/errors/hub/view-row.ts @@ -14,8 +14,10 @@ import { type PickerKind, severityItems, severityPickerTrigger, + severityReadout, stateItems, statePickerTrigger, + stateReadout, } from "../kit/pickers" import * as QuickPick from "../kit/quick-pick" import type * as ContextMenu from "@maple/views/ui/context-menu" @@ -134,6 +136,8 @@ export type RowState = Readonly<{ picker: Readonly<{ kind: PickerKind; quickPick: QuickPick.Model }> | null /** The right-click menu, when this row was the last one pressed. */ rowMenu: ContextMenu.Model | null + /** `error_issues:triage`: without it the row has no checkbox, pickers or triage menu entries. */ + canTriage: boolean }> /** The row is the link; the checkbox and pickers sit inside it. */ @@ -167,26 +171,19 @@ export const signalRow = (h: H, signal: ErrorSignal, state: RowState): Html => { h.span( [ h.Class(cn(LANE.select, "flex items-center justify-center")), - h.DataAttribute("row-select", signal.id), + // 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"), ], - Checkbox.view(h, { - model: { isChecked: state.isSelected }, - toParentMessage: () => Message.ToggledRow({ id: signal.id }), - id: `errors-row-${signal.id}`, - inputId: `errors-row-${signal.id}-input`, - ariaLabel: state.isSelected ? "Deselect this error" : "Select this error", - className: cn( - "transition-opacity", - state.isSelecting || state.isSelected - ? "opacity-100" - : "opacity-0 group-hover/row:opacity-100 group-data-focused/row:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", - ), - }), + state.canTriage ? rowCheckbox(h, signal, state) : [], ), h.span( [h.Class(cn(LANE.severity, "flex items-center justify-center"))], - [severityPicker(h, signal, state)], + [ + state.canTriage + ? severityPicker(h, signal, state) + : severityReadout(h, signal.severity), + ], ), h.span( [h.Class(cn(LANE.identity, hstackClass({ gap: "2", align: "baseline" })))], @@ -224,6 +221,21 @@ export const signalRow = (h: H, signal: ErrorSignal, state: RowState): Html => { ]) } +const rowCheckbox = (h: H, signal: ErrorSignal, state: RowState): ReadonlyArray => + Checkbox.view(h, { + model: { isChecked: state.isSelected }, + toParentMessage: () => Message.ToggledRow({ id: signal.id }), + id: `errors-row-${signal.id}`, + inputId: `errors-row-${signal.id}-input`, + ariaLabel: state.isSelected ? "Deselect this error" : "Select this error", + className: cn( + "transition-opacity", + state.isSelecting || state.isSelected + ? "opacity-100" + : "opacity-0 group-hover/row:opacity-100 group-data-focused/row:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100", + ), + }) + /** The context menu's trigger around the row; only the row last pressed carries the menu itself. */ const rowMenuTrigger = (h: H, signal: ErrorSignal, state: RowState, children: ReadonlyArray): Html => state.rowMenu === null @@ -251,7 +263,7 @@ const rowMenuTrigger = (h: H, signal: ErrorSignal, state: RowState, children: Re view: ContextMenuView.view, toParentMessage: (message) => Message.GotRowMenuMessage({ message }), viewInputs: { - entries: rowMenuEntries(h, signal.issue), + entries: rowMenuEntries(h, signal.issue, state.canTriage), triggerClassName: "select-none", triggerContent: [], toTriggerContent: () => children, @@ -308,7 +320,10 @@ const trailingLanes = (h: H, signal: ErrorSignal, state: RowState): ReadonlyArra typography(h, { truncate: true }, [signal.serviceName]), ], ), - h.span([h.Class(LANE.state)], [statePicker(h, signal, state)]), + h.span( + [h.Class(LANE.state)], + [state.canTriage ? statePicker(h, signal, state) : stateReadout(h, signal.issue.workflowState)], + ), h.span([h.Class(LANE.actor)], signal.assignee === null ? [] : [actorAvatar(h, signal.assignee)]), timestamp(h, { value: signal.lastSeenAt, diff --git a/apps/web/src/page/errors/hub/view.ts b/apps/web/src/page/errors/hub/view.ts index 514150e5a1..8543d68250 100644 --- a/apps/web/src/page/errors/hub/view.ts +++ b/apps/web/src/page/errors/hub/view.ts @@ -15,6 +15,7 @@ import { isNarrowedBySidebar } from "./view-empty" import { listBody } from "./view-list" import { statStrip, toolbar } from "./view-toolbar" import { Array as Arr } from "effect" +import { canTriageIssues } from "../kit/grants" type H = HtmlBuilder @@ -35,6 +36,7 @@ const hub = (h: H, model: Model, inputs: PageViewInputs): Html => { const status = listStatusOf(model) const sparkWindow = stableSparkWindow(model.window) const isSelecting = model.selectedIds.length > 0 + const canTriage = canTriageIssues(model) const strip = statStrip(h, model) return h.div( [], @@ -60,6 +62,7 @@ const hub = (h: H, model: Model, inputs: PageViewInputs): Html => { isMounted(model.rowMenu.isOpen, model.rowMenu.animation) ? model.rowMenu : null, + canTriage, }), }), ], diff --git a/apps/web/src/page/errors/issue/analyst.scene.test.ts b/apps/web/src/page/errors/issue/analyst.scene.test.ts new file mode 100644 index 0000000000..befe6b1794 --- /dev/null +++ b/apps/web/src/page/errors/issue/analyst.scene.test.ts @@ -0,0 +1,323 @@ +import { OrgId } from "@maple/domain" +import { + ErrorIssueEventsResponse, + ErrorIssueId, + ErrorIssuePullRequestsResponse, + IssueEscalationAttemptsResponse, +} from "@maple/domain/http" +import { encodePublicId, V2ErrorIssueDetail } from "@maple/domain/http/v2" +import * as Policy from "@maple/domain/policy" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, test } from "vitest" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import type { PageContext } from "../../contract" +import { MountRightRail } from "./command" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// 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") +const issueId = Schema.decodeSync(ErrorIssueId)("0199a004-0000-7000-8000-000000000001") +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const window = { startTime: "2026-10-01 00:00:00", endTime: "2026-10-01 12:00:00" } +const NOW = Date.parse("2026-10-01T12:00:00Z") +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const MEMBER = Policy.grantsOf(["org:member"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) + +const holder = { + id: encodePublicId("actor", "0199a005-0000-7000-8000-000000000001"), + type: "agent", + user_id: null, + agent_name: "triage-bot", + model: null, + capabilities: [], + last_active_at: null, +} + +const HELD = { + lease_holder: holder, + lease_expires_at: "2026-10-01T12:05:00.000Z", + claimed_at: "2026-10-01T11:50:00.000Z", +} +const MERGED = { + merged_into_issue_id: encodePublicId("iss", "0199a004-0000-7000-8000-000000000002"), +} + +const detailOf = (overrides: Readonly>) => + Schema.decodeSync(V2ErrorIssueDetail)({ + id: encodePublicId("iss", issueId), + object: "error_issue", + kind: "error", + fingerprint_hash: "12660752272409977512", + service_name: "payment-svc", + exception_type: "ConnectionTimeout", + exception_message: "timed out acquiring a connection", + error_label: "ConnectionTimeout", + top_frame: "", + workflow_state: "in_progress", + priority: 1, + severity: "high", + severity_source: null, + source_ref: null, + assigned_actor: null, + lease_holder: null, + lease_expires_at: null, + claimed_at: null, + notes: null, + first_seen_at: "2026-10-01T10:00:00.000Z", + last_seen_at: "2026-10-01T11:44:00.000Z", + occurrence_count: 12, + resolved_at: null, + last_resolved_at: null, + last_regressed_at: null, + regression_count: 0, + resolved_versions: [], + snooze_until: null, + archived_at: null, + has_open_incident: false, + comment_count: 0, + open_pull_request_count: 0, + merged_pull_request_count: 0, + timeseries: [], + sample_traces: [], + incidents: [], + environments: [], + ...overrides, + }) + +const pullRequests = Schema.decodeSync(ErrorIssuePullRequestsResponse)({ + pullRequests: [ + { + id: "0199a007-0000-7000-8000-000000000001", + issueId, + provider: "github", + repoFullName: "acme/payments", + number: 412, + url: "https://github.com/acme/payments/pull/412", + title: "Raise the pool timeout", + authorLogin: "sam", + state: "open", + mergedAt: null, + mergeCommitSha: null, + linkSource: "user", + linkedByActor: null, + createdAt: "2026-10-01T11:00:00.000Z", + }, + ], + suggestedRepository: null, +}) + +const events = Schema.decodeSync(ErrorIssueEventsResponse)({ + events: [ + { + id: "0199a008-0000-7000-8000-000000000001", + issueId, + actor: null, + type: "state_change", + fromState: "triage", + toState: "in_progress", + payload: {}, + createdAt: "2026-10-01T11:30:00.000Z", + }, + ], +}) + +type Fixture = Readonly<{ + grants: ReadonlyArray + detail?: Readonly> + tab?: "activity" + hasPullRequest?: boolean +}> + +/** Entered with the session's grants and every read the page shows answered. */ +const loaded = (fixture: Fixture): Model => { + const entered = init({ issueId, search: fixture.tab === undefined ? {} : { tab: fixture.tab } }, context) + const windowed = Arr.reduce( + [ + Message.CompletedFetchSession({ orgId, grants: fixture.grants }), + Message.CompletedResolveWindow({ window, nowMs: NOW, mode: "Load" }), + ], + entered.model, + (model, message) => pageUpdate(model, message).model, + ) + const model = Arr.reduce( + [ + Message.CompletedFetchDetail({ + key: windowed.detail.key ?? "", + result: Result.succeed(detailOf(fixture.detail ?? {})), + }), + Message.CompletedFetchEvents({ key: windowed.events.key ?? "", result: Result.succeed(events) }), + Message.CompletedFetchEscalations({ + key: windowed.escalations.key ?? "", + result: Result.succeed(new IssueEscalationAttemptsResponse({ attempts: [] })), + }), + Message.CompletedFetchPullRequests({ + key: windowed.pullRequests.key ?? "", + result: Result.succeed( + (fixture.hasPullRequest ?? false) + ? pullRequests + : new ErrorIssuePullRequestsResponse({ pullRequests: [], suggestedRepository: null }), + ), + }), + ], + windowed, + (model, message) => pageUpdate(model, message).model, + ) + return { ...model, isRightRailMounted: true } +} + +const mounted = [ + Mount.resolve(MountRightRail, Message.MountedRightRail()), + Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), +] as const + +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: ANALYST })), + ...mounted, + expectView(text("In progress")).toExist(), + expectView(text("High")).toExist(), + expectView(text("Unclaimed")).toExist(), + expectView(role("combobox")).toBeAbsent(), + expectView(role("button", { name: "Claim" })).toBeAbsent(), + expectView(role("button", { name: "Attach" })).toBeAbsent(), + expectView(role("button", { name: "Attach the PR that fixes this" })).toBeAbsent(), + ) + }) + + test("a member picks state and severity, claims and attaches", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: MEMBER })), + ...mounted, + expectView(role("combobox", { name: "In progress" })).toExist(), + expectView(role("combobox", { name: "Severity" })).toExist(), + expectView(role("button", { name: "Claim" })).toExist(), + expectView(role("button", { name: "Attach" })).toExist(), + expectView(role("button", { name: "Attach the PR that fixes this" })).toExist(), + ) + }) + + test("a held lease shows its countdown without Extend or Release", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: ANALYST, detail: HELD })), + ...mounted, + expectView(text("Active lease")).toExist(), + expectView(text("5:00")).toExist(), + expectView(role("button", { name: "Extend" })).toBeAbsent(), + expectView(role("button", { name: "Release" })).toBeAbsent(), + ) + }) + + test("a member extends and releases a held lease", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: MEMBER, detail: HELD })), + ...mounted, + expectView(role("button", { name: "Extend" })).toExist(), + expectView(role("button", { name: "Release" })).toExist(), + ) + }) + + test("a merged issue says where it went and offers no Unmerge", () => { + scene( + { update: pageUpdate, view }, + 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(), + expectView(role("button", { name: "Unmerge" })).toBeAbsent(), + ) + }) + + test("a member unmerges a merged issue", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: MEMBER, detail: MERGED })), + ...mounted, + expectView(role("button", { name: "Unmerge" })).toExist(), + ) + }) + + test("a linked pull request is a link with no detach", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: ANALYST, hasPullRequest: true })), + ...mounted, + expectView(role("link", { name: "acme/payments#412" })).toExist(), + expectView(role("button", { name: "Detach acme/payments#412" })).toBeAbsent(), + ) + }) + + test("a member detaches a linked pull request", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: MEMBER, hasPullRequest: true })), + ...mounted, + expectView(role("button", { name: "Detach acme/payments#412" })).toExist(), + ) + }) + + test("the activity tab keeps the timeline and has no comment box", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: ANALYST, tab: "activity" })), + ...mounted, + expectView(text("triage → in_progress")).toExist(), + expectView(role("textbox")).toBeAbsent(), + expectView(role("button", { name: "Comment" })).toBeAbsent(), + ) + }) + + test("a member comments from the activity tab", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: MEMBER, tab: "activity" })), + ...mounted, + expectView(role("textbox")).toExist(), + expectView(role("button", { name: "Comment" })).toExist(), + ) + }) + + test("an analyst is not offered an investigation or the agent prompt, and a member is", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: ANALYST })), + ...mounted, + expectView(role("button", { name: "Start investigation" })).toBeAbsent(), + expectView(role("button", { name: "Copy agent prompt" })).toBeAbsent(), + ) + scene( + { update: pageUpdate, view }, + given(loaded({ grants: MEMBER })), + ...mounted, + expectView(role("button", { name: "Start investigation" })).toExist(), + expectView(role("button", { name: "Copy agent prompt" })).toExist(), + ) + }) + + test("grants answered for another org are dropped", () => { + const other = Schema.decodeSync(OrgId)("org_other") + const model = pageUpdate( + loaded({ grants: MEMBER }), + Message.CompletedFetchSession({ orgId: other, grants: ANALYST }), + ).model + scene( + { update: pageUpdate, view }, + given(model), + ...mounted, + expectView(role("button", { name: "Claim" })).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/errors/issue/command.ts b/apps/web/src/page/errors/issue/command.ts index 68bf791128..d0c53427e8 100644 --- a/apps/web/src/page/errors/issue/command.ts +++ b/apps/web/src/page/errors/issue/command.ts @@ -19,9 +19,12 @@ import { Command, Mount } from "foldkit" import { MapleApiV2Client, MapleInternalApiClient } from "../../../platform/api-clients" import { writeClipboard } from "@maple/ui/lib/clipboard" import { IssueWrite, Message } from "./message" +import { fetchSessionGrants } from "../../kit/session-grants" export const ResolveWindow = resolveWindowCommand("ResolveIssueWindow", Message.CompletedResolveWindow) +export const FetchIssueSession = fetchSessionGrants("FetchErrorIssueSession", Message.CompletedFetchSession) + export const DetailQuery = Schema.Struct({ start_time: Schema.String, end_time: Schema.String, diff --git a/apps/web/src/page/errors/issue/composer.scene.test.ts b/apps/web/src/page/errors/issue/composer.scene.test.ts index 6d4a991d0f..494b192aca 100644 --- a/apps/web/src/page/errors/issue/composer.scene.test.ts +++ b/apps/web/src/page/errors/issue/composer.scene.test.ts @@ -15,7 +15,7 @@ const view = (model: Model, h: HtmlBuilder): Html => h.div( [], [ - commentComposer(h, { draft: model.draft, isPosting: false, participants: [] }), + commentComposer(h, { draft: model.draft, isPosting: false, participants: [], canComment: true }), h.output([], [`${model.submits} submits`]), ], ) diff --git a/apps/web/src/page/errors/issue/message.ts b/apps/web/src/page/errors/issue/message.ts index f7ea59c3a0..d8df702471 100644 --- a/apps/web/src/page/errors/issue/message.ts +++ b/apps/web/src/page/errors/issue/message.ts @@ -24,6 +24,7 @@ import * as Menu from "@maple/views/ui/menu" import * as Select from "@maple/views/ui/select" import * as TimeRangePicker from "../../kit/time-range-picker" import { DestinationName } from "./model" +import { SessionGrants } from "../../kit/session-grants" const result = (schema: A) => ({ key: Schema.String, @@ -54,6 +55,7 @@ export const EscalationPreview = Schema.Struct({ export const Message = defineMessageUnion({ CompletedResolveWindow: { window: TimeWindow, nowMs: Schema.Number, mode: RequestMode }, + CompletedFetchSession: SessionGrants, CompletedFetchDetail: result(V2ErrorIssueDetail), CompletedFetchEvents: result(ErrorIssueEventsResponse), CompletedFetchAffectedUsers: result(ErrorIssueAffectedUsersResponse), diff --git a/apps/web/src/page/errors/issue/model.ts b/apps/web/src/page/errors/issue/model.ts index fe4757a0a2..262a295eb5 100644 --- a/apps/web/src/page/errors/issue/model.ts +++ b/apps/web/src/page/errors/issue/model.ts @@ -1,4 +1,5 @@ import { ChartEnvFields } from "@maple/views/charts/chart-env" +import * as Policy from "@maple/domain/policy" import { ErrorIssueAffectedUsersResponse, ErrorIssueEventsResponse, @@ -32,6 +33,8 @@ 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 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), nowMs: Schema.Number, diff --git a/apps/web/src/page/errors/issue/update.ts b/apps/web/src/page/errors/issue/update.ts index e909e96dce..6935609aa1 100644 --- a/apps/web/src/page/errors/issue/update.ts +++ b/apps/web/src/page/errors/issue/update.ts @@ -32,6 +32,7 @@ import { FetchEvents, FetchGithubStatus, FetchInvestigations, + FetchIssueSession, FetchPullRequests, FetchVcsPullRequests, FetchVerifications, @@ -68,6 +69,8 @@ import { startWrite, } from "./writes" import { isIssueSeverity } from "../../kit/issue-severity" +import { acceptGrants } from "../../kit/session-grants" +import { initialGrants, readsSessionGrants } from "../kit/grants" const decodePullRequestId = Schema.decodeUnknownOption(ErrorIssuePullRequestId) @@ -224,6 +227,7 @@ export const init = ( const model: Model = { issueId: route.issueId, search: route.search, + grants: initialGrants, window: null, nowMs: 0, leaseNowMs: 0, @@ -272,6 +276,7 @@ export const init = ( model: reads.model, commands: [ resolveCommand(model.search, context, "Load"), + ...(readsSessionGrants ? [FetchIssueSession({ orgId: context.orgId })] : []), ...(reads.commands ?? []), LoadChartRuntime(), ], @@ -390,6 +395,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re detailStep(context, window, mode), affectedUsersStep(context, window, mode), ]), + CompletedFetchSession: (answer) => ({ model: acceptGrants(model, context.orgId, answer) }), CompletedFetchDetail: ({ key, result }) => runSteps(syncSelects({ ...model, detail: Keyed.accept(model.detail, key, result) }), [ loadedReads(context), diff --git a/apps/web/src/page/errors/issue/view-activity.ts b/apps/web/src/page/errors/issue/view-activity.ts index 025091d999..c5732d5905 100644 --- a/apps/web/src/page/errors/issue/view-activity.ts +++ b/apps/web/src/page/errors/issue/view-activity.ts @@ -339,9 +339,17 @@ const participantStrip = (h: H, participants: ReadonlyArray): Rea /** The composer; self-hosted has no directory, so no "me" avatar. */ export const commentComposer = ( h: H, - options: Readonly<{ draft: string; isPosting: boolean; participants: ReadonlyArray }>, + options: Readonly<{ + draft: string + isPosting: boolean + participants: ReadonlyArray + /** Commenting is triage: a viewer without it keeps the participants and gets no box. */ + canComment: boolean + }>, ): Html => { const canSubmit = !options.isPosting && Str.trim(options.draft).length > 0 + if (!options.canComment) + return h.div([h.Class(cn("space-y-2", "mt-6"))], participantStrip(h, options.participants)) return h.div( [h.Class(cn("space-y-2", "mt-6"))], [ diff --git a/apps/web/src/page/errors/issue/view-agent-handoff.ts b/apps/web/src/page/errors/issue/view-agent-handoff.ts index e3ac796bc2..6e132deb37 100644 --- a/apps/web/src/page/errors/issue/view-agent-handoff.ts +++ b/apps/web/src/page/errors/issue/view-agent-handoff.ts @@ -15,6 +15,7 @@ import type { Model } from "./model" import { typography } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" import { Array as Arr, String as Str } from "effect" +import { canRunInvestigations } from "../../kit/session-grants" type H = HtmlBuilder @@ -191,8 +192,9 @@ export const getItFixedPanel = ( investigation: V2Investigation | null, ): Html => { const maple = investigationLane(h, investigation, model.isStartingInvestigation) - // The prompt drives the error tools, so alert issues get Maple's lane alone. - if (issue.kind !== "error") + // The prompt drives the error tools, so alert issues get Maple's lane alone. So does a viewer + // who cannot run one: the prompt tells an agent to claim and fix the issue. + if (issue.kind !== "error" || !canRunInvestigations(model)) return panel(h, { className: "shrink-0 overflow-hidden" }, [ panelHeader(h, { title: "Get it fixed" }), maple, diff --git a/apps/web/src/page/errors/issue/view-panels.ts b/apps/web/src/page/errors/issue/view-panels.ts index 68a6573d6e..7f8379edf1 100644 --- a/apps/web/src/page/errors/issue/view-panels.ts +++ b/apps/web/src/page/errors/issue/view-panels.ts @@ -36,7 +36,24 @@ const SOURCE_HINT = { auto: "found in the pull request description", } satisfies Record -const pullRequestRow = (h: H, pr: ErrorIssuePullRequestDocument, isBusy: boolean): Html => { +const detachButton = (h: H, pr: ErrorIssuePullRequestDocument, isBusy: boolean): Html => + iconButton( + h, + { + size: "icon", + label: `Detach ${pr.repoFullName}#${pr.number}`, + className: + "size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100", + isDisabled: isBusy, + attributes: [h.OnClick(Message.ClickedDetachPullRequest({ id: pr.id }))], + }, + [icon(h, "TrashIcon")], + ) + +/** Attaching and detaching are triage: a viewer without it reads the linked pull requests. */ +type PanelState = Readonly<{ isBusy: boolean; canTriage: boolean }> + +const pullRequestRow = (h: H, pr: ErrorIssuePullRequestDocument, state: PanelState): Html => { const hint = SOURCE_HINT[pr.linkSource] return item(h, { tag: "li", variant: "flush", size: "lg", className: "group flex-nowrap items-start" }, [ itemMedia(h, { variant: "icon", className: "text-muted-foreground" }, [ @@ -66,47 +83,40 @@ const pullRequestRow = (h: H, pr: ErrorIssuePullRequestDocument, isBusy: boolean ...(pr.title ? [itemDescription(h, [pr.title], "line-clamp-1")] : []), ...(hint ? [itemDescription(h, [hint], "text-foreground-subtle")] : []), ]), - iconButton( - h, - { - size: "icon", - label: `Detach ${pr.repoFullName}#${pr.number}`, - className: - "size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100", - isDisabled: isBusy, - attributes: [h.OnClick(Message.ClickedDetachPullRequest({ id: pr.id }))], - }, - [icon(h, "TrashIcon")], - ), + ...(state.canTriage ? [detachButton(h, pr, state.isBusy)] : []), ]) } export const pullRequestsPanel = ( h: H, pullRequests: ReadonlyArray, - isBusy: boolean, + state: PanelState, ): Html => panel(h, {}, [ panelHeader(h, { title: "Pull requests", - action: button( - h, - { - size: "sm", - variant: "ghost", - className: "gap-1 px-2 text-xs", - isDisabled: isBusy, - attributes: [h.OnClick(Message.ClickedAttach())], - }, - [icon(h, "PlusIcon"), "Attach"], - ), + action: state.canTriage + ? button( + h, + { + size: "sm", + variant: "ghost", + className: "gap-1 px-2 text-xs", + isDisabled: state.isBusy, + attributes: [h.OnClick(Message.ClickedAttach())], + }, + [icon(h, "PlusIcon"), "Attach"], + ) + : undefined, }), pullRequests.length === 0 ? h.div( [h.Class("space-y-2 px-4 py-3")], [ typography(h, { size: "xs", tone: "muted", as: "p" }, [ - "Attach the pull request that fixes this. When it merges, Maple watches for the error to come back and closes this issue if it doesn't.", + state.canTriage + ? "Attach the pull request that fixes this. When it merges, Maple watches for the error to come back and closes this issue if it doesn't." + : "No pull request is attached. When the one that fixes this merges, Maple watches for the error to come back and closes this issue if it doesn't.", ]), docsLink(h, "github", "GitHub integration docs"), ], @@ -114,7 +124,7 @@ export const pullRequestsPanel = ( : dividedList( h, { as: "ul", bordered: false }, - Arr.map(pullRequests, (pr) => pullRequestRow(h, pr, isBusy)), + Arr.map(pullRequests, (pr) => pullRequestRow(h, pr, state)), ), ]) diff --git a/apps/web/src/page/errors/issue/view-sidebar.ts b/apps/web/src/page/errors/issue/view-sidebar.ts index 96b4e97ac8..d34e4afc26 100644 --- a/apps/web/src/page/errors/issue/view-sidebar.ts +++ b/apps/web/src/page/errors/issue/view-sidebar.ts @@ -26,6 +26,7 @@ import { affectedUsersGroup } from "./view-affected-users" import { leaseHud } from "./view-lease" import type { Model } from "./model" import { sidebarBusy } from "./writes" +import { canTriageIssues } from "../kit/grants" import { typography } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" import { Array as Arr } from "effect" @@ -128,6 +129,10 @@ const severitySelect = (h: H, model: Model): Html => toParentMessage: (message) => Message.GotSeveritySelectMessage({ message }), }) +/** A rail value that is read, not picked: the mark and its label, as the Priority row. */ +const readValue = (h: H, mark: Html, label: string): Html => + hstack(h, { gap: "2", as: "span" }, [mark, typography(h, { size: "sm", tone: "foreground" }, [label])]) + const ENVIRONMENT_ROWS_SHOWN = 4 const environmentValue = (h: H, environments: ReadonlyArray): Html => { @@ -179,8 +184,12 @@ const issueIdCopy = (h: H, model: Model, id: string): Html => ], ) -/** A held lease: the HUD with Extend and Release; otherwise Claim, or nothing on a closed issue. */ +/** + * A held lease: the HUD with Extend and Release; otherwise Claim, or nothing on a closed issue. + * A viewer who cannot triage reads the HUD and the row without their buttons. + */ const leaseGroup = (h: H, model: Model, issue: ErrorIssueDocument, canClaim: boolean): Html => { + const canTriage = canTriageIssues(model) const hud = issue.leaseHolder !== null && issue.leaseExpiresAt !== null ? leaseHud( @@ -193,6 +202,7 @@ const leaseGroup = (h: H, model: Model, issue: ErrorIssueDocument, canClaim: boo model.leaseNowMs === 0 ? model.nowMs : model.leaseNowMs, ) : undefined + if (hud !== undefined && !canTriage) return vstack(h, { gap: "2" }, hud === null ? [] : [hud]) if (hud !== undefined) return vstack(h, { gap: "2" }, [ ...(hud === null ? [] : [hud]), @@ -220,7 +230,7 @@ const leaseGroup = (h: H, model: Model, issue: ErrorIssueDocument, canClaim: boo ]), ]) return detailRailRow(h, { label: canClaim ? "Unclaimed" : "Closed" }, [ - canClaim + canClaim && canTriage ? button( h, { @@ -232,7 +242,9 @@ const leaseGroup = (h: H, model: Model, issue: ErrorIssueDocument, canClaim: boo }, ["Claim"], ) - : typography(h, { size: "xs", tone: "muted" }, ["No lease needed"]), + : typography(h, { size: "xs", tone: "muted" }, [ + canClaim ? "No one holds it" : "No lease needed", + ]), ]) } @@ -245,22 +257,36 @@ export const issueSidebar = ( const priority = clampPriority(issue.priority) const isTerminal = issue.workflowState === "cancelled" || issue.workflowState === "done" const canClaim = issue.leaseHolder === null && !isTerminal + const canTriage = canTriageIssues(model) return vstack(h, {}, [ detailRailGroup(h, "Details", [ - detailRailRow(h, { label: "Status" }, [stateSelect(h, model, issue.workflowState)]), + detailRailRow(h, { label: "Status" }, [ + canTriage + ? stateSelect(h, model, issue.workflowState) + : readValue( + h, + workflowRingIcon(h, issue.workflowState, 12), + WORKFLOW_LABEL[issue.workflowState], + ), + ]), detailRailRow( h, { label: "Severity", hint: issue.severitySource ? SEVERITY_SOURCE_HINT[issue.severitySource] : undefined, }, - [severitySelect(h, model)], + [ + canTriage + ? severitySelect(h, model) + : readValue( + h, + severityDot(h, issue.severity), + issue.severity === null ? "Not set" : SEVERITY_LABEL[issue.severity], + ), + ], ), detailRailRow(h, { label: "Priority" }, [ - hstack(h, { gap: "2", as: "span" }, [ - priorityBarsIcon(h, priority, 12), - typography(h, { size: "sm", tone: "foreground" }, [PRIORITY_LABEL[priority]]), - ]), + readValue(h, priorityBarsIcon(h, priority, 12), PRIORITY_LABEL[priority]), ]), detailRailRow(h, { label: "Assignee" }, [actorChip(h, issue.assignedActor)]), ]), diff --git a/apps/web/src/page/errors/issue/view.ts b/apps/web/src/page/errors/issue/view.ts index 4cff13ee9c..460069b724 100644 --- a/apps/web/src/page/errors/issue/view.ts +++ b/apps/web/src/page/errors/issue/view.ts @@ -41,6 +41,8 @@ import { vstack } from "@maple/views/ui/stack" import { alert, alertAction, alertDescription, alertTitle } from "@maple/views/ui/alert" import { button } from "@maple/views/ui/button" import { typography } from "@maple/views/ui/typography" +import { canRunInvestigations } from "../../kit/session-grants" +import { canTriageIssues } from "../kit/grants" type H = HtmlBuilder @@ -128,20 +130,25 @@ const mergedNotice = (h: H, model: Model, issue: ErrorIssueDetailResponse["issue ], ["the issue it was merged into"], ), - ". Unmerge it to track it on its own again.", - ]), - alertAction(h, [ - button( - h, - { - size: "sm", - variant: "outline", - isLoading: model.busy.includes("unmerge"), - attributes: [h.OnClick(Message.ClickedUnmerge())], - }, - ["Unmerge"], - ), + canTriageIssues(model) ? ". Unmerge it to track it on its own again." : ".", ]), + // Unmerging is triage: an analyst reads where the occurrences go. + ...(canTriageIssues(model) + ? [ + alertAction(h, [ + button( + h, + { + size: "sm", + variant: "outline", + isLoading: model.busy.includes("unmerge"), + attributes: [h.OnClick(Message.ClickedUnmerge())], + }, + ["Unmerge"], + ), + ]), + ] + : []), ]), ] @@ -185,20 +192,25 @@ const overview = ( surge: signal?.surge ?? null, ...clock, }) - const fix = getItFixedPanel(h, model, issue, linkedInvestigationOf(model)) + // 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 + ? [getItFixedPanel(h, model, issue, investigation)] + : [] return vstack(h, { gap: "4" }, [ ...mergedNotice(h, model, issue), ...(issue.kind === "alert" ? [ alertSourceCard(h, issue), stats, - fix, + ...fix, ...relatedAnomaliesSection(h, relatedAnomaliesOf(model), clock), ] : [ exceptionPanel(h, model, issue), stats, - fix, + ...fix, chartSlot(h, model, detail, inputs.context.timeZone), ...latestOccurrences(h, model, detail, clock), incidentsPanel(h, detail, clock), @@ -210,15 +222,21 @@ const overview = ( const rightPanel = (h: H, model: Model, detail: ErrorIssueDetailResponse): Html => { const verification = latestVerificationOf(model) const pullRequests = pullRequestsOf(model) + const canTriage = canTriageIssues(model) return vstack(h, { gap: "4", attributes: [h.OnMount(MountRightRail())] }, [ issueSidebar(h, model, detail.issue, detail.environments), vstack(h, { gap: "4", className: "px-4 pb-4" }, [ ...(verification !== null ? [verificationCard(h, verification, detail.issue.workflowState, model.nowMs)] - : AWAITING_FIX_STATES.has(detail.issue.workflowState) && pullRequests.length === 0 + : canTriage && + AWAITING_FIX_STATES.has(detail.issue.workflowState) && + pullRequests.length === 0 ? [attachNudge(h)] : []), - pullRequestsPanel(h, pullRequests, model.isAttaching || model.isDetaching), + pullRequestsPanel(h, pullRequests, { + isBusy: model.isAttaching || model.isDetaching, + canTriage, + }), ]), ]) } @@ -264,7 +282,12 @@ const otherTab = ( onData: (value) => issueTimeline(h, value.events, escalationsOf(model), clock, model.isShowingEarlier), }), - commentComposer(h, { draft: model.commentDraft, isPosting: model.isCommenting, participants }), + commentComposer(h, { + draft: model.commentDraft, + isPosting: model.isCommenting, + participants, + canComment: canTriageIssues(model), + }), ]) } diff --git a/apps/web/src/page/errors/kit/grants.ts b/apps/web/src/page/errors/kit/grants.ts new file mode 100644 index 0000000000..9fc4695587 --- /dev/null +++ b/apps/web/src/page/errors/kit/grants.ts @@ -0,0 +1,13 @@ +import * as Policy from "@maple/domain/policy" +import { isClerkAuthEnabled } from "../../../platform/auth-mode" + +type Grants = Readonly<{ grants: ReadonlyArray }> + +/** An errors page's grants on entry: none until the session answers, all when self-hosted. */ +export const initialGrants: ReadonlyArray = isClerkAuthEnabled ? [] : Policy.allPermissions + +/** The session is only read where there is one; self-hosted mode holds every permission. */ +export const readsSessionGrants = isClerkAuthEnabled + +/** The viewer may move, claim, rate, merge, link and comment on issues (`error_issues:triage`). */ +export const canTriageIssues = (model: Grants): boolean => Policy.has(model.grants, "error_issues:triage") diff --git a/apps/web/src/page/errors/kit/pickers.ts b/apps/web/src/page/errors/kit/pickers.ts index 25bb8c9e00..cc749daad9 100644 --- a/apps/web/src/page/errors/kit/pickers.ts +++ b/apps/web/src/page/errors/kit/pickers.ts @@ -12,7 +12,8 @@ import type { QuickPickItem } from "./quick-pick" import { SEVERITY_LABEL, SEVERITY_ORDER, severityIcon } from "../../kit/issue-severity" import { WORKFLOW_LABEL, workflowRingIcon } from "../../kit/issue-workflow" import { Array as Arr } from "effect" -import { typography } from "@maple/views/ui/typography" +import { typography, typographyClass } from "@maple/views/ui/typography" +import { hstack } from "@maple/views/ui/stack" export type PickerKind = "severity" | "state" @@ -90,6 +91,35 @@ export const statePickerTrigger = ( ], ) +/** The severity lane's mark for a viewer who cannot change it. */ +export const severityReadout = (h: HtmlBuilder, value: IssueSeverity | null): Html => + hstack( + h, + { + as: "span", + justify: "center", + attributes: [h.Title(value === null ? "Severity not set" : `Severity: ${SEVERITY_LABEL[value]}`)], + }, + [severityIcon(h, value, 16)], + ) + +/** The status lane's ring and label for a viewer who cannot change it. */ +export const stateReadout = (h: HtmlBuilder, current: WorkflowState): Html => + hstack( + h, + { + as: "span", + gap: "1.5", + shrink: true, + className: typographyClass({ size: "2xs", tone: "muted" }), + attributes: [h.Title(`Status: ${WORKFLOW_LABEL[current]}`)], + }, + [ + workflowRingIcon(h, current, 12, "shrink-0"), + typography(h, { truncate: true }, [WORKFLOW_LABEL[current]]), + ], + ) + export const severityItems = (h: HtmlBuilder): ReadonlyArray => [ { value: "none", diff --git a/apps/web/src/page/investigations/list/analyst.scene.test.ts b/apps/web/src/page/investigations/list/analyst.scene.test.ts new file mode 100644 index 0000000000..822d2dd9ed --- /dev/null +++ b/apps/web/src/page/investigations/list/analyst.scene.test.ts @@ -0,0 +1,95 @@ +import { OrgId } from "@maple/domain" +import * as Policy from "@maple/domain/policy" +import { Result, Schema } from "effect" +import { expect as expectView, given, Mount, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, test } from "vitest" +import { MeasureTabIndicator, Message as TabsMessage } from "@maple/views/ui/tabs" +import { buildAlertsLabFixture } from "../../../lab/alerts-fixture" +import type { PageContext } from "../../contract" +import { MountInvestigationRows } from "./command" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// An analyst reads the investigations; starting one needs `investigations:run`. + +const context: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_acme"), + pinnedNamespace: null, + timeZone: "UTC", +} +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const measured = () => + Mount.resolve( + MeasureTabIndicator, + TabsMessage.MeasuredIndicator({ + rect: { left: 2, right: 115, top: 2, bottom: 2, width: 51, height: 28 }, + }), + ) + +/** The rows' click stream mounts with the list; it answers with a row click, which only navigates. */ +const rowsMounted = () => Mount.resolve(MountInvestigationRows, Message.ClickedRow({ id: investigation.id })) + +const MEMBER = Policy.grantsOf(["org:member"]) +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?" }) + +/** The list read by a session holding `grants`, over `data`. */ +const loaded = ( + grants: ReadonlyArray, + data: ReadonlyArray, +): Model => { + const entered = init({ search: {} }, context).model + const granted = pageUpdate(entered, Message.CompletedReadGrants({ orgId: context.orgId, grants })).model + return pageUpdate( + granted, + Message.CompletedFetchFirstPage({ + key: granted.firstPage.key ?? "", + result: Result.succeed({ data, hasMore: false, nextCursor: null }), + }), + ).model +} + +describe("investigations list, analyst", () => { + test("an analyst reads the list without the investigate bar; a member has it", () => { + scene( + { update: pageUpdate, view }, + given(loaded(ANALYST, [investigation])), + measured(), + rowsMounted(), + expectView(role("tab", { name: /Active/ })).toExist(), + expectView(bar).toBeAbsent(), + ) + scene( + { update: pageUpdate, view }, + given(loaded(MEMBER, [investigation])), + measured(), + rowsMounted(), + expectView(bar).toExist(), + ) + }) + + test("an empty workspace shows an analyst the plain empty list, and a member the first-run hero", () => { + scene( + { update: pageUpdate, view }, + given(loaded(ANALYST, [])), + measured(), + expectView(text("Nothing under investigation")).toExist(), + expectView(text("When an alert rule fires, Maple opens one on its own.")).toExist(), + expectView(text("Ask, and Maple goes and finds out.")).toBeAbsent(), + expectView(bar).toBeAbsent(), + expectView(role("button", { name: /Why did checkout latency spike/ })).toBeAbsent(), + ) + scene( + { update: pageUpdate, view }, + given(loaded(MEMBER, [])), + expectView(text("Ask, and Maple goes and finds out.")).toExist(), + expectView(bar).toExist(), + expectView(role("button", { name: /Why did checkout latency spike/ })).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/investigations/list/view.ts b/apps/web/src/page/investigations/list/view.ts index 4217e6e06b..f81184a2c3 100644 --- a/apps/web/src/page/investigations/list/view.ts +++ b/apps/web/src/page/investigations/list/view.ts @@ -61,6 +61,7 @@ import { hstack, hstackClass, vstack } from "@maple/views/ui/stack" import { spaced } from "@maple/views/ui/spaced" import { Array as Arr, String as Str, Option } from "effect" import { textLink } from "@maple/views/ui/text-link" +import { canRunInvestigations } from "../../kit/session-grants" type H = HtmlBuilder @@ -327,7 +328,7 @@ const tableSkeleton = (h: H): Html => ), }) -const hubEmpty = (h: H, view: HubView, isFiltered: boolean): Html => { +const hubEmpty = (h: H, view: HubView, isFiltered: boolean, canRun: boolean): Html => { if (isFiltered) return filteredEmpty(h, { noun: "investigations", @@ -341,7 +342,7 @@ const hubEmpty = (h: H, view: HubView, isFiltered: boolean): Html => { ]), emptyDescription(h, [ view === "active" - ? "Start one above, or open an issue and choose Start investigation. When an alert rule fires, Maple opens one on its own." + ? `${canRun ? "Start one above, or open an issue and choose Start investigation. " : ""}When an alert rule fires, Maple opens one on its own.` : "Investigations you resolve, and any that fail, stay here for reference.", ]), ]), @@ -529,7 +530,9 @@ const budgetNotice = (h: H, model: Model, timeZone: string): Html | null => { export const view = Submodel.defineView((model, inputs, h) => { const timeZone = inputs.context.timeZone const all = allOf(model) - const isFirstRun = AsyncData.isSuccess(model.firstPage.data) && all.length === 0 + // The hero is the investigate bar and what to ask it: only for who may start one. + const canRun = canRunInvestigations(model) + const isFirstRun = canRun && AsyncData.isSuccess(model.firstPage.data) && all.length === 0 if (isFirstRun) return dashboardPage(h, {}, [hubHero(h, model)]) const visible = visibleOf(model, all) const isLoadingMore = isLoadingMoreOf(model) @@ -555,7 +558,7 @@ export const view = Submodel.defineView((model, onRefreshing: () => loaded(), }) function loaded(): Html { - if (visible.length === 0) return hubEmpty(h, viewOf(model.search), isFiltered) + if (visible.length === 0) return hubEmpty(h, viewOf(model.search), isFiltered, canRun) return h.div( [h.Class("contents")], [ @@ -599,7 +602,10 @@ export const view = Submodel.defineView((model, h, { title: "Investigations", - sticky: h.div([h.Class("contents")], [...(notice === null ? [] : [notice]), bar(h, model)]), + sticky: h.div( + [h.Class("contents")], + [...(notice === null ? [] : [notice]), ...(canRun ? [bar(h, model)] : [])], + ), }, [ isFailed diff --git a/apps/web/src/page/kit/session-grants.ts b/apps/web/src/page/kit/session-grants.ts index e9ae42405f..c11d2087a5 100644 --- a/apps/web/src/page/kit/session-grants.ts +++ b/apps/web/src/page/kit/session-grants.ts @@ -34,3 +34,9 @@ export const acceptGrants = ( /** The viewer may change alert rules, schedules, SLOs and routing (`alerts:manage`). */ export const canManageAlerts = (model: Grants): boolean => Policy.has(model.grants, "alerts:manage") + +/** The viewer may acknowledge and resolve alert and anomaly incidents (`incidents:respond`). */ +export const canRespondToIncidents = (model: Grants): boolean => Policy.has(model.grants, "incidents:respond") + +/** The viewer may start, restart and resolve investigations (`investigations:run`). */ +export const canRunInvestigations = (model: Grants): boolean => Policy.has(model.grants, "investigations:run") diff --git a/apps/web/src/page/status-pages/editor/analyst.scene.test.ts b/apps/web/src/page/status-pages/editor/analyst.scene.test.ts new file mode 100644 index 0000000000..2101d07174 --- /dev/null +++ b/apps/web/src/page/status-pages/editor/analyst.scene.test.ts @@ -0,0 +1,120 @@ +import { OrgId } from "@maple/domain" +import { StatusPagePublicId } from "@maple/domain/http/v2" +import * as Policy from "@maple/domain/policy" +import { Result, Schema, Array as Arr } from "effect" +import { expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { describe, test } from "vitest" +import type { PageContext } from "../../contract" +import { ORG } from "../../kit/alert-test-rows" +import { downMonitor, httpMonitor } from "../../kit/uptime/test-monitors" +import { component, incident, incidentId, statusPage } from "../shared/test-pages" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// 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" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const MEMBER = Policy.grantsOf(["org:member"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) + +/** The editor's Page tab over `incidents`, read by a session holding `grants`. */ +const loaded = ( + grants: ReadonlyArray, + incidents: ReadonlyArray>, +): Model => { + const entered = init( + { _tag: "StatusPage", pageId: Schema.encodeSync(StatusPagePublicId)(statusPage().id), search: {} }, + context, + ).model + return Arr.reduce( + [ + Message.ReadClock({ nowMs: Date.UTC(2026, 9, 1, 12) }), + Message.CompletedFetchSession({ orgId: ORG, grants }), + Message.CompletedFetchPage({ key: entered.page.key ?? "", result: Result.succeed(statusPage()) }), + Message.CompletedFetchComponents({ + key: entered.components.key ?? "", + result: Result.succeed([component(1), component(2)]), + }), + Message.CompletedFetchIncidents({ + key: entered.incidents.key ?? "", + result: Result.succeed(incidents), + }), + Message.CompletedFetchMonitors({ + key: entered.monitors.key ?? "", + result: Result.succeed([httpMonitor(), downMonitor()]), + }), + ], + entered, + (model, message) => pageUpdate(model, message).model, + ) +} + +const DRAFT = incident(1, { published: false }) +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, analyst", () => { + test("Post incident is offered to a member and not to an analyst", () => { + scene( + { update: pageUpdate, view }, + given(loaded(ANALYST, [])), + expectView(text("Nothing posted.")).toExist(), + expectView(role("button", { name: "Post incident" })).toBeAbsent(), + ) + scene( + { update: pageUpdate, view }, + given(loaded(MEMBER, [])), + expectView(text("Nothing posted. Post incident when something breaks.")).toExist(), + expectView(role("button", { name: "Post incident" })).toExist(), + ) + }) + + test("an analyst reads an open incident's updates and gets none of its actions", () => { + scene( + { update: pageUpdate, view }, + given(opened(ANALYST, DRAFT, 1)), + expectView(text("Draft, not public")).toExist(), + expectView(role("region", { name: "Updates" })).toExist(), + expectView(role("button", { name: "Publish" })).toBeAbsent(), + expectView(role("button", { name: "Post update" })).toBeAbsent(), + expectView(role("button", { name: "Resolve" })).toBeAbsent(), + expectView(role("button", { name: "Edit details" })).toBeAbsent(), + expectView(role("button", { name: "Delete incident" })).toBeAbsent(), + ) + }) + + test("a member publishes, updates, resolves, edits and deletes an incident", () => { + scene( + { update: pageUpdate, view }, + given(opened(MEMBER, DRAFT, 1)), + expectView(role("button", { name: "Publish" })).toExist(), + expectView(role("button", { name: "Post update" })).toExist(), + expectView(role("button", { name: "Resolve" })).toExist(), + expectView(role("button", { name: "Edit details" })).toExist(), + expectView(role("button", { name: "Delete incident" })).toExist(), + ) + }) + + test("a maintenance notice is the same: a member completes or deletes it, an analyst reads it", () => { + scene( + { update: pageUpdate, view }, + given(opened(ANALYST, MAINTENANCE, 2)), + expectView(role("button", { name: "Complete" })).toBeAbsent(), + expectView(role("button", { name: "Delete maintenance" })).toBeAbsent(), + ) + scene( + { update: pageUpdate, view }, + given(opened(MEMBER, MAINTENANCE, 2)), + expectView(role("button", { name: "Complete" })).toExist(), + expectView(role("button", { name: "Delete maintenance" })).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/status-pages/editor/model.ts b/apps/web/src/page/status-pages/editor/model.ts index ab7eadea3c..7afe503652 100644 --- a/apps/web/src/page/status-pages/editor/model.ts +++ b/apps/web/src/page/status-pages/editor/model.ts @@ -174,6 +174,10 @@ export type Model = typeof Model.Type export const canManage = (model: Pick): boolean => Policy.has(model.grants, "status_pages:edit") +/** The viewer may post, update, edit and delete incidents and maintenance (`status_page_incidents:post`). */ +export const canPostIncidents = (model: Pick): boolean => + Policy.has(model.grants, "status_page_incidents:post") + /** The viewer may change who can read the page, its password included (`status_pages:access`). */ export const canChangeAccess = (model: Pick): boolean => Policy.has(model.grants, "status_pages:access") diff --git a/apps/web/src/page/status-pages/editor/view-canvas.ts b/apps/web/src/page/status-pages/editor/view-canvas.ts index 7f5db1b075..baf1842825 100644 --- a/apps/web/src/page/status-pages/editor/view-canvas.ts +++ b/apps/web/src/page/status-pages/editor/view-canvas.ts @@ -30,7 +30,7 @@ import { } from "../shared/preview" import { Message } from "./message" import { pageUrlOf } from "./update-domain" -import { canManage, type Model } from "./model" +import { canManage, canPostIncidents, type Model } from "./model" import { componentsOf, incidentsOf, monitorsOf } from "./reads" import { addPanel, componentPanel, incidentPanel, type TimeInputs } from "./view-panel" import { typography } from "@maple/views/ui/typography" @@ -317,7 +317,11 @@ const onThisPage = (h: H, model: Model): Html => { open.length + recent.length === 0 ? h.p( [h.Class("px-2 text-xs text-muted-foreground")], - ["Nothing posted. Post incident when something breaks."], + [ + canPostIncidents(model) + ? "Nothing posted. Post incident when something breaks." + : "Nothing posted.", + ], ) : vstack( h, diff --git a/apps/web/src/page/status-pages/editor/view-panel.ts b/apps/web/src/page/status-pages/editor/view-panel.ts index 480b22119b..3b1285a72e 100644 --- a/apps/web/src/page/status-pages/editor/view-panel.ts +++ b/apps/web/src/page/status-pages/editor/view-panel.ts @@ -28,7 +28,7 @@ import { checkedRows, hasRowIssues } from "../shared/picker" import { pickerView } from "../shared/picker-view" import { FocusNewRow } from "./command" import { Message } from "./message" -import { canManage, type Model } from "./model" +import { canManage, canPostIncidents, type Model } from "./model" import { componentsOf, incidentsOf, monitorsOf } from "./reads" import { ADD_PICKER_ID, PANEL_TITLE_ID } from "./update-components" import { typography, typographyClass } from "@maple/views/ui/typography" @@ -428,7 +428,7 @@ export const incidentPanel = (h: H, model: Model, incident: V2StatusPageIncident names.length === 0 ? "No components named" : Arr.join(names, ", "), ...(window === null ? [] : [" · ", ...window]), ]), - incidentActions(h, model, incident), + ...(canPostIncidents(model) ? [incidentActions(h, model, incident)] : []), vstack(h, { gap: "2", as: "section", attributes: [h.AriaLabel("Updates")] }, [ eyebrow(h, { as: "h3" }, ["Updates"]), h.ol( @@ -460,26 +460,27 @@ export const incidentPanel = (h: H, model: Model, incident: V2StatusPageIncident ), ), ]), - h.div( - [h.Class("border-t pt-4")], - [ - button( - h, - { - size: "sm", - variant: "destructive-outline", - attributes: [ - h.OnClick(Message.ClickedDeleteIncident({ incidentId: incident.id })), - ], - }, - [incident.kind === "incident" ? "Delete incident" : "Delete maintenance"], - ), - ], - ), + ...(canPostIncidents(model) ? [deleteIncidentAction(h, incident)] : []), ], ) } +const deleteIncidentAction = (h: H, incident: V2StatusPageIncident): Html => + h.div( + [h.Class("border-t pt-4")], + [ + button( + h, + { + size: "sm", + variant: "destructive-outline", + attributes: [h.OnClick(Message.ClickedDeleteIncident({ incidentId: incident.id }))], + }, + [incident.kind === "incident" ? "Delete incident" : "Delete maintenance"], + ), + ], + ) + // ADD export const addPanel = (h: H, model: Model): Html => { diff --git a/apps/web/src/page/status-pages/editor/view.ts b/apps/web/src/page/status-pages/editor/view.ts index 5f10eb4c20..3d63c1a336 100644 --- a/apps/web/src/page/status-pages/editor/view.ts +++ b/apps/web/src/page/status-pages/editor/view.ts @@ -29,7 +29,7 @@ import { pageStatus, } from "../shared/derive" import { Message } from "./message" -import { canBreakGlass, canManage, type Model } from "./model" +import { canBreakGlass, canManage, canPostIncidents, type Model } from "./model" import { componentsOf, incidentsOf, monitorsOf, pageOf, statusReadsReady } from "./reads" import { PAGE_MENU_ID, resolveView } from "./update" import { canvas } from "./view-canvas" @@ -143,7 +143,7 @@ const headerActions = (h: H, model: Model, page: V2StatusPage): Html => { const isPageView = resolveView(model.search.view) === "page" const entries = menuEntries(model, page) return detailActions(h, [ - ...(isPageView + ...(isPageView && canPostIncidents(model) ? [ button( h, diff --git a/packages/domain/src/policy/permissions.ts b/packages/domain/src/policy/permissions.ts index 975b131a16..716cf4b641 100644 --- a/packages/domain/src/policy/permissions.ts +++ b/packages/domain/src/policy/permissions.ts @@ -11,6 +11,15 @@ 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" }, + // Running the product day to day: members and above. Analysts read it and build dashboards, which + // every role may. The API refuses an analyst the rest in one place (`AnalystAccess`); these name + // it for the dashboard. + "error_issues:triage": { min: "member", action: "triage error issues" }, + "incidents:respond": { min: "member", action: "acknowledge and resolve incidents" }, + "investigations:run": { min: "member", action: "start investigations" }, + "status_page_incidents:post": { min: "member", action: "post status page incidents and updates" }, + "recommendations:triage": { min: "member", action: "dismiss and reopen recommendations" }, + // Technical setup: engineers and above. "integrations:manage": { min: "engineer", action: "manage integrations" }, "code_review:configure": { min: "engineer", action: "change code review settings" }, 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.