Skip to content

Commit 3e5a0a1

Browse files
Add create and edit functionality to anti-affinity groups (#2775)
* Enable removal of group member * Enable deletion of group * move away from useMemo for columns * Update copy in remove confirm modal * Update copy in delete modal * use apiq since we're not paginating * use the id for delete * merged main and reconciling diffs * Add new anti-affinity group * ah … crumbs * Edit anti-affinity group is working * Fix bug; add ID column to table * can add instances to an anti-affinity group * Update to ID column truncation * Refactoring * Missed a spot in the refactoring * Update snapshots * Can just use prefetchQuery, since we don't need returned data yet * reorder functions * move instanceList fetching up a level; use to disable button when no instances available * Use existing types for forms * export function as default * refactor idCell * Update mock-api/msw/handlers.ts Co-authored-by: David Crespo <david-crespo@users.noreply.github.com> * don't reuse AffinityPageHeader * Shorter button copy; new page header * Don't include sorting; already present in actual data * Try 'anti-affinity' as header / nav link * Clean up copy button a bit * More clever disabledReason; needs max member verification * use regular link for group edit row action * put it back to Affinity title * draft docs popover * A few more refactors / PR comments * routing fix * reintroduce convert in routes for group create * update max members value * Link to specific commit for line reference stability * Add e2e tests * Refresh of Affinity Groups table columns; use count in place of instance names * update test * don't fetch affinity groups * members col -> instances, don't validate name uniqueness * add delete and docs popover to group detail, use confirmDelete * help text on policy field and tip icon on policy columns * merge main and resolve conflicts with AffinityGroupPolicyBadge * put back the line about policy in the popover * remove title from icons in sidebar nav * Refactoring form in add instance to A-A group modal * type -> group type, remove description column * on second thought: make page title Affinity Groups * simplify form reset by unmounting, test that in e2e * use handleSubmit higher so we don't have to type explicitly * make enter submit add instance modal form * link to instance settings rather than default tab * hopefully final policy help copy tweaks --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com> Co-authored-by: David Crespo <david-crespo@users.noreply.github.com>
1 parent ab06a33 commit 3e5a0a1

19 files changed

+777
-134
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { Affinity16Icon } from '@oxide/design-system/icons/react'
9+
10+
import { policyHelpText } from '~/forms/affinity-util'
11+
import { TipIcon } from '~/ui/lib/TipIcon'
12+
import { docLinks } from '~/util/links'
13+
14+
import { DocsPopover } from './DocsPopover'
15+
16+
export const AffinityDocsPopover = () => (
17+
<DocsPopover
18+
heading="affinity"
19+
icon={<Affinity16Icon />}
20+
summary="Instances in an anti-affinity group will be placed on different sleds when they start. The policy attribute determines whether instances can still start when a unique sled is not available."
21+
links={[docLinks.affinity]}
22+
/>
23+
)
24+
25+
export const AffinityPolicyHeader = () => (
26+
<>
27+
Policy<TipIcon className="ml-1.5">{policyHelpText}</TipIcon>
28+
</>
29+
)

app/forms/affinity-util.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { apiq } from '~/api'
10+
import { ALL_ISH } from '~/util/consts'
11+
import type * as PP from '~/util/path-params'
12+
13+
export const instanceList = ({ project }: PP.Project) =>
14+
apiq('instanceList', { query: { project, limit: ALL_ISH } })
15+
16+
export const antiAffinityGroupList = ({ project }: PP.Project) =>
17+
apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } })
18+
19+
export const antiAffinityGroupView = ({
20+
project,
21+
antiAffinityGroup,
22+
}: PP.AntiAffinityGroup) =>
23+
apiq('antiAffinityGroupView', { path: { antiAffinityGroup }, query: { project } })
24+
25+
export const antiAffinityGroupMemberList = ({
26+
antiAffinityGroup,
27+
project,
28+
}: PP.AntiAffinityGroup) =>
29+
apiq('antiAffinityGroupMemberList', {
30+
path: { antiAffinityGroup },
31+
// member limit in DB is currently 32, so pagination isn't needed
32+
query: { project, limit: ALL_ISH },
33+
})
34+
35+
export const policyHelpText =
36+
"Determines whether member instances are allowed to start when the anti-affinity rule can't be satisfied"
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { useForm } from 'react-hook-form'
9+
import { useNavigate } from 'react-router'
10+
11+
import { queryClient, useApiMutation, type AntiAffinityGroupCreate } from '@oxide/api'
12+
13+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
14+
import { NameField } from '~/components/form/fields/NameField'
15+
import { RadioField } from '~/components/form/fields/RadioField'
16+
import { SideModalForm } from '~/components/form/SideModalForm'
17+
import { HL } from '~/components/HL'
18+
import { titleCrumb } from '~/hooks/use-crumbs'
19+
import { useProjectSelector } from '~/hooks/use-params'
20+
import { addToast } from '~/stores/toast'
21+
import { pb } from '~/util/path-builder'
22+
23+
import { policyHelpText } from './affinity-util'
24+
25+
export const handle = titleCrumb('New anti-affinity group')
26+
27+
const defaultValues: Omit<AntiAffinityGroupCreate, 'failureDomain'> = {
28+
name: '',
29+
description: '',
30+
policy: 'allow',
31+
}
32+
33+
export default function CreateAntiAffinityGroupForm() {
34+
const { project } = useProjectSelector()
35+
36+
const navigate = useNavigate()
37+
38+
const createAntiAffinityGroup = useApiMutation('antiAffinityGroupCreate', {
39+
onSuccess(antiAffinityGroup) {
40+
queryClient.invalidateEndpoint('antiAffinityGroupList')
41+
navigate(pb.antiAffinityGroup({ project, antiAffinityGroup: antiAffinityGroup.name }))
42+
addToast(<>Anti-affinity group <HL>{antiAffinityGroup.name}</HL> created</>) // prettier-ignore
43+
},
44+
})
45+
46+
const form = useForm({ defaultValues })
47+
const control = form.control
48+
49+
return (
50+
<SideModalForm
51+
form={form}
52+
formType="create"
53+
resourceName="rule"
54+
title="Add anti-affinity group"
55+
onDismiss={() => navigate(pb.affinity({ project }))}
56+
onSubmit={(values) =>
57+
createAntiAffinityGroup.mutate({
58+
query: { project },
59+
body: { ...values, failureDomain: 'sled' },
60+
})
61+
}
62+
loading={createAntiAffinityGroup.isPending}
63+
submitError={createAntiAffinityGroup.error}
64+
submitLabel="Add group"
65+
>
66+
<NameField name="name" control={control} />
67+
<DescriptionField name="description" control={control} />
68+
<RadioField
69+
name="policy"
70+
// forgive me
71+
description={`${policyHelpText}, i.e., when all available sleds already contain a group member.`}
72+
column
73+
control={control}
74+
items={[
75+
{ value: 'allow', label: 'Allow' },
76+
{ value: 'fail', label: 'Fail' },
77+
]}
78+
/>
79+
</SideModalForm>
80+
)
81+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { useForm } from 'react-hook-form'
9+
import { useNavigate, type LoaderFunctionArgs } from 'react-router'
10+
import * as R from 'remeda'
11+
12+
import {
13+
queryClient,
14+
useApiMutation,
15+
usePrefetchedQuery,
16+
type AntiAffinityGroupUpdate,
17+
} from '@oxide/api'
18+
19+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
20+
import { NameField } from '~/components/form/fields/NameField'
21+
import { SideModalForm } from '~/components/form/SideModalForm'
22+
import { HL } from '~/components/HL'
23+
import { titleCrumb } from '~/hooks/use-crumbs'
24+
import {
25+
getAntiAffinityGroupSelector,
26+
useAntiAffinityGroupSelector,
27+
} from '~/hooks/use-params'
28+
import { addToast } from '~/stores/toast'
29+
import { pb } from '~/util/path-builder'
30+
31+
import { antiAffinityGroupView } from './affinity-util'
32+
33+
export const handle = titleCrumb('New anti-affinity group')
34+
35+
export async function clientLoader({ params }: LoaderFunctionArgs) {
36+
const { project, antiAffinityGroup } = getAntiAffinityGroupSelector(params)
37+
await queryClient.prefetchQuery(antiAffinityGroupView({ project, antiAffinityGroup }))
38+
return null
39+
}
40+
41+
export default function EditAntiAffintyGroupForm() {
42+
const { project, antiAffinityGroup } = useAntiAffinityGroupSelector()
43+
44+
const navigate = useNavigate()
45+
46+
const editAntiAffinityGroup = useApiMutation('antiAffinityGroupUpdate', {
47+
onSuccess(updatedGroup) {
48+
queryClient.invalidateEndpoint('antiAffinityGroupView')
49+
queryClient.invalidateEndpoint('antiAffinityGroupList')
50+
navigate(pb.antiAffinityGroup({ project, antiAffinityGroup: updatedGroup.name }))
51+
addToast(<>Anti-affinity group <HL>{updatedGroup.name}</HL> updated</>) // prettier-ignore
52+
},
53+
})
54+
55+
const { data: group } = usePrefetchedQuery(
56+
antiAffinityGroupView({ project, antiAffinityGroup })
57+
)
58+
59+
const defaultValues: AntiAffinityGroupUpdate = R.pick(group, ['name', 'description'])
60+
const form = useForm({ defaultValues })
61+
62+
return (
63+
<SideModalForm
64+
form={form}
65+
formType="create"
66+
resourceName="rule"
67+
title="Edit anti-affinity group"
68+
onDismiss={() => navigate(pb.antiAffinityGroup({ project, antiAffinityGroup }))}
69+
onSubmit={(values) => {
70+
editAntiAffinityGroup.mutate({
71+
path: { antiAffinityGroup },
72+
query: { project },
73+
body: values,
74+
})
75+
}}
76+
loading={editAntiAffinityGroup.isPending}
77+
submitError={editAntiAffinityGroup.error}
78+
submitLabel="Edit group"
79+
>
80+
<NameField name="name" control={form.control} />
81+
<DescriptionField name="description" control={form.control} />
82+
</SideModalForm>
83+
)
84+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { useId } from 'react'
10+
import { useForm } from 'react-hook-form'
11+
12+
import { queryClient, useApiMutation, type Instance } from '~/api'
13+
import { ComboboxField } from '~/components/form/fields/ComboboxField'
14+
import { HL } from '~/components/HL'
15+
import { useAntiAffinityGroupSelector } from '~/hooks/use-params'
16+
import { addToast } from '~/stores/toast'
17+
import { toComboboxItems } from '~/ui/lib/Combobox'
18+
import { Modal } from '~/ui/lib/Modal'
19+
20+
type Values = { instance: string }
21+
22+
const defaultValues: Values = { instance: '' }
23+
24+
type Props = { instances: Instance[]; onDismiss: () => void }
25+
26+
export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }: Props) {
27+
const { project, antiAffinityGroup } = useAntiAffinityGroupSelector()
28+
29+
const form = useForm({ defaultValues })
30+
const formId = useId()
31+
32+
const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', {
33+
onSuccess(_data, variables) {
34+
onDismiss()
35+
queryClient.invalidateEndpoint('antiAffinityGroupMemberList')
36+
queryClient.invalidateEndpoint('antiAffinityGroupView')
37+
addToast(<>Instance <HL>{variables.path.instance}</HL> added to anti-affinity group <HL>{antiAffinityGroup}</HL></>) // prettier-ignore
38+
},
39+
})
40+
41+
const onSubmit = form.handleSubmit(({ instance }) => {
42+
addMember({
43+
path: { antiAffinityGroup, instance },
44+
query: { project },
45+
})
46+
})
47+
48+
return (
49+
<Modal isOpen onDismiss={onDismiss} title="Add instance to group">
50+
<Modal.Body>
51+
<Modal.Section>
52+
<p className="text-sm text-gray-500">
53+
Select an instance to add to the anti-affinity group{' '}
54+
<HL>{antiAffinityGroup}</HL>. Only stopped instances can be added to the group.
55+
</p>
56+
<form id={formId} onSubmit={onSubmit}>
57+
<ComboboxField
58+
placeholder="Select an instance"
59+
name="instance"
60+
label="Instance"
61+
items={toComboboxItems(instances)}
62+
required
63+
control={form.control}
64+
/>
65+
</form>
66+
</Modal.Section>
67+
</Modal.Body>
68+
<Modal.Footer onDismiss={onDismiss} actionText="Add to group" formId={formId} />
69+
</Modal>
70+
)
71+
}

app/layouts/ProjectLayoutBase.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
6969
{ value: 'Images', path: pb.projectImages(projectSelector) },
7070
{ value: 'VPCs', path: pb.vpcs(projectSelector) },
7171
{ value: 'Floating IPs', path: pb.floatingIps(projectSelector) },
72-
{ value: 'Affinity', path: pb.affinity(projectSelector) },
72+
{ value: 'Affinity Groups', path: pb.affinity(projectSelector) },
7373
{ value: 'Access', path: pb.projectAccess(projectSelector) },
7474
]
7575
// filter out the entry for the path we're currently on
@@ -106,7 +106,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
106106
<Snapshots16Icon /> Snapshots
107107
</NavLinkItem>
108108
<NavLinkItem to={pb.projectImages(projectSelector)}>
109-
<Images16Icon title="images" /> Images
109+
<Images16Icon /> Images
110110
</NavLinkItem>
111111
<NavLinkItem to={pb.vpcs(projectSelector)}>
112112
<Networking16Icon /> VPCs
@@ -115,10 +115,10 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
115115
<IpGlobal16Icon /> Floating IPs
116116
</NavLinkItem>
117117
<NavLinkItem to={pb.affinity(projectSelector)}>
118-
<Affinity16Icon title="Affinity" /> Affinity
118+
<Affinity16Icon /> Affinity Groups
119119
</NavLinkItem>
120120
<NavLinkItem to={pb.projectAccess(projectSelector)}>
121-
<Access16Icon title="Access" /> Access
121+
<Access16Icon /> Access
122122
</NavLinkItem>
123123
</Sidebar.Nav>
124124
</Sidebar>

0 commit comments

Comments
 (0)