Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f527b62
First stab at support bundles in console
charliepark Aug 5, 2026
382ed47
Dropped size column
charliepark Aug 5, 2026
aa89f60
Add ereports to mock data
charliepark Aug 5, 2026
c744fcf
Remove sidemodal; other UI improvements
charliepark Aug 5, 2026
a406be1
simplify e2e test
charliepark Aug 6, 2026
6653f25
copy change
charliepark Aug 6, 2026
b6a34ee
pre-review tweaks
charliepark Aug 7, 2026
8a6c945
Support bundle detail modal (#3324)
benjaminleonard Aug 18, 2026
8b6d9ad
merge main
charliepark Aug 18, 2026
af2a545
use exhaustive matching on bundle states
charliepark Aug 18, 2026
ff4ce4d
remove experimental from URL
charliepark Aug 18, 2026
e3b7bb1
small cleanup
charliepark Aug 18, 2026
6469d3f
Merge branch 'main' into support-bundles
charliepark Aug 18, 2026
6b5e1ef
RefreshButton; fixed test
charliepark Aug 18, 2026
f5aa807
Remove unused supportBundleDownload from MSW
charliepark Aug 18, 2026
d7182e4
refactor
charliepark Aug 18, 2026
0ceea8a
Merge branch 'main' into support-bundles
charliepark Aug 26, 2026
104a1c5
a bit more refactoring
charliepark Aug 26, 2026
77dcedb
follow polling precedent
charliepark Aug 26, 2026
4ed126f
simplify fetching
charliepark Aug 26, 2026
8b79647
Merge main and resolve conflicts
charliepark Sep 8, 2026
65114e4
prevent modal button weirdness on successful submission
charliepark Sep 8, 2026
22ab81e
Use folder icon for now
charliepark Sep 9, 2026
e863a9f
actually: flag icon (for now)
charliepark Sep 10, 2026
7a3b0d4
merge main
david-crespo Sep 14, 2026
65e2b18
Fix ProjectAccessPage tsc error by typing rows explicitly
david-crespo Sep 14, 2026
bf36665
tweak docs popover copy
david-crespo Sep 14, 2026
5351dae
Drop file count from bundle detail
david-crespo Sep 14, 2026
e3edd2d
Replace AsyncValue with a BundleSize component
david-crespo Sep 14, 2026
3160e72
Reorder system sidebar and quick actions
david-crespo Sep 14, 2026
9d3a69a
Drop redundant View details row action
david-crespo Sep 14, 2026
94b1202
Per-state download disabled reasons, document polling logic
david-crespo Sep 14, 2026
0b38d87
Copy tweaks: Creation reason, sentence-case link, refresh tip
david-crespo Sep 14, 2026
53c6df8
Bump design system, use Archive icon for support bundles
david-crespo Sep 14, 2026
4ca8bb3
Use 16 icon for empty state
benjaminleonard Sep 15, 2026
af1e3f2
Switch to 24 for consistency ...
benjaminleonard Sep 15, 2026
534dd07
Test collection failure and comment byte limit
david-crespo Sep 14, 2026
9ac206c
Fix comments, use real FM reason format in mock
david-crespo Sep 14, 2026
723f1a7
Test bundle download from row action and modal
david-crespo Sep 15, 2026
bc8a479
last code review tweaks
david-crespo Sep 15, 2026
7982878
Serve empty zip for bundle download in mock mode
david-crespo Sep 15, 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/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type IdentityProvider = Readonly<Merge<Silo, { provider: string }>>
export type SystemUpdate = Readonly<{ version: string }>
export type SshKey = Readonly<{ sshKey: string }>
export type Sled = Readonly<{ sledId?: string }>
export type SupportBundle = Readonly<{ bundleId?: string }>
export type IpPool = Readonly<{ pool?: string }>
export type SubnetPool = Readonly<{ subnetPool?: string }>
export type AlertReceiver = Readonly<{ receiver?: string }>
Expand Down
66 changes: 53 additions & 13 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type {
SiloIpPool,
SiloUtilization,
Sled,
SnapshotState,
SupportBundleState,
Vpc,
VpcFirewallRule,
VpcFirewallRuleUpdate,
Expand Down Expand Up @@ -111,6 +113,14 @@ export const MIN_DISK_SIZE_GiB = 1
*/
export const MAX_DISK_SIZE_GiB = 1023

// the API only enforces this on update, but apply it at create time too so
// the comment doesn't become uneditable later
// https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/support_bundle.rs#L736-L742
export const MAX_BUNDLE_COMMENT_BYTES = 4096

/** Nexus limits by UTF-8 byte length, not JS string length */
export const utf8ByteLength = (s: string) => new TextEncoder().encode(s).length

/**
* The `default_*` network interface attachment types resolve a VPC and VPC
* subnet both named literally 'default', so they fail with a 404 if that VPC
Expand Down Expand Up @@ -250,14 +260,15 @@ export const instanceCan = R.mapValues(instanceActions, (states: InstanceState[]
return test
})

/**
* States the instance is expected to leave on its own, so the UI should poll
* and show a spinner. Exhaustive match so new states have to be classified.
*/
export function instanceTransitioning(runState: InstanceState) {
return (
runState === 'creating' ||
runState === 'starting' ||
runState === 'rebooting' ||
runState === 'migrating' ||
runState === 'stopping'
)
return match(runState)
.with('creating', 'starting', 'rebooting', 'migrating', 'stopping', () => true)
.with('running', 'stopped', 'repairing', 'failed', 'destroyed', () => false)
.exhaustive()
}

/**
Expand Down Expand Up @@ -315,13 +326,42 @@ const canSnapshot = (d: SnapshotDisk) => {
}
canSnapshot.states = snapshotStates

/** See {@link instanceTransitioning} */
export function diskTransitioning(diskState: DiskState['state']) {
return (
diskState === 'attaching' ||
diskState === 'creating' ||
diskState === 'detaching' ||
diskState === 'finalizing'
)
return match(diskState)
.with('attaching', 'creating', 'detaching', 'finalizing', () => true)
.with(
'attached',
'detached',
'destroyed',
'faulted',
'maintenance',
'import_ready',
'importing_from_url',
'importing_from_bulk_writes',
() => false
)
.exhaustive()
}

/** See {@link instanceTransitioning} */
export function snapshotTransitioning(state: SnapshotState) {
return match(state)
.with('creating', () => true)
.with('ready', 'faulted', 'destroyed', () => false)
.exhaustive()
}

/**
* See {@link instanceTransitioning}. 'active' and 'failed' are terminal, and
* 'destroying' resolves by the bundle record going away.
* https://github.com/oxidecomputer/omicron/blob/6db4c7e/nexus/db-model/src/support_bundle.rs#L53-L66
*/
export function supportBundleTransitioning(state: SupportBundleState) {
return match(state)
.with('collecting', 'destroying', () => true)
.with('active', 'failed', () => false)
.exhaustive()
}

export const diskCan = {
Expand Down
27 changes: 26 additions & 1 deletion app/components/StateBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import cn from 'classnames'
import {
diskTransitioning,
instanceTransitioning,
snapshotTransitioning,
supportBundleTransitioning,
type DiskState,
type DiskType,
type InstanceState,
type SnapshotState,
type SupportBundleState,
} from '@oxide/api'
import { Badge, type BadgeColor } from '@oxide/design-system/ui'

Expand Down Expand Up @@ -78,13 +81,35 @@ const SNAPSHOT_COLORS: Record<SnapshotState, BadgeColor> = {

export const SnapshotStateBadge = (props: { state: SnapshotState; className?: string }) => (
<Badge color={SNAPSHOT_COLORS[props.state]} className={cn(props.className, badgeClasses)}>
{props.state === 'creating' && (
{snapshotTransitioning(props.state) && (
<Spinner size="sm" variant={SNAPSHOT_COLORS[props.state]} />
)}
{props.state}
</Badge>
)

const SUPPORT_BUNDLE_COLORS: Record<SupportBundleState, BadgeColor> = {
collecting: 'blue',
active: 'default',
destroying: 'neutral',
failed: 'destructive',
}

export const SupportBundleStateBadge = (props: {
state: SupportBundleState
className?: string
}) => (
<Badge
color={SUPPORT_BUNDLE_COLORS[props.state]}
className={cn(props.className, badgeClasses)}
>
{supportBundleTransitioning(props.state) && (
<Spinner size="sm" variant={SUPPORT_BUNDLE_COLORS[props.state]} />
)}
{props.state}
</Badge>
)

export const DiskTypeBadge = (props: { diskType: DiskType; className?: string }) => (
<Badge color="neutral" className={props.className}>
{props.diskType}
Expand Down
7 changes: 5 additions & 2 deletions app/components/form/SideModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,11 @@ export function SideModalForm<TFieldValues extends FieldValues>({
? `Update ${resourceName}`
: submitLabel || title || `Create ${resourceName}`

// must be destructured up here to subscribe to changes. inlining
// form.formState.isDirty does not work
// formState is a proxy whose getters register a subscription, and RHF only
// re-renders for keys that were read during render. isDirty is used in the
// onDismiss callback below, so it has to be read up here first, or the
// callback would see a stale value. See the Rules section of the docs:
// https://react-hook-form.com/docs/useform/formstate
const { isDirty, isSubmitting } = form.formState
const [showNavGuard, setShowNavGuard] = useState(false)

Expand Down
34 changes: 34 additions & 0 deletions app/components/form/fields/BundleCommentField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* 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 type { Control } from 'react-hook-form'

import { MAX_BUNDLE_COMMENT_BYTES, utf8ByteLength } from '@oxide/api'

import { TextField } from './TextField'

/** Support bundle comment textarea, shared by the create and edit forms */
export function BundleCommentField({
control,
}: {
control: Control<{ userComment: string }>
}) {
return (
<TextField
as="textarea"
name="userComment"
label="Comment"
rows={4}
control={control}
validate={(value) =>
utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES
? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes`
: true
}
/>
)
}
56 changes: 56 additions & 0 deletions app/forms/support-bundle-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* 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 } from 'react-router'

import { api, queryClient, useApiMutation } from '@oxide/api'

import { BundleCommentField } from '~/components/form/fields/BundleCommentField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { titleCrumb } from '~/hooks/use-crumbs'
import { addToast } from '~/stores/toast'
import { Message } from '~/ui/lib/Message'
import { pb } from '~/util/path-builder'

const defaultValues = { userComment: '' }

export const handle = titleCrumb('New support bundle')

export default function CreateSupportBundleSideModalForm() {
const navigate = useNavigate()

const createBundle = useApiMutation(api.supportBundleCreate, {
onSuccess() {
queryClient.invalidateEndpoint('supportBundleList')
addToast('Support bundle created')
navigate(pb.supportBundles())
},
})

const form = useForm({ defaultValues })

return (
<SideModalForm
form={form}
formType="create"
resourceName="support bundle"
onDismiss={() => navigate(pb.supportBundles())}
onSubmit={({ userComment }) => {
createBundle.mutate({ body: { userComment: userComment.trim() || null } })
}}
loading={createBundle.isPending || createBundle.isSuccess}
submitError={createBundle.error}
>
<Message
variant="info"
content="Bundle collection runs in the background and can take several minutes. The bundle can be downloaded once collection is complete."
/>
<BundleCommentField control={form.control} />
</SideModalForm>
)
}
2 changes: 2 additions & 0 deletions app/hooks/use-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const requireUpdateParams = requireParams('version')
export const getIpPoolSelector = requireParams('pool')
export const getSubnetPoolSelector = requireParams('subnetPool')
export const getAlertReceiverSelector = requireParams('receiver')
export const getSupportBundleSelector = requireParams('bundleId')
export const getAffinityGroupSelector = requireParams('project', 'affinityGroup')
export const getAntiAffinityGroupSelector = requireParams('project', 'antiAffinityGroup')

Expand Down Expand Up @@ -106,6 +107,7 @@ export const useUpdateParams = () => useSelectedParams(requireUpdateParams)
export const useIpPoolSelector = () => useSelectedParams(getIpPoolSelector)
export const useSubnetPoolSelector = () => useSelectedParams(getSubnetPoolSelector)
export const useAlertReceiverSelector = () => useSelectedParams(getAlertReceiverSelector)
export const useSupportBundleSelector = () => useSelectedParams(getSupportBundleSelector)
export const useAffinityGroupSelector = () => useSelectedParams(getAffinityGroupSelector)
export const useAntiAffinityGroupSelector = () =>
useSelectedParams(getAntiAffinityGroupSelector)
11 changes: 8 additions & 3 deletions app/layouts/SystemLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useLocation } from 'react-router'
import { api, q, queryClient } from '@oxide/api'
import {
Access16Icon,
Archive16Icon,
Cloud16Icon,
IpGlobal16Icon,
Logs16Icon,
Expand Down Expand Up @@ -60,8 +61,9 @@ export default function SystemLayout() {
{ value: 'Alerting', path: pb.alertReceivers() },
{ value: 'Alerts', path: pb.alerts() },
{ value: 'System Update', path: pb.systemUpdate() },
{ value: 'Fleet Access', path: pb.fleetAccess() },
{ value: 'Support Bundles', path: pb.supportBundles() },
{ value: 'Audit Log', path: pb.auditLog() },
{ value: 'Fleet Access', path: pb.fleetAccess() },
]
// filter out the entry for the path we're currently on
.filter((i) => i.path !== pathname)
Expand Down Expand Up @@ -112,12 +114,15 @@ export default function SystemLayout() {
<NavLinkItem to={pb.systemUpdate()}>
<SoftwareUpdate16Icon /> System Update
</NavLinkItem>
<NavLinkItem to={pb.fleetAccess()}>
<Access16Icon /> Fleet Access
<NavLinkItem to={pb.supportBundles()}>
<Archive16Icon /> Support Bundles
</NavLinkItem>
<NavLinkItem to={pb.auditLog()}>
<Logs16Icon /> Audit Log
</NavLinkItem>
<NavLinkItem to={pb.fleetAccess()}>
<Access16Icon /> Fleet Access
</NavLinkItem>
</Sidebar.Nav>
</Sidebar>
<ContentPane />
Expand Down
4 changes: 2 additions & 2 deletions app/pages/project/access/ProjectAccessPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export default function ProjectAccessPage() {
const { data: projectPolicy } = usePrefetchedQuery(projectPolicyView(projectSelector))
const projectRows = useUserRows(projectPolicy.roleAssignments, 'project')

const rows = useMemo(() => {
const rows: UserRow[] = useMemo(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was made necessary by a rather obscure TypeScript bug interacting with React Table. When I merged main into this branch, the satisfies version started failing typechecking. Claude was able to repro the issue on main by adding a single line that doesn't look like it should do anything.

microsoft/typescript-go#3973
microsoft/typescript-go#4827

Image

return groupBy(siloRows.concat(projectRows), (u) => u.id)
.map(([userId, userAssignments]) => {
const { name, identityType } = userAssignments[0]
Expand All @@ -123,7 +123,7 @@ export default function ProjectAccessPage() {
name,
projectRole: projectAccessRow?.roleName,
roleBadges,
} satisfies UserRow
}
})
.sort(byGroupThenName)
}, [siloRows, projectRows])
Expand Down
2 changes: 1 addition & 1 deletion app/pages/settings/AccessTokensPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export default function AccessTokensPage() {

const emptyState = (
<EmptyMessage
icon={<AccessToken16Icon />}
icon={<AccessToken24Icon />}
title="No access tokens"
body="Your access tokens will appear here when they are created"
/>
Expand Down
2 changes: 1 addition & 1 deletion app/pages/settings/SSHKeysPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export default function SSHKeysPage() {

const emptyState = (
<EmptyMessage
icon={<Key16Icon />}
icon={<Key24Icon />}
title="No SSH keys"
body="Add an SSH key to see it here"
buttonText="Add SSH key"
Expand Down
Loading
Loading