Skip to content
Merged
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
151 changes: 151 additions & 0 deletions apps/web/src/lab/analyst-home-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { OrgId } from "@maple/domain"
import type { V2UptimeMonitor } from "@maple/domain/http/v2"
import type { AlertIncidentRow } from "@maple/web-data/collections/alerts"
import { Result, Schema, Array as Arr } from "effect"
import { toLoadFailure } from "@maple/views/data/load-failure"
import type { SignalPresences } from "@maple/views/data/signal-presence"
import { checkoutSlo, searchSlo, sloStatus, sloUuid } from "../page/alerts/shared/slo-test-fixtures"
import type { PageContext } from "../page/contract"
import { incidentRow, NOW, ORG, ruleId } from "../page/kit/alert-test-rows"
import { downMonitor, httpMonitor, monitorId } from "../page/kit/uptime/test-monitors"
import { topErrorsOf } from "../page/overview/analyst/derive"
import { Message } from "../page/overview/analyst/message"
import type { Model, SloRow, TopError } from "../page/overview/analyst/model"
import { init, update } from "../page/overview/analyst/update"
import { buildErrorsLabFixture } from "./errors-fixture"

// The analyst Overview's fixtures, shared by `/lab/analyst-home` and its scene tests.

export const STATES = ["clear", "attention", "empty", "loading", "failed"] as const
export type AnalystHomeState = (typeof STATES)[number]

export const ANALYST_CONTEXT: PageContext = {
orgId: Schema.decodeSync(OrgId)(ORG),
pinnedNamespace: null,
timeZone: "UTC",
audience: "analyst",
}

const MINUTE = 60_000
const iso = (msBefore: number) => new Date(NOW - msBefore).toISOString()

export const CRITICAL_INCIDENT = incidentRow({
rule_name: "Checkout p95 latency",
group_key: "checkout-api",
})
export const WARNING_INCIDENT = incidentRow({
id: "0199a003-0000-7000-8000-000000000002",
rule_id: ruleId(3),
incident_key: "rule-3:all",
dedupe_key: "rule-3:all",
rule_name: "Search error rate",
group_key: "search-api",
signal_type: "error_rate",
severity: "warning",
threshold: 0.02,
last_observed_value: 0.034,
first_triggered_at: iso(26 * MINUTE),
last_triggered_at: iso(2 * MINUTE),
})

const UP_MONITORS: ReadonlyArray<V2UptimeMonitor> = Arr.map(
["checkout-api", "search-api", "Marketing site", "Customer dashboard", "Public API"],
(name, index) => httpMonitor({ id: monitorId(index + 4), name }),
)
export const DOWN_MONITOR = downMonitor()

const HEALTHY_SLOS: ReadonlyArray<SloRow> = [
{ slo: checkoutSlo(), status: sloStatus("healthy") },
{ slo: searchSlo(), status: sloStatus("healthy", { sloId: sloUuid(2), targetPercent: 99.5 }) },
]
const TROUBLED_SLOS: ReadonlyArray<SloRow> = [
{ slo: checkoutSlo(), status: sloStatus("burning") },
{ slo: searchSlo(), status: sloStatus("exhausted", { sloId: sloUuid(2), targetPercent: 99.5 }) },
]

const errorsFixture = buildErrorsLabFixture(NOW)

/** The Errors lab's issues, ranked and joined the way the page's own read joins them. */
export const TOP_ERRORS: ReadonlyArray<TopError> = topErrorsOf(
Arr.map(errorsFixture.signals, (signal) => signal.issue),
Arr.flatMap(errorsFixture.signals, (signal) =>
signal.windowCount === null
? []
: [
{
fingerprintHash: signal.fingerprintHash,
errorLabel: signal.title,
sampleMessage: signal.detail,
count: signal.windowCount,
affectedServicesCount: signal.affectedServicesCount ?? 1,
firstSeen: new Date(signal.firstSeenAt),
lastSeen: new Date(signal.lastSeenAt),
},
],
),
)

type Data = Readonly<{
rules: number
incidents: ReadonlyArray<AlertIncidentRow>
/** `null`: the uptime read fails. */
monitors: ReadonlyArray<V2UptimeMonitor> | null
slos: ReadonlyArray<SloRow>
errors: ReadonlyArray<TopError>
traces: "present" | "absent"
}>

const CLEAR: Data = {
rules: 6,
incidents: [],
monitors: UP_MONITORS,
slos: HEALTHY_SLOS,
errors: TOP_ERRORS,
traces: "present",
}

const DATA = {
clear: CLEAR,
attention: {
...CLEAR,
incidents: [WARNING_INCIDENT, CRITICAL_INCIDENT],
monitors: [...UP_MONITORS, DOWN_MONITOR],
slos: TROUBLED_SLOS,
},
empty: { rules: 0, incidents: [], monitors: [], slos: [], errors: [], traces: "absent" },
failed: { ...CLEAR, monitors: null },
} as const satisfies Record<Exclude<AnalystHomeState, "loading">, Data>

const presences = (traces: Data["traces"]): SignalPresences => ({
traces: { status: traces, lastSeenMs: traces === "present" ? NOW - MINUTE : null },
})

const step = (model: Model, message: Message): Model => update(model, message, ANALYST_CONTEXT).model

/** The page as its reads leave it in `state`, on the fixtures' fixed clock. */
export const analystHomeModel = (state: AnalystHomeState): Model => {
const entered = step(init(ANALYST_CONTEXT).model, Message.CompletedReadClock({ nowMs: NOW }))
if (state === "loading") return entered
const data: Data = DATA[state]
const answers: ReadonlyArray<(model: Model) => Message> = [
() => Message.UpdatedRules({ orgId: ORG, count: data.rules }),
() => Message.UpdatedIncidents({ orgId: ORG, rows: data.incidents }),
(model) =>
Message.CompletedFetchMonitors({
key: model.monitors.key ?? "",
result:
data.monitors === null
? Result.fail(toLoadFailure("The request timed out"))
: Result.succeed(data.monitors),
}),
(model) =>
Message.CompletedFetchSlos({ key: model.slos.key ?? "", result: Result.succeed(data.slos) }),
(model) =>
Message.CompletedFetchErrors({
key: model.errors.key ?? "",
result: Result.succeed(data.errors),
}),
() => Message.CompletedLoadSignalPresence({ result: Result.succeed(presences(data.traces)) }),
]
return Arr.reduce(answers, entered, (model, answer) => step(model, answer(model)))
}
67 changes: 67 additions & 0 deletions apps/web/src/lab/entries/analyst-home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Effect, Option, Schema, Array as Arr } from "effect"
import { Command } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { defineMessageUnion } from "foldkit/message"
import { Message as PageMessage } from "../../page/overview/analyst/message"
import { Model as PageModel } from "../../page/overview/analyst/model"
import { update as pageUpdate } from "../../page/overview/analyst/update"
import { view as pageView } from "../../page/overview/analyst/view"
import { ANALYST_CONTEXT, type AnalystHomeState, analystHomeModel, STATES } from "../analyst-home-fixture"
import { ANALYST_CLERK } from "../shell-frame"
import { defineShellLab } from "../shell-lab"

// `/lab/analyst-home`: the Overview an analyst gets, inside the analyst's shell, with nothing
// wrong, with incidents and failing checks, with nothing set up, loading and with one read failing
// (`?state=clear|attention|empty|loading|failed`).

const Model = Schema.Struct({ page: PageModel })
type Model = typeof Model.Type

const Message = defineMessageUnion({ GotPageMessage: { message: PageMessage } })
type Message = typeof Message.Type

const Flags = Schema.Struct({ state: Schema.Literals(STATES) })
type Flags = typeof Flags.Type

const decodeState = Schema.decodeUnknownOption(Schema.Literals(STATES))

// Reads that would reach the API or move the fixtures' fixed clock; the lab has neither.
const REMOTE = /^(Fetch|Load|Read|Resolve)/

const update = (model: Model, message: Message) => {
const next = pageUpdate(model.page, message.message, ANALYST_CONTEXT)
return {
model: { page: next.model },
commands: Command.mapMessages(
Arr.filter(next.commands ?? [], (command) => !REMOTE.test(command.name)),
(inner) => Message.GotPageMessage({ message: inner }),
),
}
}

const view = (model: Model, h: HtmlBuilder<Message>): Html =>
h.submodel({
slotId: "analyst-home-lab",
model: model.page,
view: pageView,
viewInputs: { context: ANALYST_CONTEXT },
toParentMessage: (message) => Message.GotPageMessage({ message }),
})

export const lab = defineShellLab("Analyst overview", {
Model,
Message,
Flags,
flags: Effect.sync(() => ({
state: Option.getOrElse(
decodeState(new URLSearchParams(window.location.search).get("state")),
(): AnalystHomeState => "clear",
),
})),
init: (flags: Flags) => ({ model: { page: analystHomeModel(flags.state) } }),
update,
view,
clerk: ANALYST_CLERK,
currentPath: "/",
breadcrumbs: [{ label: "Overview" }],
})
8 changes: 8 additions & 0 deletions apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,14 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
kind: "lab",
session: "none",
},
{
path: "/lab/analyst-home",
title: "Analyst overview",
description:
"The Overview an analyst gets, in the analyst's shell: nothing wrong, incidents open and checks failing, nothing set up, loading and one read failing (`?state=clear|attention|empty|loading|failed`).",
kind: "lab",
session: "none",
},
{
path: "/lab/shell",
title: "Shell states",
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/lab/shell-frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export const foldLabShell = <Model extends { readonly shell: ShellModel }, Messa
foldOutMessage: () => (model: Model) => ({ model }),
})

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

/** The frame around the page, then the mobile sheet and menu layers (the root's `shellRoute`). */
export const labShellView = <Message>(
h: HtmlBuilder<Message>,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lab/shell-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export type ShellLabPage<PageModel, PageMessage extends { _tag: string }, PageFl
before?: (model: PageModel, h: HtmlBuilder<PageMessage>) => Html
/** Shell state the root's reads would bring (the Alerts › Inbox count), as a fixture. */
seedShell?: (shell: Shell.Model) => Shell.Model
/** Who the shell is drawn for (`ANALYST_CLERK`); an operator when absent. */
clerk?: Shell.ShellClerk
}>

export const defineShellLab = <PageModel, PageMessage extends { _tag: string }, PageFlags>(
Expand Down Expand Up @@ -161,6 +163,7 @@ export const defineShellLab = <PageModel, PageMessage extends { _tag: string },
...labShellView(h, model.shell, toShell, {
currentPath: currentPathOf(model.page),
breadcrumbs: breadcrumbsOf(model.page),
clerk: page.clerk ?? null,
page: () =>
h.submodel({
slotId: "lab-page",
Expand Down
13 changes: 2 additions & 11 deletions apps/web/src/lab/shell-states-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ import type { Resources } from "../platform/resources"
import { dashboardPage } from "../page/kit/dashboard-page"
import { dismissSheetUrl, pushSheetUrl, sheetParamOf } from "../page/kit/sheet-param"
import { Message as ShellMessage, Theme } from "../shell/message"
import { init as initShell, Model as ShellModel, OnCallData, type ShellClerk } from "../shell/model"
import { init as initShell, Model as ShellModel, OnCallData } from "../shell/model"
import { NavAudience } from "../shell/nav"
import type { OnCallSnapshot } from "../shell/oncall"
import { foldLabShell, labShellView } from "./shell-frame"
import { foldLabShell, labShellView, ANALYST_CLERK } from "./shell-frame"

// `/lab/shell`: the real sidebar with fixture on-call states and a page to stand on, and the side
// panel held in `?sheet=` (Back closes it, the link reopens it) in both of its sizes.
Expand Down Expand Up @@ -167,15 +167,6 @@ 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 Down
14 changes: 14 additions & 0 deletions apps/web/src/page/alerts/hub/analyst.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@ describe("alerts inbox, analyst", () => {
)
})

test("an analyst's Incidents page has no New rule button, and a member's keeps it", () => {
const analystView = withViewInputs(pageView, { context: { ...context, audience: "analyst" } })()
scene(
{ update: pageUpdate, view: analystView },
given(loaded(ANALYST)),
expectView(role("link", { name: "New rule" })).toBeAbsent(),
)
scene(
{ update: pageUpdate, view },
given(loaded(MEMBER)),
expectView(role("link", { name: "New rule" })).toExist(),
)
})

test("a member gets a button to flip each row's acknowledgement", () => {
scene(
{ update: pageUpdate, view },
Expand Down
11 changes: 1 addition & 10 deletions apps/web/src/page/alerts/hub/view-slos.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { pluralize } from "@maple/ui/lib/format"
import type { AlertIncidentRow } from "@maple/web-data/collections/alerts"
import { Option, Array as Arr } from "effect"
import { AsyncData } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
Expand All @@ -26,14 +25,12 @@ import { newSloHref, sloHref } from "../shared/slo-commands"
import {
burnTone,
fastestBurn,
firingTier,
formatBurn,
formatSli,
formatTarget,
formatWindow,
holdingTier,
isBudgetSpent,
openIncident,
isBurning,
scopeLine,
sliLabel,
sliTone,
Expand Down Expand Up @@ -70,12 +67,6 @@ const slosEmptyState = (h: H, serviceName: string | undefined, canEdit: boolean)
"py-12",
)

/** Burning: a tier fires, or a tier holds (no events) while the SLO's incident stays open. */
const isBurning = (row: SloListRow, incidents: ReadonlyArray<AlertIncidentRow>): boolean =>
row.status !== null &&
(Option.isSome(firingTier(row.status)) ||
(Option.isSome(holdingTier(row.status)) && Option.isSome(openIncident(row.slo, incidents))))

const readout = (h: H, model: Model, rows: ReadonlyArray<SloListRow>): ReadonlyArray<Html> => {
// Every SLO's status arrives in one read; none at all means that read failed, not that nothing burns.
if (rows.length > 0 && Arr.every(rows, (row) => row.status === null)) {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/page/alerts/hub/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,10 @@ const headerActions = (
model: Model,
tab: AlertsTab,
overview: Option.Option<AlertsOverviewReady>,
audience: PageViewInputs["context"]["audience"],
): Html | undefined => {
// An analyst follows incidents and sets nothing up.
if (audience === "analyst") return undefined
if (tab === "inbox" || tab === "rules")
// The first-run empty state offers the templates and "Start from scratch" to who can create.
return canManageAlerts(model) && Option.exists(overview, (data) => isFirstRun(model, data, tab))
Expand Down Expand Up @@ -354,7 +357,7 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model,
h,
{
title: TAB_LABEL[tab],
actions: headerActions(h, model, tab, overview),
actions: headerActions(h, model, tab, overview, inputs.context.audience),
},
[
tab === "routing"
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/page/alerts/shared/slo-derive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ export const openIncident = (
): Option.Option<AlertIncidentRow> =>
Arr.findFirst(sloIncidents(slo, incidents), (row) => row.status === "open")

/** Burning: a tier fires, or a tier holds (no events) while the SLO's incident stays open. */
export const isBurning = (
row: Readonly<{ slo: Pick<Slo, "alert_rule_id">; status: SloStatus | null }>,
incidents: ReadonlyArray<AlertIncidentRow>,
): boolean =>
row.status !== null &&
(Option.isSome(firingTier(row.status)) ||
(Option.isSome(holdingTier(row.status)) && Option.isSome(openIncident(row.slo, incidents))))

/** "checkout · production, staging", or the service alone when every environment counts. */
export const scopeLine = (slo: Pick<Slo, "service_name" | "environments">): string =>
slo.environments.length === 0
Expand Down
Loading
Loading