diff --git a/app/api/__tests__/safety.spec.ts b/app/api/__tests__/safety.spec.ts index d8880069e..2fd34270c 100644 --- a/app/api/__tests__/safety.spec.ts +++ b/app/api/__tests__/safety.spec.ts @@ -53,6 +53,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..90c35b880 100644 --- a/app/api/util.spec.ts +++ b/app/api/util.spec.ts @@ -7,7 +7,52 @@ */ import { describe, expect, it, test } from 'vitest' -import { diskCan, genName, instanceCan, parsePortRange, synthesizeData } from './util' +import { + diskCan, + genName, + instanceCan, + parsePortRange, + 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', () => { diff --git a/app/api/util.ts b/app/api/util.ts index f3091f865..4fc2d9f7a 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -39,6 +39,40 @@ export const INSTANCE_MAX_CPU = 254 export const INSTANCE_MIN_RAM_GiB = 1 export const INSTANCE_MAX_RAM_GiB = 1536 +// 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}$`) +} + 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/SubscriptionMatchPreview.tsx b/app/components/SubscriptionMatchPreview.tsx new file mode 100644 index 000000000..bfabf4ede --- /dev/null +++ b/app/components/SubscriptionMatchPreview.tsx @@ -0,0 +1,62 @@ +/* + * 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 { Badge } from '@oxide/design-system/ui' + +import { + ALERT_SUBSCRIPTION_REGEX, + isGlobPattern, + isSubscribableClass, + subscriptionRegex, +} from '~/api/util' +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..c7705df6e --- /dev/null +++ b/app/components/form/fields/SubscriptionsField.tsx @@ -0,0 +1,517 @@ +/* + * 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() + // discard uncommitted text so it doesn't read as added + 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..72d6bd0fd --- /dev/null +++ b/app/forms/webhook-create.tsx @@ -0,0 +1,219 @@ +/* + * 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 } 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' + } +} + +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} + 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..f66aaa3f6 --- /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} + 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..4ef8936d9 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.alerts() }, + { value: 'Alert Receivers', path: pb.alertReceivers() }, { 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..bb79daa58 --- /dev/null +++ b/app/pages/system/alerting/AlertReceiverDeliveries.tsx @@ -0,0 +1,412 @@ +/* + * 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, + 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 { 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', + disabled: delivery.trigger === 'probe' && 'Probes cannot be resent', + 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. + +// nest the payload's lines under the `data` key's 2-space indent +const dataJson = (alert: Alert) => + JSON.stringify(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..c83bca79d --- /dev/null +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -0,0 +1,421 @@ +/* + * 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 { Badge, Button } from '@oxide/design-system/ui' + +import { isSubscribableClass } from '~/api/util' +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 + ? 'This is the only secret on this receiver. Payloads sent without a secret are unsigned and cannot be verified.' + : 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..0aa518160 --- /dev/null +++ b/app/pages/system/alerting/AlertReceiverTesting.tsx @@ -0,0 +1,185 @@ +/* + * 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 { useState } from 'react' + +import { api, queryClient, useApiMutation, type AlertProbeResult } from '@oxide/api' +import { Error12Icon, Success12Icon } from '@oxide/design-system/icons/react' +import { Button } from '@oxide/design-system/ui' + +import { useAlertReceiverSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +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 { Modal } from '~/ui/lib/Modal' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { TableEmptyBox } from '~/ui/lib/Table' + +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 ( + <> + + + + ) +} + +function ReceiverTesterCard() { + const [showProbeModal, setShowProbeModal] = useState(false) + const [result, setResult] = useState(null) + + 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 + + return ( + + + {attemptResultBadge(attempt.result)} + + + {status ? ( + + {attempt.result === 'succeeded' ? ( + + ) : ( + + )} + {status} + + ) : ( + + )} + + + {durationMs != null ? `${durationMs}ms` : } + + + + + + ) +} + +function ProbeModal({ + onDismiss, + onSuccess, +}: { + onDismiss: () => void + onSuccess: (result: AlertProbeResult) => void +}) { + const receiverSelector = useAlertReceiverSelector() + + const sendProbe = useApiMutation(api.alertReceiverProbe, { + onSuccess(result) { + queryClient.invalidateEndpoint('alertDeliveryList') + onSuccess(result) + onDismiss() + }, + onError(err) { + addToast({ title: 'Could not send probe', content: err.message, variant: 'error' }) + }, + }) + + return ( + + + +

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

+
+
+ sendProbe.mutate({ path: receiverSelector })} + actionLoading={sendProbe.isPending} + actionText="Send probe" + /> +
+ ) +} + +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..965febc26 --- /dev/null +++ b/app/pages/system/alerting/AlertReceiversTab.tsx @@ -0,0 +1,159 @@ +/* + * 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 { Badge } from '@oxide/design-system/ui' + +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: 'Alerts', + 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..36ccc315e --- /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.alerts()) + +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]} + /> + + + + Alerts + Receivers + + + ) +} diff --git a/app/pages/system/alerting/AlertsTab.tsx b/app/pages/system/alerting/AlertsTab.tsx new file mode 100644 index 000000000..4cffc0601 --- /dev/null +++ b/app/pages/system/alerting/AlertsTab.tsx @@ -0,0 +1,146 @@ +/* + * 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 { createColumnHelper } from '@tanstack/react-table' +import { memo, 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 { EmptyCell } from '~/table/cells/EmptyCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { Button } from '~/ui/lib/Button' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' +import { DateTime } 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' + +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 +} + +const colHelper = createColumnHelper() +const staticCols = [ + colHelper.accessor('class', { + cell: (info) => {info.getValue()}, + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + colHelper.accessor( + (alert: Alert) => + alert.timeCreated.getTime() === alert.timeModified.getTime() + ? undefined + : alert.timeModified, + { + header: 'modified', + cell: (info) => { + const value: Date | undefined = info.getValue() + return value === undefined ? : + }, + } + ), +] + +function AlertDetail({ alert, onDismiss }: { alert: Alert; onDismiss: () => void }) { + return ( + {alert.class}} + > + + + + + {alert.version} + + + + + + + + + + + + + + + + + ) +} + +const ApiResponseViewer = memo(({ body }: { body: Record }) => { + const stringified = JSON.stringify(snakeify(body), null, 2) + return ( +
+
+ Alert body + +
+
+        {stringified}
+      
+
+ ) +}) + +export default function AlertsTab() { + const [detail, setDetail] = useState(null) + const makeActions = (alert: Alert): MenuAction[] => [ + { + label: 'View alert details', + onActivate() { + setDetail(alert) + }, + }, + ] + const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ + query: alertList, + columns, + emptyState: ( + + } + title="No alerts" + body="Alerts created by the system will appear here." + /> + + ), + }) + + return ( + <> + {table} + {detail && setDetail(null)} />} + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 286b165f6..ef4122f7a 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/AlertsTab').then(convert)} + /> + import('./pages/system/alerting/AlertReceiversTab').then(convert)} + > + + + + {/* /system/alerting redirects to the alerts 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..8883d4e29 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' @@ -63,7 +63,10 @@ export function useQueryTable({ columns, getId, }: QueryTableProps) { - const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() + // 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 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/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 01f5aa12e..5d8f80a91 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/alerts", + }, + { + "label": "Receivers", + "path": "/system/alerting/receivers", + }, + { + "label": "rc", + "path": "/system/alerting/receivers/rc", + }, + ], + "alertReceiverEdit (/system/alerting/receivers/rc/edit)": [ + { + "label": "Alerting", + "path": "/system/alerting/alerts", + }, + { + "label": "Receivers", + "path": "/system/alerting/receivers", + }, + { + "label": "rc", + "path": "/system/alerting/receivers/rc", + }, + ], + "alertReceivers (/system/alerting/receivers)": [ + { + "label": "Alerting", + "path": "/system/alerting/alerts", + }, + { + "label": "Receivers", + "path": "/system/alerting/receivers", + }, + ], + "alertReceiversNew (/system/alerting/receivers-new)": [ + { + "label": "Alerting", + "path": "/system/alerting/alerts", + }, + { + "label": "Receivers", + "path": "/system/alerting/receivers", + }, + { + "label": "New webhook receiver", + "path": "/system/alerting/receivers-new", + }, + ], + "alerts (/system/alerting/alerts)": [ + { + "label": "Alerting", + "path": "/system/alerting/alerts", + }, + { + "label": "Alerts", + "path": "/system/alerting/alerts", + }, + ], "antiAffinityGroup (/projects/p/affinity/aag)": [ { "label": "Projects", 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..6cc1044b6 --- /dev/null +++ b/mock-api/alert.ts @@ -0,0 +1,335 @@ +/* + * 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('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[] = [ + { + 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), + }, + ], + }, + }, + { + 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 ca4d9bc88..1b14fe610 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -30,11 +30,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' @@ -80,6 +81,62 @@ 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) + } + } +} + export const handlers = makeHandlers({ logout: () => 204, ping: () => ({ status: 'ok' }), @@ -2318,6 +2375,39 @@ export const handlers = makeHandlers({ ) return paginated(query, affinityGroups) }, + alertList: ({ query }) => { + 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 = new RegExp( + alertClass + .replace(/\./g, '\\.') + .replace(/\*\*/g, '[a-z_.]+') + .replace(/\*/g, '[a-z_]+') + ) + final = final.filter((alert) => alert.class.match(matcher)) + } + + 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 }) { @@ -2630,6 +2720,206 @@ 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) + let deliveries = db.alertDeliveries.filter((d) => d.receiver_id === receiver.id) + // 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 all failed deliveries + let resendsStarted = null + if (query.resend && success) { + const failed = db.alertDeliveries.filter( + (d) => d.receiver_id === receiver.id && d.state === 'failed' + ) + for (const d of failed) { + 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 = failed.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, @@ -2637,17 +2927,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, @@ -2758,9 +3037,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..9d70be3b3 --- /dev/null +++ b/test/e2e/alerts.e2e.ts @@ -0,0 +1,629 @@ +/* + * 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, + clickRowActions, + 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/alerts') + await expect(page).toHaveTitle('Alerts / Alerting / Oxide Console') + + await page.getByRole('tab', { name: 'Receivers' }).click() + await expect(page).toHaveURL('/system/alerting/receivers') + // 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', + Alerts: 'hardware.power_shelf.psu.insert+1', + description: 'Main web deployments', + }) + await expectRowVisible(table, { name: 'power-mon', Alerts: 'hardware.**' }) + await expectRowVisible(table, { name: 'general-sys-webhook', Alerts: '—' }) +}) + +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() + 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', + Alerts: '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('') + + // subscribed classes sort to the top when the panel opens + await subsInput.click() + await expect(listbox.getByRole('option').first()).toContainText('system.update.fail') +}) + +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('This is the only secret on this receiver')).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') + await expect(table.getByRole('row')).toHaveCount(7) // header + 6 + + // 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('9bbdf44f-7dac-4cd0-b4c2-3e622c9693ee'), + // the singleton probe alert ID, shared by all probe deliveries + 'Alert ID': expect.stringContaining('001de000-7768-4000-8000-000000000001'), + 'Alert class': 'probe', + state: 'delivered', + trigger: 'probe', + }) + 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(3) // header + 2 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() + // 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', + }) + + // probes can't be resent + await clickRowActions(page, '9bbdf44f-7dac-4cd0-b4c2-3e622c9693ee') + await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeDisabled() + await page.keyboard.press('Escape') + + // send a liveness probe from the testing tab + 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' }) + await probeModal.getByRole('button', { name: 'Send probe' }).click() + const panel = page.getByRole('tabpanel') + await expect(panel.getByText('Succeeded')).toBeVisible() + // the modal has no resend option, so nothing gets resent + await expect(panel.getByText('resent')).toBeHidden() + + await page.getByRole('tab', { name: 'Deliveries' }).click() + // 8 rows + the probe. no resends: the probe modal doesn't offer them + await expect(table.getByRole('row')).toHaveCount(9) +}) + +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 }) => { + // omitting the trailing /alerts because this should be the default view + await page.goto('/system/alerting') + + 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) + + await expectRowVisible(table, { class: 'hardware.power_shelf.psu.remove' }) + await expectRowVisible(table, { class: 'hardware.power_shelf.psu.insert' }) + await expectRowVisible(table, { class: 'hardware.power_shelf.psu.remove' }) +}) + +test('Alert list detail view', async ({ page }) => { + await page.goto('/system/alerting/alerts') + + await page.getByRole('button', { name: 'Row actions' }).first().click() + const viewDetails = page.getByRole('menuitem', { name: 'View alert details' }) + await expect(viewDetails).toBeVisible() + await viewDetails.click() + + const alertBody = page.locator('pre') + + await expect(alertBody).toBeVisible() + + // the payload's `time` is generated relative to now in each process, so let's + // not bother checking it + const stripTime = ({ time: _, ...rest }: Record) => rest + const rendered = JSON.parse((await alertBody.textContent()) as string) + // we just expect the body to look like SOME alert so we aren't brittle to sorting + expect(alerts.map(({ alert }) => stripTime(alert))).toContainEqual(stripTime(rendered)) +}) 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() })