diff --git a/app/api/util.spec.ts b/app/api/util.spec.ts index 266f99023..1fb1185ec 100644 --- a/app/api/util.spec.ts +++ b/app/api/util.spec.ts @@ -7,7 +7,53 @@ */ import { describe, expect, it, test } from 'vitest' -import { diskCan, genName, instanceCan, parsePortRange, synthesizeData } from './util' +import { + diskCan, + genName, + instanceCan, + parsePortRange, + subscriptionRegex, + synthesizeData, +} from './util' + +describe('subscriptionRegex', () => { + it('matches exact class names', () => { + expect(subscriptionRegex('probe').test('probe')).toBe(true) + expect(subscriptionRegex('probe').test('probes')).toBe(false) + expect(subscriptionRegex('instance.create').test('instance.create')).toBe(true) + }) + + 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 68cb540f5..b98668626 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -44,6 +44,22 @@ export const INSTANCE_MAX_RAM_GiB = 1536 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('*') + +/** + * 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/form/fields/SubscriptionsField.tsx b/app/components/form/fields/SubscriptionsField.tsx new file mode 100644 index 000000000..6f74063fc --- /dev/null +++ b/app/components/form/fields/SubscriptionsField.tsx @@ -0,0 +1,495 @@ +/* + * 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, 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) => + ALERT_SUBSCRIPTION_REGEX.test(value) + ? undefined + : 'Must be an event class or a glob pattern like hardware.** (letters, numbers, and underscores only)' + +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 ?? [] + + 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.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 ( +
+
+ + Event 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 index 0e537f2fe..c401f22e6 100644 --- a/app/forms/webhook-create.tsx +++ b/app/forms/webhook-create.tsx @@ -5,25 +5,27 @@ * * Copyright Oxide Computer Company */ -import { useQuery } from '@tanstack/react-query' import { useController, useForm, useWatch, type Control } from 'react-hook-form' import { useNavigate } from 'react-router' -import { api, q, queryClient, useApiMutation } from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' +import { api, queryClient, useApiMutation } from '@oxide/api' +import { Webhooks24Icon } from '@oxide/design-system/icons/react' -import { ALERT_SUBSCRIPTION_REGEX } from '~/api/util' -import { ComboboxField } from '~/components/form/fields/ComboboxField' 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 { SideModalForm } from '~/components/form/SideModalForm' +import { Form } from '~/components/form/Form' +import { FullPageForm } from '~/components/form/FullPageForm' import { HL } from '~/components/HL' -import { SubscriptionMatchPreview } from '~/components/SubscriptionMatchPreview' -import { titleCrumb } from '~/hooks/use-crumbs' import { addToast } from '~/stores/toast' -import { ItemLabel } from '~/ui/lib/ItemLabel' +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 { KEYS } from '~/ui/util/keys' +import { links } from '~/util/links' import { pb } from '~/util/path-builder' export const validateEndpoint = (value: string) => { @@ -38,17 +40,11 @@ export const validateEndpoint = (value: string) => { } } -// segments may only contain [a-zA-Z0-9_], unlike resource names -export const validateSubscription = (value: string) => - ALERT_SUBSCRIPTION_REGEX.test(value) - ? undefined - : 'Must be an event class or a glob pattern like hardware.** (letters, numbers, and underscores only)' - -type WebhookCreateFormValues = { +export type WebhookCreateFormValues = { name: string description: string endpoint: string - secret: string + secrets: string[] subscriptions: string[] } @@ -56,79 +52,100 @@ const defaultValues: WebhookCreateFormValues = { name: '', description: '', endpoint: '', - secret: '', + secrets: [], subscriptions: [], } -const subscriptionColumns = [ +const secretColumns = [ { - header: 'Event class', - cell: (subscription: string) => {subscription}, + header: 'Secrets', + cell: (secret: string) => secret, }, ] -function SubscriptionsField({ control }: { control: Control }) { - const { field } = useController({ control, name: 'subscriptions' }) - const subform = useForm({ defaultValues: { subscription: '' } }) - const subscription = useWatch({ control: subform.control, name: 'subscription' }) - - const { data: classes } = useQuery(q(api.alertClassList, {})) - const classItems = (classes?.items || []) - .filter((c) => !field.value.includes(c.name)) - .map((c) => ({ - value: c.name, - selectedLabel: c.name, - label: {c.description}, - })) - - const submitSubform = subform.handleSubmit(({ subscription }) => { - if (!field.value.includes(subscription)) { - field.onChange([...field.value, subscription]) +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 ( <> - - - subform.reset()} - onSubmit={submitSubform} - /> +
+ { + if (e.key === KEYS.enter) { + e.preventDefault() // prevent full form submission + submitSubform(e) + } + }} + /> + subform.reset()} + onSubmit={submitSubform} + /> +
subscription} - onRemoveItem={(subscription) => - field.onChange(field.value.filter((s) => s !== subscription)) - } - removeLabel={(subscription) => `remove subscription ${subscription}`} + columns={secretColumns} + rowKey={(secret) => secret} + onRemoveItem={(secret) => field.onChange(field.value.filter((s) => s !== secret))} + removeLabel={(secret) => `remove secret ${secret}`} /> + ) } -export const handle = titleCrumb('New webhook') - -export default function CreateWebhookSideModalForm() { +const globCode = 'text-mono-sm bg-info-secondary text-info rounded-sm px-1' + +const SubscriptionsMessage = ( + <> + Event subscriptions may include simple globs to subscribe to multiple categories of + events. E.g. hardware.** or{' '} + **.fault.{' '} + + Read the Webhooks guide + {' '} + and the{' '} + + API docs + {' '} + to learn more. + +) + +export const handle = { crumb: 'New webhook receiver' } + +export default function CreateWebhookForm() { const navigate = useNavigate() - const onDismiss = () => navigate(pb.alertReceivers()) - const createWebhook = useApiMutation(api.webhookReceiverCreate, { onSuccess(receiver) { queryClient.invalidateEndpoint('alertReceiverList') @@ -141,37 +158,47 @@ export default function CreateWebhookSideModalForm() { const form = useForm({ defaultValues }) return ( - { - createWebhook.mutate({ - body: { name, description, endpoint, secrets: [secret], subscriptions }, - }) - }} - loading={createWebhook.isPending} - submitError={createWebhook.error} - > - - - - - - + <> + + }>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/pages/system/alerts/AlertReceiverPage.tsx b/app/pages/system/alerts/AlertReceiverPage.tsx index be70fb42b..679c5c8fe 100644 --- a/app/pages/system/alerts/AlertReceiverPage.tsx +++ b/app/pages/system/alerts/AlertReceiverPage.tsx @@ -36,6 +36,7 @@ import { import { Badge, Button, type BadgeColor } from '@oxide/design-system/ui' 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' @@ -43,7 +44,6 @@ import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { QueryParamTabs } from '~/components/QueryParamTabs' import { useIntervalPicker } from '~/components/RefetchIntervalPicker' import { SubscriptionMatchPreview } from '~/components/SubscriptionMatchPreview' -import { validateSubscription } from '~/forms/webhook-create' import { makeCrumb } from '~/hooks/use-crumbs' import { getAlertReceiverSelector, useAlertReceiverSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' diff --git a/app/pages/system/alerts/AlertReceiversPage.tsx b/app/pages/system/alerts/AlertReceiversPage.tsx index 38cada388..d988ed23f 100644 --- a/app/pages/system/alerts/AlertReceiversPage.tsx +++ b/app/pages/system/alerts/AlertReceiversPage.tsx @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' import { useCallback } from 'react' -import { Outlet, useNavigate } from 'react-router' +import { useNavigate } from 'react-router' import { api, @@ -24,7 +24,6 @@ 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' @@ -78,10 +77,6 @@ export async function clientLoader() { 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('Alerts', pb.alertReceivers()) - export default function AlertReceiversPage() { const navigate = useNavigate() @@ -158,7 +153,6 @@ export default function AlertReceiversPage() { New webhook {table} - ) } diff --git a/app/routes.tsx b/app/routes.tsx index 4fb8598c4..bef1016d3 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -265,16 +265,11 @@ export const routes = createRoutesFromElements( /> - import('./pages/system/alerts/AlertReceiversPage').then(convert)} - > - + import('./forms/webhook-create').then(convert)} + index + lazy={() => import('./pages/system/alerts/AlertReceiversPage').then(convert)} /> - - import('./pages/system/alerts/AlertReceiverPage').then(convert)} @@ -282,6 +277,12 @@ export const routes = createRoutesFromElements( import('./forms/webhook-edit').then(convert)} /> + + import('./forms/webhook-create').then(convert)} + /> + import('./pages/system/UpdatePage').then(convert)} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 295fc605f..a426e24aa 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -71,6 +71,10 @@ exports[`breadcrumbs 2`] = ` "label": "Alerts", "path": "/system/alerts", }, + { + "label": "New webhook receiver", + "path": "/system/alerts-new", + }, ], "antiAffinityGroup (/projects/p/affinity/aag)": [ { diff --git a/app/util/links.ts b/app/util/links.ts index 7c9fcfbf5..318cb02a1 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -28,6 +28,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', + // TODO: this guide does not exist yet; make sure it does before release + webhooksGuide: 'https://docs.oxide.computer/guides/operator/webhooks', + webhooksApiDocs: 'https://docs.oxide.computer/api/webhook_receiver_create', } // Links with a canonical label, used in DocsPopover and SideModalFormDocs. diff --git a/mock-api/alert.ts b/mock-api/alert.ts index 8b6206bf8..58dc9dddb 100644 --- a/mock-api/alert.ts +++ b/mock-api/alert.ts @@ -30,6 +30,35 @@ export const alertClasses: Json[] = [ 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 = { diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 98429e979..9418efc4c 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -30,7 +30,7 @@ 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' @@ -80,19 +80,6 @@ import { // client camel-cases the keys and parses date fields. Inside the mock API everything // is *JSON type. -/** - * 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 - */ -function subscriptionRegex(subscription: string) { - const pattern = subscription - .split('.') - .map((seg) => (seg === '**' ? '.+' : seg === '*' ? '[^.]+' : seg)) - .join('\\.') - return new RegExp(`^${pattern}$`) -} - /** * The webhook-specific endpoints return the receiver with the webhook config * (endpoint, secrets) at the top level rather than nested under `kind`. diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 9311baab1..ef2608cf1 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -39,38 +39,50 @@ test('Webhook create', async ({ page }) => { await page.getByRole('link', { name: 'New webhook' }).click() await expect(page).toHaveURL('/system/alerts-new') - const modal = page.getByRole('dialog', { name: 'Create webhook' }) - await modal.getByRole('textbox', { name: 'Name' }).fill('deploy-hook') - await modal.getByRole('textbox', { name: 'Description' }).fill('CI deploys') - await modal.getByRole('textbox', { name: 'Secret' }).fill('super-secret') + 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 modal.getByRole('textbox', { name: 'Endpoint URL' }).fill('not-a-url') - await page.getByRole('button', { name: 'Create webhook' }).click() + 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( - modal.getByText('Must be a valid URL, including the scheme (e.g., https://)') + page + .getByRole('table', { name: 'Secrets' }) + .getByRole('cell', { name: 'super-secret', exact: true }) ).toBeVisible() - await modal.getByRole('textbox', { name: 'Endpoint URL' }).fill('https://ci.example.com') + await expect(main.getByText('At least one secret is required')).toBeHidden() - // add a subscription: bad glob is rejected, good glob lands in the mini table - const combobox = modal.getByRole('combobox', { name: 'Event classes' }) - await combobox.fill('hardware..bad') - await modal.getByRole('button', { name: 'Add event class' }).click() + // add a subscription: a bad glob is rejected on Enter, a good one becomes a chip + const subsInput = page.getByRole('combobox', { name: 'Event subscriptions' }) + await subsInput.fill('hardware..bad') + await subsInput.press('Enter') await expect( - modal.getByText('Must be an event class or a glob pattern like hardware.**') + main.getByText('Must be an event class or a glob pattern like hardware.**') ).toBeVisible() - await combobox.fill('hardware.**') - // glob preview shows which classes the pattern currently matches - await expect(modal.getByText('Matches 2 event classes')).toBeVisible() - await modal.getByRole('button', { name: 'Add event class' }).click() + await subsInput.fill('hardware.**') + await subsInput.press('Enter') await expect( - modal.getByRole('table', { name: 'Event classes' }).getByRole('cell', { - name: 'hardware.**', - exact: true, - }) + page.getByRole('button', { name: 'remove subscription hardware.**' }) ).toBeVisible() + await expect(subsInput).toHaveValue('') - await page.getByRole('button', { name: 'Create webhook' }).click() + await page.getByRole('button', { name: 'Create webhook receiver' }).click() await expectToast(page, 'Webhook deploy-hook created') await expectRowVisible(page.getByRole('table'), { @@ -80,6 +92,107 @@ test('Webhook create', async ({ page }) => { }) }) +test('Webhook create subscriptions field', async ({ page }) => { + await page.goto('/system/alerts-new') + + const subsInput = page.getByRole('combobox', { name: 'Event 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(15) + + // 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 15')).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('') + + // 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 15')).toBeVisible() + 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(15) + 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('probe') + await subsInput.press('Enter') + await expect(chipRemove('probe')).toBeVisible() + await subsInput.press('ArrowLeft') // arm probe + await subsInput.press('ArrowLeft') // arm hardware.*.fault + await subsInput.press('Backspace') + await expect(chipRemove('hardware.*.fault')).toBeHidden() + await expect(chipRemove('probe')).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('probe') +}) + test('Webhook detail: properties, event classes, secrets', async ({ page }) => { await page.goto('/system/alerts') await page.getByRole('link', { name: 'webhook-1' }).click()