diff --git a/app/api/util.ts b/app/api/util.ts index 79a06b87d..fb763d494 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -131,6 +131,10 @@ const instanceActions = { updateNic: ['stopped'], // https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/src/app/instance.rs#L1520-L1522 serialConsole: ['running', 'rebooting', 'migrating', 'repairing'], + + // https://github.com/oxidecomputer/omicron/blob/5e27bde/nexus/src/app/affinity.rs#L357 checks to see that there's no VMM + // TODO: determine whether the intent is only `stopped` or also `failed` + addToAntiAffinityGroup: ['stopped'], } satisfies Record // setting .states is a cute way to make it ergonomic to call the test function diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index 64a6c8b5c..d6fe97e35 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -33,7 +33,7 @@ export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }: onSuccess(_data, variables) { onDismiss() queryClient.invalidateEndpoint('antiAffinityGroupMemberList') - queryClient.invalidateEndpoint('antiAffinityGroupView') + queryClient.invalidateEndpoint('instanceAntiAffinityGroupList') addToast(<>Instance {variables.path.instance} added to anti-affinity group {antiAffinityGroup}) // prettier-ignore }, }) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 92c3a908a..8fca74f4d 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -100,7 +100,7 @@ export default function AntiAffinityPage() { { onSuccess(_data, variables) { queryClient.invalidateEndpoint('antiAffinityGroupMemberList') - queryClient.invalidateEndpoint('antiAffinityGroupView') + queryClient.invalidateEndpoint('instanceAntiAffinityGroupList') addToast(<>Member {variables.path.instance} removed from anti-affinity group {group.name}) // prettier-ignore }, } diff --git a/app/pages/project/instances/AntiAffinityCard.tsx b/app/pages/project/instances/AntiAffinityCard.tsx index 6d4c237a7..fdaa4606c 100644 --- a/app/pages/project/instances/AntiAffinityCard.tsx +++ b/app/pages/project/instances/AntiAffinityCard.tsx @@ -7,10 +7,15 @@ */ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useMemo } from 'react' +import { useCallback, useId, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import * as R from 'remeda' import { apiq, + instanceCan, + queryClient, + useApiMutation, usePrefetchedQuery, type AffinityGroup, type AntiAffinityGroup, @@ -18,24 +23,43 @@ import { import { Affinity24Icon } from '@oxide/design-system/icons/react' import { AffinityPolicyHeader } from '~/components/AffinityDocsPopover' +import { ComboboxField } from '~/components/form/fields/ComboboxField' +import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { AffinityGroupPolicyBadge } from '~/pages/project/affinity/AffinityPage' +import { confirmAction } from '~/stores/confirm-action' +import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' +import { Button } from '~/ui/lib/Button' import { CardBlock } from '~/ui/lib/CardBlock' +import { toComboboxItems } from '~/ui/lib/Combobox' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Modal } from '~/ui/lib/Modal' import { TableEmptyBox } from '~/ui/lib/Table' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' -export const antiAffinityGroupList = ({ project, instance }: PP.Instance) => +export const instanceAntiAffinityGroups = ({ project, instance }: PP.Instance) => apiq('instanceAntiAffinityGroupList', { path: { instance }, query: { project, limit: ALL_ISH }, }) +export const allAntiAffinityGroups = ({ project }: PP.Project) => + apiq('antiAffinityGroupList', { + query: { project, limit: ALL_ISH }, + }) + +const instanceView = ({ project, instance }: PP.Instance) => + apiq('instanceView', { + path: { instance }, + query: { project }, + }) + const colHelper = createColumnHelper() const staticCols = [ colHelper.accessor('description', Columns.description), @@ -47,14 +71,67 @@ const staticCols = [ export function AntiAffinityCard() { const instanceSelector = useInstanceSelector() - const { project } = instanceSelector + const { project, instance } = instanceSelector + + const { data: memberGroups } = usePrefetchedQuery( + instanceAntiAffinityGroups(instanceSelector) + ) + const { data: allGroups } = usePrefetchedQuery(allAntiAffinityGroups(instanceSelector)) + const { data: instanceData } = usePrefetchedQuery(instanceView(instanceSelector)) + + const nonMemberGroups = useMemo( + () => R.differenceWith(allGroups.items, memberGroups.items, (a, b) => a.id === b.id), + [memberGroups, allGroups] + ) - const { data: antiAffinityGroups } = usePrefetchedQuery( - antiAffinityGroupList(instanceSelector) + const { mutateAsync: removeMember } = useApiMutation( + 'antiAffinityGroupMemberInstanceDelete', + { + onSuccess(_data, variables) { + addToast( + <> + Instance {variables.path.instance} removed from anti-affinity group{' '} + {variables.path.antiAffinityGroup} + + ) + queryClient.invalidateEndpoint('instanceAntiAffinityGroupList') + queryClient.invalidateEndpoint('antiAffinityGroupMemberList') + }, + } ) - const antiAffinityCols = useMemo( - () => [ + const makeActions = useCallback( + (group: AffinityGroup | AntiAffinityGroup): MenuAction[] => [ + { + label: 'Remove instance from group', + onActivate() { + confirmAction({ + actionType: 'danger', + doAction: () => + removeMember({ + path: { + antiAffinityGroup: group.name, + instance, + }, + query: { project }, + }), + modalTitle: 'Remove instance from group', + modalContent: ( +

+ Are you sure you want to remove instance {instance} from group{' '} + {group.name}? +

+ ), + errorTitle: 'Error removing instance from group', + }) + }, + }, + ], + [instance, project, removeMember] + ) + + const antiAffinityCols = useColsWithActions( + [ colHelper.accessor('name', { cell: makeLinkCell((antiAffinityGroup) => pb.antiAffinityGroup({ project, antiAffinityGroup }) @@ -62,21 +139,42 @@ export function AntiAffinityCard() { }), ...staticCols, ], - [project] + makeActions, + 'Copy group ID' ) - // Create tables for both types of groups + const [isModalOpen, setIsModalOpen] = useState(false) + const antiAffinityTable = useReactTable({ columns: antiAffinityCols, - data: antiAffinityGroups.items, + data: memberGroups.items, getCoreRowModel: getCoreRowModel(), }) + let disabledReason = undefined + if (!instanceCan.addToAntiAffinityGroup(instanceData)) { + disabledReason = + <>Only stopped instances can be added to a group // prettier-ignore + } else if (allGroups.items.length === 0) { + disabledReason = 'No groups found' + } else if (nonMemberGroups.length === 0) { + disabledReason = 'Instance is already in all groups' + } + return ( - + + + - {antiAffinityGroups.items.length > 0 ? ( + {memberGroups.items.length > 0 ? ( )} + {isModalOpen && ( + setIsModalOpen(false)} + nonMemberGroups={nonMemberGroups} + /> + )} ) } + +type ModalProps = { + onDismiss: () => void + nonMemberGroups: (AffinityGroup | AntiAffinityGroup)[] +} + +export function AddToGroupModal({ onDismiss, nonMemberGroups }: ModalProps) { + const { project, instance } = useInstanceSelector() + + const form = useForm({ defaultValues: { group: '' } }) + const formId = useId() + + const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', { + onSuccess(_data, variables) { + onDismiss() + queryClient.invalidateEndpoint('antiAffinityGroupMemberList') + queryClient.invalidateEndpoint('instanceAntiAffinityGroupList') + addToast( + <> + Instance {instance} added to anti-affinity group{' '} + {variables.path.antiAffinityGroup} + + ) + }, + }) + + const handleSubmit = form.handleSubmit(({ group }) => { + addMember({ + path: { antiAffinityGroup: group, instance }, + query: { project }, + }) + }) + + return ( + + + +

+ Select a group to add instance {instance} to. +

+
+ + +
+
+ +
+ ) +} diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index de2b8326e..845422662 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -455,7 +455,7 @@ export default function NetworkingTab() { disabledReason={ <> A network interface cannot be created or edited unless the instance is{' '} - {updateNicStates}. + {updateNicStates} } > diff --git a/app/pages/project/instances/SettingsTab.tsx b/app/pages/project/instances/SettingsTab.tsx index 97de07931..1a8515110 100644 --- a/app/pages/project/instances/SettingsTab.tsx +++ b/app/pages/project/instances/SettingsTab.tsx @@ -12,14 +12,21 @@ import { queryClient } from '@oxide/api' import { getInstanceSelector } from '~/hooks/use-params' -import { AntiAffinityCard, antiAffinityGroupList } from './AntiAffinityCard' +import { + allAntiAffinityGroups, + AntiAffinityCard, + instanceAntiAffinityGroups, +} from './AntiAffinityCard' import { AutoRestartCard } from './AutoRestartCard' export const handle = { crumb: 'Settings' } export async function clientLoader({ params }: LoaderFunctionArgs) { - const instanceSelector = getInstanceSelector(params) - await queryClient.prefetchQuery(antiAffinityGroupList(instanceSelector)) + const { project, instance } = getInstanceSelector(params) + await Promise.all([ + queryClient.prefetchQuery(instanceAntiAffinityGroups({ project, instance })), + queryClient.prefetchQuery(allAntiAffinityGroups({ project })), + ]) return null } diff --git a/app/table/columns/action-col.tsx b/app/table/columns/action-col.tsx index 6d2aab367..a782ec05f 100644 --- a/app/table/columns/action-col.tsx +++ b/app/table/columns/action-col.tsx @@ -42,13 +42,18 @@ export function useColsWithActions>( /** Should be static or memoized */ columns: ColumnDef[], // eslint-disable-line @typescript-eslint/no-explicit-any /** Must be memoized to avoid re-renders */ - makeActions: MakeActions + makeActions: MakeActions, + copyIdLabel?: string ) { - return useMemo(() => [...columns, getActionsCol(makeActions)], [columns, makeActions]) + return useMemo( + () => [...columns, getActionsCol(makeActions, copyIdLabel)], + [columns, makeActions, copyIdLabel] + ) } export const getActionsCol = >( - makeActions: MakeActions + makeActions: MakeActions, + copyIdLabel?: string ): ColumnDef => { return { id: 'menu', @@ -62,7 +67,7 @@ export const getActionsCol = >( // TODO: control flow here has always confused me, would like to straighten it out const actions = makeActions(row.original) const id = typeof row.original.id === 'string' ? row.original.id : null - return + return }, } } diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts index 36c7bb3f5..7e2a62388 100644 --- a/test/e2e/anti-affinity.e2e.ts +++ b/test/e2e/anti-affinity.e2e.ts @@ -165,3 +165,70 @@ test('can delete anti-affinity group from detail page', async ({ page }) => { await expectRowVisible(page.getByRole('table'), { name: 'set-osiris' }) await expect(page.getByRole('cell', { name: 'romulus-remus' })).toBeHidden() }) + +test('add and remove instance from group on instance settings', async ({ page }) => { + const groupName = 'oil-water' + + // Go to instance settings + await page.goto('/projects/mock-project/instances/db1/settings') + + // Locate the Anti-affinity card and the table within it + const groupsTable = page.getByRole('table', { name: 'Anti-affinity groups' }) + + const groupCell = groupsTable.getByRole('cell', { name: groupName }) + + // Ensure the group is not initially present + await expect(groupCell).toBeHidden() + + // Make sure Add to group button is disabled + const addToGroupButton = page.getByRole('button', { name: 'Add to group' }) + await expect(addToGroupButton).toBeDisabled() + + // Stop the instance + await page.getByRole('button', { name: 'Stop' }).click() + const confirmStopModal = page.getByRole('dialog', { name: 'Confirm stop' }) + await expect(confirmStopModal).toBeVisible() + await confirmStopModal.getByRole('button', { name: 'Confirm' }).click() + await expect(confirmStopModal).toBeHidden() + + // Add instance to group + await addToGroupButton.click() + const modal = page.getByRole('dialog', { name: 'Add to anti-affinity group' }) + await expect(modal).toBeVisible() + await modal.getByRole('combobox', { name: 'Anti-affinity group' }).click() + await page.getByRole('option', { name: groupName }).click() + await modal.getByRole('button', { name: 'Add to group' }).click() + await expect(modal).toBeHidden() + await closeToast(page) + + // Group appears in table + await expect(groupCell).toBeVisible() + + // Go to the group page + await page.getByRole('link', { name: groupName }).click() + await expect(page.getByRole('heading', { name: groupName })).toBeVisible() + const groupTable = page.getByRole('table') + + // Instance is listed in the group members table + await expectRowVisible(groupTable, { name: 'db1' }) + + // Go back to instance settings + await page.getByRole('link', { name: 'db1' }).click() + + // Remove instance from group using row action + await clickRowAction(page, groupName, 'Remove instance from group') + const confirmModal = page.getByRole('dialog', { name: 'Remove instance from group' }) + await expect(confirmModal).toBeVisible() + await confirmModal.getByRole('button', { name: 'Confirm' }).click() + await expect(confirmModal).toBeHidden() + await closeToast(page) + + // Group is no longer in table + await expect(groupCell).toBeHidden() + + // Instance is gone from group members table + await page.goto('/projects/mock-project/affinity/oil-water') + await expect(page.getByRole('heading', { name: groupName })).toBeVisible() + await expect(page.getByRole('cell', { name: 'db1' })).toBeHidden() + await expect(page.getByText('No group members')).toBeVisible() +})