-
Notifications
You must be signed in to change notification settings - Fork 19
Affinity group add/remove on instance settings tab #2789
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a8535b0
f82d252
bd2f528
0dd7aa5
a65e072
4475233
f6b159c
bedc435
f517c8a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,35 +7,59 @@ | |
| */ | ||
|
|
||
| import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' | ||
| import { useMemo } from 'react' | ||
| import { useCallback, useId, useMemo, useState } from 'react' | ||
| import { useForm } from 'react-hook-form' | ||
| import * as R from 'remeda' | ||
|
|
||
| import { | ||
| apiq, | ||
| instanceCan, | ||
| queryClient, | ||
| useApiMutation, | ||
| usePrefetchedQuery, | ||
| type AffinityGroup, | ||
| type AntiAffinityGroup, | ||
| } from '@oxide/api' | ||
| import { Affinity24Icon } from '@oxide/design-system/icons/react' | ||
|
|
||
| import { AffinityPolicyHeader } from '~/components/AffinityDocsPopover' | ||
| import { ComboboxField } from '~/components/form/fields/ComboboxField' | ||
| import { HL } from '~/components/HL' | ||
| import { useInstanceSelector } from '~/hooks/use-params' | ||
| import { AffinityGroupPolicyBadge } from '~/pages/project/affinity/AffinityPage' | ||
| import { confirmAction } from '~/stores/confirm-action' | ||
| import { addToast } from '~/stores/toast' | ||
| import { makeLinkCell } from '~/table/cells/LinkCell' | ||
| import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' | ||
| import { Columns } from '~/table/columns/common' | ||
| import { Table } from '~/table/Table' | ||
| import { Button } from '~/ui/lib/Button' | ||
| import { CardBlock } from '~/ui/lib/CardBlock' | ||
| import { toComboboxItems } from '~/ui/lib/Combobox' | ||
| import { EmptyMessage } from '~/ui/lib/EmptyMessage' | ||
| import { Modal } from '~/ui/lib/Modal' | ||
| import { TableEmptyBox } from '~/ui/lib/Table' | ||
| import { ALL_ISH } from '~/util/consts' | ||
| import { pb } from '~/util/path-builder' | ||
| import type * as PP from '~/util/path-params' | ||
|
|
||
| export const antiAffinityGroupList = ({ project, instance }: PP.Instance) => | ||
| export const instanceAntiAffinityGroups = ({ project, instance }: PP.Instance) => | ||
| apiq('instanceAntiAffinityGroupList', { | ||
| path: { instance }, | ||
| query: { project, limit: ALL_ISH }, | ||
| }) | ||
|
|
||
| export const allAntiAffinityGroups = ({ project }: PP.Project) => | ||
| apiq('antiAffinityGroupList', { | ||
| query: { project, limit: ALL_ISH }, | ||
| }) | ||
|
|
||
| const instanceView = ({ project, instance }: PP.Instance) => | ||
| apiq('instanceView', { | ||
| path: { instance }, | ||
| query: { project }, | ||
| }) | ||
|
|
||
| const colHelper = createColumnHelper<AffinityGroup | AntiAffinityGroup>() | ||
| const staticCols = [ | ||
| colHelper.accessor('description', Columns.description), | ||
|
|
@@ -47,36 +71,110 @@ const staticCols = [ | |
|
|
||
| export function AntiAffinityCard() { | ||
| const instanceSelector = useInstanceSelector() | ||
| const { project } = instanceSelector | ||
| const { project, instance } = instanceSelector | ||
|
|
||
| const { data: memberGroups } = usePrefetchedQuery( | ||
| instanceAntiAffinityGroups(instanceSelector) | ||
| ) | ||
| const { data: allGroups } = usePrefetchedQuery(allAntiAffinityGroups(instanceSelector)) | ||
| const { data: instanceData } = usePrefetchedQuery(instanceView(instanceSelector)) | ||
|
|
||
| const nonMemberGroups = useMemo( | ||
| () => R.differenceWith(allGroups.items, memberGroups.items, (a, b) => a.id === b.id), | ||
| [memberGroups, allGroups] | ||
| ) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. how cool is
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah, nice |
||
|
|
||
| const { data: antiAffinityGroups } = usePrefetchedQuery( | ||
| antiAffinityGroupList(instanceSelector) | ||
| const { mutateAsync: removeMember } = useApiMutation( | ||
| 'antiAffinityGroupMemberInstanceDelete', | ||
| { | ||
| onSuccess(_data, variables) { | ||
| addToast( | ||
| <> | ||
| Instance <b>{variables.path.instance}</b> removed from anti-affinity group{' '} | ||
| <b>{variables.path.antiAffinityGroup}</b> | ||
| </> | ||
| ) | ||
| queryClient.invalidateEndpoint('instanceAntiAffinityGroupList') | ||
| queryClient.invalidateEndpoint('antiAffinityGroupMemberList') | ||
| }, | ||
| } | ||
| ) | ||
|
|
||
| const antiAffinityCols = useMemo( | ||
| () => [ | ||
| const makeActions = useCallback( | ||
| (group: AffinityGroup | AntiAffinityGroup): MenuAction[] => [ | ||
| { | ||
| label: 'Remove instance from group', | ||
| onActivate() { | ||
| confirmAction({ | ||
| actionType: 'danger', | ||
| doAction: () => | ||
| removeMember({ | ||
| path: { | ||
| antiAffinityGroup: group.name, | ||
| instance, | ||
| }, | ||
| query: { project }, | ||
| }), | ||
| modalTitle: 'Remove instance from group', | ||
| modalContent: ( | ||
| <p> | ||
| Are you sure you want to remove instance <b>{instance}</b> from group{' '} | ||
| <b>{group.name}</b>? | ||
| </p> | ||
| ), | ||
| errorTitle: 'Error removing instance from group', | ||
| }) | ||
| }, | ||
| }, | ||
| ], | ||
| [instance, project, removeMember] | ||
| ) | ||
|
|
||
| const antiAffinityCols = useColsWithActions( | ||
| [ | ||
| colHelper.accessor('name', { | ||
| cell: makeLinkCell((antiAffinityGroup) => | ||
| pb.antiAffinityGroup({ project, antiAffinityGroup }) | ||
| ), | ||
| }), | ||
| ...staticCols, | ||
| ], | ||
| [project] | ||
| makeActions, | ||
| 'Copy group ID' | ||
| ) | ||
|
|
||
| // Create tables for both types of groups | ||
| const [isModalOpen, setIsModalOpen] = useState(false) | ||
|
|
||
| const antiAffinityTable = useReactTable({ | ||
| columns: antiAffinityCols, | ||
| data: antiAffinityGroups.items, | ||
| data: memberGroups.items, | ||
| getCoreRowModel: getCoreRowModel(), | ||
| }) | ||
|
|
||
| let disabledReason = undefined | ||
| if (!instanceCan.addToAntiAffinityGroup(instanceData)) { | ||
| disabledReason = | ||
| <>Only <HL>stopped</HL> instances can be added to a group</> // prettier-ignore | ||
| } else if (allGroups.items.length === 0) { | ||
| disabledReason = 'No groups found' | ||
| } else if (nonMemberGroups.length === 0) { | ||
| disabledReason = 'Instance is already in all groups' | ||
| } | ||
|
|
||
| return ( | ||
| <CardBlock> | ||
| <CardBlock.Header title="Anti-affinity groups" titleId="anti-affinity-groups-label" /> | ||
| <CardBlock.Header title="Anti-affinity groups" titleId="anti-affinity-groups-label"> | ||
| <Button | ||
| size="sm" | ||
| disabled={!!disabledReason} | ||
| disabledReason={disabledReason} | ||
| onClick={() => setIsModalOpen(true)} | ||
| > | ||
| Add to group | ||
| </Button> | ||
| </CardBlock.Header> | ||
| <CardBlock.Body> | ||
| {antiAffinityGroups.items.length > 0 ? ( | ||
| {memberGroups.items.length > 0 ? ( | ||
| <Table | ||
| aria-labelledby="anti-affinity-groups-label" | ||
| table={antiAffinityTable} | ||
|
|
@@ -92,6 +190,68 @@ export function AntiAffinityCard() { | |
| </TableEmptyBox> | ||
| )} | ||
| </CardBlock.Body> | ||
| {isModalOpen && ( | ||
| <AddToGroupModal | ||
| onDismiss={() => setIsModalOpen(false)} | ||
| nonMemberGroups={nonMemberGroups} | ||
| /> | ||
| )} | ||
| </CardBlock> | ||
| ) | ||
| } | ||
|
|
||
| type ModalProps = { | ||
| onDismiss: () => void | ||
| nonMemberGroups: (AffinityGroup | AntiAffinityGroup)[] | ||
| } | ||
|
|
||
| export function AddToGroupModal({ onDismiss, nonMemberGroups }: ModalProps) { | ||
| const { project, instance } = useInstanceSelector() | ||
|
|
||
| const form = useForm({ defaultValues: { group: '' } }) | ||
| const formId = useId() | ||
|
|
||
| const { mutateAsync: addMember } = useApiMutation('antiAffinityGroupMemberInstanceAdd', { | ||
| onSuccess(_data, variables) { | ||
| onDismiss() | ||
| queryClient.invalidateEndpoint('antiAffinityGroupMemberList') | ||
| queryClient.invalidateEndpoint('instanceAntiAffinityGroupList') | ||
| addToast( | ||
| <> | ||
| Instance <HL>{instance}</HL> added to anti-affinity group{' '} | ||
| <HL>{variables.path.antiAffinityGroup}</HL> | ||
| </> | ||
| ) | ||
| }, | ||
| }) | ||
|
|
||
| const handleSubmit = form.handleSubmit(({ group }) => { | ||
| addMember({ | ||
| path: { antiAffinityGroup: group, instance }, | ||
| query: { project }, | ||
| }) | ||
| }) | ||
|
|
||
| return ( | ||
| <Modal isOpen onDismiss={onDismiss} title="Add to anti-affinity group"> | ||
| <Modal.Body> | ||
| <Modal.Section> | ||
| <p className="mb-6"> | ||
| Select a group to add instance <HL>{instance}</HL> to. | ||
| </p> | ||
| <form id={formId} onSubmit={handleSubmit}> | ||
| <ComboboxField | ||
| label="Anti-affinity group" | ||
| placeholder="Select a group" | ||
| name="group" | ||
| items={toComboboxItems(nonMemberGroups)} | ||
| required | ||
| control={form.control} | ||
| /> | ||
| </form> | ||
| </Modal.Section> | ||
| </Modal.Body> | ||
| <Modal.Footer onDismiss={onDismiss} formId={formId} actionText="Add to group" /> | ||
| </Modal> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -42,13 +42,18 @@ export function useColsWithActions<TData extends Record<string, unknown>>( | |
| /** Should be static or memoized */ | ||
| columns: ColumnDef<TData, any>[], // eslint-disable-line @typescript-eslint/no-explicit-any | ||
| /** Must be memoized to avoid re-renders */ | ||
| makeActions: MakeActions<TData> | ||
| makeActions: MakeActions<TData>, | ||
| copyIdLabel?: string | ||
| ) { | ||
| return useMemo(() => [...columns, getActionsCol(makeActions)], [columns, makeActions]) | ||
| return useMemo( | ||
| () => [...columns, getActionsCol(makeActions, copyIdLabel)], | ||
| [columns, makeActions, copyIdLabel] | ||
| ) | ||
| } | ||
|
|
||
| export const getActionsCol = <TData extends Record<string, unknown>>( | ||
| makeActions: MakeActions<TData> | ||
| makeActions: MakeActions<TData>, | ||
| copyIdLabel?: string | ||
| ): ColumnDef<TData> => { | ||
| return { | ||
| id: 'menu', | ||
|
|
@@ -62,7 +67,7 @@ export const getActionsCol = <TData extends Record<string, unknown>>( | |
| // TODO: control flow here has always confused me, would like to straighten it out | ||
| const actions = makeActions(row.original) | ||
| const id = typeof row.original.id === 'string' ? row.original.id : null | ||
| return <RowActions id={id} actions={actions} /> | ||
| return <RowActions id={id} actions={actions} copyIdLabel={copyIdLabel} /> | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice |
||
| }, | ||
| } | ||
| } | ||
|
|
||

Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is kind of annoying — any time group memberships change, we have to clear cache on both endpoints.