From b9440501e5b11212e918a217811ee06c285275f2 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 6 Aug 2026 15:41:56 -0700 Subject: [PATCH 01/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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/39] 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)) +}) From 9a2d423f22fa59e65aad9c2d14fbadf23ae8cf8e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 1 Sep 2026 23:23:53 +0100 Subject: [PATCH 30/39] Update probe box with re-send option; fix MSW --- app/api/util.spec.ts | 74 +++++++++ app/api/util.ts | 25 ++++ .../system/alerting/AlertReceiverTesting.tsx | 141 +++++++++++++++--- app/ui/lib/Checkbox.tsx | 16 +- mock-api/alert.ts | 33 ++++ mock-api/msw/handlers.ts | 41 ++++- test/e2e/alerts.e2e.ts | 126 ++++++++++++++-- 7 files changed, 414 insertions(+), 42 deletions(-) diff --git a/app/api/util.spec.ts b/app/api/util.spec.ts index 90c35b880d..b4a56be160 100644 --- a/app/api/util.spec.ts +++ b/app/api/util.spec.ts @@ -12,6 +12,7 @@ import { genName, instanceCan, parsePortRange, + resendableAlertIds, subscriptionRegex, synthesizeData, } from './util' @@ -242,3 +243,76 @@ test('diskCan', () => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions diskCan.abc }) + +describe('resendableAlertIds', () => { + // the rule only reads these four fields, so build them directly + type Delivery = Parameters[0][number] + + const d = ( + alertId: string, + state: Delivery['state'], + trigger: Delivery['trigger'], + alertClass = 'hardware.sled.fault' + ): Delivery => ({ alertId, state, trigger, alertClass }) + + const ids = (deliveries: Delivery[]) => [...resendableAlertIds(deliveries)].sort() + + it('is empty when there are no deliveries', () => { + expect(ids([])).toEqual([]) + }) + + it('includes an alert whose only delivery failed', () => { + expect(ids([d('a', 'failed', 'alert')])).toEqual(['a']) + }) + + it('excludes delivered and pending alerts', () => { + expect(ids([d('a', 'delivered', 'alert'), d('b', 'pending', 'alert')])).toEqual([]) + }) + + // the bug this rule replaced: it counted failed delivery records, so an + // alert that had already been resent successfully was requeued forever + it('excludes an alert that has a failed record but also a successful resend', () => { + const deliveries = [d('a', 'failed', 'alert'), d('a', 'delivered', 'resend')] + expect(ids(deliveries)).toEqual([]) + }) + + // a resend in flight takes the alert out of the set, so a second probe does + // not double-queue it + it('excludes an alert with a resend still pending', () => { + const deliveries = [d('a', 'failed', 'alert'), d('a', 'pending', 'resend')] + expect(ids(deliveries)).toEqual([]) + }) + + it('counts an alert once no matter how many times it failed', () => { + const deliveries = [d('a', 'failed', 'alert'), d('a', 'failed', 'resend')] + expect(ids(deliveries)).toEqual(['a']) + }) + + it('ignores probe deliveries entirely', () => { + const deliveries = [ + d('probe-alert', 'delivered', 'probe', 'probe'), + d('probe-alert', 'failed', 'probe', 'probe'), + d('a', 'failed', 'alert'), + ] + expect(ids(deliveries)).toEqual(['a']) + }) + + // a successful probe of an alert does not mean the alert itself landed, so it + // must not settle the alert. matches omicron's triggered_by != probe filter + it('does not let a probe-triggered success settle a real alert', () => { + const deliveries = [d('a', 'failed', 'alert'), d('a', 'delivered', 'probe')] + expect(ids(deliveries)).toEqual(['a']) + }) + + it('handles several alerts at once', () => { + const deliveries = [ + d('w', 'failed', 'alert'), + d('x', 'delivered', 'alert'), + d('y', 'failed', 'alert'), + d('y', 'failed', 'resend'), + d('z', 'failed', 'alert'), + d('z', 'pending', 'resend'), + ] + expect(ids(deliveries)).toEqual(['w', 'y']) + }) +}) diff --git a/app/api/util.ts b/app/api/util.ts index 4fc2d9f7ae..bf8758b292 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -12,6 +12,7 @@ import { match } from 'ts-pattern' import { bytesToGiB } from '~/util/units' import type { + AlertDelivery, Disk, DiskState, DiskType, @@ -73,6 +74,30 @@ export function subscriptionRegex(subscription: string) { return new RegExp(`^${pattern}$`) } +/** + * IDs of the alerts a probe with `resend=true` would requeue: the receiver has + * a delivery for the alert and no non-probe delivery of that alert has left the + * failed state. Note this is per alert, not per delivery — delivery records are + * immutable history, so a failed one stays failed forever and a resend inserts + * a new record. The API has no endpoint for this, so we derive it from the + * delivery list to preview the count before the user commits to a resend. + * https://github.com/oxidecomputer/omicron/blob/6db4c7e/nexus/db-queries/src/db/datastore/webhook_delivery.rs#L205-L240 + * + * The mock backend applies the same rule in its own `resendableAlerts`, which + * works on snake_case records, so the two have to be changed together. + */ +export function resendableAlertIds( + deliveries: Pick[] +): Set { + const relevant = deliveries.filter((d) => d.alertClass !== PROBE_ALERT_CLASS) + const settled = new Set( + relevant + .filter((d) => d.trigger !== 'probe' && d.state !== 'failed') + .map((d) => d.alertId) + ) + return new Set(relevant.filter((d) => !settled.has(d.alertId)).map((d) => d.alertId)) +} + export const MIN_DISK_SIZE_GiB = 1 /** * Disk size limited to 1023 as that's the maximum we can safely allocate right now diff --git a/app/pages/system/alerting/AlertReceiverTesting.tsx b/app/pages/system/alerting/AlertReceiverTesting.tsx index 0aa5181604..2ab69544ca 100644 --- a/app/pages/system/alerting/AlertReceiverTesting.tsx +++ b/app/pages/system/alerting/AlertReceiverTesting.tsx @@ -6,22 +6,35 @@ * Copyright Oxide Computer Company */ +import { useQuery } from '@tanstack/react-query' import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { Link } from 'react-router' +import { match } from 'ts-pattern' -import { api, queryClient, useApiMutation, type AlertProbeResult } from '@oxide/api' +import { + api, + q, + queryClient, + resendableAlertIds, + useApiMutation, + type AlertProbeResult, +} from '@oxide/api' import { Error12Icon, Success12Icon } from '@oxide/design-system/icons/react' import { Button } from '@oxide/design-system/ui' +import { CheckboxField } from '~/components/form/fields/CheckboxField' +import { ModalForm } from '~/components/form/ModalForm' import { useAlertReceiverSelector } from '~/hooks/use-params' -import { 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 { ALL_ISH } from '~/util/consts' +import { pluralize } from '~/util/str' import { attemptResultBadge } from './AlertReceiverDeliveries' @@ -38,10 +51,39 @@ export function TestingTab() { ) } +/** + * How many alerts a resend would requeue. The API has no endpoint for this, so + * we derive it from the delivery list, which means the answer is only as + * complete as one page. `truncated` says we hit the cap and the real number is + * higher, so the copy can hedge rather than quietly undercount. + */ +type ResendPreview = + | { state: 'unknown' } + | { state: 'known'; count: number; truncated: boolean } + function ReceiverTesterCard() { + const { receiver } = useAlertReceiverSelector() const [showProbeModal, setShowProbeModal] = useState(false) const [result, setResult] = useState(null) + // throwOnError off because this only feeds a preview count: if it fails the + // modal falls back to describing the behavior without a number + const { data } = useQuery( + q( + api.alertDeliveryList, + { path: { receiver }, query: { limit: ALL_ISH } }, + { throwOnError: false } + ) + ) + + const preview: ResendPreview = data + ? { + state: 'known', + count: resendableAlertIds(data.items).size, + truncated: !!data.nextPage, + } + : { state: 'unknown' } + return ( {showProbeModal && ( - setShowProbeModal(false)} onSuccess={setResult} /> + setShowProbeModal(false)} + onSuccess={setResult} + /> )} ) @@ -81,6 +127,7 @@ function ProbeResult({ result }: { result: AlertProbeResult }) { const status = attempt.response?.status const durationMs = attempt.response?.durationMs + const resends = result.resendsStarted return ( @@ -107,18 +154,54 @@ function ProbeResult({ result }: { result: AlertProbeResult }) { + {/* null unless resends were requested and the probe succeeded */} + {resends != null && ( + + {resends === 0 ? ( + 'No failed deliveries to resend' + ) : ( + + {resends} {resends === 1 ? 'delivery' : 'deliveries'} requeued + + View deliveries + + + )} + + )} ) } +const resendNote = (preview: ResendPreview) => + match(preview) + .with( + { state: 'unknown' }, + () => 'Alerts that never reached the endpoint are queued for another attempt.' + ) + .with( + { state: 'known', count: 0 }, + () => 'Every alert has reached this endpoint, so nothing would be resent.' + ) + .with({ state: 'known' }, ({ count, truncated }) => { + const alerts = `${count} ${pluralize('alert', count)}` + const subject = truncated ? `At least ${alerts}` : alerts + const verb = !truncated && count === 1 ? 'has' : 'have' + return `${subject} ${verb} never reached this endpoint.` + }) + .exhaustive() + function ProbeModal({ + preview, onDismiss, onSuccess, }: { + preview: ResendPreview onDismiss: () => void onSuccess: (result: AlertProbeResult) => void }) { const receiverSelector = useAlertReceiverSelector() + const form = useForm({ defaultValues: { resend: false } }) const sendProbe = useApiMutation(api.alertReceiverProbe, { onSuccess(result) { @@ -126,28 +209,40 @@ function ProbeModal({ 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" - /> -
+ + sendProbe.mutate({ path: receiverSelector, query: { resend } }) + } + > +
+

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

+ {/* only disable on a known zero: while the count is unknown we can't + rule out that there is something to resend. the note below the label + says why it's off, so no tooltip is needed */} + + Resend failed deliveries if the probe succeeds + + {resendNote(preview)} + + +
+
) } diff --git a/app/ui/lib/Checkbox.tsx b/app/ui/lib/Checkbox.tsx index bfd6556ac8..54c93d781a 100644 --- a/app/ui/lib/Checkbox.tsx +++ b/app/ui/lib/Checkbox.tsx @@ -17,9 +17,12 @@ const Check = () => ( const Indeterminate = classed.div`absolute w-2 h-0.5 left-1 top-[7px] bg-(--theme-accent-800) light:bg-(--theme-accent-600) pointer-events-none` +// the disabled: rules repeat under hover: because the hover: rules below them +// would otherwise win and make a disabled box look interactive, same as Radio const inputStyle = ` appearance-none border border-default bg-default h-4 w-4 rounded-sm absolute left-0 outline-none - disabled:cursor-not-allowed + disabled:cursor-not-allowed disabled:bg-disabled disabled:border-default + hover:disabled:cursor-not-allowed hover:disabled:bg-disabled hover:disabled:border-default hover:border-raise hover:cursor-pointer checked:bg-accent checked:border-accent-tertiary checked:hover:border-accent-secondary checked:hover:light:border-accent indeterminate:bg-accent indeterminate:border-accent-tertiary indeterminate:hover:light:border-accent indeterminate:hover:border-accent-secondary @@ -63,6 +66,15 @@ export const Checkbox = ({ {indeterminate && } - {children && {children}} + {children && ( + + {children} + + )} ) diff --git a/mock-api/alert.ts b/mock-api/alert.ts index 6cc1044b6d..8cbbe565ed 100644 --- a/mock-api/alert.ts +++ b/mock-api/alert.ts @@ -180,6 +180,7 @@ export const alerts: Json[] = [ psuAlert('26cb0726-bb32-4a6f-b0a5-b207f75f3cec', 'insert', 0, 10, false), psuAlert('0d38abba-266b-4220-9975-ae9fe26093e2', 'insert', 3, 30, false), psuAlert('8c8a74ba-58b7-4a06-8c79-39ccad5624fb', 'remove', 1, 180, false), + psuAlert('81dd4626-d7ef-435e-8f4d-f3f2a1217e59', 'remove', 4, 200, false), psuAlert('beef336d-99db-4b12-ac08-7ebcaab8421a', 'insert', 1, 125, true), psuAlert('5a2009af-26a0-4217-b18f-bd4e25e691b9', 'insert', 2, 240, false), ] @@ -294,6 +295,38 @@ export const alertDeliveries: Json[] = [ ], }, }, + { + // like beef336d, this alert only ever failed, so both are resendable + id: '05085aec-e348-48c3-9624-e54c103a19c4', + alert_id: '81dd4626-d7ef-435e-8f4d-f3f2a1217e59', + alert_class: 'hardware.power_shelf.psu.remove', + receiver_id: receiverWebhook1.id, + state: 'failed', + trigger: 'alert', + time_started: minutesAgo(200), + attempts: { + webhook: [ + { + attempt: 1, + result: 'failed_timeout', + response: null, + time_sent: minutesAgo(200), + }, + { + attempt: 2, + result: 'failed_http_error', + response: { status: 502, duration_ms: 184 }, + time_sent: minutesAgo(195), + }, + { + attempt: 3, + result: 'failed_http_error', + response: { status: 502, duration_ms: 176 }, + time_sent: minutesAgo(190), + }, + ], + }, + }, { id: 'a71123dd-c817-4abd-88b3-c064e609df49', alert_id: '5a2009af-26a0-4217-b18f-bd4e25e691b9', diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 1b14fe610e..086fc5775b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -137,6 +137,36 @@ function retryPendingDeliveries(receiver: Json) { } } +/** + * Alerts eligible for resend to this receiver: it has a delivery for the alert + * and no non-probe delivery of that alert has left the failed state. Note this + * is per alert, not per delivery: delivery records are immutable history, so a + * failed one stays failed and a resend inserts a new record. A resend record + * starts out pending, which takes the alert out of this set right away, and if + * it succeeds the alert never comes back. That is what drains the backlog. + * https://github.com/oxidecomputer/omicron/blob/6db4c7e/nexus/db-queries/src/db/datastore/webhook_delivery.rs#L205-L240 + * + * Returns one delivery per alert so callers can read the alert class off it. + * The console previews this count with `resendableAlertIds` (app/api/util.ts), + * which applies the same rule to camelCase records. + * + * Omicron's NOT EXISTS subquery filters on alert_id, state, and triggered_by + * but not rx_id, so upstream a success on one receiver makes the alert + * non-resendable for every receiver. We scope per receiver, which is what the + * API docs describe. + */ +function resendableAlerts(receiver: Json) { + const forRx = db.alertDeliveries.filter( + (d) => d.receiver_id === receiver.id && d.alert_class !== 'probe' + ) + const settled = new Set( + forRx + .filter((d) => d.trigger !== 'probe' && d.state !== 'failed') + .map((d) => d.alert_id) + ) + return R.uniqueBy(forRx, (d) => d.alert_id).filter((d) => !settled.has(d.alert_id)) +} + export const handlers = makeHandlers({ logout: () => 204, ping: () => ({ status: 'ok' }), @@ -2787,13 +2817,12 @@ export const handlers = makeHandlers({ } db.alertDeliveries.unshift(probe) - // a successful probe with resend=true re-queues all failed deliveries + // a successful probe with resend=true re-queues every alert that has not + // yet been delivered successfully to this receiver let resendsStarted = null if (query.resend && success) { - const failed = db.alertDeliveries.filter( - (d) => d.receiver_id === receiver.id && d.state === 'failed' - ) - for (const d of failed) { + const alerts = resendableAlerts(receiver) + for (const d of alerts) { db.alertDeliveries.unshift({ id: uuid(), alert_id: d.alert_id, @@ -2805,7 +2834,7 @@ export const handlers = makeHandlers({ attempts: { webhook: [] }, }) } - resendsStarted = failed.length + resendsStarted = alerts.length } return { probe, resends_started: resendsStarted } }, diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 9d70be3b3f..5068d07965 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -449,7 +449,7 @@ test('Webhook receiver deliveries', async ({ page }) => { await page.getByRole('tab', { name: 'Deliveries' }).click() const table = page.getByRole('table') - await expect(table.getByRole('row')).toHaveCount(7) // header + 6 + await expect(table.getByRole('row')).toHaveCount(8) // header + 7 // Truncate renders the full ID (invisible, for stable layout) alongside the // ellipsized copy, so cell text contains both. Match on the full value. @@ -475,9 +475,9 @@ test('Webhook receiver deliveries', async ({ page }) => { // filter by state await selectOption(page, 'Filter by state', 'Failed') - await expect(table.getByRole('row')).toHaveCount(3) // header + 2 failed + await expect(table.getByRole('row')).toHaveCount(4) // header + 3 failed await selectOption(page, 'Filter by state', 'All states') - await expect(table.getByRole('row')).toHaveCount(7) + await expect(table.getByRole('row')).toHaveCount(8) // delivery detail side modal shows attempts await clickRowAction(page, '30ece63e-5efd-4365-99a6-d4f09dfa685e', 'View details') @@ -528,7 +528,7 @@ test('Webhook receiver deliveries', async ({ page }) => { ).toBeVisible() await confirmModal.getByRole('button', { name: 'Confirm' }).click() await expectToast(page, 'Delivery resend started') - await expect(table.getByRole('row')).toHaveCount(8) + await expect(table.getByRole('row')).toHaveCount(9) await expectRowVisible(table, { 'Alert class': 'hardware.power_shelf.psu.insert', state: 'pending', @@ -540,19 +540,123 @@ test('Webhook receiver deliveries', async ({ page }) => { await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeDisabled() await page.keyboard.press('Escape') - // send a liveness probe from the testing tab + // send a liveness probe from the testing tab, resending failed deliveries await page.getByRole('tab', { name: 'Testing' }).click() await page.getByRole('button', { name: 'Send liveness probe' }).click() const probeModal = page.getByRole('dialog', { name: 'Send liveness probe' }) + // the preview already accounts for the manual resend above, so it says one, + // not one per failed record + await expect( + probeModal.getByText('1 alert has never reached this endpoint') + ).toBeVisible() + await probeModal + .getByRole('checkbox', { name: 'Resend failed deliveries if the probe succeeds' }) + .check() await probeModal.getByRole('button', { name: 'Send probe' }).click() const panel = page.getByRole('tabpanel') await expect(panel.getByText('Succeeded')).toBeVisible() - // the modal has no resend option, so nothing gets resent - await expect(panel.getByText('resent')).toBeHidden() + // resends are counted per alert, not per failed delivery record. of the three + // failed records, 8c8a74ba already has a successful resend and beef336d was + // just resent by hand, so only 81dd4626 is left + await expect(panel.getByText('1 delivery requeued')).toBeVisible() + + // the result links to the deliveries tab, where the resends resolve + await panel.getByRole('link', { name: 'View deliveries' }).click() + // 9 rows + the probe + the one resend + await expect(table.getByRole('row')).toHaveCount(11) +}) - await page.getByRole('tab', { name: 'Deliveries' }).click() - // 8 rows + the probe. no resends: the probe modal doesn't offer them - await expect(table.getByRole('row')).toHaveCount(9) +// The bug that got this checkbox removed the first time: the mock resent every +// delivery record in the failed state, so already-resent alerts were requeued on +// every probe and the count never dropped. +test('Probe resends drain the backlog', async ({ page }) => { + await page.goto('/system/alerting/receivers/webhook-1?tab=testing') + + const panel = page.getByRole('tabpanel') + // the modal previews how many alerts a resend would requeue, so the user can + // see the number before committing to the checkbox + const openProbeModal = async (expectedNote: string) => { + await panel.getByRole('button', { name: 'Send liveness probe' }).click() + const modal = page.getByRole('dialog', { name: 'Send liveness probe' }) + await expect(modal.getByText(expectedNote)).toBeVisible() + return modal + } + const sendProbe = async (resend: boolean, expectedNote: string) => { + const modal = await openProbeModal(expectedNote) + if (resend) { + await modal + .getByRole('checkbox', { name: 'Resend failed deliveries if the probe succeeds' }) + .check() + } + await modal.getByRole('button', { name: 'Send probe' }).click() + await expect(panel.getByText('Succeeded')).toBeVisible() + } + + // beef336d and 81dd4626 have only ever failed. 8c8a74ba also has a failed + // record, but it already has a successful resend, so it does not count + const twoWaiting = '2 alerts have never reached this endpoint' + + // leaving the box unchecked resends nothing, even though 2 are waiting + await sendProbe(false, twoWaiting) + await expect(panel.getByText('requeued')).toBeHidden() + await expect(panel.getByText('No failed deliveries to resend')).toBeHidden() + + // the preview matches what the probe actually reports + await sendProbe(true, twoWaiting) + await expect(panel.getByText('2 deliveries requeued')).toBeVisible() + + // let the two resends land + await panel.getByRole('link', { name: 'View deliveries' }).click() + await selectOption(page, 'Filter by state', 'Pending') + const table = page.getByRole('table') + // the two resends plus the seeded pending delivery + await expect(table.getByRole('row')).toHaveCount(4) // header + 3 + await refreshUntil(page, () => + expect(page.getByText('No pending deliveries found')).toBeVisible({ timeout: 1000 }) + ) + + // now that every alert has been delivered there is nothing to resend, so the + // checkbox is disabled rather than offering a no-op + await page.getByRole('tab', { name: 'Testing' }).click() + const modal = await openProbeModal('Every alert has reached this endpoint') + const resendBox = modal.getByRole('checkbox', { + name: 'Resend failed deliveries if the probe succeeds', + }) + await expect(resendBox).toBeDisabled() + await expect(resendBox).not.toBeChecked() + + // the probe still works, it just doesn't ask for resends + await modal.getByRole('button', { name: 'Send probe' }).click() + await expect(panel.getByText('Succeeded')).toBeVisible() + await expect(panel.getByText('requeued')).toBeHidden() + await expect(panel.getByText('No failed deliveries to resend')).toBeHidden() +}) + +test('Probe failure reports no resends', async ({ page }) => { + await page.goto('/system/alerting/receivers') + + // the mock backend fails probes for endpoints containing 'unreachable' + await clickRowAction(page, 'webhook-1', 'Edit') + await page + .getByRole('dialog', { name: 'Edit webhook receiver' }) + .getByRole('textbox', { name: 'Endpoint URL' }) + .fill('https://unreachable.example.com') + await page.getByRole('button', { name: 'Update webhook receiver' }).click() + await expectToast(page, 'Webhook receiver webhook-1 updated') + + await page.getByRole('tab', { name: 'Testing' }).click() + const panel = page.getByRole('tabpanel') + await panel.getByRole('button', { name: 'Send liveness probe' }).click() + const modal = page.getByRole('dialog', { name: 'Send liveness probe' }) + await modal + .getByRole('checkbox', { name: 'Resend failed deliveries if the probe succeeds' }) + .check() + await modal.getByRole('button', { name: 'Send probe' }).click() + + // resends only happen on success, so the API returns null and we show no row + await expect(panel.getByText('Unreachable')).toBeVisible() + await expect(panel.getByText('requeued')).toBeHidden() + await expect(panel.getByText('No failed deliveries to resend')).toBeHidden() }) test('Resend fails for an unsubscribed alert class', async ({ page }) => { @@ -575,7 +679,7 @@ test('Resend fails for an unsubscribed alert class', async ({ page }) => { "Could not resend alertCannot resend alert: receiver is not subscribed to the 'hardware.power_shelf.psu.insert' alert class" ) // the rejected resend must not have created a new delivery - await expect(page.getByRole('table').getByRole('row')).toHaveCount(7) // header + 6 + await expect(page.getByRole('table').getByRole('row')).toHaveCount(8) // header + 7 }) test('Webhook receiver delete', async ({ page }) => { From bad57eeb111463fd32c5639ddfd1364f999d3097 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 1 Sep 2026 23:28:27 +0100 Subject: [PATCH 31/39] put receivers tab first --- app/layouts/SystemLayout.tsx | 6 +++--- app/pages/system/alerting/AlertingPage.tsx | 4 ++-- app/routes.tsx | 14 +++++++------- app/util/__snapshots__/path-builder.spec.ts.snap | 10 +++++----- test/e2e/alerts.e2e.ts | 14 +++++++++----- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 4ef8936d99..fb4c255874 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -56,8 +56,8 @@ export default function SystemLayout() { { value: 'Inventory', path: pb.sledInventory() }, { value: 'IP Pools', path: pb.ipPools() }, { value: 'Subnet Pools', path: pb.subnetPools() }, - { value: 'Alerting', path: pb.alerts() }, - { value: 'Alert Receivers', path: pb.alertReceivers() }, + { value: 'Alerting', path: pb.alertReceivers() }, + { value: 'Alerts', path: pb.alerts() }, { value: 'System Update', path: pb.systemUpdate() }, { value: 'Fleet Access', path: pb.fleetAccess() }, ] @@ -104,7 +104,7 @@ export default function SystemLayout() { Subnet Pools - + Alerting diff --git a/app/pages/system/alerting/AlertingPage.tsx b/app/pages/system/alerting/AlertingPage.tsx index 36ccc315ee..af8319c31a 100644 --- a/app/pages/system/alerting/AlertingPage.tsx +++ b/app/pages/system/alerting/AlertingPage.tsx @@ -15,7 +15,7 @@ import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -export const handle = makeCrumb('Alerting', pb.alerts()) +export const handle = makeCrumb('Alerting', pb.alertReceivers()) export default function AlertingPage() { return ( @@ -33,8 +33,8 @@ export default function AlertingPage() { - Alerts Receivers + Alerts ) diff --git a/app/routes.tsx b/app/routes.tsx index ef4122f7a8..1a743e36f0 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -269,20 +269,20 @@ export const routes = createRoutesFromElements( path="alerting" lazy={() => import('./pages/system/alerting/AlertingPage').then(convert)} > - } /> - import('./pages/system/alerting/AlertsTab').then(convert)} - /> + } /> import('./pages/system/alerting/AlertReceiversTab').then(convert)} > + import('./pages/system/alerting/AlertsTab').then(convert)} + /> - {/* /system/alerting redirects to the alerts tab, so point the crumb + {/* /system/alerting redirects to the receivers tab, so point the crumb straight at the tab to avoid a flash */} - + { await sidebar.getByRole('link', { name: 'Alerting' }).click() // the section root redirects to the first tab + await expect(page).toHaveURL('/system/alerting/receivers') + await expect(page).toHaveTitle('Receivers / Alerting / Oxide Console') + await expect(page.getByRole('tab', { name: 'Receivers' })).toHaveAttribute( + 'aria-selected', + 'true' + ) + + await page.getByRole('tab', { name: 'Alerts' }).click() await expect(page).toHaveURL('/system/alerting/alerts') await expect(page).toHaveTitle('Alerts / Alerting / Oxide Console') - - 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', @@ -694,8 +699,7 @@ test('Webhook receiver delete', async ({ page }) => { }) test('Alert list basics', async ({ page }) => { - // omitting the trailing /alerts because this should be the default view - await page.goto('/system/alerting') + await page.goto('/system/alerting/alerts') await expect(page).toHaveTitle('Alerts / Alerting / Oxide Console') await expect(page.getByRole('heading', { name: 'Alerting' })).toBeVisible() From a7f6fa6fe9b45850d13623f776941a1b7575e520 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Sep 2026 00:01:06 +0100 Subject: [PATCH 32/39] refactoring -> copy on secrets, max length for URLs --- app/api/util.ts | 5 +++++ app/forms/webhook-create.tsx | 5 ++++- app/pages/system/alerting/AlertReceiverPage.tsx | 2 +- test/e2e/alerts.e2e.ts | 8 +++++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/api/util.ts b/app/api/util.ts index bf8758b292..2f9de81c37 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -40,6 +40,11 @@ export const INSTANCE_MAX_CPU = 254 export const INSTANCE_MIN_RAM_GiB = 1 export const INSTANCE_MAX_RAM_GiB = 1536 +// Webhook endpoint URL column width. The API does no length validation, so a +// longer URL fails with a database error rather than a 400. +// https://github.com/oxidecomputer/omicron/blob/6db4c7e/schema/crdb/dbinit.sql#L7192 +export const WEBHOOK_ENDPOINT_MAX_LENGTH = 512 + // Valid alert subscription: an alert class or a glob pattern matching multiple // classes. https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/types/versions/src/initial/alert.rs#L22-L23 export const ALERT_SUBSCRIPTION_REGEX = diff --git a/app/forms/webhook-create.tsx b/app/forms/webhook-create.tsx index 72d6bd0fd9..fc9186f37a 100644 --- a/app/forms/webhook-create.tsx +++ b/app/forms/webhook-create.tsx @@ -8,7 +8,7 @@ import { useController, useForm, useWatch, type Control } from 'react-hook-form' import { useNavigate } from 'react-router' -import { api, queryClient, useApiMutation } from '@oxide/api' +import { api, queryClient, useApiMutation, WEBHOOK_ENDPOINT_MAX_LENGTH } from '@oxide/api' import { Webhooks24Icon } from '@oxide/design-system/icons/react' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -39,6 +39,9 @@ export const validateEndpoint = (value: string) => { if (url.protocol !== 'https:' && url.protocol !== 'http:') { return 'Must be an HTTP or HTTPS URL' } + if (value.length > WEBHOOK_ENDPOINT_MAX_LENGTH) { + return `Must be at most ${WEBHOOK_ENDPOINT_MAX_LENGTH} characters` + } } export type WebhookCreateFormValues = { diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx index c83bca79df..3a43a57585 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -330,7 +330,7 @@ function SecretsCard() { 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.' + ? 'Deleting the only secret stops deliveries until a new one is added.' : undefined, }), }, diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 58cabc3cdf..777b0a193b 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -86,6 +86,12 @@ test('Webhook receiver create', async ({ page }) => { ).toBeVisible() // at least one secret is required await expect(main.getByText('At least one secret is required')).toBeVisible() + // and no longer than the database column holding it + await page + .getByRole('textbox', { name: 'Endpoint URL' }) + .fill(`https://ci.example.com/${'a'.repeat(512)}`) + await page.getByRole('button', { name: 'Create webhook receiver' }).click() + await expect(main.getByText('Must be at most 512 characters')).toBeVisible() await page.getByRole('textbox', { name: 'Endpoint URL' }).fill('https://ci.example.com') // add a secret; it lands in the mini table @@ -304,7 +310,7 @@ test('Webhook receiver detail: properties, subscriptions, secrets', async ({ pag 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 expect(page.getByText('Deleting the only secret stops deliveries')).toBeVisible() await page.getByRole('button', { name: 'Cancel' }).click() }) From e2a60859f116332346a01ca9d2453a2173bbeac8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Sep 2026 15:50:47 +0100 Subject: [PATCH 33/39] copy cleanup: alerts -> subscriptions --- app/pages/system/alerting/AlertReceiversTab.tsx | 4 ++-- test/e2e/alerts.e2e.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/pages/system/alerting/AlertReceiversTab.tsx b/app/pages/system/alerting/AlertReceiversTab.tsx index 965febc26e..5f61f55096 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: 'Alerts', + header: 'Subscriptions', cell: (info) => ( - + {info.getValue().map((sub) => ( {sub} diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 777b0a193b..b9faf31f05 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -56,11 +56,11 @@ test('Alert receivers list', async ({ page }) => { await expectRowVisible(table, { name: 'webhook-1', - Alerts: 'hardware.power_shelf.psu.insert+1', + Subscriptions: 'hardware.power_shelf.psu.insert+1', description: 'Main web deployments', }) - await expectRowVisible(table, { name: 'power-mon', Alerts: 'hardware.**' }) - await expectRowVisible(table, { name: 'general-sys-webhook', Alerts: '—' }) + await expectRowVisible(table, { name: 'power-mon', Subscriptions: 'hardware.**' }) + await expectRowVisible(table, { name: 'general-sys-webhook', Subscriptions: '—' }) }) test('Webhook receiver create', async ({ page }) => { @@ -130,7 +130,7 @@ test('Webhook receiver create', async ({ page }) => { await expectRowVisible(page.getByRole('table'), { name: 'deploy-hook', - Alerts: 'hardware.**', + Subscriptions: 'hardware.**', description: 'CI deploys', }) }) From eb2c68cff631f41e941441cfc46f1546cc324290 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Sep 2026 18:25:22 +0100 Subject: [PATCH 34/39] Update alerts list to show audit-log-style layout; lowercasing class names; a few other small tweaks --- app/components/AlertClassBadge.tsx | 47 +++++ app/components/HighlightJSON.tsx | 111 ++++++++++++ app/components/SubscriptionMatchPreview.tsx | 6 +- .../alerting/AlertReceiverDeliveries.tsx | 7 +- .../system/alerting/AlertReceiverPage.tsx | 5 +- .../system/alerting/AlertReceiversTab.tsx | 6 +- app/pages/system/alerting/AlertsTab.tsx | 167 ++++++++++++------ app/table/QueryTable.tsx | 91 ++++++---- app/ui/lib/DateTime.tsx | 15 +- app/ui/lib/MiniTable.tsx | 13 +- app/util/classed.ts | 12 ++ app/util/date.ts | 16 ++ test/e2e/alerts.e2e.ts | 56 ++++-- 13 files changed, 424 insertions(+), 128 deletions(-) create mode 100644 app/components/AlertClassBadge.tsx create mode 100644 app/components/HighlightJSON.tsx diff --git a/app/components/AlertClassBadge.tsx b/app/components/AlertClassBadge.tsx new file mode 100644 index 0000000000..ca24c3ffe0 --- /dev/null +++ b/app/components/AlertClassBadge.tsx @@ -0,0 +1,47 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useRef, useState } from 'react' + +import { Badge } from '@oxide/design-system/ui' + +import { Tooltip } from '~/ui/lib/Tooltip' + +/** + * Badge for an alert class or subscription glob. Badges uppercase their text + * by default, but alert classes are lowercase in the API and someone who copies + * one out of the UI needs it to work as a subscription, so keep the case. + * + * Long classes truncate to fit their container, with the full name in a + * tooltip when that happens. + */ +export const AlertClassBadge = ({ children }: { children: string }) => { + const ref = useRef(null) + const [truncated, setTruncated] = useState(false) + + // Checked lazily on hover, like `Truncate`, so there's no per-badge + // ResizeObserver and the answer can't go stale between resize and hover + const checkTruncation = () => { + const el = ref.current + if (el) setTruncated(el.scrollWidth > el.clientWidth) + } + + return ( + // Badge doesn't forward refs or event handlers, so the tooltip anchors to a + // wrapper and the overflow check lives on our own span inside the badge + + + + + {children} + + + + + ) +} diff --git a/app/components/HighlightJSON.tsx b/app/components/HighlightJSON.tsx new file mode 100644 index 0000000000..3e672088e3 --- /dev/null +++ b/app/components/HighlightJSON.tsx @@ -0,0 +1,111 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +// Lifted from the audit log page (PR #2860) so the alert views can share it. +// Once that lands, AuditLog.tsx should import from here instead of keeping its +// own copy. + +import { memo } from 'react' +import { type JsonValue } from 'type-fest' + +const Indent = ({ depth }: { depth: number }) => ( + +) + +const greenText = 'text-(--color-green-1000) light:text-(--color-green-600)' +const yellowText = 'text-(--color-yellow-1000) light:text-(--color-yellow-600)' + +const Primitive = ({ value }: { value: JsonValue | Date }) => { + if (value === null) return null + if (typeof value === 'string') return {`"${value}"`} + if (value instanceof Date) + return {`"${value.toISOString()}"`} + if (typeof value === 'boolean' || typeof value === 'number') { + return {String(value)} + } + // objects/arrays are handled by HighlightJSON, never reach here + return null +} + +type Props = { + // `unknown` rather than JsonValue because the values come from API payloads + // typed `Record` and the renderer switches on runtime type + // anyway. Anything that isn't JSON-like renders nothing. + json: unknown + depth?: number + /** Render on one line with no indentation, for a truncated preview */ + inline?: boolean +} + +// memo is important to avoid re-renders if the value hasn't changed. value +// passed in must be referentially stable, which should generally be the case +// with API responses +export const HighlightJSON = memo(({ json, depth = 0, inline = false }: Props) => { + if (json === undefined) return null + + if ( + json === null || + typeof json === 'boolean' || + typeof json === 'number' || + typeof json === 'string' || + // special case. the types don't currently reflect that this is possible. + // dates have type object so you can't use typeof + json instanceof Date + ) { + return + } + + // in inline mode a space stands in for the newline + indent between entries + const open = inline ? ' ' : '\n' + const indent = (d: number) => (inline ? null : ) + + if (Array.isArray(json)) { + if (json.length === 0) return [] + + return ( + <> + [ + {open} + {json.map((item, index) => ( + + {indent(depth + 1)} + + {index < json.length - 1 && ,} + {open} + + ))} + {indent(depth)} + ] + + ) + } + + if (typeof json !== 'object') return null + + const entries = Object.entries(json) + if (entries.length === 0) return {'{}'} + + return ( + <> + {'{'} + {open} + {entries.map(([key, val], index) => ( + + {indent(depth + 1)} + {key} + : + + {index < entries.length - 1 && ,} + {open} + + ))} + {indent(depth)} + {'}'} + + ) +}) diff --git a/app/components/SubscriptionMatchPreview.tsx b/app/components/SubscriptionMatchPreview.tsx index bfabf4edeb..40e9d57fde 100644 --- a/app/components/SubscriptionMatchPreview.tsx +++ b/app/components/SubscriptionMatchPreview.tsx @@ -8,7 +8,6 @@ import { useQuery } from '@tanstack/react-query' import { api, q } from '@oxide/api' -import { Badge } from '@oxide/design-system/ui' import { ALERT_SUBSCRIPTION_REGEX, @@ -16,6 +15,7 @@ import { isSubscribableClass, subscriptionRegex, } from '~/api/util' +import { AlertClassBadge } from '~/components/AlertClassBadge' import { ALL_ISH } from '~/util/consts' /** @@ -52,9 +52,7 @@ export function SubscriptionMatchPreview({ pattern }: { pattern: string }) { Matches {classes.length} alert {classes.length === 1 ? 'class' : 'classes'}:{' '} {classes.map((c) => ( - - {c.name} - + {c.name} ))}

diff --git a/app/pages/system/alerting/AlertReceiverDeliveries.tsx b/app/pages/system/alerting/AlertReceiverDeliveries.tsx index bb79daa587..91c4ba026c 100644 --- a/app/pages/system/alerting/AlertReceiverDeliveries.tsx +++ b/app/pages/system/alerting/AlertReceiverDeliveries.tsx @@ -25,6 +25,7 @@ import { import { Webhooks16Icon, Webhooks24Icon } from '@oxide/design-system/icons/react' import { Badge, Button, type BadgeColor } from '@oxide/design-system/ui' +import { AlertClassBadge } from '~/components/AlertClassBadge' import { useIntervalPicker } from '~/components/RefetchIntervalPicker' import { useAlertReceiverSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' @@ -83,7 +84,7 @@ const staticDeliveryCols = [ deliveryColHelper.accessor('alertId', { ...Columns.shortId, header: 'Alert ID' }), deliveryColHelper.accessor('alertClass', { header: 'Alert class', - cell: (info) => {info.getValue()}, + cell: (info) => {info.getValue()}, }), deliveryColHelper.accessor('state', { cell: (info) => , @@ -132,7 +133,7 @@ export function DeliveriesTab() {

- {delivery.alertClass} + {delivery.alertClass} @@ -266,7 +267,7 @@ function DeliverySideModal({ - {delivery.alertClass} + {delivery.alertClass} diff --git a/app/pages/system/alerting/AlertReceiverPage.tsx b/app/pages/system/alerting/AlertReceiverPage.tsx index 3a43a57585..314e5ed015 100644 --- a/app/pages/system/alerting/AlertReceiverPage.tsx +++ b/app/pages/system/alerting/AlertReceiverPage.tsx @@ -22,9 +22,10 @@ import { type WebhookSecret, } from '@oxide/api' import { Webhooks24Icon } from '@oxide/design-system/icons/react' -import { Badge, Button } from '@oxide/design-system/ui' +import { Button } from '@oxide/design-system/ui' import { isSubscribableClass } from '~/api/util' +import { AlertClassBadge } from '~/components/AlertClassBadge' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { validateSubscription } from '~/components/form/fields/SubscriptionsField' import { TextField } from '~/components/form/fields/TextField' @@ -145,7 +146,7 @@ const subscriptionColHelper = createColumnHelper<{ subscription: string }>() const subscriptionCols = [ subscriptionColHelper.accessor('subscription', { header: 'Alert class', - cell: (info) => {info.getValue()}, + cell: (info) => {info.getValue()}, }), ] diff --git a/app/pages/system/alerting/AlertReceiversTab.tsx b/app/pages/system/alerting/AlertReceiversTab.tsx index 5f61f55096..4dec44ad2b 100644 --- a/app/pages/system/alerting/AlertReceiversTab.tsx +++ b/app/pages/system/alerting/AlertReceiversTab.tsx @@ -20,8 +20,8 @@ import { type AlertReceiver, } from '@oxide/api' import { Webhooks24Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' +import { AlertClassBadge } from '~/components/AlertClassBadge' import { HL } from '~/components/HL' import { ListPlusCell } from '~/components/ListPlusCell' import { makeCrumb } from '~/hooks/use-crumbs' @@ -59,9 +59,7 @@ const staticColumns = [ cell: (info) => ( {info.getValue().map((sub) => ( - - {sub} - + {sub} ))} ), diff --git a/app/pages/system/alerting/AlertsTab.tsx b/app/pages/system/alerting/AlertsTab.tsx index 4cffc06016..f2fba84fc3 100644 --- a/app/pages/system/alerting/AlertsTab.tsx +++ b/app/pages/system/alerting/AlertsTab.tsx @@ -6,24 +6,26 @@ * Copyright Oxide Computer Company */ -import { createColumnHelper } from '@tanstack/react-table' -import { memo, useState } from 'react' +import cn from 'classnames' +import { memo, useMemo, useState } from 'react' import { api, getListQFn, queryClient, snakeify, type Alert } from '@oxide/api' import { Webhooks24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' +import { AlertClassBadge } from '~/components/AlertClassBadge' +import { HighlightJSON } from '~/components/HighlightJSON' 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 { usePaginatedList } from '~/table/QueryTable' import { Button } from '~/ui/lib/Button' import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' -import { DateTime } from '~/ui/lib/DateTime' +import { DateTime, SyslogDateTime } from '~/ui/lib/DateTime' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { SideModal } from '~/ui/lib/SideModal' import { TableEmptyBox } from '~/ui/lib/Table' +import { Truncate } from '~/ui/lib/Truncate' +import { roleDiv } from '~/util/classed' export const handle = { crumb: 'Alerts' } @@ -34,38 +36,87 @@ export async function clientLoader() { return null } -const colHelper = createColumnHelper() -const staticCols = [ - colHelper.accessor('class', { - cell: (info) => {info.getValue()}, - }), - colHelper.accessor('timeCreated', Columns.timeCreated), - colHelper.accessor( - (alert: Alert) => - alert.timeCreated.getTime() === alert.timeModified.getTime() - ? undefined - : alert.timeModified, - { - header: 'modified', - cell: (info) => { - const value: Date | undefined = info.getValue() - return value === undefined ? : - }, - } - ), -] +/* + * A log-style list like the audit log rather than a resource table: fixed-width + * columns on the left and the payload filling whatever is left. Flex layout + * strips the implicit semantics of table elements, so like MiniTable this is + * divs with explicit ARIA table roles. + */ +const Table = roleDiv('table', 'text-sans-md') +const Row = roleDiv('row', 'flex items-center gap-8 border-secondary border-b') +const HeadCell = roleDiv('columnheader', 'text-mono-sm text-tertiary') +const Cell = roleDiv('cell', '') + +// Header and body cells share these so the columns line up. The payload and +// ID columns drop out first as the tab panel (a container) narrows. +const col = { + time: 'w-31 shrink-0', + id: 'w-32.5 shrink-0 @max-[600px]:hidden', + // wide enough for the current classes, e.g. hardware.power_shelf.psu.insert + class: 'w-72 shrink-0', + payload: 'min-w-0 flex-1 @max-[800px]:hidden', +} + +const getId = (alert: Alert) => alert.id + +type AlertRowProps = { + alert: Alert + selected: boolean + onSelect: (alert: Alert) => void +} + +// memoized so opening the detail for one row doesn't re-render the JSON +// preview in every other row +const AlertRow = memo(function AlertRow({ alert, selected, onSelect }: AlertRowProps) { + // stable object identity so HighlightJSON's memo holds across re-renders + const payload = useMemo(() => snakeify(alert.alert), [alert]) + const hasPayload = Object.keys(alert.alert).length > 0 -function AlertDetail({ alert, onDismiss }: { alert: Alert; onDismiss: () => void }) { return ( - {alert.class}
} + // The row itself is the click target, like the audit log. Keyboard and + // screen reader users get the visually hidden button in the first cell, + // which the row's focus ring reflects. + // oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions + onSelect(alert)} > + + + + + + + + + {alert.class} + + + {hasPayload ? : } + + + ) +}) + +function AlertDetail({ alert, onDismiss }: { alert: Alert; onDismiss: () => void }) { + return ( + + + {alert.class} + void } const ApiResponseViewer = memo(({ body }: { body: Record }) => { - const stringified = JSON.stringify(snakeify(body), null, 2) + // recomputing on every render would hand HighlightJSON a new object each + // time and defeat its memo + const snakeJson = useMemo(() => snakeify(body), [body]) + const stringified = useMemo(() => JSON.stringify(snakeJson, null, 2), [snakeJson]) return (
Alert body
-
-        {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: ( + const { items, isEmpty, pagination } = usePaginatedList(alertList, getId) + + if (isEmpty) { + return ( } @@ -134,12 +181,28 @@ export default function AlertsTab() { body="Alerts created by the system will appear here." /> - ), - }) + ) + } return ( <> - {table} +
+ + Created + Alert ID + Class + Payload + + {items.map((alert) => ( + + ))} +
+ {pagination} {detail && setDetail(null)} />} ) diff --git a/app/table/QueryTable.tsx b/app/table/QueryTable.tsx index 8883d4e292..851cb70574 100644 --- a/app/table/QueryTable.tsx +++ b/app/table/QueryTable.tsx @@ -55,14 +55,15 @@ function useScrollReset(triggerDep: string | undefined) { } } -// require ID only so we can use it in getRowId -export function useQueryTable({ - query, - rowHeight = 'small', - emptyState, - columns, - getId, -}: QueryTableProps) { +/** + * The data half of `useQueryTable`: fetch the current page of a paginated + * query and render the pagination controls for it. For lists that need this + * plumbing but render something other than a `Table`. + */ +export function usePaginatedList( + query: PaginatedQuery>, + getId: (item: TItem) => string +) { // hash the first-page key, not the current one, so paging through the same // query doesn't read as a query change const queryId = hashKey(query.optionsFn().queryKey) @@ -72,51 +73,73 @@ export function useQueryTable({ // only ensure prefetched if we're on the first page if (currentPage === undefined) ensurePrefetched(queryResult, queryOptions.queryKey) const { data, isPlaceholderData } = queryResult - const tableData = useMemo(() => data?.items || [], [data]) + const items = useMemo(() => data?.items || [], [data]) + + // trigger by first item ID and not, e.g., currentPage because currentPage + // changes as soon as you click Next, while the item ID doesn't change until + // the page actually changes. + const first = items.at(0) + const requestScrollReset = useScrollReset(first ? getId(first) : undefined) + const isEmpty = items.length === 0 && !hasPrev + + const pagination = ( + { + requestScrollReset() + goToNextPage(p) + }} + onPrev={() => { + requestScrollReset() + goToPrevPage() + }} + // I can't believe how well this works, but it exactly matches when + // we want to show the spinner. Cached page changes don't need it. + loading={isPlaceholderData} + /> + ) + + return { items, isEmpty, pagination, query: queryResult } +} + +// require ID only so we can use it in getRowId +export function useQueryTable({ + query, + rowHeight = 'small', + emptyState, + columns, + getId, +}: QueryTableProps) { const getRowId = getId ? getId : // @ts-expect-error we know from the types that getId is only defined when there is no ID (row: TItem) => row.id as string - // trigger by first item ID and not, e.g., currentPage because currentPage - // changes as soon as you click Next, while the item ID doesn't change until - // the page actually changes. - const first = tableData.at(0) - const requestScrollReset = useScrollReset(first ? getRowId(first) : undefined) + const { + items, + isEmpty, + pagination, + query: queryResult, + } = usePaginatedList(query, getRowId) const table = useReactTable({ columns, - data: tableData, + data: items, getRowId, getCoreRowModel: getCoreRowModel(), manualPagination: true, }) - const isEmpty = tableData.length === 0 && !hasPrev - const tableElement = isEmpty ? ( {emptyState || } ) : ( <> - { - requestScrollReset() - goToNextPage(p) - }} - onPrev={() => { - requestScrollReset() - goToPrevPage() - }} - // I can't believe how well this works, but it exactly matches when - // we want to show the spinner. Cached page changes don't need it. - loading={isPlaceholderData} - /> + {pagination} ) diff --git a/app/ui/lib/DateTime.tsx b/app/ui/lib/DateTime.tsx index ed91285f83..39464e1383 100644 --- a/app/ui/lib/DateTime.tsx +++ b/app/ui/lib/DateTime.tsx @@ -6,7 +6,12 @@ * Copyright Oxide Computer Company */ -import { toLocaleDateString, toLocaleTimeString } from '~/util/date' +import { + toLocaleDateString, + toLocaleTimeString, + toSyslogDateString, + toSyslogTimeString, +} from '~/util/date' export const DateTime = ({ date, locale }: { date: Date; locale?: string }) => ( ) + +/** Compact log-style timestamp like `Jan 21 23:33:45`, mono, with the date dimmed */ +export const SyslogDateTime = ({ date, locale }: { date: Date; locale?: string }) => ( + +) diff --git a/app/ui/lib/MiniTable.tsx b/app/ui/lib/MiniTable.tsx index cbfd9f919c..9e3d5aff73 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -6,10 +6,12 @@ * Copyright Oxide Computer Company */ import cn from 'classnames' -import { useRef, useState, type JSX, type ReactNode } from 'react' +import { useRef, useState, type ReactNode } from 'react' import { Error16Icon } from '@oxide/design-system/icons/react' +import { roleDiv } from '~/util/classed' + import { Button } from './Button' import { EmptyMessage } from './EmptyMessage' import { Tooltip } from './Tooltip' @@ -23,15 +25,6 @@ import { Tooltip } from './Tooltip' * than the semantic elements. */ -/** Like `classed.div`, but with an ARIA role too */ -function roleDiv(role: string, baseClassName: string) { - const Comp = ({ className, ...rest }: JSX.IntrinsicElements['div']) => ( -
- ) - Comp.displayName = `roled.${role}` - return Comp -} - /** Divider between cells, inset so it doesn't touch the row's y borders */ const headerSeparator = `relative before:border-secondary before:absolute before:inset-y-px before:left-0 before:w-px before:border-l before:content-['']` const rowSeparator = `relative before:border-tertiary before:absolute before:inset-y-px before:left-0 before:w-px before:border-l before:content-['']` diff --git a/app/util/classed.ts b/app/util/classed.ts index e51ecd83a8..7eab022de9 100644 --- a/app/util/classed.ts +++ b/app/util/classed.ts @@ -49,3 +49,15 @@ export const classed = { } as const // result: classed.button`text-green-500 uppercase` returns a component with those classes + +/** + * Like `classed.div`, but with an ARIA role too. For grid or flex layouts that + * need table semantics, where `display: grid` on real table elements would + * strip them anyway. + */ +export function roleDiv(role: string, baseClassName: string) { + const Comp = ({ className, ...rest }: JSX.IntrinsicElements['div']) => + React.createElement('div', { role, className: cn(baseClassName, className), ...rest }) + Comp.displayName = `roled.${role}` + return Comp +} diff --git a/app/util/date.ts b/app/util/date.ts index 9f504267df..81aa17e167 100644 --- a/app/util/date.ts +++ b/app/util/date.ts @@ -53,3 +53,19 @@ export const toLocaleTimeString = (d: Date, locale?: string) => export const toLocaleDateTimeString = (d: Date, locale?: string) => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d) + +// `Jan 21` +export const toSyslogDateString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + }).format(d) + +// `23:33:45` +export const toSyslogTimeString = (d: Date, locale?: string) => + new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(d) diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index b9faf31f05..0297c5e7c3 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -717,27 +717,47 @@ test('Alert list basics', async ({ page }) => { 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' }) + // newest first, with the ID and a one-line preview of the payload + await expectRowVisible(table, { + 'Alert ID': expect.stringContaining('26cb0726'), + Class: 'hardware.power_shelf.psu.insert', + Payload: expect.stringContaining('rack_id'), + }) + await expectRowVisible(table, { + 'Alert ID': expect.stringContaining('8c8a74ba'), + Class: 'hardware.power_shelf.psu.remove', + }) + // the probe alert has an empty payload + await expectRowVisible(table, { Class: 'probe', Payload: '—' }) + + // alert classes must stay lowercase so they can be copied into a subscription + await expect(table.getByText('hardware.power_shelf.psu.insert').first()).toHaveCSS( + 'text-transform', + 'none' + ) }) test('Alert list detail view', async ({ page }) => { await page.goto('/system/alerting/alerts') - 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)) + const rows = page.getByRole('table').getByRole('row') + + // the whole row opens the details + await rows.filter({ hasText: '26cb0726' }).click() + const modal = page.getByRole('dialog', { name: 'Alert details' }) + await expect(modal).toBeVisible() + const alertBody = modal.locator('pre') + await expect(alertBody).toContainText('"Murata"') + await expect(alertBody).toContainText('slot: 0') + await modal.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + await expect(modal).toBeHidden() + + // keyboard users have a hidden button per row + await rows + .filter({ hasText: '0d38abba' }) + .getByRole('button', { name: 'View alert details' }) + .focus() + await page.keyboard.press('Enter') + await expect(modal).toBeVisible() + await expect(modal.locator('pre')).toContainText('slot: 3') }) From 0f795c6f4de92331fcc63fb9c2a08cfad6c44e79 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Sep 2026 13:48:36 -0400 Subject: [PATCH 35/39] copy change --- app/pages/system/alerting/AlertsTab.tsx | 4 ++-- test/e2e/alerts.e2e.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/pages/system/alerting/AlertsTab.tsx b/app/pages/system/alerting/AlertsTab.tsx index f2fba84fc3..06cf9e21cd 100644 --- a/app/pages/system/alerting/AlertsTab.tsx +++ b/app/pages/system/alerting/AlertsTab.tsx @@ -114,7 +114,7 @@ function AlertDetail({ alert, onDismiss }: { alert: Alert; onDismiss: () => void - + {alert.class} Created Alert ID - Class + Alert class Payload {items.map((alert) => ( diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 0297c5e7c3..c687440cca 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -720,15 +720,15 @@ test('Alert list basics', async ({ page }) => { // newest first, with the ID and a one-line preview of the payload await expectRowVisible(table, { 'Alert ID': expect.stringContaining('26cb0726'), - Class: 'hardware.power_shelf.psu.insert', + 'Alert class': 'hardware.power_shelf.psu.insert', Payload: expect.stringContaining('rack_id'), }) await expectRowVisible(table, { 'Alert ID': expect.stringContaining('8c8a74ba'), - Class: 'hardware.power_shelf.psu.remove', + 'Alert class': 'hardware.power_shelf.psu.remove', }) // the probe alert has an empty payload - await expectRowVisible(table, { Class: 'probe', Payload: '—' }) + await expectRowVisible(table, { 'Alert class': 'probe', Payload: '—' }) // alert classes must stay lowercase so they can be copied into a subscription await expect(table.getByText('hardware.power_shelf.psu.insert').first()).toHaveCSS( From ade7b5776a752cb4f3b9e790ee37c48f4751118d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 2 Sep 2026 13:54:39 -0500 Subject: [PATCH 36/39] remove SideModal.Section, fix doubled gutter in alert side modals --- .../alerting/AlertReceiverDeliveries.tsx | 36 +++++------ app/pages/system/alerting/AlertsTab.tsx | 63 ++++++++----------- app/ui/lib/SideModal.tsx | 9 ++- 3 files changed, 51 insertions(+), 57 deletions(-) diff --git a/app/pages/system/alerting/AlertReceiverDeliveries.tsx b/app/pages/system/alerting/AlertReceiverDeliveries.tsx index 91c4ba026c..785a15b9ac 100644 --- a/app/pages/system/alerting/AlertReceiverDeliveries.tsx +++ b/app/pages/system/alerting/AlertReceiverDeliveries.tsx @@ -264,25 +264,23 @@ function DeliverySideModal({ } > - - - - {delivery.alertClass} - - - - - - - - - - - {delivery.trigger} - - - - + + + {delivery.alertClass} + + + + + + + + + + + {delivery.trigger} + + + Attempts diff --git a/app/pages/system/alerting/AlertsTab.tsx b/app/pages/system/alerting/AlertsTab.tsx index 06cf9e21cd..917a42af26 100644 --- a/app/pages/system/alerting/AlertsTab.tsx +++ b/app/pages/system/alerting/AlertsTab.tsx @@ -14,10 +14,10 @@ import { Webhooks24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' import { AlertClassBadge } from '~/components/AlertClassBadge' +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' import { HighlightJSON } from '~/components/HighlightJSON' import { EmptyCell } from '~/table/cells/EmptyCell' import { usePaginatedList } from '~/table/QueryTable' -import { Button } from '~/ui/lib/Button' import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' import { DateTime, SyslogDateTime } from '~/ui/lib/DateTime' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -110,41 +110,32 @@ const AlertRow = memo(function AlertRow({ alert, selected, onSelect }: AlertRowP function AlertDetail({ alert, onDismiss }: { alert: Alert; onDismiss: () => void }) { return ( - - - - - - {alert.class} - - - {alert.version} - - - - - - - - - - - - - - - - + + + + {alert.class} + + + {alert.version} + + + + + + + + + + + ) } diff --git a/app/ui/lib/SideModal.tsx b/app/ui/lib/SideModal.tsx index a25e058765..8c193f5080 100644 --- a/app/ui/lib/SideModal.tsx +++ b/app/ui/lib/SideModal.tsx @@ -42,6 +42,13 @@ export type SideModalProps = { animate?: boolean } +/** + * Low-level side modal shell. Most callers want `SideModalForm` or + * `ReadOnlySideModalForm` from `~/components/form`, which wire up the body, + * footer, and content spacing. Use this directly only for layouts those don't + * fit, and note that `SideModal.Body` handles horizontal gutters itself, so + * children should not add their own padding. + */ export function SideModal({ children, onDismiss, @@ -127,8 +134,6 @@ SideModal.Body = ({ children }: { children?: ReactNode }) => ( SideModal.Heading = classed.div`text-sans-semi-xl text-raise` -SideModal.Section = classed.div`p-8 space-y-6 border-secondary` - SideModal.Footer = ({ children, error }: { children: ReactNode; error?: boolean }) => (
{error && ( From 3c9557b237a8ac9064c5684d2ad60110f41362a5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Sep 2026 18:05:31 -0400 Subject: [PATCH 37/39] save valid globs on blur --- app/components/form/fields/SubscriptionsField.tsx | 10 +++++++--- test/e2e/alerts.e2e.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/components/form/fields/SubscriptionsField.tsx b/app/components/form/fields/SubscriptionsField.tsx index c7705df6ed..a27ec9dfa3 100644 --- a/app/components/form/fields/SubscriptionsField.tsx +++ b/app/components/form/fields/SubscriptionsField.tsx @@ -343,9 +343,13 @@ export function SubscriptionsField({ onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) { closePanel() - // discard uncommitted text so it doesn't read as added - setQuery('') - setCommitError(undefined) + // valid globs save on blur + if (isGlobPattern(queryTrimmed) && !validateSubscription(queryTrimmed)) { + commitQuery() + } else { + setQuery('') + setCommitError(undefined) + } } }} > diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index c687440cca..892768737e 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -243,10 +243,18 @@ test('Webhook receiver create: subscriptions field', async ({ page }) => { await subsInput.fill('leftover') await page.getByRole('textbox', { name: 'Name' }).click() await expect(subsInput).toHaveValue('') + await expect(chipRemove('leftover')).toBeHidden() // subscribed classes sort to the top when the panel opens await subsInput.click() await expect(listbox.getByRole('option').first()).toContainText('system.update.fail') + + // but a valid glob commits on blur, so typing one and going straight to the + // submit button doesn't silently drop it + await subsInput.fill('hardware.disk.*') + await page.getByRole('textbox', { name: 'Name' }).click() + await expect(subsInput).toHaveValue('') + await expect(chipRemove('hardware.disk.*')).toBeVisible() }) test('Webhook receiver detail: properties, subscriptions, secrets', async ({ page }) => { From 4549a5cca752669bb75778b5ea613f0f988e4e93 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Sep 2026 18:21:30 -0400 Subject: [PATCH 38/39] a few small fixes --- .../alerting/AlertReceiverDeliveries.tsx | 5 +-- mock-api/alert.ts | 2 + mock-api/msw/handlers.ts | 7 +++- test/e2e/alerts.e2e.ts | 39 +++++++------------ 4 files changed, 23 insertions(+), 30 deletions(-) diff --git a/app/pages/system/alerting/AlertReceiverDeliveries.tsx b/app/pages/system/alerting/AlertReceiverDeliveries.tsx index 785a15b9ac..0060f4e7bc 100644 --- a/app/pages/system/alerting/AlertReceiverDeliveries.tsx +++ b/app/pages/system/alerting/AlertReceiverDeliveries.tsx @@ -16,6 +16,7 @@ import { getListQFn, q, queryClient, + snakeify, useApiMutation, type Alert, type AlertDelivery, @@ -115,7 +116,6 @@ export function DeliveriesTab() { }, { label: 'Resend', - disabled: delivery.trigger === 'probe' && 'Probes cannot be resent', onActivate: () => confirmAction({ doAction: () => @@ -322,9 +322,8 @@ function DeliverySideModal({ // 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 ') + JSON.stringify(snakeify(alert.alert), null, 2).replaceAll('\n', '\n ') const payloadJson = (delivery: AlertDelivery, sentAt: string, alert?: Alert) => `{ "alert_class": ${JSON.stringify(delivery.alertClass)}, diff --git a/mock-api/alert.ts b/mock-api/alert.ts index 8cbbe565ed..ca33a198ec 100644 --- a/mock-api/alert.ts +++ b/mock-api/alert.ts @@ -189,6 +189,8 @@ export const alerts: Json[] = [ // the mock paginated() helper ignores sortBy and preserves array order export const alertDeliveries: Json[] = [ { + // stored but never returned by alertDeliveryList, like omicron. kept so + // the exclusion is exercised id: '9bbdf44f-7dac-4cd0-b4c2-3e622c9693ee', alert_id: PROBE_ALERT_ID, alert_class: 'probe', diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c173f39067..4bb3142447 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -2783,7 +2783,12 @@ export const handlers = makeHandlers({ requireFleetViewer(cookies) const receiver = lookup.alertReceiver(path) retryPendingDeliveries(receiver) - let deliveries = db.alertDeliveries.filter((d) => d.receiver_id === receiver.id) + // probe deliveries are stored like any other but never listed, matching + // omicron, which only queries the alert and resend triggers here + // https://github.com/oxidecomputer/omicron/blob/17e6fee/nexus/src/app/alert.rs#L355-L365 + let deliveries = db.alertDeliveries.filter( + (d) => d.receiver_id === receiver.id && d.trigger !== 'probe' + ) // if any state filters are specified, only include deliveries in those states const states = [ query.delivered && 'delivered', diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts index 892768737e..97040769f4 100644 --- a/test/e2e/alerts.e2e.ts +++ b/test/e2e/alerts.e2e.ts @@ -10,13 +10,7 @@ import { expect, test, type Page } from '@playwright/test' import { alerts } from '@oxide/api-mocks' -import { - clickRowAction, - clickRowActions, - expectRowVisible, - expectToast, - selectOption, -} from './utils' +import { clickRowAction, expectRowVisible, expectToast, selectOption } from './utils' test('Alerting nav and tabs', async ({ page }) => { const sidebar = page.getByRole('navigation', { name: 'Sidebar navigation' }) @@ -468,18 +462,13 @@ test('Webhook receiver deliveries', async ({ page }) => { await page.getByRole('tab', { name: 'Deliveries' }).click() const table = page.getByRole('table') - await expect(table.getByRole('row')).toHaveCount(8) // header + 7 + // header + 6. the seeded probe delivery is excluded: the API never lists + // probe-triggered deliveries + await expect(table.getByRole('row')).toHaveCount(7) + await expect(table.getByText('9bbdf44f-7dac-4cd0-b4c2-3e622c9693ee')).toBeHidden() // Truncate renders the full ID (invisible, for stable layout) alongside the // ellipsized copy, so cell text contains both. Match on the full value. - await expectRowVisible(table, { - 'Delivery ID': expect.stringContaining('9bbdf44f-7dac-4cd0-b4c2-3e622c9693ee'), - // the singleton probe alert ID, shared by all probe deliveries - 'Alert ID': expect.stringContaining('001de000-7768-4000-8000-000000000001'), - 'Alert class': 'probe', - state: 'delivered', - trigger: 'probe', - }) await expectRowVisible(table, { 'Delivery ID': expect.stringContaining('30ece63e-5efd-4365-99a6-d4f09dfa685e'), 'Alert ID': expect.stringContaining('beef336d-99db-4b12-ac08-7ebcaab8421a'), @@ -496,7 +485,7 @@ test('Webhook receiver deliveries', async ({ page }) => { await selectOption(page, 'Filter by state', 'Failed') await expect(table.getByRole('row')).toHaveCount(4) // header + 3 failed await selectOption(page, 'Filter by state', 'All states') - await expect(table.getByRole('row')).toHaveCount(8) + await expect(table.getByRole('row')).toHaveCount(7) // delivery detail side modal shows attempts await clickRowAction(page, '30ece63e-5efd-4365-99a6-d4f09dfa685e', 'View details') @@ -527,6 +516,9 @@ test('Webhook receiver deliveries', async ({ page }) => { // alert version and data payload come from the alert record await expect(request.getByText('"alert_version": 0')).toBeVisible() await expect(request.getByText('"manufacturer": "Murata"')).toBeVisible() + // payload keys are snake_case like the body the receiver got, not the + // camelCase the client uses internally + await expect(request.getByText('"firmware_revision": "1.9"')).toBeVisible() // the signature can't be reconstructed, so it stays a placeholder await expect(request.getByText('a=sha256&id=&s=')).toBeVisible() await expect(request.getByText('x-oxide-alert-class')).toBeVisible() @@ -547,18 +539,13 @@ test('Webhook receiver deliveries', async ({ page }) => { ).toBeVisible() await confirmModal.getByRole('button', { name: 'Confirm' }).click() await expectToast(page, 'Delivery resend started') - await expect(table.getByRole('row')).toHaveCount(9) + await expect(table.getByRole('row')).toHaveCount(8) await expectRowVisible(table, { 'Alert class': 'hardware.power_shelf.psu.insert', state: 'pending', trigger: 'resend', }) - // probes can't be resent - await clickRowActions(page, '9bbdf44f-7dac-4cd0-b4c2-3e622c9693ee') - await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeDisabled() - await page.keyboard.press('Escape') - // send a liveness probe from the testing tab, resending failed deliveries await page.getByRole('tab', { name: 'Testing' }).click() await page.getByRole('button', { name: 'Send liveness probe' }).click() @@ -581,8 +568,8 @@ test('Webhook receiver deliveries', async ({ page }) => { // the result links to the deliveries tab, where the resends resolve await panel.getByRole('link', { name: 'View deliveries' }).click() - // 9 rows + the probe + the one resend - await expect(table.getByRole('row')).toHaveCount(11) + // 8 rows + the one resend. the probe itself is not listed + await expect(table.getByRole('row')).toHaveCount(9) }) // The bug that got this checkbox removed the first time: the mock resent every @@ -698,7 +685,7 @@ test('Resend fails for an unsubscribed alert class', async ({ page }) => { "Could not resend alertCannot resend alert: receiver is not subscribed to the 'hardware.power_shelf.psu.insert' alert class" ) // the rejected resend must not have created a new delivery - await expect(page.getByRole('table').getByRole('row')).toHaveCount(8) // header + 7 + await expect(page.getByRole('table').getByRole('row')).toHaveCount(7) // header + 6 }) test('Webhook receiver delete', async ({ page }) => { From ea4881cbd73f31747cf7b09a4e1331201ad58ae6 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Sep 2026 18:37:28 -0400 Subject: [PATCH 39/39] a few more small fixes --- app/forms/webhook-create.tsx | 4 ++-- app/forms/webhook-edit.tsx | 2 +- mock-api/msw/handlers.ts | 12 ++++-------- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/app/forms/webhook-create.tsx b/app/forms/webhook-create.tsx index fc9186f37a..de28b6b7a9 100644 --- a/app/forms/webhook-create.tsx +++ b/app/forms/webhook-create.tsx @@ -188,7 +188,7 @@ export default function CreateWebhookForm() { body: { name, description, endpoint, secrets, subscriptions }, }) }} - loading={createWebhook.isPending} + loading={createWebhook.isPending || createWebhook.isSuccess} submitError={createWebhook.error} > @@ -211,7 +211,7 @@ export default function CreateWebhookForm() { Secrets - + Create webhook receiver navigate(pb.alertReceivers())} /> diff --git a/app/forms/webhook-edit.tsx b/app/forms/webhook-edit.tsx index f66aaa3f62..f18f74e25d 100644 --- a/app/forms/webhook-edit.tsx +++ b/app/forms/webhook-edit.tsx @@ -81,7 +81,7 @@ export default function EditWebhookSideModalForm() { body: { name, description, endpoint }, }) }} - loading={editWebhook.isPending} + loading={editWebhook.isPending || editWebhook.isSuccess} submitError={editWebhook.error} > diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 4bb3142447..224081373f 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -2413,7 +2413,8 @@ export const handlers = makeHandlers({ ) return paginated(query, affinityGroups) }, - alertList: ({ query }) => { + alertList: ({ query, cookies }) => { + requireFleetViewer(cookies) const { startTime, endTime, alertClass } = query let final = db.alerts @@ -2421,13 +2422,8 @@ export const handlers = makeHandlers({ 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)) + const matcher = subscriptionRegex(alertClass) + final = final.filter((alert) => matcher.test(alert.class)) } final = match(query.sortBy)