diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 41827bf95..a549e5cb2 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,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' @@ -155,6 +156,7 @@ const baseDefaultValues: InstanceCreateInput = { userData: null, externalIps: [{ type: 'ephemeral' }], + antiAffinityGroups: [], } export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -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', { + query: { project, limit: ALL_ISH }, + }), ]) return null } @@ -343,6 +348,7 @@ export default function CreateInstanceForm() { networkInterfaces: values.networkInterfaces, sshPublicKeys: values.sshPublicKeys, userData, + antiAffinityGroups: values.antiAffinityGroups, }, }) }} @@ -643,7 +649,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 @@ -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( @@ -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) @@ -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{' '} @@ -767,7 +814,7 @@ const AdvancedAccordion = ({ )} -
+

Floating IPs{' '} @@ -784,7 +831,7 @@ const AdvancedAccordion = ({ />

) : ( -
+ <> item.name} onRemoveItem={(item) => detachFloatingIp(item.name)} removeLabel={(item) => `remove floating IP ${item.name}`} + emptyState={{ + title: 'No floating IPs', + body: 'Attach floating IP', + }} /> - -
+ onSubmit={() => setFloatingIpModalOpen(true)} + onClear={() => + externalIps.field.onChange( + externalIps.field.value?.filter((ip) => ip.type !== 'floating') + ) + } + /> + )} +
+

+ Anti-affinity groups + + Instances in an anti-affinity group will be placed on different sleds when + they start + +

+ {antiAffinityGroupList.items.length === 0 ? ( +
+ } + title="No anti-affinity groups found" + body="Create an anti-affinity group to see it here" + /> +
+ ) : ( + <> + 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', + }} + /> + setAntiAffinityGroupModalOpen(true)} + onClear={() => antiAffinityGroups.field.onChange([])} + /> + + )} + + + + + +
+ ({ + 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/app/ui/lib/EmptyMessage.tsx b/app/ui/lib/EmptyMessage.tsx index 337472ccb..cfa1e94d2 100644 --- a/app/ui/lib/EmptyMessage.tsx +++ b/app/ui/lib/EmptyMessage.tsx @@ -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 diff --git a/app/ui/lib/MiniTable.tsx b/app/ui/lib/MiniTable.tsx index 1fe43040e..a521f074b 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -96,7 +96,7 @@ export const ClearAndAddButtons = ({ onSubmit, }: ClearAndAddButtonsProps) => (
-