Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2bcbbf0
feat(shell): URL-held right sheet, foldable sidebar groups, on-call pill
Makisuo Nov 9, 2026
5535bc2
fix(shell): sheet history, on-call freshness, foldable active group
Makisuo Nov 9, 2026
0c0b786
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 9, 2026
18c83a3
feat(alerts): sections at their own paths, sidebar sub-items, silence…
Makisuo Nov 9, 2026
5888395
fix(shell): read window and document on subscribe, not on import
Makisuo Nov 9, 2026
f649d8f
Merge remote-tracking branch 'origin/ux/shell' into ux/alerting-pages
Makisuo Nov 9, 2026
280ea47
fix(alerts): review follow-ups for section routes and the silence panel
Makisuo Nov 9, 2026
ef30170
test(shell): lab on-call fixture no longer expires on 16 Nov 2026
Makisuo Nov 9, 2026
f9d010f
fix(views): side panel dims the page lightly, and comes in a compact …
Makisuo Nov 10, 2026
f1179ea
fix(shell): one-row on-call pill, calmer sidebar states
Makisuo Nov 10, 2026
3c3e996
fix(alerts): one primary action per section, chips and viewer time in…
Makisuo Nov 10, 2026
21974a5
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
1c0bfcd
Merge remote-tracking branch 'origin/ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
d17a22e
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
b4fe115
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
cd3b2c5
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
43c444a
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
78ebb4a
fix(alerts): silence form says what is missing instead of a silent di…
Makisuo Nov 10, 2026
5afb72a
fix(alerts): silence chips split on commas and keep a chip typed twice
Makisuo Nov 10, 2026
a037e66
fix(alerts): silence chips hint sits in the form, not in a popup over…
Makisuo Nov 10, 2026
2b33c5e
fix(shell): re-check a stale 'no schedules' answer so a teammate's fi…
Makisuo Nov 10, 2026
82145ab
fix(shell): only the current page's link carries aria-current in the …
Makisuo Nov 10, 2026
f675406
fix(alerts): silence save button names its end on the 24h clock the p…
Makisuo Nov 10, 2026
d599b1d
fix(ui): make the sidebar row hover visible in the light theme
Makisuo Nov 10, 2026
717a12f
fix(alerts): first-run Inbox and Rules leave the create actions to th…
Makisuo Nov 10, 2026
994eb5a
fix(alerts): hub replaces away a ?sheet= that opens nothing
Makisuo Nov 10, 2026
fc1ac22
test(e2e): a saved rule returns to the Rules section
Makisuo Nov 10, 2026
5f2c136
Merge remote-tracking branch 'origin/main' into ux/shell
Makisuo Nov 10, 2026
c51ad1b
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
c5adf7d
fix(shell): an org without schedules is asked again hourly, so an ope…
Makisuo Nov 10, 2026
655534e
docs(design-rules): list the form sheet size
Makisuo Nov 10, 2026
479e46c
fix(shell): a folded group keeps the current sub-item; on-call is re-…
Makisuo Nov 10, 2026
7360717
docs(shell): navRow's comment follows the folded group's current sub-…
Makisuo Nov 10, 2026
3c7b214
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
8310e9d
Merge branch 'ux/shell' into ux/alerting-pages
Makisuo Nov 10, 2026
6a9012e
Merge remote-tracking branch 'origin/main' into ux/alerting-pages
Makisuo Nov 10, 2026
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/e2e/tests/alert-rules.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ test.describe("alert rules", () => {

await screen.getByLabel("Rule name").fill(`e2e tiers renamed ${tag}`)
await screen.getByRole("button", "Save changes").tap()
await expect(browser).toHaveURL(/\/alerts$/)
await expect(browser).toHaveURL(/\/alerts\/rules$/)
const renamed = await read()
expect(renamed.body.name).toBe(`e2e tiers renamed ${tag}`)
expect(renamed.body.thresholds).toEqual(tiers)
Expand All @@ -96,7 +96,7 @@ test.describe("alert rules", () => {
await app.open(editUrl)
await screen.getByLabel("Critical threshold").fill("4")
await screen.getByRole("button", "Save changes").tap()
await expect(browser).toHaveURL(/\/alerts$/)
await expect(browser).toHaveURL(/\/alerts\/rules$/)
const retuned = await read()
expect(Arr.map(retuned.body.thresholds, (tier) => tier.value)).toEqual([0.007, 0.04])
} finally {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/settings/audit-log-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export type AuditResourceLink =
| { readonly to: "/errors/issues/$issueId"; readonly params: { readonly issueId: string } }
| { readonly to: "/investigations/$id"; readonly params: { readonly id: string } }
| { readonly to: "/anomalies/$incidentId"; readonly params: { readonly incidentId: string } }
| { readonly to: "/alerts"; readonly search: { readonly tab: "routing" } }
| { readonly to: "/alerts/routing" }
| { readonly to: "/integrations"; readonly search: { readonly integration?: string } }
| { readonly to: "/settings"; readonly search: { readonly tab: string } }

Expand Down Expand Up @@ -79,7 +79,7 @@ function resourceLink(entry: AuditEntryResource): AuditResourceLink | null {
return incidentId === null ? null : { to: "/anomalies/$incidentId", params: { incidentId } }
}
case "alert_destination":
return { to: "/alerts", search: { tab: "routing" } }
return { to: "/alerts/routing" }
case "scrape_target":
return { to: "/integrations", search: { integration: "prometheus" } }
case "gcp_connector":
Expand Down
140 changes: 102 additions & 38 deletions apps/web/src/lab/alerts-hub-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,28 @@ import * as Policy from "@maple/domain/policy"
import { OrgId } from "@maple/domain"
import { AlertSilenceId } from "@maple/domain/http"
import type { V2AlertSilence } from "@maple/domain/http/v2"
import { Effect, Option, Result, Schema, Array as Arr } from "effect"
import { Effect, Option, Result, Schema, Array as Arr, String as Str } from "effect"
import { Command, type Update } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import { defineMessageUnion } from "foldkit/message"
import { zonedDateParts } from "@maple/query-engine/datetime"
import * as Combobox from "@maple/views/ui/combobox"
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 { 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 { alertsHref } from "../page/alerts/shared/links"
import { destinationRow, NOW, ORG, ruleRow, stateRow, hubAt } from "../page/alerts/shared/test-rows"
import type { PageContext } from "../page/contract"
import type { Resources } from "../platform/resources"
import { readEffectiveTimeZone } from "../platform/time-zone"

// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences tabs and the silence dialog over
// `/lab/alerts-hub`: the hub's Inbox, Rules and Silences sections and the silence side panel over
// fixtures, including a rule skipped for too few samples and a cancelled silence. `?state=` picks
// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom.
// one: inbox, rules, loading, error, silences, silence-dialog, silence-dialog-custom,
// silence-dialog-filled. The shell stands on the section's own path, `?sheet=` is played here
// rather than pushed, and times read on the viewer's own clock, as in the app.

export const LabState = Schema.Literals([
"inbox",
Expand All @@ -27,37 +33,42 @@ export const LabState = Schema.Literals([
"silences",
"silence-dialog",
"silence-dialog-custom",
"silence-dialog-filled",
])
export type LabState = typeof LabState.Type

export const Flags = Schema.Struct({ state: LabState })
export const Flags = Schema.Struct({ state: LabState, timeZone: Schema.String })
export type Flags = typeof Flags.Type

export const flags: Effect.Effect<Flags> = Effect.sync(() => ({
export const flags: Effect.Effect<Flags> = Effect.map(readEffectiveTimeZone, (timeZone) => ({
state: Option.getOrElse(
Schema.decodeUnknownOption(LabState)(new URLSearchParams(window.location.search).get("state")),
(): LabState => "inbox",
),
timeZone,
}))

export const Model = Schema.Struct({ state: LabState, hub: HubModel.Model })
export const Model = Schema.Struct({ state: LabState, timeZone: Schema.String, hub: HubModel.Model })
export type Model = typeof Model.Type

export const Message = defineMessageUnion({ GotHubMessage: { message: HubMessage.Message } })
export type Message = typeof Message.Type

type Return = Update.Return<Model, Message, Resources>

const CONTEXT: PageContext = {
orgId: Schema.decodeSync(OrgId)(ORG),
pinnedNamespace: null,
timeZone: "UTC",
}
const LAB_ORG = Schema.decodeSync(OrgId)(ORG)
const contextOf = (timeZone: string): PageContext => ({ orgId: LAB_ORG, pinnedNamespace: null, timeZone })
const GRANTS = Policy.grantsOf(["org:engineer"])

/** The hub's reads and writes are dropped: the fixture is all the data there is. */
/** The hub's reads, writes and URL changes are dropped: the fixture is all the data there is. */
const keepLocal = <M>(commands: ReadonlyArray<Command.Command<M, never, Resources>> | undefined) =>
Arr.filter(commands ?? [], (command) => !/^(Fetch|Toggle|Delete|Save|Read|End)/.test(command.name))
Arr.filter(
commands ?? [],
(command) =>
!/^(Fetch|Toggle|Delete|Save|Read|End|PushAlertsHubSheet|DismissAlertsHubSheet)/.test(
command.name,
),
)

const MINUTE = 60_000
const iso = (ms: number) => new Date(ms).toISOString()
Expand Down Expand Up @@ -123,7 +134,10 @@ const SILENCES = [
silence("11111111-1111-4111-8111-111111111111", "Deploying checkout v2.3", -20, 40, {
services: ["checkout"],
}),
silence("22222222-2222-4222-8222-222222222222", "Database migration", 180, 240, { tags: ["db"] }),
// Tomorrow: its row names the day.
silence("22222222-2222-4222-8222-222222222222", "Database migration", 26 * 60, 27 * 60, {
tags: ["db"],
}),
// Cancelled before it started: the API collapses its window to one instant.
silence("33333333-3333-4333-8333-333333333333", "Load test on staging", -45, -45, {
environments: ["staging"],
Expand All @@ -140,10 +154,24 @@ const tabOf = (state: LabState) =>
? "inbox"
: "silences"

const step = (model: HubModel.Model, message: HubMessage.Message) => HubUpdate.update(model, message, CONTEXT)
const routeTo = (model: HubModel.Model, sheet: string | undefined, context: PageContext) =>
HubUpdate.routeChanged(model, hubAt({ ...model.search, tab: model.tab, sheet }), context)

/** The hub's update, with the `?sheet=` push and dismissal it asks for played back as route changes. */
const step = (model: HubModel.Model, message: HubMessage.Message, context: PageContext) => {
const next = HubUpdate.update(model, message, context)
const asked = Arr.findFirst(next.commands ?? [], (command) => Str.endsWith("HubSheet")(command.name))
if (asked._tag === "None") return next
const routed = routeTo(
next.model,
asked.value.name === "PushAlertsHubSheet" ? HubUpdate.SILENCE_SHEET : undefined,
context,
)
return { ...routed, commands: [...(next.commands ?? []), ...(routed.commands ?? [])] }
}

const hubFor = (state: LabState): HubModel.Model => {
const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: tabOf(state) } }, CONTEXT).model
const hubFor = (state: LabState, context: PageContext): HubModel.Model => {
const entered = HubUpdate.init(hubAt({ tab: tabOf(state) }), context).model
const base = [
HubMessage.Message.ReadClock({ nowMs: NOW }),
HubMessage.Message.CompletedFetchSession({ orgId: ORG, grants: GRANTS }),
Expand All @@ -163,49 +191,85 @@ const hubFor = (state: LabState): HubModel.Model => {
result: Result.succeed(SILENCES),
}),
]
return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message).model)
return Arr.reduce([...base, ...data], entered, (model, message) => step(model, message, context).model)
}

const pad = (value: number): string => Str.padStart(2, "0")(String(value))

/** `ms` as a `datetime-local` value on the viewer's clock. */
const localInput = (ms: number, timeZone: string): string => {
const parts = zonedDateParts(ms, timeZone)
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}T${pad(parts.hour)}:${pad(parts.minute)}`
}

const dialog = (message: SilenceDialog.Message) => HubMessage.Message.GotSilenceDialogMessage({ message })
const chip = (field: SilenceDialog.ScopeField, value: string) =>
dialog(
SilenceDialog.Message.GotScopeComboMessage({
field,
message: Combobox.Message.ClickedItem({ value }),
}),
)

/** What each silence-panel state does once the panel is open. */
const panelSteps = (state: LabState, timeZone: string): ReadonlyArray<HubMessage.Message> =>
state === "silence-dialog-custom"
? [dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" }))]
: state === "silence-dialog-filled"
? [
chip("services", "checkout"),
chip("services", "payment-svc"),
chip("environments", "production"),
dialog(SilenceDialog.Message.ChangedDuration({ duration: "custom" })),
dialog(
SilenceDialog.Message.ChangedUntil({
value: localInput(NOW + 26 * 60 * MINUTE, timeZone),
}),
),
dialog(SilenceDialog.Message.ChangedReason({ value: "Deploying checkout v2.3" })),
]
: []

const toLab = (
next: Readonly<{ commands?: ReadonlyArray<Command.Command<HubMessage.Message, never, Resources>> }>,
) => Command.mapMessages(keepLocal(next.commands), (message) => Message.GotHubMessage({ message }))

export const init = (flags: Flags): Return => {
const hub = hubFor(flags.state)
if (flags.state !== "silence-dialog" && flags.state !== "silence-dialog-custom")
return { model: { state: flags.state, hub } }
const context = contextOf(flags.timeZone)
const hub = hubFor(flags.state, context)
const lab = { state: flags.state, timeZone: flags.timeZone }
if (!Str.startsWith("silence-dialog")(flags.state)) return { model: { ...lab, hub } }
// The dialog opens through its own commands, so they run here rather than in the fold.
const opened = step(hub, HubMessage.Message.ClickedNewSilence())
const picked =
flags.state === "silence-dialog-custom"
? step(
opened.model,
HubMessage.Message.GotSilenceDialogMessage({
message: SilenceDialog.Message.ChangedDuration({ duration: "custom" }),
}),
)
: { model: opened.model, commands: [] }
return {
model: { state: flags.state, hub: picked.model },
commands: [...toLab(opened), ...toLab(picked)],
}
const opened = step(hub, HubMessage.Message.ClickedNewSilence(), context)
const filled = Arr.reduce(
panelSteps(flags.state, flags.timeZone),
{ model: opened.model, commands: toLab(opened) },
(current, message) => {
const next = step(current.model, message, context)
return { model: next.model, commands: [...current.commands, ...toLab(next)] }
},
)
return { model: { ...lab, hub: filled.model }, commands: filled.commands }
}

export const update = (model: Model, message: Message): Return =>
Message.match<Return>(message, {
GotHubMessage: ({ message: child }) => {
const next = step(model.hub, child)
const next = step(model.hub, child, contextOf(model.timeZone))
return { model: { ...model, hub: next.model }, commands: toLab(next) }
},
})

export const breadcrumbs = (model: Model) => hubBreadcrumbs(model.hub)

/** The section's own path, so the sidebar opens Alerts on it. */
export const currentPath = (model: Model): string => alertsHref({}, model.hub.tab)

export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
h.submodel({
slotId: "lab-alerts-hub",
model: model.hub,
view: hubView,
viewInputs: { context: CONTEXT },
viewInputs: { context: contextOf(model.timeZone) },
toParentMessage: (message: HubMessage.Message) => Message.GotHubMessage({ message }),
})
6 changes: 4 additions & 2 deletions apps/web/src/lab/anomaly-alerts-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import {
ruleId,
ruleRow,
stateRow,
hubAt,
type HubAt,
} from "../page/alerts/shared/test-rows"
import type { PageContext } from "../page/contract"
import type { Resources } from "../platform/resources"
Expand Down Expand Up @@ -156,7 +158,7 @@ const RESOLVED = [
]

const hubFor = (state: LabState) => {
const search: HubModel.Model["search"] =
const search: HubAt =
state === "hub-rules"
? { tab: "rules" }
: state === "hub-anomalies-resolved"
Expand All @@ -167,7 +169,7 @@ const hubFor = (state: LabState) => {
? [incidentRow({ rule_name: "Payment p95 latency" })]
: [incidentRow({ rule_name: "Payment p95 latency" }), ...OPEN, ...RESOLVED]
return fold(
HubUpdate.init({ _tag: "Alerts", search }, CONTEXT).model,
HubUpdate.init(hubAt(search), CONTEXT).model,
[
HubMessage.Message.ReadClock({ nowMs: NOW }),
HubMessage.Message.UpdatedRules({ orgId: ORG, rows: RULES }),
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/lab/entries/alerts-hub.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { defineShellLab } from "../shell-lab"
import { breadcrumbs, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab"
import { breadcrumbs, currentPath, Flags, flags, init, Message, Model, update, view } from "../alerts-hub-lab"

// `/lab/alerts-hub`: the hub tabs and silence dialog over fixtures (`?state=`).
// `/lab/alerts-hub`: the hub sections and the silence side panel over fixtures (`?state=`), with
// two unacknowledged incidents counted on the sidebar's Inbox.

export const lab = defineShellLab("Alerts hub", {
Model,
Expand All @@ -11,6 +12,7 @@ export const lab = defineShellLab("Alerts hub", {
init,
update,
view,
currentPath: "/lab/alerts-hub",
currentPath,
breadcrumbs,
seedShell: (shell) => ({ ...shell, alertsInboxCount: 2 }),
})
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 @@ -28,7 +28,7 @@ import {
populatedChains,
runOf,
} from "../page/alerts/shared/escalation-test-fixtures"
import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow } from "../page/alerts/shared/test-rows"
import { incidentRow, INCIDENT_ID, NOW, ORG, ruleId, ruleRow, hubAt } from "../page/alerts/shared/test-rows"
import type { PageContext } from "../page/contract"
import type { Resources } from "../platform/resources"
import { keepLocal } from "./local-commands"
Expand Down Expand Up @@ -112,7 +112,7 @@ const SAVE_FAILURE: LoadFailure = {
}

const hubFor = (state: LabState): Step<HubModel.Model, HubMessage.Message> => {
const entered = HubUpdate.init({ _tag: "Alerts", search: { tab: "routing" } }, CONTEXT).model
const entered = HubUpdate.init(hubAt({ tab: "routing" }), CONTEXT).model
const chains = state === "chains-empty" ? [] : populatedChains([ruleId(1), ruleId(2), ruleId(3)])
const base = [
HubMessage.Message.ReadClock({ nowMs: NOW }),
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/lab/maintenance-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
weeklyWindow,
} from "../page/alerts/shared/maintenance-test-fixtures"
import { windowPublicId } from "../page/alerts/shared/maintenance-windows"
import { NOW, ORG, ruleRow } from "../page/alerts/shared/test-rows"
import { NOW, ORG, ruleRow, hubAt, type HubAt } from "../page/alerts/shared/test-rows"
import type { PageContext } from "../page/contract"
import type { Resources } from "../platform/resources"
import { keepLocal } from "./local-commands"
Expand Down Expand Up @@ -78,7 +78,7 @@ const fold = (start: HubModel.Model, messages: ReadonlyArray<HubMessage.Message>

const dialog = (message: WindowDialog.Message) => HubMessage.Message.GotWindowDialogMessage({ message })

const searchFor = (state: LabState): HubModel.Model["search"] =>
const searchFor = (state: LabState): HubAt =>
state === "silences"
? { tab: "silences" }
: state === "silences-filtered"
Expand Down Expand Up @@ -112,7 +112,7 @@ const extraFor = (state: LabState): ReadonlyArray<HubMessage.Message> =>
: []

const hubFor = (state: LabState): Step => {
const entered = HubUpdate.init({ _tag: "Alerts", search: searchFor(state) }, CONTEXT).model
const entered = HubUpdate.init(hubAt(searchFor(state)), CONTEXT).model
const windows = state === "empty" ? [] : populatedWindows
return fold(entered, [
HubMessage.Message.ReadClock({ nowMs: NOW }),
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/lab/oncall-lab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
populatedSchedules,
schedulePublicId,
} from "../page/alerts/shared/oncall-test-fixtures"
import { incidentRow, INCIDENT_ID, ORG, ruleRow } from "../page/alerts/shared/test-rows"
import { incidentRow, INCIDENT_ID, ORG, ruleRow, hubAt } from "../page/alerts/shared/test-rows"
import type { PageContext } from "../page/contract"
import type { Resources } from "../platform/resources"
import { isoMs } from "../page/alerts/shared/time"
Expand Down Expand Up @@ -128,8 +128,8 @@ const hubStep = (model: HubModel.Model, message: HubMessage.Message) =>
HubUpdate.update(model, message, CONTEXT)

const hubFor = (state: LabState): Step<HubModel.Model, HubMessage.Message> => {
const tab = state === "strip" ? "inbox" : state === "chain-dialog" ? "routing" : "oncall"
const entered = HubUpdate.init({ _tag: "Alerts", search: { tab } }, CONTEXT).model
const tab = state === "strip" ? "inbox" : state === "chain-dialog" ? "routing" : "on-call"
const entered = HubUpdate.init(hubAt({ tab }), CONTEXT).model
const schedules = state === "schedules-empty" ? [] : populatedSchedules(ONCALL_NOW)
const chains = [mixedTargetChain(chainDestinations[0]?.id ?? "")]
return fold(
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
path: "/lab/alerts-hub",
title: "Alerts hub",
description:
"The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence dialog (`?state=`).",
"The alerts Inbox, Rules and Silences tabs loading, failing and populated, with a rule skipped for too few samples and a cancelled silence, and the silence side panel empty, picking a time and filled in (`?state=`).",
kind: "lab",
session: "none",
},
Expand Down
Loading
Loading