diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index daa8abce6..825ffe514 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -69,6 +69,7 @@ it('mock-api is only referenced in test files', () => { "AGENTS.md", "app/api/__tests__/client.browser.spec.ts", "mock-api/msw/db.ts", + "test/e2e/alerts.e2e.ts", "test/e2e/fleet-access.e2e.ts", "test/e2e/instance-create.e2e.ts", "test/e2e/inventory.e2e.ts", diff --git a/app/api/index.ts b/app/api/index.ts index 7f285a5d3..01ced81b8 100644 --- a/app/api/index.ts +++ b/app/api/index.ts @@ -14,6 +14,7 @@ export * from './client' export * from './roles' export * from './util' export * from './__generated__/Api' +export { snakeify } from './__generated__/util' // export * as ZVal from './__generated__/validate' export type { ApiTypes } diff --git a/app/api/selectors.ts b/app/api/selectors.ts index 0dd0bc122..e2b3ead6e 100644 --- a/app/api/selectors.ts +++ b/app/api/selectors.ts @@ -33,6 +33,7 @@ export type SshKey = Readonly<{ sshKey: string }> export type Sled = Readonly<{ sledId?: string }> export type IpPool = Readonly<{ pool?: string }> export type SubnetPool = Readonly<{ subnetPool?: string }> +export type AlertReceiver = Readonly<{ receiver?: string }> export type ExternalSubnet = Readonly> export type FloatingIp = Readonly> diff --git a/app/api/util.spec.ts b/app/api/util.spec.ts index 266f99023..b4a56be16 100644 --- a/app/api/util.spec.ts +++ b/app/api/util.spec.ts @@ -7,7 +7,53 @@ */ import { describe, expect, it, test } from 'vitest' -import { diskCan, genName, instanceCan, parsePortRange, synthesizeData } from './util' +import { + diskCan, + genName, + instanceCan, + parsePortRange, + resendableAlertIds, + subscriptionRegex, + synthesizeData, +} from './util' + +describe('subscriptionRegex', () => { + it('matches exact class names', () => { + expect(subscriptionRegex('instance.create').test('instance.create')).toBe(true) + expect(subscriptionRegex('instance.create').test('instance.created')).toBe(false) + }) + + it('* matches exactly one segment', () => { + const re = subscriptionRegex('disk.*') + expect(re.test('disk.create')).toBe(true) + expect(re.test('disk.snapshot.create')).toBe(false) + expect(re.test('disk')).toBe(false) + }) + + it('* can appear in any position', () => { + const re = subscriptionRegex('*.create') + expect(re.test('disk.create')).toBe(true) + expect(re.test('instance.create')).toBe(true) + expect(re.test('instance.ephemeral_ip.create')).toBe(false) + }) + + it('** matches one or more segments', () => { + const re = subscriptionRegex('hardware.**') + expect(re.test('hardware.power_shelf.psu.insert')).toBe(true) + expect(re.test('hardware.psu')).toBe(true) + expect(re.test('hardware')).toBe(false) + + const suffix = subscriptionRegex('**.delete') + expect(suffix.test('project.delete')).toBe(true) + expect(suffix.test('instance.ephemeral_ip.delete')).toBe(true) + expect(suffix.test('delete')).toBe(false) + }) + + it('does not match substrings within a segment', () => { + expect(subscriptionRegex('instance.**').test('silo.instance_quota.hit')).toBe(false) + expect(subscriptionRegex('disk.*').test('bigdisk.create')).toBe(false) + }) +}) describe('parsePortRange', () => { describe('parses', () => { @@ -197,3 +243,76 @@ test('diskCan', () => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions diskCan.abc }) + +describe('resendableAlertIds', () => { + // the rule only reads these four fields, so build them directly + type Delivery = Parameters[0][number] + + const d = ( + alertId: string, + state: Delivery['state'], + trigger: Delivery['trigger'], + alertClass = 'hardware.sled.fault' + ): Delivery => ({ alertId, state, trigger, alertClass }) + + const ids = (deliveries: Delivery[]) => [...resendableAlertIds(deliveries)].sort() + + it('is empty when there are no deliveries', () => { + expect(ids([])).toEqual([]) + }) + + it('includes an alert whose only delivery failed', () => { + expect(ids([d('a', 'failed', 'alert')])).toEqual(['a']) + }) + + it('excludes delivered and pending alerts', () => { + expect(ids([d('a', 'delivered', 'alert'), d('b', 'pending', 'alert')])).toEqual([]) + }) + + // the bug this rule replaced: it counted failed delivery records, so an + // alert that had already been resent successfully was requeued forever + it('excludes an alert that has a failed record but also a successful resend', () => { + const deliveries = [d('a', 'failed', 'alert'), d('a', 'delivered', 'resend')] + expect(ids(deliveries)).toEqual([]) + }) + + // a resend in flight takes the alert out of the set, so a second probe does + // not double-queue it + it('excludes an alert with a resend still pending', () => { + const deliveries = [d('a', 'failed', 'alert'), d('a', 'pending', 'resend')] + expect(ids(deliveries)).toEqual([]) + }) + + it('counts an alert once no matter how many times it failed', () => { + const deliveries = [d('a', 'failed', 'alert'), d('a', 'failed', 'resend')] + expect(ids(deliveries)).toEqual(['a']) + }) + + it('ignores probe deliveries entirely', () => { + const deliveries = [ + d('probe-alert', 'delivered', 'probe', 'probe'), + d('probe-alert', 'failed', 'probe', 'probe'), + d('a', 'failed', 'alert'), + ] + expect(ids(deliveries)).toEqual(['a']) + }) + + // a successful probe of an alert does not mean the alert itself landed, so it + // must not settle the alert. matches omicron's triggered_by != probe filter + it('does not let a probe-triggered success settle a real alert', () => { + const deliveries = [d('a', 'failed', 'alert'), d('a', 'delivered', 'probe')] + expect(ids(deliveries)).toEqual(['a']) + }) + + it('handles several alerts at once', () => { + const deliveries = [ + d('w', 'failed', 'alert'), + d('x', 'delivered', 'alert'), + d('y', 'failed', 'alert'), + d('y', 'failed', 'resend'), + d('z', 'failed', 'alert'), + d('z', 'pending', 'resend'), + ] + expect(ids(deliveries)).toEqual(['w', 'y']) + }) +}) diff --git a/app/api/util.ts b/app/api/util.ts index 674761a8f..9eb5b14af 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -12,6 +12,7 @@ import { match } from 'ts-pattern' import { bytesToGiB } from '~/util/units' import type { + AlertDelivery, Disk, DiskState, DiskType, @@ -40,6 +41,69 @@ export const INSTANCE_MAX_CPU = 254 export const INSTANCE_MIN_RAM_GiB = 1 export const INSTANCE_MAX_RAM_GiB = 1536 +// Webhook endpoint URL column width. The API does no length validation, so a +// longer URL fails with a database error rather than a 400. +// https://github.com/oxidecomputer/omicron/blob/6db4c7e/schema/crdb/dbinit.sql#L7192 +export const WEBHOOK_ENDPOINT_MAX_LENGTH = 512 + +// Valid alert subscription: an alert class or a glob pattern matching multiple +// classes. https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/types/versions/src/initial/alert.rs#L22-L23 +export const ALERT_SUBSCRIPTION_REGEX = + /^([a-zA-Z0-9_]+|\*|\*\*)(\.([a-zA-Z0-9_]+|\*|\*\*))*$/ + +/** A subscription with a `*` or `**` segment, as opposed to an exact class */ +export const isGlobPattern = (subscription: string) => subscription.includes('*') + +/** + * The `probe` class is synthetic: it exists for webhook receiver liveness + * probes only. + * The API lists it in `alertClassList` but rejects exact subscriptions to it + * with a 400, so keep it out of anything the user can pick. Globs are exempt + * because the API returns from its glob branch before reaching this check. + * https://github.com/oxidecomputer/omicron/blob/6db4c7e/nexus/db-model/src/alert_subscription.rs#L91-L98 + */ +export const PROBE_ALERT_CLASS = 'probe' + +/** Alert classes a receiver can actually subscribe to */ +export const isSubscribableClass = (c: { name: string }) => c.name !== PROBE_ALERT_CLASS + +/** + * Convert an alert subscription to a regex matching the class names it covers: + * a `*` segment matches exactly one segment, `**` matches one or more. + * https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/db-model/src/alert_subscription.rs + */ +export function subscriptionRegex(subscription: string) { + const pattern = subscription + .split('.') + .map((seg) => (seg === '**' ? '.+' : seg === '*' ? '[^.]+' : seg)) + .join('\\.') + return new RegExp(`^${pattern}$`) +} + +/** + * IDs of the alerts a probe with `resend=true` would requeue: the receiver has + * a delivery for the alert and no non-probe delivery of that alert has left the + * failed state. Note this is per alert, not per delivery — delivery records are + * immutable history, so a failed one stays failed forever and a resend inserts + * a new record. The API has no endpoint for this, so we derive it from the + * delivery list to preview the count before the user commits to a resend. + * https://github.com/oxidecomputer/omicron/blob/6db4c7e/nexus/db-queries/src/db/datastore/webhook_delivery.rs#L205-L240 + * + * The mock backend applies the same rule in its own `resendableAlerts`, which + * works on snake_case records, so the two have to be changed together. + */ +export function resendableAlertIds( + deliveries: Pick[] +): Set { + const relevant = deliveries.filter((d) => d.alertClass !== PROBE_ALERT_CLASS) + const settled = new Set( + relevant + .filter((d) => d.trigger !== 'probe' && d.state !== 'failed') + .map((d) => d.alertId) + ) + return new Set(relevant.filter((d) => !settled.has(d.alertId)).map((d) => d.alertId)) +} + export const MIN_DISK_SIZE_GiB = 1 /** * Disk size limited to 1023 as that's the maximum we can safely allocate right now diff --git a/app/components/AlertClassBadge.tsx b/app/components/AlertClassBadge.tsx new file mode 100644 index 000000000..ca24c3ffe --- /dev/null +++ b/app/components/AlertClassBadge.tsx @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useRef, useState } from 'react' + +import { Badge } from '@oxide/design-system/ui' + +import { Tooltip } from '~/ui/lib/Tooltip' + +/** + * Badge for an alert class or subscription glob. Badges uppercase their text + * by default, but alert classes are lowercase in the API and someone who copies + * one out of the UI needs it to work as a subscription, so keep the case. + * + * Long classes truncate to fit their container, with the full name in a + * tooltip when that happens. + */ +export const AlertClassBadge = ({ children }: { children: string }) => { + const ref = useRef(null) + const [truncated, setTruncated] = useState(false) + + // Checked lazily on hover, like `Truncate`, so there's no per-badge + // ResizeObserver and the answer can't go stale between resize and hover + const checkTruncation = () => { + const el = ref.current + if (el) setTruncated(el.scrollWidth > el.clientWidth) + } + + return ( + // Badge doesn't forward refs or event handlers, so the tooltip anchors to a + // wrapper and the overflow check lives on our own span inside the badge + + + + + {children} + + + + + ) +} diff --git a/app/components/HighlightJSON.tsx b/app/components/HighlightJSON.tsx new file mode 100644 index 000000000..3e672088e --- /dev/null +++ b/app/components/HighlightJSON.tsx @@ -0,0 +1,111 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +// Lifted from the audit log page (PR #2860) so the alert views can share it. +// Once that lands, AuditLog.tsx should import from here instead of keeping its +// own copy. + +import { memo } from 'react' +import { type JsonValue } from 'type-fest' + +const Indent = ({ depth }: { depth: number }) => ( + +) + +const greenText = 'text-(--color-green-1000) light:text-(--color-green-600)' +const yellowText = 'text-(--color-yellow-1000) light:text-(--color-yellow-600)' + +const Primitive = ({ value }: { value: JsonValue | Date }) => { + if (value === null) return null + if (typeof value === 'string') return {`"${value}"`} + if (value instanceof Date) + return {`"${value.toISOString()}"`} + if (typeof value === 'boolean' || typeof value === 'number') { + return {String(value)} + } + // objects/arrays are handled by HighlightJSON, never reach here + return null +} + +type Props = { + // `unknown` rather than JsonValue because the values come from API payloads + // typed `Record` and the renderer switches on runtime type + // anyway. Anything that isn't JSON-like renders nothing. + json: unknown + depth?: number + /** Render on one line with no indentation, for a truncated preview */ + inline?: boolean +} + +// memo is important to avoid re-renders if the value hasn't changed. value +// passed in must be referentially stable, which should generally be the case +// with API responses +export const HighlightJSON = memo(({ json, depth = 0, inline = false }: Props) => { + if (json === undefined) return null + + if ( + json === null || + typeof json === 'boolean' || + typeof json === 'number' || + typeof json === 'string' || + // special case. the types don't currently reflect that this is possible. + // dates have type object so you can't use typeof + json instanceof Date + ) { + return + } + + // in inline mode a space stands in for the newline + indent between entries + const open = inline ? ' ' : '\n' + const indent = (d: number) => (inline ? null : ) + + if (Array.isArray(json)) { + if (json.length === 0) return [] + + return ( + <> + [ + {open} + {json.map((item, index) => ( + + {indent(depth + 1)} + + {index < json.length - 1 && ,} + {open} + + ))} + {indent(depth)} + ] + + ) + } + + if (typeof json !== 'object') return null + + const entries = Object.entries(json) + if (entries.length === 0) return {'{}'} + + return ( + <> + {'{'} + {open} + {entries.map(([key, val], index) => ( + + {indent(depth + 1)} + {key} + : + + {index < entries.length - 1 && ,} + {open} + + ))} + {indent(depth)} + {'}'} + + ) +}) diff --git a/app/components/SubscriptionMatchPreview.tsx b/app/components/SubscriptionMatchPreview.tsx new file mode 100644 index 000000000..40e9d57fd --- /dev/null +++ b/app/components/SubscriptionMatchPreview.tsx @@ -0,0 +1,60 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useQuery } from '@tanstack/react-query' + +import { api, q } from '@oxide/api' + +import { + ALERT_SUBSCRIPTION_REGEX, + isGlobPattern, + isSubscribableClass, + subscriptionRegex, +} from '~/api/util' +import { AlertClassBadge } from '~/components/AlertClassBadge' +import { ALL_ISH } from '~/util/consts' + +/** + * For a glob subscription pattern, show which alert classes it currently + * matches. Renders nothing for exact (non-glob) patterns. + * Note the match set is point-in-time: globs are re-evaluated by the control + * plane as alert classes are added. + */ +export function SubscriptionMatchPreview({ pattern }: { pattern: string }) { + // Same query as the class picker this sits under, so it's a cache hit rather + // than a fetch. Matching locally with `subscriptionRegex`, mirroring the + // control plane's glob compiler. + const { data } = useQuery(q(api.alertClassList, { query: { limit: ALL_ISH } })) + + // validate before subscriptionRegex, which assumes a well-formed subscription + const isValidGlob = isGlobPattern(pattern) && ALERT_SUBSCRIPTION_REGEX.test(pattern) + if (!isValidGlob || !data) return null + + const re = subscriptionRegex(pattern) + // the probe class can't be subscribed to, so don't count it as a match + const classes = data.items.filter(isSubscribableClass).filter((c) => re.test(c.name)) + + if (classes.length === 0) { + return ( +

+ No current alert classes match this pattern. It may match classes added in the + future. +

+ ) + } + + return ( +

+ Matches {classes.length} alert {classes.length === 1 ? 'class' : 'classes'}:{' '} + + {classes.map((c) => ( + {c.name} + ))} + +

+ ) +} diff --git a/app/components/form/fields/SubscriptionsField.tsx b/app/components/form/fields/SubscriptionsField.tsx new file mode 100644 index 000000000..a27ec9dfa --- /dev/null +++ b/app/components/form/fields/SubscriptionsField.tsx @@ -0,0 +1,521 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useQuery } from '@tanstack/react-query' +import cn from 'classnames' +import { useCallback, useId, useRef, useState } from 'react' +import { useController, type Control } from 'react-hook-form' +import * as R from 'remeda' +import { match, P } from 'ts-pattern' + +import { api, q } from '@oxide/api' +import { Close8Icon } from '@oxide/design-system/icons/react' + +import { + ALERT_SUBSCRIPTION_REGEX, + isGlobPattern, + isSubscribableClass, + PROBE_ALERT_CLASS, + subscriptionRegex, +} from '~/api/util' +import type { WebhookCreateFormValues } from '~/forms/webhook-create' +import { Checkbox } from '~/ui/lib/Checkbox' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { FieldLabel } from '~/ui/lib/FieldLabel' +import { ItemLabel } from '~/ui/lib/ItemLabel' +import { TextInputError } from '~/ui/lib/TextInput' +import { Tooltip } from '~/ui/lib/Tooltip' +import { KEYS } from '~/ui/util/keys' +import { ALL_ISH } from '~/util/consts' + +// segments may only contain [a-zA-Z0-9_], unlike resource names +export const validateSubscription = (value: string) => { + if (!ALERT_SUBSCRIPTION_REGEX.test(value)) + return 'Must be an alert class or a glob pattern like hardware.** (letters, numbers, and underscores only)' + // the API rejects this one with a 400, so catch it before submit + if (value === PROBE_ALERT_CLASS) + return 'The probe class is only used for liveness probes and cannot be subscribed to' + return undefined +} + +function SubscriptionChip({ + value, + matchCount, + armed, + onRemove, +}: { + value: string + /** Glob chips only: matched class count for the tooltip; undefined while loading */ + matchCount?: number + armed: boolean + onRemove: () => void +}) { + return ( + // Tooltip renders just the chip when content is undefined (exact chips, loading) + + + {value} + + + + ) +} + +function HighlightedName({ name, query }: { name: string; query: string }) { + const idx = name.toLowerCase().indexOf(query.toLowerCase()) + if (!query || idx === -1) return <>{name} + return ( +
+ {name.slice(0, idx)} + {name.slice(idx, idx + query.length)} + {name.slice(idx + query.length)} +
+ ) +} + +type RowState = + | { kind: 'covered'; via: string } + | { kind: 'picked' } + | { kind: 'pending' } + /** Not matched by the query glob, but would be by a broader `**` version */ + | { kind: 'promoted'; via: string } + | { kind: 'plain' } + +/** Split subscriptions into glob matchers and exact class names */ +function toMatchers(subscriptions: string[]) { + return { + globs: subscriptions + .filter(isGlobPattern) + .map((g) => [g, subscriptionRegex(g)] as const), + exacts: new Set(subscriptions.filter((s) => !isGlobPattern(s))), + } +} + +export function SubscriptionsField({ + control, +}: { + control: Control +}) { + const id = useId() + const listboxId = `${id}-listbox` + const inputRef = useRef(null) + const panelRef = useRef(null) + + // Keep the open panel visually stationary when adding or removing chips + // wraps the shell to a different number of lines: the panel hangs off the + // shell's bottom edge, so scrolling the page by the height delta cancels + // the layout shift. The input row (the shell's last line) stays put too; + // only the content above shifts. useCallback so the observer isn't torn + // down and recreated on every render. + const observeShellResize = useCallback((el: HTMLDivElement) => { + let prevHeight = el.offsetHeight + const observer = new ResizeObserver(() => { + const delta = el.offsetHeight - prevHeight + prevHeight = el.offsetHeight + if (delta === 0 || !panelRef.current) return + // instant, and ResizeObserver fires between layout and paint, so the + // compensation is never visible as motion. If the page can't scroll + // far enough (already at the top or bottom), the panel just moves as + // it would have without compensation. + window.scrollBy({ top: delta, behavior: 'instant' }) + }) + observer.observe(el) + return () => observer.disconnect() + }, []) + + const { field } = useController({ control, name: 'subscriptions' }) + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + // index of the chip primed for deletion. Backspace on an empty query arms + // the last chip; arrow keys move the armed selection through the chips. + const [armedIdx, setArmedIdx] = useState(null) + const [activeIdx, setActiveIdx] = useState(null) + const [commitError, setCommitError] = useState() + + const { data } = useQuery(q(api.alertClassList, { query: { limit: ALL_ISH } })) + const classes = (data?.items ?? []).filter(isSubscribableClass) + + const committed = field.value + const matchers = toMatchers(committed) + + // glob chip tooltip counts; empty while classes load so lookups come back + // undefined and the tooltip stays off + const chipMatchCounts = new Map( + data + ? matchers.globs.map(([g, re]) => [g, classes.filter((c) => re.test(c.name)).length]) + : [] + ) + + const queryTrimmed = query.trim() + const queryIsValidGlob = + isGlobPattern(queryTrimmed) && ALERT_SUBSCRIPTION_REGEX.test(queryTrimmed) + const queryRegex = queryIsValidGlob ? subscriptionRegex(queryTrimmed) : null + // broadest version of the query glob (every wildcard segment widened to `**`), + // used to keep near-miss rows visible with a hint about the covering pattern + const promotedGlob = queryIsValidGlob + ? queryTrimmed + .split('.') + .map((seg) => (seg.includes('*') ? '**' : seg)) + .join('.') + : null + const promotedRegex = promotedGlob ? subscriptionRegex(promotedGlob) : null + + // valid glob → its (widened) matches; glob still being typed (e.g. `*.`) → + // everything, since substring matching on `*` can never hit a class name; + // otherwise substring filter (which is a no-op for an empty query) + const visible = classes.filter((c) => + promotedRegex + ? promotedRegex.test(c.name) + : isGlobPattern(queryTrimmed) || + c.name.toLowerCase().includes(queryTrimmed.toLowerCase()) + ) + + // precedence: covered > picked > pending > promoted > plain + function rowState(name: string): RowState { + const via = matchers.globs.find(([, re]) => re.test(name))?.[0] + if (via) return { kind: 'covered', via } + if (matchers.exacts.has(name)) return { kind: 'picked' } + if (queryRegex?.test(name)) return { kind: 'pending' } + if (promotedGlob && promotedGlob !== queryTrimmed) { + return { kind: 'promoted', via: promotedGlob } + } + return { kind: 'plain' } + } + + // Subscribed (picked or covered) classes sort to the top, based on what was + // committed when the panel opened rather than live state, so rows don't + // jump to the top mid-picking; new picks group on the next open. + const committedAtOpen = useRef([]) + + function openPanel() { + if (open) return + committedAtOpen.current = committed + setOpen(true) + } + + const frozen = toMatchers(committedAtOpen.current) + const [subscribedRows, restRows] = R.partition( + visible.map((c) => ({ ...c, state: rowState(c.name) })), + (row) => frozen.exacts.has(row.name) || frozen.globs.some(([, re]) => re.test(row.name)) + ) + const rows = [...subscribedRows, ...restRows] + // covered rows can't be toggled, so keyboard nav skips them + const selectableIdxs = rows.flatMap((row, i) => (row.state.kind === 'covered' ? [] : [i])) + + const optionId = (idx: number) => `${id}-opt-${idx}` + + function commitQuery() { + const value = queryTrimmed + const error = validateSubscription(value) + if (error) { + setCommitError(error) + return + } + if (!committed.includes(value)) field.onChange([...committed, value]) + setQuery('') + setCommitError(undefined) + setActiveIdx(null) + } + + function toggleRow(name: string) { + const state = rowState(name) + if (state.kind === 'covered') return + field.onChange( + state.kind === 'picked' ? committed.filter((c) => c !== name) : [...committed, name] + ) + // query is deliberately not reset so multiple picks are cheap + } + + function removeChip(value: string) { + field.onChange(committed.filter((c) => c !== value)) + // indexes shift after removal, so any armed selection is stale + setArmedIdx(null) + } + + function moveActive(dir: 1 | -1) { + const n = selectableIdxs.length + if (n === 0) return + // with no active row, down enters at the top and up at the bottom + const pos = + activeIdx === null ? (dir === 1 ? -1 : n) : selectableIdxs.indexOf(activeIdx) + const next = selectableIdxs[(pos + dir + n) % n] + setActiveIdx(next) + document.getElementById(optionId(next))?.scrollIntoView({ block: 'nearest' }) + } + + function closePanel() { + setOpen(false) + setArmedIdx(null) + setActiveIdx(null) + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === KEYS.enter) { + e.preventDefault() // never submit the outer form from this input + if (open && activeIdx !== null && rows[activeIdx]) { + toggleRow(rows[activeIdx].name) + } else if (queryTrimmed) { + commitQuery() + } + } else if (e.key === KEYS.space) { + // a subscription can never contain a space, so the key is free to act as + // a commit shortcut: typing `hardware.**` and hitting space makes the + // chip without having to discover Enter. Only globs commit — an exact + // class is meant to be ticked in the list, and quietly turning a + // half-typed name into a chip would be worse than doing nothing. + e.preventDefault() + if (open && activeIdx !== null && rows[activeIdx]) { + toggleRow(rows[activeIdx].name) + } else if (isGlobPattern(queryTrimmed)) { + commitQuery() + } + } else if (e.key === KEYS.backspace || e.key === KEYS.delete) { + if (armedIdx !== null) { + e.preventDefault() + removeChip(committed[armedIdx]) + } else if (e.key === KEYS.backspace && query === '' && committed.length > 0) { + setArmedIdx(committed.length - 1) + } + // otherwise fall through to normal text deletion + } else if (e.key === KEYS.left) { + const input = inputRef.current + const caretAtStart = input?.selectionStart === 0 && input?.selectionEnd === 0 + if (armedIdx !== null) { + e.preventDefault() + setArmedIdx(Math.max(0, armedIdx - 1)) + } else if (caretAtStart && committed.length > 0) { + e.preventDefault() + setArmedIdx(committed.length - 1) + } + } else if (e.key === KEYS.right && armedIdx !== null) { + e.preventDefault() + // moving right off the last chip returns to the input text + setArmedIdx(armedIdx === committed.length - 1 ? null : armedIdx + 1) + } else if (e.key === KEYS.escape && open) { + // keep focus but close the panel; stop the event so the page/form + // doesn't also react to Escape + e.stopPropagation() + closePanel() + } else if (e.key === KEYS.down) { + e.preventDefault() + openPanel() + setArmedIdx(null) + moveActive(1) + } else if (e.key === KEYS.up) { + e.preventDefault() + setArmedIdx(null) + moveActive(-1) + } + } + + return ( +
+
+ + Alert subscriptions + +
+
{ + if (!e.currentTarget.contains(e.relatedTarget)) { + closePanel() + // valid globs save on blur + if (isGlobPattern(queryTrimmed) && !validateSubscription(queryTrimmed)) { + commitQuery() + } else { + setQuery('') + setCommitError(undefined) + } + } + }} + > + {/* click anywhere in the shell to focus the input; the input itself is + the interactive element, so no role or keyboard handler is needed */} + {/* oxlint-disable-next-line click-events-have-key-events, no-static-element-interactions */} +
inputRef.current?.focus()} + > + {committed.map((value, i) => ( + removeChip(value)} + /> + ))} + { + setQuery(e.target.value) + setArmedIdx(null) + setCommitError(undefined) + setActiveIdx(null) + openPanel() + }} + onFocus={openPanel} + onKeyDown={onKeyDown} + /> +
+ {open && ( + // ARIA 1.2 combobox pattern: focus stays on the input, which points at + // the active row via aria-activedescendant, so the listbox and options + // are divs and never take focus themselves +
e.preventDefault()} + > +
+ {queryTrimmed === '' ? ( + <> + All classes + Showing {classes.length} + + ) : ( + <> + Matching “{queryTrimmed}” + + Showing {rows.length} of {classes.length} + + + )} +
+ {/* no empty state while classes are still loading */} + {rows.length === 0 && data ? ( +
+ setQuery('')} + /> +
+ ) : ( + rows.map((row, i) => { + const { state } = row + const covered = state.kind === 'covered' + // right-aligned mono label: the pattern that covers (or would + // cover) this row + const label = match(state) + .returnType<{ text: string; className: string } | null>() + .with({ kind: 'covered' }, ({ via }) => ({ + text: `via ${via}`, + className: 'text-tertiary', + })) + .with({ kind: 'pending' }, () => ({ + text: queryTrimmed, + className: 'text-accent-secondary', + })) + .with({ kind: 'promoted' }, ({ via }) => ({ + text: via, + className: 'text-tertiary', + })) + .with({ kind: P.union('picked', 'plain') }, () => null) + .exhaustive() + return ( + // oxlint-disable-next-line click-events-have-key-events, interactive-supports-focus +
toggleRow(row.name)} + > + + + + + + ) : ( + row.name + ) + } + > + {row.description} + + + {label && ( + // mt-1 optically centers the 1rem mono label on the + // 1.5rem name line + + {label.text} + + )} +
+ ) + }) + )} +
+ )} +
+ {commitError && {commitError}} +
+ ) +} diff --git a/app/forms/webhook-create.tsx b/app/forms/webhook-create.tsx new file mode 100644 index 000000000..de28b6b7a --- /dev/null +++ b/app/forms/webhook-create.tsx @@ -0,0 +1,222 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useController, useForm, useWatch, type Control } from 'react-hook-form' +import { useNavigate } from 'react-router' + +import { api, queryClient, useApiMutation, WEBHOOK_ENDPOINT_MAX_LENGTH } from '@oxide/api' +import { Webhooks24Icon } from '@oxide/design-system/icons/react' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { ErrorMessage } from '~/components/form/fields/ErrorMessage' +import { NameField } from '~/components/form/fields/NameField' +import { SubscriptionsField } from '~/components/form/fields/SubscriptionsField' +import { TextField } from '~/components/form/fields/TextField' +import { Form } from '~/components/form/Form' +import { FullPageForm } from '~/components/form/FullPageForm' +import { HL } from '~/components/HL' +import { addToast } from '~/stores/toast' +import { FormDivider } from '~/ui/lib/Divider' +import { Message } from '~/ui/lib/Message' +import { ClearAndAddButtons, MiniTable } from '~/ui/lib/MiniTable' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { HintLink } from '~/ui/lib/TextInput' +import { KEYS } from '~/ui/util/keys' +import { links } from '~/util/links' +import { pb } from '~/util/path-builder' + +export const validateEndpoint = (value: string) => { + let url: URL + try { + url = new URL(value) + } catch { + return 'Must be a valid URL, including the scheme (e.g., https://)' + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return 'Must be an HTTP or HTTPS URL' + } + if (value.length > WEBHOOK_ENDPOINT_MAX_LENGTH) { + return `Must be at most ${WEBHOOK_ENDPOINT_MAX_LENGTH} characters` + } +} + +export type WebhookCreateFormValues = { + name: string + description: string + endpoint: string + secrets: string[] + subscriptions: string[] +} + +const defaultValues: WebhookCreateFormValues = { + name: '', + description: '', + endpoint: '', + secrets: [], + subscriptions: [], +} + +const secretColumns = [ + { + header: 'Secrets', + cell: (secret: string) => secret, + }, +] + +function SecretsField({ control }: { control: Control }) { + const { field, fieldState } = useController({ + control, + name: 'secrets', + rules: { + validate: (secrets) => secrets.length > 0 || 'At least one secret is required', + }, + }) + const subform = useForm({ defaultValues: { secret: '' } }) + const secret = useWatch({ control: subform.control, name: 'secret' }) + + const submitSubform = subform.handleSubmit(({ secret }) => { + if (!field.value.includes(secret)) { + field.onChange([...field.value, secret]) + } + subform.reset() + }) + + return ( + <> +
+ + Shared secret used to sign payloads.{' '} + Learn more about secrets + + } + required + onKeyDown={(e) => { + if (e.key === KEYS.enter) { + e.preventDefault() // prevent full form submission + submitSubform(e) + } + }} + /> + subform.reset()} + onSubmit={submitSubform} + /> +
+ secret} + onRemoveItem={(secret) => field.onChange(field.value.filter((s) => s !== secret))} + removeLabel={(secret) => `remove secret ${secret}`} + /> + + + ) +} + +const globCode = 'text-mono-sm bg-info-secondary text-info rounded-sm px-1' + +const SubscriptionsMessage = ( + <> + Alert subscriptions may include simple globs to subscribe to multiple classes of alerts. + E.g. hardware.** or{' '} + **.fault.{' '} + + Read the Webhooks guide + + , the{' '} + + globbing overview + + , and the{' '} + + API docs + {' '} + to learn more. + +) + +export const handle = { crumb: 'New webhook receiver' } + +export default function CreateWebhookForm() { + const navigate = useNavigate() + + const createWebhook = useApiMutation(api.webhookReceiverCreate, { + onSuccess(receiver) { + queryClient.invalidateEndpoint('alertReceiverList') + // prettier-ignore + addToast(<>Webhook receiver {receiver.name} created) + navigate(pb.alertReceivers()) + }, + }) + + const form = useForm({ defaultValues }) + + return ( + <> + + }>Create webhook receiver + + { + await createWebhook.mutateAsync({ + body: { name, description, endpoint, secrets, subscriptions }, + }) + }} + loading={createWebhook.isPending || createWebhook.isSuccess} + submitError={createWebhook.error} + > + + + + + Subscriptions +
+ + +
+ + Secrets + + + + Create webhook receiver + + navigate(pb.alertReceivers())} /> + +
+ + ) +} diff --git a/app/forms/webhook-edit.tsx b/app/forms/webhook-edit.tsx new file mode 100644 index 000000000..f18f74e25 --- /dev/null +++ b/app/forms/webhook-edit.tsx @@ -0,0 +1,99 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' +import { useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getAlertReceiverSelector, useAlertReceiverSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import { validateEndpoint } from './webhook-create' + +const receiverView = ({ receiver }: PP.AlertReceiver) => + q(api.alertReceiverView, { path: { receiver } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getAlertReceiverSelector(params) + await queryClient.prefetchQuery(receiverView(selector)) + return null +} + +export const handle = titleCrumb('Edit webhook receiver') + +export default function EditWebhookSideModalForm() { + const navigate = useNavigate() + const receiverSelector = useAlertReceiverSelector() + + const { data: receiver } = usePrefetchedQuery(receiverView(receiverSelector)) + + const form = useForm({ + defaultValues: { + name: receiver.name, + description: receiver.description, + endpoint: receiver.kind.endpoint, + }, + }) + + const editWebhook = useApiMutation(api.webhookReceiverUpdate, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('alertReceiverList') + // the update endpoint returns nothing, so we rely on the submitted name + const newName = variables.body.name || receiver.name + navigate(pb.alertReceiver({ receiver: newName })) + // prettier-ignore + addToast(<>Webhook receiver {newName} updated) + + // Only invalidate if we're staying on the same page. If the name _has_ + // changed, invalidating alertReceiverView causes an error page to flash + // while the loader for the target page is running because the current + // page's receiver gets cleared out while we're still on the page. If + // we're navigating to a different page, its query will fetch anew + // regardless. + if (receiver.name === newName) { + queryClient.invalidateEndpoint('alertReceiverView') + } + }, + }) + + return ( + navigate(pb.alertReceiver(receiverSelector))} + onSubmit={({ name, description, endpoint }) => { + editWebhook.mutate({ + path: { receiver: receiver.name }, + body: { name, description, endpoint }, + }) + }} + loading={editWebhook.isPending || editWebhook.isSuccess} + submitError={editWebhook.error} + > + + + + + ) +} diff --git a/app/hooks/use-pagination.browser.spec.ts b/app/hooks/use-pagination.browser.spec.ts index 26a2b9ac5..3bdaef5fa 100644 --- a/app/hooks/use-pagination.browser.spec.ts +++ b/app/hooks/use-pagination.browser.spec.ts @@ -43,6 +43,22 @@ describe('usePagination', () => { expect(result.current.hasPrev).toBeFalsy() }) + it('resets to the first page when the query changes', async () => { + const { result, rerender, act } = await renderHook( + (props) => usePagination(props?.queryId), + { initialProps: { queryId: 'a' } } + ) + + await act(() => result.current.goToNextPage('page2')) + expect(result.current.currentPage).toEqual('page2') + expect(result.current.hasPrev).toBeTruthy() + + await rerender({ queryId: 'b' }) + + expect(result.current.currentPage).toBeUndefined() + expect(result.current.hasPrev).toBeFalsy() + }) + it('remembers previous pages', async () => { const { result, act } = await renderHook(() => usePagination()) diff --git a/app/hooks/use-pagination.ts b/app/hooks/use-pagination.ts index f1749e502..48d365c57 100644 --- a/app/hooks/use-pagination.ts +++ b/app/hooks/use-pagination.ts @@ -9,10 +9,27 @@ import { useCallback, useState } from 'react' type PageToken = string | undefined -export function usePagination() { +/** + * @param queryId Identifies the query being paginated. When it changes, we jump + * back to the first page: a page token is only meaningful for the query that + * produced it, so carrying one across a query change (e.g., a filter above the + * table) means asking the API to resume from a position that doesn't exist in + * the new result set. + */ +export function usePagination(queryId?: string) { const [prevPages, setPrevPages] = useState([]) const [currentPage, setCurrentPage] = useState() + // Adjusting state during render rather than in an effect, as recommended by + // https://react.dev/learn/you-might-not-need-an-effect. An effect would let a + // render go out with the stale token, firing off a bogus request. + const [prevQueryId, setPrevQueryId] = useState(queryId) + if (queryId !== prevQueryId) { + setPrevQueryId(queryId) + setPrevPages([]) + setCurrentPage(undefined) + } + const goToPrevPage = useCallback(() => { const prevPage = prevPages.pop() setCurrentPage(prevPage) diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 5298181d9..f5f5524eb 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -53,6 +53,7 @@ export const requireSledParams = requireParams('sledId') export const requireUpdateParams = requireParams('version') export const getIpPoolSelector = requireParams('pool') export const getSubnetPoolSelector = requireParams('subnetPool') +export const getAlertReceiverSelector = requireParams('receiver') export const getAffinityGroupSelector = requireParams('project', 'affinityGroup') export const getAntiAffinityGroupSelector = requireParams('project', 'antiAffinityGroup') @@ -104,6 +105,7 @@ export const useSledParams = () => useSelectedParams(requireSledParams) export const useUpdateParams = () => useSelectedParams(requireUpdateParams) export const useIpPoolSelector = () => useSelectedParams(getIpPoolSelector) export const useSubnetPoolSelector = () => useSelectedParams(getSubnetPoolSelector) +export const useAlertReceiverSelector = () => useSelectedParams(getAlertReceiverSelector) export const useAffinityGroupSelector = () => useSelectedParams(getAffinityGroupSelector) export const useAntiAffinityGroupSelector = () => useSelectedParams(getAntiAffinityGroupSelector) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index fca0d33b8..fb4c25587 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -13,6 +13,7 @@ import { Cloud16Icon, IpGlobal16Icon, Metrics16Icon, + Notifications16Icon, Servers16Icon, SoftwareUpdate16Icon, Subnet16Icon, @@ -24,7 +25,7 @@ import { TopBar } from '~/components/TopBar' import { useCurrentUser } from '~/hooks/use-current-user' import { useQuickActions, type QuickActionItem } from '~/hooks/use-quick-actions' import { Divider } from '~/ui/lib/Divider' -import { inventoryBase, pb } from '~/util/path-builder' +import { alertingBase, inventoryBase, pb } from '~/util/path-builder' import { ContentPane, PageContainer } from './helpers' @@ -55,6 +56,8 @@ export default function SystemLayout() { { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, { value: 'Subnet Pools', path: pb.subnetPools() }, + { value: 'Alerting', path: pb.alertReceivers() }, + { value: 'Alerts', path: pb.alerts() }, { value: 'System Update', path: pb.systemUpdate() }, { value: 'Fleet Access', path: pb.fleetAccess() }, ] @@ -101,6 +104,9 @@ export default function SystemLayout() { Subnet Pools + + Alerting + System Update diff --git a/app/pages/system/alerting/AlertReceiverDeliveries.tsx b/app/pages/system/alerting/AlertReceiverDeliveries.tsx new file mode 100644 index 000000000..0060f4e7b --- /dev/null +++ b/app/pages/system/alerting/AlertReceiverDeliveries.tsx @@ -0,0 +1,410 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useQuery } from '@tanstack/react-query' +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useCallback, useState, type ReactNode } from 'react' +import { match } from 'ts-pattern' + +import { + api, + getListQFn, + q, + queryClient, + snakeify, + useApiMutation, + type Alert, + type AlertDelivery, + type AlertDeliveryState, + type WebhookDeliveryAttempt, +} from '@oxide/api' +import { Webhooks16Icon, Webhooks24Icon } from '@oxide/design-system/icons/react' +import { Badge, Button, type BadgeColor } from '@oxide/design-system/ui' + +import { AlertClassBadge } from '~/components/AlertClassBadge' +import { useIntervalPicker } from '~/components/RefetchIntervalPicker' +import { useAlertReceiverSelector } from '~/hooks/use-params' +import { confirmAction } from '~/stores/confirm-action' +import { addToast } from '~/stores/toast' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { Table } from '~/table/Table' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' +import { DateTime } from '~/ui/lib/DateTime' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Listbox } from '~/ui/lib/Listbox' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel, SideModal } from '~/ui/lib/SideModal' +import { TableEmptyBox } from '~/ui/lib/Table' +import { Tabs } from '~/ui/lib/Tabs' + +type StateFilter = 'all' | AlertDeliveryState + +const stateFilterParams = (filter: StateFilter) => + match(filter) + .with('all', () => ({})) + .with('delivered', () => ({ delivered: true })) + .with('pending', () => ({ pending: true })) + .with('failed', () => ({ failed: true })) + .exhaustive() + +export const deliveryList = (receiver: string, filter: StateFilter = 'all') => + getListQFn(api.alertDeliveryList, { + path: { receiver }, + // sort newest first: the API's default is time_and_id_ascending + query: { ...stateFilterParams(filter), sortBy: 'time_and_id_descending' }, + }) + +const stateBadgeColor: Record = { + delivered: 'default', + pending: 'purple', + failed: 'destructive', +} + +const DeliveryStateBadge = ({ state }: { state: AlertDeliveryState }) => ( + {state} +) + +const stateFilterItems: { value: StateFilter; label: string }[] = [ + { value: 'all', label: 'All states' }, + { value: 'delivered', label: 'Delivered' }, + { value: 'pending', label: 'Pending' }, + { value: 'failed', label: 'Failed' }, +] + +const deliveryColHelper = createColumnHelper() +const staticDeliveryCols = [ + // shortId for these two to force truncation + deliveryColHelper.accessor('id', { ...Columns.shortId, header: 'Delivery ID' }), + deliveryColHelper.accessor('alertId', { ...Columns.shortId, header: 'Alert ID' }), + deliveryColHelper.accessor('alertClass', { + header: 'Alert class', + cell: (info) => {info.getValue()}, + }), + deliveryColHelper.accessor('state', { + cell: (info) => , + }), + deliveryColHelper.accessor('timeStarted', { ...Columns.timeCreated, header: 'Started' }), + deliveryColHelper.accessor('trigger', { + cell: (info) => {info.getValue()}, + }), +] + +export function DeliveriesTab() { + const { receiver } = useAlertReceiverSelector() + const [filter, setFilter] = useState('all') + const [selectedDelivery, setSelectedDelivery] = useState(null) + + const { mutateAsync: resendDelivery } = useApiMutation(api.alertDeliveryResend, { + onSuccess() { + queryClient.invalidateEndpoint('alertDeliveryList') + addToast('Delivery resend started') + }, + }) + + const makeActions = useCallback( + (delivery: AlertDelivery): MenuAction[] => [ + { + label: 'View details', + onActivate: () => setSelectedDelivery(delivery), + }, + { + label: 'Resend', + onActivate: () => + confirmAction({ + doAction: () => + resendDelivery({ + path: { alertId: delivery.alertId }, + query: { receiver }, + }), + errorTitle: 'Could not resend alert', + modalTitle: 'Confirm resend', + modalContent: ( +
+

+ Are you sure you want to resend this alert? The dispatcher will attempt to + deliver it again. +

+ + + {delivery.alertClass} + + + + + + +
+ ), + actionType: 'primary', + }), + }, + ], + [resendDelivery, receiver] + ) + + const emptyState = ( + } + title="No deliveries" + body={ + filter === 'all' + ? 'Alerts delivered to this webhook receiver will show up here' + : `No ${filter} deliveries found` + } + /> + ) + + const columns = useColsWithActions(staticDeliveryCols, makeActions) + const { table, query } = useQueryTable({ + query: deliveryList(receiver, filter), + columns, + emptyState, + }) + + // polling refreshes the list under the open side modal, so show the latest + // version of the selected delivery. Fall back to the snapshot from click + // time if it's no longer on the current page (paged or filtered out) + const liveDelivery = + selectedDelivery && + (query.data?.items.find((d) => d.id === selectedDelivery.id) ?? selectedDelivery) + + // deliveries are dispatched asynchronously, so pending ones resolve on their + // own while the page is open + const { intervalPicker } = useIntervalPicker({ + enabled: true, + isLoading: query.isFetching, + fn: () => queryClient.invalidateEndpoint('alertDeliveryList'), + }) + + return ( + <> +
+ {intervalPicker} + +
+ {table} + {liveDelivery && ( + setSelectedDelivery(null)} + /> + )} + + ) +} + +export const attemptResultBadge = (result: WebhookDeliveryAttempt['result']) => + match(result) + .with('succeeded', () => Succeeded) + .with('failed_http_error', () => HTTP error) + .with('failed_unreachable', () => Unreachable) + .with('failed_timeout', () => Timeout) + .exhaustive() + +const attemptColHelper = createColumnHelper() +const attemptCols = [ + attemptColHelper.accessor('result', { + header: 'Status', + cell: (info) => attemptResultBadge(info.getValue()), + }), + attemptColHelper.accessor('timeSent', { ...Columns.timeCreated, header: 'Attempt' }), + attemptColHelper.accessor((a) => a.response?.durationMs, { + header: 'Duration', + cell: (info) => { + const ms = info.getValue() + return ms != null ? `${ms}ms` : + }, + }), +] + +function DeliverySideModal({ + delivery, + onDismiss, +}: { + delivery: AlertDelivery + onDismiss: () => void +}) { + const { receiver } = useAlertReceiverSelector() + const attemptsTable = useReactTable({ + columns: attemptCols, + data: delivery.attempts.webhook, + getCoreRowModel: getCoreRowModel(), + }) + + // fetched here rather than in RequestTab so it's usually ready by the time + // that tab is opened. throwOnError off so a missing alert falls back to the + // request tab's placeholders instead of hitting the error boundary + const { data: alert } = useQuery( + q(api.alertView, { path: { alertId: delivery.alertId } }, { throwOnError: false }) + ) + + return ( + + {receiver} + + } + > + + + + {delivery.alertClass} + + + + + + + + + + + {delivery.trigger} + + + + + + Attempts + Request + + {/* full-width tabs put the panel at the modal gutter; the extra + padding lines the content up with the properties table above */} + + {delivery.attempts.webhook.length ? ( + + ) : ( + + + + )} + + + + + + + + + + + ) +} + +// The delivery request format is defined by RFD 538 and built in +// https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/src/app/webhook.rs#L395-L555 +// The API does not return the request that was sent, so we reconstruct it from +// the delivery record and the alert fetched by ID. The signature can't be +// known from here (it's an HMAC made with the receiver's secrets), so it shows +// up as an angle-bracket placeholder, as do alert data and version while the +// alert hasn't loaded. + +const dataJson = (alert: Alert) => + JSON.stringify(snakeify(alert.alert), null, 2).replaceAll('\n', '\n ') + +const payloadJson = (delivery: AlertDelivery, sentAt: string, alert?: Alert) => `{ + "alert_class": ${JSON.stringify(delivery.alertClass)}, + "alert_version": ${alert ? alert.version : ''}, + "alert_id": ${JSON.stringify(delivery.alertId)}, + "data": ${alert ? dataJson(alert) : ''}, + "delivery": { + "id": ${JSON.stringify(delivery.id)}, + "receiver_id": ${JSON.stringify(delivery.receiverId)}, + "sent_at": ${JSON.stringify(sentAt)}, + "trigger": ${JSON.stringify(delivery.trigger)} + } +}` + +const requestHeaders = ( + delivery: AlertDelivery, + sentAt: string, + alert?: Alert +): [string, string][] => [ + ['x-oxide-receiver-id', delivery.receiverId], + ['x-oxide-delivery-id', delivery.id], + ['x-oxide-alert-id', delivery.alertId], + ['x-oxide-alert-class', delivery.alertClass], + ['x-oxide-alert-version', alert ? alert.version.toString() : ''], + ['x-oxide-timestamp', sentAt], + ['content-type', 'application/json'], + // one signature header per secret on the receiver + ['x-oxide-signature', 'a=sha256&id=&s='], +] + +function RequestTab({ delivery, alert }: { delivery: AlertDelivery; alert?: Alert }) { + // every attempt is signed and timestamped when it is sent, so the timestamp + // shown is the one from the most recent attempt + const lastSent = delivery.attempts.webhook.at(-1)?.timeSent + const sentAt = lastSent ? lastSent.toISOString() : '' + const payload = payloadJson(delivery, sentAt, alert) + const headers = requestHeaders(delivery, sentAt, alert) + const headersText = headers.map(([name, value]) => `${name}: ${value}`).join('\n') + + return ( +
+

+ The API does not return the request that was sent, so this is reconstructed from the + delivery and alert records. Values in angle brackets are not available through the + API. +

+ +
+          {payload}
+        
+
+ +
+ {headers.map(([name, value]) => ( +
+
{name}
+
{value}
+
+ ))} +
+
+
+ ) +} + +function RequestSection({ + title, + copyText, + children, +}: { + title: string + copyText: string + children: ReactNode +}) { + return ( +
+
+ {title} + +
+ {children} +
+ ) +} diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx new file mode 100644 index 000000000..314e5ed01 --- /dev/null +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -0,0 +1,422 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useQuery } from '@tanstack/react-query' +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' +import * as R from 'remeda' + +import { + api, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type WebhookSecret, +} from '@oxide/api' +import { Webhooks24Icon } from '@oxide/design-system/icons/react' +import { Button } from '@oxide/design-system/ui' + +import { isSubscribableClass } from '~/api/util' +import { AlertClassBadge } from '~/components/AlertClassBadge' +import { ComboboxField } from '~/components/form/fields/ComboboxField' +import { validateSubscription } from '~/components/form/fields/SubscriptionsField' +import { TextField } from '~/components/form/fields/TextField' +import { ModalForm } from '~/components/form/ModalForm' +import { HL } from '~/components/HL' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { QueryParamTabs } from '~/components/QueryParamTabs' +import { SubscriptionMatchPreview } from '~/components/SubscriptionMatchPreview' +import { makeCrumb } from '~/hooks/use-crumbs' +import { getAlertReceiverSelector, useAlertReceiverSelector } from '~/hooks/use-params' +import { confirmAction } from '~/stores/confirm-action' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { Table } from '~/table/Table' +import { CardBlock, LearnMore } from '~/ui/lib/CardBlock' +import { type ComboboxItem } from '~/ui/lib/Combobox' +import * as Dropdown from '~/ui/lib/DropdownMenu' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { InlineCode } from '~/ui/lib/InlineCode' +import { ItemLabel } from '~/ui/lib/ItemLabel' +import { Message } from '~/ui/lib/Message' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { TableEmptyBox } from '~/ui/lib/Table' +import { Tabs } from '~/ui/lib/Tabs' +import { HintLink } from '~/ui/lib/TextInput' +import { ALL_ISH } from '~/util/consts' +import { docLinks, links } from '~/util/links' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import { DeliveriesTab, deliveryList } from './AlertReceiverDeliveries' +import { TestingTab } from './AlertReceiverTesting' + +const receiverView = ({ receiver }: PP.AlertReceiver) => + q(api.alertReceiverView, { path: { receiver } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { receiver } = getAlertReceiverSelector(params) + await Promise.all([ + queryClient.prefetchQuery(receiverView({ receiver })), + queryClient.prefetchQuery(deliveryList(receiver).optionsFn()), + ]) + return null +} + +export const handle = makeCrumb((p) => p.receiver!) + +export default function AlertReceiverPage() { + const receiverSelector = useAlertReceiverSelector() + const { data: receiver } = usePrefetchedQuery(receiverView(receiverSelector)) + const navigate = useNavigate() + + const { mutateAsync: deleteReceiver } = useApiMutation(api.alertReceiverDelete, { + onSuccess(_data, variables) { + navigate(pb.alertReceivers()) + queryClient.invalidateEndpoint('alertReceiverList') + // prettier-ignore + addToast(<>Webhook receiver {variables.path.receiver} deleted) + }, + }) + + return ( + <> + + }>{receiver.name} + + + Edit + + deleteReceiver({ path: { receiver: receiver.name } }), + label: receiver.name, + resourceKind: 'webhook receiver', + extraContent: 'Its delivery history will also be deleted.', + })} + className="destructive" + /> + + + + + {receiver.kind.endpoint} + + + + + + + + Details + Deliveries + Testing + + + + + + + + + + + + + {/* for edit form */} + + ) +} + +// Alert subscriptions + +const subscriptionColHelper = createColumnHelper<{ subscription: string }>() +const subscriptionCols = [ + subscriptionColHelper.accessor('subscription', { + header: 'Alert class', + cell: (info) => {info.getValue()}, + }), +] + +function SubscriptionsCard() { + const receiverSelector = useAlertReceiverSelector() + const { data: receiver } = usePrefetchedQuery(receiverView(receiverSelector)) + const [showAddModal, setShowAddModal] = useState(false) + + const { mutateAsync: removeSubscription } = useApiMutation( + api.alertReceiverSubscriptionRemove, + { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('alertReceiverView') + queryClient.invalidateEndpoint('alertReceiverList') + // prettier-ignore + addToast(<>Subscription {variables.path.subscription} removed) + }, + } + ) + + const makeActions = useCallback( + ({ subscription }: { subscription: string }): MenuAction[] => [ + { + label: 'Remove', + className: 'destructive', + onActivate: () => + confirmAction({ + doAction: () => + removeSubscription({ path: { ...receiverSelector, subscription } }), + errorTitle: 'Could not remove subscription', + modalTitle: 'Remove subscription', + modalContent: ( +

+ Are you sure you want to unsubscribe from {subscription}? The + receiver will no longer receive these alerts. +

+ ), + actionType: 'danger', + }), + }, + ], + [removeSubscription, receiverSelector] + ) + + const columns = useColsWithActions(subscriptionCols, makeActions) + const rows = useMemo( + () => receiver.subscriptions.map((subscription) => ({ subscription })), + [receiver.subscriptions] + ) + const table = useReactTable({ columns, data: rows, getCoreRowModel: getCoreRowModel() }) + + return ( + + + + + + {rows.length ? ( +
+ ) : ( + + } + title="No subscriptions" + body="Subscribe to an alert class to receive alerts" + /> + + )} + + {showAddModal && setShowAddModal(false)} />} + + ) +} + +// Combobox item showing the alert class name with its description underneath. +const toClassComboboxItem = ({ + name, + description, +}: { + name: string + description: string +}): ComboboxItem => ({ + value: name, + selectedLabel: name, + label: {description}, +}) + +function AddSubscriptionModal({ onDismiss }: { onDismiss: () => void }) { + const receiverSelector = useAlertReceiverSelector() + const { data: receiver } = usePrefetchedQuery(receiverView(receiverSelector)) + const form = useForm({ defaultValues: { subscription: '' } }) + const { control } = form + const subscription = useWatch({ control, name: 'subscription' }) + + const classes = useQuery(q(api.alertClassList, { query: { limit: ALL_ISH } })) + const classItems = (classes.data?.items || []) + .filter(isSubscribableClass) + .filter((c) => !receiver.subscriptions.includes(c.name)) + .map(toClassComboboxItem) + + const addSubscription = useApiMutation(api.alertReceiverSubscriptionAdd, { + onSuccess(result) { + queryClient.invalidateEndpoint('alertReceiverView') + queryClient.invalidateEndpoint('alertReceiverList') + // prettier-ignore + addToast(<>Subscribed to {result.subscription}) + onDismiss() + }, + }) + + return ( + + addSubscription.mutate({ path: receiverSelector, body: { subscription } }) + } + loading={addSubscription.isPending} + submitError={addSubscription.error} + > + + Alert subscriptions may include simple globs to subscribe to multiple classes of + alerts, like hardware.** or{' '} + **.remove. + + } + /> + + + + ) +} + +// Secrets + +const secretColHelper = createColumnHelper() +const secretCols = [ + secretColHelper.accessor('id', Columns.id), + secretColHelper.accessor('timeCreated', Columns.timeCreated), +] + +function SecretsCard() { + const receiverSelector = useAlertReceiverSelector() + const { data: receiver } = usePrefetchedQuery(receiverView(receiverSelector)) + const [showAddModal, setShowAddModal] = useState(false) + + const { mutateAsync: deleteSecret } = useApiMutation(api.webhookSecretsDelete, { + onSuccess() { + queryClient.invalidateEndpoint('alertReceiverView') + addToast('Secret removed') + }, + }) + + const isOnlySecret = receiver.kind.secrets.length === 1 + const makeActions = useCallback( + (secret: WebhookSecret): MenuAction[] => [ + { + label: 'Delete', + className: 'destructive', + onActivate: confirmDelete({ + doDelete: () => deleteSecret({ path: { secretId: secret.id } }), + label: secret.id, + resourceKind: 'secret', + extraContent: isOnlySecret + ? 'Deleting the only secret stops deliveries until a new one is added.' + : undefined, + }), + }, + ], + [deleteSecret, isOnlySecret] + ) + + const columns = useColsWithActions(secretCols, makeActions) + // API returns secrets oldest first, but newest is more interesting + const secrets = useMemo( + () => R.sortBy(receiver.kind.secrets, [(s) => s.timeCreated, 'desc']), + [receiver.kind.secrets] + ) + const table = useReactTable({ + columns, + data: secrets, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + + + + + + {receiver.kind.secrets.length ? ( +
+ ) : ( + + } + title="No secrets" + body="Add a secret to sign webhook payloads" + /> + + )} + + + + + {showAddModal && setShowAddModal(false)} />} + + ) +} + +function AddSecretModal({ onDismiss }: { onDismiss: () => void }) { + const { receiver } = useAlertReceiverSelector() + const form = useForm({ defaultValues: { secret: '' } }) + + const addSecret = useApiMutation(api.webhookSecretsAdd, { + onSuccess() { + queryClient.invalidateEndpoint('alertReceiverView') + addToast('Secret added') + onDismiss() + }, + }) + + return ( + addSecret.mutate({ query: { receiver }, body: { secret } })} + loading={addSecret.isPending} + submitError={addSecret.error} + > + + Shared secret used to sign payloads. The value is not visible after adding.{' '} + Learn more about secrets + + } + placeholder="Enter secret" + control={form.control} + required + /> + + ) +} diff --git a/app/pages/system/alerting/AlertReceiverTesting.tsx b/app/pages/system/alerting/AlertReceiverTesting.tsx new file mode 100644 index 000000000..2ab69544c --- /dev/null +++ b/app/pages/system/alerting/AlertReceiverTesting.tsx @@ -0,0 +1,280 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { Link } from 'react-router' +import { match } from 'ts-pattern' + +import { + api, + q, + queryClient, + resendableAlertIds, + useApiMutation, + type AlertProbeResult, +} from '@oxide/api' +import { Error12Icon, Success12Icon } from '@oxide/design-system/icons/react' +import { Button } from '@oxide/design-system/ui' + +import { CheckboxField } from '~/components/form/fields/CheckboxField' +import { ModalForm } from '~/components/form/ModalForm' +import { useAlertReceiverSelector } from '~/hooks/use-params' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { CardBlock } from '~/ui/lib/CardBlock' +import { DateTime } from '~/ui/lib/DateTime' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { InlineCode } from '~/ui/lib/InlineCode' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { TableEmptyBox } from '~/ui/lib/Table' +import { ALL_ISH } from '~/util/consts' +import { pluralize } from '~/util/str' + +import { attemptResultBadge } from './AlertReceiverDeliveries' + +// Testing: send a liveness probe and show the result, plus static documentation +// of the signature scheme, which is defined by RFD 538 and implemented in +// https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/src/app/webhook.rs + +export function TestingTab() { + return ( + <> + + + + ) +} + +/** + * How many alerts a resend would requeue. The API has no endpoint for this, so + * we derive it from the delivery list, which means the answer is only as + * complete as one page. `truncated` says we hit the cap and the real number is + * higher, so the copy can hedge rather than quietly undercount. + */ +type ResendPreview = + | { state: 'unknown' } + | { state: 'known'; count: number; truncated: boolean } + +function ReceiverTesterCard() { + const { receiver } = useAlertReceiverSelector() + const [showProbeModal, setShowProbeModal] = useState(false) + const [result, setResult] = useState(null) + + // throwOnError off because this only feeds a preview count: if it fails the + // modal falls back to describing the behavior without a number + const { data } = useQuery( + q( + api.alertDeliveryList, + { path: { receiver }, query: { limit: ALL_ISH } }, + { throwOnError: false } + ) + ) + + const preview: ResendPreview = data + ? { + state: 'known', + count: resendableAlertIds(data.items).size, + truncated: !!data.nextPage, + } + : { state: 'unknown' } + + return ( + + + + + +

+ To test your integration, send a liveness probe to the endpoint. +

+ {result ? ( + + ) : ( + + + + )} +
+ {showProbeModal && ( + setShowProbeModal(false)} + onSuccess={setResult} + /> + )} +
+ ) +} + +function ProbeResult({ result }: { result: AlertProbeResult }) { + // a probe is delivered once and never retried, so there is at most one attempt + const attempt = result.probe.attempts.webhook.at(0) + if (!attempt) return null // can't happen: the API always returns the attempt it made + + const status = attempt.response?.status + const durationMs = attempt.response?.durationMs + const resends = result.resendsStarted + + return ( + + + {attemptResultBadge(attempt.result)} + + + {status ? ( + + {attempt.result === 'succeeded' ? ( + + ) : ( + + )} + {status} + + ) : ( + + )} + + + {durationMs != null ? `${durationMs}ms` : } + + + + + {/* null unless resends were requested and the probe succeeded */} + {resends != null && ( + + {resends === 0 ? ( + 'No failed deliveries to resend' + ) : ( + + {resends} {resends === 1 ? 'delivery' : 'deliveries'} requeued + + View deliveries + + + )} + + )} + + ) +} + +const resendNote = (preview: ResendPreview) => + match(preview) + .with( + { state: 'unknown' }, + () => 'Alerts that never reached the endpoint are queued for another attempt.' + ) + .with( + { state: 'known', count: 0 }, + () => 'Every alert has reached this endpoint, so nothing would be resent.' + ) + .with({ state: 'known' }, ({ count, truncated }) => { + const alerts = `${count} ${pluralize('alert', count)}` + const subject = truncated ? `At least ${alerts}` : alerts + const verb = !truncated && count === 1 ? 'has' : 'have' + return `${subject} ${verb} never reached this endpoint.` + }) + .exhaustive() + +function ProbeModal({ + preview, + onDismiss, + onSuccess, +}: { + preview: ResendPreview + onDismiss: () => void + onSuccess: (result: AlertProbeResult) => void +}) { + const receiverSelector = useAlertReceiverSelector() + const form = useForm({ defaultValues: { resend: false } }) + + const sendProbe = useApiMutation(api.alertReceiverProbe, { + onSuccess(result) { + queryClient.invalidateEndpoint('alertDeliveryList') + onSuccess(result) + onDismiss() + }, + }) + + return ( + + sendProbe.mutate({ path: receiverSelector, query: { resend } }) + } + > +
+

+ Sends a synthetic probe alert to the endpoint to check + that it is reachable. +

+ {/* only disable on a known zero: while the count is unknown we can't + rule out that there is something to resend. the note below the label + says why it's off, so no tooltip is needed */} + + Resend failed deliveries if the probe succeeds + + {resendNote(preview)} + + +
+
+ ) +} + +const SIGNATURE_PARTS: [string, string][] = [ + ['algorithm', 'Currently only the SHA256 algorithm is supported'], + ['secret-id', 'The ID of the secret used to create the signature'], + ['signature', 'The HMAC signature of the request body'], +] + +function SignatureFormatCard() { + return ( + + + +

+ For each secret key assigned to a webhook receiver, an{' '} + x-oxide-signature header is added with the HMAC digest of + the payload signed with that secret key. This data is encoded in the following + format: +

+
+          a={algorithm}&id={secret-id}&s={signature}
+        
+
+ {SIGNATURE_PARTS.map(([name, description]) => ( +
+
{name}:
+
{description}
+
+ ))} +
+
+
+ ) +} diff --git a/app/pages/system/alerting/AlertReceiversTab.tsx b/app/pages/system/alerting/AlertReceiversTab.tsx new file mode 100644 index 000000000..4dec44ad2 --- /dev/null +++ b/app/pages/system/alerting/AlertReceiversTab.tsx @@ -0,0 +1,157 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useQuery } from '@tanstack/react-query' +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback } from 'react' +import { useNavigate } from 'react-router' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + type AlertReceiver, +} from '@oxide/api' +import { Webhooks24Icon } from '@oxide/design-system/icons/react' + +import { AlertClassBadge } from '~/components/AlertClassBadge' +import { HL } from '~/components/HL' +import { ListPlusCell } from '~/components/ListPlusCell' +import { makeCrumb } from '~/hooks/use-crumbs' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { makeLinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { CreateLink } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableActions } from '~/ui/lib/Table' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' + +const EmptyState = () => ( + } + title="No webhook receivers" + body="Create a webhook receiver to see it here" + buttonText="New webhook receiver" + buttonTo={pb.alertReceiversNew()} + /> +) + +const colHelper = createColumnHelper() + +const staticColumns = [ + colHelper.accessor('name', { + cell: makeLinkCell((receiver) => pb.alertReceiver({ receiver })), + }), + colHelper.accessor('subscriptions', { + header: 'Subscriptions', + cell: (info) => ( + + {info.getValue().map((sub) => ( + {sub} + ))} + + ), + }), + colHelper.accessor('description', Columns.description), + colHelper.accessor('timeCreated', Columns.timeCreated), +] + +const receiverList = getListQFn(api.alertReceiverList, {}) + +export async function clientLoader() { + await queryClient.prefetchQuery(receiverList.optionsFn()) + return null +} + +// this handle is on a pathless layout route, so its pathname is /system. give +// the crumb an explicit path so it links to the list instead +export const handle = makeCrumb('Receivers', pb.alertReceivers()) + +export default function AlertReceiversTab() { + const navigate = useNavigate() + + const { mutateAsync: deleteReceiver } = useApiMutation(api.alertReceiverDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('alertReceiverList') + // prettier-ignore + addToast(<>Webhook receiver {variables.path.receiver} deleted) + }, + }) + + const makeActions = useCallback( + (receiver: AlertReceiver): MenuAction[] => [ + { + label: 'Edit', + onActivate: () => { + // the edit view has its own loader, but we can make the modal open + // instantaneously by preloading the fetch result + const receiverView = q(api.alertReceiverView, { + path: { receiver: receiver.name }, + }) + queryClient.setQueryData(receiverView.queryKey, receiver) + navigate(pb.alertReceiverEdit({ receiver: receiver.name })) + }, + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => deleteReceiver({ path: { receiver: receiver.name } }), + label: receiver.name, + resourceKind: 'webhook receiver', + extraContent: 'Its delivery history will also be deleted.', + }), + }, + ], + [deleteReceiver, navigate] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + const { table } = useQueryTable({ + query: receiverList, + columns, + emptyState: , + }) + + const { data: allReceivers } = useQuery( + q(api.alertReceiverList, { query: { limit: ALL_ISH } }) + ) + + useQuickActions( + () => [ + { + value: 'New webhook receiver', + navGroup: 'Actions', + action: pb.alertReceiversNew(), + }, + ...(allReceivers?.items || []).map((r) => ({ + value: r.name, + action: pb.alertReceiver({ receiver: r.name }), + navGroup: 'Go to webhook receiver', + })), + ], + [allReceivers] + ) + + return ( + <> + {/* webhook receivers are the only kind of alert receiver for now, so the + button names that kind while the tab itself stays generic */} + + New webhook receiver + + {table} + + ) +} diff --git a/app/pages/system/alerting/AlertingPage.tsx b/app/pages/system/alerting/AlertingPage.tsx new file mode 100644 index 000000000..af8319c31 --- /dev/null +++ b/app/pages/system/alerting/AlertingPage.tsx @@ -0,0 +1,41 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { Notifications16Icon, Prohibited24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { RouteTabs, Tab } from '~/components/RouteTabs' +import { makeCrumb } from '~/hooks/use-crumbs' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +export const handle = makeCrumb('Alerting', pb.alertReceivers()) + +export default function AlertingPage() { + return ( + <> + + {/* PLACEHOLDER — do not ship with Prohibited24Icon. Ben is + going to add a notifications-24 icon to the design system. */} + }>Alerting + } + summary="Alerts notify you when events occur in the system. Webhook receivers deliver them to endpoints you configure." + links={[docLinks.alerts, docLinks.webhookReceivers]} + /> + + + + Receivers + Alerts + + + ) +} diff --git a/app/pages/system/alerting/AlertsTab.tsx b/app/pages/system/alerting/AlertsTab.tsx new file mode 100644 index 000000000..917a42af2 --- /dev/null +++ b/app/pages/system/alerting/AlertsTab.tsx @@ -0,0 +1,200 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import cn from 'classnames' +import { memo, useMemo, useState } from 'react' + +import { api, getListQFn, queryClient, snakeify, type Alert } from '@oxide/api' +import { Webhooks24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { AlertClassBadge } from '~/components/AlertClassBadge' +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +import { HighlightJSON } from '~/components/HighlightJSON' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { usePaginatedList } from '~/table/QueryTable' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' +import { DateTime, SyslogDateTime } from '~/ui/lib/DateTime' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { SideModal } from '~/ui/lib/SideModal' +import { TableEmptyBox } from '~/ui/lib/Table' +import { Truncate } from '~/ui/lib/Truncate' +import { roleDiv } from '~/util/classed' + +export const handle = { crumb: 'Alerts' } + +const alertList = getListQFn(api.alertList, { query: { sortBy: 'time_and_id_descending' } }) + +export async function clientLoader() { + await queryClient.prefetchQuery(alertList.optionsFn()) + return null +} + +/* + * A log-style list like the audit log rather than a resource table: fixed-width + * columns on the left and the payload filling whatever is left. Flex layout + * strips the implicit semantics of table elements, so like MiniTable this is + * divs with explicit ARIA table roles. + */ +const Table = roleDiv('table', 'text-sans-md') +const Row = roleDiv('row', 'flex items-center gap-8 border-secondary border-b') +const HeadCell = roleDiv('columnheader', 'text-mono-sm text-tertiary') +const Cell = roleDiv('cell', '') + +// Header and body cells share these so the columns line up. The payload and +// ID columns drop out first as the tab panel (a container) narrows. +const col = { + time: 'w-31 shrink-0', + id: 'w-32.5 shrink-0 @max-[600px]:hidden', + // wide enough for the current classes, e.g. hardware.power_shelf.psu.insert + class: 'w-72 shrink-0', + payload: 'min-w-0 flex-1 @max-[800px]:hidden', +} + +const getId = (alert: Alert) => alert.id + +type AlertRowProps = { + alert: Alert + selected: boolean + onSelect: (alert: Alert) => void +} + +// memoized so opening the detail for one row doesn't re-render the JSON +// preview in every other row +const AlertRow = memo(function AlertRow({ alert, selected, onSelect }: AlertRowProps) { + // stable object identity so HighlightJSON's memo holds across re-renders + const payload = useMemo(() => snakeify(alert.alert), [alert]) + const hasPayload = Object.keys(alert.alert).length > 0 + + return ( + // The row itself is the click target, like the audit log. Keyboard and + // screen reader users get the visually hidden button in the first cell, + // which the row's focus ring reflects. + // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions + onSelect(alert)} + > + + + + + + + + + {alert.class} + + + {hasPayload ? : } + + + ) +}) + +function AlertDetail({ alert, onDismiss }: { alert: Alert; onDismiss: () => void }) { + return ( + + + + {alert.class} + + + {alert.version} + + + + + + + + + + + + ) +} + +const ApiResponseViewer = memo(({ body }: { body: Record }) => { + // recomputing on every render would hand HighlightJSON a new object each + // time and defeat its memo + const snakeJson = useMemo(() => snakeify(body), [body]) + const stringified = useMemo(() => JSON.stringify(snakeJson, null, 2), [snakeJson]) + return ( +
+
+ Alert body + +
+
+
+          
+        
+
+
+ ) +}) + +export default function AlertsTab() { + const [detail, setDetail] = useState(null) + const { items, isEmpty, pagination } = usePaginatedList(alertList, getId) + + if (isEmpty) { + return ( + + } + title="No alerts" + body="Alerts created by the system will appear here." + /> + + ) + } + + return ( + <> +
+ + Created + Alert ID + Alert class + Payload + + {items.map((alert) => ( + + ))} +
+ {pagination} + {detail && setDetail(null)} />} + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 286b165f6..1a743e36f 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -265,6 +265,44 @@ export const routes = createRoutesFromElements( /> + import('./pages/system/alerting/AlertingPage').then(convert)} + > + } /> + import('./pages/system/alerting/AlertReceiversTab').then(convert)} + > + + + import('./pages/system/alerting/AlertsTab').then(convert)} + /> + + {/* /system/alerting redirects to the receivers tab, so point the crumb + straight at the tab to avoid a flash */} + + + import('./pages/system/alerting/AlertReceiverPage').then(convert)} + > + import('./forms/webhook-edit').then(convert)} + /> + + + {/* the create form is a whole page, not a modal over the list, so it + sits outside the tabs layout. crumb links back to the list */} + + import('./forms/webhook-create').then(convert)} + /> + + import('./pages/system/UpdatePage').then(convert)} diff --git a/app/table/QueryTable.tsx b/app/table/QueryTable.tsx index fdaef9786..851cb7057 100644 --- a/app/table/QueryTable.tsx +++ b/app/table/QueryTable.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { useQuery } from '@tanstack/react-query' +import { hashKey, useQuery } from '@tanstack/react-query' import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' import { useEffect, useMemo, useRef } from 'react' @@ -55,6 +55,57 @@ function useScrollReset(triggerDep: string | undefined) { } } +/** + * The data half of `useQueryTable`: fetch the current page of a paginated + * query and render the pagination controls for it. For lists that need this + * plumbing but render something other than a `Table`. + */ +export function usePaginatedList( + query: PaginatedQuery>, + getId: (item: TItem) => string +) { + // hash the first-page key, not the current one, so paging through the same + // query doesn't read as a query change + const queryId = hashKey(query.optionsFn().queryKey) + const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination(queryId) + const queryOptions = query.optionsFn(currentPage) + const queryResult = useQuery(queryOptions) + // only ensure prefetched if we're on the first page + if (currentPage === undefined) ensurePrefetched(queryResult, queryOptions.queryKey) + const { data, isPlaceholderData } = queryResult + const items = useMemo(() => data?.items || [], [data]) + + // trigger by first item ID and not, e.g., currentPage because currentPage + // changes as soon as you click Next, while the item ID doesn't change until + // the page actually changes. + const first = items.at(0) + const requestScrollReset = useScrollReset(first ? getId(first) : undefined) + + const isEmpty = items.length === 0 && !hasPrev + + const pagination = ( + { + requestScrollReset() + goToNextPage(p) + }} + onPrev={() => { + requestScrollReset() + goToPrevPage() + }} + // I can't believe how well this works, but it exactly matches when + // we want to show the spinner. Cached page changes don't need it. + loading={isPlaceholderData} + /> + ) + + return { items, isEmpty, pagination, query: queryResult } +} + // require ID only so we can use it in getRowId export function useQueryTable({ query, @@ -63,57 +114,32 @@ export function useQueryTable({ columns, getId, }: QueryTableProps) { - const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() - const queryOptions = query.optionsFn(currentPage) - const queryResult = useQuery(queryOptions) - // only ensure prefetched if we're on the first page - if (currentPage === undefined) ensurePrefetched(queryResult, queryOptions.queryKey) - const { data, isPlaceholderData } = queryResult - const tableData = useMemo(() => data?.items || [], [data]) - const getRowId = getId ? getId : // @ts-expect-error we know from the types that getId is only defined when there is no ID (row: TItem) => row.id as string - // trigger by first item ID and not, e.g., currentPage because currentPage - // changes as soon as you click Next, while the item ID doesn't change until - // the page actually changes. - const first = tableData.at(0) - const requestScrollReset = useScrollReset(first ? getRowId(first) : undefined) + const { + items, + isEmpty, + pagination, + query: queryResult, + } = usePaginatedList(query, getRowId) const table = useReactTable({ columns, - data: tableData, + data: items, getRowId, getCoreRowModel: getCoreRowModel(), manualPagination: true, }) - const isEmpty = tableData.length === 0 && !hasPrev - const tableElement = isEmpty ? ( {emptyState || } ) : ( <> - { - requestScrollReset() - goToNextPage(p) - }} - onPrev={() => { - requestScrollReset() - goToPrevPage() - }} - // I can't believe how well this works, but it exactly matches when - // we want to show the spinner. Cached page changes don't need it. - loading={isPlaceholderData} - /> + {pagination} ) diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index 9e6a0fa83..5d784750b 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -27,6 +27,14 @@ function idCell(info: Info) { ) } +// narrow enough to leave ~5 characters on either side of the ellipsis, enough +// to tell UUIDs apart at a glance without the wide column a full one demands +function shortIdCell(info: Info) { + return ( + + ) +} + function instanceStateCell(info: Info) { return } @@ -38,6 +46,12 @@ export const Columns = { cell: (info: Info) => , }, id: { header: 'ID', cell: idCell }, + /** + * Like `id`, but middle-truncated, with the full value in a tooltip and on + * the copy button. For tables too crowded to give an ID its full width, or + * that show more than one ID per row. + */ + shortId: { header: 'ID', cell: shortIdCell }, instanceState: { header: 'state', cell: instanceStateCell }, size: { cell: (info: Info) => }, timeCreated: { header: 'created', cell: dateCell }, diff --git a/app/ui/lib/Checkbox.tsx b/app/ui/lib/Checkbox.tsx index bfd6556ac..54c93d781 100644 --- a/app/ui/lib/Checkbox.tsx +++ b/app/ui/lib/Checkbox.tsx @@ -17,9 +17,12 @@ const Check = () => ( const Indeterminate = classed.div`absolute w-2 h-0.5 left-1 top-[7px] bg-(--theme-accent-800) light:bg-(--theme-accent-600) pointer-events-none` +// the disabled: rules repeat under hover: because the hover: rules below them +// would otherwise win and make a disabled box look interactive, same as Radio const inputStyle = ` appearance-none border border-default bg-default h-4 w-4 rounded-sm absolute left-0 outline-none - disabled:cursor-not-allowed + disabled:cursor-not-allowed disabled:bg-disabled disabled:border-default + hover:disabled:cursor-not-allowed hover:disabled:bg-disabled hover:disabled:border-default hover:border-raise hover:cursor-pointer checked:bg-accent checked:border-accent-tertiary checked:hover:border-accent-secondary checked:hover:light:border-accent indeterminate:bg-accent indeterminate:border-accent-tertiary indeterminate:hover:light:border-accent indeterminate:hover:border-accent-secondary @@ -63,6 +66,15 @@ export const Checkbox = ({ {indeterminate && } - {children && {children}} + {children && ( + + {children} + + )} ) diff --git a/app/ui/lib/DateTime.tsx b/app/ui/lib/DateTime.tsx index ed91285f8..39464e138 100644 --- a/app/ui/lib/DateTime.tsx +++ b/app/ui/lib/DateTime.tsx @@ -6,7 +6,12 @@ * Copyright Oxide Computer Company */ -import { toLocaleDateString, toLocaleTimeString } from '~/util/date' +import { + toLocaleDateString, + toLocaleTimeString, + toSyslogDateString, + toSyslogTimeString, +} from '~/util/date' export const DateTime = ({ date, locale }: { date: Date; locale?: string }) => ( ) + +/** Compact log-style timestamp like `Jan 21 23:33:45`, mono, with the date dimmed */ +export const SyslogDateTime = ({ date, locale }: { date: Date; locale?: string }) => ( + +) diff --git a/app/ui/lib/MiniTable.tsx b/app/ui/lib/MiniTable.tsx index cbfd9f919..9e3d5aff7 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -6,10 +6,12 @@ * Copyright Oxide Computer Company */ import cn from 'classnames' -import { useRef, useState, type JSX, type ReactNode } from 'react' +import { useRef, useState, type ReactNode } from 'react' import { Error16Icon } from '@oxide/design-system/icons/react' +import { roleDiv } from '~/util/classed' + import { Button } from './Button' import { EmptyMessage } from './EmptyMessage' import { Tooltip } from './Tooltip' @@ -23,15 +25,6 @@ import { Tooltip } from './Tooltip' * than the semantic elements. */ -/** Like `classed.div`, but with an ARIA role too */ -function roleDiv(role: string, baseClassName: string) { - const Comp = ({ className, ...rest }: JSX.IntrinsicElements['div']) => ( -
- ) - Comp.displayName = `roled.${role}` - return Comp -} - /** Divider between cells, inset so it doesn't touch the row's y borders */ const headerSeparator = `relative before:border-secondary before:absolute before:inset-y-px before:left-0 before:w-px before:border-l before:content-['']` const rowSeparator = `relative before:border-tertiary before:absolute before:inset-y-px before:left-0 before:w-px before:border-l before:content-['']` diff --git a/app/ui/lib/SideModal.tsx b/app/ui/lib/SideModal.tsx index a25e05876..8c193f508 100644 --- a/app/ui/lib/SideModal.tsx +++ b/app/ui/lib/SideModal.tsx @@ -42,6 +42,13 @@ export type SideModalProps = { animate?: boolean } +/** + * Low-level side modal shell. Most callers want `SideModalForm` or + * `ReadOnlySideModalForm` from `~/components/form`, which wire up the body, + * footer, and content spacing. Use this directly only for layouts those don't + * fit, and note that `SideModal.Body` handles horizontal gutters itself, so + * children should not add their own padding. + */ export function SideModal({ children, onDismiss, @@ -127,8 +134,6 @@ SideModal.Body = ({ children }: { children?: ReactNode }) => ( SideModal.Heading = classed.div`text-sans-semi-xl text-raise` -SideModal.Section = classed.div`p-8 space-y-6 border-secondary` - SideModal.Footer = ({ children, error }: { children: ReactNode; error?: boolean }) => (
{error && ( diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 01f5aa12e..dfa0dec07 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -40,6 +40,68 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/affinity", }, ], + "alertReceiver (/system/alerting/receivers/rc)": [ + { + "label": "Alerting", + "path": "/system/alerting/receivers", + }, + { + "label": "Receivers", + "path": "/system/alerting/receivers", + }, + { + "label": "rc", + "path": "/system/alerting/receivers/rc", + }, + ], + "alertReceiverEdit (/system/alerting/receivers/rc/edit)": [ + { + "label": "Alerting", + "path": "/system/alerting/receivers", + }, + { + "label": "Receivers", + "path": "/system/alerting/receivers", + }, + { + "label": "rc", + "path": "/system/alerting/receivers/rc", + }, + ], + "alertReceivers (/system/alerting/receivers)": [ + { + "label": "Alerting", + "path": "/system/alerting/receivers", + }, + { + "label": "Receivers", + "path": "/system/alerting/receivers", + }, + ], + "alertReceiversNew (/system/alerting/receivers-new)": [ + { + "label": "Alerting", + "path": "/system/alerting/receivers", + }, + { + "label": "Receivers", + "path": "/system/alerting/receivers", + }, + { + "label": "New webhook receiver", + "path": "/system/alerting/receivers-new", + }, + ], + "alerts (/system/alerting/alerts)": [ + { + "label": "Alerting", + "path": "/system/alerting/receivers", + }, + { + "label": "Alerts", + "path": "/system/alerting/alerts", + }, + ], "antiAffinityGroup (/projects/p/affinity/aag)": [ { "label": "Projects", diff --git a/app/util/classed.ts b/app/util/classed.ts index e51ecd83a..7eab022de 100644 --- a/app/util/classed.ts +++ b/app/util/classed.ts @@ -49,3 +49,15 @@ export const classed = { } as const // result: classed.button`text-green-500 uppercase` returns a component with those classes + +/** + * Like `classed.div`, but with an ARIA role too. For grid or flex layouts that + * need table semantics, where `display: grid` on real table elements would + * strip them anyway. + */ +export function roleDiv(role: string, baseClassName: string) { + const Comp = ({ className, ...rest }: JSX.IntrinsicElements['div']) => + React.createElement('div', { role, className: cn(baseClassName, className), ...rest }) + Comp.displayName = `roled.${role}` + return Comp +} diff --git a/app/util/date.ts b/app/util/date.ts index 9f504267d..81aa17e16 100644 --- a/app/util/date.ts +++ b/app/util/date.ts @@ -53,3 +53,19 @@ export const toLocaleTimeString = (d: Date, locale?: string) => export const toLocaleDateTimeString = (d: Date, locale?: string) => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d) + +// `Jan 21` +export const toSyslogDateString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + }).format(d) + +// `23:33:45` +export const toSyslogTimeString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(d) diff --git a/app/util/links.ts b/app/util/links.ts index d4021b24e..0685a9009 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -15,6 +15,7 @@ export const links = { cloudInitExamples: 'https://cloudinit.readthedocs.io/en/latest/reference/examples.html', firewallRulesDocs: 'https://docs.oxide.computer/guides/configuring-guest-networking#_firewall_rules', + globbingDocs: 'https://docs.oxide.computer/guides/alerts/overview#_globbing', preparingImagesDocs: 'https://docs.oxide.computer/guides/creating-and-sharing-images#_preparing_images_for_import', identityProvidersDocs: 'https://docs.oxide.computer/guides/operator/identity-providers', @@ -28,6 +29,9 @@ export const links = { 'https://docs.oxide.computer/guides/configuring-guest-networking#_example_4_software_routing_tunnels', troubleshootingAccess: 'https://docs.oxide.computer/guides/operator/faq#_how_do_i_fix_the_something_went_wrong_error', + webhooksGuide: 'https://docs.oxide.computer/guides/alerts/webhooks', + webhookSecretsDocs: 'https://docs.oxide.computer/guides/alerts/webhooks#_secrets', + webhooksApiDocs: 'https://docs.oxide.computer/api/webhook_receiver_create', } // Links with a canonical label, used in DocsPopover and SideModalFormDocs. @@ -40,6 +44,10 @@ export const docLinks = { href: 'https://docs.oxide.computer/guides/deploying-workloads#_affinity_and_anti_affinity', linkText: 'Anti-Affinity Groups', }, + alerts: { + href: 'https://docs.oxide.computer/guides/alerts/overview', + linkText: 'Alerts Overview', + }, deviceTokens: { href: 'https://docs.oxide.computer/guides/working-with-api-and-sdk#_device_token_setup', linkText: 'Access Tokens', @@ -184,4 +192,12 @@ export const docLinks = { href: 'https://docs.oxide.computer/guides/configuring-guest-networking', linkText: 'Networking', }, + webhookReceivers: { + href: links.webhooksGuide, + linkText: 'Webhook Receivers', + }, + webhookSecretRotation: { + href: 'https://docs.oxide.computer/guides/alerts/reliable-receivers#_zero_downtime_webhook_secret_rotation', + linkText: 'Secret Rotation', + }, } diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index d314b3b21..96135a7c2 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -38,6 +38,7 @@ const params = { subnet: 'su', router: 'r', route: 'rr', + receiver: 'rc', } test('path builder', () => { @@ -47,6 +48,11 @@ test('path builder', () => { "accessTokens": "/settings/access-tokens", "affinity": "/projects/p/affinity", "affinityNew": "/projects/p/affinity-new", + "alertReceiver": "/system/alerting/receivers/rc", + "alertReceiverEdit": "/system/alerting/receivers/rc/edit", + "alertReceivers": "/system/alerting/receivers", + "alertReceiversNew": "/system/alerting/receivers-new", + "alerts": "/system/alerting/alerts", "antiAffinityGroup": "/projects/p/affinity/aag", "antiAffinityGroupEdit": "/projects/p/affinity/aag/edit", "deviceSuccess": "/device/success", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index e09ad45aa..eafd785aa 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -18,6 +18,7 @@ const vpcBase = ({ project, vpc }: PP.Vpc) => `${pb.vpcs({ project })}/${vpc}` export const instanceMetricsBase = ({ project, instance }: PP.Instance) => `${instanceBase({ project, instance })}/metrics` export const inventoryBase = () => '/system/inventory' +export const alertingBase = () => '/system/alerting' const siloBase = ({ silo }: PP.Silo) => `/system/silos/${silo}` export const pb = { @@ -129,6 +130,12 @@ export const pb = { subnetPoolEdit: (params: PP.SubnetPool) => `${pb.subnetPool(params)}/edit`, subnetPoolMemberAdd: (params: PP.SubnetPool) => `${pb.subnetPool(params)}/members-add`, + alerts: () => `${alertingBase()}/alerts`, + alertReceivers: () => `${alertingBase()}/receivers`, + alertReceiversNew: () => `${alertingBase()}/receivers-new`, + alertReceiver: (params: PP.AlertReceiver) => `${pb.alertReceivers()}/${params.receiver}`, + alertReceiverEdit: (params: PP.AlertReceiver) => `${pb.alertReceiver(params)}/edit`, + sledInventory: () => `${inventoryBase()}/sleds`, diskInventory: () => `${inventoryBase()}/disks`, sledInstances: ({ sledId }: PP.Sled) => `${pb.sledInventory()}/${sledId}/instances`, diff --git a/app/util/path-params.ts b/app/util/path-params.ts index 011afa41c..685ed59f9 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -30,4 +30,5 @@ export type SshKey = Required export type AffinityGroup = Required export type AntiAffinityGroup = Required export type SubnetPool = Required +export type AlertReceiver = Required export type Disk = Required diff --git a/mock-api/alert.ts b/mock-api/alert.ts new file mode 100644 index 000000000..ca33a198e --- /dev/null +++ b/mock-api/alert.ts @@ -0,0 +1,370 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { subMinutes } from 'date-fns' + +import type { Alert, AlertClass, AlertDelivery, AlertReceiver } from '@oxide/api' + +import type { Json } from './json-type' +import { rack } from './rack' +import { getTimestamps } from './util' + +// Descriptions come from AlertClass in Omicron. Test-only classes are excluded +// from the public list endpoint. +// https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/types/src/alert.rs#L61-L127 +export const alertClasses: Json[] = [ + { + name: 'hardware.power_shelf.psu.insert', + description: 'A power supply unit (PSU) has been inserted into a power shelf', + }, + { + name: 'hardware.power_shelf.psu.remove', + description: 'A power supply unit (PSU) has been removed from a power shelf', + }, + { + name: 'probe', + description: + 'Synthetic events sent for webhook receiver liveness probes. Receivers should return 2xx HTTP responses for these events, but they should NOT be treated as notifications of an actual event in the system.', + }, + // The classes below are mock-only: alerts are system-level events, so these + // are modeled on Omicron's hardware.power_shelf.psu.* taxonomy and the fault + // management subsystem (RFD 538 says alerts come from FMA, RFD 307). They + // are not yet defined in Omicron's alert.rs; they exist to exercise the + // catalog UI. + { name: 'hardware.sled.insert', description: 'A sled has been inserted into the rack' }, + { name: 'hardware.sled.remove', description: 'A sled has been removed from the rack' }, + { name: 'hardware.sled.fault', description: 'A sled has reported a hardware fault' }, + { + name: 'hardware.disk.insert', + description: 'A physical disk has been inserted into a sled', + }, + { + name: 'hardware.disk.remove', + description: 'A physical disk has been removed from a sled', + }, + { name: 'hardware.disk.fault', description: 'A physical disk has reported a fault' }, + { name: 'hardware.fan.fault', description: 'A fan has failed or is running out of spec' }, + { + name: 'hardware.power_shelf.psu.fault', + description: 'A power supply unit (PSU) has reported a fault', + }, + { + name: 'hardware.sensor.overtemp', + description: 'A temperature sensor has exceeded its critical threshold', + }, + { name: 'system.update.start', description: 'A system software update has started' }, + { name: 'system.update.complete', description: 'A system software update has completed' }, + { name: 'system.update.fail', description: 'A system software update has failed' }, +] + +export const receiverWebhook1: Json = { + id: 'ae2d6e09-9f4d-4dd1-ac54-160d61c7ce42', + name: 'webhook-1', + description: 'Main web deployments', + kind: { + kind: 'webhook', + endpoint: 'https://fma.corp.oxide.computer', + secrets: [ + // distinct timestamps so newest-first ordering is deterministic + { + id: '88c7b9bb-fa79-4516-8f12-abebd2626062', + time_created: '2024-03-01T00:00:00Z', + }, + { + id: 'b15f4584-98f1-4cac-b0d3-67294e41aab7', + time_created: '2024-06-01T00:00:00Z', + }, + ], + }, + subscriptions: ['hardware.power_shelf.psu.insert', 'hardware.power_shelf.psu.remove'], + ...getTimestamps(), +} + +export const receiverPowerMon: Json = { + id: 'c4683abf-664f-4ece-b433-7fd228c1d2ea', + name: 'power-mon', + description: '', + kind: { + kind: 'webhook', + endpoint: 'https://power-mon.corp.oxide.computer/webhooks', + secrets: [ + { + id: 'bccb6692-d8d4-4d21-822f-50ea7809ef73', + time_created: new Date().toISOString(), + }, + ], + }, + subscriptions: ['hardware.**'], + ...getTimestamps(), +} + +export const receiverGeneral: Json = { + id: '423059fe-d340-4478-8734-141dbf19dc54', + name: 'general-sys-webhook', + description: '', + kind: { + kind: 'webhook', + endpoint: 'https://api.example.dev/hooks/oxide', + secrets: [ + { + id: '1a457038-b558-49e9-810b-bda6f73d2b85', + time_created: new Date().toISOString(), + }, + ], + }, + subscriptions: [], + ...getTimestamps(), +} + +// alphabetical by name to match the API's default name_ascending sort. the mock +// paginated() helper preserves array order, so the seed order is the sort order +export const alertReceivers = [receiverGeneral, receiverPowerMon, receiverWebhook1] + +const minutesAgo = (n: number) => subMinutes(new Date(), n).toISOString() + +// Alerts backing the seeded deliveries, so alertView can resolve their IDs. +// All current alert classes are at payload version 0. + +// Probe deliveries all reference a well-known singleton alert rather than +// creating a row per probe. +// https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/db-model/src/alert.rs#L63-L66 +export const PROBE_ALERT_ID = '001de000-7768-4000-8000-000000000001' + +// v0 payload for the PSU insert/remove classes. Schema: +// https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/types/output/alert_schemas/hardware.power_shelf.psu.insert/v0.json +const psuAlert = ( + id: string, + action: 'insert' | 'remove', + slot: number, + minutes: number, + modified: boolean +): Json => ({ + id, + class: `hardware.power_shelf.psu.${action}`, + version: 0, + alert: { + rack_id: rack.id, + power_shelf: { + shelf: 0, + baseboard: { part: '913-0000019', revision: 6, serial: 'BRM42220081' }, + }, + psu: { + slot, + identity: { + manufacturer: 'Murata', + part: 'MWOCP68-3600-D-RM', + serial: 'M5426000101', + firmware_revision: '1.9', + }, + }, + time: minutesAgo(minutes), + }, + time_created: minutesAgo(minutes), + time_modified: minutesAgo(modified ? minutes - 120 : minutes), +}) + +export const alerts: Json[] = [ + { + id: PROBE_ALERT_ID, + class: 'probe', + version: 0, + alert: {}, + time_created: minutesAgo(24 * 60), + time_modified: minutesAgo(24 * 60), + }, + psuAlert('26cb0726-bb32-4a6f-b0a5-b207f75f3cec', 'insert', 0, 10, false), + psuAlert('0d38abba-266b-4220-9975-ae9fe26093e2', 'insert', 3, 30, false), + psuAlert('8c8a74ba-58b7-4a06-8c79-39ccad5624fb', 'remove', 1, 180, false), + psuAlert('81dd4626-d7ef-435e-8f4d-f3f2a1217e59', 'remove', 4, 200, false), + psuAlert('beef336d-99db-4b12-ac08-7ebcaab8421a', 'insert', 1, 125, true), + psuAlert('5a2009af-26a0-4217-b18f-bd4e25e691b9', 'insert', 2, 240, false), +] + +// newest first, matching the time_and_id_descending sort the console requests. +// the mock paginated() helper ignores sortBy and preserves array order +export const alertDeliveries: Json[] = [ + { + // stored but never returned by alertDeliveryList, like omicron. kept so + // the exclusion is exercised + id: '9bbdf44f-7dac-4cd0-b4c2-3e622c9693ee', + alert_id: PROBE_ALERT_ID, + alert_class: 'probe', + receiver_id: receiverWebhook1.id, + state: 'delivered', + trigger: 'probe', + time_started: minutesAgo(5), + attempts: { + webhook: [ + { + attempt: 1, + result: 'succeeded', + response: { status: 200, duration_ms: 118 }, + time_sent: minutesAgo(5), + }, + ], + }, + }, + { + id: 'a3d830ee-a590-40df-8281-42282c056196', + alert_id: '26cb0726-bb32-4a6f-b0a5-b207f75f3cec', + alert_class: 'hardware.power_shelf.psu.insert', + receiver_id: receiverWebhook1.id, + state: 'pending', + trigger: 'alert', + time_started: minutesAgo(10), + attempts: { + webhook: [ + { + attempt: 1, + result: 'failed_unreachable', + response: null, + time_sent: minutesAgo(10), + }, + ], + }, + }, + { + id: 'a717b76e-8cac-4f07-b9d9-dfa75e245d53', + alert_id: '8c8a74ba-58b7-4a06-8c79-39ccad5624fb', + alert_class: 'hardware.power_shelf.psu.remove', + receiver_id: receiverWebhook1.id, + state: 'delivered', + trigger: 'resend', + time_started: minutesAgo(60), + attempts: { + webhook: [ + { + attempt: 1, + result: 'succeeded', + response: { status: 200, duration_ms: 388 }, + time_sent: minutesAgo(60), + }, + ], + }, + }, + { + id: '30ece63e-5efd-4365-99a6-d4f09dfa685e', + alert_id: 'beef336d-99db-4b12-ac08-7ebcaab8421a', + alert_class: 'hardware.power_shelf.psu.insert', + receiver_id: receiverWebhook1.id, + state: 'failed', + trigger: 'alert', + time_started: minutesAgo(125), + attempts: { + webhook: [ + { + attempt: 1, + result: 'failed_timeout', + response: null, + time_sent: minutesAgo(125), + }, + { + attempt: 2, + result: 'failed_http_error', + response: { status: 503, duration_ms: 210 }, + time_sent: minutesAgo(120), + }, + { + attempt: 3, + result: 'failed_unreachable', + response: null, + time_sent: minutesAgo(115), + }, + ], + }, + }, + { + id: '8a24bc9b-7dbe-4abf-b6a0-b7fdceb6ea26', + alert_id: '8c8a74ba-58b7-4a06-8c79-39ccad5624fb', + alert_class: 'hardware.power_shelf.psu.remove', + receiver_id: receiverWebhook1.id, + state: 'failed', + trigger: 'alert', + time_started: minutesAgo(180), + attempts: { + webhook: [ + { + attempt: 1, + result: 'failed_http_error', + response: { status: 500, duration_ms: 152 }, + time_sent: minutesAgo(180), + }, + ], + }, + }, + { + // like beef336d, this alert only ever failed, so both are resendable + id: '05085aec-e348-48c3-9624-e54c103a19c4', + alert_id: '81dd4626-d7ef-435e-8f4d-f3f2a1217e59', + alert_class: 'hardware.power_shelf.psu.remove', + receiver_id: receiverWebhook1.id, + state: 'failed', + trigger: 'alert', + time_started: minutesAgo(200), + attempts: { + webhook: [ + { + attempt: 1, + result: 'failed_timeout', + response: null, + time_sent: minutesAgo(200), + }, + { + attempt: 2, + result: 'failed_http_error', + response: { status: 502, duration_ms: 184 }, + time_sent: minutesAgo(195), + }, + { + attempt: 3, + result: 'failed_http_error', + response: { status: 502, duration_ms: 176 }, + time_sent: minutesAgo(190), + }, + ], + }, + }, + { + id: 'a71123dd-c817-4abd-88b3-c064e609df49', + alert_id: '5a2009af-26a0-4217-b18f-bd4e25e691b9', + alert_class: 'hardware.power_shelf.psu.insert', + receiver_id: receiverWebhook1.id, + state: 'delivered', + trigger: 'alert', + time_started: minutesAgo(240), + attempts: { + webhook: [ + { + attempt: 1, + result: 'succeeded', + response: { status: 200, duration_ms: 275 }, + time_sent: minutesAgo(240), + }, + ], + }, + }, + { + id: '5caa3035-d9d9-4699-831f-383a3e15f59c', + alert_id: '0d38abba-266b-4220-9975-ae9fe26093e2', + alert_class: 'hardware.power_shelf.psu.insert', + receiver_id: receiverPowerMon.id, + state: 'delivered', + trigger: 'alert', + time_started: minutesAgo(30), + attempts: { + webhook: [ + { + attempt: 1, + result: 'succeeded', + response: { status: 200, duration_ms: 94 }, + time_sent: minutesAgo(30), + }, + ], + }, + }, +] diff --git a/mock-api/index.ts b/mock-api/index.ts index 3620d30c2..c8d62166d 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -7,6 +7,7 @@ */ export * from './affinity-group' +export * from './alert' export * from './disk' export * from './external-ip' export * from './external-subnet' @@ -23,8 +24,8 @@ export * from './role-assignment' export * from './silo' export * from './sled' export * from './snapshot' -export * from './subnet-pool' export * from './sshKeys' +export * from './subnet-pool' export * from './switch' export * from './system-update' export * from './token' diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 631f65173..f66a95f93 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -140,6 +140,15 @@ const toSiloIpPool = ( }) export const lookup = { + alertReceiver({ receiver: id }: Sel.AlertReceiver): Json { + if (!id) throw notFoundErr('no alert receiver specified') + + if (isUuid(id)) return lookupById(db.alertReceivers, id) + + const receiver = db.alertReceivers.find((r) => r.name === id) + if (!receiver) throw notFoundErr(`alert receiver '${id}'`) + return receiver + }, affinityGroup({ affinityGroup: id, ...projectSelector @@ -619,6 +628,9 @@ type DiskBulkImport = { const initDb = { affinityGroups: [...mock.affinityGroups], + alertDeliveries: [...mock.alertDeliveries], + alertReceivers: [...mock.alertReceivers], + alerts: [...mock.alerts], affinityGroupMemberLists: [...mock.affinityGroupMemberLists], antiAffinityGroups: [...mock.antiAffinityGroups], antiAffinityGroupMemberLists: [...mock.antiAffinityGroupMemberLists], diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 66eecbf02..224081373 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -31,11 +31,12 @@ import { } from '@oxide/api' import { json, makeHandlers, type Json } from '~/api/__generated__/msw-handlers' -import { instanceCan, OXQL_GROUP_BY_ERROR } from '~/api/util' +import { instanceCan, OXQL_GROUP_BY_ERROR, subscriptionRegex } from '~/api/util' import { parseIpNet } from '~/util/ip' import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' +import { alertClasses, PROBE_ALERT_ID } from '../alert' import { defaultSilo, toIdp } from '../silo' import { getTimestamps } from '../util' import { defaultFirewallRules } from '../vpc' @@ -81,6 +82,92 @@ import { // client camel-cases the keys and parses date fields. Inside the mock API everything // is *JSON type. +/** + * The webhook-specific endpoints return the receiver with the webhook config + * (endpoint, secrets) at the top level rather than nested under `kind`. + */ +function toWebhookReceiver(receiver: Json): Json { + const { kind, ...rest } = receiver + return { ...rest, endpoint: kind.endpoint, secrets: kind.secrets } +} + +/** How long a pending delivery waits before its next attempt */ +const RETRY_DELAY_MS = 5000 +/** After this many failed attempts the delivery fails permanently */ +const MAX_ATTEMPTS = 3 + +/** When each pending delivery, by ID, makes its next attempt */ +const nextAttemptAt = new Map() + +/** + * In the real system the deliverator RPW retries pending deliveries in the + * background, so pending is a transient state. Stand in for that by making one + * more attempt whenever the list is fetched after the retry delay has passed. + * State transitions match + * https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/db-queries/src/db/datastore/webhook_delivery.rs#L449-L473 + */ +function retryPendingDeliveries(receiver: Json) { + const now = Date.now() + // same sentinel as the liveness probe: endpoints we can't reach keep failing + const success = !receiver.kind.endpoint.includes('unreachable') + + for (const delivery of db.alertDeliveries) { + if (delivery.receiver_id !== receiver.id || delivery.state !== 'pending') continue + + const dueAt = nextAttemptAt.get(delivery.id) + if (dueAt === undefined) { + nextAttemptAt.set(delivery.id, now + RETRY_DELAY_MS) + continue + } + if (now < dueAt) continue + + const attempt = delivery.attempts.webhook.length + 1 + delivery.attempts.webhook.push({ + attempt, + result: success ? 'succeeded' : 'failed_unreachable', + response: success ? { status: 200, duration_ms: 137 } : null, + time_sent: new Date().toISOString(), + }) + delivery.state = success ? 'delivered' : attempt >= MAX_ATTEMPTS ? 'failed' : 'pending' + + if (delivery.state === 'pending') { + nextAttemptAt.set(delivery.id, now + RETRY_DELAY_MS) + } else { + nextAttemptAt.delete(delivery.id) + } + } +} + +/** + * Alerts eligible for resend to this receiver: it has a delivery for the alert + * and no non-probe delivery of that alert has left the failed state. Note this + * is per alert, not per delivery: delivery records are immutable history, so a + * failed one stays failed and a resend inserts a new record. A resend record + * starts out pending, which takes the alert out of this set right away, and if + * it succeeds the alert never comes back. That is what drains the backlog. + * https://github.com/oxidecomputer/omicron/blob/6db4c7e/nexus/db-queries/src/db/datastore/webhook_delivery.rs#L205-L240 + * + * Returns one delivery per alert so callers can read the alert class off it. + * The console previews this count with `resendableAlertIds` (app/api/util.ts), + * which applies the same rule to camelCase records. + * + * Omicron's NOT EXISTS subquery filters on alert_id, state, and triggered_by + * but not rx_id, so upstream a success on one receiver makes the alert + * non-resendable for every receiver. We scope per receiver, which is what the + * API docs describe. + */ +function resendableAlerts(receiver: Json) { + const forRx = db.alertDeliveries.filter( + (d) => d.receiver_id === receiver.id && d.alert_class !== 'probe' + ) + const settled = new Set( + forRx + .filter((d) => d.trigger !== 'probe' && d.state !== 'failed') + .map((d) => d.alert_id) + ) + return R.uniqueBy(forRx, (d) => d.alert_id).filter((d) => !settled.has(d.alert_id)) +} + export const handlers = makeHandlers({ logout: () => 204, ping: () => ({ status: 'ok' }), @@ -2326,6 +2413,35 @@ export const handlers = makeHandlers({ ) return paginated(query, affinityGroups) }, + alertList: ({ query, cookies }) => { + requireFleetViewer(cookies) + const { startTime, endTime, alertClass } = query + let final = db.alerts + + if (startTime) + final = final.filter((alert) => new Date(alert.time_created) >= startTime) + if (endTime) final = final.filter((alert) => new Date(alert.time_created) <= endTime) + if (alertClass) { + const matcher = subscriptionRegex(alertClass) + final = final.filter((alert) => matcher.test(alert.class)) + } + + final = match(query.sortBy) + .with(undefined, () => final) + .with('time_and_id_descending', () => + R.reverse(R.sortBy(final, ({ time_created, id }) => `${time_created}|${id}`)) + ) + .with('time_and_id_ascending', () => + R.sortBy(final, ({ time_created, id }) => `${time_created}|${id}`) + ) + .exhaustive() + + return paginated(query, final) + }, + alertView({ path, cookies }) { + requireFleetViewer(cookies) + return lookupById(db.alerts, path.alertId) + }, // SCIM token endpoints scimTokenList({ query, cookies }) { @@ -2638,6 +2754,210 @@ export const handlers = makeHandlers({ return paginated(query, pools) }, + alertClassList({ query, cookies }) { + requireFleetViewer(cookies) + const filter = query.filter ? subscriptionRegex(query.filter) : null + // can't use paginated() because alert classes have no ID + return { items: alertClasses.filter((c) => !filter || filter.test(c.name)) } + }, + alertReceiverList({ query, cookies }) { + requireFleetViewer(cookies) + return paginated(query, db.alertReceivers) + }, + alertReceiverView({ path, cookies }) { + requireFleetViewer(cookies) + return lookup.alertReceiver(path) + }, + alertReceiverDelete({ path, cookies }) { + requireFleetAdmin(cookies) + const receiver = lookup.alertReceiver(path) + db.alertReceivers = db.alertReceivers.filter((r) => r.id !== receiver.id) + db.alertDeliveries = db.alertDeliveries.filter((d) => d.receiver_id !== receiver.id) + return 204 + }, + alertDeliveryList({ path, query, cookies }) { + requireFleetViewer(cookies) + const receiver = lookup.alertReceiver(path) + retryPendingDeliveries(receiver) + // probe deliveries are stored like any other but never listed, matching + // omicron, which only queries the alert and resend triggers here + // https://github.com/oxidecomputer/omicron/blob/17e6fee/nexus/src/app/alert.rs#L355-L365 + let deliveries = db.alertDeliveries.filter( + (d) => d.receiver_id === receiver.id && d.trigger !== 'probe' + ) + // if any state filters are specified, only include deliveries in those states + const states = [ + query.delivered && 'delivered', + query.failed && 'failed', + query.pending && 'pending', + ].filter((s) => !!s) + if (states.length > 0) { + deliveries = deliveries.filter((d) => states.includes(d.state)) + } + return paginated(query, deliveries) + }, + alertReceiverProbe({ path, query, cookies }) { + requireFleetAdmin(cookies) + const receiver = lookup.alertReceiver(path) + const now = new Date().toISOString() + // sentinel to let tests exercise the failure path + const success = !receiver.kind.endpoint.includes('unreachable') + const probe: Json = { + id: uuid(), + // all probes reference the singleton probe alert, mirroring omicron + alert_id: PROBE_ALERT_ID, + alert_class: 'probe', + receiver_id: receiver.id, + state: success ? 'delivered' : 'failed', + trigger: 'probe', + time_started: now, + attempts: { + webhook: [ + success + ? { + attempt: 1, + result: 'succeeded', + response: { status: 200, duration_ms: 123 }, + time_sent: now, + } + : { attempt: 1, result: 'failed_unreachable', response: null, time_sent: now }, + ], + }, + } + db.alertDeliveries.unshift(probe) + + // a successful probe with resend=true re-queues every alert that has not + // yet been delivered successfully to this receiver + let resendsStarted = null + if (query.resend && success) { + const alerts = resendableAlerts(receiver) + for (const d of alerts) { + db.alertDeliveries.unshift({ + id: uuid(), + alert_id: d.alert_id, + alert_class: d.alert_class, + receiver_id: receiver.id, + state: 'pending', + trigger: 'resend', + time_started: now, + attempts: { webhook: [] }, + }) + } + resendsStarted = alerts.length + } + return { probe, resends_started: resendsStarted } + }, + alertReceiverSubscriptionAdd({ path, body, cookies }) { + requireFleetAdmin(cookies) + const receiver = lookup.alertReceiver(path) + if (!receiver.subscriptions.includes(body.subscription)) { + receiver.subscriptions.push(body.subscription) + receiver.time_modified = new Date().toISOString() + } + return json({ subscription: body.subscription }, { status: 201 }) + }, + alertReceiverSubscriptionRemove({ path, cookies }) { + requireFleetAdmin(cookies) + const receiver = lookup.alertReceiver({ receiver: path.receiver }) + if (!receiver.subscriptions.includes(path.subscription)) { + throw notFoundErr(`subscription '${path.subscription}'`) + } + receiver.subscriptions = receiver.subscriptions.filter((s) => s !== path.subscription) + receiver.time_modified = new Date().toISOString() + return 204 + }, + alertDeliveryResend({ path, query, cookies }) { + requireFleetAdmin(cookies) + const receiver = lookup.alertReceiver({ receiver: query.receiver }) + const delivery = db.alertDeliveries.find( + (d) => d.alert_id === path.alertId && d.receiver_id === receiver.id + ) + if (!delivery) throw notFoundErr(`alert ${path.alertId}`) + // the real API rejects resends of alerts the receiver is no longer subscribed to + // https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/src/app/alert.rs#L439-L449 + const subscribed = receiver.subscriptions.some((s) => + subscriptionRegex(s).test(delivery.alert_class) + ) + if (!subscribed) { + throw invalidRequest( + `cannot resend alert: receiver is not subscribed to the '${delivery.alert_class}' alert class` + ) + } + const now = new Date().toISOString() + const newDelivery: Json = { + id: uuid(), + alert_id: delivery.alert_id, + alert_class: delivery.alert_class, + receiver_id: receiver.id, + state: 'pending', + trigger: 'resend', + time_started: now, + attempts: { webhook: [] }, + } + db.alertDeliveries.unshift(newDelivery) + return json({ delivery_id: newDelivery.id }, { status: 201 }) + }, + webhookReceiverCreate({ body, cookies }) { + requireFleetAdmin(cookies) + errIfExists(db.alertReceivers, { name: body.name }, 'webhook receiver') + + const now = new Date().toISOString() + const newReceiver: Json = { + id: uuid(), + name: body.name, + description: body.description, + kind: { + kind: 'webhook', + endpoint: body.endpoint, + // secret values are write-only; only IDs are stored + secrets: body.secrets.map(() => ({ id: uuid(), time_created: now })), + }, + subscriptions: body.subscriptions || [], + ...getTimestamps(), + } + db.alertReceivers.push(newReceiver) + return json(toWebhookReceiver(newReceiver), { status: 201 }) + }, + webhookReceiverUpdate({ path, body, cookies }) { + requireFleetAdmin(cookies) + const receiver = lookup.alertReceiver(path) + + if (body.name && body.name !== receiver.name) { + errIfExists(db.alertReceivers, { name: body.name }) + receiver.name = body.name + } + updateDesc(receiver, body) + if (body.endpoint) { + receiver.kind.endpoint = body.endpoint + } + receiver.time_modified = new Date().toISOString() + return 204 + }, + webhookSecretsList({ query, cookies }) { + requireFleetViewer(cookies) + const receiver = lookup.alertReceiver({ receiver: query.receiver }) + return { secrets: receiver.kind.secrets } + }, + webhookSecretsAdd({ query, body: _body, cookies }) { + requireFleetAdmin(cookies) + const receiver = lookup.alertReceiver({ receiver: query.receiver }) + const secret: Json = { + id: uuid(), + time_created: new Date().toISOString(), + } + receiver.kind.secrets.push(secret) + return json(secret, { status: 201 }) + }, + webhookSecretsDelete({ path, cookies }) { + requireFleetAdmin(cookies) + const receiver = db.alertReceivers.find((r) => + r.kind.secrets.some((s) => s.id === path.secretId) + ) + if (!receiver) throw notFoundErr(`secret ${path.secretId}`) + receiver.kind.secrets = receiver.kind.secrets.filter((s) => s.id !== path.secretId) + return 204 + }, + // Misc endpoints we're not using yet in the console affinityGroupCreate: NotImplemented, affinityGroupDelete: NotImplemented, @@ -2645,17 +2965,6 @@ export const handlers = makeHandlers({ affinityGroupMemberInstanceDelete: NotImplemented, affinityGroupMemberInstanceView: NotImplemented, affinityGroupUpdate: NotImplemented, - alertClassList: NotImplemented, - alertDeliveryList: NotImplemented, - alertDeliveryResend: NotImplemented, - alertList: NotImplemented, - alertReceiverDelete: NotImplemented, - alertReceiverList: NotImplemented, - alertReceiverProbe: NotImplemented, - alertReceiverSubscriptionAdd: NotImplemented, - alertReceiverSubscriptionRemove: NotImplemented, - alertReceiverView: NotImplemented, - alertView: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, auditLogList: NotImplemented, certificateCreate: NotImplemented, @@ -2766,9 +3075,4 @@ export const handlers = makeHandlers({ userSessionList: NotImplemented, userTokenList: NotImplemented, userView: NotImplemented, - webhookReceiverCreate: NotImplemented, - webhookReceiverUpdate: NotImplemented, - webhookSecretsAdd: NotImplemented, - webhookSecretsDelete: NotImplemented, - webhookSecretsList: NotImplemented, }) diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts new file mode 100644 index 000000000..97040769f --- /dev/null +++ b/test/e2e/alerts.e2e.ts @@ -0,0 +1,758 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { expect, test, type Page } from '@playwright/test' + +import { alerts } from '@oxide/api-mocks' + +import { clickRowAction, expectRowVisible, expectToast, selectOption } from './utils' + +test('Alerting nav and tabs', async ({ page }) => { + const sidebar = page.getByRole('navigation', { name: 'Sidebar navigation' }) + + await page.goto('/system/silos') + await sidebar.getByRole('link', { name: 'Alerting' }).click() + + // the section root redirects to the first tab + await expect(page).toHaveURL('/system/alerting/receivers') + await expect(page).toHaveTitle('Receivers / Alerting / Oxide Console') + await expect(page.getByRole('tab', { name: 'Receivers' })).toHaveAttribute( + 'aria-selected', + 'true' + ) + + await page.getByRole('tab', { name: 'Alerts' }).click() + await expect(page).toHaveURL('/system/alerting/alerts') + await expect(page).toHaveTitle('Alerts / Alerting / Oxide Console') + // nav item stays highlighted on both tabs + await expect(sidebar.getByRole('link', { name: 'Alerting' })).toHaveAttribute( + 'aria-current', + 'page' + ) +}) + +test('Alert receivers list', async ({ page }) => { + await page.goto('/system/alerting/receivers') + await expect(page).toHaveTitle('Receivers / Alerting / Oxide Console') + await expect(page.getByRole('heading', { name: 'Alerting' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Receivers' })).toHaveAttribute( + 'aria-selected', + 'true' + ) + + const table = page.getByRole('table') + await expect(table.getByRole('row')).toHaveCount(4) // header + 3 receivers + + await expectRowVisible(table, { + name: 'webhook-1', + Subscriptions: 'hardware.power_shelf.psu.insert+1', + description: 'Main web deployments', + }) + await expectRowVisible(table, { name: 'power-mon', Subscriptions: 'hardware.**' }) + await expectRowVisible(table, { name: 'general-sys-webhook', Subscriptions: '—' }) +}) + +test('Webhook receiver create', async ({ page }) => { + await page.goto('/system/alerting/receivers') + + await page.getByRole('link', { name: 'New webhook receiver' }).click() + await expect(page).toHaveURL('/system/alerting/receivers-new') + + await expect(page.getByRole('heading', { name: 'Create webhook receiver' })).toBeVisible() + + // scope text assertions to main to avoid matching the aria-live announcer, + // which repeats validation error messages at the body level + const main = page.getByRole('main') + + await page.getByRole('textbox', { name: 'Name' }).fill('deploy-hook') + await page.getByRole('textbox', { name: 'Description' }).fill('CI deploys') + + // endpoint must be a valid URL + await page.getByRole('textbox', { name: 'Endpoint URL' }).fill('not-a-url') + await page.getByRole('button', { name: 'Create webhook receiver' }).click() + await expect( + main.getByText('Must be a valid URL, including the scheme (e.g., https://)') + ).toBeVisible() + // at least one secret is required + await expect(main.getByText('At least one secret is required')).toBeVisible() + // and no longer than the database column holding it + await page + .getByRole('textbox', { name: 'Endpoint URL' }) + .fill(`https://ci.example.com/${'a'.repeat(512)}`) + await page.getByRole('button', { name: 'Create webhook receiver' }).click() + await expect(main.getByText('Must be at most 512 characters')).toBeVisible() + await page.getByRole('textbox', { name: 'Endpoint URL' }).fill('https://ci.example.com') + + // add a secret; it lands in the mini table + await page.getByRole('textbox', { name: 'Secret' }).fill('super-secret') + await page.getByRole('button', { name: 'Add secret' }).click() + await expect( + page + .getByRole('table', { name: 'Secrets' }) + .getByRole('cell', { name: 'super-secret', exact: true }) + ).toBeVisible() + await expect(main.getByText('At least one secret is required')).toBeHidden() + + // add a subscription: a bad glob is rejected on Enter, a good one becomes a chip + const subsInput = page.getByRole('combobox', { name: 'Alert subscriptions' }) + await subsInput.fill('hardware..bad') + await subsInput.press('Enter') + await expect( + main.getByText('Must be an alert class or a glob pattern like hardware.**') + ).toBeVisible() + + // the probe class is synthetic and the API rejects subscribing to it + await subsInput.fill('probe') + await subsInput.press('Enter') + await expect( + main.getByText('The probe class is only used for liveness probes') + ).toBeVisible() + await subsInput.fill('hardware.**') + await subsInput.press('Enter') + await expect( + page.getByRole('button', { name: 'remove subscription hardware.**' }) + ).toBeVisible() + await expect(subsInput).toHaveValue('') + + await page.getByRole('button', { name: 'Create webhook receiver' }).click() + await expectToast(page, 'Webhook receiver deploy-hook created') + + await expectRowVisible(page.getByRole('table'), { + name: 'deploy-hook', + Subscriptions: 'hardware.**', + description: 'CI deploys', + }) +}) + +test('Webhook receiver create: subscriptions field', async ({ page }) => { + await page.goto('/system/alerting/receivers-new') + + const subsInput = page.getByRole('combobox', { name: 'Alert subscriptions' }) + const listbox = page.getByRole('listbox') + const chipRemove = (sub: string) => + page.getByRole('button', { name: `remove subscription ${sub}` }) + + // accessible-name matching is brittle here because the highlighted name is + // split across elements, so filter rows by rendered text instead + const option = (name: string) => listbox.getByRole('option').filter({ hasText: name }) + + // focusing opens the catalog showing all classes + await subsInput.click() + await expect(listbox.getByText('All classes')).toBeVisible() + await expect(listbox.getByRole('option')).toHaveCount(14) + + // a glob query filters the catalog and labels matched rows with the pattern + await subsInput.fill('hardware.*.fault') + await expect(listbox.getByText('Matching “hardware.*.fault”')).toBeVisible() + // 3 classes match; psu.fault is one segment too deep, shown as a near miss + // labeled with the broader pattern that would cover it + await expect(listbox.getByText('Showing 4 of 14')).toBeVisible() + const pendingRow = option('hardware.disk.fault') + await expect(pendingRow.getByText('hardware.*.fault', { exact: true })).toBeVisible() + const nearMissRow = option('hardware.power_shelf.psu.fault') + await expect(nearMissRow.getByText('hardware.**.fault', { exact: true })).toBeVisible() + + // Enter commits the glob as a chip and clears the query + await subsInput.press('Enter') + await expect(chipRemove('hardware.*.fault')).toBeVisible() + await expect(subsInput).toHaveValue('') + + // space commits a glob too, since a subscription can't contain one. Remove + // the chip again so it doesn't cover the rows picked further down. + await subsInput.fill('system.**') + await subsInput.press(' ') + await expect(chipRemove('system.**')).toBeVisible() + await expect(subsInput).toHaveValue('') + await chipRemove('system.**').click() + + // rows matched by the committed glob are locked and can't be double-added + await subsInput.fill('fault') + const coveredRow = option('hardware.disk.fault') + await expect(coveredRow.getByText('via hardware.*.fault')).toBeVisible() + await expect(coveredRow).toHaveAttribute('aria-disabled', 'true') + // force because playwright refuses to click aria-disabled elements; we want + // to verify the click is a no-op anyway + await coveredRow.click({ force: true }) + await expect(chipRemove('hardware.disk.fault')).toBeHidden() + + // plain-text filter + ticking rows commits exact classes without resetting the query + await subsInput.fill('update') + await expect(listbox.getByText('Showing 3 of 14')).toBeVisible() + // space is a no-op on a non-glob query: no stray space in the filter, and no + // chip made from a half-typed class name + await subsInput.press(' ') + await expect(subsInput).toHaveValue('update') + await expect(chipRemove('update')).toBeHidden() + await option('system.update.start').click() + await option('system.update.complete').click() + await expect(chipRemove('system.update.start')).toBeVisible() + await expect(chipRemove('system.update.complete')).toBeVisible() + await expect(subsInput).toHaveValue('update') + await expect(listbox).toBeVisible() + + // clicking a picked row unpicks it + await option('system.update.start').click() + await expect(chipRemove('system.update.start')).toBeHidden() + + // zero matches shows an explicit empty state with a clear action + await subsInput.fill('zzz') + await expect(listbox.getByText('No classes match')).toBeVisible() + await listbox.getByRole('button', { name: 'Clear' }).click() + await expect(listbox.getByText('All classes')).toBeVisible() + + // an incomplete glob shows the full catalog, not a bogus empty state + await subsInput.fill('*.') + await expect(listbox.getByRole('option')).toHaveCount(14) + await subsInput.fill('') + + // backspace on an empty query arms the last chip, a second one removes it + await subsInput.press('Backspace') + await expect(chipRemove('system.update.complete')).toBeVisible() + await subsInput.press('Backspace') + await expect(chipRemove('system.update.complete')).toBeHidden() + + // typing disarms, so the chip survives + await subsInput.press('Backspace') + await subsInput.pressSequentially('x') + await subsInput.press('Backspace') + await subsInput.press('Backspace') + await expect(chipRemove('hardware.*.fault')).toBeVisible() + + // arrow keys move the armed selection, so a specific chip can be deleted + await subsInput.fill('system.update.fail') + await subsInput.press('Enter') + await expect(chipRemove('system.update.fail')).toBeVisible() + await subsInput.press('ArrowLeft') // arm system.update.fail + await subsInput.press('ArrowLeft') // arm hardware.*.fault + await subsInput.press('Backspace') + await expect(chipRemove('hardware.*.fault')).toBeHidden() + await expect(chipRemove('system.update.fail')).toBeVisible() + + // uncommitted text is discarded on blur so it doesn't read as added + await subsInput.fill('leftover') + await page.getByRole('textbox', { name: 'Name' }).click() + await expect(subsInput).toHaveValue('') + await expect(chipRemove('leftover')).toBeHidden() + + // subscribed classes sort to the top when the panel opens + await subsInput.click() + await expect(listbox.getByRole('option').first()).toContainText('system.update.fail') + + // but a valid glob commits on blur, so typing one and going straight to the + // submit button doesn't silently drop it + await subsInput.fill('hardware.disk.*') + await page.getByRole('textbox', { name: 'Name' }).click() + await expect(subsInput).toHaveValue('') + await expect(chipRemove('hardware.disk.*')).toBeVisible() +}) + +test('Webhook receiver detail: properties, subscriptions, secrets', async ({ page }) => { + await page.goto('/system/alerting/receivers') + await page.getByRole('link', { name: 'webhook-1' }).click() + await expect(page).toHaveURL('/system/alerting/receivers/webhook-1') + + await expect(page.getByRole('heading', { name: 'webhook-1' })).toBeVisible() + await expect(page.getByText('https://fma.corp.oxide.computer')).toBeVisible() + await expect(page.getByText('Main web deployments')).toBeVisible() + + // subscriptions card + const subscriptions = page.getByRole('table', { name: 'Alert classes' }) + await expect(subscriptions.getByRole('row')).toHaveCount(3) // header + 2 + + // add a subscription + await page.getByRole('button', { name: 'Add subscription' }).click() + const addModal = page.getByRole('dialog', { name: 'Add subscription' }) + await addModal + .getByRole('combobox', { name: 'Subscription' }) + .fill('hardware.sensor.overtemp') + await page.getByRole('option', { name: 'hardware.sensor.overtemp' }).click() + await addModal.getByRole('button', { name: 'Add' }).click() + await expectToast(page, 'Subscribed to hardware.sensor.overtemp') + await expect(subscriptions.getByRole('row')).toHaveCount(4) + + // remove it again + await clickRowAction(page, 'hardware.sensor.overtemp', 'Remove') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Subscription hardware.sensor.overtemp removed') + await expect(subscriptions.getByRole('row')).toHaveCount(3) + + // secrets card + const secrets = page.getByRole('table', { name: 'Secrets' }) + await expect(secrets.getByRole('row')).toHaveCount(3) // header + 2 + + // newest first + await expect(secrets.getByRole('row').nth(1)).toContainText('b15f4584') + await expect(secrets.getByRole('row').nth(2)).toContainText('88c7b9bb') + + // add a secret + await page.getByRole('button', { name: 'Add secret' }).click() + const secretModal = page.getByRole('dialog', { name: 'Add secret' }) + await secretModal.getByRole('textbox', { name: 'Secret' }).fill('another-secret') + await secretModal.getByRole('button', { name: 'Add' }).click() + await expectToast(page, 'Secret added') + await expect(secrets.getByRole('row')).toHaveCount(4) + // the new secret sorts above the seeded ones + await expect(secrets.getByRole('row').nth(1)).not.toContainText('b15f4584') + + // delete one of the seeded secrets + await clickRowAction(page, '88c7b9bb-fa79-4516-8f12-abebd2626062', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Secret removed') + await expect(secrets.getByRole('row')).toHaveCount(3) + + // deleting down to one secret warns that payloads will be unverifiable + await clickRowAction(page, 'b15f4584-98f1-4cac-b0d3-67294e41aab7', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Secret removed') + const remainingRow = secrets.getByRole('row').nth(1) + await remainingRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await expect(page.getByText('Deleting the only secret stops deliveries')).toBeVisible() + await page.getByRole('button', { name: 'Cancel' }).click() +}) + +test('Add subscription modal previews the classes a glob matches', async ({ page }) => { + await page.goto('/system/alerting/receivers/webhook-1') + + await page.getByRole('button', { name: 'Add subscription' }).click() + const modal = page.getByRole('dialog', { name: 'Add subscription' }) + const input = modal.getByRole('combobox', { name: 'Subscription' }) + const preview = modal.getByText(/Matches \d+ alert class/) + + // an exact class only ever matches itself, so there is nothing to preview + await input.fill('hardware.sled.fault') + await expect(preview).toBeHidden() + + await input.fill('hardware.**') + await expect(preview).toHaveText(/^Matches 11 alert classes:/) + await expect(preview).toContainText('hardware.sensor.overtemp') + await expect(preview).not.toContainText('system.update.start') + + // ** matches every class except the synthetic probe class, which can't be + // subscribed to + await input.fill('**') + await expect(preview).toHaveText(/^Matches 14 alert classes:/) + await expect(preview).not.toContainText('probe') + + // a well-formed glob matching nothing says so rather than rendering an + // empty list + await input.fill('zzz.**') + await expect(preview).toBeHidden() + await expect(modal.getByText('No current alert classes match this pattern')).toBeVisible() +}) + +test('Testing tab: probe result and signature format', async ({ page }) => { + await page.goto('/system/alerting/receivers/webhook-1') + await page.getByRole('tab', { name: 'Testing' }).click() + + const panel = page.getByRole('tabpanel') + await expect( + panel.getByText('Send a liveness probe to see the result here') + ).toBeVisible() + + await panel.getByRole('button', { name: 'Send liveness probe' }).click() + const probeModal = page.getByRole('dialog', { name: 'Send liveness probe' }) + await probeModal.getByRole('button', { name: 'Send probe' }).click() + + await expect(panel.getByText('Succeeded')).toBeVisible() + await expect(panel.getByText('200')).toBeVisible() + await expect(panel.getByText('123ms')).toBeVisible() + + // signature format docs + await expect(panel.getByText('a={algorithm}&id={secret-id}&s={signature}')).toBeVisible() + await expect(panel.getByText('The HMAC signature of the request body')).toBeVisible() +}) + +test('Testing tab: probe failure', async ({ page }) => { + await page.goto('/system/alerting/receivers') + + // the mock backend fails probes for endpoints containing 'unreachable' + await clickRowAction(page, 'power-mon', 'Edit') + await page + .getByRole('dialog', { name: 'Edit webhook receiver' }) + .getByRole('textbox', { name: 'Endpoint URL' }) + .fill('https://unreachable.example.com') + await page.getByRole('button', { name: 'Update webhook receiver' }).click() + await expectToast(page, 'Webhook receiver power-mon updated') + + await page.getByRole('tab', { name: 'Testing' }).click() + const panel = page.getByRole('tabpanel') + await panel.getByRole('button', { name: 'Send liveness probe' }).click() + await page + .getByRole('dialog', { name: 'Send liveness probe' }) + .getByRole('button', { name: 'Send probe' }) + .click() + + await expect(panel.getByText('Unreachable')).toBeVisible() +}) + +test('Webhook receiver edit', async ({ page }) => { + await page.goto('/system/alerting/receivers') + await clickRowAction(page, 'general-sys-webhook', 'Edit') + + const modal = page.getByRole('dialog', { name: 'Edit webhook receiver' }) + await expect(modal.getByRole('textbox', { name: 'Endpoint URL' })).toHaveValue( + 'https://api.example.dev/hooks/oxide' + ) + await modal.getByRole('textbox', { name: 'Name' }).fill('general-webhook') + await modal + .getByRole('textbox', { name: 'Endpoint URL' }) + .fill('https://hooks.example.dev') + await page.getByRole('button', { name: 'Update webhook receiver' }).click() + + await expectToast(page, 'Webhook receiver general-webhook updated') + // lands on the detail page for the new name + await expect(page).toHaveURL('/system/alerting/receivers/general-webhook') + await expect(page.getByText('https://hooks.example.dev')).toBeVisible() +}) + +// The mock backend retries a pending delivery 5s after the list is first +// fetched, so refresh until the state settles rather than sleeping. +const refreshUntil = (page: Page, expectation: () => Promise) => + expect(async () => { + await page.getByRole('button', { name: 'Refresh data' }).click() + await expectation() + }).toPass({ timeout: 30_000 }) + +test('Pending delivery resolves to delivered', async ({ page }) => { + await page.goto('/system/alerting/receivers/webhook-1?tab=deliveries') + + const row = page.getByRole('row', { name: /a3d830ee/ }) + await expect(row.getByText('pending')).toBeVisible() + + await refreshUntil(page, () => + expect(row.getByText('delivered')).toBeVisible({ timeout: 1000 }) + ) + + // the retry shows up as a second attempt on the delivery + await clickRowAction(page, 'a3d830ee-a590-40df-8281-42282c056196', 'View details') + const sideModal = page.getByRole('dialog', { name: 'Webhook delivery' }) + await expect(sideModal.getByRole('table').getByRole('row')).toHaveCount(3) // header + 2 +}) + +test('Pending delivery fails after exhausting retries', async ({ page }) => { + await page.goto('/system/alerting/receivers') + + // the mock backend fails delivery to endpoints containing 'unreachable' + await clickRowAction(page, 'webhook-1', 'Edit') + await page + .getByRole('dialog', { name: 'Edit webhook receiver' }) + .getByRole('textbox', { name: 'Endpoint URL' }) + .fill('https://unreachable.example.com') + await page.getByRole('button', { name: 'Update webhook receiver' }).click() + await expectToast(page, 'Webhook receiver webhook-1 updated') + + await page.getByRole('tab', { name: 'Deliveries' }).click() + const row = page.getByRole('row', { name: /a3d830ee/ }) + await expect(row.getByText('pending')).toBeVisible() + + // one attempt already failed, so it takes two more to hit the 3-attempt limit + await refreshUntil(page, () => + expect(row.getByText('failed')).toBeVisible({ timeout: 1000 }) + ) +}) + +test('Webhook receiver deliveries', async ({ page }) => { + await page.goto('/system/alerting/receivers/webhook-1') + await page.getByRole('tab', { name: 'Deliveries' }).click() + + const table = page.getByRole('table') + // header + 6. the seeded probe delivery is excluded: the API never lists + // probe-triggered deliveries + await expect(table.getByRole('row')).toHaveCount(7) + await expect(table.getByText('9bbdf44f-7dac-4cd0-b4c2-3e622c9693ee')).toBeHidden() + + // Truncate renders the full ID (invisible, for stable layout) alongside the + // ellipsized copy, so cell text contains both. Match on the full value. + await expectRowVisible(table, { + 'Delivery ID': expect.stringContaining('30ece63e-5efd-4365-99a6-d4f09dfa685e'), + 'Alert ID': expect.stringContaining('beef336d-99db-4b12-ac08-7ebcaab8421a'), + 'Alert class': 'hardware.power_shelf.psu.insert', + state: 'failed', + trigger: 'alert', + }) + // the untruncated ID is still the row's accessible name, so it stays findable + await expect( + table.getByRole('row', { name: '30ece63e-5efd-4365-99a6-d4f09dfa685e' }) + ).toBeVisible() + + // filter by state + await selectOption(page, 'Filter by state', 'Failed') + await expect(table.getByRole('row')).toHaveCount(4) // header + 3 failed + await selectOption(page, 'Filter by state', 'All states') + await expect(table.getByRole('row')).toHaveCount(7) + + // delivery detail side modal shows attempts + await clickRowAction(page, '30ece63e-5efd-4365-99a6-d4f09dfa685e', 'View details') + const sideModal = page.getByRole('dialog', { name: 'Webhook delivery' }) + + // the metadata table spells out all three IDs, which are easy to confuse. + // IdRow truncates, but keeps the full value as the accessible name + const props = sideModal.getByLabel('Properties table') + await expect(props).toContainText('Delivery ID') + await expect(props.getByLabel('30ece63e-5efd-4365-99a6-d4f09dfa685e')).toBeVisible() + await expect(props).toContainText('Alert ID') + await expect(props.getByLabel('beef336d-99db-4b12-ac08-7ebcaab8421a')).toBeVisible() + await expect(props).toContainText('Receiver ID') + await expect(props.getByLabel('ae2d6e09-9f4d-4dd1-ac54-160d61c7ce42')).toBeVisible() + + const attempts = sideModal.getByRole('table') + await expect(attempts.getByRole('row')).toHaveCount(4) // header + 3 attempts + await expect(attempts.getByRole('cell', { name: 'HTTP error' })).toBeVisible() + + // request tab reconstructs the payload and headers from the delivery and + // the alert fetched by ID + await sideModal.getByRole('tab', { name: 'Request' }).click() + await expect(attempts).toBeHidden() + const request = sideModal.getByRole('tabpanel') + await expect( + request.getByText('"id": "30ece63e-5efd-4365-99a6-d4f09dfa685e"') + ).toBeVisible() + // alert version and data payload come from the alert record + await expect(request.getByText('"alert_version": 0')).toBeVisible() + await expect(request.getByText('"manufacturer": "Murata"')).toBeVisible() + // payload keys are snake_case like the body the receiver got, not the + // camelCase the client uses internally + await expect(request.getByText('"firmware_revision": "1.9"')).toBeVisible() + // the signature can't be reconstructed, so it stays a placeholder + await expect(request.getByText('a=sha256&id=&s=')).toBeVisible() + await expect(request.getByText('x-oxide-alert-class')).toBeVisible() + await expect( + request.getByText('hardware.power_shelf.psu.insert', { exact: true }) + ).toBeVisible() + + await sideModal.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + + // resend a failed delivery requires confirmation, then creates a new + // pending delivery + await clickRowAction(page, '30ece63e-5efd-4365-99a6-d4f09dfa685e', 'Resend') + const confirmModal = page.getByRole('dialog', { name: 'Confirm resend' }) + // the alert ID is truncated for display, but keeps the full value as its + // accessible name + await expect( + confirmModal.getByLabel('beef336d-99db-4b12-ac08-7ebcaab8421a') + ).toBeVisible() + await confirmModal.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Delivery resend started') + await expect(table.getByRole('row')).toHaveCount(8) + await expectRowVisible(table, { + 'Alert class': 'hardware.power_shelf.psu.insert', + state: 'pending', + trigger: 'resend', + }) + + // send a liveness probe from the testing tab, resending failed deliveries + await page.getByRole('tab', { name: 'Testing' }).click() + await page.getByRole('button', { name: 'Send liveness probe' }).click() + const probeModal = page.getByRole('dialog', { name: 'Send liveness probe' }) + // the preview already accounts for the manual resend above, so it says one, + // not one per failed record + await expect( + probeModal.getByText('1 alert has never reached this endpoint') + ).toBeVisible() + await probeModal + .getByRole('checkbox', { name: 'Resend failed deliveries if the probe succeeds' }) + .check() + await probeModal.getByRole('button', { name: 'Send probe' }).click() + const panel = page.getByRole('tabpanel') + await expect(panel.getByText('Succeeded')).toBeVisible() + // resends are counted per alert, not per failed delivery record. of the three + // failed records, 8c8a74ba already has a successful resend and beef336d was + // just resent by hand, so only 81dd4626 is left + await expect(panel.getByText('1 delivery requeued')).toBeVisible() + + // the result links to the deliveries tab, where the resends resolve + await panel.getByRole('link', { name: 'View deliveries' }).click() + // 8 rows + the one resend. the probe itself is not listed + await expect(table.getByRole('row')).toHaveCount(9) +}) + +// The bug that got this checkbox removed the first time: the mock resent every +// delivery record in the failed state, so already-resent alerts were requeued on +// every probe and the count never dropped. +test('Probe resends drain the backlog', async ({ page }) => { + await page.goto('/system/alerting/receivers/webhook-1?tab=testing') + + const panel = page.getByRole('tabpanel') + // the modal previews how many alerts a resend would requeue, so the user can + // see the number before committing to the checkbox + const openProbeModal = async (expectedNote: string) => { + await panel.getByRole('button', { name: 'Send liveness probe' }).click() + const modal = page.getByRole('dialog', { name: 'Send liveness probe' }) + await expect(modal.getByText(expectedNote)).toBeVisible() + return modal + } + const sendProbe = async (resend: boolean, expectedNote: string) => { + const modal = await openProbeModal(expectedNote) + if (resend) { + await modal + .getByRole('checkbox', { name: 'Resend failed deliveries if the probe succeeds' }) + .check() + } + await modal.getByRole('button', { name: 'Send probe' }).click() + await expect(panel.getByText('Succeeded')).toBeVisible() + } + + // beef336d and 81dd4626 have only ever failed. 8c8a74ba also has a failed + // record, but it already has a successful resend, so it does not count + const twoWaiting = '2 alerts have never reached this endpoint' + + // leaving the box unchecked resends nothing, even though 2 are waiting + await sendProbe(false, twoWaiting) + await expect(panel.getByText('requeued')).toBeHidden() + await expect(panel.getByText('No failed deliveries to resend')).toBeHidden() + + // the preview matches what the probe actually reports + await sendProbe(true, twoWaiting) + await expect(panel.getByText('2 deliveries requeued')).toBeVisible() + + // let the two resends land + await panel.getByRole('link', { name: 'View deliveries' }).click() + await selectOption(page, 'Filter by state', 'Pending') + const table = page.getByRole('table') + // the two resends plus the seeded pending delivery + await expect(table.getByRole('row')).toHaveCount(4) // header + 3 + await refreshUntil(page, () => + expect(page.getByText('No pending deliveries found')).toBeVisible({ timeout: 1000 }) + ) + + // now that every alert has been delivered there is nothing to resend, so the + // checkbox is disabled rather than offering a no-op + await page.getByRole('tab', { name: 'Testing' }).click() + const modal = await openProbeModal('Every alert has reached this endpoint') + const resendBox = modal.getByRole('checkbox', { + name: 'Resend failed deliveries if the probe succeeds', + }) + await expect(resendBox).toBeDisabled() + await expect(resendBox).not.toBeChecked() + + // the probe still works, it just doesn't ask for resends + await modal.getByRole('button', { name: 'Send probe' }).click() + await expect(panel.getByText('Succeeded')).toBeVisible() + await expect(panel.getByText('requeued')).toBeHidden() + await expect(panel.getByText('No failed deliveries to resend')).toBeHidden() +}) + +test('Probe failure reports no resends', async ({ page }) => { + await page.goto('/system/alerting/receivers') + + // the mock backend fails probes for endpoints containing 'unreachable' + await clickRowAction(page, 'webhook-1', 'Edit') + await page + .getByRole('dialog', { name: 'Edit webhook receiver' }) + .getByRole('textbox', { name: 'Endpoint URL' }) + .fill('https://unreachable.example.com') + await page.getByRole('button', { name: 'Update webhook receiver' }).click() + await expectToast(page, 'Webhook receiver webhook-1 updated') + + await page.getByRole('tab', { name: 'Testing' }).click() + const panel = page.getByRole('tabpanel') + await panel.getByRole('button', { name: 'Send liveness probe' }).click() + const modal = page.getByRole('dialog', { name: 'Send liveness probe' }) + await modal + .getByRole('checkbox', { name: 'Resend failed deliveries if the probe succeeds' }) + .check() + await modal.getByRole('button', { name: 'Send probe' }).click() + + // resends only happen on success, so the API returns null and we show no row + await expect(panel.getByText('Unreachable')).toBeVisible() + await expect(panel.getByText('requeued')).toBeHidden() + await expect(panel.getByText('No failed deliveries to resend')).toBeHidden() +}) + +test('Resend fails for an unsubscribed alert class', async ({ page }) => { + await page.goto('/system/alerting/receivers/webhook-1') + + // unsubscribe from the class of an existing failed delivery + await clickRowAction(page, 'hardware.power_shelf.psu.insert', 'Remove') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Subscription hardware.power_shelf.psu.insert removed') + + // resending a delivery of that class is rejected, matching the real API + await page.getByRole('tab', { name: 'Deliveries' }).click() + await clickRowAction(page, '30ece63e-5efd-4365-99a6-d4f09dfa685e', 'Resend') + await page + .getByRole('dialog', { name: 'Confirm resend' }) + .getByRole('button', { name: 'Confirm' }) + .click() + await expectToast( + page, + "Could not resend alertCannot resend alert: receiver is not subscribed to the 'hardware.power_shelf.psu.insert' alert class" + ) + // the rejected resend must not have created a new delivery + await expect(page.getByRole('table').getByRole('row')).toHaveCount(7) // header + 6 +}) + +test('Webhook receiver delete', async ({ page }) => { + await page.goto('/system/alerting/receivers') + + await clickRowAction(page, 'power-mon', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Webhook receiver power-mon deleted') + + await expect(page.getByRole('cell', { name: 'power-mon' })).toBeHidden() + await expect(page.getByRole('table').getByRole('row')).toHaveCount(3) // header + 2 +}) + +test('Alert list basics', async ({ page }) => { + await page.goto('/system/alerting/alerts') + + await expect(page).toHaveTitle('Alerts / Alerting / Oxide Console') + await expect(page.getByRole('heading', { name: 'Alerting' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Alerts' })).toHaveAttribute( + 'aria-selected', + 'true' + ) + + const table = page.getByRole('table') + await expect(table.getByRole('row')).toHaveCount(alerts.length + 1) + + // newest first, with the ID and a one-line preview of the payload + await expectRowVisible(table, { + 'Alert ID': expect.stringContaining('26cb0726'), + 'Alert class': 'hardware.power_shelf.psu.insert', + Payload: expect.stringContaining('rack_id'), + }) + await expectRowVisible(table, { + 'Alert ID': expect.stringContaining('8c8a74ba'), + 'Alert class': 'hardware.power_shelf.psu.remove', + }) + // the probe alert has an empty payload + await expectRowVisible(table, { 'Alert class': 'probe', Payload: '—' }) + + // alert classes must stay lowercase so they can be copied into a subscription + await expect(table.getByText('hardware.power_shelf.psu.insert').first()).toHaveCSS( + 'text-transform', + 'none' + ) +}) + +test('Alert list detail view', async ({ page }) => { + await page.goto('/system/alerting/alerts') + + const rows = page.getByRole('table').getByRole('row') + + // the whole row opens the details + await rows.filter({ hasText: '26cb0726' }).click() + const modal = page.getByRole('dialog', { name: 'Alert details' }) + await expect(modal).toBeVisible() + const alertBody = modal.locator('pre') + await expect(alertBody).toContainText('"Murata"') + await expect(alertBody).toContainText('slot: 0') + await modal.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(modal).toBeHidden() + + // keyboard users have a hidden button per row + await rows + .filter({ hasText: '0d38abba' }) + .getByRole('button', { name: 'View alert details' }) + .focus() + await page.keyboard.press('Enter') + await expect(modal).toBeVisible() + await expect(modal.locator('pre')).toContainText('slot: 3') +}) diff --git a/test/e2e/authz.e2e.ts b/test/e2e/authz.e2e.ts index 3e0d280ee..d211504e2 100644 --- a/test/e2e/authz.e2e.ts +++ b/test/e2e/authz.e2e.ts @@ -54,4 +54,7 @@ test('dev user gets 404 on system pages', async ({ browser }) => { await page.goto('/system/inventory/sleds') await expect(page.getByText('Page not found')).toBeVisible() + + await page.goto('/system/alerting/receivers') + await expect(page.getByText('Page not found')).toBeVisible() })