diff --git a/AGENTS.md b/AGENTS.md index 6ef203b8a..cf3a75dd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 isn’t 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 @@ -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 @@ -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 `` (`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` 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`. diff --git a/app/components/form/ReadOnlySideModalForm.tsx b/app/components/form/ReadOnlySideModalForm.tsx new file mode 100644 index 000000000..6540adcdf --- /dev/null +++ b/app/components/form/ReadOnlySideModalForm.tsx @@ -0,0 +1,55 @@ +/* + * 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 { ReactNode } from 'react' + +import { Button } from '~/ui/lib/Button' +import { SideModal } from '~/ui/lib/SideModal' + +type ReadOnlySideModalFormProps = { + title: string + subtitle?: ReactNode + onDismiss: () => void + children: ReactNode + /** + * Whether to animate the modal opening. Defaults to true. Used to prevent + * modal from animating in on a fresh pageload where it should already be + * open. + */ + animate?: boolean +} + +/** + * A read-only side modal that displays form fields in a non-editable state. + * Use this for "view" or "detail" modals where fields are shown but not editable. + */ +export function ReadOnlySideModalForm({ + title, + subtitle, + onDismiss, + children, + animate, +}: ReadOnlySideModalFormProps) { + return ( + + +
{children}
+
+ + + +
+ ) +} diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index 5d8f15ba5..067ceba7e 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -104,7 +104,7 @@ export function SideModalForm({
{ if (!onSubmit) return diff --git a/app/forms/idp/edit.tsx b/app/forms/idp/edit.tsx index eea0fbae6..4f1edc103 100644 --- a/app/forms/idp/edit.tsx +++ b/app/forms/idp/edit.tsx @@ -6,7 +6,12 @@ * Copyright Oxide Computer Company */ import { useForm } from 'react-hook-form' -import { useNavigate, type LoaderFunctionArgs } from 'react-router' +import { + NavigationType, + useNavigate, + useNavigationType, + type LoaderFunctionArgs, +} from 'react-router' import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { Access16Icon } from '@oxide/design-system/icons/react' @@ -14,7 +19,7 @@ import { Access16Icon } from '@oxide/design-system/icons/react' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' -import { SideModalForm } from '~/components/form/SideModalForm' +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' import { titleCrumb } from '~/hooks/use-crumbs' import { getIdpSelector, useIdpSelector } from '~/hooks/use-params' import { FormDivider } from '~/ui/lib/Divider' @@ -40,24 +45,20 @@ export default function EditIdpSideModalForm() { const navigate = useNavigate() const onDismiss = () => navigate(pb.silo({ silo })) + const animate = useNavigationType() === NavigationType.Push const form = useForm({ defaultValues: idp }) return ( - {idp.name} } - // TODO: pass actual error when this form is hooked up - submitError={null} - loading={false} > @@ -79,7 +80,6 @@ export default function EditIdpSideModalForm() { Service provider - {/* TODO: help text */} Identity provider - {/* TODO: help text */} - + ) } diff --git a/app/forms/image-edit.tsx b/app/forms/image-edit.tsx index 4f864a61b..559d9067f 100644 --- a/app/forms/image-edit.tsx +++ b/app/forms/image-edit.tsx @@ -14,7 +14,7 @@ import { Images16Icon } from '@oxide/design-system/icons/react' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' -import { SideModalForm } from '~/components/form/SideModalForm' +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { ResourceLabel } from '~/ui/lib/SideModal' import { capitalize } from '~/util/str' @@ -24,30 +24,28 @@ export function EditImageSideModalForm({ image, dismissLink, type, + animate, }: { image: Image dismissLink: string type: 'Project' | 'Silo' + animate?: boolean }) { const navigate = useNavigate() const form = useForm({ defaultValues: image }) const resourceName = type === 'Project' ? 'project image' : 'silo image' + const onDismiss = () => navigate(dismissLink) return ( - navigate(dismissLink)} + onDismiss={onDismiss} + animate={animate} subtitle={ {image.name} } - // TODO: pass actual error when this form is hooked up - submitError={null} - loading={false} > {type} @@ -63,6 +61,6 @@ export function EditImageSideModalForm({ - + ) } diff --git a/app/forms/ssh-key-edit.tsx b/app/forms/ssh-key-edit.tsx index 930ae4e6d..6bb6d7f66 100644 --- a/app/forms/ssh-key-edit.tsx +++ b/app/forms/ssh-key-edit.tsx @@ -6,7 +6,12 @@ * Copyright Oxide Computer Company */ import { useForm } from 'react-hook-form' -import { useNavigate, type LoaderFunctionArgs } from 'react-router' +import { + NavigationType, + useNavigate, + useNavigationType, + type LoaderFunctionArgs, +} from 'react-router' import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' import { Key16Icon } from '@oxide/design-system/icons/react' @@ -14,7 +19,7 @@ import { Key16Icon } from '@oxide/design-system/icons/react' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' -import { SideModalForm } from '~/components/form/SideModalForm' +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' import { titleCrumb } from '~/hooks/use-crumbs' import { getSshKeySelector, useSshKeySelector } from '~/hooks/use-params' import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' @@ -41,22 +46,19 @@ export default function EditSSHKeySideModalForm() { const { data } = usePrefetchedQuery(sshKeyView(selector)) const form = useForm({ defaultValues: data }) + const onDismiss = () => navigate(pb.sshKeys()) + const animate = useNavigationType() === NavigationType.Push return ( - navigate(pb.sshKeys())} + onDismiss={onDismiss} + animate={animate} subtitle={ {data.name} } - // TODO: pass actual error when this form is hooked up - loading={false} - submitError={null} > @@ -77,6 +79,6 @@ export default function EditSSHKeySideModalForm() { disabled /> - + ) } diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index f11a3aae8..9619697c7 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -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') @@ -81,6 +82,7 @@ function useSelectedParams(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) diff --git a/app/pages/SiloImageEdit.tsx b/app/pages/SiloImageEdit.tsx index 95e3db9a4..831176e4d 100644 --- a/app/pages/SiloImageEdit.tsx +++ b/app/pages/SiloImageEdit.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { type LoaderFunctionArgs } from 'react-router' +import { NavigationType, useNavigationType, type LoaderFunctionArgs } from 'react-router' import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' @@ -28,6 +28,14 @@ export const handle = titleCrumb('Edit Image') export default function SiloImageEdit() { const selector = useSiloImageSelector() const { data } = usePrefetchedQuery(imageView(selector)) + const animate = useNavigationType() === NavigationType.Push - return + return ( + + ) } diff --git a/app/pages/project/disks/DiskDetailSideModal.tsx b/app/pages/project/disks/DiskDetailSideModal.tsx new file mode 100644 index 000000000..5d3facb7d --- /dev/null +++ b/app/pages/project/disks/DiskDetailSideModal.tsx @@ -0,0 +1,105 @@ +/* + * 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 { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' +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 } 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 ( + 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 ( + + {disk.name} + + } + > + + + + {bytesToGiB(disk.size)} GiB + + + + {/* TODO: show attached instance by name like the table does? */} + + {disk.blockSize.toLocaleString()} bytes + + + {disk.imageId ?? } + + + {disk.snapshotId ?? } + + + + + + ) +} diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index e4530a6a4..2dc5871ff 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -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 { @@ -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' @@ -80,25 +81,6 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const colHelper = createColumnHelper() -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) => , - } - ), - colHelper.accessor('size', Columns.size), - colHelper.accessor('state.state', { - header: 'state', - cell: (info) => , - }), - colHelper.accessor('timeCreated', Columns.timeCreated), -] - export default function DisksPage() { const { project } = useProjectSelector() @@ -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) => , + } + ), + colHelper.accessor('size', Columns.size), + colHelper.accessor('state.state', { + header: 'state', + cell: (info) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + ], + [project] + ), + makeActions + ) const { table } = useQueryTable({ query: diskList({ project }), columns, diff --git a/app/pages/project/images/ProjectImageEdit.tsx b/app/pages/project/images/ProjectImageEdit.tsx index f089c5e05..8a86c2947 100644 --- a/app/pages/project/images/ProjectImageEdit.tsx +++ b/app/pages/project/images/ProjectImageEdit.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { type LoaderFunctionArgs } from 'react-router' +import { NavigationType, useNavigationType, type LoaderFunctionArgs } from 'react-router' import { api, q, queryClient, usePrefetchedQuery } from '@oxide/api' @@ -29,7 +29,15 @@ export const handle = titleCrumb('Edit Image') export default function ProjectImageEdit() { const selector = useProjectImageSelector() const { data } = usePrefetchedQuery(imageView(selector)) + const animate = useNavigationType() === NavigationType.Push const dismissLink = pb.projectImages({ project: selector.project }) - return + return ( + + ) } diff --git a/app/pages/project/instances/StorageTab.tsx b/app/pages/project/instances/StorageTab.tsx index c5772a9de..e6b928fa6 100644 --- a/app/pages/project/instances/StorageTab.tsx +++ b/app/pages/project/instances/StorageTab.tsx @@ -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' @@ -66,16 +68,6 @@ type InstanceDisk = Disk & { } const colHelper = createColumnHelper() -const staticCols = [ - colHelper.accessor('name', { header: 'Disk' }), - colHelper.accessor('size', Columns.size), - colHelper.accessor((row) => row.state.state, { - header: 'state', - cell: (info) => , - }), - colHelper.accessor('timeCreated', Columns.timeCreated), -] - export const handle = { crumb: 'Storage' } export default function StorageTab() { @@ -88,6 +80,26 @@ export default function StorageTab() { [instanceName, project] ) + const staticCols = useMemo( + () => [ + colHelper.accessor('name', { + header: 'Disk', + cell: (info) => ( + setSelectedDisk(info.row.original)}> + {info.getValue()} + + ), + }), + colHelper.accessor('size', Columns.size), + colHelper.accessor((row) => row.state.state, { + header: 'state', + cell: (info) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + ], + [] + ) + const { mutateAsync: detachDisk } = useApiMutation(api.instanceDiskDetach, { onSuccess(disk) { queryClient.invalidateEndpoint('instanceDiskList') @@ -121,6 +133,9 @@ export default function StorageTab() { }, }) + // for showing disk detail side modal + const [selectedDisk, setSelectedDisk] = useState(null) + // shared between boot and other disks const getSnapshotAction = useCallback( (disk: InstanceDisk) => ({ @@ -395,6 +410,9 @@ export default function StorageTab() { submitError={attachDisk.error} /> )} + {selectedDisk && ( + setSelectedDisk(null)} /> + )} ) } diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 449e8a28f..32d5e4de3 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { @@ -17,6 +17,7 @@ import { qErrorsAllowed, queryClient, useApiMutation, + type Disk, type Snapshot, } from '@oxide/api' import { Snapshots16Icon, Snapshots24Icon } from '@oxide/design-system/icons/react' @@ -26,8 +27,10 @@ import { DocsPopover } from '~/components/DocsPopover' import { SnapshotStateBadge } from '~/components/StateBadge' import { makeCrumb } from '~/hooks/use-crumbs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { DiskDetailSideModal } from '~/pages/project/disks/DiskDetailSideModal' import { confirmDelete } from '~/stores/confirm-delete' import { SkeletonCell } from '~/table/cells/EmptyCell' +import { ButtonCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' @@ -38,12 +41,18 @@ import { TableActions } from '~/ui/lib/Table' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -const DiskNameFromId = ({ value }: { value: string }) => { +const DiskNameFromId = ({ + value, + onClick, +}: { + value: string + onClick: (disk: Disk) => void +}) => { const { data } = useQuery(qErrorsAllowed(api.diskView, { path: { disk: value } })) if (!data) return if (data.type === 'error') return Deleted - return {data.data.name} + return onClick(data.data)}>{data.data.name} } const EmptyState = () => ( @@ -89,23 +98,25 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { export const handle = makeCrumb('Snapshots', (p) => pb.snapshots(getProjectSelector(p))) const colHelper = createColumnHelper() -const staticCols = [ - colHelper.accessor('name', {}), - colHelper.accessor('description', Columns.description), - colHelper.accessor('diskId', { - header: 'disk', - cell: (info) => , - }), - colHelper.accessor('state', { - cell: (info) => , - }), - colHelper.accessor('size', Columns.size), - colHelper.accessor('timeCreated', Columns.timeCreated), -] export default function SnapshotsPage() { const { project } = useProjectSelector() const navigate = useNavigate() + const [selectedDisk, setSelectedDisk] = useState(null) + + const staticCols = [ + colHelper.accessor('name', {}), + colHelper.accessor('description', Columns.description), + colHelper.accessor('diskId', { + header: 'disk', + cell: (info) => , + }), + colHelper.accessor('state', { + cell: (info) => , + }), + colHelper.accessor('size', Columns.size), + colHelper.accessor('timeCreated', Columns.timeCreated), + ] const { mutateAsync: deleteSnapshot } = useApiMutation(api.snapshotDelete, { onSuccess() { @@ -157,6 +168,9 @@ export default function SnapshotsPage() { {table} + {selectedDisk && ( + setSelectedDisk(null)} /> + )} ) } diff --git a/app/pages/project/vpcs/internet-gateway-edit.tsx b/app/pages/project/vpcs/internet-gateway-edit.tsx index 2e29e984b..2365b0dc2 100644 --- a/app/pages/project/vpcs/internet-gateway-edit.tsx +++ b/app/pages/project/vpcs/internet-gateway-edit.tsx @@ -7,13 +7,18 @@ */ import { useQuery } from '@tanstack/react-query' -import { useForm } from 'react-hook-form' -import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router' +import { + Link, + NavigationType, + useNavigate, + useNavigationType, + type LoaderFunctionArgs, +} from 'react-router' import { Gateway16Icon } from '@oxide/design-system/icons/react' import { api, q, queryClient, usePrefetchedQuery } from '~/api' -import { SideModalForm } from '~/components/form/SideModalForm' +import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' import { titleCrumb } from '~/hooks/use-crumbs' import { getInternetGatewaySelector, useInternetGatewaySelector } from '~/hooks/use-params' import { IpPoolCell } from '~/table/cells/IpPoolCell' @@ -91,6 +96,7 @@ export default function EditInternetGatewayForm() { const navigate = useNavigate() const { project, vpc, gateway } = useInternetGatewaySelector() const onDismiss = () => navigate(pb.vpcInternetGateways({ project, vpc })) + const animate = useNavigationType() === NavigationType.Push const { data: internetGateway } = usePrefetchedQuery( q(api.internetGatewayView, { query: { project, vpc }, @@ -104,25 +110,18 @@ export default function EditInternetGatewayForm() { gatewayIpAddressList({ project, vpc, gateway }).optionsFn() ) - const form = useForm({}) - const hasAttachedPool = gatewayIpPools && gatewayIpPools.length > 0 return ( - {internetGateway.name} } - form={form} - // TODO: pass actual error when this form is hooked up - submitError={null} - loading={false} > - + ) } diff --git a/app/routes.tsx b/app/routes.tsx index 8a16d50b5..dbd05d438 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -500,6 +500,10 @@ export const routes = createRoutesFromElements( path="disks-new" lazy={() => import('./pages/project/disks/DiskCreate').then(convert)} /> + import('./pages/project/disks/DiskDetailSideModal').then(convert)} + /> import('./pages/project/snapshots/SnapshotsPage').then(convert)} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 0666f345a..3dcb8ddac 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -77,6 +77,20 @@ exports[`breadcrumbs 2`] = ` }, ], "deviceSuccess (/device/success)": [], + "disk (/projects/p/disks/d)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Disks", + "path": "/projects/p/disks", + }, + ], "diskInventory (/system/inventory/disks)": [ { "label": "Inventory", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 35aefaab2..74a40e4ef 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -28,6 +28,7 @@ const params = { provider: 'pr', sledId: '5c56b522-c9b8-49e4-9f9a-8d52a89ec3e0', image: 'im', + disk: 'd', sshKey: 'ss', snapshot: 'sn', pool: 'pl', @@ -47,6 +48,7 @@ test('path builder', () => { "antiAffinityGroup": "/projects/p/affinity/aag", "antiAffinityGroupEdit": "/projects/p/affinity/aag/edit", "deviceSuccess": "/device/success", + "disk": "/projects/p/disks/d", "diskInventory": "/system/inventory/disks", "disks": "/projects/p/disks", "disksNew": "/projects/p/disks-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index da7e04c69..6d5509213 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -55,6 +55,7 @@ export const pb = { disksNew: (params: PP.Project) => `${projectBase(params)}/disks-new`, disks: (params: PP.Project) => `${projectBase(params)}/disks`, + disk: (params: PP.Disk) => `${pb.disks(params)}/${params.disk}`, snapshotsNew: (params: PP.Project) => `${projectBase(params)}/snapshots-new`, snapshots: (params: PP.Project) => `${projectBase(params)}/snapshots`, diff --git a/app/util/path-params.ts b/app/util/path-params.ts index cc477edf5..ee4549a86 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -28,3 +28,4 @@ export type VpcInternetGateway = Required export type SshKey = Required export type AffinityGroup = Required export type AntiAffinityGroup = Required +export type Disk = Required diff --git a/mock-api/disk.ts b/mock-api/disk.ts index 2a6f8e003..490539408 100644 --- a/mock-api/disk.ts +++ b/mock-api/disk.ts @@ -110,7 +110,8 @@ export const disks: Json[] = [ { id: '4d6f4c76-675f-4cda-b609-f3b8b301addb', name: 'disk-5', - description: '', + description: + "It is an ancient Mariner, And he stoppeth one of three. 'By thy long grey beard and glittering eye, Now wherefore stopp'st thou me? The Bridegroom's doors are opened wide, And I am next of kin; The guests are met, the feast is set: May'st hear the merry din.'", project_id: project.id, time_created: new Date().toISOString(), time_modified: new Date().toISOString(), diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 13482ad3e..5f657169e 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -14,6 +14,18 @@ import { test, } from './utils' +test('Disk detail side modal', async ({ page }) => { + await page.goto('/projects/mock-project/disks') + + await page.getByRole('link', { name: 'disk-1', exact: true }).click() + + const modal = page.getByRole('dialog', { name: 'Disk details' }) + await expect(modal).toBeVisible() + await expect(modal.getByText('disk-1')).toBeVisible() + await expect(modal.getByText('2 GiB')).toBeVisible() + await expect(modal.getByText('2,048 bytes')).toBeVisible() // block size +}) + test('List disks and snapshot', async ({ page }) => { await page.goto('/projects/mock-project/disks') diff --git a/test/e2e/instance-disks.e2e.ts b/test/e2e/instance-disks.e2e.ts index f6c59b063..7bbd24172 100644 --- a/test/e2e/instance-disks.e2e.ts +++ b/test/e2e/instance-disks.e2e.ts @@ -17,6 +17,17 @@ import { test, } from './utils' +test('Disk detail side modal', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1') + + await page.getByRole('button', { name: 'disk-1' }).click() + + const modal = page.getByRole('dialog', { name: 'Disk details' }) + await expect(modal).toBeVisible() + await expect(modal.getByText('disk-1')).toBeVisible() + await expect(modal.getByText('2 GiB')).toBeVisible() +}) + test('Disabled actions', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1') diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 4260e0a09..1c76229a3 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -204,7 +204,7 @@ test('Identity providers', async ({ page }) => { 'groups' ) - await page.getByRole('button', { name: 'Close' }).click() + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() await expect(dialog).toBeHidden() diff --git a/test/e2e/snapshots.e2e.ts b/test/e2e/snapshots.e2e.ts index d5112cce5..c6ddc8183 100644 --- a/test/e2e/snapshots.e2e.ts +++ b/test/e2e/snapshots.e2e.ts @@ -25,14 +25,26 @@ test('Click through snapshots', async ({ page }) => { await expectRowVisible(table, { name: 'snapshot-disk-deleted', disk: 'Deleted' }) }) +test('Disk button opens detail modal', async ({ page }) => { + await page.goto('/projects/mock-project/snapshots') + + const table = page.getByRole('table') + await expectRowVisible(table, { name: 'snapshot-1', disk: 'disk-1' }) + + await page.getByRole('button', { name: 'disk-1' }).first().click() + + const modal = page.getByRole('dialog', { name: 'Disk details' }) + await expect(modal).toBeVisible() + await expect(modal.getByText('disk-1')).toBeVisible() +}) + test('Confirm delete snapshot', async ({ page }) => { await page.goto('/projects/mock-project/snapshots') const row = page.getByRole('row', { name: 'disk-1-snapshot-10' }) - // scroll a little so the dropdown menu isn't behind the pagination bar - await page.getByRole('table').click() // focus the content pane - await page.mouse.wheel(0, 200) + // scroll so the dropdown menu isn't behind the pagination bar + await row.scrollIntoViewIfNeeded() async function clickDelete() { await row.getByRole('button', { name: 'Row actions' }).click() diff --git a/test/e2e/ssh-keys.e2e.ts b/test/e2e/ssh-keys.e2e.ts index 0f45d7da6..8e752b6aa 100644 --- a/test/e2e/ssh-keys.e2e.ts +++ b/test/e2e/ssh-keys.e2e.ts @@ -36,7 +36,7 @@ test('SSH keys', async ({ page }) => { await expect(modal.getByRole('textbox', { name: 'Public key' })).toBeDisabled() // close modal - await modal.getByRole('button', { name: 'Close' }).click() + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() await expect(modal).toBeHidden() // delete the two ssh keys diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 6efb700cb..952fc4d67 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -354,7 +354,7 @@ test('can view internet gateways', async ({ page }) => { await expect(sidemodal.getByText('123.4.56.3')).toBeVisible() // close the sidemodal - await sidemodal.getByRole('button', { name: 'Close' }).click() + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() await expect(sidemodal).toBeHidden() await page.getByRole('link', { name: 'internet-gateway-2' }).click() @@ -373,7 +373,7 @@ test('internet gateway shows proper list of routes targeting it', async ({ page await expect(table.locator('tbody >> tr')).toHaveCount(1) // close the sidemodal - await sidemodal.getByRole('button', { name: 'Close' }).click() + await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() await expect(sidemodal).toBeHidden() // check for the route count; which should be 1 await expect(page.getByRole('link', { name: '1', exact: true })).toBeVisible()