diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index d9609e752..ff52782fd 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -20,6 +20,7 @@ import { useApiMutation, useApiQueryClient, usePrefetchedApiQuery, + type AntiAffinityGroup, type ExternalIpCreate, type FloatingIp, type Image, @@ -29,6 +30,7 @@ import { type SiloIpPool, } from '@oxide/api' import { + Affinity16Icon, Images16Icon, Instances16Icon, Instances24Icon, @@ -60,6 +62,7 @@ import { FullPageForm } from '~/components/form/FullPageForm' import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { Badge } from '~/ui/lib/Badge' import { Button } from '~/ui/lib/Button' import { Checkbox } from '~/ui/lib/Checkbox' import { toComboboxItems } from '~/ui/lib/Combobox' @@ -155,6 +158,7 @@ const baseDefaultValues: InstanceCreateInput = { userData: null, externalIps: [{ type: 'ephemeral' }], + antiAffinityGroups: [], } export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -169,6 +173,9 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { apiQueryClient.prefetchQuery('currentUserSshKeyList', {}), apiQueryClient.prefetchQuery('projectIpPoolList', { query: { limit: ALL_ISH } }), apiQueryClient.prefetchQuery('floatingIpList', { query: { project, limit: ALL_ISH } }), + apiQueryClient.prefetchQuery('antiAffinityGroupList', { + query: { project, limit: ALL_ISH }, + }), ]) return null } @@ -343,6 +350,7 @@ export default function CreateInstanceForm() { networkInterfaces: values.networkInterfaces, sshPublicKeys: values.sshPublicKeys, userData, + antiAffinityGroups: values.antiAffinityGroups, }, }) }} @@ -645,7 +653,13 @@ const AdvancedAccordion = ({ const [openItems, setOpenItems] = useState([]) const [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false) const [selectedFloatingIp, setSelectedFloatingIp] = useState() + const [antiAffinityGroupModalOpen, setAntiAffinityGroupModalOpen] = useState(false) + const [selectedAntiAffinityGroup, setSelectedAntiAffinityGroup] = useState< + AntiAffinityGroup | undefined + >() + const externalIps = useController({ control, name: 'externalIps' }) + const antiAffinityGroups = useController({ control, name: 'antiAffinityGroups' }) const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral') const assignEphemeralIp = !!ephemeralIp const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp.pool : undefined @@ -658,6 +672,9 @@ const AdvancedAccordion = ({ const { data: floatingIpList } = usePrefetchedApiQuery('floatingIpList', { query: { project, limit: ALL_ISH }, }) + const { data: antiAffinityGroupList } = usePrefetchedApiQuery('antiAffinityGroupList', { + query: { project, limit: ALL_ISH }, + }) // Filter out the IPs that are already attached to an instance const attachableFloatingIps = useMemo( @@ -673,6 +690,17 @@ const AdvancedAccordion = ({ .map((ip) => attachableFloatingIps.find((fip) => fip.name === ip.floatingIp)) .filter((ip) => !!ip) + const attachedAntiAffinityGroupNames = antiAffinityGroups.field.value || [] + + const attachedAntiAffinityGroupData = attachedAntiAffinityGroupNames + .map((name) => antiAffinityGroupList.items.find((group) => group.name === name)) + .filter((group) => !!group) + + // Available anti-affinity groups with those already attached removed + const availableAntiAffinityGroups = antiAffinityGroupList.items.filter( + (group) => !attachedAntiAffinityGroupNames.includes(group.name) + ) + const closeFloatingIpModal = () => { setFloatingIpModalOpen(false) setSelectedFloatingIp(undefined) @@ -696,6 +724,27 @@ const AdvancedAccordion = ({ ) } + const closeAntiAffinityGroupModal = () => { + setAntiAffinityGroupModalOpen(false) + setSelectedAntiAffinityGroup(undefined) + } + + const attachAntiAffinityGroup = () => { + if (selectedAntiAffinityGroup) { + antiAffinityGroups.field.onChange([ + ...(antiAffinityGroups.field.value || []), + selectedAntiAffinityGroup.name, + ]) + } + closeAntiAffinityGroupModal() + } + + const detachAntiAffinityGroup = (name: string) => { + antiAffinityGroups.field.onChange( + antiAffinityGroups.field.value?.filter((groupName) => groupName !== name) + ) + } + const isFloatingIpAttached = attachedFloatingIps.some((ip) => ip.floatingIp !== '') const selectedFloatingIpMessage = ( @@ -774,12 +823,12 @@ const AdvancedAccordion = ({ )} -
+

Floating IPs{' '} Floating IPs exist independently of instances and can be attached to and - detached from them as needed. + detached from them as needed

{isFloatingIpAttached && ( @@ -821,7 +870,6 @@ const AdvancedAccordion = ({
+
+ )} + + + + + +
+ ({ + value: group.name, + label: ( +
+
{group.name}
+
+
{group.policy}
+ {group.description && ( + <> + +
+ {group.description} +
+ + )} +
+
+ ), + selectedLabel: group.name, + }))} + label="Group" + onChange={(name) => { + setSelectedAntiAffinityGroup( + availableAntiAffinityGroups.find((group) => group.name === name) + ) + }} + required + placeholder="Select a group" + selected={selectedAntiAffinityGroup?.name || ''} + /> + +
+
+ +
+
) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 4494d66c4..349c39c34 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -591,6 +591,39 @@ export const handlers = makeHandlers({ newInstance.run_state = 'running' }, 4000) + // Add instance to specified anti-affinity groups + if (body.anti_affinity_groups && body.anti_affinity_groups.length > 0) { + for (const groupName of body.anti_affinity_groups) { + try { + const antiAffinityGroup = lookup.antiAffinityGroup({ + project: project.id, + antiAffinityGroup: groupName, + }) + + // Check if instance is already in the group + const alreadyThere = db.antiAffinityGroupMemberLists.some( + (i) => + i.anti_affinity_group_id === antiAffinityGroup.id && + i.anti_affinity_group_member.id === instanceId + ) + + if (!alreadyThere) { + db.antiAffinityGroupMemberLists.push({ + anti_affinity_group_id: antiAffinityGroup.id, + anti_affinity_group_member: { + id: instanceId, + type: 'instance', + }, + ...getTimestamps(), + }) + } + } catch (_e) { + // Silently ignore if group not found - API will handle validation + console.warn(`Anti-affinity group ${groupName} not found, skipping`) + } + } + } + db.instances.push(newInstance) return json(newInstance, { status: 201 }) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 45d757284..e627a8915 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -612,3 +612,40 @@ test('create instance with additional disks', async ({ page }) => { await expectRowVisible(otherDisksTable, { Disk: 'new-disk-1', size: '5 GiB' }) await expectRowVisible(otherDisksTable, { Disk: 'disk-3', size: '6 GiB' }) }) + +test('can add anti-affinity group when creating an instance', async ({ page }) => { + const instanceName = 'anti-affinity-instance' + await page.goto('/projects/mock-project/instances-new') + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectAProjectImage(page, 'image-1') + + // Open the Configuration accordion to expose anti-affinity group controls + await page.getByRole('button', { name: 'Configuration' }).click() + + await page.getByRole('button', { name: 'Add to group' }).click() + + const dialog = page.getByRole('dialog') + await expect(dialog.getByText('Add instance to group')).toBeVisible() + + const groupListbox = dialog.getByRole('button', { name: 'Group' }) + await groupListbox.click() + await page.getByRole('option', { name: 'romulus-remus' }).click() + + await dialog.getByRole('button', { name: 'Add', exact: true }).click() + + // Verify the group appears in the mini-table + const antiAffinityTable = page.getByRole('table') + await expectRowVisible(antiAffinityTable, { Name: 'romulus-remus', Policy: 'fail' }) + + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) + await expectVisible(page, [`h1:has-text("${instanceName}")`]) + await page.getByRole('tab', { name: 'Settings' }).click() + + const ipsTable = page.getByRole('table', { name: 'Anti-affinity groups' }) + await expectRowVisible(ipsTable, { + name: 'romulus-remus', + Policy: 'fail', + }) +})