From b9440501e5b11212e918a217811ee06c285275f2 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 6 Aug 2026 15:41:56 -0700 Subject: [PATCH 01/29] More updates --- app/api/selectors.ts | 1 + app/api/util.ts | 5 + app/components/SubscriptionMatchPreview.tsx | 53 ++ app/forms/webhook-create.tsx | 177 ++++ app/forms/webhook-edit.tsx | 99 +++ app/hooks/use-params.ts | 2 + app/layouts/SystemLayout.tsx | 5 + app/pages/system/alerts/AlertReceiverPage.tsx | 808 ++++++++++++++++++ .../system/alerts/AlertReceiversPage.tsx | 159 ++++ app/routes.tsx | 17 + .../__snapshots__/path-builder.spec.ts.snap | 36 + app/util/path-builder.spec.ts | 5 + app/util/path-builder.ts | 5 + app/util/path-params.ts | 1 + mock-api/alert.ts | 244 ++++++ mock-api/index.ts | 1 + mock-api/msw/db.ts | 11 + mock-api/msw/handlers.ts | 225 ++++- test/e2e/alerts.e2e.ts | 253 ++++++ test/e2e/authz.e2e.ts | 3 + 20 files changed, 2096 insertions(+), 14 deletions(-) create mode 100644 app/components/SubscriptionMatchPreview.tsx create mode 100644 app/forms/webhook-create.tsx create mode 100644 app/forms/webhook-edit.tsx create mode 100644 app/pages/system/alerts/AlertReceiverPage.tsx create mode 100644 app/pages/system/alerts/AlertReceiversPage.tsx create mode 100644 mock-api/alert.ts create mode 100644 test/e2e/alerts.e2e.ts diff --git a/app/api/selectors.ts b/app/api/selectors.ts index 0dd0bc1225..e2b3ead6e4 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.ts b/app/api/util.ts index f3091f865c..68cb540f52 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -39,6 +39,11 @@ export const INSTANCE_MAX_CPU = 254 export const INSTANCE_MIN_RAM_GiB = 1 export const INSTANCE_MAX_RAM_GiB = 1536 +// Valid alert subscription: an event 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_]+|\*|\*\*))*$/ + 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 0000000000..deaf07ee08 --- /dev/null +++ b/app/components/SubscriptionMatchPreview.tsx @@ -0,0 +1,53 @@ +/* + * 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 } from '~/api/util' + +/** + * For a glob subscription pattern, show which alert classes it currently + * matches, using the API's own matching logic (`alertClassList` accepts a + * subscription as a filter). 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 }) { + const isGlob = pattern.includes('*') + const valid = ALERT_SUBSCRIPTION_REGEX.test(pattern) + const enabled = valid && isGlob + const { data } = useQuery( + q(api.alertClassList, { query: { filter: pattern } }, { enabled }) + ) + + if (!enabled || !data) return null + + if (data.items.length === 0) { + return ( +

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

+ ) + } + + return ( +

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

+ ) +} diff --git a/app/forms/webhook-create.tsx b/app/forms/webhook-create.tsx new file mode 100644 index 0000000000..0e537f2fee --- /dev/null +++ b/app/forms/webhook-create.tsx @@ -0,0 +1,177 @@ +/* + * 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 { 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 { ALERT_SUBSCRIPTION_REGEX } from '~/api/util' +import { ComboboxField } from '~/components/form/fields/ComboboxField' +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 { SubscriptionMatchPreview } from '~/components/SubscriptionMatchPreview' +import { titleCrumb } from '~/hooks/use-crumbs' +import { addToast } from '~/stores/toast' +import { ItemLabel } from '~/ui/lib/ItemLabel' +import { ClearAndAddButtons, MiniTable } from '~/ui/lib/MiniTable' +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' + } +} + +// 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 = { + name: string + description: string + endpoint: string + secret: string + subscriptions: string[] +} + +const defaultValues: WebhookCreateFormValues = { + name: '', + description: '', + endpoint: '', + secret: '', + subscriptions: [], +} + +const subscriptionColumns = [ + { + header: 'Event class', + cell: (subscription: string) => {subscription}, + }, +] + +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]) + } + subform.reset() + }) + + return ( + <> + + + subform.reset()} + onSubmit={submitSubform} + /> + subscription} + onRemoveItem={(subscription) => + field.onChange(field.value.filter((s) => s !== subscription)) + } + removeLabel={(subscription) => `remove subscription ${subscription}`} + /> + + ) +} + +export const handle = titleCrumb('New webhook') + +export default function CreateWebhookSideModalForm() { + const navigate = useNavigate() + + const onDismiss = () => navigate(pb.alertReceivers()) + + const createWebhook = useApiMutation(api.webhookReceiverCreate, { + onSuccess(receiver) { + queryClient.invalidateEndpoint('alertReceiverList') + // prettier-ignore + addToast(<>Webhook {receiver.name} created) + navigate(pb.alertReceivers()) + }, + }) + + const form = useForm({ defaultValues }) + + return ( + { + createWebhook.mutate({ + body: { name, description, endpoint, secrets: [secret], subscriptions }, + }) + }} + loading={createWebhook.isPending} + submitError={createWebhook.error} + > + + + + + + + ) +} diff --git a/app/forms/webhook-edit.tsx b/app/forms/webhook-edit.tsx new file mode 100644 index 0000000000..52ff429903 --- /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 { makeCrumb } 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 = makeCrumb('Edit webhook') + +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 {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-params.ts b/app/hooks/use-params.ts index 5298181d96..f5f5524eb1 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 fca0d33b88..f7a4fc01a0 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -13,6 +13,7 @@ import { Cloud16Icon, IpGlobal16Icon, Metrics16Icon, + Notifications16Icon, Servers16Icon, SoftwareUpdate16Icon, Subnet16Icon, @@ -55,6 +56,7 @@ export default function SystemLayout() { { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, { value: 'Subnet Pools', path: pb.subnetPools() }, + { value: 'Alerts', path: pb.alertReceivers() }, { value: 'System Update', path: pb.systemUpdate() }, { value: 'Fleet Access', path: pb.fleetAccess() }, ] @@ -101,6 +103,9 @@ export default function SystemLayout() { Subnet Pools + + Alerts + System Update diff --git a/app/pages/system/alerts/AlertReceiverPage.tsx b/app/pages/system/alerts/AlertReceiverPage.tsx new file mode 100644 index 0000000000..53f216f438 --- /dev/null +++ b/app/pages/system/alerts/AlertReceiverPage.tsx @@ -0,0 +1,808 @@ +/* + * 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 { match } from 'ts-pattern' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type AlertDelivery, + type AlertDeliveryState, + type WebhookDeliveryAttempt, + type WebhookSecret, +} from '@oxide/api' +import { Webhooks16Icon, Webhooks24Icon } from '@oxide/design-system/icons/react' +import { Badge, Button, type BadgeColor } from '@oxide/design-system/ui' + +import { CheckboxField } from '~/components/form/fields/CheckboxField' +import { ComboboxField } from '~/components/form/fields/ComboboxField' +import { TextField } from '~/components/form/fields/TextField' +import { HL } from '~/components/HL' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { QueryParamTabs } from '~/components/QueryParamTabs' +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' +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 { useQueryTable } from '~/table/QueryTable' +import { Table } from '~/table/Table' +import { CardBlock } from '~/ui/lib/CardBlock' +import { type ComboboxItem } from '~/ui/lib/Combobox' +import { DateTime } from '~/ui/lib/DateTime' +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 { Listbox } from '~/ui/lib/Listbox' +import { Message } from '~/ui/lib/Message' +import { Modal } from '~/ui/lib/Modal' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel, SideModal } from '~/ui/lib/SideModal' +import { Table as UITable, TableEmptyBox } from '~/ui/lib/Table' +import { Tabs } from '~/ui/lib/Tabs' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +const receiverView = ({ receiver }: PP.AlertReceiver) => + q(api.alertReceiverView, { path: { receiver } }) + +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() + +const deliveryList = (receiver: string, filter: StateFilter = 'all') => + getListQFn(api.alertDeliveryList, { + path: { receiver }, + query: stateFilterParams(filter), + }) + +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 {variables.path.receiver} deleted) + }, + }) + + const [showProbeModal, setShowProbeModal] = useState(false) + + return ( + <> + + }>{receiver.name} + + + Edit + + setShowProbeModal(true)} + /> + deleteReceiver({ path: { receiver: receiver.name } }), + label: receiver.name, + resourceKind: 'webhook', + extraContent: 'Its delivery history will also be deleted.', + })} + className="destructive" + /> + + + {showProbeModal && setShowProbeModal(false)} />} + + + {receiver.kind.endpoint} + + + + + + + + Details + Deliveries + Developer + + + + + + + + + + + + + {/* for edit form */} + + ) +} + +function ProbeModal({ onDismiss }: { onDismiss: () => void }) { + const receiverSelector = useAlertReceiverSelector() + const { control, handleSubmit } = useForm({ defaultValues: { resend: false } }) + + const sendProbe = useApiMutation(api.alertReceiverProbe, { + onSuccess(result) { + queryClient.invalidateEndpoint('alertDeliveryList') + if (result.probe.state === 'delivered') { + const resends = result.resendsStarted + addToast({ + title: 'Liveness probe delivered', + content: + resends != null + ? `Resending ${resends} failed ${resends === 1 ? 'delivery' : 'deliveries'}` + : undefined, + }) + } else { + addToast({ content: 'Liveness probe failed', variant: 'error' }) + } + onDismiss() + }, + onError(err) { + addToast({ title: 'Could not send probe', content: err.message, variant: 'error' }) + }, + }) + + const onSubmit = handleSubmit(({ resend }) => { + sendProbe.mutate({ path: receiverSelector, query: { resend } }) + }) + + return ( + + + +

+ Sends a synthetic probe event to the endpoint to check + that it is reachable. Probes do not count as real events and are not retried. +

+ + Resend failed deliveries if the probe succeeds + +
+
+ +
+ ) +} + +// Developer: static documentation of the delivery request format. Headers and +// signature scheme are defined by RFD 538 and implemented in +// https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/src/app/webhook.rs + +const REQUEST_HEADERS: [string, string][] = [ + ['x-oxide-alert-id', 'UUID of the alert'], + ['x-oxide-alert-class', 'Class of the alert'], + ['x-oxide-delivery-id', 'UUID of this delivery, stable across retries'], + ['x-oxide-receiver-id', 'UUID of this receiver'], + ['x-oxide-signature', 'HMAC signature of the request body, one header per secret'], +] + +function DeveloperTab() { + return ( + <> + + + + + + + Header + Description + + + + {REQUEST_HEADERS.map(([name, description]) => ( + + + {name} + + {description} + + ))} + + + + + + + +

+ Requests are signed with HMAC-SHA256 using every secret on the receiver. Each + request carries one x-oxide-signature header per secret + in the form{' '} + a=sha256&id=<secret ID>&s=<signature>. To + verify a request, find the header whose id matches a + secret you hold, compute the HMAC-SHA256 of the raw request body with that + secret, and compare the hex digest to s. +

+
+
+ + ) +} + +// Event classes + +const subscriptionColHelper = createColumnHelper<{ subscription: string }>() +const subscriptionCols = [ + subscriptionColHelper.accessor('subscription', { + header: 'Event class', + cell: (info) => {info.getValue()}, + }), +] + +function EventClassesCard() { + 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 + webhook will no longer receive these events. +

+ ), + 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 event class to receive events" + /> + + )} + + {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 { control, handleSubmit } = useForm({ defaultValues: { subscription: '' } }) + const subscription = useWatch({ control, name: 'subscription' }) + + const classes = useQuery(q(api.alertClassList, {})) + const classItems = (classes.data?.items || []) + .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() + }, + onError(err) { + addToast({ + title: 'Could not add subscription', + content: err.message, + variant: 'error', + }) + }, + }) + + const onSubmit = handleSubmit(({ subscription }) => { + if (!subscription) return // can't happen, subscription is required + addSubscription.mutate({ path: receiverSelector, body: { subscription } }) + }) + + return ( + + + +
{ + e.stopPropagation() + onSubmit(e) + }} + className="space-y-4" + > + + Event subscriptions may include simple globs to subscribe to multiple + categories of events, 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) + const table = useReactTable({ + columns, + data: receiver.kind.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 { control, handleSubmit } = useForm({ defaultValues: { secret: '' } }) + + const addSecret = useApiMutation(api.webhookSecretsAdd, { + onSuccess() { + queryClient.invalidateEndpoint('alertReceiverView') + addToast('Secret added') + onDismiss() + }, + onError(err) { + addToast({ title: 'Could not add secret', content: err.message, variant: 'error' }) + }, + }) + + const onSubmit = handleSubmit(({ secret }) => { + if (!secret) return // can't happen, secret is required + addSecret.mutate({ query: { receiver }, body: { secret } }) + }) + + return ( + + + +
{ + e.stopPropagation() + onSubmit(e) + }} + className="space-y-4" + > + + +
+
+ +
+ ) +} + +// Deliveries + +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 = [ + deliveryColHelper.accessor('id', Columns.id), + deliveryColHelper.accessor('alertClass', { + header: 'Event class', + cell: (info) => {info.getValue()}, + }), + deliveryColHelper.accessor('state', { + cell: (info) => , + }), + deliveryColHelper.accessor('timeStarted', { ...Columns.timeCreated, header: 'started' }), + deliveryColHelper.accessor('trigger', { + cell: (info) => {info.getValue()}, + }), +] + +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 event', + modalTitle: 'Confirm resend', + modalContent: ( +
+

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

+ + + {delivery.alertClass} + + + + + + +
+ ), + actionType: 'primary', + }), + }, + ], + [resendDelivery, receiver] + ) + + const emptyState = ( + } + title="No deliveries" + body={ + filter === 'all' + ? 'Events delivered to this webhook will show up here' + : `No ${filter} deliveries found` + } + /> + ) + + const columns = useColsWithActions(staticDeliveryCols, makeActions) + const { table } = useQueryTable({ + query: deliveryList(receiver, filter), + columns, + emptyState, + }) + + return ( + <> +
+ +
+ {table} + {selectedDelivery && ( + setSelectedDelivery(null)} + /> + )} + + ) +} + +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(), + }) + + return ( + + {receiver} + + } + > + + + + + {delivery.alertClass} + + + + + + + + + + {delivery.trigger} + + + +
+ Attempts + {delivery.attempts.webhook.length ? ( +
+ ) : ( + + + + )} + + + + + + + + ) +} diff --git a/app/pages/system/alerts/AlertReceiversPage.tsx b/app/pages/system/alerts/AlertReceiversPage.tsx new file mode 100644 index 0000000000..f63663be4a --- /dev/null +++ b/app/pages/system/alerts/AlertReceiversPage.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 { Outlet, 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 { 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 { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TableActions } from '~/ui/lib/Table' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' + +const EmptyState = () => ( + } + title="No alert receivers" + body="Create a webhook receiver to see it here" + buttonText="New webhook" + buttonTo={pb.alertReceiversNew()} + /> +) + +const colHelper = createColumnHelper() + +const staticColumns = [ + colHelper.accessor('name', { + cell: makeLinkCell((receiver) => pb.alertReceiver({ receiver })), + }), + colHelper.accessor('subscriptions', { + header: 'Events', + 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 +} + +export const handle = { crumb: 'Alerts' } + +export default function AlertReceiversPage() { + const navigate = useNavigate() + + const { mutateAsync: deleteReceiver } = useApiMutation(api.alertReceiverDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('alertReceiverList') + // prettier-ignore + addToast(<>Webhook {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', + 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', + navGroup: 'Actions', + action: pb.alertReceiversNew(), + }, + ...(allReceivers?.items || []).map((r) => ({ + value: r.name, + action: pb.alertReceiver({ receiver: r.name }), + navGroup: 'Go to alert receiver', + })), + ], + [allReceivers] + ) + + return ( + <> + + }>Alert Receivers + + + New webhook + + {table} + + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 2fdaadc22f..4fb8598c48 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -265,6 +265,23 @@ export const routes = createRoutesFromElements( /> + import('./pages/system/alerts/AlertReceiversPage').then(convert)} + > + + import('./forms/webhook-create').then(convert)} + /> + + + import('./pages/system/alerts/AlertReceiverPage').then(convert)} + > + import('./forms/webhook-edit').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 300fee5831..fced7898fd 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -40,6 +40,42 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/", }, ], + "alertReceiver (/system/alerts/rc)": [ + { + "label": "Alerts", + "path": "/system/alerts", + }, + { + "label": "rc", + "path": "/system/alerts/rc", + }, + ], + "alertReceiverEdit (/system/alerts/rc/edit)": [ + { + "label": "Alerts", + "path": "/system/alerts", + }, + { + "label": "rc", + "path": "/system/alerts/rc", + }, + { + "label": "Edit webhook", + "path": "/system/alerts/rc/edit", + }, + ], + "alertReceivers (/system/alerts)": [ + { + "label": "Alerts", + "path": "/system/", + }, + ], + "alertReceiversNew (/system/alerts-new)": [ + { + "label": "Alerts", + "path": "/system/", + }, + ], "antiAffinityGroup (/projects/p/affinity/aag)": [ { "label": "Projects", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 9fc90181e0..b478cbc1af 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,10 @@ test('path builder', () => { "accessTokens": "/settings/access-tokens", "affinity": "/projects/p/affinity", "affinityNew": "/projects/p/affinity-new", + "alertReceiver": "/system/alerts/rc", + "alertReceiverEdit": "/system/alerts/rc/edit", + "alertReceivers": "/system/alerts", + "alertReceiversNew": "/system/alerts-new", "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 e09ad45aa7..2878dc7456 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -129,6 +129,11 @@ export const pb = { subnetPoolEdit: (params: PP.SubnetPool) => `${pb.subnetPool(params)}/edit`, subnetPoolMemberAdd: (params: PP.SubnetPool) => `${pb.subnetPool(params)}/members-add`, + alertReceivers: () => '/system/alerts', + alertReceiversNew: () => '/system/alerts-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 011afa41c3..685ed59f92 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 0000000000..32fc297064 --- /dev/null +++ b/mock-api/alert.ts @@ -0,0 +1,244 @@ +/* + * 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 { AlertClass, AlertDelivery, AlertReceiver } from '@oxide/api' + +import type { Json } from './json-type' +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.', + }, +] + +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: [ + { + id: '88c7b9bb-fa79-4516-8f12-abebd2626062', + time_created: new Date().toISOString(), + }, + { + id: 'b15f4584-98f1-4cac-b0d3-67294e41aab7', + time_created: new Date().toISOString(), + }, + ], + }, + 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(), +} + +export const alertReceivers = [receiverWebhook1, receiverPowerMon, receiverGeneral] + +const minutesAgo = (n: number) => subMinutes(new Date(), n).toISOString() + +// newest first, the order the list endpoint returns +export const alertDeliveries: Json[] = [ + { + id: '9bbdf44f-7dac-4cd0-b4c2-3e622c9693ee', + alert_id: '391a8e04-a160-4132-a989-6104113311f5', + 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 3620d30c2e..3abb4d639c 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' diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 9986205ed2..7dbac44970 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -124,6 +124,15 @@ export const getIpFromPool = (pool: Json) => { } 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 @@ -603,6 +612,8 @@ type DiskBulkImport = { const initDb = { affinityGroups: [...mock.affinityGroups], + alertDeliveries: [...mock.alertDeliveries], + alertReceivers: [...mock.alertReceivers], affinityGroupMemberLists: [...mock.affinityGroupMemberLists], antiAffinityGroups: [...mock.antiAffinityGroups], antiAffinityGroupMemberLists: [...mock.antiAffinityGroupMemberLists], diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 5f2b056373..49a29e70b5 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -35,6 +35,7 @@ import { parseIpNet } from '~/util/ip' import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' +import { alertClasses } from '../alert' import { defaultSilo, toIdp } from '../silo' import { getTimestamps } from '../util' import { defaultFirewallRules } from '../vpc' @@ -79,6 +80,28 @@ 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`. + */ +function toWebhookReceiver(receiver: Json): Json { + const { kind, ...rest } = receiver + return { ...rest, endpoint: kind.endpoint, secrets: kind.secrets } +} + export const handlers = makeHandlers({ logout: () => 204, ping: () => ({ status: 'ok' }), @@ -2623,6 +2646,194 @@ 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) + 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(), + alert_id: uuid(), + 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}`) + 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, @@ -2630,15 +2841,6 @@ export const handlers = makeHandlers({ affinityGroupMemberInstanceDelete: NotImplemented, affinityGroupMemberInstanceView: NotImplemented, affinityGroupUpdate: NotImplemented, - alertClassList: NotImplemented, - alertDeliveryList: NotImplemented, - alertDeliveryResend: NotImplemented, - alertReceiverDelete: NotImplemented, - alertReceiverList: NotImplemented, - alertReceiverProbe: NotImplemented, - alertReceiverSubscriptionAdd: NotImplemented, - alertReceiverSubscriptionRemove: NotImplemented, - alertReceiverView: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, auditLogList: NotImplemented, certificateCreate: NotImplemented, @@ -2752,9 +2954,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 0000000000..973feb8cce --- /dev/null +++ b/test/e2e/alerts.e2e.ts @@ -0,0 +1,253 @@ +/* + * 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 } from '@playwright/test' + +import { + clickRowAction, + clickRowActions, + expectRowVisible, + expectToast, + selectOption, +} from './utils' + +test('Alert receivers list', async ({ page }) => { + await page.goto('/system/alerts') + await expect(page).toHaveTitle('Alerts / Oxide Console') + await expect(page.getByRole('heading', { name: 'Alert Receivers' })).toBeVisible() + + const table = page.getByRole('table') + await expect(table.getByRole('row')).toHaveCount(4) // header + 3 receivers + + await expectRowVisible(table, { + name: 'webhook-1', + Events: 'hardware.power_shelf.psu.insert+1', + description: 'Main web deployments', + }) + await expectRowVisible(table, { name: 'power-mon', Events: 'hardware.**' }) + await expectRowVisible(table, { name: 'general-sys-webhook', Events: '—' }) +}) + +test('Webhook create', async ({ page }) => { + await page.goto('/system/alerts') + + 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') + + // 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 expect( + modal.getByText('Must be a valid URL, including the scheme (e.g., https://)') + ).toBeVisible() + await modal.getByRole('textbox', { name: 'Endpoint URL' }).fill('https://ci.example.com') + + // 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() + await expect( + modal.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 expect( + modal.getByRole('table', { name: 'Event classes' }).getByRole('cell', { + name: 'hardware.**', + exact: true, + }) + ).toBeVisible() + + await page.getByRole('button', { name: 'Create webhook' }).click() + await expectToast(page, 'Webhook deploy-hook created') + + await expectRowVisible(page.getByRole('table'), { + name: 'deploy-hook', + Events: 'hardware.**', + description: 'CI deploys', + }) +}) + +test('Webhook detail: properties, event classes, secrets', async ({ page }) => { + await page.goto('/system/alerts') + await page.getByRole('link', { name: 'webhook-1' }).click() + await expect(page).toHaveURL('/system/alerts/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() + + // event classes card + const eventClasses = page.getByRole('table', { name: 'Event classes' }) + await expect(eventClasses.getByRole('row')).toHaveCount(3) // header + 2 + + // add a subscription + await page.getByRole('button', { name: 'Add event class' }).click() + const addModal = page.getByRole('dialog', { name: 'Add event class' }) + await addModal.getByRole('combobox', { name: 'Subscription' }).fill('probe') + await page.getByRole('option', { name: 'probe' }).click() + await addModal.getByRole('button', { name: 'Add' }).click() + await expectToast(page, 'Subscribed to probe') + await expect(eventClasses.getByRole('row')).toHaveCount(4) + + // remove it again + await clickRowAction(page, 'probe', 'Remove') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Subscription probe removed') + await expect(eventClasses.getByRole('row')).toHaveCount(3) + + // secrets card + const secrets = page.getByRole('table', { name: 'Secrets' }) + await expect(secrets.getByRole('row')).toHaveCount(3) // header + 2 + + // 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) + + // 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('Developer tab documents the request format', async ({ page }) => { + await page.goto('/system/alerts/webhook-1') + await page.getByRole('tab', { name: 'Developer' }).click() + + const headers = page.getByRole('table', { name: 'Request headers' }) + await expect(headers.getByRole('cell', { name: 'x-oxide-alert-class' })).toBeVisible() + await expect( + page.getByRole('cell', { name: 'x-oxide-signature', exact: true }) + ).toBeVisible() + await expect(page.getByText('HMAC-SHA256')).toBeVisible() +}) + +test('Webhook edit', async ({ page }) => { + await page.goto('/system/alerts') + await clickRowAction(page, 'general-sys-webhook', 'Edit') + + const modal = page.getByRole('dialog', { name: 'Edit webhook' }) + 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' }).click() + + await expectToast(page, 'Webhook general-webhook updated') + // lands on the detail page for the new name + await expect(page).toHaveURL('/system/alerts/general-webhook') + await expect(page.getByText('https://hooks.example.dev')).toBeVisible() +}) + +test('Webhook deliveries', async ({ page }) => { + await page.goto('/system/alerts/webhook-1') + await page.getByRole('tab', { name: 'Deliveries' }).click() + + const table = page.getByRole('table') + await expect(table.getByRole('row')).toHaveCount(7) // header + 6 + + await expectRowVisible(table, { + 'Event class': 'probe', + state: 'delivered', + trigger: 'probe', + }) + await expectRowVisible(table, { + 'Event class': 'hardware.power_shelf.psu.insert', + state: 'failed', + trigger: 'alert', + }) + + // 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' }) + await expect(sideModal.getByText('Attempts')).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() + 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, truncated in the modal + await expect(confirmModal.getByText(/beef336d/)).toBeVisible() + await confirmModal.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Delivery resend started') + await expect(table.getByRole('row')).toHaveCount(8) + await expectRowVisible(table, { + 'Event 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 page actions menu, resending failed + // deliveries on success + await page.getByRole('button', { name: 'Webhook actions' }).click() + await page.getByRole('menuitem', { name: 'Send liveness probe' }).click() + const probeModal = page.getByRole('dialog', { name: 'Send liveness probe' }) + await probeModal + .getByRole('checkbox', { name: 'Resend failed deliveries if the probe succeeds' }) + .click() + await probeModal.getByRole('button', { name: 'Send probe' }).click() + await expectToast(page, 'Liveness probe delivered') + // 8 rows + 1 probe + 2 resends of the 2 failed deliveries + await expect(table.getByRole('row')).toHaveCount(11) + await expectRowVisible(table, { + 'Event class': 'hardware.power_shelf.psu.remove', + state: 'pending', + trigger: 'resend', + }) +}) + +test('Webhook delete', async ({ page }) => { + await page.goto('/system/alerts') + + await clickRowAction(page, 'power-mon', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Webhook power-mon deleted') + + await expect(page.getByRole('cell', { name: 'power-mon' })).toBeHidden() + await expect(page.getByRole('table').getByRole('row')).toHaveCount(3) // header + 2 +}) diff --git a/test/e2e/authz.e2e.ts b/test/e2e/authz.e2e.ts index 3e0d280ee4..1c4e2b5c64 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/alerts') + await expect(page.getByText('Page not found')).toBeVisible() }) From 4755f7304faa4ff2f86e383b49c0fdc77602d64e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 7 Aug 2026 16:36:16 -0700 Subject: [PATCH 02/29] Update side modals, polling --- app/pages/system/alerts/AlertReceiverPage.tsx | 341 +++++++++++++----- mock-api/msw/handlers.ts | 48 +++ test/e2e/alerts.e2e.ts | 120 +++++- 3 files changed, 408 insertions(+), 101 deletions(-) diff --git a/app/pages/system/alerts/AlertReceiverPage.tsx b/app/pages/system/alerts/AlertReceiverPage.tsx index 53f216f438..0e9b25109f 100644 --- a/app/pages/system/alerts/AlertReceiverPage.tsx +++ b/app/pages/system/alerts/AlertReceiverPage.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useForm, useWatch } from 'react-hook-form' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { match } from 'ts-pattern' @@ -22,10 +22,16 @@ import { usePrefetchedQuery, type AlertDelivery, type AlertDeliveryState, + type AlertProbeResult, type WebhookDeliveryAttempt, type WebhookSecret, } from '@oxide/api' -import { Webhooks16Icon, Webhooks24Icon } from '@oxide/design-system/icons/react' +import { + Error12Icon, + Success12Icon, + Webhooks16Icon, + Webhooks24Icon, +} from '@oxide/design-system/icons/react' import { Badge, Button, type BadgeColor } from '@oxide/design-system/ui' import { CheckboxField } from '~/components/form/fields/CheckboxField' @@ -34,6 +40,7 @@ import { TextField } from '~/components/form/fields/TextField' import { HL } from '~/components/HL' 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' @@ -41,12 +48,14 @@ import { getAlertReceiverSelector, useAlertReceiverSelector } from '~/hooks/use- import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' +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 { Table } from '~/table/Table' import { CardBlock } from '~/ui/lib/CardBlock' import { type ComboboxItem } from '~/ui/lib/Combobox' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' import { DateTime } from '~/ui/lib/DateTime' import * as Dropdown from '~/ui/lib/DropdownMenu' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -58,7 +67,7 @@ import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { ResourceLabel, SideModal } from '~/ui/lib/SideModal' -import { Table as UITable, TableEmptyBox } from '~/ui/lib/Table' +import { TableEmptyBox } from '~/ui/lib/Table' import { Tabs } from '~/ui/lib/Tabs' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' @@ -107,8 +116,6 @@ export default function AlertReceiverPage() { }, }) - const [showProbeModal, setShowProbeModal] = useState(false) - return ( <> @@ -117,10 +124,6 @@ export default function AlertReceiverPage() { Edit - setShowProbeModal(true)} - /> - {showProbeModal && setShowProbeModal(false)} />} {receiver.kind.endpoint} @@ -146,7 +148,7 @@ export default function AlertReceiverPage() { Details Deliveries - Developer + Testing @@ -155,8 +157,8 @@ export default function AlertReceiverPage() { - - + + {/* for edit form */} @@ -164,25 +166,114 @@ export default function AlertReceiverPage() { ) } -function ProbeModal({ onDismiss }: { onDismiss: () => void }) { +// 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 + +function TestingTab() { + return ( + <> + + + + ) +} + +function WebhookTesterCard() { + const [showProbeModal, setShowProbeModal] = useState(false) + const [result, setResult] = useState(null) + + return ( + + + + + +

+ To test your integration, send a liveness probe to the endpoint. A probe is a + synthetic probe event: it checks that the endpoint is + reachable, but does not count as a real event and is not retried. +

+ {result ? ( + + ) : ( + + + + )} +
+ {showProbeModal && ( + setShowProbeModal(false)} onSuccess={setResult} /> + )} +
+ ) +} + +function ProbeResult({ result }: { result: AlertProbeResult }) { + // a probe is delivered once and never retried, so there is at most one attempt + const attempt = result.probe.attempts.webhook.at(0) + if (!attempt) return null // can't happen: the API always returns the attempt it made + + const status = attempt.response?.status + const durationMs = attempt.response?.durationMs + const resends = result.resendsStarted + + return ( + + + {attemptResultBadge(attempt.result)} + + + {status ? ( + + {attempt.result === 'succeeded' ? ( + + ) : ( + + )} + {status} + + ) : ( + + )} + + + {durationMs != null ? `${durationMs}ms` : } + + + + + {resends != null && ( + + {resends} failed {resends === 1 ? 'delivery' : 'deliveries'} resent + + )} + + ) +} + +function ProbeModal({ + onDismiss, + onSuccess, +}: { + onDismiss: () => void + onSuccess: (result: AlertProbeResult) => void +}) { const receiverSelector = useAlertReceiverSelector() const { control, handleSubmit } = useForm({ defaultValues: { resend: false } }) const sendProbe = useApiMutation(api.alertReceiverProbe, { onSuccess(result) { queryClient.invalidateEndpoint('alertDeliveryList') - if (result.probe.state === 'delivered') { - const resends = result.resendsStarted - addToast({ - title: 'Liveness probe delivered', - content: - resends != null - ? `Resending ${resends} failed ${resends === 1 ? 'delivery' : 'deliveries'}` - : undefined, - }) - } else { - addToast({ content: 'Liveness probe failed', variant: 'error' }) - } + onSuccess(result) onDismiss() }, onError(err) { @@ -217,65 +308,36 @@ function ProbeModal({ onDismiss }: { onDismiss: () => void }) { ) } -// Developer: static documentation of the delivery request format. Headers and -// signature scheme are defined by RFD 538 and implemented in -// https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/src/app/webhook.rs - -const REQUEST_HEADERS: [string, string][] = [ - ['x-oxide-alert-id', 'UUID of the alert'], - ['x-oxide-alert-class', 'Class of the alert'], - ['x-oxide-delivery-id', 'UUID of this delivery, stable across retries'], - ['x-oxide-receiver-id', 'UUID of this receiver'], - ['x-oxide-signature', 'HMAC signature of the request body, one header per secret'], +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 DeveloperTab() { +function SignatureFormatCard() { return ( - <> - - - - - - - Header - Description - - - - {REQUEST_HEADERS.map(([name, description]) => ( - - - {name} - - {description} - - ))} - - - - - - - -

- Requests are signed with HMAC-SHA256 using every secret on the receiver. Each - request carries one x-oxide-signature header per secret - in the form{' '} - a=sha256&id=<secret ID>&s=<signature>. To - verify a request, find the header whose id matches a - secret you hold, compute the HMAC-SHA256 of the raw request body with that - secret, and compare the hex digest to s. -

-
-
- + + + +

+ 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}
+
+ ))} +
+
+
) } @@ -687,15 +749,24 @@ function DeliveriesTab() { ) const columns = useColsWithActions(staticDeliveryCols, makeActions) - const { table } = useQueryTable({ + const { table, query } = useQueryTable({ query: deliveryList(receiver, filter), columns, emptyState, }) + // 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} -
- Attempts + + + + 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 ? (
) : ( @@ -795,8 +873,11 @@ function DeliverySideModal({ /> )} - - + + + + + + + ) + return glob ? ( + + {chip} + + ) : ( + chip + ) +} + +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' } + +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 globRegexes = committed + .filter(isGlobPattern) + .map((g) => [g, subscriptionRegex(g)] as const) + const exacts = new Set(committed.filter((s) => !isGlobPattern(s))) + + 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 (all `*` promoted to `**`), used to keep + // near-miss rows visible with a hint about the pattern that would cover them + const promotedGlob = queryIsValidGlob + ? queryTrimmed.replaceAll('*', '**').replaceAll('****', '**') + : null + const promotedRegex = promotedGlob ? subscriptionRegex(promotedGlob) : null + + const visible = + queryTrimmed === '' + ? classes + : promotedRegex + ? classes.filter((c) => promotedRegex.test(c.name)) + : classes.filter((c) => c.name.toLowerCase().includes(queryTrimmed.toLowerCase())) + + // precedence: covered > picked > pending > promoted > plain + function rowState(name: string): RowState { + const via = globRegexes.find(([, re]) => re.test(name))?.[0] + if (via) return { kind: 'covered', via } + if (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' } + } + + const rows = visible.map((c) => ({ ...c, state: rowState(c.name) })) + // 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) { + if (selectableIdxs.length === 0) return + const pos = activeIdx === null ? -1 : selectableIdxs.indexOf(activeIdx) + const nextPos = + pos === -1 + ? dir === 1 + ? 0 + : selectableIdxs.length - 1 + : (pos + dir + selectableIdxs.length) % selectableIdxs.length + const next = selectableIdxs[nextPos] + setActiveIdx(next) + document.getElementById(optionId(next))?.scrollIntoView({ block: 'nearest' }) + } + + 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() + setOpen(false) + setArmedIdx(null) + setActiveIdx(null) + } else if (e.key === KEYS.down) { + e.preventDefault() + if (!open) setOpen(true) + 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)) { + setOpen(false) + setArmedIdx(null) + setActiveIdx(null) + 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) => ( + subscriptionRegex(value).test(c.name)).length + : undefined + } + armed={armedIdx === i} + onRemove={() => removeChip(value)} + /> + ))} + { + setQuery(e.target.value) + setArmedIdx(null) + setCommitError(undefined) + setActiveIdx(null) + setOpen(true) + }} + onFocus={() => setOpen(true)} + 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' + return ( + // oxlint-disable-next-line click-events-have-key-events, interactive-supports-focus +
toggleRow(row.name)} + > + + + + + {queryTrimmed && !queryRegex ? ( + + ) : ( + row.name + )} + + {state.kind === 'covered' && ( + via {state.via} + )} + {state.kind === 'pending' && ( + + {queryTrimmed} + + )} + {state.kind === 'promoted' && ( + {state.via} + )} +
+ ) + }) + )} +
+ )} +
+ {commitError && {commitError}} +
+ ) +} diff --git a/app/forms/webhook-create.tsx b/app/forms/webhook-create.tsx index 599b12b85b..805da7f685 100644 --- a/app/forms/webhook-create.tsx +++ b/app/forms/webhook-create.tsx @@ -5,30 +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 { api, queryClient, useApiMutation } from '@oxide/api' import { Webhooks24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' -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 { Form } from '~/components/form/Form' import { FullPageForm } from '~/components/form/FullPageForm' import { HL } from '~/components/HL' -import { SubscriptionMatchPreview } from '~/components/SubscriptionMatchPreview' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' -import { ItemLabel } from '~/ui/lib/ItemLabel' +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) => { @@ -43,13 +40,7 @@ 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 @@ -127,70 +118,28 @@ function SecretsField({ control }: { control: Control } ) } -const subscriptionColumns = [ - { - header: 'Event class', - cell: (subscription: string) => {subscription}, - }, -] - -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]) - } - subform.reset() - }) - - return ( - <> -
- - - subform.reset()} - onSubmit={submitSubform} - /> -
- subscription} - onRemoveItem={(subscription) => - field.onChange(field.value.filter((s) => s !== subscription)) - } - removeLabel={(subscription) => `remove subscription ${subscription}`} - /> - - ) -} +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. instance.* or{' '} + *.delete.{' '} + + Read the Webhooks guide + {' '} + and the{' '} + + API docs + {' '} + to learn more. + +) export const handle = { crumb: 'New webhook receiver' } @@ -235,11 +184,14 @@ export default function CreateWebhookForm() { validate={validateEndpoint} /> + Subscriptions +
+ + +
+ Secrets - - Subscriptions - Create webhook receiver diff --git a/app/pages/system/alerts/AlertReceiverPage.tsx b/app/pages/system/alerts/AlertReceiverPage.tsx index 0e9b25109f..c6a4cd5e20 100644 --- a/app/pages/system/alerts/AlertReceiverPage.tsx +++ b/app/pages/system/alerts/AlertReceiverPage.tsx @@ -36,13 +36,13 @@ import { Badge, Button, type BadgeColor } from '@oxide/design-system/ui' import { CheckboxField } from '~/components/form/fields/CheckboxField' import { ComboboxField } from '~/components/form/fields/ComboboxField' +import { validateSubscription } from '~/components/form/fields/SubscriptionsField' import { TextField } from '~/components/form/fields/TextField' import { HL } from '~/components/HL' 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/util/links.ts b/app/util/links.ts index 7c9fcfbf5a..318cb02a14 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 32fc297064..f2ec1f8974 100644 --- a/mock-api/alert.ts +++ b/mock-api/alert.ts @@ -30,6 +30,28 @@ 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, based on examples in RFD 538. They are not + // yet defined in Omicron's alert.rs; they exist to exercise the catalog UI. + { name: 'instance.create', description: 'An instance has been created' }, + { name: 'instance.start', description: 'An instance has been started' }, + { name: 'instance.stop', description: 'An instance has been stopped' }, + { name: 'instance.delete', description: 'An instance has been deleted' }, + { name: 'instance.reboot', description: 'An instance has been rebooted' }, + { name: 'instance.fail', description: 'An instance has entered a failed state' }, + { + name: 'instance.ephemeral_ip.attach', + description: 'An ephemeral IP has been attached to an instance', + }, + { + name: 'instance.ephemeral_ip.detach', + description: 'An ephemeral IP has been detached from an instance', + }, + { name: 'project.create', description: 'A project has been created' }, + { name: 'project.update', description: 'A project has been updated' }, + { name: 'project.delete', description: 'A project has been deleted' }, + { name: 'image.delete', description: 'An image has been deleted' }, + { name: 'image.promote', description: 'An image has been promoted to a silo image' }, + { name: 'image.demote', description: 'An image has been demoted to a project image' }, ] export const receiverWebhook1: Json = { diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 98429e9790..9418efc4cc 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 b742d4617e..307ce5c452 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -68,23 +68,19 @@ test('Webhook create', async ({ page }) => { ).toBeVisible() 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 = page.getByRole('combobox', { name: 'Event classes' }) - await combobox.fill('hardware..bad') - await page.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( 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(main.getByText('Matches 2 event classes')).toBeVisible() - await page.getByRole('button', { name: 'Add event class' }).click() + await subsInput.fill('hardware.**') + await subsInput.press('Enter') await expect( - page.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 receiver' }).click() await expectToast(page, 'Webhook deploy-hook created') @@ -96,6 +92,89 @@ 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}` }) + + // focusing opens the catalog showing all classes + await subsInput.click() + await expect(listbox.getByText('All classes')).toBeVisible() + await expect(listbox.getByRole('option')).toHaveCount(17) + + // a glob query filters the catalog and labels matched rows with the pattern + await subsInput.fill('instance.*') + await expect(listbox.getByText('Matching “instance.*”')).toBeVisible() + // 6 direct children match instance.*; the two ephemeral_ip classes are shown + // as near misses labeled with the broader pattern that would cover them + await expect(listbox.getByText('Showing 8 of 17')).toBeVisible() + const pendingRow = listbox.getByRole('option', { name: 'instance.create' }) + await expect(pendingRow.getByText('instance.*', { exact: true })).toBeVisible() + const nearMissRow = listbox.getByRole('option', { name: 'instance.ephemeral_ip.attach' }) + await expect(nearMissRow.getByText('instance.**', { exact: true })).toBeVisible() + + // Enter commits the glob as a chip and clears the query + await subsInput.press('Enter') + await expect(chipRemove('instance.*')).toBeVisible() + await expect(subsInput).toHaveValue('') + + // rows matched by the committed glob are locked and can't be double-added + await subsInput.fill('instance') + const coveredRow = listbox.getByRole('option', { name: 'instance.create' }) + await expect(coveredRow.getByText('via instance.*')).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('instance.create')).toBeHidden() + + // plain-text filter + ticking rows commits exact classes without resetting the query + await subsInput.fill('proj') + await expect(listbox.getByText('Showing 3 of 17')).toBeVisible() + await listbox.getByRole('option', { name: 'project.create' }).click() + await listbox.getByRole('option', { name: 'project.delete' }).click() + await expect(chipRemove('project.create')).toBeVisible() + await expect(chipRemove('project.delete')).toBeVisible() + await expect(subsInput).toHaveValue('proj') + await expect(listbox).toBeVisible() + + // clicking a picked row unpicks it + await listbox.getByRole('option', { name: 'project.create' }).click() + await expect(chipRemove('project.create')).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() + + // backspace on an empty query arms the last chip, a second one removes it + await subsInput.press('Backspace') + await expect(chipRemove('project.delete')).toBeVisible() + await subsInput.press('Backspace') + await expect(chipRemove('project.delete')).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('instance.*')).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 instance.* + await subsInput.press('Backspace') + await expect(chipRemove('instance.*')).toBeHidden() + await expect(chipRemove('probe')).toBeVisible() +}) + test('Webhook detail: properties, event classes, secrets', async ({ page }) => { await page.goto('/system/alerts') await page.getByRole('link', { name: 'webhook-1' }).click() From 59e0d9b08be13c924c88412710ff3380d4363665 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 12 Aug 2026 17:03:02 -0400 Subject: [PATCH 05/29] Remove resend checkbox; other tweaks to lists --- app/pages/system/alerts/AlertReceiverPage.tsx | 31 ++++++------------- .../system/alerts/AlertReceiversPage.tsx | 15 ++++++--- .../__snapshots__/path-builder.spec.ts.snap | 4 +-- mock-api/alert.ts | 5 +-- test/e2e/alerts.e2e.ts | 28 ++++++++--------- 5 files changed, 39 insertions(+), 44 deletions(-) diff --git a/app/pages/system/alerts/AlertReceiverPage.tsx b/app/pages/system/alerts/AlertReceiverPage.tsx index 0e9b25109f..c0eb05bd1a 100644 --- a/app/pages/system/alerts/AlertReceiverPage.tsx +++ b/app/pages/system/alerts/AlertReceiverPage.tsx @@ -11,6 +11,7 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/re import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useForm, useWatch } from 'react-hook-form' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' +import * as R from 'remeda' import { match } from 'ts-pattern' import { @@ -34,7 +35,6 @@ import { } from '@oxide/design-system/icons/react' import { Badge, Button, type BadgeColor } from '@oxide/design-system/ui' -import { CheckboxField } from '~/components/form/fields/CheckboxField' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { TextField } from '~/components/form/fields/TextField' import { HL } from '~/components/HL' @@ -195,9 +195,7 @@ function WebhookTesterCard() {

- To test your integration, send a liveness probe to the endpoint. A probe is a - synthetic probe event: it checks that the endpoint is - reachable, but does not count as a real event and is not retried. + To test your integration, send a liveness probe to the endpoint.

{result ? ( @@ -224,7 +222,6 @@ function ProbeResult({ result }: { result: AlertProbeResult }) { const status = attempt.response?.status const durationMs = attempt.response?.durationMs - const resends = result.resendsStarted return ( @@ -251,11 +248,6 @@ function ProbeResult({ result }: { result: AlertProbeResult }) { - {resends != null && ( - - {resends} failed {resends === 1 ? 'delivery' : 'deliveries'} resent - - )} ) } @@ -268,7 +260,6 @@ function ProbeModal({ onSuccess: (result: AlertProbeResult) => void }) { const receiverSelector = useAlertReceiverSelector() - const { control, handleSubmit } = useForm({ defaultValues: { resend: false } }) const sendProbe = useApiMutation(api.alertReceiverProbe, { onSuccess(result) { @@ -281,26 +272,19 @@ function ProbeModal({ }, }) - const onSubmit = handleSubmit(({ resend }) => { - sendProbe.mutate({ path: receiverSelector, query: { resend } }) - }) - return (

Sends a synthetic probe event to the endpoint to check - that it is reachable. Probes do not count as real events and are not retried. + that it is reachable.

- - Resend failed deliveries if the probe succeeds -
sendProbe.mutate({ path: receiverSelector })} actionLoading={sendProbe.isPending} actionText="Send probe" /> @@ -560,9 +544,14 @@ function SecretsCard() { ) 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: receiver.kind.secrets, + data: secrets, getCoreRowModel: getCoreRowModel(), }) diff --git a/app/pages/system/alerts/AlertReceiversPage.tsx b/app/pages/system/alerts/AlertReceiversPage.tsx index f63663be4a..38cada3886 100644 --- a/app/pages/system/alerts/AlertReceiversPage.tsx +++ b/app/pages/system/alerts/AlertReceiversPage.tsx @@ -24,6 +24,7 @@ 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' @@ -41,8 +42,8 @@ import { pb } from '~/util/path-builder' const EmptyState = () => ( } - title="No alert receivers" - body="Create a webhook receiver to see it here" + title="No webhooks" + body="Create a webhook to see it here" buttonText="New webhook" buttonTo={pb.alertReceiversNew()} /> @@ -77,7 +78,9 @@ export async function clientLoader() { return null } -export const handle = { crumb: 'Alerts' } +// 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() @@ -138,7 +141,7 @@ export default function AlertReceiversPage() { ...(allReceivers?.items || []).map((r) => ({ value: r.name, action: pb.alertReceiver({ receiver: r.name }), - navGroup: 'Go to alert receiver', + navGroup: 'Go to webhook', })), ], [allReceivers] @@ -147,7 +150,9 @@ export default function AlertReceiversPage() { return ( <> - }>Alert Receivers + {/* webhooks are the only kind of alert receiver for now, so the page + says webhook everywhere. the section is still called Alerts */} + }>Webhooks New webhook diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index fced7898fd..fe07e2327f 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -67,13 +67,13 @@ exports[`breadcrumbs 2`] = ` "alertReceivers (/system/alerts)": [ { "label": "Alerts", - "path": "/system/", + "path": "/system/alerts", }, ], "alertReceiversNew (/system/alerts-new)": [ { "label": "Alerts", - "path": "/system/", + "path": "/system/alerts", }, ], "antiAffinityGroup (/projects/p/affinity/aag)": [ diff --git a/mock-api/alert.ts b/mock-api/alert.ts index 32fc297064..8b6206bf8f 100644 --- a/mock-api/alert.ts +++ b/mock-api/alert.ts @@ -40,13 +40,14 @@ export const receiverWebhook1: Json = { 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: new Date().toISOString(), + time_created: '2024-03-01T00:00:00Z', }, { id: 'b15f4584-98f1-4cac-b0d3-67294e41aab7', - time_created: new Date().toISOString(), + time_created: '2024-06-01T00:00:00Z', }, ], }, diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index a1ed07c1c8..9311baab14 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -19,7 +19,7 @@ import { test('Alert receivers list', async ({ page }) => { await page.goto('/system/alerts') await expect(page).toHaveTitle('Alerts / Oxide Console') - await expect(page.getByRole('heading', { name: 'Alert Receivers' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Webhooks' })).toBeVisible() const table = page.getByRole('table') await expect(table.getByRole('row')).toHaveCount(4) // header + 3 receivers @@ -112,6 +112,10 @@ test('Webhook detail: properties, event classes, secrets', async ({ page }) => { 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' }) @@ -119,6 +123,8 @@ test('Webhook detail: properties, event classes, secrets', async ({ page }) => { 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') @@ -314,25 +320,19 @@ test('Webhook deliveries', async ({ page }) => { await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeDisabled() await page.keyboard.press('Escape') - // send a liveness probe from the testing tab, resending failed deliveries on - // success + // 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('checkbox', { name: 'Resend failed deliveries if the probe succeeds' }) - .click() await probeModal.getByRole('button', { name: 'Send probe' }).click() - await expect(page.getByText('2 failed deliveries resent')).toBeVisible() + 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 + 1 probe + 2 resends of the 2 failed deliveries - await expect(table.getByRole('row')).toHaveCount(11) - await expectRowVisible(table, { - 'Event class': 'hardware.power_shelf.psu.remove', - state: 'pending', - trigger: 'resend', - }) + // 8 rows + the probe. no resends: the probe modal doesn't offer them + await expect(table.getByRole('row')).toHaveCount(9) }) test('Webhook delete', async ({ page }) => { From a262089fdbfad53a0e6f1086a72aff10b8865e3f Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Thu, 13 Aug 2026 12:24:04 +0100 Subject: [PATCH 06/29] Refinement and more accurate mock data --- .../form/fields/SubscriptionsField.tsx | 216 +++++++++++------- app/forms/webhook-create.tsx | 4 +- mock-api/alert.ts | 43 ++-- test/e2e/alerts.e2e.ts | 76 +++--- 4 files changed, 208 insertions(+), 131 deletions(-) diff --git a/app/components/form/fields/SubscriptionsField.tsx b/app/components/form/fields/SubscriptionsField.tsx index 64473b5bd3..6f74063fc4 100644 --- a/app/components/form/fields/SubscriptionsField.tsx +++ b/app/components/form/fields/SubscriptionsField.tsx @@ -9,6 +9,8 @@ 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' @@ -18,6 +20,7 @@ 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' @@ -36,32 +39,13 @@ function SubscriptionChip({ onRemove, }: { value: string - /** Number of event classes a glob matches; undefined while classes load */ + /** Glob chips only: matched class count for the tooltip; undefined while loading */ matchCount?: number armed: boolean onRemove: () => void }) { - const glob = isGlobPattern(value) - const chip = ( - - {value} - - - ) - return glob ? ( + return ( + // Tooltip renders just the chip when content is undefined (exact chips, loading) - {chip} + + {value} + + - ) : ( - chip ) } @@ -80,11 +78,11 @@ 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)} - +
) } @@ -96,6 +94,16 @@ type RowState = | { 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, }: { @@ -141,34 +149,45 @@ export function SubscriptionsField({ const classes = data?.items ?? [] const committed = field.value - const globRegexes = committed - .filter(isGlobPattern) - .map((g) => [g, subscriptionRegex(g)] as const) - const exacts = new Set(committed.filter((s) => !isGlobPattern(s))) + 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 (all `*` promoted to `**`), used to keep - // near-miss rows visible with a hint about the pattern that would cover them + // 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.replaceAll('*', '**').replaceAll('****', '**') + ? queryTrimmed + .split('.') + .map((seg) => (seg.includes('*') ? '**' : seg)) + .join('.') : null const promotedRegex = promotedGlob ? subscriptionRegex(promotedGlob) : null - const visible = - queryTrimmed === '' - ? classes - : promotedRegex - ? classes.filter((c) => promotedRegex.test(c.name)) - : classes.filter((c) => c.name.toLowerCase().includes(queryTrimmed.toLowerCase())) + // 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 = globRegexes.find(([, re]) => re.test(name))?.[0] + const via = matchers.globs.find(([, re]) => re.test(name))?.[0] if (via) return { kind: 'covered', via } - if (exacts.has(name)) return { kind: 'picked' } + if (matchers.exacts.has(name)) return { kind: 'picked' } if (queryRegex?.test(name)) return { kind: 'pending' } if (promotedGlob && promotedGlob !== queryTrimmed) { return { kind: 'promoted', via: promotedGlob } @@ -176,7 +195,23 @@ export function SubscriptionsField({ return { kind: 'plain' } } - const rows = visible.map((c) => ({ ...c, state: rowState(c.name) })) + // 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])) @@ -211,19 +246,22 @@ export function SubscriptionsField({ } function moveActive(dir: 1 | -1) { - if (selectableIdxs.length === 0) return - const pos = activeIdx === null ? -1 : selectableIdxs.indexOf(activeIdx) - const nextPos = - pos === -1 - ? dir === 1 - ? 0 - : selectableIdxs.length - 1 - : (pos + dir + selectableIdxs.length) % selectableIdxs.length - const next = selectableIdxs[nextPos] + 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 @@ -258,12 +296,10 @@ export function SubscriptionsField({ // keep focus but close the panel; stop the event so the page/form // doesn't also react to Escape e.stopPropagation() - setOpen(false) - setArmedIdx(null) - setActiveIdx(null) + closePanel() } else if (e.key === KEYS.down) { e.preventDefault() - if (!open) setOpen(true) + openPanel() setArmedIdx(null) moveActive(1) } else if (e.key === KEYS.up) { @@ -284,9 +320,9 @@ export function SubscriptionsField({ className="relative" onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) { - setOpen(false) - setArmedIdx(null) - setActiveIdx(null) + closePanel() + // discard uncommitted text so it doesn't read as added + setQuery('') setCommitError(undefined) } }} @@ -308,11 +344,7 @@ export function SubscriptionsField({ subscriptionRegex(value).test(c.name)).length - : undefined - } + matchCount={chipMatchCounts.get(value)} armed={armedIdx === i} onRemove={() => removeChip(value)} /> @@ -336,9 +368,9 @@ export function SubscriptionsField({ setArmedIdx(null) setCommitError(undefined) setActiveIdx(null) - setOpen(true) + openPanel() }} - onFocus={() => setOpen(true)} + onFocus={openPanel} onKeyDown={onKeyDown} /> @@ -386,6 +418,24 @@ export function SubscriptionsField({ 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
- - {queryTrimmed && !queryRegex ? ( - - ) : ( - row.name - )} + + + ) : ( + row.name + ) + } + > + {row.description} + - {state.kind === 'covered' && ( - via {state.via} - )} - {state.kind === 'pending' && ( - - {queryTrimmed} + {label && ( + // mt-1 optically centers the 1rem mono label on the + // 1.5rem name line + + {label.text} )} - {state.kind === 'promoted' && ( - {state.via} - )}
) }) diff --git a/app/forms/webhook-create.tsx b/app/forms/webhook-create.tsx index 805da7f685..c401f22e63 100644 --- a/app/forms/webhook-create.tsx +++ b/app/forms/webhook-create.tsx @@ -123,8 +123,8 @@ 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. instance.* or{' '} - *.delete.{' '} + events. E.g. hardware.** or{' '} + **.fault.{' '} [] = [ 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, based on examples in RFD 538. They are not - // yet defined in Omicron's alert.rs; they exist to exercise the catalog UI. - { name: 'instance.create', description: 'An instance has been created' }, - { name: 'instance.start', description: 'An instance has been started' }, - { name: 'instance.stop', description: 'An instance has been stopped' }, - { name: 'instance.delete', description: 'An instance has been deleted' }, - { name: 'instance.reboot', description: 'An instance has been rebooted' }, - { name: 'instance.fail', description: 'An instance has entered a failed state' }, + // 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: 'instance.ephemeral_ip.attach', - description: 'An ephemeral IP has been attached to an instance', + name: 'hardware.disk.insert', + description: 'A physical disk has been inserted into a sled', }, { - name: 'instance.ephemeral_ip.detach', - description: 'An ephemeral IP has been detached from an instance', + name: 'hardware.disk.remove', + description: 'A physical disk has been removed from a sled', }, - { name: 'project.create', description: 'A project has been created' }, - { name: 'project.update', description: 'A project has been updated' }, - { name: 'project.delete', description: 'A project has been deleted' }, - { name: 'image.delete', description: 'An image has been deleted' }, - { name: 'image.promote', description: 'An image has been promoted to a silo image' }, - { name: 'image.demote', description: 'An image has been demoted to a project image' }, + { 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/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 307ce5c452..4fcad20286 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -100,50 +100,54 @@ test('Webhook create subscriptions field', async ({ page }) => { 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(17) + await expect(listbox.getByRole('option')).toHaveCount(15) // a glob query filters the catalog and labels matched rows with the pattern - await subsInput.fill('instance.*') - await expect(listbox.getByText('Matching “instance.*”')).toBeVisible() - // 6 direct children match instance.*; the two ephemeral_ip classes are shown - // as near misses labeled with the broader pattern that would cover them - await expect(listbox.getByText('Showing 8 of 17')).toBeVisible() - const pendingRow = listbox.getByRole('option', { name: 'instance.create' }) - await expect(pendingRow.getByText('instance.*', { exact: true })).toBeVisible() - const nearMissRow = listbox.getByRole('option', { name: 'instance.ephemeral_ip.attach' }) - await expect(nearMissRow.getByText('instance.**', { exact: true })).toBeVisible() + 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('instance.*')).toBeVisible() + 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('instance') - const coveredRow = listbox.getByRole('option', { name: 'instance.create' }) - await expect(coveredRow.getByText('via instance.*')).toBeVisible() + 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('instance.create')).toBeHidden() + await expect(chipRemove('hardware.disk.fault')).toBeHidden() // plain-text filter + ticking rows commits exact classes without resetting the query - await subsInput.fill('proj') - await expect(listbox.getByText('Showing 3 of 17')).toBeVisible() - await listbox.getByRole('option', { name: 'project.create' }).click() - await listbox.getByRole('option', { name: 'project.delete' }).click() - await expect(chipRemove('project.create')).toBeVisible() - await expect(chipRemove('project.delete')).toBeVisible() - await expect(subsInput).toHaveValue('proj') + 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 listbox.getByRole('option', { name: 'project.create' }).click() - await expect(chipRemove('project.create')).toBeHidden() + 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') @@ -151,28 +155,42 @@ test('Webhook create subscriptions field', async ({ page }) => { 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('project.delete')).toBeVisible() + await expect(chipRemove('system.update.complete')).toBeVisible() await subsInput.press('Backspace') - await expect(chipRemove('project.delete')).toBeHidden() + 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('instance.*')).toBeVisible() + 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 instance.* + await subsInput.press('ArrowLeft') // arm hardware.*.fault await subsInput.press('Backspace') - await expect(chipRemove('instance.*')).toBeHidden() + 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 }) => { From d68c2e5932c167c38ecdd356a2193a515b827b37 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 13 Aug 2026 11:21:21 -0400 Subject: [PATCH 07/29] adjustments to filtering with pagination --- app/forms/webhook-edit.tsx | 4 +- app/hooks/use-pagination.spec.ts | 15 ++ app/hooks/use-pagination.ts | 19 ++- app/pages/system/alerts/AlertReceiverPage.tsx | 148 +++++++----------- app/table/QueryTable.tsx | 7 +- .../__snapshots__/path-builder.spec.ts.snap | 4 - 6 files changed, 95 insertions(+), 102 deletions(-) diff --git a/app/forms/webhook-edit.tsx b/app/forms/webhook-edit.tsx index 52ff429903..769b409354 100644 --- a/app/forms/webhook-edit.tsx +++ b/app/forms/webhook-edit.tsx @@ -15,7 +15,7 @@ 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 { makeCrumb } from '~/hooks/use-crumbs' +import { titleCrumb } from '~/hooks/use-crumbs' import { getAlertReceiverSelector, useAlertReceiverSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -32,7 +32,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { return null } -export const handle = makeCrumb('Edit webhook') +export const handle = titleCrumb('Edit webhook') export default function EditWebhookSideModalForm() { const navigate = useNavigate() diff --git a/app/hooks/use-pagination.spec.ts b/app/hooks/use-pagination.spec.ts index 62e12865e0..c0d60eff72 100644 --- a/app/hooks/use-pagination.spec.ts +++ b/app/hooks/use-pagination.spec.ts @@ -43,6 +43,21 @@ describe('usePagination', () => { expect(result.current.hasPrev).toBeFalsy() }) + it('resets to the first page when the query changes', () => { + const { result, rerender } = renderHook(({ queryId }) => usePagination(queryId), { + initialProps: { queryId: 'a' }, + }) + + act(() => result.current.goToNextPage('page2')) + expect(result.current.currentPage).toEqual('page2') + expect(result.current.hasPrev).toBeTruthy() + + rerender({ queryId: 'b' }) + + expect(result.current.currentPage).toBeUndefined() + expect(result.current.hasPrev).toBeFalsy() + }) + it('remembers previous pages', () => { const { result } = renderHook(() => usePagination()) diff --git a/app/hooks/use-pagination.ts b/app/hooks/use-pagination.ts index f1749e5029..48d365c578 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/pages/system/alerts/AlertReceiverPage.tsx b/app/pages/system/alerts/AlertReceiverPage.tsx index c0eb05bd1a..be70fb42b5 100644 --- a/app/pages/system/alerts/AlertReceiverPage.tsx +++ b/app/pages/system/alerts/AlertReceiverPage.tsx @@ -37,6 +37,7 @@ import { Badge, Button, type BadgeColor } from '@oxide/design-system/ui' import { ComboboxField } from '~/components/form/fields/ComboboxField' 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' @@ -427,7 +428,8 @@ const toClassComboboxItem = ({ function AddSubscriptionModal({ onDismiss }: { onDismiss: () => void }) { const receiverSelector = useAlertReceiverSelector() const { data: receiver } = usePrefetchedQuery(receiverView(receiverSelector)) - const { control, handleSubmit } = useForm({ defaultValues: { subscription: '' } }) + const form = useForm({ defaultValues: { subscription: '' } }) + const { control } = form const subscription = useWatch({ control, name: 'subscription' }) const classes = useQuery(q(api.alertClassList, {})) @@ -443,64 +445,43 @@ function AddSubscriptionModal({ onDismiss }: { onDismiss: () => void }) { addToast(<>Subscribed to {result.subscription}) onDismiss() }, - onError(err) { - addToast({ - title: 'Could not add subscription', - content: err.message, - variant: 'error', - }) - }, - }) - - const onSubmit = handleSubmit(({ subscription }) => { - if (!subscription) return // can't happen, subscription is required - addSubscription.mutate({ path: receiverSelector, body: { subscription } }) }) return ( - - - -
{ - e.stopPropagation() - onSubmit(e) - }} - className="space-y-4" - > - - Event subscriptions may include simple globs to subscribe to multiple - categories of events, like hardware.** or{' '} - **.remove. - - } - /> - - - -
-
- + addSubscription.mutate({ path: receiverSelector, body: { subscription } }) + } + loading={addSubscription.isPending} + submitError={addSubscription.error} + > + + Event subscriptions may include simple globs to subscribe to multiple categories + of events, like hardware.** or{' '} + **.remove. + + } /> -
+ + + ) } @@ -585,7 +566,7 @@ function SecretsCard() { function AddSecretModal({ onDismiss }: { onDismiss: () => void }) { const { receiver } = useAlertReceiverSelector() - const { control, handleSubmit } = useForm({ defaultValues: { secret: '' } }) + const form = useForm({ defaultValues: { secret: '' } }) const addSecret = useApiMutation(api.webhookSecretsAdd, { onSuccess() { @@ -593,46 +574,27 @@ function AddSecretModal({ onDismiss }: { onDismiss: () => void }) { addToast('Secret added') onDismiss() }, - onError(err) { - addToast({ title: 'Could not add secret', content: err.message, variant: 'error' }) - }, - }) - - const onSubmit = handleSubmit(({ secret }) => { - if (!secret) return // can't happen, secret is required - addSecret.mutate({ query: { receiver }, body: { secret } }) }) return ( - - - -
{ - e.stopPropagation() - onSubmit(e) - }} - className="space-y-4" - > - - -
-
- addSecret.mutate({ query: { receiver }, body: { secret } })} + loading={addSecret.isPending} + submitError={addSecret.error} + > + -
+ ) } @@ -665,7 +627,7 @@ const staticDeliveryCols = [ deliveryColHelper.accessor('state', { cell: (info) => , }), - deliveryColHelper.accessor('timeStarted', { ...Columns.timeCreated, header: 'started' }), + deliveryColHelper.accessor('timeStarted', { ...Columns.timeCreated, header: 'Started' }), deliveryColHelper.accessor('trigger', { cell: (info) => {info.getValue()}, }), diff --git a/app/table/QueryTable.tsx b/app/table/QueryTable.tsx index fdaef9786d..8883d4e292 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/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index fe07e2327f..295fc605f0 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -59,10 +59,6 @@ exports[`breadcrumbs 2`] = ` "label": "rc", "path": "/system/alerts/rc", }, - { - "label": "Edit webhook", - "path": "/system/alerts/rc/edit", - }, ], "alertReceivers (/system/alerts)": [ { From 01bbbf04a1dc0e40dfae07a14dfa5bce9951c284 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 14 Aug 2026 14:03:36 +0100 Subject: [PATCH 08/29] Test fix --- app/pages/system/alerts/AlertReceiversPage.tsx | 5 ----- app/util/__snapshots__/path-builder.spec.ts.snap | 4 ---- 2 files changed, 9 deletions(-) diff --git a/app/pages/system/alerts/AlertReceiversPage.tsx b/app/pages/system/alerts/AlertReceiversPage.tsx index d174a3e54e..d988ed23f4 100644 --- a/app/pages/system/alerts/AlertReceiversPage.tsx +++ b/app/pages/system/alerts/AlertReceiversPage.tsx @@ -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() diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index acbc6aab98..a426e24aa9 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -65,10 +65,6 @@ exports[`breadcrumbs 2`] = ` "label": "Alerts", "path": "/system/alerts", }, - { - "label": "Alerts", - "path": "/system/alerts", - }, ], "alertReceiversNew (/system/alerts-new)": [ { From 7bf1bedf68186ab6e3166d566d587f91dd4f63ff Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 05:21:49 -0400 Subject: [PATCH 09/29] nav changes, tabs, column updates --- app/layouts/SystemLayout.tsx | 9 ++- .../AlertReceiverPage.tsx | 7 +- .../AlertReceiversTab.tsx} | 12 +-- app/pages/system/alerting/AlertingPage.tsx | 31 ++++++++ app/pages/system/alerting/AlertsTab.tsx | 26 +++++++ app/routes.tsx | 35 ++++++--- app/table/columns/common.tsx | 13 ++++ .../__snapshots__/path-builder.spec.ts.snap | 52 +++++++++---- app/util/path-builder.spec.ts | 9 ++- app/util/path-builder.ts | 6 +- test/e2e/alerts.e2e.ts | 73 +++++++++++++++---- test/e2e/authz.e2e.ts | 2 +- 12 files changed, 217 insertions(+), 58 deletions(-) rename app/pages/system/{alerts => alerting}/AlertReceiverPage.tsx (98%) rename app/pages/system/{alerts/AlertReceiversPage.tsx => alerting/AlertReceiversTab.tsx} (91%) create mode 100644 app/pages/system/alerting/AlertingPage.tsx create mode 100644 app/pages/system/alerting/AlertsTab.tsx diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index f7a4fc01a0..4ef8936d99 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -25,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' @@ -56,7 +56,8 @@ export default function SystemLayout() { { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, { value: 'Subnet Pools', path: pb.subnetPools() }, - { value: 'Alerts', path: pb.alertReceivers() }, + { value: 'Alerting', path: pb.alerts() }, + { value: 'Alert Receivers', path: pb.alertReceivers() }, { value: 'System Update', path: pb.systemUpdate() }, { value: 'Fleet Access', path: pb.fleetAccess() }, ] @@ -103,8 +104,8 @@ export default function SystemLayout() { Subnet Pools - - Alerts + + Alerting System Update diff --git a/app/pages/system/alerts/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx similarity index 98% rename from app/pages/system/alerts/AlertReceiverPage.tsx rename to app/pages/system/alerting/AlertReceiverPage.tsx index be70fb42b5..7b8793f842 100644 --- a/app/pages/system/alerts/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -619,7 +619,9 @@ const stateFilterItems: { value: StateFilter; label: string }[] = [ const deliveryColHelper = createColumnHelper() const staticDeliveryCols = [ - deliveryColHelper.accessor('id', Columns.id), + // shortId for these two to force truncation + deliveryColHelper.accessor('id', { ...Columns.shortId, header: 'Delivery ID' }), + deliveryColHelper.accessor('alertId', { ...Columns.shortId, header: 'Event ID' }), deliveryColHelper.accessor('alertClass', { header: 'Event class', cell: (info) => {info.getValue()}, @@ -793,7 +795,8 @@ function DeliverySideModal({ {delivery.alertClass} - + + diff --git a/app/pages/system/alerts/AlertReceiversPage.tsx b/app/pages/system/alerting/AlertReceiversTab.tsx similarity index 91% rename from app/pages/system/alerts/AlertReceiversPage.tsx rename to app/pages/system/alerting/AlertReceiversTab.tsx index 38cada3886..a08bde3eef 100644 --- a/app/pages/system/alerts/AlertReceiversPage.tsx +++ b/app/pages/system/alerting/AlertReceiversTab.tsx @@ -34,7 +34,6 @@ import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions } from '~/ui/lib/Table' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' @@ -80,9 +79,9 @@ export async function clientLoader() { // 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 const handle = makeCrumb('Receivers', pb.alertReceivers()) -export default function AlertReceiversPage() { +export default function AlertReceiversTab() { const navigate = useNavigate() const { mutateAsync: deleteReceiver } = useApiMutation(api.alertReceiverDelete, { @@ -149,11 +148,8 @@ export default function AlertReceiversPage() { return ( <> - - {/* webhooks are the only kind of alert receiver for now, so the page - says webhook everywhere. the section is still called Alerts */} - }>Webhooks - + {/* webhooks are the only kind of alert receiver for now, so the tab says + webhook everywhere while the tab itself is called Receivers */} New webhook diff --git a/app/pages/system/alerting/AlertingPage.tsx b/app/pages/system/alerting/AlertingPage.tsx new file mode 100644 index 0000000000..c8f557faa8 --- /dev/null +++ b/app/pages/system/alerting/AlertingPage.tsx @@ -0,0 +1,31 @@ +/* + * 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 { Monitoring24Icon } from '@oxide/design-system/icons/react' + +import { RouteTabs, Tab } from '~/components/RouteTabs' +import { makeCrumb } from '~/hooks/use-crumbs' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { pb } from '~/util/path-builder' + +export const handle = makeCrumb('Alerting', pb.alerts()) + +export default function AlertingPage() { + return ( + <> + + }>Alerting + + + + Alerts + Receivers + + + ) +} diff --git a/app/pages/system/alerting/AlertsTab.tsx b/app/pages/system/alerting/AlertsTab.tsx new file mode 100644 index 0000000000..570e893601 --- /dev/null +++ b/app/pages/system/alerting/AlertsTab.tsx @@ -0,0 +1,26 @@ +/* + * 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 { Monitoring24Icon } from '@oxide/design-system/icons/react' + +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { TableEmptyBox } from '~/ui/lib/Table' + +export const handle = { crumb: 'Alerts' } + +export default function AlertsTab() { + return ( + + } + title="No alerts" + body="Alerts fired by the system will appear here" + /> + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 4fb8598c48..613a31aeaa 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -266,20 +266,37 @@ export const routes = createRoutesFromElements( import('./pages/system/alerts/AlertReceiversPage').then(convert)} + path="alerting" + lazy={() => import('./pages/system/alerting/AlertingPage').then(convert)} > - + } /> import('./forms/webhook-create').then(convert)} + path="alerts" + lazy={() => import('./pages/system/alerting/AlertsTab').then(convert)} /> - - import('./pages/system/alerts/AlertReceiverPage').then(convert)} + lazy={() => import('./pages/system/alerting/AlertReceiversTab').then(convert)} > - import('./forms/webhook-edit').then(convert)} /> + + import('./forms/webhook-create').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)} + /> + from RT, but in these @@ -33,6 +34,12 @@ function idCell(info: Info) { ) } +// 12 works out to 5 characters on either side of the ellipsis, enough to tell +// UUIDs apart at a glance without the 36-character column a full one demands +function shortIdCell(info: Info) { + return +} + function instanceStateCell(info: Info) { return } @@ -44,6 +51,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 295fc605f0..e152e4b238 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -40,36 +40,62 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/", }, ], - "alertReceiver (/system/alerts/rc)": [ + "alertReceiver (/system/alerting/receivers/rc)": [ { - "label": "Alerts", - "path": "/system/alerts", + "label": "Alerting", + "path": "/system/alerting/alerts", + }, + { + "label": "Receivers", + "path": "/system/alerting/receivers", }, { "label": "rc", - "path": "/system/alerts/rc", + "path": "/system/alerting/receivers/rc", }, ], - "alertReceiverEdit (/system/alerts/rc/edit)": [ + "alertReceiverEdit (/system/alerting/receivers/rc/edit)": [ { - "label": "Alerts", - "path": "/system/alerts", + "label": "Alerting", + "path": "/system/alerting/alerts", + }, + { + "label": "Receivers", + "path": "/system/alerting/receivers", }, { "label": "rc", - "path": "/system/alerts/rc", + "path": "/system/alerting/receivers/rc", }, ], - "alertReceivers (/system/alerts)": [ + "alertReceivers (/system/alerting/receivers)": [ { - "label": "Alerts", - "path": "/system/alerts", + "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", }, ], - "alertReceiversNew (/system/alerts-new)": [ + "alerts (/system/alerting/alerts)": [ + { + "label": "Alerting", + "path": "/system/alerting/alerts", + }, { "label": "Alerts", - "path": "/system/alerts", + "path": "/system/alerting/alerts", }, ], "antiAffinityGroup (/projects/p/affinity/aag)": [ diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index b478cbc1af..4aa31a0c41 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -48,10 +48,11 @@ test('path builder', () => { "accessTokens": "/settings/access-tokens", "affinity": "/projects/p/affinity", "affinityNew": "/projects/p/affinity-new", - "alertReceiver": "/system/alerts/rc", - "alertReceiverEdit": "/system/alerts/rc/edit", - "alertReceivers": "/system/alerts", - "alertReceiversNew": "/system/alerts-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 2878dc7456..eafd785aa6 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,8 +130,9 @@ export const pb = { subnetPoolEdit: (params: PP.SubnetPool) => `${pb.subnetPool(params)}/edit`, subnetPoolMemberAdd: (params: PP.SubnetPool) => `${pb.subnetPool(params)}/members-add`, - alertReceivers: () => '/system/alerts', - alertReceiversNew: () => '/system/alerts-new', + 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`, diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 9311baab14..8bb0952eb6 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -16,10 +16,33 @@ import { 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/alerts') - await expect(page).toHaveTitle('Alerts / Oxide Console') - await expect(page.getByRole('heading', { name: 'Webhooks' })).toBeVisible() + 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 @@ -34,10 +57,10 @@ test('Alert receivers list', async ({ page }) => { }) test('Webhook create', async ({ page }) => { - await page.goto('/system/alerts') + await page.goto('/system/alerting/receivers') await page.getByRole('link', { name: 'New webhook' }).click() - await expect(page).toHaveURL('/system/alerts-new') + await expect(page).toHaveURL('/system/alerting/receivers-new') const modal = page.getByRole('dialog', { name: 'Create webhook' }) await modal.getByRole('textbox', { name: 'Name' }).fill('deploy-hook') @@ -81,9 +104,9 @@ test('Webhook create', async ({ page }) => { }) test('Webhook detail: properties, event classes, secrets', async ({ page }) => { - await page.goto('/system/alerts') + await page.goto('/system/alerting/receivers') await page.getByRole('link', { name: 'webhook-1' }).click() - await expect(page).toHaveURL('/system/alerts/webhook-1') + 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() @@ -144,7 +167,7 @@ test('Webhook detail: properties, event classes, secrets', async ({ page }) => { }) test('Testing tab: probe result and signature format', async ({ page }) => { - await page.goto('/system/alerts/webhook-1') + await page.goto('/system/alerting/receivers/webhook-1') await page.getByRole('tab', { name: 'Testing' }).click() const panel = page.getByRole('tabpanel') @@ -166,7 +189,7 @@ test('Testing tab: probe result and signature format', async ({ page }) => { }) test('Testing tab: probe failure', async ({ page }) => { - await page.goto('/system/alerts') + await page.goto('/system/alerting/receivers') // the mock backend fails probes for endpoints containing 'unreachable' await clickRowAction(page, 'power-mon', 'Edit') @@ -189,7 +212,7 @@ test('Testing tab: probe failure', async ({ page }) => { }) test('Webhook edit', async ({ page }) => { - await page.goto('/system/alerts') + await page.goto('/system/alerting/receivers') await clickRowAction(page, 'general-sys-webhook', 'Edit') const modal = page.getByRole('dialog', { name: 'Edit webhook' }) @@ -204,7 +227,7 @@ test('Webhook edit', async ({ page }) => { await expectToast(page, 'Webhook general-webhook updated') // lands on the detail page for the new name - await expect(page).toHaveURL('/system/alerts/general-webhook') + await expect(page).toHaveURL('/system/alerting/receivers/general-webhook') await expect(page.getByText('https://hooks.example.dev')).toBeVisible() }) @@ -217,7 +240,7 @@ const refreshUntil = (page: Page, expectation: () => Promise) => }).toPass({ timeout: 30_000 }) test('Pending delivery resolves to delivered', async ({ page }) => { - await page.goto('/system/alerts/webhook-1?tab=deliveries') + await page.goto('/system/alerting/receivers/webhook-1?tab=deliveries') const row = page.getByRole('row', { name: /a3d830ee/ }) await expect(row.getByText('pending')).toBeVisible() @@ -233,7 +256,7 @@ test('Pending delivery resolves to delivered', async ({ page }) => { }) test('Pending delivery fails after exhausting retries', async ({ page }) => { - await page.goto('/system/alerts') + await page.goto('/system/alerting/receivers') // the mock backend fails delivery to endpoints containing 'unreachable' await clickRowAction(page, 'webhook-1', 'Edit') @@ -255,22 +278,31 @@ test('Pending delivery fails after exhausting retries', async ({ page }) => { }) test('Webhook deliveries', async ({ page }) => { - await page.goto('/system/alerts/webhook-1') + 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 + // IDs are middle-truncated, with the full value in the tooltip await expectRowVisible(table, { + 'Delivery ID': '9bbdf…693ee', + 'Event ID': '391a8…311f5', 'Event class': 'probe', state: 'delivered', trigger: 'probe', }) await expectRowVisible(table, { + 'Delivery ID': '30ece…a685e', + 'Event ID': 'beef3…8421a', 'Event 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') @@ -281,6 +313,17 @@ test('Webhook deliveries', async ({ page }) => { // 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('Event ID') + await expect(props.getByLabel('beef336d-99db-4b12-ac08-7ebcaab8421a')).toBeVisible() + await expect(props).toContainText('Webhook 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() @@ -336,7 +379,7 @@ test('Webhook deliveries', async ({ page }) => { }) test('Webhook delete', async ({ page }) => { - await page.goto('/system/alerts') + await page.goto('/system/alerting/receivers') await clickRowAction(page, 'power-mon', 'Delete') await page.getByRole('button', { name: 'Confirm' }).click() diff --git a/test/e2e/authz.e2e.ts b/test/e2e/authz.e2e.ts index 1c4e2b5c64..d211504e2f 100644 --- a/test/e2e/authz.e2e.ts +++ b/test/e2e/authz.e2e.ts @@ -55,6 +55,6 @@ 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/alerts') + await page.goto('/system/alerting/receivers') await expect(page.getByText('Page not found')).toBeVisible() }) From a178ae265430a1a17bf088fa1cf64a240ccc260b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 18 Aug 2026 10:23:01 -0400 Subject: [PATCH 10/29] Update app/forms/webhook-create.tsx Co-authored-by: Eliza Weisman --- app/forms/webhook-create.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/forms/webhook-create.tsx b/app/forms/webhook-create.tsx index c401f22e63..e30466ba57 100644 --- a/app/forms/webhook-create.tsx +++ b/app/forms/webhook-create.tsx @@ -122,8 +122,8 @@ 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{' '} + Alert subscriptions may include simple globs to subscribe to multiple classes of + alerts. E.g. hardware.** or{' '} **.fault.{' '}
Date: Thu, 27 Aug 2026 10:38:01 +0200 Subject: [PATCH 11/29] new receiver form should be on its own page --- app/pages/system/alerting/AlertReceiversTab.tsx | 3 +-- app/routes.tsx | 12 ++++++++---- mock-api/alert.ts | 4 +++- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/pages/system/alerting/AlertReceiversTab.tsx b/app/pages/system/alerting/AlertReceiversTab.tsx index a08bde3eef..e684378c4a 100644 --- a/app/pages/system/alerting/AlertReceiversTab.tsx +++ b/app/pages/system/alerting/AlertReceiversTab.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, @@ -154,7 +154,6 @@ export default function AlertReceiversTab() { New webhook
{table} - ) } diff --git a/app/routes.tsx b/app/routes.tsx index 8bf8b5f5f3..ef4122f7a8 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -278,10 +278,6 @@ export const routes = createRoutesFromElements( lazy={() => import('./pages/system/alerting/AlertReceiversTab').then(convert)} > - import('./forms/webhook-create').then(convert)} - /> {/* /system/alerting redirects to the alerts tab, so point the crumb @@ -298,6 +294,14 @@ export const routes = createRoutesFromElements( /> + {/* 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)} + /> + = { ...getTimestamps(), } -export const alertReceivers = [receiverWebhook1, receiverPowerMon, receiverGeneral] +// 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() From 416a074809b33b21bc6ce127ddcefa16e5cc6823 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 27 Aug 2026 11:00:21 +0200 Subject: [PATCH 12/29] filter probe from list of subscription classes; update docs links --- app/api/util.spec.ts | 3 +- app/api/util.ts | 12 ++++++ app/components/SubscriptionMatchPreview.tsx | 11 ++++-- .../form/fields/SubscriptionsField.tsx | 22 ++++++++--- app/forms/webhook-create.tsx | 13 ++++++- .../system/alerting/AlertReceiverPage.tsx | 2 + app/util/links.ts | 4 +- test/e2e/alerts.e2e.ts | 37 ++++++++++++------- 8 files changed, 74 insertions(+), 30 deletions(-) diff --git a/app/api/util.spec.ts b/app/api/util.spec.ts index 1fb1185ec3..90c35b880d 100644 --- a/app/api/util.spec.ts +++ b/app/api/util.spec.ts @@ -18,9 +18,8 @@ import { 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) + expect(subscriptionRegex('instance.create').test('instance.created')).toBe(false) }) it('* matches exactly one segment', () => { diff --git a/app/api/util.ts b/app/api/util.ts index b986686266..fb3767be4b 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -47,6 +47,18 @@ export const ALERT_SUBSCRIPTION_REGEX = /** 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 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. diff --git a/app/components/SubscriptionMatchPreview.tsx b/app/components/SubscriptionMatchPreview.tsx index deaf07ee08..b9aa5c1646 100644 --- a/app/components/SubscriptionMatchPreview.tsx +++ b/app/components/SubscriptionMatchPreview.tsx @@ -10,7 +10,7 @@ import { useQuery } from '@tanstack/react-query' import { api, q } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' -import { ALERT_SUBSCRIPTION_REGEX } from '~/api/util' +import { ALERT_SUBSCRIPTION_REGEX, isSubscribableClass } from '~/api/util' /** * For a glob subscription pattern, show which alert classes it currently @@ -29,7 +29,10 @@ export function SubscriptionMatchPreview({ pattern }: { pattern: string }) { if (!enabled || !data) return null - if (data.items.length === 0) { + // the probe class can't be subscribed to, so don't count it as a match + const classes = data.items.filter(isSubscribableClass) + + if (classes.length === 0) { return (

No current event classes match this pattern. It may match classes added in the @@ -40,9 +43,9 @@ export function SubscriptionMatchPreview({ pattern }: { pattern: string }) { return (

- Matches {data.items.length} event {data.items.length === 1 ? 'class' : 'classes'}:{' '} + Matches {classes.length} event {classes.length === 1 ? 'class' : 'classes'}:{' '} - {data.items.map((c) => ( + {classes.map((c) => ( {c.name} diff --git a/app/components/form/fields/SubscriptionsField.tsx b/app/components/form/fields/SubscriptionsField.tsx index 6f74063fc4..0989549725 100644 --- a/app/components/form/fields/SubscriptionsField.tsx +++ b/app/components/form/fields/SubscriptionsField.tsx @@ -15,7 +15,13 @@ 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 { + 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' @@ -27,10 +33,14 @@ 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)' +export const validateSubscription = (value: string) => { + if (!ALERT_SUBSCRIPTION_REGEX.test(value)) + return 'Must be an event 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, @@ -146,7 +156,7 @@ export function SubscriptionsField({ const [commitError, setCommitError] = useState() const { data } = useQuery(q(api.alertClassList, { query: { limit: ALL_ISH } })) - const classes = data?.items ?? [] + const classes = (data?.items ?? []).filter(isSubscribableClass) const committed = field.value const matchers = toMatchers(committed) diff --git a/app/forms/webhook-create.tsx b/app/forms/webhook-create.tsx index 24f11eef1c..fe26136551 100644 --- a/app/forms/webhook-create.tsx +++ b/app/forms/webhook-create.tsx @@ -132,8 +132,17 @@ const SubscriptionsMessage = ( className="mt-1 inline-block" > Read the Webhooks guide - {' '} - and the{' '} + + , the{' '} + + globbing overview + + , and the{' '} API docs {' '} diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx index d0c6a7cba3..e7a1d1d2f1 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -35,6 +35,7 @@ import { } from '@oxide/design-system/icons/react' import { Badge, Button, type BadgeColor } 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' @@ -434,6 +435,7 @@ function AddSubscriptionModal({ onDismiss }: { onDismiss: () => void }) { const classes = useQuery(q(api.alertClassList, {})) const classItems = (classes.data?.items || []) + .filter(isSubscribableClass) .filter((c) => !receiver.subscriptions.includes(c.name)) .map(toClassComboboxItem) diff --git a/app/util/links.ts b/app/util/links.ts index feb920a2ae..41aa84ae3a 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,8 +29,7 @@ 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', + webhooksGuide: 'https://docs.oxide.computer/guides/alerts/webhooks', webhooksApiDocs: 'https://docs.oxide.computer/api/webhook_receiver_create', } diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 0d7d047db0..46f7079a8c 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -98,6 +98,13 @@ test('Webhook create', async ({ page }) => { await expect( main.getByText('Must be an event 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( @@ -130,14 +137,14 @@ test('Webhook create subscriptions field', async ({ page }) => { // focusing opens the catalog showing all classes await subsInput.click() await expect(listbox.getByText('All classes')).toBeVisible() - await expect(listbox.getByRole('option')).toHaveCount(15) + 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 15')).toBeVisible() + 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') @@ -160,7 +167,7 @@ test('Webhook create subscriptions field', async ({ page }) => { // 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 expect(listbox.getByText('Showing 3 of 14')).toBeVisible() await option('system.update.start').click() await option('system.update.complete').click() await expect(chipRemove('system.update.start')).toBeVisible() @@ -180,7 +187,7 @@ test('Webhook create subscriptions field', async ({ page }) => { // an incomplete glob shows the full catalog, not a bogus empty state await subsInput.fill('*.') - await expect(listbox.getByRole('option')).toHaveCount(15) + await expect(listbox.getByRole('option')).toHaveCount(14) await subsInput.fill('') // backspace on an empty query arms the last chip, a second one removes it @@ -197,14 +204,14 @@ test('Webhook create subscriptions field', async ({ page }) => { 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.fill('system.update.fail') await subsInput.press('Enter') - await expect(chipRemove('probe')).toBeVisible() - await subsInput.press('ArrowLeft') // arm probe + 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('probe')).toBeVisible() + await expect(chipRemove('system.update.fail')).toBeVisible() // uncommitted text is discarded on blur so it doesn't read as added await subsInput.fill('leftover') @@ -213,7 +220,7 @@ test('Webhook create subscriptions field', async ({ page }) => { // subscribed classes sort to the top when the panel opens await subsInput.click() - await expect(listbox.getByRole('option').first()).toContainText('probe') + await expect(listbox.getByRole('option').first()).toContainText('system.update.fail') }) test('Webhook detail: properties, event classes, secrets', async ({ page }) => { @@ -232,16 +239,18 @@ test('Webhook detail: properties, event classes, secrets', async ({ page }) => { // add a subscription await page.getByRole('button', { name: 'Add event class' }).click() const addModal = page.getByRole('dialog', { name: 'Add event class' }) - await addModal.getByRole('combobox', { name: 'Subscription' }).fill('probe') - await page.getByRole('option', { name: 'probe' }).click() + 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 probe') + await expectToast(page, 'Subscribed to hardware.sensor.overtemp') await expect(eventClasses.getByRole('row')).toHaveCount(4) // remove it again - await clickRowAction(page, 'probe', 'Remove') + await clickRowAction(page, 'hardware.sensor.overtemp', 'Remove') await page.getByRole('button', { name: 'Confirm' }).click() - await expectToast(page, 'Subscription probe removed') + await expectToast(page, 'Subscription hardware.sensor.overtemp removed') await expect(eventClasses.getByRole('row')).toHaveCount(3) // secrets card From dd204ca5698748f219d3310490f7b88262bed8d1 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 27 Aug 2026 17:54:44 +0200 Subject: [PATCH 13/29] getting clever with spaces and chip creation --- app/components/form/fields/SubscriptionsField.tsx | 12 ++++++++++++ test/e2e/alerts.e2e.ts | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/app/components/form/fields/SubscriptionsField.tsx b/app/components/form/fields/SubscriptionsField.tsx index 0989549725..b572af2198 100644 --- a/app/components/form/fields/SubscriptionsField.tsx +++ b/app/components/form/fields/SubscriptionsField.tsx @@ -280,6 +280,18 @@ export function SubscriptionsField({ } 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() diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 46f7079a8c..17e53479c6 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -155,6 +155,14 @@ test('Webhook create subscriptions field', async ({ page }) => { 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') @@ -168,6 +176,11 @@ test('Webhook create subscriptions field', async ({ page }) => { // 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() From 28fad68046107426b3ca66de88528452272df348 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 00:35:41 +0200 Subject: [PATCH 14/29] Update app/pages/system/alerting/AlertReceiversTab.tsx Co-authored-by: Eliza Weisman --- app/pages/system/alerting/AlertReceiversTab.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/system/alerting/AlertReceiversTab.tsx b/app/pages/system/alerting/AlertReceiversTab.tsx index e684378c4a..e7b2e07cec 100644 --- a/app/pages/system/alerting/AlertReceiversTab.tsx +++ b/app/pages/system/alerting/AlertReceiversTab.tsx @@ -55,9 +55,9 @@ const staticColumns = [ cell: makeLinkCell((receiver) => pb.alertReceiver({ receiver })), }), colHelper.accessor('subscriptions', { - header: 'Events', + header: 'Alerts', cell: (info) => ( - + {info.getValue().map((sub) => ( {sub} From 01ca961d9bb03b9d2f1cf511ba59bfdda8f0ca80 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 00:35:59 +0200 Subject: [PATCH 15/29] Update app/pages/system/alerting/AlertReceiverPage.tsx Co-authored-by: Eliza Weisman --- app/pages/system/alerting/AlertReceiverPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx index e7a1d1d2f1..f5a21e71fe 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -327,12 +327,12 @@ function SignatureFormatCard() { ) } -// Event classes +// Alert classes const subscriptionColHelper = createColumnHelper<{ subscription: string }>() const subscriptionCols = [ subscriptionColHelper.accessor('subscription', { - header: 'Event class', + header: 'Alert class', cell: (info) => {info.getValue()}, }), ] From c97b759d01975d220db7bb1d7df327c02419f37e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 00:36:28 +0200 Subject: [PATCH 16/29] Update app/pages/system/alerting/AlertReceiverPage.tsx Co-authored-by: Eliza Weisman --- app/pages/system/alerting/AlertReceiverPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx index f5a21e71fe..ed0c04c9d9 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -390,7 +390,8 @@ function EventClassesCard() { + title="Alert subscriptions" + description="The alert classes the webhook receiver is subscribed to" From a4418d639b6c9c1a2a876f28da98bfdeac2f8a8e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 00:36:50 +0200 Subject: [PATCH 17/29] Update app/pages/system/alerting/AlertsTab.tsx Co-authored-by: Eliza Weisman --- app/pages/system/alerting/AlertsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/alerting/AlertsTab.tsx b/app/pages/system/alerting/AlertsTab.tsx index 570e893601..a57ac17657 100644 --- a/app/pages/system/alerting/AlertsTab.tsx +++ b/app/pages/system/alerting/AlertsTab.tsx @@ -19,7 +19,7 @@ export default function AlertsTab() { } title="No alerts" - body="Alerts fired by the system will appear here" + body="Alerts published by the system will appear here" /> ) From 5df3af28a797750a5a28125534d07099a4b6db23 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 00:37:05 +0200 Subject: [PATCH 18/29] Update app/pages/system/alerting/AlertReceiversTab.tsx Co-authored-by: Eliza Weisman --- app/pages/system/alerting/AlertReceiversTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/alerting/AlertReceiversTab.tsx b/app/pages/system/alerting/AlertReceiversTab.tsx index e7b2e07cec..598ced805a 100644 --- a/app/pages/system/alerting/AlertReceiversTab.tsx +++ b/app/pages/system/alerting/AlertReceiversTab.tsx @@ -88,7 +88,7 @@ export default function AlertReceiversTab() { onSuccess(_data, variables) { queryClient.invalidateEndpoint('alertReceiverList') // prettier-ignore - addToast(<>Webhook {variables.path.receiver} deleted) + addToast(<>Webhook receiver {variables.path.receiver} deleted) }, }) From 27215273c6796ccfe18cd5f6f4c57c3008f7b3e9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 00:37:19 +0200 Subject: [PATCH 19/29] Update app/pages/system/alerting/AlertReceiverPage.tsx Co-authored-by: Eliza Weisman --- app/pages/system/alerting/AlertReceiverPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx index ed0c04c9d9..33245c4475 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -799,7 +799,7 @@ function DeliverySideModal({ {delivery.alertClass} - + From 1d11577d625474898ac351b44fe0bb35901cdf2a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 00:37:33 +0200 Subject: [PATCH 20/29] Update app/pages/system/alerting/AlertReceiverPage.tsx Co-authored-by: Eliza Weisman --- app/pages/system/alerting/AlertReceiverPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx index 33245c4475..7a047ab0eb 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -393,7 +393,7 @@ function EventClassesCard() { title="Alert subscriptions" description="The alert classes the webhook receiver is subscribed to" From e6fcabfd84909fa9d572cc9755e31f5b85f1b151 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 00:37:56 +0200 Subject: [PATCH 21/29] Update app/pages/system/alerting/AlertReceiverPage.tsx Co-authored-by: Eliza Weisman --- app/pages/system/alerting/AlertReceiverPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx index 7a047ab0eb..16ae8c2801 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -795,7 +795,7 @@ function DeliverySideModal({ - + {delivery.alertClass} From 8ca246309c65c5041099b7a994cdac04f57eaed0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 00:38:18 +0200 Subject: [PATCH 22/29] Update app/pages/system/alerting/AlertReceiverPage.tsx Co-authored-by: Eliza Weisman --- app/pages/system/alerting/AlertReceiverPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx index 16ae8c2801..236d9355f1 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -398,13 +398,13 @@ function EventClassesCard() { {rows.length ? ( -

+
) : ( } title="No subscriptions" - body="Subscribe to an event class to receive events" + body="Subscribe to an alert class to receive alerts" /> )} From 3b7f2d25de7ce602da259bd813a90edda0edbe29 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 00:34:41 +0200 Subject: [PATCH 23/29] refactoring --- app/components/SubscriptionMatchPreview.tsx | 3 ++- .../system/alerting/AlertReceiverPage.tsx | 17 ++++++++++---- mock-api/alert.ts | 3 ++- mock-api/msw/handlers.ts | 10 ++++++++ test/e2e/alerts.e2e.ts | 23 +++++++++++++++++++ 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/app/components/SubscriptionMatchPreview.tsx b/app/components/SubscriptionMatchPreview.tsx index b9aa5c1646..f81430c1a0 100644 --- a/app/components/SubscriptionMatchPreview.tsx +++ b/app/components/SubscriptionMatchPreview.tsx @@ -11,6 +11,7 @@ import { api, q } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' import { ALERT_SUBSCRIPTION_REGEX, isSubscribableClass } from '~/api/util' +import { ALL_ISH } from '~/util/consts' /** * For a glob subscription pattern, show which alert classes it currently @@ -24,7 +25,7 @@ export function SubscriptionMatchPreview({ pattern }: { pattern: string }) { const valid = ALERT_SUBSCRIPTION_REGEX.test(pattern) const enabled = valid && isGlob const { data } = useQuery( - q(api.alertClassList, { query: { filter: pattern } }, { enabled }) + q(api.alertClassList, { query: { filter: pattern, limit: ALL_ISH } }, { enabled }) ) if (!enabled || !data) return null diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx index 236d9355f1..9d02dd2134 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -71,6 +71,7 @@ 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' +import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' @@ -90,7 +91,8 @@ const stateFilterParams = (filter: StateFilter) => const deliveryList = (receiver: string, filter: StateFilter = 'all') => getListQFn(api.alertDeliveryList, { path: { receiver }, - query: stateFilterParams(filter), + // sort newest first: the API's default is time_and_id_ascending + query: { ...stateFilterParams(filter), sortBy: 'time_and_id_descending' }, }) export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -434,7 +436,7 @@ function AddSubscriptionModal({ onDismiss }: { onDismiss: () => void }) { const { control } = form const subscription = useWatch({ control, name: 'subscription' }) - const classes = useQuery(q(api.alertClassList, {})) + const classes = useQuery(q(api.alertClassList, { query: { limit: ALL_ISH } })) const classItems = (classes.data?.items || []) .filter(isSubscribableClass) .filter((c) => !receiver.subscriptions.includes(c.name)) @@ -711,6 +713,13 @@ function DeliveriesTab() { 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({ @@ -733,9 +742,9 @@ function DeliveriesTab() { /> {table} - {selectedDelivery && ( + {liveDelivery && ( setSelectedDelivery(null)} /> )} diff --git a/mock-api/alert.ts b/mock-api/alert.ts index 0e66443524..8157a45771 100644 --- a/mock-api/alert.ts +++ b/mock-api/alert.ts @@ -126,7 +126,8 @@ export const alertReceivers = [receiverGeneral, receiverPowerMon, receiverWebhoo const minutesAgo = (n: number) => subMinutes(new Date(), n).toISOString() -// newest first, the order the list endpoint returns +// 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', diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index ea129fa50a..dbd78cc4d8 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -2801,6 +2801,16 @@ export const handlers = makeHandlers({ (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(), diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 17e53479c6..4dd184d07a 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -517,6 +517,29 @@ test('Webhook deliveries', async ({ page }) => { await expect(table.getByRole('row')).toHaveCount(9) }) +test('Resend fails for an unsubscribed event 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 eventCannot 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 delete', async ({ page }) => { await page.goto('/system/alerting/receivers') From 43e0c403cbc711ab36c76d8a8a12ec4e5cf74ba4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 14:20:20 +0200 Subject: [PATCH 24/29] update wording in more places; integrate alert_view --- app/components/SubscriptionMatchPreview.tsx | 4 +- .../form/fields/SubscriptionsField.tsx | 6 +- app/forms/webhook-create.tsx | 2 +- app/forms/webhook-edit.tsx | 6 +- .../alerting/AlertReceiverDeliveries.tsx | 412 +++++++++++++ .../system/alerting/AlertReceiverPage.tsx | 570 +----------------- .../system/alerting/AlertReceiverTesting.tsx | 185 ++++++ .../system/alerting/AlertReceiversTab.tsx | 14 +- app/pages/system/alerting/AlertingPage.tsx | 10 +- app/util/links.ts | 12 + mock-api/alert.ts | 61 +- mock-api/msw/db.ts | 1 + mock-api/msw/handlers.ts | 10 +- test/e2e/alerts.e2e.ts | 80 +-- 14 files changed, 767 insertions(+), 606 deletions(-) create mode 100644 app/pages/system/alerting/AlertReceiverDeliveries.tsx create mode 100644 app/pages/system/alerting/AlertReceiverTesting.tsx diff --git a/app/components/SubscriptionMatchPreview.tsx b/app/components/SubscriptionMatchPreview.tsx index f81430c1a0..36065cc191 100644 --- a/app/components/SubscriptionMatchPreview.tsx +++ b/app/components/SubscriptionMatchPreview.tsx @@ -36,7 +36,7 @@ export function SubscriptionMatchPreview({ pattern }: { pattern: string }) { if (classes.length === 0) { return (

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

) @@ -44,7 +44,7 @@ export function SubscriptionMatchPreview({ pattern }: { pattern: string }) { return (

- Matches {classes.length} event {classes.length === 1 ? 'class' : 'classes'}:{' '} + Matches {classes.length} alert {classes.length === 1 ? 'class' : 'classes'}:{' '} {classes.map((c) => ( diff --git a/app/components/form/fields/SubscriptionsField.tsx b/app/components/form/fields/SubscriptionsField.tsx index b572af2198..c7705df6ed 100644 --- a/app/components/form/fields/SubscriptionsField.tsx +++ b/app/components/form/fields/SubscriptionsField.tsx @@ -35,7 +35,7 @@ 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 event class or a glob pattern like hardware.** (letters, numbers, and underscores only)' + 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' @@ -60,7 +60,7 @@ function SubscriptionChip({ content={ matchCount === undefined ? undefined - : `Matches ${matchCount} event ${matchCount === 1 ? 'class' : 'classes'}` + : `Matches ${matchCount} alert ${matchCount === 1 ? 'class' : 'classes'}` } >

- Event subscriptions + Alert subscriptions
Webhook {receiver.name} created) + addToast(<>Webhook receiver {receiver.name} created) navigate(pb.alertReceivers()) }, }) diff --git a/app/forms/webhook-edit.tsx b/app/forms/webhook-edit.tsx index 769b409354..f66aaa3f62 100644 --- a/app/forms/webhook-edit.tsx +++ b/app/forms/webhook-edit.tsx @@ -32,7 +32,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { return null } -export const handle = titleCrumb('Edit webhook') +export const handle = titleCrumb('Edit webhook receiver') export default function EditWebhookSideModalForm() { const navigate = useNavigate() @@ -55,7 +55,7 @@ export default function EditWebhookSideModalForm() { const newName = variables.body.name || receiver.name navigate(pb.alertReceiver({ receiver: newName })) // prettier-ignore - addToast(<>Webhook {newName} updated) + 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 @@ -73,7 +73,7 @@ export default function EditWebhookSideModalForm() { navigate(pb.alertReceiver(receiverSelector))} onSubmit={({ name, description, endpoint }) => { editWebhook.mutate({ diff --git a/app/pages/system/alerting/AlertReceiverDeliveries.tsx b/app/pages/system/alerting/AlertReceiverDeliveries.tsx new file mode 100644 index 0000000000..bb79daa587 --- /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 index 9d02dd2134..15c7a42e78 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -8,32 +8,21 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useCallback, useMemo, useState, type ReactNode } from 'react' +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 { match } from 'ts-pattern' import { api, - getListQFn, q, queryClient, useApiMutation, usePrefetchedQuery, - type AlertDelivery, - type AlertDeliveryState, - type AlertProbeResult, - type WebhookDeliveryAttempt, type WebhookSecret, } from '@oxide/api' -import { - Error12Icon, - Success12Icon, - Webhooks16Icon, - Webhooks24Icon, -} from '@oxide/design-system/icons/react' -import { Badge, Button, type BadgeColor } from '@oxide/design-system/ui' +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' @@ -43,58 +32,37 @@ import { ModalForm } from '~/components/form/ModalForm' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { QueryParamTabs } from '~/components/QueryParamTabs' -import { useIntervalPicker } from '~/components/RefetchIntervalPicker' 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 { 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 { Table } from '~/table/Table' -import { CardBlock } from '~/ui/lib/CardBlock' +import { CardBlock, LearnMore } from '~/ui/lib/CardBlock' import { type ComboboxItem } from '~/ui/lib/Combobox' -import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' -import { DateTime } from '~/ui/lib/DateTime' 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 { Listbox } from '~/ui/lib/Listbox' import { Message } from '~/ui/lib/Message' -import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' 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' import { ALL_ISH } from '~/util/consts' +import { docLinks } 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 } }) -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() - -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' }, - }) - export async function clientLoader({ params }: LoaderFunctionArgs) { const { receiver } = getAlertReceiverSelector(params) await Promise.all([ @@ -116,7 +84,7 @@ export default function AlertReceiverPage() { navigate(pb.alertReceivers()) queryClient.invalidateEndpoint('alertReceiverList') // prettier-ignore - addToast(<>Webhook {variables.path.receiver} deleted) + addToast(<>Webhook receiver {variables.path.receiver} deleted) }, }) @@ -124,7 +92,7 @@ export default function AlertReceiverPage() { <> }>{receiver.name} - + Edit @@ -133,7 +101,7 @@ export default function AlertReceiverPage() { onSelect={confirmDelete({ doDelete: () => deleteReceiver({ path: { receiver: receiver.name } }), label: receiver.name, - resourceKind: 'webhook', + resourceKind: 'webhook receiver', extraContent: 'Its delivery history will also be deleted.', })} className="destructive" @@ -155,7 +123,7 @@ export default function AlertReceiverPage() { Testing - + @@ -170,166 +138,7 @@ export default function AlertReceiverPage() { ) } -// 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 - -function TestingTab() { - return ( - <> - - - - ) -} - -function WebhookTesterCard() { - 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 event 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}
-
- ))} -
-
-
- ) -} - -// Alert classes +// Alert subscriptions const subscriptionColHelper = createColumnHelper<{ subscription: string }>() const subscriptionCols = [ @@ -339,7 +148,7 @@ const subscriptionCols = [ }), ] -function EventClassesCard() { +function SubscriptionsCard() { const receiverSelector = useAlertReceiverSelector() const { data: receiver } = usePrefetchedQuery(receiverView(receiverSelector)) const [showAddModal, setShowAddModal] = useState(false) @@ -370,7 +179,7 @@ function EventClassesCard() { modalContent: (

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

), actionType: 'danger', @@ -390,10 +199,9 @@ function EventClassesCard() { return ( @@ -456,7 +264,7 @@ function AddSubscriptionModal({ onDismiss }: { onDismiss: () => void }) { addSubscription.mutate({ path: receiverSelector, body: { subscription } }) @@ -468,8 +276,8 @@ function AddSubscriptionModal({ onDismiss }: { onDismiss: () => void }) { variant="info" content={ <> - Event subscriptions may include simple globs to subscribe to multiple categories - of events, like hardware.** or{' '} + Alert subscriptions may include simple globs to subscribe to multiple classes of + alerts, like hardware.** or{' '} **.remove. } @@ -478,7 +286,7 @@ function AddSubscriptionModal({ onDismiss }: { onDismiss: () => void }) { control={control} name="subscription" label="Subscription" - placeholder="Enter event pattern" + placeholder="Enter alert pattern" items={classItems} isLoading={classes.isPending} allowArbitraryValues @@ -564,6 +372,9 @@ function SecretsCard() { )} + + + {showAddModal && setShowAddModal(false)} />} ) @@ -602,338 +413,3 @@ function AddSecretModal({ onDismiss }: { onDismiss: () => void }) { ) } - -// Deliveries - -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: 'Event ID' }), - deliveryColHelper.accessor('alertClass', { - header: 'Event class', - cell: (info) => {info.getValue()}, - }), - deliveryColHelper.accessor('state', { - cell: (info) => , - }), - deliveryColHelper.accessor('timeStarted', { ...Columns.timeCreated, header: 'Started' }), - deliveryColHelper.accessor('trigger', { - cell: (info) => {info.getValue()}, - }), -] - -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 event', - modalTitle: 'Confirm resend', - modalContent: ( -
-

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

- - - {delivery.alertClass} - - - - - - -
- ), - actionType: 'primary', - }), - }, - ], - [resendDelivery, receiver] - ) - - const emptyState = ( - } - title="No deliveries" - body={ - filter === 'all' - ? 'Events delivered to this webhook 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)} - /> - )} - - ) -} - -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(), - }) - - 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. Alert data, the alert version, and the signature can't -// be known from here, so they show up as angle-bracket placeholders. -const payloadJson = (delivery: AlertDelivery, sentAt: string) => `{ - "alert_class": ${JSON.stringify(delivery.alertClass)}, - "alert_version": , - "alert_id": ${JSON.stringify(delivery.alertId)}, - "data": , - "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): [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', ''], - ['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 }: { delivery: AlertDelivery }) { - // 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) - const headers = requestHeaders(delivery, sentAt) - 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 record. 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/AlertReceiverTesting.tsx b/app/pages/system/alerting/AlertReceiverTesting.tsx new file mode 100644 index 0000000000..619fb4fa7b --- /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 WebhookTesterCard() { + 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 index 598ced805a..94bf1ac95c 100644 --- a/app/pages/system/alerting/AlertReceiversTab.tsx +++ b/app/pages/system/alerting/AlertReceiversTab.tsx @@ -41,9 +41,9 @@ import { pb } from '~/util/path-builder' const EmptyState = () => ( } - title="No webhooks" - body="Create a webhook to see it here" - buttonText="New webhook" + title="No webhook receivers" + body="Create a webhook receiver to see it here" + buttonText="New webhook receiver" buttonTo={pb.alertReceiversNew()} /> ) @@ -111,7 +111,7 @@ export default function AlertReceiversTab() { onActivate: confirmDelete({ doDelete: () => deleteReceiver({ path: { receiver: receiver.name } }), label: receiver.name, - resourceKind: 'webhook', + resourceKind: 'webhook receiver', extraContent: 'Its delivery history will also be deleted.', }), }, @@ -133,14 +133,14 @@ export default function AlertReceiversTab() { useQuickActions( () => [ { - value: 'New webhook', + 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', + navGroup: 'Go to webhook receiver', })), ], [allReceivers] @@ -151,7 +151,7 @@ export default function AlertReceiversTab() { {/* webhooks are the only kind of alert receiver for now, so the tab says webhook everywhere while the tab itself is called Receivers */} - New webhook + New webhook receiver {table} diff --git a/app/pages/system/alerting/AlertingPage.tsx b/app/pages/system/alerting/AlertingPage.tsx index c8f557faa8..99864e5f57 100644 --- a/app/pages/system/alerting/AlertingPage.tsx +++ b/app/pages/system/alerting/AlertingPage.tsx @@ -6,11 +6,13 @@ * Copyright Oxide Computer Company */ -import { Monitoring24Icon } from '@oxide/design-system/icons/react' +import { Monitoring16Icon, Monitoring24Icon } 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()) @@ -20,6 +22,12 @@ export default function AlertingPage() { <> }>Alerting + } + summary="Alerts notify you when events occur in the system. Webhook receivers deliver them to endpoints you configure." + links={[docLinks.alerts, docLinks.webhookReceivers]} + /> diff --git a/app/util/links.ts b/app/util/links.ts index 41aa84ae3a..f73f457940 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -43,6 +43,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', @@ -187,4 +191,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/mock-api/alert.ts b/mock-api/alert.ts index 8157a45771..704ee47dfd 100644 --- a/mock-api/alert.ts +++ b/mock-api/alert.ts @@ -8,9 +8,10 @@ import { subMinutes } from 'date-fns' -import type { AlertClass, AlertDelivery, AlertReceiver } from '@oxide/api' +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 @@ -126,12 +127,68 @@ export const alertReceivers = [receiverGeneral, receiverPowerMon, receiverWebhoo 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 +): 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(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), + psuAlert('0d38abba-266b-4220-9975-ae9fe26093e2', 'insert', 3, 30), + psuAlert('8c8a74ba-58b7-4a06-8c79-39ccad5624fb', 'remove', 1, 180), + psuAlert('beef336d-99db-4b12-ac08-7ebcaab8421a', 'insert', 1, 125), + psuAlert('5a2009af-26a0-4217-b18f-bd4e25e691b9', 'insert', 2, 240), +] + // 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: '391a8e04-a160-4132-a989-6104113311f5', + alert_id: PROBE_ALERT_ID, alert_class: 'probe', receiver_id: receiverWebhook1.id, state: 'delivered', diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index db4f7d5bf2..f66a95f938 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -630,6 +630,7 @@ 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 dbd78cc4d8..abba126e00 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -35,7 +35,7 @@ import { parseIpNet } from '~/util/ip' import { commaSeries } from '~/util/str' import { GiB } from '~/util/units' -import { alertClasses } from '../alert' +import { alertClasses, PROBE_ALERT_ID } from '../alert' import { defaultSilo, toIdp } from '../silo' import { getTimestamps } from '../util' import { defaultFirewallRules } from '../vpc' @@ -2693,6 +2693,10 @@ export const handlers = makeHandlers({ // can't use paginated() because alert classes have no ID return { items: alertClasses.filter((c) => !filter || filter.test(c.name)) } }, + alertView({ path, cookies }) { + requireFleetViewer(cookies) + return lookupById(db.alerts, path.alertId) + }, alertReceiverList({ query, cookies }) { requireFleetViewer(cookies) return paginated(query, db.alertReceivers) @@ -2732,7 +2736,8 @@ export const handlers = makeHandlers({ const success = !receiver.kind.endpoint.includes('unreachable') const probe: Json = { id: uuid(), - alert_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', @@ -2894,7 +2899,6 @@ export const handlers = makeHandlers({ affinityGroupMemberInstanceView: NotImplemented, affinityGroupUpdate: NotImplemented, alertList: NotImplemented, - alertView: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, auditLogList: NotImplemented, certificateCreate: NotImplemented, diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 4dd184d07a..3250dec256 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -49,17 +49,17 @@ test('Alert receivers list', async ({ page }) => { await expectRowVisible(table, { name: 'webhook-1', - Events: 'hardware.power_shelf.psu.insert+1', + Alerts: 'hardware.power_shelf.psu.insert+1', description: 'Main web deployments', }) - await expectRowVisible(table, { name: 'power-mon', Events: 'hardware.**' }) - await expectRowVisible(table, { name: 'general-sys-webhook', Events: '—' }) + await expectRowVisible(table, { name: 'power-mon', Alerts: 'hardware.**' }) + await expectRowVisible(table, { name: 'general-sys-webhook', Alerts: '—' }) }) test('Webhook create', async ({ page }) => { await page.goto('/system/alerting/receivers') - await page.getByRole('link', { name: 'New webhook' }).click() + 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() @@ -92,11 +92,11 @@ test('Webhook create', async ({ page }) => { 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: 'Event subscriptions' }) + const subsInput = page.getByRole('combobox', { name: 'Alert subscriptions' }) await subsInput.fill('hardware..bad') await subsInput.press('Enter') await expect( - main.getByText('Must be an event class or a glob pattern like hardware.**') + 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 @@ -113,11 +113,11 @@ test('Webhook create', async ({ page }) => { await expect(subsInput).toHaveValue('') await page.getByRole('button', { name: 'Create webhook receiver' }).click() - await expectToast(page, 'Webhook deploy-hook created') + await expectToast(page, 'Webhook receiver deploy-hook created') await expectRowVisible(page.getByRole('table'), { name: 'deploy-hook', - Events: 'hardware.**', + Alerts: 'hardware.**', description: 'CI deploys', }) }) @@ -125,7 +125,7 @@ test('Webhook create', async ({ page }) => { test('Webhook create subscriptions field', async ({ page }) => { await page.goto('/system/alerting/receivers-new') - const subsInput = page.getByRole('combobox', { name: 'Event subscriptions' }) + const subsInput = page.getByRole('combobox', { name: 'Alert subscriptions' }) const listbox = page.getByRole('listbox') const chipRemove = (sub: string) => page.getByRole('button', { name: `remove subscription ${sub}` }) @@ -236,7 +236,7 @@ test('Webhook create subscriptions field', async ({ page }) => { await expect(listbox.getByRole('option').first()).toContainText('system.update.fail') }) -test('Webhook detail: properties, event classes, secrets', async ({ page }) => { +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') @@ -246,25 +246,25 @@ test('Webhook detail: properties, event classes, secrets', async ({ page }) => { await expect(page.getByText('Main web deployments')).toBeVisible() // event classes card - const eventClasses = page.getByRole('table', { name: 'Event classes' }) - await expect(eventClasses.getByRole('row')).toHaveCount(3) // header + 2 + 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 event class' }).click() - const addModal = page.getByRole('dialog', { name: 'Add event class' }) + 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(eventClasses.getByRole('row')).toHaveCount(4) + 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(eventClasses.getByRole('row')).toHaveCount(3) + await expect(subscriptions.getByRole('row')).toHaveCount(3) // secrets card const secrets = page.getByRole('table', { name: 'Secrets' }) @@ -329,11 +329,11 @@ test('Testing tab: probe failure', async ({ page }) => { // the mock backend fails probes for endpoints containing 'unreachable' await clickRowAction(page, 'power-mon', 'Edit') await page - .getByRole('dialog', { name: 'Edit webhook' }) + .getByRole('dialog', { name: 'Edit webhook receiver' }) .getByRole('textbox', { name: 'Endpoint URL' }) .fill('https://unreachable.example.com') - await page.getByRole('button', { name: 'Update webhook' }).click() - await expectToast(page, 'Webhook power-mon updated') + 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') @@ -350,7 +350,7 @@ test('Webhook edit', async ({ page }) => { await page.goto('/system/alerting/receivers') await clickRowAction(page, 'general-sys-webhook', 'Edit') - const modal = page.getByRole('dialog', { name: 'Edit webhook' }) + const modal = page.getByRole('dialog', { name: 'Edit webhook receiver' }) await expect(modal.getByRole('textbox', { name: 'Endpoint URL' })).toHaveValue( 'https://api.example.dev/hooks/oxide' ) @@ -358,9 +358,9 @@ test('Webhook edit', async ({ page }) => { await modal .getByRole('textbox', { name: 'Endpoint URL' }) .fill('https://hooks.example.dev') - await page.getByRole('button', { name: 'Update webhook' }).click() + await page.getByRole('button', { name: 'Update webhook receiver' }).click() - await expectToast(page, 'Webhook general-webhook updated') + 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() @@ -396,11 +396,11 @@ test('Pending delivery fails after exhausting retries', async ({ page }) => { // the mock backend fails delivery to endpoints containing 'unreachable' await clickRowAction(page, 'webhook-1', 'Edit') await page - .getByRole('dialog', { name: 'Edit webhook' }) + .getByRole('dialog', { name: 'Edit webhook receiver' }) .getByRole('textbox', { name: 'Endpoint URL' }) .fill('https://unreachable.example.com') - await page.getByRole('button', { name: 'Update webhook' }).click() - await expectToast(page, 'Webhook webhook-1 updated') + 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/ }) @@ -423,15 +423,16 @@ test('Webhook deliveries', async ({ page }) => { // ellipsized copy, so cell text contains both. Match on the full value. await expectRowVisible(table, { 'Delivery ID': expect.stringContaining('9bbdf44f-7dac-4cd0-b4c2-3e622c9693ee'), - 'Event ID': expect.stringContaining('391a8e04-a160-4132-a989-6104113311f5'), - 'Event class': 'probe', + // 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'), - 'Event ID': expect.stringContaining('beef336d-99db-4b12-ac08-7ebcaab8421a'), - 'Event class': 'hardware.power_shelf.psu.insert', + 'Alert ID': expect.stringContaining('beef336d-99db-4b12-ac08-7ebcaab8421a'), + 'Alert class': 'hardware.power_shelf.psu.insert', state: 'failed', trigger: 'alert', }) @@ -455,23 +456,28 @@ test('Webhook deliveries', async ({ page }) => { 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('Event ID') + await expect(props).toContainText('Alert ID') await expect(props.getByLabel('beef336d-99db-4b12-ac08-7ebcaab8421a')).toBeVisible() - await expect(props).toContainText('Webhook ID') + 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 + // 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() - await expect(request.getByText('"data": ')).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 }) @@ -492,7 +498,7 @@ test('Webhook deliveries', async ({ page }) => { await expectToast(page, 'Delivery resend started') await expect(table.getByRole('row')).toHaveCount(8) await expectRowVisible(table, { - 'Event class': 'hardware.power_shelf.psu.insert', + 'Alert class': 'hardware.power_shelf.psu.insert', state: 'pending', trigger: 'resend', }) @@ -517,7 +523,7 @@ test('Webhook deliveries', async ({ page }) => { await expect(table.getByRole('row')).toHaveCount(9) }) -test('Resend fails for an unsubscribed event class', async ({ page }) => { +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 @@ -534,7 +540,7 @@ test('Resend fails for an unsubscribed event class', async ({ page }) => { .click() await expectToast( page, - "Could not resend eventCannot resend alert: receiver is not subscribed to the 'hardware.power_shelf.psu.insert' alert class" + "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 @@ -545,7 +551,7 @@ test('Webhook delete', async ({ page }) => { await clickRowAction(page, 'power-mon', 'Delete') await page.getByRole('button', { name: 'Confirm' }).click() - await expectToast(page, 'Webhook power-mon deleted') + 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 From 330d17c292419056f97d78d7ab6f2d2915c37c17 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Aug 2026 18:31:20 +0200 Subject: [PATCH 25/29] a few small copy changes --- app/api/util.ts | 5 +++-- app/pages/system/alerting/AlertReceiverTesting.tsx | 6 +++--- app/pages/system/alerting/AlertReceiversTab.tsx | 4 ++-- test/e2e/alerts.e2e.ts | 12 ++++++------ 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/api/util.ts b/app/api/util.ts index fb3767be4b..4fc2d9f7ae 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -39,7 +39,7 @@ export const INSTANCE_MAX_CPU = 254 export const INSTANCE_MIN_RAM_GiB = 1 export const INSTANCE_MAX_RAM_GiB = 1536 -// Valid alert subscription: an event class or a glob pattern matching multiple +// 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_]+|\*|\*\*))*$/ @@ -48,7 +48,8 @@ export const ALERT_SUBSCRIPTION_REGEX = export const isGlobPattern = (subscription: string) => subscription.includes('*') /** - * The `probe` class is synthetic: it exists for webhook liveness probes only. + * 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. diff --git a/app/pages/system/alerting/AlertReceiverTesting.tsx b/app/pages/system/alerting/AlertReceiverTesting.tsx index 619fb4fa7b..0aa5181604 100644 --- a/app/pages/system/alerting/AlertReceiverTesting.tsx +++ b/app/pages/system/alerting/AlertReceiverTesting.tsx @@ -32,20 +32,20 @@ import { attemptResultBadge } from './AlertReceiverDeliveries' export function TestingTab() { return ( <> - + ) } -function WebhookTesterCard() { +function ReceiverTesterCard() { const [showProbeModal, setShowProbeModal] = useState(false) const [result, setResult] = useState(null) return ( + + + ) +} + +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 ( - - } - title="No alerts" - body="Alerts published by the system will appear here" - /> - + <> + {table} + {detail && setDetail(null)} />} + ) } diff --git a/mock-api/alert.ts b/mock-api/alert.ts index 704ee47dfd..6cc1044b6d 100644 --- a/mock-api/alert.ts +++ b/mock-api/alert.ts @@ -141,7 +141,8 @@ const psuAlert = ( id: string, action: 'insert' | 'remove', slot: number, - minutes: number + minutes: number, + modified: boolean ): Json => ({ id, class: `hardware.power_shelf.psu.${action}`, @@ -164,7 +165,7 @@ const psuAlert = ( time: minutesAgo(minutes), }, time_created: minutesAgo(minutes), - time_modified: minutesAgo(minutes), + time_modified: minutesAgo(modified ? minutes - 120 : minutes), }) export const alerts: Json[] = [ @@ -176,11 +177,11 @@ export const alerts: Json[] = [ time_created: minutesAgo(24 * 60), time_modified: minutesAgo(24 * 60), }, - psuAlert('26cb0726-bb32-4a6f-b0a5-b207f75f3cec', 'insert', 0, 10), - psuAlert('0d38abba-266b-4220-9975-ae9fe26093e2', 'insert', 3, 30), - psuAlert('8c8a74ba-58b7-4a06-8c79-39ccad5624fb', 'remove', 1, 180), - psuAlert('beef336d-99db-4b12-ac08-7ebcaab8421a', 'insert', 1, 125), - psuAlert('5a2009af-26a0-4217-b18f-bd4e25e691b9', 'insert', 2, 240), + 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. diff --git a/mock-api/index.ts b/mock-api/index.ts index 3abb4d639c..c8d62166dd 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -24,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/handlers.ts b/mock-api/msw/handlers.ts index abba126e00..1b14fe610e 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -2375,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 }) { @@ -2693,10 +2726,6 @@ export const handlers = makeHandlers({ // can't use paginated() because alert classes have no ID return { items: alertClasses.filter((c) => !filter || filter.test(c.name)) } }, - alertView({ path, cookies }) { - requireFleetViewer(cookies) - return lookupById(db.alerts, path.alertId) - }, alertReceiverList({ query, cookies }) { requireFleetViewer(cookies) return paginated(query, db.alertReceivers) @@ -2898,7 +2927,6 @@ export const handlers = makeHandlers({ affinityGroupMemberInstanceDelete: NotImplemented, affinityGroupMemberInstanceView: NotImplemented, affinityGroupUpdate: NotImplemented, - alertList: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, auditLogList: NotImplemented, certificateCreate: NotImplemented, diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 6a749ff816..9d70be3b3f 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -8,6 +8,8 @@ import { expect, test, type Page } from '@playwright/test' +import { alerts } from '@oxide/api-mocks' + import { clickRowAction, clickRowActions, @@ -586,3 +588,42 @@ test('Webhook receiver delete', async ({ page }) => { 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)) +})