Skip to content

Commit 93ccb53

Browse files
authored
Refactor InstanceLinkCell and floating IP form (#3142)
Extracted from external subnets work coming in #3039 but not present yet in that PR.
1 parent 643e9dd commit 93ccb53

File tree

12 files changed

+86
-69
lines changed

12 files changed

+86
-69
lines changed

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- Favor well-supported libraries, avoid premature abstractions, and use routes to capture state.
55
- Before starting a feature, skim an existing page or form with similar behavior and mirror the conventions—this codebase is intentionally conventional. Look for similar pages in `app/pages` and forms in `app/forms` to use as templates.
66
- `@oxide/api` is at `app/api` and `@oxide/api-mocks` is at `mock-api/index.ts`.
7+
- The language server often has out of date errors. tsgo is extremely fast, so confirm errors that come from the language server by running `npm run tsc`
78
- Use Node.js 22+, then install deps and start the mock-backed dev server (skip if `npm run dev` is already running in another terminal):
89

910
```sh
@@ -24,7 +25,7 @@
2425

2526
- Run local checks before sending PRs: `npm run lint`, `npm run tsc`, `npm test run`, and `npm run e2ec`.
2627
- You don't usually need to run all the e2e tests, so try to filter by file and tes t name like `npm run e2ec -- instance -g 'boot disk'`. CI will run the full set.
27-
- Keep Playwright specs focused on user-visible behavior—use accessible locators (`getByRole`, `getByLabel`), the helpers in `test/e2e/utils.ts` (`expectToast`, `expectRowVisible`, `selectOption`, `clickRowAction`), and close toasts so follow-on assertions aren’t blocked. Avoid Playwright’s legacy string selector syntax like `page.click(‘role=button[name="..."]’)`; prefer `page.getByRole(‘button’, { name: ‘...’ }).click()` and friends.
28+
- Keep Playwright specs focused on user-visible behavior—use accessible locators (`getByRole`, `getByLabel`), the helpers in `test/e2e/utils.ts` (`expectToast`, `expectRowVisible`, `selectOption`, `clickRowAction`), and close toasts so follow-on assertions aren’t blocked. Avoid Playwright’s legacy string selector syntax like `page.click(‘role=button[name="..."]’)`; prefer `page.getByRole(‘button’, { name: ‘...’ }).click()` and friends. Avoid `getByTestId` in e2e tests—prefer scoping with accessible locators like `page.getByRole(‘dialog’)` when possible.
2829
- Cover role-gated flows by logging in with `getPageAsUser`; exercise negative paths (e.g., forbidden actions) alongside happy paths as shown in `test/e2e/system-update.e2e.ts`.
2930
- Consider `expectVisible` and `expectNotVisible` deprecated: prefer `expect().toBeVisible()` and `toBeHidden()` in new code.
3031
- When UI needs new mock behavior, extend the MSW handlers/db minimally so E2E tests stay deterministic; prefer storing full API responses so subsequent calls see the updated state (`mock-api/msw/db.ts`, `mock-api/msw/handlers.ts`).

app/forms/firewall-rules-common.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
487487
control={protocolForm.control}
488488
description={
489489
<>
490-
Enter a code (0) or range (e.g. 1&ndash;3). Leave blank for all
490+
Enter a code (0) or range (e.g., 1&ndash;3). Leave blank for all
491491
traffic of type {selectedIcmpType}.
492492
</>
493493
}

app/forms/floating-ip-edit.tsx

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { useForm } from 'react-hook-form'
9-
import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router'
9+
import { useNavigate, type LoaderFunctionArgs } from 'react-router'
1010

1111
import {
1212
api,
13-
getListQFn,
1413
q,
14+
qErrorsAllowed,
1515
queryClient,
1616
useApiMutation,
1717
usePrefetchedQuery,
@@ -24,26 +24,40 @@ import { HL } from '~/components/HL'
2424
import { titleCrumb } from '~/hooks/use-crumbs'
2525
import { getFloatingIpSelector, useFloatingIpSelector } from '~/hooks/use-params'
2626
import { addToast } from '~/stores/toast'
27-
import { EmptyCell } from '~/table/cells/EmptyCell'
27+
import { InstanceLink } from '~/table/cells/InstanceLinkCell'
2828
import { IpPoolCell } from '~/table/cells/IpPoolCell'
2929
import { CopyableIp } from '~/ui/lib/CopyableIp'
3030
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
3131
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
32-
import { ALL_ISH } from '~/util/consts'
3332
import { docLinks } from '~/util/links'
3433
import { pb } from '~/util/path-builder'
3534
import type * as PP from '~/util/path-params'
3635

3736
const floatingIpView = ({ project, floatingIp }: PP.FloatingIp) =>
3837
q(api.floatingIpView, { path: { floatingIp }, query: { project } })
39-
const instanceList = (project: string) =>
40-
getListQFn(api.instanceList, { query: { project, limit: ALL_ISH } })
4138

4239
export async function clientLoader({ params }: LoaderFunctionArgs) {
4340
const selector = getFloatingIpSelector(params)
41+
const fip = await queryClient.fetchQuery(floatingIpView(selector))
4442
await Promise.all([
45-
queryClient.fetchQuery(floatingIpView(selector)),
46-
queryClient.fetchQuery(instanceList(selector.project).optionsFn()),
43+
queryClient.prefetchQuery(
44+
// ip pool cell uses errors allowed, so we have to do that here to match
45+
qErrorsAllowed(
46+
api.ipPoolView,
47+
{ path: { pool: fip.ipPoolId } },
48+
{
49+
errorsExpected: {
50+
explanation: 'the referenced IP pool may have been deleted.',
51+
statusCode: 404,
52+
},
53+
}
54+
)
55+
),
56+
fip.instanceId
57+
? queryClient.prefetchQuery(
58+
q(api.instanceView, { path: { instance: fip.instanceId } })
59+
)
60+
: null,
4761
])
4862
return null
4963
}
@@ -58,10 +72,6 @@ export default function EditFloatingIpSideModalForm() {
5872
const onDismiss = () => navigate(pb.floatingIps({ project: floatingIpSelector.project }))
5973

6074
const { data: floatingIp } = usePrefetchedQuery(floatingIpView(floatingIpSelector))
61-
const { data: instances } = usePrefetchedQuery(
62-
instanceList(floatingIpSelector.project).optionsFn()
63-
)
64-
const instanceName = instances.items.find((i) => i.id === floatingIp.instanceId)?.name
6575

6676
const editFloatingIp = useApiMutation(api.floatingIpUpdate, {
6777
onSuccess(_floatingIp) {
@@ -100,19 +110,7 @@ export default function EditFloatingIpSideModalForm() {
100110
<IpPoolCell ipPoolId={floatingIp.ipPoolId} />
101111
</PropertiesTable.Row>
102112
<PropertiesTable.Row label="Instance">
103-
{instanceName ? (
104-
<Link
105-
to={pb.instanceNetworking({
106-
project: floatingIpSelector.project,
107-
instance: instanceName,
108-
})}
109-
className="link-with-underline text-sans-md group"
110-
>
111-
{instanceName}
112-
</Link>
113-
) : (
114-
<EmptyCell />
115-
)}
113+
<InstanceLink instanceId={floatingIp.instanceId} tab="networking" />
116114
</PropertiesTable.Row>
117115
</PropertiesTable>
118116
<NameField name="name" control={form.control} />

app/pages/project/disks/DisksPage.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
3030
import { useQuickActions } from '~/hooks/use-quick-actions'
3131
import { confirmDelete } from '~/stores/confirm-delete'
3232
import { addToast } from '~/stores/toast'
33-
import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell'
33+
import { InstanceLink } from '~/table/cells/InstanceLinkCell'
3434
import { LinkCell } from '~/table/cells/LinkCell'
3535
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
3636
import { Columns } from '~/table/columns/common'
@@ -68,7 +68,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
6868
queryClient.prefetchQuery(diskList({ project }).optionsFn()),
6969

7070
// fetch instances and preload into RQ cache so fetches by ID in
71-
// InstanceLinkCell can be mostly instant yet gracefully fall back to
71+
// InstanceLink can be mostly instant yet gracefully fall back to
7272
// fetching individually if we don't fetch them all here
7373
queryClient.fetchQuery(instanceList({ project }).optionsFn()).then((instances) => {
7474
for (const instance of instances.items) {
@@ -165,7 +165,9 @@ export default function DisksPage() {
165165
(disk) => ('instance' in disk.state ? disk.state.instance : undefined),
166166
{
167167
header: 'Attached to',
168-
cell: (info) => <InstanceLinkCell instanceId={info.getValue()} />,
168+
cell: (info) => (
169+
<InstanceLink instanceId={info.getValue()} tab="storage" cell />
170+
),
169171
}
170172
),
171173
colHelper.accessor('diskType', {

app/pages/project/floating-ips/FloatingIpsPage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { useQuickActions } from '~/hooks/use-quick-actions'
3333
import { confirmAction } from '~/stores/confirm-action'
3434
import { confirmDelete } from '~/stores/confirm-delete'
3535
import { addToast } from '~/stores/toast'
36-
import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell'
36+
import { InstanceLink } from '~/table/cells/InstanceLinkCell'
3737
import { IpPoolCell } from '~/table/cells/IpPoolCell'
3838
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
3939
import { Columns } from '~/table/columns/common'
@@ -102,7 +102,7 @@ const staticCols = [
102102
}),
103103
colHelper.accessor('instanceId', {
104104
header: 'Instance',
105-
cell: (info) => <InstanceLinkCell instanceId={info.getValue()} />,
105+
cell: (info) => <InstanceLink instanceId={info.getValue()} tab="networking" cell />,
106106
}),
107107
]
108108

@@ -227,7 +227,7 @@ export default function FloatingIpsPage() {
227227
...(allFips?.items || []).map((f) => ({
228228
value: f.name,
229229
action: pb.floatingIpEdit({ project, floatingIp: f.name }),
230-
navGroup: 'Go to floating IP',
230+
navGroup: 'Edit floating IP',
231231
})),
232232
],
233233
[project, allFips]

app/table/cells/InstanceLinkCell.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import { useQuery } from '@tanstack/react-query'
10+
import { Link } from 'react-router'
1011

1112
import { api, q } from '@oxide/api'
1213

@@ -16,19 +17,31 @@ import { pb } from '~/util/path-builder'
1617
import { EmptyCell, SkeletonCell } from './EmptyCell'
1718
import { LinkCell } from './LinkCell'
1819

19-
export const InstanceLinkCell = ({ instanceId }: { instanceId?: string | null }) => {
20+
type InstanceLinkProps = {
21+
instanceId?: string | null
22+
tab: 'storage' | 'networking'
23+
/** Use table cell styling with hover highlight. */
24+
cell?: boolean
25+
}
26+
27+
export const InstanceLink = ({ instanceId, tab, cell }: InstanceLinkProps) => {
2028
const { project } = useProjectSelector()
2129
const { data: instance } = useQuery(
2230
q(api.instanceView, { path: { instance: instanceId! } }, { enabled: !!instanceId })
2331
)
2432

25-
// has to be after the hooks because hooks can't be executed conditionally
2633
if (!instanceId) return <EmptyCell />
2734
if (!instance) return <SkeletonCell />
2835

29-
return (
30-
<LinkCell to={pb.instance({ project, instance: instance.name })}>
36+
const params = { project, instance: instance.name }
37+
const to =
38+
tab === 'networking' ? pb.instanceNetworking(params) : pb.instanceStorage(params)
39+
40+
return cell ? (
41+
<LinkCell to={to}>{instance.name}</LinkCell>
42+
) : (
43+
<Link to={to} className="link-with-underline text-sans-md">
3144
{instance.name}
32-
</LinkCell>
45+
</Link>
3346
)
3447
}

app/table/cells/IpPoolCell.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ export const IpPoolCell = ({ ipPoolId }: { ipPoolId: string }) => {
2626
)
2727
)
2828
if (!result) return <SkeletonCell />
29-
// this should essentially never happen, but it's probably better than blowing
30-
// up the whole page if the pool is not found
29+
// Defensive: the error case should never happen in practice. It should not be
30+
// possible for a resource to reference a pool without that pool existing.
3131
if (result.type === 'error') return <EmptyCell />
3232
const pool = result.data
3333
return (

mock-api/msw/handlers.ts

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -368,10 +368,7 @@ export const handlers = makeHandlers({
368368
},
369369
floatingIpDetach({ path, query }) {
370370
const floatingIp = lookup.floatingIp({ ...path, ...query })
371-
db.floatingIps = db.floatingIps.map((ip) =>
372-
ip.id !== floatingIp.id ? ip : { ...ip, instance_id: undefined }
373-
)
374-
371+
floatingIp.instance_id = undefined
375372
return floatingIp
376373
},
377374
imageList({ query }) {
@@ -1917,6 +1914,25 @@ export const handlers = makeHandlers({
19171914

19181915
return { role_assignments }
19191916
},
1917+
systemPolicyUpdate({ body, cookies }) {
1918+
requireFleetAdmin(cookies)
1919+
1920+
const newAssignments = body.role_assignments
1921+
.filter((r) => fleetRoles.some((role) => role === r.role_name))
1922+
.map((r) => ({
1923+
resource_type: 'fleet' as const,
1924+
resource_id: FLEET_ID,
1925+
...R.pick(r, ['identity_id', 'identity_type', 'role_name']),
1926+
}))
1927+
1928+
const unrelatedAssignments = db.roleAssignments.filter(
1929+
(r) => !(r.resource_type === 'fleet' && r.resource_id === FLEET_ID)
1930+
)
1931+
1932+
db.roleAssignments = [...unrelatedAssignments, ...newAssignments]
1933+
1934+
return body
1935+
},
19201936
systemMetric(params) {
19211937
requireFleetViewer(params.cookies)
19221938
return handleMetrics(params)
@@ -2348,25 +2364,6 @@ export const handlers = makeHandlers({
23482364
supportBundleUpdate: NotImplemented,
23492365
supportBundleView: NotImplemented,
23502366
switchView: NotImplemented,
2351-
systemPolicyUpdate({ body, cookies }) {
2352-
requireFleetAdmin(cookies)
2353-
2354-
const newAssignments = body.role_assignments
2355-
.filter((r) => fleetRoles.some((role) => role === r.role_name))
2356-
.map((r) => ({
2357-
resource_type: 'fleet' as const,
2358-
resource_id: FLEET_ID,
2359-
...R.pick(r, ['identity_id', 'identity_type', 'role_name']),
2360-
}))
2361-
2362-
const unrelatedAssignments = db.roleAssignments.filter(
2363-
(r) => !(r.resource_type === 'fleet' && r.resource_id === FLEET_ID)
2364-
)
2365-
2366-
db.roleAssignments = [...unrelatedAssignments, ...newAssignments]
2367-
2368-
return body
2369-
},
23702367
systemQuotasList: NotImplemented,
23712368
systemTimeseriesSchemaList: NotImplemented,
23722369
systemUpdateRecoveryFinish: NotImplemented,

test/e2e/floating-ip-update.e2e.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ test('can update a floating IP', async ({ page }) => {
3131
await clickRowAction(page, 'cola-float', 'Edit')
3232
await expectVisible(page, expectedFormElements)
3333

34+
// Properties table should show resolved instance and pool names
35+
const dialog = page.getByRole('dialog')
36+
await expect(dialog.getByText('ip-pool-1')).toBeVisible()
37+
// cola-float is attached to db1
38+
await expect(dialog.getByRole('link', { name: 'db1' })).toBeVisible()
39+
3440
await page.fill('input[name=name]', updatedName)
3541
await page.getByRole('textbox', { name: 'Description' }).fill(updatedDescription)
3642
await page.getByRole('button', { name: 'Update floating IP' }).click()

test/e2e/instance-networking.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ test('Instance networking tab — NIC table', async ({ page }) => {
6565
await page.getByRole('textbox', { name: 'Name' }).fill('nic-2')
6666
await page.getByLabel('VPC', { exact: true }).click()
6767
await page.getByRole('option', { name: 'mock-vpc' }).click()
68-
await page.getByLabel('Subnet').click()
68+
await page.getByRole('dialog').getByLabel('Subnet').click()
6969
await page.getByRole('option', { name: 'mock-subnet', exact: true }).click()
7070
await page
7171
.getByRole('dialog')

0 commit comments

Comments
 (0)