Skip to content
63 changes: 17 additions & 46 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@
# API utilities & constants

- 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.
- 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.
- 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.
- API constants live in `app/api/util.ts` with links to Omicron source.

# Testing code

Expand All @@ -31,10 +30,10 @@

# Data fetching pattern

- Define endpoints with `apiq`, prefetch them in a `clientLoader`, then read data with `usePrefetchedQuery`.
- 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`).
- 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.
- Use `ALL_ISH` from `app/util/consts.ts` when UI needs "all" items. Use `queryClient.invalidateEndpoint` to invalidate queries.
- 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`).
- 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`).
- 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`).

# Mutations & UI flow

Expand Down Expand Up @@ -83,15 +82,14 @@
# Layout & accessibility

- 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`).
- 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.
- Surface page-level buttons and pagination via the `PageActions` and `Pagination` tunnels from `tunnel-rat`; anything rendered through `.In` lands in `.Target` automatically.
- 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`).
- 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.

# Route params & loaders

- 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`).
- Param selectors use React Query's `hashKey` internally to ensure stable object references across renders—same values = same object identity, preventing unnecessary re-renders.
- 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`).
- 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.

# Global stores & modals

Expand All @@ -100,47 +98,20 @@

# UI components & styling

- Reach for primitives in `app/ui` before inventing page-specific widgets; that directory intentionally holds router-agnostic building blocks (`app/ui/README.md`).
- Reach for primitives in `app/ui` before inventing page-specific widgets; that directory holds router-agnostic building blocks.
- When you just need Tailwind classes on a DOM element, use the `classed` helper instead of creating one-off wrappers (`app/util/classed.ts`).
- 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`).

# Docs & external links

- Keep help URLs centralized: add new docs to `links`/`docLinks` and reference them when wiring `DocsPopover` or help badges (`app/util/links.ts`).
- Reuse utility components for consistent formatting—`TimeAgo`, `EmptyMessage`, `CardBlock`, `DocsPopover`, `PropertiesTable`, etc.
- Import icons from `@oxide/design-system/icons/react` with size suffixes: `16` for inline/table, `24` for headers/buttons, `12` for tiny indicators.
- Keep help URLs in `links`/`docLinks` (`app/util/links.ts`).

# Error handling

- 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).
- 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`).
- 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`).
- 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`).

# Validation patterns

- 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.
- Description validation: use `validateDescription` for max 512 char limit (`app/components/form/fields/DescriptionField.tsx`).
- 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.
- All validation functions return `string | undefined` for react-hook-form compatibility.

# Type utilities

- Check `types/util.d.ts` for `NoExtraKeys` (catches accidental extra properties) and other type helpers.
- Prefer `type-fest` utilities for advanced type manipulation.
- Route param types in `app/util/path-params.ts` use `Required<Sel.X>` pattern to distinguish required path params from optional query params.

# Utility functions

- Check `app/util/*` for string formatting, date handling, math, IP parsing, arrays, and file utilities. Use existing helpers before writing new ones.

# Icons & visual feedback

- Import icons from `@oxide/design-system/icons/react` with size suffixes: `16` for inline/table use, `24` for headers/buttons, `12` for tiny indicators.
- Use `StateBadge` for resource states, `EmptyMessage` for empty states, `HL` for highlighted text in messages.
- All API errors flow through `processServerError` in `app/api/errors.ts`, which transforms raw errors into user-friendly messages.
- On 401 errors, requests auto-redirect to `/login`. On 403, the error boundary checks for IDP misconfiguration.
- Throw `trigger404` in loaders when resources don't exist; the error boundary will render Not Found.

# Role & permission patterns
# Utilities & helpers

- Role helpers in `app/api/roles.ts`: `getEffectiveRole` determines most permissive role from a list, `roleOrder` defines hierarchy (admin > collaborator > viewer).
- Use `useUserRows` hook to enrich role assignments with user/group names, sorted via `byGroupThenName` (groups first, then alphabetically).
- Use `useActorsNotInPolicy` to fetch users/groups not already in a policy (for add-user forms).
- Policy transformations: `updateRole` and `deleteRole` produce new policies immutably.
- Check `userRoleFromPolicies` to determine effective user role across multiple policies (e.g., project + silo).
- Check `app/util/*` for string formatting, date handling, IP parsing, etc. Check `types/util.d.ts` for type helpers.
- Use `validateName` for resource names, `validateDescription` for descriptions, `validateIp`/`validateIpNet` for IPs.
- Role helpers live in `app/api/roles.ts`.
2 changes: 2 additions & 0 deletions app/hooks/use-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const getSiloImageSelector = requireParams('image')
export const getSshKeySelector = requireParams('sshKey')
export const getIdpSelector = requireParams('silo', 'provider')
export const getProjectImageSelector = requireParams('project', 'image')
export const getDiskSelector = requireParams('project', 'disk')
export const getProjectSnapshotSelector = requireParams('project', 'snapshot')
export const requireSledParams = requireParams('sledId')
export const requireUpdateParams = requireParams('version')
Expand Down Expand Up @@ -81,6 +82,7 @@ function useSelectedParams<T>(getSelector: (params: AllParams) => T) {
export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector)
export const useProjectSelector = () => useSelectedParams(getProjectSelector)
export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector)
export const useDiskSelector = () => useSelectedParams(getDiskSelector)
export const useSshKeySelector = () => useSelectedParams(getSshKeySelector)
export const useProjectSnapshotSelector = () =>
useSelectedParams(getProjectSnapshotSelector)
Expand Down
109 changes: 109 additions & 0 deletions app/pages/project/disks/DiskDetailSideModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* 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 {
NavigationType,
useNavigate,
useNavigationType,
type LoaderFunctionArgs,
} from 'react-router'

import { api, q, queryClient, usePrefetchedQuery, type Disk } from '@oxide/api'
import { Storage16Icon } from '@oxide/design-system/icons/react'

import { DiskStateBadge } from '~/components/StateBadge'
import { titleCrumb } from '~/hooks/use-crumbs'
import { getDiskSelector, useDiskSelector } from '~/hooks/use-params'
import { EmptyCell } from '~/table/cells/EmptyCell'
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
import { ResourceLabel, SideModal } from '~/ui/lib/SideModal'
import { pb } from '~/util/path-builder'
import type * as PP from '~/util/path-params'
import { bytesToGiB } from '~/util/units'

const diskView = ({ disk, project }: PP.Disk) =>
q(api.diskView, { path: { disk }, query: { project } })

export async function clientLoader({ params }: LoaderFunctionArgs) {
const { project, disk } = getDiskSelector(params)
await queryClient.prefetchQuery(diskView({ project, disk }))
return null
}

export const handle = titleCrumb('Disk')

export default function DiskDetailSideModalRoute() {
const { project, disk } = useDiskSelector()
const navigate = useNavigate()
const { data } = usePrefetchedQuery(diskView({ project, disk }))
const animate = useNavigationType() === NavigationType.Push

return (
<DiskDetailSideModal
disk={data}
onDismiss={() => navigate(pb.disks({ project }))}
animate={animate}
/>
)
}

/**
* The inner content of the disk detail modal, separated so it can be used
* either as a standalone page/route or embedded in another page via query params.
*/

type DiskDetailSideModalProps = {
disk: Disk
onDismiss: () => void
/** Default true because when used outside a route (e.g., StorageTab), it's always a click action */
animate?: boolean
}

export function DiskDetailSideModal({
disk,
onDismiss,
animate = true,
}: DiskDetailSideModalProps) {
return (
<SideModal
isOpen
onDismiss={onDismiss}
animate={animate}
title="Disk details"
Copy link
Collaborator Author

@david-crespo david-crespo Dec 17, 2025

Choose a reason for hiding this comment

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

I would prefer to call this Disk, but it looks stupid. The other modals like this (project image, silo image, identity provider) are all naturally two-word titles. @benjaminleonard @charliepark if you think it looks fine as Disk, I'm fine changing it.

Image

subtitle={
<ResourceLabel>
<Storage16Icon /> {disk.name}
</ResourceLabel>
}
>
<SideModal.Body>
<PropertiesTable>
<PropertiesTable.IdRow id={disk.id} />
<PropertiesTable.DescriptionRow description={disk.description} />
<PropertiesTable.Row label="Size">
{bytesToGiB(disk.size)} GiB
</PropertiesTable.Row>
<PropertiesTable.Row label="State">
<DiskStateBadge state={disk.state.state} />
</PropertiesTable.Row>
{/* TODO: show attached instance by name like the table does? */}
<PropertiesTable.Row label="Block size">
{disk.blockSize.toLocaleString()} bytes
</PropertiesTable.Row>
<PropertiesTable.Row label="Image ID">
{disk.imageId ?? <EmptyCell />}
</PropertiesTable.Row>
<PropertiesTable.Row label="Snapshot ID">
{disk.snapshotId ?? <EmptyCell />}
</PropertiesTable.Row>
<PropertiesTable.DateRow label="Created" date={disk.timeCreated} />
<PropertiesTable.DateRow label="Last Modified" date={disk.timeModified} />
</PropertiesTable>
</SideModal.Body>
</SideModal>
)
}
49 changes: 28 additions & 21 deletions app/pages/project/disks/DisksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Copyright Oxide Computer Company
*/
import { createColumnHelper } from '@tanstack/react-table'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { Outlet, type LoaderFunctionArgs } from 'react-router'

import {
Expand All @@ -29,6 +29,7 @@ import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
import { confirmDelete } from '~/stores/confirm-delete'
import { addToast } from '~/stores/toast'
import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell'
import { makeLinkCell } from '~/table/cells/LinkCell'
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
import { Columns } from '~/table/columns/common'
import { useQueryTable } from '~/table/QueryTable'
Expand Down Expand Up @@ -80,25 +81,6 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {

const colHelper = createColumnHelper<Disk>()

const staticCols = [
colHelper.accessor('name', {}),
// sneaky: rather than looking at particular states, just look at
// whether it has an instance field
colHelper.accessor(
(disk) => ('instance' in disk.state ? disk.state.instance : undefined),
{
header: 'Attached to',
cell: (info) => <InstanceLinkCell instanceId={info.getValue()} />,
}
),
colHelper.accessor('size', Columns.size),
colHelper.accessor('state.state', {
header: 'state',
cell: (info) => <DiskStateBadge state={info.getValue()} />,
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
]

export default function DisksPage() {
const { project } = useProjectSelector()

Expand Down Expand Up @@ -162,7 +144,32 @@ export default function DisksPage() {
[createSnapshot, deleteDisk, project]
)

const columns = useColsWithActions(staticCols, makeActions)
const columns = useColsWithActions(
useMemo(
() => [
colHelper.accessor('name', {
cell: makeLinkCell((name) => pb.disk({ project, disk: name })),
}),
// sneaky: rather than looking at particular states, just look at
// whether it has an instance field
colHelper.accessor(
(disk) => ('instance' in disk.state ? disk.state.instance : undefined),
{
header: 'Attached to',
cell: (info) => <InstanceLinkCell instanceId={info.getValue()} />,
}
),
colHelper.accessor('size', Columns.size),
colHelper.accessor('state.state', {
header: 'state',
cell: (info) => <DiskStateBadge state={info.getValue()} />,
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
],
[project]
),
makeActions
)
const { table } = useQueryTable({
query: diskList({ project }),
columns,
Expand Down
38 changes: 28 additions & 10 deletions app/pages/project/instances/StorageTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,10 @@ import { DiskStateBadge } from '~/components/StateBadge'
import { AttachDiskModalForm } from '~/forms/disk-attach'
import { CreateDiskSideModalForm } from '~/forms/disk-create'
import { getInstanceSelector, useInstanceSelector } from '~/hooks/use-params'
import { DiskDetailSideModal } from '~/pages/project/disks/DiskDetailSideModal'
import { confirmAction } from '~/stores/confirm-action'
import { addToast } from '~/stores/toast'
import { ButtonCell } from '~/table/cells/LinkCell'
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
import { Columns } from '~/table/columns/common'
import { Table } from '~/table/Table'
Expand Down Expand Up @@ -66,16 +68,6 @@ type InstanceDisk = Disk & {
}

const colHelper = createColumnHelper<InstanceDisk>()
const staticCols = [
colHelper.accessor('name', { header: 'Disk' }),
colHelper.accessor('size', Columns.size),
colHelper.accessor((row) => row.state.state, {
header: 'state',
cell: (info) => <DiskStateBadge state={info.getValue()} />,
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
]

export const handle = { crumb: 'Storage' }

export default function StorageTab() {
Expand All @@ -88,6 +80,26 @@ export default function StorageTab() {
[instanceName, project]
)

const staticCols = useMemo(
() => [
colHelper.accessor('name', {
header: 'Disk',
cell: (info) => (
<ButtonCell onClick={() => setSelectedDisk(info.row.original)}>
{info.getValue()}
</ButtonCell>
),
}),
colHelper.accessor('size', Columns.size),
colHelper.accessor((row) => row.state.state, {
header: 'state',
cell: (info) => <DiskStateBadge state={info.getValue()} />,
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
],
[]
)

const { mutateAsync: detachDisk } = useApiMutation(api.instanceDiskDetach, {
onSuccess(disk) {
queryClient.invalidateEndpoint('instanceDiskList')
Expand Down Expand Up @@ -121,6 +133,9 @@ export default function StorageTab() {
},
})

// for showing disk detail side modal
const [selectedDisk, setSelectedDisk] = useState<Disk | null>(null)

// shared between boot and other disks
const getSnapshotAction = useCallback(
(disk: InstanceDisk) => ({
Expand Down Expand Up @@ -395,6 +410,9 @@ export default function StorageTab() {
submitError={attachDisk.error}
/>
)}
{selectedDisk && (
<DiskDetailSideModal disk={selectedDisk} onDismiss={() => setSelectedDisk(null)} />
)}
</div>
)
}
Expand Down
Loading
Loading