From 2b3cea909d63b556def7461d4c0c82e588027d19 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Jul 2026 13:26:45 -0700 Subject: [PATCH 1/6] Add metadata to sidebar edit forms --- app/forms/firewall-rules-edit.tsx | 8 ++++ app/forms/floating-ip-edit.tsx | 2 + app/forms/ip-pool-edit.tsx | 8 ++++ app/forms/network-interface-edit.tsx | 47 +++++++++++++++++++ app/forms/project-edit.tsx | 8 ++++ app/forms/subnet-edit.tsx | 9 ++++ app/forms/subnet-pool-edit.tsx | 8 ++++ app/forms/vpc-edit.tsx | 8 ++++ app/forms/vpc-router-edit.tsx | 12 +++++ app/forms/vpc-router-route-edit.tsx | 12 +++++ .../project/floating-ips/FloatingIpsPage.tsx | 17 +++++-- test/e2e/floating-ip-update.e2e.ts | 7 +++ 12 files changed, 143 insertions(+), 3 deletions(-) diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 0e7c0c5902..aadc276c3e 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -27,6 +27,8 @@ import { useVpcSelector, } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { FormDivider } from '~/ui/lib/Divider' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { ALL_ISH } from '~/util/consts' import { invariant } from '~/util/invariant' import { pb } from '~/util/path-builder' @@ -134,6 +136,12 @@ export default function EditFirewallRuleForm() { loading={updateRules.isPending} submitError={updateRules.error} > + + + + + + + diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index 15c6b7505e..d951918f55 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -18,7 +18,9 @@ import { HL } from '~/components/HL' import { makeCrumb } from '~/hooks/use-crumbs' import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { FormDivider } from '~/ui/lib/Divider' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' @@ -73,6 +75,12 @@ export default function EditIpPoolSideModalForm() { loading={editPool.isPending} submitError={editPool.error} > + + + + + + diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index cbc7ef6d01..76e9a7b82d 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -5,12 +5,14 @@ * * Copyright Oxide Computer Company */ +import { useQuery } from '@tanstack/react-query' import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { match } from 'ts-pattern' import { api, + q, queryClient, useApiMutation, type InstanceNetworkInterface, @@ -24,11 +26,14 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { SkeletonCell } from '~/table/cells/EmptyCell' +import { CopyableIp } from '~/ui/lib/CopyableIp' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' import { Message } from '~/ui/lib/Message' import { ClearAndAddButtons, MiniTable } from '~/ui/lib/MiniTable' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { HintLink, TextInputHint } from '~/ui/lib/TextInput' import { KEYS } from '~/ui/util/keys' import { parseIpNet, validateIpNet } from '~/util/ip' @@ -36,6 +41,27 @@ import { docLinks, links } from '~/util/links' const transitIpTableColumns = [{ header: 'Transit IPs', cell: (ip: string) => ip }] +// The NIC's subnet stores only an ID; look up the name for display. The +// networking tab that opens this form prefetches subnet views by ID, so this +// usually hits a warm cache. +const SubnetNameFromId = ({ subnetId }: { subnetId: string }) => { + const { data: subnet } = useQuery( + q(api.vpcSubnetView, { path: { subnet: subnetId } }, { throwOnError: false }) + ) + if (!subnet) return + return subnet.name +} + +// IP addresses aren't editable — a new interface must be created to change them +// — so surface them as read-only metadata. A NIC has a v4 address, a v6 +// address, or both, depending on its stack type. +const privateIps = (ipStack: InstanceNetworkInterface['ipStack']) => + match(ipStack) + .with({ type: 'v4' }, ({ value }) => ({ v4: value.ip, v6: undefined })) + .with({ type: 'v6' }, ({ value }) => ({ v4: undefined, v6: value.ip })) + .with({ type: 'dual_stack' }, ({ value }) => ({ v4: value.v4.ip, v6: value.v6.ip })) + .exhaustive() + type EditNetworkInterfaceFormProps = { editing: InstanceNetworkInterface onDismiss: () => void @@ -46,6 +72,7 @@ export function EditNetworkInterfaceForm({ editing, }: EditNetworkInterfaceFormProps) { const instanceSelector = useInstanceSelector() + const ips = privateIps(editing.ipStack) const editNetworkInterface = useApiMutation(api.instanceNetworkInterfaceUpdate, { onSuccess(nic) { @@ -113,6 +140,26 @@ export function EditNetworkInterfaceForm({ loading={editNetworkInterface.isPending} submitError={editNetworkInterface.error} > + + + + + {ips.v4 && ( + + + + )} + {ips.v6 && ( + + + + )} + {editing.mac} + + + + + diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index 43144736a2..f9ec8cbdd0 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -17,7 +17,9 @@ import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { FormDivider } from '~/ui/lib/Divider' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' @@ -68,6 +70,12 @@ export default function EditProjectSideModalForm() { loading={editProject.isPending} submitError={editProject.error} > + + + + + + diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 8b5bcb61d3..79ba8658a3 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -33,6 +33,7 @@ import { getVpcSubnetSelector, useVpcSubnetSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' @@ -95,6 +96,14 @@ export default function EditSubnetForm() { loading={updateSubnet.isPending} submitError={updateSubnet.error} > + + + + + {subnet.ipv4Block} + {subnet.ipv6Block} + + diff --git a/app/forms/subnet-pool-edit.tsx b/app/forms/subnet-pool-edit.tsx index 21049c539c..9d0b2348a9 100644 --- a/app/forms/subnet-pool-edit.tsx +++ b/app/forms/subnet-pool-edit.tsx @@ -18,7 +18,9 @@ import { HL } from '~/components/HL' import { makeCrumb } from '~/hooks/use-crumbs' import { getSubnetPoolSelector, useSubnetPoolSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { FormDivider } from '~/ui/lib/Divider' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' @@ -72,6 +74,12 @@ export default function EditSubnetPoolSideModalForm() { loading={editPool.isPending} submitError={editPool.error} > + + + + + + diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index a2e5c60e11..7148f9235a 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -17,7 +17,9 @@ import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { FormDivider } from '~/ui/lib/Divider' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' @@ -75,6 +77,12 @@ export default function EditVpcSideModalForm() { loading={editVpc.isPending} submitError={editVpc.error} > + + + + + + diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 4feccb1946..2df30c9bce 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -16,6 +16,7 @@ import { usePrefetchedQuery, type VpcRouterUpdate, } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -24,7 +25,9 @@ import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { FormDivider } from '~/ui/lib/Divider' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' @@ -78,6 +81,15 @@ export default function EditRouterSideModalForm() { loading={editRouter.isPending} submitError={editRouter.error} > + + + + + + {routerData.kind} + + + diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index f0d2dbd245..be5f92d938 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -10,6 +10,7 @@ import { useNavigate, type LoaderFunctionArgs } from 'react-router' import * as R from 'remeda' import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' @@ -22,6 +23,8 @@ import { import { titleCrumb } from '~/hooks/use-crumbs' import { getVpcRouterRouteSelector, useVpcRouterRouteSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { FormDivider } from '~/ui/lib/Divider' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' @@ -99,6 +102,15 @@ export default function EditRouterRouteSideModalForm() { submitError={updateRouterRoute.error} submitDisabled={disabled ? routeFormMessage.vpcSubnetNotModifiable : undefined} > + + + + + + {route.kind.replace('_', ' ')} + + + diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index 8339f6777a..fa1ae89727 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' @@ -35,6 +35,7 @@ import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { InstanceLink } from '~/table/cells/InstanceLinkCell' import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell' +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' @@ -90,7 +91,6 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const colHelper = createColumnHelper() const staticCols = [ - colHelper.accessor('name', {}), colHelper.accessor('description', Columns.description), colHelper.accessor('ip', { header: 'IP address', @@ -204,7 +204,18 @@ export default function FloatingIpsPage() { [deleteFloatingIp, floatingIpDetach, navigate, project, instances] ) - const columns = useColsWithActions(staticCols, makeActions) + // name column links to the edit side modal; defined here (not in staticCols) + // because the link needs the project from the route + const cols = useMemo( + () => [ + colHelper.accessor('name', { + cell: makeLinkCell((floatingIp) => pb.floatingIpEdit({ project, floatingIp })), + }), + ...staticCols, + ], + [project] + ) + const columns = useColsWithActions(cols, makeActions) const { table } = useQueryTable({ query: fipList(project), columns, diff --git a/test/e2e/floating-ip-update.e2e.ts b/test/e2e/floating-ip-update.e2e.ts index 7aeb8498d6..d60eeb4867 100644 --- a/test/e2e/floating-ip-update.e2e.ts +++ b/test/e2e/floating-ip-update.e2e.ts @@ -50,6 +50,13 @@ test('can update a floating IP', async ({ page }) => { await expectToast(page, `Floating IP ${updatedName} updated`) }) +test('clicking the name opens the edit side modal', async ({ page }) => { + await page.goto(floatingIpsPage) + await page.getByRole('link', { name: originalName }).click() + await expect(page).toHaveURL(`${floatingIpsPage}/${originalName}/edit`) + await expectVisible(page, expectedFormElements) +}) + // Make sure that it still works even if the name doesn't change test('can update *just* the floating IP description', async ({ page }) => { // Go to the edit page for the original floating IP From dad6e78d9a673df8b13d9e778adabb6bce079d94 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Jul 2026 15:01:29 -0700 Subject: [PATCH 2/6] convert to ResourceRows --- app/forms/external-subnet-edit.tsx | 4 +--- app/forms/firewall-rules-edit.tsx | 4 +--- app/forms/floating-ip-edit.tsx | 4 +--- app/forms/idp/edit.tsx | 4 +--- app/forms/ip-pool-edit.tsx | 4 +--- app/forms/network-interface-edit.tsx | 4 +--- app/forms/project-edit.tsx | 4 +--- app/forms/ssh-key-edit.tsx | 4 +--- app/forms/subnet-edit.tsx | 4 +--- app/forms/subnet-pool-edit.tsx | 4 +--- app/forms/vpc-edit.tsx | 4 +--- app/forms/vpc-router-edit.tsx | 4 +--- app/forms/vpc-router-route-edit.tsx | 4 +--- app/ui/lib/PropertiesTable.tsx | 18 ++++++++++++++++++ 14 files changed, 31 insertions(+), 39 deletions(-) diff --git a/app/forms/external-subnet-edit.tsx b/app/forms/external-subnet-edit.tsx index 0efa2622c8..5e43b0556e 100644 --- a/app/forms/external-subnet-edit.tsx +++ b/app/forms/external-subnet-edit.tsx @@ -101,9 +101,7 @@ export default function EditExternalSubnetSideModalForm() { submitError={editExternalSubnet.error} > - - - + {subnet.subnet} diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index aadc276c3e..1998b279d0 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -137,9 +137,7 @@ export default function EditFirewallRuleForm() { submitError={updateRules.error} > - - - + - - - + diff --git a/app/forms/idp/edit.tsx b/app/forms/idp/edit.tsx index 2f02e27235..e5cea605b0 100644 --- a/app/forms/idp/edit.tsx +++ b/app/forms/idp/edit.tsx @@ -56,9 +56,7 @@ export default function EditIdpSideModalForm() { } > - - - + diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index d951918f55..8f1a43750d 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -76,9 +76,7 @@ export default function EditIpPoolSideModalForm() { submitError={editPool.error} > - - - + diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index 76e9a7b82d..26a130c754 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -141,9 +141,7 @@ export function EditNetworkInterfaceForm({ submitError={editNetworkInterface.error} > - - - + {ips.v4 && ( diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index f9ec8cbdd0..00dea19027 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -71,9 +71,7 @@ export default function EditProjectSideModalForm() { submitError={editProject.error} > - - - + diff --git a/app/forms/ssh-key-edit.tsx b/app/forms/ssh-key-edit.tsx index 77408c1eec..210c986cb1 100644 --- a/app/forms/ssh-key-edit.tsx +++ b/app/forms/ssh-key-edit.tsx @@ -56,9 +56,7 @@ export default function EditSSHKeySideModalForm() { } > - - - + diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 79ba8658a3..8bc8415d7e 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -97,9 +97,7 @@ export default function EditSubnetForm() { submitError={updateSubnet.error} > - - - + {subnet.ipv4Block} {subnet.ipv6Block} diff --git a/app/forms/subnet-pool-edit.tsx b/app/forms/subnet-pool-edit.tsx index 9d0b2348a9..1d5c860ae6 100644 --- a/app/forms/subnet-pool-edit.tsx +++ b/app/forms/subnet-pool-edit.tsx @@ -75,9 +75,7 @@ export default function EditSubnetPoolSideModalForm() { submitError={editPool.error} > - - - + diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index 7148f9235a..a286c9b731 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -78,9 +78,7 @@ export default function EditVpcSideModalForm() { submitError={editVpc.error} > - - - + diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 2df30c9bce..42720d8ade 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -82,9 +82,7 @@ export default function EditRouterSideModalForm() { submitError={editRouter.error} > - - - + {routerData.kind} diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index be5f92d938..546cddc658 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -103,9 +103,7 @@ export default function EditRouterRouteSideModalForm() { submitDisabled={disabled ? routeFormMessage.vpcSubnetNotModifiable : undefined} > - - - + {route.kind.replace('_', ' ')} diff --git a/app/ui/lib/PropertiesTable.tsx b/app/ui/lib/PropertiesTable.tsx index 9f7f963036..e7f7a5b696 100644 --- a/app/ui/lib/PropertiesTable.tsx +++ b/app/ui/lib/PropertiesTable.tsx @@ -37,6 +37,7 @@ export function PropertiesTable({ PropertiesTable.DateRow, PropertiesTable.SizeRow, PropertiesTable.CopyableRow, + PropertiesTable.ResourceRows, ]), 'PropertiesTable only accepts specific Row components as children' ) @@ -122,3 +123,20 @@ PropertiesTable.CopyableRow = ({ label, text }: { label: string; text: string }) ) + +/** + * The ID + created/updated timestamps every API resource carries. Renders the + * three rows nearly every edit side modal opens with; pass additional + * resource-specific `PropertiesTable.*` rows as siblings after it. + */ +PropertiesTable.ResourceRows = ({ + resource, +}: { + resource: { id: string; timeCreated: Date; timeModified: Date } +}) => ( + <> + + + + +) From 6cfb3c3626ff710d3b5b62578d4bf9ee459610ee Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Jul 2026 15:19:17 -0700 Subject: [PATCH 3/6] pick up a few last sidebars without dividers --- app/forms/idp/edit.tsx | 1 + app/forms/image-from-snapshot.tsx | 2 ++ app/pages/project/vpcs/internet-gateway-edit.tsx | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/forms/idp/edit.tsx b/app/forms/idp/edit.tsx index e5cea605b0..ebec9d6637 100644 --- a/app/forms/idp/edit.tsx +++ b/app/forms/idp/edit.tsx @@ -58,6 +58,7 @@ export default function EditIdpSideModalForm() { + diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx index b6f7767e88..012548590a 100644 --- a/app/forms/image-from-snapshot.tsx +++ b/app/forms/image-from-snapshot.tsx @@ -25,6 +25,7 @@ import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { getProjectSnapshotSelector, useProjectSnapshotSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { FormDivider } from '~/ui/lib/Divider' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { docLinks } from '~/util/links' @@ -97,6 +98,7 @@ export default function CreateImageFromSnapshotSideModalForm() { {formatBytes(data.size).label} + diff --git a/app/pages/project/vpcs/internet-gateway-edit.tsx b/app/pages/project/vpcs/internet-gateway-edit.tsx index 5d5ac415dd..4c54c8bb85 100644 --- a/app/pages/project/vpcs/internet-gateway-edit.tsx +++ b/app/pages/project/vpcs/internet-gateway-edit.tsx @@ -128,8 +128,8 @@ export default function EditInternetGatewayForm() { /> - +
Internet gateway IP address From ac5336d9cded075258d7c30cc3181bfc2b7f2113 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 21 Jul 2026 17:09:28 -0700 Subject: [PATCH 4/6] add SubnetNameCell --- app/forms/network-interface-edit.tsx | 15 +---------- app/pages/project/instances/NetworkingTab.tsx | 15 ++--------- app/table/cells/SubnetNameCell.tsx | 25 +++++++++++++++++++ 3 files changed, 28 insertions(+), 27 deletions(-) create mode 100644 app/table/cells/SubnetNameCell.tsx diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index 26a130c754..f0caaa5898 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -5,14 +5,12 @@ * * Copyright Oxide Computer Company */ -import { useQuery } from '@tanstack/react-query' import { useEffect } from 'react' import { useForm } from 'react-hook-form' import { match } from 'ts-pattern' import { api, - q, queryClient, useApiMutation, type InstanceNetworkInterface, @@ -26,7 +24,7 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { SkeletonCell } from '~/table/cells/EmptyCell' +import { SubnetNameFromId } from '~/table/cells/SubnetNameCell' import { CopyableIp } from '~/ui/lib/CopyableIp' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' @@ -41,17 +39,6 @@ import { docLinks, links } from '~/util/links' const transitIpTableColumns = [{ header: 'Transit IPs', cell: (ip: string) => ip }] -// The NIC's subnet stores only an ID; look up the name for display. The -// networking tab that opens this form prefetches subnet views by ID, so this -// usually hits a warm cache. -const SubnetNameFromId = ({ subnetId }: { subnetId: string }) => { - const { data: subnet } = useQuery( - q(api.vpcSubnetView, { path: { subnet: subnetId } }, { throwOnError: false }) - ) - if (!subnet) return - return subnet.name -} - // IP addresses aren't editable — a new interface must be created to change them // — so surface them as read-only metadata. A NIC has a v4 address, a v6 // address, or both, depending on its stack type. diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 9f651adc41..bc622861eb 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -52,6 +52,7 @@ import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { IpPoolCell } from '~/table/cells/IpPoolCell' import { LinkCell } from '~/table/cells/LinkCell' +import { SubnetNameFromId } from '~/table/cells/SubnetNameCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' @@ -88,18 +89,6 @@ const VpcNameFromId = ({ value }: { value: string }) => { return {vpc.name} } -const SubnetNameFromId = ({ value }: { value: string }) => { - const { data: subnet, isError } = useQuery( - q(api.vpcSubnetView, { path: { subnet: value } }, { throwOnError: false }) - ) - - // same deal as VPC: probably not possible but let's be safe - if (isError) return Deleted - if (!subnet) return // loading - - return {subnet.name} -} - const NonFloatingEmptyCell = ({ kind }: { kind: 'snat' | 'ephemeral' }) => ( , + cell: (info) => , }), colHelper.display({ id: 'transitIps', diff --git a/app/table/cells/SubnetNameCell.tsx b/app/table/cells/SubnetNameCell.tsx new file mode 100644 index 0000000000..85710e3286 --- /dev/null +++ b/app/table/cells/SubnetNameCell.tsx @@ -0,0 +1,25 @@ +/* + * 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 { useQuery } from '@tanstack/react-query' + +import { api, q } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +import { SkeletonCell } from './EmptyCell' + +/** Resolve a subnet ID to its name. Callers must ensure the query is enabled. */ +export const SubnetNameFromId = ({ subnetId }: { subnetId: string }) => { + const { data: subnet, isError } = useQuery( + q(api.vpcSubnetView, { path: { subnet: subnetId } }, { throwOnError: false }) + ) + // probably not possible but let's be safe + if (isError) return Deleted + if (!subnet) return // loading + return {subnet.name} +} From dae0c0c6de9361968d50933f7c79317cde218f52 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 22 Jul 2026 12:51:24 -0700 Subject: [PATCH 5/6] Use NameLinkCell in a few more spots --- .../external-subnets/ExternalSubnetsPage.tsx | 19 ++++++++++++++++--- app/pages/project/vpcs/RouterPage.tsx | 12 +++++++++++- test/e2e/external-subnets.e2e.ts | 7 +++++++ test/e2e/vpcs.e2e.ts | 8 ++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/app/pages/project/external-subnets/ExternalSubnetsPage.tsx b/app/pages/project/external-subnets/ExternalSubnetsPage.tsx index 8c3bec28fd..c52b9b69d5 100644 --- a/app/pages/project/external-subnets/ExternalSubnetsPage.tsx +++ b/app/pages/project/external-subnets/ExternalSubnetsPage.tsx @@ -7,7 +7,7 @@ */ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' @@ -34,6 +34,7 @@ import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { InstanceLink } from '~/table/cells/InstanceLinkCell' +import { makeLinkCell } from '~/table/cells/LinkCell' import { SubnetPoolCell } from '~/table/cells/SubnetPoolCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -77,7 +78,6 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const colHelper = createColumnHelper() const staticCols = [ - colHelper.accessor('name', {}), colHelper.accessor('description', Columns.description), colHelper.accessor('subnet', { header: 'Subnet', @@ -189,7 +189,20 @@ export default function ExternalSubnetsPage() { [deleteExternalSubnet, externalSubnetDetach, navigate, project, instances] ) - const columns = useColsWithActions(staticCols, makeActions) + // name column links to the edit side modal; defined here (not in staticCols) + // because the link needs the project from the route + const cols = useMemo( + () => [ + colHelper.accessor('name', { + cell: makeLinkCell((externalSubnet) => + pb.externalSubnetEdit({ project, externalSubnet }) + ), + }), + ...staticCols, + ], + [project] + ) + const columns = useColsWithActions(cols, makeActions) const { table } = useQueryTable({ query: subnetList(project), columns, diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index 5dff2e1091..2efc338488 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -34,6 +34,7 @@ import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' +import { LinkCell } from '~/table/cells/LinkCell' import { TypeValueCell } from '~/table/cells/TypeValueCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' @@ -114,7 +115,16 @@ export default function RouterPage() { const routerRoutesColHelper = createColumnHelper() const routerRoutesStaticCols = [ - routerRoutesColHelper.accessor('name', { header: 'Name' }), + routerRoutesColHelper.accessor('name', { + header: 'Name', + cell: (info) => ( + + {info.getValue()} + + ), + }), routerRoutesColHelper.accessor('kind', { header: 'Kind', cell: (info) => {info.getValue().replace('_', ' ')}, diff --git a/test/e2e/external-subnets.e2e.ts b/test/e2e/external-subnets.e2e.ts index 3aa1133d65..dc343c0ff9 100644 --- a/test/e2e/external-subnets.e2e.ts +++ b/test/e2e/external-subnets.e2e.ts @@ -128,6 +128,13 @@ test('can create an external subnet with explicit CIDR', async ({ page }) => { }) }) +test('clicking the name opens the edit side modal', async ({ page }) => { + await page.goto(externalSubnetsPage) + await page.getByRole('link', { name: 'web-subnet' }).click() + await expect(page).toHaveURL(`${externalSubnetsPage}/web-subnet/edit`) + await expect(page.getByRole('heading', { name: /Edit external subnet/ })).toBeVisible() +}) + test('can update an external subnet', async ({ page }) => { await page.goto(externalSubnetsPage) await clickRowAction(page, 'web-subnet', 'Edit') diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 6df00b31ba..1694c6a67a 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -280,6 +280,14 @@ test('create router route', async ({ page }) => { }) }) +test('clicking a route name opens the edit modal', async ({ page }) => { + const router = '/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router' + await page.goto(router) + await page.getByRole('link', { name: 'drop-local' }).click() + await expect(page).toHaveURL(`${router}/routes/drop-local/edit`) + await expect(page.getByRole('dialog', { name: 'Edit route' })).toBeVisible() +}) + test('edit and delete router route', async ({ page }) => { await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router') From b73f1e987c09fdcef9b5f43e1793d5550c89da7f Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 22 Jul 2026 12:54:50 -0700 Subject: [PATCH 6/6] memoize router route column generation --- app/pages/project/vpcs/RouterPage.tsx | 57 ++++++++++++++------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index 2efc338488..43210dbff5 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper } from '@tanstack/react-table' -import { useCallback } from 'react' +import { useCallback, useMemo } from 'react' import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' @@ -74,6 +74,8 @@ const routeTypes = { vpc: 'VPC', } +const routerRoutesColHelper = createColumnHelper() + // All will have a type and a value except `Drop`, which only has a type const RouterRouteTypeValueBadge = ({ type, @@ -112,32 +114,33 @@ export default function RouterPage() { ) const navigate = useNavigate() - const routerRoutesColHelper = createColumnHelper() - - const routerRoutesStaticCols = [ - routerRoutesColHelper.accessor('name', { - header: 'Name', - cell: (info) => ( - - {info.getValue()} - - ), - }), - routerRoutesColHelper.accessor('kind', { - header: 'Kind', - cell: (info) => {info.getValue().replace('_', ' ')}, - }), - routerRoutesColHelper.accessor('destination', { - header: 'Destination', - cell: (info) => , - }), - routerRoutesColHelper.accessor('target', { - header: 'Target', - cell: (info) => , - }), - ] + const routerRoutesStaticCols = useMemo( + () => [ + routerRoutesColHelper.accessor('name', { + header: 'Name', + cell: (info) => ( + + {info.getValue()} + + ), + }), + routerRoutesColHelper.accessor('kind', { + header: 'Kind', + cell: (info) => {info.getValue().replace('_', ' ')}, + }), + routerRoutesColHelper.accessor('destination', { + header: 'Destination', + cell: (info) => , + }), + routerRoutesColHelper.accessor('target', { + header: 'Target', + cell: (info) => , + }), + ], + [project, vpc, router] + ) const makeRangeActions = useCallback( (routerRoute: RouterRoute): MenuAction[] => [