77 */
88
99import { createColumnHelper , getCoreRowModel , useReactTable } from '@tanstack/react-table'
10- import { useMemo } from 'react'
10+ import { useCallback , useId , useMemo , useState } from 'react'
11+ import { useForm } from 'react-hook-form'
12+ import * as R from 'remeda'
1113
1214import {
1315 apiq ,
16+ instanceCan ,
17+ queryClient ,
18+ useApiMutation ,
1419 usePrefetchedQuery ,
1520 type AffinityGroup ,
1621 type AntiAffinityGroup ,
1722} from '@oxide/api'
1823import { Affinity24Icon } from '@oxide/design-system/icons/react'
1924
2025import { AffinityPolicyHeader } from '~/components/AffinityDocsPopover'
26+ import { ComboboxField } from '~/components/form/fields/ComboboxField'
27+ import { HL } from '~/components/HL'
2128import { useInstanceSelector } from '~/hooks/use-params'
2229import { AffinityGroupPolicyBadge } from '~/pages/project/affinity/AffinityPage'
30+ import { confirmAction } from '~/stores/confirm-action'
31+ import { addToast } from '~/stores/toast'
2332import { makeLinkCell } from '~/table/cells/LinkCell'
33+ import { useColsWithActions , type MenuAction } from '~/table/columns/action-col'
2434import { Columns } from '~/table/columns/common'
2535import { Table } from '~/table/Table'
36+ import { Button } from '~/ui/lib/Button'
2637import { CardBlock } from '~/ui/lib/CardBlock'
38+ import { toComboboxItems } from '~/ui/lib/Combobox'
2739import { EmptyMessage } from '~/ui/lib/EmptyMessage'
40+ import { Modal } from '~/ui/lib/Modal'
2841import { TableEmptyBox } from '~/ui/lib/Table'
2942import { ALL_ISH } from '~/util/consts'
3043import { pb } from '~/util/path-builder'
3144import type * as PP from '~/util/path-params'
3245
33- export const antiAffinityGroupList = ( { project, instance } : PP . Instance ) =>
46+ export const instanceAntiAffinityGroups = ( { project, instance } : PP . Instance ) =>
3447 apiq ( 'instanceAntiAffinityGroupList' , {
3548 path : { instance } ,
3649 query : { project, limit : ALL_ISH } ,
3750 } )
3851
52+ export const allAntiAffinityGroups = ( { project } : PP . Project ) =>
53+ apiq ( 'antiAffinityGroupList' , {
54+ query : { project, limit : ALL_ISH } ,
55+ } )
56+
57+ const instanceView = ( { project, instance } : PP . Instance ) =>
58+ apiq ( 'instanceView' , {
59+ path : { instance } ,
60+ query : { project } ,
61+ } )
62+
3963const colHelper = createColumnHelper < AffinityGroup | AntiAffinityGroup > ( )
4064const staticCols = [
4165 colHelper . accessor ( 'description' , Columns . description ) ,
@@ -47,36 +71,110 @@ const staticCols = [
4771
4872export function AntiAffinityCard ( ) {
4973 const instanceSelector = useInstanceSelector ( )
50- const { project } = instanceSelector
74+ const { project, instance } = instanceSelector
75+
76+ const { data : memberGroups } = usePrefetchedQuery (
77+ instanceAntiAffinityGroups ( instanceSelector )
78+ )
79+ const { data : allGroups } = usePrefetchedQuery ( allAntiAffinityGroups ( instanceSelector ) )
80+ const { data : instanceData } = usePrefetchedQuery ( instanceView ( instanceSelector ) )
81+
82+ const nonMemberGroups = useMemo (
83+ ( ) => R . differenceWith ( allGroups . items , memberGroups . items , ( a , b ) => a . id === b . id ) ,
84+ [ memberGroups , allGroups ]
85+ )
5186
52- const { data : antiAffinityGroups } = usePrefetchedQuery (
53- antiAffinityGroupList ( instanceSelector )
87+ const { mutateAsync : removeMember } = useApiMutation (
88+ 'antiAffinityGroupMemberInstanceDelete' ,
89+ {
90+ onSuccess ( _data , variables ) {
91+ addToast (
92+ < >
93+ Instance < b > { variables . path . instance } </ b > removed from anti-affinity group{ ' ' }
94+ < b > { variables . path . antiAffinityGroup } </ b >
95+ </ >
96+ )
97+ queryClient . invalidateEndpoint ( 'instanceAntiAffinityGroupList' )
98+ queryClient . invalidateEndpoint ( 'antiAffinityGroupMemberList' )
99+ } ,
100+ }
54101 )
55102
56- const antiAffinityCols = useMemo (
57- ( ) => [
103+ const makeActions = useCallback (
104+ ( group : AffinityGroup | AntiAffinityGroup ) : MenuAction [ ] => [
105+ {
106+ label : 'Remove instance from group' ,
107+ onActivate ( ) {
108+ confirmAction ( {
109+ actionType : 'danger' ,
110+ doAction : ( ) =>
111+ removeMember ( {
112+ path : {
113+ antiAffinityGroup : group . name ,
114+ instance,
115+ } ,
116+ query : { project } ,
117+ } ) ,
118+ modalTitle : 'Remove instance from group' ,
119+ modalContent : (
120+ < p >
121+ Are you sure you want to remove instance < b > { instance } </ b > from group{ ' ' }
122+ < b > { group . name } </ b > ?
123+ </ p >
124+ ) ,
125+ errorTitle : 'Error removing instance from group' ,
126+ } )
127+ } ,
128+ } ,
129+ ] ,
130+ [ instance , project , removeMember ]
131+ )
132+
133+ const antiAffinityCols = useColsWithActions (
134+ [
58135 colHelper . accessor ( 'name' , {
59136 cell : makeLinkCell ( ( antiAffinityGroup ) =>
60137 pb . antiAffinityGroup ( { project, antiAffinityGroup } )
61138 ) ,
62139 } ) ,
63140 ...staticCols ,
64141 ] ,
65- [ project ]
142+ makeActions ,
143+ 'Copy group ID'
66144 )
67145
68- // Create tables for both types of groups
146+ const [ isModalOpen , setIsModalOpen ] = useState ( false )
147+
69148 const antiAffinityTable = useReactTable ( {
70149 columns : antiAffinityCols ,
71- data : antiAffinityGroups . items ,
150+ data : memberGroups . items ,
72151 getCoreRowModel : getCoreRowModel ( ) ,
73152 } )
74153
154+ let disabledReason = undefined
155+ if ( ! instanceCan . addToAntiAffinityGroup ( instanceData ) ) {
156+ disabledReason =
157+ < > Only < HL > stopped</ HL > instances can be added to a group</ > // prettier-ignore
158+ } else if ( allGroups . items . length === 0 ) {
159+ disabledReason = 'No groups found'
160+ } else if ( nonMemberGroups . length === 0 ) {
161+ disabledReason = 'Instance is already in all groups'
162+ }
163+
75164 return (
76165 < CardBlock >
77- < CardBlock . Header title = "Anti-affinity groups" titleId = "anti-affinity-groups-label" />
166+ < CardBlock . Header title = "Anti-affinity groups" titleId = "anti-affinity-groups-label" >
167+ < Button
168+ size = "sm"
169+ disabled = { ! ! disabledReason }
170+ disabledReason = { disabledReason }
171+ onClick = { ( ) => setIsModalOpen ( true ) }
172+ >
173+ Add to group
174+ </ Button >
175+ </ CardBlock . Header >
78176 < CardBlock . Body >
79- { antiAffinityGroups . items . length > 0 ? (
177+ { memberGroups . items . length > 0 ? (
80178 < Table
81179 aria-labelledby = "anti-affinity-groups-label"
82180 table = { antiAffinityTable }
@@ -92,6 +190,68 @@ export function AntiAffinityCard() {
92190 </ TableEmptyBox >
93191 ) }
94192 </ CardBlock . Body >
193+ { isModalOpen && (
194+ < AddToGroupModal
195+ onDismiss = { ( ) => setIsModalOpen ( false ) }
196+ nonMemberGroups = { nonMemberGroups }
197+ />
198+ ) }
95199 </ CardBlock >
96200 )
97201}
202+
203+ type ModalProps = {
204+ onDismiss : ( ) => void
205+ nonMemberGroups : ( AffinityGroup | AntiAffinityGroup ) [ ]
206+ }
207+
208+ export function AddToGroupModal ( { onDismiss, nonMemberGroups } : ModalProps ) {
209+ const { project, instance } = useInstanceSelector ( )
210+
211+ const form = useForm ( { defaultValues : { group : '' } } )
212+ const formId = useId ( )
213+
214+ const { mutateAsync : addMember } = useApiMutation ( 'antiAffinityGroupMemberInstanceAdd' , {
215+ onSuccess ( _data , variables ) {
216+ onDismiss ( )
217+ queryClient . invalidateEndpoint ( 'antiAffinityGroupMemberList' )
218+ queryClient . invalidateEndpoint ( 'instanceAntiAffinityGroupList' )
219+ addToast (
220+ < >
221+ Instance < HL > { instance } </ HL > added to anti-affinity group{ ' ' }
222+ < HL > { variables . path . antiAffinityGroup } </ HL >
223+ </ >
224+ )
225+ } ,
226+ } )
227+
228+ const handleSubmit = form . handleSubmit ( ( { group } ) => {
229+ addMember ( {
230+ path : { antiAffinityGroup : group , instance } ,
231+ query : { project } ,
232+ } )
233+ } )
234+
235+ return (
236+ < Modal isOpen onDismiss = { onDismiss } title = "Add to anti-affinity group" >
237+ < Modal . Body >
238+ < Modal . Section >
239+ < p className = "mb-6" >
240+ Select a group to add instance < HL > { instance } </ HL > to.
241+ </ p >
242+ < form id = { formId } onSubmit = { handleSubmit } >
243+ < ComboboxField
244+ label = "Anti-affinity group"
245+ placeholder = "Select a group"
246+ name = "group"
247+ items = { toComboboxItems ( nonMemberGroups ) }
248+ required
249+ control = { form . control }
250+ />
251+ </ form >
252+ </ Modal . Section >
253+ </ Modal . Body >
254+ < Modal . Footer onDismiss = { onDismiss } formId = { formId } actionText = "Add to group" />
255+ </ Modal >
256+ )
257+ }
0 commit comments