From 2067a166f83044ef5971b65f8890d97d6744b519 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 01:21:43 +0100 Subject: [PATCH 1/6] feat(dashboards): show observers no control that changes a dashboard The policy table now names the changes a member can make and an observer cannot. The API refuses them all in one place; these names are for the dashboard, so a page can leave out controls that would only fail. On the dashboards list, a board and the templates gallery, a viewer without dashboards:edit gets no create, import, duplicate, tag, delete, edit mode, rename or version restore, and one without dashboards:share gets no Share or Embed chart. Opening a board with ?mode=edit shows them the read view. Reading stays whole: time range, variables, version history and JSON export. --- apps/web/src/lab/entries/dashboards.ts | 313 ++++++++++++++++++ apps/web/src/lab/registry.ts | 8 + apps/web/src/page/dashboards/kit/grants.ts | 16 + apps/web/src/page/dashboards/list/command.ts | 6 + apps/web/src/page/dashboards/list/message.ts | 2 + apps/web/src/page/dashboards/list/model.ts | 3 + .../dashboards/list/observer.scene.test.ts | 122 +++++++ apps/web/src/page/dashboards/list/update.ts | 13 +- .../web/src/page/dashboards/list/view-list.ts | 32 +- apps/web/src/page/dashboards/list/view.ts | 19 +- .../src/page/dashboards/templates/command.ts | 6 + .../src/page/dashboards/templates/message.ts | 2 + .../src/page/dashboards/templates/model.ts | 3 + .../templates/observer.scene.test.ts | 86 +++++ .../src/page/dashboards/templates/update.ts | 8 +- .../page/dashboards/templates/view-list.ts | 56 ++-- .../page/dashboards/templates/view-panel.ts | 4 +- .../web/src/page/dashboards/templates/view.ts | 16 +- apps/web/src/page/dashboards/view/command.ts | 3 + .../page/dashboards/view/history-update.ts | 5 +- apps/web/src/page/dashboards/view/message.ts | 2 + apps/web/src/page/dashboards/view/mode.ts | 11 +- apps/web/src/page/dashboards/view/model.ts | 3 + .../dashboards/view/observer.scene.test.ts | 231 +++++++++++++ apps/web/src/page/dashboards/view/update.ts | 28 +- .../src/page/dashboards/view/view-history.ts | 43 +-- .../src/page/dashboards/view/view-toolbar.ts | 60 ++-- .../src/page/dashboards/view/view-widgets.ts | 5 +- apps/web/src/page/dashboards/view/view.ts | 49 ++- packages/domain/src/policy/permissions.ts | 10 + 30 files changed, 1054 insertions(+), 111 deletions(-) create mode 100644 apps/web/src/lab/entries/dashboards.ts create mode 100644 apps/web/src/page/dashboards/kit/grants.ts create mode 100644 apps/web/src/page/dashboards/list/observer.scene.test.ts create mode 100644 apps/web/src/page/dashboards/templates/observer.scene.test.ts create mode 100644 apps/web/src/page/dashboards/view/observer.scene.test.ts diff --git a/apps/web/src/lab/entries/dashboards.ts b/apps/web/src/lab/entries/dashboards.ts new file mode 100644 index 0000000000..af3434e860 --- /dev/null +++ b/apps/web/src/lab/entries/dashboards.ts @@ -0,0 +1,313 @@ +import * as Policy from "@maple/domain/policy" +import { OrgId } from "@maple/domain" +import { DashboardDocument } from "@maple/domain/http" +import { Effect, Option, Result, Schema, Array as Arr } from "effect" +import { Command } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { defineMessageUnion } from "foldkit/message" +import { button } from "@maple/views/ui/button" +import * as Menu from "@maple/views/ui/menu" +import type { PageContext } from "../../page/contract" +import { Message as ListMessage } from "../../page/dashboards/list/message" +import { type ListDashboard, Model as ListModel } from "../../page/dashboards/list/model" +import { init as listInit, update as listUpdate } from "../../page/dashboards/list/update" +import { view as listView } from "../../page/dashboards/list/view" +import { Message as BoardMessage } from "../../page/dashboards/view/message" +import { Model as BoardModel } from "../../page/dashboards/view/model" +import { init as boardInit, update as boardUpdate } from "../../page/dashboards/view/update" +import { view as boardView } from "../../page/dashboards/view/view" +import type { Resources } from "../../platform/resources" +import { defineShellLab } from "../shell-lab" + +// `/lab/dashboards`: the dashboards list and one board as a member or an observer, who reads +// dashboards but cannot change or share them (`?page=list|detail&role=member|observer&mode=edit`). + +const PAGES = ["list", "detail"] as const +const ROLES = ["member", "observer"] as const +type LabPage = (typeof PAGES)[number] +type LabRole = (typeof ROLES)[number] + +const NOW = Date.UTC(2026, 10, 6, 12) +const context: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_lab_dashboards"), + pinnedNamespace: null, + timeZone: "UTC", +} +const BOARD_ID = "dash_platform" + +const row = (fields: Partial): ListDashboard => ({ + id: BOARD_ID, + name: "Platform overview", + description: "Errors and latency across the platform services.", + tags: ["platform"], + widgetCount: 3, + readsShort: "Errors", + readsFull: "Errors", + glyph: "CircleWarningIcon", + createdAt: "2026-09-19T12:00:00.000Z", + updatedAt: "2026-11-06T09:00:00.000Z", + ...fields, +}) + +const ROWS: ReadonlyArray = [ + row({}), + row({ + id: "dash_checkout", + name: "Checkout health", + description: "Payment and cart traces.", + tags: ["checkout"], + widgetCount: 8, + readsShort: "Traces · Errors", + readsFull: "Traces · Errors", + glyph: "PulseIcon", + updatedAt: "2026-11-04T15:30:00.000Z", + }), + row({ + id: "dash_empty", + name: "Queue workers", + description: null, + tags: [], + widgetCount: 0, + readsShort: "", + readsFull: "", + glyph: "GridIcon", + updatedAt: "2026-10-21T08:00:00.000Z", + }), +] + +const stat = (id: string, title: string, field: string, x: number) => ({ + id, + visualization: "stat", + dataSource: { + kind: "route", + endpoint: "errors_summary", + transform: { reduceToValue: { field, aggregate: "first" } }, + }, + display: { title, unit: "number" }, + layout: { x, y: 0, w: 3, h: 2 }, +}) + +const BOARD = Schema.decodeUnknownSync(DashboardDocument)({ + id: BOARD_ID, + name: "Platform overview", + timeRange: { type: "relative", value: "6h" }, + widgets: [ + stat("total-errors", "Total errors", "totalErrors", 0), + stat("affected-services", "Affected services", "affectedServices", 3), + { + id: "notes", + visualization: "markdown", + dataSource: { kind: "static" }, + display: { + title: "Runbook", + markdown: { + content: "Error budget review is every Monday. Page the platform on-call first.", + }, + }, + layout: { x: 6, y: 0, w: 6, h: 2 }, + }, + ], + createdAt: "2026-09-19T12:00:00.000Z", + updatedAt: "2026-11-06T09:00:00.000Z", + schemaVersion: 3, +}) + +/** What every stat tile's fetch answers in the lab. */ +const SUMMARY = { data: [{ totalErrors: 1284, affectedServices: 7 }] } + +const grantsOf = (role: LabRole): ReadonlyArray => Policy.grantsOf([`org:${role}`]) + +const listModel = (role: LabRole): ListModel => + Arr.reduce( + [ + ListMessage.CompletedReadClock({ nowMs: NOW, locale: "en-US" }), + ListMessage.CompletedLoadPreferences({ favorites: [BOARD_ID], sort: "updated" }), + ListMessage.CompletedFetchSession({ orgId: context.orgId, grants: grantsOf(role) }), + ListMessage.UpdatedDashboards({ orgId: context.orgId, dashboards: ROWS }), + ], + listInit({ search: {} }, context).model, + (model, message) => listUpdate(model, message, context).model, + ) + +const WidgetFetch = Schema.Struct({ key: Schema.String }) +const decodeWidgetFetch = Schema.decodeUnknownOption(WidgetFetch) + +// Reads and writes that would reach the API; the lab has none. +const REMOTE = + /^(Fetch|Save|Create|Duplicate|Export|Import|Delete|Restore|Rename|Remove|Clone|Update|Add|AutoLayout|Edit|Write|Reload)/ + +type BoardStep = Readonly<{ + model: BoardModel + commands: ReadonlyArray> +}> + +/** One board step; a tile's fetch is answered from the fixture, other remote commands are dropped. */ +const boardStep = (model: BoardModel, message: BoardMessage): BoardStep => { + const next = boardUpdate(model, message, context) + const commands = next.commands ?? [] + const fetched = Arr.flatMap(commands, (command) => + command.name === "FetchDashboardWidget" ? Option.toArray(decodeWidgetFetch(command.args)) : [], + ) + const local = Arr.filter(commands, (command) => !REMOTE.test(command.name)) + return Arr.reduce(fetched, { model: next.model, commands: local }, (step, { key }): BoardStep => { + const answered = boardStep( + step.model, + BoardMessage.CompletedFetchWidget({ key, result: Result.succeed(SUMMARY) }), + ) + return { model: answered.model, commands: [...step.commands, ...answered.commands] } + }) +} + +const boardModel = (role: LabRole, isEdit: boolean): BoardModel => + Arr.reduce( + [ + BoardMessage.CompletedReadClock({ nowMs: NOW }), + BoardMessage.CompletedFetchSession({ orgId: context.orgId, grants: grantsOf(role) }), + BoardMessage.UpdatedDashboard({ orgId: context.orgId, maybeDocument: Option.some(BOARD) }), + ], + boardInit({ dashboardId: BOARD_ID, search: isEdit ? { mode: "edit" } : {} }, context).model, + (model, message) => boardStep(model, message).model, + ) + +const Model = Schema.Struct({ + page: Schema.Literals(PAGES), + role: Schema.Literals(ROLES), + /** The board was opened at `?mode=edit`: an observer still gets the read view. */ + isEdit: Schema.Boolean, + list: ListModel, + board: BoardModel, +}) +type Model = typeof Model.Type + +const Message = defineMessageUnion({ + ClickedPage: { page: Schema.Literals(PAGES) }, + ClickedRole: { role: Schema.Literals(ROLES) }, + /** The list's row menus open here; its other messages would write, so they are dropped. */ + GotRowMenuMessage: { rowId: Schema.String, message: Menu.Message }, + Ignored: {}, + GotBoardMessage: { message: BoardMessage }, +}) +type Message = typeof Message.Type + +const Flags = Schema.Struct({ + page: Schema.Literals(PAGES), + role: Schema.Literals(ROLES), + isEdit: Schema.Boolean, +}) +type Flags = typeof Flags.Type + +const decodePage = Schema.decodeUnknownOption(Schema.Literals(PAGES)) +const decodeRole = Schema.decodeUnknownOption(Schema.Literals(ROLES)) + +const build = (page: LabPage, role: LabRole, isEdit: boolean): Model => ({ + page, + role, + isEdit, + list: listModel(role), + board: boardModel(role, isEdit), +}) + +const toListMessage = (message: ListMessage): Message => + message._tag === "GotRowMenuMessage" + ? Message.GotRowMenuMessage({ rowId: message.rowId, message: message.message }) + : Message.Ignored() + +const update = (model: Model, message: Message) => + Message.match(message, { + ClickedPage: ({ page }) => ({ model: { ...model, page }, commands: [] }), + ClickedRole: ({ role }) => ({ model: build(model.page, role, model.isEdit), commands: [] }), + GotRowMenuMessage: (rowMenu) => { + const next = listUpdate(model.list, ListMessage.GotRowMenuMessage(rowMenu), context) + return { + model: { ...model, list: next.model }, + commands: Command.mapMessages( + Arr.filter(next.commands ?? [], (command) => !REMOTE.test(command.name)), + toListMessage, + ), + } + }, + Ignored: () => ({ model, commands: [] }), + GotBoardMessage: ({ message: inner }) => { + const next = boardStep(model.board, inner) + return { + model: { ...model, board: next.model }, + commands: Command.mapMessages(next.commands, (boardMessage) => + Message.GotBoardMessage({ message: boardMessage }), + ), + } + }, + }) + +const view = (model: Model, h: HtmlBuilder): Html => + model.page === "list" + ? h.submodel({ + slotId: "dashboards-lab-list", + model: model.list, + view: listView, + viewInputs: { context }, + toParentMessage: toListMessage, + }) + : h.submodel({ + slotId: "dashboards-lab-board", + model: model.board, + view: boardView, + viewInputs: { context }, + toParentMessage: (message) => Message.GotBoardMessage({ message }), + }) + +/** The page and role switcher, fixed at the bottom like the other shell labs'. */ +const switcher = (model: Model, h: HtmlBuilder): Html => + h.div( + [ + h.Class( + "bg-background fixed bottom-3 left-1/2 z-50 flex -translate-x-1/2 gap-1 rounded-lg border p-1", + ), + ], + [ + ...Arr.map(PAGES, (page) => + button( + h, + { + size: "xs", + variant: model.page === page ? "secondary" : "ghost", + attributes: [h.OnClick(Message.ClickedPage({ page }))], + }, + [page], + ), + ), + ...Arr.map(ROLES, (role) => + button( + h, + { + size: "xs", + variant: model.role === role ? "secondary" : "ghost", + attributes: [h.OnClick(Message.ClickedRole({ role }))], + }, + [role], + ), + ), + ], + ) + +export const lab = defineShellLab("Dashboards", { + Model, + Message, + Flags, + flags: Effect.sync(() => { + const params = new URLSearchParams(window.location.search) + return { + page: Option.getOrElse(decodePage(params.get("page")), (): LabPage => "list"), + role: Option.getOrElse(decodeRole(params.get("role")), (): LabRole => "member"), + isEdit: params.get("mode") === "edit", + } + }), + init: (flags: Flags) => ({ model: build(flags.page, flags.role, flags.isEdit) }), + update, + view, + before: switcher, + currentPath: "/dashboards", + breadcrumbs: (model) => + model.page === "list" + ? [{ label: "Dashboards", isPageHeading: true }] + : [{ label: "Dashboards" }, { label: BOARD.name }], +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 5f3069d72c..d07278033e 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -372,6 +372,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/dashboards", + title: "Dashboards", + description: + "The `/dashboards` list and one board as a member or an observer, who reads dashboards but cannot change or share them (?page=list|detail&role=member|observer&mode=edit).", + kind: "lab", + session: "none", + }, { path: "/lab/agent-tools", title: "Agent tools", diff --git a/apps/web/src/page/dashboards/kit/grants.ts b/apps/web/src/page/dashboards/kit/grants.ts new file mode 100644 index 0000000000..5927328ee3 --- /dev/null +++ b/apps/web/src/page/dashboards/kit/grants.ts @@ -0,0 +1,16 @@ +import * as Policy from "@maple/domain/policy" +import { isClerkAuthEnabled } from "../../../platform/auth-mode" + +type Grants = Readonly<{ grants: ReadonlyArray }> + +/** A dashboards 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 create, edit, duplicate, import, restore and delete dashboards (`dashboards:edit`). */ +export const canEditDashboards = (model: Grants): boolean => Policy.has(model.grants, "dashboards:edit") + +/** The viewer may share a dashboard or embed a widget outside the org (`dashboards:share`). */ +export const canShareDashboards = (model: Grants): boolean => Policy.has(model.grants, "dashboards:share") diff --git a/apps/web/src/page/dashboards/list/command.ts b/apps/web/src/page/dashboards/list/command.ts index 5a3c4faadd..fc4fe4aa38 100644 --- a/apps/web/src/page/dashboards/list/command.ts +++ b/apps/web/src/page/dashboards/list/command.ts @@ -32,6 +32,7 @@ import type { V2Dashboard } from "@maple/domain/http/v2" import { documentOf } from "../view/command-history" import { toListDashboard } from "./derive" import { Message, type WriteFailure } from "./message" +import { fetchSessionGrants } from "../../kit/session-grants" /** The localStorage key of the default sort for a URL without `?sort`. */ const SORT_KEY = "maple.dashboards.sort" @@ -53,6 +54,11 @@ export const ReadClock = Command.define("ReadDashboardsListClock", { ), }) +export const FetchListSession = fetchSessionGrants( + "FetchDashboardsListSession", + Message.CompletedFetchSession, +) + /** A picked tag keeps focus in the filter's search, as Base UI's items do. */ export const FocusTagSearch = Command.define("FocusDashboardsTagSearch", { args: { id: Schema.String }, diff --git a/apps/web/src/page/dashboards/list/message.ts b/apps/web/src/page/dashboards/list/message.ts index 584b4ad5f1..ee6c275875 100644 --- a/apps/web/src/page/dashboards/list/message.ts +++ b/apps/web/src/page/dashboards/list/message.ts @@ -7,6 +7,7 @@ import * as Menu from "@maple/views/ui/menu" import * as Combobox from "@maple/views/ui/combobox" import { ListDashboard, Scope } from "./model" import { WriteSync } from "../kit/store" +import { SessionGrants } from "../../kit/session-grants" /** A created dashboard: its id to open and its name for the toast. */ const Created = Schema.Struct({ @@ -27,6 +28,7 @@ export const Message = defineMessageUnion({ /** The clock and the browser's locale, read together before the list renders. */ CompletedReadClock: { nowMs: Schema.Number, locale: Schema.String }, CompletedLoadPreferences: { favorites: Schema.Array(Schema.String), sort: Schema.String }, + CompletedFetchSession: SessionGrants, UpdatedDashboards: { orgId: Schema.String, dashboards: Schema.Array(ListDashboard) }, /** The live list sat loading past the timeout. */ TimedOutLiveDashboards: { orgId: Schema.String }, diff --git a/apps/web/src/page/dashboards/list/model.ts b/apps/web/src/page/dashboards/list/model.ts index 4e4bf10c4a..db97429d1d 100644 --- a/apps/web/src/page/dashboards/list/model.ts +++ b/apps/web/src/page/dashboards/list/model.ts @@ -1,3 +1,4 @@ +import * as Policy from "@maple/domain/policy" import { Schema } from "effect" import { DashboardsSearch } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" @@ -46,6 +47,8 @@ export type Scope = typeof Scope.Type export const Model = Schema.Struct({ search: DashboardsSearch, + /** What the viewer may do; an observer reads the list and gets no write control. */ + grants: Schema.Array(Policy.PermissionName), /** `null` until the collection is ready. */ dashboards: Schema.NullOr(Schema.Array(ListDashboard)), nowMs: Schema.NullOr(Schema.Number), diff --git a/apps/web/src/page/dashboards/list/observer.scene.test.ts b/apps/web/src/page/dashboards/list/observer.scene.test.ts new file mode 100644 index 0000000000..9c9a9bbcd7 --- /dev/null +++ b/apps/web/src/page/dashboards/list/observer.scene.test.ts @@ -0,0 +1,122 @@ +import { OrgId } from "@maple/domain" +import * as Policy from "@maple/domain/policy" +import { 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 * as Menu from "@maple/views/ui/menu" +import type { PageContext } from "../../contract" +import { Message } from "./message" +import type { ListDashboard, Model } from "./model" +import { init, rowMenuIdOf, update } from "./update" +import { view as pageView } from "./view" + +// An observer reads the list; creating, importing and the row's writes need `dashboards:edit`. + +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 board: ListDashboard = { + id: "dash_platform", + name: "Platform overview", + description: "Cross-service health.", + tags: [], + widgetCount: 5, + readsShort: "Traces · Errors", + readsFull: "Traces · Errors", + glyph: "PulseIcon", + createdAt: "2026-09-19T12:00:00.000Z", + updatedAt: "2026-09-28T12:00:00.000Z", +} + +const MEMBER = Policy.grantsOf(["org:member"]) +const OBSERVER = Policy.grantsOf(["org:observer"]) + +/** Entered with its clock, preferences, the session's grants and rows. */ +const loaded = (grants: ReadonlyArray, dashboards: ReadonlyArray): Model => + Arr.reduce( + [ + Message.CompletedReadClock({ nowMs: Date.parse("2026-10-01T12:00:00Z"), locale: "en-US" }), + Message.CompletedLoadPreferences({ favorites: [], sort: "updated" }), + Message.CompletedFetchSession({ orgId, grants }), + Message.UpdatedDashboards({ orgId, dashboards }), + ], + init({ search: {} }, context).model, + (model, message) => pageUpdate(model, message).model, + ) + +const withRowMenuOpen = (model: Model): Model => ({ + ...model, + rowMenuId: board.id, + rowMenu: Menu.init(rowMenuIdOf(board.id), { isOpen: true }), +}) + +describe("dashboards list for an observer", () => { + test("the header keeps Browse templates and drops Import and Create", () => { + scene( + { update: pageUpdate, view }, + given(loaded(OBSERVER, [board])), + expectView(text("Platform overview")).toExist(), + expectView(role("button", { name: "Browse templates" })).toExist(), + expectView(role("button", { name: "Import" })).toBeAbsent(), + expectView(role("button", { name: "Create dashboard" })).toBeAbsent(), + expectView(text("New dashboard")).toBeAbsent(), + ) + }) + + test("a member gets Import, Create and the New dashboard row", () => { + scene( + { update: pageUpdate, view }, + given(loaded(MEMBER, [board])), + expectView(role("button", { name: "Import" })).toExist(), + expectView(role("button", { name: "Create dashboard" })).toExist(), + expectView(text("New dashboard")).toExist(), + ) + }) + + test("the row menu exports and offers no write", () => { + scene( + { update: pageUpdate, view }, + given(withRowMenuOpen(loaded(OBSERVER, [board]))), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(role("menuitem", { name: "Export JSON" })).toExist(), + expectView(role("menuitem", { name: "Duplicate" })).toBeAbsent(), + expectView(role("menuitem", { name: "Edit tags…" })).toBeAbsent(), + expectView(role("menuitem", { name: "Delete" })).toBeAbsent(), + ) + }) + + test("a member's row menu duplicates, tags and deletes", () => { + scene( + { update: pageUpdate, view }, + given(withRowMenuOpen(loaded(MEMBER, [board]))), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(role("menuitem", { name: "Duplicate" })).toExist(), + expectView(role("menuitem", { name: "Edit tags…" })).toExist(), + expectView(role("menuitem", { name: "Delete" })).toExist(), + ) + }) + + test("an empty org offers nothing to create", () => { + scene( + { update: pageUpdate, view }, + given(loaded(OBSERVER, [])), + expectView(text("No dashboards yet")).toExist(), + expectView(role("button", { name: "Create blank dashboard" })).toBeAbsent(), + ) + }) + + test("grants answered for another org are dropped", () => { + const other = Schema.decodeSync(OrgId)("org_other") + const model = pageUpdate( + loaded(MEMBER, [board]), + Message.CompletedFetchSession({ orgId: other, grants: OBSERVER }), + ).model + scene( + { update: pageUpdate, view }, + given(model), + expectView(role("button", { name: "Create dashboard" })).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/dashboards/list/update.ts b/apps/web/src/page/dashboards/list/update.ts index 47bae5aa09..d1d05fc132 100644 --- a/apps/web/src/page/dashboards/list/update.ts +++ b/apps/web/src/page/dashboards/list/update.ts @@ -21,7 +21,10 @@ import { WaitSearchDebounce, FocusTagSearch, FetchDashboardsFallback, + FetchListSession, } from "./command" +import { acceptGrants } from "../../kit/session-grants" +import { initialGrants, readsSessionGrants } from "../kit/grants" import { countLabel } from "@maple/ui/lib/format" import * as TagEditor from "../kit/tag-editor" import { displayName, queryOf, tagsOf } from "./derive" @@ -38,9 +41,10 @@ export const SORT_MENU_ID = "dashboards-sort-menu" export const TAG_FILTER_ID = "dashboards-tag-filter" export const rowMenuIdOf = (id: string): string => `dashboard-row-menu-${id}` -export const init = (route: Route, _context: PageContext): Return => ({ +export const init = (route: Route, context: PageContext): Return => ({ model: { search: route.search, + grants: initialGrants, dashboards: null, nowMs: null, locale: INITIAL_LOCALE, @@ -62,7 +66,11 @@ export const init = (route: Route, _context: PageContext): Return => ({ isCreating: false, fallback: { _tag: "Idle" }, }, - commands: [ReadClock(), LoadPreferences()], + commands: [ + ReadClock(), + LoadPreferences(), + ...(readsSessionGrants ? [FetchListSession({ orgId: context.orgId })] : []), + ], }) /** An outside change to `q` replaces the search input, its own echo does not. */ @@ -266,6 +274,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedLoadPreferences: ({ favorites, sort }) => ({ model: { ...model, favorites, storedSort: sort, isPrefsLoaded: true }, }), + CompletedFetchSession: (answer) => ({ model: acceptGrants(model, context.orgId, answer) }), // A live update is a healthy sync: writes come back on. UpdatedDashboards: ({ orgId, dashboards }) => orgId !== context.orgId ? { model } : { model: { ...model, dashboards, persistenceError: null } }, diff --git a/apps/web/src/page/dashboards/list/view-list.ts b/apps/web/src/page/dashboards/list/view-list.ts index bb2b77b7f4..e5ecb1e56f 100644 --- a/apps/web/src/page/dashboards/list/view-list.ts +++ b/apps/web/src/page/dashboards/list/view-list.ts @@ -29,6 +29,7 @@ import * as TagEditor from "../kit/tag-editor" import { Message } from "./message" import type { ListDashboard, Model, Scope } from "./model" import { isReadOnly, rowMenuIdOf } from "./update" +import { canEditDashboards } from "../kit/grants" import { deleteDialog } from "./view-dialogs" import { tagFilterView } from "./view-tag-filter" import { typography, typographyClass } from "@maple/views/ui/typography" @@ -155,12 +156,17 @@ const READ_ONLY_NOTE: ReadonlyArray = [ }, ] +const EXPORT_ENTRY: MenuView.MenuEntry = { _tag: "Item", value: "export", label: "Export JSON" } + +/** All an observer's row menu holds: the entries that write are not offered. */ +const OBSERVER_ROW_ENTRIES: ReadonlyArray = [{ _tag: "Group", entries: [EXPORT_ENTRY] }] + /** Export reads what the list holds, so it survives a write failure; the rest write, so they don't. */ const rowMenuEntries = (isReadOnly: boolean): ReadonlyArray => [ { _tag: "Group", entries: [ - { _tag: "Item", value: "export", label: "Export JSON" }, + EXPORT_ENTRY, { _tag: "Item", value: "duplicate", label: "Duplicate", isDisabled: isReadOnly }, { _tag: "Item", value: "tags", label: "Edit tags…", isDisabled: isReadOnly }, { _tag: "Separator" }, @@ -182,7 +188,7 @@ const rowMenu = (h: H, model: Model, dashboard: ListDashboard): Html => { return rowActionsMenu(h, { slotId: `row-menu-${dashboard.id}`, model: menu, - entries: rowMenuEntries(isReadOnly(model)), + entries: canEditDashboards(model) ? rowMenuEntries(isReadOnly(model)) : OBSERVER_ROW_ENTRIES, label: `More actions for ${displayName(dashboard.name)}`, className: "text-muted-foreground", toParentMessage: (message) => Message.GotRowMenuMessage({ rowId: dashboard.id, message }), @@ -397,6 +403,23 @@ const firstRunEmpty = (h: H, isReadOnly: boolean): Html => "py-16", ) +/** An observer's empty list: nothing to start from, so it says where dashboards come from. */ +const observerEmpty = (h: H): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyMedia(h, { variant: "icon" }, [icon(h, "GridIcon", { size: 18 })]), + emptyTitle(h, ["No dashboards yet"]), + emptyDescription(h, [ + "Dashboards your team creates show up here. Your role can read them but not create one.", + ]), + ]), + docsLink(h, "dashboards"), + ], + "py-16", + ) + /** A search or tag filter hid every dashboard. */ const nothingMatched = (h: H, model: Model): Html => { const query = Str.trim(queryOf(model.search)) @@ -487,9 +510,10 @@ export const listBody = ( const isFiltering = Str.trim(queryOf(model.search)).length > 0 || tagsOf(model.search).length > 0 const isNothingAtAll = dashboards.length === 0 const isWriteDisabled = isReadOnly(model) + const canEdit = canEditDashboards(model) const context: RowContext = { model, nowMs, timeZone } const body: ReadonlyArray = isNothingAtAll - ? [firstRunEmpty(h, isWriteDisabled)] + ? [canEdit ? firstRunEmpty(h, isWriteDisabled) : observerEmpty(h)] : sections.matched === 0 ? [nothingMatched(h, model)] : sections.scoped === 0 @@ -519,7 +543,7 @@ export const listBody = ( dashboardRow(h, context, dashboard, false), ), ]), - createRow(h, isWriteDisabled), + ...(canEdit ? [createRow(h, isWriteDisabled)] : []), ] return vstack(h, { className: "shrink-0 pb-6" }, [ toolbar(h, model, dashboards, sections.scoped), diff --git a/apps/web/src/page/dashboards/list/view.ts b/apps/web/src/page/dashboards/list/view.ts index 132e99fcad..0c87047726 100644 --- a/apps/web/src/page/dashboards/list/view.ts +++ b/apps/web/src/page/dashboards/list/view.ts @@ -16,6 +16,7 @@ import type { Model } from "./model" import { isDegraded, isLoadFailed, listedOf } from "./update" import { listBody, listSkeleton } from "./view-list" import { snapshotBanner } from "../kit/live-sync" +import { canEditDashboards } from "../kit/grants" import { String as Str } from "effect" import { hstack } from "@maple/views/ui/stack" @@ -59,17 +60,16 @@ const readOnlyReason = (model: Model): string | null => ? "Couldn't reach Maple. Editing is off until it reconnects." : `${writeFailureCause(model.persistenceError)}. Editing is off until it recovers.` +const browseTemplates = (h: H): Html => + button(h, { variant: "outline", size: "sm", attributes: [h.OnClick(Message.ClickedBrowseTemplates())] }, [ + icon(h, "GridIcon", { attributes: [h.DataAttribute("icon", "inline-start")] }), + "Browse templates", + ]) + /** Browse templates, Import (the hidden file input) and Create dashboard. */ const actions = (h: H, reason: string | null): Html => hstack(h, { gap: "1", align: "center", wrap: true }, [ - button( - h, - { variant: "outline", size: "sm", attributes: [h.OnClick(Message.ClickedBrowseTemplates())] }, - [ - icon(h, "GridIcon", { attributes: [h.DataAttribute("icon", "inline-start")] }), - "Browse templates", - ], - ), + browseTemplates(h), withReason( h, reason, @@ -160,7 +160,8 @@ export const view = Submodel.defineView((model, h, { titleContent: title, - actions: actions(h, reason), + // An observer browses the templates but neither imports nor creates. + actions: canEditDashboards(model) ? actions(h, reason) : browseTemplates(h), sticky: persistenceBanner(h, model.persistenceError, isLoadFailed(model)), }, isLoadFailed(model) diff --git a/apps/web/src/page/dashboards/templates/command.ts b/apps/web/src/page/dashboards/templates/command.ts index 9e6b766db2..bf734a9f4c 100644 --- a/apps/web/src/page/dashboards/templates/command.ts +++ b/apps/web/src/page/dashboards/templates/command.ts @@ -9,9 +9,15 @@ import { MapleApiV2Client } from "../../../platform/api-clients" import { fetchWidgetData, toWidgetFailure } from "../kit/widget-data" import { awaitTxid } from "../kit/store" import { Message } from "./message" +import { fetchSessionGrants } from "../../kit/session-grants" export const ReadClock = readClock("ReadTemplatesClock", Message.CompletedReadClock) +export const FetchTemplatesSession = fetchSessionGrants( + "FetchDashboardTemplatesSession", + Message.CompletedFetchSession, +) + export const FetchTemplates = Command.define("FetchDashboardTemplates", { args: { key: Schema.String }, messages: [Message.CompletedFetchTemplates], diff --git a/apps/web/src/page/dashboards/templates/message.ts b/apps/web/src/page/dashboards/templates/message.ts index fc0fe0aee7..b343b8b5b8 100644 --- a/apps/web/src/page/dashboards/templates/message.ts +++ b/apps/web/src/page/dashboards/templates/message.ts @@ -7,6 +7,7 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import { WidgetFailure } from "../kit/widget-data" import { MetricSeen, ReadinessFilter } from "./model" +import { SessionGrants } from "../../kit/session-grants" const result = (schema: A) => ({ key: Schema.String, @@ -15,6 +16,7 @@ const result = (schema: A) => ({ export const Message = defineMessageUnion({ CompletedReadClock: { nowMs: Schema.Number }, + CompletedFetchSession: SessionGrants, CompletedFetchTemplates: result(Schema.Array(V2DashboardTemplate)), CompletedFetchMetrics: result(Schema.Array(MetricSeen)), ElapsedPreviewDelay: { generation: Schema.Number }, diff --git a/apps/web/src/page/dashboards/templates/model.ts b/apps/web/src/page/dashboards/templates/model.ts index 75630a8e7d..bb89ea0072 100644 --- a/apps/web/src/page/dashboards/templates/model.ts +++ b/apps/web/src/page/dashboards/templates/model.ts @@ -1,5 +1,6 @@ import { DashboardWidgetSchema } from "@maple/domain/http" import { V2DashboardTemplate, V2DashboardTemplatePreview } 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 { ChartRuntime } from "@maple/views/charts/chart-runtime" @@ -20,6 +21,8 @@ export type MetricSeen = typeof MetricSeen.Type export const Model = Schema.Struct({ search: DashboardsTemplatesSearch, + /** What the viewer may do; an observer previews templates and gets no create control. */ + grants: Schema.Array(Policy.PermissionName), nowMs: Schema.NullOr(Schema.Number), templates: KeyedSchema(Schema.Array(V2DashboardTemplate), LoadFailure), /** Readiness fails open until it answers. */ diff --git a/apps/web/src/page/dashboards/templates/observer.scene.test.ts b/apps/web/src/page/dashboards/templates/observer.scene.test.ts new file mode 100644 index 0000000000..ca47737f71 --- /dev/null +++ b/apps/web/src/page/dashboards/templates/observer.scene.test.ts @@ -0,0 +1,86 @@ +import { OrgId } from "@maple/domain" +import { V2DashboardTemplate } 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 } from "@maple/views/charts/theme-colors" +import type { PageContext } from "../../contract" +import { CreateFromTemplate } from "./command" +import { Message } from "./message" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// An observer previews templates; creating a dashboard from one needs `dashboards:edit`. + +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 names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + Arr.map(commands ?? [], ({ name }) => name) + +const template = Schema.decodeUnknownSync(Schema.toType(V2DashboardTemplate))({ + id: "service-health", + object: "dashboard_template", + name: "Service Health", + description: "Service Health board.", + category: "application", + tags: [], + requirements: [], + requirement: null, + requiredMetricPrefixes: [], + parameters: [], + preview: [], +}) + +const MEMBER = Policy.grantsOf(["org:member"]) +const OBSERVER = Policy.grantsOf(["org:observer"]) + +/** The gallery with its one template selected and the session's grants answered. */ +const selected = (grants: ReadonlyArray): Model => { + const entered = init({ search: { template: "service-health" } }, context).model + return Arr.reduce( + [ + Message.CompletedFetchSession({ orgId, grants }), + Message.CompletedFetchTemplates({ + key: entered.templates.key ?? "", + result: Result.succeed([template]), + }), + ], + entered, + (model, message) => pageUpdate(model, message).model, + ) +} + +describe("dashboard templates for an observer", () => { + test("the template previews with no way to create from it", () => { + const model = selected(OBSERVER) + expect(names(pageUpdate(model, Message.ClickedCreate({ templateId: template.id })).commands)).toEqual( + [], + ) + scene( + { update: pageUpdate, view }, + given(model), + Mount.resolve(ResolveThemeColors, Message.ResolvedThemeColors({ colors: {} })), + expectView(text("Service Health board.")).toExist(), + expectView(role("button", { name: "Create dashboard" })).toBeAbsent(), + expectView(text("Blank dashboard")).toBeAbsent(), + ) + }) + + test("a member creates from the template or starts blank", () => { + const model = selected(MEMBER) + expect(names(pageUpdate(model, Message.ClickedCreate({ templateId: template.id })).commands)).toEqual( + [CreateFromTemplate.name], + ) + scene( + { update: pageUpdate, view }, + given(model), + Mount.resolve(ResolveThemeColors, Message.ResolvedThemeColors({ colors: {} })), + expectView(role("button", { name: "Create dashboard" })).toExist(), + expectView(text("Blank dashboard")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/dashboards/templates/update.ts b/apps/web/src/page/dashboards/templates/update.ts index 16817dd889..d46b0f92ac 100644 --- a/apps/web/src/page/dashboards/templates/update.ts +++ b/apps/web/src/page/dashboards/templates/update.ts @@ -20,6 +20,7 @@ import { FetchMetrics, FetchPreview, FetchTemplates, + FetchTemplatesSession, FetchWidget, ReadClock, WaitParameterDebounce, @@ -29,6 +30,8 @@ import { import { filledParametersOf, missingRequiredOf, previewVariableValues } from "./derive" import { Message } from "./message" import type { Model } from "./model" +import { acceptGrants } from "../../kit/session-grants" +import { canEditDashboards, initialGrants, readsSessionGrants } from "../kit/grants" export type Return = PageReturn type Search = typeof DashboardsTemplatesSearch.Type @@ -51,6 +54,7 @@ export const init = (route: Route, context: PageContext): Return => { return { model: { search: route.search, + grants: initialGrants, nowMs: null, templates: { key: templates, data: AsyncData.Loading() }, metrics: { key: metrics, data: AsyncData.Loading() }, @@ -78,6 +82,7 @@ export const init = (route: Route, context: PageContext): Return => { FetchTemplates({ key: templates }), FetchMetrics({ key: metrics }), loadChartRuntime, + ...(readsSessionGrants ? [FetchTemplatesSession({ orgId: context.orgId })] : []), ], } } @@ -199,7 +204,7 @@ const selectionHref = (templateId: string | undefined) => /** Only filled parameters are sent. */ const create = (model: Model, templateId: string): Return => { - if (model.isCreating) return { model } + if (model.isCreating || !canEditDashboards(model)) return { model } const parameters = filledParametersOf(model.parameters) return { model: { ...model, isCreating: true }, @@ -221,6 +226,7 @@ const sortStep = (model: Model, widgetId: string, field: string, isText: boolean export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { CompletedReadClock: ({ nowMs }) => planPreview({ ...model, nowMs }, context), + CompletedFetchSession: (answer) => ({ model: acceptGrants(model, context.orgId, answer) }), CompletedFetchTemplates: ({ key, result }) => { const next = { ...model, templates: Keyed.accept(model.templates, key, result) } // The panel mounts once the list resolves the selection; its arming delay starts then. diff --git a/apps/web/src/page/dashboards/templates/view-list.ts b/apps/web/src/page/dashboards/templates/view-list.ts index 59d40b6385..b67645ada2 100644 --- a/apps/web/src/page/dashboards/templates/view-list.ts +++ b/apps/web/src/page/dashboards/templates/view-list.ts @@ -25,6 +25,7 @@ import { filteredEmpty as filteredEmptyState } from "@maple/views/ui/signal-empt import { typography, typographyClass } from "@maple/views/ui/typography" import { Array as Arr, String as Str } from "effect" import { hstack, vstack } from "@maple/views/ui/stack" +import { canEditDashboards } from "../kit/grants" type H = HtmlBuilder @@ -212,6 +213,34 @@ const groups = ( ] } +/** The footer row that starts a blank dashboard. */ +const blankRow = (h: H): Html => + pressable( + h, + { + onClick: Message.ClickedCreate({ templateId: BLANK_TEMPLATE_ID }), + className: + "flex shrink-0 items-center gap-2.5 border-t border-border bg-sidebar py-3 pr-4 pl-4 text-left hover:bg-surface-sunken active:bg-surface-sunken", + }, + [ + hstack( + h, + { + align: "center", + justify: "center", + as: "span", + className: "size-5.5 shrink-0 rounded-sm border border-input border-dashed", + }, + [icon(h, "PlusIcon", { size: 12, className: "text-muted-foreground" })], + ), + hstack(h, { align: "baseline", shrink: true, as: "span", className: "grow gap-2" }, [ + typography(h, { size: "sm", tone: "foreground" }, ["Blank dashboard"]), + typography(h, { size: "2xs", tone: "muted" }, ["start from nothing"]), + ]), + icon(h, "ArrowRightIcon", { size: 14, className: "text-muted-foreground shrink-0" }), + ], + ) + export const templateList = ( h: H, model: Model, @@ -308,30 +337,7 @@ export const templateList = ( ), ], ), - pressable( - h, - { - onClick: Message.ClickedCreate({ templateId: BLANK_TEMPLATE_ID }), - className: - "flex shrink-0 items-center gap-2.5 border-t border-border bg-sidebar py-3 pr-4 pl-4 text-left hover:bg-surface-sunken active:bg-surface-sunken", - }, - [ - hstack( - h, - { - align: "center", - justify: "center", - as: "span", - className: "size-5.5 shrink-0 rounded-sm border border-input border-dashed", - }, - [icon(h, "PlusIcon", { size: 12, className: "text-muted-foreground" })], - ), - hstack(h, { align: "baseline", shrink: true, as: "span", className: "grow gap-2" }, [ - typography(h, { size: "sm", tone: "foreground" }, ["Blank dashboard"]), - typography(h, { size: "2xs", tone: "muted" }, ["start from nothing"]), - ]), - icon(h, "ArrowRightIcon", { size: 14, className: "text-muted-foreground shrink-0" }), - ], - ), + // Starting blank creates a dashboard, so it needs `dashboards:edit`. + ...(canEditDashboards(model) ? [blankRow(h)] : []), ]) } diff --git a/apps/web/src/page/dashboards/templates/view-panel.ts b/apps/web/src/page/dashboards/templates/view-panel.ts index 5cf61cba28..839a6ffdc1 100644 --- a/apps/web/src/page/dashboards/templates/view-panel.ts +++ b/apps/web/src/page/dashboards/templates/view-panel.ts @@ -37,6 +37,7 @@ import { MAX_PREVIEW_WIDGETS, selectedOf } from "./update" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" import { Array as Arr } from "effect" +import { canEditDashboards } from "../kit/grants" type H = HtmlBuilder @@ -473,7 +474,8 @@ export const detailPanel = ( ]), ...requirementBlock(h, template, readiness, model.nowMs ?? 0, timeZone), ...parameterFields(h, model, template), - createRow(h, model, template, isReady), + // Without `dashboards:edit` the panel previews the template and offers no create row. + ...(canEditDashboards(model) ? [createRow(h, model, template, isReady)] : []), ], ) } diff --git a/apps/web/src/page/dashboards/templates/view.ts b/apps/web/src/page/dashboards/templates/view.ts index f90a78cf37..08b7d4cb1a 100644 --- a/apps/web/src/page/dashboards/templates/view.ts +++ b/apps/web/src/page/dashboards/templates/view.ts @@ -17,6 +17,7 @@ import { templateList } from "./view-list" import { detailPanel, panelRestState } from "./view-panel" import { typography } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" +import { canEditDashboards } from "../kit/grants" // Below `lg` the detail panel is a right sheet over the list, else the side pane. @@ -29,7 +30,7 @@ const summary = (h: H, readyCount: number, catalogueCount: number): Html => typography(h, { tone: "muted", mono: true }, [String(catalogueCount), " templates"]), ]) -const failed = (h: H, failure: LoadFailure): Html => +const failed = (h: H, failure: LoadFailure, canCreate: boolean): Html => h.div( [h.Class("p-4")], [ @@ -38,10 +39,13 @@ const failed = (h: H, failure: LoadFailure): Html => title: "Couldn't load the templates", note: "Nothing you've built is affected.", onRetry: Message.ClickedRetry(), - secondaryAction: { - label: "Start blank instead", - onClick: Message.ClickedCreate({ templateId: BLANK_TEMPLATE_ID }), - }, + // Starting blank creates a dashboard, so it needs `dashboards:edit`. + secondaryAction: canCreate + ? { + label: "Start blank instead", + onClick: Message.ClickedCreate({ templateId: BLANK_TEMPLATE_ID }), + } + : undefined, }), ], ) @@ -78,7 +82,7 @@ export const view = Submodel.defineView((model, }, [ failure !== null - ? failed(h, failure) + ? failed(h, failure, canEditDashboards(model)) : hstack(h, { align: "stretch", className: "h-full min-h-0" }, [ vstack( h, diff --git a/apps/web/src/page/dashboards/view/command.ts b/apps/web/src/page/dashboards/view/command.ts index 502b394d7a..683694661c 100644 --- a/apps/web/src/page/dashboards/view/command.ts +++ b/apps/web/src/page/dashboards/view/command.ts @@ -32,9 +32,12 @@ import { ShareMessage } from "./share" import { settleTo } from "@maple/views/data/load-failure" import { presetById } from "./picker-catalog" import { applySectionEdit, SectionEdit } from "./section-edit" +import { fetchSessionGrants } from "../../kit/session-grants" export const ReadClock = readClock("ReadDashboardClock", Message.CompletedReadClock) +export const FetchBoardSession = fetchSessionGrants("FetchDashboardSession", Message.CompletedFetchSession) + /** The chart code failed to load: a fresh page load is the only way to fetch it again. */ export const ReloadPage = Command.define("ReloadDashboardPage", { messages: [Message.ReloadedPage], diff --git a/apps/web/src/page/dashboards/view/history-update.ts b/apps/web/src/page/dashboards/view/history-update.ts index 5be32ef5b5..1740bbf647 100644 --- a/apps/web/src/page/dashboards/view/history-update.ts +++ b/apps/web/src/page/dashboards/view/history-update.ts @@ -6,6 +6,7 @@ import { FetchSnapshot, FetchVersions, RestoreVersion } from "./command-history" import type { HistoryState } from "./history" import { Message } from "./message" import { isWriteBlocked } from "./mode" +import { canEditDashboards } from "../kit/grants" import type { Model } from "./model" type Return = PageReturn @@ -116,7 +117,9 @@ export const historyStep = (model: Model, message: HistoryMessage): Return => { case "ClickedCancelPreview": return { model: closePreview(model) } case "ClickedRestoreVersion": - return isWriteBlocked(model) ? { model } : dialogStep(model, Dialog.open(history.restoreDialog)) + return isWriteBlocked(model) || !canEditDashboards(model) + ? { model } + : dialogStep(model, Dialog.open(history.restoreDialog)) case "GotRestoreDialogMessage": return dialogStep(model, Dialog.update(history.restoreDialog, message.message)) case "ConfirmedRestore": diff --git a/apps/web/src/page/dashboards/view/message.ts b/apps/web/src/page/dashboards/view/message.ts index a587825b01..1d591ee9ed 100644 --- a/apps/web/src/page/dashboards/view/message.ts +++ b/apps/web/src/page/dashboards/view/message.ts @@ -17,6 +17,7 @@ import { WriteSync } from "../kit/store" import { LoadFailure } from "@maple/views/data/load-failure" import { VersionRow } from "./history" import { ShareMessage } from "./share" +import { SessionGrants } from "../../kit/session-grants" /** A write's outcome: the failure is the message the persistence banner shows. */ const WriteResult = Schema.Result(WriteSync, Schema.String) @@ -32,6 +33,7 @@ export const Message = defineMessageUnion({ /** "Retry sync" on the snapshot banner, or Try again on the load error: reconnect, read again. */ ClickedRetrySync: {}, CompletedReadClock: { nowMs: Schema.Number }, + CompletedFetchSession: SessionGrants, MeasuredGrid: { width: Schema.Number }, MeasuredWidget: { widgetId: Schema.String, widthPx: Schema.Number }, RevealedWidget: { widgetId: Schema.String }, diff --git a/apps/web/src/page/dashboards/view/mode.ts b/apps/web/src/page/dashboards/view/mode.ts index 12c56c2c9e..fe503c22c6 100644 --- a/apps/web/src/page/dashboards/view/mode.ts +++ b/apps/web/src/page/dashboards/view/mode.ts @@ -1,9 +1,16 @@ +import { canEditDashboards } from "../kit/grants" import type { Model } from "./model" /** Writes are off: a write failed, or the board is an HTTP snapshot while live sync is down. */ export const isWriteBlocked = (model: Model): boolean => model.persistenceError !== null || model.snapshot._tag === "Shown" -/** Edit only from the URL, never while writes are unavailable or a version is previewed. */ +/** + * Edit only from the URL, never while writes are unavailable or a version is previewed. A viewer + * without `dashboards:edit` who opens `?mode=edit` gets the read view. + */ export const isEditing = (model: Model): boolean => - model.search.mode === "edit" && !isWriteBlocked(model) && model.history.previewed === null + model.search.mode === "edit" && + canEditDashboards(model) && + !isWriteBlocked(model) && + model.history.previewed === null diff --git a/apps/web/src/page/dashboards/view/model.ts b/apps/web/src/page/dashboards/view/model.ts index 686d0eb61d..4cc77dd64e 100644 --- a/apps/web/src/page/dashboards/view/model.ts +++ b/apps/web/src/page/dashboards/view/model.ts @@ -1,4 +1,5 @@ import { DashboardDocument } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" import { Schema } from "effect" import * as Chart from "@maple/views/charts/chart" import { ChartRuntime } from "@maple/views/charts/chart-runtime" @@ -151,6 +152,8 @@ export const Model = Schema.Struct({ dashboardId: Schema.String, search: DashboardSearch, status: Status, + /** What the viewer may do; an observer reads the board and gets no edit or share control. */ + grants: Schema.Array(Policy.PermissionName), document: Schema.NullOr(DashboardDocument), /** The live board never answered within `COLLECTION_LOAD_TIMEOUT_MS`: the HTTP snapshot's state. */ snapshot: Snapshot, diff --git a/apps/web/src/page/dashboards/view/observer.scene.test.ts b/apps/web/src/page/dashboards/view/observer.scene.test.ts new file mode 100644 index 0000000000..e7c4f49d84 --- /dev/null +++ b/apps/web/src/page/dashboards/view/observer.scene.test.ts @@ -0,0 +1,231 @@ +import { OrgId } from "@maple/domain" +import { DashboardDocument } from "@maple/domain/http" +import * as Policy from "@maple/domain/policy" +import { Option, Result, Schema, Array as Arr } from "effect" +import { Mount, expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" +import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" +import { describe, expect, test } from "vitest" +import * as Menu from "@maple/views/ui/menu" +import type { PageContext } from "../../contract" +import { MeasureGrid, WatchGridHandles, WatchWidget } from "./command" +import { Message } from "./message" +import { isEditing } from "./mode" +import type { Model } from "./model" +import { init, update } from "./update" +import { view as pageView } from "./view" + +// An observer reads a board; editing needs `dashboards:edit` and sharing `dashboards:share`. + +const orgId = Schema.decodeSync(OrgId)("org_acme") +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const DASHBOARD_ID = "dash_platform" +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const run = (model: Model, messages: ReadonlyArray) => + Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) +const view = withViewInputs(pageView, { context })() +const themed = () => Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) +/** The canvas measured and its one tile in view. */ +const mounted = () => [ + themed(), + Mount.resolve(MeasureGrid, Message.MeasuredGrid({ width: 1200 })), + Mount.resolve(WatchGridHandles, Message.BlurredGrip({ widgetId: "note" })), + Mount.resolve(WatchWidget, Message.RevealedWidget({ widgetId: "note" })), +] + +const note = { + id: "note", + visualization: "markdown", + dataSource: { kind: "static" }, + display: { title: "Runbook", markdown: { content: "Page the platform on-call first." } }, + layout: { x: 0, y: 0, w: 6, h: 2 }, +} + +const board = (widgets: ReadonlyArray) => + Schema.decodeUnknownSync(DashboardDocument)({ + id: DASHBOARD_ID, + name: "Platform overview", + timeRange: { type: "relative", value: "6h" }, + widgets, + createdAt: "2026-09-19T12:00:00.000Z", + updatedAt: "2026-09-28T12:00:00.000Z", + schemaVersion: 3, + }) + +const version = { + id: "0192a3b4-0000-7000-8000-000000000002", + versionNumber: 2, + changeKind: "widget_added" as const, + changeSummary: null, + isRestore: false, + createdAt: "2026-09-29T12:00:00.000Z", + createdBy: "user_ada", +} + +const MEMBER = Policy.grantsOf(["org:member"]) +const OBSERVER = Policy.grantsOf(["org:observer"]) +type Grants = ReadonlyArray + +/** Entered, clocked, holding the session's grants and the document. */ +const ready = (grants: Grants, search: { mode?: "edit" } = {}, document = board([note])): Model => + run(init({ dashboardId: DASHBOARD_ID, search }, context).model, [ + Message.CompletedReadClock({ nowMs: Date.parse("2026-10-01T12:00:00Z") }), + Message.CompletedFetchSession({ orgId, grants }), + Message.UpdatedDashboard({ orgId, maybeDocument: Option.some(document) }), + ]) + +const withMoreMenuOpen = (model: Model): Model => ({ + ...model, + moreMenu: Menu.init(model.moreMenu.id, { isOpen: true }), +}) + +/** History opened, then v2 previewed with its snapshot loaded. */ +const previewV2 = (model: Model): Model => + run(model, [ + Message.GotMoreMenuMessage({ + message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value: "history" } }, + }), + Message.CompletedFetchVersions({ + generation: model.history.versionsGeneration + 1, + nowMs: 0, + result: Result.succeed([version]), + }), + Message.ClickedVersion({ versionId: version.id }), + Message.CompletedFetchSnapshot({ versionId: version.id, result: Result.succeed(board([note])) }), + ]) + +describe("a dashboard for an observer", () => { + test("the board and its widgets render without an Edit control", () => { + scene( + { update: pageUpdate, view }, + given(ready(OBSERVER)), + ...mounted(), + expectView(text("Page the platform on-call first.")).toExist(), + expectView(role("button", { name: "Reload" })).toExist(), + expectView(role("button", { name: "Edit dashboard" })).toBeAbsent(), + ) + }) + + test("a member gets the Edit control", () => { + scene( + { update: pageUpdate, view }, + given(ready(MEMBER)), + ...mounted(), + expectView(role("button", { name: "Edit dashboard" })).toExist(), + ) + }) + + test("?mode=edit lands on the read view", () => { + const model = ready(OBSERVER, { mode: "edit" }) + expect(isEditing(model)).toBe(false) + scene( + { update: pageUpdate, view }, + given(model), + ...mounted(), + expectView(text("Page the platform on-call first.")).toExist(), + expectView(role("button", { name: "Done editing" })).toBeAbsent(), + expectView(role("button", { name: "Add widget" })).toBeAbsent(), + expectView(role("button", { name: "Widget actions" })).toBeAbsent(), + ) + }) + + test("?mode=edit edits for a member", () => { + const model = ready(MEMBER, { mode: "edit" }) + expect(isEditing(model)).toBe(true) + scene( + { update: pageUpdate, view }, + given(model), + ...mounted(), + expectView(role("button", { name: "Done editing" })).toExist(), + expectView(role("button", { name: "Add widget" })).toExist(), + ) + }) + + test("the name is a heading, not a rename control", () => { + const model = ready(OBSERVER) + expect(pageUpdate(model, Message.ClickedTitle()).model.titleDraft).toBeNull() + scene( + { update: pageUpdate, view }, + given(model), + ...mounted(), + expectView(role("heading", { name: "Platform overview" })).toExist(), + expectView(role("button", { name: "Platform overview" })).toBeAbsent(), + ) + }) + + test("a member renames from the name", () => { + const model = ready(MEMBER) + expect(pageUpdate(model, Message.ClickedTitle()).model.titleDraft).toBe("Platform overview") + scene( + { update: pageUpdate, view }, + given(model), + ...mounted(), + expectView(role("button", { name: "Platform overview" })).toExist(), + ) + }) + + test("the more menu keeps history and export and drops Share", () => { + scene( + { update: pageUpdate, view }, + given(withMoreMenuOpen(ready(OBSERVER))), + ...mounted(), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(role("menuitem", { name: "Version history" })).toExist(), + expectView(role("menuitem", { name: "Export as JSON" })).toExist(), + expectView(role("menuitem", { name: "Share…" })).toBeAbsent(), + ) + }) + + test("a member's more menu shares", () => { + scene( + { update: pageUpdate, view }, + given(withMoreMenuOpen(ready(MEMBER))), + ...mounted(), + Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), + expectView(role("menuitem", { name: "Share…" })).toExist(), + ) + }) + + test("a previewed version can be left but not restored", () => { + const previewed = previewV2(ready(OBSERVER)) + expect(previewed.history.previewed?.id).toBe(version.id) + expect(pageUpdate(previewed, Message.ClickedRestoreVersion()).model.history.restoreDialog).toEqual( + previewed.history.restoreDialog, + ) + scene( + { update: pageUpdate, view }, + given(previewed), + ...mounted(), + expectView(role("button", { name: "Cancel" })).toExist(), + expectView(role("button", { name: /Restore this version/ })).toBeAbsent(), + ) + }) + + test("a member restores a previewed version", () => { + scene( + { update: pageUpdate, view }, + given(previewV2(ready(MEMBER))), + ...mounted(), + expectView(role("button", { name: /Restore this version/ })).toBeEnabled(), + ) + }) + + test("an empty board offers nothing to add", () => { + scene( + { update: pageUpdate, view }, + given(ready(OBSERVER, {}, board([]))), + themed(), + expectView(text("Nothing on this board yet")).toExist(), + expectView(role("button", { name: "Add your first widget" })).toBeAbsent(), + expectView(role("link", { name: "Start from a template" })).toBeAbsent(), + ) + }) + + test("a member's empty board offers the first widget", () => { + scene( + { update: pageUpdate, view }, + given(ready(MEMBER, {}, board([]))), + themed(), + expectView(role("button", { name: "Add your first widget" })).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/dashboards/view/update.ts b/apps/web/src/page/dashboards/view/update.ts index b7de643f9c..036829c2cd 100644 --- a/apps/web/src/page/dashboards/view/update.ts +++ b/apps/web/src/page/dashboards/view/update.ts @@ -27,6 +27,7 @@ import { EditSections, CompleteReloadSpin, ExportDashboard, + FetchBoardSession, FetchShares, ReadClock, ReloadPage, @@ -69,16 +70,19 @@ import { SectionEdit } from "./section-edit" import { moveTargetOf } from "./move-target" import * as TagEditor from "../kit/tag-editor" import { createAlertSearch } from "./widget-actions" +import { acceptGrants } from "../../kit/session-grants" +import { canEditDashboards, canShareDashboards, initialGrants, readsSessionGrants } from "../kit/grants" export type Return = PageReturn type Search = typeof DashboardSearch.Type type Route = Readonly<{ dashboardId: string; search: Search }> -export const init = (route: Route, _context: PageContext): Return => ({ +export const init = (route: Route, context: PageContext): Return => ({ model: { dashboardId: route.dashboardId, search: route.search, status: "Loading", + grants: initialGrants, document: null, snapshot: { _tag: "Idle" }, nowMs: null, @@ -130,7 +134,11 @@ export const init = (route: Route, _context: PageContext): Return => ({ variableComboName: null, variableDrafts: {}, }, - commands: [ReadClock(), LoadChartRuntime()], + commands: [ + ReadClock(), + LoadChartRuntime(), + ...(readsSessionGrants ? [FetchBoardSession({ orgId: context.orgId })] : []), + ], }) const sameValues = (a: Readonly>, b: Readonly>): boolean => @@ -448,7 +456,7 @@ const moreAction = (model: Model, context: PageContext, value: string): Return = ? { model, commands: [AutoLayout({ orgId: context.orgId, dashboardId: model.dashboardId })] } : { model } case "share": - return openShare(model) + return canShareDashboards(model) ? openShare(model) : { model } case "export": // A preview still loading has no snapshot yet; exporting now would export the live board. if (model.history.previewed !== null && shownDocumentOf(model) === null) return { model } @@ -547,7 +555,9 @@ const widgetAction = (model: Model, context: PageContext, widgetId: string, valu ) } case "embed": - return model.history.previewed === null ? openEmbed(model, widgetId) : { model } + return model.history.previewed === null && canShareDashboards(model) + ? openEmbed(model, widgetId) + : { model } default: return { model } } @@ -603,6 +613,11 @@ export const update = (model: Model, message: Message, context: PageContext): Re model.status === "Failed" ? readSnapshot(model, "retry") : { model, outMessage: PageOutMessage.RequestedReconnectOrgCollections() }, + // Grants decide whether `?mode=edit` edits, which moves the range the tiles are planned on. + CompletedFetchSession: (answer) => { + const next = acceptGrants(model, context.orgId, answer) + return isEditing(next) === isEditing(model) ? { model: next } : withReplan(next, context, "Load") + }, CompletedReadClock: ({ nowMs }) => withReplan({ ...model, nowMs }, context, model.refreshVersion > 0 ? "Refresh" : "Load"), MeasuredGrid: ({ width }) => cancelStaleGesture({ ...model, gridWidth: width }, context), @@ -737,7 +752,10 @@ export const update = (model: Model, message: Message, context: PageContext): Re }) }, ClickedTitle: () => - isWriteBlocked(model) || model.document === null || model.history.previewed !== null + !canEditDashboards(model) || + isWriteBlocked(model) || + model.document === null || + model.history.previewed !== null ? { model } : { model: { ...model, titleDraft: model.document.name } }, ChangedTitleDraft: ({ value }) => ({ model: { ...model, titleDraft: value } }), diff --git a/apps/web/src/page/dashboards/view/view-history.ts b/apps/web/src/page/dashboards/view/view-history.ts index 42ce75a7ff..467b1ecf4c 100644 --- a/apps/web/src/page/dashboards/view/view-history.ts +++ b/apps/web/src/page/dashboards/view/view-history.ts @@ -17,6 +17,7 @@ import { sectionHeading } from "../../kit/section-heading" import { actorInitials, KIND_DOT, KIND_LABEL, type VersionRow } from "./history" import { Message } from "./message" import { isWriteBlocked } from "./mode" +import { canEditDashboards } from "../kit/grants" import type { Model } from "./model" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" @@ -221,9 +222,28 @@ export const historyPanel = (h: H, model: Model, clock: Clock): Html => { const RESTORE_BLOCKED_ID = "dashboard-restore-blocked" -/** Restore is a write, so it is off when writes are. */ +const restoreButton = (h: H, isRestoreBlocked: boolean): Html => + button( + h, + { + variant: "default", + size: "sm", + isDisabled: isRestoreBlocked, + attributes: [ + h.OnClick(Message.ClickedRestoreVersion()), + ...(isRestoreBlocked ? [h.AriaDescribedBy(RESTORE_BLOCKED_ID)] : []), + ], + }, + [ + icon(h, "ArrowPathIcon", { attributes: [h.DataAttribute("icon", "inline-start")] }), + "Restore this version", + ], + ) + +/** Restore is a write, so it is off when writes are, and not offered without `dashboards:edit`. */ export const previewBanner = (h: H, model: Model, version: VersionRow, clock: Clock): ReadonlyArray => { - const isRestoreBlocked = isWriteBlocked(model) + const canRestore = canEditDashboards(model) + const isRestoreBlocked = canRestore && isWriteBlocked(model) return [ hstack( h, @@ -270,24 +290,7 @@ export const previewBanner = (h: H, model: Model, version: VersionRow, clock: Cl }, ["Cancel"], ), - button( - h, - { - variant: "default", - size: "sm", - isDisabled: isRestoreBlocked, - attributes: [ - h.OnClick(Message.ClickedRestoreVersion()), - ...(isRestoreBlocked ? [h.AriaDescribedBy(RESTORE_BLOCKED_ID)] : []), - ], - }, - [ - icon(h, "ArrowPathIcon", { - attributes: [h.DataAttribute("icon", "inline-start")], - }), - "Restore this version", - ], - ), + ...(canRestore ? [restoreButton(h, isRestoreBlocked)] : []), ]), ], ), diff --git a/apps/web/src/page/dashboards/view/view-toolbar.ts b/apps/web/src/page/dashboards/view/view-toolbar.ts index bbd88082f3..99135c25d9 100644 --- a/apps/web/src/page/dashboards/view/view-toolbar.ts +++ b/apps/web/src/page/dashboards/view/view-toolbar.ts @@ -17,6 +17,7 @@ import { isWriteBlocked } from "./mode" import { variableSelects } from "./view-variables" import type { PageContext } from "../../contract" import { hstack } from "@maple/views/ui/stack" +import { canEditDashboards, canShareDashboards } from "../kit/grants" type H = HtmlBuilder @@ -30,7 +31,7 @@ const refreshControls = (h: H, model: Model): Html => toMenuMessage: (message) => Message.GotRefreshMenuMessage({ message }), }) -const moreEntries = (isEdit: boolean): ReadonlyArray => [ +const moreEntries = (isEdit: boolean, canShare: boolean): ReadonlyArray => [ ...(isEdit ? ([ { @@ -71,7 +72,17 @@ const moreEntries = (isEdit: boolean): ReadonlyArray => [ className: "whitespace-nowrap", }, { _tag: "Separator" }, - { _tag: "Item", value: "share", label: "Share…", icon: "LinkIcon", className: "whitespace-nowrap" }, + ...(canShare + ? ([ + { + _tag: "Item", + value: "share", + label: "Share…", + icon: "LinkIcon", + className: "whitespace-nowrap", + }, + ] as const) + : []), { _tag: "Item", value: "export", @@ -85,11 +96,33 @@ const moreMenu = (h: H, model: Model): Html => rowActionsMenu(h, { slotId: model.moreMenu.id, model: model.moreMenu, - entries: moreEntries(isEditing(model)), + entries: moreEntries(isEditing(model), canShareDashboards(model)), label: "More dashboard actions", toParentMessage: (message) => Message.GotMoreMenuMessage({ message }), }) +/** Edit, or Done while editing: one element across both, so focus survives the toggle. */ +const editToggle = (h: H, isEdit: boolean, isReadOnly: boolean): Html => + button( + h, + { + variant: isEdit ? "default" : "outline", + size: "sm", + isDisabled: isReadOnly, + attributes: [ + h.Key("toggle-edit"), + h.AriaLabel(isEdit ? "Done editing" : "Edit dashboard"), + h.OnClick(Message.ClickedToggleEdit()), + ], + }, + [ + icon(h, isEdit ? "CheckIcon" : "PencilIcon", { + attributes: [h.DataAttribute("icon", "inline-start")], + }), + h.span([h.Class("hidden @min-[560px]/page:inline")], [isEdit ? "Done" : "Edit"]), + ], + ) + /** The trigger's label; a custom window reads like the widget chips ("Last 2 hours", or both ends). */ const timeLabel = (timeRange: TimeRange | null, timeZone: string, nowMs: number | null): string => timeRange === null @@ -149,26 +182,7 @@ export const toolbar = (h: H, model: Model, context: PageContext): Html => { ), ] : []), - button( - h, - { - variant: isEdit ? "default" : "outline", - size: "sm", - isDisabled: isReadOnly, - attributes: [ - // One element across Edit and Done, so focus survives the toggle. - h.Key("toggle-edit"), - h.AriaLabel(isEdit ? "Done editing" : "Edit dashboard"), - h.OnClick(Message.ClickedToggleEdit()), - ], - }, - [ - icon(h, isEdit ? "CheckIcon" : "PencilIcon", { - attributes: [h.DataAttribute("icon", "inline-start")], - }), - h.span([h.Class("hidden @min-[560px]/page:inline")], [isEdit ? "Done" : "Edit"]), - ], - ), + ...(canEditDashboards(model) ? [editToggle(h, isEdit, isReadOnly)] : []), moreMenu(h, model), ]), ], diff --git a/apps/web/src/page/dashboards/view/view-widgets.ts b/apps/web/src/page/dashboards/view/view-widgets.ts index dbecef7827..6d46913e13 100644 --- a/apps/web/src/page/dashboards/view/view-widgets.ts +++ b/apps/web/src/page/dashboards/view/view-widgets.ts @@ -21,6 +21,7 @@ import { isAlertable } from "./widget-actions" import { moveValue } from "./move-target" import { Schema, Array as Arr, Record as Rec } from "effect" import { DashboardWidgetSchema } from "@maple/widgets/dashboard" +import { canShareDashboards } from "../kit/grants" type H = HtmlBuilder type Widget = DashboardDocument["widgets"][number] @@ -69,7 +70,9 @@ const tileInputsOf = (model: Model, widget: Widget): TileInputs => { isEdit, sections: isEdit ? model.document?.sections : undefined, // A previewed version's widgets may not exist on the live board, so they are not embeddable. - isSharesLoaded: model.shares === "Loaded" && model.history.previewed === null, + // Embedding shares the widget outside the org, so it is offered only with `dashboards:share`. + isSharesLoaded: + model.shares === "Loaded" && model.history.previewed === null && canShareDashboards(model), menu: model.widgetMenuId === widget.id ? model.widgetMenu : null, chart: model.charts[widget.id], sparklineChart: model.charts[`${widget.id}:spark`], diff --git a/apps/web/src/page/dashboards/view/view.ts b/apps/web/src/page/dashboards/view/view.ts index 7de0b3fdef..d3b3345aa8 100644 --- a/apps/web/src/page/dashboards/view/view.ts +++ b/apps/web/src/page/dashboards/view/view.ts @@ -45,6 +45,7 @@ import { errorState } from "@maple/views/ui/error-state" import { typography } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" import { grid } from "@maple/views/ui/grid" +import { canEditDashboards } from "../kit/grants" type H = HtmlBuilder @@ -72,23 +73,31 @@ const title = (h: H, model: Model, name: string): Html => { ) const isBlank = Str.trim(name) === "" const shown = isBlank ? "Untitled dashboard" : name + // Without `dashboards:edit` the name is a plain heading, not a rename control. + const canRename = canEditDashboards(model) return pageTitle( h, { title: shown, className: cn( - !isReadOnly && "cursor-pointer hover:text-foreground-soft active:text-foreground-soft", + canRename && + !isReadOnly && + "cursor-pointer hover:text-foreground-soft active:text-foreground-soft", isBlank && "text-muted-foreground", ), - attributes: [ - h.Role("button"), - h.Tabindex(0), - h.OnClick(Message.ClickedTitle()), - h.OnKeyDownPreventDefault((key) => - key === "Enter" || key === " " ? Option.some(Message.ClickedTitle()) : Option.none(), - ), - h.AriaDisabled(isReadOnly), - ], + attributes: canRename + ? [ + h.Role("button"), + h.Tabindex(0), + h.OnClick(Message.ClickedTitle()), + h.OnKeyDownPreventDefault((key) => + key === "Enter" || key === " " + ? Option.some(Message.ClickedTitle()) + : Option.none(), + ), + h.AriaDisabled(isReadOnly), + ] + : [], }, [shown], ) @@ -142,6 +151,22 @@ const GRID_PREVIEW = [ { glyph: "TextWrapIcon", className: "h-16 w-10" }, ] as const +/** A board with no widgets for a viewer who cannot add one. */ +const blankReadOnlyDashboard = (h: H): Html => + empty( + h, + [ + emptyHeader(h, [ + emptyTitle(h, ["Nothing on this board yet"]), + emptyDescription(h, [ + "Widgets added to this dashboard show up here. Your role can read it but not add one.", + ]), + ]), + emptyContent(h, [docsLink(h, "dashboards")], "max-w-none"), + ], + "py-16", + ) + const blankDashboard = (h: H, isReadOnly: boolean): Html => empty( h, @@ -271,7 +296,9 @@ const body = (h: H, model: Model, context: PageContext): ReadonlyArray => return [ ...banner, isBlank - ? blankDashboard(h, isWriteBlocked(model)) + ? canEditDashboards(model) + ? blankDashboard(h, isWriteBlocked(model)) + : blankReadOnlyDashboard(h) : dashboardSections( h, { model, timeZone, variables: variablesOf(model, context).values }, diff --git a/packages/domain/src/policy/permissions.ts b/packages/domain/src/policy/permissions.ts index 975b131a16..42fc50c4bd 100644 --- a/packages/domain/src/policy/permissions.ts +++ b/packages/domain/src/policy/permissions.ts @@ -11,6 +11,16 @@ export const permissions = { // The public ingest key ships in browser bundles, so every member may read it to set up an app. "ingest_keys:view_public": { min: "member", action: "view the public ingest key" }, + // Shared work on what the telemetry shows: members and above. Observers read it. The API refuses + // an observer every change in one place (`ObserverAccess`); these name them for the dashboard. + "dashboards:edit": { min: "member", action: "create and edit dashboards" }, + "dashboards:share": { min: "member", action: "share dashboards outside the organization" }, + "error_issues:triage": { min: "member", action: "triage error issues" }, + "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" }, From 9b1cba9cf0b79c3c2724d8ab1c8120fd9f242412 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 01:37:24 +0100 Subject: [PATCH 2/6] feat(errors): show observers no control that triages an issue On the errors list and an issue, a viewer without error_issues:triage reads everything but gets no status or severity picker, claim, release, merge or unmerge, pull request attach or detach, comment box, row selection or bulk bar. The pickers become plain readouts, and the list's x, s and p shortcuts do nothing for them. The Get it fixed panel needs investigations:run: without it, an observer sees an investigation that already ran but is offered neither a new one nor the agent prompt, which tells an agent to claim the issue. --- apps/web/src/lab/entries/error-issue.ts | 39 ++- apps/web/src/lab/errors-lab/hub-step.ts | 9 +- apps/web/src/lab/errors-lab/model.ts | 10 +- apps/web/src/lab/errors-lab/update.ts | 33 +- apps/web/src/lab/errors-lab/view.ts | 11 +- apps/web/src/lab/registry.ts | 4 +- .../web/src/lab/worst-case-lab/view-errors.ts | 1 + apps/web/src/page/errors/hub/command.ts | 3 + apps/web/src/page/errors/hub/message.ts | 2 + apps/web/src/page/errors/hub/model.ts | 3 + .../page/errors/hub/observer.scene.test.ts | 200 +++++++++++ apps/web/src/page/errors/hub/row-menu.ts | 21 +- apps/web/src/page/errors/hub/selection.ts | 5 + apps/web/src/page/errors/hub/update.ts | 6 + apps/web/src/page/errors/hub/view-list.ts | 12 +- apps/web/src/page/errors/hub/view-row.ts | 49 ++- apps/web/src/page/errors/hub/view.ts | 3 + apps/web/src/page/errors/issue/command.ts | 3 + .../page/errors/issue/composer.scene.test.ts | 2 +- apps/web/src/page/errors/issue/message.ts | 2 + apps/web/src/page/errors/issue/model.ts | 3 + .../page/errors/issue/observer.scene.test.ts | 323 ++++++++++++++++++ apps/web/src/page/errors/issue/update.ts | 6 + .../src/page/errors/issue/view-activity.ts | 10 +- .../page/errors/issue/view-agent-handoff.ts | 6 +- apps/web/src/page/errors/issue/view-panels.ts | 64 ++-- .../web/src/page/errors/issue/view-sidebar.ts | 44 ++- apps/web/src/page/errors/issue/view.ts | 60 ++-- apps/web/src/page/errors/kit/grants.ts | 16 + apps/web/src/page/errors/kit/pickers.ts | 32 +- 30 files changed, 877 insertions(+), 105 deletions(-) create mode 100644 apps/web/src/page/errors/hub/observer.scene.test.ts create mode 100644 apps/web/src/page/errors/issue/observer.scene.test.ts create mode 100644 apps/web/src/page/errors/kit/grants.ts diff --git a/apps/web/src/lab/entries/error-issue.ts b/apps/web/src/lab/entries/error-issue.ts index eb2244af6a..2bf8aa2583 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 observer, who reads it and gets no triage control +// (`?role=member|observer&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", "observer"] 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/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..12bab048f7 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|observer`). +// Shell-free; the entry wraps it. export const LAB_STATES = ["ready", "loading", "failed", "empty"] as const export const LabState = Schema.Literals(LAB_STATES) export type LabState = typeof LabState.Type +/** A member triages; an observer reads the list and gets no checkbox, picker or bulk bar. */ +export const LAB_ROLES = ["member", "observer"] as const +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 d07278033e..002e1dd007 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -479,7 +479,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/errors", title: "Errors list", description: - "The `/errors` list over a fixture holding every row state at once: surging critical with an open incident, live and diagnosed investigations, a truncating message, a fingerprint gone quiet, an unset severity.", + "The `/errors` list over a fixture holding every row state at once: surging critical with an open incident, live and diagnosed investigations, a truncating message, a fingerprint gone quiet, an unset severity. As a member or an observer, who gets no checkbox, picker or bulk bar (?role=member|observer).", kind: "lab", session: "none", }, @@ -487,7 +487,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/error-issue", title: "Error issue", description: - "The error issue page over one surging, regressed high-severity TypeError: the occurrence chart and its tooltip, the Get it fixed panel, the stat rail and the rail.", + "The error issue page over one surging, regressed high-severity TypeError: the occurrence chart and its tooltip, the Get it fixed panel, the stat rail and the rail. As a member or an observer, who reads it and gets no triage control (?role=member|observer&tab=overview|occurrences|activity).", kind: "lab", session: "none", }, 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/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..18df5b2988 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 observer 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/observer.scene.test.ts b/apps/web/src/page/errors/hub/observer.scene.test.ts new file mode 100644 index 0000000000..f5e4bab9f9 --- /dev/null +++ b/apps/web/src/page/errors/hub/observer.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 observer 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 OBSERVER = Policy.grantsOf(["org:observer"]) + +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 observer", () => { + test("a row reads its severity and status with no checkbox or picker", () => { + scene( + { update: pageUpdate, view }, + given(loaded(OBSERVER)), + ...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 observer 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(OBSERVER), 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(OBSERVER), "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(OBSERVER), 0).commands)).toContain("OpenErrorsRowInNewTab") + // Where a member's menu has Claim, an observer's has Copy link. + const second = names(pickedFromRowMenu(loaded(OBSERVER), 1).commands) + expect(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: OBSERVER }), + ).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/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..3f186b48e2 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 observer. */ +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..0f9d2ca65b 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 observer 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..e8cb5dadaa 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 observer'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/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..308913c384 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 observer 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/observer.scene.test.ts b/apps/web/src/page/errors/issue/observer.scene.test.ts new file mode 100644 index 0000000000..b85118993e --- /dev/null +++ b/apps/web/src/page/errors/issue/observer.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 observer 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 OBSERVER = Policy.grantsOf(["org:observer"]) + +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 observer", () => { + test("the rail reads state and severity and offers no pick, claim or attach", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: OBSERVER })), + ...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: OBSERVER, 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: OBSERVER, 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: OBSERVER, 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: OBSERVER, 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 observer is not offered an investigation or the agent prompt, and a member is", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: OBSERVER })), + ...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: OBSERVER }), + ).model + scene( + { update: pageUpdate, view }, + given(model), + ...mounted, + expectView(role("button", { name: "Claim" })).toExist(), + ) + }) +}) 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..640232f4c1 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/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..6a98d7088a 100644 --- a/apps/web/src/page/errors/issue/view.ts +++ b/apps/web/src/page/errors/issue/view.ts @@ -41,6 +41,7 @@ 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, canTriageIssues } from "../kit/grants" type H = HtmlBuilder @@ -128,20 +129,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 observer 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 +191,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 observer 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 +221,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 +281,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..11162350da --- /dev/null +++ b/apps/web/src/page/errors/kit/grants.ts @@ -0,0 +1,16 @@ +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 start an investigation or hand the issue to an agent (`investigations:run`). */ +export const canRunInvestigations = (model: Grants): boolean => Policy.has(model.grants, "investigations:run") + +/** 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", From 043b5cc17de03684b8a0b92f26ab68783e2bccd7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 01:52:11 +0100 Subject: [PATCH 3/6] feat(alerts): show observers no control that responds to an incident A viewer without incidents:respond gets no Acknowledge on an alert incident or an inbox row, and no Resolve, link or unlink on an anomaly. One without status_page_incidents:post gets no Post incident, Post update, Resolve, Edit details or Delete on a status page. One without investigations:run gets no investigate bar on the investigations list and no Investigate on an incident or anomaly. Controls that already need alerts:manage or status_pages:edit are unchanged. The labs take ?role=admin|member|observer. --- apps/web/src/lab/alerts-lab/update.ts | 16 +- apps/web/src/lab/alerts-lab/view-page.ts | 25 +-- apps/web/src/lab/alerts-lab/view.ts | 17 ++- apps/web/src/lab/anomaly-alerts-lab.ts | 19 ++- apps/web/src/lab/entries/status-pages.ts | 100 ++++++++++-- apps/web/src/lab/registry.ts | 6 +- apps/web/src/lab/viewer-role.ts | 24 +++ .../page/alerts/hub/observer.scene.test.ts | 98 ++++++++++++ apps/web/src/page/alerts/hub/view-inbox.ts | 55 ++++--- apps/web/src/page/alerts/hub/view.ts | 14 +- .../alerts/incident/incident.scene.test.ts | 6 + .../alerts/incident/observer.scene.test.ts | 103 +++++++++++++ apps/web/src/page/alerts/incident/view.ts | 62 +++++--- apps/web/src/page/anomalies/detail/command.ts | 3 + apps/web/src/page/anomalies/detail/message.ts | 2 + apps/web/src/page/anomalies/detail/model.ts | 3 + .../anomalies/detail/observer.scene.test.ts | 142 ++++++++++++++++++ apps/web/src/page/anomalies/detail/update.ts | 14 +- .../src/page/anomalies/detail/view-body.ts | 86 ++++++----- .../src/page/anomalies/detail/view-sidebar.ts | 88 ++++++----- apps/web/src/page/anomalies/detail/view.ts | 32 ++-- .../page/errors/issue/view-agent-handoff.ts | 2 +- apps/web/src/page/errors/issue/view.ts | 3 +- apps/web/src/page/errors/kit/grants.ts | 3 - .../list/observer.scene.test.ts | 95 ++++++++++++ apps/web/src/page/investigations/list/view.ts | 16 +- apps/web/src/page/kit/session-grants.ts | 6 + .../web/src/page/status-pages/editor/model.ts | 4 + .../editor/observer.scene.test.ts | 120 +++++++++++++++ .../page/status-pages/editor/view-canvas.ts | 8 +- .../page/status-pages/editor/view-panel.ts | 37 ++--- apps/web/src/page/status-pages/editor/view.ts | 4 +- 32 files changed, 997 insertions(+), 216 deletions(-) create mode 100644 apps/web/src/lab/viewer-role.ts create mode 100644 apps/web/src/page/alerts/hub/observer.scene.test.ts create mode 100644 apps/web/src/page/alerts/incident/observer.scene.test.ts create mode 100644 apps/web/src/page/anomalies/detail/observer.scene.test.ts create mode 100644 apps/web/src/page/investigations/list/observer.scene.test.ts create mode 100644 apps/web/src/page/status-pages/editor/observer.scene.test.ts diff --git a/apps/web/src/lab/alerts-lab/update.ts b/apps/web/src/lab/alerts-lab/update.ts index bde34007bc..d75c5912e6 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|observer`: an observer 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..d38c81d719 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|observer` reads the hub and the incident as that viewer: an observer 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/status-pages.ts b/apps/web/src/lab/entries/status-pages.ts index 1434b59e54..a9fcee53f4 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 observer 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/registry.ts b/apps/web/src/lab/registry.ts index 002e1dd007..e435d23de4 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 observer, who gets no Acknowledge (`?role=member|observer`).", kind: "lab", session: "none", }, @@ -495,7 +495,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/alerts", title: "Alerts", description: - "The `/alerts` Inbox, Rules and Routing tabs and the incident page over one fixture org: two firing, one held, four blind spots, a delivery retry and a diagnosed investigation.", + "The `/alerts` Inbox, Rules and Routing tabs and the incident page over one fixture org: two firing, one held, four blind spots, a delivery retry and a diagnosed investigation. As an admin, a member or an observer, who gets no Acknowledge (`?role=member|observer`).", kind: "lab", session: "none", }, @@ -511,7 +511,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/status-pages", title: "Status pages", description: - "The status pages list, the look picker and the form fields (hint and error) over fixture pages.", + "The status pages list, the look picker and the form fields (hint and error) over fixture pages, or a page's editor open on an incident (`?page=list|editor`). As an admin, a member or an observer, who gets no control that posts, edits or deletes an incident (`?role=member|observer`).", 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..d99e58f831 --- /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|observer` for a lab that renders a page as the viewer would see it. Without +// the flag a lab keeps the full rights it always had. + +const ROLES = ["admin", "member", "observer"] as const + +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/page/alerts/hub/observer.scene.test.ts b/apps/web/src/page/alerts/hub/observer.scene.test.ts new file mode 100644 index 0000000000..a7eb2a95e9 --- /dev/null +++ b/apps/web/src/page/alerts/hub/observer.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 observer reads the Inbox; acknowledging an incident from its row needs `incidents:respond`. + +const context: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() + +const MEMBER = Policy.grantsOf(["org:member"]) +const OBSERVER = Policy.grantsOf(["org:observer"]) + +const 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, observer", () => { + test("an observer reads the open incidents and gets no acknowledge button on a row", () => { + scene( + { update: pageUpdate, view }, + given(loaded(OBSERVER)), + 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 observer", () => { + scene( + { update: pageUpdate, view }, + given(loaded(OBSERVER, { 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/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/observer.scene.test.ts b/apps/web/src/page/alerts/incident/observer.scene.test.ts new file mode 100644 index 0000000000..ef903091b7 --- /dev/null +++ b/apps/web/src/page/alerts/incident/observer.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 observer 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 OBSERVER = Policy.grantsOf(["org:observer"]) + +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, observer", () => { + test("an observer is not offered Acknowledge or Investigate, and a member is", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: OBSERVER })), + 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 observer reads who acknowledged it and is not offered Unacknowledge", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: OBSERVER, 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 observer can still open an investigation that already ran", () => { + scene( + { update: pageUpdate, view }, + given(loaded({ grants: OBSERVER, hasInvestigation: true })), + themed(), + expectView(role("link", { name: "Open investigation" })).toExist(), + ) + }) +}) 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/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..e24cb87f21 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 observer 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/observer.scene.test.ts b/apps/web/src/page/anomalies/detail/observer.scene.test.ts new file mode 100644 index 0000000000..79c01f5d9d --- /dev/null +++ b/apps/web/src/page/anomalies/detail/observer.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 observer 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 OBSERVER = Policy.grantsOf(["org:observer"]) + +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, observer", () => { + test("an observer reads the anomaly and is not offered Resolve, Link issue or an investigation", () => { + scene( + { update: pageUpdate, view }, + given(loaded(OBSERVER)), + 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 observer can open the linked issue and cannot relink or unlink it; a member can", () => { + scene( + { update: pageUpdate, view }, + given(linked(OBSERVER)), + 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(OBSERVER) + const other = pageUpdate(model, Message.CompletedFetchSession({ orgId: "org_other", grants: MEMBER })) + expect(other.model.grants).toEqual(OBSERVER) + }) +}) 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/issue/view-agent-handoff.ts b/apps/web/src/page/errors/issue/view-agent-handoff.ts index 640232f4c1..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,7 +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/grants" +import { canRunInvestigations } from "../../kit/session-grants" type H = HtmlBuilder diff --git a/apps/web/src/page/errors/issue/view.ts b/apps/web/src/page/errors/issue/view.ts index 6a98d7088a..0e0053150d 100644 --- a/apps/web/src/page/errors/issue/view.ts +++ b/apps/web/src/page/errors/issue/view.ts @@ -41,7 +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, canTriageIssues } from "../kit/grants" +import { canRunInvestigations } from "../../kit/session-grants" +import { canTriageIssues } from "../kit/grants" type H = HtmlBuilder diff --git a/apps/web/src/page/errors/kit/grants.ts b/apps/web/src/page/errors/kit/grants.ts index 11162350da..9fc4695587 100644 --- a/apps/web/src/page/errors/kit/grants.ts +++ b/apps/web/src/page/errors/kit/grants.ts @@ -9,8 +9,5 @@ export const initialGrants: ReadonlyArray = isClerkAuthEnable /** The session is only read where there is one; self-hosted mode holds every permission. */ export const readsSessionGrants = isClerkAuthEnabled -/** The viewer may start an investigation or hand the issue to an agent (`investigations:run`). */ -export const canRunInvestigations = (model: Grants): boolean => Policy.has(model.grants, "investigations:run") - /** 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/investigations/list/observer.scene.test.ts b/apps/web/src/page/investigations/list/observer.scene.test.ts new file mode 100644 index 0000000000..fa35905c39 --- /dev/null +++ b/apps/web/src/page/investigations/list/observer.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 observer 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 OBSERVER = Policy.grantsOf(["org:observer"]) +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, observer", () => { + test("an observer reads the list without the investigate bar; a member has it", () => { + scene( + { update: pageUpdate, view }, + given(loaded(OBSERVER, [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 observer the plain empty list, and a member the first-run hero", () => { + scene( + { update: pageUpdate, view }, + given(loaded(OBSERVER, [])), + 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/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/observer.scene.test.ts b/apps/web/src/page/status-pages/editor/observer.scene.test.ts new file mode 100644 index 0000000000..81fb267b71 --- /dev/null +++ b/apps/web/src/page/status-pages/editor/observer.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 observer 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 OBSERVER = Policy.grantsOf(["org:observer"]) + +/** 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, observer", () => { + test("Post incident is offered to a member and not to an observer", () => { + scene( + { update: pageUpdate, view }, + given(loaded(OBSERVER, [])), + 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 observer reads an open incident's updates and gets none of its actions", () => { + scene( + { update: pageUpdate, view }, + given(opened(OBSERVER, 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 observer reads it", () => { + scene( + { update: pageUpdate, view }, + given(opened(OBSERVER, 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/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, From 8888c599b19711df906654fb97cbad9eb209db30 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 03:22:37 +0100 Subject: [PATCH 4/6] chore(ui): regenerate the kit index for the new grant helpers --- packages/views/src/ui/INDEX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 21cb0ae2c90f4605d3b932ecd4372c0d7a9e30f5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:12:10 +0100 Subject: [PATCH 5/6] refactor: rename the observer role to analyst --- CLAUDE.md | 4 +-- apps/ai/src/chat/agents.test.ts | 6 ++-- apps/ai/src/chat/profiles.ts | 4 +-- apps/ai/src/mcp/server.ts | 2 +- .../mcp/tools/__tests__/read-only-key.test.ts | 8 ++--- apps/ai/src/mcp/tools/types.ts | 2 +- .../src/routes/v2/mobile-devices.http.test.ts | 4 +-- .../routes/v2/phase1-resources.http.test.ts | 6 ++-- apps/api/src/routes/v2/telemetry.http.test.ts | 4 +-- apps/web/src/lab/alerts-lab/update.ts | 2 +- apps/web/src/lab/anomaly-alerts-lab.ts | 2 +- apps/web/src/lab/entries/dashboards.ts | 8 ++--- apps/web/src/lab/entries/error-issue.ts | 6 ++-- apps/web/src/lab/entries/status-pages.ts | 2 +- apps/web/src/lab/errors-lab/model.ts | 6 ++-- apps/web/src/lab/registry.ts | 12 +++---- .../src/lab/shell-states-lab.scene.test.ts | 6 ++-- apps/web/src/lab/shell-states-lab.ts | 10 +++--- apps/web/src/lab/viewer-role.ts | 4 +-- ...er.scene.test.ts => analyst.scene.test.ts} | 14 ++++---- ...er.scene.test.ts => analyst.scene.test.ts} | 18 +++++------ ...er.scene.test.ts => analyst.scene.test.ts} | 18 +++++------ apps/web/src/page/anomalies/detail/model.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 14 ++++---- apps/web/src/page/dashboards/list/model.ts | 2 +- .../web/src/page/dashboards/list/view-list.ts | 12 +++---- apps/web/src/page/dashboards/list/view.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 8 ++--- .../src/page/dashboards/templates/model.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 18 +++++------ apps/web/src/page/dashboards/view/model.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 22 ++++++------- apps/web/src/page/errors/hub/model.ts | 2 +- apps/web/src/page/errors/hub/selection.ts | 2 +- apps/web/src/page/errors/hub/view-list.ts | 2 +- apps/web/src/page/errors/hub/view-row.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 22 ++++++------- apps/web/src/page/errors/issue/model.ts | 2 +- apps/web/src/page/errors/issue/view.ts | 4 +-- ...er.scene.test.ts => analyst.scene.test.ts} | 14 ++++---- .../page/settings/kit/org-membership.test.ts | 14 ++++---- .../src/page/settings/kit/org-membership.ts | 2 +- ...er.scene.test.ts => analyst.scene.test.ts} | 18 +++++------ apps/web/src/shell/model.ts | 4 +-- apps/web/src/shell/nav.test.ts | 8 ++--- apps/web/src/shell/nav.ts | 16 +++++----- apps/web/src/shell/palette/entries.ts | 10 +++--- apps/web/src/shell/palette/palette.test.ts | 8 ++--- apps/web/src/shell/view-sidebar.ts | 4 +-- apps/web/src/shell/view.ts | 4 +-- .../src/services/auth/ApiAuthorizationLive.ts | 6 ++-- .../services/auth/ApiAuthorizationV2Live.ts | 8 ++--- .../services/auth/SessionAuthorizationLive.ts | 6 ++-- ...rver-gate.test.ts => analyst-gate.test.ts} | 32 +++++++++---------- .../{observer-gate.ts => analyst-gate.ts} | 16 +++++----- packages/domain/src/http/ai-models.ts | 6 ++-- packages/domain/src/http/ai-sessions.ts | 6 ++-- ...-access.test.ts => analyst-access.test.ts} | 16 +++++----- .../{observer-access.ts => analyst-access.ts} | 16 +++++----- packages/domain/src/http/auth.ts | 14 ++++---- packages/domain/src/http/billing.ts | 4 +-- packages/domain/src/http/current-tenant.ts | 2 +- packages/domain/src/http/digest.ts | 10 +++--- packages/domain/src/http/errors.ts | 4 +-- packages/domain/src/http/index.ts | 2 +- packages/domain/src/http/integrations.ts | 6 ++-- packages/domain/src/http/mcp.ts | 6 ++-- packages/domain/src/http/query-engine.ts | 6 ++-- packages/domain/src/http/session-replay.ts | 6 ++-- packages/domain/src/http/users.ts | 6 ++-- packages/domain/src/http/v2/agent-feedback.ts | 4 +-- .../domain/src/http/v2/device-credentials.ts | 8 ++--- .../domain/src/http/v2/integrations-chat.ts | 4 +-- .../domain/src/http/v2/mcp-connections.ts | 4 +-- packages/domain/src/http/v2/mobile-devices.ts | 10 +++--- .../domain/src/http/v2/widget-credentials.ts | 6 ++-- packages/domain/src/policy/index.ts | 8 ++--- packages/domain/src/policy/permissions.ts | 4 +-- packages/domain/src/policy/policy.test.ts | 24 +++++++------- packages/domain/src/policy/roles.ts | 16 +++++----- 80 files changed, 318 insertions(+), 318 deletions(-) rename apps/web/src/page/alerts/hub/{observer.scene.test.ts => analyst.scene.test.ts} (88%) rename apps/web/src/page/alerts/incident/{observer.scene.test.ts => analyst.scene.test.ts} (84%) rename apps/web/src/page/anomalies/detail/{observer.scene.test.ts => analyst.scene.test.ts} (90%) rename apps/web/src/page/dashboards/list/{observer.scene.test.ts => analyst.scene.test.ts} (91%) rename apps/web/src/page/dashboards/templates/{observer.scene.test.ts => analyst.scene.test.ts} (92%) rename apps/web/src/page/dashboards/view/{observer.scene.test.ts => analyst.scene.test.ts} (94%) rename apps/web/src/page/errors/hub/{observer.scene.test.ts => analyst.scene.test.ts} (90%) rename apps/web/src/page/errors/issue/{observer.scene.test.ts => analyst.scene.test.ts} (93%) rename apps/web/src/page/investigations/list/{observer.scene.test.ts => analyst.scene.test.ts} (87%) rename apps/web/src/page/status-pages/editor/{observer.scene.test.ts => analyst.scene.test.ts} (89%) rename packages/backend/src/services/auth/{observer-gate.test.ts => analyst-gate.test.ts} (52%) rename packages/backend/src/services/auth/{observer-gate.ts => analyst-gate.ts} (71%) rename packages/domain/src/http/{observer-access.test.ts => analyst-access.test.ts} (75%) rename packages/domain/src/http/{observer-access.ts => analyst-access.ts} (57%) diff --git a/CLAUDE.md b/CLAUDE.md index e91207cae8..42a0c57ba0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,8 @@ with `packageManager`. - LLM code (Effect AI + `@yielded/agent`) is only in `apps/ai`; Maple-specific wiring lives in `apps/ai/src/platform/Llm.ts`. - Shared services live in `packages/backend`, imported via `@maple/backend/*` subpaths. -- Observers (`org:observer`) only read. The auth middlewares refuse them every non-GET endpoint unless - it is marked `ObserverAccess` (`packages/domain/src/http/observer-access.ts`): `read` for a POST that +- Analysts (`org:analyst`) only read. The auth middlewares refuse them every non-GET endpoint unless + it is marked `AnalystAccess` (`packages/domain/src/http/analyst-access.ts`): `read` for a POST that only queries, `personal` for the caller's own state. An unmarked read-only POST is a 403 for them. ### Service conventions diff --git a/apps/ai/src/chat/agents.test.ts b/apps/ai/src/chat/agents.test.ts index 6f206ceef8..0412a4587e 100644 --- a/apps/ai/src/chat/agents.test.ts +++ b/apps/ai/src/chat/agents.test.ts @@ -137,14 +137,14 @@ describe("profileForTurn", () => { } }) - it("offers an observer's turn the read tools and none that change data", () => { + it("offers an analyst's turn the read tools and none that change data", () => { const profile = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ - "org:observer", + "org:analyst", ]) const { tools } = buildChatToolkit(executor, tenant, profile.ruleset, profile.surface).toolkit assert.property(tools, "search_traces") for (const name of MUTATING_TOOL_NAMES) { - assert.notProperty(tools, name, `${name} was offered to an observer`) + assert.notProperty(tools, name, `${name} was offered to an analyst`) } // A member's turn on the same session still proposes them. const member = profileForRoles(profileForTurn(agentForSession(appSession), { kind: "app" }), [ diff --git a/apps/ai/src/chat/profiles.ts b/apps/ai/src/chat/profiles.ts index bcdf976a03..f80614f1cd 100644 --- a/apps/ai/src/chat/profiles.ts +++ b/apps/ai/src/chat/profiles.ts @@ -23,13 +23,13 @@ export interface TurnProfile { readonly prompt: string } -/** The ruleset with every approval-gated tool withheld: an observer could not approve one. */ +/** The ruleset with every approval-gated tool withheld: an analyst could not approve one. */ const withoutProposals = (ruleset: PermissionRuleset): PermissionRuleset => Arr.map(ruleset, (rule) => rule.action === "ask" ? new PermissionRule({ tool: rule.tool, action: "deny" }) : rule, ) -/** The profile for whoever the turn's tools run as: an observer is offered nothing that changes data. */ +/** The profile for whoever the turn's tools run as: an analyst is offered nothing that changes data. */ export const profileForRoles = (profile: TurnProfile, roles: ReadonlyArray): TurnProfile => Policy.isReadOnly(roles) ? { ...profile, ruleset: withoutProposals(profile.ruleset) } : profile diff --git a/apps/ai/src/mcp/server.ts b/apps/ai/src/mcp/server.ts index c00bc1e4bd..264fcf9293 100644 --- a/apps/ai/src/mcp/server.ts +++ b/apps/ai/src/mcp/server.ts @@ -69,7 +69,7 @@ const annotateToolFailure = (result: McpToolResult) => : Effect.void /** - * Hides a tool from a read-only credential's or an observer's `tools/list`, and answers a call to it as unknown: + * Hides a tool from a read-only credential's or an analyst's `tools/list`, and answers a call to it as unknown: * the registry would refuse every such call anyway, and a listed tool invites it. */ const HiddenFromReadOnlyCredentials = EffectMcpServer.visibility(() => diff --git a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts index 090ad00061..52c6d0e614 100644 --- a/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts +++ b/apps/ai/src/mcp/tools/__tests__/read-only-key.test.ts @@ -71,15 +71,15 @@ describe("read-only API key over MCP", () => { ) }) -describe("an observer over MCP", () => { +describe("an analyst over MCP", () => { // A layer per test: each owns a database that `afterEach` removes. - const observer = () => makeLayer(false, undefined, "org:observer") + const analyst = () => makeLayer(false, undefined, "org:analyst") it.effect("runs read tools", () => Effect.gen(function* () { const result = yield* call("get_error_notification_policy", {}) expect(Result.isSuccess(result)).toBe(true) - }).pipe(Effect.provide(observer())), + }).pipe(Effect.provide(analyst())), ) it.effect("refuses a tool that changes data and says which role can", () => @@ -91,7 +91,7 @@ describe("an observer over MCP", () => { expect(result.failure.message).toContain("update_error_notification_policy") expect(result.failure.message).toContain("Ask an org admin for the Member role") } - }).pipe(Effect.provide(observer())), + }).pipe(Effect.provide(analyst())), ) }) diff --git a/apps/ai/src/mcp/tools/types.ts b/apps/ai/src/mcp/tools/types.ts index 384e0240f6..3b949cf34e 100644 --- a/apps/ai/src/mcp/tools/types.ts +++ b/apps/ai/src/mcp/tools/types.ts @@ -124,7 +124,7 @@ export const FEEDBACK_TOOL = "send_maple_feedback" export const refusesReadOnlyCredential = (name: string, readOnly: boolean): boolean => !readOnly && name !== FEEDBACK_TOOL -/** Whether the caller may only read: a read-only API key, or anyone whose role is observer. */ +/** Whether the caller may only read: a read-only API key, or anyone whose role is analyst. */ export const isReadOnlyTenant = (tenant: { readonly readOnly?: true readonly roles: ReadonlyArray diff --git a/apps/api/src/routes/v2/mobile-devices.http.test.ts b/apps/api/src/routes/v2/mobile-devices.http.test.ts index 61c527929c..08fc58a2b4 100644 --- a/apps/api/src/routes/v2/mobile-devices.http.test.ts +++ b/apps/api/src/routes/v2/mobile-devices.http.test.ts @@ -190,10 +190,10 @@ describe("v2 mobile devices", () => { } }) - it("lets an observer register and remove their own device", async () => { + it("lets an analyst register and remove their own device", async () => { const harness = makeHarness() try { - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const created = await harness.request("PUT", `/v2/mobile_devices/${TOKEN}`, key.secret, { platform: "ios", environment: "sandbox", diff --git a/apps/api/src/routes/v2/phase1-resources.http.test.ts b/apps/api/src/routes/v2/phase1-resources.http.test.ts index b37296d26b..f863cf37f8 100644 --- a/apps/api/src/routes/v2/phase1-resources.http.test.ts +++ b/apps/api/src/routes/v2/phase1-resources.http.test.ts @@ -902,9 +902,9 @@ describe("v2 error_issues over HTTP", () => { await harness.dispose() }) - it("lets an observer read an issue and refuses to let them triage it", async () => { + it("lets an analyst read an issue and refuses to let them triage it", async () => { const harness = makeHarness() - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const read = await harness.request("GET", `/v2/error_issues/${ISS_ID}`, { token: key.secret }) expect(read.status).toBe(200) @@ -915,7 +915,7 @@ describe("v2 error_issues over HTTP", () => { }) expect(refused.status).toBe(403) expect(refused.body.error.code).toBe("insufficient_permissions") - expect(refused.body.error.message).toContain("Observers can read Maple") + expect(refused.body.error.message).toContain("Analysts can read Maple") expect(harness.issueActions()).toEqual([]) await harness.dispose() }) diff --git a/apps/api/src/routes/v2/telemetry.http.test.ts b/apps/api/src/routes/v2/telemetry.http.test.ts index 205b616bda..92fdacb02b 100644 --- a/apps/api/src/routes/v2/telemetry.http.test.ts +++ b/apps/api/src/routes/v2/telemetry.http.test.ts @@ -571,9 +571,9 @@ describe("v2 telemetry reads over HTTP", () => { await harness.dispose() }) - it("lets an observer search traces, a POST that only reads", async () => { + it("lets an analyst search traces, a POST that only reads", async () => { const harness = makeHarness() - const key = await harness.bootstrapKey(undefined, ["org:observer"]) + const key = await harness.bootstrapKey(undefined, ["org:analyst"]) const searched = await harness.request("POST", "/v2/traces/search", key.secret, { start_time: START, end_time: END, diff --git a/apps/web/src/lab/alerts-lab/update.ts b/apps/web/src/lab/alerts-lab/update.ts index d75c5912e6..3a10e0c822 100644 --- a/apps/web/src/lab/alerts-lab/update.ts +++ b/apps/web/src/lab/alerts-lab/update.ts @@ -17,7 +17,7 @@ export type Return = Update.Return export const AlertsLabFlags = Schema.Struct({ nowMs: Schema.Number, timeZone: Schema.String, - /** `?role=member|observer`: an observer gets no acknowledge, rule or routing control. */ + /** `?role=member|analyst`: an analyst gets no acknowledge, rule or routing control. */ role: LabRole, }) export type AlertsLabFlags = typeof AlertsLabFlags.Type diff --git a/apps/web/src/lab/anomaly-alerts-lab.ts b/apps/web/src/lab/anomaly-alerts-lab.ts index d38c81d719..052aaf9dd8 100644 --- a/apps/web/src/lab/anomaly-alerts-lab.ts +++ b/apps/web/src/lab/anomaly-alerts-lab.ts @@ -56,7 +56,7 @@ import { labGrants, LabRole, readLabRole } from "./viewer-role" // `/lab/anomaly-alerts`: anomalies paging through alert rules, over fixtures. `?state=` picks one: // editor-new, editor-blank, editor-scoped, editor-default, editor-critical-destination, incident-warning, incident-critical, incident-spike, // hub-anomalies, hub-anomalies-resolved, hub-anomalies-empty, hub-rules, rule-anomaly, overview-banner. -// `?role=member|observer` reads the hub and the incident as that viewer: an observer gets no acknowledge. +// `?role=member|analyst` reads the hub and the incident as that viewer: an analyst gets no acknowledge. export const LabState = Schema.Literals([ "editor-new", diff --git a/apps/web/src/lab/entries/dashboards.ts b/apps/web/src/lab/entries/dashboards.ts index af3434e860..a3cbe5b386 100644 --- a/apps/web/src/lab/entries/dashboards.ts +++ b/apps/web/src/lab/entries/dashboards.ts @@ -19,11 +19,11 @@ import { view as boardView } from "../../page/dashboards/view/view" import type { Resources } from "../../platform/resources" import { defineShellLab } from "../shell-lab" -// `/lab/dashboards`: the dashboards list and one board as a member or an observer, who reads -// dashboards but cannot change or share them (`?page=list|detail&role=member|observer&mode=edit`). +// `/lab/dashboards`: the dashboards list and one board as a member or an analyst, who reads +// dashboards but cannot change or share them (`?page=list|detail&role=member|analyst&mode=edit`). const PAGES = ["list", "detail"] as const -const ROLES = ["member", "observer"] as const +const ROLES = ["member", "analyst"] as const type LabPage = (typeof PAGES)[number] type LabRole = (typeof ROLES)[number] @@ -172,7 +172,7 @@ const boardModel = (role: LabRole, isEdit: boolean): BoardModel => const Model = Schema.Struct({ page: Schema.Literals(PAGES), role: Schema.Literals(ROLES), - /** The board was opened at `?mode=edit`: an observer still gets the read view. */ + /** The board was opened at `?mode=edit`: an analyst still gets the read view. */ isEdit: Schema.Boolean, list: ListModel, board: BoardModel, diff --git a/apps/web/src/lab/entries/error-issue.ts b/apps/web/src/lab/entries/error-issue.ts index 2bf8aa2583..a6128cbd4e 100644 --- a/apps/web/src/lab/entries/error-issue.ts +++ b/apps/web/src/lab/entries/error-issue.ts @@ -21,8 +21,8 @@ import { view as pageView } from "../../page/errors/issue/view" import { defineShellLab } from "../shell-lab" // `/lab/error-issue`: the error issue page over one fixture issue, a surging high-severity error -// with a regression, as a member or an observer, who reads it and gets no triage control -// (`?role=member|observer&tab=overview|occurrences|activity`). API Commands are dropped; the page +// with a regression, as a member or an analyst, who reads it and gets no triage control +// (`?role=member|analyst&tab=overview|occurrences|activity`). API Commands are dropped; the page // is sent the answers its reads would get. const context: PageContext = { @@ -121,7 +121,7 @@ const send = (step: Step, message: Message): Step => { } } -const ROLES = ["member", "observer"] as const +const ROLES = ["member", "analyst"] as const const TABS = ["overview", "occurrences", "activity"] as const const Flags = Schema.Struct({ role: Schema.Literals(ROLES), tab: Schema.Literals(TABS) }) type Flags = typeof Flags.Type diff --git a/apps/web/src/lab/entries/status-pages.ts b/apps/web/src/lab/entries/status-pages.ts index a9fcee53f4..85d3739c87 100644 --- a/apps/web/src/lab/entries/status-pages.ts +++ b/apps/web/src/lab/entries/status-pages.ts @@ -22,7 +22,7 @@ import { labGrants, LabRole, readLabRole } from "../viewer-role" // `/lab/status-pages`: the status pages list, the look picker and the form fields over fixture // pages, or a page's editor open on an incident (`?page=list|editor`), as the viewer `?role=` names: -// an observer reads the incident and gets no control that posts, edits or deletes one. +// an analyst reads the incident and gets no control that posts, edits or deletes one. const context: PageContext = { orgId: Schema.decodeSync(OrgId)(LAB_ORG), diff --git a/apps/web/src/lab/errors-lab/model.ts b/apps/web/src/lab/errors-lab/model.ts index 12bab048f7..99b350d093 100644 --- a/apps/web/src/lab/errors-lab/model.ts +++ b/apps/web/src/lab/errors-lab/model.ts @@ -4,15 +4,15 @@ import { Message as HubMessage } from "../../page/errors/hub/message" import { Model as HubModel } from "../../page/errors/hub/model" // `/lab/errors`: the errors hub over a fixture holding every row -// state at once, with the lab's State, Role and Width toggles (`?role=member|observer`). +// state at once, with the lab's State, Role and Width toggles (`?role=member|analyst`). // Shell-free; the entry wraps it. export const LAB_STATES = ["ready", "loading", "failed", "empty"] as const export const LabState = Schema.Literals(LAB_STATES) export type LabState = typeof LabState.Type -/** A member triages; an observer reads the list and gets no checkbox, picker or bulk bar. */ -export const LAB_ROLES = ["member", "observer"] as const +/** A member triages; an analyst reads the list and gets no checkbox, picker or bulk bar. */ +export const LAB_ROLES = ["member", "analyst"] as const export const LabRole = Schema.Literals(LAB_ROLES) export type LabRole = typeof LabRole.Type diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index e435d23de4..73c7a789c5 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -129,7 +129,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/anomaly-alerts", title: "Anomaly alerts", description: - "Anomaly rules in the rule editor (scoped and the protected default), an anomaly rule's incident with the detector's evidence, the Inbox's Anomalies view and the Overview's alert banner with open anomalies (`?state=`). The hub and the incident as an admin, a member or an observer, who gets no Acknowledge (`?role=member|observer`).", + "Anomaly rules in the rule editor (scoped and the protected default), an anomaly rule's incident with the detector's evidence, the Inbox's Anomalies view and the Overview's alert banner with open anomalies (`?state=`). The hub and the incident as an admin, a member or an analyst, who gets no Acknowledge (`?role=member|analyst`).", kind: "lab", session: "none", }, @@ -376,7 +376,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/dashboards", title: "Dashboards", description: - "The `/dashboards` list and one board as a member or an observer, who reads dashboards but cannot change or share them (?page=list|detail&role=member|observer&mode=edit).", + "The `/dashboards` list and one board as a member or an analyst, who reads dashboards but cannot change or share them (?page=list|detail&role=member|analyst&mode=edit).", kind: "lab", session: "none", }, @@ -479,7 +479,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/errors", title: "Errors list", description: - "The `/errors` list over a fixture holding every row state at once: surging critical with an open incident, live and diagnosed investigations, a truncating message, a fingerprint gone quiet, an unset severity. As a member or an observer, who gets no checkbox, picker or bulk bar (?role=member|observer).", + "The `/errors` list over a fixture holding every row state at once: surging critical with an open incident, live and diagnosed investigations, a truncating message, a fingerprint gone quiet, an unset severity. As a member or an analyst, who gets no checkbox, picker or bulk bar (?role=member|analyst).", kind: "lab", session: "none", }, @@ -487,7 +487,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/error-issue", title: "Error issue", description: - "The error issue page over one surging, regressed high-severity TypeError: the occurrence chart and its tooltip, the Get it fixed panel, the stat rail and the rail. As a member or an observer, who reads it and gets no triage control (?role=member|observer&tab=overview|occurrences|activity).", + "The error issue page over one surging, regressed high-severity TypeError: the occurrence chart and its tooltip, the Get it fixed panel, the stat rail and the rail. As a member or an analyst, who reads it and gets no triage control (?role=member|analyst&tab=overview|occurrences|activity).", kind: "lab", session: "none", }, @@ -495,7 +495,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/alerts", title: "Alerts", description: - "The `/alerts` Inbox, Rules and Routing tabs and the incident page over one fixture org: two firing, one held, four blind spots, a delivery retry and a diagnosed investigation. As an admin, a member or an observer, who gets no Acknowledge (`?role=member|observer`).", + "The `/alerts` Inbox, Rules and Routing tabs and the incident page over one fixture org: two firing, one held, four blind spots, a delivery retry and a diagnosed investigation. As an admin, a member or an analyst, who gets no Acknowledge (`?role=member|analyst`).", kind: "lab", session: "none", }, @@ -511,7 +511,7 @@ export const LAB_ENTRIES: ReadonlyArray = [ path: "/lab/status-pages", title: "Status pages", description: - "The status pages list, the look picker and the form fields (hint and error) over fixture pages, or a page's editor open on an incident (`?page=list|editor`). As an admin, a member or an observer, who gets no control that posts, edits or deletes an incident (`?role=member|observer`).", + "The status pages list, the look picker and the form fields (hint and error) over fixture pages, or a page's editor open on an incident (`?page=list|editor`). As an admin, a member or an analyst, who gets no control that posts, edits or deletes an incident (`?role=member|analyst`).", kind: "lab", session: "none", }, diff --git a/apps/web/src/lab/shell-states-lab.scene.test.ts b/apps/web/src/lab/shell-states-lab.scene.test.ts index 870399739b..0470e67128 100644 --- a/apps/web/src/lab/shell-states-lab.scene.test.ts +++ b/apps/web/src/lab/shell-states-lab.scene.test.ts @@ -140,7 +140,7 @@ describe("URL-held right sheet", () => { }) }) -describe("the shell for an observer", () => { +describe("the shell for an analyst", () => { const setup = [ role("link", { name: "Sources" }), role("link", { name: "Settings" }), @@ -156,12 +156,12 @@ describe("the shell for an observer", () => { ) }) - test("an observer has none of them, and keeps their own pages", () => { + test("an analyst has none of them, and keeps their own pages", () => { scene( { update, view }, given(at("/lab/shell")), measured, - click(role("button", { name: "Observer" })), + click(role("button", { name: "Analyst" })), ...Arr.map(setup, (it) => expect(it).toBeAbsent()), expect(role("link", { name: "Web analytics" })).toExist(), ) diff --git a/apps/web/src/lab/shell-states-lab.ts b/apps/web/src/lab/shell-states-lab.ts index 2234470dbc..2f2df77060 100644 --- a/apps/web/src/lab/shell-states-lab.ts +++ b/apps/web/src/lab/shell-states-lab.ts @@ -167,13 +167,13 @@ const sheetStep = (model: Model, step: SheetStep): Return => ({ ], }) -/** A signed-in observer with nothing else read yet: all the shell needs to pick their sidebar. */ -const OBSERVER_CLERK: ShellClerk = { +/** A signed-in analyst with nothing else read yet: all the shell needs to pick their sidebar. */ +const ANALYST_CLERK: ShellClerk = { maybeUser: Option.none(), maybeOrganization: Option.none(), memberships: [], customer: undefined, - maybeRole: Option.some("org:observer"), + maybeRole: Option.some("org:analyst"), } /** The fixture's shifts, read on the lab's fixed clock. */ @@ -364,7 +364,7 @@ const page = (h: HtmlBuilder, model: Model): Html => onChange: (viewer) => Message.ClickedViewer({ viewer }), options: [ { value: "operator", label: "Member and above" }, - { value: "observer", label: "Observer" }, + { value: "analyst", label: "Analyst" }, ], }), ), @@ -395,7 +395,7 @@ export const view = (model: Model, h: HtmlBuilder): Html => TooltipHost.view(h, model.tooltip, toTooltip), ...labShellView(h, model.shell, toShell, { currentPath: STAND_ON_PATH[model.standOn], - clerk: model.viewer === "observer" ? OBSERVER_CLERK : null, + clerk: model.viewer === "analyst" ? ANALYST_CLERK : null, breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }], page: () => page(h, model), }), diff --git a/apps/web/src/lab/viewer-role.ts b/apps/web/src/lab/viewer-role.ts index d99e58f831..bce02440f9 100644 --- a/apps/web/src/lab/viewer-role.ts +++ b/apps/web/src/lab/viewer-role.ts @@ -1,10 +1,10 @@ import * as Policy from "@maple/domain/policy" import { Effect, Option, Schema } from "effect" -// `?role=admin|member|observer` for a lab that renders a page as the viewer would see it. Without +// `?role=admin|member|analyst` for a lab that renders a page as the viewer would see it. Without // the flag a lab keeps the full rights it always had. -const ROLES = ["admin", "member", "observer"] as const +const ROLES = ["admin", "member", "analyst"] as const export const LabRole = Schema.Literals(ROLES) export type LabRole = typeof LabRole.Type diff --git a/apps/web/src/page/alerts/hub/observer.scene.test.ts b/apps/web/src/page/alerts/hub/analyst.scene.test.ts similarity index 88% rename from apps/web/src/page/alerts/hub/observer.scene.test.ts rename to apps/web/src/page/alerts/hub/analyst.scene.test.ts index a7eb2a95e9..0ec29ece44 100644 --- a/apps/web/src/page/alerts/hub/observer.scene.test.ts +++ b/apps/web/src/page/alerts/hub/analyst.scene.test.ts @@ -11,14 +11,14 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the Inbox; acknowledging an incident from its row needs `incidents:respond`. +// An analyst reads the Inbox; acknowledging an incident from its row needs `incidents:respond`. const context: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } const pageUpdate = (model: Model, message: Message) => update(model, message, context) const view = withViewInputs(pageView, { context })() const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) const ACKNOWLEDGED = incidentRow({ id: "0199a003-0000-7000-8000-000000000002", @@ -62,11 +62,11 @@ const loaded = (grants: ReadonlyArray, at: HubAt = {}): Model (model, message) => pageUpdate(model, message).model, ) -describe("alerts inbox, observer", () => { - test("an observer reads the open incidents and gets no acknowledge button on a row", () => { +describe("alerts inbox, analyst", () => { + test("an analyst reads the open incidents and gets no acknowledge button on a row", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER)), + given(loaded(ANALYST)), expectView(text(/1 acknowledged/)).toExist(), expectView(text(/^Acknowledged by Dana Kim ·/)).toExist(), expectView(role("button", { name: /^Acknowledge / })).toBeAbsent(), @@ -82,10 +82,10 @@ describe("alerts inbox, observer", () => { ) }) - test("the Anomalies view offers Acknowledge to a member and not to an observer", () => { + test("the Anomalies view offers Acknowledge to a member and not to an analyst", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, { signal: "anomaly" })), + given(loaded(ANALYST, { signal: "anomaly" })), expectView(text(/^1 firing/)).toExist(), expectView(role("button", { name: /^Acknowledge / })).toBeAbsent(), ) diff --git a/apps/web/src/page/alerts/incident/observer.scene.test.ts b/apps/web/src/page/alerts/incident/analyst.scene.test.ts similarity index 84% rename from apps/web/src/page/alerts/incident/observer.scene.test.ts rename to apps/web/src/page/alerts/incident/analyst.scene.test.ts index ef903091b7..cdabe4c0d1 100644 --- a/apps/web/src/page/alerts/incident/observer.scene.test.ts +++ b/apps/web/src/page/alerts/incident/analyst.scene.test.ts @@ -12,7 +12,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the incident; acknowledging it needs `incidents:respond` and starting an +// An analyst reads the incident; acknowledging it needs `incidents:respond` and starting an // investigation from it needs `investigations:run`. const context: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } @@ -21,7 +21,7 @@ const view = withViewInputs(pageView, { context })() const themed = () => Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) type Fixture = Readonly<{ grants: ReadonlyArray @@ -58,11 +58,11 @@ const ACKNOWLEDGED = { acknowledged_via: "slack", } -describe("alert incident, observer", () => { - test("an observer is not offered Acknowledge or Investigate, and a member is", () => { +describe("alert incident, analyst", () => { + test("an analyst is not offered Acknowledge or Investigate, and a member is", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER })), + given(loaded({ grants: ANALYST })), themed(), expectView(role("button", { name: "Acknowledge" })).toBeAbsent(), expectView(role("button", { name: "Investigate" })).toBeAbsent(), @@ -76,10 +76,10 @@ describe("alert incident, observer", () => { ) }) - test("an observer reads who acknowledged it and is not offered Unacknowledge", () => { + test("an analyst reads who acknowledged it and is not offered Unacknowledge", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, row: ACKNOWLEDGED })), + given(loaded({ grants: ANALYST, row: ACKNOWLEDGED })), themed(), expectView(text(/Acknowledged by Dana Kim at/)).toExist(), expectView(role("button", { name: "Unacknowledge" })).toBeAbsent(), @@ -92,10 +92,10 @@ describe("alert incident, observer", () => { ) }) - test("an observer can still open an investigation that already ran", () => { + test("an analyst can still open an investigation that already ran", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, hasInvestigation: true })), + given(loaded({ grants: ANALYST, hasInvestigation: true })), themed(), expectView(role("link", { name: "Open investigation" })).toExist(), ) diff --git a/apps/web/src/page/anomalies/detail/observer.scene.test.ts b/apps/web/src/page/anomalies/detail/analyst.scene.test.ts similarity index 90% rename from apps/web/src/page/anomalies/detail/observer.scene.test.ts rename to apps/web/src/page/anomalies/detail/analyst.scene.test.ts index 79c01f5d9d..f40cf2797b 100644 --- a/apps/web/src/page/anomalies/detail/observer.scene.test.ts +++ b/apps/web/src/page/anomalies/detail/analyst.scene.test.ts @@ -13,7 +13,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the anomaly; resolving it and linking an issue need `incidents:respond`, and +// An analyst reads the anomaly; resolving it and linking an issue need `incidents:respond`, and // opening an investigation on it needs `investigations:run`. const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -25,7 +25,7 @@ const ID = Schema.decodeSync(AnomalyIncidentId)("0199a060-0000-7000-8000-0000000 const NOW = Date.UTC(2026, 9, 1, 12) const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) const incidentOf = (overrides: Readonly>) => Schema.decodeSync(V2AnomalyIncident)({ @@ -88,11 +88,11 @@ const linked = (grants: ReadonlyArray): Model => { ).model } -describe("anomaly detail, observer", () => { - test("an observer reads the anomaly and is not offered Resolve, Link issue or an investigation", () => { +describe("anomaly detail, analyst", () => { + test("an analyst reads the anomaly and is not offered Resolve, Link issue or an investigation", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER)), + given(loaded(ANALYST)), themed(), expectView(text("Not linked to an issue")).toExist(), expectView(text("Details")).toExist(), @@ -116,10 +116,10 @@ describe("anomaly detail, observer", () => { ) }) - test("an observer can open the linked issue and cannot relink or unlink it; a member can", () => { + test("an analyst can open the linked issue and cannot relink or unlink it; a member can", () => { scene( { update: pageUpdate, view }, - given(linked(OBSERVER)), + given(linked(ANALYST)), themed(), expectView(role("link", { name: "Open issue" })).toExist(), expectView(role("button", { name: "Link a different issue" })).toBeAbsent(), @@ -135,8 +135,8 @@ describe("anomaly detail, observer", () => { }) test("an answer for another org leaves the page's grants as they were", () => { - const model = loaded(OBSERVER) + const model = loaded(ANALYST) const other = pageUpdate(model, Message.CompletedFetchSession({ orgId: "org_other", grants: MEMBER })) - expect(other.model.grants).toEqual(OBSERVER) + expect(other.model.grants).toEqual(ANALYST) }) }) diff --git a/apps/web/src/page/anomalies/detail/model.ts b/apps/web/src/page/anomalies/detail/model.ts index e24cb87f21..71046f7ad8 100644 --- a/apps/web/src/page/anomalies/detail/model.ts +++ b/apps/web/src/page/anomalies/detail/model.ts @@ -15,7 +15,7 @@ export type Busy = typeof Busy.Type export const Model = Schema.Struct({ incidentId: AnomalyIncidentId, nowMs: Schema.Number, - /** What the viewer may do; an observer reads the anomaly and gets no resolve or link control. */ + /** What the viewer may do; an analyst reads the anomaly and gets no resolve or link control. */ grants: Schema.Array(Policy.PermissionName), incident: KeyedSchema(V2AnomalyIncident, LoadFailure), timeseries: KeyedSchema(V2AnomalyIncidentTimeseries, LoadFailure), diff --git a/apps/web/src/page/dashboards/list/observer.scene.test.ts b/apps/web/src/page/dashboards/list/analyst.scene.test.ts similarity index 91% rename from apps/web/src/page/dashboards/list/observer.scene.test.ts rename to apps/web/src/page/dashboards/list/analyst.scene.test.ts index 9c9a9bbcd7..9525b344da 100644 --- a/apps/web/src/page/dashboards/list/observer.scene.test.ts +++ b/apps/web/src/page/dashboards/list/analyst.scene.test.ts @@ -10,7 +10,7 @@ import type { ListDashboard, Model } from "./model" import { init, rowMenuIdOf, update } from "./update" import { view as pageView } from "./view" -// An observer reads the list; creating, importing and the row's writes need `dashboards:edit`. +// An analyst reads the list; creating, importing and the row's writes need `dashboards:edit`. const orgId = Schema.decodeSync(OrgId)("org_acme") const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -31,7 +31,7 @@ const board: ListDashboard = { } const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) /** Entered with its clock, preferences, the session's grants and rows. */ const loaded = (grants: ReadonlyArray, dashboards: ReadonlyArray): Model => @@ -52,11 +52,11 @@ const withRowMenuOpen = (model: Model): Model => ({ rowMenu: Menu.init(rowMenuIdOf(board.id), { isOpen: true }), }) -describe("dashboards list for an observer", () => { +describe("dashboards list for an analyst", () => { test("the header keeps Browse templates and drops Import and Create", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, [board])), + given(loaded(ANALYST, [board])), expectView(text("Platform overview")).toExist(), expectView(role("button", { name: "Browse templates" })).toExist(), expectView(role("button", { name: "Import" })).toBeAbsent(), @@ -78,7 +78,7 @@ describe("dashboards list for an observer", () => { test("the row menu exports and offers no write", () => { scene( { update: pageUpdate, view }, - given(withRowMenuOpen(loaded(OBSERVER, [board]))), + given(withRowMenuOpen(loaded(ANALYST, [board]))), Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), expectView(role("menuitem", { name: "Export JSON" })).toExist(), expectView(role("menuitem", { name: "Duplicate" })).toBeAbsent(), @@ -101,7 +101,7 @@ describe("dashboards list for an observer", () => { test("an empty org offers nothing to create", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, [])), + given(loaded(ANALYST, [])), expectView(text("No dashboards yet")).toExist(), expectView(role("button", { name: "Create blank dashboard" })).toBeAbsent(), ) @@ -111,7 +111,7 @@ describe("dashboards list for an observer", () => { const other = Schema.decodeSync(OrgId)("org_other") const model = pageUpdate( loaded(MEMBER, [board]), - Message.CompletedFetchSession({ orgId: other, grants: OBSERVER }), + Message.CompletedFetchSession({ orgId: other, grants: ANALYST }), ).model scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/dashboards/list/model.ts b/apps/web/src/page/dashboards/list/model.ts index db97429d1d..095b7715f5 100644 --- a/apps/web/src/page/dashboards/list/model.ts +++ b/apps/web/src/page/dashboards/list/model.ts @@ -47,7 +47,7 @@ export type Scope = typeof Scope.Type export const Model = Schema.Struct({ search: DashboardsSearch, - /** What the viewer may do; an observer reads the list and gets no write control. */ + /** What the viewer may do; an analyst reads the list and gets no write control. */ grants: Schema.Array(Policy.PermissionName), /** `null` until the collection is ready. */ dashboards: Schema.NullOr(Schema.Array(ListDashboard)), diff --git a/apps/web/src/page/dashboards/list/view-list.ts b/apps/web/src/page/dashboards/list/view-list.ts index e5ecb1e56f..afbe60ac00 100644 --- a/apps/web/src/page/dashboards/list/view-list.ts +++ b/apps/web/src/page/dashboards/list/view-list.ts @@ -158,8 +158,8 @@ const READ_ONLY_NOTE: ReadonlyArray = [ const EXPORT_ENTRY: MenuView.MenuEntry = { _tag: "Item", value: "export", label: "Export JSON" } -/** All an observer's row menu holds: the entries that write are not offered. */ -const OBSERVER_ROW_ENTRIES: ReadonlyArray = [{ _tag: "Group", entries: [EXPORT_ENTRY] }] +/** All an analyst's row menu holds: the entries that write are not offered. */ +const ANALYST_ROW_ENTRIES: ReadonlyArray = [{ _tag: "Group", entries: [EXPORT_ENTRY] }] /** Export reads what the list holds, so it survives a write failure; the rest write, so they don't. */ const rowMenuEntries = (isReadOnly: boolean): ReadonlyArray => [ @@ -188,7 +188,7 @@ const rowMenu = (h: H, model: Model, dashboard: ListDashboard): Html => { return rowActionsMenu(h, { slotId: `row-menu-${dashboard.id}`, model: menu, - entries: canEditDashboards(model) ? rowMenuEntries(isReadOnly(model)) : OBSERVER_ROW_ENTRIES, + entries: canEditDashboards(model) ? rowMenuEntries(isReadOnly(model)) : ANALYST_ROW_ENTRIES, label: `More actions for ${displayName(dashboard.name)}`, className: "text-muted-foreground", toParentMessage: (message) => Message.GotRowMenuMessage({ rowId: dashboard.id, message }), @@ -403,8 +403,8 @@ const firstRunEmpty = (h: H, isReadOnly: boolean): Html => "py-16", ) -/** An observer's empty list: nothing to start from, so it says where dashboards come from. */ -const observerEmpty = (h: H): Html => +/** An analyst's empty list: nothing to start from, so it says where dashboards come from. */ +const analystEmpty = (h: H): Html => empty( h, [ @@ -513,7 +513,7 @@ export const listBody = ( const canEdit = canEditDashboards(model) const context: RowContext = { model, nowMs, timeZone } const body: ReadonlyArray = isNothingAtAll - ? [canEdit ? firstRunEmpty(h, isWriteDisabled) : observerEmpty(h)] + ? [canEdit ? firstRunEmpty(h, isWriteDisabled) : analystEmpty(h)] : sections.matched === 0 ? [nothingMatched(h, model)] : sections.scoped === 0 diff --git a/apps/web/src/page/dashboards/list/view.ts b/apps/web/src/page/dashboards/list/view.ts index 0c87047726..7e44d388d5 100644 --- a/apps/web/src/page/dashboards/list/view.ts +++ b/apps/web/src/page/dashboards/list/view.ts @@ -160,7 +160,7 @@ export const view = Submodel.defineView((model, h, { titleContent: title, - // An observer browses the templates but neither imports nor creates. + // An analyst browses the templates but neither imports nor creates. actions: canEditDashboards(model) ? actions(h, reason) : browseTemplates(h), sticky: persistenceBanner(h, model.persistenceError, isLoadFailed(model)), }, diff --git a/apps/web/src/page/dashboards/templates/observer.scene.test.ts b/apps/web/src/page/dashboards/templates/analyst.scene.test.ts similarity index 92% rename from apps/web/src/page/dashboards/templates/observer.scene.test.ts rename to apps/web/src/page/dashboards/templates/analyst.scene.test.ts index ca47737f71..e03f3b2186 100644 --- a/apps/web/src/page/dashboards/templates/observer.scene.test.ts +++ b/apps/web/src/page/dashboards/templates/analyst.scene.test.ts @@ -12,7 +12,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer previews templates; creating a dashboard from one needs `dashboards:edit`. +// An analyst previews templates; creating a dashboard from one needs `dashboards:edit`. const orgId = Schema.decodeSync(OrgId)("org_acme") const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -36,7 +36,7 @@ const template = Schema.decodeUnknownSync(Schema.toType(V2DashboardTemplate))({ }) const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) /** The gallery with its one template selected and the session's grants answered. */ const selected = (grants: ReadonlyArray): Model => { @@ -54,9 +54,9 @@ const selected = (grants: ReadonlyArray): Model => { ) } -describe("dashboard templates for an observer", () => { +describe("dashboard templates for an analyst", () => { test("the template previews with no way to create from it", () => { - const model = selected(OBSERVER) + const model = selected(ANALYST) expect(names(pageUpdate(model, Message.ClickedCreate({ templateId: template.id })).commands)).toEqual( [], ) diff --git a/apps/web/src/page/dashboards/templates/model.ts b/apps/web/src/page/dashboards/templates/model.ts index bb89ea0072..2ae82aa65c 100644 --- a/apps/web/src/page/dashboards/templates/model.ts +++ b/apps/web/src/page/dashboards/templates/model.ts @@ -21,7 +21,7 @@ export type MetricSeen = typeof MetricSeen.Type export const Model = Schema.Struct({ search: DashboardsTemplatesSearch, - /** What the viewer may do; an observer previews templates and gets no create control. */ + /** What the viewer may do; an analyst previews templates and gets no create control. */ grants: Schema.Array(Policy.PermissionName), nowMs: Schema.NullOr(Schema.Number), templates: KeyedSchema(Schema.Array(V2DashboardTemplate), LoadFailure), diff --git a/apps/web/src/page/dashboards/view/observer.scene.test.ts b/apps/web/src/page/dashboards/view/analyst.scene.test.ts similarity index 94% rename from apps/web/src/page/dashboards/view/observer.scene.test.ts rename to apps/web/src/page/dashboards/view/analyst.scene.test.ts index e7c4f49d84..9a06b72f5f 100644 --- a/apps/web/src/page/dashboards/view/observer.scene.test.ts +++ b/apps/web/src/page/dashboards/view/analyst.scene.test.ts @@ -14,7 +14,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads a board; editing needs `dashboards:edit` and sharing `dashboards:share`. +// An analyst reads a board; editing needs `dashboards:edit` and sharing `dashboards:share`. const orgId = Schema.decodeSync(OrgId)("org_acme") const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } @@ -62,7 +62,7 @@ const version = { } const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) type Grants = ReadonlyArray /** Entered, clocked, holding the session's grants and the document. */ @@ -93,11 +93,11 @@ const previewV2 = (model: Model): Model => Message.CompletedFetchSnapshot({ versionId: version.id, result: Result.succeed(board([note])) }), ]) -describe("a dashboard for an observer", () => { +describe("a dashboard for an analyst", () => { test("the board and its widgets render without an Edit control", () => { scene( { update: pageUpdate, view }, - given(ready(OBSERVER)), + given(ready(ANALYST)), ...mounted(), expectView(text("Page the platform on-call first.")).toExist(), expectView(role("button", { name: "Reload" })).toExist(), @@ -115,7 +115,7 @@ describe("a dashboard for an observer", () => { }) test("?mode=edit lands on the read view", () => { - const model = ready(OBSERVER, { mode: "edit" }) + const model = ready(ANALYST, { mode: "edit" }) expect(isEditing(model)).toBe(false) scene( { update: pageUpdate, view }, @@ -141,7 +141,7 @@ describe("a dashboard for an observer", () => { }) test("the name is a heading, not a rename control", () => { - const model = ready(OBSERVER) + const model = ready(ANALYST) expect(pageUpdate(model, Message.ClickedTitle()).model.titleDraft).toBeNull() scene( { update: pageUpdate, view }, @@ -166,7 +166,7 @@ describe("a dashboard for an observer", () => { test("the more menu keeps history and export and drops Share", () => { scene( { update: pageUpdate, view }, - given(withMoreMenuOpen(ready(OBSERVER))), + given(withMoreMenuOpen(ready(ANALYST))), ...mounted(), Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), expectView(role("menuitem", { name: "Version history" })).toExist(), @@ -186,7 +186,7 @@ describe("a dashboard for an observer", () => { }) test("a previewed version can be left but not restored", () => { - const previewed = previewV2(ready(OBSERVER)) + const previewed = previewV2(ready(ANALYST)) expect(previewed.history.previewed?.id).toBe(version.id) expect(pageUpdate(previewed, Message.ClickedRestoreVersion()).model.history.restoreDialog).toEqual( previewed.history.restoreDialog, @@ -212,7 +212,7 @@ describe("a dashboard for an observer", () => { test("an empty board offers nothing to add", () => { scene( { update: pageUpdate, view }, - given(ready(OBSERVER, {}, board([]))), + given(ready(ANALYST, {}, board([]))), themed(), expectView(text("Nothing on this board yet")).toExist(), expectView(role("button", { name: "Add your first widget" })).toBeAbsent(), diff --git a/apps/web/src/page/dashboards/view/model.ts b/apps/web/src/page/dashboards/view/model.ts index 4cc77dd64e..e9d74a88fd 100644 --- a/apps/web/src/page/dashboards/view/model.ts +++ b/apps/web/src/page/dashboards/view/model.ts @@ -152,7 +152,7 @@ export const Model = Schema.Struct({ dashboardId: Schema.String, search: DashboardSearch, status: Status, - /** What the viewer may do; an observer reads the board and gets no edit or share control. */ + /** What the viewer may do; an analyst reads the board and gets no edit or share control. */ grants: Schema.Array(Policy.PermissionName), document: Schema.NullOr(DashboardDocument), /** The live board never answered within `COLLECTION_LOAD_TIMEOUT_MS`: the HTTP snapshot's state. */ diff --git a/apps/web/src/page/errors/hub/observer.scene.test.ts b/apps/web/src/page/errors/hub/analyst.scene.test.ts similarity index 90% rename from apps/web/src/page/errors/hub/observer.scene.test.ts rename to apps/web/src/page/errors/hub/analyst.scene.test.ts index f5e4bab9f9..1b6f6f72c5 100644 --- a/apps/web/src/page/errors/hub/observer.scene.test.ts +++ b/apps/web/src/page/errors/hub/analyst.scene.test.ts @@ -13,7 +13,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the list; selection, the row pickers, the bulk bar and the row menu's +// An analyst reads the list; selection, the row pickers, the bulk bar and the row menu's // status and lease entries need `error_issues:triage`. const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -25,7 +25,7 @@ const pageUpdate = (model: Model, message: Message) => update(model, message, co const view = withViewInputs(pageView, { context })() const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) const RECT = { left: 2, right: 115, top: 2, bottom: 2, width: 51, height: 28 } const mounted = [ @@ -106,11 +106,11 @@ const pickedFromRowMenu = (model: Model, index: number) => { ) } -describe("errors list for an observer", () => { +describe("errors list for an analyst", () => { test("a row reads its severity and status with no checkbox or picker", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER)), + given(loaded(ANALYST)), ...mounted, expectView(role("link", { name: /ConnectionTimeout/ })).toHaveAttr( "href", @@ -136,7 +136,7 @@ describe("errors list for an observer", () => { ) }) - test("a selection brings a member the bulk bar and an observer nothing", () => { + test("a selection brings a member the bulk bar and an analyst nothing", () => { const bulkBar = role("region", { name: "Bulk actions" }) scene( { update: pageUpdate, view }, @@ -147,7 +147,7 @@ describe("errors list for an observer", () => { ) scene( { update: pageUpdate, view }, - given({ ...loaded(OBSERVER), selectedIds: [ISSUE_ID] }), + given({ ...loaded(ANALYST), selectedIds: [ISSUE_ID] }), ...mounted, expectView(bulkBar).toBeAbsent(), expectView(role("button", { name: "Claim" })).toBeAbsent(), @@ -155,7 +155,7 @@ describe("errors list for an observer", () => { }) test("x, s and p do nothing; j still moves the focus and Enter opens the issue", () => { - const focused = press(loaded(OBSERVER), "j") + const focused = press(loaded(ANALYST), "j") expect(focused.focusedId).toBe(ISSUE_ID) expect(press(focused, "x").selectedIds).toEqual([]) expect(press(focused, "s").picker).toBeNull() @@ -173,9 +173,9 @@ describe("errors list for an observer", () => { }) test("the row menu starts at Open in new tab and writes nothing", () => { - expect(names(pickedFromRowMenu(loaded(OBSERVER), 0).commands)).toContain("OpenErrorsRowInNewTab") - // Where a member's menu has Claim, an observer's has Copy link. - const second = names(pickedFromRowMenu(loaded(OBSERVER), 1).commands) + expect(names(pickedFromRowMenu(loaded(ANALYST), 0).commands)).toContain("OpenErrorsRowInNewTab") + // Where a member's menu has Claim, an analyst's has Copy link. + const second = names(pickedFromRowMenu(loaded(ANALYST), 1).commands) expect(second).toContain("CopyErrorsRowText") expect(second).not.toContain("RunErrorsHubWrite") }) @@ -188,7 +188,7 @@ describe("errors list for an observer", () => { const other = Schema.decodeSync(OrgId)("org_other") const model = pageUpdate( loaded(MEMBER), - Message.CompletedFetchSession({ orgId: other, grants: OBSERVER }), + Message.CompletedFetchSession({ orgId: other, grants: ANALYST }), ).model scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/errors/hub/model.ts b/apps/web/src/page/errors/hub/model.ts index 18df5b2988..0e2b0a7636 100644 --- a/apps/web/src/page/errors/hub/model.ts +++ b/apps/web/src/page/errors/hub/model.ts @@ -57,7 +57,7 @@ export type PageSlot = typeof PageSlot.Type export const Model = Schema.Struct({ search: ErrorsSearch, - /** What the viewer may do; an observer reads the list and gets no triage control. */ + /** What the viewer may do; an analyst reads the list and gets no triage control. */ grants: Schema.Array(Policy.PermissionName), /** The trend window (`ERRORS_WINDOW`) and the clock it was resolved at. */ window: Schema.NullOr(TimeWindow), diff --git a/apps/web/src/page/errors/hub/selection.ts b/apps/web/src/page/errors/hub/selection.ts index 3f186b48e2..0c056fbff6 100644 --- a/apps/web/src/page/errors/hub/selection.ts +++ b/apps/web/src/page/errors/hub/selection.ts @@ -46,7 +46,7 @@ export const toggledSelection = (model: Model, id: string, isShift: boolean): Mo const cleared = (model: Model): Model => ({ ...model, selectedIds: [], selectionAnchor: null }) -/** `x` selects for the bulk bar and `s`/`p` open the pickers: triage, so nothing for an observer. */ +/** `x` selects for the bulk bar and `s`/`p` open the pickers: triage, so nothing for an analyst. */ const TRIAGE_KEYS = new Set(["x", "X", "s", "S", "p", "P"]) /** `j`/`k`/arrows move the focus, Enter opens, `x` selects, Escape clears; `s`/`p` open its pickers. */ diff --git a/apps/web/src/page/errors/hub/view-list.ts b/apps/web/src/page/errors/hub/view-list.ts index 0f9d2ca65b..c7056fad10 100644 --- a/apps/web/src/page/errors/hub/view-list.ts +++ b/apps/web/src/page/errors/hub/view-list.ts @@ -121,7 +121,7 @@ const memoRow = (h: H, signal: ErrorSignal, index: number, state: RowState): Htm /** The rows, or the empty state, under the column header. */ const readyBody = (h: H, model: Model, inputs: ListInputs): ReadonlyArray => { const { signals } = inputs - // Selection and the bulk bar are triage: an observer gets neither. + // Selection and the bulk bar are triage: an analyst gets neither. const canTriage = canTriageIssues(model) if (signals.length === 0) { return [ diff --git a/apps/web/src/page/errors/hub/view-row.ts b/apps/web/src/page/errors/hub/view-row.ts index e8cb5dadaa..55bd53a630 100644 --- a/apps/web/src/page/errors/hub/view-row.ts +++ b/apps/web/src/page/errors/hub/view-row.ts @@ -171,7 +171,7 @@ export const signalRow = (h: H, signal: ErrorSignal, state: RowState): Html => { h.span( [ h.Class(cn(LANE.select, "flex items-center justify-center")), - // Selection only feeds the bulk bar: an observer's lane is empty and selects nothing. + // Selection only feeds the bulk bar: an analyst's lane is empty and selects nothing. ...(state.canTriage ? [h.DataAttribute("row-select", signal.id)] : []), h.Role("presentation"), ], diff --git a/apps/web/src/page/errors/issue/observer.scene.test.ts b/apps/web/src/page/errors/issue/analyst.scene.test.ts similarity index 93% rename from apps/web/src/page/errors/issue/observer.scene.test.ts rename to apps/web/src/page/errors/issue/analyst.scene.test.ts index b85118993e..befe6b1794 100644 --- a/apps/web/src/page/errors/issue/observer.scene.test.ts +++ b/apps/web/src/page/errors/issue/analyst.scene.test.ts @@ -18,7 +18,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the issue; state, severity, the lease, merges, pull request links and +// An analyst reads the issue; state, severity, the lease, merges, pull request links and // comments need `error_issues:triage`. const orgId = Schema.decodeSync(OrgId)("org_acme") @@ -30,7 +30,7 @@ const pageUpdate = (model: Model, message: Message) => update(model, message, co const view = withViewInputs(pageView, { context })() const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) const holder = { id: encodePublicId("actor", "0199a005-0000-7000-8000-000000000001"), @@ -179,11 +179,11 @@ const mounted = [ Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })), ] as const -describe("error issue for an observer", () => { +describe("error issue for an analyst", () => { test("the rail reads state and severity and offers no pick, claim or attach", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER })), + given(loaded({ grants: ANALYST })), ...mounted, expectView(text("In progress")).toExist(), expectView(text("High")).toExist(), @@ -211,7 +211,7 @@ describe("error issue for an observer", () => { test("a held lease shows its countdown without Extend or Release", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, detail: HELD })), + given(loaded({ grants: ANALYST, detail: HELD })), ...mounted, expectView(text("Active lease")).toExist(), expectView(text("5:00")).toExist(), @@ -233,7 +233,7 @@ describe("error issue for an observer", () => { test("a merged issue says where it went and offers no Unmerge", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, detail: MERGED })), + given(loaded({ grants: ANALYST, detail: MERGED })), ...mounted, expectView(text("Merged into another issue")).toExist(), expectView(role("link", { name: "the issue it was merged into" })).toExist(), @@ -253,7 +253,7 @@ describe("error issue for an observer", () => { test("a linked pull request is a link with no detach", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, hasPullRequest: true })), + given(loaded({ grants: ANALYST, hasPullRequest: true })), ...mounted, expectView(role("link", { name: "acme/payments#412" })).toExist(), expectView(role("button", { name: "Detach acme/payments#412" })).toBeAbsent(), @@ -272,7 +272,7 @@ describe("error issue for an observer", () => { test("the activity tab keeps the timeline and has no comment box", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER, tab: "activity" })), + given(loaded({ grants: ANALYST, tab: "activity" })), ...mounted, expectView(text("triage → in_progress")).toExist(), expectView(role("textbox")).toBeAbsent(), @@ -290,10 +290,10 @@ describe("error issue for an observer", () => { ) }) - test("an observer is not offered an investigation or the agent prompt, and a member is", () => { + test("an analyst is not offered an investigation or the agent prompt, and a member is", () => { scene( { update: pageUpdate, view }, - given(loaded({ grants: OBSERVER })), + given(loaded({ grants: ANALYST })), ...mounted, expectView(role("button", { name: "Start investigation" })).toBeAbsent(), expectView(role("button", { name: "Copy agent prompt" })).toBeAbsent(), @@ -311,7 +311,7 @@ describe("error issue for an observer", () => { const other = Schema.decodeSync(OrgId)("org_other") const model = pageUpdate( loaded({ grants: MEMBER }), - Message.CompletedFetchSession({ orgId: other, grants: OBSERVER }), + Message.CompletedFetchSession({ orgId: other, grants: ANALYST }), ).model scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/errors/issue/model.ts b/apps/web/src/page/errors/issue/model.ts index 308913c384..262a295eb5 100644 --- a/apps/web/src/page/errors/issue/model.ts +++ b/apps/web/src/page/errors/issue/model.ts @@ -33,7 +33,7 @@ export const DestinationName = Schema.Struct({ id: Schema.String, name: Schema.S export const Model = Schema.Struct({ issueId: ErrorIssueId, search: ErrorIssueSearch, - /** What the viewer may do; an observer reads the issue and gets no triage control. */ + /** What the viewer may do; an analyst reads the issue and gets no triage control. */ grants: Schema.Array(Policy.PermissionName), /** The resolved window and the clock it was resolved at; `null` until the first resolve. */ window: Schema.NullOr(TimeWindow), diff --git a/apps/web/src/page/errors/issue/view.ts b/apps/web/src/page/errors/issue/view.ts index 0e0053150d..460069b724 100644 --- a/apps/web/src/page/errors/issue/view.ts +++ b/apps/web/src/page/errors/issue/view.ts @@ -132,7 +132,7 @@ const mergedNotice = (h: H, model: Model, issue: ErrorIssueDetailResponse["issue ), canTriageIssues(model) ? ". Unmerge it to track it on its own again." : ".", ]), - // Unmerging is triage: an observer reads where the occurrences go. + // Unmerging is triage: an analyst reads where the occurrences go. ...(canTriageIssues(model) ? [ alertAction(h, [ @@ -192,7 +192,7 @@ const overview = ( surge: signal?.surge ?? null, ...clock, }) - // Getting it fixed is a member's job: an observer only reads an investigation that already ran. + // Getting it fixed is a member's job: an analyst only reads an investigation that already ran. const investigation = linkedInvestigationOf(model) const fix = canRunInvestigations(model) || investigation !== null diff --git a/apps/web/src/page/investigations/list/observer.scene.test.ts b/apps/web/src/page/investigations/list/analyst.scene.test.ts similarity index 87% rename from apps/web/src/page/investigations/list/observer.scene.test.ts rename to apps/web/src/page/investigations/list/analyst.scene.test.ts index fa35905c39..822d2dd9ed 100644 --- a/apps/web/src/page/investigations/list/observer.scene.test.ts +++ b/apps/web/src/page/investigations/list/analyst.scene.test.ts @@ -12,7 +12,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads the investigations; starting one needs `investigations:run`. +// An analyst reads the investigations; starting one needs `investigations:run`. const context: PageContext = { orgId: Schema.decodeSync(OrgId)("org_acme"), @@ -33,7 +33,7 @@ const measured = () => const rowsMounted = () => Mount.resolve(MountInvestigationRows, Message.ClickedRow({ id: investigation.id })) const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) const NOW = Date.UTC(2026, 9, 1, 12) const investigation = buildAlertsLabFixture(NOW).incident.investigation const bar = role("textbox", { name: "What should Maple investigate?" }) @@ -54,11 +54,11 @@ const loaded = ( ).model } -describe("investigations list, observer", () => { - test("an observer reads the list without the investigate bar; a member has it", () => { +describe("investigations list, analyst", () => { + test("an analyst reads the list without the investigate bar; a member has it", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, [investigation])), + given(loaded(ANALYST, [investigation])), measured(), rowsMounted(), expectView(role("tab", { name: /Active/ })).toExist(), @@ -73,10 +73,10 @@ describe("investigations list, observer", () => { ) }) - test("an empty workspace shows an observer the plain empty list, and a member the first-run hero", () => { + test("an empty workspace shows an analyst the plain empty list, and a member the first-run hero", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, [])), + given(loaded(ANALYST, [])), measured(), expectView(text("Nothing under investigation")).toExist(), expectView(text("When an alert rule fires, Maple opens one on its own.")).toExist(), diff --git a/apps/web/src/page/settings/kit/org-membership.test.ts b/apps/web/src/page/settings/kit/org-membership.test.ts index 25784a1999..3fd4bff249 100644 --- a/apps/web/src/page/settings/kit/org-membership.test.ts +++ b/apps/web/src/page/settings/kit/org-membership.test.ts @@ -22,21 +22,21 @@ describe("leaveEligibility", () => { }) describe("toOrgRole", () => { - it("reads unknown Clerk roles as observer, the least a person can hold", () => { + it("reads unknown Clerk roles as analyst, the least a person can hold", () => { expect(toOrgRole("org:admin")).toBe("org:admin") - expect(toOrgRole("org:billing")).toBe("org:observer") - expect(orgRoleLabel("org:viewer")).toBe("Observer") + expect(toOrgRole("org:billing")).toBe("org:analyst") + expect(orgRoleLabel("org:viewer")).toBe("Analyst") }) it("shows root, which has no entry of its own, as a member", () => { expect(orgRoleLabel("root")).toBe("Member") }) - it("names the observer role in a role change prompt", () => { - expect(roleChangePrompt("Ada", "org:observer")).toEqual({ + it("names the analyst role in a role change prompt", () => { + expect(roleChangePrompt("Ada", "org:analyst")).toEqual({ question: - "Make Ada an observer? Follows incidents, reliability, product analytics and cost, without changing anything.", - confirmLabel: "Make observer", + "Make Ada an analyst? Follows incidents, reliability, product analytics and cost, without changing anything.", + confirmLabel: "Make analyst", }) }) }) diff --git a/apps/web/src/page/settings/kit/org-membership.ts b/apps/web/src/page/settings/kit/org-membership.ts index 4b421a693f..6443e5b73d 100644 --- a/apps/web/src/page/settings/kit/org-membership.ts +++ b/apps/web/src/page/settings/kit/org-membership.ts @@ -10,7 +10,7 @@ export const orgRoleKeys: ReadonlyArray = Arr.map(Policy.assignableRole // `root`, which Clerk never assigns, has no entry of its own and shows as a member. const memberEntry = Policy.assignableRoles[1] -/** A Clerk role string's entry, read through `Policy.roleOf` (unknown roles are observers). */ +/** A Clerk role string's entry, read through `Policy.roleOf` (unknown roles are analysts). */ const entryOf = (role: string): Policy.AssignableRole => { const policyRole = Policy.roleOf(role) return Option.getOrElse( diff --git a/apps/web/src/page/status-pages/editor/observer.scene.test.ts b/apps/web/src/page/status-pages/editor/analyst.scene.test.ts similarity index 89% rename from apps/web/src/page/status-pages/editor/observer.scene.test.ts rename to apps/web/src/page/status-pages/editor/analyst.scene.test.ts index 81fb267b71..2101d07174 100644 --- a/apps/web/src/page/status-pages/editor/observer.scene.test.ts +++ b/apps/web/src/page/status-pages/editor/analyst.scene.test.ts @@ -13,7 +13,7 @@ import type { Model } from "./model" import { init, update } from "./update" import { view as pageView } from "./view" -// An observer reads a status page and its incidents; posting, updating, editing and deleting an +// An analyst reads a status page and its incidents; posting, updating, editing and deleting an // incident or maintenance need `status_page_incidents:post`. const context: PageContext = { orgId: Schema.decodeSync(OrgId)(ORG), pinnedNamespace: null, timeZone: "UTC" } @@ -21,7 +21,7 @@ const pageUpdate = (model: Model, message: Message) => update(model, message, co const view = withViewInputs(pageView, { context })() const MEMBER = Policy.grantsOf(["org:member"]) -const OBSERVER = Policy.grantsOf(["org:observer"]) +const ANALYST = Policy.grantsOf(["org:analyst"]) /** The editor's Page tab over `incidents`, read by a session holding `grants`. */ const loaded = ( @@ -61,11 +61,11 @@ const MAINTENANCE = incident(2, { kind: "maintenance", title: "Database upgrade" const opened = (grants: ReadonlyArray, row: ReturnType, n: number) => pageUpdate(loaded(grants, [row]), Message.ClickedIncident({ incidentId: incidentId(n) })).model -describe("status page editor, observer", () => { - test("Post incident is offered to a member and not to an observer", () => { +describe("status page editor, analyst", () => { + test("Post incident is offered to a member and not to an analyst", () => { scene( { update: pageUpdate, view }, - given(loaded(OBSERVER, [])), + given(loaded(ANALYST, [])), expectView(text("Nothing posted.")).toExist(), expectView(role("button", { name: "Post incident" })).toBeAbsent(), ) @@ -77,10 +77,10 @@ describe("status page editor, observer", () => { ) }) - test("an observer reads an open incident's updates and gets none of its actions", () => { + test("an analyst reads an open incident's updates and gets none of its actions", () => { scene( { update: pageUpdate, view }, - given(opened(OBSERVER, DRAFT, 1)), + given(opened(ANALYST, DRAFT, 1)), expectView(text("Draft, not public")).toExist(), expectView(role("region", { name: "Updates" })).toExist(), expectView(role("button", { name: "Publish" })).toBeAbsent(), @@ -103,10 +103,10 @@ describe("status page editor, observer", () => { ) }) - test("a maintenance notice is the same: a member completes or deletes it, an observer reads it", () => { + test("a maintenance notice is the same: a member completes or deletes it, an analyst reads it", () => { scene( { update: pageUpdate, view }, - given(opened(OBSERVER, MAINTENANCE, 2)), + given(opened(ANALYST, MAINTENANCE, 2)), expectView(role("button", { name: "Complete" })).toBeAbsent(), expectView(role("button", { name: "Delete maintenance" })).toBeAbsent(), ) diff --git a/apps/web/src/shell/model.ts b/apps/web/src/shell/model.ts index b205ea7592..f791a14b85 100644 --- a/apps/web/src/shell/model.ts +++ b/apps/web/src/shell/model.ts @@ -134,10 +134,10 @@ export type ShellClerk = Readonly<{ maybeRole: Option.Option }> -/** Who the shell is drawn for; self-hosted has no Clerk role, so it is never an observer. */ +/** Who the shell is drawn for; self-hosted has no Clerk role, so it is never an analyst. */ export const shellAudience = (clerk: ShellClerk | null): NavAudience => clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.isReadOnly([role])) - ? "observer" + ? "analyst" : "operator" /** A rail item's tooltip: right of the item, centred. */ diff --git a/apps/web/src/shell/nav.test.ts b/apps/web/src/shell/nav.test.ts index e9bc82a8c5..8ecc8cb7af 100644 --- a/apps/web/src/shell/nav.test.ts +++ b/apps/web/src/shell/nav.test.ts @@ -62,12 +62,12 @@ describe("isNavItemActive", () => { }) }) -describe("navGroups for an observer", () => { +describe("navGroups for an analyst", () => { const off = { cloudCost: false, releases: false, securityAudit: false, statusPages: false } - /** Every page the observer's sidebar links to: rows, then their sub-rows. */ + /** Every page the analyst's sidebar links to: rows, then their sub-rows. */ const hrefs = (flags: NavFlags) => Arr.dedupe( - Arr.flatMap(navGroups(flags, "observer"), (group) => + Arr.flatMap(navGroups(flags, "analyst"), (group) => Arr.flatMap(group.items, (item) => [ item.href, ...Arr.map(item.subItems ?? [], (sub) => sub.href), @@ -98,7 +98,7 @@ describe("navGroups for an observer", () => { }) test("marks only Incidents on an uptime page, with its Uptime row", () => { - const items = Arr.flatMap(navGroups(off, "observer"), (group) => group.items) + const items = Arr.flatMap(navGroups(off, "analyst"), (group) => group.items) const active = Arr.filter(items, (item) => isNavItemActive("/alerts/uptime", item)) expect(Arr.map(active, (item) => item.title)).toEqual(["Incidents"]) }) diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index 4e96819ddf..cf85977aa7 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -182,8 +182,8 @@ const alertsItem: NavItem = { ], } -/** The observer's Alerts: what is wrong now and how reliable things are, without the setup pages. */ -const observerIncidentsItem: NavItem = { +/** The analyst's Alerts: what is wrong now and how reliable things are, without the setup pages. */ +const analystIncidentsItem: NavItem = { title: "Incidents", href: "/alerts", icon: "BellIcon", @@ -203,10 +203,10 @@ const observerIncidentsItem: NavItem = { } /** - * Who the sidebar is drawn for. An observer follows the product without operating it, so they get + * Who the sidebar is drawn for. An analyst follows the product without operating it, so they get * the pages that answer "is it working and how is it doing", not the ones for digging or setup. */ -export const NavAudience = Schema.Literals(["operator", "observer"]) +export const NavAudience = Schema.Literals(["operator", "analyst"]) export type NavAudience = typeof NavAudience.Type /** The org rollout flags that gate a nav row each. */ @@ -217,13 +217,13 @@ export type NavFlags = Readonly<{ statusPages: boolean }> -const observerNavGroups = (flags: NavFlags): ReadonlyArray => [ +const analystNavGroups = (flags: NavFlags): ReadonlyArray => [ { id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] }, { id: "reliability", label: "Reliability", items: [ - observerIncidentsItem, + analystIncidentsItem, { title: "Errors", href: "/errors", icon: "CircleWarningIcon" }, ...(flags.statusPages ? [{ title: "Status pages", href: "/status-pages", icon: "WindowChartLineIcon" } as const] @@ -304,10 +304,10 @@ const operatorNavGroups = (flags: NavFlags): ReadonlyArray => [ /** * `navGroups(flags, audience)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a - * row each, and an observer gets {@link observerNavGroups}. + * row each, and an analyst gets {@link analystNavGroups}. */ export const navGroups = (flags: NavFlags, audience: NavAudience = "operator"): ReadonlyArray => - audience === "observer" ? observerNavGroups(flags) : operatorNavGroups(flags) + audience === "analyst" ? analystNavGroups(flags) : operatorNavGroups(flags) /** Segment-aware prefix match: `/services` must not claim `/service-map`. */ export const isPathActive = (currentPath: string, href: string): boolean => diff --git a/apps/web/src/shell/palette/entries.ts b/apps/web/src/shell/palette/entries.ts index 0f074f4f3b..1ba68a5cf5 100644 --- a/apps/web/src/shell/palette/entries.ts +++ b/apps/web/src/shell/palette/entries.ts @@ -88,14 +88,14 @@ const navRowsOf = (groups: ReadonlyArray) => ]), ) -/** What an observer's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */ -const OBSERVER_TITLES: ReadonlyMap = new Map([ - ...Arr.map(navRowsOf(navGroups(EVERY_FLAG, "observer")), (row) => [row.href, row.title] as const), +/** What an analyst's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */ +const ANALYST_TITLES: ReadonlyMap = new Map([ + ...Arr.map(navRowsOf(navGroups(EVERY_FLAG, "analyst")), (row) => [row.href, row.title] as const), ["/account", "Account"] as const, ]) /** - * The entries as the viewer's palette lists them. An observer gets their own pages under their + * The entries as the viewer's palette lists them. An analyst gets their own pages under their * sidebar's names, dashboards and the app actions; the ids stay, so a pick resolves either way. */ export const entriesFor = ( @@ -107,7 +107,7 @@ export const entriesFor = ( : Arr.flatMap(entries, (entry): ReadonlyArray => { if (entry.group === "Actions" || entry.group === "Dashboards") return [entry] if (entry.group !== "Navigation" || entry.target._tag !== "Href") return [] - const title = OBSERVER_TITLES.get(entry.target.href) + const title = ANALYST_TITLES.get(entry.target.href) return title === undefined ? [] : [{ ...entry, title, keywords: `${entry.keywords} ${title}` }] diff --git a/apps/web/src/shell/palette/palette.test.ts b/apps/web/src/shell/palette/palette.test.ts index afa5a94390..2830cc59ed 100644 --- a/apps/web/src/shell/palette/palette.test.ts +++ b/apps/web/src/shell/palette/palette.test.ts @@ -151,11 +151,11 @@ describe("command palette", () => { expect(titles).toEqual(expect.arrayContaining(["Connect Slack", "Connect Discord"])) }) - test("an observer finds their own pages and dashboards, not setup or the explorers", () => { + test("an analyst finds their own pages and dashboards, not setup or the explorers", () => { const model = loadedWith(["storefront"]) const shown = (query: string) => Arr.map( - shownEntries(typed(model, query).model, context, ["/releases", "/cost"], "observer").entries, + shownEntries(typed(model, query).model, context, ["/releases", "/cost"], "analyst").entries, (entry) => entry.title, ) expect(shown("analytics")).toContain("Web analytics") @@ -175,11 +175,11 @@ describe("command palette", () => { expect(operator).toContain("Traces") }) - test("Enter opens the page an observer sees highlighted, not the full list's", () => { + test("Enter opens the page an analyst sees highlighted, not the full list's", () => { const model = typed(loadedWith([]), "uptime").model const result = update( model, - Message.PressedEnter({ visibleFlagged: [], audience: "observer" }), + Message.PressedEnter({ visibleFlagged: [], audience: "analyst" }), context, ) expect(result.outMessage).toEqual(OutMessage.Navigated({ href: "/alerts/uptime" })) diff --git a/apps/web/src/shell/view-sidebar.ts b/apps/web/src/shell/view-sidebar.ts index 6e4392524f..1ec2c911d0 100644 --- a/apps/web/src/shell/view-sidebar.ts +++ b/apps/web/src/shell/view-sidebar.ts @@ -405,8 +405,8 @@ export const shellSidebarFooter = ( sidebarMenu(h, undefined, [ // A row of the footer's list, so it keeps the list's 4px rhythm. ...onCallPill(h, shellOnCallStatus(model), tip, !model.isSidebarOpen && !model.isMobile), - // An observer sets nothing up: their account is in the user menu below. - ...(shellAudience(clerk) === "observer" + // An analyst sets nothing up: their account is in the user menu below. + ...(shellAudience(clerk) === "analyst" ? [] : [ footerNavRow( diff --git a/apps/web/src/shell/view.ts b/apps/web/src/shell/view.ts index c6fac636cd..db0784c776 100644 --- a/apps/web/src/shell/view.ts +++ b/apps/web/src/shell/view.ts @@ -68,7 +68,7 @@ const railTipTitles = (model: Model, clerk: ShellClerk | null): ReadonlyArray Arr.map(group.items, (item) => item.title)), ...(isPillShown(onCall) ? [onCallDetail(onCall)] : []), - ...(shellAudience(clerk) === "observer" ? [] : ["Sources", "Settings"]), + ...(shellAudience(clerk) === "analyst" ? [] : ["Sources", "Settings"]), ] } @@ -196,7 +196,7 @@ export const frameView = Submodel.defineView((model inputs.topbarActions(), railTriggers, !hidesTopbarConnect(inputs.currentPath) && - shellAudience(inputs.clerk ?? null) !== "observer", + shellAudience(inputs.clerk ?? null) !== "analyst", inputs.sourceLink ?? null, ), ...(inputs.clerk === undefined || inputs.clerk === null diff --git a/packages/backend/src/services/auth/ApiAuthorizationLive.ts b/packages/backend/src/services/auth/ApiAuthorizationLive.ts index 95ba60f791..11c7b604a7 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationLive.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationLive.ts @@ -11,7 +11,7 @@ import { withAuditedRead } from "@maple/backend/services/audit/audit-access" import { Env } from "@maple/backend/platform/Env" import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" -import { refuseObserverWrites } from "./observer-gate" +import { refuseAnalystWrites } from "./analyst-gate" export const ApiAuthorizationLive = Layer.effect( CurrentTenant.Authorization, @@ -75,7 +75,7 @@ export const ApiAuthorizationLive = Layer.effect( roles: resolved.roles ?? API_KEY_DEFAULT_ROLES, authMode: "self_hosted", }) - yield* refuseObserverWrites(tenant.roles, options).pipe( + yield* refuseAnalystWrites(tenant.roles, options).pipe( Effect.tapError((error) => recordDenied(error.message)), ) return yield* httpEffect.pipe( @@ -103,7 +103,7 @@ export const ApiAuthorizationLive = Layer.effect( yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) // API keys are minted per instance; only a session can name an org from elsewhere. yield* regions.ensureServedHere(tenant.orgId) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), diff --git a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts index 6bd974d9d3..aa2c1e8b79 100644 --- a/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts +++ b/packages/backend/src/services/auth/ApiAuthorizationV2Live.ts @@ -33,7 +33,7 @@ import { deviceRateLimitKey, } from "./ApiV2RateLimiter" import { API_KEY_DEFAULT_ROLES, getBearerToken } from "./bearer-token" -import { refuseObserverWritesV2 } from "./observer-gate" +import { refuseAnalystWritesV2 } from "./analyst-gate" /** The access token of an RFC 9449 `Authorization: DPoP ` request. */ const getDpopToken = (headers: Record): string | undefined => { @@ -181,7 +181,7 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", scopes: resolved.scopes, }) - yield* refuseObserverWritesV2(tenant.roles, options, route.path) + yield* refuseAnalystWritesV2(tenant.roles, options, route.path) const label = "Device credential" return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, tenant), @@ -302,7 +302,7 @@ export const ApiAuthorizationV2Live = Layer.effect( authMode: "self_hosted", ...(resolved.scopes !== null ? { scopes: resolved.scopes } : undefined), }) - yield* refuseObserverWritesV2(tenant.roles, options, route.path).pipe( + yield* refuseAnalystWritesV2(tenant.roles, options, route.path).pipe( Effect.tapError((error) => recordDenied(error.message)), ) return yield* httpEffect.pipe( @@ -347,7 +347,7 @@ export const ApiAuthorizationV2Live = Layer.effect( .pipe(Effect.mapError((error) => V2OrganizationAccessDenied.make(error.message))) } const { route } = yield* HttpRouter.RouteContext - yield* refuseObserverWritesV2(tenant.roles, options, route.path) + yield* refuseAnalystWritesV2(tenant.roles, options, route.path) return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), Effect.provideService(CurrentAuditActor, { type: "user", source: "dashboard" }), diff --git a/packages/backend/src/services/auth/SessionAuthorizationLive.ts b/packages/backend/src/services/auth/SessionAuthorizationLive.ts index 7760b8ab5a..3aa7548927 100644 --- a/packages/backend/src/services/auth/SessionAuthorizationLive.ts +++ b/packages/backend/src/services/auth/SessionAuthorizationLive.ts @@ -10,7 +10,7 @@ import { withAuditedRead } from "@maple/backend/services/audit/audit-access" import { Env } from "@maple/backend/platform/Env" import { OrganizationRegionService } from "@maple/backend/services/org/OrganizationRegionService" import { getBearerToken } from "./bearer-token" -import { refuseObserverWrites } from "./observer-gate" +import { refuseAnalystWrites } from "./analyst-gate" /** * Authorization for the internal API: Clerk sessions only. @@ -48,7 +48,7 @@ export const SessionAuthorizationLive = Layer.effect( const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) yield* regions.ensureServedHere(tenant.orgId) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), @@ -87,7 +87,7 @@ export const RegionlessSessionAuthorizationLive = Layer.effect( } const tenant = yield* resolveTenant(request.headers) yield* annotateAuthSpan("session", { orgId: tenant.orgId, userId: tenant.userId }) - yield* refuseObserverWrites(tenant.roles, options) + yield* refuseAnalystWrites(tenant.roles, options) const actor = { type: "user", source: "dashboard" } as const return yield* httpEffect.pipe( Effect.provideService(CurrentTenant.Context, new CurrentTenant.TenantSchema(tenant)), diff --git a/packages/backend/src/services/auth/observer-gate.test.ts b/packages/backend/src/services/auth/analyst-gate.test.ts similarity index 52% rename from packages/backend/src/services/auth/observer-gate.test.ts rename to packages/backend/src/services/auth/analyst-gate.test.ts index b59b4a992c..abc15270e9 100644 --- a/packages/backend/src/services/auth/observer-gate.test.ts +++ b/packages/backend/src/services/auth/analyst-gate.test.ts @@ -2,9 +2,9 @@ import { assert, describe, expect, it } from "@effect/vitest" import { CurrentTenant, MapleInternalApi } from "@maple/domain/http" import { MapleApiV2 } from "@maple/domain/http/v2" import { Effect, Exit } from "effect" -import { refuseObserverWrites, refuseObserverWritesV2 } from "./observer-gate" +import { refuseAnalystWrites, refuseAnalystWritesV2 } from "./analyst-gate" -const OBSERVER = ["org:observer"] +const ANALYST = ["org:analyst"] const MEMBER = ["org:member"] const errors = MapleInternalApi.groups.errors @@ -16,10 +16,10 @@ const dashboards = MapleApiV2.groups.dashboards /** Whether the gate let the call through. */ const allowed = (gate: Effect.Effect) => Effect.map(Effect.exit(gate), Exit.isSuccess) -describe("refuseObserverWrites", () => { - it.effect("refuses an observer an issue transition and says how to get it", () => +describe("refuseAnalystWrites", () => { + it.effect("refuses an analyst an issue transition and says how to get it", () => Effect.gen(function* () { - const error = yield* Effect.flip(refuseObserverWrites(OBSERVER, transition)) + const error = yield* Effect.flip(refuseAnalystWrites(ANALYST, transition)) assert.instanceOf(error, CurrentTenant.ReadOnlyRoleError) expect(error.message).toContain("Ask an org admin for the Member role") }), @@ -27,34 +27,34 @@ describe("refuseObserverWrites", () => { it.effect("lets a member, and Maple's own agents with no roles, make the same change", () => Effect.gen(function* () { - expect(yield* allowed(refuseObserverWrites(MEMBER, transition))).toBe(true) - expect(yield* allowed(refuseObserverWrites([], transition))).toBe(true) + expect(yield* allowed(refuseAnalystWrites(MEMBER, transition))).toBe(true) + expect(yield* allowed(refuseAnalystWrites([], transition))).toBe(true) }), ) - it.effect("lets an observer run a dashboard query", () => + it.effect("lets an analyst run a dashboard query", () => Effect.gen(function* () { const query = { group: queries, endpoint: queries.endpoints.executeBatch } - expect(yield* allowed(refuseObserverWrites(OBSERVER, query))).toBe(true) + expect(yield* allowed(refuseAnalystWrites(ANALYST, query))).toBe(true) }), ) }) -describe("refuseObserverWritesV2", () => { +describe("refuseAnalystWritesV2", () => { const create = { group: dashboards, endpoint: dashboards.endpoints.create } - it.effect("lets an observer search traces, which the scope registry names as a read", () => + it.effect("lets an analyst search traces, which the scope registry names as a read", () => Effect.gen(function* () { const search = { group: traces, endpoint: traces.endpoints.search } - expect(yield* allowed(refuseObserverWritesV2(OBSERVER, search, "/v2/traces/search"))).toBe(true) + expect(yield* allowed(refuseAnalystWritesV2(ANALYST, search, "/v2/traces/search"))).toBe(true) }), ) - it.effect("refuses an observer a new dashboard and lets a member create one", () => + it.effect("refuses an analyst a new dashboard and lets a member create one", () => Effect.gen(function* () { - const error = yield* Effect.flip(refuseObserverWritesV2(OBSERVER, create, "/v2/dashboards")) - expect(error.message).toContain("Observers can read Maple") - expect(yield* allowed(refuseObserverWritesV2(MEMBER, create, "/v2/dashboards"))).toBe(true) + const error = yield* Effect.flip(refuseAnalystWritesV2(ANALYST, create, "/v2/dashboards")) + expect(error.message).toContain("Analysts can read Maple") + expect(yield* allowed(refuseAnalystWritesV2(MEMBER, create, "/v2/dashboards"))).toBe(true) }), ) }) diff --git a/packages/backend/src/services/auth/observer-gate.ts b/packages/backend/src/services/auth/analyst-gate.ts similarity index 71% rename from packages/backend/src/services/auth/observer-gate.ts rename to packages/backend/src/services/auth/analyst-gate.ts index d3bc7b0025..a3afc86ef6 100644 --- a/packages/backend/src/services/auth/observer-gate.ts +++ b/packages/backend/src/services/auth/analyst-gate.ts @@ -1,34 +1,34 @@ -import { CurrentTenant, isOpenToObservers } from "@maple/domain/http" +import { CurrentTenant, isOpenToAnalysts } from "@maple/domain/http" import { requiredScopeForRoute, V2InsufficientPermissions } from "@maple/domain/http/v2" import * as Policy from "@maple/domain/policy" import { Effect } from "effect" -type Called = Parameters[0] +type Called = Parameters[0] /** * Refuses a read-only role every endpoint that changes the organization. It runs in the auth * layers, so an endpoint added later is refused until it, or its whole group, is marked - * `ObserverAccess`. + * `AnalystAccess`. */ -export const refuseObserverWrites = ( +export const refuseAnalystWrites = ( roles: ReadonlyArray, called: Called, ): Effect.Effect => - Policy.isReadOnly(roles) && !isOpenToObservers(called) + Policy.isReadOnly(roles) && !isOpenToAnalysts(called) ? Effect.fail(new CurrentTenant.ReadOnlyRoleError({ message: Policy.readOnlyMessage })) : Effect.void /** - * {@link refuseObserverWrites} for `/v2`, where the scope registry already names the POSTs that + * {@link refuseAnalystWrites} for `/v2`, where the scope registry already names the POSTs that * only read. `routePath` is the matched route template, as that registry expects. */ -export const refuseObserverWritesV2 = ( +export const refuseAnalystWritesV2 = ( roles: ReadonlyArray, called: Called, routePath: string, ): Effect.Effect> => Policy.isReadOnly(roles) && - !isOpenToObservers(called) && + !isOpenToAnalysts(called) && requiredScopeForRoute(called.endpoint.method, routePath)?.access !== "read" ? Effect.fail(V2InsufficientPermissions.make(Policy.readOnlyMessage)) : Effect.void diff --git a/packages/domain/src/http/ai-models.ts b/packages/domain/src/http/ai-models.ts index d15ad4d15e..555f03a866 100644 --- a/packages/domain/src/http/ai-models.ts +++ b/packages/domain/src/http/ai-models.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { SessionAuthorization } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // AI model detection: a model string as an instrumentation reported it // (`gen_ai.request.model`, an OpenRouter id, a Bedrock id) → the model's @@ -75,5 +75,5 @@ export class AiModelsInternalApiGroup extends HttpApiGroup.make("aiModelsInterna ) .prefix("/internal/ai-models") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index c20c2ed8da..92357cff53 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -6,7 +6,7 @@ import { BucketSeconds } from "./query-engine" import { SessionAuthorization } from "./current-tenant" import { HttpTaggedError } from "./error-policy" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Backed by the `maple_ai.*` span attributes the ingest gateway stamps at // decode time; a session is resolved at trace granularity by @@ -1101,5 +1101,5 @@ export class AiSessionsInternalApiGroup extends HttpApiGroup.make("aiSessionsInt ) .prefix("/internal/ai-sessions") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/observer-access.test.ts b/packages/domain/src/http/analyst-access.test.ts similarity index 75% rename from packages/domain/src/http/observer-access.test.ts rename to packages/domain/src/http/analyst-access.test.ts index fc4a9907c2..cb31658008 100644 --- a/packages/domain/src/http/observer-access.test.ts +++ b/packages/domain/src/http/analyst-access.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "@effect/vitest" import { Array as Arr } from "effect" import { MapleApi } from "./api" import { MapleInternalApi } from "./internal-api" -import { isOpenToObservers } from "./observer-access" +import { isOpenToAnalysts } from "./analyst-access" import { MapleApiV2 } from "./v2/api" -type Called = Parameters[0] +type Called = Parameters[0] type Group = Called["group"] & { readonly endpoints: { readonly [name: string]: Called["endpoint"] & { readonly identifier: string } } } @@ -16,18 +16,18 @@ const endpointsOf = (group: Group) => Object.values(group.endpoints) const open = (group: Group, name: string): boolean => Arr.some( endpointsOf(group), - (endpoint) => endpoint.identifier === name && isOpenToObservers({ group, endpoint }), + (endpoint) => endpoint.identifier === name && isOpenToAnalysts({ group, endpoint }), ) const internal = MapleInternalApi.groups const v2 = MapleApiV2.groups -describe("isOpenToObservers", () => { - it("lets an observer run every dashboard query, which are all POSTs", () => { +describe("isOpenToAnalysts", () => { + it("lets an analyst run every dashboard query, which are all POSTs", () => { const queries = endpointsOf(internal.queryEngine) expect(queries.length).toBeGreaterThan(0) expect( - Arr.every(queries, (endpoint) => isOpenToObservers({ group: internal.queryEngine, endpoint })), + Arr.every(queries, (endpoint) => isOpenToAnalysts({ group: internal.queryEngine, endpoint })), ).toBe(true) }) @@ -41,7 +41,7 @@ describe("isOpenToObservers", () => { it("keeps every GET of a group that also changes things", () => { const reads = Arr.filter(endpointsOf(internal.errors), (endpoint) => endpoint.method === "GET") expect(reads.length).toBeGreaterThan(0) - expect(Arr.every(reads, (endpoint) => isOpenToObservers({ group: internal.errors, endpoint }))).toBe( + expect(Arr.every(reads, (endpoint) => isOpenToAnalysts({ group: internal.errors, endpoint }))).toBe( true, ) }) @@ -51,7 +51,7 @@ describe("isOpenToObservers", () => { expect(open(internal.digest, "preview")).toBe(true) }) - it("lets an observer manage their own digest, devices and connections", () => { + it("lets an analyst manage their own digest, devices and connections", () => { expect(open(internal.digest, "upsertSubscription")).toBe(true) expect(open(internal.digest, "deleteSubscription")).toBe(true) expect(open(v2.mobileDevices, "register")).toBe(true) diff --git a/packages/domain/src/http/observer-access.ts b/packages/domain/src/http/analyst-access.ts similarity index 57% rename from packages/domain/src/http/observer-access.ts rename to packages/domain/src/http/analyst-access.ts index ebcf0bde89..8b7a4159bd 100644 --- a/packages/domain/src/http/observer-access.ts +++ b/packages/domain/src/http/analyst-access.ts @@ -1,12 +1,12 @@ import { Context } from "effect" /** - * Why an observer, who only reads, may still call an endpoint that is not a GET: `read` only looks + * Why an analyst, who only reads, may still call an endpoint that is not a GET: `read` only looks * (a query with a body), `personal` changes the caller's own state. Unset means the endpoint changes - * the organization, and the auth middlewares refuse it to an observer. + * the organization, and the auth middlewares refuse it to an analyst. */ -export class ObserverAccess extends Context.Reference<"read" | "personal" | undefined>( - "@maple/http/ObserverAccess", +export class AnalystAccess extends Context.Reference<"read" | "personal" | undefined>( + "@maple/http/AnalystAccess", { defaultValue: () => undefined }, ) {} @@ -14,8 +14,8 @@ interface Annotated { readonly annotations: Context.Context } -/** Whether a read-only role may call the endpoint: a GET, or one marked {@link ObserverAccess}. */ -export const isOpenToObservers = (options: { +/** Whether a read-only role may call the endpoint: a GET, or one marked {@link AnalystAccess}. */ +export const isOpenToAnalysts = (options: { readonly endpoint: Annotated & { readonly method: string } readonly group: Annotated }): boolean => { @@ -23,7 +23,7 @@ export const isOpenToObservers = (options: { if (method === "GET" || method === "HEAD") return true // A group-level `.annotate` lands on the group only, so both are read; the endpoint wins. const access = - Context.get(options.endpoint.annotations, ObserverAccess) ?? - Context.get(options.group.annotations, ObserverAccess) + Context.get(options.endpoint.annotations, AnalystAccess) ?? + Context.get(options.group.annotations, AnalystAccess) return access !== undefined } diff --git a/packages/domain/src/http/auth.ts b/packages/domain/src/http/auth.ts index 72e2981dee..f2c40962ce 100644 --- a/packages/domain/src/http/auth.ts +++ b/packages/domain/src/http/auth.ts @@ -2,7 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { Schema } from "effect" import { OrgId, UserId } from "@maple/primitives" import { Authorization, SessionAuthorization, TenantSchema, UnauthorizedError } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export class CliDeviceStartRequest extends Schema.Class("CliDeviceStartRequest")({ deviceName: Schema.String, @@ -265,13 +265,13 @@ export class AuthApiGroup extends HttpApiGroup.make("auth") HttpApiEndpoint.post("sessionRefresh", "/session/refresh", { success: SelfHostedLoginResponse, error: [SelfHostedAuthDisabledError, UnauthorizedError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.delete("cliSessionRevoke", "/cli/session", { success: CliDeviceActionResponse, error: [CliDeviceConflictError, CliDevicePersistenceError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .prefix("/api/auth") .middleware(Authorization) {} @@ -298,7 +298,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("cliDeviceDeny", "/cli/device/:userCode/deny", { @@ -310,7 +310,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") CliDeviceConflictError, CliDevicePersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.get("mcpOAuthAuthorizationInspect", "/mcp/oauth/authorization/:requestId", { @@ -333,7 +333,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("mcpOAuthAuthorizationDeny", "/mcp/oauth/authorization/:requestId/deny", { @@ -345,7 +345,7 @@ export class AuthSessionApiGroup extends HttpApiGroup.make("authSession") McpOAuthAuthorizationConflictError, McpOAuthPersistenceError, ], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .prefix("/api/auth") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/billing.ts b/packages/domain/src/http/billing.ts index 23b430de22..ae110490ae 100644 --- a/packages/domain/src/http/billing.ts +++ b/packages/domain/src/http/billing.ts @@ -15,7 +15,7 @@ import { LinkBillingOrgRequest, } from "./billing-account" import { WarehouseQueryError } from "./warehouse-errors" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Contract for raw Autumn proxy responses. Schemas model only consumed fields // and tolerate additive upstream fields. @@ -726,7 +726,7 @@ export class BillingApiGroup extends HttpApiGroup.make("billing") payload: PreviewAttachRequest, success: PreviewAttachResult, error: [BillingManagedByBillingOrgError, ...billingRequestErrors], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("openCustomerPortal", "/portal", { diff --git a/packages/domain/src/http/current-tenant.ts b/packages/domain/src/http/current-tenant.ts index 1c4706cb48..c76c0bdfb3 100644 --- a/packages/domain/src/http/current-tenant.ts +++ b/packages/domain/src/http/current-tenant.ts @@ -91,7 +91,7 @@ export class OrganizationWrongRegionError extends HttpTaggedError()( "@maple/http/errors/ReadOnlyRoleError", { message: Schema.String }, diff --git a/packages/domain/src/http/digest.ts b/packages/domain/src/http/digest.ts index ac8ec68c6e..c0e38e6bc2 100644 --- a/packages/domain/src/http/digest.ts +++ b/packages/domain/src/http/digest.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/http-api" import { DateTime, Option, Schema } from "effect" import { SessionAuthorization } from "./current-tenant" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export const DigestSubscriptionId = Schema.String.check(Schema.isUUID()).pipe( Schema.brand("@maple/DigestSubscriptionId"), @@ -164,25 +164,25 @@ export class DigestApiGroup extends HttpApiGroup.make("digest") payload: UpsertDigestSubscriptionRequest, success: DigestSubscriptionResponse, error: [DigestPersistenceError, DigestNotFoundError, DigestEmailUnavailableError], - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.delete("deleteSubscription", "/", { success: Schema.Void, error: DigestPersistenceError, - }).annotate(ObserverAccess, "personal"), + }).annotate(AnalystAccess, "personal"), ) .add( HttpApiEndpoint.post("preview", "/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("previewWebAnalytics", "/web-analytics/preview", { success: DigestPreviewResponse, error: [DigestPersistenceError, DigestNotConfiguredError, DigestRenderError], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .prefix("/internal/digest") .middleware(SessionAuthorization) {} diff --git a/packages/domain/src/http/errors.ts b/packages/domain/src/http/errors.ts index 89750ffcba..b46fae0a47 100644 --- a/packages/domain/src/http/errors.ts +++ b/packages/domain/src/http/errors.ts @@ -43,7 +43,7 @@ import { IssueSeveritySource, WorkflowState, } from "../error-issues" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export { ActorType, ErrorIncidentReason, @@ -920,7 +920,7 @@ export class ErrorsApiGroup extends HttpApiGroup.make("errors") payload: EscalationPolicyEvaluationRequest, success: EscalationPolicyEvaluationDocument, error: ErrorPersistenceError, - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.get("listIssueEscalations", "/issues/:issueId/escalations", { diff --git a/packages/domain/src/http/index.ts b/packages/domain/src/http/index.ts index e079d9a9d5..491c80a7a0 100644 --- a/packages/domain/src/http/index.ts +++ b/packages/domain/src/http/index.ts @@ -21,7 +21,7 @@ export * from "./anomalies" export * from "./api-keys" export * from "./device-credentials" export * from "./audit-log" -export * from "./observer-access" +export * from "./analyst-access" export * from "./alerts" export * from "./escalation-chains" export * from "./oncall-schedules" diff --git a/packages/domain/src/http/integrations.ts b/packages/domain/src/http/integrations.ts index 9902e298a2..a0f0d24bd9 100644 --- a/packages/domain/src/http/integrations.ts +++ b/packages/domain/src/http/integrations.ts @@ -35,7 +35,7 @@ import { GcpCloudSqlSettings } from "../gcp" export { GcpCloudSqlSettings } from "../gcp" import { SupabaseAdvisorLint, SupabaseLogDrainSummary, SupabaseTopQuery } from "../supabase" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" export { SupabaseAdvisorKind, SupabaseAdvisorLevel, @@ -1736,7 +1736,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") HttpApiEndpoint.post("infraSurfaces", "/infra-surfaces", { payload: InfraPresenceRequest, success: InfraSurfacesResponse, - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.get("cloudflareStatus", "/cloudflare/status", { @@ -1761,7 +1761,7 @@ export class IntegrationsApiGroup extends HttpApiGroup.make("integrations") IntegrationsUpstreamError, IntegrationsPersistenceError, ], - }).annotate(ObserverAccess, "read"), + }).annotate(AnalystAccess, "read"), ) .add( HttpApiEndpoint.post("cloudflareStart", "/cloudflare/start", { diff --git a/packages/domain/src/http/mcp.ts b/packages/domain/src/http/mcp.ts index 3975ecb914..a278f9c6ef 100644 --- a/packages/domain/src/http/mcp.ts +++ b/packages/domain/src/http/mcp.ts @@ -5,7 +5,7 @@ import { TinybirdDateTime } from "../query-engine" import { BucketSeconds } from "./query-engine" import { SessionAuthorization } from "./current-tenant" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // MCP hub endpoint schemas (`/mcp-servers`, a server's overview, its tool page and its sessions). // @@ -341,5 +341,5 @@ export class McpInternalApiGroup extends HttpApiGroup.make("mcpInternal") ) .prefix("/internal/mcp-servers") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") {} + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") {} diff --git a/packages/domain/src/http/query-engine.ts b/packages/domain/src/http/query-engine.ts index d60d43dc93..afbd27dc42 100644 --- a/packages/domain/src/http/query-engine.ts +++ b/packages/domain/src/http/query-engine.ts @@ -44,7 +44,7 @@ import { PathsInclude, } from "@maple/query-model" import { WireRow } from "../wire" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" /** * A timeseries bucket width. @@ -5069,7 +5069,7 @@ export class QueryEngineApiGroup extends HttpApiGroup.make("queryEngine") ) .prefix("/internal/query-engine") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") // Every endpoint here reads telemetry for the dashboard. .annotate(AuditedRead, "telemetry.read") {} diff --git a/packages/domain/src/http/session-replay.ts b/packages/domain/src/http/session-replay.ts index bebe700627..623f91f91a 100644 --- a/packages/domain/src/http/session-replay.ts +++ b/packages/domain/src/http/session-replay.ts @@ -6,7 +6,7 @@ import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" import { QueryEngineExecutionError, QueryEngineTimeoutError } from "./query-engine" import { warehouseHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // Session replay schemas shared by the v2 contract and the internal replay group. @@ -170,6 +170,6 @@ export class SessionReplaysInternalApiGroup extends HttpApiGroup.make("sessionRe ) .prefix("/internal/session-replays") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") .annotate(AuditedRead, "session_replay.read") {} diff --git a/packages/domain/src/http/users.ts b/packages/domain/src/http/users.ts index 1deb76bd37..05b76487b8 100644 --- a/packages/domain/src/http/users.ts +++ b/packages/domain/src/http/users.ts @@ -7,7 +7,7 @@ import { TinybirdDateTime } from "../query-engine" import { AuditedRead } from "./audit-log" import { SessionAuthorization } from "./current-tenant" import { warehouseReadHttpErrors } from "./warehouse" -import { ObserverAccess } from "./observer-access" +import { AnalystAccess } from "./analyst-access" // End users endpoint schemas (`/users`): the people Maple's customers identified with `identify()` // or a `user.id` span attribute, read by `ch/queries/users.ts` and `user-activity.ts`. Times are @@ -459,6 +459,6 @@ export class UsersInternalApiGroup extends HttpApiGroup.make("usersInternal") ) .prefix("/internal/users") .middleware(SessionAuthorization) - // Every endpoint here only reads, so an observer may call it. - .annotate(ObserverAccess, "read") + // Every endpoint here only reads, so an analyst may call it. + .annotate(AnalystAccess, "read") .annotate(AuditedRead, "telemetry.read") {} diff --git a/packages/domain/src/http/v2/agent-feedback.ts b/packages/domain/src/http/v2/agent-feedback.ts index 78ce306610..a894ae206c 100644 --- a/packages/domain/src/http/v2/agent-feedback.ts +++ b/packages/domain/src/http/v2/agent-feedback.ts @@ -12,7 +12,7 @@ import { ListOf, ListQuery, Timestamp, wireExample } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { AgentFeedbackPublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" const isNotBlank = Schema.makeFilter((value: string) => Str.trim(value).length > 0, { description: "Must contain a non-whitespace character", @@ -136,7 +136,7 @@ export class V2AgentFeedbackApiGroup extends HttpApiGroup.make("agentFeedback") success: V2AgentFeedback, error: [persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "createAgentFeedback", diff --git a/packages/domain/src/http/v2/device-credentials.ts b/packages/domain/src/http/v2/device-credentials.ts index 9d50979fc4..a9000a67ed 100644 --- a/packages/domain/src/http/v2/device-credentials.ts +++ b/packages/domain/src/http/v2/device-credentials.ts @@ -18,7 +18,7 @@ import { V2RateLimited } from "./errors" import { publicError } from "./public-error" import { DeviceCredentialPublicId } from "./resource-ids" import { InstallationId } from "./widget-credentials" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" /** * Device-bound credentials (DPoP, RFC 9449) for the mobile Home Screen widgets. @@ -190,7 +190,7 @@ export class V2DeviceCredentialsApiGroup extends HttpApiGroup.make("deviceCreden persistence, ], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerDeviceCredential", @@ -206,7 +206,7 @@ export class V2DeviceCredentialsApiGroup extends HttpApiGroup.make("deviceCreden success: V2DeviceCredentialDeleteResponse, error: [sessionRequired, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeDeviceCredential", @@ -253,7 +253,7 @@ export class V2DeviceTokensApiGroup extends HttpApiGroup.make("deviceTokens") publicError(DeviceCredentialPersistenceError), ], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "issueDeviceToken", diff --git a/packages/domain/src/http/v2/integrations-chat.ts b/packages/domain/src/http/v2/integrations-chat.ts index 3307d82d2c..704970eef5 100644 --- a/packages/domain/src/http/v2/integrations-chat.ts +++ b/packages/domain/src/http/v2/integrations-chat.ts @@ -14,7 +14,7 @@ import { wireExample, Timestamp } from "./envelopes" import { V2CallbackHostUnavailable, V2InsufficientPermissions } from "./errors" import { publicErrors } from "./public-error" import { PublicId, PublicIdPrefixes } from "./public-id" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" // The chat-platform surface: one contract for every chat platform Maple can // connect to. The platform is a value (`connector`), never a path segment or a @@ -391,7 +391,7 @@ export class V2ChatIntegrationsApiGroup extends HttpApiGroup.make("chatIntegrati success: V2ChatIdentityDeleteResponse, error: [V2InsufficientPermissions.schema, chatNotFound, chatPersistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "deleteChatIdentity", diff --git a/packages/domain/src/http/v2/mcp-connections.ts b/packages/domain/src/http/v2/mcp-connections.ts index 6da5af4814..2b2a67af52 100644 --- a/packages/domain/src/http/v2/mcp-connections.ts +++ b/packages/domain/src/http/v2/mcp-connections.ts @@ -7,7 +7,7 @@ import { wireExample, ListOf, ListQuery, Timestamp } from "./envelopes" import { V2ParameterInvalid } from "./errors" import { publicError } from "./public-error" import { McpConnectionPublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" const mcpConnectionExample = { id: "mcpc_p12S8T8agEHUkYc825nMz6", @@ -82,7 +82,7 @@ export class V2McpConnectionsApiGroup extends HttpApiGroup.make("mcpConnections" success: V2McpConnection, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeMcpConnection", diff --git a/packages/domain/src/http/v2/mobile-devices.ts b/packages/domain/src/http/v2/mobile-devices.ts index 46fa78a8f7..ba55eb443f 100644 --- a/packages/domain/src/http/v2/mobile-devices.ts +++ b/packages/domain/src/http/v2/mobile-devices.ts @@ -11,7 +11,7 @@ import { AuthorizationV2 } from "./auth" import { wireExample, ListOf, Timestamp } from "./envelopes" import { publicError } from "./public-error" import { AlertIncidentPublicId, MobileDevicePublicId } from "./resource-ids" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" // Code points, not UTF-16 units: identical for these ASCII values, and the only length the // JSON Schema `minLength` can state exactly (Effect halves a UTF-16 bound when it exports one). @@ -196,7 +196,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2MobileDevice, error: [persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerMobileDevice", @@ -212,7 +212,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2MobileDeviceDeleteResponse, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "unregisterMobileDevice", @@ -229,7 +229,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2LiveActivity, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "registerLiveActivity", @@ -245,7 +245,7 @@ export class V2MobileDevicesApiGroup extends HttpApiGroup.make("mobileDevices") success: V2LiveActivityDeleteResponse, error: [notFound, persistence], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "endLiveActivity", diff --git a/packages/domain/src/http/v2/widget-credentials.ts b/packages/domain/src/http/v2/widget-credentials.ts index dd1a91d729..cd48e0a652 100644 --- a/packages/domain/src/http/v2/widget-credentials.ts +++ b/packages/domain/src/http/v2/widget-credentials.ts @@ -5,7 +5,7 @@ import { OrgId } from "@maple/primitives" import { AuthorizationV2 } from "./auth" import { wireExample, Timestamp } from "./envelopes" import { publicError } from "./public-error" -import { ObserverAccess } from "../observer-access" +import { AnalystAccess } from "../analyst-access" /** * The credential a device's Home Screen widgets fetch with. @@ -100,7 +100,7 @@ export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCreden success: V2WidgetCredential, error: [publicError(ApiKeyPersistenceError)], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "mintWidgetCredential", @@ -116,7 +116,7 @@ export class V2WidgetCredentialsApiGroup extends HttpApiGroup.make("widgetCreden success: V2WidgetCredentialDeleteResponse, error: [publicError(ApiKeyPersistenceError)], }) - .annotate(ObserverAccess, "personal") + .annotate(AnalystAccess, "personal") .annotateMerge( OpenApi.annotations({ identifier: "revokeWidgetCredential", diff --git a/packages/domain/src/policy/index.ts b/packages/domain/src/policy/index.ts index d31d176803..d80afc066f 100644 --- a/packages/domain/src/policy/index.ts +++ b/packages/domain/src/policy/index.ts @@ -51,12 +51,12 @@ export const check = ( ): Effect.Effect => can(roles, permission) ? Effect.void : Effect.fail(onDenied(denialFor(permission))) -/** Whether the roles may only read: an observer, with no higher role beside it. */ -export const isReadOnly = (roles: ReadonlyArray): boolean => highestRole(roles) === "observer" +/** Whether the roles may only read: an analyst, with no higher role beside it. */ +export const isReadOnly = (roles: ReadonlyArray): boolean => highestRole(roles) === "analyst" -/** What an observer is told when a change is refused, on every surface. */ +/** What an analyst is told when a change is refused, on every surface. */ export const readOnlyMessage = - "Observers can read Maple but not change it. Ask an org admin for the Member role to make changes." + "Analysts can read Maple but not change it. Ask an org admin for the Member role to make changes." /** Every permission the roles hold, for a page model to keep instead of role flags. */ export const grantsOf = (roles: ReadonlyArray): ReadonlyArray => diff --git a/packages/domain/src/policy/permissions.ts b/packages/domain/src/policy/permissions.ts index 42fc50c4bd..c9156e8058 100644 --- a/packages/domain/src/policy/permissions.ts +++ b/packages/domain/src/policy/permissions.ts @@ -11,8 +11,8 @@ export const permissions = { // The public ingest key ships in browser bundles, so every member may read it to set up an app. "ingest_keys:view_public": { min: "member", action: "view the public ingest key" }, - // Shared work on what the telemetry shows: members and above. Observers read it. The API refuses - // an observer every change in one place (`ObserverAccess`); these name them for the dashboard. + // Shared work on what the telemetry shows: members and above. Analysts read it. The API refuses + // an analyst every change in one place (`AnalystAccess`); these name them for the dashboard. "dashboards:edit": { min: "member", action: "create and edit dashboards" }, "dashboards:share": { min: "member", action: "share dashboards outside the organization" }, "error_issues:triage": { min: "member", action: "triage error issues" }, diff --git a/packages/domain/src/policy/policy.test.ts b/packages/domain/src/policy/policy.test.ts index 1e5631d6af..5a1bdce69b 100644 --- a/packages/domain/src/policy/policy.test.ts +++ b/packages/domain/src/policy/policy.test.ts @@ -32,8 +32,8 @@ describe("Policy.can", () => { } }) - it("holds nothing a member has for an observer or a Clerk role it does not know", () => { - for (const roles of [["org:observer"], ["org:billing"]]) { + it("holds nothing a member has for an analyst or a Clerk role it does not know", () => { + for (const roles of [["org:analyst"], ["org:billing"]]) { expect(Policy.can(roles, "ingest_keys:view_public")).toBe(false) expect(Policy.grantsOf(roles)).toEqual([]) } @@ -67,11 +67,11 @@ describe("Policy.check", () => { }), ) - it.effect("tells an observer which roles hold a member permission", () => + it.effect("tells an analyst which roles hold a member permission", () => Effect.gen(function* () { const error = yield* Effect.flip( Policy.check( - ["org:observer"], + ["org:analyst"], "ingest_keys:view_public", (denial) => new Forbidden({ message: denial.message }), ), @@ -113,7 +113,7 @@ describe("Policy.grantsOf", () => { describe("roleOf", () => { it("maps the Clerk keys the Members page assigns", () => { expect(Arr.map(Policy.assignableRoles, (entry) => Policy.roleOf(entry.clerkKey))).toEqual([ - "observer", + "analyst", "member", "engineer", "admin", @@ -131,17 +131,17 @@ describe("roleOf", () => { }) describe("highestRole", () => { - it("keeps an observer an observer instead of lifting them to member", () => { - expect(Policy.highestRole(["org:observer"])).toBe("observer") - expect(Policy.highestRole(["org:observer", "org:billing"])).toBe("observer") + it("keeps an analyst an analyst instead of lifting them to member", () => { + expect(Policy.highestRole(["org:analyst"])).toBe("analyst") + expect(Policy.highestRole(["org:analyst", "org:billing"])).toBe("analyst") }) - it("takes the highest when an observer role sits beside a higher one", () => { - expect(Policy.highestRole(["org:observer", "org:engineer"])).toBe("engineer") + it("takes the highest when an analyst role sits beside a higher one", () => { + expect(Policy.highestRole(["org:analyst", "org:engineer"])).toBe("engineer") }) - it("retires a member-pinned credential when its owner becomes an observer", () => { + it("retires a member-pinned credential when its owner becomes an analyst", () => { const pinned = Policy.highestRole(["org:member"]) - expect(Policy.outranks(pinned, Policy.highestRole(["org:observer"]))).toBe(true) + expect(Policy.outranks(pinned, Policy.highestRole(["org:analyst"]))).toBe(true) }) }) diff --git a/packages/domain/src/policy/roles.ts b/packages/domain/src/policy/roles.ts index 8ba1dcfefc..26988c35ef 100644 --- a/packages/domain/src/policy/roles.ts +++ b/packages/domain/src/policy/roles.ts @@ -5,11 +5,11 @@ import { Array as Arr, Option, Order, Schema } from "effect" * permission names only its lowest role. `root` is Maple's own operator access and self-hosted * mode; it is never assigned in Clerk. */ -export const Role = Schema.Literals(["observer", "member", "engineer", "admin", "root"]) +export const Role = Schema.Literals(["analyst", "member", "engineer", "admin", "root"]) export type Role = typeof Role.Type const RANK = { - observer: 0, + analyst: 0, member: 1, engineer: 2, admin: 3, @@ -21,9 +21,9 @@ const byRank: Order.Order = Order.mapInput(Order.Number, (role) => RANK[ro /** What the Members page shows for a role a person can be given in Clerk. */ export const assignableRoles = [ { - role: "observer", - clerkKey: "org:observer", - label: "Observer", + role: "analyst", + clerkKey: "org:analyst", + label: "Analyst", summary: "Follows incidents, reliability, product analytics and cost, without changing anything.", }, { @@ -51,7 +51,7 @@ export type ClerkRoleKey = AssignableRole["clerkKey"] /** * A Clerk or session role string as a {@link Role}. A role this code does not know reads as - * observer, the least it can hold. + * analyst, the least it can hold. */ export const roleOf = (name: string): Role => { if (name === "root") return "root" @@ -60,7 +60,7 @@ export const roleOf = (name: string): Role => { Arr.findFirst(assignableRoles, (entry) => entry.clerkKey === name), (entry): Role => entry.role, ), - (): Role => "observer", + (): Role => "analyst", ) } @@ -82,7 +82,7 @@ export const outranks = (role: Role, other: Role): boolean => RANK[role] > RANK[ /** "org admins and engineers": who holds a permission whose lowest role is `min`, for messages. */ export const holdersPhrase = (min: Role): string => { switch (min) { - case "observer": + case "analyst": return "people in the organization" case "member": return "org admins, engineers and members" From 378bf6557ce5a9001fd471301653f0ba6e352460 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 13 Nov 2026 12:19:13 +0100 Subject: [PATCH 6/6] feat(dashboards): leave dashboards editable for analysts An analyst builds and shares dashboards like any other role, so the dashboard pages no longer read session grants or hide controls, and the dashboards:edit and dashboards:share permissions are gone. Errors, incidents, investigations and status pages still hide what the API refuses. --- apps/web/src/lab/entries/dashboards.ts | 313 ------------------ apps/web/src/lab/registry.ts | 8 - apps/web/src/page/dashboards/kit/grants.ts | 16 - .../dashboards/list/analyst.scene.test.ts | 122 ------- apps/web/src/page/dashboards/list/command.ts | 6 - apps/web/src/page/dashboards/list/message.ts | 2 - apps/web/src/page/dashboards/list/model.ts | 3 - apps/web/src/page/dashboards/list/update.ts | 13 +- .../web/src/page/dashboards/list/view-list.ts | 32 +- apps/web/src/page/dashboards/list/view.ts | 19 +- .../templates/analyst.scene.test.ts | 86 ----- .../src/page/dashboards/templates/command.ts | 6 - .../src/page/dashboards/templates/message.ts | 2 - .../src/page/dashboards/templates/model.ts | 3 - .../src/page/dashboards/templates/update.ts | 8 +- .../page/dashboards/templates/view-list.ts | 56 ++-- .../page/dashboards/templates/view-panel.ts | 4 +- .../web/src/page/dashboards/templates/view.ts | 16 +- .../dashboards/view/analyst.scene.test.ts | 231 ------------- apps/web/src/page/dashboards/view/command.ts | 3 - .../page/dashboards/view/history-update.ts | 5 +- apps/web/src/page/dashboards/view/message.ts | 2 - apps/web/src/page/dashboards/view/mode.ts | 11 +- apps/web/src/page/dashboards/view/model.ts | 3 - apps/web/src/page/dashboards/view/update.ts | 28 +- .../src/page/dashboards/view/view-history.ts | 43 ++- .../src/page/dashboards/view/view-toolbar.ts | 60 ++-- .../src/page/dashboards/view/view-widgets.ts | 5 +- apps/web/src/page/dashboards/view/view.ts | 49 +-- packages/domain/src/policy/permissions.ts | 7 +- 30 files changed, 114 insertions(+), 1048 deletions(-) delete mode 100644 apps/web/src/lab/entries/dashboards.ts delete mode 100644 apps/web/src/page/dashboards/kit/grants.ts delete mode 100644 apps/web/src/page/dashboards/list/analyst.scene.test.ts delete mode 100644 apps/web/src/page/dashboards/templates/analyst.scene.test.ts delete mode 100644 apps/web/src/page/dashboards/view/analyst.scene.test.ts diff --git a/apps/web/src/lab/entries/dashboards.ts b/apps/web/src/lab/entries/dashboards.ts deleted file mode 100644 index a3cbe5b386..0000000000 --- a/apps/web/src/lab/entries/dashboards.ts +++ /dev/null @@ -1,313 +0,0 @@ -import * as Policy from "@maple/domain/policy" -import { OrgId } from "@maple/domain" -import { DashboardDocument } from "@maple/domain/http" -import { Effect, Option, Result, Schema, Array as Arr } from "effect" -import { Command } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { defineMessageUnion } from "foldkit/message" -import { button } from "@maple/views/ui/button" -import * as Menu from "@maple/views/ui/menu" -import type { PageContext } from "../../page/contract" -import { Message as ListMessage } from "../../page/dashboards/list/message" -import { type ListDashboard, Model as ListModel } from "../../page/dashboards/list/model" -import { init as listInit, update as listUpdate } from "../../page/dashboards/list/update" -import { view as listView } from "../../page/dashboards/list/view" -import { Message as BoardMessage } from "../../page/dashboards/view/message" -import { Model as BoardModel } from "../../page/dashboards/view/model" -import { init as boardInit, update as boardUpdate } from "../../page/dashboards/view/update" -import { view as boardView } from "../../page/dashboards/view/view" -import type { Resources } from "../../platform/resources" -import { defineShellLab } from "../shell-lab" - -// `/lab/dashboards`: the dashboards list and one board as a member or an analyst, who reads -// dashboards but cannot change or share them (`?page=list|detail&role=member|analyst&mode=edit`). - -const PAGES = ["list", "detail"] as const -const ROLES = ["member", "analyst"] as const -type LabPage = (typeof PAGES)[number] -type LabRole = (typeof ROLES)[number] - -const NOW = Date.UTC(2026, 10, 6, 12) -const context: PageContext = { - orgId: Schema.decodeSync(OrgId)("org_lab_dashboards"), - pinnedNamespace: null, - timeZone: "UTC", -} -const BOARD_ID = "dash_platform" - -const row = (fields: Partial): ListDashboard => ({ - id: BOARD_ID, - name: "Platform overview", - description: "Errors and latency across the platform services.", - tags: ["platform"], - widgetCount: 3, - readsShort: "Errors", - readsFull: "Errors", - glyph: "CircleWarningIcon", - createdAt: "2026-09-19T12:00:00.000Z", - updatedAt: "2026-11-06T09:00:00.000Z", - ...fields, -}) - -const ROWS: ReadonlyArray = [ - row({}), - row({ - id: "dash_checkout", - name: "Checkout health", - description: "Payment and cart traces.", - tags: ["checkout"], - widgetCount: 8, - readsShort: "Traces · Errors", - readsFull: "Traces · Errors", - glyph: "PulseIcon", - updatedAt: "2026-11-04T15:30:00.000Z", - }), - row({ - id: "dash_empty", - name: "Queue workers", - description: null, - tags: [], - widgetCount: 0, - readsShort: "", - readsFull: "", - glyph: "GridIcon", - updatedAt: "2026-10-21T08:00:00.000Z", - }), -] - -const stat = (id: string, title: string, field: string, x: number) => ({ - id, - visualization: "stat", - dataSource: { - kind: "route", - endpoint: "errors_summary", - transform: { reduceToValue: { field, aggregate: "first" } }, - }, - display: { title, unit: "number" }, - layout: { x, y: 0, w: 3, h: 2 }, -}) - -const BOARD = Schema.decodeUnknownSync(DashboardDocument)({ - id: BOARD_ID, - name: "Platform overview", - timeRange: { type: "relative", value: "6h" }, - widgets: [ - stat("total-errors", "Total errors", "totalErrors", 0), - stat("affected-services", "Affected services", "affectedServices", 3), - { - id: "notes", - visualization: "markdown", - dataSource: { kind: "static" }, - display: { - title: "Runbook", - markdown: { - content: "Error budget review is every Monday. Page the platform on-call first.", - }, - }, - layout: { x: 6, y: 0, w: 6, h: 2 }, - }, - ], - createdAt: "2026-09-19T12:00:00.000Z", - updatedAt: "2026-11-06T09:00:00.000Z", - schemaVersion: 3, -}) - -/** What every stat tile's fetch answers in the lab. */ -const SUMMARY = { data: [{ totalErrors: 1284, affectedServices: 7 }] } - -const grantsOf = (role: LabRole): ReadonlyArray => Policy.grantsOf([`org:${role}`]) - -const listModel = (role: LabRole): ListModel => - Arr.reduce( - [ - ListMessage.CompletedReadClock({ nowMs: NOW, locale: "en-US" }), - ListMessage.CompletedLoadPreferences({ favorites: [BOARD_ID], sort: "updated" }), - ListMessage.CompletedFetchSession({ orgId: context.orgId, grants: grantsOf(role) }), - ListMessage.UpdatedDashboards({ orgId: context.orgId, dashboards: ROWS }), - ], - listInit({ search: {} }, context).model, - (model, message) => listUpdate(model, message, context).model, - ) - -const WidgetFetch = Schema.Struct({ key: Schema.String }) -const decodeWidgetFetch = Schema.decodeUnknownOption(WidgetFetch) - -// Reads and writes that would reach the API; the lab has none. -const REMOTE = - /^(Fetch|Save|Create|Duplicate|Export|Import|Delete|Restore|Rename|Remove|Clone|Update|Add|AutoLayout|Edit|Write|Reload)/ - -type BoardStep = Readonly<{ - model: BoardModel - commands: ReadonlyArray> -}> - -/** One board step; a tile's fetch is answered from the fixture, other remote commands are dropped. */ -const boardStep = (model: BoardModel, message: BoardMessage): BoardStep => { - const next = boardUpdate(model, message, context) - const commands = next.commands ?? [] - const fetched = Arr.flatMap(commands, (command) => - command.name === "FetchDashboardWidget" ? Option.toArray(decodeWidgetFetch(command.args)) : [], - ) - const local = Arr.filter(commands, (command) => !REMOTE.test(command.name)) - return Arr.reduce(fetched, { model: next.model, commands: local }, (step, { key }): BoardStep => { - const answered = boardStep( - step.model, - BoardMessage.CompletedFetchWidget({ key, result: Result.succeed(SUMMARY) }), - ) - return { model: answered.model, commands: [...step.commands, ...answered.commands] } - }) -} - -const boardModel = (role: LabRole, isEdit: boolean): BoardModel => - Arr.reduce( - [ - BoardMessage.CompletedReadClock({ nowMs: NOW }), - BoardMessage.CompletedFetchSession({ orgId: context.orgId, grants: grantsOf(role) }), - BoardMessage.UpdatedDashboard({ orgId: context.orgId, maybeDocument: Option.some(BOARD) }), - ], - boardInit({ dashboardId: BOARD_ID, search: isEdit ? { mode: "edit" } : {} }, context).model, - (model, message) => boardStep(model, message).model, - ) - -const Model = Schema.Struct({ - page: Schema.Literals(PAGES), - role: Schema.Literals(ROLES), - /** The board was opened at `?mode=edit`: an analyst still gets the read view. */ - isEdit: Schema.Boolean, - list: ListModel, - board: BoardModel, -}) -type Model = typeof Model.Type - -const Message = defineMessageUnion({ - ClickedPage: { page: Schema.Literals(PAGES) }, - ClickedRole: { role: Schema.Literals(ROLES) }, - /** The list's row menus open here; its other messages would write, so they are dropped. */ - GotRowMenuMessage: { rowId: Schema.String, message: Menu.Message }, - Ignored: {}, - GotBoardMessage: { message: BoardMessage }, -}) -type Message = typeof Message.Type - -const Flags = Schema.Struct({ - page: Schema.Literals(PAGES), - role: Schema.Literals(ROLES), - isEdit: Schema.Boolean, -}) -type Flags = typeof Flags.Type - -const decodePage = Schema.decodeUnknownOption(Schema.Literals(PAGES)) -const decodeRole = Schema.decodeUnknownOption(Schema.Literals(ROLES)) - -const build = (page: LabPage, role: LabRole, isEdit: boolean): Model => ({ - page, - role, - isEdit, - list: listModel(role), - board: boardModel(role, isEdit), -}) - -const toListMessage = (message: ListMessage): Message => - message._tag === "GotRowMenuMessage" - ? Message.GotRowMenuMessage({ rowId: message.rowId, message: message.message }) - : Message.Ignored() - -const update = (model: Model, message: Message) => - Message.match(message, { - ClickedPage: ({ page }) => ({ model: { ...model, page }, commands: [] }), - ClickedRole: ({ role }) => ({ model: build(model.page, role, model.isEdit), commands: [] }), - GotRowMenuMessage: (rowMenu) => { - const next = listUpdate(model.list, ListMessage.GotRowMenuMessage(rowMenu), context) - return { - model: { ...model, list: next.model }, - commands: Command.mapMessages( - Arr.filter(next.commands ?? [], (command) => !REMOTE.test(command.name)), - toListMessage, - ), - } - }, - Ignored: () => ({ model, commands: [] }), - GotBoardMessage: ({ message: inner }) => { - const next = boardStep(model.board, inner) - return { - model: { ...model, board: next.model }, - commands: Command.mapMessages(next.commands, (boardMessage) => - Message.GotBoardMessage({ message: boardMessage }), - ), - } - }, - }) - -const view = (model: Model, h: HtmlBuilder): Html => - model.page === "list" - ? h.submodel({ - slotId: "dashboards-lab-list", - model: model.list, - view: listView, - viewInputs: { context }, - toParentMessage: toListMessage, - }) - : h.submodel({ - slotId: "dashboards-lab-board", - model: model.board, - view: boardView, - viewInputs: { context }, - toParentMessage: (message) => Message.GotBoardMessage({ message }), - }) - -/** The page and role switcher, fixed at the bottom like the other shell labs'. */ -const switcher = (model: Model, h: HtmlBuilder): Html => - h.div( - [ - h.Class( - "bg-background fixed bottom-3 left-1/2 z-50 flex -translate-x-1/2 gap-1 rounded-lg border p-1", - ), - ], - [ - ...Arr.map(PAGES, (page) => - button( - h, - { - size: "xs", - variant: model.page === page ? "secondary" : "ghost", - attributes: [h.OnClick(Message.ClickedPage({ page }))], - }, - [page], - ), - ), - ...Arr.map(ROLES, (role) => - button( - h, - { - size: "xs", - variant: model.role === role ? "secondary" : "ghost", - attributes: [h.OnClick(Message.ClickedRole({ role }))], - }, - [role], - ), - ), - ], - ) - -export const lab = defineShellLab("Dashboards", { - Model, - Message, - Flags, - flags: Effect.sync(() => { - const params = new URLSearchParams(window.location.search) - return { - page: Option.getOrElse(decodePage(params.get("page")), (): LabPage => "list"), - role: Option.getOrElse(decodeRole(params.get("role")), (): LabRole => "member"), - isEdit: params.get("mode") === "edit", - } - }), - init: (flags: Flags) => ({ model: build(flags.page, flags.role, flags.isEdit) }), - update, - view, - before: switcher, - currentPath: "/dashboards", - breadcrumbs: (model) => - model.page === "list" - ? [{ label: "Dashboards", isPageHeading: true }] - : [{ label: "Dashboards" }, { label: BOARD.name }], -}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 73c7a789c5..b26a880730 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -372,14 +372,6 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, - { - path: "/lab/dashboards", - title: "Dashboards", - description: - "The `/dashboards` list and one board as a member or an analyst, who reads dashboards but cannot change or share them (?page=list|detail&role=member|analyst&mode=edit).", - kind: "lab", - session: "none", - }, { path: "/lab/agent-tools", title: "Agent tools", diff --git a/apps/web/src/page/dashboards/kit/grants.ts b/apps/web/src/page/dashboards/kit/grants.ts deleted file mode 100644 index 5927328ee3..0000000000 --- a/apps/web/src/page/dashboards/kit/grants.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as Policy from "@maple/domain/policy" -import { isClerkAuthEnabled } from "../../../platform/auth-mode" - -type Grants = Readonly<{ grants: ReadonlyArray }> - -/** A dashboards 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 create, edit, duplicate, import, restore and delete dashboards (`dashboards:edit`). */ -export const canEditDashboards = (model: Grants): boolean => Policy.has(model.grants, "dashboards:edit") - -/** The viewer may share a dashboard or embed a widget outside the org (`dashboards:share`). */ -export const canShareDashboards = (model: Grants): boolean => Policy.has(model.grants, "dashboards:share") diff --git a/apps/web/src/page/dashboards/list/analyst.scene.test.ts b/apps/web/src/page/dashboards/list/analyst.scene.test.ts deleted file mode 100644 index 9525b344da..0000000000 --- a/apps/web/src/page/dashboards/list/analyst.scene.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { OrgId } from "@maple/domain" -import * as Policy from "@maple/domain/policy" -import { 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 * as Menu from "@maple/views/ui/menu" -import type { PageContext } from "../../contract" -import { Message } from "./message" -import type { ListDashboard, Model } from "./model" -import { init, rowMenuIdOf, update } from "./update" -import { view as pageView } from "./view" - -// An analyst reads the list; creating, importing and the row's writes need `dashboards:edit`. - -const orgId = Schema.decodeSync(OrgId)("org_acme") -const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } -const pageUpdate = (model: Model, message: Message) => update(model, message, context) -const view = withViewInputs(pageView, { context })() - -const board: ListDashboard = { - id: "dash_platform", - name: "Platform overview", - description: "Cross-service health.", - tags: [], - widgetCount: 5, - readsShort: "Traces · Errors", - readsFull: "Traces · Errors", - glyph: "PulseIcon", - createdAt: "2026-09-19T12:00:00.000Z", - updatedAt: "2026-09-28T12:00:00.000Z", -} - -const MEMBER = Policy.grantsOf(["org:member"]) -const ANALYST = Policy.grantsOf(["org:analyst"]) - -/** Entered with its clock, preferences, the session's grants and rows. */ -const loaded = (grants: ReadonlyArray, dashboards: ReadonlyArray): Model => - Arr.reduce( - [ - Message.CompletedReadClock({ nowMs: Date.parse("2026-10-01T12:00:00Z"), locale: "en-US" }), - Message.CompletedLoadPreferences({ favorites: [], sort: "updated" }), - Message.CompletedFetchSession({ orgId, grants }), - Message.UpdatedDashboards({ orgId, dashboards }), - ], - init({ search: {} }, context).model, - (model, message) => pageUpdate(model, message).model, - ) - -const withRowMenuOpen = (model: Model): Model => ({ - ...model, - rowMenuId: board.id, - rowMenu: Menu.init(rowMenuIdOf(board.id), { isOpen: true }), -}) - -describe("dashboards list for an analyst", () => { - test("the header keeps Browse templates and drops Import and Create", () => { - scene( - { update: pageUpdate, view }, - given(loaded(ANALYST, [board])), - expectView(text("Platform overview")).toExist(), - expectView(role("button", { name: "Browse templates" })).toExist(), - expectView(role("button", { name: "Import" })).toBeAbsent(), - expectView(role("button", { name: "Create dashboard" })).toBeAbsent(), - expectView(text("New dashboard")).toBeAbsent(), - ) - }) - - test("a member gets Import, Create and the New dashboard row", () => { - scene( - { update: pageUpdate, view }, - given(loaded(MEMBER, [board])), - expectView(role("button", { name: "Import" })).toExist(), - expectView(role("button", { name: "Create dashboard" })).toExist(), - expectView(text("New dashboard")).toExist(), - ) - }) - - test("the row menu exports and offers no write", () => { - scene( - { update: pageUpdate, view }, - given(withRowMenuOpen(loaded(ANALYST, [board]))), - Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), - expectView(role("menuitem", { name: "Export JSON" })).toExist(), - expectView(role("menuitem", { name: "Duplicate" })).toBeAbsent(), - expectView(role("menuitem", { name: "Edit tags…" })).toBeAbsent(), - expectView(role("menuitem", { name: "Delete" })).toBeAbsent(), - ) - }) - - test("a member's row menu duplicates, tags and deletes", () => { - scene( - { update: pageUpdate, view }, - given(withRowMenuOpen(loaded(MEMBER, [board]))), - Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), - expectView(role("menuitem", { name: "Duplicate" })).toExist(), - expectView(role("menuitem", { name: "Edit tags…" })).toExist(), - expectView(role("menuitem", { name: "Delete" })).toExist(), - ) - }) - - test("an empty org offers nothing to create", () => { - scene( - { update: pageUpdate, view }, - given(loaded(ANALYST, [])), - expectView(text("No dashboards yet")).toExist(), - expectView(role("button", { name: "Create blank dashboard" })).toBeAbsent(), - ) - }) - - test("grants answered for another org are dropped", () => { - const other = Schema.decodeSync(OrgId)("org_other") - const model = pageUpdate( - loaded(MEMBER, [board]), - Message.CompletedFetchSession({ orgId: other, grants: ANALYST }), - ).model - scene( - { update: pageUpdate, view }, - given(model), - expectView(role("button", { name: "Create dashboard" })).toExist(), - ) - }) -}) diff --git a/apps/web/src/page/dashboards/list/command.ts b/apps/web/src/page/dashboards/list/command.ts index fc4fe4aa38..5a3c4faadd 100644 --- a/apps/web/src/page/dashboards/list/command.ts +++ b/apps/web/src/page/dashboards/list/command.ts @@ -32,7 +32,6 @@ import type { V2Dashboard } from "@maple/domain/http/v2" import { documentOf } from "../view/command-history" import { toListDashboard } from "./derive" import { Message, type WriteFailure } from "./message" -import { fetchSessionGrants } from "../../kit/session-grants" /** The localStorage key of the default sort for a URL without `?sort`. */ const SORT_KEY = "maple.dashboards.sort" @@ -54,11 +53,6 @@ export const ReadClock = Command.define("ReadDashboardsListClock", { ), }) -export const FetchListSession = fetchSessionGrants( - "FetchDashboardsListSession", - Message.CompletedFetchSession, -) - /** A picked tag keeps focus in the filter's search, as Base UI's items do. */ export const FocusTagSearch = Command.define("FocusDashboardsTagSearch", { args: { id: Schema.String }, diff --git a/apps/web/src/page/dashboards/list/message.ts b/apps/web/src/page/dashboards/list/message.ts index ee6c275875..584b4ad5f1 100644 --- a/apps/web/src/page/dashboards/list/message.ts +++ b/apps/web/src/page/dashboards/list/message.ts @@ -7,7 +7,6 @@ import * as Menu from "@maple/views/ui/menu" import * as Combobox from "@maple/views/ui/combobox" import { ListDashboard, Scope } from "./model" import { WriteSync } from "../kit/store" -import { SessionGrants } from "../../kit/session-grants" /** A created dashboard: its id to open and its name for the toast. */ const Created = Schema.Struct({ @@ -28,7 +27,6 @@ export const Message = defineMessageUnion({ /** The clock and the browser's locale, read together before the list renders. */ CompletedReadClock: { nowMs: Schema.Number, locale: Schema.String }, CompletedLoadPreferences: { favorites: Schema.Array(Schema.String), sort: Schema.String }, - CompletedFetchSession: SessionGrants, UpdatedDashboards: { orgId: Schema.String, dashboards: Schema.Array(ListDashboard) }, /** The live list sat loading past the timeout. */ TimedOutLiveDashboards: { orgId: Schema.String }, diff --git a/apps/web/src/page/dashboards/list/model.ts b/apps/web/src/page/dashboards/list/model.ts index 095b7715f5..4e4bf10c4a 100644 --- a/apps/web/src/page/dashboards/list/model.ts +++ b/apps/web/src/page/dashboards/list/model.ts @@ -1,4 +1,3 @@ -import * as Policy from "@maple/domain/policy" import { Schema } from "effect" import { DashboardsSearch } from "../../../route/search" import * as Dialog from "@maple/views/ui/dialog" @@ -47,8 +46,6 @@ export type Scope = typeof Scope.Type export const Model = Schema.Struct({ search: DashboardsSearch, - /** What the viewer may do; an analyst reads the list and gets no write control. */ - grants: Schema.Array(Policy.PermissionName), /** `null` until the collection is ready. */ dashboards: Schema.NullOr(Schema.Array(ListDashboard)), nowMs: Schema.NullOr(Schema.Number), diff --git a/apps/web/src/page/dashboards/list/update.ts b/apps/web/src/page/dashboards/list/update.ts index d1d05fc132..47bae5aa09 100644 --- a/apps/web/src/page/dashboards/list/update.ts +++ b/apps/web/src/page/dashboards/list/update.ts @@ -21,10 +21,7 @@ import { WaitSearchDebounce, FocusTagSearch, FetchDashboardsFallback, - FetchListSession, } from "./command" -import { acceptGrants } from "../../kit/session-grants" -import { initialGrants, readsSessionGrants } from "../kit/grants" import { countLabel } from "@maple/ui/lib/format" import * as TagEditor from "../kit/tag-editor" import { displayName, queryOf, tagsOf } from "./derive" @@ -41,10 +38,9 @@ export const SORT_MENU_ID = "dashboards-sort-menu" export const TAG_FILTER_ID = "dashboards-tag-filter" export const rowMenuIdOf = (id: string): string => `dashboard-row-menu-${id}` -export const init = (route: Route, context: PageContext): Return => ({ +export const init = (route: Route, _context: PageContext): Return => ({ model: { search: route.search, - grants: initialGrants, dashboards: null, nowMs: null, locale: INITIAL_LOCALE, @@ -66,11 +62,7 @@ export const init = (route: Route, context: PageContext): Return => ({ isCreating: false, fallback: { _tag: "Idle" }, }, - commands: [ - ReadClock(), - LoadPreferences(), - ...(readsSessionGrants ? [FetchListSession({ orgId: context.orgId })] : []), - ], + commands: [ReadClock(), LoadPreferences()], }) /** An outside change to `q` replaces the search input, its own echo does not. */ @@ -274,7 +266,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re CompletedLoadPreferences: ({ favorites, sort }) => ({ model: { ...model, favorites, storedSort: sort, isPrefsLoaded: true }, }), - CompletedFetchSession: (answer) => ({ model: acceptGrants(model, context.orgId, answer) }), // A live update is a healthy sync: writes come back on. UpdatedDashboards: ({ orgId, dashboards }) => orgId !== context.orgId ? { model } : { model: { ...model, dashboards, persistenceError: null } }, diff --git a/apps/web/src/page/dashboards/list/view-list.ts b/apps/web/src/page/dashboards/list/view-list.ts index afbe60ac00..bb2b77b7f4 100644 --- a/apps/web/src/page/dashboards/list/view-list.ts +++ b/apps/web/src/page/dashboards/list/view-list.ts @@ -29,7 +29,6 @@ import * as TagEditor from "../kit/tag-editor" import { Message } from "./message" import type { ListDashboard, Model, Scope } from "./model" import { isReadOnly, rowMenuIdOf } from "./update" -import { canEditDashboards } from "../kit/grants" import { deleteDialog } from "./view-dialogs" import { tagFilterView } from "./view-tag-filter" import { typography, typographyClass } from "@maple/views/ui/typography" @@ -156,17 +155,12 @@ const READ_ONLY_NOTE: ReadonlyArray = [ }, ] -const EXPORT_ENTRY: MenuView.MenuEntry = { _tag: "Item", value: "export", label: "Export JSON" } - -/** All an analyst's row menu holds: the entries that write are not offered. */ -const ANALYST_ROW_ENTRIES: ReadonlyArray = [{ _tag: "Group", entries: [EXPORT_ENTRY] }] - /** Export reads what the list holds, so it survives a write failure; the rest write, so they don't. */ const rowMenuEntries = (isReadOnly: boolean): ReadonlyArray => [ { _tag: "Group", entries: [ - EXPORT_ENTRY, + { _tag: "Item", value: "export", label: "Export JSON" }, { _tag: "Item", value: "duplicate", label: "Duplicate", isDisabled: isReadOnly }, { _tag: "Item", value: "tags", label: "Edit tags…", isDisabled: isReadOnly }, { _tag: "Separator" }, @@ -188,7 +182,7 @@ const rowMenu = (h: H, model: Model, dashboard: ListDashboard): Html => { return rowActionsMenu(h, { slotId: `row-menu-${dashboard.id}`, model: menu, - entries: canEditDashboards(model) ? rowMenuEntries(isReadOnly(model)) : ANALYST_ROW_ENTRIES, + entries: rowMenuEntries(isReadOnly(model)), label: `More actions for ${displayName(dashboard.name)}`, className: "text-muted-foreground", toParentMessage: (message) => Message.GotRowMenuMessage({ rowId: dashboard.id, message }), @@ -403,23 +397,6 @@ const firstRunEmpty = (h: H, isReadOnly: boolean): Html => "py-16", ) -/** An analyst's empty list: nothing to start from, so it says where dashboards come from. */ -const analystEmpty = (h: H): Html => - empty( - h, - [ - emptyHeader(h, [ - emptyMedia(h, { variant: "icon" }, [icon(h, "GridIcon", { size: 18 })]), - emptyTitle(h, ["No dashboards yet"]), - emptyDescription(h, [ - "Dashboards your team creates show up here. Your role can read them but not create one.", - ]), - ]), - docsLink(h, "dashboards"), - ], - "py-16", - ) - /** A search or tag filter hid every dashboard. */ const nothingMatched = (h: H, model: Model): Html => { const query = Str.trim(queryOf(model.search)) @@ -510,10 +487,9 @@ export const listBody = ( const isFiltering = Str.trim(queryOf(model.search)).length > 0 || tagsOf(model.search).length > 0 const isNothingAtAll = dashboards.length === 0 const isWriteDisabled = isReadOnly(model) - const canEdit = canEditDashboards(model) const context: RowContext = { model, nowMs, timeZone } const body: ReadonlyArray = isNothingAtAll - ? [canEdit ? firstRunEmpty(h, isWriteDisabled) : analystEmpty(h)] + ? [firstRunEmpty(h, isWriteDisabled)] : sections.matched === 0 ? [nothingMatched(h, model)] : sections.scoped === 0 @@ -543,7 +519,7 @@ export const listBody = ( dashboardRow(h, context, dashboard, false), ), ]), - ...(canEdit ? [createRow(h, isWriteDisabled)] : []), + createRow(h, isWriteDisabled), ] return vstack(h, { className: "shrink-0 pb-6" }, [ toolbar(h, model, dashboards, sections.scoped), diff --git a/apps/web/src/page/dashboards/list/view.ts b/apps/web/src/page/dashboards/list/view.ts index 7e44d388d5..132e99fcad 100644 --- a/apps/web/src/page/dashboards/list/view.ts +++ b/apps/web/src/page/dashboards/list/view.ts @@ -16,7 +16,6 @@ import type { Model } from "./model" import { isDegraded, isLoadFailed, listedOf } from "./update" import { listBody, listSkeleton } from "./view-list" import { snapshotBanner } from "../kit/live-sync" -import { canEditDashboards } from "../kit/grants" import { String as Str } from "effect" import { hstack } from "@maple/views/ui/stack" @@ -60,16 +59,17 @@ const readOnlyReason = (model: Model): string | null => ? "Couldn't reach Maple. Editing is off until it reconnects." : `${writeFailureCause(model.persistenceError)}. Editing is off until it recovers.` -const browseTemplates = (h: H): Html => - button(h, { variant: "outline", size: "sm", attributes: [h.OnClick(Message.ClickedBrowseTemplates())] }, [ - icon(h, "GridIcon", { attributes: [h.DataAttribute("icon", "inline-start")] }), - "Browse templates", - ]) - /** Browse templates, Import (the hidden file input) and Create dashboard. */ const actions = (h: H, reason: string | null): Html => hstack(h, { gap: "1", align: "center", wrap: true }, [ - browseTemplates(h), + button( + h, + { variant: "outline", size: "sm", attributes: [h.OnClick(Message.ClickedBrowseTemplates())] }, + [ + icon(h, "GridIcon", { attributes: [h.DataAttribute("icon", "inline-start")] }), + "Browse templates", + ], + ), withReason( h, reason, @@ -160,8 +160,7 @@ export const view = Submodel.defineView((model, h, { titleContent: title, - // An analyst browses the templates but neither imports nor creates. - actions: canEditDashboards(model) ? actions(h, reason) : browseTemplates(h), + actions: actions(h, reason), sticky: persistenceBanner(h, model.persistenceError, isLoadFailed(model)), }, isLoadFailed(model) diff --git a/apps/web/src/page/dashboards/templates/analyst.scene.test.ts b/apps/web/src/page/dashboards/templates/analyst.scene.test.ts deleted file mode 100644 index e03f3b2186..0000000000 --- a/apps/web/src/page/dashboards/templates/analyst.scene.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { OrgId } from "@maple/domain" -import { V2DashboardTemplate } 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 } from "@maple/views/charts/theme-colors" -import type { PageContext } from "../../contract" -import { CreateFromTemplate } from "./command" -import { Message } from "./message" -import type { Model } from "./model" -import { init, update } from "./update" -import { view as pageView } from "./view" - -// An analyst previews templates; creating a dashboard from one needs `dashboards:edit`. - -const orgId = Schema.decodeSync(OrgId)("org_acme") -const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } -const pageUpdate = (model: Model, message: Message) => update(model, message, context) -const view = withViewInputs(pageView, { context })() -const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => - Arr.map(commands ?? [], ({ name }) => name) - -const template = Schema.decodeUnknownSync(Schema.toType(V2DashboardTemplate))({ - id: "service-health", - object: "dashboard_template", - name: "Service Health", - description: "Service Health board.", - category: "application", - tags: [], - requirements: [], - requirement: null, - requiredMetricPrefixes: [], - parameters: [], - preview: [], -}) - -const MEMBER = Policy.grantsOf(["org:member"]) -const ANALYST = Policy.grantsOf(["org:analyst"]) - -/** The gallery with its one template selected and the session's grants answered. */ -const selected = (grants: ReadonlyArray): Model => { - const entered = init({ search: { template: "service-health" } }, context).model - return Arr.reduce( - [ - Message.CompletedFetchSession({ orgId, grants }), - Message.CompletedFetchTemplates({ - key: entered.templates.key ?? "", - result: Result.succeed([template]), - }), - ], - entered, - (model, message) => pageUpdate(model, message).model, - ) -} - -describe("dashboard templates for an analyst", () => { - test("the template previews with no way to create from it", () => { - const model = selected(ANALYST) - expect(names(pageUpdate(model, Message.ClickedCreate({ templateId: template.id })).commands)).toEqual( - [], - ) - scene( - { update: pageUpdate, view }, - given(model), - Mount.resolve(ResolveThemeColors, Message.ResolvedThemeColors({ colors: {} })), - expectView(text("Service Health board.")).toExist(), - expectView(role("button", { name: "Create dashboard" })).toBeAbsent(), - expectView(text("Blank dashboard")).toBeAbsent(), - ) - }) - - test("a member creates from the template or starts blank", () => { - const model = selected(MEMBER) - expect(names(pageUpdate(model, Message.ClickedCreate({ templateId: template.id })).commands)).toEqual( - [CreateFromTemplate.name], - ) - scene( - { update: pageUpdate, view }, - given(model), - Mount.resolve(ResolveThemeColors, Message.ResolvedThemeColors({ colors: {} })), - expectView(role("button", { name: "Create dashboard" })).toExist(), - expectView(text("Blank dashboard")).toExist(), - ) - }) -}) diff --git a/apps/web/src/page/dashboards/templates/command.ts b/apps/web/src/page/dashboards/templates/command.ts index bf734a9f4c..9e6b766db2 100644 --- a/apps/web/src/page/dashboards/templates/command.ts +++ b/apps/web/src/page/dashboards/templates/command.ts @@ -9,15 +9,9 @@ import { MapleApiV2Client } from "../../../platform/api-clients" import { fetchWidgetData, toWidgetFailure } from "../kit/widget-data" import { awaitTxid } from "../kit/store" import { Message } from "./message" -import { fetchSessionGrants } from "../../kit/session-grants" export const ReadClock = readClock("ReadTemplatesClock", Message.CompletedReadClock) -export const FetchTemplatesSession = fetchSessionGrants( - "FetchDashboardTemplatesSession", - Message.CompletedFetchSession, -) - export const FetchTemplates = Command.define("FetchDashboardTemplates", { args: { key: Schema.String }, messages: [Message.CompletedFetchTemplates], diff --git a/apps/web/src/page/dashboards/templates/message.ts b/apps/web/src/page/dashboards/templates/message.ts index b343b8b5b8..fc0fe0aee7 100644 --- a/apps/web/src/page/dashboards/templates/message.ts +++ b/apps/web/src/page/dashboards/templates/message.ts @@ -7,7 +7,6 @@ import { LoadFailure } from "@maple/views/data/load-failure" import * as Dialog from "@maple/views/ui/dialog" import { WidgetFailure } from "../kit/widget-data" import { MetricSeen, ReadinessFilter } from "./model" -import { SessionGrants } from "../../kit/session-grants" const result = (schema: A) => ({ key: Schema.String, @@ -16,7 +15,6 @@ const result = (schema: A) => ({ export const Message = defineMessageUnion({ CompletedReadClock: { nowMs: Schema.Number }, - CompletedFetchSession: SessionGrants, CompletedFetchTemplates: result(Schema.Array(V2DashboardTemplate)), CompletedFetchMetrics: result(Schema.Array(MetricSeen)), ElapsedPreviewDelay: { generation: Schema.Number }, diff --git a/apps/web/src/page/dashboards/templates/model.ts b/apps/web/src/page/dashboards/templates/model.ts index 2ae82aa65c..75630a8e7d 100644 --- a/apps/web/src/page/dashboards/templates/model.ts +++ b/apps/web/src/page/dashboards/templates/model.ts @@ -1,6 +1,5 @@ import { DashboardWidgetSchema } from "@maple/domain/http" import { V2DashboardTemplate, V2DashboardTemplatePreview } 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 { ChartRuntime } from "@maple/views/charts/chart-runtime" @@ -21,8 +20,6 @@ export type MetricSeen = typeof MetricSeen.Type export const Model = Schema.Struct({ search: DashboardsTemplatesSearch, - /** What the viewer may do; an analyst previews templates and gets no create control. */ - grants: Schema.Array(Policy.PermissionName), nowMs: Schema.NullOr(Schema.Number), templates: KeyedSchema(Schema.Array(V2DashboardTemplate), LoadFailure), /** Readiness fails open until it answers. */ diff --git a/apps/web/src/page/dashboards/templates/update.ts b/apps/web/src/page/dashboards/templates/update.ts index d46b0f92ac..16817dd889 100644 --- a/apps/web/src/page/dashboards/templates/update.ts +++ b/apps/web/src/page/dashboards/templates/update.ts @@ -20,7 +20,6 @@ import { FetchMetrics, FetchPreview, FetchTemplates, - FetchTemplatesSession, FetchWidget, ReadClock, WaitParameterDebounce, @@ -30,8 +29,6 @@ import { import { filledParametersOf, missingRequiredOf, previewVariableValues } from "./derive" import { Message } from "./message" import type { Model } from "./model" -import { acceptGrants } from "../../kit/session-grants" -import { canEditDashboards, initialGrants, readsSessionGrants } from "../kit/grants" export type Return = PageReturn type Search = typeof DashboardsTemplatesSearch.Type @@ -54,7 +51,6 @@ export const init = (route: Route, context: PageContext): Return => { return { model: { search: route.search, - grants: initialGrants, nowMs: null, templates: { key: templates, data: AsyncData.Loading() }, metrics: { key: metrics, data: AsyncData.Loading() }, @@ -82,7 +78,6 @@ export const init = (route: Route, context: PageContext): Return => { FetchTemplates({ key: templates }), FetchMetrics({ key: metrics }), loadChartRuntime, - ...(readsSessionGrants ? [FetchTemplatesSession({ orgId: context.orgId })] : []), ], } } @@ -204,7 +199,7 @@ const selectionHref = (templateId: string | undefined) => /** Only filled parameters are sent. */ const create = (model: Model, templateId: string): Return => { - if (model.isCreating || !canEditDashboards(model)) return { model } + if (model.isCreating) return { model } const parameters = filledParametersOf(model.parameters) return { model: { ...model, isCreating: true }, @@ -226,7 +221,6 @@ const sortStep = (model: Model, widgetId: string, field: string, isText: boolean export const update = (model: Model, message: Message, context: PageContext): Return => Message.match(message, { CompletedReadClock: ({ nowMs }) => planPreview({ ...model, nowMs }, context), - CompletedFetchSession: (answer) => ({ model: acceptGrants(model, context.orgId, answer) }), CompletedFetchTemplates: ({ key, result }) => { const next = { ...model, templates: Keyed.accept(model.templates, key, result) } // The panel mounts once the list resolves the selection; its arming delay starts then. diff --git a/apps/web/src/page/dashboards/templates/view-list.ts b/apps/web/src/page/dashboards/templates/view-list.ts index b67645ada2..59d40b6385 100644 --- a/apps/web/src/page/dashboards/templates/view-list.ts +++ b/apps/web/src/page/dashboards/templates/view-list.ts @@ -25,7 +25,6 @@ import { filteredEmpty as filteredEmptyState } from "@maple/views/ui/signal-empt import { typography, typographyClass } from "@maple/views/ui/typography" import { Array as Arr, String as Str } from "effect" import { hstack, vstack } from "@maple/views/ui/stack" -import { canEditDashboards } from "../kit/grants" type H = HtmlBuilder @@ -213,34 +212,6 @@ const groups = ( ] } -/** The footer row that starts a blank dashboard. */ -const blankRow = (h: H): Html => - pressable( - h, - { - onClick: Message.ClickedCreate({ templateId: BLANK_TEMPLATE_ID }), - className: - "flex shrink-0 items-center gap-2.5 border-t border-border bg-sidebar py-3 pr-4 pl-4 text-left hover:bg-surface-sunken active:bg-surface-sunken", - }, - [ - hstack( - h, - { - align: "center", - justify: "center", - as: "span", - className: "size-5.5 shrink-0 rounded-sm border border-input border-dashed", - }, - [icon(h, "PlusIcon", { size: 12, className: "text-muted-foreground" })], - ), - hstack(h, { align: "baseline", shrink: true, as: "span", className: "grow gap-2" }, [ - typography(h, { size: "sm", tone: "foreground" }, ["Blank dashboard"]), - typography(h, { size: "2xs", tone: "muted" }, ["start from nothing"]), - ]), - icon(h, "ArrowRightIcon", { size: 14, className: "text-muted-foreground shrink-0" }), - ], - ) - export const templateList = ( h: H, model: Model, @@ -337,7 +308,30 @@ export const templateList = ( ), ], ), - // Starting blank creates a dashboard, so it needs `dashboards:edit`. - ...(canEditDashboards(model) ? [blankRow(h)] : []), + pressable( + h, + { + onClick: Message.ClickedCreate({ templateId: BLANK_TEMPLATE_ID }), + className: + "flex shrink-0 items-center gap-2.5 border-t border-border bg-sidebar py-3 pr-4 pl-4 text-left hover:bg-surface-sunken active:bg-surface-sunken", + }, + [ + hstack( + h, + { + align: "center", + justify: "center", + as: "span", + className: "size-5.5 shrink-0 rounded-sm border border-input border-dashed", + }, + [icon(h, "PlusIcon", { size: 12, className: "text-muted-foreground" })], + ), + hstack(h, { align: "baseline", shrink: true, as: "span", className: "grow gap-2" }, [ + typography(h, { size: "sm", tone: "foreground" }, ["Blank dashboard"]), + typography(h, { size: "2xs", tone: "muted" }, ["start from nothing"]), + ]), + icon(h, "ArrowRightIcon", { size: 14, className: "text-muted-foreground shrink-0" }), + ], + ), ]) } diff --git a/apps/web/src/page/dashboards/templates/view-panel.ts b/apps/web/src/page/dashboards/templates/view-panel.ts index 839a6ffdc1..5cf61cba28 100644 --- a/apps/web/src/page/dashboards/templates/view-panel.ts +++ b/apps/web/src/page/dashboards/templates/view-panel.ts @@ -37,7 +37,6 @@ import { MAX_PREVIEW_WIDGETS, selectedOf } from "./update" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" import { Array as Arr } from "effect" -import { canEditDashboards } from "../kit/grants" type H = HtmlBuilder @@ -474,8 +473,7 @@ export const detailPanel = ( ]), ...requirementBlock(h, template, readiness, model.nowMs ?? 0, timeZone), ...parameterFields(h, model, template), - // Without `dashboards:edit` the panel previews the template and offers no create row. - ...(canEditDashboards(model) ? [createRow(h, model, template, isReady)] : []), + createRow(h, model, template, isReady), ], ) } diff --git a/apps/web/src/page/dashboards/templates/view.ts b/apps/web/src/page/dashboards/templates/view.ts index 08b7d4cb1a..f90a78cf37 100644 --- a/apps/web/src/page/dashboards/templates/view.ts +++ b/apps/web/src/page/dashboards/templates/view.ts @@ -17,7 +17,6 @@ import { templateList } from "./view-list" import { detailPanel, panelRestState } from "./view-panel" import { typography } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" -import { canEditDashboards } from "../kit/grants" // Below `lg` the detail panel is a right sheet over the list, else the side pane. @@ -30,7 +29,7 @@ const summary = (h: H, readyCount: number, catalogueCount: number): Html => typography(h, { tone: "muted", mono: true }, [String(catalogueCount), " templates"]), ]) -const failed = (h: H, failure: LoadFailure, canCreate: boolean): Html => +const failed = (h: H, failure: LoadFailure): Html => h.div( [h.Class("p-4")], [ @@ -39,13 +38,10 @@ const failed = (h: H, failure: LoadFailure, canCreate: boolean): Html => title: "Couldn't load the templates", note: "Nothing you've built is affected.", onRetry: Message.ClickedRetry(), - // Starting blank creates a dashboard, so it needs `dashboards:edit`. - secondaryAction: canCreate - ? { - label: "Start blank instead", - onClick: Message.ClickedCreate({ templateId: BLANK_TEMPLATE_ID }), - } - : undefined, + secondaryAction: { + label: "Start blank instead", + onClick: Message.ClickedCreate({ templateId: BLANK_TEMPLATE_ID }), + }, }), ], ) @@ -82,7 +78,7 @@ export const view = Submodel.defineView((model, }, [ failure !== null - ? failed(h, failure, canEditDashboards(model)) + ? failed(h, failure) : hstack(h, { align: "stretch", className: "h-full min-h-0" }, [ vstack( h, diff --git a/apps/web/src/page/dashboards/view/analyst.scene.test.ts b/apps/web/src/page/dashboards/view/analyst.scene.test.ts deleted file mode 100644 index 9a06b72f5f..0000000000 --- a/apps/web/src/page/dashboards/view/analyst.scene.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { OrgId } from "@maple/domain" -import { DashboardDocument } from "@maple/domain/http" -import * as Policy from "@maple/domain/policy" -import { Option, Result, Schema, Array as Arr } from "effect" -import { Mount, expect as expectView, given, role, scene, text, withViewInputs } from "foldkit/scene" -import { ResolveThemeColors, ThemeMessage } from "@maple/views/charts/theme-colors" -import { describe, expect, test } from "vitest" -import * as Menu from "@maple/views/ui/menu" -import type { PageContext } from "../../contract" -import { MeasureGrid, WatchGridHandles, WatchWidget } from "./command" -import { Message } from "./message" -import { isEditing } from "./mode" -import type { Model } from "./model" -import { init, update } from "./update" -import { view as pageView } from "./view" - -// An analyst reads a board; editing needs `dashboards:edit` and sharing `dashboards:share`. - -const orgId = Schema.decodeSync(OrgId)("org_acme") -const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } -const DASHBOARD_ID = "dash_platform" -const pageUpdate = (model: Model, message: Message) => update(model, message, context) -const run = (model: Model, messages: ReadonlyArray) => - Arr.reduce(messages, model, (current, message) => pageUpdate(current, message).model) -const view = withViewInputs(pageView, { context })() -const themed = () => Mount.resolve(ResolveThemeColors, ThemeMessage.ResolvedThemeColors({ colors: {} })) -/** The canvas measured and its one tile in view. */ -const mounted = () => [ - themed(), - Mount.resolve(MeasureGrid, Message.MeasuredGrid({ width: 1200 })), - Mount.resolve(WatchGridHandles, Message.BlurredGrip({ widgetId: "note" })), - Mount.resolve(WatchWidget, Message.RevealedWidget({ widgetId: "note" })), -] - -const note = { - id: "note", - visualization: "markdown", - dataSource: { kind: "static" }, - display: { title: "Runbook", markdown: { content: "Page the platform on-call first." } }, - layout: { x: 0, y: 0, w: 6, h: 2 }, -} - -const board = (widgets: ReadonlyArray) => - Schema.decodeUnknownSync(DashboardDocument)({ - id: DASHBOARD_ID, - name: "Platform overview", - timeRange: { type: "relative", value: "6h" }, - widgets, - createdAt: "2026-09-19T12:00:00.000Z", - updatedAt: "2026-09-28T12:00:00.000Z", - schemaVersion: 3, - }) - -const version = { - id: "0192a3b4-0000-7000-8000-000000000002", - versionNumber: 2, - changeKind: "widget_added" as const, - changeSummary: null, - isRestore: false, - createdAt: "2026-09-29T12:00:00.000Z", - createdBy: "user_ada", -} - -const MEMBER = Policy.grantsOf(["org:member"]) -const ANALYST = Policy.grantsOf(["org:analyst"]) -type Grants = ReadonlyArray - -/** Entered, clocked, holding the session's grants and the document. */ -const ready = (grants: Grants, search: { mode?: "edit" } = {}, document = board([note])): Model => - run(init({ dashboardId: DASHBOARD_ID, search }, context).model, [ - Message.CompletedReadClock({ nowMs: Date.parse("2026-10-01T12:00:00Z") }), - Message.CompletedFetchSession({ orgId, grants }), - Message.UpdatedDashboard({ orgId, maybeDocument: Option.some(document) }), - ]) - -const withMoreMenuOpen = (model: Model): Model => ({ - ...model, - moreMenu: Menu.init(model.moreMenu.id, { isOpen: true }), -}) - -/** History opened, then v2 previewed with its snapshot loaded. */ -const previewV2 = (model: Model): Model => - run(model, [ - Message.GotMoreMenuMessage({ - message: { _tag: "ClickedItem", index: 0, item: { kind: "Item", value: "history" } }, - }), - Message.CompletedFetchVersions({ - generation: model.history.versionsGeneration + 1, - nowMs: 0, - result: Result.succeed([version]), - }), - Message.ClickedVersion({ versionId: version.id }), - Message.CompletedFetchSnapshot({ versionId: version.id, result: Result.succeed(board([note])) }), - ]) - -describe("a dashboard for an analyst", () => { - test("the board and its widgets render without an Edit control", () => { - scene( - { update: pageUpdate, view }, - given(ready(ANALYST)), - ...mounted(), - expectView(text("Page the platform on-call first.")).toExist(), - expectView(role("button", { name: "Reload" })).toExist(), - expectView(role("button", { name: "Edit dashboard" })).toBeAbsent(), - ) - }) - - test("a member gets the Edit control", () => { - scene( - { update: pageUpdate, view }, - given(ready(MEMBER)), - ...mounted(), - expectView(role("button", { name: "Edit dashboard" })).toExist(), - ) - }) - - test("?mode=edit lands on the read view", () => { - const model = ready(ANALYST, { mode: "edit" }) - expect(isEditing(model)).toBe(false) - scene( - { update: pageUpdate, view }, - given(model), - ...mounted(), - expectView(text("Page the platform on-call first.")).toExist(), - expectView(role("button", { name: "Done editing" })).toBeAbsent(), - expectView(role("button", { name: "Add widget" })).toBeAbsent(), - expectView(role("button", { name: "Widget actions" })).toBeAbsent(), - ) - }) - - test("?mode=edit edits for a member", () => { - const model = ready(MEMBER, { mode: "edit" }) - expect(isEditing(model)).toBe(true) - scene( - { update: pageUpdate, view }, - given(model), - ...mounted(), - expectView(role("button", { name: "Done editing" })).toExist(), - expectView(role("button", { name: "Add widget" })).toExist(), - ) - }) - - test("the name is a heading, not a rename control", () => { - const model = ready(ANALYST) - expect(pageUpdate(model, Message.ClickedTitle()).model.titleDraft).toBeNull() - scene( - { update: pageUpdate, view }, - given(model), - ...mounted(), - expectView(role("heading", { name: "Platform overview" })).toExist(), - expectView(role("button", { name: "Platform overview" })).toBeAbsent(), - ) - }) - - test("a member renames from the name", () => { - const model = ready(MEMBER) - expect(pageUpdate(model, Message.ClickedTitle()).model.titleDraft).toBe("Platform overview") - scene( - { update: pageUpdate, view }, - given(model), - ...mounted(), - expectView(role("button", { name: "Platform overview" })).toExist(), - ) - }) - - test("the more menu keeps history and export and drops Share", () => { - scene( - { update: pageUpdate, view }, - given(withMoreMenuOpen(ready(ANALYST))), - ...mounted(), - Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), - expectView(role("menuitem", { name: "Version history" })).toExist(), - expectView(role("menuitem", { name: "Export as JSON" })).toExist(), - expectView(role("menuitem", { name: "Share…" })).toBeAbsent(), - ) - }) - - test("a member's more menu shares", () => { - scene( - { update: pageUpdate, view }, - given(withMoreMenuOpen(ready(MEMBER))), - ...mounted(), - Mount.resolve(Menu.MountMenu, Menu.Message.CompletedMountMenu()), - expectView(role("menuitem", { name: "Share…" })).toExist(), - ) - }) - - test("a previewed version can be left but not restored", () => { - const previewed = previewV2(ready(ANALYST)) - expect(previewed.history.previewed?.id).toBe(version.id) - expect(pageUpdate(previewed, Message.ClickedRestoreVersion()).model.history.restoreDialog).toEqual( - previewed.history.restoreDialog, - ) - scene( - { update: pageUpdate, view }, - given(previewed), - ...mounted(), - expectView(role("button", { name: "Cancel" })).toExist(), - expectView(role("button", { name: /Restore this version/ })).toBeAbsent(), - ) - }) - - test("a member restores a previewed version", () => { - scene( - { update: pageUpdate, view }, - given(previewV2(ready(MEMBER))), - ...mounted(), - expectView(role("button", { name: /Restore this version/ })).toBeEnabled(), - ) - }) - - test("an empty board offers nothing to add", () => { - scene( - { update: pageUpdate, view }, - given(ready(ANALYST, {}, board([]))), - themed(), - expectView(text("Nothing on this board yet")).toExist(), - expectView(role("button", { name: "Add your first widget" })).toBeAbsent(), - expectView(role("link", { name: "Start from a template" })).toBeAbsent(), - ) - }) - - test("a member's empty board offers the first widget", () => { - scene( - { update: pageUpdate, view }, - given(ready(MEMBER, {}, board([]))), - themed(), - expectView(role("button", { name: "Add your first widget" })).toExist(), - ) - }) -}) diff --git a/apps/web/src/page/dashboards/view/command.ts b/apps/web/src/page/dashboards/view/command.ts index 683694661c..502b394d7a 100644 --- a/apps/web/src/page/dashboards/view/command.ts +++ b/apps/web/src/page/dashboards/view/command.ts @@ -32,12 +32,9 @@ import { ShareMessage } from "./share" import { settleTo } from "@maple/views/data/load-failure" import { presetById } from "./picker-catalog" import { applySectionEdit, SectionEdit } from "./section-edit" -import { fetchSessionGrants } from "../../kit/session-grants" export const ReadClock = readClock("ReadDashboardClock", Message.CompletedReadClock) -export const FetchBoardSession = fetchSessionGrants("FetchDashboardSession", Message.CompletedFetchSession) - /** The chart code failed to load: a fresh page load is the only way to fetch it again. */ export const ReloadPage = Command.define("ReloadDashboardPage", { messages: [Message.ReloadedPage], diff --git a/apps/web/src/page/dashboards/view/history-update.ts b/apps/web/src/page/dashboards/view/history-update.ts index 1740bbf647..5be32ef5b5 100644 --- a/apps/web/src/page/dashboards/view/history-update.ts +++ b/apps/web/src/page/dashboards/view/history-update.ts @@ -6,7 +6,6 @@ import { FetchSnapshot, FetchVersions, RestoreVersion } from "./command-history" import type { HistoryState } from "./history" import { Message } from "./message" import { isWriteBlocked } from "./mode" -import { canEditDashboards } from "../kit/grants" import type { Model } from "./model" type Return = PageReturn @@ -117,9 +116,7 @@ export const historyStep = (model: Model, message: HistoryMessage): Return => { case "ClickedCancelPreview": return { model: closePreview(model) } case "ClickedRestoreVersion": - return isWriteBlocked(model) || !canEditDashboards(model) - ? { model } - : dialogStep(model, Dialog.open(history.restoreDialog)) + return isWriteBlocked(model) ? { model } : dialogStep(model, Dialog.open(history.restoreDialog)) case "GotRestoreDialogMessage": return dialogStep(model, Dialog.update(history.restoreDialog, message.message)) case "ConfirmedRestore": diff --git a/apps/web/src/page/dashboards/view/message.ts b/apps/web/src/page/dashboards/view/message.ts index 1d591ee9ed..a587825b01 100644 --- a/apps/web/src/page/dashboards/view/message.ts +++ b/apps/web/src/page/dashboards/view/message.ts @@ -17,7 +17,6 @@ import { WriteSync } from "../kit/store" import { LoadFailure } from "@maple/views/data/load-failure" import { VersionRow } from "./history" import { ShareMessage } from "./share" -import { SessionGrants } from "../../kit/session-grants" /** A write's outcome: the failure is the message the persistence banner shows. */ const WriteResult = Schema.Result(WriteSync, Schema.String) @@ -33,7 +32,6 @@ export const Message = defineMessageUnion({ /** "Retry sync" on the snapshot banner, or Try again on the load error: reconnect, read again. */ ClickedRetrySync: {}, CompletedReadClock: { nowMs: Schema.Number }, - CompletedFetchSession: SessionGrants, MeasuredGrid: { width: Schema.Number }, MeasuredWidget: { widgetId: Schema.String, widthPx: Schema.Number }, RevealedWidget: { widgetId: Schema.String }, diff --git a/apps/web/src/page/dashboards/view/mode.ts b/apps/web/src/page/dashboards/view/mode.ts index fe503c22c6..12c56c2c9e 100644 --- a/apps/web/src/page/dashboards/view/mode.ts +++ b/apps/web/src/page/dashboards/view/mode.ts @@ -1,16 +1,9 @@ -import { canEditDashboards } from "../kit/grants" import type { Model } from "./model" /** Writes are off: a write failed, or the board is an HTTP snapshot while live sync is down. */ export const isWriteBlocked = (model: Model): boolean => model.persistenceError !== null || model.snapshot._tag === "Shown" -/** - * Edit only from the URL, never while writes are unavailable or a version is previewed. A viewer - * without `dashboards:edit` who opens `?mode=edit` gets the read view. - */ +/** Edit only from the URL, never while writes are unavailable or a version is previewed. */ export const isEditing = (model: Model): boolean => - model.search.mode === "edit" && - canEditDashboards(model) && - !isWriteBlocked(model) && - model.history.previewed === null + model.search.mode === "edit" && !isWriteBlocked(model) && model.history.previewed === null diff --git a/apps/web/src/page/dashboards/view/model.ts b/apps/web/src/page/dashboards/view/model.ts index e9d74a88fd..686d0eb61d 100644 --- a/apps/web/src/page/dashboards/view/model.ts +++ b/apps/web/src/page/dashboards/view/model.ts @@ -1,5 +1,4 @@ import { DashboardDocument } from "@maple/domain/http" -import * as Policy from "@maple/domain/policy" import { Schema } from "effect" import * as Chart from "@maple/views/charts/chart" import { ChartRuntime } from "@maple/views/charts/chart-runtime" @@ -152,8 +151,6 @@ export const Model = Schema.Struct({ dashboardId: Schema.String, search: DashboardSearch, status: Status, - /** What the viewer may do; an analyst reads the board and gets no edit or share control. */ - grants: Schema.Array(Policy.PermissionName), document: Schema.NullOr(DashboardDocument), /** The live board never answered within `COLLECTION_LOAD_TIMEOUT_MS`: the HTTP snapshot's state. */ snapshot: Snapshot, diff --git a/apps/web/src/page/dashboards/view/update.ts b/apps/web/src/page/dashboards/view/update.ts index 036829c2cd..b7de643f9c 100644 --- a/apps/web/src/page/dashboards/view/update.ts +++ b/apps/web/src/page/dashboards/view/update.ts @@ -27,7 +27,6 @@ import { EditSections, CompleteReloadSpin, ExportDashboard, - FetchBoardSession, FetchShares, ReadClock, ReloadPage, @@ -70,19 +69,16 @@ import { SectionEdit } from "./section-edit" import { moveTargetOf } from "./move-target" import * as TagEditor from "../kit/tag-editor" import { createAlertSearch } from "./widget-actions" -import { acceptGrants } from "../../kit/session-grants" -import { canEditDashboards, canShareDashboards, initialGrants, readsSessionGrants } from "../kit/grants" export type Return = PageReturn type Search = typeof DashboardSearch.Type type Route = Readonly<{ dashboardId: string; search: Search }> -export const init = (route: Route, context: PageContext): Return => ({ +export const init = (route: Route, _context: PageContext): Return => ({ model: { dashboardId: route.dashboardId, search: route.search, status: "Loading", - grants: initialGrants, document: null, snapshot: { _tag: "Idle" }, nowMs: null, @@ -134,11 +130,7 @@ export const init = (route: Route, context: PageContext): Return => ({ variableComboName: null, variableDrafts: {}, }, - commands: [ - ReadClock(), - LoadChartRuntime(), - ...(readsSessionGrants ? [FetchBoardSession({ orgId: context.orgId })] : []), - ], + commands: [ReadClock(), LoadChartRuntime()], }) const sameValues = (a: Readonly>, b: Readonly>): boolean => @@ -456,7 +448,7 @@ const moreAction = (model: Model, context: PageContext, value: string): Return = ? { model, commands: [AutoLayout({ orgId: context.orgId, dashboardId: model.dashboardId })] } : { model } case "share": - return canShareDashboards(model) ? openShare(model) : { model } + return openShare(model) case "export": // A preview still loading has no snapshot yet; exporting now would export the live board. if (model.history.previewed !== null && shownDocumentOf(model) === null) return { model } @@ -555,9 +547,7 @@ const widgetAction = (model: Model, context: PageContext, widgetId: string, valu ) } case "embed": - return model.history.previewed === null && canShareDashboards(model) - ? openEmbed(model, widgetId) - : { model } + return model.history.previewed === null ? openEmbed(model, widgetId) : { model } default: return { model } } @@ -613,11 +603,6 @@ export const update = (model: Model, message: Message, context: PageContext): Re model.status === "Failed" ? readSnapshot(model, "retry") : { model, outMessage: PageOutMessage.RequestedReconnectOrgCollections() }, - // Grants decide whether `?mode=edit` edits, which moves the range the tiles are planned on. - CompletedFetchSession: (answer) => { - const next = acceptGrants(model, context.orgId, answer) - return isEditing(next) === isEditing(model) ? { model: next } : withReplan(next, context, "Load") - }, CompletedReadClock: ({ nowMs }) => withReplan({ ...model, nowMs }, context, model.refreshVersion > 0 ? "Refresh" : "Load"), MeasuredGrid: ({ width }) => cancelStaleGesture({ ...model, gridWidth: width }, context), @@ -752,10 +737,7 @@ export const update = (model: Model, message: Message, context: PageContext): Re }) }, ClickedTitle: () => - !canEditDashboards(model) || - isWriteBlocked(model) || - model.document === null || - model.history.previewed !== null + isWriteBlocked(model) || model.document === null || model.history.previewed !== null ? { model } : { model: { ...model, titleDraft: model.document.name } }, ChangedTitleDraft: ({ value }) => ({ model: { ...model, titleDraft: value } }), diff --git a/apps/web/src/page/dashboards/view/view-history.ts b/apps/web/src/page/dashboards/view/view-history.ts index 467b1ecf4c..42ce75a7ff 100644 --- a/apps/web/src/page/dashboards/view/view-history.ts +++ b/apps/web/src/page/dashboards/view/view-history.ts @@ -17,7 +17,6 @@ import { sectionHeading } from "../../kit/section-heading" import { actorInitials, KIND_DOT, KIND_LABEL, type VersionRow } from "./history" import { Message } from "./message" import { isWriteBlocked } from "./mode" -import { canEditDashboards } from "../kit/grants" import type { Model } from "./model" import { typography, typographyClass } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" @@ -222,28 +221,9 @@ export const historyPanel = (h: H, model: Model, clock: Clock): Html => { const RESTORE_BLOCKED_ID = "dashboard-restore-blocked" -const restoreButton = (h: H, isRestoreBlocked: boolean): Html => - button( - h, - { - variant: "default", - size: "sm", - isDisabled: isRestoreBlocked, - attributes: [ - h.OnClick(Message.ClickedRestoreVersion()), - ...(isRestoreBlocked ? [h.AriaDescribedBy(RESTORE_BLOCKED_ID)] : []), - ], - }, - [ - icon(h, "ArrowPathIcon", { attributes: [h.DataAttribute("icon", "inline-start")] }), - "Restore this version", - ], - ) - -/** Restore is a write, so it is off when writes are, and not offered without `dashboards:edit`. */ +/** Restore is a write, so it is off when writes are. */ export const previewBanner = (h: H, model: Model, version: VersionRow, clock: Clock): ReadonlyArray => { - const canRestore = canEditDashboards(model) - const isRestoreBlocked = canRestore && isWriteBlocked(model) + const isRestoreBlocked = isWriteBlocked(model) return [ hstack( h, @@ -290,7 +270,24 @@ export const previewBanner = (h: H, model: Model, version: VersionRow, clock: Cl }, ["Cancel"], ), - ...(canRestore ? [restoreButton(h, isRestoreBlocked)] : []), + button( + h, + { + variant: "default", + size: "sm", + isDisabled: isRestoreBlocked, + attributes: [ + h.OnClick(Message.ClickedRestoreVersion()), + ...(isRestoreBlocked ? [h.AriaDescribedBy(RESTORE_BLOCKED_ID)] : []), + ], + }, + [ + icon(h, "ArrowPathIcon", { + attributes: [h.DataAttribute("icon", "inline-start")], + }), + "Restore this version", + ], + ), ]), ], ), diff --git a/apps/web/src/page/dashboards/view/view-toolbar.ts b/apps/web/src/page/dashboards/view/view-toolbar.ts index 99135c25d9..bbd88082f3 100644 --- a/apps/web/src/page/dashboards/view/view-toolbar.ts +++ b/apps/web/src/page/dashboards/view/view-toolbar.ts @@ -17,7 +17,6 @@ import { isWriteBlocked } from "./mode" import { variableSelects } from "./view-variables" import type { PageContext } from "../../contract" import { hstack } from "@maple/views/ui/stack" -import { canEditDashboards, canShareDashboards } from "../kit/grants" type H = HtmlBuilder @@ -31,7 +30,7 @@ const refreshControls = (h: H, model: Model): Html => toMenuMessage: (message) => Message.GotRefreshMenuMessage({ message }), }) -const moreEntries = (isEdit: boolean, canShare: boolean): ReadonlyArray => [ +const moreEntries = (isEdit: boolean): ReadonlyArray => [ ...(isEdit ? ([ { @@ -72,17 +71,7 @@ const moreEntries = (isEdit: boolean, canShare: boolean): ReadonlyArray rowActionsMenu(h, { slotId: model.moreMenu.id, model: model.moreMenu, - entries: moreEntries(isEditing(model), canShareDashboards(model)), + entries: moreEntries(isEditing(model)), label: "More dashboard actions", toParentMessage: (message) => Message.GotMoreMenuMessage({ message }), }) -/** Edit, or Done while editing: one element across both, so focus survives the toggle. */ -const editToggle = (h: H, isEdit: boolean, isReadOnly: boolean): Html => - button( - h, - { - variant: isEdit ? "default" : "outline", - size: "sm", - isDisabled: isReadOnly, - attributes: [ - h.Key("toggle-edit"), - h.AriaLabel(isEdit ? "Done editing" : "Edit dashboard"), - h.OnClick(Message.ClickedToggleEdit()), - ], - }, - [ - icon(h, isEdit ? "CheckIcon" : "PencilIcon", { - attributes: [h.DataAttribute("icon", "inline-start")], - }), - h.span([h.Class("hidden @min-[560px]/page:inline")], [isEdit ? "Done" : "Edit"]), - ], - ) - /** The trigger's label; a custom window reads like the widget chips ("Last 2 hours", or both ends). */ const timeLabel = (timeRange: TimeRange | null, timeZone: string, nowMs: number | null): string => timeRange === null @@ -182,7 +149,26 @@ export const toolbar = (h: H, model: Model, context: PageContext): Html => { ), ] : []), - ...(canEditDashboards(model) ? [editToggle(h, isEdit, isReadOnly)] : []), + button( + h, + { + variant: isEdit ? "default" : "outline", + size: "sm", + isDisabled: isReadOnly, + attributes: [ + // One element across Edit and Done, so focus survives the toggle. + h.Key("toggle-edit"), + h.AriaLabel(isEdit ? "Done editing" : "Edit dashboard"), + h.OnClick(Message.ClickedToggleEdit()), + ], + }, + [ + icon(h, isEdit ? "CheckIcon" : "PencilIcon", { + attributes: [h.DataAttribute("icon", "inline-start")], + }), + h.span([h.Class("hidden @min-[560px]/page:inline")], [isEdit ? "Done" : "Edit"]), + ], + ), moreMenu(h, model), ]), ], diff --git a/apps/web/src/page/dashboards/view/view-widgets.ts b/apps/web/src/page/dashboards/view/view-widgets.ts index 6d46913e13..dbecef7827 100644 --- a/apps/web/src/page/dashboards/view/view-widgets.ts +++ b/apps/web/src/page/dashboards/view/view-widgets.ts @@ -21,7 +21,6 @@ import { isAlertable } from "./widget-actions" import { moveValue } from "./move-target" import { Schema, Array as Arr, Record as Rec } from "effect" import { DashboardWidgetSchema } from "@maple/widgets/dashboard" -import { canShareDashboards } from "../kit/grants" type H = HtmlBuilder type Widget = DashboardDocument["widgets"][number] @@ -70,9 +69,7 @@ const tileInputsOf = (model: Model, widget: Widget): TileInputs => { isEdit, sections: isEdit ? model.document?.sections : undefined, // A previewed version's widgets may not exist on the live board, so they are not embeddable. - // Embedding shares the widget outside the org, so it is offered only with `dashboards:share`. - isSharesLoaded: - model.shares === "Loaded" && model.history.previewed === null && canShareDashboards(model), + isSharesLoaded: model.shares === "Loaded" && model.history.previewed === null, menu: model.widgetMenuId === widget.id ? model.widgetMenu : null, chart: model.charts[widget.id], sparklineChart: model.charts[`${widget.id}:spark`], diff --git a/apps/web/src/page/dashboards/view/view.ts b/apps/web/src/page/dashboards/view/view.ts index d3b3345aa8..7de0b3fdef 100644 --- a/apps/web/src/page/dashboards/view/view.ts +++ b/apps/web/src/page/dashboards/view/view.ts @@ -45,7 +45,6 @@ import { errorState } from "@maple/views/ui/error-state" import { typography } from "@maple/views/ui/typography" import { hstack, vstack } from "@maple/views/ui/stack" import { grid } from "@maple/views/ui/grid" -import { canEditDashboards } from "../kit/grants" type H = HtmlBuilder @@ -73,31 +72,23 @@ const title = (h: H, model: Model, name: string): Html => { ) const isBlank = Str.trim(name) === "" const shown = isBlank ? "Untitled dashboard" : name - // Without `dashboards:edit` the name is a plain heading, not a rename control. - const canRename = canEditDashboards(model) return pageTitle( h, { title: shown, className: cn( - canRename && - !isReadOnly && - "cursor-pointer hover:text-foreground-soft active:text-foreground-soft", + !isReadOnly && "cursor-pointer hover:text-foreground-soft active:text-foreground-soft", isBlank && "text-muted-foreground", ), - attributes: canRename - ? [ - h.Role("button"), - h.Tabindex(0), - h.OnClick(Message.ClickedTitle()), - h.OnKeyDownPreventDefault((key) => - key === "Enter" || key === " " - ? Option.some(Message.ClickedTitle()) - : Option.none(), - ), - h.AriaDisabled(isReadOnly), - ] - : [], + attributes: [ + h.Role("button"), + h.Tabindex(0), + h.OnClick(Message.ClickedTitle()), + h.OnKeyDownPreventDefault((key) => + key === "Enter" || key === " " ? Option.some(Message.ClickedTitle()) : Option.none(), + ), + h.AriaDisabled(isReadOnly), + ], }, [shown], ) @@ -151,22 +142,6 @@ const GRID_PREVIEW = [ { glyph: "TextWrapIcon", className: "h-16 w-10" }, ] as const -/** A board with no widgets for a viewer who cannot add one. */ -const blankReadOnlyDashboard = (h: H): Html => - empty( - h, - [ - emptyHeader(h, [ - emptyTitle(h, ["Nothing on this board yet"]), - emptyDescription(h, [ - "Widgets added to this dashboard show up here. Your role can read it but not add one.", - ]), - ]), - emptyContent(h, [docsLink(h, "dashboards")], "max-w-none"), - ], - "py-16", - ) - const blankDashboard = (h: H, isReadOnly: boolean): Html => empty( h, @@ -296,9 +271,7 @@ const body = (h: H, model: Model, context: PageContext): ReadonlyArray => return [ ...banner, isBlank - ? canEditDashboards(model) - ? blankDashboard(h, isWriteBlocked(model)) - : blankReadOnlyDashboard(h) + ? blankDashboard(h, isWriteBlocked(model)) : dashboardSections( h, { model, timeZone, variables: variablesOf(model, context).values }, diff --git a/packages/domain/src/policy/permissions.ts b/packages/domain/src/policy/permissions.ts index c9156e8058..716cf4b641 100644 --- a/packages/domain/src/policy/permissions.ts +++ b/packages/domain/src/policy/permissions.ts @@ -11,10 +11,9 @@ export const permissions = { // The public ingest key ships in browser bundles, so every member may read it to set up an app. "ingest_keys:view_public": { min: "member", action: "view the public ingest key" }, - // Shared work on what the telemetry shows: members and above. Analysts read it. The API refuses - // an analyst every change in one place (`AnalystAccess`); these name them for the dashboard. - "dashboards:edit": { min: "member", action: "create and edit dashboards" }, - "dashboards:share": { min: "member", action: "share dashboards outside the organization" }, + // 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" },