Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/api/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Merge<Project, { externalSubnet?: string }>>
export type FloatingIp = Readonly<Merge<Project, { floatingIp?: string }>>

Expand Down
5 changes: 5 additions & 0 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions app/components/SubscriptionMatchPreview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-sans-sm text-secondary">
No current event classes match this pattern. It may match classes added in the
future.
</p>
)
}

return (
<p className="text-sans-sm text-secondary">
Matches {data.items.length} event {data.items.length === 1 ? 'class' : 'classes'}:{' '}
<span className="inline-flex flex-wrap gap-1 align-bottom">
{data.items.map((c) => (
<Badge key={c.name} color="neutral">
{c.name}
</Badge>
))}
</span>
</p>
)
}
177 changes: 177 additions & 0 deletions app/forms/webhook-create.tsx
Original file line number Diff line number Diff line change
@@ -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) => <Badge color="neutral">{subscription}</Badge>,
},
]

function SubscriptionsField({ control }: { control: Control<WebhookCreateFormValues> }) {
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: <ItemLabel name={c.name}>{c.description}</ItemLabel>,
}))

const submitSubform = subform.handleSubmit(({ subscription }) => {
if (!field.value.includes(subscription)) {
field.onChange([...field.value, subscription])
}
subform.reset()
})

return (
<>
<ComboboxField
control={subform.control}
name="subscription"
label="Event classes"
description="Events to subscribe the webhook to. Globs like hardware.** match multiple classes."
items={classItems}
allowArbitraryValues
onEnter={submitSubform}
validate={validateSubscription}
hideOptionalTag
/>
<SubscriptionMatchPreview pattern={subscription} />
<ClearAndAddButtons
addButtonCopy="Add event class"
disabled={!subscription}
onClear={() => subform.reset()}
onSubmit={submitSubform}
/>
<MiniTable
ariaLabel="Event classes"
items={field.value}
columns={subscriptionColumns}
rowKey={(subscription) => 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 <HL>{receiver.name}</HL> created</>)
navigate(pb.alertReceivers())
},
})

const form = useForm({ defaultValues })

return (
<SideModalForm
form={form}
formType="create"
resourceName="webhook"
onDismiss={onDismiss}
onSubmit={({ name, description, endpoint, secret, subscriptions }) => {
createWebhook.mutate({
body: { name, description, endpoint, secrets: [secret], subscriptions },
})
}}
loading={createWebhook.isPending}
submitError={createWebhook.error}
>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<TextField
name="endpoint"
label="Endpoint URL"
description="The URL that payloads should be sent to"
control={form.control}
required
validate={validateEndpoint}
/>
<TextField
name="secret"
label="Secret"
description="Shared secret used to sign webhook payloads. More secrets can be added later."
control={form.control}
required
/>
<SubscriptionsField control={form.control} />
</SideModalForm>
)
}
99 changes: 99 additions & 0 deletions app/forms/webhook-edit.tsx
Original file line number Diff line number Diff line change
@@ -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 <HL>{newName}</HL> 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 (
<SideModalForm
form={form}
formType="edit"
resourceName="webhook"
onDismiss={() => navigate(pb.alertReceiver(receiverSelector))}
onSubmit={({ name, description, endpoint }) => {
editWebhook.mutate({
path: { receiver: receiver.name },
body: { name, description, endpoint },
})
}}
loading={editWebhook.isPending}
submitError={editWebhook.error}
>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<TextField
name="endpoint"
label="Endpoint URL"
description="The URL that payloads should be sent to"
control={form.control}
required
validate={validateEndpoint}
/>
</SideModalForm>
)
}
15 changes: 15 additions & 0 deletions app/hooks/use-pagination.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
Loading
Loading