Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 { Option } from "effect"
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 @@ export const labShellView = <Message>(
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
}>,
): 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 observer", () => {
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 observer has none of them, and keeps their own pages", () => {
scene(
{ update, view },
given(at("/lab/shell")),
measured,
click(role("button", { name: "Observer" })),
...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 observer with nothing else read yet: all the shell needs to pick their sidebar. */
const OBSERVER_CLERK: ShellClerk = {
maybeUser: Option.none(),
maybeOrganization: Option.none(),
memberships: [],
customer: undefined,
maybeRole: Option.some("org:observer"),
}

/** The fixture's shifts, read on the lab's fixed clock. */
const withOnCall = (shell: ShellModel, fixture: OnCallFixture): ShellModel => ({
...shell,
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: "observer", label: "Observer" },
],
}),
),
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 === "observer" ? OBSERVER_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 observer. */
export const shellAudience = (clerk: ShellClerk | null): NavAudience =>
clerk !== null && Option.exists(clerk.maybeRole, (role) => Policy.isReadOnly([role]))
? "observer"
: "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 observer", () => {
const off = { cloudCost: false, releases: false, securityAudit: false, statusPages: false }
/** Every page the observer's sidebar links to: rows, then their sub-rows. */
const hrefs = (flags: NavFlags) =>
Arr.dedupe(
Arr.flatMap(navGroups(flags, "observer"), (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, "observer"), (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 observer's Alerts: what is wrong now and how reliable things are, without the setup pages. */
const observerIncidentsItem: 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 observer 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 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 observerNavGroups = (flags: NavFlags): ReadonlyArray<NavGroup> => [
{ id: "overview", items: [{ title: "Overview", href: "/", icon: "HouseIcon" }] },
{
id: "reliability",
label: "Reliability",
items: [
observerIncidentsItem,
{ 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 observer gets {@link observerNavGroups}.
*/
export const navGroups = (flags: NavFlags, audience: NavAudience = "operator"): ReadonlyArray<NavGroup> =>
audience === "observer" ? observerNavGroups(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 observer's sidebar calls each of its pages (`/alerts` is "Incidents"), plus their account. */
const OBSERVER_TITLES: ReadonlyMap<string, string> = new Map([
...Arr.map(navRowsOf(navGroups(EVERY_FLAG, "observer")), (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
* 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 = OBSERVER_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