Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/api/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ const instanceActions = {
updateNic: ['stopped'],
// https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/src/app/instance.rs#L1520-L1522
serialConsole: ['running', 'rebooting', 'migrating', 'repairing'],

// https://github.com/oxidecomputer/omicron/blob/5e27bde/nexus/src/app/affinity.rs#L357 checks to see that there's no VMM
// TODO: determine whether the intent is only `stopped` or also `failed`
addToAntiAffinityGroup: ['stopped'],
} satisfies Record<string, InstanceState[]>

// setting .states is a cute way to make it ergonomic to call the test function
Expand Down
2 changes: 1 addition & 1 deletion app/forms/anti-affinity-group-member-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }:
onSuccess(_data, variables) {
onDismiss()
queryClient.invalidateEndpoint('antiAffinityGroupMemberList')
queryClient.invalidateEndpoint('antiAffinityGroupView')
queryClient.invalidateEndpoint('instanceAntiAffinityGroupList')
Copy link
Collaborator Author

@david-crespo david-crespo Apr 8, 2025

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.

addToast(<>Instance <HL>{variables.path.instance}</HL> added to anti-affinity group <HL>{antiAffinityGroup}</HL></>) // prettier-ignore
},
})
Expand Down
2 changes: 1 addition & 1 deletion app/pages/project/affinity/AntiAffinityGroupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export default function AntiAffinityPage() {
{
onSuccess(_data, variables) {
queryClient.invalidateEndpoint('antiAffinityGroupMemberList')
queryClient.invalidateEndpoint('antiAffinityGroupView')
queryClient.invalidateEndpoint('instanceAntiAffinityGroupList')
addToast(<>Member <HL>{variables.path.instance}</HL> removed from anti-affinity group <HL>{group.name}</HL></>) // prettier-ignore
},
}
Expand Down
184 changes: 172 additions & 12 deletions app/pages/project/instances/AntiAffinityCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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]
)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how cool is differenceWith

Copy link
Contributor

Choose a reason for hiding this comment

The 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}
Expand All @@ -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>
)
}
2 changes: 1 addition & 1 deletion app/pages/project/instances/NetworkingTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ export default function NetworkingTab() {
disabledReason={
<>
A network interface cannot be created or edited unless the instance is{' '}
{updateNicStates}.
{updateNicStates}
</>
}
>
Expand Down
13 changes: 10 additions & 3 deletions app/pages/project/instances/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,21 @@ import { queryClient } from '@oxide/api'

import { getInstanceSelector } from '~/hooks/use-params'

import { AntiAffinityCard, antiAffinityGroupList } from './AntiAffinityCard'
import {
allAntiAffinityGroups,
AntiAffinityCard,
instanceAntiAffinityGroups,
} from './AntiAffinityCard'
import { AutoRestartCard } from './AutoRestartCard'

export const handle = { crumb: 'Settings' }

export async function clientLoader({ params }: LoaderFunctionArgs) {
const instanceSelector = getInstanceSelector(params)
await queryClient.prefetchQuery(antiAffinityGroupList(instanceSelector))
const { project, instance } = getInstanceSelector(params)
await Promise.all([
queryClient.prefetchQuery(instanceAntiAffinityGroups({ project, instance })),
queryClient.prefetchQuery(allAntiAffinityGroups({ project })),
])
return null
}

Expand Down
13 changes: 9 additions & 4 deletions app/table/columns/action-col.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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} />
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes in this file let us hook into this copy ID column logic while overriding the name to say Copy group ID because otherwise it's not clear what the ID is of.

image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

},
}
}
Expand Down
Loading
Loading