Skip to content

Commit e0d52ef

Browse files
authored
Add Transit IPs column to instance NIC table (#2437)
* Add Transit IPs column to instance NIC table * Add Transit IPs mini table to edit form * Add Transit IP sub-form in sidemodal * Update links, capitalization * Move EmptyCell into ListPlusCell as fallback when no children passed in * Dedupe ClearAndAddButtons component * Including the new file is helpful * Add tooltip header for Other transit IPs * Layout adjustments, and show TransitIPs as optional * update tooltip header styling for ListPlusCell * A few post-review tweaks * Fix broken aria-describedby tooltipText code
1 parent 1625d02 commit e0d52ef

File tree

9 files changed

+155
-48
lines changed

9 files changed

+155
-48
lines changed

app/components/ListPlusCell.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import React from 'react'
1010

11+
import { EmptyCell } from '~/table/cells/EmptyCell'
1112
import { Tooltip } from '~/ui/lib/Tooltip'
1213

1314
type ListPlusCellProps = {
@@ -18,19 +19,25 @@ type ListPlusCellProps = {
1819
}
1920

2021
/**
21-
* Gives a count with a tooltip that expands to show details when the user hovers over it
22+
* Gives a count with a tooltip that expands to show details when the user hovers over it.
23+
* The ReactNode children are split into two groups: the first `numInCell` are shown in the cell,
24+
* and the rest are shown in the tooltip. If the number of children is less than or equal to
25+
* `numInCell`, no tooltip (or `+N` target) is shown.
2226
*/
2327
export const ListPlusCell = ({
2428
tooltipTitle,
2529
children,
2630
numInCell = 1,
2731
}: ListPlusCellProps) => {
2832
const array = React.Children.toArray(children)
33+
if (array.length === 0) {
34+
return <EmptyCell />
35+
}
2936
const inCell = array.slice(0, numInCell)
3037
const rest = array.slice(numInCell)
3138
const content = (
3239
<div>
33-
<div className="mb-2">{tooltipTitle}</div>
40+
<div className="mb-2 text-sans-semi-md text-default">{tooltipTitle}</div>
3441
<div className="flex flex-col items-start gap-2">{...rest}</div>
3542
</div>
3643
)

app/components/form/fields/TextField.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import cn from 'classnames'
98
import { useId } from 'react'
109
import {
1110
useController,
@@ -47,7 +46,7 @@ export interface TextFieldProps<
4746
* Displayed in a tooltip beside the title. This field should be used
4847
* for auxiliary context that helps users understand extra context about
4948
* a field but isn't specifically required to know how to complete the input.
50-
* This is announced as an `aria-description`
49+
* This is announced as an `aria-description`, immediately following the aria-labelledby text.
5150
*
5251
* @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-description
5352
*/
@@ -87,7 +86,7 @@ export function TextField<
8786
)}
8887
</div>
8988
{/* passing the generated id is very important for a11y */}
90-
<TextFieldInner name={name} {...props} id={id} />
89+
<TextFieldInner name={name} {...props} id={id} tooltipText={tooltipText} />
9190
</div>
9291
)
9392
}
@@ -129,12 +128,17 @@ export const TextFieldInner = <
129128
title={label}
130129
type={type}
131130
error={!!error}
132-
aria-labelledby={cn(`${id}-label`, !!tooltipText && `${id}-help-text`)}
133-
aria-describedby={tooltipText ? `${id}-label-tip` : undefined}
131+
aria-labelledby={`${id}-label ${id}-help-text`}
132+
aria-describedby={tooltipText ? `${id}-tooltipText` : undefined}
134133
onChange={(e) => onChange(transform ? transform(e.target.value) : e.target.value)}
135134
{...fieldRest}
136135
{...props}
137136
/>
137+
{tooltipText && (
138+
<div className="sr-only" id={`${id}-tooltipText`}>
139+
{tooltipText}
140+
</div>
141+
)}
138142
<ErrorMessage error={error} label={label} />
139143
</>
140144
)

app/forms/firewall-rules-common.tsx

Lines changed: 12 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import { RadioField } from '~/components/form/fields/RadioField'
3333
import { TextField, TextFieldInner } from '~/components/form/fields/TextField'
3434
import { useVpcSelector } from '~/hooks/use-params'
3535
import { Badge } from '~/ui/lib/Badge'
36-
import { Button } from '~/ui/lib/Button'
3736
import { FormDivider } from '~/ui/lib/Divider'
3837
import { Message } from '~/ui/lib/Message'
3938
import * as MiniTable from '~/ui/lib/MiniTable'
@@ -50,9 +49,9 @@ import { type FirewallRuleValues } from './firewall-rules-util'
5049
* a few sub-sections (Ports, Protocols, and Hosts).
5150
*
5251
* The Targets section and the Filters:Hosts section are very similar, so we've
53-
* pulled common code to the DynamicTypeAndValueFields and ClearAndAddButtons
54-
* components. We also then set up the Targets / Ports / Hosts variables at the
55-
* top of the CommonFields component.
52+
* pulled common code to the DynamicTypeAndValueFields components.
53+
* We also then set up the Targets / Ports / Hosts variables at the top of the
54+
* CommonFields component.
5655
*/
5756

5857
type TargetAndHostFilterType =
@@ -154,34 +153,6 @@ const DynamicTypeAndValueFields = ({
154153
)
155154
}
156155

157-
// The "Clear" and "Add …" buttons that appear below the filter input fields
158-
const ClearAndAddButtons = ({
159-
isDirty,
160-
onClear,
161-
onSubmit,
162-
buttonCopy,
163-
}: {
164-
isDirty: boolean
165-
onClear: () => void
166-
onSubmit: () => void
167-
buttonCopy: string
168-
}) => (
169-
<div className="flex justify-end">
170-
<Button
171-
variant="ghost"
172-
size="sm"
173-
className="mr-2.5"
174-
disabled={!isDirty}
175-
onClick={onClear}
176-
>
177-
Clear
178-
</Button>
179-
<Button size="sm" onClick={onSubmit}>
180-
{buttonCopy}
181-
</Button>
182-
</div>
183-
)
184-
185156
type TypeAndValueTableProps = {
186157
sectionType: 'target' | 'host'
187158
items: ControllerRenderProps<FirewallRuleValues, 'targets' | 'hosts'>
@@ -447,11 +418,11 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
447418
onInputChange={(value) => targetForm.setValue('value', value)}
448419
onSubmitTextField={submitTarget}
449420
/>
450-
<ClearAndAddButtons
451-
isDirty={!!targetValue}
421+
<MiniTable.ClearAndAddButtons
422+
addButtonCopy="Add target"
423+
disableClear={!!targetValue}
452424
onClear={() => targetForm.reset()}
453425
onSubmit={submitTarget}
454-
buttonCopy="Add target"
455426
/>
456427
</div>
457428
{!!targets.value.length && <TypeAndValueTable sectionType="target" items={targets} />}
@@ -498,11 +469,11 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
498469
}}
499470
/>
500471
</div>
501-
<ClearAndAddButtons
502-
isDirty={!!portValue}
472+
<MiniTable.ClearAndAddButtons
473+
addButtonCopy="Add port filter"
474+
disableClear={!!portValue}
503475
onClear={portRangeForm.reset}
504476
onSubmit={submitPortRange}
505-
buttonCopy="Add port filter"
506477
/>
507478
</div>
508479
{!!ports.value.length && (
@@ -554,11 +525,11 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
554525
onInputChange={(value) => hostForm.setValue('value', value)}
555526
onSubmitTextField={submitHost}
556527
/>
557-
<ClearAndAddButtons
558-
isDirty={!!hostValue}
528+
<MiniTable.ClearAndAddButtons
529+
addButtonCopy="Add host filter"
530+
disableClear={!!hostValue}
559531
onClear={() => hostForm.reset()}
560532
onSubmit={submitHost}
561-
buttonCopy="Add host filter"
562533
/>
563534
</div>
564535
{!!hosts.value.length && <TypeAndValueTable sectionType="host" items={hosts} />}

app/forms/network-interface-edit.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,15 @@ import {
1717

1818
import { DescriptionField } from '~/components/form/fields/DescriptionField'
1919
import { NameField } from '~/components/form/fields/NameField'
20+
import { TextFieldInner } from '~/components/form/fields/TextField'
2021
import { SideModalForm } from '~/components/form/SideModalForm'
2122
import { useInstanceSelector } from '~/hooks/use-params'
23+
import { FormDivider } from '~/ui/lib/Divider'
24+
import { FieldLabel } from '~/ui/lib/FieldLabel'
25+
import * as MiniTable from '~/ui/lib/MiniTable'
26+
import { TextInputHint } from '~/ui/lib/TextInput'
27+
import { KEYS } from '~/ui/util/keys'
28+
import { links } from '~/util/links'
2229

2330
type EditNetworkInterfaceFormProps = {
2431
editing: InstanceNetworkInterface
@@ -42,9 +49,20 @@ export function EditNetworkInterfaceForm({
4249
const defaultValues = R.pick(editing, [
4350
'name',
4451
'description',
52+
'transitIps',
4553
]) satisfies InstanceNetworkInterfaceUpdate
4654

4755
const form = useForm({ defaultValues })
56+
const transitIps = form.watch('transitIps') || []
57+
58+
const transitIpsForm = useForm({ defaultValues: { transitIp: '' } })
59+
60+
const submitTransitIp = () => {
61+
const transitIp = transitIpsForm.getValues('transitIp')
62+
if (!transitIp) return
63+
form.setValue('transitIps', [...transitIps, transitIp])
64+
transitIpsForm.reset()
65+
}
4866

4967
return (
5068
<SideModalForm
@@ -65,6 +83,69 @@ export function EditNetworkInterfaceForm({
6583
>
6684
<NameField name="name" control={form.control} />
6785
<DescriptionField name="description" control={form.control} />
86+
<FormDivider />
87+
88+
<div className="flex flex-col gap-3">
89+
{/* We have to blow this up instead of using TextField for better layout control of field and ClearAndAddButtons */}
90+
<div>
91+
<FieldLabel id="transitIp-label" htmlFor="transitIp" optional>
92+
Transit IPs
93+
</FieldLabel>
94+
<TextInputHint id="transitIp-help-text" className="mb-2">
95+
Enter an IPv4 or IPv6 address.{' '}
96+
<a href={links.transitIpsDocs} target="_blank" rel="noreferrer">
97+
Learn more about transit IPs.
98+
</a>
99+
</TextInputHint>
100+
<TextFieldInner
101+
id="transitIp"
102+
name="transitIp"
103+
control={transitIpsForm.control}
104+
onKeyDown={(e) => {
105+
if (e.key === KEYS.enter) {
106+
e.preventDefault() // prevent full form submission
107+
submitTransitIp()
108+
}
109+
}}
110+
/>
111+
</div>
112+
<MiniTable.ClearAndAddButtons
113+
addButtonCopy="Add Transit IP"
114+
disableClear={!!transitIpsForm.formState.dirtyFields.transitIp}
115+
onClear={transitIpsForm.reset}
116+
onSubmit={submitTransitIp}
117+
/>
118+
</div>
119+
{transitIps.length > 0 && (
120+
<MiniTable.Table className="mb-4" aria-label="Transit IPs">
121+
<MiniTable.Header>
122+
<MiniTable.HeadCell>Transit IPs</MiniTable.HeadCell>
123+
{/* For remove button */}
124+
<MiniTable.HeadCell className="w-12" />
125+
</MiniTable.Header>
126+
<MiniTable.Body>
127+
{transitIps.map((ip, index) => (
128+
<MiniTable.Row
129+
tabIndex={0}
130+
aria-rowindex={index + 1}
131+
aria-label={ip}
132+
key={ip}
133+
>
134+
<MiniTable.Cell>{ip}</MiniTable.Cell>
135+
<MiniTable.RemoveCell
136+
label={`remove IP ${ip}`}
137+
onClick={() => {
138+
form.setValue(
139+
'transitIps',
140+
transitIps.filter((item) => item !== ip)
141+
)
142+
}}
143+
/>
144+
</MiniTable.Row>
145+
))}
146+
</MiniTable.Body>
147+
</MiniTable.Table>
148+
)}
68149
</SideModalForm>
69150
)
70151
}

app/pages/project/instances/instance/tabs/NetworkingTab.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { IpGlobal24Icon, Networking24Icon } from '@oxide/design-system/icons/rea
2424
import { AttachEphemeralIpModal } from '~/components/AttachEphemeralIpModal'
2525
import { AttachFloatingIpModal } from '~/components/AttachFloatingIpModal'
2626
import { HL } from '~/components/HL'
27+
import { ListPlusCell } from '~/components/ListPlusCell'
2728
import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create'
2829
import { EditNetworkInterfaceForm } from '~/forms/network-interface-edit'
2930
import {
@@ -133,6 +134,14 @@ const staticCols = [
133134
header: 'subnet',
134135
cell: (info) => <SubnetNameFromId value={info.getValue()} />,
135136
}),
137+
colHelper.accessor('transitIps', {
138+
header: 'Transit IPs',
139+
cell: (info) => (
140+
<ListPlusCell tooltipTitle="Other transit IPs">
141+
{info.getValue()?.map((ip) => <div key={ip}>{ip}</div>)}
142+
</ListPlusCell>
143+
),
144+
}),
136145
]
137146

138147
const updateNicStates = fancifyStates(instanceCan.updateNic.states)

app/ui/lib/MiniTable.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Error16Icon from '@oxide/design-system/icons/react/Error16Icon'
99

1010
import { classed } from '~/util/classed'
1111

12+
import { Button } from './Button'
1213
import { Table as BigTable } from './Table'
1314

1415
type Children = { children: React.ReactNode }
@@ -44,3 +45,30 @@ export const RemoveCell = ({ onClick, label }: { onClick: () => void; label: str
4445
</button>
4546
</Cell>
4647
)
48+
49+
type ClearAndAddButtonsProps = {
50+
addButtonCopy: string
51+
disableClear: boolean
52+
onClear: () => void
53+
onSubmit: () => void
54+
}
55+
56+
/**
57+
* A set of buttons used with embedded sub-forms to add items to MiniTables,
58+
* like in the firewall rules and NIC edit forms.
59+
*/
60+
export const ClearAndAddButtons = ({
61+
addButtonCopy,
62+
disableClear,
63+
onClear,
64+
onSubmit,
65+
}: ClearAndAddButtonsProps) => (
66+
<div className="flex justify-end gap-2.5">
67+
<Button variant="ghost" size="sm" disabled={disableClear} onClick={onClear}>
68+
Clear
69+
</Button>
70+
<Button size="sm" onClick={onSubmit}>
71+
{addButtonCopy}
72+
</Button>
73+
</div>
74+
)

app/util/links.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export const links = {
4242
systemIpPoolsDocs: 'https://docs.oxide.computer/guides/operator/ip-pool-management',
4343
systemMetricsDocs: 'https://docs.oxide.computer/guides/operator/system-metrics',
4444
systemSiloDocs: 'https://docs.oxide.computer/guides/operator/silo-management',
45+
transitIpsDocs:
46+
'https://docs.oxide.computer/guides/configuring-guest-networking#_example_4_software_routing_tunnels',
4547
instancesDocs: 'https://docs.oxide.computer/guides/deploying-workloads',
4648
vpcsDocs: 'https://docs.oxide.computer/guides/configuring-guest-networking',
4749
}

mock-api/msw/handlers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,10 @@ export const handlers = makeHandlers({
677677
nic.primary = !!body.primary
678678
}
679679

680+
if (body.transit_ips) {
681+
nic.transit_ips = body.transit_ips
682+
}
683+
680684
return nic
681685
},
682686
instanceNetworkInterfaceDelete({ path, query }) {

mock-api/network-interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ export const networkInterface: Json<InstanceNetworkInterface> = {
2222
subnet_id: vpcSubnet.id,
2323
time_created: new Date().toISOString(),
2424
time_modified: new Date().toISOString(),
25+
transit_ips: ['172.30.0.0/22'],
2526
vpc_id: vpc.id,
2627
}

0 commit comments

Comments
 (0)