Skip to content

Commit 04aceb5

Browse files
Affinity group add/remove on instance settings tab (#2789)
* affinity group add/remove on instance settings tab * say anti-affinity one fewer time * Prevent adding instance to A-A group when not stopped * extract disabledReason to its own function * Only call it once * Update test * add instanceCan.addToAntiAffinityGroup * tweak disabled message to match ones for stop/start/etc * shorter disabledReason logic --------- Co-authored-by: Charlie Park <charlie@oxidecomputer.com>
1 parent 1ecc569 commit 04aceb5

File tree

8 files changed

+265
-22
lines changed

8 files changed

+265
-22
lines changed

app/api/util.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ const instanceActions = {
131131
updateNic: ['stopped'],
132132
// https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/src/app/instance.rs#L1520-L1522
133133
serialConsole: ['running', 'rebooting', 'migrating', 'repairing'],
134+
135+
// https://github.com/oxidecomputer/omicron/blob/5e27bde/nexus/src/app/affinity.rs#L357 checks to see that there's no VMM
136+
// TODO: determine whether the intent is only `stopped` or also `failed`
137+
addToAntiAffinityGroup: ['stopped'],
134138
} satisfies Record<string, InstanceState[]>
135139

136140
// setting .states is a cute way to make it ergonomic to call the test function

app/forms/anti-affinity-group-member-add.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }:
3333
onSuccess(_data, variables) {
3434
onDismiss()
3535
queryClient.invalidateEndpoint('antiAffinityGroupMemberList')
36-
queryClient.invalidateEndpoint('antiAffinityGroupView')
36+
queryClient.invalidateEndpoint('instanceAntiAffinityGroupList')
3737
addToast(<>Instance <HL>{variables.path.instance}</HL> added to anti-affinity group <HL>{antiAffinityGroup}</HL></>) // prettier-ignore
3838
},
3939
})

app/pages/project/affinity/AntiAffinityGroupPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export default function AntiAffinityPage() {
100100
{
101101
onSuccess(_data, variables) {
102102
queryClient.invalidateEndpoint('antiAffinityGroupMemberList')
103-
queryClient.invalidateEndpoint('antiAffinityGroupView')
103+
queryClient.invalidateEndpoint('instanceAntiAffinityGroupList')
104104
addToast(<>Member <HL>{variables.path.instance}</HL> removed from anti-affinity group <HL>{group.name}</HL></>) // prettier-ignore
105105
},
106106
}

app/pages/project/instances/AntiAffinityCard.tsx

Lines changed: 172 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,59 @@
77
*/
88

99
import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table'
10-
import { useMemo } from 'react'
10+
import { useCallback, useId, useMemo, useState } from 'react'
11+
import { useForm } from 'react-hook-form'
12+
import * as R from 'remeda'
1113

1214
import {
1315
apiq,
16+
instanceCan,
17+
queryClient,
18+
useApiMutation,
1419
usePrefetchedQuery,
1520
type AffinityGroup,
1621
type AntiAffinityGroup,
1722
} from '@oxide/api'
1823
import { Affinity24Icon } from '@oxide/design-system/icons/react'
1924

2025
import { AffinityPolicyHeader } from '~/components/AffinityDocsPopover'
26+
import { ComboboxField } from '~/components/form/fields/ComboboxField'
27+
import { HL } from '~/components/HL'
2128
import { useInstanceSelector } from '~/hooks/use-params'
2229
import { AffinityGroupPolicyBadge } from '~/pages/project/affinity/AffinityPage'
30+
import { confirmAction } from '~/stores/confirm-action'
31+
import { addToast } from '~/stores/toast'
2332
import { makeLinkCell } from '~/table/cells/LinkCell'
33+
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
2434
import { Columns } from '~/table/columns/common'
2535
import { Table } from '~/table/Table'
36+
import { Button } from '~/ui/lib/Button'
2637
import { CardBlock } from '~/ui/lib/CardBlock'
38+
import { toComboboxItems } from '~/ui/lib/Combobox'
2739
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
40+
import { Modal } from '~/ui/lib/Modal'
2841
import { TableEmptyBox } from '~/ui/lib/Table'
2942
import { ALL_ISH } from '~/util/consts'
3043
import { pb } from '~/util/path-builder'
3144
import type * as PP from '~/util/path-params'
3245

33-
export const antiAffinityGroupList = ({ project, instance }: PP.Instance) =>
46+
export const instanceAntiAffinityGroups = ({ project, instance }: PP.Instance) =>
3447
apiq('instanceAntiAffinityGroupList', {
3548
path: { instance },
3649
query: { project, limit: ALL_ISH },
3750
})
3851

52+
export const allAntiAffinityGroups = ({ project }: PP.Project) =>
53+
apiq('antiAffinityGroupList', {
54+
query: { project, limit: ALL_ISH },
55+
})
56+
57+
const instanceView = ({ project, instance }: PP.Instance) =>
58+
apiq('instanceView', {
59+
path: { instance },
60+
query: { project },
61+
})
62+
3963
const colHelper = createColumnHelper<AffinityGroup | AntiAffinityGroup>()
4064
const staticCols = [
4165
colHelper.accessor('description', Columns.description),
@@ -47,36 +71,110 @@ const staticCols = [
4771

4872
export function AntiAffinityCard() {
4973
const instanceSelector = useInstanceSelector()
50-
const { project } = instanceSelector
74+
const { project, instance } = instanceSelector
75+
76+
const { data: memberGroups } = usePrefetchedQuery(
77+
instanceAntiAffinityGroups(instanceSelector)
78+
)
79+
const { data: allGroups } = usePrefetchedQuery(allAntiAffinityGroups(instanceSelector))
80+
const { data: instanceData } = usePrefetchedQuery(instanceView(instanceSelector))
81+
82+
const nonMemberGroups = useMemo(
83+
() => R.differenceWith(allGroups.items, memberGroups.items, (a, b) => a.id === b.id),
84+
[memberGroups, allGroups]
85+
)
5186

52-
const { data: antiAffinityGroups } = usePrefetchedQuery(
53-
antiAffinityGroupList(instanceSelector)
87+
const { mutateAsync: removeMember } = useApiMutation(
88+
'antiAffinityGroupMemberInstanceDelete',
89+
{
90+
onSuccess(_data, variables) {
91+
addToast(
92+
<>
93+
Instance <b>{variables.path.instance}</b> removed from anti-affinity group{' '}
94+
<b>{variables.path.antiAffinityGroup}</b>
95+
</>
96+
)
97+
queryClient.invalidateEndpoint('instanceAntiAffinityGroupList')
98+
queryClient.invalidateEndpoint('antiAffinityGroupMemberList')
99+
},
100+
}
54101
)
55102

56-
const antiAffinityCols = useMemo(
57-
() => [
103+
const makeActions = useCallback(
104+
(group: AffinityGroup | AntiAffinityGroup): MenuAction[] => [
105+
{
106+
label: 'Remove instance from group',
107+
onActivate() {
108+
confirmAction({
109+
actionType: 'danger',
110+
doAction: () =>
111+
removeMember({
112+
path: {
113+
antiAffinityGroup: group.name,
114+
instance,
115+
},
116+
query: { project },
117+
}),
118+
modalTitle: 'Remove instance from group',
119+
modalContent: (
120+
<p>
121+
Are you sure you want to remove instance <b>{instance}</b> from group{' '}
122+
<b>{group.name}</b>?
123+
</p>
124+
),
125+
errorTitle: 'Error removing instance from group',
126+
})
127+
},
128+
},
129+
],
130+
[instance, project, removeMember]
131+
)
132+
133+
const antiAffinityCols = useColsWithActions(
134+
[
58135
colHelper.accessor('name', {
59136
cell: makeLinkCell((antiAffinityGroup) =>
60137
pb.antiAffinityGroup({ project, antiAffinityGroup })
61138
),
62139
}),
63140
...staticCols,
64141
],
65-
[project]
142+
makeActions,
143+
'Copy group ID'
66144
)
67145

68-
// Create tables for both types of groups
146+
const [isModalOpen, setIsModalOpen] = useState(false)
147+
69148
const antiAffinityTable = useReactTable({
70149
columns: antiAffinityCols,
71-
data: antiAffinityGroups.items,
150+
data: memberGroups.items,
72151
getCoreRowModel: getCoreRowModel(),
73152
})
74153

154+
let disabledReason = undefined
155+
if (!instanceCan.addToAntiAffinityGroup(instanceData)) {
156+
disabledReason =
157+
<>Only <HL>stopped</HL> instances can be added to a group</> // prettier-ignore
158+
} else if (allGroups.items.length === 0) {
159+
disabledReason = 'No groups found'
160+
} else if (nonMemberGroups.length === 0) {
161+
disabledReason = 'Instance is already in all groups'
162+
}
163+
75164
return (
76165
<CardBlock>
77-
<CardBlock.Header title="Anti-affinity groups" titleId="anti-affinity-groups-label" />
166+
<CardBlock.Header title="Anti-affinity groups" titleId="anti-affinity-groups-label">
167+
<Button
168+
size="sm"
169+
disabled={!!disabledReason}
170+
disabledReason={disabledReason}
171+
onClick={() => setIsModalOpen(true)}
172+
>
173+
Add to group
174+
</Button>
175+
</CardBlock.Header>
78176
<CardBlock.Body>
79-
{antiAffinityGroups.items.length > 0 ? (
177+
{memberGroups.items.length > 0 ? (
80178
<Table
81179
aria-labelledby="anti-affinity-groups-label"
82180
table={antiAffinityTable}
@@ -92,6 +190,68 @@ export function AntiAffinityCard() {
92190
</TableEmptyBox>
93191
)}
94192
</CardBlock.Body>
193+
{isModalOpen && (
194+
<AddToGroupModal
195+
onDismiss={() => setIsModalOpen(false)}
196+
nonMemberGroups={nonMemberGroups}
197+
/>
198+
)}
95199
</CardBlock>
96200
)
97201
}
202+
203+
type ModalProps = {
204+
onDismiss: () => void
205+
nonMemberGroups: (AffinityGroup | AntiAffinityGroup)[]
206+
}
207+
208+
export function AddToGroupModal({ onDismiss, nonMemberGroups }: ModalProps) {
209+
const { project, instance } = useInstanceSelector()
210+
211+
const form = useForm({ defaultValues: { group: '' } })
212+
const formId = useId()
213+
214+
const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', {
215+
onSuccess(_data, variables) {
216+
onDismiss()
217+
queryClient.invalidateEndpoint('antiAffinityGroupMemberList')
218+
queryClient.invalidateEndpoint('instanceAntiAffinityGroupList')
219+
addToast(
220+
<>
221+
Instance <HL>{instance}</HL> added to anti-affinity group{' '}
222+
<HL>{variables.path.antiAffinityGroup}</HL>
223+
</>
224+
)
225+
},
226+
})
227+
228+
const handleSubmit = form.handleSubmit(({ group }) => {
229+
addMember({
230+
path: { antiAffinityGroup: group, instance },
231+
query: { project },
232+
})
233+
})
234+
235+
return (
236+
<Modal isOpen onDismiss={onDismiss} title="Add to anti-affinity group">
237+
<Modal.Body>
238+
<Modal.Section>
239+
<p className="mb-6">
240+
Select a group to add instance <HL>{instance}</HL> to.
241+
</p>
242+
<form id={formId} onSubmit={handleSubmit}>
243+
<ComboboxField
244+
label="Anti-affinity group"
245+
placeholder="Select a group"
246+
name="group"
247+
items={toComboboxItems(nonMemberGroups)}
248+
required
249+
control={form.control}
250+
/>
251+
</form>
252+
</Modal.Section>
253+
</Modal.Body>
254+
<Modal.Footer onDismiss={onDismiss} formId={formId} actionText="Add to group" />
255+
</Modal>
256+
)
257+
}

app/pages/project/instances/NetworkingTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ export default function NetworkingTab() {
455455
disabledReason={
456456
<>
457457
A network interface cannot be created or edited unless the instance is{' '}
458-
{updateNicStates}.
458+
{updateNicStates}
459459
</>
460460
}
461461
>

app/pages/project/instances/SettingsTab.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@ import { queryClient } from '@oxide/api'
1212

1313
import { getInstanceSelector } from '~/hooks/use-params'
1414

15-
import { AntiAffinityCard, antiAffinityGroupList } from './AntiAffinityCard'
15+
import {
16+
allAntiAffinityGroups,
17+
AntiAffinityCard,
18+
instanceAntiAffinityGroups,
19+
} from './AntiAffinityCard'
1620
import { AutoRestartCard } from './AutoRestartCard'
1721

1822
export const handle = { crumb: 'Settings' }
1923

2024
export async function clientLoader({ params }: LoaderFunctionArgs) {
21-
const instanceSelector = getInstanceSelector(params)
22-
await queryClient.prefetchQuery(antiAffinityGroupList(instanceSelector))
25+
const { project, instance } = getInstanceSelector(params)
26+
await Promise.all([
27+
queryClient.prefetchQuery(instanceAntiAffinityGroups({ project, instance })),
28+
queryClient.prefetchQuery(allAntiAffinityGroups({ project })),
29+
])
2330
return null
2431
}
2532

app/table/columns/action-col.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,18 @@ export function useColsWithActions<TData extends Record<string, unknown>>(
4242
/** Should be static or memoized */
4343
columns: ColumnDef<TData, any>[], // eslint-disable-line @typescript-eslint/no-explicit-any
4444
/** Must be memoized to avoid re-renders */
45-
makeActions: MakeActions<TData>
45+
makeActions: MakeActions<TData>,
46+
copyIdLabel?: string
4647
) {
47-
return useMemo(() => [...columns, getActionsCol(makeActions)], [columns, makeActions])
48+
return useMemo(
49+
() => [...columns, getActionsCol(makeActions, copyIdLabel)],
50+
[columns, makeActions, copyIdLabel]
51+
)
4852
}
4953

5054
export const getActionsCol = <TData extends Record<string, unknown>>(
51-
makeActions: MakeActions<TData>
55+
makeActions: MakeActions<TData>,
56+
copyIdLabel?: string
5257
): ColumnDef<TData> => {
5358
return {
5459
id: 'menu',
@@ -62,7 +67,7 @@ export const getActionsCol = <TData extends Record<string, unknown>>(
6267
// TODO: control flow here has always confused me, would like to straighten it out
6368
const actions = makeActions(row.original)
6469
const id = typeof row.original.id === 'string' ? row.original.id : null
65-
return <RowActions id={id} actions={actions} />
70+
return <RowActions id={id} actions={actions} copyIdLabel={copyIdLabel} />
6671
},
6772
}
6873
}

0 commit comments

Comments
 (0)