diff --git a/app/api/selectors.ts b/app/api/selectors.ts index 0dd0bc122..e2b3ead6e 100644 --- a/app/api/selectors.ts +++ b/app/api/selectors.ts @@ -33,6 +33,7 @@ export type SshKey = Readonly<{ sshKey: string }> export type Sled = Readonly<{ sledId?: string }> export type IpPool = Readonly<{ pool?: string }> export type SubnetPool = Readonly<{ subnetPool?: string }> +export type AlertReceiver = Readonly<{ receiver?: string }> export type ExternalSubnet = Readonly> export type FloatingIp = Readonly> diff --git a/app/api/util.ts b/app/api/util.ts index f3091f865..68cb540f5 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 000000000..deaf07ee0 --- /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 000000000..0e537f2fe --- /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 000000000..769b40935 --- /dev/null +++ b/app/forms/webhook-edit.tsx @@ -0,0 +1,99 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' +import { useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getAlertReceiverSelector, useAlertReceiverSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import { validateEndpoint } from './webhook-create' + +const receiverView = ({ receiver }: PP.AlertReceiver) => + q(api.alertReceiverView, { path: { receiver } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getAlertReceiverSelector(params) + await queryClient.prefetchQuery(receiverView(selector)) + return null +} + +export const handle = titleCrumb('Edit webhook') + +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-pagination.spec.ts b/app/hooks/use-pagination.spec.ts index 62e12865e..c0d60eff7 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 f1749e502..48d365c57 100644 --- a/app/hooks/use-pagination.ts +++ b/app/hooks/use-pagination.ts @@ -9,10 +9,27 @@ import { useCallback, useState } from 'react' type PageToken = string | undefined -export function usePagination() { +/** + * @param queryId Identifies the query being paginated. When it changes, we jump + * back to the first page: a page token is only meaningful for the query that + * produced it, so carrying one across a query change (e.g., a filter above the + * table) means asking the API to resume from a position that doesn't exist in + * the new result set. + */ +export function usePagination(queryId?: string) { const [prevPages, setPrevPages] = useState([]) const [currentPage, setCurrentPage] = useState() + // Adjusting state during render rather than in an effect, as recommended by + // https://react.dev/learn/you-might-not-need-an-effect. An effect would let a + // render go out with the stale token, firing off a bogus request. + const [prevQueryId, setPrevQueryId] = useState(queryId) + if (queryId !== prevQueryId) { + setPrevQueryId(queryId) + setPrevPages([]) + setCurrentPage(undefined) + } + const goToPrevPage = useCallback(() => { const prevPage = prevPages.pop() setCurrentPage(prevPage) diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 5298181d9..f5f5524eb 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -53,6 +53,7 @@ export const requireSledParams = requireParams('sledId') export const requireUpdateParams = requireParams('version') export const getIpPoolSelector = requireParams('pool') export const getSubnetPoolSelector = requireParams('subnetPool') +export const getAlertReceiverSelector = requireParams('receiver') export const getAffinityGroupSelector = requireParams('project', 'affinityGroup') export const getAntiAffinityGroupSelector = requireParams('project', 'antiAffinityGroup') @@ -104,6 +105,7 @@ export const useSledParams = () => useSelectedParams(requireSledParams) export const useUpdateParams = () => useSelectedParams(requireUpdateParams) export const useIpPoolSelector = () => useSelectedParams(getIpPoolSelector) export const useSubnetPoolSelector = () => useSelectedParams(getSubnetPoolSelector) +export const useAlertReceiverSelector = () => useSelectedParams(getAlertReceiverSelector) export const useAffinityGroupSelector = () => useSelectedParams(getAffinityGroupSelector) export const useAntiAffinityGroupSelector = () => useSelectedParams(getAntiAffinityGroupSelector) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index fca0d33b8..f7a4fc01a 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 000000000..be70fb42b --- /dev/null +++ b/app/pages/system/alerts/AlertReceiverPage.tsx @@ -0,0 +1,924 @@ +/* + * 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, 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 { + 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 { 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' +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' +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' +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 { 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) + }, + }) + + return ( + <> + + }>{receiver.name} + + + Edit + + deleteReceiver({ path: { receiver: receiver.name } }), + label: receiver.name, + resourceKind: 'webhook', + extraContent: 'Its delivery history will also be deleted.', + })} + className="destructive" + /> + + + + + {receiver.kind.endpoint} + + + + + + + + Details + Deliveries + Testing + + + + + + + + + + + + + {/* for edit form */} + + ) +} + +// 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}
+
+ ))} +
+
+
+ ) +} + +// 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 form = useForm({ defaultValues: { subscription: '' } }) + const { control } = form + 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() + }, + }) + + return ( + + 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. + + } + /> + + + + ) +} + +// Secrets + +const secretColHelper = createColumnHelper() +const secretCols = [ + secretColHelper.accessor('id', Columns.id), + secretColHelper.accessor('timeCreated', Columns.timeCreated), +] + +function SecretsCard() { + const receiverSelector = useAlertReceiverSelector() + const { data: receiver } = usePrefetchedQuery(receiverView(receiverSelector)) + const [showAddModal, setShowAddModal] = useState(false) + + const { mutateAsync: deleteSecret } = useApiMutation(api.webhookSecretsDelete, { + onSuccess() { + queryClient.invalidateEndpoint('alertReceiverView') + addToast('Secret removed') + }, + }) + + const isOnlySecret = receiver.kind.secrets.length === 1 + const makeActions = useCallback( + (secret: WebhookSecret): MenuAction[] => [ + { + label: 'Delete', + className: 'destructive', + onActivate: confirmDelete({ + doDelete: () => deleteSecret({ path: { secretId: secret.id } }), + label: secret.id, + resourceKind: 'secret', + extraContent: isOnlySecret + ? 'This is the only secret on this receiver. Payloads sent without a secret are unsigned and cannot be verified.' + : undefined, + }), + }, + ], + [deleteSecret, isOnlySecret] + ) + + const columns = useColsWithActions(secretCols, makeActions) + // API returns secrets oldest first, but newest is more interesting + const secrets = useMemo( + () => R.sortBy(receiver.kind.secrets, [(s) => s.timeCreated, 'desc']), + [receiver.kind.secrets] + ) + const table = useReactTable({ + columns, + data: secrets, + getCoreRowModel: getCoreRowModel(), + }) + + return ( + + + + + + {receiver.kind.secrets.length ? ( +
+ ) : ( + + } + title="No secrets" + body="Add a secret to sign webhook payloads" + /> + + )} + + {showAddModal && setShowAddModal(false)} />} + + ) +} + +function AddSecretModal({ onDismiss }: { onDismiss: () => void }) { + const { receiver } = useAlertReceiverSelector() + const form = useForm({ defaultValues: { secret: '' } }) + + const addSecret = useApiMutation(api.webhookSecretsAdd, { + onSuccess() { + queryClient.invalidateEndpoint('alertReceiverView') + addToast('Secret added') + onDismiss() + }, + }) + + return ( + addSecret.mutate({ query: { receiver }, body: { secret } })} + loading={addSecret.isPending} + submitError={addSecret.error} + > + + + ) +} + +// 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, 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} + +
+ {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 + 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/alerts/AlertReceiversPage.tsx b/app/pages/system/alerts/AlertReceiversPage.tsx new file mode 100644 index 000000000..38cada388 --- /dev/null +++ b/app/pages/system/alerts/AlertReceiversPage.tsx @@ -0,0 +1,164 @@ +/* + * 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 { makeCrumb } from '~/hooks/use-crumbs' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { makeLinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { CreateLink } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { 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 webhooks" + body="Create a webhook 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 +} + +// 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() + + 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 webhook', + })), + ], + [allReceivers] + ) + + return ( + <> + + {/* 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 + + {table} + + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 2fdaadc22..4fb8598c4 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/table/QueryTable.tsx b/app/table/QueryTable.tsx index fdaef9786..8883d4e29 100644 --- a/app/table/QueryTable.tsx +++ b/app/table/QueryTable.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { useQuery } from '@tanstack/react-query' +import { hashKey, useQuery } from '@tanstack/react-query' import { getCoreRowModel, useReactTable, type ColumnDef } from '@tanstack/react-table' import { useEffect, useMemo, useRef } from 'react' @@ -63,7 +63,10 @@ export function useQueryTable({ columns, getId, }: QueryTableProps) { - const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination() + // hash the first-page key, not the current one, so paging through the same + // query doesn't read as a query change + const queryId = hashKey(query.optionsFn().queryKey) + const { currentPage, goToNextPage, goToPrevPage, hasPrev } = usePagination(queryId) const queryOptions = query.optionsFn(currentPage) const queryResult = useQuery(queryOptions) // only ensure prefetched if we're on the first page diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 300fee583..295fc605f 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -40,6 +40,38 @@ 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", + }, + ], + "alertReceivers (/system/alerts)": [ + { + "label": "Alerts", + "path": "/system/alerts", + }, + ], + "alertReceiversNew (/system/alerts-new)": [ + { + "label": "Alerts", + "path": "/system/alerts", + }, + ], "antiAffinityGroup (/projects/p/affinity/aag)": [ { "label": "Projects", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 9fc90181e..b478cbc1a 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 e09ad45aa..2878dc745 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 011afa41c..685ed59f9 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -30,4 +30,5 @@ export type SshKey = Required export type AffinityGroup = Required export type AntiAffinityGroup = Required export type SubnetPool = Required +export type AlertReceiver = Required export type Disk = Required diff --git a/mock-api/alert.ts b/mock-api/alert.ts new file mode 100644 index 000000000..8b6206bf8 --- /dev/null +++ b/mock-api/alert.ts @@ -0,0 +1,245 @@ +/* + * 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: [ + // distinct timestamps so newest-first ordering is deterministic + { + id: '88c7b9bb-fa79-4516-8f12-abebd2626062', + time_created: '2024-03-01T00:00:00Z', + }, + { + id: 'b15f4584-98f1-4cac-b0d3-67294e41aab7', + time_created: '2024-06-01T00:00:00Z', + }, + ], + }, + subscriptions: ['hardware.power_shelf.psu.insert', 'hardware.power_shelf.psu.remove'], + ...getTimestamps(), +} + +export const receiverPowerMon: Json = { + id: 'c4683abf-664f-4ece-b433-7fd228c1d2ea', + name: 'power-mon', + description: '', + kind: { + kind: 'webhook', + endpoint: 'https://power-mon.corp.oxide.computer/webhooks', + secrets: [ + { + id: 'bccb6692-d8d4-4d21-822f-50ea7809ef73', + time_created: new Date().toISOString(), + }, + ], + }, + subscriptions: ['hardware.**'], + ...getTimestamps(), +} + +export const receiverGeneral: Json = { + id: '423059fe-d340-4478-8734-141dbf19dc54', + name: 'general-sys-webhook', + description: '', + kind: { + kind: 'webhook', + endpoint: 'https://api.example.dev/hooks/oxide', + secrets: [ + { + id: '1a457038-b558-49e9-810b-bda6f73d2b85', + time_created: new Date().toISOString(), + }, + ], + }, + subscriptions: [], + ...getTimestamps(), +} + +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 3620d30c2..3abb4d639 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 9986205ed..7dbac4497 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 5f2b05637..98429e979 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,75 @@ 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 } +} + +/** How long a pending delivery waits before its next attempt */ +const RETRY_DELAY_MS = 5000 +/** After this many failed attempts the delivery fails permanently */ +const MAX_ATTEMPTS = 3 + +/** When each pending delivery, by ID, makes its next attempt */ +const nextAttemptAt = new Map() + +/** + * In the real system the deliverator RPW retries pending deliveries in the + * background, so pending is a transient state. Stand in for that by making one + * more attempt whenever the list is fetched after the retry delay has passed. + * State transitions match + * https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/db-queries/src/db/datastore/webhook_delivery.rs#L449-L473 + */ +function retryPendingDeliveries(receiver: Json) { + const now = Date.now() + // same sentinel as the liveness probe: endpoints we can't reach keep failing + const success = !receiver.kind.endpoint.includes('unreachable') + + for (const delivery of db.alertDeliveries) { + if (delivery.receiver_id !== receiver.id || delivery.state !== 'pending') continue + + const dueAt = nextAttemptAt.get(delivery.id) + if (dueAt === undefined) { + nextAttemptAt.set(delivery.id, now + RETRY_DELAY_MS) + continue + } + if (now < dueAt) continue + + const attempt = delivery.attempts.webhook.length + 1 + delivery.attempts.webhook.push({ + attempt, + result: success ? 'succeeded' : 'failed_unreachable', + response: success ? { status: 200, duration_ms: 137 } : null, + time_sent: new Date().toISOString(), + }) + delivery.state = success ? 'delivered' : attempt >= MAX_ATTEMPTS ? 'failed' : 'pending' + + if (delivery.state === 'pending') { + nextAttemptAt.set(delivery.id, now + RETRY_DELAY_MS) + } else { + nextAttemptAt.delete(delivery.id) + } + } +} + export const handlers = makeHandlers({ logout: () => 204, ping: () => ({ status: 'ok' }), @@ -2623,6 +2693,195 @@ export const handlers = makeHandlers({ return paginated(query, pools) }, + alertClassList({ query, cookies }) { + requireFleetViewer(cookies) + const filter = query.filter ? subscriptionRegex(query.filter) : null + // can't use paginated() because alert classes have no ID + return { items: alertClasses.filter((c) => !filter || filter.test(c.name)) } + }, + alertReceiverList({ query, cookies }) { + requireFleetViewer(cookies) + return paginated(query, db.alertReceivers) + }, + alertReceiverView({ path, cookies }) { + requireFleetViewer(cookies) + return lookup.alertReceiver(path) + }, + alertReceiverDelete({ path, cookies }) { + requireFleetAdmin(cookies) + const receiver = lookup.alertReceiver(path) + db.alertReceivers = db.alertReceivers.filter((r) => r.id !== receiver.id) + db.alertDeliveries = db.alertDeliveries.filter((d) => d.receiver_id !== receiver.id) + return 204 + }, + alertDeliveryList({ path, query, cookies }) { + requireFleetViewer(cookies) + const receiver = lookup.alertReceiver(path) + retryPendingDeliveries(receiver) + let deliveries = db.alertDeliveries.filter((d) => d.receiver_id === receiver.id) + // if any state filters are specified, only include deliveries in those states + const states = [ + query.delivered && 'delivered', + query.failed && 'failed', + query.pending && 'pending', + ].filter((s) => !!s) + if (states.length > 0) { + deliveries = deliveries.filter((d) => states.includes(d.state)) + } + return paginated(query, deliveries) + }, + alertReceiverProbe({ path, query, cookies }) { + requireFleetAdmin(cookies) + const receiver = lookup.alertReceiver(path) + const now = new Date().toISOString() + // sentinel to let tests exercise the failure path + const success = !receiver.kind.endpoint.includes('unreachable') + const probe: Json = { + id: uuid(), + 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 +2889,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 +3002,4 @@ export const handlers = makeHandlers({ userSessionList: NotImplemented, userTokenList: NotImplemented, userView: NotImplemented, - webhookReceiverCreate: NotImplemented, - webhookReceiverUpdate: NotImplemented, - webhookSecretsAdd: NotImplemented, - webhookSecretsDelete: NotImplemented, - webhookSecretsList: NotImplemented, }) diff --git a/test/e2e/alerts.e2e.ts b/test/e2e/alerts.e2e.ts new file mode 100644 index 000000000..9311baab1 --- /dev/null +++ b/test/e2e/alerts.e2e.ts @@ -0,0 +1,347 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { expect, test, type Page } from '@playwright/test' + +import { + 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: 'Webhooks' })).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 + + // newest first + await expect(secrets.getByRole('row').nth(1)).toContainText('b15f4584') + await expect(secrets.getByRole('row').nth(2)).toContainText('88c7b9bb') + + // add a secret + await page.getByRole('button', { name: 'Add secret' }).click() + const secretModal = page.getByRole('dialog', { name: 'Add secret' }) + await secretModal.getByRole('textbox', { name: 'Secret' }).fill('another-secret') + await secretModal.getByRole('button', { name: 'Add' }).click() + await expectToast(page, 'Secret added') + await expect(secrets.getByRole('row')).toHaveCount(4) + // the new secret sorts above the seeded ones + await expect(secrets.getByRole('row').nth(1)).not.toContainText('b15f4584') + + // delete one of the seeded secrets + await clickRowAction(page, '88c7b9bb-fa79-4516-8f12-abebd2626062', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Secret removed') + await expect(secrets.getByRole('row')).toHaveCount(3) + + // deleting down to one secret warns that payloads will be unverifiable + await clickRowAction(page, 'b15f4584-98f1-4cac-b0d3-67294e41aab7', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Secret removed') + const remainingRow = secrets.getByRole('row').nth(1) + await remainingRow.getByRole('button', { name: 'Row actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await expect(page.getByText('This is the only secret on this receiver')).toBeVisible() + await page.getByRole('button', { name: 'Cancel' }).click() +}) + +test('Testing tab: probe result and signature format', async ({ page }) => { + await page.goto('/system/alerts/webhook-1') + await page.getByRole('tab', { name: 'Testing' }).click() + + const panel = page.getByRole('tabpanel') + await expect( + panel.getByText('Send a liveness probe to see the result here') + ).toBeVisible() + + await panel.getByRole('button', { name: 'Send liveness probe' }).click() + const probeModal = page.getByRole('dialog', { name: 'Send liveness probe' }) + await probeModal.getByRole('button', { name: 'Send probe' }).click() + + await expect(panel.getByText('Succeeded')).toBeVisible() + await expect(panel.getByText('200')).toBeVisible() + await expect(panel.getByText('123ms')).toBeVisible() + + // signature format docs + await expect(panel.getByText('a={algorithm}&id={secret-id}&s={signature}')).toBeVisible() + await expect(panel.getByText('The HMAC signature of the request body')).toBeVisible() +}) + +test('Testing tab: probe failure', async ({ page }) => { + await page.goto('/system/alerts') + + // the mock backend fails probes for endpoints containing 'unreachable' + await clickRowAction(page, 'power-mon', 'Edit') + await page + .getByRole('dialog', { name: 'Edit webhook' }) + .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('tab', { name: 'Testing' }).click() + const panel = page.getByRole('tabpanel') + await panel.getByRole('button', { name: 'Send liveness probe' }).click() + await page + .getByRole('dialog', { name: 'Send liveness probe' }) + .getByRole('button', { name: 'Send probe' }) + .click() + + await expect(panel.getByText('Unreachable')).toBeVisible() +}) + +test('Webhook 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() +}) + +// The mock backend retries a pending delivery 5s after the list is first +// fetched, so refresh until the state settles rather than sleeping. +const refreshUntil = (page: Page, expectation: () => Promise) => + expect(async () => { + await page.getByRole('button', { name: 'Refresh data' }).click() + await expectation() + }).toPass({ timeout: 30_000 }) + +test('Pending delivery resolves to delivered', async ({ page }) => { + await page.goto('/system/alerts/webhook-1?tab=deliveries') + + const row = page.getByRole('row', { name: /a3d830ee/ }) + await expect(row.getByText('pending')).toBeVisible() + + await refreshUntil(page, () => + expect(row.getByText('delivered')).toBeVisible({ timeout: 1000 }) + ) + + // the retry shows up as a second attempt on the delivery + await clickRowAction(page, 'a3d830ee-a590-40df-8281-42282c056196', 'View details') + const sideModal = page.getByRole('dialog', { name: 'Webhook delivery' }) + await expect(sideModal.getByRole('table').getByRole('row')).toHaveCount(3) // header + 2 +}) + +test('Pending delivery fails after exhausting retries', async ({ page }) => { + await page.goto('/system/alerts') + + // the mock backend fails delivery to endpoints containing 'unreachable' + await clickRowAction(page, 'webhook-1', 'Edit') + await page + .getByRole('dialog', { name: 'Edit webhook' }) + .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('tab', { name: 'Deliveries' }).click() + const row = page.getByRole('row', { name: /a3d830ee/ }) + await expect(row.getByText('pending')).toBeVisible() + + // one attempt already failed, so it takes two more to hit the 3-attempt limit + await refreshUntil(page, () => + expect(row.getByText('failed')).toBeVisible({ timeout: 1000 }) + ) +}) + +test('Webhook 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' }) + 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 + 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() + await expect(request.getByText('x-oxide-alert-class')).toBeVisible() + await expect( + request.getByText('hardware.power_shelf.psu.insert', { exact: true }) + ).toBeVisible() + + await sideModal.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() + + // resend a failed delivery requires confirmation, then creates a new + // pending delivery + await clickRowAction(page, '30ece63e-5efd-4365-99a6-d4f09dfa685e', 'Resend') + const confirmModal = page.getByRole('dialog', { name: 'Confirm resend' }) + // the alert ID, 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 testing tab + await page.getByRole('tab', { name: 'Testing' }).click() + await page.getByRole('button', { name: 'Send liveness probe' }).click() + const probeModal = page.getByRole('dialog', { name: 'Send liveness probe' }) + await probeModal.getByRole('button', { name: 'Send probe' }).click() + const panel = page.getByRole('tabpanel') + await expect(panel.getByText('Succeeded')).toBeVisible() + // the modal has no resend option, so nothing gets resent + await expect(panel.getByText('resent')).toBeHidden() + + await page.getByRole('tab', { name: 'Deliveries' }).click() + // 8 rows + the probe. no resends: the probe modal doesn't offer them + await expect(table.getByRole('row')).toHaveCount(9) +}) + +test('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 3e0d280ee..1c4e2b5c6 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() })