Skip to content

Commit f251924

Browse files
committed
Update form with IPv4, IPv6, dual stack
1 parent b5bac86 commit f251924

File tree

4 files changed

+155
-23
lines changed

4 files changed

+155
-23
lines changed

app/forms/network-interface-create.tsx

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,23 @@ import { api, q, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxi
1414
import { DescriptionField } from '~/components/form/fields/DescriptionField'
1515
import { ListboxField } from '~/components/form/fields/ListboxField'
1616
import { NameField } from '~/components/form/fields/NameField'
17+
import { RadioField } from '~/components/form/fields/RadioField'
1718
import { SubnetListbox } from '~/components/form/fields/SubnetListbox'
1819
import { TextField } from '~/components/form/fields/TextField'
1920
import { SideModalForm } from '~/components/form/SideModalForm'
2021
import { useProjectSelector } from '~/hooks/use-params'
2122
import { FormDivider } from '~/ui/lib/Divider'
2223

24+
type IpStackType = 'v4' | 'v6' | 'dual_stack'
25+
2326
const defaultValues = {
2427
name: '',
2528
description: '',
2629
subnetName: '',
2730
vpcName: '',
28-
ip: '',
31+
ipStackType: 'dual_stack' as IpStackType,
32+
ipv4: '',
33+
ipv6: '',
2934
}
3035

3136
type CreateNetworkInterfaceFormProps = {
@@ -51,6 +56,7 @@ export function CreateNetworkInterfaceForm({
5156
const vpcs = useMemo(() => vpcsData?.items || [], [vpcsData])
5257

5358
const form = useForm({ defaultValues })
59+
const ipStackType = form.watch('ipStackType')
5460

5561
return (
5662
<SideModalForm
@@ -59,17 +65,47 @@ export function CreateNetworkInterfaceForm({
5965
resourceName="network interface"
6066
title="Add network interface"
6167
onDismiss={onDismiss}
62-
onSubmit={({ ip, ...rest }) => {
63-
// Transform to IPv4 ipConfig structure
64-
const ipConfig = ip.trim()
65-
? {
66-
type: 'v4' as const,
67-
value: {
68-
ip: { type: 'explicit' as const, value: ip.trim() },
68+
onSubmit={({ ipStackType, ipv4, ipv6, ...rest }) => {
69+
// Build ipConfig based on the selected IP stack type
70+
let ipConfig: InstanceNetworkInterfaceCreate['ipConfig']
71+
72+
if (ipStackType === 'v4') {
73+
ipConfig = {
74+
type: 'v4',
75+
value: {
76+
ip: ipv4.trim() ? { type: 'explicit', value: ipv4.trim() } : { type: 'auto' },
77+
transitIps: [],
78+
},
79+
}
80+
} else if (ipStackType === 'v6') {
81+
ipConfig = {
82+
type: 'v6',
83+
value: {
84+
ip: ipv6.trim() ? { type: 'explicit', value: ipv6.trim() } : { type: 'auto' },
85+
transitIps: [],
86+
},
87+
}
88+
} else {
89+
// dual_stack
90+
ipConfig = {
91+
type: 'dual_stack',
92+
value: {
93+
v4: {
94+
ip: ipv4.trim()
95+
? { type: 'explicit', value: ipv4.trim() }
96+
: { type: 'auto' },
97+
transitIps: [],
98+
},
99+
v6: {
100+
ip: ipv6.trim()
101+
? { type: 'explicit', value: ipv6.trim() }
102+
: { type: 'auto' },
69103
transitIps: [],
70104
},
71-
}
72-
: undefined
105+
},
106+
}
107+
}
108+
73109
onSubmit({ ...rest, ipConfig })
74110
}}
75111
loading={loading}
@@ -94,12 +130,45 @@ export function CreateNetworkInterfaceForm({
94130
required
95131
control={form.control}
96132
/>
97-
<TextField
98-
name="ip"
99-
label="IP Address (IPv4)"
133+
134+
<RadioField
135+
name="ipStackType"
136+
label="IP configuration"
100137
control={form.control}
101-
placeholder="Leave blank for auto-assignment"
138+
column
139+
items={[
140+
{
141+
value: 'dual_stack',
142+
label: 'IPv4 & IPv6',
143+
},
144+
{
145+
value: 'v4',
146+
label: 'IPv4',
147+
},
148+
{
149+
value: 'v6',
150+
label: 'IPv6',
151+
},
152+
]}
102153
/>
154+
155+
{(ipStackType === 'v4' || ipStackType === 'dual_stack') && (
156+
<TextField
157+
name="ipv4"
158+
label="IPv4 Address"
159+
control={form.control}
160+
placeholder="Leave blank for auto-assignment"
161+
/>
162+
)}
163+
164+
{(ipStackType === 'v6' || ipStackType === 'dual_stack') && (
165+
<TextField
166+
name="ipv6"
167+
label="IPv6 Address"
168+
control={form.control}
169+
placeholder="Leave blank for auto-assignment"
170+
/>
171+
)}
103172
</SideModalForm>
104173
)
105174
}

mock-api/msw/handlers.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -891,7 +891,14 @@ export const handlers = makeHandlers({
891891
description,
892892
ip_stack: ip_config
893893
? resolveIpStack(ip_config, '123.45.68.8', 'fd12:3456::')
894-
: { type: 'v4', value: { ip: '123.45.68.8', transit_ips: [] } },
894+
: // Default is dual-stack with auto-assigned IPs
895+
{
896+
type: 'dual_stack',
897+
value: {
898+
v4: { ip: '123.45.68.8', transit_ips: [] },
899+
v6: { ip: 'fd12:3456::', transit_ips: [] },
900+
},
901+
},
895902
vpc_id: vpc.id,
896903
subnet_id: subnet.id,
897904
mac: '',

test/e2e/instance-networking.e2e.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ test('Instance networking tab — NIC table', async ({ page }) => {
5151
await expectVisible(page, [
5252
'role=heading[name="Add network interface"]',
5353
'role=textbox[name="Description"]',
54-
'role=textbox[name="IP Address (IPv4)"]',
54+
'role=textbox[name="IPv4 Address"]',
55+
'role=textbox[name="IPv6 Address"]',
5556
])
5657

5758
await page.getByRole('textbox', { name: 'Name' }).fill('nic-2')

test/e2e/network-interface-create.e2e.ts

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { test } from '@playwright/test'
1010
import { expect, expectRowVisible, stopInstance } from './utils'
1111

1212
test('can create a NIC with a specified IP address', async ({ page }) => {
13-
// go to an instances Network Interfaces page
13+
// go to an instance's Network Interfaces page
1414
await page.goto('/projects/mock-project/instances/db1/networking')
1515

1616
await stopInstance(page)
@@ -24,7 +24,10 @@ test('can create a NIC with a specified IP address', async ({ page }) => {
2424
await page.getByRole('option', { name: 'mock-vpc' }).click()
2525
await page.getByRole('button', { name: 'Subnet' }).click()
2626
await page.getByRole('option', { name: 'mock-subnet' }).click()
27-
await page.getByLabel('IP Address').fill('1.2.3.4')
27+
28+
// Select IPv4 only
29+
await page.getByRole('radio', { name: 'IPv4', exact: true }).click()
30+
await page.getByLabel('IPv4 Address').fill('1.2.3.4')
2831

2932
const sidebar = page.getByRole('dialog', { name: 'Add network interface' })
3033

@@ -37,7 +40,7 @@ test('can create a NIC with a specified IP address', async ({ page }) => {
3740
})
3841

3942
test('can create a NIC with a blank IP address', async ({ page }) => {
40-
// go to an instances Network Interfaces page
43+
// go to an instance's Network Interfaces page
4144
await page.goto('/projects/mock-project/instances/db1/networking')
4245

4346
await stopInstance(page)
@@ -52,8 +55,9 @@ test('can create a NIC with a blank IP address', async ({ page }) => {
5255
await page.getByRole('button', { name: 'Subnet' }).click()
5356
await page.getByRole('option', { name: 'mock-subnet' }).click()
5457

55-
// make sure the IP address field has a non-conforming bit of text in it
56-
await page.getByLabel('IP Address').fill('x')
58+
// Dual-stack is selected by default, so both fields should be visible
59+
// make sure the IPv4 address field has a non-conforming bit of text in it
60+
await page.getByLabel('IPv4 Address').fill('x')
5761

5862
// try to submit it
5963
const sidebar = page.getByRole('dialog', { name: 'Add network interface' })
@@ -62,8 +66,9 @@ test('can create a NIC with a blank IP address', async ({ page }) => {
6266
// it should error out
6367
await expect(sidebar.getByText('Zod error for body')).toBeVisible()
6468

65-
// make sure the IP address field has spaces in it
66-
await page.getByLabel('IP Address').fill(' ')
69+
// make sure both IP address fields have spaces in them
70+
await page.getByLabel('IPv4 Address').fill(' ')
71+
await page.getByLabel('IPv6 Address').fill(' ')
6772

6873
// test that the form can be submitted and a new network interface is created
6974
await sidebar.getByRole('button', { name: 'Add network interface' }).click()
@@ -73,3 +78,53 @@ test('can create a NIC with a blank IP address', async ({ page }) => {
7378
const table = page.getByRole('table', { name: 'Network interfaces' })
7479
await expectRowVisible(table, { name: 'nic-2', 'Private IP': '123.45.68.8fd12:3456::' })
7580
})
81+
82+
test('can create a NIC with IPv6 only', async ({ page }) => {
83+
await page.goto('/projects/mock-project/instances/db1/networking')
84+
85+
await stopInstance(page)
86+
87+
await page.getByRole('button', { name: 'Add network interface' }).click()
88+
89+
await page.getByLabel('Name').fill('nic-3')
90+
await page.getByLabel('VPC', { exact: true }).click()
91+
await page.getByRole('option', { name: 'mock-vpc' }).click()
92+
await page.getByRole('button', { name: 'Subnet' }).click()
93+
await page.getByRole('option', { name: 'mock-subnet' }).click()
94+
95+
// Select IPv6 only
96+
await page.getByRole('radio', { name: 'IPv6', exact: true }).click()
97+
await page.getByLabel('IPv6 Address').fill('::1')
98+
99+
const sidebar = page.getByRole('dialog', { name: 'Add network interface' })
100+
await sidebar.getByRole('button', { name: 'Add network interface' }).click()
101+
await expect(sidebar).toBeHidden()
102+
103+
const table = page.getByRole('table', { name: 'Network interfaces' })
104+
await expectRowVisible(table, { name: 'nic-3', 'Private IP': '::1' })
105+
})
106+
107+
test('can create a NIC with dual-stack and explicit IPs', async ({ page }) => {
108+
await page.goto('/projects/mock-project/instances/db1/networking')
109+
110+
await stopInstance(page)
111+
112+
await page.getByRole('button', { name: 'Add network interface' }).click()
113+
114+
await page.getByLabel('Name').fill('nic-4')
115+
await page.getByLabel('VPC', { exact: true }).click()
116+
await page.getByRole('option', { name: 'mock-vpc' }).click()
117+
await page.getByRole('button', { name: 'Subnet' }).click()
118+
await page.getByRole('option', { name: 'mock-subnet' }).click()
119+
120+
// Dual-stack is selected by default
121+
await page.getByLabel('IPv4 Address').fill('10.0.0.5')
122+
await page.getByLabel('IPv6 Address').fill('fd00::5')
123+
124+
const sidebar = page.getByRole('dialog', { name: 'Add network interface' })
125+
await sidebar.getByRole('button', { name: 'Add network interface' }).click()
126+
await expect(sidebar).toBeHidden()
127+
128+
const table = page.getByRole('table', { name: 'Network interfaces' })
129+
await expectRowVisible(table, { name: 'nic-4', 'Private IP': '10.0.0.5fd00::5' })
130+
})

0 commit comments

Comments
 (0)