From a8535b098cb83346ea1a869a8a367e167bafee10 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Apr 2025 11:37:26 -0500 Subject: [PATCH 1/9] affinity group add/remove on instance settings tab --- app/forms/anti-affinity-group-member-add.tsx | 2 +- .../affinity/AntiAffinityGroupPage.tsx | 2 +- .../project/instances/AntiAffinityCard.tsx | 172 ++++++++++++++++-- app/pages/project/instances/SettingsTab.tsx | 13 +- app/table/columns/action-col.tsx | 13 +- test/e2e/anti-affinity.e2e.ts | 56 ++++++ 6 files changed, 237 insertions(+), 21 deletions(-) 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..05445a34c 100644 --- a/app/pages/project/instances/AntiAffinityCard.tsx +++ b/app/pages/project/instances/AntiAffinityCard.tsx @@ -7,10 +7,14 @@ */ 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, + queryClient, + useApiMutation, usePrefetchedQuery, type AffinityGroup, type AntiAffinityGroup, @@ -18,24 +22,37 @@ 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 colHelper = createColumnHelper() const staticCols = [ colHelper.accessor('description', Columns.description), @@ -47,14 +64,66 @@ 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 nonMemberGroups = useMemo( + () => R.differenceWith(allGroups.items, memberGroups.items, (a, b) => a.id === b.id), + [memberGroups, allGroups] + ) + + 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 { data: antiAffinityGroups } = usePrefetchedQuery( - antiAffinityGroupList(instanceSelector) + 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 = useMemo( - () => [ + const antiAffinityCols = useColsWithActions( + [ colHelper.accessor('name', { cell: makeLinkCell((antiAffinityGroup) => pb.antiAffinityGroup({ project, antiAffinityGroup }) @@ -62,21 +131,38 @@ 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(), }) 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 an anti-affinity group to add instance {instance} to. +

+
+ + +
+
+ +
+ ) +} 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..ff496b0fd 100644 --- a/test/e2e/anti-affinity.e2e.ts +++ b/test/e2e/anti-affinity.e2e.ts @@ -165,3 +165,59 @@ 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() + + // add instance to group + await page.getByRole('button', { name: 'Add to group' }).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() +}) From f82d25240d68cfe73cb6acc5040766bace8c8aeb Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 8 Apr 2025 16:40:54 -0500 Subject: [PATCH 2/9] say anti-affinity one fewer time --- app/pages/project/instances/AntiAffinityCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/project/instances/AntiAffinityCard.tsx b/app/pages/project/instances/AntiAffinityCard.tsx index 05445a34c..b6b845696 100644 --- a/app/pages/project/instances/AntiAffinityCard.tsx +++ b/app/pages/project/instances/AntiAffinityCard.tsx @@ -224,8 +224,8 @@ export function AddToGroupModal({ onDismiss, nonMemberGroups }: ModalProps) { -

- Select an anti-affinity group to add instance {instance} to. +

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

Date: Tue, 8 Apr 2025 16:44:34 -0700 Subject: [PATCH 3/9] Prevent adding instance to A-A group when not stopped --- .../project/instances/AntiAffinityCard.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/app/pages/project/instances/AntiAffinityCard.tsx b/app/pages/project/instances/AntiAffinityCard.tsx index b6b845696..4513b1b32 100644 --- a/app/pages/project/instances/AntiAffinityCard.tsx +++ b/app/pages/project/instances/AntiAffinityCard.tsx @@ -53,6 +53,12 @@ export const allAntiAffinityGroups = ({ project }: PP.Project) => 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), @@ -70,6 +76,7 @@ export function AntiAffinityCard() { 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), @@ -148,13 +155,15 @@ export function AntiAffinityCard() {