Skip to content

Commit b1fba78

Browse files
authored
Disk details side modal (#2992)
* clean up AGENTS.md * disk details side modal * use ButtonCell for disk name on instance storage tab * make disk name a link on snapshots table * fix test flake due to clicking a disk link accidentally * add footer with close button so ben can sleep * make image edit and idp edit work the same way * add ReadOnlySideModalForm, use it for the others * open disk detail right on snapshots page, don't nav * put back my smart quote
1 parent 08d801f commit b1fba78

26 files changed

+396
-152
lines changed

AGENTS.md

Lines changed: 17 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@
1717
# API utilities & constants
1818

1919
- Treat `app/api/util.ts` (and friends) as a thin translation layer: mirror backend rules only when the UI needs them, keep the client copy minimal, and always link to the authoritative Omicron source so reviewers can verify the behavior.
20-
- API constants live in `app/api/util.ts:25-38` with links to Omicron source: `MAX_NICS_PER_INSTANCE` (8), `INSTANCE_MAX_CPU` (64), `INSTANCE_MAX_RAM_GiB` (1536), `MIN_DISK_SIZE_GiB` (1), `MAX_DISK_SIZE_GiB` (1023), etc.
21-
- Use `ALL_ISH` (1000) from `app/util/consts.ts` when UI needs "approximately everything" for non-paginated queries—convention is to use this constant rather than magic numbers.
20+
- API constants live in `app/api/util.ts` with links to Omicron source.
2221

2322
# Testing code
2423

@@ -31,10 +30,10 @@
3130

3231
# Data fetching pattern
3332

34-
- Define endpoints with `apiq`, prefetch them in a `clientLoader`, then read data with `usePrefetchedQuery`.
35-
- Use `ALL_ISH` when the UI needs every item (e.g. release lists) and rely on `queryClient.invalidateEndpoint`—it now returns the `invalidateQueries` promise so it can be awaited (see `app/pages/system/UpdatePage.tsx`).
33+
- Define queries with `q(api.endpoint, params)` for single items or `getListQFn(api.listEndpoint, params)` for lists. Prefetch in `clientLoader` and read with `usePrefetchedQuery`; for on-demand fetches (modals, secondary data), use `useQuery` directly.
34+
- Use `ALL_ISH` from `app/util/consts.ts` when UI needs "all" items. Use `queryClient.invalidateEndpoint` to invalidate queries.
3635
- For paginated tables, compose `getListQFn` with `useQueryTable`; the helper wraps `limit`/`pageToken` handling and keeps placeholder data stable (`app/api/hooks.ts:123-188`, `app/pages/ProjectsPage.tsx:40-132`).
37-
- When a loader needs dependent data, fetch the primary list with `queryClient.fetchQuery`, prefetch its per-item queries, and only await a bounded batch so render isnt blocked (see `app/pages/project/affinity/AffinityPage.tsx`).
36+
- When a loader needs dependent data, fetch the primary list with `queryClient.fetchQuery`, prefetch its per-item queries, and only await a bounded batch so render isn't blocked (see `app/pages/project/affinity/AffinityPage.tsx`).
3837

3938
# Mutations & UI flow
4039

@@ -83,15 +82,14 @@
8382
# Layout & accessibility
8483

8584
- Build pages inside the shared `PageContainer`/`ContentPane` so you inherit the skip link, sticky footer, pagination target, and scroll restoration tied to `#scroll-container` (`app/layouts/helpers.tsx`, `app/hooks/use-scroll-restoration.ts`).
86-
- Surface page-level buttons and pagination via the `PageActions` and `Pagination` tunnels from `tunnel-rat`; anything rendered through `.In` components lands in the footer `.Target` automatically (`app/components/PageActions.tsx`, `app/components/Pagination.tsx`). This tunnel pattern is preferred over React portals for maintaining component co-location.
85+
- Surface page-level buttons and pagination via the `PageActions` and `Pagination` tunnels from `tunnel-rat`; anything rendered through `.In` lands in `.Target` automatically.
8786
- For global loading states, reuse `PageSkeleton`—it keeps the MSW banner and grid layout stable, and `skipPaths` lets you opt-out for routes with custom layouts (`app/components/PageSkeleton.tsx`).
8887
- Enforce accessibility at the type level: use `AriaLabel` type from `app/ui/util/aria.ts` which requires exactly one of `aria-label` or `aria-labelledby` on custom interactive components.
8988

9089
# Route params & loaders
9190

9291
- Wrap `useParams` with the provided selectors (`useProjectSelector`, `useInstanceSelector`, etc.) so required params throw during dev and produce memoized results safe for dependency arrays (`app/hooks/use-params.ts`).
93-
- Param selectors use React Query's `hashKey` internally to ensure stable object references across renders—same values = same object identity, preventing unnecessary re-renders.
94-
- Prefer `queryClient.fetchQuery` inside `clientLoader` blocks when the page needs data up front, and throw `trigger404` on real misses so the shared error boundary can render Not Found or the 403 IDP guidance (`app/pages/ProjectsPage.tsx`, `app/layouts/SystemLayout.tsx`, `app/components/ErrorBoundary.tsx`).
92+
- Prefer `queryClient.fetchQuery` inside `clientLoader` blocks when the page needs data up front, and throw `trigger404` on real misses so the error boundary renders Not Found.
9593

9694
# Global stores & modals
9795

@@ -100,47 +98,20 @@
10098

10199
# UI components & styling
102100

103-
- Reach for primitives in `app/ui` before inventing page-specific widgets; that directory intentionally holds router-agnostic building blocks (`app/ui/README.md`).
101+
- Reach for primitives in `app/ui` before inventing page-specific widgets; that directory holds router-agnostic building blocks.
104102
- When you just need Tailwind classes on a DOM element, use the `classed` helper instead of creating one-off wrappers (`app/util/classed.ts`).
105-
- Reuse utility components for consistent formatting—`TimeAgo`, `EmptyMessage`, `CardBlock`, `DocsPopover`, `PropertiesTable`, and friends exist so pages stay visually aligned (`app/components/TimeAgo.tsx`, `app/ui/lib`).
106-
107-
# Docs & external links
108-
109-
- Keep help URLs centralized: add new docs to `links`/`docLinks` and reference them when wiring `DocsPopover` or help badges (`app/util/links.ts`).
103+
- Reuse utility components for consistent formatting—`TimeAgo`, `EmptyMessage`, `CardBlock`, `DocsPopover`, `PropertiesTable`, etc.
104+
- Import icons from `@oxide/design-system/icons/react` with size suffixes: `16` for inline/table, `24` for headers/buttons, `12` for tiny indicators.
105+
- Keep help URLs in `links`/`docLinks` (`app/util/links.ts`).
110106

111107
# Error handling
112108

113-
- All API errors flow through `processServerError` in `app/api/errors.ts`, which transforms raw errors into user-friendly messages with special handling for common cases (Forbidden, ObjectNotFound, ObjectAlreadyExists).
114-
- On 401 errors, requests auto-redirect to `/login?redirect_uri=...` except for `loginLocal` endpoint which handles 401 in-page (`app/api/hooks.ts:49-57`).
115-
- On 403 errors, the error boundary automatically checks if the user has no groups and no silo role, displaying IDP misconfiguration guidance when detected (`app/components/ErrorBoundary.tsx:42-54`).
116-
- Throw `trigger404` (an object `{ type: 'error', statusCode: 404 }`) in loaders when resources don't exist; the error boundary will render `<NotFound />` (`app/components/ErrorBoundary.tsx`).
117-
118-
# Validation patterns
119-
120-
- Resource name validation: use `validateName` from `app/components/form/fields/NameField.tsx:44-60` (max 63 chars, lowercase letters/numbers/dashes, must start with letter, must end with letter or number). This matches backend validation.
121-
- Description validation: use `validateDescription` for max 512 char limit (`app/components/form/fields/DescriptionField.tsx`).
122-
- IP validation: use `validateIp` and `validateIpNet` from `app/util/ip.ts` for IPv4/IPv6 and CIDR notation—regexes match Rust `std::net` behavior for consistency.
123-
- All validation functions return `string | undefined` for react-hook-form compatibility.
124-
125-
# Type utilities
126-
127-
- Check `types/util.d.ts` for `NoExtraKeys` (catches accidental extra properties) and other type helpers.
128-
- Prefer `type-fest` utilities for advanced type manipulation.
129-
- Route param types in `app/util/path-params.ts` use `Required<Sel.X>` pattern to distinguish required path params from optional query params.
130-
131-
# Utility functions
132-
133-
- Check `app/util/*` for string formatting, date handling, math, IP parsing, arrays, and file utilities. Use existing helpers before writing new ones.
134-
135-
# Icons & visual feedback
136-
137-
- Import icons from `@oxide/design-system/icons/react` with size suffixes: `16` for inline/table use, `24` for headers/buttons, `12` for tiny indicators.
138-
- Use `StateBadge` for resource states, `EmptyMessage` for empty states, `HL` for highlighted text in messages.
109+
- All API errors flow through `processServerError` in `app/api/errors.ts`, which transforms raw errors into user-friendly messages.
110+
- On 401 errors, requests auto-redirect to `/login`. On 403, the error boundary checks for IDP misconfiguration.
111+
- Throw `trigger404` in loaders when resources don't exist; the error boundary will render Not Found.
139112

140-
# Role & permission patterns
113+
# Utilities & helpers
141114

142-
- Role helpers in `app/api/roles.ts`: `getEffectiveRole` determines most permissive role from a list, `roleOrder` defines hierarchy (admin > collaborator > viewer).
143-
- Use `useUserRows` hook to enrich role assignments with user/group names, sorted via `byGroupThenName` (groups first, then alphabetically).
144-
- Use `useActorsNotInPolicy` to fetch users/groups not already in a policy (for add-user forms).
145-
- Policy transformations: `updateRole` and `deleteRole` produce new policies immutably.
146-
- Check `userRoleFromPolicies` to determine effective user role across multiple policies (e.g., project + silo).
115+
- Check `app/util/*` for string formatting, date handling, IP parsing, etc. Check `types/util.d.ts` for type helpers.
116+
- Use `validateName` for resource names, `validateDescription` for descriptions, `validateIp`/`validateIpNet` for IPs.
117+
- Role helpers live in `app/api/roles.ts`.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import type { ReactNode } from 'react'
9+
10+
import { Button } from '~/ui/lib/Button'
11+
import { SideModal } from '~/ui/lib/SideModal'
12+
13+
type ReadOnlySideModalFormProps = {
14+
title: string
15+
subtitle?: ReactNode
16+
onDismiss: () => void
17+
children: ReactNode
18+
/**
19+
* Whether to animate the modal opening. Defaults to true. Used to prevent
20+
* modal from animating in on a fresh pageload where it should already be
21+
* open.
22+
*/
23+
animate?: boolean
24+
}
25+
26+
/**
27+
* A read-only side modal that displays form fields in a non-editable state.
28+
* Use this for "view" or "detail" modals where fields are shown but not editable.
29+
*/
30+
export function ReadOnlySideModalForm({
31+
title,
32+
subtitle,
33+
onDismiss,
34+
children,
35+
animate,
36+
}: ReadOnlySideModalFormProps) {
37+
return (
38+
<SideModal
39+
isOpen
40+
onDismiss={onDismiss}
41+
title={title}
42+
subtitle={subtitle}
43+
animate={animate}
44+
>
45+
<SideModal.Body>
46+
<div className="ox-form">{children}</div>
47+
</SideModal.Body>
48+
<SideModal.Footer>
49+
<Button variant="ghost" size="sm" onClick={onDismiss}>
50+
Close
51+
</Button>
52+
</SideModal.Footer>
53+
</SideModal>
54+
)
55+
}

app/components/form/SideModalForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export function SideModalForm<TFieldValues extends FieldValues>({
104104
<SideModal.Body>
105105
<form
106106
id={id}
107-
className="ox-form is-side-modal"
107+
className="ox-form"
108108
autoComplete="off"
109109
onSubmit={(e) => {
110110
if (!onSubmit) return

app/forms/idp/edit.tsx

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,20 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useForm } from 'react-hook-form'
9-
import { useNavigate, type LoaderFunctionArgs } from 'react-router'
9+
import {
10+
NavigationType,
11+
useNavigate,
12+
useNavigationType,
13+
type LoaderFunctionArgs,
14+
} from 'react-router'
1015

1116
import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api'
1217
import { Access16Icon } from '@oxide/design-system/icons/react'
1318

1419
import { DescriptionField } from '~/components/form/fields/DescriptionField'
1520
import { NameField } from '~/components/form/fields/NameField'
1621
import { TextField } from '~/components/form/fields/TextField'
17-
import { SideModalForm } from '~/components/form/SideModalForm'
22+
import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm'
1823
import { titleCrumb } from '~/hooks/use-crumbs'
1924
import { getIdpSelector, useIdpSelector } from '~/hooks/use-params'
2025
import { FormDivider } from '~/ui/lib/Divider'
@@ -40,24 +45,20 @@ export default function EditIdpSideModalForm() {
4045

4146
const navigate = useNavigate()
4247
const onDismiss = () => navigate(pb.silo({ silo }))
48+
const animate = useNavigationType() === NavigationType.Push
4349

4450
const form = useForm({ defaultValues: idp })
4551

4652
return (
47-
<SideModalForm
48-
form={form}
49-
formType="edit"
50-
resourceName="identity provider"
53+
<ReadOnlySideModalForm
5154
title="Identity provider"
5255
onDismiss={onDismiss}
56+
animate={animate}
5357
subtitle={
5458
<ResourceLabel>
5559
<Access16Icon /> {idp.name}
5660
</ResourceLabel>
5761
}
58-
// TODO: pass actual error when this form is hooked up
59-
submitError={null}
60-
loading={false}
6162
>
6263
<PropertiesTable>
6364
<PropertiesTable.IdRow id={idp.id} />
@@ -79,7 +80,6 @@ export default function EditIdpSideModalForm() {
7980
<FormDivider />
8081

8182
<SideModal.Heading>Service provider</SideModal.Heading>
82-
{/* TODO: help text */}
8383
<TextField
8484
name="spClientId"
8585
label="Service provider client ID"
@@ -107,7 +107,6 @@ export default function EditIdpSideModalForm() {
107107
<FormDivider />
108108

109109
<SideModal.Heading>Identity provider</SideModal.Heading>
110-
{/* TODO: help text */}
111110
<TextField
112111
name="idpEntityId"
113112
label="Entity ID"
@@ -123,6 +122,6 @@ export default function EditIdpSideModalForm() {
123122
control={form.control}
124123
disabled
125124
/>
126-
</SideModalForm>
125+
</ReadOnlySideModalForm>
127126
)
128127
}

app/forms/image-edit.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { Images16Icon } from '@oxide/design-system/icons/react'
1414
import { DescriptionField } from '~/components/form/fields/DescriptionField'
1515
import { NameField } from '~/components/form/fields/NameField'
1616
import { TextField } from '~/components/form/fields/TextField'
17-
import { SideModalForm } from '~/components/form/SideModalForm'
17+
import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm'
1818
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
1919
import { ResourceLabel } from '~/ui/lib/SideModal'
2020
import { capitalize } from '~/util/str'
@@ -24,30 +24,28 @@ export function EditImageSideModalForm({
2424
image,
2525
dismissLink,
2626
type,
27+
animate,
2728
}: {
2829
image: Image
2930
dismissLink: string
3031
type: 'Project' | 'Silo'
32+
animate?: boolean
3133
}) {
3234
const navigate = useNavigate()
3335
const form = useForm({ defaultValues: image })
3436
const resourceName = type === 'Project' ? 'project image' : 'silo image'
37+
const onDismiss = () => navigate(dismissLink)
3538

3639
return (
37-
<SideModalForm
40+
<ReadOnlySideModalForm
3841
title={capitalize(resourceName)}
39-
form={form}
40-
formType="edit"
41-
resourceName={resourceName}
42-
onDismiss={() => navigate(dismissLink)}
42+
onDismiss={onDismiss}
43+
animate={animate}
4344
subtitle={
4445
<ResourceLabel>
4546
<Images16Icon /> {image.name}
4647
</ResourceLabel>
4748
}
48-
// TODO: pass actual error when this form is hooked up
49-
submitError={null}
50-
loading={false}
5149
>
5250
<PropertiesTable>
5351
<PropertiesTable.Row label="Visibility">{type}</PropertiesTable.Row>
@@ -63,6 +61,6 @@ export function EditImageSideModalForm({
6361
<DescriptionField name="description" control={form.control} required disabled />
6462
<TextField name="os" label="OS" control={form.control} required disabled />
6563
<TextField name="version" control={form.control} required disabled />
66-
</SideModalForm>
64+
</ReadOnlySideModalForm>
6765
)
6866
}

app/forms/ssh-key-edit.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,20 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useForm } from 'react-hook-form'
9-
import { useNavigate, type LoaderFunctionArgs } from 'react-router'
9+
import {
10+
NavigationType,
11+
useNavigate,
12+
useNavigationType,
13+
type LoaderFunctionArgs,
14+
} from 'react-router'
1015

1116
import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api'
1217
import { Key16Icon } from '@oxide/design-system/icons/react'
1318

1419
import { DescriptionField } from '~/components/form/fields/DescriptionField'
1520
import { NameField } from '~/components/form/fields/NameField'
1621
import { TextField } from '~/components/form/fields/TextField'
17-
import { SideModalForm } from '~/components/form/SideModalForm'
22+
import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm'
1823
import { titleCrumb } from '~/hooks/use-crumbs'
1924
import { getSshKeySelector, useSshKeySelector } from '~/hooks/use-params'
2025
import { CopyToClipboard } from '~/ui/lib/CopyToClipboard'
@@ -41,22 +46,19 @@ export default function EditSSHKeySideModalForm() {
4146
const { data } = usePrefetchedQuery(sshKeyView(selector))
4247

4348
const form = useForm({ defaultValues: data })
49+
const onDismiss = () => navigate(pb.sshKeys())
50+
const animate = useNavigationType() === NavigationType.Push
4451

4552
return (
46-
<SideModalForm
47-
form={form}
48-
formType="edit"
49-
resourceName="SSH key"
53+
<ReadOnlySideModalForm
5054
title="View SSH key"
51-
onDismiss={() => navigate(pb.sshKeys())}
55+
onDismiss={onDismiss}
56+
animate={animate}
5257
subtitle={
5358
<ResourceLabel>
5459
<Key16Icon /> {data.name}
5560
</ResourceLabel>
5661
}
57-
// TODO: pass actual error when this form is hooked up
58-
loading={false}
59-
submitError={null}
6062
>
6163
<PropertiesTable>
6264
<PropertiesTable.IdRow id={data.id} />
@@ -77,6 +79,6 @@ export default function EditSSHKeySideModalForm() {
7779
disabled
7880
/>
7981
</div>
80-
</SideModalForm>
82+
</ReadOnlySideModalForm>
8183
)
8284
}

app/hooks/use-params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const getSiloImageSelector = requireParams('image')
4646
export const getSshKeySelector = requireParams('sshKey')
4747
export const getIdpSelector = requireParams('silo', 'provider')
4848
export const getProjectImageSelector = requireParams('project', 'image')
49+
export const getDiskSelector = requireParams('project', 'disk')
4950
export const getProjectSnapshotSelector = requireParams('project', 'snapshot')
5051
export const requireSledParams = requireParams('sledId')
5152
export const requireUpdateParams = requireParams('version')
@@ -81,6 +82,7 @@ function useSelectedParams<T>(getSelector: (params: AllParams) => T) {
8182
export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector)
8283
export const useProjectSelector = () => useSelectedParams(getProjectSelector)
8384
export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector)
85+
export const useDiskSelector = () => useSelectedParams(getDiskSelector)
8486
export const useSshKeySelector = () => useSelectedParams(getSshKeySelector)
8587
export const useProjectSnapshotSelector = () =>
8688
useSelectedParams(getProjectSnapshotSelector)

app/pages/SiloImageEdit.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { type LoaderFunctionArgs } from 'react-router'
8+
import { NavigationType, useNavigationType, type LoaderFunctionArgs } from 'react-router'
99

1010
import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api'
1111

@@ -28,6 +28,14 @@ export const handle = titleCrumb('Edit Image')
2828
export default function SiloImageEdit() {
2929
const selector = useSiloImageSelector()
3030
const { data } = usePrefetchedQuery(imageView(selector))
31+
const animate = useNavigationType() === NavigationType.Push
3132

32-
return <EditImageSideModalForm image={data} dismissLink={pb.siloImages()} type="Silo" />
33+
return (
34+
<EditImageSideModalForm
35+
image={data}
36+
dismissLink={pb.siloImages()}
37+
type="Silo"
38+
animate={animate}
39+
/>
40+
)
3341
}

0 commit comments

Comments
 (0)