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
4 changes: 2 additions & 2 deletions apps/web/src/lab/alerts-hub-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { defineMessageUnion } from "foldkit/message"
import * as HubMessage from "../page/alerts/hub/message"
import * as HubModel from "../page/alerts/hub/model"
import * as HubUpdate from "../page/alerts/hub/update"
import { view as hubView } from "../page/alerts/hub/view"
import { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view"
import * as SilenceDialog from "../page/alerts/silence-dialog/dialog"
import { destinationRow, NOW, ORG, ruleRow, stateRow } from "../page/alerts/shared/test-rows"
import type { PageContext } from "../page/contract"
Expand Down Expand Up @@ -199,7 +199,7 @@ export const update = (model: Model, message: Message): Return =>
},
})

export const breadcrumbs = () => [{ label: "Alerts" }]
export const breadcrumbs = (model: Model) => hubBreadcrumbs(model.hub)

export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
h.submodel({
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lab/anomaly-alerts-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { breadcrumbsOf as editorCrumbs, view as editorView } from "../page/alert
import * as HubMessage from "../page/alerts/hub/message"
import * as HubModel from "../page/alerts/hub/model"
import * as HubUpdate from "../page/alerts/hub/update"
import { view as hubView } from "../page/alerts/hub/view"
import { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view"
import type { Incident } from "../page/overview/model"
import { alertFiringHero, heroCountsOf } from "../page/overview/view-hero"
import { vstack } from "@maple/views/ui/stack"
Expand Down Expand Up @@ -417,7 +417,7 @@ export const breadcrumbs = (model: Model) =>
? editorCrumbs(model.editor)
: isIncident(model.state)
? incidentCrumbs(model.incident)
: [{ label: "Alerts" }]
: hubBreadcrumbs(model.hub)

const openIncident = (severity: Incident["severity"], signalType: Incident["signalType"]): Incident => ({
status: "open",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lab/escalation-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { chainSelectIdOf } from "../page/alerts/editor/form-steps"
import * as HubMessage from "../page/alerts/hub/message"
import * as HubModel from "../page/alerts/hub/model"
import * as HubUpdate from "../page/alerts/hub/update"
import { view as hubView } from "../page/alerts/hub/view"
import { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view"
import * as IncidentMessage from "../page/alerts/incident/message"
import * as IncidentModel from "../page/alerts/incident/model"
import * as IncidentUpdate from "../page/alerts/incident/update"
Expand Down Expand Up @@ -311,7 +311,7 @@ export const update = (model: Model, message: Message): Return =>

export const breadcrumbs = (model: Model) =>
isHub(model.state)
? [{ label: "Alerts" }]
? hubBreadcrumbs(model.hub)
: isIncident(model.state)
? incidentBreadcrumbs(model.incident)
: editorBreadcrumbs(model.editor)
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lab/maintenance-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as WindowDialog from "../page/alerts/maintenance-dialog/dialog"
import * as HubMessage from "../page/alerts/hub/message"
import * as HubModel from "../page/alerts/hub/model"
import * as HubUpdate from "../page/alerts/hub/update"
import { view as hubView } from "../page/alerts/hub/view"
import { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view"
import {
populatedWindows,
silencesWithWindows,
Expand Down Expand Up @@ -155,7 +155,7 @@ export const update = (model: Model, message: Message): Return =>
},
})

export const breadcrumbs = () => [{ label: "Alerts" }]
export const breadcrumbs = (model: Model) => hubBreadcrumbs(model.hub)

export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
h.submodel({
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lab/oncall-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { keepLocal } from "./local-commands"
import * as HubMessage from "../page/alerts/hub/message"
import * as HubModel from "../page/alerts/hub/model"
import * as HubUpdate from "../page/alerts/hub/update"
import { view as hubView } from "../page/alerts/hub/view"
import { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view"
import * as IncidentMessage from "../page/alerts/incident/message"
import * as IncidentModel from "../page/alerts/incident/model"
import * as IncidentUpdate from "../page/alerts/incident/update"
Expand Down Expand Up @@ -450,7 +450,7 @@ export const update = (model: Model, message: Message): Return =>

export const breadcrumbs = (model: Model) =>
isHub(model.state)
? [{ label: "Alerts" }]
? hubBreadcrumbs(model.hub)
: isForm(model.state)
? [{ label: "Alerts", href: "/alerts" }, { label: "On-call" }]
: isSchedule(model.state)
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lab/slos-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { toLoadFailure } from "@maple/views/data/load-failure"
import * as HubMessage from "../page/alerts/hub/message"
import * as HubModel from "../page/alerts/hub/model"
import * as HubUpdate from "../page/alerts/hub/update"
import { view as hubView } from "../page/alerts/hub/view"
import { breadcrumbs as hubBreadcrumbs, view as hubView } from "../page/alerts/hub/view"
import {
checkoutSlo,
SLO_RULE_UUID,
Expand Down Expand Up @@ -283,7 +283,7 @@ const isForm = (state: LabState) => state === "form-new" || state === "form-inva

export const breadcrumbs = (model: Model) =>
isTab(model.state)
? [{ label: "Alerts" }]
? hubBreadcrumbs(model.hub)
: isForm(model.state)
? [{ label: "Alerts" }, { label: "SLOs" }]
: breadcrumbsOf()
Expand Down
29 changes: 28 additions & 1 deletion apps/web/src/page/alerts/hub/hub.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { destinationRow, incidentRow, NOW, ORG, ruleId, ruleRow, stateRow } from
import { Message } from "./message"
import type { Model } from "./model"
import { init, ruleMenuId, update } from "./update"
import { view as pageView } from "./view"
import { breadcrumbs, view as pageView } from "./view"

const ENGINEER = Policy.grantsOf(["org:engineer"])

Expand Down Expand Up @@ -104,6 +104,33 @@ describe("alerts hub", () => {
)
})

test("the section rail marks the open section, which titles the page and its crumbs", () => {
scene(
{ update: pageUpdate, view },
given(loaded({ tab: "rules" })),
expectView(role("link", { name: /^Rules\s*3$/ })).toHaveAttr("aria-current", "page"),
expectView(role("link", { name: /^Inbox\s*1$/ })).not.toHaveAttr("aria-current", "page"),
expectView(role("heading", { name: "Rules" })).toExist(),
)
// The anomaly filters belong to the Inbox: its row keeps them, every other row drops them.
scene(
{ update: pageUpdate, view },
given(loaded({ tab: "inbox", signal: "anomaly", serviceName: "checkout" })),
expectView(role("link", { name: /^Inbox\s*1$/ })).toHaveAttr(
"href",
"/alerts?tab=inbox&serviceName=checkout&signal=anomaly",
),
expectView(role("link", { name: "Silences" })).toHaveAttr(
"href",
"/alerts?tab=silences&serviceName=checkout",
),
)
expect(breadcrumbs(loaded({ tab: "silences" }))).toEqual([
{ label: "Alerts", href: "/alerts" },
{ label: "Silences" },
])
})

const v2Incident = Schema.decodeSync(V2AlertIncident)({
id: encodePublicId(PublicIdPrefixes.alertIncident, incidentRow().id),
object: "alert_incident",
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/page/alerts/hub/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { Message } from "./message"
import { Model } from "./model"
import { subscriptions } from "./subscription"
import { init, routeChanged, update } from "./update"
import { view } from "./view"
import { breadcrumbs, view } from "./view"

/** `/alerts`: the incident-first hub (Inbox, Rules, Routing). One instance per org; tabs are search. */
/** `/alerts`: the incident-first hub and its sections. One instance per org; the section is search. */
export const page = definePage(
"AlertsHub",
{ Model, Message },
Expand All @@ -16,6 +16,6 @@ export const page = definePage(
routeChanged,
view,
subscriptions,
breadcrumbs: () => [{ label: "Alerts", isPageHeading: true }],
breadcrumbs,
},
)
170 changes: 110 additions & 60 deletions apps/web/src/page/alerts/hub/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import * as MenuView from "@maple/views/ui/menu-view"
import { skeleton, skeletonList } from "@maple/views/ui/skeleton"
import type { PageViewInputs } from "../../contract"
import { dashboardPage } from "../../kit/dashboard-page"
import { linkTabStrip } from "../../kit/underline-tabs"
import { navShell, type NavShellCount, type NavShellSection } from "../../kit/nav-shell"
import type { BreadcrumbEntry } from "../../../shell"
import { alertsHref, createHref } from "../shared/links"
import { newSloHref } from "../shared/slo-commands"
import {
Expand Down Expand Up @@ -54,75 +55,122 @@ import {
import { hstack, vstack } from "@maple/views/ui/stack"
import { typography } from "@maple/views/ui/typography"

// `AlertsPage` and `AlertsHub`: the `/alerts` page and its tabs.
// `AlertsPage` and `AlertsHub`: the `/alerts` page and the rail of its sections.

type H = HtmlBuilder<Message>

const ALERTS_TABS: ReadonlyArray<{ value: AlertsTab; label: string }> = [
{ value: "inbox", label: "Inbox" },
{ value: "rules", label: "Rules" },
{ value: "uptime", label: "Uptime" },
{ value: "slos", label: "SLOs" },
{ value: "silences", label: "Silences" },
{ value: "maintenance", label: "Maintenance" },
{ value: "oncall", label: "On-call" },
{ value: "routing", label: "Routing" },
const TAB_LABEL = {
inbox: "Inbox",
rules: "Rules",
uptime: "Uptime",
slos: "SLOs",
silences: "Silences",
maintenance: "Maintenance",
oncall: "On-call",
routing: "Routing",
} as const satisfies Record<AlertsTab, string>

type RailRow = Readonly<{ tab: AlertsTab; icon: IconName }>

const RAIL: ReadonlyArray<Readonly<{ id: string; title: string; rows: ReadonlyArray<RailRow> }>> = [
{ id: "triage", title: "Triage", rows: [{ tab: "inbox", icon: "BellIcon" }] },
{
id: "monitors",
title: "Monitors",
rows: [
{ tab: "rules", icon: "ChartLineIcon" },
{ tab: "uptime", icon: "GlobeIcon" },
{ tab: "slos", icon: "GaugeIcon" },
],
},
{
id: "quiet",
title: "Quiet",
rows: [
{ tab: "silences", icon: "BellSlashIcon" },
{ tab: "maintenance", icon: "CalendarIcon" },
],
},
{
id: "response",
title: "Response",
rows: [
{ tab: "oncall", icon: "UsersIcon" },
{ tab: "routing", icon: "BranchForkIcon" },
],
},
]

const HUB_SKELETON_ROWS = ["h-14 w-1/2", "h-40 w-full", "h-48 w-full"]

const tabBar = (
h: H,
/** `Alerts › <section>`: the rail's active row names the page. */
export const breadcrumbs = (model: Model): ReadonlyArray<BreadcrumbEntry> => [
{ label: "Alerts", href: alertsHref({}) },
{ label: TAB_LABEL[resolveTab(model.search.tab)] },
]

const countOf = (
model: Model,
overview: Option.Option<AlertsOverviewReady>,
active: AlertsTab,
): Html => {
const monitorCount = Option.map(AsyncData.getData(model.monitors.data), (monitors) => monitors.length)
const sloCount = Option.map(AsyncData.getData(model.slos.data), (slos) => slos.length)
const scheduleCount = Option.map(AsyncData.getData(model.schedules.data), (rows) => rows.length)
return linkTabStrip(
tab: AlertsTab,
): number | undefined => {
const lengthOf = <A>(rows: Option.Option<ReadonlyArray<A>>) =>
Option.getOrUndefined(Option.map(rows, Arr.length))
switch (tab) {
case "uptime":
return lengthOf(AsyncData.getData(model.monitors.data))
case "slos":
return lengthOf(AsyncData.getData(model.slos.data))
case "oncall":
return lengthOf(AsyncData.getData(model.schedules.data))
case "inbox":
return Option.getOrUndefined(
Option.map(overview, (data) => unacknowledgedCount(data.openIncidents)),
)
case "rules":
return Option.getOrUndefined(Option.map(overview, (data) => data.rules.length))
case "silences":
case "maintenance":
case "routing":
return undefined
}
}

/** The section rail in `dashboardPage`'s side slot, which folds into a sheet below `lg`. */
const rail = (h: H, model: Model, overview: Option.Option<AlertsOverviewReady>, active: AlertsTab): Html =>
navShell<Message, never>(
h,
Arr.map(RAIL, (section): NavShellSection<never> => ({
id: section.id,
title: section.title,
items: Arr.map(section.rows, (row) => {
const count = countOf(model, overview, row.tab)
const tone: NavShellCount["tone"] = row.tab === "inbox" ? "error" : "muted"
return {
kind: "link",
id: row.tab,
label: TAB_LABEL[row.tab],
icon: row.icon,
count: count !== undefined && count > 0 ? { value: count, tone } : undefined,
// The Anomalies view belongs to the Inbox: other sections drop its filters.
href: alertsHref(
row.tab === "inbox"
? { ...model.search, tab: row.tab }
: {
...model.search,
tab: row.tab,
signal: undefined,
anomalyStatus: undefined,
window: undefined,
},
),
}
}),
})),
active,
(tab) => tab,
"Alerts sections",
Arr.map(ALERTS_TABS, (tab) => {
const count =
tab.value === "uptime"
? Option.getOrUndefined(monitorCount)
: tab.value === "slos"
? Option.getOrUndefined(sloCount)
: tab.value === "oncall"
? Option.getOrUndefined(scheduleCount)
: Option.match(overview, {
onNone: () => undefined,
onSome: (data) =>
tab.value === "inbox"
? unacknowledgedCount(data.openIncidents)
: tab.value === "rules"
? data.rules.length
: undefined,
})
return {
key: tab.value,
// The Anomalies view belongs to the Inbox: other tabs drop its filters.
href: alertsHref(
tab.value === "inbox"
? { ...model.search, tab: tab.value }
: {
...model.search,
tab: tab.value,
signal: undefined,
anomalyStatus: undefined,
window: undefined,
},
),
label: tab.label,
count: count !== undefined && count > 0 ? count : undefined,
countTone: tab.value === "inbox" ? "error" : "muted",
isCurrent: tab.value === active,
isRouteActive: tab.value === active,
}
}),
)
}

/** A "New rule" menu row: icon, label and one line of what it watches. */
const newRuleItem = (
Expand Down Expand Up @@ -405,9 +453,11 @@ export const view = Submodel.defineView<Model, Message, PageViewInputs>((model,
dashboardPage(
h,
{
title: "Alerts",
title: TAB_LABEL[tab],
actions: headerActions(h, model, tab),
tabs: tabBar(h, model, overview, tab),
filters: rail(h, model, overview, tab),
filtersLabel: "Alerts",
filtersWidth: "w-56",
},
[
tab === "routing"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/page/integrations/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { catalogEntry, chatConnectorOf, integrationOfSearch, type IntegrationId
import { ConnectProvider } from "./connect"
import { Message } from "./message"
import { canManage, type Model } from "./model"
import { navShell } from "../settings/kit/nav-shell"
import { navShell } from "../kit/nav-shell"
import { isClerkAuthEnabled } from "../../lib/services/common/auth-mode"
import { visibleSections } from "../settings/nav"
import { settingsNavVisibility } from "./derive"
Expand Down
Loading
Loading