Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
b944050
More updates
charliepark Aug 6, 2026
4755f73
Update side modals, polling
charliepark Aug 7, 2026
6958a48
Make webhook create a dedicated page
benjaminleonard Aug 10, 2026
da0cb41
Merge branch 'main' into webhooks
charliepark Aug 10, 2026
6e8c8f4
Initial input pass
benjaminleonard Aug 10, 2026
59e0d9b
Remove resend checkbox; other tweaks to lists
charliepark Aug 12, 2026
a262089
Refinement and more accurate mock data
benjaminleonard Aug 13, 2026
d68c2e5
adjustments to filtering with pagination
charliepark Aug 13, 2026
8172938
Merge branch 'webhooks' into webhooks-input
benjaminleonard Aug 14, 2026
01bbbf0
Test fix
benjaminleonard Aug 14, 2026
7bf1bed
nav changes, tabs, column updates
charliepark Aug 18, 2026
dc9a334
merge main
charliepark Aug 18, 2026
a178ae2
Update app/forms/webhook-create.tsx
charliepark Aug 18, 2026
0857569
merge main and resolve conflicts
charliepark Aug 27, 2026
c95b08c
merge webhooks-input and resolve conflicts
charliepark Aug 27, 2026
fa18981
new receiver form should be on its own page
charliepark Aug 27, 2026
416a074
filter probe from list of subscription classes; update docs links
charliepark Aug 27, 2026
dd204ca
getting clever with spaces and chip creation
charliepark Aug 27, 2026
28fad68
Update app/pages/system/alerting/AlertReceiversTab.tsx
charliepark Aug 27, 2026
01ca961
Update app/pages/system/alerting/AlertReceiverPage.tsx
charliepark Aug 27, 2026
c97b759
Update app/pages/system/alerting/AlertReceiverPage.tsx
charliepark Aug 27, 2026
a4418d6
Update app/pages/system/alerting/AlertsTab.tsx
charliepark Aug 27, 2026
5df3af2
Update app/pages/system/alerting/AlertReceiversTab.tsx
charliepark Aug 27, 2026
2721527
Update app/pages/system/alerting/AlertReceiverPage.tsx
charliepark Aug 27, 2026
1d11577
Update app/pages/system/alerting/AlertReceiverPage.tsx
charliepark Aug 27, 2026
e6fcabf
Update app/pages/system/alerting/AlertReceiverPage.tsx
charliepark Aug 27, 2026
8ca2463
Update app/pages/system/alerting/AlertReceiverPage.tsx
charliepark Aug 27, 2026
3b7f2d2
refactoring
charliepark Aug 27, 2026
43e0c40
update wording in more places; integrate alert_view
charliepark Aug 28, 2026
330d17c
a few small copy changes
charliepark Aug 28, 2026
53f4cce
Update combobox querying; update icon
charliepark Aug 28, 2026
119156f
Add link to Secrets to webhook creation form and secret creation modal
charliepark Aug 28, 2026
8d070f9
use placeholder icon until Ben adds a 24px bell
charliepark Aug 28, 2026
3afe559
Add alerts list
fakemonster Aug 17, 2026
9a2d423
Update probe box with re-send option; fix MSW
charliepark Sep 1, 2026
bad57ee
put receivers tab first
charliepark Sep 1, 2026
a7f6fa6
refactoring -> copy on secrets, max length for URLs
charliepark Sep 1, 2026
e2a6085
copy cleanup: alerts -> subscriptions
charliepark Sep 2, 2026
51ae562
Merge branch 'main' into webhooks
charliepark Sep 2, 2026
eb2c68c
Update alerts list to show audit-log-style layout; lowercasing class …
charliepark Sep 2, 2026
0f795c6
copy change
charliepark Sep 2, 2026
ade7b57
remove SideModal.Section, fix doubled gutter in alert side modals
david-crespo Sep 2, 2026
3c9557b
save valid globs on blur
charliepark Sep 2, 2026
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/__tests__/safety.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ it('mock-api is only referenced in test files', () => {
"AGENTS.md",
"app/api/__tests__/client.browser.spec.ts",
"mock-api/msw/db.ts",
"test/e2e/alerts.e2e.ts",
"test/e2e/fleet-access.e2e.ts",
"test/e2e/instance-create.e2e.ts",
"test/e2e/inventory.e2e.ts",
Expand Down
1 change: 1 addition & 0 deletions app/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export * from './client'
export * from './roles'
export * from './util'
export * from './__generated__/Api'
export { snakeify } from './__generated__/util'
// export * as ZVal from './__generated__/validate'

export type { ApiTypes }
Expand Down
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
121 changes: 120 additions & 1 deletion app/api/util.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,53 @@
*/
import { describe, expect, it, test } from 'vitest'

import { diskCan, genName, instanceCan, parsePortRange, synthesizeData } from './util'
import {
diskCan,
genName,
instanceCan,
parsePortRange,
resendableAlertIds,
subscriptionRegex,
synthesizeData,
} from './util'

describe('subscriptionRegex', () => {
it('matches exact class names', () => {
expect(subscriptionRegex('instance.create').test('instance.create')).toBe(true)
expect(subscriptionRegex('instance.create').test('instance.created')).toBe(false)
})

it('* matches exactly one segment', () => {
const re = subscriptionRegex('disk.*')
expect(re.test('disk.create')).toBe(true)
expect(re.test('disk.snapshot.create')).toBe(false)
expect(re.test('disk')).toBe(false)
})

it('* can appear in any position', () => {
const re = subscriptionRegex('*.create')
expect(re.test('disk.create')).toBe(true)
expect(re.test('instance.create')).toBe(true)
expect(re.test('instance.ephemeral_ip.create')).toBe(false)
})

it('** matches one or more segments', () => {
const re = subscriptionRegex('hardware.**')
expect(re.test('hardware.power_shelf.psu.insert')).toBe(true)
expect(re.test('hardware.psu')).toBe(true)
expect(re.test('hardware')).toBe(false)

const suffix = subscriptionRegex('**.delete')
expect(suffix.test('project.delete')).toBe(true)
expect(suffix.test('instance.ephemeral_ip.delete')).toBe(true)
expect(suffix.test('delete')).toBe(false)
})

it('does not match substrings within a segment', () => {
expect(subscriptionRegex('instance.**').test('silo.instance_quota.hit')).toBe(false)
expect(subscriptionRegex('disk.*').test('bigdisk.create')).toBe(false)
})
})

describe('parsePortRange', () => {
describe('parses', () => {
Expand Down Expand Up @@ -197,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<typeof resendableAlertIds>[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'])
})
})
64 changes: 64 additions & 0 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { match } from 'ts-pattern'
import { bytesToGiB } from '~/util/units'

import type {
AlertDelivery,
Disk,
DiskState,
DiskType,
Expand Down Expand Up @@ -40,6 +41,69 @@ 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 =
/^([a-zA-Z0-9_]+|\*|\*\*)(\.([a-zA-Z0-9_]+|\*|\*\*))*$/

/** A subscription with a `*` or `**` segment, as opposed to an exact class */
export const isGlobPattern = (subscription: string) => subscription.includes('*')

/**
* 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.
* 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.
* https://github.com/oxidecomputer/omicron/blob/32615a35/nexus/db-model/src/alert_subscription.rs
*/
export function subscriptionRegex(subscription: string) {
const pattern = subscription
.split('.')
.map((seg) => (seg === '**' ? '.+' : seg === '*' ? '[^.]+' : seg))
.join('\\.')
return new RegExp(`^${pattern}$`)
}

/**
* 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<AlertDelivery, 'alertId' | 'alertClass' | 'state' | 'trigger'>[]
): Set<string> {
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
Expand Down
47 changes: 47 additions & 0 deletions app/components/AlertClassBadge.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement>(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
<Tooltip content={truncated ? children : undefined} delay={300}>
<span className="inline-flex max-w-full min-w-0">
<Badge color="neutral" className="max-w-full min-w-0 normal-case! *:min-w-0">
<span ref={ref} className="block truncate" onPointerEnter={checkTruncation}>
{children}
</span>
</Badge>
</span>
</Tooltip>
)
}
111 changes: 111 additions & 0 deletions app/components/HighlightJSON.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<span className="inline-block" style={{ width: `${depth * 2}ch` }} />
)

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 <span className={yellowText}>null</span>
if (typeof value === 'string') return <span className={greenText}>{`"${value}"`}</span>
if (value instanceof Date)
return <span className={greenText}>{`"${value.toISOString()}"`}</span>
if (typeof value === 'boolean' || typeof value === 'number') {
return <span className={yellowText}>{String(value)}</span>
}
// 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<string, unknown>` 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 <Primitive value={json} />
}

// in inline mode a space stands in for the newline + indent between entries
const open = inline ? ' ' : '\n'
const indent = (d: number) => (inline ? null : <Indent depth={d} />)

if (Array.isArray(json)) {
if (json.length === 0) return <span className="text-quaternary">[]</span>

return (
<>
<span className="text-quaternary">[</span>
{open}
{json.map((item, index) => (
<span key={index}>
{indent(depth + 1)}
<HighlightJSON json={item} depth={depth + 1} inline={inline} />
{index < json.length - 1 && <span className="text-quaternary">,</span>}
{open}
</span>
))}
{indent(depth)}
<span className="text-quaternary">]</span>
</>
)
}

if (typeof json !== 'object') return null

const entries = Object.entries(json)
if (entries.length === 0) return <span className="text-quaternary">{'{}'}</span>

return (
<>
<span className="text-quaternary">{'{'}</span>
{open}
{entries.map(([key, val], index) => (
<span key={key}>
{indent(depth + 1)}
<span className="text-default">{key}</span>
<span className="text-quaternary">: </span>
<HighlightJSON json={val} depth={depth + 1} inline={inline} />
{index < entries.length - 1 && <span className="text-quaternary">,</span>}
{open}
</span>
))}
{indent(depth)}
<span className="text-quaternary">{'}'}</span>
</>
)
})
Loading
Loading