Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 162 additions & 14 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
useApiMutation,
useApiQueryClient,
usePrefetchedApiQuery,
type AntiAffinityGroup,
type ExternalIpCreate,
type FloatingIp,
type Image,
Expand All @@ -29,6 +30,7 @@ import {
type SiloIpPool,
} from '@oxide/api'
import {
Affinity16Icon,
Images16Icon,
Instances16Icon,
Instances24Icon,
Expand Down Expand Up @@ -60,14 +62,13 @@ import { FullPageForm } from '~/components/form/FullPageForm'
import { HL } from '~/components/HL'
import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { Button } from '~/ui/lib/Button'
import { Checkbox } from '~/ui/lib/Checkbox'
import { toComboboxItems } from '~/ui/lib/Combobox'
import { FormDivider } from '~/ui/lib/Divider'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { Listbox } from '~/ui/lib/Listbox'
import { Message } from '~/ui/lib/Message'
import { MiniTable } from '~/ui/lib/MiniTable'
import { ClearAndAddButtons, MiniTable } from '~/ui/lib/MiniTable'
import { Modal } from '~/ui/lib/Modal'
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
import { RadioCard } from '~/ui/lib/Radio'
Expand Down Expand Up @@ -155,6 +156,7 @@ const baseDefaultValues: InstanceCreateInput = {

userData: null,
externalIps: [{ type: 'ephemeral' }],
antiAffinityGroups: [],
}

export async function clientLoader({ params }: LoaderFunctionArgs) {
Expand All @@ -169,6 +171,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', {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Number of groups is likely to be low. Curious if there is a threshold where its better to move this stuff after the page is loaded.

query: { project, limit: ALL_ISH },
}),
])
return null
}
Expand Down Expand Up @@ -343,6 +348,7 @@ export default function CreateInstanceForm() {
networkInterfaces: values.networkInterfaces,
sshPublicKeys: values.sshPublicKeys,
userData,
antiAffinityGroups: values.antiAffinityGroups,
},
})
}}
Expand Down Expand Up @@ -643,7 +649,13 @@ const AdvancedAccordion = ({
const [openItems, setOpenItems] = useState<string[]>([])
const [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false)
const [selectedFloatingIp, setSelectedFloatingIp] = useState<FloatingIp | undefined>()
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
Expand All @@ -656,6 +668,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(
Expand All @@ -671,6 +686,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)
Expand All @@ -694,6 +720,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 selectedFloatingIpMessage = (
<>
This instance will be reachable at{' '}
Expand Down Expand Up @@ -767,7 +814,7 @@ const AdvancedAccordion = ({
)}
</div>

<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-1 flex-col gap-2">
<h2 className="text-sans-md flex items-center">
Floating IPs{' '}
<TipIcon className="ml-1.5">
Expand All @@ -784,7 +831,7 @@ const AdvancedAccordion = ({
/>
</div>
) : (
<div className="flex flex-col items-start gap-3">
<>
<MiniTable
ariaLabel="Floating IPs"
items={attachedFloatingIpsData}
Expand All @@ -795,18 +842,22 @@ const AdvancedAccordion = ({
rowKey={(item) => item.name}
onRemoveItem={(item) => detachFloatingIp(item.name)}
removeLabel={(item) => `remove floating IP ${item.name}`}
emptyState={{
title: 'No floating IPs',
body: 'Attach floating IP',
}}
/>
<Button
variant="secondary"
size="sm"
className="shrink-0"
<ClearAndAddButtons
addButtonCopy="Attach floating IP"
disabled={availableFloatingIps.length === 0}
disabledReason="No floating IPs available"
onClick={() => setFloatingIpModalOpen(true)}
>
Attach floating IP
</Button>
</div>
onSubmit={() => setFloatingIpModalOpen(true)}
onClear={() =>
externalIps.field.onChange(
externalIps.field.value?.filter((ip) => ip.type !== 'floating')
)
}
/>
</>
)}
<Modal
isOpen={floatingIpModalOpen}
Expand Down Expand Up @@ -859,6 +910,103 @@ const AdvancedAccordion = ({
control={control}
disabled={isSubmitting}
/>
<div className="flex flex-1 flex-col gap-2">
<h2 className="text-sans-md flex items-center">
Anti-affinity groups
<TipIcon className="ml-1.5">
Instances in an anti-affinity group will be placed on different sleds when
they start
</TipIcon>
</h2>
{antiAffinityGroupList.items.length === 0 ? (
<div className="border-default flex max-w-lg items-center justify-center rounded-lg border p-6">
<EmptyMessage
icon={<Affinity16Icon />}
title="No anti-affinity groups found"
body="Create an anti-affinity group to see it here"
/>
</div>
) : (
<>
<MiniTable
ariaLabel="Anti-affinity groups"
items={attachedAntiAffinityGroupData}
columns={[
{ header: 'Name', cell: (item) => item.name },
{ header: 'Policy', cell: (item) => item.policy },
]}
rowKey={(item) => item.name}
onRemoveItem={(item) => detachAntiAffinityGroup(item.name)}
removeLabel={(item) => `remove anti-affinity group ${item.name}`}
emptyState={{
title: 'No anti-affinity groups',
body: 'Add instance to group',
}}
/>
<ClearAndAddButtons
addButtonCopy="Add to group"
disabled={availableAntiAffinityGroups.length === 0}
onSubmit={() => setAntiAffinityGroupModalOpen(true)}
onClear={() => antiAffinityGroups.field.onChange([])}
/>
</>
)}

<Modal
isOpen={antiAffinityGroupModalOpen}
onDismiss={closeAntiAffinityGroupModal}
title="Add instance to group"
>
<Modal.Body>
<Modal.Section>
<Message
variant="info"
content="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."
/>
<form>
<Listbox
name="antiAffinityGroup"
items={availableAntiAffinityGroups.map((group) => ({
value: group.name,
label: (
<div>
<div>{group.name}</div>
<div className="text-secondary selected:text-accent-secondary flex gap-0.5">
<div>{group.policy}</div>
{group.description && (
<>
<Slash />
<div className="grow overflow-hidden text-left overflow-ellipsis whitespace-pre">
{group.description}
</div>
</>
)}
</div>
</div>
),
selectedLabel: group.name,
}))}
label="Group"
onChange={(name) => {
setSelectedAntiAffinityGroup(
availableAntiAffinityGroups.find((group) => group.name === name)
)
}}
required
placeholder="Select a group"
selected={selectedAntiAffinityGroup?.name || ''}
/>
</form>
</Modal.Section>
</Modal.Body>
<Modal.Footer
actionText="Add"
disabled={!selectedAntiAffinityGroup}
onAction={attachAntiAffinityGroup}
onDismiss={closeAntiAffinityGroupModal}
></Modal.Footer>
</Modal>
</div>
</AccordionItem>
</Accordion.Root>
)
Expand Down
11 changes: 6 additions & 5 deletions app/ui/lib/EmptyMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ import { Button, buttonStyle } from './Button'

const buttonStyleProps = { variant: 'ghost', size: 'sm', color: 'secondary' } as const

export type EmptyStateButtonProps =
| { buttonText: string; buttonTo: string }
| { buttonText: string; onClick: () => void }
| { buttonText?: never }

type Props = {
icon?: ReactElement
title: string
body?: ReactNode
} & ( // only require buttonTo or onClick if buttonText is present
| { buttonText: string; buttonTo: string }
| { buttonText: string; onClick: () => void }
| { buttonText?: never }
)
} & EmptyStateButtonProps

export function EmptyMessage(props: Props) {
let button: ReactElement | null = null
Expand Down
2 changes: 1 addition & 1 deletion app/ui/lib/MiniTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const ClearAndAddButtons = ({
onSubmit,
}: ClearAndAddButtonsProps) => (
<div className="flex justify-end gap-2.5">
<Button variant="ghost" size="sm" onClick={onClear} disabled={disabled}>
<Button variant="ghost" size="sm" onClick={onClear}>
Clear
</Button>
<Button size="sm" onClick={onSubmit} disabled={disabled}>
Expand Down
33 changes: 33 additions & 0 deletions mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,39 @@ export const handlers = makeHandlers({
}, 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 })
Expand Down
37 changes: 37 additions & 0 deletions test/e2e/instance-create.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,43 @@ test('create instance with additional disks', async ({ page }) => {
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',
})
})

test('Validate CPU and RAM', async ({ page }) => {
await page.goto('/projects/mock-project/instances-new')

Expand Down
Loading