diff --git a/app/api/hooks.ts b/app/api/hooks.ts index a44aba237c..88c82a2053 100644 --- a/app/api/hooks.ts +++ b/app/api/hooks.ts @@ -219,6 +219,11 @@ export const wrapQueryClient = (api: A, queryClient: QueryC queryClient.invalidateQueries({ queryKey: [method], ...filters }), setQueryData: (method: M, params: Params, data: Result) => queryClient.setQueryData([method, params], data), + setQueryDataErrorsAllowed: ( + method: M, + params: Params, + data: ErrorsAllowed, ApiError> + ) => queryClient.setQueryData([method, params, ERRORS_ALLOWED], data), fetchQuery: ( method: M, params: Params, diff --git a/app/components/AccessNameCell.tsx b/app/components/AccessNameCell.tsx deleted file mode 100644 index f3d0b920be..0000000000 --- a/app/components/AccessNameCell.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 { CellContext } from '@tanstack/react-table' - -import type { IdentityType } from '@oxide/api' - -import { Badge } from '~/ui/lib/Badge' - -/** - * Display the user or group name. If the row is for a group, add a GROUP badge. - */ -export const AccessNameCell = < - RowData extends { name: string; identityType: IdentityType }, ->( - info: CellContext -) => { - const name = info.getValue() - const identityType = info.row.original.identityType - return ( - <> - {name} - {identityType === 'silo_group' ? ( - - Group - - ) : null} - - ) -} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index e4b383282b..352c3497fd 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import cn from 'classnames' -import { NavLink } from 'react-router-dom' +import { NavLink, useLocation } from 'react-router-dom' import { Action16Icon, Document16Icon } from '@oxide/design-system/icons/react' @@ -88,20 +88,24 @@ export const NavLinkItem = (props: { children: React.ReactNode end?: boolean disabled?: boolean -}) => ( -
  • - - cn(linkStyles, { - 'text-accent !bg-accent-secondary hover:!bg-accent-secondary-hover svg:!text-accent-tertiary': - isActive, - 'pointer-events-none text-disabled': props.disabled, - }) - } - end={props.end} - > - {props.children} - -
  • -) +}) => { + // If the current page's URL matches the create form for this NavLink resource, we want to highlight the NavLink in the sidebar. + const currentPathIsCreateForm = useLocation().pathname === `${props.to}-new` + return ( +
  • + + cn(linkStyles, { + 'text-accent !bg-accent-secondary hover:!bg-accent-secondary-hover svg:!text-accent-tertiary': + isActive || currentPathIsCreateForm, + 'pointer-events-none text-disabled': props.disabled, + }) + } + end={props.end} + > + {props.children} + +
  • + ) +} diff --git a/app/pages/SiloAccessPage.tsx b/app/pages/SiloAccessPage.tsx index 0ba0d4f014..f331a3d877 100644 --- a/app/pages/SiloAccessPage.tsx +++ b/app/pages/SiloAccessPage.tsx @@ -22,7 +22,6 @@ import { } from '@oxide/api' import { Access24Icon } from '@oxide/design-system/icons/react' -import { AccessNameCell } from '~/components/AccessNameCell' import { HL } from '~/components/HL' import { RoleBadgeCell } from '~/components/RoleBadgeCell' import { @@ -36,6 +35,7 @@ import { Button } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { accessTypeLabel } from '~/util/access' import { groupBy, isTruthy } from '~/util/array' const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -111,7 +111,11 @@ export function SiloAccessPage() { const columns = useMemo( () => [ - colHelper.accessor('name', { header: 'Name', cell: AccessNameCell }), + colHelper.accessor('name', { header: 'Name' }), + colHelper.accessor('identityType', { + header: 'Type', + cell: (props) => accessTypeLabel(props.getValue()), + }), colHelper.accessor('siloRole', { header: 'Silo role', cell: RoleBadgeCell, diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 08d365b291..e81c245d40 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -24,7 +24,6 @@ import { } from '@oxide/api' import { Access24Icon } from '@oxide/design-system/icons/react' -import { AccessNameCell } from '~/components/AccessNameCell' import { HL } from '~/components/HL' import { RoleBadgeCell } from '~/components/RoleBadgeCell' import { @@ -39,6 +38,7 @@ import { Button } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' +import { accessTypeLabel } from '~/util/access' import { groupBy, isTruthy } from '~/util/array' const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -127,7 +127,11 @@ export function ProjectAccessPage() { const columns = useMemo( () => [ - colHelper.accessor('name', { header: 'Name', cell: AccessNameCell }), + colHelper.accessor('name', { header: 'Name' }), + colHelper.accessor('identityType', { + header: 'Type', + cell: (props) => accessTypeLabel(props.getValue()), + }), colHelper.accessor('siloRole', { header: 'Silo role', cell: RoleBadgeCell, diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 0ac9448ef1..9fe75ed560 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -44,9 +44,27 @@ const EmptyState = () => ( ) DisksPage.loader = async ({ params }: LoaderFunctionArgs) => { - await apiQueryClient.prefetchQuery('diskList', { - query: { ...getProjectSelector(params), limit: 25 }, - }) + const { project } = getProjectSelector(params) + await Promise.all([ + apiQueryClient.prefetchQuery('diskList', { + query: { project, limit: 25 }, + }), + + // fetch instances and preload into RQ cache so fetches by ID in + // InstanceLinkCell can be mostly instant yet gracefully fall back to + // fetching individually if we don't fetch them all here + apiQueryClient + .fetchQuery('instanceList', { query: { project, limit: 200 } }) + .then((instances) => { + for (const instance of instances.items) { + apiQueryClient.setQueryData( + 'instanceView', + { path: { instance: instance.id } }, + instance + ) + } + }), + ]) return null } diff --git a/app/pages/project/snapshots/SnapshotsPage.tsx b/app/pages/project/snapshots/SnapshotsPage.tsx index 13fdf3aed6..4208dee58a 100644 --- a/app/pages/project/snapshots/SnapshotsPage.tsx +++ b/app/pages/project/snapshots/SnapshotsPage.tsx @@ -50,9 +50,32 @@ const EmptyState = () => ( ) SnapshotsPage.loader = async ({ params }: LoaderFunctionArgs) => { - await apiQueryClient.prefetchQuery('snapshotList', { - query: { ...getProjectSelector(params), limit: 25 }, - }) + const { project } = getProjectSelector(params) + await Promise.all([ + apiQueryClient.prefetchQuery('snapshotList', { + query: { project, limit: 25 }, + }), + + // Fetch disks and preload into RQ cache so fetches by ID in DiskNameFromId + // can be mostly instant yet gracefully fall back to fetching individually + // if we don't fetch them all here. This has to be the *ErrorsAllowed + // version of setQueryData because the disk fetchs are also the errors + // allowed version. If we use regular setQueryData, nothing blows up; the + // data is just never found in the cache. Note that the disks that error + // (delete disks) are not prefetched here because they are (obviously) not + // in the disk list response. + apiQueryClient + .fetchQuery('diskList', { query: { project, limit: 200 } }) + .then((disks) => { + for (const disk of disks.items) { + apiQueryClient.setQueryDataErrorsAllowed( + 'diskView', + { path: { disk: disk.id } }, + { type: 'success', data: disk } + ) + } + }), + ]) return null } diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index ddaaf89ab5..b64b777dcd 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -54,6 +54,15 @@ IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) { path: { pool }, query: { limit: 25 }, // match QueryTable }), + + // fetch silos and preload into RQ cache so fetches by ID in SiloNameFromId + // can be mostly instant yet gracefully fall back to fetching individually + // if we don't fetch them all here + apiQueryClient.fetchQuery('siloList', { query: { limit: 200 } }).then((silos) => { + for (const silo of silos.items) { + apiQueryClient.setQueryData('siloView', { path: { silo: silo.id } }, silo) + } + }), ]) return null } diff --git a/app/util/access.spec.tsx b/app/util/access.spec.tsx new file mode 100644 index 0000000000..2f0ed47884 --- /dev/null +++ b/app/util/access.spec.tsx @@ -0,0 +1,15 @@ +/* + * 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 { expect, test } from 'vitest' + +import { accessTypeLabel } from './access' + +test('accessTypeLabel', () => { + expect(accessTypeLabel('silo_group')).toEqual('Group') + expect(accessTypeLabel('silo_user')).toEqual('User') +}) diff --git a/app/util/access.ts b/app/util/access.ts new file mode 100644 index 0000000000..0816af6136 --- /dev/null +++ b/app/util/access.ts @@ -0,0 +1,11 @@ +/* + * 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 { IdentityType } from '~/api' + +export const accessTypeLabel = (identityType: IdentityType) => + identityType === 'silo_group' ? 'Group' : 'User' diff --git a/test/e2e/project-access.e2e.ts b/test/e2e/project-access.e2e.ts index a6481688bc..cc177ced09 100644 --- a/test/e2e/project-access.e2e.ts +++ b/test/e2e/project-access.e2e.ts @@ -18,22 +18,26 @@ test('Click through project access page', async ({ page }) => { const table = page.locator('table') await expectRowVisible(table, { Name: 'Hannah Arendt', + Type: 'User', 'Silo role': 'admin', 'Project role': '', }) await expectRowVisible(table, { Name: 'Jacob Klein', + Type: 'User', 'Silo role': '', 'Project role': 'collaborator', }) await expectRowVisible(table, { // no space because expectRowVisible uses textContent, not accessible name - Name: 'real-estate-devsGroup', + Name: 'real-estate-devs', + Type: 'Group', 'Silo role': 'collaborator', }) await expectRowVisible(table, { // no space because expectRowVisible uses textContent, not accessible name - Name: 'kernel-devsGroup', + Name: 'kernel-devs', + Type: 'Group', 'Silo role': '', 'Project role': 'viewer', }) diff --git a/test/e2e/silo-access.e2e.ts b/test/e2e/silo-access.e2e.ts index aaf6142c59..ec45ede7b7 100644 --- a/test/e2e/silo-access.e2e.ts +++ b/test/e2e/silo-access.e2e.ts @@ -20,11 +20,13 @@ test('Click through silo access page', async ({ page }) => { await expectVisible(page, ['role=heading[name*="Access & IAM"]']) await expectRowVisible(table, { // no space because expectRowVisible uses textContent, not accessible name - Name: 'real-estate-devsGroup', + Name: 'real-estate-devs', + Type: 'Group', 'Silo role': 'collaborator', }) await expectRowVisible(table, { Name: 'Hannah Arendt', + Type: 'User', 'Silo role': 'admin', }) await expectNotVisible(page, [`role=cell[name="${user4.display_name}"]`])