Skip to content
Open
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 @@ -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 ExternalSubnet = Readonly<Merge<Project, { externalSubnet?: string }>>
Expand Down
8 changes: 8 additions & 0 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,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

type PortRange = [number, number]

/** Parse '1234' into [1234, 1234] and '80-100' into [80, 100] */
Expand Down
23 changes: 23 additions & 0 deletions app/components/StateBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type DiskType,
type InstanceState,
type SnapshotState,
type SupportBundleState,
} from '@oxide/api'
import { Badge, type BadgeColor } from '@oxide/design-system/ui'

Expand Down Expand Up @@ -85,6 +86,28 @@ export const SnapshotStateBadge = (props: { state: SnapshotState; className?: st
</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)}
>
{(props.state === 'collecting' || props.state === 'destroying') && (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

really minor given the context, but i always like to use a match for this kind of thing, so the compiler can require our input if we add e.g. a rebuilding state in the future

<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
76 changes: 76 additions & 0 deletions app/forms/support-bundle-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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,
MAX_BUNDLE_COMMENT_BYTES,
queryClient,
useApiMutation,
utf8ByteLength,
} from '@oxide/api'

import { TextField } from '~/components/form/fields/TextField'
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 onDismiss = () => navigate(pb.supportBundles())

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={onDismiss}
onSubmit={({ userComment }) => {
createBundle.mutate({ body: { userComment: userComment || null } })
}}
loading={createBundle.isPending}
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."
/>
<TextField
as="textarea"
name="userComment"
label="Comment"
description="Note about why this bundle is being collected"
rows={4}
control={form.control}
validate={(value) =>
utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES
? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes`
: true
}
/>
</SideModalForm>
)
}
89 changes: 89 additions & 0 deletions app/forms/support-bundle-edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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,
MAX_BUNDLE_COMMENT_BYTES,
q,
queryClient,
useApiMutation,
usePrefetchedQuery,
utf8ByteLength,
} from '@oxide/api'

import { TextField } from '~/components/form/fields/TextField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { titleCrumb } from '~/hooks/use-crumbs'
import { getSupportBundleSelector, useSupportBundleSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { pb } from '~/util/path-builder'
import type * as PP from '~/util/path-params'

const bundleView = ({ bundleId }: PP.SupportBundle) =>
q(api.supportBundleView, { path: { bundleId } })

export async function clientLoader({ params }: LoaderFunctionArgs) {
const selector = getSupportBundleSelector(params)
await queryClient.prefetchQuery(bundleView(selector))
return null
}

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

export default function EditSupportBundleSideModalForm() {
const navigate = useNavigate()
const selector = useSupportBundleSelector()

const { data: bundle } = usePrefetchedQuery(bundleView(selector))

const form = useForm({ defaultValues: { userComment: bundle.userComment || '' } })

const onDismiss = () => navigate(pb.supportBundles())

const editBundle = useApiMutation(api.supportBundleUpdate, {
onSuccess() {
queryClient.invalidateEndpoint('supportBundleList')
queryClient.invalidateEndpoint('supportBundleView')
addToast('Support bundle updated')
navigate(pb.supportBundles())
},
})

return (
<SideModalForm
form={form}
formType="edit"
resourceName="support bundle"
onDismiss={onDismiss}
onSubmit={({ userComment }) => {
editBundle.mutate({
path: { bundleId: selector.bundleId },
body: { userComment: userComment || null },
})
}}
loading={editBundle.isPending}
submitError={editBundle.error}
>
<TextField
as="textarea"
name="userComment"
label="Comment"
description="Note about why this bundle is being collected"
rows={4}
control={form.control}
validate={(value) =>
utf8ByteLength(value) > MAX_BUNDLE_COMMENT_BYTES
? `Comment cannot exceed ${MAX_BUNDLE_COMMENT_BYTES} bytes`
: true
}
/>
</SideModalForm>
)
}
2 changes: 2 additions & 0 deletions app/hooks/use-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 getSupportBundleSelector = requireParams('bundleId')
export const getAffinityGroupSelector = requireParams('project', 'affinityGroup')
export const getAntiAffinityGroupSelector = requireParams('project', 'antiAffinityGroup')

Expand Down Expand Up @@ -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 useSupportBundleSelector = () => useSelectedParams(getSupportBundleSelector)
export const useAffinityGroupSelector = () => useSelectedParams(getAffinityGroupSelector)
export const useAntiAffinityGroupSelector = () =>
useSelectedParams(getAntiAffinityGroupSelector)
5 changes: 5 additions & 0 deletions app/layouts/SystemLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Access16Icon,
Cloud16Icon,
IpGlobal16Icon,
Logs16Icon,
Metrics16Icon,
Servers16Icon,
SoftwareUpdate16Icon,
Expand Down Expand Up @@ -56,6 +57,7 @@ export default function SystemLayout() {
{ value: 'IP Pools', path: pb.ipPools() },
{ value: 'Subnet Pools', path: pb.subnetPools() },
{ value: 'System Update', path: pb.systemUpdate() },
{ value: 'Support Bundles', path: pb.supportBundles() },
{ value: 'Fleet Access', path: pb.fleetAccess() },
]
// filter out the entry for the path we're currently on
Expand Down Expand Up @@ -104,6 +106,9 @@ export default function SystemLayout() {
<NavLinkItem to={pb.systemUpdate()}>
<SoftwareUpdate16Icon /> System Update
</NavLinkItem>
<NavLinkItem to={pb.supportBundles()}>
<Logs16Icon /> Support Bundles
</NavLinkItem>
<NavLinkItem to={pb.fleetAccess()}>
<Access16Icon /> Fleet Access
</NavLinkItem>
Expand Down
Loading
Loading