From 32d18ad00a43ac6743ba430f43d2463b43a4f45c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 27 Mar 2025 14:20:48 -0700 Subject: [PATCH 01/56] Enable removal of group member --- .../affinity/AntiAffinityGroupPage.tsx | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index ee043715d8..b8645a44eb 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import type { LoaderFunctionArgs } from 'react-router' import { Affinity24Icon } from '@oxide/design-system/icons/react' @@ -16,15 +16,20 @@ import { apiq, getListQFn, queryClient, + useApiMutation, usePrefetchedQuery, type AntiAffinityGroupMember, } from '~/api' +import { HL } from '~/components/HL' import { makeCrumb } from '~/hooks/use-crumbs' import { getAntiAffinityGroupSelector, useAntiAffinityGroupSelector, } from '~/hooks/use-params' +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 { Badge } from '~/ui/lib/Badge' @@ -84,7 +89,7 @@ export default function AntiAffinityPage() { memberList({ antiAffinityGroup, project }).optionsFn() ) const membersCount = members.items.length - const columns = useMemo( + const cols = useMemo( () => [ colHelper.accessor('value.name', { header: 'Name', @@ -95,6 +100,57 @@ export default function AntiAffinityPage() { [project] ) + const { mutateAsync: removeMember } = useApiMutation( + 'antiAffinityGroupMemberInstanceDelete', + { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('antiAffinityGroupMemberList') + queryClient.invalidateEndpoint('antiAffinityGroupView') + addToast(<>Member {variables.path.instance} removed from anti-affinity group {group.name}) // prettier-ignore + }, + } + ) + + const makeActions = useCallback( + (antiAffinityGroupMember: AntiAffinityGroupMember): MenuAction[] => [ + { + label: 'Copy instance ID', + onActivate() { + navigator.clipboard.writeText(antiAffinityGroupMember.value.id) + addToast('ID copied to clipboard') + }, + }, + { + label: 'Remove from group', + onActivate() { + confirmAction({ + actionType: 'danger', + doAction: () => + removeMember({ + path: { + antiAffinityGroup: antiAffinityGroup, + instance: antiAffinityGroupMember.value.name, + }, + query: { project }, + }), + modalTitle: 'Remove instance from anti-affinity group', + modalContent: ( +

+ Are you sure you want to remove{' '} + {antiAffinityGroupMember.value.name} from the anti-affinity group{' '} + {antiAffinityGroup}? +

+ ), + errorTitle: `Error removing ${antiAffinityGroupMember.value.name}`, + }) + }, + }, + ], + [project, removeMember, antiAffinityGroup] + ) + + const columns = useColsWithActions(cols, makeActions) + const table = useReactTable({ columns, data: members.items, From 429eda6535edc8f9837bb6a382a0e8a7a57293e6 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 27 Mar 2025 15:00:31 -0700 Subject: [PATCH 02/56] Enable deletion of group --- app/pages/project/affinity/AffinityPage.tsx | 40 +++++++++++++++++++-- mock-api/msw/handlers.ts | 11 +++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 4692233b17..d753e4e8dc 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -8,22 +8,27 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { Link, type LoaderFunctionArgs } from 'react-router' import { apiq, getListQFn, queryClient, + useApiMutation, usePrefetchedQuery, type AffinityPolicy, type AntiAffinityGroup, } from '@oxide/api' import { Affinity24Icon } from '@oxide/design-system/icons/react' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' 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 { Badge } from '~/ui/lib/Badge' @@ -98,7 +103,7 @@ const staticCols = [ export default function AffinityPage() { const { project } = useProjectSelector() - const columns = useMemo( + const cols = useMemo( () => [ colHelper.accessor('name', { cell: makeLinkCell((antiAffinityGroup) => @@ -112,6 +117,37 @@ export default function AffinityPage() { ) const { data } = usePrefetchedQuery(antiAffinityGroupList({ project }).optionsFn()) + const { mutateAsync: deleteGroup } = useApiMutation('antiAffinityGroupDelete', { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('antiAffinityGroupList') + queryClient.invalidateEndpoint('antiAffinityGroupMemberList') + addToast( + <> + Anti-affinity group {variables.path.antiAffinityGroup} deleted + + ) + }, + }) + + const makeActions = useCallback( + (antiAffinityGroup: AntiAffinityGroup): MenuAction[] => [ + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + deleteGroup({ + path: { antiAffinityGroup: antiAffinityGroup.name }, + query: { project }, + }), + label: antiAffinityGroup.name, + }), + }, + ], + [project, deleteGroup] + ) + + const columns = useColsWithActions(cols, makeActions) + const table = useReactTable({ columns, data: data.items, diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 04be2d35f1..2e7fcccfe7 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1637,6 +1637,16 @@ export const handlers = makeHandlers({ }, antiAffinityGroupView: ({ path, query }) => lookup.antiAffinityGroup({ ...path, ...query }), + antiAffinityGroupDelete: ({ path, query }) => { + const antiAffinityGroup = lookup.antiAffinityGroup({ ...path, ...query }) + db.antiAffinityGroups = db.antiAffinityGroups.filter( + (i) => + !( + i.name === antiAffinityGroup.name && i.project_id === antiAffinityGroup.project_id + ) + ) + return 204 + }, antiAffinityGroupMemberList: ({ path, query }) => { const antiAffinityGroup = lookup.antiAffinityGroup({ ...path, ...query }) const members: Json[] = db.antiAffinityGroupMemberLists @@ -1672,7 +1682,6 @@ export const handlers = makeHandlers({ affinityGroupMemberInstanceView: NotImplemented, affinityGroupUpdate: NotImplemented, antiAffinityGroupCreate: NotImplemented, - antiAffinityGroupDelete: NotImplemented, antiAffinityGroupMemberInstanceAdd: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, antiAffinityGroupUpdate: NotImplemented, From eb387a02e8cda30937ab624ffc774110b912411d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 27 Mar 2025 16:33:14 -0700 Subject: [PATCH 03/56] move away from useMemo for columns --- app/pages/project/affinity/AffinityPage.tsx | 27 +++++++++---------- .../affinity/AntiAffinityGroupPage.tsx | 23 ++++++++-------- 2 files changed, 24 insertions(+), 26 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index d753e4e8dc..0c67039ef3 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import { Link, type LoaderFunctionArgs } from 'react-router' import { @@ -103,18 +103,6 @@ const staticCols = [ export default function AffinityPage() { const { project } = useProjectSelector() - const cols = useMemo( - () => [ - colHelper.accessor('name', { - cell: makeLinkCell((antiAffinityGroup) => - pb.antiAffinityGroup({ project, antiAffinityGroup }) - ), - id: 'members', - }), - ...staticCols, - ], - [project] - ) const { data } = usePrefetchedQuery(antiAffinityGroupList({ project }).optionsFn()) const { mutateAsync: deleteGroup } = useApiMutation('antiAffinityGroupDelete', { @@ -146,7 +134,18 @@ export default function AffinityPage() { [project, deleteGroup] ) - const columns = useColsWithActions(cols, makeActions) + const columns = useColsWithActions( + [ + colHelper.accessor('name', { + cell: makeLinkCell((antiAffinityGroup) => + pb.antiAffinityGroup({ project, antiAffinityGroup }) + ), + id: 'members', + }), + ...staticCols, + ], + makeActions + ) const table = useReactTable({ columns, diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index b8645a44eb..ece9e45c74 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useCallback, useMemo } from 'react' +import { useCallback } from 'react' import type { LoaderFunctionArgs } from 'react-router' import { Affinity24Icon } from '@oxide/design-system/icons/react' @@ -89,16 +89,6 @@ export default function AntiAffinityPage() { memberList({ antiAffinityGroup, project }).optionsFn() ) const membersCount = members.items.length - const cols = useMemo( - () => [ - colHelper.accessor('value.name', { - header: 'Name', - cell: makeLinkCell((instance) => pb.instance({ project, instance })), - }), - colHelper.accessor('value.runState', Columns.instanceState), - ], - [project] - ) const { mutateAsync: removeMember } = useApiMutation( 'antiAffinityGroupMemberInstanceDelete', @@ -149,7 +139,16 @@ export default function AntiAffinityPage() { [project, removeMember, antiAffinityGroup] ) - const columns = useColsWithActions(cols, makeActions) + const columns = useColsWithActions( + [ + colHelper.accessor('value.name', { + header: 'Name', + cell: makeLinkCell((instance) => pb.instance({ project, instance })), + }), + colHelper.accessor('value.runState', Columns.instanceState), + ], + makeActions + ) const table = useReactTable({ columns, From 16795fc2bf96e3b65edfbc8ed5b3c9b74ea8a069 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 27 Mar 2025 16:46:19 -0700 Subject: [PATCH 04/56] Update copy in remove confirm modal --- .../project/affinity/AntiAffinityGroupPage.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index ece9e45c74..25f9410b8b 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -125,11 +125,17 @@ export default function AntiAffinityPage() { }), modalTitle: 'Remove instance from anti-affinity group', modalContent: ( -

- Are you sure you want to remove{' '} - {antiAffinityGroupMember.value.name} from the anti-affinity group{' '} - {antiAffinityGroup}? -

+ <> +

+ Are you sure you want to remove{' '} + {antiAffinityGroupMember.value.name} from the anti-affinity group{' '} + {antiAffinityGroup}? +

+

+ Future placement of this instance will not attempt to satisfy the affinity + rules. +

+ ), errorTitle: `Error removing ${antiAffinityGroupMember.value.name}`, }) From 3b2db66d1bbd727226764c89b1fdc9a383caa038 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 27 Mar 2025 20:34:57 -0700 Subject: [PATCH 05/56] Update copy in delete modal --- app/pages/project/affinity/AffinityPage.tsx | 35 +++++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 0c67039ef3..01f5a363e1 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -24,7 +24,7 @@ import { Affinity24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' -import { confirmDelete } from '~/stores/confirm-delete' +import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -108,7 +108,6 @@ export default function AffinityPage() { const { mutateAsync: deleteGroup } = useApiMutation('antiAffinityGroupDelete', { onSuccess(_data, variables) { queryClient.invalidateEndpoint('antiAffinityGroupList') - queryClient.invalidateEndpoint('antiAffinityGroupMemberList') addToast( <> Anti-affinity group {variables.path.antiAffinityGroup} deleted @@ -121,14 +120,30 @@ export default function AffinityPage() { (antiAffinityGroup: AntiAffinityGroup): MenuAction[] => [ { label: 'Delete', - onActivate: confirmDelete({ - doDelete: () => - deleteGroup({ - path: { antiAffinityGroup: antiAffinityGroup.name }, - query: { project }, - }), - label: antiAffinityGroup.name, - }), + onActivate() { + confirmAction({ + actionType: 'danger', + doAction: () => + deleteGroup({ + path: { antiAffinityGroup: antiAffinityGroup.name }, + query: { project }, + }), + modalTitle: 'Delete anti-affinity group', + modalContent: ( + <> +

+ Are you sure you want to delete the anti-affinity group{' '} + {antiAffinityGroup.name}? +

+

+ Future placement of the affinity group’s members will not attempt to + satisfy the affinity rules. +

+ + ), + errorTitle: `Error removing ${antiAffinityGroup.name}`, + }) + }, }, ], [project, deleteGroup] From 58e6e6b10b8a4ef4ffa46745f939065c85a55d67 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 28 Mar 2025 17:05:07 -0500 Subject: [PATCH 06/56] use apiq since we're not paginating --- app/pages/project/affinity/AffinityPage.tsx | 9 +++------ app/pages/project/affinity/AntiAffinityGroupPage.tsx | 9 +++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 01f5a363e1..db8ab826a9 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -13,7 +13,6 @@ import { Link, type LoaderFunctionArgs } from 'react-router' import { apiq, - getListQFn, queryClient, useApiMutation, usePrefetchedQuery, @@ -42,7 +41,7 @@ import { pb } from '~/util/path-builder' import type * as PP from '~/util/path-params' const antiAffinityGroupList = ({ project }: PP.Project) => - getListQFn('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) + apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) const memberList = ({ antiAffinityGroup, project }: PP.AntiAffinityGroup) => apiq('antiAffinityGroupMemberList', { path: { antiAffinityGroup }, @@ -52,9 +51,7 @@ const memberList = ({ antiAffinityGroup, project }: PP.AntiAffinityGroup) => export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) - const groups = await queryClient.fetchQuery( - antiAffinityGroupList({ project }).optionsFn() - ) + const groups = await queryClient.fetchQuery(antiAffinityGroupList({ project })) const memberFetches = groups.items.map(({ name }) => queryClient.prefetchQuery(memberList({ antiAffinityGroup: name, project })) ) @@ -103,7 +100,7 @@ const staticCols = [ export default function AffinityPage() { const { project } = useProjectSelector() - const { data } = usePrefetchedQuery(antiAffinityGroupList({ project }).optionsFn()) + const { data } = usePrefetchedQuery(antiAffinityGroupList({ project })) const { mutateAsync: deleteGroup } = useApiMutation('antiAffinityGroupDelete', { onSuccess(_data, variables) { diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 25f9410b8b..8976438601 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -14,7 +14,6 @@ import { Affinity24Icon } from '@oxide/design-system/icons/react' import { apiq, - getListQFn, queryClient, useApiMutation, usePrefetchedQuery, @@ -54,7 +53,7 @@ const colHelper = createColumnHelper() const antiAffinityGroupView = ({ antiAffinityGroup, project }: PP.AntiAffinityGroup) => apiq('antiAffinityGroupView', { path: { antiAffinityGroup }, query: { project } }) const memberList = ({ antiAffinityGroup, project }: PP.AntiAffinityGroup) => - getListQFn('antiAffinityGroupMemberList', { + apiq('antiAffinityGroupMemberList', { path: { antiAffinityGroup }, // member limit in DB is currently 32, so pagination isn't needed query: { project, limit: ALL_ISH }, @@ -64,7 +63,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const { antiAffinityGroup, project } = getAntiAffinityGroupSelector(params) await Promise.all([ queryClient.fetchQuery(antiAffinityGroupView({ antiAffinityGroup, project })), - queryClient.fetchQuery(memberList({ antiAffinityGroup, project }).optionsFn()), + queryClient.fetchQuery(memberList({ antiAffinityGroup, project })), ]) return null } @@ -85,9 +84,7 @@ export default function AntiAffinityPage() { antiAffinityGroupView({ antiAffinityGroup, project }) ) const { id, name, description, policy, timeCreated } = group - const { data: members } = usePrefetchedQuery( - memberList({ antiAffinityGroup, project }).optionsFn() - ) + const { data: members } = usePrefetchedQuery(memberList({ antiAffinityGroup, project })) const membersCount = members.items.length const { mutateAsync: removeMember } = useApiMutation( From d42ad1861e6351c1255785937eb9927f94e2354e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 28 Mar 2025 17:06:59 -0500 Subject: [PATCH 07/56] use the id for delete --- mock-api/msw/handlers.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 2e7fcccfe7..e0600c95a5 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1638,13 +1638,8 @@ export const handlers = makeHandlers({ antiAffinityGroupView: ({ path, query }) => lookup.antiAffinityGroup({ ...path, ...query }), antiAffinityGroupDelete: ({ path, query }) => { - const antiAffinityGroup = lookup.antiAffinityGroup({ ...path, ...query }) - db.antiAffinityGroups = db.antiAffinityGroups.filter( - (i) => - !( - i.name === antiAffinityGroup.name && i.project_id === antiAffinityGroup.project_id - ) - ) + const group = lookup.antiAffinityGroup({ ...path, ...query }) + db.antiAffinityGroups = db.antiAffinityGroups.filter((i) => i.id !== group.id) return 204 }, antiAffinityGroupMemberList: ({ path, query }) => { From 067f9ffdce547d6bd208124499ff3c9ee27478a2 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 28 Mar 2025 15:08:46 -0700 Subject: [PATCH 08/56] merged main and reconciling diffs --- app/pages/project/affinity/AffinityPage.tsx | 14 ++++---------- .../project/affinity/AntiAffinityGroupPage.tsx | 17 +++++------------ 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index db8ab826a9..ceb19ab0ed 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -127,16 +127,10 @@ export default function AffinityPage() { }), modalTitle: 'Delete anti-affinity group', modalContent: ( - <> -

- Are you sure you want to delete the anti-affinity group{' '} - {antiAffinityGroup.name}? -

-

- Future placement of the affinity group’s members will not attempt to - satisfy the affinity rules. -

- +

+ Are you sure you want to delete the anti-affinity group{' '} + {antiAffinityGroup.name}? +

), errorTitle: `Error removing ${antiAffinityGroup.name}`, }) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 8976438601..4e259aaeba 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -104,7 +104,6 @@ export default function AntiAffinityPage() { label: 'Copy instance ID', onActivate() { navigator.clipboard.writeText(antiAffinityGroupMember.value.id) - addToast('ID copied to clipboard') }, }, { @@ -122,17 +121,11 @@ export default function AntiAffinityPage() { }), modalTitle: 'Remove instance from anti-affinity group', modalContent: ( - <> -

- Are you sure you want to remove{' '} - {antiAffinityGroupMember.value.name} from the anti-affinity group{' '} - {antiAffinityGroup}? -

-

- Future placement of this instance will not attempt to satisfy the affinity - rules. -

- +

+ Are you sure you want to remove{' '} + {antiAffinityGroupMember.value.name} from the anti-affinity group{' '} + {antiAffinityGroup}? +

), errorTitle: `Error removing ${antiAffinityGroupMember.value.name}`, }) From 6e2249cdd393f813263bc3fca03b3aee8cbfd2e2 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 1 Apr 2025 13:30:55 -0700 Subject: [PATCH 09/56] Add new anti-affinity group --- app/forms/anti-affinity-group-create.tsx | 124 ++++++++++++++++++++ app/pages/project/affinity/AffinityPage.tsx | 21 +++- app/routes.tsx | 10 ++ app/util/path-builder.ts | 1 + mock-api/msw/handlers.ts | 34 +++++- 5 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 app/forms/anti-affinity-group-create.tsx diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx new file mode 100644 index 0000000000..95bf07e2f9 --- /dev/null +++ b/app/forms/anti-affinity-group-create.tsx @@ -0,0 +1,124 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' +import { useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { + apiq, + queryClient, + useApiMutation, + usePrefetchedQuery, + type AffinityPolicy, +} from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { RadioField } from '~/components/form/fields/RadioField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +export const handle = titleCrumb('New anti-affinity group') + +type AntiAffinityGroupFormValues = { + name: string + description: string + policy: AffinityPolicy + affinityGroupMembers: string[] +} + +const affinityGroupList = ({ project }: PP.Project) => + apiq('affinityGroupList', { query: { project, limit: ALL_ISH } }) +const antiAffinityGroupList = ({ project }: PP.Project) => + apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { project } = getProjectSelector(params) + await Promise.all([ + queryClient.prefetchQuery(antiAffinityGroupList({ project })), + queryClient.prefetchQuery(affinityGroupList({ project })), + ]) + return null +} + +export default function CreateAntiAffintyGroupForm() { + const { project } = useProjectSelector() + + const navigate = useNavigate() + const onDismiss = () => navigate(pb.affinity({ project })) + + const createAntiAffinityGroup = useApiMutation('antiAffinityGroupCreate', { + onSuccess(antiAffinityGroup) { + navigate(pb.antiAffinityGroup({ project, antiAffinityGroup: antiAffinityGroup.name })) + addToast(<>Anti-affinity group {antiAffinityGroup.name} created) // prettier-ignore + queryClient.invalidateQueries(antiAffinityGroupList({ project })) + }, + }) + + const { + data: { items: existingAntiAffinityGroups }, + } = usePrefetchedQuery(antiAffinityGroupList({ project })) + const defaultValues: AntiAffinityGroupFormValues = { + name: '', + description: '', + policy: 'allow', + affinityGroupMembers: [], + } + const form = useForm({ defaultValues }) + const control = form.control + + return ( + { + createAntiAffinityGroup.mutate({ + query: { project }, + body: { + name: values.name, + description: values.description, + policy: values.policy, + failureDomain: 'sled', + }, + }) + }} + loading={createAntiAffinityGroup.isPending} + submitError={createAntiAffinityGroup.error} + submitLabel="Add group" + > + { + if (existingAntiAffinityGroups.find((g) => g.name === name)) { + return 'Name taken. To update an existing group, edit it directly.' + } + }} + /> + + + + + ) +} diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index ceb19ab0ed..8dfe773b47 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback } from 'react' -import { Link, type LoaderFunctionArgs } from 'react-router' +import { Link, Outlet, type LoaderFunctionArgs } from 'react-router' import { apiq, @@ -31,10 +31,11 @@ import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' +import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { Slash } from '~/ui/lib/Slash' -import { TableEmptyBox } from '~/ui/lib/Table' +import { TableActions, TableEmptyBox } from '~/ui/lib/Table' import { intersperse } from '~/util/array' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' @@ -100,7 +101,9 @@ const staticCols = [ export default function AffinityPage() { const { project } = useProjectSelector() - const { data } = usePrefetchedQuery(antiAffinityGroupList({ project })) + const { + data: { items: antiAffinityGroups }, + } = usePrefetchedQuery(antiAffinityGroupList({ project })) const { mutateAsync: deleteGroup } = useApiMutation('antiAffinityGroupDelete', { onSuccess(_data, variables) { @@ -155,14 +158,22 @@ export default function AffinityPage() { const table = useReactTable({ columns, - data: data.items, + data: antiAffinityGroups, getCoreRowModel: getCoreRowModel(), }) return ( <> - {data.items.length ? : } + + New anti-affinity group + + {antiAffinityGroups.length ? ( +
+ ) : ( + + )} + ) } diff --git a/app/routes.tsx b/app/routes.tsx index d3bd20531a..740e2fe205 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -500,6 +500,16 @@ export const routes = createRoutesFromElements( path="access" lazy={() => import('./pages/project/access/ProjectAccessPage').then(convert)} /> + import('./pages/project/affinity/AffinityPage').then(convert)} + element={null} + > + + import('./forms/anti-affinity-group-create').then(convert)} + /> + `${projectBase(params)}/affinity`, + affinityNew: (params: PP.Project) => `${projectBase(params)}/affinity-new`, antiAffinityGroup: (params: PP.AntiAffinityGroup) => `${pb.affinity(params)}/${params.antiAffinityGroup}`, diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index e0600c95a5..cb672f729b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1628,6 +1628,38 @@ export const handlers = makeHandlers({ }) return { items: members } }, + antiAffinityGroupCreate(params) { + const project = lookup.project(params.query) + errIfExists(db.antiAffinityGroups, { name: params.body.name, project_id: project.id }) + + const newAntiAffinityGroup: Json = { + id: uuid(), + project_id: project.id, + ...params.body, + ...getTimestamps(), + } + db.antiAffinityGroups.push(newAntiAffinityGroup) + + return json(newAntiAffinityGroup, { status: 201 }) + }, + antiAffinityGroupUpdate({ body, path, query }) { + const antiAffinityGroup = lookup.antiAffinityGroup({ ...path, ...query }) + if (body.name) { + // Error if changing the group name and that group name already exists + if (body.name !== antiAffinityGroup.name) { + errIfExists(db.antiAffinityGroups, { + project_id: antiAffinityGroup.project_id, + name: body.name, + }) + } + antiAffinityGroup.name = body.name + } + updateDesc(antiAffinityGroup, body) + if (body.description) { + antiAffinityGroup.description = body.description + } + return antiAffinityGroup + }, antiAffinityGroupList: ({ query }) => { const project = lookup.project({ ...query }) const antiAffinityGroups = db.antiAffinityGroups.filter( @@ -1676,10 +1708,8 @@ export const handlers = makeHandlers({ affinityGroupMemberInstanceDelete: NotImplemented, affinityGroupMemberInstanceView: NotImplemented, affinityGroupUpdate: NotImplemented, - antiAffinityGroupCreate: NotImplemented, antiAffinityGroupMemberInstanceAdd: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, - antiAffinityGroupUpdate: NotImplemented, certificateCreate: NotImplemented, certificateDelete: NotImplemented, certificateList: NotImplemented, From ffd7cb079d08a94bc7174d315f015bb518029ee7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 1 Apr 2025 13:40:18 -0700 Subject: [PATCH 10/56] =?UTF-8?q?ah=20=E2=80=A6=C2=A0crumbs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routes.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/routes.tsx b/app/routes.tsx index 740e2fe205..f9b5b20a83 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -502,9 +502,8 @@ export const routes = createRoutesFromElements( /> import('./pages/project/affinity/AffinityPage').then(convert)} - element={null} + handle={{ crumb: 'Affinity' }} > - import('./forms/anti-affinity-group-create').then(convert)} From 7456008cf4e9d728b2173ed58afb14bc274ca37d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 1 Apr 2025 14:32:18 -0700 Subject: [PATCH 11/56] Edit anti-affinity group is working --- app/forms/anti-affinity-group-edit.tsx | 114 ++++++++++++++++++ app/pages/project/affinity/AffinityPage.tsx | 49 ++++++-- .../affinity/AntiAffinityGroupPage.tsx | 3 +- app/routes.tsx | 7 +- app/util/path-builder.ts | 2 + 5 files changed, 163 insertions(+), 12 deletions(-) create mode 100644 app/forms/anti-affinity-group-edit.tsx diff --git a/app/forms/anti-affinity-group-edit.tsx b/app/forms/anti-affinity-group-edit.tsx new file mode 100644 index 0000000000..673e2c2992 --- /dev/null +++ b/app/forms/anti-affinity-group-edit.tsx @@ -0,0 +1,114 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' +import { useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { + apiq, + queryClient, + useApiMutation, + usePrefetchedQuery, + type AntiAffinityGroupUpdate, +} from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { + getAntiAffinityGroupSelector, + useAntiAffinityGroupSelector, +} from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +export const handle = titleCrumb('New anti-affinity group') + +const affinityGroupList = ({ project }: PP.Project) => + apiq('affinityGroupList', { query: { project, limit: ALL_ISH } }) +const antiAffinityGroupList = ({ project }: PP.Project) => + apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) +const antiAffinityGroupView = ({ project, antiAffinityGroup }: PP.AntiAffinityGroup) => + apiq('antiAffinityGroupView', { path: { antiAffinityGroup }, query: { project } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { project, antiAffinityGroup } = getAntiAffinityGroupSelector(params) + await Promise.all([ + queryClient.prefetchQuery(antiAffinityGroupList({ project })), + queryClient.prefetchQuery(affinityGroupList({ project })), + queryClient.prefetchQuery(antiAffinityGroupView({ project, antiAffinityGroup })), + ]) + return null +} + +export default function EditAntiAffintyGroupForm() { + const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() + + const navigate = useNavigate() + const onDismiss = () => navigate(pb.antiAffinityGroup({ project, antiAffinityGroup })) + + const editAntiAffinityGroup = useApiMutation('antiAffinityGroupUpdate', { + onSuccess(updatedAntiAffinityGroup) { + navigate( + pb.antiAffinityGroup({ project, antiAffinityGroup: updatedAntiAffinityGroup.name }) + ) + addToast(<>Anti-affinity group {updatedAntiAffinityGroup.name} updated) // prettier-ignore + queryClient.invalidateQueries(antiAffinityGroupList({ project })) + }, + }) + + const { + data: { items: existingAntiAffinityGroups }, + } = usePrefetchedQuery(antiAffinityGroupList({ project })) + const { data: antiAffinityGroupData } = usePrefetchedQuery( + antiAffinityGroupView({ project, antiAffinityGroup }) + ) + const defaultValues: AntiAffinityGroupUpdate = { + name: antiAffinityGroupData.name, + description: antiAffinityGroupData.description, + } + const form = useForm({ defaultValues }) + const control = form.control + + return ( + { + editAntiAffinityGroup.mutate({ + path: { antiAffinityGroup }, + query: { project }, + body: { + name: values.name || antiAffinityGroupData.name, + description: values.description || '', + }, + }) + }} + loading={editAntiAffinityGroup.isPending} + submitError={editAntiAffinityGroup.error} + submitLabel="Edit group" + > + { + if (existingAntiAffinityGroups.find((g) => g.name === name)) { + return 'Name taken. To update an existing group, edit it directly.' + } + }} + /> + + + ) +} diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 8dfe773b47..7a58af0d3b 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback } from 'react' -import { Link, Outlet, type LoaderFunctionArgs } from 'react-router' +import { Link, Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { apiq, @@ -22,7 +22,12 @@ import { import { Affinity24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' -import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { + getProjectSelector, + useAntiAffinityGroupSelector, + useProjectSelector, +} from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' @@ -32,6 +37,7 @@ import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' +import * as DropdownMenu from '~/ui/lib/DropdownMenu' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { Slash } from '~/ui/lib/Slash' @@ -65,13 +71,27 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const colHelper = createColumnHelper() -export const AffinityPageHeader = ({ name = 'Affinity' }: { name?: string }) => ( - - }>{name} - {/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */} - -) - +export const AffinityPageHeader = ({ name = 'Affinity' }: { name?: string }) => { + const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() + return ( + + }>{name} + {name !== 'Affinity' && ( +
+ {/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */} + + + + Edit + + +
+ )} +
+ ) +} type AffinityGroupPolicyBadgeProps = { policy: AffinityPolicy; className?: string } const AffinityGroupPolicyBadge = ({ policy, className }: AffinityGroupPolicyBadgeProps) => ( [ + { + label: 'Edit', + onActivate() { + navigate( + pb.antiAffinityGroupEdit({ project, antiAffinityGroup: antiAffinityGroup.name }) + ) + }, + }, { label: 'Delete', onActivate() { @@ -140,7 +169,7 @@ export default function AffinityPage() { }, }, ], - [project, deleteGroup] + [project, deleteGroup, navigate] ) const columns = useColsWithActions( diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 4e259aaeba..d2703fe019 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -8,7 +8,7 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback } from 'react' -import type { LoaderFunctionArgs } from 'react-router' +import { Outlet, type LoaderFunctionArgs } from 'react-router' import { Affinity24Icon } from '@oxide/design-system/icons/react' @@ -177,6 +177,7 @@ export default function AntiAffinityPage() { {membersCount ?
: } + ) } diff --git a/app/routes.tsx b/app/routes.tsx index f9b5b20a83..6dd1b5397e 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -519,7 +519,12 @@ export const routes = createRoutesFromElements( lazy={() => import('./pages/project/affinity/AntiAffinityGroupPage.tsx').then(convert) } - /> + > + import('./forms/anti-affinity-group-edit').then(convert)} + /> + diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 756b24b7dc..11ddc6ab3f 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -100,6 +100,8 @@ export const pb = { affinityNew: (params: PP.Project) => `${projectBase(params)}/affinity-new`, antiAffinityGroup: (params: PP.AntiAffinityGroup) => `${pb.affinity(params)}/${params.antiAffinityGroup}`, + antiAffinityGroupEdit: (params: PP.AntiAffinityGroup) => + `${pb.antiAffinityGroup(params)}/edit`, siloUtilization: () => '/utilization', siloAccess: () => '/access', From e837d9d85df58fe364b539a24a22d4de1bdd4deb Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 1 Apr 2025 14:52:15 -0700 Subject: [PATCH 12/56] Fix bug; add ID column to table --- app/pages/project/affinity/AffinityPage.tsx | 22 +++++++++------------ app/table/columns/common.tsx | 12 +++++++++++ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 7a58af0d3b..0c032c65b5 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -23,11 +23,7 @@ import { Affinity24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' -import { - getProjectSelector, - useAntiAffinityGroupSelector, - useProjectSelector, -} from '~/hooks/use-params' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' @@ -72,23 +68,22 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const colHelper = createColumnHelper() export const AffinityPageHeader = ({ name = 'Affinity' }: { name?: string }) => { - const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() + const { project } = useProjectSelector() return ( }>{name} - {name !== 'Affinity' && ( -
- {/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */} - +
+ {/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */} + {name !== 'Affinity' && ( Edit -
- )} + )} +
) } @@ -104,6 +99,7 @@ const AffinityGroupPolicyBadge = ({ policy, className }: AffinityGroupPolicyBadg ) const staticCols = [ + colHelper.accessor('id', Columns.id), colHelper.accessor('description', Columns.description), colHelper.accessor(() => {}, { header: 'type', diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index f2d0496830..a25f34afd5 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -11,7 +11,9 @@ import { filesize } from 'filesize' import type { InstanceState } from '~/api' import { InstanceStateBadge } from '~/components/StateBadge' import { DescriptionCell } from '~/table/cells/DescriptionCell' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' import { DateTime } from '~/ui/lib/DateTime' +import { Truncate } from '~/ui/lib/Truncate' // the full type of the info arg is CellContext from RT, but in these // cells we only care about the return value of getValue @@ -21,6 +23,15 @@ function dateCell(info: Info) { return } +function idCell(info: Info) { + return ( + + + + + ) +} + function instanceStateCell(info: Info) { return } @@ -40,6 +51,7 @@ export const Columns = { description: { cell: (info: Info) => , }, + id: { header: 'ID', cell: idCell }, instanceState: { header: 'state', cell: instanceStateCell }, size: { cell: sizeCell }, timeCreated: { header: 'created', cell: dateCell }, From e93d50603eae61b1b5b3e70f0711402e46652658 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 15:14:41 -0700 Subject: [PATCH 13/56] can add instances to an anti-affinity group --- app/forms/anti-affinity-group-member-add.tsx | 122 ++++++++++++++++++ .../affinity/AntiAffinityGroupPage.tsx | 22 +++- mock-api/msw/handlers.ts | 30 ++++- 3 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 app/forms/anti-affinity-group-member-add.tsx diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx new file mode 100644 index 0000000000..adbabbe443 --- /dev/null +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -0,0 +1,122 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useForm } from 'react-hook-form' +import type { LoaderFunctionArgs } from 'react-router' + +import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '~/api' +import { ComboboxField } from '~/components/form/fields/ComboboxField' +import { HL } from '~/components/HL' +import { + getAntiAffinityGroupSelector, + useAntiAffinityGroupSelector, +} from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { toComboboxItems } from '~/ui/lib/Combobox' +import { Modal } from '~/ui/lib/Modal' +import { ALL_ISH } from '~/util/consts' +import type * as PP from '~/util/path-params' + +const memberList = ({ antiAffinityGroup, project }: PP.AntiAffinityGroup) => + apiq('antiAffinityGroupMemberList', { + path: { antiAffinityGroup }, + // member limit in DB is currently 32, so pagination isn't needed + query: { project, limit: ALL_ISH }, + }) +const instanceList = ({ project }: PP.Project) => + apiq('instanceList', { query: { project, limit: ALL_ISH } }) +const affinityGroupList = ({ project }: PP.Project) => + apiq('affinityGroupList', { query: { project, limit: ALL_ISH } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { antiAffinityGroup, project } = getAntiAffinityGroupSelector(params) + await Promise.all([ + queryClient.fetchQuery(memberList({ antiAffinityGroup, project })), + queryClient.prefetchQuery(instanceList({ project })), + queryClient.prefetchQuery(affinityGroupList({ project })), + ]) + return null +} + +export function AddAntiAffinityGroupMemberForm({ + isModalOpen, + setIsModalOpen, +}: { + isModalOpen: boolean + setIsModalOpen: (open: boolean) => void +}) { + const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() + + const form = useForm({ + defaultValues: { + antiAffinityGroupMember: '', + }, + }) + + const onDismiss = () => { + setIsModalOpen(false) + form.reset() + } + + const { data: members } = usePrefetchedQuery(memberList({ antiAffinityGroup, project })) + + // We need to construct a list of availableInstances. These should not include any + // instances already in this anti-affinity group. They should also not include any + // instances that are already in an affinity group that is included in this list. + const { data: instances } = usePrefetchedQuery(instanceList({ project })) + const availableInstances = toComboboxItems( + instances.items.filter((instance) => { + const isInThisGroup = members.items.some(({ value }) => value.name === instance.name) + // TODO: Check if the instance is already in an affinity group that is in this anti-affinity group + return !isInThisGroup + }) + ) + + const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', { + onSuccess(_data, variables) { + onDismiss() + queryClient.invalidateEndpoint('antiAffinityGroupMemberList') + queryClient.invalidateEndpoint('antiAffinityGroupView') + addToast(<>Member {variables.path.instance} added to anti-affinity group {antiAffinityGroup}) // prettier-ignore + }, + }) + + return ( + + + +

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

+ +
+
+ + addMember({ + path: { + antiAffinityGroup, + instance: form.getValues('antiAffinityGroupMember'), + }, + query: { project }, + }) + } + actionText="Add to group" + /> +
+ ) +} diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index d2703fe019..ca34d175f2 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -7,7 +7,7 @@ */ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' import { Affinity24Icon } from '@oxide/design-system/icons/react' @@ -20,6 +20,7 @@ import { type AntiAffinityGroupMember, } from '~/api' import { HL } from '~/components/HL' +import { AddAntiAffinityGroupMemberForm } from '~/forms/anti-affinity-group-member-add' import { makeCrumb } from '~/hooks/use-crumbs' import { getAntiAffinityGroupSelector, @@ -33,6 +34,7 @@ import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' import { CardBlock } from '~/ui/lib/CardBlock' +import { CreateButton } from '~/ui/lib/CreateButton' import { Divider } from '~/ui/lib/Divider' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PropertiesTable } from '~/ui/lib/PropertiesTable' @@ -58,12 +60,18 @@ const memberList = ({ antiAffinityGroup, project }: PP.AntiAffinityGroup) => // member limit in DB is currently 32, so pagination isn't needed query: { project, limit: ALL_ISH }, }) +const instanceList = ({ project }: PP.Project) => + apiq('instanceList', { query: { project, limit: ALL_ISH } }) +const affinityGroupList = ({ project }: PP.Project) => + apiq('affinityGroupList', { query: { project, limit: ALL_ISH } }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { antiAffinityGroup, project } = getAntiAffinityGroupSelector(params) await Promise.all([ queryClient.fetchQuery(antiAffinityGroupView({ antiAffinityGroup, project })), queryClient.fetchQuery(memberList({ antiAffinityGroup, project })), + queryClient.prefetchQuery(instanceList({ project })), + queryClient.prefetchQuery(affinityGroupList({ project })), ]) return null } @@ -97,6 +105,8 @@ export default function AntiAffinityPage() { }, } ) + // useState is at this level because the CreateButton needs to control the modal + const [isModalOpen, setIsModalOpen] = useState(false) const makeActions = useCallback( (antiAffinityGroupMember: AntiAffinityGroupMember): MenuAction[] => [ @@ -172,11 +182,19 @@ export default function AntiAffinityPage() { + > + setIsModalOpen(true)}> + Add instance to group + + {membersCount ?
: } + ) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index cb672f729b..acf64c93c7 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1686,6 +1686,35 @@ export const handlers = makeHandlers({ }) return { items: members } }, + antiAffinityGroupMemberInstanceAdd({ path, query }) { + const project = lookup.project({ ...query }) + const instance = lookup.instance({ ...query, instance: path.instance }) + const antiAffinityGroup = lookup.antiAffinityGroup({ + project: project.id, + antiAffinityGroup: path.antiAffinityGroup, + }) + const alreadyThere = db.antiAffinityGroupMemberLists.some( + (i) => + i.anti_affinity_group_id === antiAffinityGroup.id && + i.anti_affinity_group_member.id === instance.id + ) + if (alreadyThere) { + throw 'Instance already in anti-affinity group' + } + const newMember: Json = { + type: 'instance', + value: { + id: instance.id, + name: instance.name, + run_state: instance.run_state, + }, + } + db.antiAffinityGroupMemberLists.push({ + anti_affinity_group_id: antiAffinityGroup.id, + anti_affinity_group_member: { type: 'instance', id: instance.id }, + }) + return json(newMember, { status: 201 }) + }, antiAffinityGroupMemberInstanceDelete: ({ path, query }) => { const project = lookup.project({ ...query }) const instance = lookup.instance({ ...query, instance: path.instance }) @@ -1708,7 +1737,6 @@ export const handlers = makeHandlers({ affinityGroupMemberInstanceDelete: NotImplemented, affinityGroupMemberInstanceView: NotImplemented, affinityGroupUpdate: NotImplemented, - antiAffinityGroupMemberInstanceAdd: NotImplemented, antiAffinityGroupMemberInstanceView: NotImplemented, certificateCreate: NotImplemented, certificateDelete: NotImplemented, From 3511e0d356fae71fefcdf12db8d3ad298d691c93 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 15:31:17 -0700 Subject: [PATCH 14/56] Update to ID column truncation --- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 1 + app/table/columns/common.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index ca34d175f2..159057c8c2 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -151,6 +151,7 @@ export default function AntiAffinityPage() { header: 'Name', cell: makeLinkCell((instance) => pb.instance({ project, instance })), }), + colHelper.accessor('value.id', Columns.id), colHelper.accessor('value.runState', Columns.instanceState), ], makeActions diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index a25f34afd5..e20059eb21 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -26,7 +26,7 @@ function dateCell(info: Info) { function idCell(info: Info) { return ( - + ) From 271b8545893b928269815979321e3414d3fe900e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 16:43:37 -0700 Subject: [PATCH 15/56] Refactoring --- app/forms/affinity-util.tsx | 36 +++++++++++++++ app/forms/anti-affinity-group-create.tsx | 10 +--- app/forms/anti-affinity-group-edit.tsx | 16 +++---- app/forms/anti-affinity-group-member-add.tsx | 46 +++++++------------ app/pages/project/affinity/AffinityPage.tsx | 13 +----- .../affinity/AntiAffinityGroupPage.tsx | 30 +++++------- 6 files changed, 73 insertions(+), 78 deletions(-) create mode 100644 app/forms/affinity-util.tsx diff --git a/app/forms/affinity-util.tsx b/app/forms/affinity-util.tsx new file mode 100644 index 0000000000..21503233b8 --- /dev/null +++ b/app/forms/affinity-util.tsx @@ -0,0 +1,36 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { apiq } from '~/api' +import { ALL_ISH } from '~/util/consts' +import type * as PP from '~/util/path-params' + +export const antiAffinityGroupMemberList = ({ + antiAffinityGroup, + project, +}: PP.AntiAffinityGroup) => + apiq('antiAffinityGroupMemberList', { + path: { antiAffinityGroup }, + // member limit in DB is currently 32, so pagination isn't needed + query: { project, limit: ALL_ISH }, + }) + +export const instanceList = ({ project }: PP.Project) => + apiq('instanceList', { query: { project, limit: ALL_ISH } }) + +export const affinityGroupList = ({ project }: PP.Project) => + apiq('affinityGroupList', { query: { project, limit: ALL_ISH } }) + +export const antiAffinityGroupList = ({ project }: PP.Project) => + apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) + +export const antiAffinityGroupView = ({ + project, + antiAffinityGroup, +}: PP.AntiAffinityGroup) => + apiq('antiAffinityGroupView', { path: { antiAffinityGroup }, query: { project } }) diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx index 95bf07e2f9..1b45352f75 100644 --- a/app/forms/anti-affinity-group-create.tsx +++ b/app/forms/anti-affinity-group-create.tsx @@ -9,7 +9,6 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, queryClient, useApiMutation, usePrefetchedQuery, @@ -24,9 +23,9 @@ import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -import type * as PP from '~/util/path-params' + +import { affinityGroupList, antiAffinityGroupList } from './affinity-util' export const handle = titleCrumb('New anti-affinity group') @@ -37,11 +36,6 @@ type AntiAffinityGroupFormValues = { affinityGroupMembers: string[] } -const affinityGroupList = ({ project }: PP.Project) => - apiq('affinityGroupList', { query: { project, limit: ALL_ISH } }) -const antiAffinityGroupList = ({ project }: PP.Project) => - apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) - export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) await Promise.all([ diff --git a/app/forms/anti-affinity-group-edit.tsx b/app/forms/anti-affinity-group-edit.tsx index 673e2c2992..08a9f91784 100644 --- a/app/forms/anti-affinity-group-edit.tsx +++ b/app/forms/anti-affinity-group-edit.tsx @@ -9,7 +9,6 @@ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, queryClient, useApiMutation, usePrefetchedQuery, @@ -26,18 +25,15 @@ import { useAntiAffinityGroupSelector, } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -import type * as PP from '~/util/path-params' -export const handle = titleCrumb('New anti-affinity group') +import { + affinityGroupList, + antiAffinityGroupList, + antiAffinityGroupView, +} from './affinity-util' -const affinityGroupList = ({ project }: PP.Project) => - apiq('affinityGroupList', { query: { project, limit: ALL_ISH } }) -const antiAffinityGroupList = ({ project }: PP.Project) => - apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) -const antiAffinityGroupView = ({ project, antiAffinityGroup }: PP.AntiAffinityGroup) => - apiq('antiAffinityGroupView', { path: { antiAffinityGroup }, query: { project } }) +export const handle = titleCrumb('New anti-affinity group') export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, antiAffinityGroup } = getAntiAffinityGroupSelector(params) diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index adbabbe443..a724861afd 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form' import type { LoaderFunctionArgs } from 'react-router' -import { apiq, queryClient, useApiMutation, usePrefetchedQuery } from '~/api' +import { queryClient, useApiMutation, usePrefetchedQuery } from '~/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { HL } from '~/components/HL' import { @@ -19,24 +19,17 @@ import { import { addToast } from '~/stores/toast' import { toComboboxItems } from '~/ui/lib/Combobox' import { Modal } from '~/ui/lib/Modal' -import { ALL_ISH } from '~/util/consts' -import type * as PP from '~/util/path-params' -const memberList = ({ antiAffinityGroup, project }: PP.AntiAffinityGroup) => - apiq('antiAffinityGroupMemberList', { - path: { antiAffinityGroup }, - // member limit in DB is currently 32, so pagination isn't needed - query: { project, limit: ALL_ISH }, - }) -const instanceList = ({ project }: PP.Project) => - apiq('instanceList', { query: { project, limit: ALL_ISH } }) -const affinityGroupList = ({ project }: PP.Project) => - apiq('affinityGroupList', { query: { project, limit: ALL_ISH } }) +import { + affinityGroupList, + antiAffinityGroupMemberList, + instanceList, +} from './affinity-util' export async function clientLoader({ params }: LoaderFunctionArgs) { const { antiAffinityGroup, project } = getAntiAffinityGroupSelector(params) await Promise.all([ - queryClient.fetchQuery(memberList({ antiAffinityGroup, project })), + queryClient.fetchQuery(antiAffinityGroupMemberList({ antiAffinityGroup, project })), queryClient.prefetchQuery(instanceList({ project })), queryClient.prefetchQuery(affinityGroupList({ project })), ]) @@ -52,6 +45,15 @@ export function AddAntiAffinityGroupMemberForm({ }) { const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() + const { data: members } = usePrefetchedQuery( + antiAffinityGroupMemberList({ antiAffinityGroup, project }) + ) + const { data: instances } = usePrefetchedQuery(instanceList({ project })) + // Construct a list of all instances not currently in this anti-affinity group. + const availableInstances = instances.items.filter( + (instance) => !members.items.some(({ value }) => value.name === instance.name) + ) + const form = useForm({ defaultValues: { antiAffinityGroupMember: '', @@ -63,20 +65,6 @@ export function AddAntiAffinityGroupMemberForm({ form.reset() } - const { data: members } = usePrefetchedQuery(memberList({ antiAffinityGroup, project })) - - // We need to construct a list of availableInstances. These should not include any - // instances already in this anti-affinity group. They should also not include any - // instances that are already in an affinity group that is included in this list. - const { data: instances } = usePrefetchedQuery(instanceList({ project })) - const availableInstances = toComboboxItems( - instances.items.filter((instance) => { - const isInThisGroup = members.items.some(({ value }) => value.name === instance.name) - // TODO: Check if the instance is already in an affinity group that is in this anti-affinity group - return !isInThisGroup - }) - ) - const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', { onSuccess(_data, variables) { onDismiss() @@ -98,7 +86,7 @@ export function AddAntiAffinityGroupMemberForm({ label="Instance" placeholder="Select an instance" name="antiAffinityGroupMember" - items={availableInstances} + items={toComboboxItems(availableInstances)} required control={form.control} /> diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 0c032c65b5..94a4d5f06d 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -12,7 +12,6 @@ import { useCallback } from 'react' import { Link, Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { - apiq, queryClient, useApiMutation, usePrefetchedQuery, @@ -23,6 +22,7 @@ import { Affinity24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { antiAffinityGroupList, memberList } from '~/forms/affinity-util' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' @@ -39,18 +39,7 @@ import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { Slash } from '~/ui/lib/Slash' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' import { intersperse } from '~/util/array' -import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -import type * as PP from '~/util/path-params' - -const antiAffinityGroupList = ({ project }: PP.Project) => - apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) -const memberList = ({ antiAffinityGroup, project }: PP.AntiAffinityGroup) => - apiq('antiAffinityGroupMemberList', { - path: { antiAffinityGroup }, - // We only need to get the first 2 members for preview - query: { project, limit: 2 }, - }) export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 159057c8c2..e9c889b671 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -13,13 +13,18 @@ import { Outlet, type LoaderFunctionArgs } from 'react-router' import { Affinity24Icon } from '@oxide/design-system/icons/react' import { - apiq, queryClient, useApiMutation, usePrefetchedQuery, type AntiAffinityGroupMember, } from '~/api' import { HL } from '~/components/HL' +import { + affinityGroupList, + antiAffinityGroupMemberList, + antiAffinityGroupView, + instanceList, +} from '~/forms/affinity-util' import { AddAntiAffinityGroupMemberForm } from '~/forms/anti-affinity-group-member-add' import { makeCrumb } from '~/hooks/use-crumbs' import { @@ -39,9 +44,7 @@ import { Divider } from '~/ui/lib/Divider' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PropertiesTable } from '~/ui/lib/PropertiesTable' 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' import { AffinityPageHeader } from './AffinityPage' @@ -52,24 +55,11 @@ export const handle = makeCrumb( const colHelper = createColumnHelper() -const antiAffinityGroupView = ({ antiAffinityGroup, project }: PP.AntiAffinityGroup) => - apiq('antiAffinityGroupView', { path: { antiAffinityGroup }, query: { project } }) -const memberList = ({ antiAffinityGroup, project }: PP.AntiAffinityGroup) => - apiq('antiAffinityGroupMemberList', { - path: { antiAffinityGroup }, - // member limit in DB is currently 32, so pagination isn't needed - query: { project, limit: ALL_ISH }, - }) -const instanceList = ({ project }: PP.Project) => - apiq('instanceList', { query: { project, limit: ALL_ISH } }) -const affinityGroupList = ({ project }: PP.Project) => - apiq('affinityGroupList', { query: { project, limit: ALL_ISH } }) - export async function clientLoader({ params }: LoaderFunctionArgs) { const { antiAffinityGroup, project } = getAntiAffinityGroupSelector(params) await Promise.all([ queryClient.fetchQuery(antiAffinityGroupView({ antiAffinityGroup, project })), - queryClient.fetchQuery(memberList({ antiAffinityGroup, project })), + queryClient.fetchQuery(antiAffinityGroupMemberList({ antiAffinityGroup, project })), queryClient.prefetchQuery(instanceList({ project })), queryClient.prefetchQuery(affinityGroupList({ project })), ]) @@ -92,7 +82,9 @@ export default function AntiAffinityPage() { antiAffinityGroupView({ antiAffinityGroup, project }) ) const { id, name, description, policy, timeCreated } = group - const { data: members } = usePrefetchedQuery(memberList({ antiAffinityGroup, project })) + const { data: members } = usePrefetchedQuery( + antiAffinityGroupMemberList({ antiAffinityGroup, project }) + ) const membersCount = members.items.length const { mutateAsync: removeMember } = useApiMutation( @@ -105,7 +97,7 @@ export default function AntiAffinityPage() { }, } ) - // useState is at this level because the CreateButton needs to control the modal + // useState is at this level so the CreateButton can open the modal const [isModalOpen, setIsModalOpen] = useState(false) const makeActions = useCallback( From edd0456c3404f08dc171144d6482308684bd285f Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 16:49:00 -0700 Subject: [PATCH 16/56] Missed a spot in the refactoring --- app/pages/project/affinity/AffinityPage.tsx | 12 ++++++++---- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 94a4d5f06d..deaf08b03a 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -22,7 +22,7 @@ import { Affinity24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' -import { antiAffinityGroupList, memberList } from '~/forms/affinity-util' +import { antiAffinityGroupList, antiAffinityGroupMemberList } from '~/forms/affinity-util' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' @@ -45,7 +45,9 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) const groups = await queryClient.fetchQuery(antiAffinityGroupList({ project })) const memberFetches = groups.items.map(({ name }) => - queryClient.prefetchQuery(memberList({ antiAffinityGroup: name, project })) + queryClient.prefetchQuery( + antiAffinityGroupMemberList({ antiAffinityGroup: name, project }) + ) ) // The browser will fetch up to 6 anti-affinity group member lists without queuing, // so we can prefetch them without slowing down the page. If there are more than 6 groups, @@ -88,7 +90,6 @@ const AffinityGroupPolicyBadge = ({ policy, className }: AffinityGroupPolicyBadg ) const staticCols = [ - colHelper.accessor('id', Columns.id), colHelper.accessor('description', Columns.description), colHelper.accessor(() => {}, { header: 'type', @@ -101,6 +102,7 @@ const staticCols = [ header: 'members', cell: (info) => , }), + colHelper.accessor('id', Columns.id), colHelper.accessor('timeCreated', Columns.timeCreated), ] @@ -209,7 +211,9 @@ export const AffinityGroupMembersCell = ({ antiAffinityGroup: string }) => { const { project } = useProjectSelector() - const { data: members } = useQuery(memberList({ antiAffinityGroup, project })) + const { data: members } = useQuery( + antiAffinityGroupMemberList({ antiAffinityGroup, project }) + ) if (!members) return if (!members.items.length) return diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index e9c889b671..f98908cee3 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -143,8 +143,8 @@ export default function AntiAffinityPage() { header: 'Name', cell: makeLinkCell((instance) => pb.instance({ project, instance })), }), - colHelper.accessor('value.id', Columns.id), colHelper.accessor('value.runState', Columns.instanceState), + colHelper.accessor('value.id', Columns.id), ], makeActions ) From c307e7231a2b7f0fb1f8dc45d9483591a0de620d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 17:40:54 -0700 Subject: [PATCH 17/56] Update snapshots --- .../__snapshots__/path-builder.spec.ts.snap | 32 +++++++++++++++++++ app/util/path-builder.spec.ts | 2 ++ 2 files changed, 34 insertions(+) diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 922c3b6c12..fc51c123cc 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -16,6 +16,20 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/affinity", }, ], + "affinityNew (/projects/p/affinity-new)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Affinity", + "path": "/projects/p/", + }, + ], "antiAffinityGroup (/projects/p/affinity/aag)": [ { "label": "Projects", @@ -34,6 +48,24 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/affinity/aag", }, ], + "antiAffinityGroupEdit (/projects/p/affinity/aag/edit)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "Affinity", + "path": "/projects/p/affinity", + }, + { + "label": "aag", + "path": "/projects/p/affinity/aag", + }, + ], "deviceSuccess (/device/success)": [], "diskInventory (/system/inventory/disks)": [ { diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index a7949dcca0..c8992564f3 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -42,7 +42,9 @@ test('path builder', () => { .toMatchInlineSnapshot(` { "affinity": "/projects/p/affinity", + "affinityNew": "/projects/p/affinity-new", "antiAffinityGroup": "/projects/p/affinity/aag", + "antiAffinityGroupEdit": "/projects/p/affinity/aag/edit", "deviceSuccess": "/device/success", "diskInventory": "/system/inventory/disks", "disks": "/projects/p/disks", From b31c1ca9298c7d07db10b54a9d8238402bc786f8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 17:53:15 -0700 Subject: [PATCH 18/56] Can just use prefetchQuery, since we don't need returned data yet --- app/forms/anti-affinity-group-member-add.tsx | 2 +- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index a724861afd..86233f6590 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -29,7 +29,7 @@ import { export async function clientLoader({ params }: LoaderFunctionArgs) { const { antiAffinityGroup, project } = getAntiAffinityGroupSelector(params) await Promise.all([ - queryClient.fetchQuery(antiAffinityGroupMemberList({ antiAffinityGroup, project })), + queryClient.prefetchQuery(antiAffinityGroupMemberList({ antiAffinityGroup, project })), queryClient.prefetchQuery(instanceList({ project })), queryClient.prefetchQuery(affinityGroupList({ project })), ]) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index f98908cee3..922f30d6f2 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -58,8 +58,8 @@ const colHelper = createColumnHelper() export async function clientLoader({ params }: LoaderFunctionArgs) { const { antiAffinityGroup, project } = getAntiAffinityGroupSelector(params) await Promise.all([ - queryClient.fetchQuery(antiAffinityGroupView({ antiAffinityGroup, project })), - queryClient.fetchQuery(antiAffinityGroupMemberList({ antiAffinityGroup, project })), + queryClient.prefetchQuery(antiAffinityGroupView({ antiAffinityGroup, project })), + queryClient.prefetchQuery(antiAffinityGroupMemberList({ antiAffinityGroup, project })), queryClient.prefetchQuery(instanceList({ project })), queryClient.prefetchQuery(affinityGroupList({ project })), ]) From d1f7945ee66129e36fae1e1b45f065a8caaa9d1a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 17:54:53 -0700 Subject: [PATCH 19/56] reorder functions --- app/forms/affinity-util.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/forms/affinity-util.tsx b/app/forms/affinity-util.tsx index 21503233b8..1dac1b6992 100644 --- a/app/forms/affinity-util.tsx +++ b/app/forms/affinity-util.tsx @@ -10,16 +10,6 @@ import { apiq } from '~/api' import { ALL_ISH } from '~/util/consts' import type * as PP from '~/util/path-params' -export const antiAffinityGroupMemberList = ({ - antiAffinityGroup, - project, -}: PP.AntiAffinityGroup) => - apiq('antiAffinityGroupMemberList', { - path: { antiAffinityGroup }, - // member limit in DB is currently 32, so pagination isn't needed - query: { project, limit: ALL_ISH }, - }) - export const instanceList = ({ project }: PP.Project) => apiq('instanceList', { query: { project, limit: ALL_ISH } }) @@ -34,3 +24,13 @@ export const antiAffinityGroupView = ({ antiAffinityGroup, }: PP.AntiAffinityGroup) => apiq('antiAffinityGroupView', { path: { antiAffinityGroup }, query: { project } }) + +export const antiAffinityGroupMemberList = ({ + antiAffinityGroup, + project, +}: PP.AntiAffinityGroup) => + apiq('antiAffinityGroupMemberList', { + path: { antiAffinityGroup }, + // member limit in DB is currently 32, so pagination isn't needed + query: { project, limit: ALL_ISH }, + }) From 97b01375281ded667eb6bdc272ca3f26f0738a2b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 21:28:28 -0700 Subject: [PATCH 20/56] move instanceList fetching up a level; use to disable button when no instances available --- app/forms/anti-affinity-group-member-add.tsx | 35 +++---------------- .../affinity/AntiAffinityGroupPage.tsx | 12 ++++++- 2 files changed, 15 insertions(+), 32 deletions(-) diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index 86233f6590..adc9d8c34a 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -7,53 +7,26 @@ */ import { useForm } from 'react-hook-form' -import type { LoaderFunctionArgs } from 'react-router' -import { queryClient, useApiMutation, usePrefetchedQuery } from '~/api' +import { queryClient, useApiMutation, type Instance } from '~/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { HL } from '~/components/HL' -import { - getAntiAffinityGroupSelector, - useAntiAffinityGroupSelector, -} from '~/hooks/use-params' +import { useAntiAffinityGroupSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { toComboboxItems } from '~/ui/lib/Combobox' import { Modal } from '~/ui/lib/Modal' -import { - affinityGroupList, - antiAffinityGroupMemberList, - instanceList, -} from './affinity-util' - -export async function clientLoader({ params }: LoaderFunctionArgs) { - const { antiAffinityGroup, project } = getAntiAffinityGroupSelector(params) - await Promise.all([ - queryClient.prefetchQuery(antiAffinityGroupMemberList({ antiAffinityGroup, project })), - queryClient.prefetchQuery(instanceList({ project })), - queryClient.prefetchQuery(affinityGroupList({ project })), - ]) - return null -} - export function AddAntiAffinityGroupMemberForm({ + availableInstances, isModalOpen, setIsModalOpen, }: { + availableInstances: Instance[] isModalOpen: boolean setIsModalOpen: (open: boolean) => void }) { const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() - const { data: members } = usePrefetchedQuery( - antiAffinityGroupMemberList({ antiAffinityGroup, project }) - ) - const { data: instances } = usePrefetchedQuery(instanceList({ project })) - // Construct a list of all instances not currently in this anti-affinity group. - const availableInstances = instances.items.filter( - (instance) => !members.items.some(({ value }) => value.name === instance.name) - ) - const form = useForm({ defaultValues: { antiAffinityGroupMember: '', diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 922f30d6f2..6fc2afd762 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -87,6 +87,12 @@ export default function AntiAffinityPage() { ) const membersCount = members.items.length + const { data: instances } = usePrefetchedQuery(instanceList({ project })) + // Construct a list of all instances not currently in this anti-affinity group. + const availableInstances = instances.items + .filter((instance) => !members.items.some(({ value }) => value.name === instance.name)) + .sort((a, b) => a.name.localeCompare(b.name)) + const { mutateAsync: removeMember } = useApiMutation( 'antiAffinityGroupMemberInstanceDelete', { @@ -176,7 +182,10 @@ export default function AntiAffinityPage() { title="Members" description="Instances in this anti-affinity group" > - setIsModalOpen(true)}> + setIsModalOpen(true)} + disabled={!availableInstances.length} + > Add instance to group @@ -185,6 +194,7 @@ export default function AntiAffinityPage() { From e6d996b9d3ffc45a744ecc473fd6ae327cd2fa33 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 21:58:49 -0700 Subject: [PATCH 21/56] Use existing types for forms --- app/forms/anti-affinity-group-create.tsx | 24 +++++------------ app/forms/anti-affinity-group-edit.tsx | 28 +++++++++----------- app/forms/anti-affinity-group-member-add.tsx | 15 +++++++---- 3 files changed, 28 insertions(+), 39 deletions(-) diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx index 1b45352f75..4b8e6c9287 100644 --- a/app/forms/anti-affinity-group-create.tsx +++ b/app/forms/anti-affinity-group-create.tsx @@ -12,7 +12,7 @@ import { queryClient, useApiMutation, usePrefetchedQuery, - type AffinityPolicy, + type AntiAffinityGroupCreate, } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -29,13 +29,6 @@ import { affinityGroupList, antiAffinityGroupList } from './affinity-util' export const handle = titleCrumb('New anti-affinity group') -type AntiAffinityGroupFormValues = { - name: string - description: string - policy: AffinityPolicy - affinityGroupMembers: string[] -} - export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) await Promise.all([ @@ -62,13 +55,13 @@ export default function CreateAntiAffintyGroupForm() { const { data: { items: existingAntiAffinityGroups }, } = usePrefetchedQuery(antiAffinityGroupList({ project })) - const defaultValues: AntiAffinityGroupFormValues = { + const defaultValues = { name: '', description: '', - policy: 'allow', - affinityGroupMembers: [], + failureDomain: 'sled' as const, + policy: 'allow' as const, } - const form = useForm({ defaultValues }) + const form = useForm({ defaultValues }) const control = form.control return ( @@ -81,12 +74,7 @@ export default function CreateAntiAffintyGroupForm() { onSubmit={(values) => { createAntiAffinityGroup.mutate({ query: { project }, - body: { - name: values.name, - description: values.description, - policy: values.policy, - failureDomain: 'sled', - }, + body: { ...values }, }) }} loading={createAntiAffinityGroup.isPending} diff --git a/app/forms/anti-affinity-group-edit.tsx b/app/forms/anti-affinity-group-edit.tsx index 08a9f91784..c9f65fe1a2 100644 --- a/app/forms/anti-affinity-group-edit.tsx +++ b/app/forms/anti-affinity-group-edit.tsx @@ -61,18 +61,17 @@ export default function EditAntiAffintyGroupForm() { }, }) - const { - data: { items: existingAntiAffinityGroups }, - } = usePrefetchedQuery(antiAffinityGroupList({ project })) + const { data: existingAntiAffinityGroups } = usePrefetchedQuery( + antiAffinityGroupList({ project }) + ) + const { data: antiAffinityGroupData } = usePrefetchedQuery( antiAffinityGroupView({ project, antiAffinityGroup }) ) - const defaultValues: AntiAffinityGroupUpdate = { - name: antiAffinityGroupData.name, - description: antiAffinityGroupData.description, - } - const form = useForm({ defaultValues }) - const control = form.control + + const form = useForm({ + defaultValues: { ...antiAffinityGroupData }, + }) return ( { - if (existingAntiAffinityGroups.find((g) => g.name === name)) { + if (existingAntiAffinityGroups.items.find((g) => g.name === name)) { return 'Name taken. To update an existing group, edit it directly.' } }} /> - + ) } diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index adc9d8c34a..0a82018671 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -8,7 +8,12 @@ import { useForm } from 'react-hook-form' -import { queryClient, useApiMutation, type Instance } from '~/api' +import { + queryClient, + useApiMutation, + type AntiAffinityGroupMemberInstanceAddPathParams, + type Instance, +} from '~/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { HL } from '~/components/HL' import { useAntiAffinityGroupSelector } from '~/hooks/use-params' @@ -27,9 +32,9 @@ export function AddAntiAffinityGroupMemberForm({ }) { const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() - const form = useForm({ + const form = useForm({ defaultValues: { - antiAffinityGroupMember: '', + instance: '', }, }) @@ -58,7 +63,7 @@ export function AddAntiAffinityGroupMemberForm({ Date: Wed, 2 Apr 2025 22:05:07 -0700 Subject: [PATCH 22/56] export function as default --- app/forms/anti-affinity-group-member-add.tsx | 2 +- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index 0a82018671..ecaeef1605 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -21,7 +21,7 @@ import { addToast } from '~/stores/toast' import { toComboboxItems } from '~/ui/lib/Combobox' import { Modal } from '~/ui/lib/Modal' -export function AddAntiAffinityGroupMemberForm({ +export default function AddAntiAffinityGroupMemberForm({ availableInstances, isModalOpen, setIsModalOpen, diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 6fc2afd762..107ea84769 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -25,7 +25,7 @@ import { antiAffinityGroupView, instanceList, } from '~/forms/affinity-util' -import { AddAntiAffinityGroupMemberForm } from '~/forms/anti-affinity-group-member-add' +import AddAntiAffinityGroupMemberForm from '~/forms/anti-affinity-group-member-add' import { makeCrumb } from '~/hooks/use-crumbs' import { getAntiAffinityGroupSelector, From 829344b8ce8624c2223f9e9a6bb5e3f13528ec3a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 22:22:00 -0700 Subject: [PATCH 23/56] refactor idCell --- app/table/columns/common.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index e20059eb21..eb8c2ac451 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -11,7 +11,6 @@ import { filesize } from 'filesize' import type { InstanceState } from '~/api' import { InstanceStateBadge } from '~/components/StateBadge' import { DescriptionCell } from '~/table/cells/DescriptionCell' -import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' import { DateTime } from '~/ui/lib/DateTime' import { Truncate } from '~/ui/lib/Truncate' @@ -24,12 +23,7 @@ function dateCell(info: Info) { } function idCell(info: Info) { - return ( - - - - - ) + return } function instanceStateCell(info: Info) { From a54352dbf957f93c114aba7f0cb7d9c827e02eb7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 22:23:44 -0700 Subject: [PATCH 24/56] Update mock-api/msw/handlers.ts Co-authored-by: David Crespo --- mock-api/msw/handlers.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c025467d0c..2e55a4f8be 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1668,9 +1668,6 @@ export const handlers = makeHandlers({ antiAffinityGroup.name = body.name } updateDesc(antiAffinityGroup, body) - if (body.description) { - antiAffinityGroup.description = body.description - } return antiAffinityGroup }, antiAffinityGroupList: ({ query }) => { From 3d2ae30f37e25a3ca72d2dec439d464b92ff5488 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 22:29:34 -0700 Subject: [PATCH 25/56] don't reuse AffinityPageHeader --- app/pages/project/affinity/AffinityPage.tsx | 29 ++++--------------- .../affinity/AntiAffinityGroupPage.tsx | 19 ++++++++++-- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index deaf08b03a..4ef27dcfb0 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -21,7 +21,6 @@ import { import { Affinity24Icon } from '@oxide/design-system/icons/react' import { HL } from '~/components/HL' -import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { antiAffinityGroupList, antiAffinityGroupMemberList } from '~/forms/affinity-util' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' @@ -33,7 +32,6 @@ import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' -import * as DropdownMenu from '~/ui/lib/DropdownMenu' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { Slash } from '~/ui/lib/Slash' @@ -58,26 +56,6 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const colHelper = createColumnHelper() -export const AffinityPageHeader = ({ name = 'Affinity' }: { name?: string }) => { - const { project } = useProjectSelector() - return ( - - }>{name} -
- {/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */} - {name !== 'Affinity' && ( - - - Edit - - - )} -
-
- ) -} type AffinityGroupPolicyBadgeProps = { policy: AffinityPolicy; className?: string } const AffinityGroupPolicyBadge = ({ policy, className }: AffinityGroupPolicyBadgeProps) => ( - + + }>Affinity +
+ {/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */} +
+
New anti-affinity group diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 107ea84769..44f6eb10ed 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -19,6 +19,7 @@ import { type AntiAffinityGroupMember, } from '~/api' import { HL } from '~/components/HL' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { affinityGroupList, antiAffinityGroupMemberList, @@ -41,13 +42,13 @@ import { Badge } from '~/ui/lib/Badge' import { CardBlock } from '~/ui/lib/CardBlock' import { CreateButton } from '~/ui/lib/CreateButton' import { Divider } from '~/ui/lib/Divider' +import * as DropdownMenu from '~/ui/lib/DropdownMenu' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { TableEmptyBox } from '~/ui/lib/Table' import { pb } from '~/util/path-builder' -import { AffinityPageHeader } from './AffinityPage' - export const handle = makeCrumb( (p) => p.antiAffinityGroup!, (p) => pb.antiAffinityGroup(getAntiAffinityGroupSelector(p)) @@ -163,7 +164,19 @@ export default function AntiAffinityPage() { return ( <> - + + }>{name} +
+ {/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */} + + + Edit + + +
+
anti-affinity From 72efb005520135077e9bc390e1c82cb3fa090adc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 22:35:49 -0700 Subject: [PATCH 26/56] Shorter button copy; new page header --- app/pages/project/affinity/AffinityPage.tsx | 4 ++-- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 4ef27dcfb0..187636b309 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -159,13 +159,13 @@ export default function AffinityPage() { return ( <> - }>Affinity + }>Anti-Affinity Groups
{/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */}
- New anti-affinity group + New group {antiAffinityGroups.length ? (
diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 44f6eb10ed..f16025e9db 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -199,7 +199,7 @@ export default function AntiAffinityPage() { onClick={() => setIsModalOpen(true)} disabled={!availableInstances.length} > - Add instance to group + Add instance From 5e341961a03c96a00522a0f84d485dc7bb33b040 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 22:44:03 -0700 Subject: [PATCH 27/56] Don't include sorting; already present in actual data --- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index f16025e9db..219f1c2391 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -90,9 +90,9 @@ export default function AntiAffinityPage() { const { data: instances } = usePrefetchedQuery(instanceList({ project })) // Construct a list of all instances not currently in this anti-affinity group. - const availableInstances = instances.items - .filter((instance) => !members.items.some(({ value }) => value.name === instance.name)) - .sort((a, b) => a.name.localeCompare(b.name)) + const availableInstances = instances.items.filter( + (instance) => !members.items.some(({ value }) => value.name === instance.name) + ) const { mutateAsync: removeMember } = useApiMutation( 'antiAffinityGroupMemberInstanceDelete', From 3ae6cf9df7dc7d37ec3a72ff3df0cd495b7d7ec3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 22:55:53 -0700 Subject: [PATCH 28/56] Try 'anti-affinity' as header / nav link --- app/layouts/ProjectLayoutBase.tsx | 4 ++-- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index cbb5fb9d35..17b7cfda7f 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -69,7 +69,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { { value: 'Images', path: pb.projectImages(projectSelector) }, { value: 'VPCs', path: pb.vpcs(projectSelector) }, { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, - { value: 'Affinity', path: pb.affinity(projectSelector) }, + { value: 'Anti-affinity', path: pb.affinity(projectSelector) }, { value: 'Access', path: pb.projectAccess(projectSelector) }, ] // filter out the entry for the path we're currently on @@ -115,7 +115,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Floating IPs - Affinity + Anti-affinity Access diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 219f1c2391..b3b1acd067 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -198,6 +198,9 @@ export default function AntiAffinityPage() { setIsModalOpen(true)} disabled={!availableInstances.length} + disabledReason={ + availableInstances.length ? undefined : 'No instances are available to add' + } > Add instance From 666230d258a66cffd4fd263e15afe11d37bb1c76 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 23:04:52 -0700 Subject: [PATCH 29/56] Clean up copy button a bit --- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 6 ------ app/table/columns/common.tsx | 12 ++++++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index b3b1acd067..7059216755 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -109,12 +109,6 @@ export default function AntiAffinityPage() { const makeActions = useCallback( (antiAffinityGroupMember: AntiAffinityGroupMember): MenuAction[] => [ - { - label: 'Copy instance ID', - onActivate() { - navigator.clipboard.writeText(antiAffinityGroupMember.value.id) - }, - }, { label: 'Remove from group', onActivate() { diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index eb8c2ac451..21c368f3cb 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -11,8 +11,8 @@ import { filesize } from 'filesize' import type { InstanceState } from '~/api' import { InstanceStateBadge } from '~/components/StateBadge' import { DescriptionCell } from '~/table/cells/DescriptionCell' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' import { DateTime } from '~/ui/lib/DateTime' -import { Truncate } from '~/ui/lib/Truncate' // the full type of the info arg is CellContext from RT, but in these // cells we only care about the return value of getValue @@ -23,7 +23,15 @@ function dateCell(info: Info) { } function idCell(info: Info) { - return + const text = info.getValue() + return ( +
+ {text} +
+ +
+
+ ) } function instanceStateCell(info: Info) { From 2a996271b9692c6c2bd1a79dabf71fb1a49e2dc0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 2 Apr 2025 23:42:50 -0700 Subject: [PATCH 30/56] More clever disabledReason; needs max member verification --- .../project/affinity/AntiAffinityGroupPage.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 7059216755..dfdc076861 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -156,6 +156,20 @@ export default function AntiAffinityPage() { getCoreRowModel: getCoreRowModel(), }) + const disabledReason = () => { + // TODO: Verify maximum number of members + if (membersCount >= 16) { + return 'Maximum number of members reached' + } + if (!instances.items.length) { + return 'No instances available' + } + if (!availableInstances.length) { + return 'All instances are already in this group' + } + return undefined + } + return ( <> @@ -192,9 +206,7 @@ export default function AntiAffinityPage() { setIsModalOpen(true)} disabled={!availableInstances.length} - disabledReason={ - availableInstances.length ? undefined : 'No instances are available to add' - } + disabledReason={disabledReason()} > Add instance From 22978df265fba8c6de221c224791ee89129790d7 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 3 Apr 2025 10:27:54 -0500 Subject: [PATCH 31/56] use regular link for group edit row action --- app/pages/project/affinity/AffinityPage.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 187636b309..1d136e7707 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback } from 'react' -import { Link, Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' +import { Link, Outlet, type LoaderFunctionArgs } from 'react-router' import { queryClient, @@ -89,7 +89,6 @@ export default function AffinityPage() { const { data: { items: antiAffinityGroups }, } = usePrefetchedQuery(antiAffinityGroupList({ project })) - const navigate = useNavigate() const { mutateAsync: deleteGroup } = useApiMutation('antiAffinityGroupDelete', { onSuccess(_data, variables) { @@ -106,11 +105,10 @@ export default function AffinityPage() { (antiAffinityGroup: AntiAffinityGroup): MenuAction[] => [ { label: 'Edit', - onActivate() { - navigate( - pb.antiAffinityGroupEdit({ project, antiAffinityGroup: antiAffinityGroup.name }) - ) - }, + to: pb.antiAffinityGroupEdit({ + project, + antiAffinityGroup: antiAffinityGroup.name, + }), }, { label: 'Delete', @@ -134,7 +132,7 @@ export default function AffinityPage() { }, }, ], - [project, deleteGroup, navigate] + [project, deleteGroup] ) const columns = useColsWithActions( From cc9875a2a8272037447054a8e81619c3e2fd94d4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 3 Apr 2025 10:34:54 -0500 Subject: [PATCH 32/56] put it back to Affinity title --- app/layouts/ProjectLayoutBase.tsx | 8 ++++---- app/pages/project/affinity/AffinityPage.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 17b7cfda7f..8266c34a37 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -69,7 +69,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { { value: 'Images', path: pb.projectImages(projectSelector) }, { value: 'VPCs', path: pb.vpcs(projectSelector) }, { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, - { value: 'Anti-affinity', path: pb.affinity(projectSelector) }, + { value: 'Affinity', path: pb.affinity(projectSelector) }, { value: 'Access', path: pb.projectAccess(projectSelector) }, ] // filter out the entry for the path we're currently on @@ -106,7 +106,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Snapshots
- Images + Images VPCs @@ -115,10 +115,10 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Floating IPs - Anti-affinity + Affinity - Access + Access diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 1d136e7707..f08f90fd04 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -157,7 +157,7 @@ export default function AffinityPage() { return ( <> - }>Anti-Affinity Groups + }>Affinity
{/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */}
From 00cc0292631c185029a3d56eb3813c4d3eef6f53 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 3 Apr 2025 11:06:01 -0500 Subject: [PATCH 33/56] draft docs popover --- app/layouts/ProjectLayoutBase.tsx | 4 ++-- app/pages/project/affinity/AffinityPage.tsx | 13 +++++++++---- app/util/links.ts | 7 +++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 8266c34a37..b404cc64fa 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -115,10 +115,10 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Floating IPs - Affinity + Affinity - Access + Access diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index f08f90fd04..aeeab7f6b2 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -18,8 +18,9 @@ import { type AffinityPolicy, type AntiAffinityGroup, } from '@oxide/api' -import { Affinity24Icon } from '@oxide/design-system/icons/react' +import { Affinity16Icon, Affinity24Icon } from '@oxide/design-system/icons/react' +import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' import { antiAffinityGroupList, antiAffinityGroupMemberList } from '~/forms/affinity-util' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' @@ -37,6 +38,7 @@ import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { Slash } from '~/ui/lib/Slash' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' import { intersperse } from '~/util/array' +import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -158,9 +160,12 @@ export default function AffinityPage() { <> }>Affinity -
- {/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */} -
+ } + summary="Instances in an anti-affinity group will be placed on different sleds when they start. The policy attribute controls whether this is a hard or soft constraint." + links={[docLinks.affinity]} + />{' '}
New group diff --git a/app/util/links.ts b/app/util/links.ts index 3d780d18cc..1113b14888 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -10,6 +10,9 @@ const remoteAccess = 'https://docs.oxide.computer/guides/remote-access' export const links = { accessDocs: 'https://docs.oxide.computer/guides/configuring-access', + // TODO: make sure this is right before merging + affinityDocs: + 'https://docs.oxide.computer/guides/deploying-workloads#_anti_affinity_groups', cloudInitFormat: 'https://cloudinit.readthedocs.io/en/latest/explanation/format.html', cloudInitExamples: 'https://cloudinit.readthedocs.io/en/latest/reference/examples.html', disksDocs: 'https://docs.oxide.computer/guides/managing-disks-and-snapshots', @@ -67,6 +70,10 @@ export const docLinks = { href: links.accessDocs, linkText: 'Access Control', }, + affinity: { + href: links.affinityDocs, + linkText: 'Anti-Affinity Groups', + }, disks: { href: links.disksDocs, linkText: 'Disks and Snapshots', From 5e5b3ee752346aff2f9246306be7fb4162f1e621 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 3 Apr 2025 09:22:08 -0700 Subject: [PATCH 34/56] A few more refactors / PR comments --- app/forms/anti-affinity-group-create.tsx | 24 +++++++------------ app/pages/project/affinity/AffinityPage.tsx | 1 - .../affinity/AntiAffinityGroupPage.tsx | 7 +++--- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx index 4b8e6c9287..342158ea33 100644 --- a/app/forms/anti-affinity-group-create.tsx +++ b/app/forms/anti-affinity-group-create.tsx @@ -5,15 +5,11 @@ * * Copyright Oxide Computer Company */ +import { useQuery } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' -import { - queryClient, - useApiMutation, - usePrefetchedQuery, - type AntiAffinityGroupCreate, -} from '@oxide/api' +import { queryClient, useApiMutation, type AntiAffinityGroupCreate } from '@oxide/api' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' @@ -25,16 +21,13 @@ import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' -import { affinityGroupList, antiAffinityGroupList } from './affinity-util' +import { antiAffinityGroupList } from './affinity-util' export const handle = titleCrumb('New anti-affinity group') -export async function clientLoader({ params }: LoaderFunctionArgs) { +export function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) - await Promise.all([ - queryClient.prefetchQuery(antiAffinityGroupList({ project })), - queryClient.prefetchQuery(affinityGroupList({ project })), - ]) + queryClient.prefetchQuery(antiAffinityGroupList({ project })) return null } @@ -52,9 +45,7 @@ export default function CreateAntiAffintyGroupForm() { }, }) - const { - data: { items: existingAntiAffinityGroups }, - } = usePrefetchedQuery(antiAffinityGroupList({ project })) + const { data: existingAntiAffinityGroups } = useQuery(antiAffinityGroupList({ project })) const defaultValues = { name: '', description: '', @@ -78,6 +69,7 @@ export default function CreateAntiAffintyGroupForm() { }) }} loading={createAntiAffinityGroup.isPending} + submitDisabled={existingAntiAffinityGroups === undefined ? 'Loading …' : undefined} submitError={createAntiAffinityGroup.error} submitLabel="Add group" > @@ -85,7 +77,7 @@ export default function CreateAntiAffintyGroupForm() { name="name" control={control} validate={(name) => { - if (existingAntiAffinityGroups.find((g) => g.name === name)) { + if (existingAntiAffinityGroups?.items.find((g) => g.name === name)) { return 'Name taken. To update an existing group, edit it directly.' } }} diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index aeeab7f6b2..0f2fcc3ed1 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -82,7 +82,6 @@ const staticCols = [ header: 'members', cell: (info) => , }), - colHelper.accessor('id', Columns.id), colHelper.accessor('timeCreated', Columns.timeCreated), ] diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index dfdc076861..1be2be4892 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -39,8 +39,8 @@ import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' +import { Button } from '~/ui/lib/Button' import { CardBlock } from '~/ui/lib/CardBlock' -import { CreateButton } from '~/ui/lib/CreateButton' import { Divider } from '~/ui/lib/Divider' import * as DropdownMenu from '~/ui/lib/DropdownMenu' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -203,13 +203,14 @@ export default function AntiAffinityPage() { title="Members" description="Instances in this anti-affinity group" > - setIsModalOpen(true)} disabled={!availableInstances.length} disabledReason={disabledReason()} > Add instance - + {membersCount ?
: } From 3fe3fc2438e24f556ff4fcd5fbb2661af34efec7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 3 Apr 2025 09:36:05 -0700 Subject: [PATCH 35/56] routing fix --- app/routes.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes.tsx b/app/routes.tsx index 6dd1b5397e..f5b01da296 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -506,7 +506,7 @@ export const routes = createRoutesFromElements( > import('./forms/anti-affinity-group-create').then(convert)} + lazy={() => import('./forms/anti-affinity-group-create')} /> From 8bcb26bfef3a91f2a8be1867c1b0fe23f78735e4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 3 Apr 2025 10:18:10 -0700 Subject: [PATCH 36/56] reintroduce convert in routes for group create --- app/forms/anti-affinity-group-create.tsx | 5 +++-- app/routes.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx index 342158ea33..f8d2250317 100644 --- a/app/forms/anti-affinity-group-create.tsx +++ b/app/forms/anti-affinity-group-create.tsx @@ -25,10 +25,11 @@ import { antiAffinityGroupList } from './affinity-util' export const handle = titleCrumb('New anti-affinity group') -export function clientLoader({ params }: LoaderFunctionArgs) { +export async function clientLoader({ params }: LoaderFunctionArgs) { const { project } = getProjectSelector(params) queryClient.prefetchQuery(antiAffinityGroupList({ project })) - return null + // the async demands a promise, so this just returns a promise that resolves to null + return Promise.resolve(null) } export default function CreateAntiAffintyGroupForm() { diff --git a/app/routes.tsx b/app/routes.tsx index f5b01da296..6dd1b5397e 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -506,7 +506,7 @@ export const routes = createRoutesFromElements( > import('./forms/anti-affinity-group-create')} + lazy={() => import('./forms/anti-affinity-group-create').then(convert)} /> From 36884e5bf4bac531e972fcb82606628424f1f333 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 3 Apr 2025 12:43:27 -0700 Subject: [PATCH 37/56] update max members value --- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 1be2be4892..1d9637ebbb 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -157,8 +157,8 @@ export default function AntiAffinityPage() { }) const disabledReason = () => { - // TODO: Verify maximum number of members - if (membersCount >= 16) { + // https://github.com/oxidecomputer/omicron/blob/main/nexus/db-queries/src/db/datastore/affinity.rs#L66 + if (membersCount >= 32) { return 'Maximum number of members reached' } if (!instances.items.length) { From 8e5e31ca5ce3ca6bf0dd705d8f450ca837ef751a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 3 Apr 2025 13:56:44 -0700 Subject: [PATCH 38/56] Link to specific commit for line reference stability --- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 1d9637ebbb..1e8e5e33cb 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -157,7 +157,7 @@ export default function AntiAffinityPage() { }) const disabledReason = () => { - // https://github.com/oxidecomputer/omicron/blob/main/nexus/db-queries/src/db/datastore/affinity.rs#L66 + // https://github.com/oxidecomputer/omicron/blob/77c4136a767d4d1365c3ad715a335da9035415db/nexus/db-queries/src/db/datastore/affinity.rs#L66 if (membersCount >= 32) { return 'Maximum number of members reached' } From e27d8546c90c024a30f006f0d7287afbdefe7232 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 3 Apr 2025 17:38:53 -0700 Subject: [PATCH 39/56] Add e2e tests --- app/forms/anti-affinity-group-edit.tsx | 2 +- .../affinity/AntiAffinityGroupPage.tsx | 4 +- test/e2e/anti-affinity.e2e.ts | 128 ++++++++++++++++++ 3 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 test/e2e/anti-affinity.e2e.ts diff --git a/app/forms/anti-affinity-group-edit.tsx b/app/forms/anti-affinity-group-edit.tsx index c9f65fe1a2..415aff532b 100644 --- a/app/forms/anti-affinity-group-edit.tsx +++ b/app/forms/anti-affinity-group-edit.tsx @@ -78,7 +78,7 @@ export default function EditAntiAffintyGroupForm() { form={form} formType="create" resourceName="rule" - title="Add anti-affinity group" + title="Edit anti-affinity group" onDismiss={onDismiss} onSubmit={(values) => { editAntiAffinityGroup.mutate({ diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 1e8e5e33cb..b636505978 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -21,7 +21,6 @@ import { import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { - affinityGroupList, antiAffinityGroupMemberList, antiAffinityGroupView, instanceList, @@ -62,7 +61,6 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { queryClient.prefetchQuery(antiAffinityGroupView({ antiAffinityGroup, project })), queryClient.prefetchQuery(antiAffinityGroupMemberList({ antiAffinityGroup, project })), queryClient.prefetchQuery(instanceList({ project })), - queryClient.prefetchQuery(affinityGroupList({ project })), ]) return null } @@ -141,7 +139,7 @@ export default function AntiAffinityPage() { const columns = useColsWithActions( [ colHelper.accessor('value.name', { - header: 'Name', + header: 'name', cell: makeLinkCell((instance) => pb.instance({ project, instance })), }), colHelper.accessor('value.runState', Columns.instanceState), diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts new file mode 100644 index 0000000000..42d97c9da1 --- /dev/null +++ b/test/e2e/anti-affinity.e2e.ts @@ -0,0 +1,128 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { expect, test } from '@playwright/test' + +import { clickRowAction, expectRowVisible } from './utils' + +test('can nav to Affinity from /', async ({ page }) => { + await page.goto('/') + await page.getByRole('table').getByRole('link', { name: 'mock-project' }).click() + await page.getByRole('link', { name: 'Affinity' }).click() + + await expectRowVisible(page.getByRole('table'), { + name: 'romulus-remus', + type: 'anti-affinity', + policy: 'fail', + members: 'db1/db2', + }) + + // click the anti-affinity group name cell to go to the view page + await page.getByRole('link', { name: 'romulus-remus' }).click() + + await expect(page.getByRole('heading', { name: 'romulus-remus' })).toBeVisible() + await expect(page).toHaveURL('/projects/mock-project/affinity/romulus-remus') + await expect(page).toHaveTitle( + 'romulus-remus / Affinity / mock-project / Projects / Oxide Console' + ) +}) + +test('can add a new anti-affinity group', async ({ page }) => { + await page.goto('/projects/mock-project/affinity') + await page.getByRole('link', { name: 'New group' }).click() + await expect(page).toHaveURL('/projects/mock-project/affinity-new') + await expect(page.getByRole('heading', { name: 'Add anti-affinity group' })).toBeVisible() + + // fill out the form + await page.getByLabel('Name').fill('new-anti-affinity-group') + await page + .getByRole('textbox', { name: 'Description' }) + .fill('this is a new anti-affinity group') + await page.getByRole('radio', { name: 'Fail' }).click() + + // submit the form + await page.getByRole('button', { name: 'Add group' }).click() + + // check that we are on the view page for the new anti-affinity group + await expect(page).toHaveURL('/projects/mock-project/affinity/new-anti-affinity-group') + + // add a member to the new anti-affinity group + await page.getByRole('button', { name: 'Add instance' }).click() + await expect(page.getByRole('heading', { name: 'Add instance to group' })).toBeVisible() + await page.getByRole('combobox', { name: 'Instance' }).fill('db1') + await page.getByRole('option', { name: 'db1' }).click() + await page.getByRole('button', { name: 'Add to group' }).click() + + const cell = page.getByRole('cell', { name: 'db1' }) + await expect(cell).toBeVisible() + + // remove the instance from the group + await clickRowAction(page, 'db1', 'Remove from group') + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(cell).toBeHidden() + + // expect empty message + await expect(page.getByText('No anti-affinity group members')).toBeVisible() +}) + +// edit an anti-affinity group from the view page +test('can edit an anti-affinity group', async ({ page }) => { + await page.goto('/projects/mock-project/affinity/romulus-remus') + await page.getByRole('button', { name: 'Anti-affinity group actions' }).click() + await page.getByRole('menuitem', { name: 'Edit' }).click() + + // can see Add anti-affinity group header + await expect( + page.getByRole('heading', { name: 'Edit anti-affinity group' }) + ).toBeVisible() + + // change the name to romulus-remus-2 + await page.getByLabel('Name').fill('romulus-remus-2') + await page.getByRole('button', { name: 'Edit group' }).click() + await expect(page).toHaveURL('/projects/mock-project/affinity/romulus-remus-2') + await expect(page.getByRole('heading', { name: 'romulus-remus-2' })).toBeVisible() +}) + +// delete an anti-affinity group +test('can delete an anti-affinity group', async ({ page }) => { + await page.goto('/projects/mock-project/affinity') + await clickRowAction(page, 'set-osiris', 'Delete') + + await expect( + page.getByRole('heading', { name: 'Delete anti-affinity group' }) + ).toBeVisible() + + // confirm the deletion + await page.getByRole('button', { name: 'Confirm' }).click() + + // check that we are back on the affinity page + await expect(page).toHaveURL('/projects/mock-project/affinity') + + // can't see set-osiris in the table + await expect(page.getByRole('table').getByText('set-osiris')).toBeHidden() + + // can create a new anti-affinity group with the same name + await page.getByRole('link', { name: 'New group' }).click() + await expect(page).toHaveURL('/projects/mock-project/affinity-new') + await expect(page.getByRole('heading', { name: 'Add anti-affinity group' })).toBeVisible() + await page.getByLabel('Name').fill('set-osiris') + await page + .getByRole('textbox', { name: 'Description' }) + .fill('this is a new anti-affinity group') + await page.getByRole('radio', { name: 'Fail' }).click() + await page.getByRole('button', { name: 'Add group' }).click() + + await expect(page).toHaveURL('/projects/mock-project/affinity/set-osiris') + await expect(page.getByRole('heading', { name: 'set-osiris' })).toBeVisible() + + // click on Affinity in crumbs + await page.getByRole('link', { name: 'Affinity' }).first().click() + await expect(page).toHaveURL('/projects/mock-project/affinity') + // check that we can see the new anti-affinity group in the table + await expect(page.getByRole('table').getByText('set-osiris')).toBeVisible() +}) From ff31223bc3ef4e3281ff88e66d23d857d8552b82 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 4 Apr 2025 09:53:31 -0700 Subject: [PATCH 40/56] Refresh of Affinity Groups table columns; use count in place of instance names --- app/pages/project/affinity/AffinityPage.tsx | 28 +++------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 0f2fcc3ed1..9acbba69cc 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -6,10 +6,9 @@ * Copyright Oxide Computer Company */ -import { useQuery } from '@tanstack/react-query' import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback } from 'react' -import { Link, Outlet, type LoaderFunctionArgs } from 'react-router' +import { Outlet, type LoaderFunctionArgs } from 'react-router' import { queryClient, @@ -35,9 +34,7 @@ import { Badge } from '~/ui/lib/Badge' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { Slash } from '~/ui/lib/Slash' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' -import { intersperse } from '~/util/array' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' @@ -70,7 +67,6 @@ const AffinityGroupPolicyBadge = ({ policy, className }: AffinityGroupPolicyBadg ) const staticCols = [ - colHelper.accessor('description', Columns.description), colHelper.accessor(() => {}, { header: 'type', cell: () => anti-affinity, @@ -82,6 +78,7 @@ const staticCols = [ header: 'members', cell: (info) => , }), + colHelper.accessor('description', Columns.description), colHelper.accessor('timeCreated', Columns.timeCreated), ] @@ -189,33 +186,16 @@ export const AntiAffinityGroupEmptyState = () => ( ) -// TODO: Use the prefetched query export const AffinityGroupMembersCell = ({ antiAffinityGroup, }: { antiAffinityGroup: string }) => { const { project } = useProjectSelector() - const { data: members } = useQuery( + const { data: members } = usePrefetchedQuery( antiAffinityGroupMemberList({ antiAffinityGroup, project }) ) - if (!members) return if (!members.items.length) return - - const instances = members.items.map((member) => member.value.name) - const instancesToShow = instances.slice(0, 2) - const links = instancesToShow.map((instance) => ( - - {instance} - - )) - if (instances.length > instancesToShow.length) { - links.push(<>…) - } - return
{intersperse(links, )}
+ return <>{members.items.length} } From 4c7f8f3efc258874438e50b941ed0fd9323d5dab Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 4 Apr 2025 09:54:55 -0700 Subject: [PATCH 41/56] update test --- test/e2e/anti-affinity.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts index 42d97c9da1..835f3a34c2 100644 --- a/test/e2e/anti-affinity.e2e.ts +++ b/test/e2e/anti-affinity.e2e.ts @@ -19,7 +19,7 @@ test('can nav to Affinity from /', async ({ page }) => { name: 'romulus-remus', type: 'anti-affinity', policy: 'fail', - members: 'db1/db2', + members: '2', }) // click the anti-affinity group name cell to go to the view page From ee4e2f193ebda6f6a54540d80714c0c7b85146e3 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Apr 2025 14:34:51 -0500 Subject: [PATCH 42/56] don't fetch affinity groups --- app/forms/affinity-util.tsx | 3 --- app/forms/anti-affinity-group-edit.tsx | 7 +------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/forms/affinity-util.tsx b/app/forms/affinity-util.tsx index 1dac1b6992..275da1bd50 100644 --- a/app/forms/affinity-util.tsx +++ b/app/forms/affinity-util.tsx @@ -13,9 +13,6 @@ import type * as PP from '~/util/path-params' export const instanceList = ({ project }: PP.Project) => apiq('instanceList', { query: { project, limit: ALL_ISH } }) -export const affinityGroupList = ({ project }: PP.Project) => - apiq('affinityGroupList', { query: { project, limit: ALL_ISH } }) - export const antiAffinityGroupList = ({ project }: PP.Project) => apiq('antiAffinityGroupList', { query: { project, limit: ALL_ISH } }) diff --git a/app/forms/anti-affinity-group-edit.tsx b/app/forms/anti-affinity-group-edit.tsx index 415aff532b..97bae3f100 100644 --- a/app/forms/anti-affinity-group-edit.tsx +++ b/app/forms/anti-affinity-group-edit.tsx @@ -27,11 +27,7 @@ import { import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' -import { - affinityGroupList, - antiAffinityGroupList, - antiAffinityGroupView, -} from './affinity-util' +import { antiAffinityGroupList, antiAffinityGroupView } from './affinity-util' export const handle = titleCrumb('New anti-affinity group') @@ -39,7 +35,6 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, antiAffinityGroup } = getAntiAffinityGroupSelector(params) await Promise.all([ queryClient.prefetchQuery(antiAffinityGroupList({ project })), - queryClient.prefetchQuery(affinityGroupList({ project })), queryClient.prefetchQuery(antiAffinityGroupView({ project, antiAffinityGroup })), ]) return null From fb18dd3ae9c18be521562e949607a66849cdd8e8 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Apr 2025 14:55:26 -0500 Subject: [PATCH 43/56] members col -> instances, don't validate name uniqueness --- app/forms/anti-affinity-group-create.tsx | 32 +++---------------- app/forms/anti-affinity-group-edit.tsx | 35 ++++++--------------- app/pages/project/affinity/AffinityPage.tsx | 11 ++++--- test/e2e/anti-affinity.e2e.ts | 2 +- 4 files changed, 22 insertions(+), 58 deletions(-) diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx index f8d2250317..7de55e8d5d 100644 --- a/app/forms/anti-affinity-group-create.tsx +++ b/app/forms/anti-affinity-group-create.tsx @@ -5,9 +5,8 @@ * * Copyright Oxide Computer Company */ -import { useQuery } from '@tanstack/react-query' import { useForm } from 'react-hook-form' -import { useNavigate, type LoaderFunctionArgs } from 'react-router' +import { useNavigate } from 'react-router' import { queryClient, useApiMutation, type AntiAffinityGroupCreate } from '@oxide/api' @@ -17,36 +16,25 @@ import { RadioField } from '~/components/form/fields/RadioField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' -import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' -import { antiAffinityGroupList } from './affinity-util' - export const handle = titleCrumb('New anti-affinity group') -export async function clientLoader({ params }: LoaderFunctionArgs) { - const { project } = getProjectSelector(params) - queryClient.prefetchQuery(antiAffinityGroupList({ project })) - // the async demands a promise, so this just returns a promise that resolves to null - return Promise.resolve(null) -} - export default function CreateAntiAffintyGroupForm() { const { project } = useProjectSelector() const navigate = useNavigate() - const onDismiss = () => navigate(pb.affinity({ project })) const createAntiAffinityGroup = useApiMutation('antiAffinityGroupCreate', { onSuccess(antiAffinityGroup) { + queryClient.invalidateEndpoint('antiAffinityGroupList') navigate(pb.antiAffinityGroup({ project, antiAffinityGroup: antiAffinityGroup.name })) addToast(<>Anti-affinity group {antiAffinityGroup.name} created) // prettier-ignore - queryClient.invalidateQueries(antiAffinityGroupList({ project })) }, }) - const { data: existingAntiAffinityGroups } = useQuery(antiAffinityGroupList({ project })) const defaultValues = { name: '', description: '', @@ -62,7 +50,7 @@ export default function CreateAntiAffintyGroupForm() { formType="create" resourceName="rule" title="Add anti-affinity group" - onDismiss={onDismiss} + onDismiss={() => navigate(pb.affinity({ project }))} onSubmit={(values) => { createAntiAffinityGroup.mutate({ query: { project }, @@ -70,21 +58,11 @@ export default function CreateAntiAffintyGroupForm() { }) }} loading={createAntiAffinityGroup.isPending} - submitDisabled={existingAntiAffinityGroups === undefined ? 'Loading …' : undefined} submitError={createAntiAffinityGroup.error} submitLabel="Add group" > - { - if (existingAntiAffinityGroups?.items.find((g) => g.name === name)) { - return 'Name taken. To update an existing group, edit it directly.' - } - }} - /> + - navigate(pb.antiAffinityGroup({ project, antiAffinityGroup })) const editAntiAffinityGroup = useApiMutation('antiAffinityGroupUpdate', { - onSuccess(updatedAntiAffinityGroup) { - navigate( - pb.antiAffinityGroup({ project, antiAffinityGroup: updatedAntiAffinityGroup.name }) - ) - addToast(<>Anti-affinity group {updatedAntiAffinityGroup.name} updated) // prettier-ignore - queryClient.invalidateQueries(antiAffinityGroupList({ project })) + onSuccess(updatedGroup) { + queryClient.invalidateEndpoint('antiAffinityGroupView') + queryClient.invalidateEndpoint('antiAffinityGroupList') + navigate(pb.antiAffinityGroup({ project, antiAffinityGroup: updatedGroup.name })) + addToast(<>Anti-affinity group {updatedGroup.name} updated) // prettier-ignore }, }) - const { data: existingAntiAffinityGroups } = usePrefetchedQuery( - antiAffinityGroupList({ project }) - ) - const { data: antiAffinityGroupData } = usePrefetchedQuery( antiAffinityGroupView({ project, antiAffinityGroup }) ) @@ -74,7 +65,7 @@ export default function EditAntiAffintyGroupForm() { formType="create" resourceName="rule" title="Edit anti-affinity group" - onDismiss={onDismiss} + onDismiss={() => navigate(pb.antiAffinityGroup({ project, antiAffinityGroup }))} onSubmit={(values) => { editAntiAffinityGroup.mutate({ path: { antiAffinityGroup }, @@ -86,15 +77,7 @@ export default function EditAntiAffintyGroupForm() { submitError={editAntiAffinityGroup.error} submitLabel="Edit group" > - { - if (existingAntiAffinityGroups.items.find((g) => g.name === name)) { - return 'Name taken. To update an existing group, edit it directly.' - } - }} - /> + ) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 9acbba69cc..2fd769da6a 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -25,6 +25,7 @@ import { antiAffinityGroupList, antiAffinityGroupMemberList } from '~/forms/affi import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' +import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -71,14 +72,16 @@ const staticCols = [ header: 'type', cell: () => anti-affinity, }), - colHelper.accessor('policy', { - cell: (info) => , + colHelper.accessor('description', { + cell: (info) => , }), colHelper.accessor('name', { - header: 'members', + header: 'instances', cell: (info) => , }), - colHelper.accessor('description', Columns.description), + colHelper.accessor('policy', { + cell: (info) => , + }), colHelper.accessor('timeCreated', Columns.timeCreated), ] diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts index 835f3a34c2..451f9137b0 100644 --- a/test/e2e/anti-affinity.e2e.ts +++ b/test/e2e/anti-affinity.e2e.ts @@ -19,7 +19,7 @@ test('can nav to Affinity from /', async ({ page }) => { name: 'romulus-remus', type: 'anti-affinity', policy: 'fail', - members: '2', + instances: '2', }) // click the anti-affinity group name cell to go to the view page From f635780fa78255143a4d2cdbb25c31b70ecf90d0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Apr 2025 15:01:34 -0500 Subject: [PATCH 44/56] add delete and docs popover to group detail, use confirmDelete --- app/components/AffinityDocsPopover.tsx | 21 ++++++++++ app/forms/anti-affinity-group-create.tsx | 20 ++++----- app/forms/anti-affinity-group-edit.tsx | 10 ++--- app/pages/project/affinity/AffinityPage.tsx | 41 ++++++------------- .../affinity/AntiAffinityGroupPage.tsx | 30 +++++++++++++- test/e2e/anti-affinity.e2e.ts | 22 +++++++++- 6 files changed, 98 insertions(+), 46 deletions(-) create mode 100644 app/components/AffinityDocsPopover.tsx diff --git a/app/components/AffinityDocsPopover.tsx b/app/components/AffinityDocsPopover.tsx new file mode 100644 index 0000000000..f52f37c89e --- /dev/null +++ b/app/components/AffinityDocsPopover.tsx @@ -0,0 +1,21 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { Affinity16Icon } from '@oxide/design-system/icons/react' + +import { docLinks } from '~/util/links' + +import { DocsPopover } from './DocsPopover' + +export const AffinityDocsPopover = () => ( + } + summary="Instances in an anti-affinity group will be placed on different sleds when they start. The policy controls whether this is a hard or soft constraint." + links={[docLinks.affinity]} + /> +) diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx index 7de55e8d5d..290875226b 100644 --- a/app/forms/anti-affinity-group-create.tsx +++ b/app/forms/anti-affinity-group-create.tsx @@ -22,6 +22,12 @@ import { pb } from '~/util/path-builder' export const handle = titleCrumb('New anti-affinity group') +const defaultValues: Omit = { + name: '', + description: '', + policy: 'allow', +} + export default function CreateAntiAffintyGroupForm() { const { project } = useProjectSelector() @@ -35,13 +41,7 @@ export default function CreateAntiAffintyGroupForm() { }, }) - const defaultValues = { - name: '', - description: '', - failureDomain: 'sled' as const, - policy: 'allow' as const, - } - const form = useForm({ defaultValues }) + const form = useForm({ defaultValues }) const control = form.control return ( @@ -51,12 +51,12 @@ export default function CreateAntiAffintyGroupForm() { resourceName="rule" title="Add anti-affinity group" onDismiss={() => navigate(pb.affinity({ project }))} - onSubmit={(values) => { + onSubmit={(values) => createAntiAffinityGroup.mutate({ query: { project }, - body: { ...values }, + body: { ...values, failureDomain: 'sled' }, }) - }} + } loading={createAntiAffinityGroup.isPending} submitError={createAntiAffinityGroup.error} submitLabel="Add group" diff --git a/app/forms/anti-affinity-group-edit.tsx b/app/forms/anti-affinity-group-edit.tsx index f8dfd8d43d..e098c05f6a 100644 --- a/app/forms/anti-affinity-group-edit.tsx +++ b/app/forms/anti-affinity-group-edit.tsx @@ -7,6 +7,7 @@ */ import { useForm } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' +import * as R from 'remeda' import { queryClient, @@ -51,13 +52,12 @@ export default function EditAntiAffintyGroupForm() { }, }) - const { data: antiAffinityGroupData } = usePrefetchedQuery( + const { data: group } = usePrefetchedQuery( antiAffinityGroupView({ project, antiAffinityGroup }) ) - const form = useForm({ - defaultValues: { ...antiAffinityGroupData }, - }) + const defaultValues: AntiAffinityGroupUpdate = R.pick(group, ['name', 'description']) + const form = useForm({ defaultValues }) return ( - deleteGroup({ - path: { antiAffinityGroup: antiAffinityGroup.name }, - query: { project }, - }), - modalTitle: 'Delete anti-affinity group', - modalContent: ( -

- Are you sure you want to delete the anti-affinity group{' '} - {antiAffinityGroup.name}? -

- ), - errorTitle: `Error removing ${antiAffinityGroup.name}`, - }) - }, + onActivate: confirmDelete({ + doDelete: () => + deleteGroup({ + path: { antiAffinityGroup: antiAffinityGroup.name }, + query: { project }, + }), + label: antiAffinityGroup.name, + resourceKind: 'anti-affinity group', + }), }, ], [project, deleteGroup] @@ -159,12 +149,7 @@ export default function AffinityPage() { <> }>Affinity - } - summary="Instances in an anti-affinity group will be placed on different sleds when they start. The policy attribute controls whether this is a hard or soft constraint." - links={[docLinks.affinity]} - />{' '} + New group diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index b636505978..4eda5d869a 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -8,7 +8,7 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback, useState } from 'react' -import { Outlet, type LoaderFunctionArgs } from 'react-router' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' import { Affinity24Icon } from '@oxide/design-system/icons/react' @@ -18,6 +18,7 @@ import { usePrefetchedQuery, type AntiAffinityGroupMember, } from '~/api' +import { AffinityDocsPopover } from '~/components/AffinityDocsPopover' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { @@ -32,6 +33,7 @@ import { useAntiAffinityGroupSelector, } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' +import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -102,6 +104,17 @@ export default function AntiAffinityPage() { }, } ) + + const navigate = useNavigate() + + const { mutateAsync: deleteGroup } = useApiMutation('antiAffinityGroupDelete', { + onSuccess() { + navigate(pb.affinity({ project })) + queryClient.invalidateEndpoint('antiAffinityGroupList') + addToast(<>Anti-affinity group {group.name} deleted) // prettier-ignore + }, + }) + // useState is at this level so the CreateButton can open the modal const [isModalOpen, setIsModalOpen] = useState(false) @@ -173,13 +186,26 @@ export default function AntiAffinityPage() { }>{name}
- {/* TODO: Add a DocsPopover with docLinks.affinity once the doc page exists */} + Edit + + deleteGroup({ + path: { antiAffinityGroup: group.name }, + query: { project }, + }), + label: group.name, + resourceKind: 'anti-affinity group', + })} + className="destructive" + />
diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts index 451f9137b0..fde1952c2c 100644 --- a/test/e2e/anti-affinity.e2e.ts +++ b/test/e2e/anti-affinity.e2e.ts @@ -8,7 +8,7 @@ import { expect, test } from '@playwright/test' -import { clickRowAction, expectRowVisible } from './utils' +import { clickRowAction, closeToast, expectRowVisible } from './utils' test('can nav to Affinity from /', async ({ page }) => { await page.goto('/') @@ -126,3 +126,23 @@ test('can delete an anti-affinity group', async ({ page }) => { // check that we can see the new anti-affinity group in the table await expect(page.getByRole('table').getByText('set-osiris')).toBeVisible() }) + +test('can delete anti-affinity group from detail page', async ({ page }) => { + await page.goto('/projects/mock-project/affinity/romulus-remus') + + const modal = page.getByRole('dialog', { name: 'Confirm delete' }) + await expect(modal).toBeHidden() + + await page.getByLabel('Anti-affinity group actions').click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + + await expect(modal).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + + // modal closes, row is gone + await expect(modal).toBeHidden() + await closeToast(page) + await expect(page).toHaveURL('/projects/mock-project/affinity') + await expectRowVisible(page.getByRole('table'), { name: 'set-osiris' }) + await expect(page.getByRole('cell', { name: 'romulus-remus' })).toBeHidden() +}) From ff32750948d09d0a770ee6c94d650ed488e9669e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Apr 2025 16:19:41 -0500 Subject: [PATCH 45/56] help text on policy field and tip icon on policy columns --- app/components/AffinityDocsPopover.tsx | 10 +++++++++- app/forms/affinity-util.tsx | 3 +++ app/forms/anti-affinity-group-create.tsx | 5 ++++- app/pages/project/affinity/AffinityPage.tsx | 3 ++- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 4 ++-- app/pages/project/instances/AntiAffinityCard.tsx | 2 ++ app/ui/lib/PropertiesTable.tsx | 2 +- test/e2e/anti-affinity.e2e.ts | 2 +- 8 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/components/AffinityDocsPopover.tsx b/app/components/AffinityDocsPopover.tsx index f52f37c89e..b86159884e 100644 --- a/app/components/AffinityDocsPopover.tsx +++ b/app/components/AffinityDocsPopover.tsx @@ -7,6 +7,8 @@ */ import { Affinity16Icon } from '@oxide/design-system/icons/react' +import { policyHelpText } from '~/forms/affinity-util' +import { TipIcon } from '~/ui/lib/TipIcon' import { docLinks } from '~/util/links' import { DocsPopover } from './DocsPopover' @@ -15,7 +17,13 @@ export const AffinityDocsPopover = () => ( } - summary="Instances in an anti-affinity group will be placed on different sleds when they start. The policy controls whether this is a hard or soft constraint." + summary="Instances in an anti-affinity group will be placed on different sleds when they start." links={[docLinks.affinity]} /> ) + +export const AffinityPolicyHeader = () => ( + <> + Policy{policyHelpText} + +) diff --git a/app/forms/affinity-util.tsx b/app/forms/affinity-util.tsx index 275da1bd50..a2fd247e33 100644 --- a/app/forms/affinity-util.tsx +++ b/app/forms/affinity-util.tsx @@ -31,3 +31,6 @@ export const antiAffinityGroupMemberList = ({ // member limit in DB is currently 32, so pagination isn't needed query: { project, limit: ALL_ISH }, }) + +export const policyHelpText = + 'Whether instances are allowed to start when every available sled already has a group member on it' diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx index 290875226b..0e80f65a19 100644 --- a/app/forms/anti-affinity-group-create.tsx +++ b/app/forms/anti-affinity-group-create.tsx @@ -20,6 +20,8 @@ import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' +import { policyHelpText } from './affinity-util' + export const handle = titleCrumb('New anti-affinity group') const defaultValues: Omit = { @@ -28,7 +30,7 @@ const defaultValues: Omit = { policy: 'allow', } -export default function CreateAntiAffintyGroupForm() { +export default function CreateAntiAffinityGroupForm() { const { project } = useProjectSelector() const navigate = useNavigate() @@ -65,6 +67,7 @@ export default function CreateAntiAffintyGroupForm() { , }), colHelper.accessor('policy', { + header: AffinityPolicyHeader, cell: (info) => , }), colHelper.accessor('timeCreated', Columns.timeCreated), diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 4eda5d869a..7a8b0c8faa 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -18,7 +18,7 @@ import { usePrefetchedQuery, type AntiAffinityGroupMember, } from '~/api' -import { AffinityDocsPopover } from '~/components/AffinityDocsPopover' +import { AffinityDocsPopover, AffinityPolicyHeader } from '~/components/AffinityDocsPopover' import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { @@ -214,7 +214,7 @@ export default function AntiAffinityPage() { anti-affinity - + }> {policy} diff --git a/app/pages/project/instances/AntiAffinityCard.tsx b/app/pages/project/instances/AntiAffinityCard.tsx index 95c4649fcb..7063687597 100644 --- a/app/pages/project/instances/AntiAffinityCard.tsx +++ b/app/pages/project/instances/AntiAffinityCard.tsx @@ -17,6 +17,7 @@ import { } from '@oxide/api' import { Affinity24Icon } from '@oxide/design-system/icons/react' +import { AffinityPolicyHeader } from '~/components/AffinityDocsPopover' import { useInstanceSelector } from '~/hooks/use-params' import { makeLinkCell } from '~/table/cells/LinkCell' import { Columns } from '~/table/columns/common' @@ -39,6 +40,7 @@ const colHelper = createColumnHelper() const staticCols = [ colHelper.accessor('description', Columns.description), colHelper.accessor('policy', { + header: AffinityPolicyHeader, cell: (info) => {info.getValue()}, }), ] diff --git a/app/ui/lib/PropertiesTable.tsx b/app/ui/lib/PropertiesTable.tsx index 9162cb4eae..8101ea8b30 100644 --- a/app/ui/lib/PropertiesTable.tsx +++ b/app/ui/lib/PropertiesTable.tsx @@ -54,7 +54,7 @@ export function PropertiesTable({ } interface PropertiesTableRowProps { - label: string + label: ReactNode children: ReactNode } PropertiesTable.Row = ({ label, children }: PropertiesTableRowProps) => ( diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts index fde1952c2c..a1c7d3d869 100644 --- a/test/e2e/anti-affinity.e2e.ts +++ b/test/e2e/anti-affinity.e2e.ts @@ -18,7 +18,7 @@ test('can nav to Affinity from /', async ({ page }) => { await expectRowVisible(page.getByRole('table'), { name: 'romulus-remus', type: 'anti-affinity', - policy: 'fail', + Policy: 'fail', instances: '2', }) From 872feac6ccf4196a112cd11ee3161c3a10864414 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 4 Apr 2025 14:39:01 -0700 Subject: [PATCH 46/56] merge main and resolve conflicts with AffinityGroupPolicyBadge --- app/pages/project/affinity/AffinityPage.tsx | 14 ++++---------- .../project/affinity/AntiAffinityGroupPage.tsx | 3 ++- app/pages/project/instances/AntiAffinityCard.tsx | 4 ++-- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 89d2c74277..bef8193545 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -55,16 +55,10 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const colHelper = createColumnHelper() -type AffinityGroupPolicyBadgeProps = { policy: AffinityPolicy; className?: string } -const AffinityGroupPolicyBadge = ({ policy, className }: AffinityGroupPolicyBadgeProps) => ( - - {policy} - -) +export const AffinityGroupPolicyBadge = ({ policy }: { policy: AffinityPolicy }) => { + const variant = { allow: 'default' as const, fail: 'solid' as const }[policy] + return {policy} // prettier-ignore +} const staticCols = [ colHelper.accessor(() => {}, { diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 7a8b0c8faa..0b5b1fb3d8 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -32,6 +32,7 @@ import { getAntiAffinityGroupSelector, useAntiAffinityGroupSelector, } from '~/hooks/use-params' +import { AffinityGroupPolicyBadge } from '~/pages/project/affinity/AffinityPage' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -215,7 +216,7 @@ export default function AntiAffinityPage() { }> - {policy} + {membersCount} diff --git a/app/pages/project/instances/AntiAffinityCard.tsx b/app/pages/project/instances/AntiAffinityCard.tsx index 7063687597..6d4c237a7e 100644 --- a/app/pages/project/instances/AntiAffinityCard.tsx +++ b/app/pages/project/instances/AntiAffinityCard.tsx @@ -19,10 +19,10 @@ import { Affinity24Icon } from '@oxide/design-system/icons/react' import { AffinityPolicyHeader } from '~/components/AffinityDocsPopover' import { useInstanceSelector } from '~/hooks/use-params' +import { AffinityGroupPolicyBadge } from '~/pages/project/affinity/AffinityPage' import { makeLinkCell } from '~/table/cells/LinkCell' import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' -import { Badge } from '~/ui/lib/Badge' import { CardBlock } from '~/ui/lib/CardBlock' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TableEmptyBox } from '~/ui/lib/Table' @@ -41,7 +41,7 @@ const staticCols = [ colHelper.accessor('description', Columns.description), colHelper.accessor('policy', { header: AffinityPolicyHeader, - cell: (info) => {info.getValue()}, + cell: (info) => , }), ] From 08b81e1df6050978374de657cb67569849111d74 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Apr 2025 17:17:07 -0500 Subject: [PATCH 47/56] put back the line about policy in the popover --- app/components/AffinityDocsPopover.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/AffinityDocsPopover.tsx b/app/components/AffinityDocsPopover.tsx index b86159884e..5d4ec1101a 100644 --- a/app/components/AffinityDocsPopover.tsx +++ b/app/components/AffinityDocsPopover.tsx @@ -17,7 +17,7 @@ export const AffinityDocsPopover = () => ( } - summary="Instances in an anti-affinity group will be placed on different sleds when they start." + summary="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." links={[docLinks.affinity]} /> ) From ee98abbbce772e6e01274a440b19fe34ec8b8884 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 4 Apr 2025 18:05:30 -0500 Subject: [PATCH 48/56] remove title from icons in sidebar nav --- app/layouts/ProjectLayoutBase.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index b404cc64fa..8266c34a37 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -115,10 +115,10 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Floating IPs - Affinity + Affinity - Access + Access From 5a5831269aeb8753e722eacc69e5604816cd83f5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 4 Apr 2025 17:30:51 -0700 Subject: [PATCH 49/56] Refactoring form in add instance to A-A group modal --- app/forms/anti-affinity-group-member-add.tsx | 52 ++++++++++---------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index ecaeef1605..5b43319ac8 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -32,15 +32,16 @@ export default function AddAntiAffinityGroupMemberForm({ }) { const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() - const form = useForm({ - defaultValues: { - instance: '', - }, - }) + const { control, handleSubmit, reset } = + useForm({ + defaultValues: { + instance: '', + }, + }) const onDismiss = () => { setIsModalOpen(false) - form.reset() + reset() } const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', { @@ -48,39 +49,40 @@ export default function AddAntiAffinityGroupMemberForm({ onDismiss() queryClient.invalidateEndpoint('antiAffinityGroupMemberList') queryClient.invalidateEndpoint('antiAffinityGroupView') - addToast(<>Member {variables.path.instance} added to anti-affinity group {antiAffinityGroup}) // prettier-ignore + addToast(<>Instance {variables.path.instance} added to anti-affinity group {antiAffinityGroup}) // prettier-ignore }, }) + const onSubmit = ({ instance }: AntiAffinityGroupMemberInstanceAddPathParams) => { + addMember({ + path: { antiAffinityGroup, instance }, + query: { project }, + }) + } + return (

Select an instance to add to the anti-affinity group{' '} - {antiAffinityGroup}. + {antiAffinityGroup}. Only stopped instances can be added to the group.

- +
+ +
- addMember({ - path: { - antiAffinityGroup, - instance: form.getValues('instance'), - }, - query: { project }, - }) - } + onAction={handleSubmit(onSubmit)} actionText="Add to group" />
From 99a2c788a4cc4c29f0531c3b915b863ac56bed0c Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 7 Apr 2025 10:18:38 -0500 Subject: [PATCH 50/56] type -> group type, remove description column --- app/forms/affinity-util.tsx | 2 +- app/pages/project/affinity/AffinityPage.tsx | 6 +----- test/e2e/anti-affinity.e2e.ts | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/forms/affinity-util.tsx b/app/forms/affinity-util.tsx index a2fd247e33..99106e2933 100644 --- a/app/forms/affinity-util.tsx +++ b/app/forms/affinity-util.tsx @@ -33,4 +33,4 @@ export const antiAffinityGroupMemberList = ({ }) export const policyHelpText = - 'Whether instances are allowed to start when every available sled already has a group member on it' + 'Whether member instances are allowed to start when every available sled already has a group member on it' diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index bef8193545..4b067613e9 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -25,7 +25,6 @@ import { antiAffinityGroupList, antiAffinityGroupMemberList } from '~/forms/affi import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -62,12 +61,9 @@ export const AffinityGroupPolicyBadge = ({ policy }: { policy: AffinityPolicy }) const staticCols = [ colHelper.accessor(() => {}, { - header: 'type', + header: 'Group type', cell: () => anti-affinity, }), - colHelper.accessor('description', { - cell: (info) => , - }), colHelper.accessor('name', { header: 'instances', cell: (info) => , diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts index a1c7d3d869..784b68b4da 100644 --- a/test/e2e/anti-affinity.e2e.ts +++ b/test/e2e/anti-affinity.e2e.ts @@ -17,7 +17,7 @@ test('can nav to Affinity from /', async ({ page }) => { await expectRowVisible(page.getByRole('table'), { name: 'romulus-remus', - type: 'anti-affinity', + 'Group type': 'anti-affinity', Policy: 'fail', instances: '2', }) From 6f21f11aaf33cb97f6df46962bd0e5092c599e57 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 7 Apr 2025 11:00:32 -0500 Subject: [PATCH 51/56] on second thought: make page title Affinity Groups --- app/layouts/ProjectLayoutBase.tsx | 4 ++-- app/pages/project/affinity/AffinityPage.tsx | 6 +++--- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 2 +- app/routes.tsx | 4 ++-- app/util/__snapshots__/path-builder.spec.ts.snap | 8 ++++---- test/e2e/anti-affinity.e2e.ts | 6 +++--- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 8266c34a37..5381ee9597 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -69,7 +69,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { { value: 'Images', path: pb.projectImages(projectSelector) }, { value: 'VPCs', path: pb.vpcs(projectSelector) }, { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, - { value: 'Affinity', path: pb.affinity(projectSelector) }, + { value: 'Affinity Groups', path: pb.affinity(projectSelector) }, { value: 'Access', path: pb.projectAccess(projectSelector) }, ] // filter out the entry for the path we're currently on @@ -115,7 +115,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Floating IPs - Affinity + Affinity Groups Access diff --git a/app/pages/project/affinity/AffinityPage.tsx b/app/pages/project/affinity/AffinityPage.tsx index 4b067613e9..8c9c72452b 100644 --- a/app/pages/project/affinity/AffinityPage.tsx +++ b/app/pages/project/affinity/AffinityPage.tsx @@ -60,8 +60,8 @@ export const AffinityGroupPolicyBadge = ({ policy }: { policy: AffinityPolicy }) } const staticCols = [ - colHelper.accessor(() => {}, { - header: 'Group type', + colHelper.display({ + header: 'type', cell: () => anti-affinity, }), colHelper.accessor('name', { @@ -139,7 +139,7 @@ export default function AffinityPage() { return ( <> - }>Affinity + }>Affinity Groups diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 0b5b1fb3d8..dd8ccfcafa 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -72,7 +72,7 @@ const AntiAffinityGroupMemberEmptyState = () => ( } - title="No anti-affinity group members" + title="No group members" body="Add an instance to the group to see it here" /> diff --git a/app/routes.tsx b/app/routes.tsx index 6dd1b5397e..965944f833 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -502,14 +502,14 @@ export const routes = createRoutesFromElements( /> import('./pages/project/affinity/AffinityPage').then(convert)} - handle={{ crumb: 'Affinity' }} + handle={{ crumb: 'Affinity Groups' }} > import('./forms/anti-affinity-group-create').then(convert)} /> - + import('./pages/project/affinity/AffinityPage.tsx').then(convert)} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index fc51c123cc..d08eaa234f 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -12,7 +12,7 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Affinity", + "label": "Affinity Groups", "path": "/projects/p/affinity", }, ], @@ -26,7 +26,7 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Affinity", + "label": "Affinity Groups", "path": "/projects/p/", }, ], @@ -40,7 +40,7 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Affinity", + "label": "Affinity Groups", "path": "/projects/p/affinity", }, { @@ -58,7 +58,7 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/instances", }, { - "label": "Affinity", + "label": "Affinity Groups", "path": "/projects/p/affinity", }, { diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts index 784b68b4da..edd751d9b1 100644 --- a/test/e2e/anti-affinity.e2e.ts +++ b/test/e2e/anti-affinity.e2e.ts @@ -17,7 +17,7 @@ test('can nav to Affinity from /', async ({ page }) => { await expectRowVisible(page.getByRole('table'), { name: 'romulus-remus', - 'Group type': 'anti-affinity', + type: 'anti-affinity', Policy: 'fail', instances: '2', }) @@ -28,7 +28,7 @@ test('can nav to Affinity from /', async ({ page }) => { await expect(page.getByRole('heading', { name: 'romulus-remus' })).toBeVisible() await expect(page).toHaveURL('/projects/mock-project/affinity/romulus-remus') await expect(page).toHaveTitle( - 'romulus-remus / Affinity / mock-project / Projects / Oxide Console' + 'romulus-remus / Affinity Groups / mock-project / Projects / Oxide Console' ) }) @@ -67,7 +67,7 @@ test('can add a new anti-affinity group', async ({ page }) => { await expect(cell).toBeHidden() // expect empty message - await expect(page.getByText('No anti-affinity group members')).toBeVisible() + await expect(page.getByText('No group members')).toBeVisible() }) // edit an anti-affinity group from the view page From 064879ec42e36a11f834d713f2573c7156f6fefb Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 7 Apr 2025 11:19:08 -0500 Subject: [PATCH 52/56] simplify form reset by unmounting, test that in e2e --- app/forms/anti-affinity-group-member-add.tsx | 40 ++++++------------- .../affinity/AntiAffinityGroupPage.tsx | 11 ++--- test/e2e/anti-affinity.e2e.ts | 21 ++++++++-- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index 5b43319ac8..3c1944709e 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -8,12 +8,7 @@ import { useForm } from 'react-hook-form' -import { - queryClient, - useApiMutation, - type AntiAffinityGroupMemberInstanceAddPathParams, - type Instance, -} from '~/api' +import { queryClient, useApiMutation, type Instance } from '~/api' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { HL } from '~/components/HL' import { useAntiAffinityGroupSelector } from '~/hooks/use-params' @@ -21,28 +16,19 @@ import { addToast } from '~/stores/toast' import { toComboboxItems } from '~/ui/lib/Combobox' import { Modal } from '~/ui/lib/Modal' +type Values = { instance: string } + +const defaultValues: Values = { instance: '' } + +type Props = { instances: Instance[]; onDismiss: () => void } + export default function AddAntiAffinityGroupMemberForm({ - availableInstances, - isModalOpen, - setIsModalOpen, -}: { - availableInstances: Instance[] - isModalOpen: boolean - setIsModalOpen: (open: boolean) => void -}) { + instances: availableInstances, + onDismiss, +}: Props) { const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() - const { control, handleSubmit, reset } = - useForm({ - defaultValues: { - instance: '', - }, - }) - - const onDismiss = () => { - setIsModalOpen(false) - reset() - } + const { control, handleSubmit } = useForm({ defaultValues }) const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', { onSuccess(_data, variables) { @@ -53,7 +39,7 @@ export default function AddAntiAffinityGroupMemberForm({ }, }) - const onSubmit = ({ instance }: AntiAffinityGroupMemberInstanceAddPathParams) => { + const onSubmit = ({ instance }: Values) => { addMember({ path: { antiAffinityGroup, instance }, query: { project }, @@ -61,7 +47,7 @@ export default function AddAntiAffinityGroupMemberForm({ } return ( - +

diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index dd8ccfcafa..67d7c88066 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -241,11 +241,12 @@ export default function AntiAffinityPage() { {membersCount ?

: } - + {isModalOpen && ( + setIsModalOpen(false)} + /> + )} ) diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts index edd751d9b1..873c80ccc7 100644 --- a/test/e2e/anti-affinity.e2e.ts +++ b/test/e2e/anti-affinity.e2e.ts @@ -52,9 +52,24 @@ test('can add a new anti-affinity group', async ({ page }) => { await expect(page).toHaveURL('/projects/mock-project/affinity/new-anti-affinity-group') // add a member to the new anti-affinity group - await page.getByRole('button', { name: 'Add instance' }).click() - await expect(page.getByRole('heading', { name: 'Add instance to group' })).toBeVisible() - await page.getByRole('combobox', { name: 'Instance' }).fill('db1') + const addInstanceButton = page.getByRole('button', { name: 'Add instance' }) + const addInstanceModal = page.getByRole('dialog', { name: 'Add instance to group' }) + const instanceCombobox = page.getByRole('combobox', { name: 'Instance' }) + + // open modal and pick instance + await addInstanceButton.click() + await expect(addInstanceModal).toBeVisible() + await instanceCombobox.fill('db1') + await page.getByRole('option', { name: 'db1' }).click() + await expect(instanceCombobox).toHaveValue('db1') + + // close and reopen the modal to make sure the field clears + await page.getByRole('button', { name: 'Cancel' }).click() + await expect(addInstanceModal).toBeHidden() + await addInstanceButton.click() + await expect(instanceCombobox).toHaveValue('') + + // now do it again for real and submit await page.getByRole('option', { name: 'db1' }).click() await page.getByRole('button', { name: 'Add to group' }).click() From 16bb0329756463a7a5cb73f04fd45bbae0a079e9 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 7 Apr 2025 11:42:58 -0500 Subject: [PATCH 53/56] use handleSubmit higher so we don't have to type explicitly --- app/forms/anti-affinity-group-member-add.tsx | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index 3c1944709e..88d8dc8e77 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -22,10 +22,7 @@ const defaultValues: Values = { instance: '' } type Props = { instances: Instance[]; onDismiss: () => void } -export default function AddAntiAffinityGroupMemberForm({ - instances: availableInstances, - onDismiss, -}: Props) { +export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }: Props) { const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() const { control, handleSubmit } = useForm({ defaultValues }) @@ -39,12 +36,12 @@ export default function AddAntiAffinityGroupMemberForm({ }, }) - const onSubmit = ({ instance }: Values) => { + const onSubmit = handleSubmit(({ instance }) => { addMember({ path: { antiAffinityGroup, instance }, query: { project }, }) - } + }) return ( @@ -54,23 +51,19 @@ export default function AddAntiAffinityGroupMemberForm({ Select an instance to add to the anti-affinity group{' '} {antiAffinityGroup}. Only stopped instances can be added to the group.

-
+ - +
) } From 37a673383c7d1de68f13429432ca08b93c3472df Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 7 Apr 2025 14:50:25 -0500 Subject: [PATCH 54/56] make enter submit add instance modal form --- app/forms/anti-affinity-group-member-add.tsx | 12 +++++++----- app/ui/lib/Combobox.tsx | 12 +++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index 88d8dc8e77..64a6c8b5cf 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -6,6 +6,7 @@ * Copyright Oxide Computer Company */ +import { useId } from 'react' import { useForm } from 'react-hook-form' import { queryClient, useApiMutation, type Instance } from '~/api' @@ -25,7 +26,8 @@ type Props = { instances: Instance[]; onDismiss: () => void } export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }: Props) { const { project, antiAffinityGroup } = useAntiAffinityGroupSelector() - const { control, handleSubmit } = useForm({ defaultValues }) + const form = useForm({ defaultValues }) + const formId = useId() const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', { onSuccess(_data, variables) { @@ -36,7 +38,7 @@ export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }: }, }) - const onSubmit = handleSubmit(({ instance }) => { + const onSubmit = form.handleSubmit(({ instance }) => { addMember({ path: { antiAffinityGroup, instance }, query: { project }, @@ -51,19 +53,19 @@ export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }: Select an instance to add to the anti-affinity group{' '} {antiAffinityGroup}. Only stopped instances can be added to the group.

-
+ - + ) } diff --git a/app/ui/lib/Combobox.tsx b/app/ui/lib/Combobox.tsx index 4369921695..45859241f8 100644 --- a/app/ui/lib/Combobox.tsx +++ b/app/ui/lib/Combobox.tsx @@ -213,12 +213,14 @@ export const Combobox = ({ onInputChange?.(value) }} onKeyDown={(e) => { - // Prevent form submission when the user presses Enter inside a combobox. - // The combobox component already handles Enter keypresses to select items, - // so we only preventDefault when the combobox is closed. - if (e.key === 'Enter' && !open) { + // If the caller is using onEnter to override enter behavior, preventDefault + // in order to prevent the containing form from being submitted too. We don't + // need to do this when the combobox is open because that enter keypress is + // already handled internally (selects the highlighted item). So we only do + // this when the combobox is closed. + if (e.key === 'Enter' && onEnter && !open) { e.preventDefault() - onEnter?.(e) + onEnter(e) } }} placeholder={placeholder} From 42f772e8c0b04624e745bf303d557cf5e3e1402d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 7 Apr 2025 15:20:54 -0500 Subject: [PATCH 55/56] link to instance settings rather than default tab --- app/pages/project/affinity/AntiAffinityGroupPage.tsx | 2 +- test/e2e/anti-affinity.e2e.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/pages/project/affinity/AntiAffinityGroupPage.tsx b/app/pages/project/affinity/AntiAffinityGroupPage.tsx index 67d7c88066..92c3a908ab 100644 --- a/app/pages/project/affinity/AntiAffinityGroupPage.tsx +++ b/app/pages/project/affinity/AntiAffinityGroupPage.tsx @@ -154,7 +154,7 @@ export default function AntiAffinityPage() { [ colHelper.accessor('value.name', { header: 'name', - cell: makeLinkCell((instance) => pb.instance({ project, instance })), + cell: makeLinkCell((instance) => pb.instanceSettings({ project, instance })), }), colHelper.accessor('value.runState', Columns.instanceState), colHelper.accessor('value.id', Columns.id), diff --git a/test/e2e/anti-affinity.e2e.ts b/test/e2e/anti-affinity.e2e.ts index 873c80ccc7..36c7bb3f5b 100644 --- a/test/e2e/anti-affinity.e2e.ts +++ b/test/e2e/anti-affinity.e2e.ts @@ -30,6 +30,10 @@ test('can nav to Affinity from /', async ({ page }) => { await expect(page).toHaveTitle( 'romulus-remus / Affinity Groups / mock-project / Projects / Oxide Console' ) + + // click through to instance + await page.getByRole('link', { name: 'db1' }).click() + await expect(page).toHaveURL('/projects/mock-project/instances/db1/settings') }) test('can add a new anti-affinity group', async ({ page }) => { From 696484e3365af25d217e45fa84aee94a9c145881 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 7 Apr 2025 15:49:27 -0500 Subject: [PATCH 56/56] hopefully final policy help copy tweaks --- app/forms/affinity-util.tsx | 2 +- app/forms/anti-affinity-group-create.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/forms/affinity-util.tsx b/app/forms/affinity-util.tsx index 99106e2933..32408f32ae 100644 --- a/app/forms/affinity-util.tsx +++ b/app/forms/affinity-util.tsx @@ -33,4 +33,4 @@ export const antiAffinityGroupMemberList = ({ }) export const policyHelpText = - 'Whether member instances are allowed to start when every available sled already has a group member on it' + "Determines whether member instances are allowed to start when the anti-affinity rule can't be satisfied" diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx index 0e80f65a19..119888f921 100644 --- a/app/forms/anti-affinity-group-create.tsx +++ b/app/forms/anti-affinity-group-create.tsx @@ -67,7 +67,8 @@ export default function CreateAntiAffinityGroupForm() {