Skip to content
4 changes: 3 additions & 1 deletion apps/web/src/lab/shell-frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import type { Message as ShellMessage } from "../shell/message"
import type { Model as ShellModel } from "../shell/model"
import type { Model as ShellModel, ShellClerk } from "../shell/model"
import { update as shellUpdate } from "../shell/update"
import { type BreadcrumbEntry, frameView, menuLayerView, sheetLayerView } from "../shell/view"

Expand Down Expand Up @@ -30,6 +30,8 @@
currentPath: string
breadcrumbs: ReadonlyArray<BreadcrumbEntry>
page: () => Html
/** Who the shell is drawn for; a lab has no Clerk session, so it hands in a fixture. */
clerk?: ShellClerk | null

Check notice on line 34 in apps/web/src/lab/shell-frame.ts

View check run for this annotation

Maple Review Bot / Maple / review

correctness: The lab's `clerk` fixture reaches `frameView` but not the sheet or menu layers

The new input is only handed to `frameView`; the sheet and menu layers below still get `{ currentPath }` alone, so at a phone width `/lab/shell` with Viewer on Analyst opens the mobile nav through `shellNavGroups(null)` and draws the operator sidebar with Sources and Settings.
}>,
): ReadonlyArray<Html> => [
h.submodel({
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/lab/shell-states-lab.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,31 @@ describe("URL-held right sheet", () => {
)
})
})

describe("the shell for an analyst", () => {
const setup = [
role("link", { name: "Sources" }),
role("link", { name: "Settings" }),
role("button", { name: "Connect" }),
]

test("a member has the Sources and Settings rows and the Connect button", () => {
scene(
{ update, view },
given(at("/lab/shell")),
measured,
...Arr.map(setup, (it) => expect(it).toExist()),
)
})

test("an analyst has none of them, and keeps their own pages", () => {
scene(
{ update, view },
given(at("/lab/shell")),
measured,
click(role("button", { name: "Analyst" })),
...Arr.map(setup, (it) => expect(it).toBeAbsent()),
expect(role("link", { name: "Web analytics" })).toExist(),
)
})
})
30 changes: 29 additions & 1 deletion apps/web/src/lab/shell-states-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import type { Resources } from "../platform/resources"
import { dashboardPage } from "../page/kit/dashboard-page"
import { dismissSheetUrl, pushSheetUrl, sheetParamOf } from "../page/kit/sheet-param"
import { Message as ShellMessage, Theme } from "../shell/message"
import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model"
import { init as initShell, Model as ShellModel, OnCallData, type ShellClerk } from "../shell/model"
import { NavAudience } from "../shell/nav"
import type { OnCallSnapshot } from "../shell/oncall"
import { foldLabShell, labShellView } from "./shell-frame"

Expand Down Expand Up @@ -106,6 +107,7 @@ export const Model = Schema.Struct({
href: Schema.String,
onCall: OnCallFixture,
standOn: StandOn,
viewer: NavAudience,
})
export type Model = typeof Model.Type

Expand All @@ -119,6 +121,7 @@ export const Message = defineMessageUnion({
ClickedSubmitPanel: {},
ClickedOnCallFixture: { fixture: OnCallFixture },
ClickedStandOn: { standOn: StandOn },
ClickedViewer: { viewer: NavAudience },
CompletedNavigate: {},
})
export type Message = typeof Message.Type
Expand Down Expand Up @@ -164,6 +167,15 @@ const sheetStep = (model: Model, step: SheetStep): Return => ({
],
})

/** A signed-in analyst with nothing else read yet: all the shell needs to pick their sidebar. */
const ANALYST_CLERK: ShellClerk = {
maybeUser: Option.none(),
maybeOrganization: Option.none(),
memberships: [],
customer: undefined,
maybeRole: Option.some("org:analyst"),
}

/** The fixture's shifts, read on the lab's fixed clock. */
const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({
...shell,
Expand All @@ -184,6 +196,7 @@ export const init = (flags: Flags): Return => {
href: flags.href,
onCall: "onCall",
standOn: "lab",
viewer: "operator",
},
commands: mapSheetCommands(sheet, toSheet),
}
Expand Down Expand Up @@ -222,6 +235,7 @@ export const update = (model: Model, message: Message): Return =>
model: { ...model, onCall: fixture, shell: withOnCall(model.shell, fixture) },
}),
ClickedStandOn: ({ standOn }) => ({ model: { ...model, standOn } }),
ClickedViewer: ({ viewer }) => ({ model: { ...model, viewer } }),
CompletedNavigate: () => ({ model }),
})

Expand Down Expand Up @@ -341,6 +355,19 @@ const page = (h: HtmlBuilder<Message>, model: Model): Html =>
],
}),
),
control(
h,
"Viewer",
segmentedSelect(h, {
ariaLabel: "Viewer",
value: model.viewer,
onChange: (viewer) => Message.ClickedViewer({ viewer }),
options: [
{ value: "operator", label: "Member and above" },
{ value: "analyst", label: "Analyst" },
],
}),
),
control(
h,
"Current page",
Expand Down Expand Up @@ -368,6 +395,7 @@ export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
TooltipHost.view(h, model.tooltip, toTooltip),
...labShellView(h, model.shell, toShell, {
currentPath: STAND_ON_PATH[model.standOn],
clerk: model.viewer === "analyst" ? ANALYST_CLERK : null,
breadcrumbs: [{ label: "Lab" }, { label: "Shell states" }],
page: () => page(h, model),
}),
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/shell/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { InfraSurfaceLiteral } from "@maple/domain/http"
import { V2Source } from "@maple/domain/http/v2"
import type { BillingCustomer } from "@maple/domain/http"
import type { ClerkOrganization, ClerkUser } from "../platform/clerk"
import * as Policy from "@maple/domain/policy"
import { Option, Schema } from "effect"
import { AsyncData } from "foldkit"
import { PopupPosition } from "@maple/views/ui/anchor"
Expand All @@ -15,7 +16,7 @@ import { PinnedDashboardRow } from "./dashboards-shape"
import * as Palette from "./palette/model"
import { Platform } from "./palette/shortcuts"
import { Menu, Theme } from "./message"
import { NavSurface } from "./nav"
import { type NavAudience, NavSurface } from "./nav"
import { OnCallSnapshot } from "./oncall"
import {
type InfraSurfaces,
Expand Down Expand Up @@ -133,6 +134,12 @@ export type ShellClerk = Readonly<{
maybeRole: Option.Option<string>
}>

/** 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.isAnalyst([role]))
? "analyst"
: "operator"

/** A rail item's tooltip: right of the item, centred. */
export const initRailTooltip = (): Tooltip.Model =>
Tooltip.init("shell-rail-tooltip", { side: "right", align: "center" })
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/shell/nav.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,48 @@ describe("isNavItemActive", () => {
})
})

describe("navGroups for an analyst", () => {
const off = { cloudCost: false, releases: false, securityAudit: false, statusPages: false }
/** Every page the analyst's sidebar links to: rows, then their sub-rows. */
const hrefs = (flags: NavFlags) =>
Arr.dedupe(
Arr.flatMap(navGroups(flags, "analyst"), (group) =>
Arr.flatMap(group.items, (item) => [
item.href,
...Arr.map(item.subItems ?? [], (sub) => sub.href),
]),
),
)

test("links to what is wrong and how the product is doing, and to no setup or explorer page", () => {
expect(hrefs(off)).toEqual([
"/",
"/alerts",
"/alerts/uptime",
"/alerts/slos",
"/errors",
"/analytics",
"/users",
"/replays",
"/dashboards",
])
})

test("adds Status pages, Releases and Cost only for an org in each rollout", () => {
expect(hrefs({ ...off, statusPages: true })).toContain("/status-pages")
expect(hrefs({ ...off, releases: true })).toContain("/releases")
expect(hrefs({ ...off, cloudCost: true })).toContain("/cost")
// Security is an engineer's page, whatever the flag says.
expect(hrefs({ ...off, securityAudit: true })).not.toContain("/security")
})

test("marks only Incidents on an uptime page, with its Uptime row", () => {
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"])
})
})

describe("navGroups", () => {
const titles = (flags: NavFlags) =>
Arr.flatMap(navGroups(flags), (group) => Arr.map(group.items, (item) => item.title))
Expand Down
68 changes: 66 additions & 2 deletions apps/web/src/shell/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,33 @@ const alertsItem: 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",
alsoActive: ["/anomalies"],
subItems: [
{
title: "Inbox",
href: "/alerts",
icon: "BellIcon",
paletteTitle: "Incidents",
alsoActive: ["/anomalies"],
count: "alertsInbox",
},
{ title: "Uptime", href: "/alerts/uptime", icon: "GlobeIcon", paletteTitle: "Uptime checks" },
{ title: "SLOs", href: "/alerts/slos", icon: "GaugeIcon" },
],
}

/**
* 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", "analyst"])
export type NavAudience = typeof NavAudience.Type

/** The org rollout flags that gate a nav row each. */
export type NavFlags = Readonly<{
cloudCost: boolean
Expand All @@ -190,8 +217,38 @@ export type NavFlags = Readonly<{
statusPages: boolean
}>

/** `navGroups(flags)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a row each. */
export const navGroups = (flags: NavFlags): ReadonlyArray<NavGroup> => [
const analystNavGroups = (flags: NavFlags): ReadonlyArray<NavGroup> => [
{ id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] },
{
id: "reliability",
label: "Reliability",
items: [
analystIncidentsItem,
{ title: "Errors", href: "/errors", icon: "CircleWarningIcon" },
...(flags.statusPages
? [{ title: "Status pages", href: "/status-pages", icon: "WindowChartLineIcon" } as const]
: []),
],
},
{
id: "product",
label: "Product",
items: [
{ title: "Web analytics", href: "/analytics", icon: "ChartBarHorizontalIcon" },
{ title: "Users", href: "/users", icon: "UsersIcon" },
{ title: "Replays", href: "/replays", icon: "PlayRotateClockwiseIcon" },
{ title: "Dashboards", href: "/dashboards", icon: "GridSquareCirclePlusIcon" },
...(flags.releases
? [{ title: "Releases", href: "/releases", icon: "RocketIcon" } as const]
: []),
...(flags.cloudCost
? [{ title: "Cost", href: "/cost", icon: "CircleDollarIcon", badge: "Beta" } as const]
: []),
],
},
]

const operatorNavGroups = (flags: NavFlags): ReadonlyArray<NavGroup> => [
{ id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] },
{
id: "monitor",
Expand Down Expand Up @@ -245,6 +302,13 @@ export const navGroups = (flags: NavFlags): ReadonlyArray<NavGroup> => [
},
]

/**
* `navGroups(flags, audience)`; `cloudCost`, `releases`, `securityAudit` and `statusPages` gate a
* row each, and an analyst gets {@link analystNavGroups}.
*/
export const navGroups = (flags: NavFlags, audience: NavAudience = "operator"): ReadonlyArray<NavGroup> =>
audience === "analyst" ? analystNavGroups(flags) : operatorNavGroups(flags)

/** Segment-aware prefix match: `/services` must not claim `/service-map`. */
export const isPathActive = (currentPath: string, href: string): boolean =>
href === "/"
Expand Down
73 changes: 51 additions & 22 deletions apps/web/src/shell/palette/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { AppRoute, hrefOf } from "../../route"
import type { IconName } from "@maple/views/ui/icon"
import type { PinnedDashboardRow } from "../dashboards-shape"
import type { Theme } from "../message"
import { navGroups } from "../nav"
import { type NavAudience, type NavGroup, navGroups } from "../nav"

// The palette's entries.

Expand Down Expand Up @@ -64,29 +64,58 @@ export const FLAGGED_HREFS: ReadonlySet<string> = new Set([
const icon = (name: IconName): Glyph => ({ _tag: "Icon", name })
const href = (url: string): Target => ({ _tag: "Href", href: url })

/** Every section, its children and their views, deduplicated by title and href. */
const navigationEntries = (): ReadonlyArray<PaletteEntry> => {
const rows = Arr.flatMap(
navGroups({ cloudCost: true, releases: true, securityAudit: true, statusPages: true }),
(group) =>
Arr.flatMap(group.items, (item) => [
{ id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon },
...Arr.flatMap(item.subItems ?? [], (sub) => [
{
id: `nav:${item.title}:${sub.title}`,
title: sub.paletteTitle ?? sub.title,
href: sub.href,
icon: sub.icon ?? item.icon,
},
...Arr.map(sub.views ?? [], (view) => ({
id: `nav:${item.title}:${sub.title}:${view.title}`,
title: view.paletteTitle ?? `${sub.title} ${view.title}`,
href: view.href,
icon: view.paletteIcon ?? sub.icon ?? item.icon,
})),
]),
const EVERY_FLAG = { cloudCost: true, releases: true, securityAudit: true, statusPages: true }

/** A page's palette row under each title the nav gives it: the section, its children, their views. */
const navRowsOf = (groups: ReadonlyArray<NavGroup>) =>
Arr.flatMap(groups, (group) =>
Arr.flatMap(group.items, (item) => [
{ id: `nav:${item.title}`, title: item.title, href: item.href, icon: item.icon },
...Arr.flatMap(item.subItems ?? [], (sub) => [
{
id: `nav:${item.title}:${sub.title}`,
title: sub.paletteTitle ?? sub.title,
href: sub.href,
icon: sub.icon ?? item.icon,
},
...Arr.map(sub.views ?? [], (view) => ({
id: `nav:${item.title}:${sub.title}:${view.title}`,
title: view.paletteTitle ?? `${sub.title} ${view.title}`,
href: view.href,
icon: view.paletteIcon ?? sub.icon ?? item.icon,
})),
]),
]),
)

/** What an analyst's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */
const ANALYST_TITLES: ReadonlyMap<string, string> = 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 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 = (
entries: ReadonlyArray<PaletteEntry>,
audience: NavAudience,
): ReadonlyArray<PaletteEntry> =>
audience === "operator"
? entries
: Arr.flatMap(entries, (entry): ReadonlyArray<PaletteEntry> => {
if (entry.group === "Actions" || entry.group === "Dashboards") return [entry]
if (entry.group !== "Navigation" || entry.target._tag !== "Href") return []
const title = ANALYST_TITLES.get(entry.target.href)
return title === undefined
? []
: [{ ...entry, title, keywords: `${entry.keywords} ${title}` }]
})

/** Every section, its children and their views, deduplicated by title and href. */
const navigationEntries = (): ReadonlyArray<PaletteEntry> => {
const rows = navRowsOf(navGroups(EVERY_FLAG))
// One row per page, titled by its most specific row ("Traces", not its "Explore" parent);
// every title that opens the same page stays searchable as a keyword.
const unique = Arr.reverse(Arr.dedupeWith(Arr.reverse(rows), (a, b) => a.href === b.href))
Expand Down
Loading
Loading